From 8a8af1c84f6489c7aaad39514d8afd313568df4c Mon Sep 17 00:00:00 2001 From: Dominic <127579510+Tanakrit-D@users.noreply.github.com> Date: Tue, 26 May 2026 11:58:38 +1200 Subject: [PATCH] feat: app update (provenance implementation) --- .../Backend/Observable/DownloadManager.swift | 15 +- .../Backend/Observable/UpdateManager.swift | 256 ++++++++++++++++++ .../Feather.xcdatamodel/contents | 17 +- Feather/Backend/Storage/Storage+Shared.swift | 2 + .../Storage/Storage+SourceMetadata.swift | 192 +++++++++++++ Feather/Backend/Storage/Storage.swift | 3 + Feather/Utilities/FR.swift | 7 +- .../Utilities/Handlers/AppFileHandler.swift | 20 +- .../Utilities/Handlers/SigningHandler.swift | 7 + Feather/Views/Library/LibraryCellView.swift | 87 +++++- Feather/Views/Library/LibraryView.swift | 61 +++++ Feather/Views/Settings/Reset/ResetView.swift | 2 + .../Apps/Detail/SourceAppsDetailView.swift | 14 +- .../Apps/Detail/VersionHistoryView.swift | 11 +- .../Sources/Apps/DownloadButtonView.swift | 13 +- .../Sources/Apps/SourceAppsCellView.swift | 7 +- .../Views/Sources/Apps/SourceAppsView.swift | 43 ++- .../SourceAppsTableRepresentableView.swift | 80 ++++-- 18 files changed, 771 insertions(+), 66 deletions(-) create mode 100644 Feather/Backend/Observable/UpdateManager.swift create mode 100644 Feather/Backend/Storage/Storage+SourceMetadata.swift diff --git a/Feather/Backend/Observable/DownloadManager.swift b/Feather/Backend/Observable/DownloadManager.swift index 199c2f470..8650f49df 100644 --- a/Feather/Backend/Observable/DownloadManager.swift +++ b/Feather/Backend/Observable/DownloadManager.swift @@ -29,15 +29,18 @@ class Download: Identifiable, @unchecked Sendable { let url: URL let fileName: String let onlyArchiving: Bool + var sourceProvenance: SourceAppProvenance? init( id: String, url: URL, - onlyArchiving: Bool = false + onlyArchiving: Bool = false, + sourceProvenance: SourceAppProvenance? = nil ) { self.id = id self.url = url self.onlyArchiving = onlyArchiving + self.sourceProvenance = sourceProvenance self.fileName = url.lastPathComponent } } @@ -73,14 +76,18 @@ class DownloadManager: NSObject, ObservableObject { func startDownload( from url: URL, - id: String = UUID().uuidString + id: String = UUID().uuidString, + sourceProvenance: SourceAppProvenance? = nil ) -> Download { - if let existingDownload = downloads.first(where: { $0.url == url }) { + let requestHasSourceProvenance = sourceProvenance != nil + if let existingDownload = downloads.first(where: { + $0.url == url && ($0.sourceProvenance != nil) == requestHasSourceProvenance + }) { resumeDownload(existingDownload) return existingDownload } - let download = Download(id: id, url: url) + let download = Download(id: id, url: url, sourceProvenance: sourceProvenance) let task = _session.downloadTask(with: url) download.task = task diff --git a/Feather/Backend/Observable/UpdateManager.swift b/Feather/Backend/Observable/UpdateManager.swift new file mode 100644 index 000000000..33731bcd7 --- /dev/null +++ b/Feather/Backend/Observable/UpdateManager.swift @@ -0,0 +1,256 @@ +// +// 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 + let sourceProvenance: SourceAppProvenance +} + +@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 else { + continue + } + + guard 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] = [:] + let metadataByUUID = Storage.shared.getSourceMetadata().reduce(into: [String: AppSourceMetadata]()) { + $0[$1.appUUID] = $1 + } + let metadataCandidates = localApps.compactMap { app -> SourceMetadataCandidate? in + guard + let uuid = app.uuid, + let metadata = metadataByUUID[uuid] + else { + return nil + } + return SourceMetadataCandidate(appUUID: uuid, app: app, metadata: metadata) + } + + for localApp in localApps { + guard let localUUID = localApp.uuid else { + continue + } + + let sourceAppIdentifier: String + let sourceAppVersion: String? + let storedSourceURL: URL + if let directMetadata = metadataByUUID[localUUID] { + guard + let metadataSourceAppIdentifier = directMetadata.sourceAppIdentifier, + let metadataSourceURL = directMetadata.sourceRepositoryURL + else { + continue + } + + sourceAppIdentifier = metadataSourceAppIdentifier + sourceAppVersion = directMetadata.sourceAppVersion + storedSourceURL = metadataSourceURL + } else if let fallback = _fallbackMetadataCandidate( + for: localApp, + localUUID: localUUID, + candidates: metadataCandidates + ) { + guard + let metadataSourceAppIdentifier = fallback.metadata.sourceAppIdentifier, + let metadataSourceURL = fallback.metadata.sourceRepositoryURL + else { + continue + } + + sourceAppIdentifier = metadataSourceAppIdentifier + sourceAppVersion = fallback.metadata.sourceAppVersion + storedSourceURL = metadataSourceURL + Storage.shared.copySourceMetadata( + from: fallback.appUUID, + to: localUUID, + kind: localApp.isSigned ? .signed : .imported + ) + } else if + let localSourceURL = localApp.source, + let localIdentifier = localApp.identifier + { + sourceAppIdentifier = localIdentifier + sourceAppVersion = localApp.version + storedSourceURL = localSourceURL + } else { + continue + } + + for (source, repository) in repositories { + guard let sourceURL = source.sourceURL else { + continue + } + + guard _matchesStoredRepository(storedSourceURL: storedSourceURL, sourceURL: sourceURL) else { + continue + } + + guard let remoteApp = repository.apps.first(where: { $0.id == sourceAppIdentifier }) else { + continue + } + + guard let remoteVersion = remoteApp.currentVersion, !remoteVersion.isEmpty else { + continue + } + + guard remoteVersion != sourceAppVersion else { + continue + } + + guard let downloadURL = remoteApp.currentDownloadUrl else { + continue + } + + guard let provenance = SourceAppProvenance( + sourceURL: sourceURL, + repository: repository, + app: remoteApp + ) else { + continue + } + + foundUpdates[localUUID] = AppUpdate( + id: localUUID, + localUUID: localUUID, + localVersion: sourceAppVersion ?? localApp.version, + remoteVersion: remoteVersion, + appName: remoteApp.currentName, + bundleIdentifier: sourceAppIdentifier, + downloadURL: downloadURL, + sourceURL: sourceURL, + sourceProvenance: provenance + ) + break + } + } + + return foundUpdates + } + + private func _matchesStoredRepository( + storedSourceURL: URL, + sourceURL: URL + ) -> Bool { + _normalizedSourceURL(storedSourceURL) == _normalizedSourceURL(sourceURL) + } + + private func _normalizedSourceURL(_ url: URL) -> String { + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) + let scheme = components?.scheme?.lowercased() + let host = components?.host?.lowercased() + components?.scheme = scheme + components?.host = host + components?.fragment = nil + + let normalized = components?.url ?? url + let absoluteString = normalized.absoluteString + return absoluteString.hasSuffix("/") ? String(absoluteString.dropLast()) : absoluteString + } + + private func _fallbackMetadataCandidate( + for localApp: AppInfoPresentable, + localUUID: String, + candidates: [SourceMetadataCandidate] + ) -> SourceMetadataCandidate? { + guard + localApp.isSigned, + let localIdentifier = localApp.identifier, + let localVersion = localApp.version + else { + return nil + } + + return candidates.first { + $0.appUUID != localUUID && + !$0.app.isSigned && + $0.app.identifier == localIdentifier && + $0.app.version == localVersion + } + } +} + +private struct SourceMetadataCandidate { + let appUUID: String + let app: AppInfoPresentable + let metadata: AppSourceMetadata +} diff --git a/Feather/Backend/Storage/Feather.xcdatamodeld/Feather.xcdatamodel/contents b/Feather/Backend/Storage/Feather.xcdatamodeld/Feather.xcdatamodel/contents index 50e9019e9..566e108cd 100644 --- a/Feather/Backend/Storage/Feather.xcdatamodeld/Feather.xcdatamodel/contents +++ b/Feather/Backend/Storage/Feather.xcdatamodeld/Feather.xcdatamodel/contents @@ -7,6 +7,21 @@ + + + + + + + + + + + + + + + @@ -37,4 +52,4 @@ - \ No newline at end of file + diff --git a/Feather/Backend/Storage/Storage+Shared.swift b/Feather/Backend/Storage/Storage+Shared.swift index 05cf9a05c..709a72517 100644 --- a/Feather/Backend/Storage/Storage+Shared.swift +++ b/Feather/Backend/Storage/Storage+Shared.swift @@ -26,6 +26,7 @@ extension Storage { if let url = getUuidDirectory(for: app) { try? FileManager.default.removeItem(at: url) } + deleteSourceMetadata(for: app.uuid) if let object = app as? NSManagedObject { context.delete(object) } @@ -58,6 +59,7 @@ protocol AppInfoPresentable { var date: Date? { get } var icon: String? { get } var uuid: String? { get } + var source: URL? { get } var isSigned: Bool { get } } diff --git a/Feather/Backend/Storage/Storage+SourceMetadata.swift b/Feather/Backend/Storage/Storage+SourceMetadata.swift new file mode 100644 index 000000000..81fec3630 --- /dev/null +++ b/Feather/Backend/Storage/Storage+SourceMetadata.swift @@ -0,0 +1,192 @@ +// +// Storage+SourceMetadata.swift +// Feather +// +// Created by Dominic on 26.05.2026. +// + +import AltSourceKit +import CoreData +import Foundation + +struct SourceAppProvenance: Equatable { + let sourceRepositoryURL: URL + let sourceRepositoryIdentifier: String? + let sourceRepositoryName: String? + let sourceAppIdentifier: String + let sourceAppName: String? + let sourceAppVersion: String? + let sourceAppVersionDate: Date? + let sourceAppDownloadURL: URL? + + var sourceVersionID: String { + [ + sourceRepositoryIdentifier ?? sourceRepositoryURL.absoluteString, + sourceAppIdentifier, + sourceAppVersion ?? "", + sourceAppDownloadURL?.absoluteString ?? "" + ].joined(separator: "|") + } +} + +enum SourceLinkedAppKind: String { + case imported + case signed +} + +extension SourceAppProvenance { + init?( + sourceURL: URL?, + repository: ASRepository, + app: ASRepository.App, + version: ASRepository.App.Version? = nil + ) { + guard + let sourceURL, + let appIdentifier = app.id + else { + return nil + } + + self.sourceRepositoryURL = sourceURL + self.sourceRepositoryIdentifier = repository.id + self.sourceRepositoryName = repository.name + self.sourceAppIdentifier = appIdentifier + self.sourceAppName = app.currentName + let appVersion = version?.version ?? app.currentVersion + let appVersionDate = version?.date?.date ?? app.currentDate?.date + let appDownloadURL = version?.downloadURL ?? app.currentDownloadUrl + self.sourceAppVersion = appVersion + self.sourceAppVersionDate = appVersionDate + self.sourceAppDownloadURL = appDownloadURL + } +} + +extension Storage { + func sourceMetadata(for appUUID: String) -> AppSourceMetadata? { + let request: NSFetchRequest = AppSourceMetadata.fetchRequest() + request.fetchLimit = 1 + request.predicate = NSPredicate(format: "appUUID == %@", appUUID) + request.sortDescriptors = [NSSortDescriptor(key: "updatedAt", ascending: false)] + + do { + return try context.fetch(request).first + } catch { + return nil + } + } + + func sourceMetadata(for app: AppInfoPresentable) -> AppSourceMetadata? { + guard let uuid = app.uuid else { return nil } + return sourceMetadata(for: uuid) + } + + func getSourceMetadata() -> [AppSourceMetadata] { + let request: NSFetchRequest = AppSourceMetadata.fetchRequest() + request.sortDescriptors = [NSSortDescriptor(key: "updatedAt", ascending: true)] + do { + return try context.fetch(request) + } catch { + return [] + } + } + + func addSourceMetadata( + for appUUID: String, + kind: SourceLinkedAppKind, + provenance: SourceAppProvenance + ) { + let metadata = sourceMetadata(for: appUUID) ?? AppSourceMetadata(context: context) + let now = Date() + + if metadata.createdAt == nil { + metadata.createdAt = now + } + + metadata.appUUID = appUUID + metadata.appKind = kind.rawValue + metadata.sourceRepositoryURL = provenance.sourceRepositoryURL + metadata.sourceRepositoryIdentifier = provenance.sourceRepositoryIdentifier + metadata.sourceRepositoryName = provenance.sourceRepositoryName + metadata.sourceAppIdentifier = provenance.sourceAppIdentifier + metadata.sourceAppName = provenance.sourceAppName + metadata.sourceAppVersion = provenance.sourceAppVersion + metadata.sourceAppVersionDate = provenance.sourceAppVersionDate + metadata.sourceAppDownloadURL = provenance.sourceAppDownloadURL + metadata.sourceVersionID = provenance.sourceVersionID + metadata.updatedAt = now + + saveContext() + } + + func copySourceMetadata( + from sourceAppUUID: String?, + to destinationAppUUID: String, + kind: SourceLinkedAppKind + ) { + guard + let sourceAppUUID + else { + return + } + + guard let source = sourceMetadata(for: sourceAppUUID) else { + return + } + + guard + let repositoryURL = source.sourceRepositoryURL, + let appIdentifier = source.sourceAppIdentifier, + let versionID = source.sourceVersionID + else { + return + } + + let metadata = sourceMetadata(for: destinationAppUUID) ?? AppSourceMetadata(context: context) + let now = Date() + if metadata.createdAt == nil { + metadata.createdAt = now + } + metadata.appUUID = destinationAppUUID + metadata.appKind = kind.rawValue + metadata.sourceRepositoryURL = repositoryURL + metadata.sourceRepositoryIdentifier = source.sourceRepositoryIdentifier + metadata.sourceRepositoryName = source.sourceRepositoryName + metadata.sourceAppIdentifier = appIdentifier + metadata.sourceAppName = source.sourceAppName + metadata.sourceAppVersion = source.sourceAppVersion + metadata.sourceAppVersionDate = source.sourceAppVersionDate + metadata.sourceAppDownloadURL = source.sourceAppDownloadURL + metadata.sourceVersionID = versionID + metadata.updatedAt = now + + saveContext() + } + + func deleteSourceMetadata(for appUUID: String?) { + guard let appUUID else { + return + } + + let request: NSFetchRequest = AppSourceMetadata.fetchRequest() + request.predicate = NSPredicate(format: "appUUID == %@", appUUID) + + do { + let metadata = try context.fetch(request) + metadata.forEach(context.delete) + saveContext() + } catch { + } + } + + func deleteSourceMetadata(kind: SourceLinkedAppKind) { + let request: NSFetchRequest = AppSourceMetadata.fetchRequest() + request.predicate = NSPredicate(format: "appKind == %@", kind.rawValue) + + do { + let deleteRequest = NSBatchDeleteRequest(fetchRequest: request as! NSFetchRequest) + try context.execute(deleteRequest) + } catch { + } + } +} diff --git a/Feather/Backend/Storage/Storage.swift b/Feather/Backend/Storage/Storage.swift index 3a7615fda..a5fc28fed 100644 --- a/Feather/Backend/Storage/Storage.swift +++ b/Feather/Backend/Storage/Storage.swift @@ -22,6 +22,9 @@ final class Storage: ObservableObject { container.persistentStoreDescriptions.first?.url = URL(fileURLWithPath: "/dev/null") } + + container.persistentStoreDescriptions.first?.shouldMigrateStoreAutomatically = true + container.persistentStoreDescriptions.first?.shouldInferMappingModelAutomatically = true _loadPersistentStoreAggressively() container.viewContext.automaticallyMergesChangesFromParent = true diff --git a/Feather/Utilities/FR.swift b/Feather/Utilities/FR.swift index 90523f997..f53abab00 100644 --- a/Feather/Utilities/FR.swift +++ b/Feather/Utilities/FR.swift @@ -16,10 +16,15 @@ enum FR { static func handlePackageFile( _ ipa: URL, download: Download? = nil, + sourceProvenance: SourceAppProvenance? = nil, completion: @escaping (Error?) -> Void ) { Task.detached { - let handler = AppFileHandler(file: ipa, download: download) + let handler = AppFileHandler( + file: ipa, + download: download, + sourceProvenance: sourceProvenance + ) do { try await handler.copy() diff --git a/Feather/Utilities/Handlers/AppFileHandler.swift b/Feather/Utilities/Handlers/AppFileHandler.swift index e8d7d7424..2468c71ce 100644 --- a/Feather/Utilities/Handlers/AppFileHandler.swift +++ b/Feather/Utilities/Handlers/AppFileHandler.swift @@ -8,7 +8,6 @@ import Foundation import Zip import SwiftUI -import OSLog final class AppFileHandler: NSObject, @unchecked Sendable { private let _fileManager = FileManager.default @@ -19,20 +18,22 @@ final class AppFileHandler: NSObject, @unchecked Sendable { private var _ipa: URL private let _install: Bool private let _download: Download? + private let _sourceProvenance: SourceAppProvenance? init( file ipa: URL, install: Bool = false, - download: Download? = nil + download: Download? = nil, + sourceProvenance: SourceAppProvenance? = nil ) { self._ipa = ipa self._install = install self._download = download + self._sourceProvenance = sourceProvenance ?? download?.sourceProvenance self._uniqueWorkDir = _fileManager.temporaryDirectory .appendingPathComponent("FeatherImport_\(_uuid)", isDirectory: true) super.init() - Logger.misc.debug("Import initiated for: \(self._ipa.lastPathComponent) with ID: \(self._uuid)") } func copy() async throws { @@ -44,7 +45,6 @@ final class AppFileHandler: NSObject, @unchecked Sendable { try _fileManager.copyItem(at: _ipa, to: destinationURL) _ipa = destinationURL - Logger.misc.info("[\(self._uuid)] File copied to: \(self._ipa.path)") } func extract() async throws { @@ -101,7 +101,6 @@ final class AppFileHandler: NSObject, @unchecked Sendable { } try _fileManager.moveItem(at: payloadURL, to: destinationURL) - Logger.misc.info("[\(self._uuid)] Moved Payload to: \(destinationURL.path)") try? _fileManager.removeItem(at: _uniqueWorkDir) } @@ -117,12 +116,19 @@ final class AppFileHandler: NSObject, @unchecked Sendable { Storage.shared.addImported( uuid: _uuid, + source: _sourceProvenance?.sourceRepositoryURL, appName: bundle?.name, appIdentifier: bundle?.bundleIdentifier, appVersion: bundle?.version, appIcon: bundle?.iconFileName - ) { _ in - Logger.misc.info("[\(self._uuid)] Added to database") + ) { _ in } + + if let sourceProvenance = _sourceProvenance { + Storage.shared.addSourceMetadata( + for: _uuid, + kind: .imported, + provenance: sourceProvenance + ) } } diff --git a/Feather/Utilities/Handlers/SigningHandler.swift b/Feather/Utilities/Handlers/SigningHandler.swift index d7dfd10ef..123e9d63d 100644 --- a/Feather/Utilities/Handlers/SigningHandler.swift +++ b/Feather/Utilities/Handlers/SigningHandler.swift @@ -156,6 +156,7 @@ final class SigningHandler: NSObject { Storage.shared.addSigned( uuid: _uuid, + source: _app.source, certificate: _options.signingOption != .default ? nil : appCertificate, appName: bundle?.name, appIdentifier: bundle?.bundleIdentifier, @@ -166,6 +167,12 @@ final class SigningHandler: NSObject { continuation.resume() } } + + Storage.shared.copySourceMetadata( + from: _app.uuid, + to: _uuid, + kind: .signed + ) } private func _directory() async throws -> URL { diff --git a/Feather/Views/Library/LibraryCellView.swift b/Feather/Views/Library/LibraryCellView.swift index 27ec7aad6..a933ec9c7 100644 --- a/Feather/Views/Library/LibraryCellView.swift +++ b/Feather/Views/Library/LibraryCellView.swift @@ -13,6 +13,9 @@ import NimbleViews struct LibraryCellView: View { @Environment(\.horizontalSizeClass) private var horizontalSizeClass @Environment(\.editMode) private var editMode + @ObservedObject private var updateManager = UpdateManager.shared + @State private var _signedUpdateConfirmation: AppUpdate? + @State private var _isSignedUpdateConfirmationPresented = false var certInfo: Date.ExpirationInfo? { Storage.shared.getCertificate(from: app)?.expiration?.expirationInfo() @@ -60,7 +63,7 @@ struct LibraryCellView: View { .buttonStyle(.borderless) } - FRAppIconView(app: app, size: 57) + _appIcon(for: app) NBTitleWithSubtitleView( title: app.name ?? .localized("Unknown"), @@ -99,6 +102,25 @@ struct LibraryCellView: View { _actions(for: app) } } + .confirmationDialog( + .localized("Update Available"), + isPresented: $_isSignedUpdateConfirmationPresented, + titleVisibility: .visible + ) { + Button(.localized("Install Current Version"), systemImage: "square.and.arrow.down") { + selectedInstallAppPresenting = AnyApp(base: app) + } + if let update = _signedUpdateConfirmation { + Button(.localized("Download Update"), systemImage: "arrow.down.circle") { + _startUpdateDownload(update) + } + } + Button(.localized("Cancel"), role: .cancel) {} + } message: { + if let update = _signedUpdateConfirmation { + Text("\(update.appName) \(update.remoteVersion)") + } + } } private var _desc: String { @@ -113,6 +135,25 @@ struct LibraryCellView: View { // MARK: - Extension: View extension LibraryCellView { + private func _appIcon(for app: AppInfoPresentable) -> some View { + FRAppIconView(app: app, size: 57) + .overlay(alignment: .topTrailing) { + if updateManager.update(for: app) != nil { + Image(systemName: "arrow.down.circle.fill") + .font(.system(size: 18, weight: .semibold)) + .symbolRenderingMode(.palette) + .foregroundStyle(.white, Color.accentColor) + .background( + Circle() + .fill(Color(.systemBackground)) + .frame(width: 20, height: 20) + ) + .offset(x: 5, y: -5) + .accessibilityLabel(.localized("Update Available")) + } + } + } + @ViewBuilder private func _actions(for app: AppInfoPresentable) -> some View { Button(.localized("Delete"), systemImage: "trash", role: .destructive) { @@ -129,6 +170,17 @@ 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") { + if app.isSigned { + _signedUpdateConfirmation = update + _isSignedUpdateConfirmationPresented = true + } else { + _startUpdateDownload(update) + } + } + } + if app.isSigned { if let id = app.identifier { Button(.localized("Open"), systemImage: "app.badge.checkmark") { @@ -157,7 +209,30 @@ extension LibraryCellView { @ViewBuilder private func _buttonActions(for app: AppInfoPresentable) -> some View { Group { - if app.isSigned { + if let update = updateManager.update(for: app) { + if app.isSigned { + Button { + _signedUpdateConfirmation = update + _isSignedUpdateConfirmationPresented = true + } label: { + FRExpirationPillView( + title: .localized("Install"), + revoked: certRevoked, + expiration: certInfo + ) + } + } else { + Button { + _startUpdateDownload(update) + } label: { + FRExpirationPillView( + title: .localized("Update"), + revoked: false, + expiration: nil + ) + } + } + } else if app.isSigned { Button { selectedInstallAppPresenting = AnyApp(base: app) } label: { @@ -181,4 +256,12 @@ extension LibraryCellView { } .buttonStyle(.borderless) } + + private func _startUpdateDownload(_ update: AppUpdate) { + _ = DownloadManager.shared.startDownload( + from: update.downloadURL, + id: "FeatherManualDownload_Update_\(update.localUUID)", + sourceProvenance: update.sourceProvenance + ) + } } diff --git a/Feather/Views/Library/LibraryView.swift b/Feather/Views/Library/LibraryView.swift index 21b4d9434..b9c3aeeec 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? @@ -19,6 +20,8 @@ struct LibraryView: View { @State private var _isImportingPresenting = false @State private var _isDownloadingPresenting = false @State private var _alertDownloadString: String = "" // for _isDownloadingPresenting + @State private var _updateCheckRotation = 0.0 + @State private var _isUpdateCheckCompleteVisible = false // MARK: Selection State @State private var _selectedAppUUIDs: Set = [] @@ -59,6 +62,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 +155,25 @@ struct LibraryView: View { _bulkDeleteSelectedApps() } } else { + ToolbarItem(placement: .topBarTrailing) { + Button { + Task { + await _checkForUpdates() + } + } label: { + Image(systemName: _isUpdateCheckCompleteVisible ? "checkmark.circle.fill" : "arrow.triangle.2.circlepath") + .rotationEffect(.degrees(_updateCheckRotation)) + .animation( + updateManager.isChecking + ? .linear(duration: 0.8).repeatForever(autoreverses: false) + : .default, + value: _updateCheckRotation + ) + } + .disabled(updateManager.isChecking) + .accessibilityLabel(.localized("Check for Updates")) + } + NBToolbarMenu( systemImage: "plus", style: .icon, @@ -206,6 +234,9 @@ struct LibraryView: View { _selectedAppUUIDs.removeAll() } } + .onChange(of: updateManager.isChecking) { isChecking in + _handleUpdateCheckStateChange(isChecking) + } } } } @@ -253,6 +284,36 @@ 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 + ) + } + + private func _handleUpdateCheckStateChange(_ isChecking: Bool) { + if isChecking { + _isUpdateCheckCompleteVisible = false + _updateCheckRotation = 0 + withAnimation(.linear(duration: 0.8).repeatForever(autoreverses: false)) { + _updateCheckRotation = 360 + } + } else { + withAnimation(.none) { + _updateCheckRotation = 0 + } + + _isUpdateCheckCompleteVisible = true + Task { @MainActor in + try? await Task.sleep(nanoseconds: 900_000_000) + if !updateManager.isChecking { + _isUpdateCheckCompleteVisible = false + } + } + } + } } // MARK: - Extension: View (Sort) diff --git a/Feather/Views/Settings/Reset/ResetView.swift b/Feather/Views/Settings/Reset/ResetView.swift index bce6f8b32..312e28b8d 100644 --- a/Feather/Views/Settings/Reset/ResetView.swift +++ b/Feather/Views/Settings/Reset/ResetView.swift @@ -172,11 +172,13 @@ extension ResetView { } static func deleteSignedApps() { + Storage.shared.deleteSourceMetadata(kind: .signed) Storage.shared.clearContext(request: Signed.fetchRequest()) try? FileManager.default.removeFileIfNeeded(at: FileManager.default.signed) } static func deleteImportedApps() { + Storage.shared.deleteSourceMetadata(kind: .imported) Storage.shared.clearContext(request: Imported.fetchRequest()) try? FileManager.default.removeFileIfNeeded(at: FileManager.default.unsigned) } diff --git a/Feather/Views/Sources/Apps/Detail/SourceAppsDetailView.swift b/Feather/Views/Sources/Apps/Detail/SourceAppsDetailView.swift index 61a2bf614..3207a06f0 100644 --- a/Feather/Views/Sources/Apps/Detail/SourceAppsDetailView.swift +++ b/Feather/Views/Sources/Apps/Detail/SourceAppsDetailView.swift @@ -23,8 +23,9 @@ struct SourceAppsDetailView: View { downloadManager.getDownload(by: app.currentUniqueId) } - var source: ASRepository - var app: ASRepository.App + let sourceURL: URL? + let source: ASRepository + let app: ASRepository.App var body: some View { ScrollView { @@ -57,7 +58,7 @@ struct SourceAppsDetailView: View { Spacer() - DownloadButtonView(app: app) + DownloadButtonView(sourceURL: sourceURL, source: source, app: app) } .lineLimit(2) .frame(maxWidth: .infinity, alignment: .leading) @@ -87,7 +88,12 @@ struct SourceAppsDetailView: View { ) if let versions = app.versions { NavigationLink( - destination: VersionHistoryView(app: app, versions: versions) + destination: VersionHistoryView( + sourceURL: sourceURL, + source: source, + app: app, + versions: versions + ) .navigationTitle(.localized("Version History")) .navigationBarTitleDisplayMode(.large) ) { diff --git a/Feather/Views/Sources/Apps/Detail/VersionHistoryView.swift b/Feather/Views/Sources/Apps/Detail/VersionHistoryView.swift index 484f471dc..48d099776 100644 --- a/Feather/Views/Sources/Apps/Detail/VersionHistoryView.swift +++ b/Feather/Views/Sources/Apps/Detail/VersionHistoryView.swift @@ -12,6 +12,8 @@ import AltSourceKit struct VersionHistoryView: View { @Environment(\.dismiss) var dismiss + let sourceURL: URL? + let source: ASRepository let app: ASRepository.App let versions: [ASRepository.App.Version] @@ -31,7 +33,13 @@ struct VersionHistoryView: View { Button { _ = DownloadManager.shared.startDownload( from: downloadURL, - id: app.currentUniqueId + id: app.currentUniqueId, + sourceProvenance: SourceAppProvenance( + sourceURL: sourceURL, + repository: source, + app: app, + version: version + ) ) dismiss() } label: { @@ -53,4 +61,3 @@ struct VersionHistoryView: View { } } } - diff --git a/Feather/Views/Sources/Apps/DownloadButtonView.swift b/Feather/Views/Sources/Apps/DownloadButtonView.swift index 52cb126c2..1296abff3 100644 --- a/Feather/Views/Sources/Apps/DownloadButtonView.swift +++ b/Feather/Views/Sources/Apps/DownloadButtonView.swift @@ -11,6 +11,8 @@ import AltSourceKit import NimbleViews struct DownloadButtonView: View { + let sourceURL: URL? + let source: ASRepository? let app: ASRepository.App @ObservedObject private var downloadManager = DownloadManager.shared @@ -41,7 +43,11 @@ struct DownloadButtonView: View { } else { Button { if let url = app.currentDownloadUrl { - _ = downloadManager.startDownload(from: url, id: app.currentUniqueId) + _ = downloadManager.startDownload( + from: url, + id: app.currentUniqueId, + sourceProvenance: _sourceProvenance() + ) } } label: { Text(.localized("Get")) @@ -82,4 +88,9 @@ struct DownloadButtonView: View { downloadProgress = download.overallProgress } } + + private func _sourceProvenance() -> SourceAppProvenance? { + guard let source else { return nil } + return SourceAppProvenance(sourceURL: sourceURL, repository: source, app: app) + } } diff --git a/Feather/Views/Sources/Apps/SourceAppsCellView.swift b/Feather/Views/Sources/Apps/SourceAppsCellView.swift index 8253443f1..5ff8819a9 100644 --- a/Feather/Views/Sources/Apps/SourceAppsCellView.swift +++ b/Feather/Views/Sources/Apps/SourceAppsCellView.swift @@ -15,8 +15,9 @@ import NukeUI struct SourceAppsCellView: View { @AppStorage("Feather.storeCellAppearance") private var _storeCellAppearance: Int = 0 - var source: ASRepository - var app: ASRepository.App + let sourceURL: URL? + let source: ASRepository + let app: ASRepository.App var body: some View { VStack { @@ -37,7 +38,7 @@ struct SourceAppsCellView: View { } } } - DownloadButtonView(app: app) + DownloadButtonView(sourceURL: sourceURL, source: source, app: app) } if diff --git a/Feather/Views/Sources/Apps/SourceAppsView.swift b/Feather/Views/Sources/Apps/SourceAppsView.swift index a5bbb4ea1..6e0aec0aa 100644 --- a/Feather/Views/Sources/Apps/SourceAppsView.swift +++ b/Feather/Views/Sources/Apps/SourceAppsView.swift @@ -49,17 +49,17 @@ struct SourceAppsView: View { var object: [AltSource] @ObservedObject var viewModel: SourcesViewModel - @State private var _sources: [ASRepository]? + @State private var _sourceContexts: [SourceRepositoryContext]? // MARK: Body var body: some View { ZStack { if - let _sources, - !_sources.isEmpty + let _sourceContexts, + !_sourceContexts.isEmpty { SourceAppsTableRepresentableView( - sources: _sources, + sourceContexts: _sourceContexts, searchText: $_searchText, sortOption: $_sortOption, sortAscending: $_sortAscending, @@ -74,16 +74,16 @@ struct SourceAppsView: View { .searchable(text: $_searchText, placement: .platform()) .toolbarTitleMenu { if - let _sources, - _sources.count == 1 + let _sourceContexts, + _sourceContexts.count == 1 { - if let url = _sources[0].website { + if let url = _sourceContexts[0].repository.website { Button(.localized("Visit Website"), systemImage: "globe") { UIApplication.open(url) } } - if let url = _sources[0].patreonURL { + if let url = _sourceContexts[0].repository.patreonURL { Button(.localized("Visit Patreon"), systemImage: "dollarsign.circle") { UIApplication.open(url) } @@ -132,7 +132,11 @@ struct SourceAppsView: View { _sortOptionRawValue = newValue.rawValue } .navigationDestinationIfAvailable(item: $_selectedRoute) { route in - SourceAppsDetailView(source: route.source, app: route.app) + SourceAppsDetailView( + sourceURL: route.sourceURL, + source: route.source, + app: route.app + ) } } @@ -140,15 +144,32 @@ struct SourceAppsView: View { isLoading = true Task { - let loadedSources = object.compactMap { viewModel.sources[$0] } - _sources = loadedSources + let loadedSources = object.compactMap { source -> SourceRepositoryContext? in + guard let repository = viewModel.sources[source] else { return nil } + return SourceRepositoryContext(sourceURL: source.sourceURL, repository: repository) + } + _sourceContexts = loadedSources withAnimation(.easeIn(duration: 0.2)) { isLoading = false } } } + struct SourceRepositoryContext: Equatable { + let sourceURL: URL? + let repository: ASRepository + + static func == (lhs: SourceRepositoryContext, rhs: SourceRepositoryContext) -> Bool { + lhs.sourceURL == rhs.sourceURL && + lhs.repository.id == rhs.repository.id && + lhs.repository.name == rhs.repository.name && + lhs.repository.apps.map { "\($0.currentUniqueId)|\($0.currentVersion ?? "")" } == + rhs.repository.apps.map { "\($0.currentUniqueId)|\($0.currentVersion ?? "")" } + } + } + struct SourceAppRoute: Identifiable, Hashable { + let sourceURL: URL? let source: ASRepository let app: ASRepository.App let id: String = UUID().uuidString diff --git a/Feather/Views/Sources/Apps/UIKit/SourceAppsTableRepresentableView.swift b/Feather/Views/Sources/Apps/UIKit/SourceAppsTableRepresentableView.swift index 631655d1f..45248eda6 100644 --- a/Feather/Views/Sources/Apps/UIKit/SourceAppsTableRepresentableView.swift +++ b/Feather/Views/Sources/Apps/UIKit/SourceAppsTableRepresentableView.swift @@ -10,7 +10,7 @@ import AltSourceKit // MARK: - Representable struct SourceAppsTableRepresentableView: UIViewRepresentable { - var sources: [ASRepository] + var sourceContexts: [SourceAppsView.SourceRepositoryContext] @Binding var searchText: String @Binding var sortOption: SourceAppsView.SortOption @Binding var sortAscending: Bool @@ -30,9 +30,9 @@ struct SourceAppsTableRepresentableView: UIViewRepresentable { } if - let firstSource = sources.first, - sources.count == 1, - let news = firstSource.news, + let firstSource = sourceContexts.first, + sourceContexts.count == 1, + let news = firstSource.repository.news, !news.isEmpty { let header = UIHostingController(rootView: SourceNewsView(news: news)) @@ -59,12 +59,12 @@ struct SourceAppsTableRepresentableView: UIViewRepresentable { func updateUIView(_ tableView: UITableView, context: Context) { context.coordinator.uiTableView = tableView - let sourcesChanged = context.coordinator.sources != sources + let sourcesChanged = context.coordinator.sourceContexts != sourceContexts let searchChanged = context.coordinator.searchText != searchText let sortOptionChanged = context.coordinator.sortOption != sortOption let sortDirectionChanged = context.coordinator.sortAscending != sortAscending - context.coordinator.sources = sources + context.coordinator.sourceContexts = sourceContexts context.coordinator.searchText = searchText context.coordinator.sortOption = sortOption context.coordinator.sortAscending = sortAscending @@ -76,7 +76,7 @@ struct SourceAppsTableRepresentableView: UIViewRepresentable { func makeCoordinator() -> Coordinator { Coordinator( - sources: sources, + sourceContexts: sourceContexts, searchText: searchText, sortOption: sortOption, sortAscending: sortAscending, @@ -87,24 +87,32 @@ struct SourceAppsTableRepresentableView: UIViewRepresentable { // MARK: - Representable Extension: Coordinator extension SourceAppsTableRepresentableView { class Coordinator: NSObject, UITableViewDataSource, UITableViewDelegate { - var sources: [ASRepository] + var sourceContexts: [SourceAppsView.SourceRepositoryContext] var searchText: String var sortOption: SourceAppsView.SortOption var sortAscending: Bool let onSelect: (SourceAppsView.SourceAppRoute) -> Void - private var _groupedAppsByNameFirstLetter: [String: [(source: ASRepository, app: ASRepository.App)]] = [:] - private var _groupedAppsByDate: [String: [(source: ASRepository, app: ASRepository.App)]] = [:] + private var _groupedAppsByNameFirstLetter: [String: [SourceAppEntry]] = [:] + private var _groupedAppsByDate: [String: [SourceAppEntry]] = [:] private var _sortedSectionTitles: [String] = [] - private var _cachedSortedApps: [(source: ASRepository, app: ASRepository.App)] = [] + private var _cachedSortedApps: [SourceAppEntry] = [] weak var uiTableView: UITableView? - private var _allAppsWithSource: [(source: ASRepository, app: ASRepository.App)] { - sources.flatMap { source in source.apps.map { (source: source, app: $0) } } + private var _allAppsWithSource: [SourceAppEntry] { + sourceContexts.flatMap { context in + context.repository.apps.map { + SourceAppEntry( + sourceURL: context.sourceURL, + source: context.repository, + app: $0 + ) + } + } } - private var _sortedApps: [(source: ASRepository, app: ASRepository.App)] { + private var _sortedApps: [SourceAppEntry] { if !_cachedSortedApps.isEmpty { return _cachedSortedApps } @@ -113,13 +121,13 @@ extension SourceAppsTableRepresentableView { class Coordinator: NSObject, UITabl } init( - sources: [ASRepository], + sourceContexts: [SourceAppsView.SourceRepositoryContext], searchText: String, sortOption: SourceAppsView.SortOption, sortAscending: Bool, onSelect: @escaping (SourceAppsView.SourceAppRoute) -> Void ) { - self.sources = sources + self.sourceContexts = sourceContexts self.searchText = searchText self.sortOption = sortOption self.sortAscending = sortAscending @@ -131,7 +139,7 @@ extension SourceAppsTableRepresentableView { class Coordinator: NSObject, UITabl } } - private func _calculateSortedApps() -> [(source: ASRepository, app: ASRepository.App)] { + private func _calculateSortedApps() -> [SourceAppEntry] { let filtered = _allAppsWithSource.filter { searchText.isEmpty || ($0.app.id?.range(of: searchText, options: [.caseInsensitive, .diacriticInsensitive], locale: Locale(identifier: "en_US")) != nil) || @@ -218,7 +226,7 @@ extension SourceAppsTableRepresentableView { class Coordinator: NSObject, UITabl func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "AppCell", for: indexPath) - let entry: (source: ASRepository, app: ASRepository.App) + let entry: SourceAppEntry switch sortOption { case .default: entry = _sortedApps[indexPath.row] case .name: entry = _groupedAppsByNameFirstLetter[_sortedSectionTitles[indexPath.section]]?[indexPath.row] ?? _sortedApps[indexPath.row] @@ -226,7 +234,7 @@ extension SourceAppsTableRepresentableView { class Coordinator: NSObject, UITabl } cell.contentConfiguration = UIHostingConfiguration { - SourceAppsCellView(source: entry.source, app: entry.app) + SourceAppsCellView(sourceURL: entry.sourceURL, source: entry.source, app: entry.app) } return cell } @@ -235,14 +243,14 @@ extension SourceAppsTableRepresentableView { class Coordinator: NSObject, UITabl if #available(iOS 17, *) { tableView.deselectRow(at: indexPath, animated: true) - let entry: (source: ASRepository, app: ASRepository.App) + let entry: SourceAppEntry switch sortOption { case .default: entry = _sortedApps[indexPath.row] case .name: entry = _groupedAppsByNameFirstLetter[_sortedSectionTitles[indexPath.section]]?[indexPath.row] ?? _sortedApps[indexPath.row] case .date: entry = _groupedAppsByDate[_sortedSectionTitles[indexPath.section]]?[indexPath.row] ?? _sortedApps[indexPath.row] } - onSelect(SourceAppsView.SourceAppRoute(source: entry.source, app: entry.app)) + onSelect(SourceAppsView.SourceAppRoute(sourceURL: entry.sourceURL, source: entry.source, app: entry.app)) } } @@ -276,7 +284,7 @@ extension SourceAppsTableRepresentableView { class Coordinator: NSObject, UITabl } func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { - let entry: (source: ASRepository, app: ASRepository.App) + let entry: SourceAppEntry switch sortOption { case .default: entry = _sortedApps[indexPath.row] case .name: entry = _groupedAppsByNameFirstLetter[_sortedSectionTitles[indexPath.section]]?[indexPath.row] ?? _sortedApps[indexPath.row] @@ -290,19 +298,25 @@ extension SourceAppsTableRepresentableView { class Coordinator: NSObject, UITabl let versionsMenu = UIMenu( title: .localized("Copy Download URLs"), image: UIImage(systemName: "list.bullet"), - children: self._contextActions(for: entry.app, with: { version in - UIPasteboard.general.string = version?.absoluteString + children: self._contextActions(for: entry.app, with: { _, url in + UIPasteboard.general.string = url?.absoluteString }, image: UIImage(systemName: "doc.on.clipboard")) ) let downloadsMenu = UIMenu( title: .localized("Previous Versions"), image: UIImage(systemName: "square.and.arrow.down.on.square"), - children: self._contextActions(for: entry.app, with: { version in - if let url = version { + children: self._contextActions(for: entry.app, with: { version, url in + if let url { _ = DownloadManager.shared.startDownload( from: url, - id: entry.app.currentUniqueId + id: entry.app.currentUniqueId, + sourceProvenance: SourceAppProvenance( + sourceURL: entry.sourceURL, + repository: entry.source, + app: entry.app, + version: version + ) ) } }, image: UIImage(systemName: "arrow.down")) @@ -316,7 +330,7 @@ extension SourceAppsTableRepresentableView { class Coordinator: NSObject, UITabl private func _contextActions( for app: ASRepository.App, - with action: @escaping (URL?) -> Void, + with action: @escaping (ASRepository.App.Version?, URL?) -> Void, image: UIImage? ) -> [UIAction] { if let versions = app.versions, !versions.isEmpty { @@ -325,7 +339,7 @@ extension SourceAppsTableRepresentableView { class Coordinator: NSObject, UITabl title: version.version, image: image ) { _ in - action(version.downloadURL) + action(version, version.downloadURL) } } } else { @@ -334,9 +348,15 @@ extension SourceAppsTableRepresentableView { class Coordinator: NSObject, UITabl title: app.currentVersion ?? "", image: image ) { _ in - action(app.currentDownloadUrl) + action(nil, app.currentDownloadUrl) } ] } } }} + +private struct SourceAppEntry { + let sourceURL: URL? + let source: ASRepository + let app: ASRepository.App +}