From daa28c1a831953d52784e0f3acd25245fcbf3dd6 Mon Sep 17 00:00:00 2001 From: Stephen B <158498287+StephenDev0@users.noreply.github.com> Date: Sun, 19 Oct 2025 15:05:08 -0400 Subject: [PATCH] Better Icon Loading --- StikJIT/Views/InstalledAppsListView.swift | 429 ++++++++++++++-------- 1 file changed, 266 insertions(+), 163 deletions(-) diff --git a/StikJIT/Views/InstalledAppsListView.swift b/StikJIT/Views/InstalledAppsListView.swift index c543b36..d1fa166 100644 --- a/StikJIT/Views/InstalledAppsListView.swift +++ b/StikJIT/Views/InstalledAppsListView.swift @@ -15,7 +15,6 @@ import Combine 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] = [] @@ -329,7 +328,6 @@ struct InstalledAppsListView: View { appName: viewModel.debuggableApps[bundleID] ?? bundleID, recentApps: $recentApps, favoriteApps: $favoriteApps, - appIcons: $appIcons, onSelectApp: onSelectApp, sharedDefaults: sharedDefaults, performanceMode: performanceMode @@ -348,7 +346,6 @@ struct InstalledAppsListView: View { appName: viewModel.debuggableApps[bundleID] ?? bundleID, recentApps: $recentApps, favoriteApps: $favoriteApps, - appIcons: $appIcons, onSelectApp: onSelectApp, sharedDefaults: sharedDefaults, performanceMode: performanceMode @@ -366,7 +363,6 @@ struct InstalledAppsListView: View { appName: appName, recentApps: $recentApps, favoriteApps: $favoriteApps, - appIcons: $appIcons, onSelectApp: onSelectApp, sharedDefaults: sharedDefaults, performanceMode: performanceMode @@ -402,7 +398,7 @@ struct InstalledAppsListView: View { guard !toPrefetch.isEmpty else { return } prefetchedBundleIDs.formUnion(toPrefetch) - IconCache.shared.prefetchIcons(for: toPrefetch) + AppIconRepository.prefetch(bundleIDs: toPrefetch) } private func launchSections(apps: [(key: String, value: String)]) -> some View { @@ -414,7 +410,6 @@ struct InstalledAppsListView: View { bundleID: bundleID, appName: appName, isLaunching: launchingBundles.contains(bundleID), - appIcons: $appIcons, performanceMode: performanceMode ) { startLaunching(bundleID: bundleID) @@ -773,7 +768,6 @@ struct AppButton: View { @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 @@ -785,9 +779,29 @@ struct AppButton: View { let performanceMode: Bool @State private var showScriptPicker = false + @StateObject private var iconLoader: IconLoader private var rowBackgroundStyle: BackgroundStyle { themeExpansion?.backgroundStyle(for: appThemeRaw) ?? AppTheme.system.backgroundStyle } + init( + bundleID: String, + appName: String, + recentApps: Binding<[String]>, + favoriteApps: Binding<[String]>, + onSelectApp: @escaping (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)) + } + var body: some View { Button(action: selectApp) { HStack(spacing: loadAppIconsOnJIT ? 16 : 12) { @@ -864,7 +878,12 @@ struct AppButton: View { } .onAppear { if loadAppIconsOnJIT { - loadAppIcon(for: bundleID) + iconLoader.beginLoading() + } + } + .onChange(of: loadAppIconsOnJIT) { _, newValue in + if newValue { + iconLoader.beginLoading() } } .accessibilityElement(children: .ignore) @@ -876,7 +895,7 @@ struct AppButton: View { private var iconView: some View { Group { - if loadAppIconsOnJIT, let image = appIcons[bundleID] { + if loadAppIconsOnJIT, let image = iconLoader.image { Image(uiImage: image) .resizable() .aspectRatio(contentMode: .fit) @@ -954,33 +973,15 @@ struct AppButton: View { } if touched { WidgetCenter.shared.reloadAllTimelines() } } - - private func loadAppIcon(for bundleID: String) { - 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 { - withAnimation(.linear(duration: 0.12)) { - appIcons[bundleID] = image - } - } else { - appIcons[bundleID] = image - } - } - } } -// MARK: - IconCache +// MARK: - Launch Row 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 @@ -988,8 +989,25 @@ struct LaunchAppRow: View { let performanceMode: Bool var launchAction: () -> Void + @StateObject private var iconLoader: IconLoader + private var rowBackgroundStyle: BackgroundStyle { themeExpansion?.backgroundStyle(for: appThemeRaw) ?? AppTheme.system.backgroundStyle } + 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 } @@ -1037,7 +1055,12 @@ struct LaunchAppRow: View { .disabled(isLaunching) .onAppear { if loadAppIconsOnJIT { - loadAppIcon(for: bundleID) + iconLoader.beginLoading() + } + } + .onChange(of: loadAppIconsOnJIT) { _, newValue in + if newValue { + iconLoader.beginLoading() } } .accessibilityElement(children: .ignore) @@ -1047,7 +1070,7 @@ struct LaunchAppRow: View { private var iconView: some View { Group { - if loadAppIconsOnJIT, let image = appIcons[bundleID] { + if loadAppIconsOnJIT, let image = iconLoader.image { Image(uiImage: image) .resizable() .aspectRatio(contentMode: .fit) @@ -1072,22 +1095,6 @@ struct LaunchAppRow: View { private var rowBackground: some View { ThemedRowBackground(performanceMode: performanceMode, style: rowBackgroundStyle, cornerRadius: 16) } - - private func loadAppIcon(for bundleID: String) { - 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 { - withAnimation(.linear(duration: 0.12)) { - appIcons[bundleID] = image - } - } else { - appIcons[bundleID] = image - } - } - } } private struct ThemedRowBackground: View { @@ -1171,126 +1178,197 @@ private struct ThemedRowBackground: View { } } -final class IconCache { - static let shared = IconCache() +private actor IconFetchRegistry { + private var tasks: [String: Task] = [:] - private let mem = NSCache() - 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 - queue.qualityOfService = .userInitiated - mem.countLimit = 1000 - mem.totalCostLimit = 64 * 1024 * 1024 + func task(for bundleID: String, create: () -> Task) -> Task { + if let existing = tasks[bundleID] { + return existing + } + let task = create() + tasks[bundleID] = task + return task } - 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) } + 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 } - - var shouldStartLoad = false - callbackQueue.sync { - 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 - } - } - - guard shouldStartLoad else { return } - - let operation = BlockOperation { [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 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(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 - } - - DispatchQueue.main.async { - 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 } + await withCheckedContinuation { continuation in + waiters.append(continuation) } } - private func diskCachedIcon(for bundleID: String) -> UIImage? { - guard let containerURL = FileManager.default - .containerURL(forSecurityApplicationGroupIdentifier: "group.com.stik.sj") - else { return nil } + func release() { + if let next = waiters.first { + waiters.removeFirst() + next.resume() + } else { + permits += 1 + } + } +} - let iconsDir = containerURL.appendingPathComponent("icons", isDirectory: true) - let fileURL = iconsDir.appendingPathComponent("\(bundleID).png") - guard FileManager.default.fileExists(atPath: fileURL.path) else { return nil } +enum AppIconRepository { + private static let memory: NSCache = { + let cache = NSCache() + cache.countLimit = 2000 + cache.totalCostLimit = 64 * 1024 * 1024 + return cache + }() - guard let data = try? Data(contentsOf: fileURL) else { - try? FileManager.default.removeItem(at: fileURL) + 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? { + await withCheckedContinuation { continuation 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: UIScreen.main.scale) 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 } - - if let image = UIImage(data: data, scale: UIScreen.main.scale) { - return image + let directory = container.appendingPathComponent("icons", isDirectory: true) + do { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } catch { + return nil } - - try? FileManager.default.removeItem(at: fileURL) - return nil + return directory.appendingPathComponent("\(bundleID).png") } - private func prepareForDisplay(_ image: UIImage) -> UIImage { + 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 } @@ -1298,6 +1376,43 @@ final class IconCache { } } +@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 + } + } + } +} + // MARK: - Shared UI Bits private struct BackgroundGradient: View { @@ -1338,17 +1453,6 @@ private extension View { } } -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() } @@ -1459,4 +1563,3 @@ class InstalledAppsViewModel: ObservableObject { } } } -