Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions Feather/Backend/Observable/UpdateManager.swift
Original file line number Diff line number Diff line change
@@ -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<ASRepository, Error>

@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
}
}
26 changes: 25 additions & 1 deletion Feather/Views/Library/LibraryCellView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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: {
Expand All @@ -181,4 +198,11 @@ extension LibraryCellView {
}
.buttonStyle(.borderless)
}

private func _startUpdateDownload(_ update: AppUpdate) {
_ = DownloadManager.shared.startDownload(
from: update.downloadURL,
id: "FeatherManualDownload_Update_\(update.localUUID)"
)
}
}
27 changes: 27 additions & 0 deletions Feather/Views/Library/LibraryView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -59,6 +60,12 @@ struct LibraryView: View {
animation: .snappy
) private var _importedApps: FetchedResults<Imported>

@FetchRequest(
entity: AltSource.entity(),
sortDescriptors: [NSSortDescriptor(keyPath: \AltSource.name, ascending: true)],
animation: .snappy
) private var _sources: FetchedResults<AltSource>

// MARK: Body
var body: some View {
NBNavigationView(.localized("Library")) {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down