mirror of
https://github.com/StikDebug/StikDebug.git
synced 2026-10-15 20:10:30 +00:00
Lot's of new stuff
This commit is contained in:
parent
5fef72528d
commit
9800b3be5d
11 changed files with 1362 additions and 182 deletions
|
|
@ -3,6 +3,7 @@
|
|||
//
|
||||
|
||||
#include "idevice/JITEnableContext.h"
|
||||
#import "Utilities/ProcessInspectorBridge.h"
|
||||
#include "idevice/idevice.h"
|
||||
#include "idevice/heartbeat.h"
|
||||
#include "JSSupport/JSSupport.h"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
13
StikJIT/Utilities/ProcessInspectorBridge.h
Normal file
13
StikJIT/Utilities/ProcessInspectorBridge.h
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
//
|
||||
// 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
|
||||
34
StikJIT/Utilities/ProcessInspectorBridge.m
Normal file
34
StikJIT/Utilities/ProcessInspectorBridge.m
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
//
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
37
StikJIT/Utilities/TabConfiguration.swift
Normal file
37
StikJIT/Utilities/TabConfiguration.swift
Normal file
|
|
@ -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: ",")
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
382
StikJIT/Views/ProcessInspectorView.swift
Normal file
382
StikJIT/Views/ProcessInspectorView.swift
Normal file
|
|
@ -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<Void, Never>?
|
||||
|
||||
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<Void, Never>?
|
||||
private var killTimeoutTask: Task<Void, Never>?
|
||||
@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)"
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Content: View>(@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) {
|
||||
|
|
|
|||
|
|
@ -32,4 +32,6 @@ typedef void (^SyslogErrorHandler)(NSError *error);
|
|||
- (NSArray<NSData*>*)fetchAllProfiles:(NSError **)error;
|
||||
- (BOOL)removeProfileWithUUID:(NSString*)uuid error:(NSError **)error;
|
||||
- (BOOL)addProfile:(NSData*)profile error:(NSError **)error;
|
||||
- (NSArray<NSDictionary*>*)fetchProcessListWithError:(NSError**)error;
|
||||
- (BOOL)killProcessWithPID:(int)pid error:(NSError **)error;
|
||||
@end
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
//
|
||||
#include "idevice.h"
|
||||
#include <arpa/inet.h>
|
||||
#include <signal.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
#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<NSDictionary*>*)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<NSDictionary*>*)_fetchProcessListLocked:(NSError**)error {
|
||||
if (provider) {
|
||||
[self ensureHeartbeat];
|
||||
}
|
||||
return [self fetchProcessesViaAppServiceWithError:error];
|
||||
}
|
||||
|
||||
- (NSArray<NSDictionary*>*)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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue