diff --git a/LBox/AppData.swift b/LBox/AppData.swift index ce89a37..36d6111 100644 --- a/LBox/AppData.swift +++ b/LBox/AppData.swift @@ -36,7 +36,6 @@ enum RepoSortOption: String, CaseIterable, Identifiable { } struct AppItem: Codable, Identifiable, Hashable, Sendable { - // Modified ID to include sourceRepoName for uniqueness var id: String { if let repo = sourceRepoName { return "\(repo)|\(downloadURL)" @@ -115,14 +114,13 @@ struct AppItem: Codable, Identifiable, Hashable, Sendable { } } -// Represents an installed .app in the Applications folder struct LocalApp: Identifiable, Hashable { var id: String { url.path } let name: String let bundleID: String let version: String? let url: URL - let iconURL: URL? // Local file URL to the icon if found + let iconURL: URL? } struct MetaData: Codable, Sendable { @@ -155,7 +153,6 @@ struct ExportableRepo: Codable { if onlyEnabled { self.isEnabled = nil } else { self.isEnabled = repo.isEnabled } self.repoListURL = repo.repoListURL - // If this is a remote folder (has repoListURL), do not include children in export if repo.repoListURL != nil { self.children = nil } else if let kids = repo.children { @@ -183,7 +180,6 @@ struct ExportableRepo: Codable { func toSavedRepo() -> SavedRepo { let enabled = isEnabled ?? true if repoListURL != nil || children != nil { - // If it has children or is a remote folder let mappedChildren = children?.map { $0.toSavedRepo() } ?? [] var folder = SavedRepo(folderName: name, children: mappedChildren, repoListURL: repoListURL) folder.isEnabled = enabled @@ -202,12 +198,8 @@ struct SavedRepo: Codable, Identifiable, Hashable, Sendable { var isEnabled: Bool var children: [SavedRepo]? var appCount: Int = 0 - - var repoListURL: URL? // URL for subscription folder source - - // Cache the apps to persist between launches + var repoListURL: URL? var cachedApps: [AppItem]? = nil - var fetchStatus: RepoFetchStatus = .idle var isFolder: Bool { children != nil } @@ -221,7 +213,6 @@ struct SavedRepo: Codable, Identifiable, Hashable, Sendable { } } - // Counts the number of repos (leaves) inside recursively var totalRepoCount: Int { if let children = children { return children.reduce(0) { $0 + $1.totalRepoCount } @@ -258,13 +249,25 @@ struct SavedRepo: Codable, Identifiable, Hashable, Sendable { } init(url: URL, name: String? = nil, iconURL: String? = nil, isEnabled: Bool = true, appCount: Int = 0) { - self.id = url.absoluteString; self.url = url; self.name = name ?? "Unknown"; self.iconURL = iconURL; self.isEnabled = isEnabled; self.children = nil; self.appCount = appCount + self.id = url.absoluteString + self.url = url + self.name = name ?? "Unknown" + self.iconURL = iconURL + self.isEnabled = isEnabled + self.children = nil + self.appCount = appCount self.repoListURL = nil self.cachedApps = nil } init(folderName: String, children: [SavedRepo] = [], repoListURL: URL? = nil) { - self.id = UUID().uuidString; self.name = folderName; self.url = nil; self.iconURL = nil; self.isEnabled = true; self.children = children; self.appCount = 0 + self.id = UUID().uuidString + self.name = folderName + self.url = nil + self.iconURL = nil + self.isEnabled = true + self.children = children + self.appCount = 0 self.repoListURL = repoListURL self.cachedApps = nil } @@ -287,23 +290,34 @@ struct SavedRepo: Codable, Identifiable, Hashable, Sendable { func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(id, forKey: .id); try container.encode(name, forKey: .name); try container.encode(url, forKey: .url) - try container.encode(iconURL, forKey: .iconURL); try container.encode(isEnabled, forKey: .isEnabled) - try container.encode(children, forKey: .children); try container.encode(appCount, forKey: .appCount) + try container.encode(id, forKey: .id) + try container.encode(name, forKey: .name) + try container.encode(url, forKey: .url) + try container.encode(iconURL, forKey: .iconURL) + try container.encode(isEnabled, forKey: .isEnabled) + try container.encode(children, forKey: .children) + try container.encode(appCount, forKey: .appCount) try container.encode(cachedApps, forKey: .cachedApps) try container.encode(repoListURL, forKey: .repoListURL) } func hash(into hasher: inout Hasher) { - hasher.combine(id); hasher.combine(name); hasher.combine(url); hasher.combine(iconURL) - hasher.combine(isEnabled); hasher.combine(children); hasher.combine(appCount); hasher.combine(fetchStatus) + hasher.combine(id) + hasher.combine(name) + hasher.combine(url) + hasher.combine(iconURL) + hasher.combine(isEnabled) + hasher.combine(children) + hasher.combine(appCount) + hasher.combine(fetchStatus) hasher.combine(repoListURL) } static func == (lhs: SavedRepo, rhs: SavedRepo) -> Bool { - return lhs.id == rhs.id && lhs.name == rhs.name && lhs.url == rhs.url && lhs.iconURL == rhs.iconURL && - lhs.isEnabled == rhs.isEnabled && lhs.children == rhs.children && lhs.appCount == rhs.appCount && lhs.fetchStatus == rhs.fetchStatus && - lhs.repoListURL == rhs.repoListURL + return lhs.id == rhs.id && lhs.name == rhs.name && lhs.url == rhs.url && + lhs.iconURL == rhs.iconURL && lhs.isEnabled == rhs.isEnabled && + lhs.children == rhs.children && lhs.appCount == rhs.appCount && + lhs.fetchStatus == rhs.fetchStatus && lhs.repoListURL == rhs.repoListURL } } @@ -325,15 +339,10 @@ class AppStoreViewModel: ObservableObject { @Published var isLoading = false @Published var selectedRepoID: String? = nil - // Updates - @Published var availableUpdates: [String: String] = [:] // BundleID -> Latest Version + @Published var availableUpdates: [String: String] = [:] - // Sort Options - @Published var appSortOrder: AppSortOption = .name { - didSet { - // Trigger async refresh when sort changes - Task { await refreshDisplayApps() } - } + @Published var appSortOrder: AppSortOption = .date { + didSet { Task { await refreshDisplayApps() } } } @Published var repoSortOrder: RepoSortOption = .standard { didSet { @@ -341,8 +350,6 @@ class AppStoreViewModel: ObservableObject { sortRepos() } } - - // NEW: Strict Grouping Setting to separate apps by source/details @Published var strictGrouping: Bool = true { didSet { UserDefaults.standard.set(strictGrouping, forKey: "kStrictGrouping") @@ -350,19 +357,27 @@ class AppStoreViewModel: ObservableObject { } } - // Track fetch progress @Published var fetchProgress: Int = 0 @Published var fetchTotal: Int = 0 - @Published var filteredApps: [AppItem] = [] + private var cancellables = Set() - @Published var isAutoUnzipEnabled: Bool = false { didSet { UserDefaults.standard.set(isAutoUnzipEnabled, forKey: "kAutoUnzipEnabled") } } + @Published var isAutoUnzipEnabled: Bool = false { + didSet { UserDefaults.standard.set(isAutoUnzipEnabled, forKey: "kAutoUnzipEnabled") } + } private let kSavedReposKey = "kSavedReposKey" + // MARK: - Pagination + private let pageSize = 30 + @Published var visibleAppCount: Int = 30 + + // MARK: - Batched merge state + private var pendingMergeApps: [AppItem] = [] + private var mergeTask: Task? = nil + // MARK: - Persistence Path private var reposFileURL: URL { - // Use Application Support so it isn't visible in user's Documents/Downloads FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] .appendingPathComponent("repos.json") } @@ -373,26 +388,37 @@ class AppStoreViewModel: ObservableObject { let option = RepoSortOption(rawValue: storedSort) { self.repoSortOrder = option } - - // Initialize Strict Grouping (Default to true) if UserDefaults.standard.object(forKey: "kStrictGrouping") != nil { self.strictGrouping = UserDefaults.standard.bool(forKey: "kStrictGrouping") } else { self.strictGrouping = true } - loadRepos() setupSearchSubscription() } + // MARK: - Pagination helpers + + var pagedApps: [AppItem] { Array(displayApps.prefix(visibleAppCount)) } + + func loadNextPage() { + guard visibleAppCount < displayApps.count else { return } + visibleAppCount = min(visibleAppCount + pageSize, displayApps.count) + } + + func resetPagination() { + visibleAppCount = pageSize + } + private func setupSearchSubscription() { Publishers.CombineLatest3( $searchText.debounce(for: .milliseconds(300), scheduler: DispatchQueue.main), - $displayApps, + $displayApps.debounce(for: .milliseconds(200), scheduler: DispatchQueue.main), $selectedRepoID ) .receive(on: DispatchQueue.global(qos: .userInitiated)) - .map { (text, apps, repoID) -> [AppItem] in + .map { [weak self] (text, apps, repoID) -> [AppItem] in + guard let self = self else { return [] } let appsFromRepo = (repoID == nil) ? apps : apps.filter { $0.sourceRepoName == repoID } if text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return appsFromRepo @@ -404,23 +430,22 @@ class AppStoreViewModel: ObservableObject { } } .receive(on: DispatchQueue.main) - .assign(to: &$filteredApps) + .sink { [weak self] apps in + self?.filteredApps = apps + self?.resetPagination() + } + .store(in: &cancellables) } // MARK: - Update Logic func checkForUpdates(installedApps: [LocalApp]) { Task { - // Flatten all enabled apps from repos let allStoreApps = getEnabledLeafRepos().flatMap { $0.cachedApps ?? [] } - - // Group store apps by Bundle ID and find latest version - var latestStoreVersions: [String: String] = [:] // BundleID : Version - + var latestStoreVersions: [String: String] = [:] for app in allStoreApps { let bid = app.bundleIdentifier if bid.isEmpty || bid == "unknown" { continue } - if let currentBest = latestStoreVersions[bid] { if compareVersions(app.version, currentBest) == .orderedDescending { latestStoreVersions[bid] = app.version @@ -429,8 +454,6 @@ class AppStoreViewModel: ObservableObject { latestStoreVersions[bid] = app.version } } - - // Compare with installed apps var newUpdates: [String: String] = [:] for installed in installedApps { guard let currentVer = installed.version else { continue } @@ -440,11 +463,8 @@ class AppStoreViewModel: ObservableObject { } } } - let finalUpdates = newUpdates - await MainActor.run { - self.availableUpdates = finalUpdates - } + await MainActor.run { self.availableUpdates = finalUpdates } } } @@ -456,50 +476,36 @@ class AppStoreViewModel: ObservableObject { func loadRepos() { let fileManager = FileManager.default - - // 0. Migration from Documents (Visible to User) -> Application Support (Hidden) let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0] .appendingPathComponent("repos.json") - if fileManager.fileExists(atPath: documentsURL.path) { do { print("Migrating repos.json to Application Support...") let destFolder = reposFileURL.deletingLastPathComponent() try fileManager.createDirectory(at: destFolder, withIntermediateDirectories: true) - - // If destination exists for some reason, remove it if fileManager.fileExists(atPath: reposFileURL.path) { try fileManager.removeItem(at: reposFileURL) } - try fileManager.moveItem(at: documentsURL, to: reposFileURL) } catch { print("Migration failed: \(error)") } } - - // 1. Try loading from File (Application Support) if let data = try? Data(contentsOf: reposFileURL), let decoded = try? JSONDecoder().decode([SavedRepo].self, from: data) { savedRepos = decoded sortRepos() return } - - // 2. Migration: Try loading from UserDefaults (Legacy) if let data = UserDefaults.standard.data(forKey: kSavedReposKey), let decoded = try? JSONDecoder().decode([SavedRepo].self, from: data) { print("Migrating repos from UserDefaults to File...") savedRepos = decoded sortRepos() - // Save to new file location saveRepos() - // Clean up old storage UserDefaults.standard.removeObject(forKey: kSavedReposKey) return } - - // 3. Fallback resetReposToDefault() } @@ -604,10 +610,10 @@ class AppStoreViewModel: ObservableObject { func insert(_ nodes: inout [SavedRepo]) -> Bool { for i in 0.. Bool { for i in 0.. Bool { for i in 0.. [SavedRepo] { var res: [SavedRepo] = [] for node in nodes { - if node.id == excludingId { continue } // Can't move into itself + if node.id == excludingId { continue } if node.isFolder { - // Can't move into remote folder if node.repoListURL == nil { res.append(node) if let children = node.children { res.append(contentsOf: collect(children)) } @@ -742,13 +743,9 @@ class AppStoreViewModel: ObservableObject { nodes.sort { (lhs, rhs) -> Bool in if lhs.isFolder && !rhs.isFolder { return true } if !lhs.isFolder && rhs.isFolder { return false } - - // If sort order is name, we sort purely by name if repoSortOrder == .name { return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending } - - // Default: allow manual ordering / insertion order (stable) return false } for i in 0.. ($1.versionDate ?? "") + }.first! + + if let idx = display.firstIndex(where: { + AppStoreViewModel.generateGroupingKey(for: $0, strict: isStrict) == key + }) { + display[idx] = representative + } else { + display.append(representative) + } + } + + display.sort { lhs, rhs in + switch sortOrder { + case .name: return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending + case .date: return (lhs.versionDate ?? "") > (rhs.versionDate ?? "") + case .size: return (lhs.size ?? 0) > (rhs.size ?? 0) + } + } + + return (variants, display) + }.value + + self.allAppsByVariant = updatedVariants + self.displayApps = updatedDisplay + } + func fetchAllRepos() async { isLoading = true - - // 1. Update Remote Folders first + func findRemoteFolders(_ nodes: [SavedRepo]) -> [SavedRepo] { var res: [SavedRepo] = [] for node in nodes { @@ -941,15 +990,23 @@ class AppStoreViewModel: ObservableObject { } } } - - // 2. Update Leaf Repos + + displayApps = [] + allAppsByVariant = [:] + resetPagination() + let leafRepos = getEnabledLeafRepos() - + var cachedApps: [AppItem] = [] + for repo in leafRepos { if let cache = repo.cachedApps { cachedApps.append(contentsOf: cache) } } + if !cachedApps.isEmpty { + await processApps(cachedApps) + } + fetchTotal = leafRepos.count fetchProgress = 0 - for repo in leafRepos { updateRepoStatus(id: repo.id, status: .waiting) } - let semaphore = Semaphore(3) + + let semaphore = Semaphore(6) await withTaskGroup(of: Void.self) { group in for repo in leafRepos { group.addTask { @@ -960,25 +1017,24 @@ class AppStoreViewModel: ObservableObject { } } } - - // Save once at the end + + mergeTask?.cancel() + mergeTask = nil + if !pendingMergeApps.isEmpty { + let remaining = pendingMergeApps + pendingMergeApps = [] + await _flushMerge(remaining) + } + saveRepos() - - await refreshDisplayApps() isLoading = false } - // Helper for consistent key generation across processes nonisolated static func generateGroupingKey(for app: AppItem, strict: Bool) -> String { if strict { - // Combine bundleID (or url) + name + repo + description to enforce strict separation let bid = (app.bundleIdentifier != "unknown" && !app.bundleIdentifier.isEmpty) ? app.bundleIdentifier : app.downloadURL - let name = app.name - let repo = app.sourceRepoName ?? "" - let desc = app.localizedDescription ?? "" - return "\(bid)|#|\(name)|#|\(repo)|#|\(desc)" + return "\(bid)|#|\(app.name)|#|\(app.sourceRepoName ?? "")|#|\(app.localizedDescription ?? "")" } else { - // Default grouping: Only Bundle Identifier matters if app.bundleIdentifier != "unknown" && !app.bundleIdentifier.isEmpty { return app.bundleIdentifier } @@ -991,64 +1047,43 @@ class AppStoreViewModel: ObservableObject { var allApps: [AppItem] = [] for repo in enabled { if let cache = repo.cachedApps { allApps.append(contentsOf: cache) } } await processApps(allApps) + resetPagination() } private func processApps(_ apps: [AppItem]) async { let sortOrder = self.appSortOrder - let isStrict = self.strictGrouping // Capture for detached task - - // Offload heavy sorting/filtering to background + let isStrict = self.strictGrouping let (grouped, displayList) = await Task.detached(priority: .userInitiated) { - // Group using the helper let grouped = Dictionary(grouping: apps) { app in AppStoreViewModel.generateGroupingKey(for: app, strict: isStrict) } - var representatives: [AppItem] = [] for (_, versions) in grouped { - // Latest version based on version string comparison (or date) if let latest = versions.sorted(by: { - // Try numeric comparison first, fallback to date - let v1 = $0.version - let v2 = $1.version - if v1 != v2 { - return v1.compare(v2, options: .numeric) == .orderedDescending - } + let v1 = $0.version; let v2 = $1.version + if v1 != v2 { return v1.compare(v2, options: .numeric) == .orderedDescending } return ($0.versionDate ?? "") > ($1.versionDate ?? "") }).first { representatives.append(latest) } } - let sorted = representatives.sorted { lhs, rhs in switch sortOrder { - case .name: - return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending - case .date: - let lDate = lhs.versionDate ?? "" - let rDate = rhs.versionDate ?? "" - return lDate > rDate - case .size: - let lSize = lhs.size ?? 0 - let rSize = rhs.size ?? 0 - return lSize > rSize + case .name: return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending + case .date: return (lhs.versionDate ?? "") > (rhs.versionDate ?? "") + case .size: return (lhs.size ?? 0) > (rhs.size ?? 0) } } return (grouped, sorted) }.value - self.allAppsByVariant = grouped self.displayApps = displayList } - // Updated to use the correct key logic func getVersions(for app: AppItem) -> [AppItem] { let key = AppStoreViewModel.generateGroupingKey(for: app, strict: self.strictGrouping) guard let list = allAppsByVariant[key] else { return [] } - - return list.sorted { - $0.version.compare($1.version, options: .numeric) == .orderedDescending - } + return list.sorted { $0.version.compare($1.version, options: .numeric) == .orderedDescending } } } diff --git a/LBox/ContentView.swift b/LBox/ContentView.swift index 4c0d919..c0502bd 100644 --- a/LBox/ContentView.swift +++ b/LBox/ContentView.swift @@ -8,18 +8,16 @@ struct ContentView: View { @State private var selectedTab: Int = 0 @State private var showSetupAlert = false @State private var showSetupPicker = false - @State private var showFilePickerHelp = false + @State private var showFilePickerHelp = false @AppStorage("kHasAskedForLiveContainerSetup") private var hasAskedForSetup = false - - // Alert States + @State private var verificationBackup: AppBackup? = nil - @State private var showConflictAlert = false - + @State private var showConflictAlert = false + var body: some View { ZStack { mainTabView .environmentObject(downloadManager) - InAppNotificationView() } .task { await performInitialSetup() } @@ -27,77 +25,59 @@ struct ContentView: View { .onChange(of: downloadManager.installedApps) { newApps in viewModel.checkForUpdates(installedApps: newApps) } .onChange(of: showSetupPicker) { isPresented in checkForPickerFailure(isPresented: isPresented) } .onChange(of: scenePhase) { phase in handleScenePhase(phase) } - - // Watch pendingInstallation to trigger local alert state - .onChange(of: downloadManager.pendingInstallation?.id) { newID in - if newID != nil { - showConflictAlert = true - } else { - showConflictAlert = false - // Conflict resolution finished (or cancelled). - // Check if we have an incomplete update (backup exists, app config missing). - // We add a small delay to ensure the previous alert effectively dismissed in the UI system - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - if let backup = downloadManager.pendingBackups.last { - // If checkUpdateStatus returns false, it means the app is installed but lacks config (needs run). - if !downloadManager.checkUpdateStatus(for: backup) { - verificationBackup = backup - } + .onChange(of: downloadManager.pendingInstallation?.id) { newID in + if newID != nil { + showConflictAlert = true + } else { + showConflictAlert = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + if let backup = downloadManager.pendingBackups.last { + if !downloadManager.checkUpdateStatus(for: backup) { + verificationBackup = backup } } } } - - // MARK: - Alerts - .alert("Complete Update", isPresented: updateAlertBinding, presenting: verificationBackup) { backup in - Button("Open LiveContainer") { - let folderName = backup.originalInstallPath - if let url = URL(string: "livecontainer://livecontainer-launch?bundle-name=\(folderName)") { - UIApplication.shared.open(url) - } + } + .alert("Complete Update", isPresented: updateAlertBinding, presenting: verificationBackup) { backup in + Button("Open LiveContainer") { + let folderName = backup.originalInstallPath + if let url = URL(string: "livecontainer://livecontainer-launch?bundle-name=\(folderName)") { + UIApplication.shared.open(url) } - Button("Cancel Update (Restore)") { downloadManager.restoreBackup(backup) } - // Modified to separate delete logic to optionally delete containers - Button("Delete Backup", role: .destructive) { downloadManager.discardBackup(backup, deleteContainers: true) } - } message: { backup in - Text("Please run '\(backup.appName)' in LiveContainer to finalize the update, and then return to LBox.\n\nIf you have already run it and the update is not detected, you can try again or restore the previous version.") - } - .alert("Setup LiveContainer", isPresented: $showSetupAlert) { - Button("Select Folder") { hasAskedForSetup = true; showSetupPicker = true } - Button("Trouble Selecting?", role: .none) { showFilePickerHelp = true } - Button("Later", role: .cancel) { hasAskedForSetup = true } - } message: { - Text("To enable auto-installation and launching, please select your LiveContainer storage directory.") - } - .fileImporter(isPresented: $showSetupPicker, allowedContentTypes: [.folder]) { res in - if case .success(let url) = res { downloadManager.setCustomFolder(url, forApps: true) } } - .alert("Have trouble selecting?", isPresented: $showFilePickerHelp) { - Button("Open LiveContainer") { - if let url = URL(string: "livecontainer://install") { UIApplication.shared.open(url) } - } - Button("Cancel", role: .cancel) { } - } message: { - Text("Solution: Open LiveContainer, find LBox in the \"My Apps\" list, press and hold the icon, tap Settings, and enable Fix File Picker. Then try selecting the directory again.") - } - // Use local state binding for Conflict Alert to prevent flickering - .alert("App Conflict", isPresented: $showConflictAlert, presenting: downloadManager.pendingInstallation) { pending in - Button("Update Existing") { - downloadManager.finalizeInstallation(action: .updateExisting) - // We intentionally do NOT clear pendingInstallation here manually to allow background task to finish - // showConflictAlert becomes false automatically by SwiftUI on button press - } - Button("Install as Separate App") { - downloadManager.finalizeInstallation(action: .installSeparate) - } - Button("Cancel", role: .cancel) { - downloadManager.finalizeInstallation(action: .cancel) - } - } message: { pending in - Text("An app with the Bundle ID '\(pending.bundleID)' is already installed (\(pending.appName)). Would you like to update it (preserving data) or install it separately?") + Button("Cancel Update (Restore)") { downloadManager.restoreBackup(backup) } + Button("Delete Backup", role: .destructive) { downloadManager.discardBackup(backup, deleteContainers: true) } + } message: { backup in + Text("Please run '\(backup.appName)' in LiveContainer to finalize the update, and then return to LBox.\n\nIf you have already run it and the update is not detected, you can try again or restore the previous version.") + } + .alert("Setup LiveContainer", isPresented: $showSetupAlert) { + Button("Select Folder") { hasAskedForSetup = true; showSetupPicker = true } + Button("Trouble Selecting?", role: .none) { showFilePickerHelp = true } + Button("Later", role: .cancel) { hasAskedForSetup = true } + } message: { + Text("To enable auto-installation and launching, please select your LiveContainer storage directory.") + } + .fileImporter(isPresented: $showSetupPicker, allowedContentTypes: [.folder]) { res in + if case .success(let url) = res { downloadManager.setCustomFolder(url, forApps: true) } + } + .alert("Have trouble selecting?", isPresented: $showFilePickerHelp) { + Button("Open LiveContainer") { + if let url = URL(string: "livecontainer://install") { UIApplication.shared.open(url) } } + Button("Cancel", role: .cancel) { } + } message: { + Text("Solution: Open LiveContainer, find LBox in the \"My Apps\" list, press and hold the icon, tap Settings, and enable Fix File Picker. Then try selecting the directory again.") + } + .alert("App Conflict", isPresented: $showConflictAlert, presenting: downloadManager.pendingInstallation) { pending in + Button("Update Existing") { downloadManager.finalizeInstallation(action: .updateExisting) } + Button("Install as Separate App") { downloadManager.finalizeInstallation(action: .installSeparate) } + Button("Cancel", role: .cancel) { downloadManager.finalizeInstallation(action: .cancel) } + } message: { pending in + Text("An app with the Bundle ID '\(pending.bundleID)' is already installed (\(pending.appName)). Would you like to update it (preserving data) or install it separately?") + } } - + var mainTabView: some View { TabView(selection: $selectedTab) { StoreView(viewModel: viewModel) @@ -114,12 +94,11 @@ struct ContentView: View { .tag(3) } } - + var updateAlertBinding: Binding { Binding(get: { verificationBackup != nil }, set: { if !$0 { verificationBackup = nil } }) } - // Removed conflictAlertBinding computed property in favor of local @State - + func performInitialSetup() async { if viewModel.displayApps.isEmpty { await viewModel.fetchAllRepos() } downloadManager.refreshFileList() @@ -131,6 +110,7 @@ struct ContentView: View { showSetupAlert = true } } + func checkForPickerFailure(isPresented: Bool) { if !isPresented { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { @@ -140,13 +120,11 @@ struct ContentView: View { } } } + func handleScenePhase(_ phase: ScenePhase) { NotificationManager.shared.isAppInForeground = (phase == .active) - if phase == .active { - // Check the backup currently being verified, or fall back to the latest one let backupToCheck = verificationBackup ?? downloadManager.pendingBackups.last - if let backup = backupToCheck { if downloadManager.checkUpdateStatus(for: backup) { verificationBackup = nil @@ -159,8 +137,10 @@ struct ContentView: View { } // MARK: - Store View + struct StoreView: View { @ObservedObject var viewModel: AppStoreViewModel + var body: some View { NavigationStack { List { @@ -181,7 +161,7 @@ struct StoreView: View { .listRowBackground(Color.clear) .listRowSeparator(.hidden) } - + if !viewModel.savedRepos.isEmpty { Picker("Source", selection: $viewModel.selectedRepoID) { Text("All Sources").tag(String?.none) @@ -191,11 +171,22 @@ struct StoreView: View { } .pickerStyle(.menu).listRowBackground(Color.clear).padding(.bottom, 5) } - ForEach(viewModel.filteredApps) { app in + + ForEach(viewModel.filteredApps.prefix(viewModel.visibleAppCount)) { app in NavigationLink(destination: AppDetailView(app: app, viewModel: viewModel)) { AppListRow(app: app) } } + + if viewModel.visibleAppCount < viewModel.filteredApps.count { + Color.clear + .frame(height: 1) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + .onAppear { + viewModel.loadNextPage() + } + } } .navigationTitle("Store") .toolbar { @@ -217,19 +208,27 @@ struct StoreView: View { } } +// MARK: - App List Row + struct AppListRow: View { let app: AppItem @EnvironmentObject var downloadManager: DownloadManager @EnvironmentObject var viewModel: AppStoreViewModel + var updateAvailable: Bool { guard let installedVer = downloadManager.getInstalledVersion(bundleID: app.bundleIdentifier) else { return false } return app.version.compare(installedVer, options: .numeric) == .orderedDescending } + var body: some View { HStack(spacing: 16) { AsyncImage(url: URL(string: app.iconURL ?? "")) { phase in if let image = phase.image { image.resizable() } else { Color.gray.opacity(0.2) } - }.aspectRatio(contentMode: .fill).frame(width: 60, height: 60).clipShape(RoundedRectangle(cornerRadius: 12)) + } + .aspectRatio(contentMode: .fill) + .frame(width: 60, height: 60) + .clipShape(RoundedRectangle(cornerRadius: 12)) + VStack(alignment: .leading, spacing: 4) { HStack { Text(app.name).font(.headline).lineLimit(1) @@ -246,26 +245,64 @@ struct AppListRow: View { .clipShape(Capsule()) } } - if let r = app.sourceRepoName { Text("via " + r).font(.caption2).foregroundColor(.blue) } - if let d = app.localizedDescription { Text(d).font(.caption).foregroundColor(.secondary).lineLimit(2) } + if let r = app.sourceRepoName { + Text("via " + r).font(.caption2).foregroundColor(.blue) + } + if let d = app.localizedDescription { + Text(d).font(.caption).foregroundColor(.secondary).lineLimit(2) + } } Spacer() - }.padding(.vertical, 4) + } + .padding(.vertical, 4) + } +} + +// MARK: - Local App Icon (async, non-blocking) + +struct LocalAppIcon: View { + let iconURL: URL? + @State private var image: UIImage? = nil + + var body: some View { + Group { + if let img = image { + Image(uiImage: img) + .resizable() + } else { + Color.gray.opacity(0.2) + } + } + .frame(width: 70, height: 70) + .cornerRadius(14) + .task(id: iconURL) { + guard let url = iconURL else { image = nil; return } + let loaded = await Task.detached(priority: .userInitiated) { + (try? Data(contentsOf: url)).flatMap { UIImage(data: $0) } + }.value + image = loaded + } } } +// MARK: - Installed Apps View + struct InstalledAppsView: View { @EnvironmentObject var downloadManager: DownloadManager @Binding var selectedTab: Int @ObservedObject var viewModel: AppStoreViewModel - // Changed alignment to .top to prevent icons from shifting when "Update" button is present let columns = [GridItem(.adaptive(minimum: 80, maximum: 100), spacing: 20, alignment: .top)] + var body: some View { NavigationStack { ScrollView { if downloadManager.installedApps.isEmpty { - ContentUnavailableView("No Apps", systemImage: "square.dashed", description: Text("Apps extracted to the applications folder will appear here.")) - .padding(.top, 50) + ContentUnavailableView( + "No Apps", + systemImage: "square.dashed", + description: Text("Apps extracted to the applications folder will appear here.") + ) + .padding(.top, 50) } else { LazyVGrid(columns: columns, spacing: 24) { ForEach(downloadManager.installedApps) { app in @@ -289,15 +326,17 @@ struct InstalledAppsView: View { } } +// MARK: - Installed App Item + struct InstalledAppItem: View { let app: LocalApp @Binding var selectedTab: Int @ObservedObject var viewModel: AppStoreViewModel @EnvironmentObject var downloadManager: DownloadManager @State private var showSetupNeeded = false - var newVersion: String? { - viewModel.availableUpdates[app.bundleID] - } + + var newVersion: String? { viewModel.availableUpdates[app.bundleID] } + var body: some View { Button { if downloadManager.hasLCAppInfo(bundleID: app.bundleID) { @@ -309,34 +348,44 @@ struct InstalledAppItem: View { } label: { VStack(spacing: 6) { ZStack(alignment: .topTrailing) { - if let icon = app.iconURL, let data = try? Data(contentsOf: icon), let img = UIImage(data: data) { - Image(uiImage: img).resizable().frame(width: 70, height: 70).cornerRadius(14) - } else { - Image(systemName: "app.fill").resizable().frame(width: 70, height: 70).foregroundColor(.gray).cornerRadius(14) - } + LocalAppIcon(iconURL: app.iconURL) if newVersion != nil { Circle().fill(Color.red).frame(width: 12, height: 12).offset(x: 4, y: -4) } } Text(app.name).font(.caption).lineLimit(2).frame(height: 35, alignment: .top) - if let newVer = newVersion { + if let _ = newVersion { Button { viewModel.searchText = app.bundleID - selectedTab = 0 + selectedTab = 0 } label: { - Text("Update").font(.system(size: 10, weight: .bold)).foregroundColor(.white).padding(.horizontal, 8).padding(.vertical, 4).background(Color.blue).clipShape(Capsule()) + Text("Update") + .font(.system(size: 10, weight: .bold)) + .foregroundColor(.white) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.blue) + .clipShape(Capsule()) } } } } .buttonStyle(.plain) .contextMenu { - Button(role: .destructive) { downloadManager.deleteApp(app) } label: { Label("Delete App", systemImage: "trash") } + Button(role: .destructive) { downloadManager.deleteApp(app) } label: { + Label("Delete App", systemImage: "trash") + } Divider() - Button { viewModel.searchText = app.name; selectedTab = 0 } label: { Label("Search Name", systemImage: "magnifyingglass") } - Button { viewModel.searchText = app.bundleID; selectedTab = 0 } label: { Label("Search Bundle ID", systemImage: "barcode.viewfinder") } + Button { viewModel.searchText = app.name; selectedTab = 0 } label: { + Label("Search Name", systemImage: "magnifyingglass") + } + Button { viewModel.searchText = app.bundleID; selectedTab = 0 } label: { + Label("Search Bundle ID", systemImage: "barcode.viewfinder") + } Divider() - if let ver = app.version { Button("Version: \(ver)") {}.disabled(true) } + if let ver = app.version { + Button("Version: \(ver)") {}.disabled(true) + } Button("Bundle: \(app.bundleID)") {}.disabled(true) Button("File: \(app.url.lastPathComponent)") {}.disabled(true) #if DEBUG @@ -366,6 +415,8 @@ struct InstalledAppItem: View { } } +// MARK: - Direct Download View + struct DirectDownloadView: View { @ObservedObject var viewModel: AppStoreViewModel @EnvironmentObject var downloadManager: DownloadManager @@ -373,6 +424,7 @@ struct DirectDownloadView: View { @State private var renamingFile: URL? @State private var newFileName = "" @State private var showFileImporter = false + var body: some View { NavigationStack { List { @@ -385,6 +437,7 @@ struct DirectDownloadView: View { if let url = URL(string: final) { downloadManager.startDownload(url: url); urlString = "" } }.disabled(urlString.isEmpty) } header: { Text("Download from URL") } + if !downloadManager.downloadStates.isEmpty { Section { ForEach(downloadManager.downloadStates.keys.sorted(by: { $0.absoluteString < $1.absoluteString }), id: \.self) { url in @@ -392,6 +445,7 @@ struct DirectDownloadView: View { } } header: { Text("Active Downloads") } } + Section { ForEach(downloadManager.fileList, id: \.self) { file in FileRow(localURL: file, onRename: { @@ -405,7 +459,8 @@ struct DirectDownloadView: View { Text("Files") Spacer() if !downloadManager.fileList.isEmpty { - Button("Clear All") { downloadManager.clearAllFiles() }.font(.caption).foregroundColor(.red) + Button("Clear All") { downloadManager.clearAllFiles() } + .font(.caption).foregroundColor(.red) } } } footer: { @@ -421,18 +476,14 @@ struct DirectDownloadView: View { } } .fileImporter(isPresented: $showFileImporter, allowedContentTypes: [.data, .archive]) { res in - if case .success(let url) = res { - downloadManager.importFile(at: url) - } + if case .success(let url) = res { downloadManager.importFile(at: url) } } .refreshable { downloadManager.refreshFileList() } .alert("Rename File", isPresented: Binding(get: { renamingFile != nil }, set: { if !$0 { renamingFile = nil } })) { TextField("Filename.ipa", text: $newFileName) Button("Cancel", role: .cancel) { renamingFile = nil } Button("Save") { - if let file = renamingFile { - downloadManager.renameFile(file, newName: newFileName) - } + if let file = renamingFile { downloadManager.renameFile(file, newName: newFileName) } renamingFile = nil } } @@ -440,20 +491,22 @@ struct DirectDownloadView: View { } } +// MARK: - Download Row + struct DownloadRow: View { let url: URL let status: DownloadStatus var viewModel: AppStoreViewModel? = nil @EnvironmentObject var downloadManager: DownloadManager + func getFallbackTotalSize() -> Int64? { guard let vm = viewModel else { return nil } for (_, apps) in vm.allAppsByVariant { - if let match = apps.first(where: { $0.downloadURL == url.absoluteString }) { - return match.size - } + if let match = apps.first(where: { $0.downloadURL == url.absoluteString }) { return match.size } } return nil } + var body: some View { HStack(spacing: 12) { Image(systemName: "arrow.down.doc.fill").font(.title2).foregroundColor(.blue) @@ -466,12 +519,10 @@ struct DownloadRow: View { Text("•") if total > 0 { Text("\(format(written)) / \(format(total))") + } else if let fallback = getFallbackTotalSize() { + Text("\(format(written)) / \(format(fallback))") } else { - if let fallback = getFallbackTotalSize() { - Text("\(format(written)) / \(format(fallback))") - } else { - Text(format(written)) - } + Text(format(written)) } } .font(.caption).foregroundColor(.secondary) @@ -486,23 +537,31 @@ struct DownloadRow: View { Spacer() Button { if case .paused = status { downloadManager.resumeDownload(url: url) } - else if case .waitingForConnection = status { downloadManager.pauseDownload(url: url) } else { downloadManager.pauseDownload(url: url) } } label: { ZStack { Circle().stroke(lineWidth: 3).opacity(0.3).foregroundColor(.blue) if case .downloading(let p, _, _) = status { - Circle().trim(from: 0.0, to: CGFloat(max(0.01, p))).stroke(style: StrokeStyle(lineWidth: 3, lineCap: .round)).rotationEffect(.degrees(270)).foregroundColor(.blue) + Circle().trim(from: 0.0, to: CGFloat(max(0.01, p))) + .stroke(style: StrokeStyle(lineWidth: 3, lineCap: .round)) + .rotationEffect(.degrees(270)) + .foregroundColor(.blue) } - Image(systemName: (status == .paused || status == .waitingForConnection) ? "play.fill" : "pause.fill").font(.system(size: 10)) + Image(systemName: (status == .paused || status == .waitingForConnection) ? "play.fill" : "pause.fill") + .font(.system(size: 10)) }.frame(width: 28, height: 28) }.buttonStyle(.plain) - Button { downloadManager.cancelDownload(url: url) } label: { Image(systemName: "xmark.circle.fill").foregroundColor(.gray).font(.title3) }.buttonStyle(.plain) + Button { downloadManager.cancelDownload(url: url) } label: { + Image(systemName: "xmark.circle.fill").foregroundColor(.gray).font(.title3) + }.buttonStyle(.plain) }.padding(.vertical, 4) } + func format(_ bytes: Int64) -> String { ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file) } } +// MARK: - File Row + struct FileRow: View { let localURL: URL @EnvironmentObject var manager: DownloadManager @@ -510,6 +569,7 @@ struct FileRow: View { @State private var tempURL: URL? var onRename: (() -> Void)? = nil var isExtracting: Bool { manager.extractingFiles.contains(localURL) } + var body: some View { HStack { Image(systemName: isArchive ? "archivebox.fill" : "doc.fill").foregroundStyle(.blue) @@ -532,23 +592,33 @@ struct FileRow: View { if let onRename = onRename { Button { onRename() } label: { Label("Rename", systemImage: "pencil") } } - Button(role: .destructive) { manager.deleteFile(localURL) } label: { Label("Delete", systemImage: "trash") } + Button(role: .destructive) { manager.deleteFile(localURL) } label: { + Label("Delete", systemImage: "trash") + } if isArchive { - Button { manager.convertToApp(file: localURL) } label: { Label("Convert to .app", systemImage: "cube") } + Button { manager.convertToApp(file: localURL) } label: { + Label("Convert to .app", systemImage: "cube") + } + } + Button { tempURL = prepareShare(localURL); showShare = true } label: { + Label("Share", systemImage: "square.and.arrow.up") } - Button { - tempURL = prepareShare(localURL); showShare = true - } label: { Label("Share", systemImage: "square.and.arrow.up") } } .disabled(isExtracting) - .sheet(isPresented: $showShare) { if let u = tempURL { ShareSheet(activityItems: [u]) } } + .sheet(isPresented: $showShare) { + if let u = tempURL { ShareSheet(activityItems: [u]) } + } } + var isArchive: Bool { let ext = localURL.pathExtension.lowercased() return ext == "ipa" || ext == "zip" } + func prepareShare(_ url: URL) -> URL? { - let tmp = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString).appendingPathComponent(url.lastPathComponent) + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathComponent(url.lastPathComponent) try? FileManager.default.createDirectory(at: tmp.deletingLastPathComponent(), withIntermediateDirectories: true) _ = url.startAccessingSecurityScopedResource() try? FileManager.default.copyItem(at: url, to: tmp) @@ -557,12 +627,13 @@ struct FileRow: View { } } +// MARK: - Settings View + struct SettingsView: View { @ObservedObject var viewModel: AppStoreViewModel @EnvironmentObject var dm: DownloadManager @State private var showFolderImporter = false @State private var isPickingLiveContainer = false - @State private var confirmReset = false @State private var showExporter = false @State private var showImporter = false @@ -570,41 +641,38 @@ struct SettingsView: View { @State private var showCopyAlert = false @State private var didSelectLiveContainer = false @State private var showSettingsHelp = false + var body: some View { NavigationStack { List { Section("Directories") { - HStack { + HStack { Text("Downloads") - Spacer() - if dm.customDownloadFolder != nil { - Button("Default") { dm.clearCustomFolder(forApps: false) }.buttonStyle(.bordered) + Spacer() + if dm.customDownloadFolder != nil { + Button("Default") { dm.clearCustomFolder(forApps: false) }.buttonStyle(.bordered) } - Button("Select") { - isPickingLiveContainer = false - showFolderImporter = true - } + Button("Select") { isPickingLiveContainer = false; showFolderImporter = true } } - HStack { + HStack { Text("LiveContainer") - Spacer() - if dm.customLiveContainerFolder != nil { - Button("Default") { dm.clearCustomFolder(forApps: true) }.buttonStyle(.bordered) + Spacer() + if dm.customLiveContainerFolder != nil { + Button("Default") { dm.clearCustomFolder(forApps: true) }.buttonStyle(.bordered) } - Button("Select") { - isPickingLiveContainer = true - showFolderImporter = true - } + Button("Select") { isPickingLiveContainer = true; showFolderImporter = true } } Toggle("Auto .ipa to .app", isOn: $dm.isAutoUnzipEnabled) } Section("Store") { - Toggle("Strict Grouping", isOn: $viewModel.strictGrouping) - if viewModel.strictGrouping { - Text("Apps with different sources, names, or descriptions will be listed separately.").font(.caption).foregroundColor(.secondary) - } else { - Text("Apps with the same Bundle ID will be grouped together regardless of source.").font(.caption).foregroundColor(.secondary) - } + Toggle("Strict Grouping", isOn: $viewModel.strictGrouping) + if viewModel.strictGrouping { + Text("Apps with different sources, names, or descriptions will be listed separately.") + .font(.caption).foregroundColor(.secondary) + } else { + Text("Apps with the same Bundle ID will be grouped together regardless of source.") + .font(.caption).foregroundColor(.secondary) + } } Section("Repositories") { NavigationLink("Manage Sources") { RepoManagementView(viewModel: viewModel, parentID: nil) } @@ -615,13 +683,25 @@ struct SettingsView: View { Section("Backup/Restore") { Menu("Export Repos...") { Button("Copy as JSON") { - if viewModel.savedRepos.contains(where: { $0.hasDisabledContentRecursive }) { showCopyAlert = true } else { if let s = viewModel.exportReposJSON() { UIPasteboard.general.string = s } } + if viewModel.savedRepos.contains(where: { $0.hasDisabledContentRecursive }) { + showCopyAlert = true + } else { + if let s = viewModel.exportReposJSON() { UIPasteboard.general.string = s } + } } Button("Copy as URL List") { UIPasteboard.general.string = viewModel.exportReposURLList() } if #available(iOS 16.0, *) { - ShareLink(item: viewModel.exportReposJSON() ?? "", subject: Text("LBox Repos"), preview: SharePreview("LBox Repos")) { Text("Share JSON File") } + ShareLink( + item: viewModel.exportReposJSON() ?? "", + subject: Text("LBox Repos"), + preview: SharePreview("LBox Repos") + ) { + Text("Share JSON File") + } } else { - Button("Share JSON File") { if let s = viewModel.exportReposJSON() { exportJSONString = s; showExporter = true } } + Button("Share JSON File") { + if let s = viewModel.exportReposJSON() { exportJSONString = s; showExporter = true } + } } } Button("Import Repos (JSON)") { showImporter = true } @@ -635,56 +715,78 @@ struct SettingsView: View { } } .navigationTitle("Settings") - .fileImporter(isPresented: $showFolderImporter, allowedContentTypes: [.folder]) { res in - if case .success(let url) = res { + .fileImporter(isPresented: $showFolderImporter, allowedContentTypes: [.folder]) { res in + if case .success(let url) = res { if isPickingLiveContainer { - dm.setCustomFolder(url, forApps: true) - didSelectLiveContainer = true + dm.setCustomFolder(url, forApps: true) + didSelectLiveContainer = true } else { dm.setCustomFolder(url, forApps: false) } - } + } } - .onChange(of: showFolderImporter) { isPresented in - // Only run Help logic if we were picking for LiveContainer + .onChange(of: showFolderImporter) { isPresented in if isPickingLiveContainer { - if !isPresented { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - if !didSelectLiveContainer { showSettingsHelp = true } - } - } else { - didSelectLiveContainer = false - } - } - } - .alert("Reset?", isPresented: $confirmReset) { Button("Cancel", role: .cancel) {}; Button("Reset", role: .destructive) { viewModel.resetReposToDefault() } } - - // Corrected Confirmation Dialog for SettingsView - .confirmationDialog("Copy Options", isPresented: $showCopyAlert) { - Button("Copy All") { - if let s = viewModel.exportReposJSON(onlyEnabled: false) { UIPasteboard.general.string = s } - } - Button("Only Enabled") { - if let s = viewModel.exportReposJSON(onlyEnabled: true) { UIPasteboard.general.string = s } - } - Button("Cancel", role: .cancel) { } + if !isPresented { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + if !didSelectLiveContainer { showSettingsHelp = true } + } + } else { + didSelectLiveContainer = false + } + } + } + .alert("Reset?", isPresented: $confirmReset) { + Button("Cancel", role: .cancel) {} + Button("Reset", role: .destructive) { viewModel.resetReposToDefault() } + } + .confirmationDialog("Copy Options", isPresented: $showCopyAlert) { + Button("Copy All") { + if let s = viewModel.exportReposJSON(onlyEnabled: false) { UIPasteboard.general.string = s } + } + Button("Only Enabled") { + if let s = viewModel.exportReposJSON(onlyEnabled: true) { UIPasteboard.general.string = s } + } + Button("Cancel", role: .cancel) { } } message: { Text("Some repositories are disabled. Which would you like to copy?") } - .sheet(isPresented: $showExporter) { ShareSheet(activityItems: [exportJSONString]) } .sheet(isPresented: $showImporter) { ImportJSONView(viewModel: viewModel) } - .alert("Have trouble selecting?", isPresented: $showSettingsHelp) { Button("Open LiveContainer") { if let url = URL(string: "livecontainer://install") { UIApplication.shared.open(url) } }; Button("Cancel", role: .cancel) { } } message: { Text("Solution: Open LiveContainer, find LBox in the \"My Apps\" list, press and hold the icon, tap Settings, and enable Fix File Picker. Then try selecting the directory again.") } + .alert("Have trouble selecting?", isPresented: $showSettingsHelp) { + Button("Open LiveContainer") { + if let url = URL(string: "livecontainer://install") { UIApplication.shared.open(url) } + } + Button("Cancel", role: .cancel) { } + } message: { + Text("Solution: Open LiveContainer, find LBox in the \"My Apps\" list, press and hold the icon, tap Settings, and enable Fix File Picker. Then try selecting the directory again.") + } } } } -// [RepoManagementView and helpers preserved] +// MARK: - Repo Management + struct RepoManagementView: View { - @ObservedObject var viewModel: AppStoreViewModel; let parentID: String? - @State private var showAdd = false; @State private var showCopyAlert = false; @State private var pendingCopyRepo: SavedRepo? = nil; @State private var copyMode: CopyMode = .json - @State private var showRenameAlert = false; @State private var renameText = ""; @State private var pendingRenameRepo: SavedRepo? = nil; @State private var showMoveSheet = false; @State private var pendingMoveRepo: SavedRepo? = nil + @ObservedObject var viewModel: AppStoreViewModel + let parentID: String? + + @State private var showAdd = false + @State private var showCopyAlert = false + @State private var pendingCopyRepo: SavedRepo? = nil + @State private var copyMode: CopyMode = .json + @State private var showRenameAlert = false + @State private var renameText = "" + @State private var pendingRenameRepo: SavedRepo? = nil + @State private var showMoveSheet = false + @State private var pendingMoveRepo: SavedRepo? = nil + enum CopyMode { case json, url } + var displayedRepos: [SavedRepo] { viewModel.getRepos(in: parentID) } - var isReadOnly: Bool { guard let pid = parentID, let parent = viewModel.getRepo(pid) else { return false }; return parent.isRemoteFolder } + var isReadOnly: Bool { + guard let pid = parentID, let parent = viewModel.getRepo(pid) else { return false } + return parent.isRemoteFolder + } + var body: some View { List { ForEach(displayedRepos) { repo in @@ -692,41 +794,310 @@ struct RepoManagementView: View { if repo.isFolder { NavigationLink(destination: RepoManagementView(viewModel: viewModel, parentID: repo.id)) { HStack { - if repo.isRemoteFolder { Image(systemName: "folder.badge.gear").foregroundColor(.purple) } else { Image(systemName: "folder.fill").foregroundColor(.blue) } - VStack(alignment: .leading) { Text(repo.name).foregroundColor(repo.isEnabled ? .primary : .secondary); Text("\(repo.totalRepoCount) repos • \(repo.totalAppCount) apps").font(.caption).foregroundColor(.secondary) } + if repo.isRemoteFolder { + Image(systemName: "folder.badge.gear").foregroundColor(.purple) + } else { + Image(systemName: "folder.fill").foregroundColor(.blue) + } + VStack(alignment: .leading) { + Text(repo.name).foregroundColor(repo.isEnabled ? .primary : .secondary) + Text("\(repo.totalRepoCount) repos • \(repo.totalAppCount) apps") + .font(.caption).foregroundColor(.secondary) + } } } - Spacer(); Toggle("", isOn: Binding(get: { repo.isEnabled }, set: { val in viewModel.setRepoEnabled(id: repo.id, enabled: val) })).labelsHidden() + Spacer() + Toggle("", isOn: Binding( + get: { repo.isEnabled }, + set: { val in viewModel.setRepoEnabled(id: repo.id, enabled: val) } + )).labelsHidden() } else { - if case .loading = repo.fetchStatus { ProgressView().scaleEffect(0.5).frame(width: 30, height: 30) } else if case .error = repo.fetchStatus { Image(systemName: "exclamationmark.triangle.fill").foregroundColor(.red).frame(width: 30, height: 30) } else if case .waiting = repo.fetchStatus { Image(systemName: "clock.fill").foregroundColor(.gray).frame(width: 30, height: 30) } else { AsyncImage(url: URL(string: repo.iconURL ?? "")) { p in if let i = p.image { i.resizable() } else { Image(systemName: "server.rack") } }.frame(width: 30, height: 30).cornerRadius(6) } - VStack(alignment: .leading) { Text(repo.name).foregroundColor(repo.isEnabled ? .primary : .secondary); Text(repo.url?.absoluteString ?? "").font(.caption).foregroundColor(.secondary).lineLimit(1) } - Spacer(); Text("\(repo.appCount)").font(.caption).foregroundColor(.secondary); Toggle("", isOn: Binding(get: { repo.isEnabled }, set: { val in viewModel.setRepoEnabled(id: repo.id, enabled: val) })).labelsHidden() + if case .loading = repo.fetchStatus { + ProgressView().scaleEffect(0.5).frame(width: 30, height: 30) + } else if case .error = repo.fetchStatus { + Image(systemName: "exclamationmark.triangle.fill").foregroundColor(.red).frame(width: 30, height: 30) + } else if case .waiting = repo.fetchStatus { + Image(systemName: "clock.fill").foregroundColor(.gray).frame(width: 30, height: 30) + } else { + AsyncImage(url: URL(string: repo.iconURL ?? "")) { p in + if let i = p.image { i.resizable() } else { Image(systemName: "server.rack") } + }.frame(width: 30, height: 30).cornerRadius(6) + } + VStack(alignment: .leading) { + Text(repo.name).foregroundColor(repo.isEnabled ? .primary : .secondary) + Text(repo.url?.absoluteString ?? "") + .font(.caption).foregroundColor(.secondary).lineLimit(1) + } + Spacer() + Text("\(repo.appCount)").font(.caption).foregroundColor(.secondary) + Toggle("", isOn: Binding( + get: { repo.isEnabled }, + set: { val in viewModel.setRepoEnabled(id: repo.id, enabled: val) } + )).labelsHidden() } - }.contextMenu { + } + .contextMenu { if repo.isFolder { - if repo.isRemoteFolder { Button { Task { await viewModel.fetchRemoteFolderList(folderID: repo.id) } } label: { Label("Update List", systemImage: "arrow.triangle.2.circlepath") }; if let listURL = repo.repoListURL { Button { UIPasteboard.general.string = listURL.absoluteString } label: { Label("Copy Folder URL", systemImage: "link") } } } - Button { pendingRenameRepo = repo; renameText = repo.name; showRenameAlert = true } label: { Label("Rename", systemImage: "pencil") } + if repo.isRemoteFolder { + Button { + Task { await viewModel.fetchRemoteFolderList(folderID: repo.id) } + } label: { + Label("Update List", systemImage: "arrow.triangle.2.circlepath") + } + if let listURL = repo.repoListURL { + Button { + UIPasteboard.general.string = listURL.absoluteString + } label: { + Label("Copy Folder URL", systemImage: "link") + } + } + } + Button { + pendingRenameRepo = repo + renameText = repo.name + showRenameAlert = true + } label: { + Label("Rename", systemImage: "pencil") + } } - Button { pendingMoveRepo = repo; showMoveSheet = true } label: { Label("Move...", systemImage: "folder.badge.gear") } - Button("Copy URLs") { if repo.hasDisabledContentRecursive { pendingCopyRepo = repo; copyMode = .url; showCopyAlert = true } else { UIPasteboard.general.string = repo.allURLs(onlyEnabled: false) } } - Button("Copy JSON") { if repo.hasDisabledContentRecursive { pendingCopyRepo = repo; copyMode = .json; showCopyAlert = true } else { if let s = viewModel.exportSingleRepoJSON(repo, onlyEnabled: false) { UIPasteboard.general.string = s } } } - if !isReadOnly { Button(role: .destructive) { viewModel.deleteRepo(id: repo.id) } label: { Label(repo.isFolder ? "Delete Folder" : "Delete", systemImage: "trash") } } + Button { pendingMoveRepo = repo; showMoveSheet = true } label: { + Label("Move...", systemImage: "folder.badge.gear") + } + Button("Copy URLs") { + if repo.hasDisabledContentRecursive { + pendingCopyRepo = repo; copyMode = .url; showCopyAlert = true + } else { + UIPasteboard.general.string = repo.allURLs(onlyEnabled: false) + } + } + Button("Copy JSON") { + if repo.hasDisabledContentRecursive { + pendingCopyRepo = repo; copyMode = .json; showCopyAlert = true + } else { + if let s = viewModel.exportSingleRepoJSON(repo, onlyEnabled: false) { + UIPasteboard.general.string = s + } + } + } + if !isReadOnly { + Button(role: .destructive) { viewModel.deleteRepo(id: repo.id) } label: { + Label(repo.isFolder ? "Delete Folder" : "Delete", systemImage: "trash") + } + } + } + } + .onDelete { offsets in + if isReadOnly { return } + offsets.forEach { index in + if index < displayedRepos.count { viewModel.deleteRepo(id: displayedRepos[index].id) } } - }.onDelete { offsets in if isReadOnly { return }; offsets.forEach { index in if index < displayedRepos.count { viewModel.deleteRepo(id: displayedRepos[index].id) } } } + } } .navigationTitle("Repos") - .toolbar { ToolbarItem(placement: .navigationBarTrailing) { HStack { if !isReadOnly { EditButton(); Button(action: { showAdd = true }) { Image(systemName: "plus") } } } } } - .sheet(isPresented: $showAdd) { AddRepoSheet(viewModel: viewModel, parentID: parentID) } - .sheet(isPresented: $showMoveSheet) { if let m = pendingMoveRepo { MoveRepoSheet(itemToMove: m, viewModel: viewModel) } } - .alert("Rename Folder", isPresented: $showRenameAlert) { TextField("New Name", text: $renameText); Button("Cancel", role: .cancel) { pendingRenameRepo = nil }; Button("Save") { if let r = pendingRenameRepo { viewModel.renameRepo(id: r.id, newName: renameText) }; pendingRenameRepo = nil } } - .confirmationDialog("Copy Options", isPresented: $showCopyAlert, titleVisibility: .visible) { Button("Copy All") { performCopy(all: true) }; Button("Only Enabled") { performCopy(all: false) }; Button("Cancel", role: .cancel) { pendingCopyRepo = nil } } message: { Text("This item contains disabled content.") } + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + HStack { + if !isReadOnly { + EditButton() + Button(action: { showAdd = true }) { Image(systemName: "plus") } + } + } + } + } + .sheet(isPresented: $showAdd) { + AddRepoSheet(viewModel: viewModel, parentID: parentID) + } + .sheet(isPresented: $showMoveSheet) { + if let m = pendingMoveRepo { MoveRepoSheet(itemToMove: m, viewModel: viewModel) } + } + .alert("Rename Folder", isPresented: $showRenameAlert) { + TextField("New Name", text: $renameText) + Button("Cancel", role: .cancel) { pendingRenameRepo = nil } + Button("Save") { + if let r = pendingRenameRepo { viewModel.renameRepo(id: r.id, newName: renameText) } + pendingRenameRepo = nil + } + } + .confirmationDialog("Copy Options", isPresented: $showCopyAlert, titleVisibility: .visible) { + Button("Copy All") { performCopy(all: true) } + Button("Only Enabled") { performCopy(all: false) } + Button("Cancel", role: .cancel) { pendingCopyRepo = nil } + } message: { + Text("This item contains disabled content.") + } } + func performCopy(all: Bool) { - guard let repo = pendingCopyRepo else { return }; if copyMode == .json { if let s = viewModel.exportSingleRepoJSON(repo, onlyEnabled: !all) { UIPasteboard.general.string = s } } else { UIPasteboard.general.string = repo.allURLs(onlyEnabled: !all) }; pendingCopyRepo = nil + guard let repo = pendingCopyRepo else { return } + if copyMode == .json { + if let s = viewModel.exportSingleRepoJSON(repo, onlyEnabled: !all) { UIPasteboard.general.string = s } + } else { + UIPasteboard.general.string = repo.allURLs(onlyEnabled: !all) + } + pendingCopyRepo = nil + } +} + +// MARK: - Move Repo Sheet + +struct MoveRepoSheet: View { + let itemToMove: SavedRepo + @ObservedObject var viewModel: AppStoreViewModel + @Environment(\.dismiss) var dismiss + var body: some View { + NavigationStack { + List { + Button { + viewModel.moveRepo(id: itemToMove.id, toParentId: nil) + dismiss() + } label: { + HStack { + Image(systemName: "house.fill") + Text("Root") + } + } + Section("Folders") { + ForEach(viewModel.getFolderTargets(excludingId: itemToMove.id)) { folder in + Button { + viewModel.moveRepo(id: itemToMove.id, toParentId: folder.id) + dismiss() + } label: { + HStack { + Image(systemName: "folder") + Text(folder.name) + } + } + } + } + } + .navigationTitle("Move to...") + .toolbar { + Button("Cancel") { dismiss() } + } + } } } -struct MoveRepoSheet: View { let itemToMove: SavedRepo; @ObservedObject var viewModel: AppStoreViewModel; @Environment(\.dismiss) var dismiss; var body: some View { NavigationStack { List { Button { viewModel.moveRepo(id: itemToMove.id, toParentId: nil); dismiss() } label: { HStack { Image(systemName: "house.fill"); Text("Root") } }; Section("Folders") { ForEach(viewModel.getFolderTargets(excludingId: itemToMove.id)) { folder in Button { viewModel.moveRepo(id: itemToMove.id, toParentId: folder.id); dismiss() } label: { HStack { Image(systemName: "folder"); Text(folder.name) } } } } }.navigationTitle("Move to...").toolbar { Button("Cancel") { dismiss() } } } } } -struct AddRepoSheet: View { @ObservedObject var viewModel: AppStoreViewModel; let parentID: String?; @Environment(\.dismiss) var dismiss; @State private var mode = 0; @State private var textInput = ""; @State private var folderName = ""; @State private var folderLink = ""; var body: some View { NavigationStack { Form { Picker("Add", selection: $mode) { Text("Sources").tag(0); Text("Folder").tag(1) }.pickerStyle(.segmented); if mode == 0 { Section(header: Text("URLs (One per line)")) { TextEditor(text: $textInput).frame(height: 150).autocorrectionDisabled().textInputAutocapitalization(.never); Button("Paste from Clipboard") { if let s = UIPasteboard.general.string { textInput = s } } } } else { Section(header: Text("New Folder")) { TextField("Name", text: $folderName); TextField("Link URL (Optional)", text: $folderLink).autocorrectionDisabled().textInputAutocapitalization(.never).keyboardType(.URL) }; Section(footer: Text("If a link is provided, this folder will automatically populate with repositories from that URL (one per line).")) { EmptyView() } } }.navigationTitle("Add").toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } }; ToolbarItem(placement: .confirmationAction) { Button("Save") { if mode == 0 { let lines = textInput.components(separatedBy: .newlines); for line in lines { let t = line.trimmingCharacters(in: .whitespacesAndNewlines); if !t.isEmpty { let fixed = (!t.lowercased().hasPrefix("http") ? "https://" + t : t); if let u = URL(string: fixed) { viewModel.addRepo(url: u, parentID: parentID) } } } } else { let link = folderLink.trimmingCharacters(in: .whitespacesAndNewlines); var finalName = folderName.trimmingCharacters(in: .whitespacesAndNewlines); if finalName.isEmpty && !link.isEmpty { finalName = link; if finalName.lowercased().hasPrefix("https://") { finalName = String(finalName.dropFirst(8)) } else if finalName.lowercased().hasPrefix("http://") { finalName = String(finalName.dropFirst(7)) } }; if !finalName.isEmpty { let url = link.isEmpty ? nil : URL(string: (!link.lowercased().hasPrefix("http") ? "https://" + link : link)); viewModel.addFolder(name: finalName, parentID: parentID, repoListURL: url) } }; dismiss() } } } } } } -struct ImportJSONView: View { @ObservedObject var viewModel: AppStoreViewModel; @Environment(\.dismiss) var dismiss; @State private var text = ""; var body: some View { NavigationStack { Form { TextEditor(text: $text).frame(height: 300); Button("Paste") { if let s = UIPasteboard.general.string { text = s } } }.navigationTitle("Import JSON").toolbar { Button("Import") { viewModel.importReposJSON(text); dismiss() } } } } } -struct ShareSheet: UIViewControllerRepresentable { let activityItems: [Any]; func makeUIViewController(context: Context) -> UIActivityViewController { UIActivityViewController(activityItems: activityItems, applicationActivities: nil) }; func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} } + +// MARK: - Add Repo Sheet + +struct AddRepoSheet: View { + @ObservedObject var viewModel: AppStoreViewModel + let parentID: String? + @Environment(\.dismiss) var dismiss + + @State private var mode = 0 + @State private var textInput = "" + @State private var folderName = "" + @State private var folderLink = "" + + var body: some View { + NavigationStack { + Form { + Picker("Add", selection: $mode) { + Text("Sources").tag(0) + Text("Folder").tag(1) + } + .pickerStyle(.segmented) + + if mode == 0 { + Section(header: Text("URLs (One per line)")) { + TextEditor(text: $textInput) + .frame(height: 150) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + Button("Paste from Clipboard") { + if let s = UIPasteboard.general.string { textInput = s } + } + } + } else { + Section(header: Text("New Folder")) { + TextField("Name", text: $folderName) + TextField("Link URL (Optional)", text: $folderLink) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + .keyboardType(.URL) + } + Section(footer: Text("If a link is provided, this folder will automatically populate with repositories from that URL (one per line).")) { + EmptyView() + } + } + } + .navigationTitle("Add") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button("Save") { + if mode == 0 { + let lines = textInput.components(separatedBy: .newlines) + for line in lines { + let t = line.trimmingCharacters(in: .whitespacesAndNewlines) + if !t.isEmpty { + let fixed = (!t.lowercased().hasPrefix("http") ? "https://" + t : t) + if let u = URL(string: fixed) { viewModel.addRepo(url: u, parentID: parentID) } + } + } + } else { + let link = folderLink.trimmingCharacters(in: .whitespacesAndNewlines) + var finalName = folderName.trimmingCharacters(in: .whitespacesAndNewlines) + if finalName.isEmpty && !link.isEmpty { + finalName = link + if finalName.lowercased().hasPrefix("https://") { + finalName = String(finalName.dropFirst(8)) + } else if finalName.lowercased().hasPrefix("http://") { + finalName = String(finalName.dropFirst(7)) + } + } + if !finalName.isEmpty { + let url = link.isEmpty ? nil : URL(string: (!link.lowercased().hasPrefix("http") ? "https://" + link : link)) + viewModel.addFolder(name: finalName, parentID: parentID, repoListURL: url) + } + } + dismiss() + } + } + } + } + } +} + +// MARK: - Import JSON View + +struct ImportJSONView: View { + @ObservedObject var viewModel: AppStoreViewModel + @Environment(\.dismiss) var dismiss + @State private var text = "" + + var body: some View { + NavigationStack { + Form { + TextEditor(text: $text) + .frame(height: 300) + Button("Paste") { + if let s = UIPasteboard.general.string { text = s } + } + } + .navigationTitle("Import JSON") + .toolbar { + Button("Import") { + viewModel.importReposJSON(text) + dismiss() + } + } + } + } +} + +// MARK: - Share Sheet + +struct ShareSheet: UIViewControllerRepresentable { + let activityItems: [Any] + + func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: activityItems, applicationActivities: nil) + } + + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} +} diff --git a/LBox/DownloadManager.swift b/LBox/DownloadManager.swift index d9e600e..08551fb 100644 --- a/LBox/DownloadManager.swift +++ b/LBox/DownloadManager.swift @@ -24,10 +24,10 @@ struct PendingInstallation: Identifiable { let id = UUID() let appName: String let bundleID: String - let tempPayloadURL: URL - let extractedAppURL: URL - let sourceArchiveURL: URL - let existingApp: LocalApp? + let tempPayloadURL: URL + let extractedAppURL: URL + let sourceArchiveURL: URL + let existingApp: LocalApp? } struct AppBackup: Codable, Identifiable, Sendable { @@ -35,10 +35,10 @@ struct AppBackup: Codable, Identifiable, Sendable { let bundleID: String let appName: String let version: String? - let backupPath: String - let originalInstallPath: String + let backupPath: String + let originalInstallPath: String let date: Date - let hadLCAppInfo: Bool + let hadLCAppInfo: Bool } @MainActor @@ -46,25 +46,16 @@ class DownloadManager: NSObject, ObservableObject { @Published var activeDownloads: [URL: Double] = [:] @Published var pausedDownloads: Set = [] - // Files in the Download Folder @Published var fileList: [URL] = [] - - // Installed Apps in the Apps Folder @Published var installedApps: [LocalApp] = [] - - // Files currently being extracted @Published var extractingFiles: Set = [] @Published var customDownloadFolder: URL? = nil - @Published var customLiveContainerFolder: URL? = nil + @Published var customLiveContainerFolder: URL? = nil - // Pending Installation (Collision) @Published var pendingInstallation: PendingInstallation? = nil - - // Backups for Updates @Published var pendingBackups: [AppBackup] = [] - // Changed to Published for reactive UI updates @Published var isAutoUnzipEnabled: Bool { didSet { UserDefaults.standard.set(isAutoUnzipEnabled, forKey: "kAutoUnzipEnabled") @@ -82,10 +73,8 @@ class DownloadManager: NSObject, ObservableObject { private let kCustomLiveContainerFolderKey = "kCustomLiveContainerFolderBookmark" private let kBackgroundSessionID = "com.lbox.downloadSession" private let kResumeDataMapKey = "kResumeDataMapKey" - private let kPendingBackupsKey = "kPendingBackupsKey" - // URL String -> Filename in Caches private var diskResumeDataPaths: [String: String] = [:] override init() { @@ -95,8 +84,8 @@ class DownloadManager: NSObject, ObservableObject { config.isDiscretionary = false config.sessionSendsLaunchEvents = true config.waitsForConnectivity = true - config.timeoutIntervalForResource = 86400 - config.timeoutIntervalForRequest = 600 + config.timeoutIntervalForResource = 86400 + config.timeoutIntervalForRequest = 600 self.urlSession = URLSession(configuration: config, delegate: self, delegateQueue: nil) @@ -206,16 +195,11 @@ class DownloadManager: NSObject, ObservableObject { } if hasLCAppInfo(bundleID: bundleID) { - // Verify if the plist is actually new (created after backup) - // This prevents using a plist that might have come with the IPA or wasn't removed correctly if let app = installedApps.first(where: { $0.bundleID == bundleID }) { let plistURL = app.url.appendingPathComponent("LCAppInfo.plist") - // 1. Check Creation Date if let attrs = try? FileManager.default.attributesOfItem(atPath: plistURL.path), let date = attrs[.creationDate] as? Date { - // If plist is older than backup, it's not the new one generated by LiveContainer - // We add a small buffer (e.g. 1 second) to avoid precision issues if date < backup.date.addingTimeInterval(1) { Logger.shared.log("CheckUpdate: LCAppInfo.plist is older than backup. Plist: \(date), Backup: \(backup.date)") return false @@ -223,22 +207,20 @@ class DownloadManager: NSObject, ObservableObject { Logger.shared.log("CheckUpdate: New LCAppInfo found. Plist: \(date), Backup: \(backup.date)") } } else { - // If we can't read attributes, assume it's invalid/old to be safe Logger.shared.log("CheckUpdate: Could not read attributes of LCAppInfo.plist") return false } - // 2. Check Content (Must have at least one container) if let data = try? Data(contentsOf: plistURL), let plist = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any] { if let containers = plist["LCContainers"] as? [[String: Any]] { if containers.isEmpty { - Logger.shared.log("CheckUpdate: LCAppInfo.plist has empty LCContainers. Waiting for LiveContainer to populate it.") + Logger.shared.log("CheckUpdate: LCAppInfo.plist has empty LCContainers.") return false } } else { - Logger.shared.log("CheckUpdate: LCAppInfo.plist missing LCContainers key. Waiting...") + Logger.shared.log("CheckUpdate: LCAppInfo.plist missing LCContainers key.") return false } } else { @@ -285,7 +267,6 @@ class DownloadManager: NSObject, ObservableObject { let newData = try Data(contentsOf: newInfoURL) var newPlist = try PropertyListSerialization.propertyList(from: newData, format: nil) as? [String: Any] ?? [:] - // NEW: Clean up newly created empty containers before restoring old ones if let newContainers = newPlist["LCContainers"] as? [[String: Any]], let dataAppFolder = currentDataApplicationFolder { for container in newContainers { @@ -299,7 +280,6 @@ class DownloadManager: NSObject, ObservableObject { } } - // Patch new plist with old data to preserve UserData for (key, value) in oldPlist { newPlist[key] = value } @@ -308,24 +288,19 @@ class DownloadManager: NSObject, ObservableObject { try patchedData.write(to: newInfoURL) Logger.shared.log("FinalizeUpdate: Patched LCAppInfo for \(backup.bundleID) with old data.") - // Only send notification and discard backup if we successfully patched sendNotification(title: "Update Completed", body: "\(backup.appName) has been successfully updated.", type: .success) discardBackup(backup) } catch { Logger.shared.log("FinalizeUpdate: Failed to patch LCAppInfo: \(error)") - // If patching failed, we might want to keep the backup or alert the user? - // For now, we'll assume if it failed, we can't recover easily, so we discard to avoid loops discardBackup(backup) } } else { - Logger.shared.log("FinalizeUpdate: Missing plist files (New: \(fileManager.fileExists(atPath: newInfoURL.path)), Old: \(fileManager.fileExists(atPath: oldInfoURL.path)))") - // If files are missing, we can't proceed - discardBackup(backup) + Logger.shared.log("FinalizeUpdate: Missing plist files (New: \(fileManager.fileExists(atPath: newInfoURL.path)), Old: \(fileManager.fileExists(atPath: oldInfoURL.path)))") + discardBackup(backup) } } - // ... [Remaining Backup, Restore, Task methods unchanged] func restoreBackup(_ backup: AppBackup) { let fileManager = FileManager.default let backupFolder = backupDirectory.appendingPathComponent(backup.backupPath) @@ -482,7 +457,7 @@ class DownloadManager: NSObject, ObservableObject { if forApps { self.customLiveContainerFolder = url - self.isAutoUnzipEnabled = true // Auto-enable as requested + self.isAutoUnzipEnabled = true refreshInstalledApps() } else { self.customDownloadFolder = url @@ -578,17 +553,29 @@ class DownloadManager: NSObject, ObservableObject { } catch { self.fileList = [] } } + // MARK: - refreshInstalledApps (off main thread) + func refreshInstalledApps() { - do { - let files = try FileManager.default.contentsOfDirectory(at: currentAppsFolder, includingPropertiesForKeys: nil) + let appsFolder = currentAppsFolder + + Task.detached(priority: .userInitiated) { var newApps: [LocalApp] = [] + + guard let files = try? FileManager.default.contentsOfDirectory( + at: appsFolder, + includingPropertiesForKeys: nil + ) else { + await MainActor.run { self.installedApps = [] } + return + } + for file in files where file.pathExtension == "app" { let plistURL = file.appendingPathComponent("Info.plist") var name = file.deletingPathExtension().lastPathComponent var bundleID = "unknown" var version: String? = nil var iconURL: URL? = nil - + if let plistData = try? Data(contentsOf: plistURL), let plist = try? PropertyListSerialization.propertyList(from: plistData, format: nil) as? [String: Any] { if let bid = plist["CFBundleIdentifier"] as? String { @@ -599,13 +586,12 @@ class DownloadManager: NSObject, ObservableObject { } else if let bundleName = plist["CFBundleName"] as? String { name = bundleName.trimmingCharacters(in: .whitespacesAndNewlines) } - if let ver = plist["CFBundleShortVersionString"] as? String { version = ver } else if let ver = plist["CFBundleVersion"] as? String { version = ver } - + var iconFiles: [String] = [] if let iconsDict = plist["CFBundleIcons"] as? [String: Any], let primaryIcon = iconsDict["CFBundlePrimaryIcon"] as? [String: Any], @@ -620,22 +606,27 @@ class DownloadManager: NSObject, ObservableObject { if let legacyFiles = plist["CFBundleIconFiles"] as? [String] { iconFiles.append(contentsOf: legacyFiles) } - + for iconName in iconFiles.reversed() { - if let found = findIconFile(in: file, name: iconName) { iconURL = found; break } + if let found = Self.findIconFile(in: file, name: iconName) { + iconURL = found + break + } } - if iconURL == nil { - iconURL = findIconFile(in: file, name: "AppIcon60x60") ?? findIconFile(in: file, name: "AppIcon") + iconURL = Self.findIconFile(in: file, name: "AppIcon60x60") + ?? Self.findIconFile(in: file, name: "AppIcon") } } newApps.append(LocalApp(name: name, bundleID: bundleID, version: version, url: file, iconURL: iconURL)) } - self.installedApps = newApps - } catch { self.installedApps = [] } + + await MainActor.run { self.installedApps = newApps } + } } - private func findIconFile(in folder: URL, name: String) -> URL? { + // Made static so it can be called from a detached task safely + private nonisolated static func findIconFile(in folder: URL, name: String) -> URL? { let extensions = ["png", "jpg"] let candidates = [name, "\(name)@2x", "\(name)@3x", "\(name)60x60@2x"] for c in candidates { @@ -775,9 +766,9 @@ class DownloadManager: NSObject, ObservableObject { if let existingApp = self.installedApps.first(where: { $0.bundleID == bundleID && bundleID != "unknown" }) { _ = await MainActor.run { self.pendingInstallation = PendingInstallation( - appName: existingApp.name, + appName: existingApp.name, bundleID: bundleID, - tempPayloadURL: payloadDir, + tempPayloadURL: payloadDir, extractedAppURL: appBundle, sourceArchiveURL: sourceURL, existingApp: existingApp @@ -966,7 +957,7 @@ extension DownloadManager: URLSessionDownloadDelegate { nonisolated func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { guard let sourceURL = downloadTask.originalRequest?.url else { return } let progress = totalBytesExpectedToWrite > 0 ? Double(totalBytesWritten) / Double(totalBytesExpectedToWrite) : 0.0 - Task { @MainActor in + Task { @MainActor in self.downloadStates[sourceURL] = .downloading(progress: progress, written: totalBytesWritten, total: totalBytesExpectedToWrite) } }