mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-05 08:59:05 +00:00
ae3476293e
- fix(media): use temp config file for yt-dlp credentials - fix(rpc): exclude rpcSecret and rpcPort from serialization - fix(ssrf): validate private hosts for all fetches - fix(uri): add rate limiting to firelink scheme - fix(auth): implement HMAC-SHA256 request signing for LocalExtensionServer - fix(fs): prevent path traversal during file deletion - fix(fs): apply strict 0o600 permissions to aria2.conf - fix(crypto): remove insecure UUID fallback for pairing token - fix(http): strip CRLF characters from custom headers - fix(network): use POSIX socket to eliminate port-finding race window - fix(ui): limit text input lengths in Add Downloads view - fix(concurrency): implement thread-safe temp directory cleanup - feat(ui): modernize sidebar and download table components
556 lines
18 KiB
Swift
556 lines
18 KiB
Swift
import Foundation
|
|
|
|
struct SiteLogin: Identifiable, Codable, Equatable, Sendable {
|
|
var id = UUID()
|
|
var urlPattern: String
|
|
var username: String
|
|
}
|
|
|
|
enum ProxyMode: String, Codable, CaseIterable, Sendable {
|
|
case none
|
|
case system
|
|
case custom
|
|
|
|
var title: String {
|
|
switch self {
|
|
case .none: "No proxy"
|
|
case .system: "Use system proxy"
|
|
case .custom: "Set proxy"
|
|
}
|
|
}
|
|
}
|
|
|
|
enum BrowserCookieSource: String, Codable, CaseIterable, Sendable {
|
|
case none = "None"
|
|
case safari = "Safari"
|
|
case chrome = "Chrome"
|
|
case firefox = "Firefox"
|
|
case edge = "Edge"
|
|
case brave = "Brave"
|
|
|
|
var ytDlpBrowserName: String? {
|
|
switch self {
|
|
case .none:
|
|
nil
|
|
case .safari, .chrome, .firefox, .edge, .brave:
|
|
rawValue.lowercased()
|
|
}
|
|
}
|
|
|
|
var statusTitle: String {
|
|
switch self {
|
|
case .none:
|
|
"Not using browser cookies"
|
|
case .safari, .chrome, .firefox, .edge, .brave:
|
|
"Using \(rawValue) cookies"
|
|
}
|
|
}
|
|
|
|
var statusDetail: String {
|
|
switch self {
|
|
case .none:
|
|
"Restricted media may fail if the site requires login."
|
|
case .safari:
|
|
"yt-dlp reads Safari cookies during metadata fetch and download. Safari may require Full Disk Access."
|
|
case .chrome, .firefox, .edge, .brave:
|
|
"yt-dlp reads these browser cookies during metadata fetch and download. Firelink does not store them."
|
|
}
|
|
}
|
|
}
|
|
|
|
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 (legacy)"
|
|
case .ftp: "FTP (legacy)"
|
|
case .socks5: "SOCKS5"
|
|
}
|
|
}
|
|
|
|
var uriScheme: String {
|
|
switch self {
|
|
case .http, .https, .ftp:
|
|
"http"
|
|
case .socks5:
|
|
"socks5"
|
|
}
|
|
}
|
|
}
|
|
|
|
struct ProxySettings: Codable, Equatable, Sendable {
|
|
var mode: ProxyMode = .none
|
|
var type: ProxyType = .http
|
|
var host = ""
|
|
var port = 8080
|
|
|
|
var normalized: ProxySettings {
|
|
var copy = self
|
|
copy.host = copy.host.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
copy.port = min(max(copy.port, 1), 65_535)
|
|
return copy
|
|
}
|
|
|
|
var customProxyURI: String? {
|
|
let clean = normalized
|
|
guard !clean.host.isEmpty else { return nil }
|
|
return "\(clean.type.uriScheme)://\(clean.host):\(clean.port)"
|
|
}
|
|
}
|
|
|
|
struct DownloadProxyConfiguration: Equatable, Sendable {
|
|
var mode: ProxyMode
|
|
var customProxyURI: String?
|
|
}
|
|
|
|
@MainActor
|
|
final class AppSettings: ObservableObject {
|
|
@Published var appTheme: AppTheme = .system {
|
|
didSet { save() }
|
|
}
|
|
|
|
@Published var appFontSize: AppFontSize = .standard {
|
|
didSet { save() }
|
|
}
|
|
|
|
@Published var listRowDensity: ListRowDensity = .standard {
|
|
didSet { save() }
|
|
}
|
|
|
|
@Published var perServerConnections: Int {
|
|
didSet {
|
|
let clamped = min(max(perServerConnections, 1), 16)
|
|
if perServerConnections != clamped {
|
|
perServerConnections = clamped
|
|
}
|
|
save()
|
|
}
|
|
}
|
|
|
|
@Published var maxConcurrentDownloads: Int {
|
|
didSet {
|
|
let clamped = min(max(maxConcurrentDownloads, 1), 12)
|
|
if maxConcurrentDownloads != clamped {
|
|
maxConcurrentDownloads = clamped
|
|
}
|
|
save()
|
|
}
|
|
}
|
|
|
|
@Published var globalSpeedLimitKiBPerSecond: Int {
|
|
didSet {
|
|
let clamped = min(max(globalSpeedLimitKiBPerSecond, 0), 10_485_760)
|
|
if globalSpeedLimitKiBPerSecond != clamped {
|
|
globalSpeedLimitKiBPerSecond = clamped
|
|
return
|
|
}
|
|
save()
|
|
}
|
|
}
|
|
|
|
@Published var preventsSleepWhileDownloading: Bool {
|
|
didSet { save() }
|
|
}
|
|
|
|
@Published var showNotifications: Bool {
|
|
didSet { save() }
|
|
}
|
|
|
|
@Published var customUserAgent: String {
|
|
didSet { save() }
|
|
}
|
|
|
|
@Published var playCompletionSound: Bool {
|
|
didSet { save() }
|
|
}
|
|
|
|
@Published var showDockBadge: Bool {
|
|
didSet { save() }
|
|
}
|
|
|
|
@Published var maxAutomaticRetries: Int {
|
|
didSet {
|
|
let clamped = min(max(maxAutomaticRetries, 0), 10)
|
|
if maxAutomaticRetries != clamped {
|
|
maxAutomaticRetries = clamped
|
|
return
|
|
}
|
|
save()
|
|
}
|
|
}
|
|
|
|
@Published var proxySettings: ProxySettings {
|
|
didSet {
|
|
let normalized = proxySettings.normalized
|
|
if proxySettings != normalized {
|
|
proxySettings = normalized
|
|
return
|
|
}
|
|
save()
|
|
}
|
|
}
|
|
|
|
@Published var downloadDirectories: [DownloadCategory: String] {
|
|
didSet { save() }
|
|
}
|
|
|
|
@Published var siteLogins: [SiteLogin] {
|
|
didSet { save() }
|
|
}
|
|
|
|
@Published var mediaCookieSource: BrowserCookieSource {
|
|
didSet { save() }
|
|
}
|
|
|
|
@Published var extensionPairingToken: String {
|
|
didSet {
|
|
if isKeychainAccessGranted {
|
|
KeychainCredentialStore.setExtensionToken(extensionPairingToken)
|
|
}
|
|
}
|
|
}
|
|
|
|
@Published var isKeychainAccessGranted: Bool {
|
|
didSet { save() }
|
|
}
|
|
|
|
@Published var showKeychainPrimer = false
|
|
|
|
@Published var askWhereToSaveEachFile: Bool {
|
|
didSet { save() }
|
|
}
|
|
|
|
@Published var message = ""
|
|
|
|
private let defaults: UserDefaults
|
|
private let storageKey = "Firelink.AppSettings.v1"
|
|
private var saveTask: Task<Void, Never>?
|
|
|
|
init(defaults: UserDefaults = .standard) {
|
|
self.defaults = defaults
|
|
|
|
let granted: Bool
|
|
if let data = defaults.data(forKey: storageKey),
|
|
let stored = try? JSONDecoder().decode(StoredSettings.self, from: data) {
|
|
appTheme = stored.appTheme ?? .system
|
|
appFontSize = stored.appFontSize ?? .standard
|
|
listRowDensity = stored.listRowDensity ?? .standard
|
|
perServerConnections = min(max(stored.perServerConnections, 1), 16)
|
|
maxConcurrentDownloads = min(max(stored.maxConcurrentDownloads ?? 3, 1), 12)
|
|
globalSpeedLimitKiBPerSecond = min(max(stored.globalSpeedLimitKiBPerSecond ?? 0, 0), 10_485_760)
|
|
preventsSleepWhileDownloading = stored.preventsSleepWhileDownloading
|
|
showNotifications = stored.showNotifications ?? true
|
|
playCompletionSound = stored.playCompletionSound ?? true
|
|
showDockBadge = stored.showDockBadge ?? true
|
|
customUserAgent = stored.customUserAgent ?? ""
|
|
maxAutomaticRetries = min(max(stored.maxAutomaticRetries ?? 3, 0), 10)
|
|
proxySettings = stored.proxySettings?.normalized ?? ProxySettings()
|
|
siteLogins = stored.siteLogins
|
|
mediaCookieSource = stored.mediaCookieSource ?? .none
|
|
downloadDirectories = Self.decodeDirectories(stored.downloadDirectories)
|
|
granted = stored.isKeychainAccessGranted ?? false
|
|
isKeychainAccessGranted = granted
|
|
askWhereToSaveEachFile = stored.askWhereToSaveEachFile ?? false
|
|
} else {
|
|
appTheme = .system
|
|
appFontSize = .standard
|
|
listRowDensity = .standard
|
|
perServerConnections = 16
|
|
maxConcurrentDownloads = 3
|
|
globalSpeedLimitKiBPerSecond = 0
|
|
preventsSleepWhileDownloading = true
|
|
showNotifications = true
|
|
playCompletionSound = true
|
|
showDockBadge = true
|
|
customUserAgent = ""
|
|
maxAutomaticRetries = 3
|
|
proxySettings = ProxySettings()
|
|
siteLogins = []
|
|
mediaCookieSource = .none
|
|
downloadDirectories = Self.defaultDirectories()
|
|
granted = false
|
|
isKeychainAccessGranted = granted
|
|
askWhereToSaveEachFile = false
|
|
}
|
|
|
|
let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
|
|
let currentBuild = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "unknown"
|
|
var execHash = "unknown"
|
|
if let execPath = Bundle.main.executablePath,
|
|
let attr = try? FileManager.default.attributesOfItem(atPath: execPath),
|
|
let modDate = attr[.modificationDate] as? Date {
|
|
execHash = String(modDate.timeIntervalSince1970)
|
|
}
|
|
let fullVersion = "\(currentVersion).\(currentBuild).\(execHash)"
|
|
let lastVersion = defaults.string(forKey: "Firelink.lastLaunchedVersion")
|
|
defaults.set(fullVersion, forKey: "Firelink.lastLaunchedVersion")
|
|
|
|
var needsPrimer = false
|
|
if granted {
|
|
if let lastVersion, lastVersion != fullVersion {
|
|
needsPrimer = true
|
|
}
|
|
}
|
|
|
|
if granted {
|
|
if needsPrimer {
|
|
showKeychainPrimer = true
|
|
extensionPairingToken = ""
|
|
} else {
|
|
if let token = KeychainCredentialStore.extensionToken() {
|
|
extensionPairingToken = token
|
|
} else {
|
|
extensionPairingToken = Self.generateSecureToken()
|
|
}
|
|
}
|
|
} else {
|
|
extensionPairingToken = ""
|
|
}
|
|
|
|
for category in DownloadCategory.allCases where downloadDirectories[category] == nil {
|
|
downloadDirectories[category] = Self.defaultDirectory(for: category).path
|
|
}
|
|
}
|
|
|
|
func destinationDirectory(for category: DownloadCategory) -> URL {
|
|
let path = downloadDirectories[category] ?? Self.defaultDirectory(for: category).path
|
|
return URL(fileURLWithPath: NSString(string: path).expandingTildeInPath, isDirectory: true)
|
|
}
|
|
|
|
func setDirectory(_ path: String, for category: DownloadCategory) {
|
|
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return }
|
|
downloadDirectories[category] = NSString(string: trimmed).expandingTildeInPath
|
|
}
|
|
|
|
func resetDirectories() {
|
|
downloadDirectories = Self.defaultDirectories()
|
|
}
|
|
|
|
var downloadProxyConfiguration: DownloadProxyConfiguration {
|
|
DownloadProxyConfiguration(
|
|
mode: proxySettings.mode,
|
|
customProxyURI: proxySettings.customProxyURI
|
|
)
|
|
}
|
|
|
|
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 cleanUsername = username.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
guard !pattern.isEmpty, !cleanUsername.isEmpty else {
|
|
message = "Add a URL pattern and username."
|
|
return
|
|
}
|
|
|
|
if let id,
|
|
siteLogins.contains(where: { $0.id != id && $0.urlPattern.caseInsensitiveCompare(pattern) == .orderedSame }) {
|
|
message = "A login for \(pattern) already exists."
|
|
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)
|
|
message = "Added login for \(pattern)."
|
|
}
|
|
|
|
func deleteSiteLogins(at offsets: IndexSet) {
|
|
for offset in offsets {
|
|
KeychainCredentialStore.deletePassword(for: siteLogins[offset].id)
|
|
}
|
|
siteLogins.remove(atOffsets: offsets)
|
|
}
|
|
|
|
func credentials(for url: URL) -> DownloadCredentials? {
|
|
guard let login = siteLogins.first(where: { Self.matches(url: url, pattern: $0.urlPattern) }),
|
|
let password = KeychainCredentialStore.password(for: login.id) else {
|
|
return nil
|
|
}
|
|
|
|
return DownloadCredentials(username: login.username, password: password)
|
|
}
|
|
|
|
func credentials(for login: SiteLogin) -> DownloadCredentials? {
|
|
guard let password = KeychainCredentialStore.password(for: login.id) else {
|
|
return nil
|
|
}
|
|
|
|
return DownloadCredentials(username: login.username, password: password)
|
|
}
|
|
|
|
func grantKeychainAccess() {
|
|
isKeychainAccessGranted = true
|
|
if let token = KeychainCredentialStore.extensionToken() {
|
|
extensionPairingToken = token
|
|
} else {
|
|
extensionPairingToken = Self.generateSecureToken()
|
|
}
|
|
}
|
|
|
|
func revokeKeychainAccess() {
|
|
KeychainCredentialStore.deleteExtensionToken()
|
|
for login in siteLogins {
|
|
KeychainCredentialStore.deletePassword(for: login.id)
|
|
}
|
|
siteLogins.removeAll()
|
|
extensionPairingToken = ""
|
|
isKeychainAccessGranted = false
|
|
}
|
|
|
|
func resolveKeychainPrimer(grantAccess: Bool) {
|
|
showKeychainPrimer = false
|
|
if grantAccess {
|
|
if let token = KeychainCredentialStore.extensionToken() {
|
|
extensionPairingToken = token
|
|
} else {
|
|
extensionPairingToken = Self.generateSecureToken()
|
|
}
|
|
} else {
|
|
revokeKeychainAccess()
|
|
}
|
|
}
|
|
|
|
private func save() {
|
|
let stored = StoredSettings(
|
|
appTheme: appTheme,
|
|
appFontSize: appFontSize,
|
|
listRowDensity: listRowDensity,
|
|
perServerConnections: perServerConnections,
|
|
maxConcurrentDownloads: maxConcurrentDownloads,
|
|
globalSpeedLimitKiBPerSecond: globalSpeedLimitKiBPerSecond,
|
|
preventsSleepWhileDownloading: preventsSleepWhileDownloading,
|
|
showNotifications: showNotifications,
|
|
playCompletionSound: playCompletionSound,
|
|
showDockBadge: showDockBadge,
|
|
customUserAgent: customUserAgent,
|
|
maxAutomaticRetries: maxAutomaticRetries,
|
|
proxySettings: proxySettings.normalized,
|
|
downloadDirectories: Dictionary(uniqueKeysWithValues: downloadDirectories.map { ($0.key.rawValue, $0.value) }),
|
|
siteLogins: siteLogins,
|
|
mediaCookieSource: mediaCookieSource,
|
|
isKeychainAccessGranted: isKeychainAccessGranted,
|
|
askWhereToSaveEachFile: askWhereToSaveEachFile
|
|
)
|
|
let defaults = self.defaults
|
|
let storageKey = self.storageKey
|
|
|
|
saveTask?.cancel()
|
|
saveTask = Task { @MainActor [defaults, storageKey] in
|
|
let data = await Task.detached(priority: .background) {
|
|
try? JSONEncoder().encode(stored)
|
|
}.value
|
|
|
|
guard !Task.isCancelled, let encoded = data else { return }
|
|
defaults.set(encoded, forKey: storageKey)
|
|
}
|
|
}
|
|
|
|
private static func matches(url: URL, pattern rawPattern: String) -> Bool {
|
|
let pattern = rawPattern.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
guard !pattern.isEmpty else { return false }
|
|
|
|
let host = (url.host(percentEncoded: false) ?? "").lowercased()
|
|
let absolute = url.absoluteString.lowercased()
|
|
let normalizedPattern = URL(string: pattern)?.host ?? pattern
|
|
|
|
if normalizedPattern.hasPrefix("*.") {
|
|
let suffix = String(normalizedPattern.dropFirst(2))
|
|
return host == suffix || host.hasSuffix(".\(suffix)")
|
|
}
|
|
|
|
if normalizedPattern.contains("*") {
|
|
let escaped = NSRegularExpression.escapedPattern(for: normalizedPattern)
|
|
.replacingOccurrences(of: "\\*", with: ".*")
|
|
return host.range(of: "^\(escaped)$", options: .regularExpression) != nil
|
|
}
|
|
|
|
if normalizedPattern.contains("/") {
|
|
return absolute.contains(normalizedPattern)
|
|
}
|
|
|
|
return host == normalizedPattern
|
|
}
|
|
|
|
private static func generateSecureToken() -> String {
|
|
var bytes = [UInt8](repeating: 0, count: 32)
|
|
let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
|
|
guard status == errSecSuccess else {
|
|
return UUID().uuidString
|
|
}
|
|
return Data(bytes).base64EncodedString()
|
|
}
|
|
|
|
private static func defaultDirectories() -> [DownloadCategory: String] {
|
|
Dictionary(uniqueKeysWithValues: DownloadCategory.allCases.map { ($0, defaultDirectory(for: $0).path) })
|
|
}
|
|
|
|
private static func defaultDirectory(for category: DownloadCategory) -> URL {
|
|
let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
|
|
?? URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Downloads")
|
|
return downloads.appendingPathComponent(category.rawValue, isDirectory: true)
|
|
}
|
|
|
|
private static func decodeDirectories(_ stored: [String: String]) -> [DownloadCategory: String] {
|
|
Dictionary(uniqueKeysWithValues: stored.compactMap { key, value in
|
|
guard let category = DownloadCategory(rawValue: key) else { return nil }
|
|
return (category, value)
|
|
})
|
|
}
|
|
}
|
|
|
|
private struct StoredSettings: Codable {
|
|
var appTheme: AppTheme?
|
|
var appFontSize: AppFontSize?
|
|
var listRowDensity: ListRowDensity?
|
|
var perServerConnections: Int
|
|
var maxConcurrentDownloads: Int?
|
|
var globalSpeedLimitKiBPerSecond: Int?
|
|
var preventsSleepWhileDownloading: Bool
|
|
var showNotifications: Bool?
|
|
var playCompletionSound: Bool?
|
|
var showDockBadge: Bool?
|
|
var customUserAgent: String?
|
|
var maxAutomaticRetries: Int?
|
|
var proxySettings: ProxySettings?
|
|
var downloadDirectories: [String: String]
|
|
var siteLogins: [SiteLogin]
|
|
var mediaCookieSource: BrowserCookieSource?
|
|
var isKeychainAccessGranted: Bool?
|
|
var askWhereToSaveEachFile: Bool?
|
|
}
|