From 2b820ec8024d681a44bc88cff066b9f08b20bd09 Mon Sep 17 00:00:00 2001 From: nimbold <11913706+nimbold@users.noreply.github.com> Date: Fri, 5 Jun 2026 02:52:52 +0330 Subject: [PATCH] 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 --- Sources/Firelink/AppSettings.swift | 2 + Sources/Firelink/DownloadTable.swift | 1 + .../Firelink/Settings/AboutSettingsPane.swift | 179 +++++ .../Settings/DownloadSettingsPane.swift | 46 ++ .../Settings/EngineSettingsPane.swift | 46 ++ .../Settings/IntegrationSettingsPane.swift | 164 ++++ .../Settings/LocationsSettingsPane.swift | 68 ++ .../Settings/LookAndFeelSettingsPane.swift | 47 ++ .../Settings/NetworkSettingsPane.swift | 74 ++ .../Firelink/Settings/PowerSettingsPane.swift | 17 + .../Firelink/Settings/SettingsContainer.swift | 76 ++ .../Settings/SiteLoginsSettingsPane.swift | 62 ++ Sources/Firelink/SettingsView.swift | 750 ------------------ Sources/Firelink/Theme.swift | 8 + 14 files changed, 790 insertions(+), 750 deletions(-) create mode 100644 Sources/Firelink/Settings/AboutSettingsPane.swift create mode 100644 Sources/Firelink/Settings/DownloadSettingsPane.swift create mode 100644 Sources/Firelink/Settings/EngineSettingsPane.swift create mode 100644 Sources/Firelink/Settings/IntegrationSettingsPane.swift create mode 100644 Sources/Firelink/Settings/LocationsSettingsPane.swift create mode 100644 Sources/Firelink/Settings/LookAndFeelSettingsPane.swift create mode 100644 Sources/Firelink/Settings/NetworkSettingsPane.swift create mode 100644 Sources/Firelink/Settings/PowerSettingsPane.swift create mode 100644 Sources/Firelink/Settings/SettingsContainer.swift create mode 100644 Sources/Firelink/Settings/SiteLoginsSettingsPane.swift delete mode 100644 Sources/Firelink/SettingsView.swift diff --git a/Sources/Firelink/AppSettings.swift b/Sources/Firelink/AppSettings.swift index 0e9530a..26c9edc 100644 --- a/Sources/Firelink/AppSettings.swift +++ b/Sources/Firelink/AppSettings.swift @@ -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" } } diff --git a/Sources/Firelink/DownloadTable.swift b/Sources/Firelink/DownloadTable.swift index 3d7b545..fde8e6c 100644 --- a/Sources/Firelink/DownloadTable.swift +++ b/Sources/Firelink/DownloadTable.swift @@ -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) } diff --git a/Sources/Firelink/Settings/AboutSettingsPane.swift b/Sources/Firelink/Settings/AboutSettingsPane.swift new file mode 100644 index 0000000..afae3fe --- /dev/null +++ b/Sources/Firelink/Settings/AboutSettingsPane.swift @@ -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 + } +} diff --git a/Sources/Firelink/Settings/DownloadSettingsPane.swift b/Sources/Firelink/Settings/DownloadSettingsPane.swift new file mode 100644 index 0000000..3070308 --- /dev/null +++ b/Sources/Firelink/Settings/DownloadSettingsPane.swift @@ -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) + } +} diff --git a/Sources/Firelink/Settings/EngineSettingsPane.swift b/Sources/Firelink/Settings/EngineSettingsPane.swift new file mode 100644 index 0000000..a317dd9 --- /dev/null +++ b/Sources/Firelink/Settings/EngineSettingsPane.swift @@ -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" + } + } +} diff --git a/Sources/Firelink/Settings/IntegrationSettingsPane.swift b/Sources/Firelink/Settings/IntegrationSettingsPane.swift new file mode 100644 index 0000000..25f4b12 --- /dev/null +++ b/Sources/Firelink/Settings/IntegrationSettingsPane.swift @@ -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) + } + } +} diff --git a/Sources/Firelink/Settings/LocationsSettingsPane.swift b/Sources/Firelink/Settings/LocationsSettingsPane.swift new file mode 100644 index 0000000..72a9eb7 --- /dev/null +++ b/Sources/Firelink/Settings/LocationsSettingsPane.swift @@ -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) + } + } +} diff --git a/Sources/Firelink/Settings/LookAndFeelSettingsPane.swift b/Sources/Firelink/Settings/LookAndFeelSettingsPane.swift new file mode 100644 index 0000000..b30e4a2 --- /dev/null +++ b/Sources/Firelink/Settings/LookAndFeelSettingsPane.swift @@ -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) + } +} diff --git a/Sources/Firelink/Settings/NetworkSettingsPane.swift b/Sources/Firelink/Settings/NetworkSettingsPane.swift new file mode 100644 index 0000000..a92b6f4 --- /dev/null +++ b/Sources/Firelink/Settings/NetworkSettingsPane.swift @@ -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(_ keyPath: WritableKeyPath) -> Binding { + Binding { + settings.proxySettings[keyPath: keyPath] + } set: { newValue in + var proxySettings = settings.proxySettings + proxySettings[keyPath: keyPath] = newValue + settings.proxySettings = proxySettings + } + } +} diff --git a/Sources/Firelink/Settings/PowerSettingsPane.swift b/Sources/Firelink/Settings/PowerSettingsPane.swift new file mode 100644 index 0000000..fc3c2b4 --- /dev/null +++ b/Sources/Firelink/Settings/PowerSettingsPane.swift @@ -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) + } +} diff --git a/Sources/Firelink/Settings/SettingsContainer.swift b/Sources/Firelink/Settings/SettingsContainer.swift new file mode 100644 index 0000000..119a367 --- /dev/null +++ b/Sources/Firelink/Settings/SettingsContainer.swift @@ -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() + } + } +} diff --git a/Sources/Firelink/Settings/SiteLoginsSettingsPane.swift b/Sources/Firelink/Settings/SiteLoginsSettingsPane.swift new file mode 100644 index 0000000..3467f93 --- /dev/null +++ b/Sources/Firelink/Settings/SiteLoginsSettingsPane.swift @@ -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) + } +} diff --git a/Sources/Firelink/SettingsView.swift b/Sources/Firelink/SettingsView.swift deleted file mode 100644 index 727d4dc..0000000 --- a/Sources/Firelink/SettingsView.swift +++ /dev/null @@ -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(_ keyPath: WritableKeyPath) -> Binding { - 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) - } - } -} diff --git a/Sources/Firelink/Theme.swift b/Sources/Firelink/Theme.swift index 6b39a73..7628ccf 100644 --- a/Sources/Firelink/Theme.swift +++ b/Sources/Firelink/Theme.swift @@ -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 {