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
15 changes: 11 additions & 4 deletions Feather/Backend/Observable/DownloadManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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
Expand Down
256 changes: 256 additions & 0 deletions Feather/Backend/Observable/UpdateManager.swift
Original file line number Diff line number Diff line change
@@ -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<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 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
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,21 @@
<attribute name="name" optional="YES" attributeType="String"/>
<attribute name="sourceURL" optional="YES" attributeType="URI"/>
</entity>
<entity name="AppSourceMetadata" representedClassName="AppSourceMetadata" syncable="YES" codeGenerationType="class">
<attribute name="appKind" attributeType="String"/>
<attribute name="appUUID" attributeType="String"/>
<attribute name="createdAt" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="sourceAppDownloadURL" optional="YES" attributeType="URI"/>
<attribute name="sourceAppIdentifier" attributeType="String"/>
<attribute name="sourceAppName" optional="YES" attributeType="String"/>
<attribute name="sourceAppVersion" optional="YES" attributeType="String"/>
<attribute name="sourceAppVersionDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="sourceRepositoryIdentifier" optional="YES" attributeType="String"/>
<attribute name="sourceRepositoryName" optional="YES" attributeType="String"/>
<attribute name="sourceRepositoryURL" attributeType="URI"/>
<attribute name="sourceVersionID" attributeType="String"/>
<attribute name="updatedAt" attributeType="Date" usesScalarValueType="NO"/>
</entity>
<entity name="CertificatePair" representedClassName="CertificatePair" syncable="YES" codeGenerationType="class">
<attribute name="date" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="expiration" attributeType="Date" usesScalarValueType="NO"/>
Expand Down Expand Up @@ -37,4 +52,4 @@
<attribute name="version" attributeType="String"/>
<relationship name="certificate" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="CertificatePair" inverseName="signedApps" inverseEntity="CertificatePair"/>
</entity>
</model>
</model>
2 changes: 2 additions & 0 deletions Feather/Backend/Storage/Storage+Shared.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 }

}
Expand Down
Loading