mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +00:00
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:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user