mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-21 16:36:50 +00:00
fix: harden download workflows
This commit is contained in:
+1
-1
Submodule Extensions/Firefox updated: 2f99dda366...f443e096c2
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: build app dmg run clean
|
.PHONY: build app dmg run verify clean
|
||||||
|
|
||||||
build:
|
build:
|
||||||
swift build -c release
|
swift build -c release
|
||||||
@@ -12,6 +12,9 @@ dmg: app
|
|||||||
run:
|
run:
|
||||||
swift run Firelink
|
swift run Firelink
|
||||||
|
|
||||||
|
verify:
|
||||||
|
Scripts/verify.sh
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
swift package clean
|
swift package clean
|
||||||
rm -rf build dist
|
rm -rf build dist
|
||||||
|
|||||||
+4
-1
@@ -13,7 +13,10 @@ let package = Package(
|
|||||||
targets: [
|
targets: [
|
||||||
.executableTarget(
|
.executableTarget(
|
||||||
name: "Firelink",
|
name: "Firelink",
|
||||||
path: "Sources/Firelink"
|
path: "Sources/Firelink",
|
||||||
|
resources: [
|
||||||
|
.process("Assets.xcassets")
|
||||||
|
]
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
Executable
+8
@@ -0,0 +1,8 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
swift build
|
||||||
|
python3 -m json.tool Extensions/Firefox/manifest.json >/dev/null
|
||||||
@@ -52,6 +52,7 @@ struct AddDownloadsView: View {
|
|||||||
targetQueueID = controller.pendingAddQueueID ?? DownloadQueue.mainQueueID
|
targetQueueID = controller.pendingAddQueueID ?? DownloadQueue.mainQueueID
|
||||||
controller.pendingAddQueueID = nil
|
controller.pendingAddQueueID = nil
|
||||||
if let text = controller.pendingPasteboardText {
|
if let text = controller.pendingPasteboardText {
|
||||||
|
applyPendingReferer()
|
||||||
linkText = text
|
linkText = text
|
||||||
controller.pendingPasteboardText = nil
|
controller.pendingPasteboardText = nil
|
||||||
}
|
}
|
||||||
@@ -388,6 +389,24 @@ struct AddDownloadsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func applyPendingReferer() {
|
||||||
|
guard let referer = controller.pendingReferer?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!referer.isEmpty,
|
||||||
|
URL(string: referer) != nil else {
|
||||||
|
controller.pendingReferer = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let refererHeader = "Referer: \(referer)"
|
||||||
|
let existingHeaders = DownloadTransferOptionParser.parseHeaders(headerText)
|
||||||
|
if !existingHeaders.contains(where: { $0.normalized.name.lowercased() == "referer" }) {
|
||||||
|
headerText = headerText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
? refererHeader
|
||||||
|
: "\(headerText.trimmingCharacters(in: .whitespacesAndNewlines))\n\(refererHeader)"
|
||||||
|
}
|
||||||
|
controller.pendingReferer = nil
|
||||||
|
}
|
||||||
|
|
||||||
private func refreshMetadata(for text: String) {
|
private func refreshMetadata(for text: String) {
|
||||||
let urls = DownloadURLParser.parse(text)
|
let urls = DownloadURLParser.parse(text)
|
||||||
metadataTask?.cancel()
|
metadataTask?.cancel()
|
||||||
@@ -458,7 +477,7 @@ struct AddDownloadsView: View {
|
|||||||
var savedHosts = Set<String>()
|
var savedHosts = Set<String>()
|
||||||
for item in pendingDownloads {
|
for item in pendingDownloads {
|
||||||
if let host = item.url.host, !savedHosts.contains(host) {
|
if let host = item.url.host, !savedHosts.contains(host) {
|
||||||
settings.addSiteLogin(urlPattern: "*.\(host)", username: cleanUsername, password: authPassword)
|
settings.addSiteLogin(urlPattern: host, username: cleanUsername, password: authPassword)
|
||||||
savedHosts.insert(host)
|
savedHosts.insert(host)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -285,7 +285,7 @@ final class AppSettings: ObservableObject {
|
|||||||
return absolute.contains(normalizedPattern)
|
return absolute.contains(normalizedPattern)
|
||||||
}
|
}
|
||||||
|
|
||||||
return host == normalizedPattern || host.hasSuffix(".\(normalizedPattern)")
|
return host == normalizedPattern
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func defaultDirectories() -> [DownloadCategory: String] {
|
private static func defaultDirectories() -> [DownloadCategory: String] {
|
||||||
|
|||||||
@@ -202,6 +202,30 @@ final class Aria2DownloadEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func updateSpeedLimit(handle: Handle, speedLimitKiBPerSecond: Int?) async {
|
||||||
|
guard let url = URL(string: "http://127.0.0.1:\(handle.rpcPort)/jsonrpc") else { return }
|
||||||
|
|
||||||
|
let limitValue = speedLimitKiBPerSecond.map { "\($0)K" } ?? "0"
|
||||||
|
let payload: [String: Any] = [
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": "aria2.changeGlobalOption",
|
||||||
|
"id": UUID().uuidString,
|
||||||
|
"params": [
|
||||||
|
"token:\(handle.rpcSecret)",
|
||||||
|
["max-download-limit": limitValue]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return }
|
||||||
|
var request = URLRequest(url: url)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.httpBody = data
|
||||||
|
request.timeoutInterval = 3
|
||||||
|
|
||||||
|
_ = try? await URLSession.shared.data(for: request)
|
||||||
|
}
|
||||||
|
|
||||||
private func arguments(
|
private func arguments(
|
||||||
for item: DownloadItem,
|
for item: DownloadItem,
|
||||||
proxyConfiguration: DownloadProxyConfiguration,
|
proxyConfiguration: DownloadProxyConfiguration,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ struct ContentView: View {
|
|||||||
if let url = url {
|
if let url = url {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
controller.pendingPasteboardText = url.absoluteString
|
controller.pendingPasteboardText = url.absoluteString
|
||||||
|
controller.pendingReferer = nil
|
||||||
openWindow(id: "add-downloads")
|
openWindow(id: "add-downloads")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -37,6 +38,7 @@ struct ContentView: View {
|
|||||||
if let text = text {
|
if let text = text {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
controller.pendingPasteboardText = text
|
controller.pendingPasteboardText = text
|
||||||
|
controller.pendingReferer = nil
|
||||||
openWindow(id: "add-downloads")
|
openWindow(id: "add-downloads")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -117,7 +119,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
.disabled(!canStop)
|
.disabled(!canStop)
|
||||||
|
|
||||||
let canStart = selectedItems.isEmpty ? true : selectedItems.contains(where: { $0.status == .paused || $0.status == .failed || $0.status == .canceled })
|
let canStart = selectedItems.isEmpty ? hasQueuedDownloads(in: queueID) : selectedItems.contains(where: { $0.status == .paused || $0.status == .failed || $0.status == .canceled })
|
||||||
Button {
|
Button {
|
||||||
if selectedItems.isEmpty {
|
if selectedItems.isEmpty {
|
||||||
controller.startQueue(queueID: queueID)
|
controller.startQueue(queueID: queueID)
|
||||||
@@ -183,6 +185,7 @@ struct ContentView: View {
|
|||||||
private func handlePaste(queueID: UUID?) {
|
private func handlePaste(queueID: UUID?) {
|
||||||
guard let text = NSPasteboard.general.string(forType: .string), !text.isEmpty else { return }
|
guard let text = NSPasteboard.general.string(forType: .string), !text.isEmpty else { return }
|
||||||
controller.pendingPasteboardText = text
|
controller.pendingPasteboardText = text
|
||||||
|
controller.pendingReferer = nil
|
||||||
controller.pendingAddQueueID = queueID
|
controller.pendingAddQueueID = queueID
|
||||||
openWindow(id: "add-downloads")
|
openWindow(id: "add-downloads")
|
||||||
}
|
}
|
||||||
@@ -199,6 +202,14 @@ struct ContentView: View {
|
|||||||
return controller.activeCount > 0
|
return controller.activeCount > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func hasQueuedDownloads(in queueID: UUID?) -> Bool {
|
||||||
|
if let queueID {
|
||||||
|
return controller.queueItems(for: queueID).contains { $0.status == .queued }
|
||||||
|
}
|
||||||
|
|
||||||
|
return controller.queuedCount > 0
|
||||||
|
}
|
||||||
|
|
||||||
private func filteredDownloads(for filter: DownloadSidebarFilter) -> [DownloadItem] {
|
private func filteredDownloads(for filter: DownloadSidebarFilter) -> [DownloadItem] {
|
||||||
switch filter {
|
switch filter {
|
||||||
case .all:
|
case .all:
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ final class DownloadController: ObservableObject {
|
|||||||
@Published var queues: [DownloadQueue] = [.main]
|
@Published var queues: [DownloadQueue] = [.main]
|
||||||
@Published var engineMessage = ""
|
@Published var engineMessage = ""
|
||||||
@Published var pendingPasteboardText: String?
|
@Published var pendingPasteboardText: String?
|
||||||
|
@Published var pendingReferer: String?
|
||||||
var pendingAddQueueID: UUID?
|
var pendingAddQueueID: UUID?
|
||||||
|
|
||||||
private let settings: AppSettings
|
private let settings: AppSettings
|
||||||
@@ -39,6 +40,25 @@ final class DownloadController: ObservableObject {
|
|||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
settings.$globalSpeedLimitKiBPerSecond
|
||||||
|
.dropFirst()
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
Task { @MainActor in
|
||||||
|
self?.applySpeedLimitsToActiveDownloads()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
settings.$maxConcurrentDownloads
|
||||||
|
.dropFirst()
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
Task { @MainActor in
|
||||||
|
self?.applySpeedLimitsToActiveDownloads()
|
||||||
|
self?.pumpQueue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
$downloads
|
$downloads
|
||||||
.dropFirst()
|
.dropFirst()
|
||||||
.debounce(for: .seconds(2.0), scheduler: RunLoop.main)
|
.debounce(for: .seconds(2.0), scheduler: RunLoop.main)
|
||||||
@@ -131,10 +151,11 @@ final class DownloadController: ObservableObject {
|
|||||||
let speedLimitKiBPerSecond = normalizedSpeedLimit(speedLimitKiBPerSecond)
|
let speedLimitKiBPerSecond = normalizedSpeedLimit(speedLimitKiBPerSecond)
|
||||||
|
|
||||||
let items = pendingDownloads.map { pending in
|
let items = pendingDownloads.map { pending in
|
||||||
DownloadItem(
|
let fileName = FileClassifier.sanitizedFileName(pending.fileName)
|
||||||
|
return DownloadItem(
|
||||||
url: pending.url,
|
url: pending.url,
|
||||||
fileName: pending.fileName,
|
fileName: fileName,
|
||||||
category: pending.category,
|
category: FileClassifier.category(forFileName: fileName),
|
||||||
destinationDirectory: overrideDirectory ?? pending.defaultDirectory,
|
destinationDirectory: overrideDirectory ?? pending.defaultDirectory,
|
||||||
connectionsPerServer: clampedConnections,
|
connectionsPerServer: clampedConnections,
|
||||||
credentials: credentials ?? settings.credentials(for: pending.url),
|
credentials: credentials ?? settings.credentials(for: pending.url),
|
||||||
@@ -526,8 +547,8 @@ final class DownloadController: ObservableObject {
|
|||||||
) {
|
) {
|
||||||
update(id) {
|
update(id) {
|
||||||
$0.url = url
|
$0.url = url
|
||||||
$0.fileName = fileName
|
$0.fileName = FileClassifier.sanitizedFileName(fileName)
|
||||||
$0.category = FileClassifier.category(forFileName: fileName)
|
$0.category = FileClassifier.category(forFileName: $0.fileName)
|
||||||
$0.destinationDirectory = destinationDirectory
|
$0.destinationDirectory = destinationDirectory
|
||||||
$0.connectionsPerServer = min(max(connectionsPerServer, 1), 16)
|
$0.connectionsPerServer = min(max(connectionsPerServer, 1), 16)
|
||||||
$0.credentials = credentials
|
$0.credentials = credentials
|
||||||
@@ -543,6 +564,7 @@ final class DownloadController: ObservableObject {
|
|||||||
} else if credentials == nil {
|
} else if credentials == nil {
|
||||||
KeychainCredentialStore.deletePassword(for: id)
|
KeychainCredentialStore.deletePassword(for: id)
|
||||||
}
|
}
|
||||||
|
applySpeedLimitToActiveDownload(id: id)
|
||||||
saveDownloads()
|
saveDownloads()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -568,6 +590,24 @@ final class DownloadController: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func applySpeedLimitsToActiveDownloads() {
|
||||||
|
for item in downloads where item.status == .downloading {
|
||||||
|
applySpeedLimitToActiveDownload(id: item.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applySpeedLimitToActiveDownload(id: UUID) {
|
||||||
|
guard let handle = activeHandles[id],
|
||||||
|
let item = downloads.first(where: { $0.id == id }) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let limit = effectiveSpeedLimitKiBPerSecond(for: item)
|
||||||
|
Task {
|
||||||
|
await Aria2DownloadEngine.updateSpeedLimit(handle: handle, speedLimitKiBPerSecond: limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func markQueuedDownloadsForAutoResume(queueID: UUID?) {
|
private func markQueuedDownloadsForAutoResume(queueID: UUID?) {
|
||||||
if let queueID {
|
if let queueID {
|
||||||
let normalizedID = normalizedQueueID(queueID)
|
let normalizedID = normalizedQueueID(queueID)
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
enum DownloadURLParser {
|
enum DownloadURLParser {
|
||||||
|
private static let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
|
||||||
|
|
||||||
static func parse(_ text: String) -> [URL] {
|
static func parse(_ text: String) -> [URL] {
|
||||||
let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
|
|
||||||
let range = NSRange(text.startIndex..<text.endIndex, in: text)
|
let range = NSRange(text.startIndex..<text.endIndex, in: text)
|
||||||
let detected = detector?.matches(in: text, range: range).compactMap(\.url) ?? []
|
let detected = detector?.matches(in: text, range: range).compactMap(\.url) ?? []
|
||||||
|
|
||||||
@@ -71,8 +72,8 @@ enum DownloadMetadataFetcher {
|
|||||||
|
|
||||||
if let disposition = httpResponse.value(forHTTPHeaderField: "Content-Disposition"),
|
if let disposition = httpResponse.value(forHTTPHeaderField: "Content-Disposition"),
|
||||||
let fileName = fileName(fromContentDisposition: disposition) {
|
let fileName = fileName(fromContentDisposition: disposition) {
|
||||||
pending.fileName = fileName
|
pending.fileName = FileClassifier.sanitizedFileName(fileName)
|
||||||
pending.category = FileClassifier.category(forFileName: fileName)
|
pending.category = FileClassifier.category(forFileName: pending.fileName)
|
||||||
pending.defaultDirectory = await settings.destinationDirectory(for: pending.category)
|
pending.defaultDirectory = await settings.destinationDirectory(for: pending.category)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -221,7 +221,7 @@ struct DownloadPropertiesView: View {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let cleanFileName = fileName.trimmingCharacters(in: .whitespacesAndNewlines)
|
let cleanFileName = FileClassifier.sanitizedFileName(fileName)
|
||||||
guard !cleanFileName.isEmpty else {
|
guard !cleanFileName.isEmpty else {
|
||||||
errorMessage = "File name cannot be empty."
|
errorMessage = "File name cannot be empty."
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -37,70 +37,75 @@ struct DownloadTable: View {
|
|||||||
|
|
||||||
Table(sortedItems, selection: $selection, sortOrder: $sortOrder) {
|
Table(sortedItems, selection: $selection, sortOrder: $sortOrder) {
|
||||||
TableColumn("File Name", value: \.fileName) { item in
|
TableColumn("File Name", value: \.fileName) { item in
|
||||||
HStack(alignment: .top, spacing: 8) {
|
doubleClickableCell(for: item) {
|
||||||
Image(systemName: item.category.symbolName)
|
HStack(alignment: .top, spacing: 8) {
|
||||||
.font(.title3)
|
Image(systemName: item.category.symbolName)
|
||||||
.foregroundStyle(categoryColor(for: item.category))
|
.font(.title3)
|
||||||
.frame(width: 22)
|
.foregroundStyle(categoryColor(for: item.category))
|
||||||
Text(item.fileName)
|
.frame(width: 22)
|
||||||
.font(.headline)
|
Text(item.fileName)
|
||||||
.lineLimit(1)
|
.font(.headline)
|
||||||
.truncationMode(.tail)
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
}
|
||||||
|
.draggable(item.id.uuidString)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.width(min: 200, ideal: 340)
|
.width(min: 200, ideal: 340)
|
||||||
|
|
||||||
TableColumn("Size", value: \.sortableSize) { item in
|
TableColumn("Size", value: \.sortableSize) { item in
|
||||||
Text(ByteFormatter.string(item.sizeBytes))
|
doubleClickableCell(for: item) {
|
||||||
.monospacedDigit()
|
Text(ByteFormatter.string(item.sizeBytes))
|
||||||
.lineLimit(1)
|
.monospacedDigit()
|
||||||
.truncationMode(.tail)
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.width(min: 80, ideal: 100)
|
.width(min: 80, ideal: 100)
|
||||||
|
|
||||||
TableColumn("Progress", value: \.progress) { item in
|
TableColumn("Progress", value: \.progress) { item in
|
||||||
progressBarCell(for: item)
|
doubleClickableCell(for: item) {
|
||||||
|
progressBarCell(for: item)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.width(min: 100, ideal: 115)
|
.width(min: 100, ideal: 115)
|
||||||
|
|
||||||
TableColumn("Status", value: \.status.rawValue) { item in
|
TableColumn("Status", value: \.status.rawValue) { item in
|
||||||
Text(item.status.rawValue)
|
doubleClickableCell(for: item) {
|
||||||
|
Text(item.status.rawValue)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.width(min: 80, ideal: 105)
|
.width(min: 80, ideal: 105)
|
||||||
|
|
||||||
TableColumn("Speed", value: \.speedText) { item in
|
TableColumn("Speed", value: \.speedText) { item in
|
||||||
Text(item.speedText)
|
doubleClickableCell(for: item) {
|
||||||
.lineLimit(1)
|
Text(item.speedText)
|
||||||
.truncationMode(.tail)
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.width(min: 70, ideal: 90)
|
.width(min: 70, ideal: 90)
|
||||||
|
|
||||||
TableColumn("ETA", value: \.etaText) { item in
|
TableColumn("ETA", value: \.etaText) { item in
|
||||||
Text(item.etaText)
|
doubleClickableCell(for: item) {
|
||||||
.lineLimit(1)
|
Text(item.etaText)
|
||||||
.truncationMode(.tail)
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.width(min: 70, ideal: 90)
|
.width(min: 70, ideal: 90)
|
||||||
|
|
||||||
TableColumn("Date Added", value: \.createdAt) { item in
|
TableColumn("Date Added", value: \.createdAt) { item in
|
||||||
Text(formatted(item.createdAt))
|
doubleClickableCell(for: item) {
|
||||||
.lineLimit(1)
|
Text(formatted(item.createdAt))
|
||||||
.truncationMode(.tail)
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.width(min: 100, ideal: 155)
|
.width(min: 100, ideal: 155)
|
||||||
}
|
}
|
||||||
.contextMenu(forSelectionType: DownloadItem.ID.self) { itemIDs in
|
.contextMenu(forSelectionType: DownloadItem.ID.self) { itemIDs in
|
||||||
rowContextMenu(for: itemIDs)
|
rowContextMenu(for: itemIDs)
|
||||||
} primaryAction: { itemIDs in
|
|
||||||
for itemID in itemIDs {
|
|
||||||
if let item = controller.downloads.first(where: { $0.id == itemID }) {
|
|
||||||
if item.status == .completed {
|
|
||||||
openFile(item)
|
|
||||||
} else {
|
|
||||||
openWindow(value: item.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.overlay {
|
.overlay {
|
||||||
if items.isEmpty {
|
if items.isEmpty {
|
||||||
@@ -151,6 +156,25 @@ struct DownloadTable: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func doubleClickableCell<Content: View>(
|
||||||
|
for item: DownloadItem,
|
||||||
|
@ViewBuilder content: () -> Content
|
||||||
|
) -> some View {
|
||||||
|
content()
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture(count: 2) {
|
||||||
|
performPrimaryAction(for: item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func performPrimaryAction(for item: DownloadItem) {
|
||||||
|
if item.status == .completed {
|
||||||
|
openFile(item)
|
||||||
|
} else {
|
||||||
|
openWindow(value: item.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func rowContextMenu(for itemIDs: Set<DownloadItem.ID>) -> some View {
|
private func rowContextMenu(for itemIDs: Set<DownloadItem.ID>) -> some View {
|
||||||
let targetItems = controller.downloads.filter { itemIDs.contains($0.id) }
|
let targetItems = controller.downloads.filter { itemIDs.contains($0.id) }
|
||||||
|
|||||||
@@ -57,10 +57,30 @@ enum FileClassifier {
|
|||||||
static func fileName(from url: URL) -> String {
|
static func fileName(from url: URL) -> String {
|
||||||
let pathName = url.lastPathComponent.removingPercentEncoding ?? url.lastPathComponent
|
let pathName = url.lastPathComponent.removingPercentEncoding ?? url.lastPathComponent
|
||||||
if !pathName.isEmpty, pathName != "/" {
|
if !pathName.isEmpty, pathName != "/" {
|
||||||
return pathName
|
return sanitizedFileName(pathName)
|
||||||
}
|
}
|
||||||
|
|
||||||
let host = url.host(percentEncoded: false) ?? "download"
|
let host = url.host(percentEncoded: false) ?? "download"
|
||||||
return "\(host)-download"
|
return sanitizedFileName("\(host)-download")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func sanitizedFileName(_ rawValue: String, fallback: String = "download") -> String {
|
||||||
|
let separators = CharacterSet(charactersIn: "/\\")
|
||||||
|
let invalidCharacters = separators
|
||||||
|
.union(.newlines)
|
||||||
|
.union(.controlCharacters)
|
||||||
|
|
||||||
|
let components = rawValue
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
.components(separatedBy: invalidCharacters)
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
let clean = components.joined(separator: "_")
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
||||||
|
guard !clean.isEmpty, clean != ".", clean != ".." else {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(clean.prefix(255))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ struct FirelinkApp: App {
|
|||||||
.modifier(AppFontSizeModifier(fontSize: settings.appFontSize))
|
.modifier(AppFontSizeModifier(fontSize: settings.appFontSize))
|
||||||
.onOpenURL { url in
|
.onOpenURL { url in
|
||||||
controller.pendingPasteboardText = url.absoluteString
|
controller.pendingPasteboardText = url.absoluteString
|
||||||
|
controller.pendingReferer = nil
|
||||||
NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil)
|
NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil)
|
||||||
}
|
}
|
||||||
.frame(minWidth: 1180, idealWidth: 1280, minHeight: 720, idealHeight: 760)
|
.frame(minWidth: 1180, idealWidth: 1280, minHeight: 720, idealHeight: 760)
|
||||||
@@ -87,7 +88,7 @@ struct FirelinkApp: App {
|
|||||||
.environmentObject(controller)
|
.environmentObject(controller)
|
||||||
} label: {
|
} label: {
|
||||||
if let nsImage = { () -> NSImage? in
|
if let nsImage = { () -> NSImage? in
|
||||||
guard let url = Bundle.main.url(forResource: "MenuBarIconTemplate", withExtension: "png"),
|
guard let url = menuBarIconURL(),
|
||||||
let img = NSImage(contentsOf: url) else { return nil }
|
let img = NSImage(contentsOf: url) else { return nil }
|
||||||
img.size = NSSize(width: 21, height: 21)
|
img.size = NSSize(width: 21, height: 21)
|
||||||
img.isTemplate = true
|
img.isTemplate = true
|
||||||
@@ -99,4 +100,19 @@ struct FirelinkApp: App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func menuBarIconURL() -> URL? {
|
||||||
|
if let bundled = Bundle.main.url(forResource: "MenuBarIconTemplate", withExtension: "png") {
|
||||||
|
return bundled
|
||||||
|
}
|
||||||
|
|
||||||
|
let sourceFile = URL(fileURLWithPath: #filePath)
|
||||||
|
let projectRoot = sourceFile
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
let sourceTreeIcon = projectRoot
|
||||||
|
.appendingPathComponent("Sources/Firelink/Assets.xcassets/MenuBarIcon.imageset/MenuBarIconTemplate.png")
|
||||||
|
return FileManager.default.fileExists(atPath: sourceTreeIcon.path) ? sourceTreeIcon : nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,25 +4,30 @@ import AppKit
|
|||||||
|
|
||||||
final class LocalExtensionServer: @unchecked Sendable {
|
final class LocalExtensionServer: @unchecked Sendable {
|
||||||
private enum Constants {
|
private enum Constants {
|
||||||
static let port = NWEndpoint.Port(rawValue: 6412)!
|
static let portRange = 6412...6422
|
||||||
static let maxRequestBytes = 128 * 1024
|
static let maxRequestBytes = 128 * 1024
|
||||||
static let maxURLCount = 200
|
static let maxURLCount = 200
|
||||||
|
static let extensionRequestHeader = "x-firelink-extension"
|
||||||
|
static let extensionRequestToken = "firelink-extension-v1"
|
||||||
static let allowedSchemes = Set(["http", "https", "ftp", "sftp"])
|
static let allowedSchemes = Set(["http", "https", "ftp", "sftp"])
|
||||||
}
|
}
|
||||||
|
|
||||||
private let listener: NWListener
|
private let listener: NWListener
|
||||||
private let downloadController: DownloadController
|
private let downloadController: DownloadController
|
||||||
private let queue = DispatchQueue(label: "local.firelink.server")
|
private let queue = DispatchQueue(label: "local.firelink.server")
|
||||||
|
let port: UInt16
|
||||||
|
|
||||||
init?(downloadController: DownloadController) {
|
init?(downloadController: DownloadController) {
|
||||||
self.downloadController = downloadController
|
self.downloadController = downloadController
|
||||||
let parameters = NWParameters.tcp
|
let parameters = NWParameters.tcp
|
||||||
|
|
||||||
var createdListener: NWListener?
|
var createdListener: NWListener?
|
||||||
for portValue in 6412...6422 {
|
var selectedPort: UInt16?
|
||||||
|
for portValue in Constants.portRange {
|
||||||
parameters.requiredLocalEndpoint = .hostPort(host: .ipv4(.loopback), port: NWEndpoint.Port(rawValue: UInt16(portValue))!)
|
parameters.requiredLocalEndpoint = .hostPort(host: .ipv4(.loopback), port: NWEndpoint.Port(rawValue: UInt16(portValue))!)
|
||||||
do {
|
do {
|
||||||
createdListener = try NWListener(using: parameters)
|
createdListener = try NWListener(using: parameters)
|
||||||
|
selectedPort = UInt16(portValue)
|
||||||
break
|
break
|
||||||
} catch {
|
} catch {
|
||||||
continue
|
continue
|
||||||
@@ -35,6 +40,7 @@ final class LocalExtensionServer: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.listener = createdListener
|
self.listener = createdListener
|
||||||
|
self.port = selectedPort ?? 6412
|
||||||
}
|
}
|
||||||
|
|
||||||
func start() {
|
func start() {
|
||||||
@@ -95,7 +101,7 @@ final class LocalExtensionServer: @unchecked Sendable {
|
|||||||
headers.append("Access-Control-Allow-Origin: \(origin)")
|
headers.append("Access-Control-Allow-Origin: \(origin)")
|
||||||
headers.append("Vary: Origin")
|
headers.append("Vary: Origin")
|
||||||
headers.append("Access-Control-Allow-Methods: POST, OPTIONS")
|
headers.append("Access-Control-Allow-Methods: POST, OPTIONS")
|
||||||
headers.append("Access-Control-Allow-Headers: Content-Type")
|
headers.append("Access-Control-Allow-Headers: Content-Type, X-Firelink-Extension")
|
||||||
}
|
}
|
||||||
|
|
||||||
let response = headers.joined(separator: "\r\n") + "\r\n\r\n"
|
let response = headers.joined(separator: "\r\n") + "\r\n\r\n"
|
||||||
@@ -132,6 +138,10 @@ final class LocalExtensionServer: @unchecked Sendable {
|
|||||||
return .methodNotAllowed
|
return .methodNotAllowed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
guard request.header(named: Constants.extensionRequestHeader) == Constants.extensionRequestToken else {
|
||||||
|
return .forbidden
|
||||||
|
}
|
||||||
|
|
||||||
guard request.header(named: "content-type")?.lowercased().contains("application/json") == true else {
|
guard request.header(named: "content-type")?.lowercased().contains("application/json") == true else {
|
||||||
return .unsupportedMediaType
|
return .unsupportedMediaType
|
||||||
}
|
}
|
||||||
@@ -161,6 +171,7 @@ final class LocalExtensionServer: @unchecked Sendable {
|
|||||||
|
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
self.downloadController.pendingPasteboardText = validURLs.joined(separator: "\n")
|
self.downloadController.pendingPasteboardText = validURLs.joined(separator: "\n")
|
||||||
|
self.downloadController.pendingReferer = payload.referer
|
||||||
NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil)
|
NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil)
|
||||||
NSApp.activate(ignoringOtherApps: true)
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ struct SchedulerSettings: Codable, Equatable {
|
|||||||
var isEveryday: Bool = true
|
var isEveryday: Bool = true
|
||||||
var selectedDays: Set<SchedulerDay> = Set(SchedulerDay.allCases)
|
var selectedDays: Set<SchedulerDay> = Set(SchedulerDay.allCases)
|
||||||
var postQueueAction: PostQueueAction = .doNothing
|
var postQueueAction: PostQueueAction = .doNothing
|
||||||
var targetQueueIDs: Set<UUID> = []
|
var targetQueueIDs: Set<UUID> = [DownloadQueue.mainQueueID]
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -62,6 +62,9 @@ final class SchedulerController: ObservableObject {
|
|||||||
} else {
|
} else {
|
||||||
self.settings = SchedulerSettings()
|
self.settings = SchedulerSettings()
|
||||||
}
|
}
|
||||||
|
if self.settings.targetQueueIDs.isEmpty {
|
||||||
|
self.settings.targetQueueIDs = [DownloadQueue.mainQueueID]
|
||||||
|
}
|
||||||
|
|
||||||
checkAutomationPermission()
|
checkAutomationPermission()
|
||||||
startTimer()
|
startTimer()
|
||||||
@@ -131,7 +134,8 @@ final class SchedulerController: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func triggerQueues() {
|
private func triggerQueues() {
|
||||||
let runnableQueueIDs = settings.targetQueueIDs.filter { queueID in
|
let targetQueueIDs = effectiveTargetQueueIDs()
|
||||||
|
let runnableQueueIDs = targetQueueIDs.filter { queueID in
|
||||||
downloadController.queues.contains(where: { $0.id == queueID }) &&
|
downloadController.queues.contains(where: { $0.id == queueID }) &&
|
||||||
downloadController.queueItems(for: queueID).contains(where: { $0.status == .queued })
|
downloadController.queueItems(for: queueID).contains(where: { $0.status == .queued })
|
||||||
}
|
}
|
||||||
@@ -150,7 +154,8 @@ final class SchedulerController: ObservableObject {
|
|||||||
private func checkIfRunningFinished() {
|
private func checkIfRunningFinished() {
|
||||||
guard isRunning else { return }
|
guard isRunning else { return }
|
||||||
|
|
||||||
let hasActiveItems = settings.targetQueueIDs.contains { queueID in
|
let targetQueueIDs = effectiveTargetQueueIDs()
|
||||||
|
let hasActiveItems = targetQueueIDs.contains { queueID in
|
||||||
downloadController.queueItems(for: queueID).contains {
|
downloadController.queueItems(for: queueID).contains {
|
||||||
$0.status == .queued || $0.status == .downloading
|
$0.status == .queued || $0.status == .downloading
|
||||||
}
|
}
|
||||||
@@ -162,6 +167,10 @@ final class SchedulerController: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func effectiveTargetQueueIDs() -> Set<UUID> {
|
||||||
|
settings.targetQueueIDs.isEmpty ? [DownloadQueue.mainQueueID] : settings.targetQueueIDs
|
||||||
|
}
|
||||||
|
|
||||||
private func performPostAction() {
|
private func performPostAction() {
|
||||||
guard settings.postQueueAction != .doNothing else { return }
|
guard settings.postQueueAction != .doNothing else { return }
|
||||||
|
|
||||||
|
|||||||
@@ -199,7 +199,9 @@ struct SchedulerView: View {
|
|||||||
isEveryday = schedulerController.settings.isEveryday
|
isEveryday = schedulerController.settings.isEveryday
|
||||||
selectedDays = schedulerController.settings.selectedDays
|
selectedDays = schedulerController.settings.selectedDays
|
||||||
postQueueAction = schedulerController.settings.postQueueAction
|
postQueueAction = schedulerController.settings.postQueueAction
|
||||||
targetQueueIDs = schedulerController.settings.targetQueueIDs
|
targetQueueIDs = schedulerController.settings.targetQueueIDs.isEmpty
|
||||||
|
? [DownloadQueue.mainQueueID]
|
||||||
|
: schedulerController.settings.targetQueueIDs
|
||||||
}
|
}
|
||||||
|
|
||||||
private func saveState() {
|
private func saveState() {
|
||||||
@@ -208,7 +210,9 @@ struct SchedulerView: View {
|
|||||||
schedulerController.settings.isEveryday = isEveryday
|
schedulerController.settings.isEveryday = isEveryday
|
||||||
schedulerController.settings.selectedDays = selectedDays
|
schedulerController.settings.selectedDays = selectedDays
|
||||||
schedulerController.settings.postQueueAction = postQueueAction
|
schedulerController.settings.postQueueAction = postQueueAction
|
||||||
schedulerController.settings.targetQueueIDs = targetQueueIDs
|
schedulerController.settings.targetQueueIDs = targetQueueIDs.isEmpty
|
||||||
|
? [DownloadQueue.mainQueueID]
|
||||||
|
: targetQueueIDs
|
||||||
schedulerController.saveSettings()
|
schedulerController.saveSettings()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -666,7 +666,7 @@ private struct IntegrationSettingsPane: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func copyExtensionToDownloads() {
|
private func copyExtensionToDownloads() {
|
||||||
guard let sourceURL = Bundle.main.url(forResource: "FirefoxExtension", withExtension: nil) else {
|
guard let sourceURL = bundledFirefoxExtensionURL() else {
|
||||||
installMessage = "The bundled Firefox extension folder was not found."
|
installMessage = "The bundled Firefox extension folder was not found."
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -677,7 +677,7 @@ private struct IntegrationSettingsPane: View {
|
|||||||
try FileManager.default.removeItem(at: destinationURL)
|
try FileManager.default.removeItem(at: destinationURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
try FileManager.default.copyItem(at: sourceURL, to: destinationURL)
|
try copyFirefoxExtension(from: sourceURL, to: destinationURL)
|
||||||
copiedExtensionURL = destinationURL
|
copiedExtensionURL = destinationURL
|
||||||
installMessage = "Copied to \(destinationURL.path). Select manifest.json from this folder in Firefox."
|
installMessage = "Copied to \(destinationURL.path). Select manifest.json from this folder in Firefox."
|
||||||
showCopiedExtensionInFinder()
|
showCopiedExtensionInFinder()
|
||||||
@@ -686,6 +686,34 @@ private struct IntegrationSettingsPane: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
private func showCopiedExtensionInFinder() {
|
||||||
let folderURL = copiedExtensionURL ?? downloadsExtensionURL
|
let folderURL = copiedExtensionURL ?? downloadsExtensionURL
|
||||||
let manifestURL = folderURL.appendingPathComponent("manifest.json")
|
let manifestURL = folderURL.appendingPathComponent("manifest.json")
|
||||||
|
|||||||
Reference in New Issue
Block a user