feat: modernize settings and add SOCKS5 support

- Split monolithic SettingsView.swift into modular Settings panes
- Add SOCKS5 proxy support to proxy configuration
- Fix ListRowDensity application in DownloadTable
- Improve discoverability of global speed limit by adding it to Downloads pane
This commit is contained in:
nimbold
2026-06-05 02:52:52 +03:30
parent 544db2aab6
commit 2b820ec802
14 changed files with 790 additions and 750 deletions
+2
View File
@@ -24,12 +24,14 @@ enum ProxyType: String, Codable, CaseIterable, Sendable {
case http
case https
case ftp
case socks5
var title: String {
switch self {
case .http: "HTTP"
case .https: "HTTPS"
case .ftp: "FTP"
case .socks5: "SOCKS5"
}
}
+1
View File
@@ -104,6 +104,7 @@ struct DownloadTable: View {
}
.width(min: 100, ideal: 155)
}
.environment(\.defaultMinListRowHeight, settings.listRowDensity.minRowHeight)
.contextMenu(forSelectionType: DownloadItem.ID.self) { itemIDs in
rowContextMenu(for: itemIDs)
}
@@ -0,0 +1,179 @@
import AppKit
import SwiftUI
struct AboutSettingsPane: View {
@StateObject private var updateChecker = AppUpdateChecker()
@State private var availableUpdate: AvailableUpdate?
private let developerProfileURL = URL(string: "https://github.com/nimbold")!
private let projectURL = URL(string: "https://github.com/nimbold/Firelink")!
private let aria2URL = URL(string: "https://aria2.github.io/")!
private let licenseURL = URL(string: "https://github.com/nimbold/Firelink/blob/main/LICENSE")!
private var appVersion: String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.1.0"
}
private var buildNumber: String {
Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Development"
}
var body: some View {
Form {
Section {
HStack(alignment: .center, spacing: 14) {
Image(nsImage: NSApp.applicationIconImage)
.resizable()
.frame(width: 56, height: 56)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 4) {
Text("Firelink")
.font(.title2.weight(.semibold))
Text("Version \(appVersion)")
.foregroundStyle(.secondary)
Text("A native macOS download manager for fast, organized, segmented transfers.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 4)
}
Section("Updates") {
LabeledContent("Status") {
Label(updateChecker.status.message, systemImage: updateStatusSymbol)
.foregroundStyle(updateStatusColor)
}
if let lastChecked = updateChecker.lastChecked {
LabeledContent("Last checked") {
Text(lastChecked, format: .dateTime.month().day().hour().minute())
.foregroundStyle(.secondary)
}
}
HStack {
Button {
Task {
await updateChecker.checkForUpdates(currentVersion: appVersion)
}
} label: {
Label("Check for Updates", systemImage: "arrow.clockwise")
}
.disabled(updateChecker.status == .checking)
Button {
openReleasesPage()
} label: {
Label("Open Releases", systemImage: "arrow.up.right.square")
}
Spacer()
}
if case .updateAvailable(_, let releaseURL) = updateChecker.status {
Button {
NSWorkspace.shared.open(releaseURL)
} label: {
Label("Download Latest Version", systemImage: "square.and.arrow.down")
}
.buttonStyle(.borderedProminent)
}
}
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)
}
Text("Copyright © 2026 NimBold. Firelink is released under the MIT License.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
.onChange(of: updateChecker.status) { _, status in
if case .updateAvailable(let latestVersion, let releaseURL) = status {
availableUpdate = AvailableUpdate(version: latestVersion, url: releaseURL)
}
}
.alert("Update Available", isPresented: Binding(
get: { availableUpdate != nil },
set: { isPresented in
if !isPresented {
availableUpdate = nil
}
}
)) {
Button("Not Now", role: .cancel) {
availableUpdate = nil
}
Button("Yes") {
if let releaseURL = availableUpdate?.url {
NSWorkspace.shared.open(releaseURL)
}
availableUpdate = nil
}
} message: {
Text("Firelink version \(availableUpdate?.version ?? "") is available. Do you want to open the download page?")
}
}
private var updateStatusSymbol: String {
switch updateChecker.status {
case .idle:
"sparkle.magnifyingglass"
case .checking:
"arrow.clockwise"
case .upToDate:
"checkmark.seal.fill"
case .updateAvailable:
"arrow.down.circle.fill"
case .unavailable:
"exclamationmark.triangle.fill"
}
}
private var updateStatusColor: Color {
switch updateChecker.status {
case .idle, .checking:
.secondary
case .upToDate:
.green
case .updateAvailable:
.accentColor
case .unavailable:
.orange
}
}
private func openReleasesPage() {
NSWorkspace.shared.open(updateChecker.releasesURL)
}
private struct AvailableUpdate: Equatable {
var version: String
var url: URL
}
}
@@ -0,0 +1,46 @@
import SwiftUI
struct DownloadSettingsPane: View {
@EnvironmentObject private var settings: AppSettings
var body: some View {
Form {
Section {
Stepper(
"Default connections per server: \(settings.perServerConnections)",
value: $settings.perServerConnections,
in: 1...16
)
Text("Used as the default for new downloads. The Add Downloads window can override it per batch.")
.font(.caption)
.foregroundStyle(.secondary)
Stepper(
"Parallel downloads: \(settings.maxConcurrentDownloads)",
value: $settings.maxConcurrentDownloads,
in: 1...12
)
Text("Controls how many files Firelink downloads at the same time.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section {
LabeledContent("Global speed limit") {
HStack {
TextField("0", value: $settings.globalSpeedLimitKiBPerSecond, format: .number)
.textFieldStyle(.roundedBorder)
.multilineTextAlignment(.trailing)
.frame(width: 80)
Text("KiB/s")
.foregroundStyle(.secondary)
}
}
Text("Set to 0 for unlimited speed. This limit is divided across all active parallel downloads.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
}
}
@@ -0,0 +1,46 @@
import SwiftUI
import AppKit
struct EngineSettingsPane: View {
@State private var version = "Checking..."
private var executableURL: URL? {
Aria2DownloadEngine.findExecutable()
}
var body: some View {
Form {
Section {
LabeledContent("Status") {
Label(
executableURL == nil ? "Missing" : "Ready",
systemImage: executableURL == nil ? "exclamationmark.triangle.fill" : "checkmark.seal.fill"
)
.foregroundStyle(executableURL == nil ? .orange : .green)
}
LabeledContent("Binary") {
Text(executableURL?.path ?? "Not found")
.font(.system(.body, design: .monospaced))
.textSelection(.enabled)
}
LabeledContent("Version") {
Text(version)
.font(.system(.body, design: .monospaced))
.textSelection(.enabled)
}
if executableURL == nil {
Text("Install aria2 with Homebrew or bundle aria2c inside the app resources.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.formStyle(.grouped)
.task {
version = await Aria2DownloadEngine.versionString() ?? "Unavailable"
}
}
}
@@ -0,0 +1,164 @@
import AppKit
import SwiftUI
struct IntegrationSettingsPane: View {
@State private var copiedExtensionURL: URL?
@State private var installMessage = ""
var body: some View {
Form {
Section {
HStack(alignment: .center, spacing: 14) {
Image(systemName: "puzzlepiece.extension")
.resizable()
.frame(width: 48, height: 48)
.foregroundStyle(.orange)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 4) {
Text("Firefox Extension")
.font(.title2.weight(.semibold))
Text("Capture downloads directly from your browser.")
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 4)
}
Section("Installation") {
HStack(spacing: 10) {
Button {
copyExtensionToDownloads()
} label: {
Label("Copy to Downloads", systemImage: "folder.badge.plus")
}
Button {
showCopiedExtensionInFinder()
} label: {
Label("Show Copied Folder", systemImage: "folder.fill")
}
.disabled(copiedExtensionURL == nil)
Button {
openFirefoxDebugging()
} label: {
Label("Open Firefox Debugging", systemImage: "link")
}
}
.buttonStyle(.borderedProminent)
if !installMessage.isEmpty {
Text(installMessage)
.font(.caption)
.foregroundStyle(.secondary)
}
Text("Until the official Firefox Add-ons listing is approved, load the extension manually:\n1. Click 'Copy to Downloads'.\n2. Click 'Open Firefox Debugging'.\n3. Click 'This Firefox' on the left sidebar.\n4. Click 'Load Temporary Add-on' and select manifest.json inside Downloads/Firelink Firefox Extension.\n\nKeep the copied folder while Firefox is running. Temporary add-ons are removed when Firefox restarts, so you can delete the folder after restart or after installing the official add-on.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Permissions & Privacy") {
Text("The Firelink extension uses download, context menu, storage, active tab, scripting, and local Firelink endpoint permissions. It reads the active tab URL for per-site settings and explicit right-click actions, and forwards download URLs only when you use a Firelink action or enable global capture.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
.onAppear {
if FileManager.default.fileExists(atPath: downloadsExtensionURL.path) {
copiedExtensionURL = downloadsExtensionURL
}
}
}
private var downloadsExtensionURL: URL {
let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
?? URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Downloads")
return downloads.appendingPathComponent("Firelink Firefox Extension", isDirectory: true)
}
private func copyExtensionToDownloads() {
guard let sourceURL = bundledFirefoxExtensionURL() else {
installMessage = "The bundled Firefox extension folder was not found."
return
}
let destinationURL = downloadsExtensionURL
do {
if FileManager.default.fileExists(atPath: destinationURL.path) {
try FileManager.default.removeItem(at: destinationURL)
}
try copyFirefoxExtension(from: sourceURL, to: destinationURL)
copiedExtensionURL = destinationURL
installMessage = "Copied to \(destinationURL.path). Select manifest.json from this folder in Firefox."
showCopiedExtensionInFinder()
} catch {
installMessage = "Could not copy the extension to Downloads: \(error.localizedDescription)"
}
}
private func bundledFirefoxExtensionURL() -> URL? {
if let bundled = Bundle.main.url(forResource: "FirefoxExtension", withExtension: nil) {
return bundled
}
let sourceFile = URL(fileURLWithPath: #filePath)
let projectRoot = sourceFile
.deletingLastPathComponent()
.deletingLastPathComponent()
.deletingLastPathComponent()
let sourceTreeExtension = projectRoot.appendingPathComponent("Extensions/Firefox", isDirectory: true)
return FileManager.default.fileExists(atPath: sourceTreeExtension.appendingPathComponent("manifest.json").path)
? sourceTreeExtension
: nil
}
private func copyFirefoxExtension(from sourceURL: URL, to destinationURL: URL) throws {
let fileManager = FileManager.default
try fileManager.createDirectory(at: destinationURL, withIntermediateDirectories: true)
for component in ["background.js", "content.js", "manifest.json", "icons", "popup"] {
try fileManager.copyItem(
at: sourceURL.appendingPathComponent(component),
to: destinationURL.appendingPathComponent(component)
)
}
}
private func showCopiedExtensionInFinder() {
let folderURL = copiedExtensionURL ?? downloadsExtensionURL
let manifestURL = folderURL.appendingPathComponent("manifest.json")
if FileManager.default.fileExists(atPath: manifestURL.path) {
NSWorkspace.shared.activateFileViewerSelecting([manifestURL])
} else if FileManager.default.fileExists(atPath: folderURL.path) {
NSWorkspace.shared.activateFileViewerSelecting([folderURL])
}
}
private func openFirefoxDebugging() {
let bundleIDs = [
"org.mozilla.firefoxdeveloperedition",
"org.mozilla.firefox",
"org.mozilla.nightly"
]
let workspace = NSWorkspace.shared
for id in bundleIDs {
if let appURL = workspace.urlForApplication(withBundleIdentifier: id) {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/open")
process.arguments = ["-a", appURL.path, "about:debugging"]
try? process.run()
return
}
}
// Fallback
if let fallbackURL = URL(string: "about:debugging") {
workspace.open(fallbackURL)
}
}
}
@@ -0,0 +1,68 @@
import AppKit
import SwiftUI
struct LocationsSettingsPane: View {
@EnvironmentObject private var settings: AppSettings
var body: some View {
Form {
Section {
ForEach(DownloadCategory.allCases, id: \.self) { category in
DirectoryPickerRow(category: category)
}
HStack {
Spacer()
Button("Reset Defaults") {
settings.resetDirectories()
}
}
}
}
.formStyle(.grouped)
}
}
struct DirectoryPickerRow: View {
@EnvironmentObject private var settings: AppSettings
let category: DownloadCategory
@State private var path = ""
var body: some View {
LabeledContent {
HStack(spacing: 8) {
TextField("Folder path", text: Binding(
get: { settings.downloadDirectories[category] ?? path },
set: { newValue in
path = newValue
settings.setDirectory(newValue, for: category)
}
))
.textFieldStyle(.roundedBorder)
.font(.system(.body, design: .monospaced))
Button {
selectFolder()
} label: {
Label("Select", systemImage: "folder.badge.plus")
}
}
} label: {
Label(category.rawValue, systemImage: category.symbolName)
}
}
private func selectFolder() {
let panel = NSOpenPanel()
panel.canChooseFiles = false
panel.canChooseDirectories = true
panel.allowsMultipleSelection = false
panel.canCreateDirectories = true
panel.directoryURL = settings.destinationDirectory(for: category)
if panel.runModal() == .OK, let url = panel.url {
settings.setDirectory(url.path, for: category)
}
}
}
@@ -0,0 +1,47 @@
import SwiftUI
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)
}
}
@@ -0,0 +1,74 @@
import SwiftUI
struct NetworkSettingsPane: View {
@EnvironmentObject private var settings: AppSettings
var body: some View {
Form {
Section {
Picker("Proxy", selection: proxyBinding(\.mode)) {
ForEach(ProxyMode.allCases, id: \.self) { mode in
Text(mode.title)
.tag(mode)
}
}
.pickerStyle(.radioGroup)
if settings.proxySettings.mode == .custom {
Picker("Proxy type", selection: proxyBinding(\.type)) {
ForEach(ProxyType.allCases, id: \.self) { type in
Text(type.title)
.tag(type)
}
}
Grid(alignment: .leading, horizontalSpacing: 10, verticalSpacing: 10) {
GridRow {
Text("IP or Host")
TextField("127.0.0.1", text: proxyBinding(\.host))
.textFieldStyle(.roundedBorder)
.font(.system(.body, design: .monospaced))
}
GridRow {
Text("Port")
TextField("8080", value: proxyBinding(\.port), format: .number)
.textFieldStyle(.roundedBorder)
.frame(width: 110)
}
}
}
Text(networkSummary)
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
}
private var networkSummary: String {
switch settings.proxySettings.mode {
case .none:
"Downloads ignore configured proxies."
case .system:
"Downloads use the matching macOS system proxy when one is configured."
case .custom:
if let proxyURI = settings.proxySettings.customProxyURI {
"Downloads use \(proxyURI)."
} else {
"Enter a proxy host and port to enable the custom proxy."
}
}
}
private func proxyBinding<Value>(_ keyPath: WritableKeyPath<ProxySettings, Value>) -> Binding<Value> {
Binding {
settings.proxySettings[keyPath: keyPath]
} set: { newValue in
var proxySettings = settings.proxySettings
proxySettings[keyPath: keyPath] = newValue
settings.proxySettings = proxySettings
}
}
}
@@ -0,0 +1,17 @@
import SwiftUI
struct PowerSettingsPane: View {
@EnvironmentObject private var settings: AppSettings
var body: some View {
Form {
Section {
Toggle("Prevent system sleep while downloads are active", isOn: $settings.preventsSleepWhileDownloading)
Text("The display may still turn off. Firelink only keeps macOS awake enough to finish active downloads.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
}
}
@@ -0,0 +1,76 @@
import AppKit
import SwiftUI
struct SettingsPaneContainer: View {
@AppStorage("lastSettingsTab") private var activeTab: SettingsSidebarFilter = .downloads
var body: some View {
VStack(spacing: 0) {
HStack(spacing: 4) {
ForEach(SettingsSidebarFilter.allCases, id: \.self) { filter in
Button {
activeTab = filter
} label: {
VStack(spacing: 4) {
Image(systemName: filter.symbolName)
.font(.system(size: 16))
Text(filter.rawValue)
.font(.system(size: 11, weight: .medium))
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.background(activeTab == filter ? Color.accentColor : Color.clear)
.foregroundStyle(activeTab == filter ? Color.white : Color.primary)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
.padding(.horizontal, 32)
.padding(.vertical, 16)
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 0) {
Text(activeTab.rawValue)
.font(.largeTitle.weight(.semibold))
.padding(.bottom, 24)
selectedPane
.frame(maxWidth: 720, alignment: .leading)
}
.padding(32)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
@ViewBuilder
private var selectedPane: some View {
switch activeTab {
case .downloads:
DownloadSettingsPane()
case .lookAndFeel:
LookAndFeelSettingsPane()
case .network:
NetworkSettingsPane()
case .locations:
LocationsSettingsPane()
case .siteLogins:
SiteLoginsSettingsPane()
case .power:
PowerSettingsPane()
case .engine:
EngineSettingsPane()
case .integration:
IntegrationSettingsPane()
case .about:
AboutSettingsPane()
}
}
}
@@ -0,0 +1,62 @@
import SwiftUI
struct SiteLoginsSettingsPane: View {
@EnvironmentObject private var settings: AppSettings
@State private var urlPattern = ""
@State private var username = ""
@State private var password = ""
var body: some View {
Form {
Section("Add Login") {
TextField("URL Pattern (e.g., *.github.com)", text: $urlPattern)
TextField("Username", text: $username)
SecureField("Password", text: $password)
HStack {
Text(settings.message)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
Spacer()
Button {
settings.addSiteLogin(urlPattern: urlPattern, username: username, password: password)
if settings.message.hasPrefix("Added") {
urlPattern = ""
username = ""
password = ""
}
} label: {
Label("Add Login", systemImage: "plus")
}
.buttonStyle(.borderedProminent)
}
}
Section("Saved Logins") {
if settings.siteLogins.isEmpty {
Text("No saved logins.")
.foregroundStyle(.secondary)
} else {
List {
ForEach(settings.siteLogins) { login in
HStack {
Image(systemName: "key.horizontal")
.foregroundStyle(.secondary)
.accessibilityHidden(true)
Text(login.urlPattern)
.font(.system(.body, design: .monospaced))
Spacer()
Text(login.username)
.foregroundStyle(.secondary)
}
}
.onDelete(perform: settings.deleteSiteLogins)
}
.frame(minHeight: 180)
}
}
}
.formStyle(.grouped)
}
}
-750
View File
@@ -1,750 +0,0 @@
import AppKit
import SwiftUI
struct SettingsPaneContainer: View {
@AppStorage("lastSettingsTab") private var activeTab: SettingsSidebarFilter = .downloads
var body: some View {
VStack(spacing: 0) {
HStack(spacing: 4) {
ForEach(SettingsSidebarFilter.allCases, id: \.self) { filter in
Button {
activeTab = filter
} label: {
VStack(spacing: 4) {
Image(systemName: filter.symbolName)
.font(.system(size: 16))
Text(filter.rawValue)
.font(.system(size: 11, weight: .medium))
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.background(activeTab == filter ? Color.accentColor : Color.clear)
.foregroundStyle(activeTab == filter ? Color.white : Color.primary)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
.padding(.horizontal, 32)
.padding(.vertical, 16)
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 0) {
Text(activeTab.rawValue)
.font(.largeTitle.weight(.semibold))
.padding(.bottom, 24)
selectedPane
.frame(maxWidth: 720, alignment: .leading)
}
.padding(32)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
@ViewBuilder
private var selectedPane: some View {
switch activeTab {
case .downloads:
DownloadSettingsPane()
case .lookAndFeel:
LookAndFeelSettingsPane()
case .network:
NetworkSettingsPane()
case .locations:
LocationsSettingsPane()
case .siteLogins:
SiteLoginsSettingsPane()
case .power:
PowerSettingsPane()
case .engine:
EngineSettingsPane()
case .integration:
IntegrationSettingsPane()
case .about:
AboutSettingsPane()
}
}
}
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?
private let developerProfileURL = URL(string: "https://github.com/nimbold")!
private let projectURL = URL(string: "https://github.com/nimbold/Firelink")!
private let aria2URL = URL(string: "https://aria2.github.io/")!
private let licenseURL = URL(string: "https://github.com/nimbold/Firelink/blob/main/LICENSE")!
private var appVersion: String {
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.1.0"
}
private var buildNumber: String {
Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Development"
}
var body: some View {
Form {
Section {
HStack(alignment: .center, spacing: 14) {
Image(nsImage: NSApp.applicationIconImage)
.resizable()
.frame(width: 56, height: 56)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 4) {
Text("Firelink")
.font(.title2.weight(.semibold))
Text("Version \(appVersion)")
.foregroundStyle(.secondary)
Text("A native macOS download manager for fast, organized, segmented transfers.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 4)
}
Section("Updates") {
LabeledContent("Status") {
Label(updateChecker.status.message, systemImage: updateStatusSymbol)
.foregroundStyle(updateStatusColor)
}
if let lastChecked = updateChecker.lastChecked {
LabeledContent("Last checked") {
Text(lastChecked, format: .dateTime.month().day().hour().minute())
.foregroundStyle(.secondary)
}
}
HStack {
Button {
Task {
await updateChecker.checkForUpdates(currentVersion: appVersion)
}
} label: {
Label("Check for Updates", systemImage: "arrow.clockwise")
}
.disabled(updateChecker.status == .checking)
Button {
openReleasesPage()
} label: {
Label("Open Releases", systemImage: "arrow.up.right.square")
}
Spacer()
}
if case .updateAvailable(_, let releaseURL) = updateChecker.status {
Button {
NSWorkspace.shared.open(releaseURL)
} label: {
Label("Download Latest Version", systemImage: "square.and.arrow.down")
}
.buttonStyle(.borderedProminent)
}
}
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)
}
Text("Copyright © 2026 NimBold. Firelink is released under the MIT License.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
.onChange(of: updateChecker.status) { _, status in
if case .updateAvailable(let latestVersion, let releaseURL) = status {
availableUpdate = AvailableUpdate(version: latestVersion, url: releaseURL)
}
}
.alert("Update Available", isPresented: Binding(
get: { availableUpdate != nil },
set: { isPresented in
if !isPresented {
availableUpdate = nil
}
}
)) {
Button("Not Now", role: .cancel) {
availableUpdate = nil
}
Button("Yes") {
if let releaseURL = availableUpdate?.url {
NSWorkspace.shared.open(releaseURL)
}
availableUpdate = nil
}
} message: {
Text("Firelink version \(availableUpdate?.version ?? "") is available. Do you want to open the download page?")
}
}
private var updateStatusSymbol: String {
switch updateChecker.status {
case .idle:
"sparkle.magnifyingglass"
case .checking:
"arrow.clockwise"
case .upToDate:
"checkmark.seal.fill"
case .updateAvailable:
"arrow.down.circle.fill"
case .unavailable:
"exclamationmark.triangle.fill"
}
}
private var updateStatusColor: Color {
switch updateChecker.status {
case .idle, .checking:
.secondary
case .upToDate:
.green
case .updateAvailable:
.accentColor
case .unavailable:
.orange
}
}
private func openReleasesPage() {
NSWorkspace.shared.open(updateChecker.releasesURL)
}
private struct AvailableUpdate: Equatable {
var version: String
var url: URL
}
}
private struct EngineSettingsPane: View {
@State private var version = "Checking..."
private var executableURL: URL? {
Aria2DownloadEngine.findExecutable()
}
var body: some View {
Form {
Section {
LabeledContent("Status") {
Label(
executableURL == nil ? "Missing" : "Ready",
systemImage: executableURL == nil ? "exclamationmark.triangle.fill" : "checkmark.seal.fill"
)
.foregroundStyle(executableURL == nil ? .orange : .green)
}
LabeledContent("Binary") {
Text(executableURL?.path ?? "Not found")
.font(.system(.body, design: .monospaced))
.textSelection(.enabled)
}
LabeledContent("Version") {
Text(version)
.font(.system(.body, design: .monospaced))
.textSelection(.enabled)
}
if executableURL == nil {
Text("Install aria2 with Homebrew or bundle aria2c inside the app resources.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.formStyle(.grouped)
.task {
version = await Aria2DownloadEngine.versionString() ?? "Unavailable"
}
}
}
private struct NetworkSettingsPane: View {
@EnvironmentObject private var settings: AppSettings
var body: some View {
Form {
Section {
Picker("Proxy", selection: proxyBinding(\.mode)) {
ForEach(ProxyMode.allCases, id: \.self) { mode in
Text(mode.title)
.tag(mode)
}
}
.pickerStyle(.radioGroup)
if settings.proxySettings.mode == .custom {
Picker("Proxy type", selection: proxyBinding(\.type)) {
ForEach(ProxyType.allCases, id: \.self) { type in
Text(type.title)
.tag(type)
}
}
Grid(alignment: .leading, horizontalSpacing: 10, verticalSpacing: 10) {
GridRow {
Text("IP or Host")
TextField("127.0.0.1", text: proxyBinding(\.host))
.textFieldStyle(.roundedBorder)
.font(.system(.body, design: .monospaced))
}
GridRow {
Text("Port")
TextField("8080", value: proxyBinding(\.port), format: .number)
.textFieldStyle(.roundedBorder)
.frame(width: 110)
}
}
}
Text(networkSummary)
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
}
private var networkSummary: String {
switch settings.proxySettings.mode {
case .none:
"Downloads ignore configured proxies."
case .system:
"Downloads use the matching macOS system proxy when one is configured."
case .custom:
if let proxyURI = settings.proxySettings.customProxyURI {
"Downloads use \(proxyURI)."
} else {
"Enter a proxy host and port to enable the custom proxy."
}
}
}
private func proxyBinding<Value>(_ keyPath: WritableKeyPath<ProxySettings, Value>) -> Binding<Value> {
Binding {
settings.proxySettings[keyPath: keyPath]
} set: { newValue in
var proxySettings = settings.proxySettings
proxySettings[keyPath: keyPath] = newValue
settings.proxySettings = proxySettings
}
}
}
private struct DownloadSettingsPane: View {
@EnvironmentObject private var settings: AppSettings
var body: some View {
Form {
Section {
Stepper(
"Default connections per server: \(settings.perServerConnections)",
value: $settings.perServerConnections,
in: 1...16
)
Text("Used as the default for new downloads. The Add Downloads window can override it per batch.")
.font(.caption)
.foregroundStyle(.secondary)
Stepper(
"Parallel downloads: \(settings.maxConcurrentDownloads)",
value: $settings.maxConcurrentDownloads,
in: 1...12
)
Text("Controls how many files Firelink downloads at the same time.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
}
}
private struct LocationsSettingsPane: View {
@EnvironmentObject private var settings: AppSettings
var body: some View {
Form {
Section {
ForEach(DownloadCategory.allCases, id: \.self) { category in
DirectoryPickerRow(category: category)
}
HStack {
Spacer()
Button("Reset Defaults") {
settings.resetDirectories()
}
}
}
}
.formStyle(.grouped)
}
}
private struct DirectoryPickerRow: View {
@EnvironmentObject private var settings: AppSettings
let category: DownloadCategory
@State private var path = ""
var body: some View {
LabeledContent {
HStack(spacing: 8) {
TextField("Folder path", text: Binding(
get: { settings.downloadDirectories[category] ?? path },
set: { newValue in
path = newValue
settings.setDirectory(newValue, for: category)
}
))
.textFieldStyle(.roundedBorder)
.font(.system(.body, design: .monospaced))
Button {
selectFolder()
} label: {
Label("Select", systemImage: "folder.badge.plus")
}
}
} label: {
Label(category.rawValue, systemImage: category.symbolName)
}
}
private func selectFolder() {
let panel = NSOpenPanel()
panel.canChooseFiles = false
panel.canChooseDirectories = true
panel.allowsMultipleSelection = false
panel.canCreateDirectories = true
panel.directoryURL = settings.destinationDirectory(for: category)
if panel.runModal() == .OK, let url = panel.url {
settings.setDirectory(url.path, for: category)
}
}
}
private struct SiteLoginsSettingsPane: View {
@EnvironmentObject private var settings: AppSettings
@State private var urlPattern = ""
@State private var username = ""
@State private var password = ""
var body: some View {
Form {
Section("Add Login") {
TextField("URL Pattern (e.g., *.github.com)", text: $urlPattern)
TextField("Username", text: $username)
SecureField("Password", text: $password)
HStack {
Text(settings.message)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
Spacer()
Button {
settings.addSiteLogin(urlPattern: urlPattern, username: username, password: password)
if settings.message.hasPrefix("Added") {
urlPattern = ""
username = ""
password = ""
}
} label: {
Label("Add Login", systemImage: "plus")
}
.buttonStyle(.borderedProminent)
}
}
Section("Saved Logins") {
if settings.siteLogins.isEmpty {
Text("No saved logins.")
.foregroundStyle(.secondary)
} else {
List {
ForEach(settings.siteLogins) { login in
HStack {
Image(systemName: "key.horizontal")
.foregroundStyle(.secondary)
Text(login.urlPattern)
.font(.system(.body, design: .monospaced))
Spacer()
Text(login.username)
.foregroundStyle(.secondary)
}
}
.onDelete(perform: settings.deleteSiteLogins)
}
.frame(minHeight: 180)
}
}
}
.formStyle(.grouped)
}
}
private struct PowerSettingsPane: View {
@EnvironmentObject private var settings: AppSettings
var body: some View {
Form {
Section {
Toggle("Prevent system sleep while downloads are active", isOn: $settings.preventsSleepWhileDownloading)
Text("The display may still turn off. Firelink only keeps macOS awake enough to finish active downloads.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
}
}
private struct IntegrationSettingsPane: View {
@State private var copiedExtensionURL: URL?
@State private var installMessage = ""
var body: some View {
Form {
Section {
HStack(alignment: .center, spacing: 14) {
Image(systemName: "puzzlepiece.extension")
.resizable()
.frame(width: 48, height: 48)
.foregroundStyle(.orange)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 4) {
Text("Firefox Extension")
.font(.title2.weight(.semibold))
Text("Capture downloads directly from your browser.")
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 4)
}
Section("Installation") {
HStack(spacing: 10) {
Button {
copyExtensionToDownloads()
} label: {
Label("Copy to Downloads", systemImage: "folder.badge.plus")
}
Button {
showCopiedExtensionInFinder()
} label: {
Label("Show Copied Folder", systemImage: "folder.fill")
}
.disabled(copiedExtensionURL == nil)
Button {
openFirefoxDebugging()
} label: {
Label("Open Firefox Debugging", systemImage: "link")
}
}
.buttonStyle(.borderedProminent)
if !installMessage.isEmpty {
Text(installMessage)
.font(.caption)
.foregroundStyle(.secondary)
}
Text("Until the official Firefox Add-ons listing is approved, load the extension manually:\n1. Click 'Copy to Downloads'.\n2. Click 'Open Firefox Debugging'.\n3. Click 'This Firefox' on the left sidebar.\n4. Click 'Load Temporary Add-on' and select manifest.json inside Downloads/Firelink Firefox Extension.\n\nKeep the copied folder while Firefox is running. Temporary add-ons are removed when Firefox restarts, so you can delete the folder after restart or after installing the official add-on.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Permissions & Privacy") {
Text("The Firelink extension uses download, context menu, storage, active tab, scripting, and local Firelink endpoint permissions. It reads the active tab URL for per-site settings and explicit right-click actions, and forwards download URLs only when you use a Firelink action or enable global capture.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
.onAppear {
if FileManager.default.fileExists(atPath: downloadsExtensionURL.path) {
copiedExtensionURL = downloadsExtensionURL
}
}
}
private var downloadsExtensionURL: URL {
let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
?? URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Downloads")
return downloads.appendingPathComponent("Firelink Firefox Extension", isDirectory: true)
}
private func copyExtensionToDownloads() {
guard let sourceURL = bundledFirefoxExtensionURL() else {
installMessage = "The bundled Firefox extension folder was not found."
return
}
let destinationURL = downloadsExtensionURL
do {
if FileManager.default.fileExists(atPath: destinationURL.path) {
try FileManager.default.removeItem(at: destinationURL)
}
try copyFirefoxExtension(from: sourceURL, to: destinationURL)
copiedExtensionURL = destinationURL
installMessage = "Copied to \(destinationURL.path). Select manifest.json from this folder in Firefox."
showCopiedExtensionInFinder()
} catch {
installMessage = "Could not copy the extension to Downloads: \(error.localizedDescription)"
}
}
private func bundledFirefoxExtensionURL() -> URL? {
if let bundled = Bundle.main.url(forResource: "FirefoxExtension", withExtension: nil) {
return bundled
}
let sourceFile = URL(fileURLWithPath: #filePath)
let projectRoot = sourceFile
.deletingLastPathComponent()
.deletingLastPathComponent()
.deletingLastPathComponent()
let sourceTreeExtension = projectRoot.appendingPathComponent("Extensions/Firefox", isDirectory: true)
return FileManager.default.fileExists(atPath: sourceTreeExtension.appendingPathComponent("manifest.json").path)
? sourceTreeExtension
: nil
}
private func copyFirefoxExtension(from sourceURL: URL, to destinationURL: URL) throws {
let fileManager = FileManager.default
try fileManager.createDirectory(at: destinationURL, withIntermediateDirectories: true)
for component in ["background.js", "content.js", "manifest.json", "icons", "popup"] {
try fileManager.copyItem(
at: sourceURL.appendingPathComponent(component),
to: destinationURL.appendingPathComponent(component)
)
}
}
private func showCopiedExtensionInFinder() {
let folderURL = copiedExtensionURL ?? downloadsExtensionURL
let manifestURL = folderURL.appendingPathComponent("manifest.json")
if FileManager.default.fileExists(atPath: manifestURL.path) {
NSWorkspace.shared.activateFileViewerSelecting([manifestURL])
} else if FileManager.default.fileExists(atPath: folderURL.path) {
NSWorkspace.shared.activateFileViewerSelecting([folderURL])
}
}
private func openFirefoxDebugging() {
let bundleIDs = [
"org.mozilla.firefoxdeveloperedition",
"org.mozilla.firefox",
"org.mozilla.nightly"
]
let workspace = NSWorkspace.shared
for id in bundleIDs {
if let appURL = workspace.urlForApplication(withBundleIdentifier: id) {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/open")
process.arguments = ["-a", appURL.path, "about:debugging"]
try? process.run()
return
}
}
// Fallback
if let fallbackURL = URL(string: "about:debugging") {
workspace.open(fallbackURL)
}
}
}
+8
View File
@@ -53,6 +53,14 @@ enum ListRowDensity: String, Codable, CaseIterable, Identifiable, Sendable {
case .relaxed: return 14
}
}
var minRowHeight: CGFloat {
switch self {
case .compact: return 24
case .standard: return 32
case .relaxed: return 44
}
}
}
enum AppTheme: String, Codable, CaseIterable, Identifiable, Sendable {