mirror of
https://github.com/StikDebug/StikDebug.git
synced 2026-10-16 12:30:29 +00:00
* Change default UPLOAD_IPA value to 'false' * Part 1 of the great debloat * Remove Mini Tools, adopt native alerts in StikJITApp, and update HomeView UI * Refactor views to use native SwiftUI alerts instead of CustomErrorView * Update ProfileView to use minimal List UI style with native alerts * Fix up homeview * Squash: Redesign HomeView, remove Feature Flags, enable all tabs, refactor alerts, and updates - Redesign HomeView to a simpler style with 'Enable JIT' action. - Remove 'Help & Info' and 'Quick Launch' from Home. - Fix DDI false positive by starting heartbeat on HomeView appear. - Restore robust DDI checks and update app name to 'StikDebug'. - Remove FeatureFlags and Beta tags; enable 'profiles', 'processes', 'location' tabs by default. - Remove 'Devices' tab; add 'Target Device IP' field in Settings. - Hardcode 'Settings' tab to 4th position. - Refactor 'SettingsView' tab customization to be reorder-only. - Remove 'Appearance' theme section from Settings. - Refactor custom error cards to use native SwiftUI/UIKit alerts across the app (HomeView, ConsoleLogsView, ProfileView, etc). - Restore JS callback support and script execution logic in HomeView. - Add necessary Color extensions. - Update project version to 3.0.0. * Clean up: Remove In-App Purchases, update checker, and unused IPA installer code * delete more bloat * fix build issue * Remove widget to save on App IDs * Attempt heartbeat restart when Enable JIT button is pressed * Get rid of PIP * Update Tab Naming * remove more bloat * final changes for today * Remove unused packages * UX Improvements * revert bundle id * Update README.md * remove old idevice * add updated idevice * Fix ddi false positive errors and add templates for GitHub stuff * Remove more unused stuff. * Fix links and update README content * Update compatibility table in README.md * Improve performance of console logs * Remove old code * Change default UPLOAD_IPA value to 'true' * remove the welcome sheet * import support for .mobiledevicepair * Add back LC JIT stuff * Fix heartbeat issues with LC JIT * update the build yml for nightly stuff * Update README.md * Update README.md * Update GitHub Actions workflow for IPA build * Update GitHub Actions workflow for nightly builds * Add URL scheme to kill tasks stikjit://kill-process?pid=1234 * fix build issues * Added App Intents * Unify the error handling style across the app
318 lines
11 KiB
Swift
318 lines
11 KiB
Swift
//
|
|
// ProcessInspectorView.swift
|
|
// StikJIT
|
|
//
|
|
// Created by Stephen on 11/3/25.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
struct ProcessInspectorView: View {
|
|
@StateObject private var viewModel = ProcessInspectorViewModel()
|
|
@State private var killCandidate: ProcessInfoEntry?
|
|
@State private var killConfirmTask: Task<Void, Never>?
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
content
|
|
.navigationTitle("Process Inspector")
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
Button(action: viewModel.refresh) {
|
|
Label("Refresh", systemImage: "arrow.clockwise")
|
|
}
|
|
.disabled(viewModel.isRefreshing)
|
|
}
|
|
}
|
|
.searchable(text: $viewModel.searchText, placement: .navigationBarDrawer(displayMode: .always))
|
|
}
|
|
.task {
|
|
await viewModel.startAutoRefresh()
|
|
}
|
|
.onDisappear {
|
|
viewModel.stopAutoRefresh()
|
|
}
|
|
.alert(viewModel.killAlertTitle, isPresented: $viewModel.showKillAlert) {
|
|
Button("OK", role: .cancel) { }
|
|
} message: {
|
|
Text(viewModel.killAlertMessage)
|
|
}
|
|
.alert(viewModel.errorAlertTitle, isPresented: $viewModel.showErrorAlert) {
|
|
Button("Try Again") { viewModel.refresh() }
|
|
Button("OK", role: .cancel) { }
|
|
} message: {
|
|
Text(viewModel.errorAlertMessage)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var content: some View {
|
|
List {
|
|
Section("Overview") {
|
|
LabeledContent("Total Processes") {
|
|
Text("\(viewModel.processes.count)")
|
|
.font(.title2.bold())
|
|
}
|
|
}
|
|
Section("Processes") {
|
|
if viewModel.filteredProcesses.isEmpty {
|
|
Text("No matching processes.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
} else {
|
|
ForEach(viewModel.filteredProcesses) { process in
|
|
ProcessRow(
|
|
process: process,
|
|
isKilling: viewModel.killingPID == process.pid,
|
|
isConfirming: killCandidate?.pid == process.pid,
|
|
onKillTap: { handleKillTap(for: $0) }
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.listStyle(.insetGrouped)
|
|
.refreshable { viewModel.refresh() }
|
|
}
|
|
}
|
|
|
|
private extension ProcessInspectorView {
|
|
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: 4) {
|
|
HStack {
|
|
Text(process.displayName)
|
|
.font(.subheadline.weight(.semibold))
|
|
Spacer()
|
|
Text("PID \(process.pid)")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
if let bundle = process.bundleID, !bundle.isEmpty {
|
|
Text(bundle)
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
.textSelection(.enabled)
|
|
}
|
|
Text(process.executablePath)
|
|
.font(.caption2)
|
|
.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)
|
|
.controlSize(.small)
|
|
.tint(isConfirming ? .green : .red)
|
|
.labelStyle(.iconOnly)
|
|
}
|
|
}
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
}
|
|
|
|
// MARK: - View Model
|
|
|
|
@MainActor
|
|
final class ProcessInspectorViewModel: ObservableObject {
|
|
@Published private(set) var processes: [ProcessInfoEntry] = []
|
|
@Published var searchText: String = ""
|
|
@Published var isRefreshing = false
|
|
@Published var showErrorAlert = false
|
|
@Published var errorAlertTitle = ""
|
|
@Published var errorAlertMessage = ""
|
|
@Published private(set) var killingPID: Int?
|
|
@Published var showKillAlert = false
|
|
@Published var killAlertTitle = ""
|
|
@Published var killAlertMessage = ""
|
|
|
|
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
|
|
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.errorAlertTitle = "Failed to Load Processes"
|
|
self.errorAlertMessage = err.localizedDescription
|
|
self.showErrorAlert = true
|
|
} 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 {
|
|
killAlertTitle = "Busy"
|
|
killAlertMessage = "Already terminating PID \(killingPID!)."
|
|
showKillAlert = true
|
|
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.killAlertTitle = "Kill Timed Out"
|
|
self.killAlertMessage = "Could not confirm termination for PID \(targetPID). Try again."
|
|
self.showKillAlert = true
|
|
}
|
|
}
|
|
}
|
|
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.killAlertTitle = "Process Terminated"
|
|
self.killAlertMessage = "PID \(targetPID) was terminated."
|
|
self.showKillAlert = true
|
|
self.refresh()
|
|
} else {
|
|
self.killAlertTitle = "Kill Failed"
|
|
self.killAlertMessage = err?.localizedDescription ?? "Unknown error"
|
|
self.showKillAlert = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
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)"
|
|
}
|
|
}
|