diff --git a/.gitignore b/.gitignore index 17d3628..f6eda27 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ xcuserdata/ ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) build/ DerivedData/ +.derivedData*/ *.moved-aside *.pbxuser !default.pbxuser diff --git a/StikDebug.xcodeproj/project.pbxproj b/StikDebug.xcodeproj/project.pbxproj index f8961dd..73f0192 100644 --- a/StikDebug.xcodeproj/project.pbxproj +++ b/StikDebug.xcodeproj/project.pbxproj @@ -579,7 +579,7 @@ ); MACOSX_DEPLOYMENT_TARGET = 15.1; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.stik.StikDebugTests; + PRODUCT_BUNDLE_IDENTIFIER = com.stik.stikdebug.tests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; @@ -611,7 +611,7 @@ ); MACOSX_DEPLOYMENT_TARGET = 15.1; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.stik.StikDebugTests; + PRODUCT_BUNDLE_IDENTIFIER = com.stik.stikdebug.tests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; @@ -634,7 +634,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 18.2; MACOSX_DEPLOYMENT_TARGET = 15.1; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.stik.StikDebugUITests; + PRODUCT_BUNDLE_IDENTIFIER = com.stik.stikdebug.uitests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; @@ -656,7 +656,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 18.2; MACOSX_DEPLOYMENT_TARGET = 15.1; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.stik.StikDebugUITests; + PRODUCT_BUNDLE_IDENTIFIER = com.stik.stikdebug.uitests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; diff --git a/StikDebug/App/AppBootstrapper.swift b/StikDebug/App/AppBootstrapper.swift new file mode 100644 index 0000000..cc6260a --- /dev/null +++ b/StikDebug/App/AppBootstrapper.swift @@ -0,0 +1,47 @@ +// +// AppBootstrapper.swift +// StikDebug +// + +import Foundation +import ObjectiveC.runtime +import UIKit + +enum AppBootstrapper { + static func configure() { + registerDefaultSettings() + startConfiguredKeepAliveServices() + applyDocumentPickerCopyWorkaround() + } + + private static func registerDefaultSettings() { + let os = ProcessInfo.processInfo.operatingSystemVersion + let enableAdvancedOptions = os.majorVersion >= 19 + + UserDefaults.standard.register(defaults: [ + "enableAdvancedOptions": enableAdvancedOptions, + UserDefaults.Keys.txmOverride: false, + "keepAliveAudio": true, + "keepAliveLocation": true + ]) + } + + private static func startConfiguredKeepAliveServices() { + guard UserDefaults.standard.bool(forKey: "keepAliveAudio") else { + return + } + BackgroundAudioManager.shared.start() + } + + private static func applyDocumentPickerCopyWorkaround() { + let fixedSelector = NSSelectorFromString("fix_initForOpeningContentTypes:asCopy:") + let originalSelector = #selector(UIDocumentPickerViewController.init(forOpeningContentTypes:asCopy:)) + + guard let fixedMethod = class_getInstanceMethod(UIDocumentPickerViewController.self, fixedSelector), + let originalMethod = class_getInstanceMethod(UIDocumentPickerViewController.self, originalSelector) else { + return + } + + method_exchangeImplementations(originalMethod, fixedMethod) + } +} diff --git a/StikDebug/Utilities/AppFeature.swift b/StikDebug/App/AppFeature.swift similarity index 100% rename from StikDebug/Utilities/AppFeature.swift rename to StikDebug/App/AppFeature.swift diff --git a/StikDebug/Utilities/Intents.swift b/StikDebug/App/Intents.swift similarity index 99% rename from StikDebug/Utilities/Intents.swift rename to StikDebug/App/Intents.swift index 3c6ee7e..1abac57 100644 --- a/StikDebug/Utilities/Intents.swift +++ b/StikDebug/App/Intents.swift @@ -298,7 +298,7 @@ struct StikDebugShortcuts: AppShortcutsProvider { func ensureTunnel() async { await MainActor.run { - pubTunnelConnected = false + markTunnelDisconnected() startTunnelInBackground(showErrorUI: false) } try? await Task.sleep(nanoseconds: 1_000_000_000) diff --git a/StikDebug/App/StikDebugApp.swift b/StikDebug/App/StikDebugApp.swift new file mode 100644 index 0000000..5ccd112 --- /dev/null +++ b/StikDebug/App/StikDebugApp.swift @@ -0,0 +1,58 @@ +// +// StikDebugApp.swift +// StikDebug +// +// Created by Stephen on 3/26/25. +// + +import SwiftUI + +@main +struct StikDebugApp: App { + @Environment(\.scenePhase) private var scenePhase + @State private var shouldAttemptTunnelReconnect = false + + init() { + AppBootstrapper.configure() + } + + var body: some Scene { + WindowGroup { + MainTabView() + .task { + await downloadMissingDeveloperDiskImageFiles() + } + .onChange(of: scenePhase) { _, newPhase in + handleScenePhaseChange(newPhase) + } + } + } + + private func handleScenePhaseChange(_ newPhase: ScenePhase) { + switch newPhase { + case .background: + shouldAttemptTunnelReconnect = true + case .active: + if shouldAttemptTunnelReconnect { + shouldAttemptTunnelReconnect = false + startTunnelInBackground(showErrorUI: false) + } + default: + break + } + } + + private func downloadMissingDeveloperDiskImageFiles() async { + do { + try await DeveloperDiskImageService.shared.downloadMissingFiles() + } catch { + await MainActor.run { + showAlert( + title: "An Error has Occurred", + message: "[Download DDI Error]: \(error.localizedDescription)", + showOk: true + ) + } + } + } +} diff --git a/StikDebug/Utilities/Localization.swift b/StikDebug/Core/Localization.swift similarity index 100% rename from StikDebug/Utilities/Localization.swift rename to StikDebug/Core/Localization.swift diff --git a/StikDebug/Utilities/LogManager.swift b/StikDebug/Core/LogManager.swift similarity index 89% rename from StikDebug/Utilities/LogManager.swift rename to StikDebug/Core/LogManager.swift index 22952f1..6b9de52 100644 --- a/StikDebug/Utilities/LogManager.swift +++ b/StikDebug/Core/LogManager.swift @@ -90,13 +90,4 @@ final class LogManager: ObservableObject { self.errorCount = 0 } } - - func removeOldestLogs(count: Int) { - DispatchQueue.main.async { - let removed = self.logs.prefix(count) - self.logs.removeFirst(count) - let removedErrors = removed.filter { $0.type == .error }.count - self.errorCount = max(0, self.errorCount - removedErrors) - } - } } diff --git a/StikDebug/Utilities/DeviceConnectionContext.swift b/StikDebug/Device/DeviceConnectionContext.swift similarity index 100% rename from StikDebug/Utilities/DeviceConnectionContext.swift rename to StikDebug/Device/DeviceConnectionContext.swift diff --git a/StikDebug/Utilities/DeviceInfoManager.swift b/StikDebug/Device/DeviceInfoManager.swift similarity index 99% rename from StikDebug/Utilities/DeviceInfoManager.swift rename to StikDebug/Device/DeviceInfoManager.swift index 5120170..249648c 100644 --- a/StikDebug/Utilities/DeviceInfoManager.swift +++ b/StikDebug/Device/DeviceInfoManager.swift @@ -379,7 +379,9 @@ struct CSVDocument: FileDocument { static var writableContentTypes: [UTType] { [UTType.commaSeparatedText] } let url: URL? init(url: URL?) { self.url = url } - init(configuration: ReadConfiguration) throws { fatalError("Not supported") } + init(configuration: ReadConfiguration) throws { + throw CocoaError(.fileReadUnsupportedScheme) + } func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { guard let url = url else { throw NSError(domain: "CSVDocument", code: -1) } return try FileWrapper(url: url, options: .immediate) diff --git a/StikDebug/Utilities/IdeviceFFIBridge.swift b/StikDebug/Device/IdeviceFFIBridge.swift similarity index 100% rename from StikDebug/Utilities/IdeviceFFIBridge.swift rename to StikDebug/Device/IdeviceFFIBridge.swift diff --git a/StikDebug/Utilities/JITEnableContext.swift b/StikDebug/Device/JITEnableContext.swift similarity index 100% rename from StikDebug/Utilities/JITEnableContext.swift rename to StikDebug/Device/JITEnableContext.swift diff --git a/StikDebug/Device/MountingProgress.swift b/StikDebug/Device/MountingProgress.swift new file mode 100644 index 0000000..c95fbd4 --- /dev/null +++ b/StikDebug/Device/MountingProgress.swift @@ -0,0 +1,92 @@ +// +// MountingProgress.swift +// StikDebug +// + +import Foundation +import idevice + +final class MountingProgress: ObservableObject { + static let shared = MountingProgress() + + @Published private(set) var mountProgress: Double = 0.0 + @Published private(set) var mountingThread: Thread? + @Published private(set) var coolisMounted: Bool = false + + private init() {} + + func checkforMounted() { + DispatchQueue.global(qos: .utility).async { + let mounted = isMounted() + DispatchQueue.main.async { + self.coolisMounted = mounted + } + } + } + + func progressCallback(progress: size_t, total: size_t, context: UnsafeMutableRawPointer?) { + let percentage = Double(progress) / Double(total) * 100.0 + DispatchQueue.main.async { + self.mountProgress = percentage + } + } + + func pubMount() { + mount() + } + + private func mount() { + let currentlyMounted = isMounted() + DispatchQueue.main.async { + self.coolisMounted = currentlyMounted + } + + guard isPairing(), !currentlyMounted else { + return + } + + if let mountingThread { + mountingThread.cancel() + self.mountingThread = nil + } + + let thread = Thread { [weak self] in + guard let self else { return } + let mountError = mountPersonalDDI( + imagePath: URL.documentsDirectory.appendingPathComponent("DDI/Image.dmg").path, + trustcachePath: URL.documentsDirectory.appendingPathComponent("DDI/Image.dmg.trustcache").path, + manifestPath: URL.documentsDirectory.appendingPathComponent("DDI/BuildManifest.plist").path + ) + + DispatchQueue.main.async { + if let mountError { + showAlert(title: "DDI Mount Failed", message: mountError, showOk: true, showTryAgain: true) { shouldTryAgain in + if shouldTryAgain { + self.mount() + } + } + } else { + self.coolisMounted = true + self.checkforMounted() + } + self.mountingThread = nil + } + } + + thread.qualityOfService = .background + thread.name = "mounting" + thread.start() + mountingThread = thread + } +} + +func isPairing() -> Bool { + let pairingPath = PairingFileStore.prepareURL().path + var pairingFile: RpPairingFileHandle? + let error = rp_pairing_file_read(pairingPath, &pairingFile) + if error != nil { + return false + } + rp_pairing_file_free(pairingFile) + return true +} diff --git a/StikDebug/Utilities/SystemLogStream.swift b/StikDebug/Device/SystemLogStream.swift similarity index 93% rename from StikDebug/Utilities/SystemLogStream.swift rename to StikDebug/Device/SystemLogStream.swift index 5e5f796..ffcc311 100644 --- a/StikDebug/Utilities/SystemLogStream.swift +++ b/StikDebug/Device/SystemLogStream.swift @@ -14,6 +14,14 @@ final class SystemLogStream: ObservableObject { let timestamp: Date let message: String let raw: String + let searchableRaw: String + + init(timestamp: Date, message: String, raw: String) { + self.timestamp = timestamp + self.message = message + self.raw = raw + self.searchableRaw = raw.lowercased() + } } @Published private(set) var entries: [Entry] = [] @@ -167,12 +175,6 @@ final class SystemLogStream: ObservableObject { return line } - func concatenatedLog(limit: Int = 500) -> String { - let slice = entries.suffix(limit) - return slice.map { "[\(DateFormatter.consoleFormatter.string(from: $0.timestamp))] \($0.raw)" } - .joined(separator: "\n") - } - private func scheduleFlushIfNeeded() { guard !isPaused else { return } guard !pendingEntries.isEmpty else { return } @@ -215,11 +217,3 @@ final class SystemLogStream: ObservableObject { RunLoop.main.add(timer, forMode: .common) } } - -private extension DateFormatter { - static let consoleFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateFormat = "HH:mm:ss" - return formatter - }() -} diff --git a/StikDebug/Utilities/mountDDI.swift b/StikDebug/Device/mountDDI.swift similarity index 100% rename from StikDebug/Utilities/mountDDI.swift rename to StikDebug/Device/mountDDI.swift diff --git a/StikDebug/Features/Home/HomeExternalAction.swift b/StikDebug/Features/Home/HomeExternalAction.swift new file mode 100644 index 0000000..4531d06 --- /dev/null +++ b/StikDebug/Features/Home/HomeExternalAction.swift @@ -0,0 +1,86 @@ +// +// HomeExternalAction.swift +// StikDebug +// + +import Foundation +import SwiftUI + +struct JITEnableConfiguration { + var bundleID: String? + var pid: Int? + var scriptData: Data? + var scriptName: String? +} + +enum HomeExternalAction: Identifiable { + case enableJIT(JITEnableConfiguration) + case killProcess(Int) + case launchApp(String) + + var id: String { + switch self { + case .enableJIT(let configuration): + return "enable-\(configuration.bundleID ?? "")-\(configuration.pid ?? 0)-\(configuration.scriptName ?? "")" + case .killProcess(let pid): + return "kill-\(pid)" + case .launchApp(let bundleID): + return "launch-\(bundleID)" + } + } + + var title: String { + switch self { + case .enableJIT: + return "Enable JIT?" + case .killProcess: + return "Kill Process?" + case .launchApp: + return "Launch App?" + } + } + + var message: String { + switch self { + case .enableJIT(let configuration): + let scriptText = configuration.scriptData == nil ? "" : " and run a script" + return "An external link wants to enable JIT\(scriptText) for \(targetDescription(for: configuration))." + case .killProcess(let pid): + return "An external link wants to kill process \(pid)." + case .launchApp(let bundleID): + return "An external link wants to launch \(bundleID)." + } + } + + var confirmationTitle: String { + switch self { + case .enableJIT(let configuration): + return configuration.scriptData == nil ? "Enable JIT" : "Enable and Run Script" + case .killProcess: + return "Kill Process" + case .launchApp: + return "Launch App" + } + } + + var role: ButtonRole? { + switch self { + case .enableJIT(let configuration): + return configuration.scriptData == nil ? nil : .destructive + case .killProcess: + return .destructive + case .launchApp: + return nil + } + } + + private func targetDescription(for configuration: JITEnableConfiguration) -> String { + if let bundleID = configuration.bundleID { + return bundleID + } + if let pid = configuration.pid { + return "process \(pid)" + } + return "the requested app" + } +} diff --git a/StikDebug/Features/InstalledApps/AppIconRepository.swift b/StikDebug/Features/InstalledApps/AppIconRepository.swift new file mode 100644 index 0000000..a79a7c8 --- /dev/null +++ b/StikDebug/Features/InstalledApps/AppIconRepository.swift @@ -0,0 +1,252 @@ +// +// AppIconRepository.swift +// StikDebug +// + +import SwiftUI +import UIKit + +enum AppIconRepository { + private static let memory: NSCache = { + let cache = NSCache() + cache.countLimit = 2_000 + cache.totalCostLimit = 64 * 1024 * 1024 + return cache + }() + + private static let diskQueue = DispatchQueue(label: "com.stik.iconcache.disk", qos: .utility) + private static let fetchSemaphore = AsyncSemaphore(permits: 4) + private static let registry = IconFetchRegistry() + private static let appGroupIdentifier = "group.com.stik.sj" + + static func cachedImage(for bundleID: String) -> UIImage? { + memory.object(forKey: bundleID as NSString) + } + + static func image(for bundleID: String) async -> UIImage? { + if let memoryImage = cachedImage(for: bundleID) { + return memoryImage + } + + if let diskImage = await loadFromDisk(bundleID: bundleID) { + storeInMemory(diskImage, for: bundleID) + return diskImage + } + + return await fetchAndStore(bundleID: bundleID) + } + + static func prefetch(bundleIDs: [String]) { + for bundleID in Set(bundleIDs) { + Task.detached(priority: .utility) { + _ = await image(for: bundleID) + } + } + } + + private static func fetchAndStore(bundleID: String) async -> UIImage? { + let task = await registry.task(for: bundleID) { + Task.detached(priority: .utility) { + await fetchSemaphore.acquire() + + let result: UIImage? + if let fetched = await fetchFromSource(bundleID: bundleID) { + let prepared = prepareForDisplay(fetched) + store(prepared, for: bundleID) + result = prepared + } else { + result = nil + } + + await fetchSemaphore.release() + await registry.clear(bundleID: bundleID) + return result + } + } + + return await task.value + } + + private static func fetchFromSource(bundleID: String) async -> UIImage? { + await withCheckedContinuation { continuation in + AppStoreIconFetcher.getIcon(for: bundleID) { image in + continuation.resume(returning: image) + } + } + } + + private static func loadFromDisk(bundleID: String) async -> UIImage? { + let imageScale = await MainActor.run { UIScreen.main.scale } + + return await withCheckedContinuation { (continuation: CheckedContinuation) in + diskQueue.async { + guard let url = iconURL(for: bundleID), + FileManager.default.fileExists(atPath: url.path) else { + continuation.resume(returning: nil) + return + } + + guard let data = try? Data(contentsOf: url) else { + try? FileManager.default.removeItem(at: url) + continuation.resume(returning: nil) + return + } + + guard let image = UIImage(data: data, scale: imageScale) else { + try? FileManager.default.removeItem(at: url) + continuation.resume(returning: nil) + return + } + + continuation.resume(returning: prepareForDisplay(image)) + } + } + } + + private static func store(_ image: UIImage, for bundleID: String) { + storeInMemory(image, for: bundleID) + storeOnDisk(image, bundleID: bundleID) + } + + private static func storeInMemory(_ image: UIImage, for bundleID: String) { + memory.setObject(image, forKey: bundleID as NSString, cost: memoryCost(for: image)) + } + + private static func storeOnDisk(_ image: UIImage, bundleID: String) { + diskQueue.async { + guard let url = iconURL(for: bundleID), + let data = image.pngData() else { + return + } + + try? data.write(to: url, options: .atomic) + } + } + + private static func iconURL(for bundleID: String) -> URL? { + guard let container = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier), + let fileName = cacheFileName(for: bundleID) else { + return nil + } + + let directory = container.appendingPathComponent("icons", isDirectory: true) + do { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } catch { + return nil + } + + return directory.appendingPathComponent(fileName) + } + + private static func cacheFileName(for bundleID: String) -> String? { + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: ".-_")) + let sanitized = String(bundleID.unicodeScalars.map { scalar in + allowed.contains(scalar) ? Character(scalar) : "_" + }) + + guard !sanitized.isEmpty else { + return nil + } + + return "\(sanitized).png" + } + + private static func memoryCost(for image: UIImage) -> Int { + let width = Int(image.size.width * image.scale) + let height = Int(image.size.height * image.scale) + return max(width * height * 4, 1) + } + + private static func prepareForDisplay(_ image: UIImage) -> UIImage { + image.preparingForDisplay() ?? image + } +} + +@MainActor +final class IconLoader: ObservableObject { + @Published private(set) var image: UIImage? + + private let bundleID: String + private var didStart = false + + init(bundleID: String) { + self.bundleID = bundleID + if let cached = AppIconRepository.cachedImage(for: bundleID) { + image = cached + didStart = true + } + } + + func beginLoading() { + if image != nil { + didStart = true + return + } + + guard !didStart else { + return + } + + didStart = true + let targetID = bundleID + + Task { [weak self] in + if let resolved = await AppIconRepository.image(for: targetID) { + guard let self else { return } + withAnimation(.linear(duration: 0.12)) { + self.image = resolved + } + } else { + self?.didStart = false + } + } + } +} + +private actor IconFetchRegistry { + private var tasks: [String: Task] = [:] + + func task(for bundleID: String, create: () -> Task) -> Task { + if let existing = tasks[bundleID] { + return existing + } + + let task = create() + tasks[bundleID] = task + return task + } + + func clear(bundleID: String) { + tasks[bundleID] = nil + } +} + +private actor AsyncSemaphore { + private var permits: Int + private var waiters: [CheckedContinuation] = [] + + init(permits: Int) { + self.permits = permits + } + + func acquire() async { + if permits > 0 { + permits -= 1 + return + } + + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } + + func release() { + if let next = waiters.first { + waiters.removeFirst() + next.resume() + } else { + permits += 1 + } + } +} diff --git a/StikDebug/Features/InstalledApps/InstalledAppListItem.swift b/StikDebug/Features/InstalledApps/InstalledAppListItem.swift new file mode 100644 index 0000000..e969863 --- /dev/null +++ b/StikDebug/Features/InstalledApps/InstalledAppListItem.swift @@ -0,0 +1,46 @@ +// +// InstalledAppListItem.swift +// StikDebug +// + +import Foundation + +struct InstalledAppListItem: Identifiable, Equatable { + let bundleID: String + let name: String + + private let normalizedBundleID: String + private let normalizedName: String + + var id: String { + bundleID + } + + init(bundleID: String, name: String) { + self.bundleID = bundleID + self.name = name + self.normalizedBundleID = Self.normalized(bundleID) + self.normalizedName = Self.normalized(name) + } + + func matches(_ query: String) -> Bool { + query.isEmpty || normalizedBundleID.contains(query) || normalizedName.contains(query) + } + + static func sorted(from apps: [String: String]) -> [InstalledAppListItem] { + apps.map { InstalledAppListItem(bundleID: $0.key, name: $0.value) } + .sorted { lhs, rhs in + let comparison = lhs.name.localizedCaseInsensitiveCompare(rhs.name) + if comparison == .orderedSame { + return lhs.bundleID < rhs.bundleID + } + return comparison == .orderedAscending + } + } + + static func normalized(_ text: String) -> String { + text.trimmingCharacters(in: .whitespacesAndNewlines) + .folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current) + .lowercased() + } +} diff --git a/StikDebug/Features/InstalledApps/InstalledAppRows.swift b/StikDebug/Features/InstalledApps/InstalledAppRows.swift new file mode 100644 index 0000000..8820fb7 --- /dev/null +++ b/StikDebug/Features/InstalledApps/InstalledAppRows.swift @@ -0,0 +1,361 @@ +// +// InstalledAppRows.swift +// StikDebug +// + +import SwiftUI +import UIKit +import WidgetKit + +struct AppButton: View { + let bundleID: String + let appName: String + + @Binding var recentApps: [String] + @Binding var favoriteApps: [String] + + @AppStorage("loadAppIconsOnJIT") private var loadAppIconsOnJIT = true + @AppStorage("enableAdvancedOptions") private var enableAdvancedOptions = false + + var onSelectApp: (String, String) -> Void + let sharedDefaults: UserDefaults + + @State private var showScriptPicker = false + @State private var assignedScriptName: String? + @StateObject private var iconLoader: IconLoader + + init( + bundleID: String, + appName: String, + recentApps: Binding<[String]>, + favoriteApps: Binding<[String]>, + onSelectApp: @escaping (String, String) -> Void, + sharedDefaults: UserDefaults + ) { + self.bundleID = bundleID + self.appName = appName + self._recentApps = recentApps + self._favoriteApps = favoriteApps + self.onSelectApp = onSelectApp + self.sharedDefaults = sharedDefaults + _iconLoader = StateObject(wrappedValue: IconLoader(bundleID: bundleID)) + _assignedScriptName = State(initialValue: AppButton.currentAssignment(for: bundleID)) + } + + var body: some View { + Button(action: selectApp) { + HStack(spacing: loadAppIconsOnJIT ? 16 : 12) { + AppIconView(image: loadAppIconsOnJIT ? iconLoader.image : nil) + + VStack(alignment: .leading, spacing: 3) { + Text(appName) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + + Text(bundleID) + .font(.system(size: 14)) + .foregroundStyle(.secondary) + .lineLimit(1) + .textSelection(.enabled) + } + + Spacer() + + if favoriteApps.contains(bundleID) { + Image(systemName: "star.fill") + .imageScale(.medium) + .foregroundStyle(.yellow) + .accessibilityHidden(true) + } + } + .padding(.vertical, loadAppIconsOnJIT ? 4 : 8) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .contextMenu { + Button(action: toggleFavorite) { + Label( + favoriteApps.contains(bundleID) ? "Remove Favorite" : "Add to Favorites", + systemImage: favoriteApps.contains(bundleID) ? "star.slash" : "star" + ) + .disabled(!favoriteApps.contains(bundleID) && favoriteApps.count >= 4) + } + Button { + copyBundleID() + } label: { + Label("Copy Bundle ID", systemImage: "doc.on.doc") + } + if enableAdvancedOptions { + Button { + showScriptPicker = true + } label: { + Label("Assign Script", systemImage: "chevron.left.slash.chevron.right") + } + + if assignedScriptName != nil { + Button(action: resetScriptAssignment) { + Label("Reset Script", systemImage: "arrow.uturn.left") + } + } + } + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button { + toggleFavorite() + } label: { + Label(favoriteApps.contains(bundleID) ? "Unfavorite" : "Favorite", systemImage: "star") + } + .tint(.yellow) + + Button { + copyBundleID() + } label: { + Label("Copy ID", systemImage: "doc.on.doc") + } + } + .sheet(isPresented: $showScriptPicker) { + ScriptListView { url in + assignScript(url) + showScriptPicker = false + } + } + .onAppear(perform: beginIconLoadingIfNeeded) + .onChange(of: loadAppIconsOnJIT) { _, newValue in + if newValue { + iconLoader.beginLoading() + } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(String(format: "Enable JIT for %@".localized, appName)) + .accessibilityValue(accessibilityValue) + .accessibilityHint("Double-tap to open the app and enable JIT. Use the actions rotor for favorites or bundle ID.".localized) + .accessibilityAddTraits(.isButton) + .accessibilityRemoveTraits(.isStaticText) + .accessibilityAction(named: Text(favoriteAccessibilityActionLabel)) { + toggleFavorite() + } + .accessibilityAction(named: Text("Copy Bundle ID".localized)) { + copyBundleID() + } + } + + private var accessibilityValue: String { + var parts = [String(format: "Bundle ID %@".localized, bundleID)] + if favoriteApps.contains(bundleID) { + parts.append("Favorite".localized) + } + if let assignedScriptName { + parts.append(String(format: "Assigned script %@".localized, assignedScriptName)) + } + return parts.joined(separator: ", ") + } + + private var favoriteAccessibilityActionLabel: String { + favoriteApps.contains(bundleID) + ? "Remove from Favorites".localized + : "Add to Favorites".localized + } + + private func beginIconLoadingIfNeeded() { + guard loadAppIconsOnJIT else { + return + } + iconLoader.beginLoading() + } + + private func selectApp() { + Haptics.selection() + recentApps.removeAll { $0 == bundleID } + recentApps.insert(bundleID, at: 0) + if recentApps.count > 3 { + recentApps = Array(recentApps.prefix(3)) + } + persistIfChanged() + onSelectApp(bundleID, appName) + } + + private func toggleFavorite() { + Haptics.light() + let wasFavorite = favoriteApps.contains(bundleID) + + if wasFavorite { + favoriteApps.removeAll { $0 == bundleID } + } else if favoriteApps.count < 4 { + favoriteApps.insert(bundleID, at: 0) + recentApps.removeAll { $0 == bundleID } + } else { + AccessibilityAnnouncer.announce("Favorites are full".localized) + return + } + + persistIfChanged() + AccessibilityAnnouncer.announce(wasFavorite ? "Removed from Favorites".localized : "Added to Favorites".localized) + } + + private func copyBundleID() { + UIPasteboard.general.string = bundleID + Haptics.light() + AccessibilityAnnouncer.announce("Bundle ID copied".localized) + } + + private func assignScript(_ url: URL?) { + if let url { + let filename = url.lastPathComponent + ScriptStore.updateAssignedScriptName(filename, for: bundleID) + assignedScriptName = filename + } else { + ScriptStore.updateAssignedScriptName(nil, for: bundleID) + assignedScriptName = nil + } + Haptics.light() + } + + private func resetScriptAssignment() { + assignScript(nil) + } + + private static func currentAssignment(for bundleID: String) -> String? { + ScriptStore.assignedScriptName(for: bundleID) + } + + private func persistIfChanged() { + var touched = false + let previousRecents = (sharedDefaults.array(forKey: "recentApps") as? [String]) ?? [] + let previousFavorites = (sharedDefaults.array(forKey: "favoriteApps") as? [String]) ?? [] + + if previousRecents != recentApps { + sharedDefaults.set(recentApps, forKey: "recentApps") + touched = true + } + if previousFavorites != favoriteApps { + sharedDefaults.set(favoriteApps, forKey: "favoriteApps") + touched = true + } + if touched { + WidgetCenter.shared.reloadAllTimelines() + } + } +} + +struct LaunchAppRow: View { + let bundleID: String + let appName: String + let isLaunching: Bool + var launchAction: () -> Void + + @AppStorage("loadAppIconsOnJIT") private var loadAppIconsOnJIT = true + @StateObject private var iconLoader: IconLoader + + init( + bundleID: String, + appName: String, + isLaunching: Bool, + launchAction: @escaping () -> Void + ) { + self.bundleID = bundleID + self.appName = appName + self.isLaunching = isLaunching + self.launchAction = launchAction + _iconLoader = StateObject(wrappedValue: IconLoader(bundleID: bundleID)) + } + + var body: some View { + Button { + guard !isLaunching else { return } + launchAction() + } label: { + HStack(spacing: loadAppIconsOnJIT ? 16 : 12) { + AppIconView(image: loadAppIconsOnJIT ? iconLoader.image : nil) + + VStack(alignment: .leading, spacing: 3) { + Text(appName) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + + Text(bundleID) + .font(.system(size: 14)) + .foregroundStyle(.secondary) + .lineLimit(1) + .textSelection(.enabled) + } + + Spacer() + + if isLaunching { + ProgressView().controlSize(.small) + } else { + Text("Launch".localized) + .font(.footnote.weight(.semibold)) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Capsule().fill(Color.accentColor.opacity(0.18))) + .foregroundStyle(Color.accentColor) + } + } + .padding(.vertical, loadAppIconsOnJIT ? 4 : 8) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(isLaunching) + .onAppear(perform: beginIconLoadingIfNeeded) + .onChange(of: loadAppIconsOnJIT) { _, newValue in + if newValue { + iconLoader.beginLoading() + } + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(String(format: "Launch %@".localized, appName)) + .accessibilityValue(accessibilityValue) + .accessibilityHint(isLaunching + ? "Launch request in progress.".localized + : "Double-tap to launch this app without enabling JIT.".localized) + .accessibilityAddTraits(.isButton) + .accessibilityRemoveTraits(.isStaticText) + .accessibilityAction(named: Text("Launch App".localized)) { + guard !isLaunching else { return } + launchAction() + } + } + + private var accessibilityValue: String { + let state = isLaunching ? "Launching".localized : "Ready".localized + return "\(state), \(String(format: "Bundle ID %@".localized, bundleID))" + } + + private func beginIconLoadingIfNeeded() { + guard loadAppIconsOnJIT else { + return + } + iconLoader.beginLoading() + } +} + +private struct AppIconView: View { + let image: UIImage? + + var body: some View { + Group { + if let image { + Image(uiImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 54, height: 54) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .shadow(color: .black.opacity(0.18), radius: 3, x: 0, y: 1.5) + .transition(.opacity.combined(with: .scale)) + } else { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color(UIColor.systemGray5)) + .frame(width: 54, height: 54) + .overlay { + Image(systemName: "app") + .font(.system(size: 24, weight: .regular)) + .foregroundStyle(.gray) + } + } + } + .accessibilityHidden(true) + } +} diff --git a/StikDebug/Features/InstalledApps/InstalledAppsViewModel.swift b/StikDebug/Features/InstalledApps/InstalledAppsViewModel.swift new file mode 100644 index 0000000..5a93389 --- /dev/null +++ b/StikDebug/Features/InstalledApps/InstalledAppsViewModel.swift @@ -0,0 +1,147 @@ +// +// InstalledAppsViewModel.swift +// StikDebug +// + +import Combine +import Foundation + +final class InstalledAppsViewModel: ObservableObject { + @Published private(set) var debuggableApps: [String: String] = [:] + @Published private(set) var nonDebuggableApps: [String: String] = [:] + @Published private(set) var systemApps: [String: String] = [:] + @Published private(set) var debuggableItems: [InstalledAppListItem] = [] + @Published private(set) var launchItems: [InstalledAppListItem] = [] + @Published private(set) var isLoading = false + @Published private(set) var lastError: String? + + private let workQueue = DispatchQueue(label: "com.stik.installedApps", qos: .userInitiated) + private let cache = UserDefaults(suiteName: ScriptStore.favoriteAppNamesSuiteName) ?? .standard + private let cacheKeyDebuggable = "cachedDebuggableApps" + private let cacheKeyNonDebuggable = "cachedNonDebuggableApps" + private let cacheKeySystem = "cachedSystemApps" + + init() { + loadCachedApps() + refreshAppLists() + } + + func refreshAppLists() { + isLoading = true + lastError = nil + + workQueue.async { [weak self] in + guard let self else { return } + + do { + let debuggable = try JITEnableContext.shared.getAppList() + let allApps = try JITEnableContext.shared.getAllApps() + let hiddenSystem = (try? JITEnableContext.shared.getHiddenSystemApps()) ?? [:] + let classifiedApps = Self.classifyApps( + allApps: allApps, + debuggable: debuggable, + hiddenSystem: hiddenSystem + ) + + DispatchQueue.main.async { + self.apply( + debuggable: debuggable, + nonDebuggable: classifiedApps.nonDebuggable, + system: classifiedApps.system + ) + self.isLoading = false + self.cacheApps( + debuggable: debuggable, + nonDebuggable: classifiedApps.nonDebuggable, + system: classifiedApps.system + ) + } + } catch { + DispatchQueue.main.async { + self.isLoading = false + self.lastError = error.localizedDescription + } + } + } + } + + func displayName(for bundleID: String) -> String? { + debuggableApps[bundleID] ?? systemApps[bundleID] ?? nonDebuggableApps[bundleID] + } + + func launchWithoutDebug(bundleID: String, completion: @escaping (Bool) -> Void) { + workQueue.async { + let success = JITEnableContext.shared.launchAppWithoutDebug(bundleID, logger: nil) + DispatchQueue.main.async { + completion(success) + } + } + } + + private func loadCachedApps() { + let cachedDebuggable = decode(cacheKeyDebuggable) + let cachedNonDebuggable = decode(cacheKeyNonDebuggable) + let cachedSystem = decode(cacheKeySystem) + + if !cachedDebuggable.isEmpty || !cachedNonDebuggable.isEmpty || !cachedSystem.isEmpty { + apply(debuggable: cachedDebuggable, nonDebuggable: cachedNonDebuggable, system: cachedSystem) + } + } + + private func apply(debuggable: [String: String], nonDebuggable: [String: String], system: [String: String]) { + debuggableApps = debuggable + nonDebuggableApps = nonDebuggable + systemApps = system + debuggableItems = InstalledAppListItem.sorted(from: debuggable) + launchItems = InstalledAppListItem.sorted(from: Self.launchApps(nonDebuggable: nonDebuggable, system: system)) + } + + private func cacheApps(debuggable: [String: String], nonDebuggable: [String: String], system: [String: String]) { + cache.set(encode(debuggable), forKey: cacheKeyDebuggable) + cache.set(encode(nonDebuggable), forKey: cacheKeyNonDebuggable) + cache.set(encode(system), forKey: cacheKeySystem) + } + + private func decode(_ key: String) -> [String: String] { + guard let data = cache.data(forKey: key), + let decoded = try? JSONDecoder().decode([String: String].self, from: data) else { + return [:] + } + return decoded + } + + private func encode(_ value: [String: String]) -> Data? { + try? JSONEncoder().encode(value) + } + + private static func classifyApps( + allApps: [String: String], + debuggable: [String: String], + hiddenSystem: [String: String] + ) -> (nonDebuggable: [String: String], system: [String: String]) { + var nonDebuggable: [String: String] = [:] + var system: [String: String] = [:] + + for (bundleID, name) in allApps where debuggable[bundleID] == nil { + if let hiddenName = hiddenSystem[bundleID] { + system[bundleID] = hiddenName + } else { + nonDebuggable[bundleID] = name + } + } + + for (bundleID, name) in hiddenSystem where system[bundleID] == nil && debuggable[bundleID] == nil { + system[bundleID] = name + } + + return (nonDebuggable, system) + } + + private static func launchApps(nonDebuggable: [String: String], system: [String: String]) -> [String: String] { + var combined = nonDebuggable + for (bundleID, name) in system { + combined[bundleID] = name + } + return combined + } +} diff --git a/StikDebug/Info.plist b/StikDebug/Info.plist index fbd83b1..60fb164 100644 --- a/StikDebug/Info.plist +++ b/StikDebug/Info.plist @@ -8,12 +8,13 @@ CFBundleTypeRole Editor CFBundleURLName - com.stik.StikDebug.enableJIT + com.stik.stikdebug.enableJIT CFBundleURLSchemes - - stikdebug - - + + stikdebug + stikjit + + NSBonjourServices @@ -40,7 +41,7 @@ UTTypeIconFiles UTTypeIdentifier - com.stik.StikDebug.stiktool + com.stik.stikdebug.stiktool UTTypeTagSpecification public.filename-extension diff --git a/StikDebug/Utilities/AppStoreIconFetcher.swift b/StikDebug/Services/AppStoreIconFetcher.swift similarity index 86% rename from StikDebug/Utilities/AppStoreIconFetcher.swift rename to StikDebug/Services/AppStoreIconFetcher.swift index 3d7abed..702438b 100644 --- a/StikDebug/Utilities/AppStoreIconFetcher.swift +++ b/StikDebug/Services/AppStoreIconFetcher.swift @@ -10,7 +10,7 @@ import UIKit class AppStoreIconFetcher { private static var cache = [String: UIImage]() - private static let queue = DispatchQueue(label: "com.stik.StikDebug.iconFetchQueue", attributes: .concurrent) + private static let queue = DispatchQueue(label: "com.stik.stikdebug.iconFetchQueue", attributes: .concurrent) static func getIcon(for bundleID: String, completion: @escaping (UIImage?) -> Void) { if let icon = cache[bundleID] { diff --git a/StikDebug/Utilities/BackgroundAudioManager.swift b/StikDebug/Services/BackgroundAudioManager.swift similarity index 100% rename from StikDebug/Utilities/BackgroundAudioManager.swift rename to StikDebug/Services/BackgroundAudioManager.swift diff --git a/StikDebug/Utilities/BackgroundLocationManager.swift b/StikDebug/Services/BackgroundLocationManager.swift similarity index 100% rename from StikDebug/Utilities/BackgroundLocationManager.swift rename to StikDebug/Services/BackgroundLocationManager.swift diff --git a/StikDebug/Services/DebugKeepAliveLease.swift b/StikDebug/Services/DebugKeepAliveLease.swift new file mode 100644 index 0000000..41659a5 --- /dev/null +++ b/StikDebug/Services/DebugKeepAliveLease.swift @@ -0,0 +1,64 @@ +// +// DebugKeepAliveLease.swift +// StikDebug +// + +import Foundation +import UIKit + +final class DebugKeepAliveLease { + private let stateLock = NSLock() + private var isActive = false + private var backgroundTaskID: UIBackgroundTaskIdentifier = .invalid + + init() { + activate() + } + + func invalidate() { + stateLock.lock() + guard isActive else { + stateLock.unlock() + return + } + isActive = false + stateLock.unlock() + + runOnMain { + BackgroundAudioManager.shared.requestStop() + BackgroundLocationManager.shared.requestStop() + + if self.backgroundTaskID != .invalid { + UIApplication.shared.endBackgroundTask(self.backgroundTaskID) + self.backgroundTaskID = .invalid + } + } + } + + private func activate() { + stateLock.lock() + guard !isActive else { + stateLock.unlock() + return + } + isActive = true + stateLock.unlock() + + runOnMain { + BackgroundAudioManager.shared.requestStart() + BackgroundLocationManager.shared.requestStart() + self.backgroundTaskID = UIApplication.shared.beginBackgroundTask(withName: "StikDebugDebugSession") { [weak self] in + LogManager.shared.addWarningLog("Debug session background task expired") + self?.invalidate() + } + } + } + + private func runOnMain(_ work: @escaping () -> Void) { + if Thread.isMainThread { + work() + } else { + DispatchQueue.main.sync(execute: work) + } + } +} diff --git a/StikDebug/Services/DeveloperDiskImageService.swift b/StikDebug/Services/DeveloperDiskImageService.swift new file mode 100644 index 0000000..3d09332 --- /dev/null +++ b/StikDebug/Services/DeveloperDiskImageService.swift @@ -0,0 +1,124 @@ +// +// DeveloperDiskImageService.swift +// StikDebug +// + +import Foundation + +final class DeveloperDiskImageService { + static let shared = DeveloperDiskImageService() + + private let fileManager: FileManager + private let session: URLSession + + init(fileManager: FileManager = .default, session: URLSession = .shared) { + self.fileManager = fileManager + self.session = session + } + + func downloadMissingFiles() async throws { + for item in Self.downloadItems { + let destinationURL = URL.documentsDirectory.appendingPathComponent(item.relativePath) + guard !fileManager.fileExists(atPath: destinationURL.path) else { + continue + } + try await downloadFile(from: item.urlString, to: destinationURL) + } + } + + func downloadFile(from urlString: String, to destinationURL: URL) async throws { + guard let url = URL(string: urlString), + url.scheme?.lowercased() == "https" else { + throw DDIDownloadError.invalidURL(urlString) + } + + let (temporaryURL, response) = try await session.download(from: url) + guard let httpResponse = response as? HTTPURLResponse else { + throw DDIDownloadError.invalidResponse + } + guard (200..<300).contains(httpResponse.statusCode) else { + throw DDIDownloadError.badStatus(httpResponse.statusCode) + } + + try fileManager.createDirectory( + at: destinationURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + + if fileManager.fileExists(atPath: destinationURL.path) { + try fileManager.removeItem(at: destinationURL) + } + try fileManager.moveItem(at: temporaryURL, to: destinationURL) + } + + func redownload(progressHandler: ((Double, String) -> Void)? = nil) async throws { + let totalStages = Double(Self.downloadItems.count + 1) + var completedStages = 0.0 + + progressHandler?(0.0, "Removing existing DDI files...") + for item in Self.downloadItems { + let fileURL = URL.documentsDirectory.appendingPathComponent(item.relativePath) + if fileManager.fileExists(atPath: fileURL.path) { + try fileManager.removeItem(at: fileURL) + } + } + + completedStages += 1.0 + progressHandler?(completedStages / totalStages, "Starting downloads...") + + for item in Self.downloadItems { + progressHandler?(completedStages / totalStages, "Downloading \(item.name)...") + let destinationURL = URL.documentsDirectory.appendingPathComponent(item.relativePath) + try await downloadFile(from: item.urlString, to: destinationURL) + completedStages += 1.0 + progressHandler?(completedStages / totalStages, "\(item.name) ready") + } + + progressHandler?(1.0, "DDI download complete.") + } + + private static let downloadItems: [DDIDownloadItem] = [ + .init( + name: "Build Manifest", + relativePath: "DDI/BuildManifest.plist", + urlString: "https://github.com/doronz88/DeveloperDiskImage/raw/refs/heads/main/PersonalizedImages/Xcode_iOS_DDI_Personalized/BuildManifest.plist" + ), + .init( + name: "Image", + relativePath: "DDI/Image.dmg", + urlString: "https://github.com/doronz88/DeveloperDiskImage/raw/refs/heads/main/PersonalizedImages/Xcode_iOS_DDI_Personalized/Image.dmg" + ), + .init( + name: "TrustCache", + relativePath: "DDI/Image.dmg.trustcache", + urlString: "https://github.com/doronz88/DeveloperDiskImage/raw/refs/heads/main/PersonalizedImages/Xcode_iOS_DDI_Personalized/Image.dmg.trustcache" + ) + ] +} + +private struct DDIDownloadItem { + let name: String + let relativePath: String + let urlString: String +} + +enum DDIDownloadError: LocalizedError { + case invalidURL(String) + case invalidResponse + case badStatus(Int) + + var errorDescription: String? { + switch self { + case .invalidURL(let string): + return "Invalid download URL: \(string)" + case .invalidResponse: + return "The DDI server returned an invalid response." + case .badStatus(let statusCode): + return "The DDI server returned HTTP \(statusCode)." + } + } +} + +func redownloadDDI(progressHandler: ((Double, String) -> Void)? = nil) async throws { + try await DeveloperDiskImageService.shared.redownload(progressHandler: progressHandler) +} diff --git a/StikDebug/Services/TunnelManager.swift b/StikDebug/Services/TunnelManager.swift new file mode 100644 index 0000000..0249202 --- /dev/null +++ b/StikDebug/Services/TunnelManager.swift @@ -0,0 +1,215 @@ +// +// TunnelManager.swift +// StikDebug +// + +import Foundation + +final class TunnelManager: ObservableObject { + static let shared = TunnelManager() + + @Published private(set) var isConnected = false + + private var isStarting = false + + private init() {} + + func markDisconnected() { + runOnMain { + self.isConnected = false + } + } + + func start(showErrorUI: Bool = true) { + guard Thread.isMainThread else { + DispatchQueue.main.async { + self.start(showErrorUI: showErrorUI) + } + return + } + + let pairingFileURL = PairingFileStore.prepareURL() + guard FileManager.default.fileExists(atPath: pairingFileURL.path) else { + isConnected = false + return + } + + guard !isStarting else { + return + } + + isStarting = true + + DispatchQueue.global(qos: .userInteractive).async { [showErrorUI] in + let result: Result + do { + try JITEnableContext.shared.startTunnel() + result = .success(()) + } catch { + result = .failure(error as NSError) + } + + DispatchQueue.main.async { + self.finishStart(result, showErrorUI: showErrorUI) + } + } + } + + private func finishStart(_ result: Result, showErrorUI: Bool) { + isStarting = false + + switch result { + case .success: + isConnected = true + LogManager.shared.addInfoLog("Tunnel connected successfully") + mountDeveloperDiskImageIfNeeded() + case .failure(let error): + isConnected = false + handleStartFailure(error, showErrorUI: showErrorUI) + } + } + + private func mountDeveloperDiskImageIfNeeded() { + let trustcachePath = URL.documentsDirectory.appendingPathComponent("DDI/Image.dmg.trustcache").path + guard FileManager.default.fileExists(atPath: trustcachePath), + !MountingProgress.shared.coolisMounted, + MountingProgress.shared.mountingThread == nil else { + return + } + MountingProgress.shared.pubMount() + } + + private func handleStartFailure(_ error: NSError, showErrorUI: Bool) { + LogManager.shared.addErrorLog(tunnelConnectionLogMessage(for: error)) + guard showErrorUI else { + return + } + + if error.code == -9 { + handleInvalidPairingFile() + return + } + + showAlert( + title: "Connection Error", + message: tunnelConnectionAlertMessage(for: error), + showOk: false, + showTryAgain: true + ) { shouldTryAgain in + if shouldTryAgain { + startTunnelInBackground() + } + } + } + + private func handleInvalidPairingFile() { + do { + try PairingFileStore.remove() + LogManager.shared.addInfoLog("Removed invalid pairing file") + } catch { + LogManager.shared.addErrorLog("Failed to remove invalid pairing file: \(error.localizedDescription)") + } + + showAlert( + title: "Invalid Pairing File", + message: "The pairing file is invalid or expired. Please select a new pairing file.", + showOk: true, + showTryAgain: false, + primaryButtonText: "Select New File" + ) { _ in + NotificationCenter.default.post(name: NSNotification.Name("ShowPairingFilePicker"), object: nil) + } + } + + private func runOnMain(_ work: @escaping () -> Void) { + if Thread.isMainThread { + work() + } else { + DispatchQueue.main.async(execute: work) + } + } +} + +func startTunnelInBackground(showErrorUI: Bool = true) { + TunnelManager.shared.start(showErrorUI: showErrorUI) +} + +func markTunnelDisconnected() { + TunnelManager.shared.markDisconnected() +} + +private func tunnelConnectionLogMessage(for error: NSError) -> String { + let target = "\(DeviceConnectionContext.targetIPAddress):49152" + return "Tunnel connection failed for \(target): \(error.localizedDescription) (Domain: \(error.domain), Code: \(error.code), Raw: \(String(describing: error)))" +} + +private func tunnelConnectionAlertMessage(for error: NSError) -> String { + let targetIP = DeviceConnectionContext.targetIPAddress + let rawMessage = error.localizedDescription + let lowercasedMessage = rawMessage.lowercased() + + let likelyCause: String + let recoverySteps: [String] + + if error.code == 48 || lowercasedMessage.contains("address already in use") || lowercasedMessage.contains("port already in use") { + likelyCause = "A port needed for the tunnel is already in use." + recoverySteps = [ + "Close other JIT, debugging, proxy, or VPN apps that may be using the tunnel.", + "Disconnect and reconnect LocalDevVPN.", + "Restart StikDebug, then try again.", + "If it keeps happening, reboot the device to clear the stuck port." + ] + } else if error.code == 54 || lowercasedMessage.contains("connection reset") { + likelyCause = "The device or VPN closed the tunnel connection before setup finished." + recoverySteps = [ + "Open LocalDevVPN and confirm the VPN is connected.", + "Make sure LocalDevVPN is using the default \(DeviceConnectionContext.defaultTargetIPAddress) address.", + "Reconnect Wi-Fi and LocalDevVPN, then try again.", + "If this keeps happening, select a fresh pairing file." + ] + } else if error.code == -18 || lowercasedMessage.contains("parse target ip") { + likelyCause = "The configured target IP address is not valid." + recoverySteps = [ + "Open Settings and check the target IP address.", + "Use the default \(DeviceConnectionContext.defaultTargetIPAddress)." + ] + } else if lowercasedMessage.contains("timed out") || lowercasedMessage.contains("timeout") { + likelyCause = "The app could not reach the device before the connection timed out." + recoverySteps = [ + "Confirm Wi-Fi and LocalDevVPN are both connected.", + "Wake and unlock the target device.", + "Confirm LocalDevVPN is exposing the device at \(targetIP)." + ] + } else if lowercasedMessage.contains("network is unreachable") || lowercasedMessage.contains("no route") { + likelyCause = "The VPN route to the device is not available." + recoverySteps = [ + "Disconnect and reconnect LocalDevVPN.", + "Confirm iOS shows the VPN indicator.", + "Try switching Wi-Fi off and on." + ] + } else { + likelyCause = "The tunnel could not be created." + recoverySteps = [ + "Confirm Wi-Fi and LocalDevVPN are connected.", + "Wake and unlock the target device.", + "Reconnect LocalDevVPN, then try again." + ] + } + + let steps = recoverySteps.enumerated() + .map { "\($0.offset + 1). \($0.element)" } + .joined(separator: "\n") + + return """ + \(likelyCause) + + Target: \(targetIP):49152 + Expected LocalDevVPN IP: \(DeviceConnectionContext.defaultTargetIPAddress) + + Try this: + \(steps) + + Technical details: + Code \(error.code): \(rawMessage) + """ +} diff --git a/StikDebug/StikDebugApp.swift b/StikDebug/StikDebugApp.swift deleted file mode 100644 index 78f5815..0000000 --- a/StikDebug/StikDebugApp.swift +++ /dev/null @@ -1,601 +0,0 @@ -// -// StikDebugApp.swift -// StikDebug -// -// Created by Stephen on 3/26/25. -// - -import SwiftUI -import Network -import idevice - -// Register default settings before the app starts -private func registerAdvancedOptionsDefault() { - let os = ProcessInfo.processInfo.operatingSystemVersion - // Enable advanced options by default on iOS 19/26 and above - let enabled = os.majorVersion >= 19 - UserDefaults.standard.register(defaults: ["enableAdvancedOptions": enabled]) - UserDefaults.standard.register(defaults: [UserDefaults.Keys.txmOverride: false]) - UserDefaults.standard.register(defaults: ["keepAliveAudio": true]) - UserDefaults.standard.register(defaults: ["keepAliveLocation": true]) -} - -// MARK: - DNS Checker - -class DNSChecker: ObservableObject { - @Published var appleIP: String? - @Published var controlIP: String? - @Published var dnsError: String? - - func checkDNS() { - checkIfConnectedToWifi { [weak self] wifiConnected in - guard let self = self else { return } - if wifiConnected { - let group = DispatchGroup() - - group.enter() - self.lookupIPAddress(for: "gs.apple.com") { ip in - DispatchQueue.main.async { - self.appleIP = ip - } - group.leave() - } - - group.enter() - self.lookupIPAddress(for: "google.com") { ip in - DispatchQueue.main.async { - self.controlIP = ip - } - group.leave() - } - - group.notify(queue: .main) { - if self.controlIP == nil { - self.dnsError = "No internet connection." - } else if self.appleIP == nil { - self.dnsError = "Apple DNS blocked. Your network might be filtering Apple traffic." - } else { - self.dnsError = nil - } - } - } else { - DispatchQueue.main.async { - self.dnsError = nil - } - } - } - } - - private func checkIfConnectedToWifi(completion: @escaping (Bool) -> Void) { - let monitor = NWPathMonitor(requiredInterfaceType: .wifi) - monitor.pathUpdateHandler = { path in - completion(path.status == .satisfied) - monitor.cancel() - } - let queue = DispatchQueue.global(qos: .background) - monitor.start(queue: queue) - } - - private func lookupIPAddress(for host: String, completion: @escaping (String?) -> Void) { - DispatchQueue.global(qos: .background).async { - var hints = addrinfo( - ai_flags: 0, - ai_family: AF_UNSPEC, - ai_socktype: SOCK_STREAM, - ai_protocol: 0, - ai_addrlen: 0, - ai_canonname: nil, - ai_addr: nil, - ai_next: nil - ) - var res: UnsafeMutablePointer? - let err = getaddrinfo(host, nil, &hints, &res) - if err != 0 { - DispatchQueue.main.async { completion(nil) } - return - } - - var ipAddress: String? - var ptr = res - while ptr != nil { - if let addr = ptr?.pointee.ai_addr { - var hostBuffer = [CChar](repeating: 0, count: Int(NI_MAXHOST)) - if getnameinfo(addr, ptr!.pointee.ai_addrlen, - &hostBuffer, socklen_t(hostBuffer.count), - nil, 0, NI_NUMERICHOST) == 0 { - ipAddress = String(cString: hostBuffer) - break - } - } - ptr = ptr?.pointee.ai_next - } - freeaddrinfo(res) - DispatchQueue.main.async { completion(ipAddress) } - } - } -} - -// MARK: - Main App - -// Global state variable for the tunnel connection. -var pubTunnelConnected = false -private var tunnelStartPending = false -private var tunnelStartInProgress = false -private var tunnelPendingShowUI = true - -@main -struct HeartbeatApp: App { - @StateObject private var mount = MountingProgress.shared - @Environment(\.scenePhase) private var scenePhase // Observe scene lifecycle - @State private var shouldAttemptTunnelReconnect = false - - init() { - registerAdvancedOptionsDefault() - if UserDefaults.standard.bool(forKey: "keepAliveAudio") { - BackgroundAudioManager.shared.start() - } - let fixSelector = NSSelectorFromString("fix_initForOpeningContentTypes:asCopy:") - if let fixMethod = class_getInstanceMethod(UIDocumentPickerViewController.self, fixSelector), - let origMethod = class_getInstanceMethod(UIDocumentPickerViewController.self, #selector(UIDocumentPickerViewController.init(forOpeningContentTypes:asCopy:))) { - method_exchangeImplementations(origMethod, fixMethod) - } - - } - - private func handleScenePhaseChange(_ newPhase: ScenePhase) { - switch newPhase { - case .background: - shouldAttemptTunnelReconnect = true - case .active: - if shouldAttemptTunnelReconnect { - shouldAttemptTunnelReconnect = false - startTunnelInBackground(showErrorUI: false) - } - default: - break - } - } - - var body: some Scene { - WindowGroup { - MainTabView() - .onAppear { - Task { - let fileManager = FileManager.default - for item in ddiDownloadItems { - let destinationURL = URL.documentsDirectory.appendingPathComponent(item.relativePath) - if fileManager.fileExists(atPath: destinationURL.path) { continue } - do { - try await downloadFile(from: item.urlString, to: destinationURL) - } catch { - await MainActor.run { - showAlert(title: "An Error has Occurred", - message: "[Download DDI Error]: \(error.localizedDescription)", - showOk: true) - } - break - } - } - } - } - .onChange(of: scenePhase) { _, newPhase in - handleScenePhaseChange(newPhase) - } - } - - } -} - -// MARK: - Additional Helpers - -actor FunctionGuard { - private var runningTask: Task? - - func execute(_ work: @escaping @Sendable () -> T) async -> T { - if let task = runningTask { - return await task.value - } - let task = Task.detached { work() } - runningTask = task - let result = await task.value - runningTask = nil - return result - } -} - -class MountingProgress: ObservableObject { - static var shared = MountingProgress() - @Published var mountProgress: Double = 0.0 - @Published var mountingThread: Thread? - @Published var coolisMounted: Bool = false - - func checkforMounted() { - DispatchQueue.global(qos: .utility).async { - let mounted = isMounted() - DispatchQueue.main.async { - self.coolisMounted = mounted - } - } - } - - func progressCallback(progress: size_t, total: size_t, context: UnsafeMutableRawPointer?) { - let percentage = Double(progress) / Double(total) * 100.0 - DispatchQueue.main.async { - self.mountProgress = percentage - } - } - - func pubMount() { - mount() - } - - private func mount() { - let currentlyMounted = isMounted() - DispatchQueue.main.async { - self.coolisMounted = currentlyMounted - } - - if isPairing(), !currentlyMounted { - if let mountingThread = mountingThread { - mountingThread.cancel() - self.mountingThread = nil - } - - let thread = Thread { [weak self] in - guard let self = self else { return } - let mountError = mountPersonalDDI( - imagePath: URL.documentsDirectory.appendingPathComponent("DDI/Image.dmg").path, - trustcachePath: URL.documentsDirectory.appendingPathComponent("DDI/Image.dmg.trustcache").path, - manifestPath: URL.documentsDirectory.appendingPathComponent("DDI/BuildManifest.plist").path, - ) - - DispatchQueue.main.async { - if let mountError { - showAlert(title: "DDI Mount Failed", message: mountError, showOk: true, showTryAgain: true) { shouldTryAgain in - if shouldTryAgain { self.mount() } - } - } else { - self.coolisMounted = true - self.checkforMounted() - } - self.mountingThread = nil - } - } - thread.qualityOfService = .background - thread.name = "mounting" - thread.start() - mountingThread = thread - } - } -} - -func isPairing() -> Bool { - let pairingpath = PairingFileStore.prepareURL().path - var pairingFile: RpPairingFileHandle? - let err = rp_pairing_file_read(pairingpath, &pairingFile) - if err != nil { return false } - rp_pairing_file_free(pairingFile) - return true -} - -func startTunnelInBackground(showErrorUI: Bool = true) { - assert(Thread.isMainThread, "startTunnelInBackground must be called on the main thread") - let pairingFileURL = PairingFileStore.prepareURL() - - guard FileManager.default.fileExists(atPath: pairingFileURL.path) else { - tunnelStartPending = false - tunnelPendingShowUI = true - return - } - - guard !tunnelStartInProgress else { - return - } - - tunnelStartPending = false - tunnelPendingShowUI = true - tunnelStartInProgress = true - - DispatchQueue.global(qos: .userInteractive).async { - defer { - DispatchQueue.main.async { - tunnelStartInProgress = false - } - } - do { - try JITEnableContext.shared.startTunnel() - LogManager.shared.addInfoLog("Tunnel connected successfully") - pubTunnelConnected = true - - DispatchQueue.main.async { - let trustcachePath = URL.documentsDirectory.appendingPathComponent("DDI/Image.dmg.trustcache").path - guard FileManager.default.fileExists(atPath: trustcachePath), - !MountingProgress.shared.coolisMounted, - MountingProgress.shared.mountingThread == nil else { return } - MountingProgress.shared.pubMount() - } - } catch { - let err2 = error as NSError - let code = err2.code - LogManager.shared.addErrorLog(tunnelConnectionLogMessage(for: err2)) - guard showErrorUI else { return } - DispatchQueue.main.async { - if code == -9 { - do { - try PairingFileStore.remove() - LogManager.shared.addInfoLog("Removed invalid pairing file") - } catch { - LogManager.shared.addErrorLog("Failed to remove invalid pairing file: \(error.localizedDescription)") - } - - showAlert( - title: "Invalid Pairing File", - message: "The pairing file is invalid or expired. Please select a new pairing file.", - showOk: true, - showTryAgain: false, - primaryButtonText: "Select New File" - ) { _ in - NotificationCenter.default.post(name: NSNotification.Name("ShowPairingFilePicker"), object: nil) - } - } else { - showAlert( - title: "Connection Error", - message: tunnelConnectionAlertMessage(for: err2), - showOk: false, - showTryAgain: true - ) { shouldTryAgain in - if shouldTryAgain { - DispatchQueue.main.async { - startTunnelInBackground() - } - } - } - } - } - } - } - - -} - -private func tunnelConnectionLogMessage(for error: NSError) -> String { - let target = "\(DeviceConnectionContext.targetIPAddress):49152" - return "Tunnel connection failed for \(target): \(error.localizedDescription) (Domain: \(error.domain), Code: \(error.code), Raw: \(String(describing: error)))" -} - -private func tunnelConnectionAlertMessage(for error: NSError) -> String { - let targetIP = DeviceConnectionContext.targetIPAddress - let rawMessage = error.localizedDescription - let lowercasedMessage = rawMessage.lowercased() - - let likelyCause: String - let recoverySteps: [String] - - if error.code == 48 || lowercasedMessage.contains("address already in use") || lowercasedMessage.contains("port already in use") { - likelyCause = "A port needed for the tunnel is already in use." - recoverySteps = [ - "Close other JIT, debugging, proxy, or VPN apps that may be using the tunnel.", - "Disconnect and reconnect LocalDevVPN.", - "Restart StikDebug, then try again.", - "If it keeps happening, reboot the device to clear the stuck port." - ] - } else if error.code == 54 || lowercasedMessage.contains("connection reset") { - likelyCause = "The device or VPN closed the tunnel connection before setup finished." - recoverySteps = [ - "Open LocalDevVPN and confirm the VPN is connected.", - "Make sure LocalDevVPN is using the default \(DeviceConnectionContext.defaultTargetIPAddress) address.", - "Reconnect Wi-Fi and LocalDevVPN, then try again.", - "If this keeps happening, select a fresh pairing file." - ] - } else if error.code == -18 || lowercasedMessage.contains("parse target ip") { - likelyCause = "The configured target IP address is not valid." - recoverySteps = [ - "Open Settings and check the target IP address.", - "Use the default \(DeviceConnectionContext.defaultTargetIPAddress)." - ] - } else if lowercasedMessage.contains("timed out") || lowercasedMessage.contains("timeout") { - likelyCause = "The app could not reach the device before the connection timed out." - recoverySteps = [ - "Confirm Wi-Fi and LocalDevVPN are both connected.", - "Wake and unlock the target device.", - "Confirm LocalDevVPN is exposing the device at \(targetIP)." - ] - } else if lowercasedMessage.contains("network is unreachable") || lowercasedMessage.contains("no route") { - likelyCause = "The VPN route to the device is not available." - recoverySteps = [ - "Disconnect and reconnect LocalDevVPN.", - "Confirm iOS shows the VPN indicator.", - "Try switching Wi-Fi off and on." - ] - } else { - likelyCause = "The tunnel could not be created." - recoverySteps = [ - "Confirm Wi-Fi and LocalDevVPN are connected.", - "Wake and unlock the target device.", - "Reconnect LocalDevVPN, then try again." - ] - } - - let steps = recoverySteps.enumerated() - .map { "\($0.offset + 1). \($0.element)" } - .joined(separator: "\n") - - return """ - \(likelyCause) - - Target: \(targetIP):49152 - Expected LocalDevVPN IP: \(DeviceConnectionContext.defaultTargetIPAddress) - - Try this: - \(steps) - - Technical details: - Code \(error.code): \(rawMessage) - """ -} - -func checkDeviceConnection(callback: @escaping (Bool, String?) -> Void) { - let targetIP = DeviceConnectionContext.targetIPAddress - let host = NWEndpoint.Host(targetIP) - let port = NWEndpoint.Port(rawValue: 62078)! - let connection = NWConnection(host: host, port: port, using: .tcp) - var timeoutWorkItem: DispatchWorkItem? - - timeoutWorkItem = DispatchWorkItem { [weak connection] in - if connection?.state != .ready { - connection?.cancel() - DispatchQueue.main.async { - if timeoutWorkItem?.isCancelled == false { - let message = "[TIMEOUT] Could not reach the device at \(targetIP). Make sure it’s online and on the same network." - callback(false, message) - } - } - } - } - - connection.stateUpdateHandler = { [weak connection] state in - switch state { - case .ready: - timeoutWorkItem?.cancel() - connection?.cancel() - DispatchQueue.main.async { - callback(true, nil) - } - case .failed(let error): - timeoutWorkItem?.cancel() - connection?.cancel() - DispatchQueue.main.async { - let message = "Could not reach the device at \(targetIP): \(error.localizedDescription)" - callback(false, message) - } - default: - break - } - } - - connection.start(queue: .global()) - if let workItem = timeoutWorkItem { - DispatchQueue.global().asyncAfter(deadline: .now() + 20, execute: workItem) - } -} - -public func showAlert(title: String, message: String, showOk: Bool, showTryAgain: Bool = false, primaryButtonText: String? = nil, completion: ((Bool) -> Void)? = nil) { - DispatchQueue.main.async { - guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let rootViewController = scene.windows.first?.rootViewController else { - return - } - - let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) - - if showTryAgain { - alert.addAction(UIAlertAction(title: primaryButtonText ?? "Try Again", style: .default) { _ in - completion?(true) - }) - alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in - completion?(false) - }) - } else if showOk { - alert.addAction(UIAlertAction(title: primaryButtonText ?? "OK", style: .default) { _ in - completion?(true) - }) - } else { - alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in - completion?(true) - }) - } - - var topController = rootViewController - while let presented = topController.presentedViewController { - topController = presented - } - topController.present(alert, animated: true) - } -} - -private struct DDIDownloadItem { - let name: String - let relativePath: String - let urlString: String -} - -private let ddiDownloadItems: [DDIDownloadItem] = [ - .init( - name: "Build Manifest", - relativePath: "DDI/BuildManifest.plist", - urlString: "https://github.com/doronz88/DeveloperDiskImage/raw/refs/heads/main/PersonalizedImages/Xcode_iOS_DDI_Personalized/BuildManifest.plist" - ), - .init( - name: "Image", - relativePath: "DDI/Image.dmg", - urlString: "https://github.com/doronz88/DeveloperDiskImage/raw/refs/heads/main/PersonalizedImages/Xcode_iOS_DDI_Personalized/Image.dmg" - ), - .init( - name: "TrustCache", - relativePath: "DDI/Image.dmg.trustcache", - urlString: "https://github.com/doronz88/DeveloperDiskImage/raw/refs/heads/main/PersonalizedImages/Xcode_iOS_DDI_Personalized/Image.dmg.trustcache" - ) -] - -enum DDIDownloadError: LocalizedError { - case invalidURL(String) - case invalidResponse - case badStatus(Int) - - var errorDescription: String? { - switch self { - case .invalidURL(let string): - return "Invalid download URL: \(string)" - case .invalidResponse: - return "The DDI server returned an invalid response." - case .badStatus(let statusCode): - return "The DDI server returned HTTP \(statusCode)." - } - } -} - -func downloadFile(from urlString: String, to destinationURL: URL) async throws { - guard let url = URL(string: urlString), - url.scheme?.lowercased() == "https" else { - throw DDIDownloadError.invalidURL(urlString) - } - let (tempLocalUrl, response) = try await URLSession.shared.download(from: url) - guard let httpResponse = response as? HTTPURLResponse else { - throw DDIDownloadError.invalidResponse - } - guard (200..<300).contains(httpResponse.statusCode) else { - throw DDIDownloadError.badStatus(httpResponse.statusCode) - } - let fileManager = FileManager.default - try fileManager.createDirectory(at: destinationURL.deletingLastPathComponent(), - withIntermediateDirectories: true) - if fileManager.fileExists(atPath: destinationURL.path) { - try fileManager.removeItem(at: destinationURL) - } - try fileManager.moveItem(at: tempLocalUrl, to: destinationURL) -} - -func redownloadDDI(progressHandler: ((Double, String) -> Void)? = nil) async throws { - let fileManager = FileManager.default - let totalStages = Double(ddiDownloadItems.count + 1) - var completedStages = 0.0 - - progressHandler?(0.0, "Removing existing DDI files…") - for item in ddiDownloadItems { - let fileURL = URL.documentsDirectory.appendingPathComponent(item.relativePath) - if fileManager.fileExists(atPath: fileURL.path) { - try fileManager.removeItem(at: fileURL) - } - } - completedStages += 1.0 - progressHandler?(completedStages / totalStages, "Starting downloads…") - - for item in ddiDownloadItems { - progressHandler?(completedStages / totalStages, "Downloading \(item.name)…") - let destinationURL = URL.documentsDirectory.appendingPathComponent(item.relativePath) - try await downloadFile(from: item.urlString, to: destinationURL) - completedStages += 1.0 - progressHandler?(completedStages / totalStages, "\(item.name) ready") - } - progressHandler?(1.0, "DDI download complete.") -} diff --git a/StikDebug/Support/AccessibilityAnnouncer.swift b/StikDebug/Support/AccessibilityAnnouncer.swift new file mode 100644 index 0000000..045f84a --- /dev/null +++ b/StikDebug/Support/AccessibilityAnnouncer.swift @@ -0,0 +1,14 @@ +// +// AccessibilityAnnouncer.swift +// StikDebug +// + +import UIKit + +enum AccessibilityAnnouncer { + static func announce(_ message: String) { + DispatchQueue.main.async { + UIAccessibility.post(notification: .announcement, argument: message) + } + } +} diff --git a/StikDebug/Support/AlertPresenter.swift b/StikDebug/Support/AlertPresenter.swift new file mode 100644 index 0000000..a5a49b7 --- /dev/null +++ b/StikDebug/Support/AlertPresenter.swift @@ -0,0 +1,91 @@ +// +// AlertPresenter.swift +// StikDebug +// + +import UIKit + +enum AlertPresenter { + static func show( + title: String, + message: String, + showOk: Bool, + showTryAgain: Bool = false, + primaryButtonText: String? = nil, + completion: ((Bool) -> Void)? = nil + ) { + DispatchQueue.main.async { + guard let rootViewController = UIApplication.shared.activeRootViewController else { + return + } + + let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) + + if showTryAgain { + alert.addAction(UIAlertAction(title: primaryButtonText ?? "Try Again", style: .default) { _ in + completion?(true) + }) + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel) { _ in + completion?(false) + }) + } else if showOk { + alert.addAction(UIAlertAction(title: primaryButtonText ?? "OK", style: .default) { _ in + completion?(true) + }) + } else { + alert.addAction(UIAlertAction(title: "OK", style: .default) { _ in + completion?(true) + }) + } + + UIApplication.shared.topViewController(from: rootViewController)?.present(alert, animated: true) + } + } + + static func dismissPresentedAlert() { + DispatchQueue.main.async { + guard let rootViewController = UIApplication.shared.activeRootViewController, + let topController = UIApplication.shared.topViewController(from: rootViewController), + topController is UIAlertController else { + return + } + topController.dismiss(animated: true) + } + } +} + +public func showAlert( + title: String, + message: String, + showOk: Bool, + showTryAgain: Bool = false, + primaryButtonText: String? = nil, + completion: ((Bool) -> Void)? = nil +) { + AlertPresenter.show( + title: title, + message: message, + showOk: showOk, + showTryAgain: showTryAgain, + primaryButtonText: primaryButtonText, + completion: completion + ) +} + +private extension UIApplication { + var activeRootViewController: UIViewController? { + connectedScenes + .compactMap { $0 as? UIWindowScene } + .flatMap(\.windows) + .first(where: \.isKeyWindow)? + .rootViewController + } + + func topViewController(from rootViewController: UIViewController) -> UIViewController? { + var topController: UIViewController? = rootViewController + while let presented = topController?.presentedViewController { + topController = presented + } + return topController + } +} diff --git a/StikDebug/Support/AppStorageCodable.swift b/StikDebug/Support/AppStorageCodable.swift new file mode 100644 index 0000000..04abc95 --- /dev/null +++ b/StikDebug/Support/AppStorageCodable.swift @@ -0,0 +1,42 @@ +// +// AppStorageCodable.swift +// StikDebug +// + +import Foundation + +extension Array: @retroactive RawRepresentable where Element: Codable { + public init?(rawValue: String) { + guard let data = rawValue.data(using: .utf8), + let result = try? JSONDecoder().decode([Element].self, from: data) else { + return nil + } + self = result + } + + public var rawValue: String { + guard let data = try? JSONEncoder().encode(self), + let result = String(data: data, encoding: .utf8) else { + return "[]" + } + return result + } +} + +extension Dictionary: @retroactive RawRepresentable where Key: Codable, Value: Codable { + public init?(rawValue: String) { + guard let data = rawValue.data(using: .utf8), + let result = try? JSONDecoder().decode([Key: Value].self, from: data) else { + return nil + } + self = result + } + + public var rawValue: String { + guard let data = try? JSONEncoder().encode(self), + let result = String(data: data, encoding: .utf8) else { + return "{}" + } + return result + } +} diff --git a/StikDebug/Utilities/AutoScriptAssignments.swift b/StikDebug/Support/AutoScriptAssignments.swift similarity index 100% rename from StikDebug/Utilities/AutoScriptAssignments.swift rename to StikDebug/Support/AutoScriptAssignments.swift diff --git a/StikDebug/Support/FileManager+Lookup.swift b/StikDebug/Support/FileManager+Lookup.swift new file mode 100644 index 0000000..5d28022 --- /dev/null +++ b/StikDebug/Support/FileManager+Lookup.swift @@ -0,0 +1,16 @@ +// +// FileManager+Lookup.swift +// StikDebug +// + +import Foundation + +extension FileManager { + func filePath(atPath path: String, withLength length: Int) -> String? { + guard let file = try? contentsOfDirectory(atPath: path).first(where: { $0.count == length }) else { + return nil + } + + return "\(path)/\(file)" + } +} diff --git a/StikDebug/Support/Haptics.swift b/StikDebug/Support/Haptics.swift new file mode 100644 index 0000000..c3cb144 --- /dev/null +++ b/StikDebug/Support/Haptics.swift @@ -0,0 +1,20 @@ +// +// Haptics.swift +// StikDebug +// + +import UIKit + +enum Haptics { + static func light() { + UIImpactFeedbackGenerator(style: .light).impactOccurred() + } + + static func medium() { + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + } + + static func selection() { + UISelectionFeedbackGenerator().selectionChanged() + } +} diff --git a/StikDebug/Support/NotificationName+StikDebug.swift b/StikDebug/Support/NotificationName+StikDebug.swift new file mode 100644 index 0000000..7cb27e8 --- /dev/null +++ b/StikDebug/Support/NotificationName+StikDebug.swift @@ -0,0 +1,11 @@ +// +// NotificationName+StikDebug.swift +// StikDebug +// + +import Foundation + +extension Notification.Name { + static let pairingFileImported = Notification.Name("PairingFileImported") + static let intentJSScriptReady = Notification.Name("intentJSScriptReady") +} diff --git a/StikDebug/Support/PairingFileStore.swift b/StikDebug/Support/PairingFileStore.swift new file mode 100644 index 0000000..5564ade --- /dev/null +++ b/StikDebug/Support/PairingFileStore.swift @@ -0,0 +1,109 @@ +// +// PairingFileStore.swift +// StikDebug +// + +import Foundation +import UniformTypeIdentifiers + +enum PairingFileStore { + static let fileName = "rp_pairing_file.plist" + static let supportedContentTypes: [UTType] = [ + UTType(filenameExtension: "mobiledevicepairing", conformingTo: .data)!, + UTType(filenameExtension: "mobiledevicepair", conformingTo: .data)!, + .propertyList + ] + + private static let legacyFileName = "pairingFile.plist" + + static var url: URL { + directoryURL.appendingPathComponent(fileName) + } + + @discardableResult + static func prepareURL(fileManager: FileManager = .default) -> URL { + let destination = url + try? fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true) + + guard !fileManager.fileExists(atPath: destination.path) else { + removeLegacyCopies(fileManager: fileManager) + return destination + } + + migrateLegacyCopy(to: destination, fileManager: fileManager) + return destination + } + + static func replace(with sourceURL: URL, fileManager: FileManager = .default) throws { + let destination = prepareURL(fileManager: fileManager) + if fileManager.fileExists(atPath: destination.path) { + try fileManager.removeItem(at: destination) + } + + removeLegacyCopies(fileManager: fileManager) + try fileManager.copyItem(at: sourceURL, to: destination) + protectPairingFile(at: destination, fileManager: fileManager) + } + + static func importFromPicker(_ sourceURL: URL, fileManager: FileManager = .default) throws { + let accessing = sourceURL.startAccessingSecurityScopedResource() + defer { + if accessing { + sourceURL.stopAccessingSecurityScopedResource() + } + } + + guard fileManager.fileExists(atPath: sourceURL.path) else { + throw CocoaError(.fileNoSuchFile) + } + + try replace(with: sourceURL, fileManager: fileManager) + } + + static func remove(fileManager: FileManager = .default) throws { + let destination = prepareURL(fileManager: fileManager) + if fileManager.fileExists(atPath: destination.path) { + try fileManager.removeItem(at: destination) + } + removeLegacyCopies(fileManager: fileManager) + } + + private static var directoryURL: URL { + FileManager.default + .urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("Pairing", isDirectory: true) + } + + private static var legacyURLs: [URL] { + [ + URL.documentsDirectory.appendingPathComponent(fileName), + URL.documentsDirectory.appendingPathComponent(legacyFileName) + ] + } + + private static func migrateLegacyCopy(to destination: URL, fileManager: FileManager) { + for legacyURL in legacyURLs where fileManager.fileExists(atPath: legacyURL.path) { + do { + try fileManager.moveItem(at: legacyURL, to: destination) + } catch { + if let data = try? Data(contentsOf: legacyURL) { + try? data.write(to: destination, options: .atomic) + try? fileManager.removeItem(at: legacyURL) + } + } + + protectPairingFile(at: destination, fileManager: fileManager) + break + } + } + + private static func removeLegacyCopies(fileManager: FileManager) { + for legacyURL in legacyURLs where fileManager.fileExists(atPath: legacyURL.path) { + try? fileManager.removeItem(at: legacyURL) + } + } + + private static func protectPairingFile(at url: URL, fileManager: FileManager) { + try? fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path) + } +} diff --git a/StikDebug/Support/ProcessInfo+TXM.swift b/StikDebug/Support/ProcessInfo+TXM.swift new file mode 100644 index 0000000..dd099a4 --- /dev/null +++ b/StikDebug/Support/ProcessInfo+TXM.swift @@ -0,0 +1,44 @@ +// +// ProcessInfo+TXM.swift +// StikDebug +// + +import Foundation + +public extension ProcessInfo { + var hasTXM: Bool { + if isTXMOverridden { + return true + } + + return ProcessInfo.hasTXMSupport( + operatingSystemVersion: operatingSystemVersion, + localTXMDetector: ProcessInfo.detectLocalTXM + ) + } + + var isTXMOverridden: Bool { + UserDefaults.standard.bool(forKey: UserDefaults.Keys.txmOverride) + } + + static func hasTXMSupport( + operatingSystemVersion: OperatingSystemVersion, + localTXMDetector: () -> Bool + ) -> Bool { + guard operatingSystemVersion.majorVersion >= 26 else { + return false + } + return localTXMDetector() + } + + 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) { + return access("\(file)/usr/standalone/firmware/FUD/Ap,TrustedExecutionMonitor.img4", F_OK) == 0 + } + + return FileManager.default.filePath(atPath: "/private/preboot", withLength: 96).map { + access("\($0)/usr/standalone/firmware/FUD/Ap,TrustedExecutionMonitor.img4", F_OK) == 0 + } ?? false + } +} diff --git a/StikDebug/Utilities/Extensions.swift b/StikDebug/Support/ScriptStore.swift similarity index 53% rename from StikDebug/Utilities/Extensions.swift rename to StikDebug/Support/ScriptStore.swift index 762d5b5..bee09e8 100644 --- a/StikDebug/Utilities/Extensions.swift +++ b/StikDebug/Support/ScriptStore.swift @@ -1,112 +1,9 @@ // -// Extensions.swift +// ScriptStore.swift // StikDebug // -// Created by s s on 2025/7/9. -// + import Foundation -import UniformTypeIdentifiers -import UIKit - -enum AccessibilityAnnouncer { - static func announce(_ message: String) { - DispatchQueue.main.async { - UIAccessibility.post(notification: .announcement, argument: message) - } - } -} - -enum PairingFileStore { - static let fileName = "rp_pairing_file.plist" - private static let legacyFileName = "pairingFile.plist" - static let supportedContentTypes: [UTType] = [ - UTType(filenameExtension: "mobiledevicepairing", conformingTo: .data)!, - UTType(filenameExtension: "mobiledevicepair", conformingTo: .data)!, - .propertyList - ] - - static var url: URL { - directoryURL.appendingPathComponent(fileName) - } - - private static var directoryURL: URL { - FileManager.default - .urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] - .appendingPathComponent("Pairing", isDirectory: true) - } - - private static var legacyURLs: [URL] { - [ - URL.documentsDirectory.appendingPathComponent(fileName), - URL.documentsDirectory.appendingPathComponent(legacyFileName) - ] - } - - @discardableResult - static func prepareURL(fileManager: FileManager = .default) -> URL { - let destination = url - try? fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true) - - guard !fileManager.fileExists(atPath: destination.path) else { - removeLegacyCopies(fileManager: fileManager) - return destination - } - - for legacyURL in legacyURLs where fileManager.fileExists(atPath: legacyURL.path) { - do { - try fileManager.moveItem(at: legacyURL, to: destination) - } catch { - if let data = try? Data(contentsOf: legacyURL) { - try? data.write(to: destination, options: .atomic) - try? fileManager.removeItem(at: legacyURL) - } - } - try? fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: destination.path) - break - } - - return destination - } - - static func replace(with sourceURL: URL, fileManager: FileManager = .default) throws { - let destination = prepareURL(fileManager: fileManager) - if fileManager.fileExists(atPath: destination.path) { - try fileManager.removeItem(at: destination) - } - removeLegacyCopies(fileManager: fileManager) - try fileManager.copyItem(at: sourceURL, to: destination) - try? fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: destination.path) - } - - static func importFromPicker(_ sourceURL: URL, fileManager: FileManager = .default) throws { - let accessing = sourceURL.startAccessingSecurityScopedResource() - defer { - if accessing { - sourceURL.stopAccessingSecurityScopedResource() - } - } - - guard fileManager.fileExists(atPath: sourceURL.path) else { - throw CocoaError(.fileNoSuchFile) - } - - try replace(with: sourceURL, fileManager: fileManager) - } - - static func remove(fileManager: FileManager = .default) throws { - let destination = prepareURL(fileManager: fileManager) - if fileManager.fileExists(atPath: destination.path) { - try fileManager.removeItem(at: destination) - } - removeLegacyCopies(fileManager: fileManager) - } - - private static func removeLegacyCopies(fileManager: FileManager) { - for legacyURL in legacyURLs where fileManager.fileExists(atPath: legacyURL.path) { - try? fileManager.removeItem(at: legacyURL) - } - } -} struct ScriptResource { let resourceName: String @@ -137,12 +34,15 @@ enum ScriptStore { let directory = directoryURL var isDirectory: ObjCBool = false let exists = fileManager.fileExists(atPath: directory.path, isDirectory: &isDirectory) + if exists && !isDirectory.boolValue { try fileManager.removeItem(at: directory) } + if !exists || !isDirectory.boolValue { try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) } + try ensureBundledScripts(in: directory, fileManager: fileManager) return directory } @@ -151,6 +51,7 @@ enum ScriptStore { guard let scriptName = normalizedScriptFileName(scriptName) else { throw CocoaError(.fileReadInvalidFileName) } + let directory = try prepareDirectory(fileManager: fileManager) return directory.appendingPathComponent(scriptName) } @@ -173,16 +74,40 @@ enum ScriptStore { assignedScript(for: bundleID, fileManager: fileManager) ?? autoScript(for: bundleID, fileManager: fileManager) } - static func favoriteAppName(for bundleID: String, defaults: UserDefaults? = UserDefaults(suiteName: favoriteAppNamesSuiteName)) -> String? { + static func favoriteAppName( + for bundleID: String, + defaults: UserDefaults? = UserDefaults(suiteName: favoriteAppNamesSuiteName) + ) -> String? { let names = defaults?.dictionary(forKey: favoriteAppNamesKey) as? [String: String] return names?[bundleID] } + static func normalizedScriptFileName(_ candidate: String) -> String? { + let trimmed = candidate.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, + !trimmed.contains("/"), + !trimmed.contains("\\"), + !trimmed.contains("\0") else { + return nil + } + + let fileName = URL(fileURLWithPath: trimmed).lastPathComponent + guard fileName == trimmed, + fileName != ".", + fileName != "..", + URL(fileURLWithPath: fileName).pathExtension.lowercased() == "js" else { + return nil + } + + return fileName + } + private static func ensureBundledScripts(in directory: URL, fileManager: FileManager) throws { for resource in bundledResources { guard let bundleURL = Bundle.main.url(forResource: resource.resourceName, withExtension: "js") else { continue } + let destination = directory.appendingPathComponent(resource.fileName) if !fileManager.fileExists(atPath: destination.path) { try fileManager.copyItem(at: bundleURL, to: destination) @@ -191,13 +116,12 @@ enum ScriptStore { } private static func assignedScript(for bundleID: String, fileManager: FileManager) -> (data: Data, name: String)? { - guard let scriptName = assignedScriptName(for: bundleID) else { - return nil - } - guard let scriptURL = try? scriptURL(named: scriptName, fileManager: fileManager), + guard let scriptName = assignedScriptName(for: bundleID), + let scriptURL = try? scriptURL(named: scriptName, fileManager: fileManager), let data = try? Data(contentsOf: scriptURL) else { return nil } + return (data, scriptName) } @@ -224,6 +148,7 @@ enum ScriptStore { let data = try? Data(contentsOf: bundleURL) else { return nil } + return (data, resource.fileName) } @@ -231,52 +156,4 @@ enum ScriptStore { let rawMap = defaults.dictionary(forKey: assignmentKey) as? [String: String] ?? [:] return rawMap.compactMapValues(normalizedScriptFileName) } - - static func normalizedScriptFileName(_ candidate: String) -> String? { - let trimmed = candidate.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty, - !trimmed.contains("/"), - !trimmed.contains("\\"), - !trimmed.contains("\0") else { - return nil - } - - let fileName = URL(fileURLWithPath: trimmed).lastPathComponent - guard fileName == trimmed, - fileName != ".", - fileName != "..", - URL(fileURLWithPath: fileName).pathExtension.lowercased() == "js" else { - return nil - } - - return fileName - } -} - -extension FileManager { - func filePath(atPath path: String, withLength length: Int) -> String? { - guard let file = try? contentsOfDirectory(atPath: path).first(where: { $0.count == length }) else { return nil } - return "\(path)/\(file)" - } -} - -extension UIDocumentPickerViewController { - @objc func fix_init(forOpeningContentTypes contentTypes: [UTType], asCopy: Bool) -> UIDocumentPickerViewController { - return fix_init(forOpeningContentTypes: contentTypes, asCopy: true) - } -} - -extension Notification.Name { - static let pairingFileImported = Notification.Name("PairingFileImported") - static let intentJSScriptReady = Notification.Name("intentJSScriptReady") -} - -extension UserDefaults { - enum Keys { - /// Forces the app to treat the current device as TXM-capable so scripts always run. - static let txmOverride = "overrideTXMForScripts" - static let bundleScriptMap = "BundleScriptMap" - static let defaultScriptName = "DefaultScriptName" - static let defaultScriptNameValue = "attachDetach.js" - } } diff --git a/StikDebug/Support/UIDocumentPickerViewController+CopyWorkaround.swift b/StikDebug/Support/UIDocumentPickerViewController+CopyWorkaround.swift new file mode 100644 index 0000000..0c1e9b1 --- /dev/null +++ b/StikDebug/Support/UIDocumentPickerViewController+CopyWorkaround.swift @@ -0,0 +1,16 @@ +// +// UIDocumentPickerViewController+CopyWorkaround.swift +// StikDebug +// + +import UIKit +import UniformTypeIdentifiers + +extension UIDocumentPickerViewController { + @objc func fix_init( + forOpeningContentTypes contentTypes: [UTType], + asCopy: Bool + ) -> UIDocumentPickerViewController { + fix_init(forOpeningContentTypes: contentTypes, asCopy: true) + } +} diff --git a/StikDebug/Support/UserDefaults+Keys.swift b/StikDebug/Support/UserDefaults+Keys.swift new file mode 100644 index 0000000..f6ce75c --- /dev/null +++ b/StikDebug/Support/UserDefaults+Keys.swift @@ -0,0 +1,16 @@ +// +// UserDefaults+Keys.swift +// StikDebug +// + +import Foundation + +extension UserDefaults { + enum Keys { + /// Forces the app to treat the current device as TXM-capable so scripts always run. + static let txmOverride = "overrideTXMForScripts" + static let bundleScriptMap = "BundleScriptMap" + static let defaultScriptName = "DefaultScriptName" + static let defaultScriptNameValue = "attachDetach.js" + } +} diff --git a/StikDebug/Utilities/HapticFeedbackHelper.swift b/StikDebug/Utilities/HapticFeedbackHelper.swift deleted file mode 100644 index 879430b..0000000 --- a/StikDebug/Utilities/HapticFeedbackHelper.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// HapticFeedbackHelper.swift -// StikDebug -// -// Created by Stephen on 3/27/25. -// - -import SwiftUI - -struct HapticFeedbackHelper { - static func trigger() { - let generator = UIImpactFeedbackGenerator(style: .medium) - generator.impactOccurred() - } -} diff --git a/StikDebug/Utilities/LogManagerBridge.swift b/StikDebug/Utilities/LogManagerBridge.swift deleted file mode 100644 index f938d80..0000000 --- a/StikDebug/Utilities/LogManagerBridge.swift +++ /dev/null @@ -1,32 +0,0 @@ -// -// LogManagerBridge.swift -// StikDebug -// -// Created by neoarz on 3/29/25. -// - -import Foundation -//objc bridge for logs -@objc class LogManagerBridge: NSObject { - @objc static let shared = LogManagerBridge() - - private override init() { - super.init() - } - - @objc func addInfoLog(_ message: String) { - LogManager.shared.addInfoLog(message) - } - - @objc func addErrorLog(_ message: String) { - LogManager.shared.addErrorLog(message) - } - - @objc func addDebugLog(_ message: String) { - LogManager.shared.addDebugLog(message) - } - - @objc func addWarningLog(_ message: String) { - LogManager.shared.addWarningLog(message) - } -} diff --git a/StikDebug/Utilities/Security.swift b/StikDebug/Utilities/Security.swift deleted file mode 100644 index 835e0d0..0000000 --- a/StikDebug/Utilities/Security.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// Security.swift -// StikDebug -// from MeloNX -// Created by s s on 2025/4/6. -// -import Foundation -import Security - -typealias SecTaskRef = OpaquePointer - -@_silgen_name("SecTaskCopyValueForEntitlement") -func SecTaskCopyValueForEntitlement( - _ task: SecTaskRef, - _ entitlement: CFString, - _ error: UnsafeMutablePointer?>? -) -> CFTypeRef? - -@_silgen_name("SecTaskCreateFromSelf") -func SecTaskCreateFromSelf( - _ allocator: CFAllocator? -) -> SecTaskRef? - -func checkAppEntitlement(_ ent: String) -> Bool { - guard let task = SecTaskCreateFromSelf(nil) else { return false } - guard let value = SecTaskCopyValueForEntitlement(task, ent as CFString, nil) else { return false } - return value.boolValue != nil && value.boolValue -} diff --git a/StikDebug/Views/ConsoleLogsView.swift b/StikDebug/Views/ConsoleLogsView.swift index 37c7247..10db4c4 100644 --- a/StikDebug/Views/ConsoleLogsView.swift +++ b/StikDebug/Views/ConsoleLogsView.swift @@ -36,7 +36,7 @@ struct ConsoleLogsView: View { return systemLogStream.entries } let query = syslogSearchText.lowercased() - return systemLogStream.entries.filter { $0.raw.lowercased().contains(query) } + return systemLogStream.entries.filter { $0.searchableRaw.contains(query) } } var body: some View { @@ -165,7 +165,7 @@ struct ConsoleLogsView: View { } .coordinateSpace(name: "jitScroll") .onPreferenceChange(ScrollOffsetPreferenceKey.self) { offset in - jitIsAtBottom = offset > -20 + updateJITBottomState(offset > -20) } .onChange(of: logManager.logs.count) { _, _ in guard jitIsAtBottom, let lastLog = logManager.logs.last else { return } @@ -229,7 +229,7 @@ struct ConsoleLogsView: View { } .coordinateSpace(name: "syslogScroll") .onPreferenceChange(ScrollOffsetPreferenceKey.self) { offset in - syslogIsAtBottom = offset > -20 + updateSyslogBottomState(offset > -20) } .onChange(of: systemLogStream.entries.count) { _, _ in guard syslogIsAtBottom, syslogSearchText.isEmpty, @@ -460,6 +460,16 @@ struct ConsoleLogsView: View { showingCustomAlert = true } + private func updateJITBottomState(_ isAtBottom: Bool) { + guard jitIsAtBottom != isAtBottom else { return } + jitIsAtBottom = isAtBottom + } + + private func updateSyslogBottomState(_ isAtBottom: Bool) { + guard syslogIsAtBottom != isAtBottom else { return } + syslogIsAtBottom = isAtBottom + } + nonisolated private static func parseAppLogEntries(from lines: S, skipPrefixes: [String] = []) -> [LogManager.LogEntry] where S.Element == String { var parsed: [LogManager.LogEntry] = [] parsed.reserveCapacity(lines.underestimatedCount) diff --git a/StikDebug/Views/HomeView.swift b/StikDebug/Views/HomeView.swift index 8eea7d6..ffe5c54 100644 --- a/StikDebug/Views/HomeView.swift +++ b/StikDebug/Views/HomeView.swift @@ -1,167 +1,28 @@ // -// ContentView.swift +// HomeView.swift // StikDebug // // Created by Stephen on 3/26/25. // import SwiftUI -import UIKit - -struct JITEnableConfiguration { - var bundleID: String? = nil - var pid : Int? = nil - var scriptData: Data? = nil - var scriptName : String? = nil -} - -private enum ExternalURLAction: Identifiable { - case enableJIT(JITEnableConfiguration) - case killProcess(Int) - case launchApp(String) - - var id: String { - switch self { - case .enableJIT(let config): - return "enable-\(config.bundleID ?? "")-\(config.pid ?? 0)-\(config.scriptName ?? "")" - case .killProcess(let pid): - return "kill-\(pid)" - case .launchApp(let bundleID): - return "launch-\(bundleID)" - } - } - - var title: String { - switch self { - case .enableJIT: - return "Enable JIT?" - case .killProcess: - return "Kill Process?" - case .launchApp: - return "Launch App?" - } - } - - var message: String { - switch self { - case .enableJIT(let config): - let scriptText = config.scriptData == nil ? "" : " and run a script" - return "An external link wants to enable JIT\(scriptText) for \(targetDescription(for: config))." - case .killProcess(let pid): - return "An external link wants to kill process \(pid)." - case .launchApp(let bundleID): - return "An external link wants to launch \(bundleID)." - } - } - - var confirmationTitle: String { - switch self { - case .enableJIT(let config): - return config.scriptData == nil ? "Enable JIT" : "Enable and Run Script" - case .killProcess: - return "Kill Process" - case .launchApp: - return "Launch App" - } - } - - var role: ButtonRole? { - switch self { - case .enableJIT(let config): - return config.scriptData == nil ? nil : .destructive - case .killProcess: - return .destructive - case .launchApp: - return nil - } - } - - private func targetDescription(for config: JITEnableConfiguration) -> String { - if let bundleID = config.bundleID { - return bundleID - } - if let pid = config.pid { - return "process \(pid)" - } - return "the requested app" - } -} - -private final class DebugKeepAliveLease { - private let stateLock = NSLock() - private var isActive = false - private var backgroundTaskID: UIBackgroundTaskIdentifier = .invalid - - init() { - activate() - } - - func invalidate() { - stateLock.lock() - guard isActive else { - stateLock.unlock() - return - } - isActive = false - stateLock.unlock() - - runOnMain { - BackgroundAudioManager.shared.requestStop() - BackgroundLocationManager.shared.requestStop() - - if self.backgroundTaskID != .invalid { - UIApplication.shared.endBackgroundTask(self.backgroundTaskID) - self.backgroundTaskID = .invalid - } - } - } - - private func activate() { - stateLock.lock() - guard !isActive else { - stateLock.unlock() - return - } - isActive = true - stateLock.unlock() - - runOnMain { - BackgroundAudioManager.shared.requestStart() - BackgroundLocationManager.shared.requestStart() - self.backgroundTaskID = UIApplication.shared.beginBackgroundTask(withName: "StikDebugDebugSession") { [weak self] in - LogManager.shared.addWarningLog("Debug session background task expired") - self?.invalidate() - } - } - } - - private func runOnMain(_ work: @escaping () -> Void) { - if Thread.isMainThread { - work() - } else { - DispatchQueue.main.sync(execute: work) - } - } -} struct HomeView: View { - @AppStorage("autoQuitAfterEnablingJIT") private var doAutoQuitAfterEnablingJIT = false @AppStorage("bundleID") private var bundleID: String = "" - @State private var isProcessing = false - @State private var viewDidAppeared = false - @State private var pendingJITEnableConfiguration : JITEnableConfiguration? = nil - @State private var isShowingPairingFilePicker = false - @State private var debugFeedback: DebugFeedback? - @State private var pendingExternalURLAction: ExternalURLAction? + @AppStorage(UserDefaults.Keys.defaultScriptName) private var selectedScript = UserDefaults.Keys.defaultScriptNameValue - @State var scriptViewShow = false - @State private var isShowingConsole = false - @AppStorage(UserDefaults.Keys.defaultScriptName) var selectedScript = UserDefaults.Keys.defaultScriptNameValue - @State var jsModel: RunJSViewModel? @ObservedObject private var mounting = MountingProgress.shared - let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() + @State private var hasAppeared = false + @State private var pendingJITEnableConfiguration: JITEnableConfiguration? + @State private var isShowingPairingFilePicker = false + @State private var debugFeedback: DebugFeedback? + @State private var pendingExternalURLAction: HomeExternalAction? + @State private var isShowingScriptRunner = false + @State private var scriptRunModel: RunJSViewModel? + + private let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() private struct DebugFeedback: Identifiable { let id = UUID() @@ -173,7 +34,7 @@ struct HomeView: View { var body: some View { InstalledAppsListView(onSelectApp: { selectedBundle, selectedName in bundleID = selectedBundle - HapticFeedbackHelper.trigger() + Haptics.medium() startJITInBackground(bundleID: selectedBundle, displayName: selectedName) }, showDoneButton: false, onImportPairingFile: { isShowingPairingFilePicker = true }) .overlay(alignment: .bottom) { @@ -183,27 +44,10 @@ struct HomeView: View { .transition(.move(edge: .bottom).combined(with: .opacity)) } } - .onAppear { - startTunnelInBackground() - 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 } - jsModel = model - if let name = notification.userInfo?["scriptName"] as? String { - selectedScript = name - } - scriptViewShow = true - } + .onAppear(perform: handleAppear) + .onReceive(NotificationCenter.default.publisher(for: .intentJSScriptReady), perform: handleScriptReadyNotification) .onReceive(timer) { _ in - if mounting.mountingThread == nil && !mounting.coolisMounted { - MountingProgress.shared.checkforMounted() - } + refreshMountStatusIfNeeded() } .onOpenURL { url in handleExternalURL(url) @@ -231,48 +75,18 @@ struct HomeView: View { } message: { action in Text(action.message) } - .fileImporter(isPresented: $isShowingPairingFilePicker, allowedContentTypes: PairingFileStore.supportedContentTypes) { result in - switch result { - case .success(let url): - let fileManager = FileManager.default - do { - try PairingFileStore.importFromPicker(url, fileManager: fileManager) - pubTunnelConnected = false - startTunnelInBackground() - NotificationCenter.default.post(name: .pairingFileImported, object: nil) - // Dismiss any existing connection error alert - if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let root = scene.windows.first?.rootViewController { - var top = root - while let presented = top.presentedViewController { top = presented } - if top is UIAlertController { top.dismiss(animated: true) } - } - } catch { - print("Error copying pairing file: \(error)") - } - case .failure(let error): - print("Failed to import pairing file: \(error)") - } - } - .sheet(isPresented: $isShowingConsole) { + .fileImporter( + isPresented: $isShowingPairingFilePicker, + allowedContentTypes: PairingFileStore.supportedContentTypes, + onCompletion: importPairingFile + ) + .sheet(isPresented: $isShowingScriptRunner) { NavigationStack { - ConsoleLogsView() - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button("Close") { - isShowingConsole = false - } - } - } - } - } - .sheet(isPresented: $scriptViewShow) { - NavigationStack { - if let jsModel { - RunJSView(model: jsModel) + if let scriptRunModel { + RunJSView(model: scriptRunModel) .toolbar { ToolbarItem(placement: .topBarTrailing) { - Button("Done") { scriptViewShow = false } + Button("Done") { isShowingScriptRunner = false } } } .navigationTitle(selectedScript) @@ -282,6 +96,59 @@ struct HomeView: View { } } + private func handleAppear() { + startTunnelInBackground() + MountingProgress.shared.checkforMounted() + hasAppeared = true + + if let config = pendingJITEnableConfiguration { + startJITInBackground( + bundleID: config.bundleID, + pid: config.pid, + scriptData: config.scriptData, + scriptName: config.scriptName, + triggeredByURLScheme: true + ) + pendingJITEnableConfiguration = nil + } + } + + private func handleScriptReadyNotification(_ notification: Notification) { + guard let model = notification.userInfo?["model"] as? RunJSViewModel else { + return + } + + scriptRunModel = model + if let name = notification.userInfo?["scriptName"] as? String { + selectedScript = name + } + isShowingScriptRunner = true + } + + private func refreshMountStatusIfNeeded() { + guard mounting.mountingThread == nil, !mounting.coolisMounted else { + return + } + MountingProgress.shared.checkforMounted() + } + + private func importPairingFile(_ result: Result) { + switch result { + case .success(let url): + do { + try PairingFileStore.importFromPicker(url) + markTunnelDisconnected() + startTunnelInBackground() + NotificationCenter.default.post(name: .pairingFileImported, object: nil) + AlertPresenter.dismissPresentedAlert() + } catch { + LogManager.shared.addErrorLog("Failed to import pairing file: \(error.localizedDescription)") + } + case .failure(let error): + LogManager.shared.addErrorLog("Pairing file picker failed: \(error.localizedDescription)") + } + } + private func queryValue(_ names: [String], in components: URLComponents?) -> String? { guard let queryItems = components?.queryItems else { return nil } for name in names { @@ -336,10 +203,10 @@ struct HomeView: View { } } - private func performExternalURLAction(_ action: ExternalURLAction) { + private func performExternalURLAction(_ action: HomeExternalAction) { switch action { case .enableJIT(let config): - if viewDidAppeared { + if hasAppeared { startJITInBackground( bundleID: config.bundleID, pid: config.pid, @@ -351,7 +218,7 @@ struct HomeView: View { pendingJITEnableConfiguration = config } case .killProcess(let pid): - pubTunnelConnected = false + markTunnelDisconnected() startTunnelInBackground(showErrorUI: false) DispatchQueue.global(qos: .userInitiated).async { sleep(1) @@ -367,7 +234,7 @@ struct HomeView: View { } } case .launchApp(let bundleID): - HapticFeedbackHelper.trigger() + Haptics.medium() DispatchQueue.global(qos: .userInitiated).async { let _ = JITEnableContext.shared.launchAppWithoutDebug(bundleID, logger: nil) } @@ -403,8 +270,8 @@ struct HomeView: View { semaphore: semaphore) DispatchQueue.main.async { - jsModel = model - scriptViewShow = true + scriptRunModel = model + isShowingScriptRunner = true } do { @@ -419,7 +286,6 @@ struct HomeView: View { } private func startJITInBackground(bundleID: String? = nil, pid: Int? = nil, scriptData: Data? = nil, scriptName: String? = nil, triggeredByURLScheme: Bool = false, displayName: String? = nil) { - isProcessing = true let targetName = displayName ?? bundleID ?? pid.map { String(format: "process %d".localized, $0) } ?? "app".localized let startingMessage = String(format: "Starting JIT for %@".localized, targetName) LogManager.shared.addInfoLog("Starting Debug for \(bundleID ?? String(pid ?? 0))") @@ -429,7 +295,7 @@ struct HomeView: View { AccessibilityAnnouncer.announce(startingMessage) if triggeredByURLScheme { - pubTunnelConnected = false + markTunnelDisconnected() startTunnelInBackground(showErrorUI: false) } @@ -443,7 +309,6 @@ struct HomeView: View { let finishProcessing: (Bool, String?) -> Void = { success, detail in DispatchQueue.main.async { - isProcessing = false let message = success ? String(format: "JIT request completed for %@".localized, targetName) : String(format: "JIT failed for %@".localized, targetName) @@ -523,40 +388,3 @@ struct HomeView: View { #Preview { HomeView() } - -public extension ProcessInfo { - var hasTXM: Bool { - if isTXMOverridden { - return true - } - return ProcessInfo.hasTXMSupport( - operatingSystemVersion: operatingSystemVersion, - localTXMDetector: ProcessInfo.detectLocalTXM - ) - } - - var isTXMOverridden: Bool { - UserDefaults.standard.bool(forKey: UserDefaults.Keys.txmOverride) - } - - static func hasTXMSupport( - operatingSystemVersion: OperatingSystemVersion, - localTXMDetector: () -> Bool - ) -> Bool { - guard operatingSystemVersion.majorVersion >= 26 else { - return false - } - return localTXMDetector() - } - - 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) { - return access("\(file)/usr/standalone/firmware/FUD/Ap,TrustedExecutionMonitor.img4", F_OK) == 0 - } else { - return (FileManager.default.filePath(atPath: "/private/preboot", withLength: 96).map { - access("\($0)/usr/standalone/firmware/FUD/Ap,TrustedExecutionMonitor.img4", F_OK) == 0 - }) ?? false - } - } -} diff --git a/StikDebug/Views/InstalledAppsListView.swift b/StikDebug/Views/InstalledAppsListView.swift index 7986cd8..69437a6 100644 --- a/StikDebug/Views/InstalledAppsListView.swift +++ b/StikDebug/Views/InstalledAppsListView.swift @@ -8,252 +8,364 @@ import SwiftUI import UIKit import WidgetKit -import Combine - -// MARK: - Installed Apps List - -struct InstalledAppListItem: Identifiable, Equatable { - let bundleID: String - let name: String - private let normalizedBundleID: String - private let normalizedName: String - - var id: String { - bundleID - } - - init(bundleID: String, name: String) { - self.bundleID = bundleID - self.name = name - self.normalizedBundleID = Self.normalized(bundleID) - self.normalizedName = Self.normalized(name) - } - - func matches(_ query: String) -> Bool { - query.isEmpty || normalizedBundleID.contains(query) || normalizedName.contains(query) - } - - static func sorted(from apps: [String: String]) -> [InstalledAppListItem] { - apps.map { InstalledAppListItem(bundleID: $0.key, name: $0.value) } - .sorted { lhs, rhs in - let comparison = lhs.name.localizedCaseInsensitiveCompare(rhs.name) - if comparison == .orderedSame { - return lhs.bundleID < rhs.bundleID - } - return comparison == .orderedAscending - } - } - - static func normalized(_ text: String) -> String { - text.trimmingCharacters(in: .whitespacesAndNewlines) - .folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current) - .lowercased() - } -} struct InstalledAppsListView: View { - @StateObject private var viewModel = InstalledAppsViewModel() + @Environment(\.dismiss) private var dismiss + + let onSelectApp: (String, String) -> Void + let showDoneButton: Bool + let onImportPairingFile: (() -> Void)? private let sharedDefaults = UserDefaults(suiteName: ScriptStore.favoriteAppNamesSuiteName) ?? .standard + @StateObject private var viewModel = InstalledAppsViewModel() + @AppStorage("recentApps") private var recentApps: [String] = [] @AppStorage("favoriteApps") private var favoriteApps: [String] = [] { didSet { - if favoriteApps.count > 4 { - favoriteApps = Array(favoriteApps.prefix(4)) - } + favoriteApps = Array(favoriteApps.prefix(Self.maxFavorites)) persistIfChanged() } } - @AppStorage("loadAppIconsOnJIT") private var loadAppIconsOnJIT = true - private let performanceMode = true - @State private var launchingBundles: Set = [] - @State private var launchFeedback: LaunchFeedback? = nil - @State private var debuggableSearchText: String = "" - @State private var launchSearchText: String = "" - @State private var prefetchedBundleIDs: Set = [] - @State private var selectedTab: AppListTab = .debuggable @AppStorage("pinnedSystemApps") private var pinnedSystemApps: [String] = [] @AppStorage("pinnedSystemAppNames") private var pinnedSystemAppNames: [String: String] = [:] - @Environment(\.dismiss) private var dismiss - var onSelectApp: (String, String) -> Void - var showDoneButton: Bool = true - var onImportPairingFile: (() -> Void)? = nil + @State private var launchingBundles: Set = [] + @State private var launchFeedback: LaunchFeedback? + @State private var debuggableSearchText = "" + @State private var launchSearchText = "" + @State private var prefetchedBundleIDs: Set = [] + @State private var selectedTab: AppListTab = .debuggable + private static let maxFavorites = 4 + private static let maxSystemPins = 8 + private static let iconPrefetchLimit = 32 - private var currentSearchBinding: Binding { - Binding( - get: { selectedTab == .debuggable ? debuggableSearchText : launchSearchText }, - set: { - if selectedTab == .debuggable { debuggableSearchText = $0 } - else { launchSearchText = $0 } - } - ) - } - - private var debuggableSearchIsActive: Bool { - !InstalledAppListItem.normalized(debuggableSearchText).isEmpty - } - - private var filteredDebuggableApps: [InstalledAppListItem] { - let query = InstalledAppListItem.normalized(debuggableSearchText) - guard !query.isEmpty else { return viewModel.debuggableItems } - return viewModel.debuggableItems.filter { $0.matches(query) } - } - - private var filteredDebuggableSet: Set { - Set(filteredDebuggableApps.map(\.bundleID)) - } - - private var filteredFavoriteBundles: [String] { - favoriteApps.filter { filteredDebuggableSet.contains($0) } - } - - private var filteredRecentBundles: [String] { - recentApps.filter { filteredDebuggableSet.contains($0) && !favoriteApps.contains($0) } - } - - private var launchSearchIsActive: Bool { - !InstalledAppListItem.normalized(launchSearchText).isEmpty - } - - private var filteredLaunchApps: [InstalledAppListItem] { - let query = InstalledAppListItem.normalized(launchSearchText) - guard !query.isEmpty else { return viewModel.launchItems } - return viewModel.launchItems.filter { $0.matches(query) } - } - -private enum AppListTab: Int, CaseIterable, Identifiable { - case debuggable - case launch - - var id: Int { rawValue } - - var title: String { - switch self { - case .debuggable: return "JIT" - case .launch: return "Other" - } - } -} - - private struct LaunchFeedback: Identifiable { - let id = UUID() - let message: String - let success: Bool - } - - private func isEmpty(for tab: AppListTab) -> Bool { - switch tab { - case .debuggable: - return viewModel.debuggableItems.isEmpty - case .launch: - return filteredLaunchApps.isEmpty - } + init( + onSelectApp: @escaping (String, String) -> Void, + showDoneButton: Bool = true, + onImportPairingFile: (() -> Void)? = nil + ) { + self.onSelectApp = onSelectApp + self.showDoneButton = showDoneButton + self.onImportPairingFile = onImportPairingFile } var body: some View { NavigationStack { tabContent(for: selectedTab) .transition(.opacity) - .transaction { t in t.disablesAnimations = true } - .navigationTitle(selectedTab == .debuggable ? "Enable JIT".localized : "Launch Apps".localized) + .transaction { transaction in + transaction.disablesAnimations = true + } + .navigationTitle(selectedTab.navigationTitle) .searchable( text: currentSearchBinding, placement: .navigationBarDrawer(displayMode: .always), - prompt: selectedTab == .debuggable - ? "Search apps or bundle ID".localized - : "Search".localized + prompt: selectedTab.searchPrompt ) .toolbar { - ToolbarItem(placement: .principal) { - Picker("", selection: $selectedTab) { - ForEach(AppListTab.allCases) { tab in - Text(tab.title.localized).tag(tab) - } - } - .pickerStyle(.segmented) - .frame(width: 220) - } - if let onImportPairingFile { - ToolbarItem(placement: .navigationBarLeading) { - Button(action: onImportPairingFile) { - Image(systemName: "doc.badge.plus") - } - } - } - if showDoneButton { - ToolbarItem(placement: .navigationBarTrailing) { - Button("Done") { dismiss() }.fontWeight(.semibold) - } - } else { - ToolbarItem(placement: .navigationBarTrailing) { - Button { - viewModel.refreshAppLists() - } label: { - Image(systemName: "arrow.clockwise") - } - .disabled(viewModel.isLoading) - } - } - } - .onAppear { + tabPickerToolbarItem + leadingToolbarItem + trailingToolbarItem } } .overlay { - if let feedback = launchFeedback { - VStack { - Spacer() - Text(feedback.message) - .font(.subheadline.weight(.semibold)) - .padding(.horizontal, 16).padding(.vertical, 8) - .background(Capsule().fill(.ultraThinMaterial)) - .foregroundStyle(feedback.success ? .green : .red) - .shadow(radius: 4) - .transition(.move(edge: .bottom).combined(with: .opacity)) - .padding(.bottom, 40) - } - .animation(.spring(response: 0.3, dampingFraction: 0.8), value: launchFeedback?.id) - } - } - .onAppear { - prefetchedBundleIDs.removeAll() - prefetchPriorityIcons() + launchFeedbackOverlay } + .onAppear(perform: refreshIconPrefetch) .onChange(of: favoriteApps) { _, _ in prefetchPriorityIcons() } .onChange(of: recentApps) { _, _ in prefetchPriorityIcons() } - .onChange(of: viewModel.isLoading) { _, newValue in - if newValue { - prefetchedBundleIDs.removeAll() - } else { - prefetchPriorityIcons() - // Ensure names for existing favorites are persisted once app list is ready - persistIfChanged() - } - } .onChange(of: selectedTab) { _, _ in prefetchPriorityIcons() } .onChange(of: pinnedSystemApps) { _, _ in prefetchPriorityIcons() } + .onChange(of: viewModel.isLoading) { _, isLoading in + handleLoadingChange(isLoading) + } .onReceive(NotificationCenter.default.publisher(for: .pairingFileImported)) { _ in viewModel.refreshAppLists() } } - // MARK: Apps List + private var currentSearchBinding: Binding { + Binding( + get: { selectedTab == .debuggable ? debuggableSearchText : launchSearchText }, + set: { searchText in + if selectedTab == .debuggable { + debuggableSearchText = searchText + } else { + launchSearchText = searchText + } + } + ) + } - private func prefetchPriorityIcons(limit: Int = 32) { - guard loadAppIconsOnJIT else { return } + private var debuggableSnapshot: DebuggableAppListSnapshot { + let query = InstalledAppListItem.normalized(debuggableSearchText) + let filteredApps = query.isEmpty + ? viewModel.debuggableItems + : viewModel.debuggableItems.filter { $0.matches(query) } + let filteredBundleIDs = Set(filteredApps.map(\.bundleID)) - var priorityIDs: [String] = [] - var seen = Set() + return DebuggableAppListSnapshot( + apps: filteredApps, + favoriteBundles: favoriteApps.filter { filteredBundleIDs.contains($0) }, + recentBundles: recentApps.filter { filteredBundleIDs.contains($0) && !favoriteApps.contains($0) }, + searchIsActive: !query.isEmpty + ) + } - func appendUnique(_ ids: S) where S.Element == String { - guard priorityIDs.count < limit else { return } - for id in ids { - guard seen.insert(id).inserted else { continue } - priorityIDs.append(id) - if priorityIDs.count >= limit { break } + private var launchSnapshot: LaunchAppListSnapshot { + let query = InstalledAppListItem.normalized(launchSearchText) + let filteredApps = query.isEmpty + ? viewModel.launchItems + : viewModel.launchItems.filter { $0.matches(query) } + + return LaunchAppListSnapshot( + apps: filteredApps, + searchIsActive: !query.isEmpty + ) + } + + @ToolbarContentBuilder + private var tabPickerToolbarItem: some ToolbarContent { + ToolbarItem(placement: .principal) { + Picker("", selection: $selectedTab) { + ForEach(AppListTab.allCases) { tab in + Text(tab.title.localized).tag(tab) + } + } + .pickerStyle(.segmented) + .frame(width: 220) + } + } + + @ToolbarContentBuilder + private var leadingToolbarItem: some ToolbarContent { + if let onImportPairingFile { + ToolbarItem(placement: .navigationBarLeading) { + Button(action: onImportPairingFile) { + Image(systemName: "doc.badge.plus") + } + } + } + } + + @ToolbarContentBuilder + private var trailingToolbarItem: some ToolbarContent { + ToolbarItem(placement: .navigationBarTrailing) { + if showDoneButton { + Button("Done") { + dismiss() + } + .fontWeight(.semibold) + } else { + Button { + viewModel.refreshAppLists() + } label: { + Image(systemName: "arrow.clockwise") + } + .disabled(viewModel.isLoading) + } + } + } + + @ViewBuilder + private var launchFeedbackOverlay: some View { + if let launchFeedback { + VStack { + Spacer() + Text(launchFeedback.message) + .font(.subheadline.weight(.semibold)) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(Capsule().fill(.ultraThinMaterial)) + .foregroundStyle(launchFeedback.success ? .green : .red) + .shadow(radius: 4) + .transition(.move(edge: .bottom).combined(with: .opacity)) + .padding(.bottom, 40) + } + .animation(.spring(response: 0.3, dampingFraction: 0.8), value: launchFeedback.id) + } + } + + @ViewBuilder + private func tabContent(for tab: AppListTab) -> some View { + switch tab { + case .debuggable: + debuggableAppsList + case .launch: + launchAppsList + } + } + + private var debuggableAppsList: some View { + let snapshot = debuggableSnapshot + + return List { + errorSection + + if snapshot.apps.isEmpty && !viewModel.isLoading { + EmptyAppListState( + systemImage: snapshot.searchIsActive ? "text.magnifyingglass" : "magnifyingglass", + title: snapshot.searchIsActive ? "No matching apps".localized : "No JIT Apps Found".localized, + message: snapshot.searchIsActive + ? "Try a different name or bundle identifier.".localized + : "StikDebug can only connect to apps with the \"get-task-allow\" entitlement.".localized + ) + } else { + debuggableAppSections(snapshot: snapshot) + } + } + .listStyle(.insetGrouped) + } + + private var launchAppsList: some View { + let snapshot = launchSnapshot + + return List { + errorSection + + if snapshot.apps.isEmpty { + EmptyAppListState( + systemImage: "magnifyingglass", + title: snapshot.searchIsActive ? "No matches".localized : "No Apps Found".localized, + message: snapshot.searchIsActive + ? "Try another name or bundle identifier.".localized + : "Once your device pairing file is imported and CoreDevice is connected, all apps will appear here.".localized + ) + } else { + launchAppSection(snapshot: snapshot) + } + } + .listStyle(.insetGrouped) + } + + @ViewBuilder + private var errorSection: some View { + if let error = viewModel.lastError { + Section { + Text(error) + .font(.footnote) + .foregroundStyle(.orange) + } + } + } + + @ViewBuilder + private func debuggableAppSections(snapshot: DebuggableAppListSnapshot) -> some View { + if !snapshot.favoriteBundles.isEmpty { + Section(String(format: "Favorites (%d/4)".localized, snapshot.favoriteBundles.count)) { + ForEach(snapshot.favoriteBundles, id: \.self) { bundleID in + debugAppRow( + bundleID: bundleID, + appName: viewModel.displayName(for: bundleID) ?? fallbackReadableName(from: bundleID) + ) + } + } + } + + if !snapshot.recentBundles.isEmpty { + Section("Recents".localized) { + ForEach(snapshot.recentBundles, id: \.self) { bundleID in + debugAppRow( + bundleID: bundleID, + appName: viewModel.displayName(for: bundleID) ?? fallbackReadableName(from: bundleID) + ) + } + } + } + + Section("Apps with get-task-allow".localized) { + ForEach(snapshot.apps) { app in + debugAppRow(bundleID: app.bundleID, appName: app.name) + } + } + } + + private func launchAppSection(snapshot: LaunchAppListSnapshot) -> some View { + Section("All Apps".localized) { + ForEach(snapshot.apps) { app in + let isPinned = pinnedSystemApps.contains(app.bundleID) + + LaunchAppRow( + bundleID: app.bundleID, + appName: app.name, + isLaunching: launchingBundles.contains(app.bundleID) + ) { + startLaunching(bundleID: app.bundleID, appName: app.name) + } + .overlay(alignment: .topTrailing) { + if isPinned { + Image(systemName: "star.fill") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.yellow) + .padding(6) + .accessibilityHidden(true) + } + } + .contextMenu { + Button((isPinned ? "Remove from Home" : "Add to Home").localized, + systemImage: isPinned ? "star.slash" : "star") { + toggleSystemPin(bundleID: app.bundleID, appName: app.name) + } + Button("Copy Bundle ID".localized, systemImage: "doc.on.doc") { + UIPasteboard.general.string = app.bundleID + Haptics.light() + } + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button { + toggleSystemPin(bundleID: app.bundleID, appName: app.name) + } label: { + Label((isPinned ? "Unpin" : "Pin").localized, systemImage: "star") + } + .tint(.yellow) + } + } + } + } + + private func debugAppRow(bundleID: String, appName: String) -> some View { + AppButton( + bundleID: bundleID, + appName: appName, + recentApps: $recentApps, + favoriteApps: $favoriteApps, + onSelectApp: onSelectApp, + sharedDefaults: sharedDefaults + ) + } + + private func refreshIconPrefetch() { + prefetchedBundleIDs.removeAll() + prefetchPriorityIcons() + } + + private func handleLoadingChange(_ isLoading: Bool) { + if isLoading { + prefetchedBundleIDs.removeAll() + } else { + prefetchPriorityIcons() + persistIfChanged() + } + } + + private func prefetchPriorityIcons(limit: Int = Self.iconPrefetchLimit) { + guard loadAppIconsOnJIT else { + return + } + + var priorityBundleIDs: [String] = [] + var seenBundleIDs = Set() + + func appendUnique(_ bundleIDs: S) where S.Element == String { + guard priorityBundleIDs.count < limit else { return } + + for bundleID in bundleIDs { + guard seenBundleIDs.insert(bundleID).inserted else { continue } + priorityBundleIDs.append(bundleID) + if priorityBundleIDs.count >= limit { break } } } @@ -263,186 +375,59 @@ private enum AppListTab: Int, CaseIterable, Identifiable { appendUnique(viewModel.debuggableItems.map(\.bundleID)) appendUnique(viewModel.launchItems.map(\.bundleID)) - let toPrefetch = priorityIDs.filter { !prefetchedBundleIDs.contains($0) } - guard !toPrefetch.isEmpty else { return } - - prefetchedBundleIDs.formUnion(toPrefetch) - AppIconRepository.prefetch(bundleIDs: toPrefetch) - } - - @ViewBuilder - private func tabContent(for tab: AppListTab) -> some View { - switch tab { - case .debuggable: - List { - if let error = viewModel.lastError { - Section { - Text(error).font(.footnote).foregroundStyle(.orange) - } - } - if filteredDebuggableApps.isEmpty && !viewModel.isLoading { - Section { - VStack(spacing: 8) { - Image(systemName: debuggableSearchIsActive ? "text.magnifyingglass" : "magnifyingglass") - .font(.system(size: 36)).foregroundStyle(.secondary) - Text(debuggableSearchIsActive ? "No matching apps".localized : "No JIT Apps Found".localized) - .font(.headline) - Text(debuggableSearchIsActive - ? "Try a different name or bundle identifier.".localized - : "StikDebug can only connect to apps with the \"get-task-allow\" entitlement.".localized) - .font(.footnote).foregroundStyle(.secondary).multilineTextAlignment(.center) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 16) - .listRowBackground(Color.clear) - } - } else { - if !filteredFavoriteBundles.isEmpty { - Section(String(format: "Favorites (%d/4)".localized, filteredFavoriteBundles.count)) { - ForEach(filteredFavoriteBundles, id: \.self) { bundleID in - AppButton( - bundleID: bundleID, - appName: viewModel.displayName(for: bundleID) ?? fallbackReadableName(from: bundleID), - recentApps: $recentApps, favoriteApps: $favoriteApps, - onSelectApp: onSelectApp, sharedDefaults: sharedDefaults, performanceMode: performanceMode - ) - } - } - } - if !filteredRecentBundles.isEmpty { - Section("Recents".localized) { - ForEach(filteredRecentBundles, id: \.self) { bundleID in - AppButton( - bundleID: bundleID, - appName: viewModel.displayName(for: bundleID) ?? fallbackReadableName(from: bundleID), - recentApps: $recentApps, favoriteApps: $favoriteApps, - onSelectApp: onSelectApp, sharedDefaults: sharedDefaults, performanceMode: performanceMode - ) - } - } - } - Section("Apps with get-task-allow".localized) { - ForEach(filteredDebuggableApps) { app in - AppButton( - bundleID: app.bundleID, appName: app.name, - recentApps: $recentApps, favoriteApps: $favoriteApps, - onSelectApp: onSelectApp, sharedDefaults: sharedDefaults, performanceMode: performanceMode - ) - } - } - } - } - .listStyle(.insetGrouped) - - case .launch: - List { - if let error = viewModel.lastError { - Section { - Text(error).font(.footnote).foregroundStyle(.orange) - } - } - if filteredLaunchApps.isEmpty { - Section { - VStack(spacing: 8) { - Image(systemName: "magnifyingglass") - .font(.system(size: 36)).foregroundStyle(.secondary) - Text(launchSearchIsActive ? "No matches".localized : "No Apps Found".localized) - .font(.headline) - Text(launchSearchIsActive - ? "Try another name or bundle identifier.".localized - : "Once your device pairing file is imported and CoreDevice is connected, all apps will appear here.".localized) - .font(.footnote).foregroundStyle(.secondary).multilineTextAlignment(.center) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 16) - .listRowBackground(Color.clear) - } - } else { - Section("All Apps".localized) { - ForEach(filteredLaunchApps) { app in - let isPinned = pinnedSystemApps.contains(app.bundleID) - LaunchAppRow( - bundleID: app.bundleID, appName: app.name, - isLaunching: launchingBundles.contains(app.bundleID), - performanceMode: performanceMode - ) { startLaunching(bundleID: app.bundleID, appName: app.name) } - .overlay(alignment: .topTrailing) { - if isPinned { - Image(systemName: "star.fill") - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(.yellow).padding(6) - .accessibilityHidden(true) - } - } - .contextMenu { - Button((isPinned ? "Remove from Home" : "Add to Home").localized, - systemImage: isPinned ? "star.slash" : "star") { - toggleSystemPin(bundleID: app.bundleID, appName: app.name) - } - Button("Copy Bundle ID".localized, systemImage: "doc.on.doc") { - UIPasteboard.general.string = app.bundleID - Haptics.light() - } - } - .swipeActions(edge: .trailing, allowsFullSwipe: false) { - Button { - toggleSystemPin(bundleID: app.bundleID, appName: app.name) - } label: { - Label((isPinned ? "Unpin" : "Pin").localized, systemImage: "star") - } - .tint(.yellow) - } - } - } - } - } - .listStyle(.insetGrouped) + let bundleIDsToPrefetch = priorityBundleIDs.filter { !prefetchedBundleIDs.contains($0) } + guard !bundleIDsToPrefetch.isEmpty else { + return } - } - // MARK: Persistence gate (avoid redundant writes + reloads) + prefetchedBundleIDs.formUnion(bundleIDsToPrefetch) + AppIconRepository.prefetch(bundleIDs: bundleIDsToPrefetch) + } private func persistIfChanged() { var touched = false - let prevR = (sharedDefaults.array(forKey: "recentApps") as? [String]) ?? [] - let prevF = (sharedDefaults.array(forKey: "favoriteApps") as? [String]) ?? [] - let prevPinned = (sharedDefaults.array(forKey: "pinnedSystemApps") as? [String]) ?? [] - let prevPinnedNames = (sharedDefaults.dictionary(forKey: "pinnedSystemAppNames") as? [String: String]) ?? [:] - let prevFavNames = (sharedDefaults.dictionary(forKey: ScriptStore.favoriteAppNamesKey) as? [String: String]) ?? [:] + let previousRecents = (sharedDefaults.array(forKey: "recentApps") as? [String]) ?? [] + let previousFavorites = (sharedDefaults.array(forKey: "favoriteApps") as? [String]) ?? [] + let previousPinned = (sharedDefaults.array(forKey: "pinnedSystemApps") as? [String]) ?? [] + let previousPinnedNames = (sharedDefaults.dictionary(forKey: "pinnedSystemAppNames") as? [String: String]) ?? [:] + let previousFavoriteNames = (sharedDefaults.dictionary(forKey: ScriptStore.favoriteAppNamesKey) as? [String: String]) ?? [:] - if prevR != recentApps { + if previousRecents != recentApps { sharedDefaults.set(recentApps, forKey: "recentApps") touched = true } - if prevF != favoriteApps { + if previousFavorites != favoriteApps { sharedDefaults.set(favoriteApps, forKey: "favoriteApps") touched = true } - if prevPinned != pinnedSystemApps { + if previousPinned != pinnedSystemApps { sharedDefaults.set(pinnedSystemApps, forKey: "pinnedSystemApps") touched = true } - if prevPinnedNames != pinnedSystemAppNames { + if previousPinnedNames != pinnedSystemAppNames { sharedDefaults.set(pinnedSystemAppNames, forKey: "pinnedSystemAppNames") touched = true } - // Persist favorite names for the widget (prefer actual names from lists) - let computedFavNames: [String: String] = Dictionary(uniqueKeysWithValues: favoriteApps.map { id in - let name = viewModel.displayName(for: id) - ?? fallbackReadableName(from: id) - return (id, name) + let favoriteNames = Dictionary(uniqueKeysWithValues: favoriteApps.map { bundleID in + (bundleID, viewModel.displayName(for: bundleID) ?? fallbackReadableName(from: bundleID)) }) - if prevFavNames != computedFavNames { - sharedDefaults.set(computedFavNames, forKey: ScriptStore.favoriteAppNamesKey) + + if previousFavoriteNames != favoriteNames { + sharedDefaults.set(favoriteNames, forKey: ScriptStore.favoriteAppNamesKey) touched = true } - if touched { WidgetCenter.shared.reloadAllTimelines() } + if touched { + WidgetCenter.shared.reloadAllTimelines() + } } private func startLaunching(bundleID: String, appName: String) { - guard !launchingBundles.contains(bundleID) else { return } + guard !launchingBundles.contains(bundleID) else { + return + } + launchingBundles.insert(bundleID) Haptics.selection() AccessibilityAnnouncer.announce(String(format: "Launching %@".localized, appName)) @@ -458,8 +443,8 @@ private enum AppListTab: Int, CaseIterable, Identifiable { if success { Haptics.light() } - AccessibilityAnnouncer.announce(message) + AccessibilityAnnouncer.announce(message) withAnimation { launchFeedback = feedback } @@ -474,9 +459,9 @@ private enum AppListTab: Int, CaseIterable, Identifiable { } } - // Pin/unpin any launchable app (not just hidden system) private func toggleSystemPin(bundleID: String, appName: String) { Haptics.light() + if let index = pinnedSystemApps.firstIndex(of: bundleID) { pinnedSystemApps.remove(at: index) pinnedSystemAppNames.removeValue(forKey: bundleID) @@ -484,814 +469,117 @@ private enum AppListTab: Int, CaseIterable, Identifiable { pinnedSystemApps.removeAll { $0 == bundleID } pinnedSystemApps.insert(bundleID, at: 0) pinnedSystemAppNames[bundleID] = appName - let maxPins = 8 - if pinnedSystemApps.count > maxPins { - let surplus = Array(pinnedSystemApps.suffix(from: maxPins)) - for id in surplus { pinnedSystemAppNames.removeValue(forKey: id) } - pinnedSystemApps = Array(pinnedSystemApps.prefix(maxPins)) + + if pinnedSystemApps.count > Self.maxSystemPins { + let surplus = Array(pinnedSystemApps.suffix(from: Self.maxSystemPins)) + for bundleID in surplus { + pinnedSystemAppNames.removeValue(forKey: bundleID) + } + pinnedSystemApps = Array(pinnedSystemApps.prefix(Self.maxSystemPins)) } } + persistIfChanged() } - // Fallback readable name from bundle identifier private func fallbackReadableName(from bundleID: String) -> String { let components = bundleID.split(separator: ".") - if let last = components.last { - let cleaned = last.replacingOccurrences(of: "_", with: " ") + if let lastComponent = components.last { + let cleaned = lastComponent.replacingOccurrences(of: "_", with: " ") let trimmed = cleaned.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmed.isEmpty { return trimmed.capitalized } + if !trimmed.isEmpty { + return trimmed.capitalized + } } + return bundleID } } -// MARK: - App Button Row +private enum AppListTab: Int, CaseIterable, Identifiable { + case debuggable + case launch -struct AppButton: View { - let bundleID: String - let appName: String - - @Binding var recentApps: [String] - @Binding var favoriteApps: [String] - - @AppStorage("loadAppIconsOnJIT") private var loadAppIconsOnJIT = true - @AppStorage("enableAdvancedOptions") private var enableAdvancedOptions = false - - var onSelectApp: (String, String) -> Void - let sharedDefaults: UserDefaults - let performanceMode: Bool - - @State private var showScriptPicker = false - @State private var assignedScriptName: String? - @StateObject private var iconLoader: IconLoader - - init( - bundleID: String, - appName: String, - recentApps: Binding<[String]>, - favoriteApps: Binding<[String]>, - onSelectApp: @escaping (String, String) -> Void, - sharedDefaults: UserDefaults, - performanceMode: Bool - ) { - self.bundleID = bundleID - self.appName = appName - self._recentApps = recentApps - self._favoriteApps = favoriteApps - self.onSelectApp = onSelectApp - self.sharedDefaults = sharedDefaults - self.performanceMode = performanceMode - _iconLoader = StateObject(wrappedValue: IconLoader(bundleID: bundleID)) - _assignedScriptName = State(initialValue: AppButton.currentAssignment(for: bundleID)) + var id: Int { + rawValue } + var title: String { + switch self { + case .debuggable: + return "JIT" + case .launch: + return "Other" + } + } + + var navigationTitle: String { + switch self { + case .debuggable: + return "Enable JIT".localized + case .launch: + return "Launch Apps".localized + } + } + + var searchPrompt: String { + switch self { + case .debuggable: + return "Search apps or bundle ID".localized + case .launch: + return "Search".localized + } + } +} + +private struct LaunchFeedback: Identifiable { + let id = UUID() + let message: String + let success: Bool +} + +private struct DebuggableAppListSnapshot { + let apps: [InstalledAppListItem] + let favoriteBundles: [String] + let recentBundles: [String] + let searchIsActive: Bool +} + +private struct LaunchAppListSnapshot { + let apps: [InstalledAppListItem] + let searchIsActive: Bool +} + +private struct EmptyAppListState: View { + let systemImage: String + let title: String + let message: String + var body: some View { - Button(action: selectApp) { - HStack(spacing: loadAppIconsOnJIT ? 16 : 12) { - iconView + Section { + VStack(spacing: 8) { + Image(systemName: systemImage) + .font(.system(size: 36)) + .foregroundStyle(.secondary) - VStack(alignment: .leading, spacing: 3) { - Text(appName) - .font(.system(size: 17, weight: .semibold)) - .foregroundStyle(.primary) - .lineLimit(1) + Text(title) + .font(.headline) - Text(bundleID) - .font(.system(size: 14)) - .foregroundStyle(.secondary) - .lineLimit(1) - .textSelection(.enabled) - } - - Spacer() - - if favoriteApps.contains(bundleID) { - Image(systemName: "star.fill") - .imageScale(.medium) - .foregroundStyle(.yellow) - .accessibilityHidden(true) - } + Text(message) + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) } - .padding(.vertical, loadAppIconsOnJIT ? 4 : 8) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .contextMenu { - Button(action: toggleFavorite) { - Label( - favoriteApps.contains(bundleID) ? "Remove Favorite" : "Add to Favorites", - systemImage: favoriteApps.contains(bundleID) ? "star.slash" : "star" - ) - .disabled(!favoriteApps.contains(bundleID) && favoriteApps.count >= 4) - } - Button { - copyBundleID() - } label: { - Label("Copy Bundle ID", systemImage: "doc.on.doc") - } - if enableAdvancedOptions { - Button { showScriptPicker = true } label: { - Label("Assign Script", systemImage: "chevron.left.slash.chevron.right") - } - if assignedScriptName != nil { - Button { - resetScriptAssignment() - } label: { - Label("Reset Script", systemImage: "arrow.uturn.left") - } - } - } - } - .swipeActions(edge: .trailing, allowsFullSwipe: false) { - Button { - toggleFavorite() - } label: { - Label(favoriteApps.contains(bundleID) ? "Unfavorite" : "Favorite", systemImage: "star") - } - .tint(.yellow) - - Button { - copyBundleID() - } label: { - Label("Copy ID", systemImage: "doc.on.doc") - } - } - .sheet(isPresented: $showScriptPicker) { - ScriptListView { url in - assignScript(url) - showScriptPicker = false - } - } - .onAppear { - if loadAppIconsOnJIT { - iconLoader.beginLoading() - } - } - .onChange(of: loadAppIconsOnJIT) { _, newValue in - if newValue { - iconLoader.beginLoading() - } - } - .accessibilityElement(children: .ignore) - .accessibilityLabel(String(format: "Enable JIT for %@".localized, appName)) - .accessibilityValue(accessibilityValue) - .accessibilityHint("Double-tap to open the app and enable JIT. Use the actions rotor for favorites or bundle ID.".localized) - .accessibilityAddTraits(.isButton) - .accessibilityRemoveTraits(.isStaticText) - .accessibilityAction(named: Text(favoriteAccessibilityActionLabel)) { - toggleFavorite() - } - .accessibilityAction(named: Text("Copy Bundle ID".localized)) { - copyBundleID() - } - } - - private var accessibilityValue: String { - var parts = [String(format: "Bundle ID %@".localized, bundleID)] - if favoriteApps.contains(bundleID) { - parts.append("Favorite".localized) - } - if let assignedScriptName { - parts.append(String(format: "Assigned script %@".localized, assignedScriptName)) - } - return parts.joined(separator: ", ") - } - - private var favoriteAccessibilityActionLabel: String { - favoriteApps.contains(bundleID) - ? "Remove from Favorites".localized - : "Add to Favorites".localized - } - - // MARK: Icon - - private var iconView: some View { - Group { - if loadAppIconsOnJIT, let image = iconLoader.image { - Image(uiImage: image) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 54, height: 54) - .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) - .shadow(color: .black.opacity(0.18), radius: 3, x: 0, y: 1.5) - .transition(.opacity.combined(with: .scale)) - } else { - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(Color(UIColor.systemGray5)) - .frame(width: 54, height: 54) - .overlay( - Image(systemName: "app") - .font(.system(size: 24, weight: .regular)) - .foregroundStyle(.gray) - ) - } - } - .accessibilityHidden(true) - } - - // MARK: Actions - - private func selectApp() { - Haptics.selection() - recentApps.removeAll { $0 == bundleID } - recentApps.insert(bundleID, at: 0) - if recentApps.count > 3 { - recentApps = Array(recentApps.prefix(3)) - } - persistIfChanged() - onSelectApp(bundleID, appName) - } - - private func toggleFavorite() { - Haptics.light() - let wasFavorite = favoriteApps.contains(bundleID) - if wasFavorite { - favoriteApps.removeAll { $0 == bundleID } - } else if favoriteApps.count < 4 { - favoriteApps.insert(bundleID, at: 0) - recentApps.removeAll { $0 == bundleID } - } else { - AccessibilityAnnouncer.announce("Favorites are full".localized) - return - } - persistIfChanged() - AccessibilityAnnouncer.announce(wasFavorite ? "Removed from Favorites".localized : "Added to Favorites".localized) - } - - private func copyBundleID() { - UIPasteboard.general.string = bundleID - Haptics.light() - AccessibilityAnnouncer.announce("Bundle ID copied".localized) - } - - private func assignScript(_ url: URL?) { - if let url { - let filename = url.lastPathComponent - ScriptStore.updateAssignedScriptName(filename, for: bundleID) - assignedScriptName = filename - } else { - ScriptStore.updateAssignedScriptName(nil, for: bundleID) - assignedScriptName = nil - } - Haptics.light() - } - - private func resetScriptAssignment() { - assignScript(nil) - } - - private static func currentAssignment(for bundleID: String) -> String? { - ScriptStore.assignedScriptName(for: bundleID) - } - - private func persistIfChanged() { - var touched = false - let prevR = (sharedDefaults.array(forKey: "recentApps") as? [String]) ?? [] - let prevF = (sharedDefaults.array(forKey: "favoriteApps") as? [String]) ?? [] - - if prevR != recentApps { - sharedDefaults.set(recentApps, forKey: "recentApps") - touched = true - } - if prevF != favoriteApps { - sharedDefaults.set(favoriteApps, forKey: "favoriteApps") - touched = true - } - if touched { WidgetCenter.shared.reloadAllTimelines() } - } -} - -// MARK: - Launch Row - -struct LaunchAppRow: View { - let bundleID: String - let appName: String - let isLaunching: Bool - - @AppStorage("loadAppIconsOnJIT") private var loadAppIconsOnJIT = true - - let performanceMode: Bool - var launchAction: () -> Void - - @StateObject private var iconLoader: IconLoader - - init( - bundleID: String, - appName: String, - isLaunching: Bool, - performanceMode: Bool, - launchAction: @escaping () -> Void - ) { - self.bundleID = bundleID - self.appName = appName - self.isLaunching = isLaunching - self.performanceMode = performanceMode - self.launchAction = launchAction - _iconLoader = StateObject(wrappedValue: IconLoader(bundleID: bundleID)) - } - - var body: some View { - Button { - guard !isLaunching else { return } - launchAction() - } label: { - HStack(spacing: loadAppIconsOnJIT ? 16 : 12) { - iconView - - VStack(alignment: .leading, spacing: 3) { - Text(appName) - .font(.system(size: 17, weight: .semibold)) - .foregroundStyle(.primary) - .lineLimit(1) - - Text(bundleID) - .font(.system(size: 14)) - .foregroundStyle(.secondary) - .lineLimit(1) - .textSelection(.enabled) - } - - Spacer() - - if isLaunching { - ProgressView().controlSize(.small) - } else { - Text("Launch".localized) - .font(.footnote.weight(.semibold)) - .padding(.horizontal, 12).padding(.vertical, 6) - .background(Capsule().fill(Color.accentColor.opacity(0.18))) - .foregroundStyle(Color.accentColor) - } - } - .padding(.vertical, loadAppIconsOnJIT ? 4 : 8) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .disabled(isLaunching) - .onAppear { - if loadAppIconsOnJIT { - iconLoader.beginLoading() - } - } - .onChange(of: loadAppIconsOnJIT) { _, newValue in - if newValue { - iconLoader.beginLoading() - } - } - .accessibilityElement(children: .ignore) - .accessibilityLabel(String(format: "Launch %@".localized, appName)) - .accessibilityValue(accessibilityValue) - .accessibilityHint(isLaunching - ? "Launch request in progress.".localized - : "Double-tap to launch this app without enabling JIT.".localized) - .accessibilityAddTraits(.isButton) - .accessibilityRemoveTraits(.isStaticText) - .accessibilityAction(named: Text("Launch App".localized)) { - guard !isLaunching else { return } - launchAction() - } - } - - private var accessibilityValue: String { - let state = isLaunching ? "Launching".localized : "Ready".localized - return "\(state), \(String(format: "Bundle ID %@".localized, bundleID))" - } - - private var iconView: some View { - Group { - if loadAppIconsOnJIT, let image = iconLoader.image { - Image(uiImage: image) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 54, height: 54) - .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) - .shadow(color: .black.opacity(0.18), radius: 3, x: 0, y: 1.5) - .transition(.opacity.combined(with: .scale)) - } else { - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(Color(UIColor.systemGray5)) - .frame(width: 54, height: 54) - .overlay( - Image(systemName: "app") - .font(.system(size: 24, weight: .regular)) - .foregroundStyle(.gray) - ) - } - } - .accessibilityHidden(true) - } - -} - -private actor IconFetchRegistry { - private var tasks: [String: Task] = [:] - - func task(for bundleID: String, create: () -> Task) -> Task { - if let existing = tasks[bundleID] { - return existing - } - let task = create() - tasks[bundleID] = task - return task - } - - func clear(bundleID: String) { - tasks[bundleID] = nil - } -} - -private actor AsyncSemaphore { - private var permits: Int - private var waiters: [CheckedContinuation] = [] - - init(permits: Int) { - self.permits = permits - } - - func acquire() async { - if permits > 0 { - permits -= 1 - return - } - await withCheckedContinuation { continuation in - waiters.append(continuation) - } - } - - func release() { - if let next = waiters.first { - waiters.removeFirst() - next.resume() - } else { - permits += 1 + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .listRowBackground(Color.clear) } } } -enum AppIconRepository { - private static let memory: NSCache = { - let cache = NSCache() - cache.countLimit = 2000 - cache.totalCostLimit = 64 * 1024 * 1024 - return cache - }() - - private static let diskQueue = DispatchQueue(label: "com.stik.iconcache.disk", qos: .utility) - private static let fetchSemaphore = AsyncSemaphore(permits: 4) - private static let registry = IconFetchRegistry() - private static let appGroupIdentifier = "group.com.stik.sj" - - static func cachedImage(for bundleID: String) -> UIImage? { - memory.object(forKey: bundleID as NSString) - } - - static func image(for bundleID: String) async -> UIImage? { - if let mem = cachedImage(for: bundleID) { - return mem - } - - if let disk = await loadFromDisk(bundleID: bundleID) { - storeInMemory(disk, for: bundleID) - return disk - } - - return await fetchAndStore(bundleID: bundleID) - } - - static func prefetch(bundleIDs: [String]) { - let unique = Set(bundleIDs) - for bundleID in unique { - Task.detached(priority: .utility) { - _ = await image(for: bundleID) - } - } - } - - static func removeFromCache(bundleIDs: [String]) { - guard !bundleIDs.isEmpty else { return } - for id in bundleIDs { - memory.removeObject(forKey: id as NSString) - } - diskQueue.async { - for id in bundleIDs { - guard let url = iconURL(for: id) else { continue } - try? FileManager.default.removeItem(at: url) - } - } - } - - private static func fetchAndStore(bundleID: String) async -> UIImage? { - let task = await registry.task(for: bundleID) { - Task.detached(priority: .utility) { - await fetchSemaphore.acquire() - - let result: UIImage? - if let fetched = await fetchFromSource(bundleID: bundleID) { - let prepared = prepareForDisplay(fetched) - store(prepared, for: bundleID) - result = prepared - } else { - result = nil - } - - await fetchSemaphore.release() - await registry.clear(bundleID: bundleID) - return result - } - } - return await task.value - } - - private static func fetchFromSource(bundleID: String) async -> UIImage? { - await withCheckedContinuation { continuation in - AppStoreIconFetcher.getIcon(for: bundleID) { image in - continuation.resume(returning: image) - } - } - } - - private static func loadFromDisk(bundleID: String) async -> UIImage? { - let imageScale = await MainActor.run { UIScreen.main.scale } - return await withCheckedContinuation { (continuation: CheckedContinuation) in - diskQueue.async { - guard let url = iconURL(for: bundleID), - FileManager.default.fileExists(atPath: url.path) else { - continuation.resume(returning: nil) - return - } - guard let data = try? Data(contentsOf: url) else { - try? FileManager.default.removeItem(at: url) - continuation.resume(returning: nil) - return - } - guard let image = UIImage(data: data, scale: imageScale) else { - try? FileManager.default.removeItem(at: url) - continuation.resume(returning: nil) - return - } - continuation.resume(returning: prepareForDisplay(image)) - } - } - } - - private static func store(_ image: UIImage, for bundleID: String) { - storeInMemory(image, for: bundleID) - storeOnDisk(image, bundleID: bundleID) - } - - private static func storeInMemory(_ image: UIImage, for bundleID: String) { - memory.setObject(image, forKey: bundleID as NSString, cost: memoryCost(for: image)) - } - - private static func storeOnDisk(_ image: UIImage, bundleID: String) { - diskQueue.async { - guard let url = iconURL(for: bundleID), - let data = image.pngData() else { return } - do { - try data.write(to: url, options: .atomic) - } catch { - // Best-effort cache write. - } - } - } - - private static func iconURL(for bundleID: String) -> URL? { - guard let container = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroupIdentifier) else { - return nil - } - guard let fileName = cacheFileName(for: bundleID) else { - return nil - } - let directory = container.appendingPathComponent("icons", isDirectory: true) - do { - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - } catch { - return nil - } - return directory.appendingPathComponent(fileName) - } - - private static func cacheFileName(for bundleID: String) -> String? { - let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: ".-_")) - let sanitized = String(bundleID.unicodeScalars.map { scalar in - allowed.contains(scalar) ? Character(scalar) : "_" - }) - guard !sanitized.isEmpty else { return nil } - return "\(sanitized).png" - } - - private static func memoryCost(for image: UIImage) -> Int { - let width = Int(image.size.width * image.scale) - let height = Int(image.size.height * image.scale) - return max(width * height * 4, 1) - } - - private static func prepareForDisplay(_ image: UIImage) -> UIImage { - if #available(iOS 15.0, *) { - return image.preparingForDisplay() ?? image - } - return image - } -} - -@MainActor -final class IconLoader: ObservableObject { - @Published private(set) var image: UIImage? - - private let bundleID: String - private var didStart = false - - init(bundleID: String) { - self.bundleID = bundleID - if let cached = AppIconRepository.cachedImage(for: bundleID) { - image = cached - didStart = true - } - } - - func beginLoading() { - if image != nil { - didStart = true - return - } - guard !didStart else { return } - didStart = true - - let targetID = bundleID - Task { [weak self] in - if let resolved = await AppIconRepository.image(for: targetID) { - guard let self else { return } - withAnimation(.linear(duration: 0.12)) { - self.image = resolved - } - } else { - self?.didStart = false - } - } - } -} - -enum Haptics { - static func light() { UIImpactFeedbackGenerator(style: .light).impactOccurred() } - static func selection() { UISelectionFeedbackGenerator().selectionChanged() } -} - -// MARK: - Utilities - -extension Array: @retroactive RawRepresentable where Element: Codable { - public init?(rawValue: String) { - guard let data = rawValue.data(using: .utf8), - let result = try? JSONDecoder().decode([Element].self, from: data) - else { return nil } - self = result - } - public var rawValue: String { - guard let data = try? JSONEncoder().encode(self), - let result = String(data: data, encoding: .utf8) - else { return "[]" } - return result - } -} - -extension Dictionary: @retroactive RawRepresentable where Key: Codable, Value: Codable { - public init?(rawValue: String) { - guard let data = rawValue.data(using: .utf8), - let result = try? JSONDecoder().decode([Key: Value].self, from: data) - else { return nil } - self = result - } - public var rawValue: String { - guard let data = try? JSONEncoder().encode(self), - let result = String(data: data, encoding: .utf8) - else { return "{}" } - return result - } -} - -// MARK: - Preview - struct InstalledAppsListView_Previews: PreviewProvider { static var previews: some View { InstalledAppsListView { _, _ in } .environment(\.colorScheme, .dark) } } - - -class InstalledAppsViewModel: ObservableObject { - @Published var debuggableApps: [String: String] = [:] - @Published var nonDebuggableApps: [String: String] = [:] - @Published var systemApps: [String: String] = [:] - @Published private(set) var debuggableItems: [InstalledAppListItem] = [] - @Published private(set) var launchItems: [InstalledAppListItem] = [] - @Published var isLoading = false - @Published var lastError: String? = nil - - private let workQueue = DispatchQueue(label: "com.stik.installedApps", qos: .userInitiated) - private let cache = UserDefaults(suiteName: "group.com.stik.sj") ?? .standard - private let cacheKeyDebuggable = "cachedDebuggableApps" - private let cacheKeyNonDebuggable = "cachedNonDebuggableApps" - private let cacheKeySystem = "cachedSystemApps" - - init() { - loadCachedApps() - refreshAppLists() - } - - func refreshAppLists() { - isLoading = true - lastError = nil - - workQueue.async { [weak self] in - guard let self else { return } - do { - let debuggable = try JITEnableContext.shared.getAppList() - let allApps = try JITEnableContext.shared.getAllApps() - let hiddenSystem = (try? JITEnableContext.shared.getHiddenSystemApps()) ?? [:] - - let nonDebuggableSequence = allApps.filter { debuggable[$0.key] == nil } - var nonDebuggable: [String: String] = [:] - var system: [String: String] = [:] - - for (bundle, name) in nonDebuggableSequence { - if let hiddenName = hiddenSystem[bundle] { - system[bundle] = hiddenName - } else { - nonDebuggable[bundle] = name - } - } - - for (bundle, name) in hiddenSystem where system[bundle] == nil && debuggable[bundle] == nil { - system[bundle] = name - } - - DispatchQueue.main.async { - self.apply(debuggable: debuggable, nonDebuggable: nonDebuggable, system: system) - self.isLoading = false - self.cacheApps(debuggable: debuggable, nonDebuggable: nonDebuggable, system: system) - } - } catch { - DispatchQueue.main.async { - self.isLoading = false - self.lastError = error.localizedDescription - } - } - } - } - - private func loadCachedApps() { - func decode(_ key: String) -> [String: String] { - guard let data = cache.data(forKey: key), - let decoded = try? JSONDecoder().decode([String: String].self, from: data) else { return [:] } - return decoded - } - - let cachedDebuggable = decode(cacheKeyDebuggable) - let cachedNonDebuggable = decode(cacheKeyNonDebuggable) - let cachedSystem = decode(cacheKeySystem) - - if !cachedDebuggable.isEmpty || !cachedNonDebuggable.isEmpty || !cachedSystem.isEmpty { - apply(debuggable: cachedDebuggable, nonDebuggable: cachedNonDebuggable, system: cachedSystem) - } - } - - private func apply(debuggable: [String: String], nonDebuggable: [String: String], system: [String: String]) { - debuggableApps = debuggable - nonDebuggableApps = nonDebuggable - systemApps = system - debuggableItems = InstalledAppListItem.sorted(from: debuggable) - launchItems = InstalledAppListItem.sorted(from: Self.launchApps(nonDebuggable: nonDebuggable, system: system)) - } - - private static func launchApps(nonDebuggable: [String: String], system: [String: String]) -> [String: String] { - var combined = nonDebuggable - for (bundleID, name) in system { - combined[bundleID] = name - } - return combined - } - - func displayName(for bundleID: String) -> String? { - debuggableApps[bundleID] ?? systemApps[bundleID] ?? nonDebuggableApps[bundleID] - } - - private func cacheApps(debuggable: [String: String], nonDebuggable: [String: String], system: [String: String]) { - func encode(_ value: [String: String]) -> Data? { - try? JSONEncoder().encode(value) - } - - cache.set(encode(debuggable), forKey: cacheKeyDebuggable) - cache.set(encode(nonDebuggable), forKey: cacheKeyNonDebuggable) - cache.set(encode(system), forKey: cacheKeySystem) - } - - func launchWithoutDebug(bundleID: String, completion: @escaping (Bool) -> Void) { - workQueue.async { - let success = JITEnableContext.shared.launchAppWithoutDebug(bundleID, logger: nil) - DispatchQueue.main.async { - completion(success) - } - } - } -} diff --git a/StikDebug/Views/MainTabView.swift b/StikDebug/Views/MainTabView.swift index 1478a93..17fbb16 100644 --- a/StikDebug/Views/MainTabView.swift +++ b/StikDebug/Views/MainTabView.swift @@ -8,10 +8,6 @@ import SwiftUI import Foundation -extension Notification.Name { - static let switchToTab = Notification.Name("MainTabSwitchNotification") -} - private enum ExternalLocationAction: Identifiable { case simulate(URL, Double, Double) case clear @@ -55,7 +51,6 @@ private enum ExternalLocationAction: Identifiable { struct MainTabView: View { @AppStorage("primaryTabSelection") private var selection: String = AppFeature.home.id - @State private var switchObserver: Any? @State private var detachedFeature: AppFeature? @State private var didSetInitialHome = false @State private var pendingLocationAction: ExternalLocationAction? @@ -77,17 +72,6 @@ struct MainTabView: View { selection = AppFeature.home.id didSetInitialHome = true } - guard switchObserver == nil else { return } - switchObserver = NotificationCenter.default.addObserver(forName: .switchToTab, object: nil, queue: .main) { note in - guard let id = note.object as? String else { return } - openFeature(id: id) - } - } - .onDisappear { - if let observer = switchObserver { - NotificationCenter.default.removeObserver(observer) - switchObserver = nil - } } .onOpenURL { url in handleURL(url) diff --git a/StikDebug/Views/MapSelectionView.swift b/StikDebug/Views/MapSelectionView.swift index 1d07dd0..2363133 100644 --- a/StikDebug/Views/MapSelectionView.swift +++ b/StikDebug/Views/MapSelectionView.swift @@ -1651,7 +1651,7 @@ private struct RouteSearchSheet: View { } else if !activeResults.isEmpty { ScrollView { LazyVStack(spacing: 0) { - ForEach(Array(activeResults.enumerated()), id: \.offset) { index, result in + ForEach(Array(activeResults.enumerated()), id: \.element) { index, result in Button { resolve(result) } label: { diff --git a/StikDebug/Views/ProfileView.swift b/StikDebug/Views/ProfileView.swift index 2f8e948..81718cf 100644 --- a/StikDebug/Views/ProfileView.swift +++ b/StikDebug/Views/ProfileView.swift @@ -265,9 +265,9 @@ struct ProfileView: View { ) { result in switch result { case .success(let url): - print("Saved at \(url)") + LogManager.shared.addInfoLog("Provisioning profile exported to \(url.lastPathComponent)") case .failure(let error): - print("Error: \(error.localizedDescription)") + LogManager.shared.addErrorLog("Provisioning profile export failed: \(error.localizedDescription)") } } }