Compare commits

...

14 Commits

Author SHA1 Message Date
nimbold 93e6e11939 docs: update changelog for 0.4.3 release 2026-06-03 07:07:28 +03:30
nimbold 6750cce6aa perf: reduce main thread CPU usage and SSD wear from frequent JSON serialization 2026-06-03 07:03:18 +03:30
nimbold 1d22b16119 style: refine about page UI and simplify delete confirmation dialog 2026-06-03 06:48:53 +03:30
nimbold deb106f7af docs: update changelog and roadmap for 0.4.2 release 2026-06-03 06:30:28 +03:30
nimbold 1093d73b0e feat: add monochrome tray icon and download table enhancements
- Added proper monochrome template tray icon loaded explicitly with precise dimensions
- Added redownload functionality for completed or failed items
- Added double-click to open completed files directly from the table
- Added 'Copy Address' context menu action
- Improved context menu organization and conditionally displayed actions
2026-06-03 06:23:03 +03:30
nimbold 3d117fd6b0 chore: prepare release 0.4.1 2026-06-03 05:36:57 +03:30
nimbold 21b30a9369 feat: add site logins integration to Add Downloads window 2026-06-03 05:32:39 +03:30
nimbold c4f02ff78f fix: remove unused showMenuBarIcon from AppSettings and conditionally apply theme backgrounds 2026-06-03 05:17:25 +03:30
nimbold f6d34caaf1 style: use visual command icon instead of text for paste hint 2026-06-03 05:11:18 +03:30
nimbold 7e4915f31d fix: resolve SwiftUI infinite layout freeze caused by MenuBarExtra isInserted binding to StateObject by using AppStorage 2026-06-03 05:07:55 +03:30
nimbold ff03a4cda5 feat: add Font Size, List Row Density, and Menu Bar Icon settings 2026-06-03 05:03:31 +03:30
nimbold 1f82d342e3 fix: resolve Light/System theme bug by using NSApp.appearance and replace Modern Dark with standard Dark 2026-06-03 04:54:31 +03:30
nimbold c653f7e733 feat: add app theming engine with Look and Feel settings 2026-06-03 04:44:03 +03:30
nimbold 992d448e60 feat: add tray icon and context menu for main window and queues 2026-06-03 04:35:18 +03:30
14 changed files with 549 additions and 61 deletions
+35
View File
@@ -5,6 +5,41 @@ 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
- Added app theming engine with Look and Feel settings.
- Added Font Size, List Row Density, and Menu Bar Icon settings.
- Added tray icon and context menu for main window and queues.
- Added site logins integration directly into the Add Downloads window.
### Changes
- Updated the paste hint to use a visual Command icon.
### Fixes
- Resolved SwiftUI infinite layout freeze caused by MenuBarExtra binding.
- Fixed a bug with Light/System theme appearance.
- Fixed phantom state issues with Menu Bar Icon setting and conditionally applied theme backgrounds to preserve native macOS translucency.
## [0.4.0] - 2026-06-03
### Changes
+9 -3
View File
@@ -92,8 +92,8 @@ make dmg
### Release
GitHub Actions builds and publishes the macOS ARM64 DMG when a version tag is pushed:
```bash
git tag v0.1.0
git push origin v0.1.0
git tag v0.4.1
git push origin v0.4.1
```
---
@@ -102,7 +102,7 @@ git push origin v0.1.0
- [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.
@@ -110,6 +110,12 @@ git push origin v0.1.0
---
## 🏆 Credits
Firelink relies on [aria2](https://aria2.github.io/) as its underlying multi-protocol and multi-source command-line download utility. Special thanks to the aria2 contributors for their excellent engine.
---
## 📄 License
Firelink is released under the MIT License. See [LICENSE](LICENSE) for details.
+1
View File
@@ -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
+53
View File
@@ -21,6 +21,10 @@ struct AddDownloadsView: View {
@State private var headerText = ""
@State private var cookieText = ""
@State private var mirrorText = ""
@State private var useAuthorization = false
@State private var authUsername = ""
@State private var authPassword = ""
@State private var saveLogin = false
var body: some View {
VStack(spacing: 0) {
@@ -139,6 +143,30 @@ struct AddDownloadsView: View {
}
}
}
GridRow(alignment: .top) {
Label("Authorization", systemImage: "lock.shield")
.font(.headline)
.padding(.top, 4)
VStack(alignment: .leading, spacing: 10) {
Toggle("Use authorization", isOn: $useAuthorization)
.toggleStyle(.switch)
if useAuthorization {
HStack(spacing: 10) {
TextField("Username", text: $authUsername)
.textFieldStyle(.roundedBorder)
.frame(width: 160)
SecureField("Password", text: $authPassword)
.textFieldStyle(.roundedBorder)
.frame(width: 160)
}
Toggle("Save login for this website", isOn: $saveLogin)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
}
@@ -365,6 +393,13 @@ struct AddDownloadsView: View {
)
}
if let firstURL = urls.first, let creds = settings.credentials(for: firstURL) {
useAuthorization = true
authUsername = creds.username
authPassword = creds.password
saveLogin = false
}
metadataTask = Task {
var loaded: [PendingDownload] = []
for url in urls {
@@ -400,12 +435,30 @@ struct AddDownloadsView: View {
}
private func addDownloads(start: Bool) {
var explicitCredentials: DownloadCredentials? = nil
if useAuthorization {
let cleanUsername = authUsername.trimmingCharacters(in: .whitespacesAndNewlines)
if !cleanUsername.isEmpty {
explicitCredentials = DownloadCredentials(username: cleanUsername, password: authPassword)
if saveLogin {
var savedHosts = Set<String>()
for item in pendingDownloads {
if let host = item.url.host, !savedHosts.contains(host) {
settings.addSiteLogin(urlPattern: "*.\(host)", username: cleanUsername, password: authPassword)
savedHosts.insert(host)
}
}
}
}
}
controller.addPendingDownloads(
pendingDownloads,
connectionsPerServer: Int(connectionsPerServer),
overrideDirectory: overrideDirectory,
startImmediately: start,
queueID: targetQueueID,
credentials: explicitCredentials,
transferOptions: transferOptions,
speedLimitKiBPerSecond: speedLimitEnabled ? speedLimitKiBPerSecond : nil
)
+35 -2
View File
@@ -65,6 +65,18 @@ struct DownloadProxyConfiguration: Equatable, Sendable {
@MainActor
final class AppSettings: ObservableObject {
@Published var appTheme: AppTheme = .system {
didSet { save() }
}
@Published var appFontSize: AppFontSize = .standard {
didSet { save() }
}
@Published var listRowDensity: ListRowDensity = .standard {
didSet { save() }
}
@Published var perServerConnections: Int {
didSet {
let clamped = min(max(perServerConnections, 1), 16)
@@ -123,12 +135,16 @@ 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
if let data = defaults.data(forKey: storageKey),
let stored = try? JSONDecoder().decode(StoredSettings.self, from: data) {
appTheme = stored.appTheme ?? .system
appFontSize = stored.appFontSize ?? .standard
listRowDensity = stored.listRowDensity ?? .standard
perServerConnections = min(max(stored.perServerConnections, 1), 16)
maxConcurrentDownloads = min(max(stored.maxConcurrentDownloads ?? 3, 1), 12)
globalSpeedLimitKiBPerSecond = min(max(stored.globalSpeedLimitKiBPerSecond ?? 0, 0), 10_485_760)
@@ -137,6 +153,9 @@ final class AppSettings: ObservableObject {
siteLogins = stored.siteLogins
downloadDirectories = Self.decodeDirectories(stored.downloadDirectories)
} else {
appTheme = .system
appFontSize = .standard
listRowDensity = .standard
perServerConnections = 16
maxConcurrentDownloads = 3
globalSpeedLimitKiBPerSecond = 0
@@ -218,6 +237,9 @@ final class AppSettings: ObservableObject {
private func save() {
let stored = StoredSettings(
appTheme: appTheme,
appFontSize: appFontSize,
listRowDensity: listRowDensity,
perServerConnections: perServerConnections,
maxConcurrentDownloads: maxConcurrentDownloads,
globalSpeedLimitKiBPerSecond: globalSpeedLimitKiBPerSecond,
@@ -226,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)
}
}
@@ -277,6 +307,9 @@ final class AppSettings: ObservableObject {
}
private struct StoredSettings: Codable {
var appTheme: AppTheme?
var appFontSize: AppFontSize?
var listRowDensity: ListRowDensity?
var perServerConnections: Int
var maxConcurrentDownloads: Int?
var globalSpeedLimitKiBPerSecond: Int?
@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "MenuBarIconTemplate.png",
"idiom" : "mac",
"scale" : "2x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"template-rendering-intent" : "template"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

+3
View File
@@ -3,6 +3,7 @@ import SwiftUI
struct ContentView: View {
@EnvironmentObject private var controller: DownloadController
@EnvironmentObject private var settings: AppSettings
@Environment(\.openWindow) private var openWindow
@State private var selection: Set<DownloadItem.ID> = []
@State private var sidebarSelection: SidebarSelection = .downloads(.all)
@@ -12,8 +13,10 @@ struct ContentView: View {
NavigationSplitView {
SidebarView(selection: $sidebarSelection)
.navigationSplitViewColumnWidth(min: 190, ideal: 220, max: 260)
.themeBackground(settings.appTheme.theme.secondaryBackground)
} detail: {
detailView
.themeBackground(settings.appTheme.theme.background)
}
}
+38 -9
View File
@@ -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
@@ -116,6 +117,7 @@ final class DownloadController: ObservableObject {
overrideDirectory: URL?,
startImmediately: Bool,
queueID: UUID = DownloadQueue.mainQueueID,
credentials: DownloadCredentials? = nil,
transferOptions: DownloadTransferOptions = DownloadTransferOptions(),
speedLimitKiBPerSecond: Int? = nil
) {
@@ -130,7 +132,7 @@ final class DownloadController: ObservableObject {
category: pending.category,
destinationDirectory: overrideDirectory ?? pending.defaultDirectory,
connectionsPerServer: clampedConnections,
credentials: settings.credentials(for: pending.url),
credentials: credentials ?? settings.credentials(for: pending.url),
checksum: transferOptions.checksum,
requestHeaders: transferOptions.requestHeaders,
cookieHeader: transferOptions.cookieHeader,
@@ -243,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
@@ -655,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)")
}
}
}
+80 -21
View File
@@ -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?
@@ -161,7 +170,7 @@ struct DownloadTable: View {
ContentUnavailableView(
"No Downloads",
systemImage: "arrow.down.circle",
description: Text("Use Add or press Command-V to paste one or more links.")
description: Text("Use Add or press \(Image(systemName: "command"))V to paste one or more links.")
)
}
}
@@ -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,9 +572,19 @@ 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 {
@EnvironmentObject private var settings: AppSettings
let item: DownloadItem
let priorityNumber: Int?
let visibleColumns: [DownloadColumn]
@@ -537,7 +596,7 @@ private struct DownloadRow: View {
ForEach(visibleColumns) { column in
cell(for: column)
.padding(.horizontal, 8)
.padding(.vertical, 8)
.padding(.vertical, settings.listRowDensity.verticalPadding)
.frame(width: columnWidth(column), alignment: alignment(for: column))
.clipped()
}
+27 -1
View File
@@ -5,6 +5,7 @@ struct FirelinkApp: App {
@StateObject private var settings: AppSettings
@StateObject private var controller: DownloadController
@StateObject private var schedulerController: SchedulerController
@AppStorage("showMenuBarIcon") private var showMenuBarIcon = true
init() {
let settings = AppSettings()
@@ -15,11 +16,13 @@ struct FirelinkApp: App {
}
var body: some Scene {
WindowGroup {
WindowGroup(id: "main") {
ContentView()
.environmentObject(controller)
.environmentObject(settings)
.environmentObject(schedulerController)
.modifier(AppThemeModifier(theme: settings.appTheme))
.dynamicTypeSize(settings.appFontSize.dynamicTypeSize)
.frame(minWidth: 1180, idealWidth: 1280, minHeight: 720, idealHeight: 760)
}
.windowStyle(.titleBar)
@@ -28,6 +31,8 @@ struct FirelinkApp: App {
AddDownloadsView()
.environmentObject(controller)
.environmentObject(settings)
.modifier(AppThemeModifier(theme: settings.appTheme))
.dynamicTypeSize(settings.appFontSize.dynamicTypeSize)
}
.windowResizability(.contentSize)
@@ -36,8 +41,12 @@ struct FirelinkApp: App {
DownloadPropertiesWindow(downloadID: downloadID)
.environmentObject(controller)
.environmentObject(settings)
.modifier(AppThemeModifier(theme: settings.appTheme))
.dynamicTypeSize(settings.appFontSize.dynamicTypeSize)
} else {
ContentUnavailableView("Download Not Found", systemImage: "questionmark.circle")
.modifier(AppThemeModifier(theme: settings.appTheme))
.dynamicTypeSize(settings.appFontSize.dynamicTypeSize)
}
}
.windowResizability(.contentSize)
@@ -50,5 +59,22 @@ struct FirelinkApp: App {
.keyboardShortcut("r", modifiers: [.command])
}
}
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")
}
}
}
}
+74 -25
View File
@@ -4,6 +4,7 @@ import SwiftUI
private enum SettingsSection: String, CaseIterable, Hashable {
case downloads = "Downloads"
case locations = "Locations"
case lookAndFeel = "Look and feel"
case network = "Network"
case siteLogins = "Site Logins"
case power = "Power"
@@ -13,6 +14,7 @@ private enum SettingsSection: String, CaseIterable, Hashable {
static let orderedCases: [SettingsSection] = [
.downloads,
.locations,
.lookAndFeel,
.network,
.siteLogins,
.power,
@@ -23,6 +25,7 @@ private enum SettingsSection: String, CaseIterable, Hashable {
var symbolName: String {
switch self {
case .downloads: "arrow.down.circle"
case .lookAndFeel: "paintpalette"
case .network: "network"
case .locations: "folder"
case .siteLogins: "key.fill"
@@ -43,6 +46,7 @@ private enum SettingsSection: String, CaseIterable, Hashable {
}
struct SettingsView: View {
@EnvironmentObject private var settings: AppSettings
@State private var selection: SettingsSection = .downloads
var body: some View {
@@ -69,6 +73,7 @@ struct SettingsView: View {
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.themeBackground(settings.appTheme.theme.background)
}
private var settingsSidebar: some View {
@@ -96,6 +101,8 @@ struct SettingsView: View {
switch selection {
case .downloads:
DownloadSettingsPane()
case .lookAndFeel:
LookAndFeelSettingsPane()
case .network:
NetworkSettingsPane()
case .locations:
@@ -112,6 +119,52 @@ struct SettingsView: View {
}
}
private struct LookAndFeelSettingsPane: View {
@EnvironmentObject private var settings: AppSettings
@AppStorage("showMenuBarIcon") private var showMenuBarIcon = true
var body: some View {
Form {
Section("App Theme") {
Picker("Theme", selection: $settings.appTheme) {
ForEach(AppTheme.allCases) { theme in
Text(theme.rawValue)
.tag(theme)
}
}
.pickerStyle(.radioGroup)
Text("Select a color palette for the app's user interface.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Display") {
Picker("Font Size", selection: $settings.appFontSize) {
ForEach(AppFontSize.allCases) { size in
Text(size.rawValue).tag(size)
}
}
Picker("List Row Density", selection: $settings.listRowDensity) {
ForEach(ListRowDensity.allCases) { density in
Text(density.rawValue).tag(density)
}
}
}
Section("Menu Bar") {
Toggle("Show menu bar icon", isOn: $showMenuBarIcon)
Text("Provides quick access to downloads and queues from the macOS menu bar. Restart required if hiding.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
}
}
private struct AboutSettingsPane: View {
@StateObject private var updateChecker = AppUpdateChecker()
@State private var availableUpdate: AvailableUpdate?
@@ -141,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)
@@ -151,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)
@@ -217,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)
+140
View File
@@ -0,0 +1,140 @@
import SwiftUI
import AppKit
enum AppFontSize: String, Codable, CaseIterable, Identifiable, Sendable {
case small = "Small"
case standard = "Standard"
case large = "Large"
var id: String { rawValue }
var dynamicTypeSize: DynamicTypeSize {
switch self {
case .small: return .small
case .standard: return .medium
case .large: return .xLarge
}
}
}
enum ListRowDensity: String, Codable, CaseIterable, Identifiable, Sendable {
case compact = "Compact"
case standard = "Standard"
case relaxed = "Relaxed"
var id: String { rawValue }
var verticalPadding: CGFloat {
switch self {
case .compact: return 4
case .standard: return 8
case .relaxed: return 14
}
}
}
enum AppTheme: String, Codable, CaseIterable, Identifiable, Sendable {
case system = "System Default"
case light = "Light"
case dark = "Dark"
case dracula = "Dracula"
case nord = "Nord"
var id: String { rawValue }
var theme: Theme {
switch self {
case .system, .dark:
return Theme.system
case .light:
return Theme.modernLight
case .dracula:
return Theme.dracula
case .nord:
return Theme.nord
}
}
var appearance: NSAppearance? {
switch self {
case .system: return nil
case .light: return NSAppearance(named: .aqua)
case .dark, .dracula, .nord: return NSAppearance(named: .darkAqua)
}
}
}
struct Theme: Equatable, Sendable {
var background: Color?
var secondaryBackground: Color?
var text: Color?
var secondaryText: Color?
var accent: Color?
static let system = Theme(
background: nil,
secondaryBackground: nil,
text: nil,
secondaryText: nil,
accent: nil
)
static let modernLight = Theme(
background: Color(nsColor: NSColor(calibratedRed: 0.98, green: 0.98, blue: 0.99, alpha: 1.0)),
secondaryBackground: Color(nsColor: NSColor(calibratedRed: 0.94, green: 0.94, blue: 0.96, alpha: 1.0)),
text: Color.primary,
secondaryText: Color.secondary,
accent: Color.accentColor
)
static let dracula = Theme(
background: Color(nsColor: NSColor(calibratedRed: 0.16, green: 0.16, blue: 0.21, alpha: 1.0)),
secondaryBackground: Color(nsColor: NSColor(calibratedRed: 0.27, green: 0.28, blue: 0.35, alpha: 1.0)),
text: Color(nsColor: NSColor(calibratedRed: 0.97, green: 0.97, blue: 0.95, alpha: 1.0)),
secondaryText: Color(nsColor: NSColor(calibratedRed: 0.38, green: 0.44, blue: 0.58, alpha: 1.0)),
accent: Color(nsColor: NSColor(calibratedRed: 1.00, green: 0.47, blue: 0.65, alpha: 1.0)) // Pink
)
static let nord = Theme(
background: Color(nsColor: NSColor(calibratedRed: 0.18, green: 0.20, blue: 0.25, alpha: 1.0)), // nord0
secondaryBackground: Color(nsColor: NSColor(calibratedRed: 0.23, green: 0.26, blue: 0.32, alpha: 1.0)), // nord1
text: Color(nsColor: NSColor(calibratedRed: 0.85, green: 0.87, blue: 0.91, alpha: 1.0)), // nord4
secondaryText: Color(nsColor: NSColor(calibratedRed: 0.57, green: 0.63, blue: 0.70, alpha: 1.0)), // nord3
accent: Color(nsColor: NSColor(calibratedRed: 0.53, green: 0.75, blue: 0.82, alpha: 1.0)) // nord8
)
}
struct AppThemeModifier: ViewModifier {
let theme: AppTheme
func body(content: Content) -> some View {
content
.tint(theme.theme.accent)
.onAppear {
NSApp.appearance = theme.appearance
}
.onChange(of: theme) { _, newTheme in
NSApp.appearance = newTheme.appearance
}
}
}
struct ThemeBackgroundModifier: ViewModifier {
let color: Color?
func body(content: Content) -> some View {
if let color {
content
.scrollContentBackground(.hidden)
.background(color)
} else {
content
}
}
}
extension View {
func themeBackground(_ color: Color?) -> some View {
modifier(ThemeBackgroundModifier(color: color))
}
}
+38
View File
@@ -0,0 +1,38 @@
import SwiftUI
struct TrayMenuView: View {
@Environment(\.openWindow) private var openWindow
@EnvironmentObject private var controller: DownloadController
var body: some View {
Button("Show main window") {
openWindow(id: "main")
NSApp.activate(ignoringOtherApps: true)
}
Button("Add downloads") {
openWindow(id: "add-downloads")
NSApp.activate(ignoringOtherApps: true)
}
Menu("Start queue") {
ForEach(controller.queues) { queue in
Button(queue.name) {
controller.startQueue(queueID: queue.id)
}
}
}
Button("Stop downloads") {
for download in controller.downloads where download.status == .downloading {
controller.pause(download)
}
}
Divider()
Button("Exit") {
NSApplication.shared.terminate(nil)
}
}
}