mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 12:08:27 +00:00
fix(settings): align preferences with runtime behavior
This commit is contained in:
+1
-1
Submodule Extensions/Firefox updated: f443e096c2...84eb776c43
@@ -29,14 +29,19 @@ enum ProxyType: String, Codable, CaseIterable, Sendable {
|
|||||||
var title: String {
|
var title: String {
|
||||||
switch self {
|
switch self {
|
||||||
case .http: "HTTP"
|
case .http: "HTTP"
|
||||||
case .https: "HTTPS"
|
case .https: "HTTPS (legacy)"
|
||||||
case .ftp: "FTP"
|
case .ftp: "FTP (legacy)"
|
||||||
case .socks5: "SOCKS5"
|
case .socks5: "SOCKS5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var uriScheme: String {
|
var uriScheme: String {
|
||||||
rawValue
|
switch self {
|
||||||
|
case .http, .https, .ftp:
|
||||||
|
"http"
|
||||||
|
case .socks5:
|
||||||
|
"socks5"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,6 +55,9 @@ struct ProxySettings: Codable, Equatable, Sendable {
|
|||||||
var copy = self
|
var copy = self
|
||||||
copy.host = copy.host.trimmingCharacters(in: .whitespacesAndNewlines)
|
copy.host = copy.host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
copy.port = min(max(copy.port, 1), 65_535)
|
copy.port = min(max(copy.port, 1), 65_535)
|
||||||
|
if copy.type != .http {
|
||||||
|
copy.type = .http
|
||||||
|
}
|
||||||
return copy
|
return copy
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,6 +203,10 @@ final class AppSettings: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func addSiteLogin(urlPattern: String, username: String, password: String) {
|
func addSiteLogin(urlPattern: String, username: String, password: String) {
|
||||||
|
saveSiteLogin(id: nil, urlPattern: urlPattern, username: username, password: password)
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveSiteLogin(id: UUID?, urlPattern: String, username: String, password: String) {
|
||||||
let pattern = urlPattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
let pattern = urlPattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let cleanUsername = username.trimmingCharacters(in: .whitespacesAndNewlines)
|
let cleanUsername = username.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
||||||
@@ -203,12 +215,39 @@ final class AppSettings: ObservableObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let login = SiteLogin(urlPattern: pattern, username: cleanUsername)
|
if let id,
|
||||||
guard KeychainCredentialStore.setPassword(password, for: login.id) else {
|
siteLogins.contains(where: { $0.id != id && $0.urlPattern.caseInsensitiveCompare(pattern) == .orderedSame }) {
|
||||||
message = "Could not save the password to Keychain."
|
message = "A login for \(pattern) already exists."
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let index = siteLogins.firstIndex(where: { login in
|
||||||
|
if let id {
|
||||||
|
return login.id == id
|
||||||
|
}
|
||||||
|
return login.urlPattern.caseInsensitiveCompare(pattern) == .orderedSame
|
||||||
|
}) {
|
||||||
|
let loginID = siteLogins[index].id
|
||||||
|
if !password.isEmpty, !KeychainCredentialStore.setPassword(password, for: loginID) {
|
||||||
|
message = "Could not save the password to Keychain."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
siteLogins[index].urlPattern = pattern
|
||||||
|
siteLogins[index].username = cleanUsername
|
||||||
|
message = "Updated login for \(pattern)."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !password.isEmpty else {
|
||||||
|
message = "Add a password."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let login = SiteLogin(urlPattern: pattern, username: cleanUsername)
|
||||||
|
if !KeychainCredentialStore.setPassword(password, for: login.id) {
|
||||||
|
message = "Could not save the password to Keychain."
|
||||||
|
return
|
||||||
|
}
|
||||||
siteLogins.append(login)
|
siteLogins.append(login)
|
||||||
message = "Added login for \(pattern)."
|
message = "Added login for \(pattern)."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ final class Aria2DownloadEngine {
|
|||||||
"id": UUID().uuidString,
|
"id": UUID().uuidString,
|
||||||
"params": [
|
"params": [
|
||||||
"token:\(handle.rpcSecret)",
|
"token:\(handle.rpcSecret)",
|
||||||
["max-download-limit": limitValue]
|
["max-overall-download-limit": limitValue]
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -255,7 +255,7 @@ final class Aria2DownloadEngine {
|
|||||||
]
|
]
|
||||||
|
|
||||||
if let speedLimitKiBPerSecond, speedLimitKiBPerSecond > 0 {
|
if let speedLimitKiBPerSecond, speedLimitKiBPerSecond > 0 {
|
||||||
arguments.append("--max-download-limit=\(speedLimitKiBPerSecond)K")
|
arguments.append("--max-overall-download-limit=\(speedLimitKiBPerSecond)K")
|
||||||
}
|
}
|
||||||
|
|
||||||
arguments.append(contentsOf: try proxyArguments(for: item, configuration: proxyConfiguration))
|
arguments.append(contentsOf: try proxyArguments(for: item, configuration: proxyConfiguration))
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ final class DownloadController: ObservableObject {
|
|||||||
@Published var engineMessage = ""
|
@Published var engineMessage = ""
|
||||||
@Published var pendingPasteboardText: String?
|
@Published var pendingPasteboardText: String?
|
||||||
@Published var pendingReferer: String?
|
@Published var pendingReferer: String?
|
||||||
|
@Published var extensionServerPort: UInt16?
|
||||||
var pendingAddQueueID: UUID?
|
var pendingAddQueueID: UUID?
|
||||||
|
|
||||||
private let settings: AppSettings
|
private let settings: AppSettings
|
||||||
@@ -217,6 +218,7 @@ final class DownloadController: ObservableObject {
|
|||||||
}
|
}
|
||||||
automaticRetryCounts[item.id] = nil
|
automaticRetryCounts[item.id] = nil
|
||||||
saveDownloads()
|
saveDownloads()
|
||||||
|
applySpeedLimitsToActiveDownloads()
|
||||||
updateSleepActivity()
|
updateSleepActivity()
|
||||||
pumpQueue()
|
pumpQueue()
|
||||||
}
|
}
|
||||||
@@ -242,6 +244,7 @@ final class DownloadController: ObservableObject {
|
|||||||
|
|
||||||
engineMessage = "Paused \(activeItems.count) active download\(activeItems.count == 1 ? "" : "s")."
|
engineMessage = "Paused \(activeItems.count) active download\(activeItems.count == 1 ? "" : "s")."
|
||||||
saveDownloads()
|
saveDownloads()
|
||||||
|
applySpeedLimitsToActiveDownloads()
|
||||||
updateSleepActivity()
|
updateSleepActivity()
|
||||||
pumpQueue()
|
pumpQueue()
|
||||||
}
|
}
|
||||||
@@ -262,6 +265,7 @@ final class DownloadController: ObservableObject {
|
|||||||
}
|
}
|
||||||
automaticRetryCounts[item.id] = nil
|
automaticRetryCounts[item.id] = nil
|
||||||
saveDownloads()
|
saveDownloads()
|
||||||
|
applySpeedLimitsToActiveDownloads()
|
||||||
updateSleepActivity()
|
updateSleepActivity()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,6 +334,7 @@ final class DownloadController: ObservableObject {
|
|||||||
}
|
}
|
||||||
automaticRetryCounts[item.id] = nil
|
automaticRetryCounts[item.id] = nil
|
||||||
saveDownloads()
|
saveDownloads()
|
||||||
|
applySpeedLimitsToActiveDownloads()
|
||||||
updateSleepActivity()
|
updateSleepActivity()
|
||||||
pumpQueue()
|
pumpQueue()
|
||||||
}
|
}
|
||||||
@@ -353,6 +358,7 @@ final class DownloadController: ObservableObject {
|
|||||||
downloads.removeAll { $0.id == item.id }
|
downloads.removeAll { $0.id == item.id }
|
||||||
automaticRetryCounts[item.id] = nil
|
automaticRetryCounts[item.id] = nil
|
||||||
saveDownloads()
|
saveDownloads()
|
||||||
|
applySpeedLimitsToActiveDownloads()
|
||||||
updateSleepActivity()
|
updateSleepActivity()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -511,6 +517,7 @@ final class DownloadController: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.pumpQueue()
|
self.pumpQueue()
|
||||||
|
self.applySpeedLimitsToActiveDownloads()
|
||||||
self.updateSleepActivity()
|
self.updateSleepActivity()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -522,9 +529,11 @@ final class DownloadController: ObservableObject {
|
|||||||
$0.message = "Process \(handle.processIdentifier)"
|
$0.message = "Process \(handle.processIdentifier)"
|
||||||
}
|
}
|
||||||
saveDownloads()
|
saveDownloads()
|
||||||
|
applySpeedLimitsToActiveDownloads()
|
||||||
updateSleepActivity()
|
updateSleepActivity()
|
||||||
} catch {
|
} catch {
|
||||||
handleDownloadFailure(itemID: item.id, error: error)
|
handleDownloadFailure(itemID: item.id, error: error)
|
||||||
|
applySpeedLimitsToActiveDownloads()
|
||||||
updateSleepActivity()
|
updateSleepActivity()
|
||||||
pumpQueue()
|
pumpQueue()
|
||||||
}
|
}
|
||||||
@@ -569,25 +578,15 @@ final class DownloadController: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func normalizedSpeedLimit(_ value: Int?) -> Int? {
|
private func normalizedSpeedLimit(_ value: Int?) -> Int? {
|
||||||
guard let value, value > 0 else { return nil }
|
SpeedLimitPolicy.normalized(value)
|
||||||
return min(value, 10_485_760)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func effectiveSpeedLimitKiBPerSecond(for item: DownloadItem) -> Int? {
|
private func effectiveSpeedLimitKiBPerSecond(for item: DownloadItem) -> Int? {
|
||||||
let itemLimit = normalizedSpeedLimit(item.speedLimitKiBPerSecond)
|
SpeedLimitPolicy.effectiveLimit(
|
||||||
let globalLimit = normalizedSpeedLimit(settings.globalSpeedLimitKiBPerSecond)
|
itemLimit: item.speedLimitKiBPerSecond,
|
||||||
.map { max(1, $0 / max(settings.maxConcurrentDownloads, 1)) }
|
globalLimit: settings.globalSpeedLimitKiBPerSecond,
|
||||||
|
activeDownloadCount: activeCount
|
||||||
switch (itemLimit, globalLimit) {
|
)
|
||||||
case let (.some(itemLimit), .some(globalLimit)):
|
|
||||||
return min(itemLimit, globalLimit)
|
|
||||||
case let (.some(itemLimit), .none):
|
|
||||||
return itemLimit
|
|
||||||
case let (.none, .some(globalLimit)):
|
|
||||||
return globalLimit
|
|
||||||
case (.none, .none):
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func applySpeedLimitsToActiveDownloads() {
|
private func applySpeedLimitsToActiveDownloads() {
|
||||||
@@ -886,6 +885,36 @@ private struct StoredDownloadState: Codable {
|
|||||||
var downloads: [DownloadItem]
|
var downloads: [DownloadItem]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum SpeedLimitPolicy {
|
||||||
|
static let maximumKiBPerSecond = 10_485_760
|
||||||
|
|
||||||
|
static func normalized(_ value: Int?) -> Int? {
|
||||||
|
guard let value, value > 0 else { return nil }
|
||||||
|
return min(value, maximumKiBPerSecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func effectiveLimit(
|
||||||
|
itemLimit: Int?,
|
||||||
|
globalLimit: Int?,
|
||||||
|
activeDownloadCount: Int
|
||||||
|
) -> Int? {
|
||||||
|
let itemLimit = normalized(itemLimit)
|
||||||
|
let globalLimit = normalized(globalLimit)
|
||||||
|
.map { max(1, $0 / max(activeDownloadCount, 1)) }
|
||||||
|
|
||||||
|
switch (itemLimit, globalLimit) {
|
||||||
|
case let (.some(itemLimit), .some(globalLimit)):
|
||||||
|
return min(itemLimit, globalLimit)
|
||||||
|
case let (.some(itemLimit), .none):
|
||||||
|
return itemLimit
|
||||||
|
case let (.none, .some(globalLimit)):
|
||||||
|
return globalLimit
|
||||||
|
case (.none, .none):
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private final class SleepActivityHandle: @unchecked Sendable {
|
private final class SleepActivityHandle: @unchecked Sendable {
|
||||||
private let activity: NSObjectProtocol
|
private let activity: NSObjectProtocol
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ struct FirelinkApp: App {
|
|||||||
|
|
||||||
extensionServer = LocalExtensionServer(downloadController: controller)
|
extensionServer = LocalExtensionServer(downloadController: controller)
|
||||||
extensionServer?.start()
|
extensionServer?.start()
|
||||||
|
controller.extensionServerPort = extensionServer?.port
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ struct DownloadSettingsPane: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text("Set to 0 for unlimited speed. This limit is divided across all active parallel downloads.")
|
Text("Set to 0 for unlimited speed. This limit is divided across currently active downloads.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import AppKit
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct IntegrationSettingsPane: View {
|
struct IntegrationSettingsPane: View {
|
||||||
|
@EnvironmentObject private var controller: DownloadController
|
||||||
@State private var copiedExtensionURL: URL?
|
@State private var copiedExtensionURL: URL?
|
||||||
@State private var installMessage = ""
|
@State private var installMessage = ""
|
||||||
|
|
||||||
@@ -54,10 +55,32 @@ struct IntegrationSettingsPane: View {
|
|||||||
.foregroundStyle(.secondary)
|
.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.")
|
Text("Firelink Companion has been submitted to Mozilla and is awaiting review. Until it 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 approved add-on.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Section("Diagnostics") {
|
||||||
|
LabeledContent("Local receiver") {
|
||||||
|
if let port = controller.extensionServerPort {
|
||||||
|
Label("Listening on 127.0.0.1:\(port)", systemImage: "checkmark.seal.fill")
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
} else {
|
||||||
|
Label("Not listening", systemImage: "exclamationmark.triangle.fill")
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LabeledContent("Copied folder") {
|
||||||
|
if FileManager.default.fileExists(atPath: downloadsExtensionURL.appendingPathComponent("manifest.json").path) {
|
||||||
|
Label("Ready", systemImage: "checkmark.seal.fill")
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
} else {
|
||||||
|
Label("Not copied", systemImage: "folder.badge.questionmark")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Section("Permissions & Privacy") {
|
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.")
|
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.")
|
||||||
|
|||||||
@@ -28,29 +28,46 @@ struct DirectoryPickerRow: View {
|
|||||||
let category: DownloadCategory
|
let category: DownloadCategory
|
||||||
|
|
||||||
@State private var path = ""
|
@State private var path = ""
|
||||||
|
@State private var message = ""
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
LabeledContent {
|
LabeledContent {
|
||||||
HStack(spacing: 8) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
TextField("Folder path", text: Binding(
|
HStack(spacing: 8) {
|
||||||
get: { settings.downloadDirectories[category] ?? path },
|
TextField("Folder path", text: $path)
|
||||||
set: { newValue in
|
.textFieldStyle(.roundedBorder)
|
||||||
path = newValue
|
.font(.system(.body, design: .monospaced))
|
||||||
settings.setDirectory(newValue, for: category)
|
.onSubmit {
|
||||||
}
|
applyPath()
|
||||||
))
|
}
|
||||||
.textFieldStyle(.roundedBorder)
|
|
||||||
.font(.system(.body, design: .monospaced))
|
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
selectFolder()
|
applyPath()
|
||||||
} label: {
|
} label: {
|
||||||
Label("Select", systemImage: "folder.badge.plus")
|
Label("Apply", systemImage: "checkmark")
|
||||||
|
}
|
||||||
|
.disabled(path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
selectFolder()
|
||||||
|
} label: {
|
||||||
|
Label("Select", systemImage: "folder.badge.plus")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Text(message.isEmpty ? statusMessage(for: path) : message)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(message.isEmpty ? .secondary : .primary)
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Label(category.rawValue, systemImage: category.symbolName)
|
Label(category.rawValue, systemImage: category.symbolName)
|
||||||
}
|
}
|
||||||
|
.onAppear {
|
||||||
|
syncPathFromSettings()
|
||||||
|
}
|
||||||
|
.onChange(of: settings.downloadDirectories[category]) { _, _ in
|
||||||
|
syncPathFromSettings()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func selectFolder() {
|
private func selectFolder() {
|
||||||
@@ -62,7 +79,70 @@ struct DirectoryPickerRow: View {
|
|||||||
panel.directoryURL = settings.destinationDirectory(for: category)
|
panel.directoryURL = settings.destinationDirectory(for: category)
|
||||||
|
|
||||||
if panel.runModal() == .OK, let url = panel.url {
|
if panel.runModal() == .OK, let url = panel.url {
|
||||||
|
path = url.path
|
||||||
settings.setDirectory(url.path, for: category)
|
settings.setDirectory(url.path, for: category)
|
||||||
|
message = "Saved."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func syncPathFromSettings() {
|
||||||
|
path = settings.downloadDirectories[category] ?? settings.destinationDirectory(for: category).path
|
||||||
|
message = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyPath() {
|
||||||
|
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else {
|
||||||
|
message = "Enter a folder path."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let expanded = NSString(string: trimmed).expandingTildeInPath
|
||||||
|
var isDirectory: ObjCBool = false
|
||||||
|
|
||||||
|
if FileManager.default.fileExists(atPath: expanded, isDirectory: &isDirectory) {
|
||||||
|
guard isDirectory.boolValue else {
|
||||||
|
message = "This path points to a file, not a folder."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
do {
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: URL(fileURLWithPath: expanded, isDirectory: true),
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
message = "Could not create folder: \(error.localizedDescription)"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard FileManager.default.isWritableFile(atPath: expanded) else {
|
||||||
|
message = "Firelink cannot write to this folder."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
settings.setDirectory(expanded, for: category)
|
||||||
|
path = expanded
|
||||||
|
message = "Saved."
|
||||||
|
}
|
||||||
|
|
||||||
|
private func statusMessage(for path: String) -> String {
|
||||||
|
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return "Enter a folder path." }
|
||||||
|
|
||||||
|
let expanded = NSString(string: trimmed).expandingTildeInPath
|
||||||
|
var isDirectory: ObjCBool = false
|
||||||
|
|
||||||
|
if FileManager.default.fileExists(atPath: expanded, isDirectory: &isDirectory) {
|
||||||
|
if !isDirectory.boolValue {
|
||||||
|
return "This path points to a file, not a folder."
|
||||||
|
}
|
||||||
|
return FileManager.default.isWritableFile(atPath: expanded)
|
||||||
|
? "Ready."
|
||||||
|
: "Firelink cannot write to this folder."
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Folder will be created when applied."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ struct LookAndFeelSettingsPane: View {
|
|||||||
Section("Menu Bar") {
|
Section("Menu Bar") {
|
||||||
Toggle("Show menu bar icon", isOn: $showMenuBarIcon)
|
Toggle("Show menu bar icon", isOn: $showMenuBarIcon)
|
||||||
|
|
||||||
Text("Provides quick access to downloads and queues from the macOS menu bar. Restart required if hiding.")
|
Text("Provides quick access to downloads and queues from the macOS menu bar.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,13 +15,6 @@ struct NetworkSettingsPane: View {
|
|||||||
.pickerStyle(.radioGroup)
|
.pickerStyle(.radioGroup)
|
||||||
|
|
||||||
if settings.proxySettings.mode == .custom {
|
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) {
|
Grid(alignment: .leading, horizontalSpacing: 10, verticalSpacing: 10) {
|
||||||
GridRow {
|
GridRow {
|
||||||
Text("IP or Host")
|
Text("IP or Host")
|
||||||
@@ -42,6 +35,12 @@ struct NetworkSettingsPane: View {
|
|||||||
Text(networkSummary)
|
Text(networkSummary)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
if settings.proxySettings.mode == .custom {
|
||||||
|
Text("aria2 uses an HTTP-style proxy for all protocols. SOCKS proxies are not supported by aria2.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ struct SiteLoginsSettingsPane: View {
|
|||||||
@State private var urlPattern = ""
|
@State private var urlPattern = ""
|
||||||
@State private var username = ""
|
@State private var username = ""
|
||||||
@State private var password = ""
|
@State private var password = ""
|
||||||
|
@State private var editingLoginID: UUID?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Form {
|
Form {
|
||||||
Section("Add Login") {
|
Section(editingLoginID == nil ? "Add Login" : "Edit Login") {
|
||||||
TextField("URL Pattern (e.g., *.github.com)", text: $urlPattern)
|
TextField("URL Pattern (e.g., *.github.com)", text: $urlPattern)
|
||||||
TextField("Username", text: $username)
|
TextField("Username", text: $username)
|
||||||
SecureField("Password", text: $password)
|
SecureField(editingLoginID == nil ? "Password" : "Password (leave blank to keep current)", text: $password)
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Text(settings.message)
|
Text(settings.message)
|
||||||
@@ -19,15 +20,23 @@ struct SiteLoginsSettingsPane: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
Spacer()
|
Spacer()
|
||||||
|
if editingLoginID != nil {
|
||||||
|
Button("Cancel Edit") {
|
||||||
|
resetForm()
|
||||||
|
}
|
||||||
|
}
|
||||||
Button {
|
Button {
|
||||||
settings.addSiteLogin(urlPattern: urlPattern, username: username, password: password)
|
settings.saveSiteLogin(
|
||||||
if settings.message.hasPrefix("Added") {
|
id: editingLoginID,
|
||||||
urlPattern = ""
|
urlPattern: urlPattern,
|
||||||
username = ""
|
username: username,
|
||||||
password = ""
|
password: password
|
||||||
|
)
|
||||||
|
if settings.message.hasPrefix("Added") || settings.message.hasPrefix("Updated") {
|
||||||
|
resetForm()
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Label("Add Login", systemImage: "plus")
|
Label(editingLoginID == nil ? "Add Login" : "Save Login", systemImage: editingLoginID == nil ? "plus" : "checkmark")
|
||||||
}
|
}
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
}
|
}
|
||||||
@@ -49,6 +58,13 @@ struct SiteLoginsSettingsPane: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
Text(login.username)
|
Text(login.username)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
Button {
|
||||||
|
edit(login)
|
||||||
|
} label: {
|
||||||
|
Label("Edit", systemImage: "pencil")
|
||||||
|
}
|
||||||
|
.labelStyle(.iconOnly)
|
||||||
|
.buttonStyle(.borderless)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onDelete(perform: settings.deleteSiteLogins)
|
.onDelete(perform: settings.deleteSiteLogins)
|
||||||
@@ -59,4 +75,18 @@ struct SiteLoginsSettingsPane: View {
|
|||||||
}
|
}
|
||||||
.formStyle(.grouped)
|
.formStyle(.grouped)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func edit(_ login: SiteLogin) {
|
||||||
|
editingLoginID = login.id
|
||||||
|
urlPattern = login.urlPattern
|
||||||
|
username = login.username
|
||||||
|
password = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resetForm() {
|
||||||
|
editingLoginID = nil
|
||||||
|
urlPattern = ""
|
||||||
|
username = ""
|
||||||
|
password = ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user