diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a664f74..2909871 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,7 +21,7 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Resolve version id: version @@ -40,6 +40,20 @@ jobs: run: | uname -a swift --version + xcodebuild -version + xcode-select -p + xcrun --sdk macosx --show-sdk-version + + - name: Verify macOS 26 SDK + shell: bash + run: | + SDK_VERSION="$(xcrun --sdk macosx --show-sdk-version)" + SDK_MAJOR="${SDK_VERSION%%.*}" + + if [[ "$SDK_MAJOR" != "26" ]]; then + echo "Expected macOS 26 SDK, got macOS $SDK_VERSION" >&2 + exit 1 + fi - name: Install dependencies run: brew install aria2 dylibbundler @@ -54,6 +68,7 @@ jobs: run: | file build/Firelink.app/Contents/MacOS/Firelink lipo -archs build/Firelink.app/Contents/MacOS/Firelink | grep -qx arm64 + codesign --verify --deep --strict build/Firelink.app - name: Create DMG env: @@ -62,7 +77,7 @@ jobs: run: Scripts/create_dmg.sh - name: Upload workflow artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: Firelink-${{ steps.version.outputs.version }}-mac-arm64-dmg path: dist/*.dmg diff --git a/Scripts/create_app_bundle.sh b/Scripts/create_app_bundle.sh index fe832df..512e2e3 100755 --- a/Scripts/create_app_bundle.sh +++ b/Scripts/create_app_bundle.sh @@ -71,4 +71,8 @@ cat > "$CONTENTS_DIR/Info.plist" < PLIST +if command -v codesign &> /dev/null; then + codesign --force --deep --sign - "$APP_DIR" +fi + echo "Created $APP_DIR" diff --git a/Sources/Firelink/Aria2DownloadEngine.swift b/Sources/Firelink/Aria2DownloadEngine.swift index b1b73ee..ac1ef73 100644 --- a/Sources/Firelink/Aria2DownloadEngine.swift +++ b/Sources/Firelink/Aria2DownloadEngine.swift @@ -31,6 +31,11 @@ final class Aria2DownloadEngine { } static func findExecutable() -> URL? { + if let bundled = Bundle.main.url(forResource: "aria2c", withExtension: nil), + FileManager.default.isExecutableFile(atPath: bundled.path) { + return bundled + } + let candidates = [ "/opt/homebrew/bin/aria2c", "/usr/local/bin/aria2c", @@ -49,11 +54,6 @@ final class Aria2DownloadEngine { } } - if let bundled = Bundle.main.url(forResource: "aria2c", withExtension: nil), - FileManager.default.isExecutableFile(atPath: bundled.path) { - return bundled - } - return nil } diff --git a/Sources/Firelink/DownloadController.swift b/Sources/Firelink/DownloadController.swift index 73c5c6e..fce7a73 100644 --- a/Sources/Firelink/DownloadController.swift +++ b/Sources/Firelink/DownloadController.swift @@ -15,6 +15,7 @@ final class DownloadController: ObservableObject { private var activeHandles: [UUID: Aria2DownloadEngine.Handle] = [:] private var automaticRetryCounts: [UUID: Int] = [:] private var restrictQueueToAutoResume = false + private var queuePumpScope: QueuePumpScope = .idle private var sleepActivity: SleepActivityHandle? private var cancellables = Set() private let maxAutomaticRetries = 3 @@ -54,6 +55,7 @@ final class DownloadController: ObservableObject { Task { @MainActor in self.engineMessage = "Recovered downloads from the previous session." self.restrictQueueToAutoResume = true + self.queuePumpScope = .all self.pumpQueue() } } @@ -153,6 +155,20 @@ final class DownloadController: ObservableObject { func startQueue(queueID: UUID? = nil) { engineMessage = "" restrictQueueToAutoResume = false + if let queueID { + let queueID = normalizedQueueID(queueID) + switch queuePumpScope { + case .all: + break + case .idle: + queuePumpScope = .scoped(queueIDs: [queueID], itemIDs: []) + case .scoped(var queueIDs, let itemIDs): + queueIDs.insert(queueID) + queuePumpScope = .scoped(queueIDs: queueIDs, itemIDs: itemIDs) + } + } else { + queuePumpScope = .all + } markQueuedDownloadsForAutoResume(queueID: queueID) pumpQueue() } @@ -221,6 +237,7 @@ final class DownloadController: ObservableObject { $0.message = "" $0.autoResumeOnLaunch = true } + queuePumpScope = queuePumpScope.includingItem(item.id) automaticRetryCounts[item.id] = nil saveDownloads() pumpQueue() @@ -330,7 +347,8 @@ final class DownloadController: ObservableObject { } let item = downloads.remove(at: source) - downloads.insert(item, at: target) + let insertionIndex = source < target ? target - 1 : target + downloads.insert(item, at: insertionIndex) saveDownloads() } @@ -340,9 +358,13 @@ final class DownloadController: ObservableObject { return } + pruneActiveQueueScopes() + while activeCount < settings.maxConcurrentDownloads, let next = downloads.first(where: { item in - item.status == .queued && (!restrictQueueToAutoResume || item.autoResumeOnLaunch == true) + item.status == .queued && + (!restrictQueueToAutoResume || item.autoResumeOnLaunch == true) && + isAllowedToStart(item) }) { start(next) } @@ -352,6 +374,8 @@ final class DownloadController: ObservableObject { !downloads.contains(where: { $0.status == .queued && $0.autoResumeOnLaunch == true }) { restrictQueueToAutoResume = false } + + pruneActiveQueueScopes() } private func start(_ item: DownloadItem) { @@ -480,10 +504,16 @@ final class DownloadController: ObservableObject { } private func markQueuedDownloadsForAutoResume(queueID: UUID?) { - let normalizedID = queueID.map(normalizedQueueID) - for index in downloads.indices where downloads[index].status == .queued && - (normalizedID == nil || normalizedQueueID(downloads[index].queueID) == normalizedID) { - downloads[index].autoResumeOnLaunch = true + if let queueID { + let normalizedID = normalizedQueueID(queueID) + for index in downloads.indices where downloads[index].status == .queued && + validQueueID(downloads[index].queueID) == normalizedID { + downloads[index].autoResumeOnLaunch = true + } + } else { + for index in downloads.indices where downloads[index].status == .queued { + downloads[index].autoResumeOnLaunch = true + } } saveDownloads() } @@ -530,6 +560,65 @@ final class DownloadController: ObservableObject { } } + private func isAllowedToStart(_ item: DownloadItem) -> Bool { + switch queuePumpScope { + case .idle: + return false + case .all: + return true + case .scoped(let queueIDs, let itemIDs): + if itemIDs.contains(item.id) { + return true + } + guard let queueID = validQueueID(item.queueID) else { return false } + return queueIDs.contains(queueID) + } + } + + private func pruneActiveQueueScopes() { + switch queuePumpScope { + case .idle: + return + case .all: + if !downloads.contains(where: { $0.status == .queued || $0.status == .downloading }) { + queuePumpScope = .idle + } + case .scoped(let queueIDs, let itemIDs): + let activeQueueIDs = queueIDs.filter { queueID in + downloads.contains { item in + validQueueID(item.queueID) == queueID && + (item.status == .queued || item.status == .downloading) + } + } + let activeItemIDs = itemIDs.filter { itemID in + downloads.contains { item in + item.id == itemID && (item.status == .queued || item.status == .downloading) + } + } + queuePumpScope = activeQueueIDs.isEmpty && activeItemIDs.isEmpty + ? .idle + : .scoped(queueIDs: activeQueueIDs, itemIDs: activeItemIDs) + } + } + + private enum QueuePumpScope { + case idle + case all + case scoped(queueIDs: Set, itemIDs: Set) + + func includingItem(_ itemID: UUID) -> QueuePumpScope { + switch self { + case .idle: + return .scoped(queueIDs: [], itemIDs: [itemID]) + case .all: + return .all + case .scoped(let queueIDs, var itemIDs): + itemIDs.insert(itemID) + return .scoped(queueIDs: queueIDs, itemIDs: itemIDs) + } + } + } + private func updateSleepActivity() { let shouldPreventSleep = settings.preventsSleepWhileDownloading && activeCount > 0 diff --git a/Sources/Firelink/SchedulerController.swift b/Sources/Firelink/SchedulerController.swift index 52a332e..311db8c 100644 --- a/Sources/Firelink/SchedulerController.swift +++ b/Sources/Firelink/SchedulerController.swift @@ -131,15 +131,17 @@ final class SchedulerController: ObservableObject { } private func triggerQueues() { - guard !settings.targetQueueIDs.isEmpty else { return } + let runnableQueueIDs = settings.targetQueueIDs.filter { queueID in + downloadController.queues.contains(where: { $0.id == queueID }) && + downloadController.queueItems(for: queueID).contains(where: { $0.status == .queued }) + } + + guard !runnableQueueIDs.isEmpty else { return } isRunning = true - for queueID in settings.targetQueueIDs { - // Check if queue still exists - if downloadController.queues.contains(where: { $0.id == queueID }) { - downloadController.startQueue(queueID: queueID) - } + for queueID in runnableQueueIDs { + downloadController.startQueue(queueID: queueID) } checkIfRunningFinished() @@ -148,15 +150,9 @@ final class SchedulerController: ObservableObject { private func checkIfRunningFinished() { guard isRunning else { return } - // A queue is finished if there are no items in .queued or .downloading state for that queue. - // Wait, what if the queue is empty? We don't trigger anything. - var hasActiveItems = false - for item in downloadController.downloads { - if let qid = item.queueID, settings.targetQueueIDs.contains(qid) { - if item.status == .queued || item.status == .downloading { - hasActiveItems = true - break - } + let hasActiveItems = settings.targetQueueIDs.contains { queueID in + downloadController.queueItems(for: queueID).contains { + $0.status == .queued || $0.status == .downloading } }