mirror of
https://github.com/StikDebug/StikDebug.git
synced 2026-10-15 20:10:30 +00:00
Move Scripts to Tools tab, add bookmarks to Location Simulator, and replace Home screen with Apps tab
This commit is contained in:
parent
875e3cb04a
commit
d51f8e9b6c
5 changed files with 193 additions and 246 deletions
|
|
@ -7,6 +7,7 @@
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
|
import UIKit
|
||||||
|
|
||||||
struct JITEnableConfiguration {
|
struct JITEnableConfiguration {
|
||||||
var bundleID: String? = nil
|
var bundleID: String? = nil
|
||||||
|
|
@ -17,164 +18,35 @@ struct JITEnableConfiguration {
|
||||||
|
|
||||||
struct HomeView: View {
|
struct HomeView: View {
|
||||||
|
|
||||||
@AppStorage("username") private var username = "User"
|
|
||||||
@AppStorage("autoQuitAfterEnablingJIT") private var doAutoQuitAfterEnablingJIT = false
|
@AppStorage("autoQuitAfterEnablingJIT") private var doAutoQuitAfterEnablingJIT = false
|
||||||
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
|
|
||||||
@AppStorage("bundleID") private var bundleID: String = ""
|
@AppStorage("bundleID") private var bundleID: String = ""
|
||||||
@State private var isProcessing = false
|
@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 viewDidAppeared = false
|
||||||
@State private var pendingJITEnableConfiguration : JITEnableConfiguration? = nil
|
@State private var pendingJITEnableConfiguration : JITEnableConfiguration? = nil
|
||||||
|
@State private var isShowingPairingFilePicker = false
|
||||||
|
|
||||||
@State var scriptViewShow = false
|
@State var scriptViewShow = false
|
||||||
@State private var isShowingConsole = false
|
@State private var isShowingConsole = false
|
||||||
@AppStorage("DefaultScriptName") var selectedScript = "attachDetach.js"
|
@AppStorage("DefaultScriptName") var selectedScript = "attachDetach.js"
|
||||||
@State var jsModel: RunJSViewModel?
|
@State var jsModel: RunJSViewModel?
|
||||||
@ObservedObject private var mounting = MountingProgress.shared
|
@ObservedObject private var mounting = MountingProgress.shared
|
||||||
|
|
||||||
|
let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
InstalledAppsListView(onSelectApp: { selectedBundle in
|
||||||
VStack(spacing: 25) {
|
bundleID = selectedBundle
|
||||||
Spacer()
|
HapticFeedbackHelper.trigger()
|
||||||
VStack(spacing: 5) {
|
startJITInBackground(bundleID: selectedBundle)
|
||||||
Text("Welcome to StikDebug, \(username)!")
|
}, showDoneButton: false, onImportPairingFile: { isShowingPairingFilePicker = true })
|
||||||
.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()
|
|
||||||
}
|
|
||||||
.onAppear {
|
.onAppear {
|
||||||
checkPairingFileExists()
|
|
||||||
startHeartbeatInBackground()
|
startHeartbeatInBackground()
|
||||||
MountingProgress.shared.checkforMounted()
|
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
|
.onReceive(NotificationCenter.default.publisher(for: .intentJSScriptReady)) { notification in
|
||||||
guard let model = notification.userInfo?["model"] as? RunJSViewModel else { return }
|
guard let model = notification.userInfo?["model"] as? RunJSViewModel else { return }
|
||||||
|
|
@ -185,85 +57,10 @@ struct HomeView: View {
|
||||||
scriptViewShow = true
|
scriptViewShow = true
|
||||||
}
|
}
|
||||||
.onReceive(timer) { _ in
|
.onReceive(timer) { _ in
|
||||||
checkPairingFileExists()
|
|
||||||
if mounting.mountingThread == nil && !mounting.coolisMounted {
|
if mounting.mountingThread == nil && !mounting.coolisMounted {
|
||||||
MountingProgress.shared.checkforMounted()
|
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
|
.onOpenURL { url in
|
||||||
guard let host = url.host() else { return }
|
guard let host = url.host() else { return }
|
||||||
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
||||||
|
|
@ -324,11 +121,26 @@ struct HomeView: View {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear() {
|
.fileImporter(isPresented: $isShowingPairingFilePicker, allowedContentTypes: [UTType(filenameExtension: "mobiledevicepairing", conformingTo: .data)!, UTType(filenameExtension: "mobiledevicepair", conformingTo: .data)!, .propertyList]) { result in
|
||||||
viewDidAppeared = true
|
switch result {
|
||||||
if let config = pendingJITEnableConfiguration {
|
case .success(let url):
|
||||||
startJITInBackground(bundleID: config.bundleID, pid: config.pid, scriptData: config.scriptData, scriptName: config.scriptName, triggeredByURLScheme: true)
|
let fileManager = FileManager.default
|
||||||
pendingJITEnableConfiguration = nil
|
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) {
|
.sheet(isPresented: $isShowingConsole) {
|
||||||
|
|
@ -358,9 +170,8 @@ struct HomeView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
private func autoScript(for bundleID: String) -> (data: Data, name: String)? {
|
private func autoScript(for bundleID: String) -> (data: Data, name: String)? {
|
||||||
guard ProcessInfo.processInfo.hasTXM else { return nil }
|
guard ProcessInfo.processInfo.hasTXM else { return nil }
|
||||||
guard #available(iOS 26, *) else { return nil }
|
guard #available(iOS 26, *) else { return nil }
|
||||||
|
|
@ -381,7 +192,7 @@ struct HomeView: View {
|
||||||
}
|
}
|
||||||
return (data, resource.fileName)
|
return (data, resource.fileName)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func assignedScript(for bundleID: String) -> (data: Data, name: String)? {
|
private func assignedScript(for bundleID: String) -> (data: Data, name: String)? {
|
||||||
guard let mapping = UserDefaults.standard.dictionary(forKey: "BundleScriptMap") as? [String: String],
|
guard let mapping = UserDefaults.standard.dictionary(forKey: "BundleScriptMap") as? [String: String],
|
||||||
let scriptName = mapping[bundleID] else { return nil }
|
let scriptName = mapping[bundleID] else { return nil }
|
||||||
|
|
@ -392,20 +203,20 @@ struct HomeView: View {
|
||||||
let data = try? Data(contentsOf: scriptURL) else { return nil }
|
let data = try? Data(contentsOf: scriptURL) else { return nil }
|
||||||
return (data, scriptName)
|
return (data, scriptName)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func preferredScript(for bundleID: String) -> (data: Data, name: String)? {
|
private func preferredScript(for bundleID: String) -> (data: Data, name: String)? {
|
||||||
if let assigned = assignedScript(for: bundleID) {
|
if let assigned = assignedScript(for: bundleID) {
|
||||||
return assigned
|
return assigned
|
||||||
}
|
}
|
||||||
return autoScript(for: bundleID)
|
return autoScript(for: bundleID)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func storedFavoriteName(for bundleID: String) -> String? {
|
private func storedFavoriteName(for bundleID: String) -> String? {
|
||||||
let defaults = UserDefaults(suiteName: "group.com.stik.sj")
|
let defaults = UserDefaults(suiteName: "group.com.stik.sj")
|
||||||
let names = defaults?.dictionary(forKey: "favoriteAppNames") as? [String: String]
|
let names = defaults?.dictionary(forKey: "favoriteAppNames") as? [String: String]
|
||||||
return names?[bundleID]
|
return names?[bundleID]
|
||||||
}
|
}
|
||||||
|
|
||||||
private func autoScriptResource(for appName: String) -> (resource: String, fileName: String)? {
|
private func autoScriptResource(for appName: String) -> (resource: String, fileName: String)? {
|
||||||
switch appName {
|
switch appName {
|
||||||
case "maciOS":
|
case "maciOS":
|
||||||
|
|
@ -422,30 +233,26 @@ struct HomeView: View {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func getJsCallback(_ script: Data, name: String? = nil) -> DebugAppCallback {
|
private func getJsCallback(_ script: Data, name: String? = nil) -> DebugAppCallback {
|
||||||
return { pid, debugProxyHandle, remoteServerHandle, semaphore in
|
return { pid, debugProxyHandle, remoteServerHandle, semaphore in
|
||||||
let model = RunJSViewModel(pid: Int(pid),
|
let model = RunJSViewModel(pid: Int(pid),
|
||||||
debugProxy: debugProxyHandle,
|
debugProxy: debugProxyHandle,
|
||||||
remoteServer: remoteServerHandle,
|
remoteServer: remoteServerHandle,
|
||||||
semaphore: semaphore)
|
semaphore: semaphore)
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
jsModel = model
|
jsModel = model
|
||||||
scriptViewShow = true
|
scriptViewShow = true
|
||||||
}
|
}
|
||||||
|
|
||||||
DispatchQueue.global(qos: .background).async {
|
DispatchQueue.global(qos: .background).async {
|
||||||
do { try model.runScript(data: script, name: name) }
|
do { try model.runScript(data: script, name: name) }
|
||||||
catch { showAlert(title: "Error Occurred While Executing Script.".localized, message: error.localizedDescription, showOk: true) }
|
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) {
|
private func startJITInBackground(bundleID: String? = nil, pid: Int? = nil, scriptData: Data? = nil, scriptName: String? = nil, triggeredByURLScheme: Bool = false) {
|
||||||
isProcessing = true
|
isProcessing = true
|
||||||
LogManager.shared.addInfoLog("Starting Debug for \(bundleID ?? String(pid ?? 0))")
|
LogManager.shared.addInfoLog("Starting Debug for \(bundleID ?? String(pid ?? 0))")
|
||||||
|
|
@ -527,11 +334,11 @@ public extension ProcessInfo {
|
||||||
}
|
}
|
||||||
return ProcessInfo.detectLocalTXM()
|
return ProcessInfo.detectLocalTXM()
|
||||||
}
|
}
|
||||||
|
|
||||||
var isTXMOverridden: Bool {
|
var isTXMOverridden: Bool {
|
||||||
UserDefaults.standard.bool(forKey: UserDefaults.Keys.txmOverride)
|
UserDefaults.standard.bool(forKey: UserDefaults.Keys.txmOverride)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func detectLocalTXM() -> Bool {
|
private static func detectLocalTXM() -> Bool {
|
||||||
if let boot = FileManager.default.filePath(atPath: "/System/Volumes/Preboot", withLength: 36),
|
if let boot = FileManager.default.filePath(atPath: "/System/Volumes/Preboot", withLength: 36),
|
||||||
let file = FileManager.default.filePath(atPath: "\(boot)/boot", withLength: 96) {
|
let file = FileManager.default.filePath(atPath: "\(boot)/boot", withLength: 96) {
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,8 @@ struct InstalledAppsListView: View {
|
||||||
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
var onSelectApp: (String) -> Void
|
var onSelectApp: (String) -> Void
|
||||||
|
var showDoneButton: Bool = true
|
||||||
|
var onImportPairingFile: (() -> Void)? = nil
|
||||||
|
|
||||||
|
|
||||||
private var currentSearchBinding: Binding<String> {
|
private var currentSearchBinding: Binding<String> {
|
||||||
|
|
@ -164,7 +166,7 @@ private enum AppListTab: Int, CaseIterable, Identifiable {
|
||||||
tabContent(for: selectedTab)
|
tabContent(for: selectedTab)
|
||||||
.transition(.opacity)
|
.transition(.opacity)
|
||||||
.transaction { t in t.disablesAnimations = true }
|
.transaction { t in t.disablesAnimations = true }
|
||||||
.navigationTitle("Installed Apps".localized)
|
.navigationTitle(selectedTab == .debuggable ? "Enable JIT".localized : "Launch Apps".localized)
|
||||||
.searchable(
|
.searchable(
|
||||||
text: currentSearchBinding,
|
text: currentSearchBinding,
|
||||||
placement: .navigationBarDrawer(displayMode: .always),
|
placement: .navigationBarDrawer(displayMode: .always),
|
||||||
|
|
@ -182,8 +184,17 @@ private enum AppListTab: Int, CaseIterable, Identifiable {
|
||||||
.pickerStyle(.segmented)
|
.pickerStyle(.segmented)
|
||||||
.frame(width: 220)
|
.frame(width: 220)
|
||||||
}
|
}
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
if let onImportPairingFile {
|
||||||
Button("Done") { dismiss() }.fontWeight(.semibold)
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
|
Button(action: onImportPairingFile) {
|
||||||
|
Image(systemName: "doc.badge.plus")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if showDoneButton {
|
||||||
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
|
Button("Done") { dismiss() }.fontWeight(.semibold)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.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
|
ForEach(filteredDebuggableApps, id: \.key) { bundleID, appName in
|
||||||
AppButton(
|
AppButton(
|
||||||
bundleID: bundleID, appName: appName,
|
bundleID: bundleID, appName: appName,
|
||||||
|
|
@ -342,7 +353,7 @@ private enum AppListTab: Int, CaseIterable, Identifiable {
|
||||||
.listRowBackground(Color.clear)
|
.listRowBackground(Color.clear)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Section("Other Apps".localized) {
|
Section("All Apps".localized) {
|
||||||
ForEach(filteredLaunchApps, id: \.key) { bundleID, appName in
|
ForEach(filteredLaunchApps, id: \.key) { bundleID, appName in
|
||||||
let isPinned = pinnedSystemApps.contains(bundleID)
|
let isPinned = pinnedSystemApps.contains(bundleID)
|
||||||
LaunchAppRow(
|
LaunchAppRow(
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ struct MainTabView: View {
|
||||||
|
|
||||||
private var configurableTabs: [TabDescriptor] {
|
private var configurableTabs: [TabDescriptor] {
|
||||||
var tabs: [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: "scripts", title: "Scripts", systemImage: "scroll") { AnyView(ScriptListView()) },
|
||||||
TabDescriptor(id: "tools", title: "Tools", systemImage: "wrench.and.screwdriver") { AnyView(ToolsView()) },
|
TabDescriptor(id: "tools", title: "Tools", systemImage: "wrench.and.screwdriver") { AnyView(ToolsView()) },
|
||||||
TabDescriptor(id: "deviceinfo", title: "Device Info", systemImage: "iphone.and.arrow.forward") { AnyView(DeviceInfoView()) },
|
TabDescriptor(id: "deviceinfo", title: "Device Info", systemImage: "iphone.and.arrow.forward") { AnyView(DeviceInfoView()) },
|
||||||
|
|
@ -62,10 +62,10 @@ struct MainTabView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private var displayTabs: [TabDescriptor] {
|
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 })
|
configurableTabs.first(where: { $0.id == id })
|
||||||
}
|
}
|
||||||
tabs.insert(settingsTab, at: min(3, tabs.count))
|
tabs.insert(settingsTab, at: min(2, tabs.count))
|
||||||
return tabs
|
return tabs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
// MARK: - Search Completer
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
|
|
@ -61,6 +74,12 @@ struct LocationSimulationView: View {
|
||||||
@State private var searchText = ""
|
@State private var searchText = ""
|
||||||
@StateObject private var searchCompleter = LocationSearchCompleter()
|
@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 {
|
private var pairingFilePath: String {
|
||||||
URL.documentsDirectory.appendingPathComponent("pairingFile.plist").path()
|
URL.documentsDirectory.appendingPathComponent("pairingFile.plist").path()
|
||||||
}
|
}
|
||||||
|
|
@ -166,6 +185,14 @@ struct LocationSimulationView: View {
|
||||||
Button("Simulate Location", action: simulate)
|
Button("Simulate Location", action: simulate)
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
.disabled(!pairingExists || isBusy)
|
.disabled(!pairingExists || isBusy)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
showSaveBookmark = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "bookmark")
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
.tint(.blue)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Text("Tap map to drop pin")
|
Text("Tap map to drop pin")
|
||||||
|
|
@ -179,6 +206,13 @@ struct LocationSimulationView: View {
|
||||||
}
|
}
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
|
Button {
|
||||||
|
showBookmarks = true
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "bookmark.fill")
|
||||||
|
}
|
||||||
|
}
|
||||||
ToolbarItem(placement: .topBarTrailing) {
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
TextField("Search location...", text: $searchText)
|
TextField("Search location...", text: $searchText)
|
||||||
.padding(.leading, 6)
|
.padding(.leading, 6)
|
||||||
|
|
@ -193,6 +227,25 @@ struct LocationSimulationView: View {
|
||||||
} message: {
|
} message: {
|
||||||
Text(alertMessage)
|
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 {
|
.onDisappear {
|
||||||
stopResendLoop()
|
stopResendLoop()
|
||||||
if backgroundTaskID != .invalid {
|
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) {
|
private func selectSearchResult(_ result: MKLocalSearchCompletion) {
|
||||||
searchText = ""
|
searchText = ""
|
||||||
searchCompleter.results = []
|
searchCompleter.results = []
|
||||||
|
|
@ -289,3 +371,49 @@ struct LocationSimulationView: View {
|
||||||
resendTimer = nil
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ struct ToolsView: View {
|
||||||
|
|
||||||
private var tools: [ToolItem] {
|
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: "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: "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())),
|
ToolItem(id: "profiles", title: "App Expiry", detail: "Check app expiration dates", systemImage: "calendar.badge.clock", destination: AnyView(ProfileView())),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue