// // InstalledAppsListView.swift // StikJIT // // Created by Stossy11 on 28/03/2025. // import SwiftUI import UIKit import WidgetKit import Combine // MARK: - Installed Apps List struct InstalledAppsListView: View { @StateObject private var viewModel = InstalledAppsViewModel() @State private var appIcons: [String: UIImage] = [:] private let sharedDefaults = UserDefaults(suiteName: "group.com.stik.sj")! @AppStorage("recentApps") private var recentApps: [String] = [] @AppStorage("favoriteApps") private var favoriteApps: [String] = [] { didSet { if favoriteApps.count > 4 { favoriteApps = Array(favoriteApps.prefix(4)) } persistIfChanged() } } @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 = "" @Environment(\.dismiss) private var dismiss var onSelectApp: (String) -> Void @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 } private var preferredScheme: ColorScheme? { themeExpansion?.preferredColorScheme(for: appThemeRaw) } private var debuggableSearchIsActive: Bool { !debuggableSearchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } private var debuggableSortedApps: [(key: String, value: String)] { viewModel.debuggableApps.sorted { lhs, rhs in let comparison = lhs.value.localizedCaseInsensitiveCompare(rhs.value) if comparison == .orderedSame { return lhs.key < rhs.key } return comparison == .orderedAscending } } private var filteredDebuggableApps: [(key: String, value: String)] { guard debuggableSearchIsActive else { return debuggableSortedApps } let query = normalizedSearchString(debuggableSearchText) guard !query.isEmpty else { return debuggableSortedApps } return debuggableSortedApps.filter { matches(query, bundleID: $0.key, name: $0.value) } } private var filteredDebuggableSet: Set { Set(filteredDebuggableApps.map { $0.key }) } private var filteredFavoriteBundles: [String] { favoriteApps.filter { filteredDebuggableSet.contains($0) } } private var filteredRecentBundles: [String] { recentApps.filter { filteredDebuggableSet.contains($0) && !favoriteApps.contains($0) } } private enum AppListTab: String, CaseIterable, Identifiable { case debuggable case other var id: String { rawValue } var title: String { switch self { case .debuggable: return "Debuggable" case .other: return "Other" } } } private struct LaunchFeedback: Identifiable { let id = UUID() let message: String let success: Bool } private var sortedOtherApps: [(key: String, value: String)] { viewModel.nonDebuggableApps.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 } private var filteredOtherApps: [(key: String, value: String)] { let base = sortedOtherApps guard otherSearchIsActive else { return base } let query = normalizedSearchString(otherSearchText) 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) .lowercased() } private func matches(_ normalizedQuery: String, bundleID: String, name: String) -> Bool { guard !normalizedQuery.isEmpty else { return true } let identifier = normalizedSearchString(bundleID) let displayName = normalizedSearchString(name) return identifier.contains(normalizedQuery) || displayName.contains(normalizedQuery) } private func isEmpty(for tab: AppListTab) -> Bool { switch tab { case .debuggable: return viewModel.debuggableApps.isEmpty case .other: return filteredOtherApps.isEmpty } } var body: some View { NavigationStack { ZStack { ThemedBackground(style: backgroundStyle) .ignoresSafeArea() if viewModel.isLoading { ProgressView("Loading Apps…".localized) .padding(.horizontal, 24) .padding(.vertical, 20) .background( RoundedRectangle(cornerRadius: 20, style: .continuous) .fill(.ultraThinMaterial) .overlay( RoundedRectangle(cornerRadius: 20, style: .continuous) .stroke(.white.opacity(0.15), lineWidth: 1) ) ) .shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 4) .transition(.opacity.combined(with: .scale)) } else { tabbedContent .transition(.opacity) .transaction { t in t.disablesAnimations = true } } if showPerformanceToast { VStack { Spacer() Text(performanceMode ? "Performance Mode On" : "Performance Mode Off") .font(.subheadline.weight(.semibold)) .padding(.horizontal, 16) .padding(.vertical, 8) .background(.ultraThinMaterial, in: Capsule()) .shadow(radius: 4) .transition(.move(edge: .bottom).combined(with: .opacity)) .padding(.bottom, 40) } .animation(.spring(response: 0.3, dampingFraction: 0.8), value: showPerformanceToast) } if let feedback = launchFeedback { VStack { Spacer() Text(feedback.message) .font(.subheadline.weight(.semibold)) .padding(.horizontal, 16) .padding(.vertical, 8) .background( Capsule() .fill(.ultraThinMaterial) .overlay( Capsule() .stroke(feedback.success ? Color.green.opacity(0.35) : Color.red.opacity(0.35), lineWidth: 1) ) ) .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) } } .navigationTitle("Installed Apps".localized) .toolbar { ToolbarItem(placement: .navigationBarLeading) { Button { performanceMode.toggle() Haptics.selection() showPerformanceToast = true DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { withAnimation { showPerformanceToast = false } } } label: { Image(systemName: performanceMode ? "bolt.fill" : "bolt.slash.fill") .imageScale(.large) .foregroundStyle(performanceMode ? .yellow : .secondary) .accessibilityLabel("Toggle Performance Mode") .accessibilityValue(performanceMode ? "On" : "Off") } } ToolbarItem(placement: .navigationBarTrailing) { Button("Done") { dismiss() } .fontWeight(.semibold) } } } .preferredColorScheme(preferredScheme) } // MARK: Empty State private func emptyState(for tab: AppListTab) -> some View { VStack(spacing: 16) { Image(systemName: "magnifyingglass") .resizable() .scaledToFit() .frame(width: 60, height: 60) .foregroundStyle(.secondary) switch tab { case .debuggable: Text("No Debuggable App Found".localized) .font(.title2.weight(.semibold)) .foregroundStyle(.primary) Text(""" StikDebug can only connect to apps with the “get-task-allow” entitlement. Please check if the app you want to connect to is signed with a development certificate. """.localized) .font(.body) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .padding(.horizontal) case .other: Text("Nothing To Show".localized) .font(.title2.weight(.semibold)) .foregroundStyle(.primary) Text(""" Once your device pairing file is imported and CoreDevice is connected, every installed app will appear here for quick access. """.localized) .font(.body) .foregroundStyle(.secondary) .multilineTextAlignment(.center) .padding(.horizontal) } } .padding(24) .glassCard(cornerRadius: 24, material: .thinMaterial, strokeOpacity: 0.12) .accessibilityElement(children: .combine) .accessibilityLabel("Empty list. Import pairing file or sign app appropriately.".localized) } // MARK: Apps List private func debuggableSections( apps: [(key: String, value: String)], favorites: [String], recents: [String] ) -> some View { VStack(spacing: 18) { if !favorites.isEmpty { glassSection( title: String(format: "Favorites (%d/4)".localized, favorites.count) ) { LazyVStack(spacing: 12) { ForEach(favorites, id: \.self) { bundleID in AppButton( bundleID: bundleID, appName: viewModel.debuggableApps[bundleID] ?? bundleID, recentApps: $recentApps, favoriteApps: $favoriteApps, appIcons: $appIcons, onSelectApp: onSelectApp, sharedDefaults: sharedDefaults, performanceMode: performanceMode ) } } } } if !recents.isEmpty { glassSection(title: "Recents".localized) { LazyVStack(spacing: 12) { ForEach(recents, id: \.self) { bundleID in AppButton( bundleID: bundleID, appName: viewModel.debuggableApps[bundleID] ?? bundleID, recentApps: $recentApps, favoriteApps: $favoriteApps, appIcons: $appIcons, onSelectApp: onSelectApp, sharedDefaults: sharedDefaults, performanceMode: performanceMode ) } } } } glassSection(title: "All Applications".localized) { LazyVStack(spacing: 12) { ForEach(apps, id: \.key) { bundleID, appName in AppButton( bundleID: bundleID, appName: appName, recentApps: $recentApps, favoriteApps: $favoriteApps, appIcons: $appIcons, onSelectApp: onSelectApp, sharedDefaults: sharedDefaults, performanceMode: performanceMode ) } } } } } 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) } } 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) } } } } } } private var tabbedContent: some View { tabContent(for: .debuggable) } @ViewBuilder private func tabContent(for tab: AppListTab) -> some View { switch tab { case .debuggable: if viewModel.debuggableApps.isEmpty { VStack { Spacer(minLength: 0) emptyState(for: .debuggable) Spacer(minLength: 0) } .padding(.horizontal, 20) .padding(.vertical, 24) .transition(.opacity.combined(with: .scale)) .frame(maxWidth: .infinity, maxHeight: .infinity) } else { ScrollView { VStack(spacing: 18) { debuggableSearchBar if let error = viewModel.lastError { errorBanner(error) } if filteredDebuggableApps.isEmpty { debuggableSearchEmptyState .transition(.opacity.combined(with: .scale)) } else { debuggableSections( apps: filteredDebuggableApps, favorites: filteredFavoriteBundles, recents: filteredRecentBundles ) } } .padding(.horizontal, 20) .padding(.vertical, 24) } } case .other: otherTabContent(apps: filteredOtherApps) } } private func otherTabContent(apps: [(key: String, value: String)]) -> some View { ScrollView { VStack(spacing: 18) { searchBar if let error = viewModel.lastError { errorBanner(error) } if apps.isEmpty { if otherSearchIsActive { otherSearchEmptyState .transition(.opacity.combined(with: .scale)) } else { emptyState(for: .other) .transition(.opacity.combined(with: .scale)) } } else { otherSections(apps: apps) } } .padding(.horizontal, 20) .padding(.vertical, 24) } } private var debuggableSearchBar: some View { HStack(spacing: 8) { Image(systemName: "magnifyingglass") .foregroundStyle(.secondary) TextField("Search apps or bundle ID".localized, text: $debuggableSearchText) .textInputAutocapitalization(.never) .autocorrectionDisabled(true) if debuggableSearchIsActive { Button { debuggableSearchText = "" } 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 debuggableSearchEmptyState: some View { VStack(spacing: 12) { Image(systemName: "text.magnifyingglass") .font(.system(size: 44, weight: .regular)) .foregroundStyle(.secondary) Text("No matching apps".localized) .font(.title3.weight(.semibold)) Text("Try a different name or bundle identifier.".localized) .font(.callout) .foregroundStyle(.secondary) } .padding(24) .glassCard(cornerRadius: 20, material: .thinMaterial, strokeOpacity: 0.12) } private var otherSearchEmptyState: some View { VStack(spacing: 12) { Image(systemName: "magnifyingglass") .font(.system(size: 44, weight: .regular)) .foregroundStyle(.secondary) Text("No matches".localized) .font(.title3.weight(.semibold)) Text("Try another name or bundle identifier.".localized) .font(.callout) .foregroundStyle(.secondary) } .padding(24) .glassCard(cornerRadius: 20, material: .thinMaterial, strokeOpacity: 0.12) } private var searchBar: some View { HStack(spacing: 8) { Image(systemName: "magnifyingglass") .foregroundStyle(.secondary) TextField("Search".localized, text: $otherSearchText) .textInputAutocapitalization(.never) .autocorrectionDisabled(true) if !otherSearchText.isEmpty { Button { otherSearchText = "" } 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 func errorBanner(_ message: String) -> some View { Text(message) .font(.footnote) .foregroundStyle(.orange) .padding(12) .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: 16, style: .continuous) .fill(.ultraThinMaterial) .overlay( RoundedRectangle(cornerRadius: 16, style: .continuous) .stroke(.white.opacity(0.12), lineWidth: 1) ) ) } // MARK: Section Wrapper private func glassSection( title: String, @ViewBuilder content: () -> Content ) -> some View { VStack(alignment: .leading, spacing: 14) { Text(title) .font(.headline) .foregroundStyle(.secondary) .accessibilityAddTraits(.isHeader) content() } .padding(20) .glassCard(material: .thinMaterial, strokeOpacity: 0.12) } // MARK: Persistence gate (avoid redundant writes + reloads) 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() } } private func startLaunching(bundleID: String) { guard !launchingBundles.contains(bundleID) else { return } launchingBundles.insert(bundleID) Haptics.selection() viewModel.launchWithoutDebug(bundleID: bundleID) { success in launchingBundles.remove(bundleID) let message = success ? "Launch request sent".localized : "Launch failed".localized let feedback = LaunchFeedback(message: message, success: success) if success { Haptics.light() } withAnimation { launchFeedback = feedback } DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { if launchFeedback?.id == feedback.id { withAnimation { launchFeedback = nil } } } } } } // MARK: - App Button Row struct AppButton: View { let bundleID: String let appName: String @Binding var recentApps: [String] @Binding var favoriteApps: [String] @Binding var appIcons: [String: UIImage] @AppStorage("loadAppIconsOnJIT") private var loadAppIconsOnJIT = true @AppStorage("enableAdvancedOptions") private var enableAdvancedOptions = false @AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue @Environment(\.themeExpansionManager) private var themeExpansion var onSelectApp: (String) -> Void let sharedDefaults: UserDefaults let performanceMode: Bool @State private var showScriptPicker = false private var rowBackgroundStyle: BackgroundStyle { themeExpansion?.backgroundStyle(for: appThemeRaw) ?? AppTheme.system.backgroundStyle } var body: some View { Button(action: selectApp) { 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 favoriteApps.contains(bundleID) { Image(systemName: "star.fill") .imageScale(.medium) .foregroundStyle(.yellow) .accessibilityHidden(true) } } .padding(.vertical, loadAppIconsOnJIT ? 8 : 12) .padding(.horizontal, 12) .background(rowBackground) .contentShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) } .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 { UIPasteboard.general.string = bundleID Haptics.light() } label: { Label("Copy Bundle ID", systemImage: "doc.on.doc") } if enableAdvancedOptions { Button { showScriptPicker = true } label: { Label("Assign Script", systemImage: "chevron.left.slash.chevron.right") } } } .swipeActions(edge: .trailing, allowsFullSwipe: false) { Button { toggleFavorite() } label: { Label(favoriteApps.contains(bundleID) ? "Unfavorite" : "Favorite", systemImage: "star") } .tint(.yellow) Button { UIPasteboard.general.string = bundleID Haptics.light() } label: { Label("Copy ID", systemImage: "doc.on.doc") } } .sheet(isPresented: $showScriptPicker) { ScriptListView { url in assignScript(url) showScriptPicker = false } } .onAppear { if loadAppIconsOnJIT { loadAppIcon(for: bundleID) } } .accessibilityElement(children: .ignore) .accessibilityLabel("\(appName)") .accessibilityHint("Double-tap to select. Swipe for actions, long-press for options.") } // MARK: Icon private var iconView: some View { Group { if loadAppIconsOnJIT, let image = appIcons[bundleID] { 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: Row Background private var rowBackground: some View { ThemedRowBackground(performanceMode: performanceMode, style: rowBackgroundStyle, cornerRadius: 16) } // 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) } private func toggleFavorite() { Haptics.light() if favoriteApps.contains(bundleID) { favoriteApps.removeAll { $0 == bundleID } } else if favoriteApps.count < 4 { favoriteApps.insert(bundleID, at: 0) recentApps.removeAll { $0 == bundleID } } persistIfChanged() } private func assignScript(_ url: URL?) { var mapping = UserDefaults.standard.dictionary(forKey: "BundleScriptMap") as? [String: String] ?? [:] if let url { mapping[bundleID] = url.lastPathComponent } else { mapping.removeValue(forKey: bundleID) } UserDefaults.standard.set(mapping, forKey: "BundleScriptMap") Haptics.light() } 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() } } private func loadAppIcon(for bundleID: String) { guard loadAppIconsOnJIT else { return } IconCache.shared.fetchIcon(for: bundleID) { image in guard let image else { return } let shouldAnimate = appIcons[bundleID] == nil if shouldAnimate { withAnimation(.linear(duration: 0.12)) { appIcons[bundleID] = image } } else { appIcons[bundleID] = image } } } } // MARK: - IconCache struct LaunchAppRow: View { let bundleID: String let appName: String let isLaunching: Bool @Binding var appIcons: [String: UIImage] @AppStorage("loadAppIconsOnJIT") private var loadAppIconsOnJIT = true @AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue @Environment(\.themeExpansionManager) private var themeExpansion let performanceMode: Bool var launchAction: () -> Void private var rowBackgroundStyle: BackgroundStyle { themeExpansion?.backgroundStyle(for: appThemeRaw) ?? AppTheme.system.backgroundStyle } 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 ? 8 : 12) .padding(.horizontal, 12) .background(rowBackground) .contentShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) } .buttonStyle(.plain) .disabled(isLaunching) .onAppear { if loadAppIconsOnJIT { loadAppIcon(for: bundleID) } } .accessibilityElement(children: .ignore) .accessibilityLabel("\(appName)") .accessibilityHint("Double-tap to launch via CoreDevice.") } private var iconView: some View { Group { if loadAppIconsOnJIT, let image = appIcons[bundleID] { 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 var rowBackground: some View { ThemedRowBackground(performanceMode: performanceMode, style: rowBackgroundStyle, cornerRadius: 16) } private func loadAppIcon(for bundleID: String) { IconCache.shared.fetchIcon(for: bundleID) { image in guard let image else { return } let shouldAnimate = appIcons[bundleID] == nil if shouldAnimate { withAnimation(.linear(duration: 0.12)) { appIcons[bundleID] = image } } else { appIcons[bundleID] = image } } } } private struct ThemedRowBackground: View { var performanceMode: Bool var style: BackgroundStyle var cornerRadius: CGFloat var body: some View { let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) return Group { if performanceMode { shape .fill(Color(.secondarySystemBackground).opacity(0.65)) .overlay(shape.stroke(Color.white.opacity(0.10), lineWidth: 1)) } else { ZStack { shape.fill(.ultraThinMaterial) themedOverlay(shape: shape) shape.stroke(Color.white.opacity(0.15), lineWidth: 1) } .shadow(color: .black.opacity(0.08), radius: 6, x: 0, y: 3) } } } @ViewBuilder private func themedOverlay(shape: RoundedRectangle) -> some View { switch style { case .staticGradient(let colors), .customGradient(let colors): shape .fill( LinearGradient( gradient: Gradient(colors: normalized(colors)), startPoint: .topLeading, endPoint: .bottomTrailing ) ) .opacity(0.32) case .animatedGradient(let colors, _): shape .fill( LinearGradient( gradient: Gradient(colors: normalized(colors)), startPoint: .leading, endPoint: .trailing ) ) .opacity(0.38) case .blobs(let colors, _): shape .fill( LinearGradient( gradient: Gradient(colors: normalized(colors)), startPoint: .top, endPoint: .bottom ) ) .opacity(0.40) case .particles(let particle, _): shape.fill(particle.opacity(0.18)) } } private func normalized(_ colors: [Color]) -> [Color] { if colors.count >= 2 { return colors } if let first = colors.first { return [first, first.opacity(0.6)] } return [Color.blue, Color.purple] } } final class IconCache { static let shared = IconCache() private let mem = NSCache() private let queue = OperationQueue() private let callbackQueue = DispatchQueue(label: "com.stik.iconcache.callbacks") private var pendingCallbacks: [String: [(UIImage?) -> Void]] = [:] private init() { queue.maxConcurrentOperationCount = 4 queue.qualityOfService = .userInitiated mem.countLimit = 1000 mem.totalCostLimit = 64 * 1024 * 1024 } func fetchIcon(for bundleID: String, completion: @escaping (UIImage?) -> Void) { if let memImage = mem.object(forKey: bundleID as NSString) { DispatchQueue.main.async { completion(memImage) } return } var shouldStartLoad = false callbackQueue.sync { if var existing = pendingCallbacks[bundleID] { existing.append(completion) pendingCallbacks[bundleID] = existing } else { pendingCallbacks[bundleID] = [completion] shouldStartLoad = true } } guard shouldStartLoad else { return } queue.addOperation { [weak self] in guard let self else { return } var loadedFromDisk = false var result = self.diskCachedIcon(for: bundleID) if result != nil { loadedFromDisk = true } if result == nil { let semaphore = DispatchSemaphore(value: 0) AppStoreIconFetcher.getIcon(for: bundleID) { image in result = image semaphore.signal() } _ = semaphore.wait(timeout: .now() + 10) } 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) if !loadedFromDisk { saveIconToGroup(image, bundleID: bundleID) } } let callbacks = self.callbackQueue.sync { () -> [(UIImage?) -> Void] in let handlers = self.pendingCallbacks[bundleID] ?? [] self.pendingCallbacks[bundleID] = nil return handlers } DispatchQueue.main.async { callbacks.forEach { $0(result) } } } } private func diskCachedIcon(for bundleID: String) -> UIImage? { guard let containerURL = FileManager.default .containerURL(forSecurityApplicationGroupIdentifier: "group.com.stik.sj") else { return nil } 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) } } // MARK: - Shared UI Bits private struct BackgroundGradient: View { var body: some View { LinearGradient( colors: [Color(UIColor.systemBackground), Color(UIColor.secondarySystemBackground)], startPoint: .topLeading, endPoint: .bottomTrailing ) } } private struct GlassCard: ViewModifier { var cornerRadius: CGFloat = 20 var material: Material = .ultraThinMaterial var strokeOpacity: Double = 0.15 func body(content: Content) -> some View { content .background( RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) .fill(material) .overlay( RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) .stroke(.white.opacity(strokeOpacity), lineWidth: 1) ) ) .shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4) } } private extension View { func glassCard( cornerRadius: CGFloat = 20, material: Material = .ultraThinMaterial, strokeOpacity: Double = 0.15 ) -> some View { modifier(GlassCard(cornerRadius: cornerRadius, material: material, strokeOpacity: strokeOpacity)) } } fileprivate func saveIconToGroup(_ image: UIImage, bundleID: String) { guard let data = image.pngData(), let container = FileManager.default .containerURL(forSecurityApplicationGroupIdentifier: "group.com.stik.sj") else { return } let iconsDir = container.appendingPathComponent("icons", isDirectory: true) try? FileManager.default.createDirectory(at: iconsDir, withIntermediateDirectories: true) let fileURL = iconsDir.appendingPathComponent("\(bundleID).png") try? data.write(to: fileURL) } 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 } } // MARK: - Preview #Preview { InstalledAppsListView { _ in } .environment(\.colorScheme, .dark) } class InstalledAppsViewModel: ObservableObject { @Published var debuggableApps: [String: String] = [:] @Published var nonDebuggableApps: [String: String] = [:] @Published var isLoading = false @Published var lastError: String? = nil private let workQueue = DispatchQueue(label: "com.stik.installedApps", qos: .userInitiated) init() { 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 nonDebuggableSequence = allApps.filter { debuggable[$0.key] == nil } let nonDebuggable = Dictionary(uniqueKeysWithValues: nonDebuggableSequence.map { ($0.key, $0.value) }) DispatchQueue.main.async { self.debuggableApps = debuggable self.nonDebuggableApps = nonDebuggable self.isLoading = false } } catch { DispatchQueue.main.async { self.debuggableApps = [:] self.nonDebuggableApps = [:] self.isLoading = false self.lastError = error.localizedDescription print("Failed to load apps: \(error)") } } } } func launchWithoutDebug(bundleID: String, completion: @escaping (Bool) -> Void) { workQueue.async { let success = JITEnableContext.shared.launchAppWithoutDebug(bundleID, logger: nil) DispatchQueue.main.async { completion(success) } } } }