Move Scripts to Tools tab, add bookmarks to Location Simulator, and replace Home screen with Apps tab

This commit is contained in:
Stephen B 2026-03-02 09:03:39 -05:00
parent 875e3cb04a
commit d51f8e9b6c
5 changed files with 193 additions and 246 deletions

View file

@ -7,6 +7,7 @@
import SwiftUI
import UniformTypeIdentifiers
import UIKit
struct JITEnableConfiguration {
var bundleID: String? = nil
@ -17,164 +18,35 @@ struct JITEnableConfiguration {
struct HomeView: View {
@AppStorage("username") private var username = "User"
@AppStorage("autoQuitAfterEnablingJIT") private var doAutoQuitAfterEnablingJIT = false
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
@AppStorage("bundleID") private var bundleID: String = ""
@State private var isProcessing = false
@State private var isShowingInstalledApps = false
@State private var isShowingPairingFilePicker = false
@State private var pairingFileExists: Bool = false
@State private var showPairingFileMessage = false
@State private var pairingFileIsValid = false
@State private var isImportingFile = false
@State private var importProgress: Float = 0.0
@State private var viewDidAppeared = false
@State private var pendingJITEnableConfiguration : JITEnableConfiguration? = nil
@State private var isShowingPairingFilePicker = false
@State var scriptViewShow = false
@State private var isShowingConsole = false
@AppStorage("DefaultScriptName") var selectedScript = "attachDetach.js"
@State var jsModel: RunJSViewModel?
@ObservedObject private var mounting = MountingProgress.shared
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
var body: some View {
ZStack {
VStack(spacing: 25) {
Spacer()
VStack(spacing: 5) {
Text("Welcome to StikDebug, \(username)!")
.font(.system(.largeTitle, design: .rounded))
.fontWeight(.bold)
.lineLimit(1)
.minimumScaleFactor(0.5)
Text(pairingFileExists ? "Tap \"Enable JIT\" to get started" : "Pick pairing file to get started")
.font(.system(.subheadline, design: .rounded))
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.padding(.top, 40)
// Main action button - changes based on whether we have a pairing file
Button(action: {
if pairingFileExists {
// Refresh heartbeat
pubHeartBeat = false
startHeartbeatInBackground()
// Got a pairing file, check mount status
let mountStatus = checkMountStatus()
if mountStatus == .notMounted {
showAlert(title: "Device Not Mounted", message: "The Developer Disk Image has not been mounted yet. Check in settings for more information.", showOk: true) { cool in
// No Need
}
return
} else if mountStatus == .unreachable {
// Don't show a separate error here — the heartbeat
// will fail and show its own connectivity error.
return
}
isShowingInstalledApps = true
} else {
// No pairing file yet, let's get one
isShowingPairingFilePicker = true
}
}) {
HStack {
Image(systemName: pairingFileExists ? "bolt.fill" : "doc.badge.plus")
.font(.system(size: 20))
Text(pairingFileExists ? "Enable JIT" : "Select Pairing File")
.font(.system(.title3, design: .rounded))
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(16)
}
.padding(.horizontal, 20)
Button(action: {
isShowingConsole = true
}) {
HStack {
Image(systemName: "terminal")
.font(.system(size: 20))
Text("Open Console")
.font(.system(.title3, design: .rounded))
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.padding()
.background(Color.gray.opacity(0.2))
.foregroundColor(.primary)
.cornerRadius(16)
}
.padding(.horizontal, 20)
// Status message area - keeps layout consistent
ZStack {
// Progress bar for importing file
if isImportingFile {
VStack(spacing: 8) {
HStack {
Text("Processing pairing file...")
.font(.system(.caption, design: .rounded))
Spacer()
Text("\(Int(importProgress * 100))%")
.font(.system(.caption, design: .rounded))
}
GeometryReader { geometry in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 4)
.fill(Color.black.opacity(0.2))
.frame(height: 8)
RoundedRectangle(cornerRadius: 4)
.fill(Color.green)
.frame(width: geometry.size.width * CGFloat(importProgress), height: 8)
.animation(.linear(duration: 0.3), value: importProgress)
}
}
.frame(height: 8)
}
.padding(.horizontal, 40)
}
// Success message
if showPairingFileMessage && pairingFileIsValid {
Text("✓ Pairing file successfully imported")
.font(.system(.callout, design: .rounded))
.foregroundColor(.green)
.padding(.vertical, 4)
.padding(.horizontal, 12)
.background(Color.green.opacity(0.1))
.cornerRadius(8)
.transition(.opacity)
}
// Invisible text to reserve space - no layout jumps
Text(" ").opacity(0)
}
.frame(height: isImportingFile ? 60 : 30) // Adjust height based on what's showing
Spacer()
}
.padding()
}
InstalledAppsListView(onSelectApp: { selectedBundle in
bundleID = selectedBundle
HapticFeedbackHelper.trigger()
startJITInBackground(bundleID: selectedBundle)
}, showDoneButton: false, onImportPairingFile: { isShowingPairingFilePicker = true })
.onAppear {
checkPairingFileExists()
startHeartbeatInBackground()
MountingProgress.shared.checkforMounted()
viewDidAppeared = true
if let config = pendingJITEnableConfiguration {
startJITInBackground(bundleID: config.bundleID, pid: config.pid, scriptData: config.scriptData, scriptName: config.scriptName, triggeredByURLScheme: true)
pendingJITEnableConfiguration = nil
}
}
.onReceive(NotificationCenter.default.publisher(for: .intentJSScriptReady)) { notification in
guard let model = notification.userInfo?["model"] as? RunJSViewModel else { return }
@ -185,85 +57,10 @@ struct HomeView: View {
scriptViewShow = true
}
.onReceive(timer) { _ in
checkPairingFileExists()
if mounting.mountingThread == nil && !mounting.coolisMounted {
MountingProgress.shared.checkforMounted()
}
}
.fileImporter(isPresented: $isShowingPairingFilePicker, allowedContentTypes: [UTType(filenameExtension: "mobiledevicepairing", conformingTo: .data)!, UTType(filenameExtension: "mobiledevicepair", conformingTo: .data)!, .propertyList]) {result in
switch result {
case .success(let url):
let fileManager = FileManager.default
let accessing = url.startAccessingSecurityScopedResource()
if fileManager.fileExists(atPath: url.path) {
do {
if fileManager.fileExists(atPath: URL.documentsDirectory.appendingPathComponent("pairingFile.plist").path) {
try fileManager.removeItem(at: URL.documentsDirectory.appendingPathComponent("pairingFile.plist"))
}
try fileManager.copyItem(at: url, to: URL.documentsDirectory.appendingPathComponent("pairingFile.plist"))
print("File copied successfully!")
// Show progress bar and initialize progress
DispatchQueue.main.async {
isImportingFile = true
importProgress = 0.0
pairingFileExists = true
}
startHeartbeatInBackground()
let progressTimer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { timer in
DispatchQueue.main.async {
if importProgress < 1.0 {
importProgress += 0.25
} else {
timer.invalidate()
isImportingFile = false
pairingFileIsValid = true
// Show success message
withAnimation {
showPairingFileMessage = true
}
// Hide message after delay
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
withAnimation {
showPairingFileMessage = false
}
}
}
}
}
// Ensure timer keeps running
RunLoop.current.add(progressTimer, forMode: .common)
} catch {
print("Error copying file: \(error)")
}
} else {
print("Source file does not exist.")
}
if accessing {
url.stopAccessingSecurityScopedResource()
}
case .failure(let error):
print("Failed to import file: \(error)")
}
}
.sheet(isPresented: $isShowingInstalledApps) {
InstalledAppsListView { selectedBundle in
bundleID = selectedBundle
isShowingInstalledApps = false
HapticFeedbackHelper.trigger()
startJITInBackground(bundleID: selectedBundle)
}
}
.onOpenURL { url in
guard let host = url.host() else { return }
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
@ -324,11 +121,26 @@ struct HomeView: View {
break
}
}
.onAppear() {
viewDidAppeared = true
if let config = pendingJITEnableConfiguration {
startJITInBackground(bundleID: config.bundleID, pid: config.pid, scriptData: config.scriptData, scriptName: config.scriptName, triggeredByURLScheme: true)
pendingJITEnableConfiguration = nil
.fileImporter(isPresented: $isShowingPairingFilePicker, allowedContentTypes: [UTType(filenameExtension: "mobiledevicepairing", conformingTo: .data)!, UTType(filenameExtension: "mobiledevicepair", conformingTo: .data)!, .propertyList]) { result in
switch result {
case .success(let url):
let fileManager = FileManager.default
let accessing = url.startAccessingSecurityScopedResource()
if fileManager.fileExists(atPath: url.path) {
do {
let dest = URL.documentsDirectory.appendingPathComponent("pairingFile.plist")
if fileManager.fileExists(atPath: dest.path) {
try fileManager.removeItem(at: dest)
}
try fileManager.copyItem(at: url, to: dest)
startHeartbeatInBackground()
} catch {
print("Error copying pairing file: \(error)")
}
}
if accessing { url.stopAccessingSecurityScopedResource() }
case .failure(let error):
print("Failed to import pairing file: \(error)")
}
}
.sheet(isPresented: $isShowingConsole) {
@ -358,9 +170,8 @@ struct HomeView: View {
}
}
}
private func autoScript(for bundleID: String) -> (data: Data, name: String)? {
guard ProcessInfo.processInfo.hasTXM else { return nil }
guard #available(iOS 26, *) else { return nil }
@ -381,7 +192,7 @@ struct HomeView: View {
}
return (data, resource.fileName)
}
private func assignedScript(for bundleID: String) -> (data: Data, name: String)? {
guard let mapping = UserDefaults.standard.dictionary(forKey: "BundleScriptMap") as? [String: String],
let scriptName = mapping[bundleID] else { return nil }
@ -392,20 +203,20 @@ struct HomeView: View {
let data = try? Data(contentsOf: scriptURL) else { return nil }
return (data, scriptName)
}
private func preferredScript(for bundleID: String) -> (data: Data, name: String)? {
if let assigned = assignedScript(for: bundleID) {
return assigned
}
return autoScript(for: bundleID)
}
private func storedFavoriteName(for bundleID: String) -> String? {
let defaults = UserDefaults(suiteName: "group.com.stik.sj")
let names = defaults?.dictionary(forKey: "favoriteAppNames") as? [String: String]
return names?[bundleID]
}
private func autoScriptResource(for appName: String) -> (resource: String, fileName: String)? {
switch appName {
case "maciOS":
@ -422,30 +233,26 @@ struct HomeView: View {
return nil
}
}
private func getJsCallback(_ script: Data, name: String? = nil) -> DebugAppCallback {
return { pid, debugProxyHandle, remoteServerHandle, semaphore in
let model = RunJSViewModel(pid: Int(pid),
debugProxy: debugProxyHandle,
remoteServer: remoteServerHandle,
semaphore: semaphore)
DispatchQueue.main.async {
jsModel = model
scriptViewShow = true
}
DispatchQueue.global(qos: .background).async {
do { try model.runScript(data: script, name: name) }
catch { showAlert(title: "Error Occurred While Executing Script.".localized, message: error.localizedDescription, showOk: true) }
}
}
}
private func checkPairingFileExists() {
pairingFileExists = FileManager.default.fileExists(atPath: URL.documentsDirectory.appendingPathComponent("pairingFile.plist").path)
}
private func startJITInBackground(bundleID: String? = nil, pid: Int? = nil, scriptData: Data? = nil, scriptName: String? = nil, triggeredByURLScheme: Bool = false) {
isProcessing = true
LogManager.shared.addInfoLog("Starting Debug for \(bundleID ?? String(pid ?? 0))")
@ -527,11 +334,11 @@ public extension ProcessInfo {
}
return ProcessInfo.detectLocalTXM()
}
var isTXMOverridden: Bool {
UserDefaults.standard.bool(forKey: UserDefaults.Keys.txmOverride)
}
private static func detectLocalTXM() -> Bool {
if let boot = FileManager.default.filePath(atPath: "/System/Volumes/Preboot", withLength: 36),
let file = FileManager.default.filePath(atPath: "\(boot)/boot", withLength: 96) {

View file

@ -40,6 +40,8 @@ struct InstalledAppsListView: View {
@Environment(\.dismiss) private var dismiss
var onSelectApp: (String) -> Void
var showDoneButton: Bool = true
var onImportPairingFile: (() -> Void)? = nil
private var currentSearchBinding: Binding<String> {
@ -164,7 +166,7 @@ private enum AppListTab: Int, CaseIterable, Identifiable {
tabContent(for: selectedTab)
.transition(.opacity)
.transaction { t in t.disablesAnimations = true }
.navigationTitle("Installed Apps".localized)
.navigationTitle(selectedTab == .debuggable ? "Enable JIT".localized : "Launch Apps".localized)
.searchable(
text: currentSearchBinding,
placement: .navigationBarDrawer(displayMode: .always),
@ -182,8 +184,17 @@ private enum AppListTab: Int, CaseIterable, Identifiable {
.pickerStyle(.segmented)
.frame(width: 220)
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") { dismiss() }.fontWeight(.semibold)
if let onImportPairingFile {
ToolbarItem(placement: .navigationBarLeading) {
Button(action: onImportPairingFile) {
Image(systemName: "doc.badge.plus")
}
}
}
if showDoneButton {
ToolbarItem(placement: .navigationBarTrailing) {
Button("Done") { dismiss() }.fontWeight(.semibold)
}
}
}
.onAppear {
@ -305,7 +316,7 @@ private enum AppListTab: Int, CaseIterable, Identifiable {
}
}
}
Section("All Applications".localized) {
Section("Apps with get-task-allow".localized) {
ForEach(filteredDebuggableApps, id: \.key) { bundleID, appName in
AppButton(
bundleID: bundleID, appName: appName,
@ -342,7 +353,7 @@ private enum AppListTab: Int, CaseIterable, Identifiable {
.listRowBackground(Color.clear)
}
} else {
Section("Other Apps".localized) {
Section("All Apps".localized) {
ForEach(filteredLaunchApps, id: \.key) { bundleID, appName in
let isPinned = pinnedSystemApps.contains(bundleID)
LaunchAppRow(

View file

@ -27,7 +27,7 @@ struct MainTabView: View {
private var configurableTabs: [TabDescriptor] {
var tabs: [TabDescriptor] = [
TabDescriptor(id: "home", title: "Home", systemImage: "house") { AnyView(HomeView()) },
TabDescriptor(id: "home", title: "Apps", systemImage: "square.grid.2x2") { AnyView(HomeView()) },
TabDescriptor(id: "scripts", title: "Scripts", systemImage: "scroll") { AnyView(ScriptListView()) },
TabDescriptor(id: "tools", title: "Tools", systemImage: "wrench.and.screwdriver") { AnyView(ToolsView()) },
TabDescriptor(id: "deviceinfo", title: "Device Info", systemImage: "iphone.and.arrow.forward") { AnyView(DeviceInfoView()) },
@ -62,10 +62,10 @@ struct MainTabView: View {
}
private var displayTabs: [TabDescriptor] {
var tabs = ["home", "scripts", "tools"].compactMap { id in
var tabs = ["home", "tools"].compactMap { id in
configurableTabs.first(where: { $0.id == id })
}
tabs.insert(settingsTab, at: min(3, tabs.count))
tabs.insert(settingsTab, at: min(2, tabs.count))
return tabs
}

View file

@ -15,6 +15,19 @@ extension CLLocationCoordinate2D: Equatable {
}
}
// MARK: - Bookmark Model
struct LocationBookmark: Identifiable, Codable {
var id: UUID = UUID()
var name: String
var latitude: Double
var longitude: Double
var coordinate: CLLocationCoordinate2D {
CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
}
}
// MARK: - Search Completer
@MainActor
@ -61,6 +74,12 @@ struct LocationSimulationView: View {
@State private var searchText = ""
@StateObject private var searchCompleter = LocationSearchCompleter()
// Bookmarks
@State private var bookmarks: [LocationBookmark] = []
@State private var showBookmarks = false
@State private var showSaveBookmark = false
@State private var newBookmarkName = ""
private var pairingFilePath: String {
URL.documentsDirectory.appendingPathComponent("pairingFile.plist").path()
}
@ -166,6 +185,14 @@ struct LocationSimulationView: View {
Button("Simulate Location", action: simulate)
.buttonStyle(.borderedProminent)
.disabled(!pairingExists || isBusy)
Button {
showSaveBookmark = true
} label: {
Image(systemName: "bookmark")
}
.buttonStyle(.bordered)
.tint(.blue)
}
} else {
Text("Tap map to drop pin")
@ -179,6 +206,13 @@ struct LocationSimulationView: View {
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button {
showBookmarks = true
} label: {
Image(systemName: "bookmark.fill")
}
}
ToolbarItem(placement: .topBarTrailing) {
TextField("Search location...", text: $searchText)
.padding(.leading, 6)
@ -193,6 +227,25 @@ struct LocationSimulationView: View {
} message: {
Text(alertMessage)
}
.alert("Save Bookmark", isPresented: $showSaveBookmark) {
TextField("Name", text: $newBookmarkName)
Button("Save") { addBookmark() }
Button("Cancel", role: .cancel) { newBookmarkName = "" }
} message: {
Text("Enter a name for this location.")
}
.sheet(isPresented: $showBookmarks) {
BookmarksView(bookmarks: $bookmarks) { bookmark in
coordinate = bookmark.coordinate
showBookmarks = false
} onDelete: { offsets in
bookmarks.remove(atOffsets: offsets)
saveBookmarks()
}
}
.onAppear {
loadBookmarks()
}
.onDisappear {
stopResendLoop()
if backgroundTaskID != .invalid {
@ -202,6 +255,35 @@ struct LocationSimulationView: View {
}
}
// MARK: - Bookmarks
private func loadBookmarks() {
guard let data = UserDefaults.standard.data(forKey: "locationBookmarks"),
let decoded = try? JSONDecoder().decode([LocationBookmark].self, from: data) else { return }
bookmarks = decoded
}
private func saveBookmarks() {
if let data = try? JSONEncoder().encode(bookmarks) {
UserDefaults.standard.set(data, forKey: "locationBookmarks")
}
}
private func addBookmark() {
guard let coord = coordinate else { return }
let name = newBookmarkName.trimmingCharacters(in: .whitespacesAndNewlines)
let bookmark = LocationBookmark(
name: name.isEmpty ? String(format: "%.4f, %.4f", coord.latitude, coord.longitude) : name,
latitude: coord.latitude,
longitude: coord.longitude
)
bookmarks.append(bookmark)
saveBookmarks()
newBookmarkName = ""
}
// MARK: - Location
private func selectSearchResult(_ result: MKLocalSearchCompletion) {
searchText = ""
searchCompleter.results = []
@ -289,3 +371,49 @@ struct LocationSimulationView: View {
resendTimer = nil
}
}
// MARK: - Bookmarks Sheet
struct BookmarksView: View {
@Binding var bookmarks: [LocationBookmark]
let onSelect: (LocationBookmark) -> Void
let onDelete: (IndexSet) -> Void
var body: some View {
NavigationStack {
Group {
if bookmarks.isEmpty {
ContentUnavailableView(
"No Bookmarks",
systemImage: "bookmark.slash",
description: Text("Drop a pin on the map and tap the bookmark icon to save a location.")
)
} else {
List {
ForEach(bookmarks) { bookmark in
Button {
onSelect(bookmark)
} label: {
VStack(alignment: .leading, spacing: 2) {
Text(bookmark.name)
.foregroundStyle(.primary)
Text(String(format: "%.6f, %.6f", bookmark.latitude, bookmark.longitude))
.font(.caption.monospaced())
.foregroundStyle(.secondary)
}
}
}
.onDelete(perform: onDelete)
}
}
}
.navigationTitle("Bookmarks")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
if !bookmarks.isEmpty {
EditButton()
}
}
}
}
}

View file

@ -18,6 +18,7 @@ struct ToolsView: View {
private var tools: [ToolItem] {
[
ToolItem(id: "scripts", title: "Scripts", detail: "Manage and run JS scripts", systemImage: "scroll", destination: AnyView(ScriptListView())),
ToolItem(id: "console", title: "Console", detail: "Live device logs", systemImage: "terminal", destination: AnyView(ConsoleLogsView())),
ToolItem(id: "deviceinfo", title: "Device Info", detail: "View detailed device metadata", systemImage: "iphone.and.arrow.forward", destination: AnyView(DeviceInfoView())),
ToolItem(id: "profiles", title: "App Expiry", detail: "Check app expiration dates", systemImage: "calendar.badge.clock", destination: AnyView(ProfileView())),