diff --git a/StikJIT/Views/HomeView.swift b/StikJIT/Views/HomeView.swift index 671a897..e4c0e1d 100644 --- a/StikJIT/Views/HomeView.swift +++ b/StikJIT/Views/HomeView.swift @@ -31,6 +31,9 @@ struct HomeView: View { @State private var isShowingInstalledApps = false @State private var isShowingPairingFilePicker = false @State private var pairingFileExists: Bool = false + @State private var pairingFilePresentOnDisk: Bool = false + @State private var isValidatingPairingFile = false + @State private var lastValidatedPairingSignature: PairingFileSignature? = nil @State private var showPairingFileMessage = false @State private var pairingFileIsValid = false @State private var isImportingFile = false @@ -58,7 +61,7 @@ struct HomeView: View { @State private var isLoadingQuickApps = false @AppStorage("showiOS26Disclaimer") private var showiOS26Disclaimer: Bool = true - + @AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue @Environment(\.themeExpansionManager) private var themeExpansion private var backgroundStyle: BackgroundStyle { themeExpansion?.backgroundStyle(for: appThemeRaw) ?? AppTheme.system.backgroundStyle } @@ -70,6 +73,8 @@ struct HomeView: View { private var ddiMounted: Bool { isMounted() } private var canConnectByApp: Bool { pairingFileExists && ddiMounted } + + private let pairingFileURL = URL.documentsDirectory.appendingPathComponent("pairingFile.plist") private var isOnOrAfteriOS26: Bool { let v = ProcessInfo.processInfo.operatingSystemVersion @@ -351,9 +356,13 @@ struct HomeView: View { VStack(spacing: 10) { Button(action: primaryActionTapped) { - whiteCardButtonLabel(icon: primaryActionIcon, title: primaryActionTitle, isLoading: isProcessing) + whiteCardButtonLabel( + icon: primaryActionIcon, + title: primaryActionTitle, + isLoading: isProcessing || isValidatingPairingFile + ) } - .disabled(isProcessing) + .disabled(isProcessing || isValidatingPairingFile) if pairingFileExists && enableAdvancedOptions { Button(action: { showPIDSheet = true }) { @@ -373,7 +382,23 @@ struct HomeView: View { } private var readinessSummary: ReadinessSummary { + if isValidatingPairingFile { + return .init( + title: "Validating pairing file", + subtitle: "Hang tight while we verify the pairing file from your trusted computer.", + icon: "hourglass.circle.fill", + tint: .orange + ) + } if !pairingFileExists { + if pairingFilePresentOnDisk { + return .init( + title: "Pairing file needs attention", + subtitle: "We found a pairing file but couldn’t read it. Re-import it from your trusted computer.", + icon: "doc.badge.exclamationmark", + tint: .yellow + ) + } return .init( title: "Import your pairing file", subtitle: "Grab the pairing file from your computer to unlock app discovery.", @@ -414,28 +439,59 @@ struct HomeView: View { } private var primaryActionTitle: String { - if !pairingFileExists { return "Import Pairing File" } + if isValidatingPairingFile { return "Validating…" } + if !pairingFileExists { return pairingFilePresentOnDisk ? "Re-import Pairing File" : "Import Pairing File" } if !ddiMounted { return "Mount Developer Disk Image" } return "Connect by App" } private var primaryActionIcon: String { - if !pairingFileExists { return "doc.badge.plus" } + if isValidatingPairingFile { return "hourglass" } + if !pairingFileExists { return pairingFilePresentOnDisk ? "arrow.clockwise" : "doc.badge.plus" } if !ddiMounted { return "externaldrive" } return "cable.connector.horizontal" } private var readinessChecklist: [ChecklistItem] { let vpnConnected = tunnel.tunnelStatus == .connected + let pairingItem: ChecklistItem + + if isValidatingPairingFile { + pairingItem = ChecklistItem( + title: "Pairing file", + subtitle: "Validating pairing file…", + status: .waiting, + actionTitle: nil, + action: nil + ) + } else if pairingFileExists { + pairingItem = ChecklistItem( + title: "Pairing file", + subtitle: "Imported and valid.", + status: .ready, + actionTitle: nil, + action: nil + ) + } else if pairingFilePresentOnDisk { + pairingItem = ChecklistItem( + title: "Pairing file", + subtitle: "We couldn’t read the pairing file that’s on disk. Re-import it from your trusted computer.", + status: .attention, + actionTitle: "Re-import", + action: { isShowingPairingFilePicker = true } + ) + } else { + pairingItem = ChecklistItem( + title: "Pairing file", + subtitle: "Import the pairing file generated from your trusted computer.", + status: .actionRequired, + actionTitle: "Import", + action: { isShowingPairingFilePicker = true } + ) + } return [ - ChecklistItem( - title: "Pairing file", - subtitle: pairingFileExists ? "Imported and valid." : "Import the pairing file generated from your trusted computer.", - status: pairingFileExists ? .ready : .actionRequired, - actionTitle: pairingFileExists ? nil : "Import", - action: pairingFileExists ? nil : { isShowingPairingFilePicker = true } - ), + pairingItem, ChecklistItem( title: "Developer Disk Image", subtitle: ddiMounted ? "Mounted successfully." : "Open Settings → Developer Disk Image and tap Mount.", @@ -755,6 +811,7 @@ struct HomeView: View { } private func primaryActionTapped() { + guard !isValidatingPairingFile else { return } if pairingFileExists { if !ddiMounted { showAlert(title: "Device Not Mounted".localized, message: "The Developer Disk Image has not been mounted yet. Check in settings for more information.".localized, showOk: true) { _ in } @@ -790,8 +847,43 @@ struct HomeView: View { } private func checkPairingFileExists() { - let fileExists = FileManager.default.fileExists(atPath: URL.documentsDirectory.appendingPathComponent("pairingFile.plist").path) - pairingFileExists = fileExists && isPairing() + let fileExists = FileManager.default.fileExists(atPath: pairingFileURL.path) + pairingFilePresentOnDisk = fileExists + + guard fileExists else { + pairingFileExists = false + lastValidatedPairingSignature = nil + isValidatingPairingFile = false + return + } + + let signature = pairingFileSignature(for: pairingFileURL) + + guard needsValidation(for: signature) else { return } + guard !isValidatingPairingFile else { return } + + isValidatingPairingFile = true + + DispatchQueue.global(qos: .utility).async { + let valid = isPairing() + DispatchQueue.main.async { + pairingFileExists = valid + lastValidatedPairingSignature = signature + isValidatingPairingFile = false + } + } + } + + private func needsValidation(for signature: PairingFileSignature) -> Bool { + guard let lastSignature = lastValidatedPairingSignature else { return true } + return lastSignature != signature + } + + private func pairingFileSignature(for url: URL) -> PairingFileSignature { + let attributes = (try? FileManager.default.attributesOfItem(atPath: url.path)) ?? [:] + let modificationDate = attributes[.modificationDate] as? Date + let sizeValue = (attributes[.size] as? NSNumber)?.uint64Value ?? 0 + return PairingFileSignature(modificationDate: modificationDate, fileSize: sizeValue) } private func refreshBackground() { } @@ -1095,6 +1187,11 @@ private struct ReadinessSummary { let tint: Color } +private struct PairingFileSignature: Equatable { + let modificationDate: Date? + let fileSize: UInt64 +} + private enum ChecklistStatus { case ready case waiting diff --git a/StikJIT/Views/InstalledAppsListView.swift b/StikJIT/Views/InstalledAppsListView.swift index 78fc78e..21e308f 100644 --- a/StikJIT/Views/InstalledAppsListView.swift +++ b/StikJIT/Views/InstalledAppsListView.swift @@ -28,12 +28,16 @@ struct InstalledAppsListView: View { } } + @AppStorage("loadAppIconsOnJIT") private var loadAppIconsOnJIT = true @AppStorage("performanceMode") private var performanceMode = false @State private var showPerformanceToast = false @State private var launchingBundles: Set = [] @State private var launchFeedback: LaunchFeedback? = nil @State private var debuggableSearchText: String = "" @State private var otherSearchText: String = "" + @State private var prefetchedBundleIDs: Set = [] + @State private var selectedTab: AppListTab = .debuggable + @State private var systemSearchText: String = "" @Environment(\.dismiss) private var dismiss var onSelectApp: (String) -> Void @@ -78,6 +82,7 @@ struct InstalledAppsListView: View { private enum AppListTab: String, CaseIterable, Identifiable { case debuggable + case system case other var id: String { rawValue } @@ -85,6 +90,7 @@ struct InstalledAppsListView: View { var title: String { switch self { case .debuggable: return "Debuggable" + case .system: return "System Apps" case .other: return "Other" } } @@ -106,6 +112,16 @@ struct InstalledAppsListView: View { } } + private var sortedSystemApps: [(key: String, value: String)] { + viewModel.systemApps.sorted { lhs, rhs in + let comparison = lhs.value.localizedCaseInsensitiveCompare(rhs.value) + if comparison == .orderedSame { + return lhs.key < rhs.key + } + return comparison == .orderedAscending + } + } + private var otherSearchIsActive: Bool { !otherSearchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } @@ -118,6 +134,18 @@ struct InstalledAppsListView: View { return base.filter { matches(query, bundleID: $0.key, name: $0.value) } } + private var systemSearchIsActive: Bool { + !systemSearchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + private var filteredSystemApps: [(key: String, value: String)] { + let base = sortedSystemApps + guard systemSearchIsActive else { return base } + let query = normalizedSearchString(systemSearchText) + guard !query.isEmpty else { return base } + return base.filter { matches(query, bundleID: $0.key, name: $0.value) } + } + private func normalizedSearchString(_ text: String) -> String { text.trimmingCharacters(in: .whitespacesAndNewlines) .folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current) @@ -137,6 +165,8 @@ struct InstalledAppsListView: View { return viewModel.debuggableApps.isEmpty case .other: return filteredOtherApps.isEmpty + case .system: + return filteredSystemApps.isEmpty } } @@ -232,6 +262,20 @@ struct InstalledAppsListView: View { } } .preferredColorScheme(preferredScheme) + .onAppear { + prefetchedBundleIDs.removeAll() + prefetchPriorityIcons() + } + .onChange(of: favoriteApps) { _, _ in prefetchPriorityIcons() } + .onChange(of: recentApps) { _, _ in prefetchPriorityIcons() } + .onChange(of: viewModel.isLoading) { _, newValue in + if newValue { + prefetchedBundleIDs.removeAll() + } else { + prefetchPriorityIcons() + } + } + .onChange(of: selectedTab) { _, _ in prefetchPriorityIcons() } } // MARK: Empty State @@ -258,6 +302,18 @@ struct InstalledAppsListView: View { .foregroundStyle(.secondary) .multilineTextAlignment(.center) .padding(.horizontal) + case .system: + Text("No Hidden System Apps".localized) + .font(.title2.weight(.semibold)) + .foregroundStyle(.primary) + + Text(""" + Hidden system apps appear here once CoreDevice exposes them. Try launching a bundle from the Other tab to surface background services. + """.localized) + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) case .other: Text("Nothing To Show".localized) .font(.title2.weight(.semibold)) @@ -275,7 +331,18 @@ struct InstalledAppsListView: View { .padding(24) .glassCard(cornerRadius: 24, material: .thinMaterial, strokeOpacity: 0.12) .accessibilityElement(children: .combine) - .accessibilityLabel("Empty list. Import pairing file or sign app appropriately.".localized) + .accessibilityLabel(emptyStateAccessibilityLabel(for: tab).localized) + } + + private func emptyStateAccessibilityLabel(for tab: AppListTab) -> String { + switch tab { + case .debuggable: + return "No debuggable apps available" + case .system: + return "No hidden system apps available" + case .other: + return "No other apps available" + } } // MARK: Apps List @@ -345,36 +412,84 @@ struct InstalledAppsListView: View { } } - private func otherSections(apps: [(key: String, value: String)]) -> some View { - VStack(spacing: 18) { - glassSection(title: "How It Works".localized) { - VStack(alignment: .leading, spacing: 8) { - Text("Launch apps without get-task-allow by tunneling through CoreDevice. This only starts the app; attach debugging from Recents once it exposes get-task-allow.".localized) - .font(.footnote) - .foregroundStyle(.secondary) - } - } + private func prefetchPriorityIcons(limit: Int = 32) { + guard loadAppIconsOnJIT else { return } - glassSection(title: "All Installed Apps".localized) { - LazyVStack(spacing: 12) { - ForEach(apps, id: \.key) { bundleID, appName in - LaunchAppRow( - bundleID: bundleID, - appName: appName, - isLaunching: launchingBundles.contains(bundleID), - appIcons: $appIcons, - performanceMode: performanceMode - ) { - startLaunching(bundleID: bundleID) - } + var priorityIDs: [String] = [] + var seen = Set() + + 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 } + } + } + + appendUnique(favoriteApps) + appendUnique(recentApps) + appendUnique(debuggableSortedApps.map { $0.key }) + appendUnique(sortedSystemApps.map { $0.key }) + appendUnique(sortedOtherApps.map { $0.key }) + + let toPrefetch = priorityIDs.filter { !prefetchedBundleIDs.contains($0) } + guard !toPrefetch.isEmpty else { return } + + prefetchedBundleIDs.formUnion(toPrefetch) + IconCache.shared.prefetchIcons(for: toPrefetch) + } + + private func otherSections(apps: [(key: String, value: String)]) -> some View { + glassSection(title: "Installed Apps".localized) { + LazyVStack(spacing: 12) { + ForEach(apps, id: \.key) { bundleID, appName in + LaunchAppRow( + bundleID: bundleID, + appName: appName, + isLaunching: launchingBundles.contains(bundleID), + appIcons: $appIcons, + performanceMode: performanceMode + ) { + startLaunching(bundleID: bundleID) } } } } } + private func systemSections(apps: [(key: String, value: String)]) -> some View { + glassSection(title: "Hidden System Apps".localized) { + LazyVStack(spacing: 12) { + ForEach(apps, id: \.key) { bundleID, appName in + LaunchAppRow( + bundleID: bundleID, + appName: appName, + isLaunching: launchingBundles.contains(bundleID), + appIcons: $appIcons, + performanceMode: performanceMode + ) { + startLaunching(bundleID: bundleID) + } + } + } + } + } + + @ViewBuilder private var tabbedContent: some View { - tabContent(for: .debuggable) + VStack(spacing: 14) { + Picker("", selection: $selectedTab) { + ForEach(AppListTab.allCases) { tab in + Text(tab.title.localized) + .tag(tab) + } + } + .pickerStyle(.segmented) + .padding(.horizontal, 20) + + tabContent(for: selectedTab) + } } @ViewBuilder @@ -415,6 +530,8 @@ struct InstalledAppsListView: View { .padding(.vertical, 24) } } + case .system: + systemTabContent(apps: filteredSystemApps) case .other: otherTabContent(apps: filteredOtherApps) } @@ -446,6 +563,32 @@ struct InstalledAppsListView: View { } } + private func systemTabContent(apps: [(key: String, value: String)]) -> some View { + ScrollView { + VStack(spacing: 18) { + systemSearchBar + + if let error = viewModel.lastError { + errorBanner(error) + } + + if apps.isEmpty { + if systemSearchIsActive { + systemSearchEmptyState + .transition(.opacity.combined(with: .scale)) + } else { + emptyState(for: .system) + .transition(.opacity.combined(with: .scale)) + } + } else { + systemSections(apps: apps) + } + } + .padding(.horizontal, 20) + .padding(.vertical, 24) + } + } + private var debuggableSearchBar: some View { HStack(spacing: 8) { Image(systemName: "magnifyingglass") @@ -544,6 +687,55 @@ struct InstalledAppsListView: View { ) } + private var systemSearchBar: some View { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + + TextField("Search system apps".localized, text: $systemSearchText) + .textInputAutocapitalization(.never) + .autocorrectionDisabled(true) + + if !systemSearchText.isEmpty { + Button { + systemSearchText = "" + } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 16)) + .foregroundStyle(.secondary) + } + .accessibilityLabel("Clear search".localized) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(.ultraThinMaterial) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(.white.opacity(0.08), lineWidth: 1) + ) + ) + } + + private var systemSearchEmptyState: some View { + VStack(spacing: 12) { + Image(systemName: "magnifyingglass") + .font(.system(size: 44, weight: .regular)) + .foregroundStyle(.secondary) + + Text("No hidden matches".localized) + .font(.title3.weight(.semibold)) + + Text("Try another bundle identifier or internal process name.".localized) + .font(.callout) + .foregroundStyle(.secondary) + } + .padding(24) + .glassCard(cornerRadius: 20, material: .thinMaterial, strokeOpacity: 0.12) + } + private func errorBanner(_ message: String) -> some View { Text(message) .font(.footnote) @@ -818,7 +1010,8 @@ struct AppButton: View { private func loadAppIcon(for bundleID: String) { guard loadAppIconsOnJIT else { return } - IconCache.shared.fetchIcon(for: bundleID) { image in + guard appIcons[bundleID] == nil else { return } + IconCache.shared.fetchIcon(for: bundleID, priority: .veryHigh) { image in guard let image else { return } let shouldAnimate = appIcons[bundleID] == nil if shouldAnimate { @@ -934,7 +1127,9 @@ struct LaunchAppRow: View { } private func loadAppIcon(for bundleID: String) { - IconCache.shared.fetchIcon(for: bundleID) { image in + guard loadAppIconsOnJIT else { return } + guard appIcons[bundleID] == nil else { return } + IconCache.shared.fetchIcon(for: bundleID, priority: .veryHigh) { image in guard let image else { return } let shouldAnimate = appIcons[bundleID] == nil if shouldAnimate { @@ -1024,6 +1219,7 @@ final class IconCache { private let queue = OperationQueue() private let callbackQueue = DispatchQueue(label: "com.stik.iconcache.callbacks") private var pendingCallbacks: [String: [(UIImage?) -> Void]] = [:] + private var pendingOperations: [String: Operation] = [:] private init() { queue.maxConcurrentOperationCount = 4 @@ -1032,7 +1228,11 @@ final class IconCache { mem.totalCostLimit = 64 * 1024 * 1024 } - func fetchIcon(for bundleID: String, completion: @escaping (UIImage?) -> Void) { + func fetchIcon( + for bundleID: String, + priority: Operation.QueuePriority = .normal, + completion: @escaping (UIImage?) -> Void + ) { if let memImage = mem.object(forKey: bundleID as NSString) { DispatchQueue.main.async { completion(memImage) } return @@ -1043,6 +1243,9 @@ final class IconCache { if var existing = pendingCallbacks[bundleID] { existing.append(completion) pendingCallbacks[bundleID] = existing + if let op = pendingOperations[bundleID], op.queuePriority.rawValue < priority.rawValue { + op.queuePriority = priority + } } else { pendingCallbacks[bundleID] = [completion] shouldStartLoad = true @@ -1051,7 +1254,7 @@ final class IconCache { guard shouldStartLoad else { return } - queue.addOperation { [weak self] in + let operation = BlockOperation { [weak self] in guard let self else { return } var loadedFromDisk = false @@ -1070,16 +1273,19 @@ final class IconCache { } if let image = result { - let cost = Int(image.size.width * image.size.height * image.scale * image.scale) - self.mem.setObject(image, forKey: bundleID as NSString, cost: cost) + let prepared = self.prepareForDisplay(image) + let cost = Int(prepared.size.width * prepared.size.height * prepared.scale * prepared.scale) + self.mem.setObject(prepared, forKey: bundleID as NSString, cost: cost) if !loadedFromDisk { - saveIconToGroup(image, bundleID: bundleID) + saveIconToGroup(prepared, bundleID: bundleID) } + result = prepared } let callbacks = self.callbackQueue.sync { () -> [(UIImage?) -> Void] in let handlers = self.pendingCallbacks[bundleID] ?? [] self.pendingCallbacks[bundleID] = nil + self.pendingOperations[bundleID] = nil return handlers } @@ -1087,6 +1293,20 @@ final class IconCache { callbacks.forEach { $0(result) } } } + operation.queuePriority = priority + + callbackQueue.sync { + pendingOperations[bundleID] = operation + } + + queue.addOperation(operation) + } + + func prefetchIcons(for bundleIDs: [String]) { + let uniqueIDs = Set(bundleIDs) + for bundleID in uniqueIDs { + fetchIcon(for: bundleID, priority: .veryLow) { _ in } + } } private func diskCachedIcon(for bundleID: String) -> UIImage? { @@ -1097,7 +1317,25 @@ final class IconCache { let iconsDir = containerURL.appendingPathComponent("icons", isDirectory: true) let fileURL = iconsDir.appendingPathComponent("\(bundleID).png") guard FileManager.default.fileExists(atPath: fileURL.path) else { return nil } - return UIImage(contentsOfFile: fileURL.path) + + guard let data = try? Data(contentsOf: fileURL) else { + try? FileManager.default.removeItem(at: fileURL) + return nil + } + + if let image = UIImage(data: data, scale: UIScreen.main.scale) { + return image + } + + try? FileManager.default.removeItem(at: fileURL) + return nil + } + + private func prepareForDisplay(_ image: UIImage) -> UIImage { + if #available(iOS 15.0, *) { + return image.preparingForDisplay() ?? image + } + return image } } @@ -1184,6 +1422,7 @@ extension Array: @retroactive RawRepresentable where Element: Codable { class InstalledAppsViewModel: ObservableObject { @Published var debuggableApps: [String: String] = [:] @Published var nonDebuggableApps: [String: String] = [:] + @Published var systemApps: [String: String] = [:] @Published var isLoading = false @Published var lastError: String? = nil @@ -1200,19 +1439,35 @@ class InstalledAppsViewModel: ObservableObject { 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 } - let nonDebuggable = Dictionary(uniqueKeysWithValues: nonDebuggableSequence.map { ($0.key, $0.value) }) + 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.debuggableApps = debuggable self.nonDebuggableApps = nonDebuggable + self.systemApps = system self.isLoading = false } } catch { DispatchQueue.main.async { self.debuggableApps = [:] self.nonDebuggableApps = [:] + self.systemApps = [:] self.isLoading = false self.lastError = error.localizedDescription print("Failed to load apps: \(error)") diff --git a/StikJIT/idevice/JITEnableContext.h b/StikJIT/idevice/JITEnableContext.h index 4eb78bb..5d18bd3 100644 --- a/StikJIT/idevice/JITEnableContext.h +++ b/StikJIT/idevice/JITEnableContext.h @@ -23,6 +23,7 @@ typedef void (^SyslogErrorHandler)(NSError *error); - (BOOL)debugAppWithPID:(int)pid logger:(LogFunc)logger jsCallback:(DebugAppCallback)jsCallback; - (NSDictionary*)getAppListWithError:(NSError**)error; - (NSDictionary*)getAllAppsWithError:(NSError**)error; +- (NSDictionary*)getHiddenSystemAppsWithError:(NSError**)error; - (UIImage*)getAppIconWithBundleId:(NSString*)bundleId error:(NSError**)error; - (BOOL)launchAppWithoutDebug:(NSString*)bundleID logger:(LogFunc)logger; - (void)startSyslogRelayWithHandler:(SyslogLineHandler)lineHandler diff --git a/StikJIT/idevice/JITEnableContext.m b/StikJIT/idevice/JITEnableContext.m index 4aa6a6d..6525209 100644 --- a/StikJIT/idevice/JITEnableContext.m +++ b/StikJIT/idevice/JITEnableContext.m @@ -203,6 +203,22 @@ JITEnableContext* sharedJITContext = nil; return apps; } +- (NSDictionary*)getHiddenSystemAppsWithError:(NSError**)error { + if (!provider) { + NSLog(@"Provider not initialized!"); + *error = [self errorWithStr:@"Provider not initialized!" code:-1]; + return nil; + } + + NSString* errorStr = nil; + NSDictionary* apps = list_hidden_system_apps(provider, &errorStr); + if (errorStr) { + *error = [self errorWithStr:errorStr code:-17]; + return nil; + } + return apps; +} + - (UIImage*)getAppIconWithBundleId:(NSString*)bundleId error:(NSError**)error { if (!provider) { NSLog(@"Provider not initialized!"); diff --git a/StikJIT/idevice/applist.h b/StikJIT/idevice/applist.h index 2f0cdf3..a6907b8 100644 --- a/StikJIT/idevice/applist.h +++ b/StikJIT/idevice/applist.h @@ -12,6 +12,7 @@ NSDictionary* list_installed_apps(IdeviceProviderHandle* provider, NSString** error); NSDictionary* list_all_apps(IdeviceProviderHandle* provider, NSString** error); +NSDictionary* list_hidden_system_apps(IdeviceProviderHandle* provider, NSString** error); UIImage* getAppIcon(IdeviceProviderHandle* provider, NSString* bundleID, NSString** error); #endif /* APPLIST_H */ diff --git a/StikJIT/idevice/applist.m b/StikJIT/idevice/applist.m index 01f0d7c..03e808f 100644 --- a/StikJIT/idevice/applist.m +++ b/StikJIT/idevice/applist.m @@ -11,9 +11,101 @@ #include #import "applist.h" +static NSString *extractAppName(plist_t app) +{ + plist_t displayNameNode = plist_dict_get_item(app, "CFBundleDisplayName"); + if (displayNameNode) { + char *displayNameC = NULL; + plist_get_string_val(displayNameNode, &displayNameC); + if (displayNameC && displayNameC[0] != '\0') { + NSString *displayName = [NSString stringWithUTF8String:displayNameC]; + free(displayNameC); + return displayName; + } + free(displayNameC); + } + + plist_t nameNode = plist_dict_get_item(app, "CFBundleName"); + if (nameNode) { + char *nameC = NULL; + plist_get_string_val(nameNode, &nameC); + if (nameC && nameC[0] != '\0') { + NSString *name = [NSString stringWithUTF8String:nameC]; + free(nameC); + return name; + } + free(nameC); + } + + return @"Unknown"; +} + +static BOOL nodeContainsHiddenTag(plist_t tagsNode) +{ + if (!tagsNode || plist_get_node_type(tagsNode) != PLIST_ARRAY) { + return NO; + } + + uint32_t tagsCount = plist_array_get_size(tagsNode); + for (uint32_t i = 0; i < tagsCount; i++) { + plist_t tagNode = plist_array_get_item(tagsNode, i); + if (!tagNode || plist_get_node_type(tagNode) != PLIST_STRING) { + continue; + } + char *tagC = NULL; + plist_get_string_val(tagNode, &tagC); + if (!tagC) { + continue; + } + BOOL isHidden = (strcmp(tagC, "hidden") == 0 || strcmp(tagC, "hidden-system-app") == 0); + free(tagC); + if (isHidden) { + return YES; + } + } + return NO; +} + +static BOOL isHiddenSystemApp(plist_t app) +{ + plist_t typeNode = plist_dict_get_item(app, "ApplicationType"); + BOOL isSystemType = NO; + if (typeNode && plist_get_node_type(typeNode) == PLIST_STRING) { + char *typeC = NULL; + plist_get_string_val(typeNode, &typeC); + if (typeC) { + if (strcmp(typeC, "System") == 0 || strcmp(typeC, "HiddenSystemApp") == 0) { + isSystemType = YES; + } + free(typeC); + } + } + + if (!isSystemType) { + return NO; + } + + plist_t hiddenNode = plist_dict_get_item(app, "IsHidden"); + if (hiddenNode && plist_get_node_type(hiddenNode) == PLIST_BOOLEAN) { + uint8_t hidden = 0; + plist_get_bool_val(hiddenNode, &hidden); + if (hidden) { + return YES; + } + } + + plist_t tagsNode = plist_dict_get_item(app, "SBAppTags"); + if (nodeContainsHiddenTag(tagsNode)) { + return YES; + } + + return NO; +} + static NSDictionary *buildAppDictionary(void *apps, size_t count, - BOOL requireGetTaskAllow) + BOOL requireGetTaskAllow, + BOOL (^filter)(plist_t app)) { NSMutableDictionary *result = [NSMutableDictionary dictionaryWithCapacity:count]; @@ -31,6 +123,10 @@ static NSDictionary *buildAppDictionary(void *apps, if (!isAllowed) continue; } + if (filter && !filter(app)) { + continue; + } + plist_t bidNode = plist_dict_get_item(app, "CFBundleIdentifier"); if (!bidNode) continue; @@ -44,18 +140,7 @@ static NSDictionary *buildAppDictionary(void *apps, NSString *bundleID = [NSString stringWithUTF8String:bidC]; free(bidC); - NSString *appName = @"Unknown"; - plist_t nameNode = plist_dict_get_item(app, "CFBundleName"); - if (nameNode) { - char *nameC = NULL; - plist_get_string_val(nameNode, &nameC); - if (nameC && nameC[0] != '\0') { - appName = [NSString stringWithUTF8String:nameC]; - } - free(nameC); - } - - result[bundleID] = appName; + result[bundleID] = extractAppName(app); } return result; @@ -63,7 +148,8 @@ static NSDictionary *buildAppDictionary(void *apps, static NSDictionary *performAppQuery(IdeviceProviderHandle *provider, BOOL requireGetTaskAllow, - NSString **error) + NSString **error, + BOOL (^filter)(plist_t app)) { InstallationProxyClientHandle *client = NULL; if (installation_proxy_connect_tcp(provider, &client)) { @@ -79,17 +165,23 @@ static NSDictionary *performAppQuery(IdeviceProviderHandle return nil; } - NSDictionary *result = buildAppDictionary(apps, count, requireGetTaskAllow); + NSDictionary *result = buildAppDictionary(apps, count, requireGetTaskAllow, filter); installation_proxy_client_free(client); return result; } NSDictionary* list_installed_apps(IdeviceProviderHandle* provider, NSString** error) { - return performAppQuery(provider, YES, error); + return performAppQuery(provider, YES, error, nil); } NSDictionary* list_all_apps(IdeviceProviderHandle* provider, NSString** error) { - return performAppQuery(provider, NO, error); + return performAppQuery(provider, NO, error, nil); +} + +NSDictionary* list_hidden_system_apps(IdeviceProviderHandle* provider, NSString** error) { + return performAppQuery(provider, NO, error, ^BOOL(plist_t app) { + return isHiddenSystemApp(app); + }); } UIImage* getAppIcon(IdeviceProviderHandle* provider, NSString* bundleID, NSString** error) { diff --git a/StikJIT/idevice/jit.c b/StikJIT/idevice/jit.c index 2e0fe55..984b969 100644 --- a/StikJIT/idevice/jit.c +++ b/StikJIT/idevice/jit.c @@ -112,8 +112,10 @@ int debug_app(IdeviceProviderHandle* tcp_provider, const char *bundle_id, LogFun fprintf(stderr, "Failed to create TCP adapter: [%d] %s\n", err->code, err->message); idevice_error_free(err); + core_device_proxy_free(core_device); return 1; } + core_device = NULL; // adapter takes ownership of the proxy AdapterStreamHandle *stream = NULL; err = adapter_connect(adapter, rsd_port, (ReadWriteOpaque **)&stream); @@ -138,6 +140,7 @@ int debug_app(IdeviceProviderHandle* tcp_provider, const char *bundle_id, LogFun adapter_free(adapter); return 1; } + stream = NULL; // Create RemoteServerClient RemoteServerHandle *remote_server = NULL; @@ -237,8 +240,10 @@ int debug_app_pid(IdeviceProviderHandle* tcp_provider, int pid, LogFuncC logger, fprintf(stderr, "Failed to create TCP adapter: [%d] %s\n", err->code, err->message); idevice_error_free(err); + core_device_proxy_free(core_device); return 1; } + core_device = NULL; AdapterStreamHandle *stream = NULL; err = adapter_connect(adapter, rsd_port, (ReadWriteOpaque **)&stream); @@ -263,6 +268,7 @@ int debug_app_pid(IdeviceProviderHandle* tcp_provider, int pid, LogFuncC logger, adapter_free(adapter); return 1; } + stream = NULL; // Create RemoteServerClient RemoteServerHandle *remote_server = NULL; @@ -308,76 +314,66 @@ int launch_app_via_proxy(IdeviceProviderHandle* tcp_provider, const char *bundle IdeviceFfiError* err = NULL; CoreDeviceProxyHandle *core_device = NULL; + AdapterHandle *adapter = NULL; + AdapterStreamHandle *stream = NULL; + RsdHandshakeHandle *handshake = NULL; + RemoteServerHandle *remote_server = NULL; + ProcessControlHandle *process_control = NULL; + uint64_t pid = 0; + int result = 1; + err = core_device_proxy_connect(tcp_provider, &core_device); if (err != NULL) { fprintf(stderr, "Failed to connect to CoreDeviceProxy: [%d] %s\n", err->code, err->message); idevice_error_free(err); - return 1; + goto cleanup; } - uint16_t rsd_port; + uint16_t rsd_port = 0; err = core_device_proxy_get_server_rsd_port(core_device, &rsd_port); if (err != NULL) { fprintf(stderr, "Failed to get server RSD port: [%d] %s\n", err->code, err->message); idevice_error_free(err); - core_device_proxy_free(core_device); - return 1; + goto cleanup; } - AdapterHandle *adapter = NULL; err = core_device_proxy_create_tcp_adapter(core_device, &adapter); if (err != NULL) { fprintf(stderr, "Failed to create TCP adapter: [%d] %s\n", err->code, err->message); idevice_error_free(err); - core_device_proxy_free(core_device); - return 1; + goto cleanup; } + core_device = NULL; // ownership transferred to adapter - AdapterStreamHandle *stream = NULL; err = adapter_connect(adapter, rsd_port, (ReadWriteOpaque **)&stream); if (err != NULL) { fprintf(stderr, "Failed to connect to RSD port: [%d] %s\n", err->code, err->message); idevice_error_free(err); - adapter_free(adapter); - core_device_proxy_free(core_device); - return 1; + goto cleanup; } - RsdHandshakeHandle *handshake = NULL; err = rsd_handshake_new((ReadWriteOpaque *)stream, &handshake); if (err != NULL) { fprintf(stderr, "Failed to perform RSD handshake: [%d] %s\n", err->code, err->message); idevice_error_free(err); - adapter_close(stream); - adapter_free(adapter); - core_device_proxy_free(core_device); - return 1; + goto cleanup; } + stream = NULL; // consumed by handshake/adapter stack - RemoteServerHandle *remote_server = NULL; err = remote_server_connect_rsd(adapter, handshake, &remote_server); if (err != NULL) { fprintf(stderr, "Failed to create remote server: [%d] %s\n", err->code, err->message); idevice_error_free(err); - rsd_handshake_free(handshake); - adapter_free(adapter); - core_device_proxy_free(core_device); - return 1; + goto cleanup; } - ProcessControlHandle *process_control = NULL; err = process_control_new(remote_server, &process_control); if (err != NULL) { fprintf(stderr, "Failed to create process control client: [%d] %s\n", err->code, err->message); idevice_error_free(err); - remote_server_free(remote_server); - rsd_handshake_free(handshake); - adapter_free(adapter); - core_device_proxy_free(core_device); - return 1; + goto cleanup; } - uint64_t pid = 0; err = process_control_launch_app(process_control, bundle_id, NULL, @@ -390,26 +386,37 @@ int launch_app_via_proxy(IdeviceProviderHandle* tcp_provider, const char *bundle if (err != NULL) { fprintf(stderr, "Failed to launch app: [%d] %s\n", err->code, err->message); idevice_error_free(err); - process_control_free(process_control); - remote_server_free(remote_server); - rsd_handshake_free(handshake); - adapter_free(adapter); - core_device_proxy_free(core_device); if (logger) { logger("Failed to launch app: %s", bundle_id); } - return 1; + goto cleanup; } if (logger) { logger("Launched app (PID %llu)", pid); } - process_control_free(process_control); - remote_server_free(remote_server); - rsd_handshake_free(handshake); - adapter_free(adapter); - core_device_proxy_free(core_device); + result = 0; - return 0; +cleanup: + if (process_control) { + process_control_free(process_control); + } + if (remote_server) { + remote_server_free(remote_server); + } + if (handshake) { + rsd_handshake_free(handshake); + } + if (stream) { + adapter_close(stream); + } + if (adapter) { + adapter_free(adapter); + } + if (core_device) { + core_device_proxy_free(core_device); + } + + return result; }