mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-27 12:29:29 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 93e6e11939 | |||
| 6750cce6aa | |||
| 1d22b16119 | |||
| deb106f7af | |||
| 1093d73b0e |
@@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.4.3] - 2026-06-03
|
||||
|
||||
### Changes
|
||||
- Refined About page UI and simplified the delete confirmation dialog.
|
||||
|
||||
### Fixes
|
||||
- Optimized disk writes and UI state updates to significantly reduce main thread CPU usage and SSD wear during concurrent downloads and table resizing.
|
||||
|
||||
## [0.4.2] - 2026-06-03
|
||||
|
||||
### Features added
|
||||
- Added double-click to open completed files directly from the download table.
|
||||
- Added redownload functionality for completed or failed items.
|
||||
- Added 'Copy Address' context menu action.
|
||||
- Added a monochrome template tray icon loaded explicitly with precise dimensions.
|
||||
|
||||
### Changes
|
||||
- Improved context menu organization and conditionally displayed actions based on download status.
|
||||
|
||||
## [0.4.1] - 2026-06-03
|
||||
|
||||
### Features added
|
||||
|
||||
@@ -102,7 +102,7 @@ git push origin v0.4.1
|
||||
|
||||
- [x] **Data Persistence:** Store history, column layout preferences, and active queues across restarts.
|
||||
- [x] **Zero-Config Setup:** Automatically bundle and configure `aria2c` inside the `.app` bundle.
|
||||
- [ ] **Bandwidth Limits:** Add global and per-download speed caps and calendar schedules.
|
||||
- [x] **Bandwidth Limits:** Add global and per-download speed caps and calendar schedules.
|
||||
- [ ] **Browser Extensions:** Capture links directly from Safari, Chrome, and Firefox.
|
||||
- [x] **Advanced Transfer Features:** Checksum validation, cookie/header ingestion, and smart mirror failovers.
|
||||
- [x] **Updates & Releases:** GitHub Actions DMG release pipeline and built-in update checker.
|
||||
|
||||
@@ -19,6 +19,7 @@ rm -rf "$APP_DIR"
|
||||
mkdir -p "$MACOS_DIR" "$RESOURCES_DIR"
|
||||
cp ".build/$CONFIGURATION/$APP_NAME" "$MACOS_DIR/$APP_NAME"
|
||||
cp "$ROOT_DIR/Resources/$ICON_NAME.icns" "$RESOURCES_DIR/$ICON_NAME.icns"
|
||||
cp "$ROOT_DIR/Sources/Firelink/Assets.xcassets/MenuBarIcon.imageset/MenuBarIconTemplate.png" "$RESOURCES_DIR/MenuBarIconTemplate.png"
|
||||
|
||||
ARIA2C_PATH=$(which aria2c || true)
|
||||
if [[ -n "$ARIA2C_PATH" && -x "$ARIA2C_PATH" ]]; then
|
||||
|
||||
@@ -135,6 +135,7 @@ final class AppSettings: ObservableObject {
|
||||
|
||||
private let defaults: UserDefaults
|
||||
private let storageKey = "Firelink.AppSettings.v1"
|
||||
private var saveTask: Task<Void, Never>?
|
||||
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
@@ -247,9 +248,17 @@ final class AppSettings: ObservableObject {
|
||||
downloadDirectories: Dictionary(uniqueKeysWithValues: downloadDirectories.map { ($0.key.rawValue, $0.value) }),
|
||||
siteLogins: siteLogins
|
||||
)
|
||||
let defaults = self.defaults
|
||||
let storageKey = self.storageKey
|
||||
|
||||
if let data = try? JSONEncoder().encode(stored) {
|
||||
defaults.set(data, forKey: storageKey)
|
||||
saveTask?.cancel()
|
||||
saveTask = Task { @MainActor [defaults, storageKey] in
|
||||
let data = await Task.detached(priority: .background) {
|
||||
try? JSONEncoder().encode(stored)
|
||||
}.value
|
||||
|
||||
guard !Task.isCancelled, let encoded = data else { return }
|
||||
defaults.set(encoded, forKey: storageKey)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "MenuBarIconTemplate.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"properties" : {
|
||||
"template-rendering-intent" : "template"
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 4.5 KiB |
@@ -23,6 +23,7 @@ final class DownloadController: ObservableObject {
|
||||
let supportDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first ?? URL(fileURLWithPath: NSHomeDirectory())
|
||||
return supportDir.appendingPathComponent("Firelink").appendingPathComponent("downloads.json")
|
||||
}()
|
||||
private var saveTask: Task<Void, Never>?
|
||||
|
||||
init(settings: AppSettings) {
|
||||
self.settings = settings
|
||||
@@ -244,6 +245,24 @@ final class DownloadController: ObservableObject {
|
||||
pumpQueue()
|
||||
}
|
||||
|
||||
func redownload(_ item: DownloadItem) {
|
||||
trashFiles(for: item)
|
||||
restrictQueueToAutoResume = false
|
||||
update(item.id) {
|
||||
$0.status = .queued
|
||||
$0.progress = 0
|
||||
$0.speedText = "-"
|
||||
$0.etaText = "-"
|
||||
$0.connectionCount = 0
|
||||
$0.message = "Redownloading"
|
||||
$0.autoResumeOnLaunch = true
|
||||
}
|
||||
queuePumpScope = queuePumpScope.includingItem(item.id)
|
||||
automaticRetryCounts[item.id] = nil
|
||||
saveDownloads()
|
||||
pumpQueue()
|
||||
}
|
||||
|
||||
func cancel(_ item: DownloadItem) {
|
||||
activeHandles[item.id]?.cancel()
|
||||
activeHandles[item.id] = nil
|
||||
@@ -656,14 +675,23 @@ final class DownloadController: ObservableObject {
|
||||
}
|
||||
|
||||
private func saveDownloads() {
|
||||
do {
|
||||
let directory = storageURL.deletingLastPathComponent()
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
|
||||
let state = StoredDownloadState(queues: queues, downloads: downloads)
|
||||
let data = try JSONEncoder().encode(state)
|
||||
try data.write(to: storageURL, options: .atomic)
|
||||
} catch {
|
||||
print("Failed to save downloads: \(error)")
|
||||
let queuesCopy = queues
|
||||
let downloadsCopy = downloads
|
||||
let storageURL = self.storageURL
|
||||
|
||||
saveTask?.cancel()
|
||||
saveTask = Task.detached(priority: .background) {
|
||||
do {
|
||||
let directory = storageURL.deletingLastPathComponent()
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
|
||||
let state = StoredDownloadState(queues: queuesCopy, downloads: downloadsCopy)
|
||||
let data = try JSONEncoder().encode(state)
|
||||
|
||||
guard !Task.isCancelled else { return }
|
||||
try data.write(to: storageURL, options: .atomic)
|
||||
} catch {
|
||||
print("Failed to save downloads: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ final class TableSettings: ObservableObject {
|
||||
|
||||
private let defaults = UserDefaults.standard
|
||||
private let storageKey = "Firelink.TableSettings.v1"
|
||||
private var saveTask: Task<Void, Never>?
|
||||
|
||||
init() {
|
||||
let defaultVisibleColumns: Set<DownloadColumn> = [.fileName, .size, .progress, .speed, .eta, .dateAdded]
|
||||
@@ -90,8 +91,16 @@ final class TableSettings: ObservableObject {
|
||||
sortColumn: sortColumn,
|
||||
sortDirection: sortDirection
|
||||
)
|
||||
if let data = try? JSONEncoder().encode(stored) {
|
||||
defaults.set(data, forKey: storageKey)
|
||||
let storageKey = self.storageKey
|
||||
|
||||
saveTask?.cancel()
|
||||
saveTask = Task { @MainActor in
|
||||
let data = await Task.detached(priority: .background) {
|
||||
try? JSONEncoder().encode(stored)
|
||||
}.value
|
||||
|
||||
guard !Task.isCancelled, let encoded = data else { return }
|
||||
UserDefaults.standard.set(encoded, forKey: storageKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -114,7 +123,7 @@ struct DownloadTable: View {
|
||||
|
||||
@StateObject private var tableSettings = TableSettings()
|
||||
@State private var pendingDeleteItems: Set<DownloadItem.ID>?
|
||||
@State private var resizeBaseWidths: [DownloadColumn: CGFloat] = [:]
|
||||
@State private var dragOffsets: [DownloadColumn: CGFloat] = [:]
|
||||
@State private var lastSelectedIndex: Int?
|
||||
@State private var draggedItemID: DownloadItem.ID?
|
||||
|
||||
@@ -186,7 +195,7 @@ struct DownloadTable: View {
|
||||
pendingDeleteItems = nil
|
||||
}
|
||||
|
||||
Button("Move File and Cache to Trash", role: .destructive) {
|
||||
Button("Move to Trash", role: .destructive) {
|
||||
let items = controller.downloads.filter { ids.contains($0.id) }
|
||||
for item in items { controller.delete(item, deleteFiles: true) }
|
||||
selection.subtract(ids)
|
||||
@@ -218,6 +227,13 @@ struct DownloadTable: View {
|
||||
.frame(width: tableWidth, alignment: .leading)
|
||||
.background(selection.contains(item.id) ? Color.accentColor.opacity(0.12) : Color.clear)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture(count: 2) {
|
||||
if item.status == .completed {
|
||||
openFile(item)
|
||||
} else {
|
||||
openWindow(value: item.id)
|
||||
}
|
||||
}
|
||||
.onTapGesture {
|
||||
let index = sortedItems.firstIndex(where: { $0.id == item.id })
|
||||
|
||||
@@ -276,12 +292,12 @@ struct DownloadTable: View {
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 1)
|
||||
.onChanged { value in
|
||||
let baseWidth = resizeBaseWidths[column] ?? width(for: column)
|
||||
resizeBaseWidths[column] = baseWidth
|
||||
tableSettings.columnWidths[column] = max(70, baseWidth + value.translation.width)
|
||||
dragOffsets[column] = value.translation.width
|
||||
}
|
||||
.onEnded { _ in
|
||||
resizeBaseWidths[column] = nil
|
||||
.onEnded { value in
|
||||
let baseWidth = tableSettings.columnWidths[column] ?? column.width
|
||||
tableSettings.columnWidths[column] = max(70, baseWidth + value.translation.width)
|
||||
dragOffsets[column] = nil
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -326,12 +342,14 @@ struct DownloadTable: View {
|
||||
private func rowContextMenu(for item: DownloadItem) -> some View {
|
||||
let targetItems = selection.contains(item.id) ? controller.downloads.filter { selection.contains($0.id) } : [item]
|
||||
|
||||
Button {
|
||||
for target in targetItems {
|
||||
openWindow(value: target.id)
|
||||
if targetItems.allSatisfy({ $0.status == .completed }) {
|
||||
Button {
|
||||
for target in targetItems {
|
||||
openFile(target)
|
||||
}
|
||||
} label: {
|
||||
Label(targetItems.count > 1 ? "Open (\(targetItems.count))" : "Open", systemImage: "doc")
|
||||
}
|
||||
} label: {
|
||||
Label(targetItems.count > 1 ? "Properties (\(targetItems.count))" : "Properties", systemImage: "info.circle")
|
||||
}
|
||||
|
||||
Button {
|
||||
@@ -354,9 +372,9 @@ struct DownloadTable: View {
|
||||
}
|
||||
}
|
||||
|
||||
if targetItems.contains(where: { $0.status == .downloading }) {
|
||||
if targetItems.contains(where: { $0.status == .downloading || $0.status == .queued }) {
|
||||
Button {
|
||||
for target in targetItems where target.status == .downloading {
|
||||
for target in targetItems where target.status == .downloading || target.status == .queued {
|
||||
controller.pause(target)
|
||||
}
|
||||
} label: {
|
||||
@@ -364,6 +382,18 @@ struct DownloadTable: View {
|
||||
}
|
||||
}
|
||||
|
||||
if targetItems.contains(where: { $0.status == .completed || $0.status == .failed || $0.status == .canceled }) {
|
||||
Button {
|
||||
for target in targetItems where target.status == .completed || target.status == .failed || target.status == .canceled {
|
||||
controller.redownload(target)
|
||||
}
|
||||
} label: {
|
||||
Label("Redownload", systemImage: "arrow.clockwise")
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
if targetItems.contains(where: { $0.status != .completed && $0.status != .downloading }) {
|
||||
Menu {
|
||||
ForEach(controller.queues) { queue in
|
||||
@@ -375,8 +405,25 @@ struct DownloadTable: View {
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label("Add to Queue", systemImage: "list.bullet")
|
||||
Label("Move to Queue", systemImage: "list.bullet")
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
|
||||
Button {
|
||||
NSPasteboard.general.clearContents()
|
||||
let urls = targetItems.map { $0.url.absoluteString }.joined(separator: "\n")
|
||||
NSPasteboard.general.setString(urls, forType: .string)
|
||||
} label: {
|
||||
Label(targetItems.count > 1 ? "Copy Addresses" : "Copy Address", systemImage: "link")
|
||||
}
|
||||
|
||||
Button {
|
||||
for target in targetItems {
|
||||
openWindow(value: target.id)
|
||||
}
|
||||
} label: {
|
||||
Label(targetItems.count > 1 ? "Properties (\(targetItems.count))" : "Properties", systemImage: "info.circle")
|
||||
}
|
||||
|
||||
Divider()
|
||||
@@ -384,7 +431,7 @@ struct DownloadTable: View {
|
||||
Button(role: .destructive) {
|
||||
pendingDeleteItems = Set(targetItems.map(\.id))
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
Label("Remove", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,7 +454,9 @@ struct DownloadTable: View {
|
||||
}
|
||||
|
||||
private func width(for column: DownloadColumn) -> CGFloat {
|
||||
max(70, tableSettings.columnWidths[column] ?? column.width)
|
||||
let baseWidth = tableSettings.columnWidths[column] ?? column.width
|
||||
let offset = dragOffsets[column] ?? 0
|
||||
return max(70, baseWidth + offset)
|
||||
}
|
||||
|
||||
private var sortedItems: [DownloadItem] {
|
||||
@@ -523,6 +572,15 @@ struct DownloadTable: View {
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
private func openFile(_ item: DownloadItem) {
|
||||
let fileURL = item.destinationDirectory.appendingPathComponent(item.fileName)
|
||||
if FileManager.default.fileExists(atPath: fileURL.path) {
|
||||
NSWorkspace.shared.open(fileURL)
|
||||
} else {
|
||||
NSWorkspace.shared.open(existingFolder(for: item.destinationDirectory))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct DownloadRow: View {
|
||||
|
||||
@@ -60,9 +60,21 @@ struct FirelinkApp: App {
|
||||
}
|
||||
}
|
||||
|
||||
MenuBarExtra("Firelink", systemImage: "arrow.down.circle", isInserted: $showMenuBarIcon) {
|
||||
MenuBarExtra(isInserted: $showMenuBarIcon) {
|
||||
TrayMenuView()
|
||||
.environmentObject(controller)
|
||||
} label: {
|
||||
if let nsImage = { () -> NSImage? in
|
||||
guard let url = Bundle.main.url(forResource: "MenuBarIconTemplate", withExtension: "png"),
|
||||
let img = NSImage(contentsOf: url) else { return nil }
|
||||
img.size = NSSize(width: 21, height: 21)
|
||||
img.isTemplate = true
|
||||
return img
|
||||
}() {
|
||||
Image(nsImage: nsImage)
|
||||
} else {
|
||||
Image(systemName: "arrow.down.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,7 +194,7 @@ private struct AboutSettingsPane: View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Firelink")
|
||||
.font(.title2.weight(.semibold))
|
||||
Text("Version \(appVersion) (\(buildNumber))")
|
||||
Text("Version \(appVersion)")
|
||||
.foregroundStyle(.secondary)
|
||||
Text("A native macOS download manager for fast, organized, segmented transfers.")
|
||||
.font(.caption)
|
||||
@@ -204,30 +204,6 @@ private struct AboutSettingsPane: View {
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
Section("Developer") {
|
||||
LabeledContent("Created by") {
|
||||
Text("NimBold")
|
||||
}
|
||||
|
||||
LabeledContent("GitHub") {
|
||||
Link("@nimbold", destination: developerProfileURL)
|
||||
}
|
||||
|
||||
LabeledContent("Source") {
|
||||
Link("nimbold/Firelink", destination: projectURL)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Credits") {
|
||||
LabeledContent("Download engine") {
|
||||
Link("aria2", destination: aria2URL)
|
||||
}
|
||||
|
||||
Text("Firelink uses aria2c for segmented HTTP, HTTPS, FTP, and SFTP downloads.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section("Updates") {
|
||||
LabeledContent("Status") {
|
||||
Label(updateChecker.status.message, systemImage: updateStatusSymbol)
|
||||
@@ -270,6 +246,26 @@ private struct AboutSettingsPane: View {
|
||||
}
|
||||
}
|
||||
|
||||
Section("Developer") {
|
||||
LabeledContent("Created by") {
|
||||
Text("NimBold")
|
||||
}
|
||||
|
||||
LabeledContent("Source") {
|
||||
Link("nimbold/Firelink", destination: projectURL)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Credits") {
|
||||
LabeledContent("Download engine") {
|
||||
Link("aria2", destination: aria2URL)
|
||||
}
|
||||
|
||||
Text("Firelink uses aria2c for segmented HTTP, HTTPS, FTP, and SFTP downloads.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section("Legal") {
|
||||
LabeledContent("License") {
|
||||
Link("MIT License", destination: licenseURL)
|
||||
|
||||
Reference in New Issue
Block a user