mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-21 08:26:40 +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 http
|
||||||
case https
|
case https
|
||||||
case ftp
|
case ftp
|
||||||
|
case socks5
|
||||||
|
|
||||||
var title: String {
|
var title: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .http: "HTTP"
|
case .http: "HTTP"
|
||||||
case .https: "HTTPS"
|
case .https: "HTTPS"
|
||||||
case .ftp: "FTP"
|
case .ftp: "FTP"
|
||||||
|
case .socks5: "SOCKS5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ struct DownloadTable: View {
|
|||||||
}
|
}
|
||||||
.width(min: 100, ideal: 155)
|
.width(min: 100, ideal: 155)
|
||||||
}
|
}
|
||||||
|
.environment(\.defaultMinListRowHeight, settings.listRowDensity.minRowHeight)
|
||||||
.contextMenu(forSelectionType: DownloadItem.ID.self) { itemIDs in
|
.contextMenu(forSelectionType: DownloadItem.ID.self) { itemIDs in
|
||||||
rowContextMenu(for: itemIDs)
|
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
|
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 {
|
enum AppTheme: String, Codable, CaseIterable, Identifiable, Sendable {
|
||||||
|
|||||||
Reference in New Issue
Block a user