diff --git a/Feather/Backend/Observable/UpdateManager.swift b/Feather/Backend/Observable/UpdateManager.swift new file mode 100644 index 00000000..052d92e2 --- /dev/null +++ b/Feather/Backend/Observable/UpdateManager.swift @@ -0,0 +1,131 @@ +// +// UpdateManager.swift +// Feather +// +// Created by Dominic on 24.05.2026. +// + +import AltSourceKit +import CoreData +import Foundation +import NimbleJSON + +struct AppUpdate: Identifiable, Equatable { + let id: String + let localUUID: String + let localVersion: String? + let remoteVersion: String + let appName: String + let bundleIdentifier: String + let downloadURL: URL + let sourceURL: URL +} + +@MainActor +final class UpdateManager: ObservableObject { + static let shared = UpdateManager() + + typealias RepositoryDataHandler = Result + + @Published private(set) var updates: [String: AppUpdate] = [:] + @Published private(set) var isChecking = false + @Published private(set) var lastCheckedDate: Date? + + private let _dataService = NBFetchService() + + private init() {} + + func update(for app: AppInfoPresentable) -> AppUpdate? { + guard let uuid = app.uuid else { return nil } + return updates[uuid] + } + + func checkForUpdates( + sources: [AltSource], + localApps: [AppInfoPresentable] + ) async { + guard !isChecking else { return } + + isChecking = true + defer { + isChecking = false + lastCheckedDate = Date() + } + + let repositories = await _fetchRepositories(from: sources) + updates = _findUpdates(repositories: repositories, localApps: localApps) + } + + private func _fetchRepositories(from sources: [AltSource]) async -> [(AltSource, ASRepository)] { + var repositories: [(AltSource, ASRepository)] = [] + + for source in sources { + guard + let url = source.sourceURL, + let repository = await _fetchRepository(from: url) + else { + continue + } + + repositories.append((source, repository)) + } + + return repositories + } + + private func _fetchRepository(from url: URL) async -> ASRepository? { + await withCheckedContinuation { continuation in + _dataService.fetch(from: url) { (result: RepositoryDataHandler) in + switch result { + case .success(let repository): + continuation.resume(returning: repository) + case .failure: + continuation.resume(returning: nil) + } + } + } + } + + private func _findUpdates( + repositories: [(AltSource, ASRepository)], + localApps: [AppInfoPresentable] + ) -> [String: AppUpdate] { + var foundUpdates: [String: AppUpdate] = [:] + + for localApp in localApps { + guard + let localUUID = localApp.uuid, + let localIdentifier = localApp.identifier + else { + continue + } + + for (source, repository) in repositories { + guard + let sourceURL = source.sourceURL, + let remoteApp = repository.apps.first(where: { $0.id == localIdentifier }), + let remoteVersion = remoteApp.currentVersion, + !remoteVersion.isEmpty, + remoteVersion != localApp.version, + let downloadURL = remoteApp.currentDownloadUrl + else { + continue + } + + foundUpdates[localUUID] = AppUpdate( + id: localUUID, + localUUID: localUUID, + localVersion: localApp.version, + remoteVersion: remoteVersion, + appName: remoteApp.currentName, + bundleIdentifier: localIdentifier, + downloadURL: downloadURL, + sourceURL: sourceURL + ) + break + } + } + + return foundUpdates + } +} diff --git a/Feather/Views/Library/LibraryCellView.swift b/Feather/Views/Library/LibraryCellView.swift index 27ec7aad..d0b8a690 100644 --- a/Feather/Views/Library/LibraryCellView.swift +++ b/Feather/Views/Library/LibraryCellView.swift @@ -13,6 +13,7 @@ import NimbleViews struct LibraryCellView: View { @Environment(\.horizontalSizeClass) private var horizontalSizeClass @Environment(\.editMode) private var editMode + @ObservedObject private var updateManager = UpdateManager.shared var certInfo: Date.ExpirationInfo? { Storage.shared.getCertificate(from: app)?.expiration?.expirationInfo() @@ -129,6 +130,12 @@ extension LibraryCellView { @ViewBuilder private func _contextActionsExtra(for app: AppInfoPresentable) -> some View { + if let update = updateManager.update(for: app) { + Button(.localized("Update"), systemImage: "arrow.down.circle") { + _startUpdateDownload(update) + } + } + if app.isSigned { if let id = app.identifier { Button(.localized("Open"), systemImage: "app.badge.checkmark") { @@ -157,7 +164,17 @@ extension LibraryCellView { @ViewBuilder private func _buttonActions(for app: AppInfoPresentable) -> some View { Group { - if app.isSigned { + if let update = updateManager.update(for: app) { + Button { + _startUpdateDownload(update) + } label: { + FRExpirationPillView( + title: .localized("Update"), + revoked: false, + expiration: nil + ) + } + } else if app.isSigned { Button { selectedInstallAppPresenting = AnyApp(base: app) } label: { @@ -181,4 +198,11 @@ extension LibraryCellView { } .buttonStyle(.borderless) } + + private func _startUpdateDownload(_ update: AppUpdate) { + _ = DownloadManager.shared.startDownload( + from: update.downloadURL, + id: "FeatherManualDownload_Update_\(update.localUUID)" + ) + } } diff --git a/Feather/Views/Library/LibraryView.swift b/Feather/Views/Library/LibraryView.swift index 21b4d943..710d9ee6 100644 --- a/Feather/Views/Library/LibraryView.swift +++ b/Feather/Views/Library/LibraryView.swift @@ -12,6 +12,7 @@ import NimbleViews // MARK: - View struct LibraryView: View { @StateObject var downloadManager = DownloadManager.shared + @StateObject var updateManager = UpdateManager.shared @State private var _selectedInfoAppPresenting: AnyApp? @State private var _selectedSigningAppPresenting: AnyApp? @@ -59,6 +60,12 @@ struct LibraryView: View { animation: .snappy ) private var _importedApps: FetchedResults + @FetchRequest( + entity: AltSource.entity(), + sortDescriptors: [NSSortDescriptor(keyPath: \AltSource.name, ascending: true)], + animation: .snappy + ) private var _sources: FetchedResults + // MARK: Body var body: some View { NBNavigationView(.localized("Library")) { @@ -146,6 +153,18 @@ struct LibraryView: View { _bulkDeleteSelectedApps() } } else { + NBToolbarButton( + .localized("Check for Updates"), + systemImage: "arrow.triangle.2.circlepath", + style: .icon, + placement: .topBarTrailing, + isDisabled: updateManager.isChecking + ) { + Task { + await _checkForUpdates() + } + } + NBToolbarMenu( systemImage: "plus", style: .icon, @@ -253,6 +272,14 @@ extension LibraryView { return allApps } + + private func _checkForUpdates() async { + let localApps = _signedApps.map { $0 as AppInfoPresentable } + _importedApps.map { $0 as AppInfoPresentable } + await updateManager.checkForUpdates( + sources: Array(_sources), + localApps: localApps + ) + } } // MARK: - Extension: View (Sort)