From 9800b3be5d1caab04ed18e54e1c79635856b76ff Mon Sep 17 00:00:00 2001 From: Stephen B <158498287+StephenDev0@users.noreply.github.com> Date: Wed, 3 Dec 2025 03:16:13 -0500 Subject: [PATCH] Lot's of new stuff --- StikJIT/StikJIT-Bridging-Header.h | 1 + StikJIT/StikJITApp.swift | 16 +- StikJIT/Utilities/ProcessInspectorBridge.h | 13 + StikJIT/Utilities/ProcessInspectorBridge.m | 34 ++ StikJIT/Utilities/TabConfiguration.swift | 37 ++ StikJIT/Views/MainTabView.swift | 123 +++++-- StikJIT/Views/MapSelectionView.swift | 356 ++++++++++++------- StikJIT/Views/ProcessInspectorView.swift | 382 +++++++++++++++++++++ StikJIT/Views/SettingsView.swift | 228 ++++++++++-- StikJIT/idevice/JITEnableContext.h | 2 + StikJIT/idevice/JITEnableContext.m | 352 +++++++++++++++++++ 11 files changed, 1362 insertions(+), 182 deletions(-) create mode 100644 StikJIT/Utilities/ProcessInspectorBridge.h create mode 100644 StikJIT/Utilities/ProcessInspectorBridge.m create mode 100644 StikJIT/Utilities/TabConfiguration.swift create mode 100644 StikJIT/Views/ProcessInspectorView.swift diff --git a/StikJIT/StikJIT-Bridging-Header.h b/StikJIT/StikJIT-Bridging-Header.h index 32630d5..f0317ad 100644 --- a/StikJIT/StikJIT-Bridging-Header.h +++ b/StikJIT/StikJIT-Bridging-Header.h @@ -3,6 +3,7 @@ // #include "idevice/JITEnableContext.h" +#import "Utilities/ProcessInspectorBridge.h" #include "idevice/idevice.h" #include "idevice/heartbeat.h" #include "JSSupport/JSSupport.h" diff --git a/StikJIT/StikJITApp.swift b/StikJIT/StikJITApp.swift index 27357fc..a0fda6c 100644 --- a/StikJIT/StikJITApp.swift +++ b/StikJIT/StikJITApp.swift @@ -155,6 +155,7 @@ class TunnelManager: ObservableObject { static var shared = TunnelManager() private var vpnManager: NETunnelProviderManager? + private var pendingStopRequest = false private var tunnelDeviceIp: String { UserDefaults.standard.string(forKey: "TunnelDeviceIP") ?? "10.7.0.0" } @@ -205,6 +206,10 @@ class TunnelManager: ObservableObject { self.updateTunnelStatus(from: firstManager.connection.status) VPNLogger.shared.log("Using existing tunnel configuration") } + if self.pendingStopRequest, let manager = self.vpnManager { + VPNLogger.shared.log("Pending stop request detected during preference load") + self.stopVPN(with: manager) + } } else { VPNLogger.shared.log("No existing tunnel configuration found") } @@ -326,7 +331,16 @@ class TunnelManager: ObservableObject { } func stopVPN() { - guard let manager = vpnManager else { return } + guard let manager = vpnManager else { + pendingStopRequest = true + loadTunnelPreferences() + return + } + pendingStopRequest = false + stopVPN(with: manager) + } + + private func stopVPN(with manager: NETunnelProviderManager) { tunnelStatus = .disconnecting manager.connection.stopVPNTunnel() VPNLogger.shared.log("Network tunnel stop initiated") diff --git a/StikJIT/Utilities/ProcessInspectorBridge.h b/StikJIT/Utilities/ProcessInspectorBridge.h new file mode 100644 index 0000000..d90c89a --- /dev/null +++ b/StikJIT/Utilities/ProcessInspectorBridge.h @@ -0,0 +1,13 @@ +// +// ProcessInspectorBridge.h +// StikJIT +// + +#import "../idevice/JITEnableContext.h" + +NS_ASSUME_NONNULL_BEGIN + +FOUNDATION_EXPORT NSArray * _Nullable FetchDeviceProcessList(NSError **error); +FOUNDATION_EXPORT BOOL KillDeviceProcess(int pid, NSError **error); + +NS_ASSUME_NONNULL_END diff --git a/StikJIT/Utilities/ProcessInspectorBridge.m b/StikJIT/Utilities/ProcessInspectorBridge.m new file mode 100644 index 0000000..e130603 --- /dev/null +++ b/StikJIT/Utilities/ProcessInspectorBridge.m @@ -0,0 +1,34 @@ +// +// ProcessInspectorBridge.m +// StikJIT +// + +#import "ProcessInspectorBridge.h" + +NSArray * _Nullable FetchDeviceProcessList(NSError **error) { + @try { + return [[JITEnableContext shared] fetchProcessListWithError:error]; + } @catch (NSException *exception) { + if (error) { + NSString *message = [NSString stringWithFormat:@"Process fetch failed: %@", exception.reason ?: @"Unknown error"]; + *error = [NSError errorWithDomain:@"ProcessInspectorBridge" + code:-500 + userInfo:@{NSLocalizedDescriptionKey: message}]; + } + return nil; + } +} + +BOOL KillDeviceProcess(int pid, NSError **error) { + @try { + return [[JITEnableContext shared] killProcessWithPID:pid error:error]; + } @catch (NSException *exception) { + if (error) { + NSString *message = [NSString stringWithFormat:@"Process kill failed: %@", exception.reason ?: @"Unknown error"]; + *error = [NSError errorWithDomain:@"ProcessInspectorBridge" + code:-501 + userInfo:@{NSLocalizedDescriptionKey: message}]; + } + return NO; + } +} diff --git a/StikJIT/Utilities/TabConfiguration.swift b/StikJIT/Utilities/TabConfiguration.swift new file mode 100644 index 0000000..7465673 --- /dev/null +++ b/StikJIT/Utilities/TabConfiguration.swift @@ -0,0 +1,37 @@ +import Foundation + +enum TabConfiguration { + static let storageKey = "enabledTabIdentifiers" + static let maxSelectableTabs = 4 + static let allowedIDs: [String] = ["home", "console", "scripts", "profiles", "processes", "deviceinfo", "location"] + static let defaultIDs: [String] = ["home", "console", "scripts", "profiles"] + static let defaultRawValue = serialize(defaultIDs) + + static func sanitize(raw: String) -> [String] { + let ids = raw + .split(separator: ",") + .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } + return sanitize(ids: ids) + } + + static func sanitize(ids: [String]) -> [String] { + var result: [String] = [] + for id in ids { + guard allowedIDs.contains(id) else { continue } + if !result.contains(id) { + result.append(id) + } + if result.count == maxSelectableTabs { + break + } + } + if result.isEmpty { + result = defaultIDs + } + return result + } + + static func serialize(_ ids: [String]) -> String { + sanitize(ids: ids).joined(separator: ",") + } +} diff --git a/StikJIT/Views/MainTabView.swift b/StikJIT/Views/MainTabView.swift index 907cb4f..d1e73f8 100644 --- a/StikJIT/Views/MainTabView.swift +++ b/StikJIT/Views/MainTabView.swift @@ -7,10 +7,25 @@ import SwiftUI +private struct TabDescriptor: Identifiable { + let id: String + let title: String + let systemImage: String + let builder: () -> AnyView +} + +extension Notification.Name { + static let switchToTab = Notification.Name("MainTabSwitchNotification") +} + struct MainTabView: View { @AppStorage("customAccentColor") private var customAccentColorHex: String = "" @AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue - @State private var selection: Int = 0 + @AppStorage(TabConfiguration.storageKey) private var enabledTabIdentifiers: String = TabConfiguration.defaultRawValue + @AppStorage("primaryTabSelection") private var selection: String = TabConfiguration.defaultIDs.first ?? "home" + @State private var switchObserver: Any? + @State private var detachedTab: TabDescriptor? + @State private var didSetInitialHome = false // Update checking @State private var showForceUpdate: Bool = false @@ -27,9 +42,48 @@ struct MainTabView: View { } private var isAppStoreBuild: Bool { - themeExpansion?.isAppStoreBuild ?? true + #if APPSTORE + return true + #else + return false + #endif } + private let configurableTabs: [TabDescriptor] = [ + TabDescriptor(id: "home", title: "Home", systemImage: "house") { AnyView(HomeView()) }, + TabDescriptor(id: "console", title: "Console", systemImage: "terminal") { AnyView(ConsoleLogsView()) }, + TabDescriptor(id: "scripts", title: "Scripts", systemImage: "scroll") { AnyView(ScriptListView()) }, + TabDescriptor(id: "profiles", title: "Profiles", systemImage: "magazine.fill") { AnyView(ProfileView()) }, + TabDescriptor(id: "processes", title: "Processes", systemImage: "rectangle.stack.person.crop") { AnyView(ProcessInspectorView()) }, + TabDescriptor(id: "deviceinfo", title: "Device Info", systemImage: "iphone.and.arrow.forward") { AnyView(DeviceInfoView()) }, + TabDescriptor(id: "location", title: "Location", systemImage: "location") { AnyView(LocationSimulationView()) } + ] + + private var availableTabs: [TabDescriptor] { + configurableTabs.filter { descriptor in + descriptor.id != "location" || !isAppStoreBuild + } + } + + private let settingsTab = TabDescriptor(id: "settings", title: "Settings", systemImage: "gearshape.fill") { + AnyView(SettingsView()) + } + + private var selectedTabDescriptors: [TabDescriptor] { + let ids = TabConfiguration.sanitize(raw: enabledTabIdentifiers) + return ids.compactMap { id in + availableTabs.first(where: { $0.id == id }) + } + } + + private func ensureSelectionIsValid() { + let ids = selectedTabDescriptors.map { $0.id } + if ids.contains(selection) || selection == settingsTab.id { + return + } + selection = ids.first ?? settingsTab.id + } + var body: some View { ZStack { // Allow global themed background to show @@ -37,31 +91,60 @@ struct MainTabView: View { // Main tabs TabView(selection: $selection) { - HomeView() - .tabItem { Label("Home", systemImage: "house") } - .tag(0) - - ConsoleLogsView() - .tabItem { Label("Console", systemImage: "terminal") } - .tag(1) - - ScriptListView() - .tabItem { Label("Scripts", systemImage: "scroll") } - .tag(2) + ForEach(selectedTabDescriptors) { descriptor in + descriptor.builder() + .tabItem { Label(descriptor.title, systemImage: descriptor.systemImage) } + .tag(descriptor.id) + } - ProfileView() - .tabItem { Label("Profiles", systemImage: "magazine.fill") } - .tag(3) - - SettingsView() - .tabItem { Label("Settings", systemImage: "gearshape.fill") } - .tag(4) + settingsTab.builder() + .tabItem { Label(settingsTab.title, systemImage: settingsTab.systemImage) } + .tag(settingsTab.id) } .id((themeExpansion?.hasThemeExpansion == true) ? customAccentColorHex : "default-accent") .tint(accentColor) .preferredColorScheme(preferredScheme) .onAppear { + enabledTabIdentifiers = TabConfiguration.serialize(TabConfiguration.sanitize(raw: enabledTabIdentifiers)) + ensureSelectionIsValid() + if !didSetInitialHome { + if selectedTabDescriptors.contains(where: { $0.id == "home" }) { + selection = "home" + } else if let descriptor = availableTabs.first(where: { $0.id == "home" }) { + detachedTab = descriptor + } + didSetInitialHome = true + } checkForUpdate() + switchObserver = NotificationCenter.default.addObserver(forName: .switchToTab, object: nil, queue: .main) { note in + guard let id = note.object as? String else { return } + if selectedTabDescriptors.contains(where: { $0.id == id }) { + selection = id + } else if let descriptor = availableTabs.first(where: { $0.id == id }) { + detachedTab = descriptor + } + } + } + .onDisappear { + if let observer = switchObserver { + NotificationCenter.default.removeObserver(observer) + switchObserver = nil + } + } + .onChange(of: enabledTabIdentifiers) { _ in + ensureSelectionIsValid() + } + .sheet(item: $detachedTab) { descriptor in + NavigationStack { + descriptor.builder() + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Close") { + detachedTab = nil + } + } + } + } } if showForceUpdate { diff --git a/StikJIT/Views/MapSelectionView.swift b/StikJIT/Views/MapSelectionView.swift index b05a0c9..82823bd 100644 --- a/StikJIT/Views/MapSelectionView.swift +++ b/StikJIT/Views/MapSelectionView.swift @@ -1,12 +1,13 @@ // // MapSelectionView.swift -// StikDebug +// StikJIT // -// Created by Stephen on 8/3/25. +// Created by Stephen on 11/3/25. // import SwiftUI import MapKit +import UIKit struct MapSelectionView: UIViewRepresentable { @Binding var coordinate: CLLocationCoordinate2D? @@ -63,14 +64,29 @@ struct MapSelectionView: UIViewRepresentable { } } -struct LocationSimulatorView: View { +struct LocationSimulationView: View { + @Environment(\.themeExpansionManager) private var themeExpansion @State private var coordinate: CLLocationCoordinate2D? - @State private var status = "" - @State private var showKeepOpenError = false + @State private var statusMessage: String = "" + @State private var statusIsError = false + @State private var showKeepOpenAlert = false @State private var searchQuery = "" - @State private var searchResults: [MKMapItem] = [] + @State private var searchResults: [SearchResult] = [] + @State private var backgroundTaskID: UIBackgroundTaskIdentifier = .invalid + @State private var resendTimer: Timer? + @AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue - let deviceIp: String + private var backgroundStyle: BackgroundStyle { + themeExpansion?.backgroundStyle(for: appThemeRaw) ?? AppTheme.system.backgroundStyle + } + + private var isAppStoreBuild: Bool { + #if APPSTORE + return true + #else + return false + #endif + } private var pairingFilePath: String { FileManager.default @@ -79,118 +95,162 @@ struct LocationSimulatorView: View { .path } + private var pairingExists: Bool { + FileManager.default.fileExists(atPath: pairingFilePath) + } + + private var deviceIP: String { + UserDefaults.standard.string(forKey: "TunnelDeviceIP") ?? "10.7.0.2" + } + var body: some View { - VStack(spacing: 16) { - VStack{ - Text("") - HStack() { - Text("") - Text("") - Text("Location Simulator") - .font(.largeTitle.bold()) - .padding(.top) - Spacer() + NavigationStack { + ZStack { + ThemedBackground(style: backgroundStyle) + .ignoresSafeArea() + + ScrollView { + VStack(spacing: 20) { + searchCard + mapCard + actionsCard + } + .padding(.horizontal, 20) + .padding(.vertical, 24) + } + + if showKeepOpenAlert { + CustomErrorView( + title: "Keep StikDebug Open", + message: "Location simulation stops if the app is backgrounded. Keep StikDebug in the foreground while testing.", + onDismiss: { showKeepOpenAlert = false }, + primaryButtonText: "OK", + showSecondaryButton: false, + messageType: .info + ) + .transition(.opacity.combined(with: .scale)) + .zIndex(1) } } - HStack { - Image(systemName: "magnifyingglass") - .foregroundColor(.secondary) - TextField("Search for a place", text: $searchQuery, onCommit: performSearch) - .font(.body) - .autocapitalization(.none) - .disableAutocorrection(true) + .navigationTitle("Location Simulator") + .onDisappear { + stopResendLoop() + endBackgroundTask() } - .padding(12) - .background(Color(UIColor.secondarySystemBackground)) - .cornerRadius(12) - .shadow(color: .black.opacity(0.05), radius: 2, x: 0, y: 1) - .padding(.horizontal) + } + } - if !searchResults.isEmpty { - ScrollView { - VStack(spacing: 0) { - ForEach(searchResults, id: \.self) { item in - Button(action: { select(item: item) }) { - HStack { - VStack(alignment: .leading, spacing: 2) { - Text(item.name ?? "Unknown") - .font(.body) - Text(item.placemark.title ?? "") - .font(.caption) - .foregroundColor(.secondary) - } - Spacer() - } - .padding(.vertical, 8) - .padding(.horizontal) + private var searchCard: some View { + MaterialCard { + VStack(alignment: .leading, spacing: 12) { + Text("Pick a Location") + .font(.headline) + .foregroundColor(.primary) + searchField + if !searchResults.isEmpty { + Divider() + ForEach(searchResults) { result in + Button(action: { select(result: result) }) { + VStack(alignment: .leading, spacing: 4) { + Text(result.name) + .font(.subheadline.weight(.semibold)) + Text(result.subtitle) + .font(.caption) + .foregroundColor(.secondary) } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 6) + } + .buttonStyle(.plain) + if result.id != searchResults.last?.id { Divider() } } - .background(RoundedRectangle(cornerRadius: 12) - .fill(Color(UIColor.systemBackground))) - .shadow(color: .black.opacity(0.05), radius: 2, x: 0, y: 1) - .padding(.horizontal) } - .frame(maxHeight: 200) + if !pairingExists && !isAppStoreBuild { + Label("Import a pairing file first.", systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundColor(.orange) + } + if isAppStoreBuild { + Label("Location simulation is unavailable in App Store builds.", systemImage: "nosign") + .font(.caption) + .foregroundColor(.orange) + } } + } + } - MapSelectionView(coordinate: $coordinate) - .frame(height: 360) - .cornerRadius(12) - .shadow(color: .black.opacity(0.1), radius: 4, x: 0, y: 2) - .padding(.horizontal) + private var searchField: some View { + HStack { + Image(systemName: "magnifyingglass") + .foregroundColor(.secondary) + TextField("Search for a place", text: $searchQuery, onCommit: performSearch) + .textInputAutocapitalization(.never) + .disableAutocorrection(true) + } + .padding(12) + .background(Color(UIColor.secondarySystemBackground)) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + } - if let coord = coordinate { - VStack(alignment: .leading, spacing: 4) { - Text("Selected Coordinates") - .font(.headline) + private var mapCard: some View { + MaterialCard { + VStack(alignment: .leading, spacing: 12) { + Text("Map") + .font(.headline) + .foregroundColor(.primary) + MapSelectionView(coordinate: $coordinate) + .frame(height: 320) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.white.opacity(0.12), lineWidth: 1) + ) + if let coord = coordinate { Text(String(format: "%.6f, %.6f", coord.latitude, coord.longitude)) - .font(.subheadline) + .font(.footnote) + .foregroundColor(.secondary) + } else { + Text("Long-press on the map or search above to drop a pin.") + .font(.footnote) .foregroundColor(.secondary) } - .padding() - .frame(maxWidth: .infinity) - .background(RoundedRectangle(cornerRadius: 12) - .fill(Color(UIColor.secondarySystemBackground))) - .shadow(color: .black.opacity(0.05), radius: 2, x: 0, y: 1) - .padding(.horizontal) - } else { - Text("Long-press on the map or search above to pick a location.") - .font(.subheadline) - .foregroundColor(.secondary) - .padding(.horizontal) } - - HStack(spacing: 12) { - Button(action: simulate) { - Label("Simulate", systemImage: "location.fill") - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .disabled(coordinate == nil) - - Button(action: clear) { - Label("Clear", systemImage: "xmark.circle.fill") - .frame(maxWidth: .infinity) - } - .buttonStyle(.bordered) - .disabled(coordinate == nil) - } - .padding(.horizontal) - - if !status.isEmpty { - Text(status) - .font(.callout) - .foregroundColor(status.contains("failed") ? .red : .green) - .padding(.horizontal) - .transition(.opacity) - } - - Spacer() } - .padding(.bottom) - .overlay(overlayErrorView) + } + + private var actionsCard: some View { + MaterialCard { + VStack(alignment: .leading, spacing: 12) { + Text("Actions") + .font(.headline) + .foregroundColor(.primary) + HStack(spacing: 12) { + Button(action: simulate) { + Label("Simulate", systemImage: "location.fill") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .disabled(isAppStoreBuild || coordinate == nil || !pairingExists) + + Button(action: clear) { + Label("Clear", systemImage: "xmark.circle") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .disabled(isAppStoreBuild || !pairingExists) + } + if !statusMessage.isEmpty { + Text(statusMessage) + .font(.subheadline) + .foregroundColor(statusIsError ? .red : .green) + } + Text("Device IP: \(deviceIP)") + .font(.caption) + .foregroundColor(.secondary) + } + } } private func performSearch() { @@ -198,52 +258,86 @@ struct LocationSimulatorView: View { let request = MKLocalSearch.Request() request.naturalLanguageQuery = searchQuery MKLocalSearch(request: request).start { response, _ in - searchResults = response?.mapItems ?? [] + let items = response?.mapItems ?? [] + searchResults = items.map { item in + SearchResult( + name: item.name ?? "Unknown", + subtitle: item.placemark.title ?? "", + item: item + ) + } } } - private func select(item: MKMapItem) { - coordinate = item.placemark.coordinate - status = "" + private func select(result: SearchResult) { + coordinate = result.item.placemark.coordinate + statusMessage = "" searchResults = [] - searchQuery = item.name ?? "" + searchQuery = result.name } private func simulate() { + guard pairingExists else { + statusMessage = "Pairing file missing." + statusIsError = true + return + } guard let coord = coordinate else { return } - let code = simulate_location( - deviceIp, - coord.latitude, - coord.longitude, - pairingFilePath - ) + let code = simulate_location(deviceIP, coord.latitude, coord.longitude, pairingFilePath) if code == 0 { - status = "Simulation running…" - showKeepOpenError = true + statusMessage = "Simulation running…" + statusIsError = false + showKeepOpenAlert = true + beginBackgroundTask() + startResendLoop() } else { - status = "Simulation failed (code \(code))." + statusMessage = "Simulation failed (code \(code))." + statusIsError = true + stopResendLoop() + endBackgroundTask() } } private func clear() { + guard pairingExists else { return } let code = clear_simulated_location() - status = code == 0 ? "Cleared simulation." : "Clear failed (code \(code))." - showKeepOpenError = false + statusMessage = code == 0 ? "Cleared simulation." : "Clear failed (code \(code))." + statusIsError = code != 0 + showKeepOpenAlert = false + stopResendLoop() + endBackgroundTask() } - - @ViewBuilder - private var overlayErrorView: some View { - if showKeepOpenError { - CustomErrorView( - title: "Keep App Open", - message: "Your simulation will stop if the app goes to the background. Please keep the app open to continue.", - onDismiss: { showKeepOpenError = false }, - primaryButtonText: "OK", - showSecondaryButton: false, - messageType: .info - ) - .transition(.opacity.combined(with: .scale)) - .zIndex(1) + + private func beginBackgroundTask() { + guard backgroundTaskID == .invalid else { return } + backgroundTaskID = UIApplication.shared.beginBackgroundTask(withName: "LocationSimulation") { + endBackgroundTask() } } + + private func endBackgroundTask() { + guard backgroundTaskID != .invalid else { return } + UIApplication.shared.endBackgroundTask(backgroundTaskID) + backgroundTaskID = .invalid + } + + private func startResendLoop() { + resendTimer?.invalidate() + resendTimer = Timer.scheduledTimer(withTimeInterval: 4, repeats: true) { _ in + guard pairingExists, let coord = coordinate else { return } + _ = simulate_location(deviceIP, coord.latitude, coord.longitude, pairingFilePath) + } + } + + private func stopResendLoop() { + resendTimer?.invalidate() + resendTimer = nil + } +} + +private struct SearchResult: Identifiable { + let id = UUID() + let name: String + let subtitle: String + let item: MKMapItem } diff --git a/StikJIT/Views/ProcessInspectorView.swift b/StikJIT/Views/ProcessInspectorView.swift new file mode 100644 index 0000000..b92aa2a --- /dev/null +++ b/StikJIT/Views/ProcessInspectorView.swift @@ -0,0 +1,382 @@ +// +// ProcessInspectorView.swift +// StikJIT +// +// Created by Stephen on 11/3/25. +// + +import SwiftUI + +struct ProcessInspectorView: View { + @StateObject private var viewModel = ProcessInspectorViewModel() + @Environment(\.themeExpansionManager) private var themeExpansion + @AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue + @State private var killCandidate: ProcessInfoEntry? + @State private var killConfirmTask: Task? + + private var backgroundStyle: BackgroundStyle { + themeExpansion?.backgroundStyle(for: appThemeRaw) ?? AppTheme.system.backgroundStyle + } + + + var body: some View { + NavigationStack { + ZStack { + ThemedBackground(style: backgroundStyle) + .ignoresSafeArea() + content + } + .navigationTitle("Process Inspector") + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button(action: viewModel.refresh) { + Label("Refresh", systemImage: "arrow.clockwise") + } + .disabled(viewModel.isRefreshing) + } + } + } + .task { + await viewModel.startAutoRefresh() + } + .onDisappear { + viewModel.stopAutoRefresh() + } + .alert(item: $viewModel.killAlert) { info in + Alert( + title: Text(info.title), + message: Text(info.message), + dismissButton: .default(Text("OK")) + ) + } + } + + @ViewBuilder + private var content: some View { + if let error = viewModel.errorMessage { + VStack(spacing: 16) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 36)) + .foregroundStyle(.orange) + Text(error) + .multilineTextAlignment(.center) + .foregroundColor(.primary) + Button("Try Again") { + viewModel.refresh() + } + .buttonStyle(.borderedProminent) + } + .padding() + } else { + ScrollView { + VStack(spacing: 16) { + statsCard + processesCard + } + .padding(.horizontal, 20) + .padding(.top, 20) + .padding(.bottom, 30) + } + .refreshable { + viewModel.refresh() + } + .searchable(text: $viewModel.searchText, placement: .navigationBarDrawer(displayMode: .always)) + } + } +} + +private extension ProcessInspectorView { + var statsCard: some View { + MaterialCard { + VStack(alignment: .leading, spacing: 12) { + Text("Overview") + .font(.headline) + .foregroundColor(.primary) + VStack(alignment: .leading, spacing: 8) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Total Processes") + .font(.caption) + .foregroundColor(.secondary) + Text("\(viewModel.processes.count)") + .font(.title2.bold()) + } + Spacer() + } + } + } + } + } + + var processesCard: some View { + MaterialCard { + VStack(alignment: .leading, spacing: 12) { + Text("Processes") + .font(.headline) + .foregroundColor(.primary) + if viewModel.filteredProcesses.isEmpty { + Text("No matching processes.") + .font(.caption) + .foregroundColor(.secondary) + } else { + LazyVStack(spacing: 0) { + ForEach(viewModel.filteredProcesses) { process in + ProcessRow( + process: process, + isKilling: viewModel.killingPID == process.pid, + isConfirming: killCandidate?.pid == process.pid, + onKillTap: { handleKillTap(for: $0) } + ) + .padding(.vertical, 10) + + if process.id != viewModel.filteredProcesses.last?.id { + Divider() + .background(Color.white.opacity(0.1)) + } + } + } + } + } + } + } + + func handleKillTap(for process: ProcessInfoEntry) { + if killCandidate?.pid == process.pid { + killConfirmTask?.cancel() + killConfirmTask = nil + killCandidate = nil + viewModel.kill(process: process) + } else { + killCandidate = process + killConfirmTask?.cancel() + killConfirmTask = Task { + try? await Task.sleep(for: .seconds(3)) + await MainActor.run { + if killCandidate?.pid == process.pid { + killCandidate = nil + } + } + } + } + } +} + +// MARK: - Row + +private struct ProcessRow: View { + let process: ProcessInfoEntry + let isKilling: Bool + let isConfirming: Bool + let onKillTap: (ProcessInfoEntry) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(process.displayName) + .font(.headline) + Spacer() + Text("PID \(process.pid)") + .font(.caption) + .foregroundStyle(.secondary) + } + if let bundle = process.bundleID, !bundle.isEmpty { + Text(bundle) + .font(.caption) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + Text(process.executablePath) + .font(.caption) + .foregroundStyle(.secondary) + .textSelection(.enabled) + HStack { + Spacer() + if isKilling { + ProgressView() + .progressViewStyle(.circular) + .tint(.accentColor) + } else { + Button { + onKillTap(process) + } label: { + if isConfirming { + Label("Confirm", systemImage: "checkmark.circle.fill") + .labelStyle(.iconOnly) + .font(.title3) + } else { + Image(systemName: "xmark.circle") + .font(.title3) + } + } + .buttonStyle(.bordered) + .tint(isConfirming ? .green : .red) + .labelStyle(.iconOnly) + } + } + } + .padding(.vertical, 8) + } +} + +// MARK: - View Model + +@MainActor +final class ProcessInspectorViewModel: ObservableObject { + struct AlertInfo: Identifiable { + let id = UUID() + let title: String + let message: String + } + + @Published private(set) var processes: [ProcessInfoEntry] = [] + @Published var searchText: String = "" + @Published var isRefreshing = false + @Published var errorMessage: String? + @Published private(set) var killingPID: Int? + @Published var killAlert: AlertInfo? + + private var refreshTask: Task? + private var killTimeoutTask: Task? + @Published private(set) var lastUpdated: Date? + var filteredProcesses: [ProcessInfoEntry] { + guard !searchText.isEmpty else { return processes } + return processes.filter { + $0.displayName.localizedCaseInsensitiveContains(searchText) || + ($0.bundleID?.localizedCaseInsensitiveContains(searchText) ?? false) || + $0.executablePath.localizedCaseInsensitiveContains(searchText) || + "\($0.pid)".contains(searchText) + } + } + + var lastUpdatedText: String { + guard let date = lastUpdated else { return "—" } + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .short + return formatter.localizedString(for: date, relativeTo: Date()) + } + + func startAutoRefresh() async { + refresh() + refreshTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(10)) + guard let self else { break } + await MainActor.run { + self.refresh() + } + } + } + } + + func stopAutoRefresh() { + refreshTask?.cancel() + refreshTask = nil + killTimeoutTask?.cancel() + killTimeoutTask = nil + } + + func refresh() { + guard !isRefreshing else { return } + isRefreshing = true + errorMessage = nil + Task.detached(priority: .utility) { [weak self] in + var err: NSError? + let entries = FetchDeviceProcessList(&err) ?? [] + await MainActor.run { + guard let self else { return } + if let err { + self.errorMessage = err.localizedDescription + } else { + self.processes = entries.compactMap { item -> ProcessInfoEntry? in + guard let dict = item as? NSDictionary else { return nil } + return ProcessInfoEntry(dictionary: dict) + } + self.lastUpdated = Date() + } + self.isRefreshing = false + } + } + } + + func kill(process: ProcessInfoEntry) { + guard killingPID == nil else { + killAlert = AlertInfo(title: "Busy", message: "Already terminating PID \(killingPID!).") + return + } + let targetPID = process.pid + killingPID = targetPID + killTimeoutTask?.cancel() + killTimeoutTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(8)) + await MainActor.run { + guard let self else { return } + if self.killingPID == targetPID { + self.killingPID = nil + self.killAlert = AlertInfo( + title: "Kill Timed Out", + message: "Could not confirm termination for PID \(targetPID). Try again." + ) + } + } + } + Task.detached(priority: .userInitiated) { [weak self] in + var err: NSError? + let success = KillDeviceProcess(Int32(targetPID), &err) + await MainActor.run { + guard let self else { return } + self.killTimeoutTask?.cancel() + self.killTimeoutTask = nil + guard self.killingPID == targetPID else { return } + self.killingPID = nil + if success { + self.killAlert = AlertInfo( + title: "Process Terminated", + message: "PID \(targetPID) was terminated." + ) + self.refresh() + } else { + self.killAlert = AlertInfo( + title: "Kill Failed", + message: err?.localizedDescription ?? "Unknown error" + ) + } + } + } + } + +} + +struct ProcessInfoEntry: Identifiable { + let pid: Int + private let rawPath: String + let bundleID: String? + let name: String? + + init?(dictionary: NSDictionary) { + guard let pidNumber = dictionary["pid"] as? NSNumber else { return nil } + pid = pidNumber.intValue + rawPath = dictionary["path"] as? String ?? "Unknown" + bundleID = dictionary["bundleID"] as? String + name = dictionary["name"] as? String + } + + var id: Int { pid } + + var executablePath: String { + rawPath.replacingOccurrences(of: "file://", with: "") + } + + var displayName: String { + if let name = name, !name.isEmpty { + return name + } + if let bundle = bundleID, !bundle.isEmpty { + return bundle + } + let cleaned = executablePath + if let component = cleaned.split(separator: "/").last { + return String(component) + } + return "Process \(pid)" + } +} diff --git a/StikJIT/Views/SettingsView.swift b/StikJIT/Views/SettingsView.swift index 1550f9a..d25bdd1 100644 --- a/StikJIT/Views/SettingsView.swift +++ b/StikJIT/Views/SettingsView.swift @@ -18,9 +18,18 @@ struct SettingsView: View { @AppStorage(UserDefaults.Keys.txmOverride) private var overrideTXMDetection = false @AppStorage("customAccentColor") private var customAccentColorHex: String = "" @AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue + @AppStorage(TabConfiguration.storageKey) private var enabledTabIdentifiers = TabConfiguration.defaultRawValue + @AppStorage("primaryTabSelection") private var tabSelection = TabConfiguration.defaultIDs.first ?? "home" @Environment(\.themeExpansionManager) private var themeExpansion private var backgroundStyle: BackgroundStyle { themeExpansion?.backgroundStyle(for: appThemeRaw) ?? AppTheme.system.backgroundStyle } private var preferredScheme: ColorScheme? { themeExpansion?.preferredColorScheme(for: appThemeRaw) } + private var isAppStoreBuild: Bool { + #if APPSTORE + return true + #else + return false + #endif + } @State private var isShowingPairingFilePicker = false @Environment(\.colorScheme) private var colorScheme @@ -40,6 +49,7 @@ struct SettingsView: View { @State private var ddiResultMessage: (text: String, isError: Bool)? @State private var showingDisplayView = false + @State private var tabSelectionMessage: String? private var appVersion: String { let marketingVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0" @@ -57,7 +67,14 @@ struct SettingsView: View { private var accentColorDescription: String { customAccentColorHex.isEmpty ? "System Blue" : customAccentColorHex.uppercased() } - // Developer profile image URLs + struct TabOption: Identifiable { + let id: String + let title: String + let detail: String + let icon: String + let isBeta: Bool + } + private let developerProfiles: [String: String] = [ "Stephen": "https://github.com/StephenDev0.png", "jkcoxson": "https://github.com/jkcoxson.png", @@ -67,6 +84,21 @@ struct SettingsView: View { "Huge_Black": "https://github.com/HugeBlack.png", "Wynwxst": "https://github.com/Wynwxst.png" ] + + private var tabOptions: [TabOption] { + var options: [TabOption] = [ + TabOption(id: "home", title: "Home", detail: "Dashboard overview", icon: "house", isBeta: false), + TabOption(id: "console", title: "Console", detail: "Live device logs", icon: "terminal", isBeta: false), + TabOption(id: "scripts", title: "Scripts", detail: "Manage automation scripts", icon: "scroll", isBeta: false), + TabOption(id: "profiles", title: "Profiles", detail: "Install/remove profiles", icon: "magazine.fill", isBeta: false), + TabOption(id: "processes", title: "Processes", detail: "Inspect running apps", icon: "rectangle.stack.person.crop", isBeta: true), + TabOption(id: "deviceinfo", title: "Device Info", detail: "View detailed device metadata", icon: "iphone.and.arrow.forward", isBeta: false) + ] + if !isAppStoreBuild { + options.append(TabOption(id: "location", title: "Location Sim", detail: "Sideload only", icon: "location", isBeta: true)) + } + return options + } var body: some View { NavigationStack { @@ -79,6 +111,7 @@ struct SettingsView: View { VStack(spacing: 20) { headerCard appearanceCard + tabCustomizationCard pairingCard behaviorCard advancedCard @@ -295,6 +328,56 @@ struct SettingsView: View { } } + private var tabCustomizationCard: some View { + let selection = selectedTabIDs + let pinnedOptions = selectedTabIDs.compactMap { id in + tabOptions.first(where: { $0.id == id }) + } + let unpinnedOptions = tabOptions.filter { !selection.contains($0.id) } + return glassCard { + VStack(alignment: .leading, spacing: 16) { + Text("Tab Bar") + .font(.headline) + .foregroundColor(.primary) + Text("Pick up to \(TabConfiguration.maxSelectableTabs) tabs to pin. Settings is always available as the final tab.") + .font(.caption) + .foregroundColor(.secondary) + ForEach(pinnedOptions) { option in + TabRow(option: option, + isPinned: true, + isFirst: pinnedOptions.first?.id == option.id, + isLast: pinnedOptions.last?.id == option.id, + isBeta: option.isBeta, + onMove: { moveTab(option.id, offset: $0) }, + onToggle: { toggleTabOption(option, enable: $0) }, + onSelect: { if $0 { tabSelection = option.id }; switchToTab(option.id) }) + } + if !unpinnedOptions.isEmpty { + Divider() + ForEach(unpinnedOptions) { option in + TabRow(option: option, + isPinned: false, + isFirst: false, + isLast: false, + isBeta: option.isBeta, + onMove: { _ in }, + onToggle: { toggleTabOption(option, enable: $0) }, + onSelect: { _ in switchToTab(option.id) }) + } + } + Text("\(selection.count) / \(TabConfiguration.maxSelectableTabs) slots used") + .font(.caption) + .foregroundColor(.secondary) + if let message = tabSelectionMessage { + Text(message) + .font(.caption) + .foregroundColor(.orange) + } + } + .padding(4) + } + } + private var pairingCard: some View { glassCard { VStack(alignment: .leading, spacing: 14) { @@ -388,23 +471,6 @@ struct SettingsView: View { .font(.headline) .foregroundColor(.primary) - NavigationLink { - DeviceInfoView() - } label: { - HStack { - Image(systemName: "info.circle") - .font(.system(size: 18)) - .foregroundColor(.primary.opacity(0.8)) - Text("Device Info") - .foregroundColor(.primary.opacity(0.8)) - Spacer() - Image(systemName: "chevron.right") - .font(.system(size: 14)) - .foregroundColor(.white) - } - .padding(.vertical, 8) - } - Button(action: { openAppFolder() }) { HStack { Image(systemName: "folder") @@ -539,19 +605,121 @@ struct SettingsView: View { // MARK: - Helpers (UI + logic) + private var selectedTabIDs: [String] { + TabConfiguration.sanitize(raw: enabledTabIdentifiers) + } + + private func toggleTabOption(_ option: TabOption, enable: Bool) { + var ids = selectedTabIDs + if enable { + guard !ids.contains(option.id) else { return } + guard ids.count < TabConfiguration.maxSelectableTabs else { + tabSelectionMessage = "You can only pin \(TabConfiguration.maxSelectableTabs) tabs besides Settings." + return + } + ids.append(option.id) + } else { + ids.removeAll { $0 == option.id } + if ids.isEmpty { + ids = TabConfiguration.defaultIDs + } + } + tabSelectionMessage = nil + enabledTabIdentifiers = TabConfiguration.serialize(ids) + } + + private func moveTab(_ id: String, offset: Int) { + var ids = selectedTabIDs + guard let currentIndex = ids.firstIndex(of: id) else { return } + let targetIndex = max(0, min(ids.count - 1, currentIndex + offset)) + guard currentIndex != targetIndex else { return } + let element = ids.remove(at: currentIndex) + ids.insert(element, at: targetIndex) + enabledTabIdentifiers = TabConfiguration.serialize(ids) + } + + private func switchToTab(_ id: String) { + NotificationCenter.default.post(name: .switchToTab, object: id) + } + + private struct TabRow: View { + let option: TabOption + let isPinned: Bool + let isFirst: Bool + let isLast: Bool + let isBeta: Bool + let onMove: (Int) -> Void + let onToggle: (Bool) -> Void + let onSelect: (Bool) -> Void + + var body: some View { + HStack(spacing: 12) { + Button { + onSelect(isPinned) + } label: { + HStack(alignment: .center, spacing: 0) { + Image(systemName: option.icon) + .frame(width: 24, height: 24) + .foregroundColor(.primary.opacity(0.8)) + VStack(alignment: .leading, spacing: 2) { + Text(option.title) + .font(.subheadline.weight(.semibold)) + Text(option.detail) + .font(.caption) + .foregroundColor(.secondary) + } + .padding(.leading, 8) + if isBeta { + Spacer(minLength: 12) + Text("BETA") + .font(.caption2.weight(.bold)) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .foregroundColor(.orange) + .background( + Capsule() + .fill(Color.orange.opacity(0.15)) + ) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.plain) + + if isPinned { + HStack(spacing: 8) { + Button { + onMove(-1) + } label: { + Image(systemName: "chevron.up") + } + .disabled(isFirst) + + Button { + onMove(1) + } label: { + Image(systemName: "chevron.down") + } + .disabled(isLast) + } + .buttonStyle(.borderless) + } + + Toggle(isOn: Binding( + get: { isPinned }, + set: { newValue in onToggle(newValue) } + )) { + EmptyView() + } + .labelsHidden() + } + } + } + private func glassCard(@ViewBuilder content: () -> Content) -> some View { - content() - .padding(20) - .background( - RoundedRectangle(cornerRadius: 20, style: .continuous) - .fill(.ultraThinMaterial) - .overlay( - RoundedRectangle(cornerRadius: 20, style: .continuous) - .strokeBorder(Color.white.opacity(0.15), lineWidth: 1) - ) - ) - .clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous)) - .shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4) + MaterialCard { + content() + } } private func changeAppIcon(to iconName: String) { diff --git a/StikJIT/idevice/JITEnableContext.h b/StikJIT/idevice/JITEnableContext.h index 0371c00..56156e4 100644 --- a/StikJIT/idevice/JITEnableContext.h +++ b/StikJIT/idevice/JITEnableContext.h @@ -32,4 +32,6 @@ typedef void (^SyslogErrorHandler)(NSError *error); - (NSArray*)fetchAllProfiles:(NSError **)error; - (BOOL)removeProfileWithUUID:(NSString*)uuid error:(NSError **)error; - (BOOL)addProfile:(NSData*)profile error:(NSError **)error; +- (NSArray*)fetchProcessListWithError:(NSError**)error; +- (BOOL)killProcessWithPID:(int)pid error:(NSError **)error; @end diff --git a/StikJIT/idevice/JITEnableContext.m b/StikJIT/idevice/JITEnableContext.m index eb816f9..5cd9751 100644 --- a/StikJIT/idevice/JITEnableContext.m +++ b/StikJIT/idevice/JITEnableContext.m @@ -6,6 +6,7 @@ // #include "idevice.h" #include +#include #include #include "heartbeat.h" @@ -26,6 +27,7 @@ JITEnableContext* sharedJITContext = nil; SyslogRelayClientHandle *syslogClient; SyslogLineHandler syslogLineHandler; SyslogErrorHandler syslogErrorHandler; + dispatch_queue_t processInspectorQueue; } + (instancetype)shared { @@ -43,6 +45,8 @@ JITEnableContext* sharedJITContext = nil; syslogQueue = dispatch_queue_create("com.stik.syslogrelay.queue", DISPATCH_QUEUE_SERIAL); syslogStreaming = NO; syslogClient = NULL; + dispatch_queue_attr_t qosAttr = dispatch_queue_attr_make_with_qos_class(DISPATCH_QUEUE_SERIAL, QOS_CLASS_USER_INITIATED, 0); + processInspectorQueue = dispatch_queue_create("com.stikdebug.processInspector", qosAttr); return self; } @@ -399,4 +403,352 @@ JITEnableContext* sharedJITContext = nil; } } +- (NSArray*)fetchProcessesViaAppServiceWithError:(NSError **)error { + NSURL *documents = [[NSFileManager defaultManager] URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask].firstObject; + NSURL *pairingURL = [documents URLByAppendingPathComponent:@"pairingFile.plist"]; + IdevicePairingFile *pairingFile = NULL; + IdeviceProviderHandle *tempProvider = NULL; + IdeviceProviderHandle *providerToUse = provider; + CoreDeviceProxyHandle *coreProxy = NULL; + AdapterHandle *adapter = NULL; + AdapterStreamHandle *stream = NULL; + RsdHandshakeHandle *handshake = NULL; + AppServiceHandle *appService = NULL; + ProcessTokenC *processes = NULL; + uintptr_t count = 0; + NSMutableArray *result = nil; + IdeviceFfiError *ffiError = NULL; + + do { + if (!providerToUse) { + ffiError = idevice_pairing_file_read(pairingURL.path.UTF8String, &pairingFile); + if (ffiError) { + if (error) { + *error = [self errorWithStr:@"Unable to read pairing file" code:ffiError->code]; + } + idevice_error_free(ffiError); + ffiError = NULL; + break; + } + + struct sockaddr_in addr = {0}; + addr.sin_family = AF_INET; + addr.sin_port = htons(LOCKDOWN_PORT); + NSString *ip = [[NSUserDefaults standardUserDefaults] stringForKey:@"TunnelDeviceIP"]; + if (ip.length == 0) { + ip = @"10.7.0.2"; + } + inet_pton(AF_INET, ip.UTF8String, &addr.sin_addr); + + ffiError = idevice_tcp_provider_new((struct sockaddr *)&addr, pairingFile, "ProcessInspector", &tempProvider); + if (ffiError) { + if (error) { + *error = [self errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "Failed to open provider"] + code:ffiError->code]; + } + idevice_error_free(ffiError); + ffiError = NULL; + break; + } + providerToUse = tempProvider; + } + + ffiError = core_device_proxy_connect(providerToUse, &coreProxy); + if (ffiError) { + if (error) { + *error = [self errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "Failed to connect CoreDeviceProxy"] + code:ffiError->code]; + } + idevice_error_free(ffiError); + ffiError = NULL; + break; + } + + uint16_t rsdPort = 0; + ffiError = core_device_proxy_get_server_rsd_port(coreProxy, &rsdPort); + if (ffiError) { + if (error) { + *error = [self errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "Unable to resolve RSD port"] + code:ffiError->code]; + } + idevice_error_free(ffiError); + ffiError = NULL; + break; + } + + ffiError = core_device_proxy_create_tcp_adapter(coreProxy, &adapter); + if (ffiError) { + if (error) { + *error = [self errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "Failed to create adapter"] + code:ffiError->code]; + } + idevice_error_free(ffiError); + ffiError = NULL; + break; + } + + coreProxy = NULL; + ffiError = adapter_connect(adapter, rsdPort, (ReadWriteOpaque **)&stream); + if (ffiError) { + if (error) { + *error = [self errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "Adapter connect failed"] + code:ffiError->code]; + } + idevice_error_free(ffiError); + ffiError = NULL; + break; + } + + ffiError = rsd_handshake_new((ReadWriteOpaque *)stream, &handshake); + if (ffiError) { + if (error) { + *error = [self errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "RSD handshake failed"] + code:ffiError->code]; + } + idevice_error_free(ffiError); + ffiError = NULL; + break; + } + + stream = NULL; + ffiError = app_service_connect_rsd(adapter, handshake, &appService); + if (ffiError) { + if (error) { + *error = [self errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "Unable to open AppService"] + code:ffiError->code]; + } + idevice_error_free(ffiError); + ffiError = NULL; + break; + } + + ffiError = app_service_list_processes(appService, &processes, &count); + if (ffiError) { + if (error) { + *error = [self errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "Failed to list processes"] + code:ffiError->code]; + } + idevice_error_free(ffiError); + ffiError = NULL; + break; + } + + result = [NSMutableArray arrayWithCapacity:count]; + for (uintptr_t idx = 0; idx < count; idx++) { + ProcessTokenC proc = processes[idx]; + NSMutableDictionary *entry = [NSMutableDictionary dictionary]; + entry[@"pid"] = @(proc.pid); + if (proc.executable_url) { + entry[@"path"] = [NSString stringWithUTF8String:proc.executable_url]; + } + [result addObject:entry]; + } + } while (0); + + if (processes && count > 0) { + app_service_free_process_list(processes, count); + } + if (appService) { + app_service_free(appService); + } + if (handshake) { + rsd_handshake_free(handshake); + } + if (stream) { + adapter_stream_close(stream); + } + if (adapter) { + adapter_free(adapter); + } + if (coreProxy) { + core_device_proxy_free(coreProxy); + } + if (tempProvider) { + idevice_provider_free(tempProvider); + } + if (pairingFile) { + idevice_pairing_file_free(pairingFile); + } + return result; +} + +- (NSArray*)_fetchProcessListLocked:(NSError**)error { + if (provider) { + [self ensureHeartbeat]; + } + return [self fetchProcessesViaAppServiceWithError:error]; +} + +- (NSArray*)fetchProcessListWithError:(NSError**)error { + __block NSArray *result = nil; + __block NSError *localError = nil; + dispatch_sync(processInspectorQueue, ^{ + result = [self _fetchProcessListLocked:&localError]; + }); + if (error && localError) { + *error = localError; + } + return result; +} + +- (BOOL)killProcessWithPID:(int)pid error:(NSError **)error { + NSURL *documents = [[NSFileManager defaultManager] URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask].firstObject; + NSURL *pairingURL = [documents URLByAppendingPathComponent:@"pairingFile.plist"]; + IdevicePairingFile *pairingFile = NULL; + IdeviceProviderHandle *tempProvider = NULL; + IdeviceProviderHandle *providerToUse = provider; + CoreDeviceProxyHandle *coreProxy = NULL; + AdapterHandle *adapter = NULL; + AdapterStreamHandle *stream = NULL; + RsdHandshakeHandle *handshake = NULL; + AppServiceHandle *appService = NULL; + SignalResponseC *signalResponse = NULL; + IdeviceFfiError *ffiError = NULL; + BOOL success = NO; + + do { + if (!providerToUse) { + ffiError = idevice_pairing_file_read(pairingURL.path.UTF8String, &pairingFile); + if (ffiError) { + if (error) { + *error = [self errorWithStr:@"Unable to read pairing file" code:ffiError->code]; + } + idevice_error_free(ffiError); + ffiError = NULL; + break; + } + + struct sockaddr_in addr = {0}; + addr.sin_family = AF_INET; + addr.sin_port = htons(LOCKDOWN_PORT); + NSString *ip = [[NSUserDefaults standardUserDefaults] stringForKey:@"TunnelDeviceIP"]; + if (ip.length == 0) { + ip = @"10.7.0.2"; + } + inet_pton(AF_INET, ip.UTF8String, &addr.sin_addr); + + ffiError = idevice_tcp_provider_new((struct sockaddr *)&addr, pairingFile, "ProcessInspectorKill", &tempProvider); + if (ffiError) { + if (error) { + *error = [self errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "Failed to open provider"] + code:ffiError->code]; + } + idevice_error_free(ffiError); + ffiError = NULL; + break; + } + providerToUse = tempProvider; + } else { + [self ensureHeartbeat]; + } + + ffiError = core_device_proxy_connect(providerToUse, &coreProxy); + if (ffiError) { + if (error) { + *error = [self errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "Failed to connect CoreDeviceProxy"] + code:ffiError->code]; + } + idevice_error_free(ffiError); + ffiError = NULL; + break; + } + + uint16_t rsdPort = 0; + ffiError = core_device_proxy_get_server_rsd_port(coreProxy, &rsdPort); + if (ffiError) { + if (error) { + *error = [self errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "Unable to resolve RSD port"] + code:ffiError->code]; + } + idevice_error_free(ffiError); + ffiError = NULL; + break; + } + + ffiError = core_device_proxy_create_tcp_adapter(coreProxy, &adapter); + if (ffiError) { + if (error) { + *error = [self errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "Failed to create adapter"] + code:ffiError->code]; + } + idevice_error_free(ffiError); + ffiError = NULL; + break; + } + + coreProxy = NULL; + ffiError = adapter_connect(adapter, rsdPort, (ReadWriteOpaque **)&stream); + if (ffiError) { + if (error) { + *error = [self errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "Adapter connect failed"] + code:ffiError->code]; + } + idevice_error_free(ffiError); + ffiError = NULL; + break; + } + + ffiError = rsd_handshake_new((ReadWriteOpaque *)stream, &handshake); + if (ffiError) { + if (error) { + *error = [self errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "RSD handshake failed"] + code:ffiError->code]; + } + idevice_error_free(ffiError); + ffiError = NULL; + break; + } + + stream = NULL; + ffiError = app_service_connect_rsd(adapter, handshake, &appService); + if (ffiError) { + if (error) { + *error = [self errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "Unable to open AppService"] + code:ffiError->code]; + } + idevice_error_free(ffiError); + ffiError = NULL; + break; + } + + ffiError = app_service_send_signal(appService, (uint32_t)pid, SIGKILL, &signalResponse); + if (ffiError) { + if (error) { + *error = [self errorWithStr:[NSString stringWithUTF8String:ffiError->message ?: "Failed to kill process"] + code:ffiError->code]; + } + idevice_error_free(ffiError); + ffiError = NULL; + break; + } + success = YES; + } while (0); + + if (signalResponse) { + app_service_free_signal_response(signalResponse); + } + if (appService) { + app_service_free(appService); + } + if (handshake) { + rsd_handshake_free(handshake); + } + if (stream) { + adapter_stream_close(stream); + } + if (adapter) { + adapter_free(adapter); + } + if (coreProxy) { + core_device_proxy_free(coreProxy); + } + if (tempProvider) { + idevice_provider_free(tempProvider); + } + if (pairingFile) { + idevice_pairing_file_free(pairingFile); + } + return success; +} + @end