fix: harden media download flow

This commit is contained in:
nimbold
2026-06-08 01:06:00 +03:30
parent 2e5b4ae7c3
commit 4e48f1e42c
36 changed files with 576 additions and 530 deletions
+6 -4
View File
@@ -4,8 +4,10 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
APP_NAME="Firelink" APP_NAME="Firelink"
CONFIGURATION="${CONFIGURATION:-release}" CONFIGURATION="${CONFIGURATION:-release}"
MARKETING_VERSION="${MARKETING_VERSION:-0.1.0}" DEFAULT_MARKETING_VERSION="$(git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//' || true)"
BUILD_NUMBER="${BUILD_NUMBER:-1}" DEFAULT_BUILD_NUMBER="$(git rev-list --count HEAD 2>/dev/null || true)"
MARKETING_VERSION="${MARKETING_VERSION:-${DEFAULT_MARKETING_VERSION:-0.1.0}}"
BUILD_NUMBER="${BUILD_NUMBER:-${DEFAULT_BUILD_NUMBER:-1}}"
APP_DIR="$ROOT_DIR/build/$APP_NAME.app" APP_DIR="$ROOT_DIR/build/$APP_NAME.app"
CONTENTS_DIR="$APP_DIR/Contents" CONTENTS_DIR="$APP_DIR/Contents"
MACOS_DIR="$CONTENTS_DIR/MacOS" MACOS_DIR="$CONTENTS_DIR/MacOS"
@@ -35,12 +37,12 @@ ARIA2C_PATH=$(which aria2c || true)
if [[ -n "$ARIA2C_PATH" && -x "$ARIA2C_PATH" ]]; then if [[ -n "$ARIA2C_PATH" && -x "$ARIA2C_PATH" ]]; then
echo "Bundling aria2c from $ARIA2C_PATH..." echo "Bundling aria2c from $ARIA2C_PATH..."
cp "$ARIA2C_PATH" "$RESOURCES_DIR/aria2c" cp "$ARIA2C_PATH" "$RESOURCES_DIR/aria2c"
if ! command -v dylibbundler &> /dev/null; then if ! command -v dylibbundler &> /dev/null; then
echo "Installing dylibbundler..." echo "Installing dylibbundler..."
brew install dylibbundler brew install dylibbundler
fi fi
FRAMEWORKS_DIR="$CONTENTS_DIR/Frameworks" FRAMEWORKS_DIR="$CONTENTS_DIR/Frameworks"
mkdir -p "$FRAMEWORKS_DIR" mkdir -p "$FRAMEWORKS_DIR"
dylibbundler -od -b -x "$RESOURCES_DIR/aria2c" -d "$FRAMEWORKS_DIR" -p "@executable_path/../Frameworks/" dylibbundler -od -b -x "$RESOURCES_DIR/aria2c" -d "$FRAMEWORKS_DIR" -p "@executable_path/../Frameworks/"
-23
View File
@@ -1,23 +0,0 @@
import re
with open("Sources/Firelink/DownloadTable.swift", "r") as f:
content = f.read()
# Remove doubleClickableCell block
content = re.sub(r' private func doubleClickableCell.*? }\n\n', '', content, flags=re.DOTALL)
# Remove doubleClickableCell wrappers from TableColumn
content = re.sub(r'doubleClickableCell\(for: item\) \{\n\s*(.*?)\n\s*\}', r'\1', content, flags=re.DOTALL)
# Add simultaneousGesture to Table
table_end = content.find(' .environment(\\.defaultMinListRowHeight, settings.listRowDensity.minRowHeight)')
gesture = ''' .simultaneousGesture(TapGesture(count: 2).onEnded {
if let id = selection.first, let item = controller.downloads.first(where: { $0.id == id }) {
performPrimaryAction(for: item)
}
})
'''
content = content[:table_end] + gesture + content[table_end:]
with open("Sources/Firelink/DownloadTable.swift", "w") as f:
f.write(content)
-62
View File
@@ -1,62 +0,0 @@
import re
with open("Sources/Firelink/DownloadTable.swift", "r") as f:
content = f.read()
# Fix size to use bytesText if sizeBytes is nil or 0
old_size = ''' TableColumn("Size", value: \\.sortableSize) { item in
Text(ByteFormatter.string(item.sizeBytes))
.monospacedDigit()
.lineLimit(1)
.truncationMode(.tail)
}'''
new_size = ''' TableColumn("Size", value: \\.sortableSize) { item in
if let size = item.sizeBytes, size > 0 {
Text(ByteFormatter.string(size))
.monospacedDigit()
.lineLimit(1)
.truncationMode(.tail)
} else if item.bytesText != "-" && !item.bytesText.isEmpty {
Text(item.bytesText)
.monospacedDigit()
.lineLimit(1)
.truncationMode(.tail)
} else {
Text("Unknown")
.monospacedDigit()
.lineLimit(1)
.truncationMode(.tail)
}
}'''
content = content.replace(old_size, new_size)
# Add allowsHitTesting(false) to text to fix single click interception
old_text = ''' Text(item.fileName)
.font(.headline)
.lineLimit(1)
.truncationMode(.tail)'''
new_text = ''' Text(item.fileName)
.font(.headline)
.lineLimit(1)
.truncationMode(.tail)
.allowsHitTesting(false)'''
content = content.replace(old_text, new_text)
# Change performPrimaryAction to use id
old_action = 'openWindow(value: item.id)'
new_action = 'openWindow(id: "download-properties", value: item.id)'
content = content.replace(old_action, new_action)
with open("Sources/Firelink/DownloadTable.swift", "w") as f:
f.write(content)
with open("Sources/Firelink/FirelinkApp.swift", "r") as f:
app_content = f.read()
app_content = app_content.replace(
'WindowGroup("Download Properties", for: UUID.self)',
'WindowGroup("Download Properties", id: "download-properties", for: UUID.self)'
)
with open("Sources/Firelink/FirelinkApp.swift", "w") as f:
f.write(app_content)
-86
View File
@@ -1,86 +0,0 @@
with open("Sources/Firelink/FirelinkApp.swift", "r") as f:
app_content = f.read()
app_content = app_content.replace(
'WindowGroup("Download Properties", id: "download-properties", for: String.self) { $downloadIDString in\n if let idString = downloadIDString, let downloadID = UUID(uuidString: idString) {',
'WindowGroup("Download Properties", id: "download-properties", for: UUID.self) { $downloadID in\n if let downloadID {'
)
with open("Sources/Firelink/FirelinkApp.swift", "w") as f:
f.write(app_content)
with open("Sources/Firelink/DownloadTable.swift", "r") as f:
table_content = f.read()
# Fix openWindow in performPrimaryAction
table_content = table_content.replace(
'openWindow(id: "download-properties", value: item.id.uuidString)',
'openWindow(id: "download-properties", value: item.id)'
)
# Fix openWindow in rowContextMenu
table_content = table_content.replace(
'openWindow(value: target.id)',
'openWindow(id: "download-properties", value: target.id)'
)
# Remove simultaneousGesture from Table
import re
table_content = re.sub(
r'\s*\.simultaneousGesture\(TapGesture\(count: 2\)\.onEnded \{\s*if let id = selection\.first, let item = controller\.downloads\.first\(where: \{ \$0\.id == id \}\) \{\s*performPrimaryAction\(for: item\)\s*\}\s*\}\)',
'',
table_content
)
# Replace the File Name TableColumn to use doubleClickableCell
old_column = ''' TableColumn("File Name", value: \\.fileName) { item in
HStack(alignment: .top, spacing: 8) {
Image(systemName: item.category.symbolName)
.font(.title3)
.foregroundStyle(categoryColor(for: item.category))
.frame(width: 22)
Text(item.fileName)
.font(.headline)
.lineLimit(1)
.truncationMode(.tail)
.allowsHitTesting(false)
.draggable(item.id.uuidString)
}
}'''
new_column = ''' TableColumn("File Name", value: \\.fileName) { item in
doubleClickableCell(for: item) {
HStack(alignment: .top, spacing: 8) {
Image(systemName: item.category.symbolName)
.font(.title3)
.foregroundStyle(categoryColor(for: item.category))
.frame(width: 22)
Text(item.fileName)
.font(.headline)
.lineLimit(1)
.truncationMode(.tail)
.allowsHitTesting(false)
}
.draggable(item.id.uuidString)
}
}'''
table_content = table_content.replace(old_column, new_column)
# Add doubleClickableCell helper
helper = ''' private func performPrimaryAction(for item: DownloadItem) {'''
helper_new = ''' private func doubleClickableCell<Content: View>(for item: DownloadItem, @ViewBuilder content: () -> Content) -> some View {
content()
.contentShape(Rectangle())
.simultaneousGesture(TapGesture(count: 2).onEnded {
performPrimaryAction(for: item)
})
}
private func performPrimaryAction(for item: DownloadItem) {'''
table_content = table_content.replace(helper, helper_new)
with open("Sources/Firelink/DownloadTable.swift", "w") as f:
f.write(table_content)
-21
View File
@@ -1,21 +0,0 @@
with open("Sources/Firelink/FirelinkApp.swift", "r") as f:
app_content = f.read()
old_group = ''' WindowGroup("Download Properties", id: "download-properties", for: UUID.self) { $downloadID in
if let downloadID {'''
new_group = ''' WindowGroup("Download Properties", id: "download-properties", for: String.self) { $downloadIDString in
if let idString = downloadIDString, let downloadID = UUID(uuidString: idString) {'''
app_content = app_content.replace(old_group, new_group)
with open("Sources/Firelink/FirelinkApp.swift", "w") as f:
f.write(app_content)
with open("Sources/Firelink/DownloadTable.swift", "r") as f:
table_content = f.read()
old_open1 = 'openWindow(id: "download-properties", value: item.id)'
new_open1 = 'openWindow(id: "download-properties", value: item.id.uuidString)'
table_content = table_content.replace(old_open1, new_open1)
with open("Sources/Firelink/DownloadTable.swift", "w") as f:
f.write(table_content)
-45
View File
@@ -1,45 +0,0 @@
with open("Sources/Firelink/DownloadTable.swift", "r") as f:
content = f.read()
old_column = ''' TableColumn("File Name", value: \\.fileName) { item in
doubleClickableCell(for: item) {
HStack(alignment: .top, spacing: 8) {
Image(systemName: item.category.symbolName)
.font(.title3)
.foregroundStyle(categoryColor(for: item.category))
.frame(width: 22)
Text(item.fileName)
.font(.headline)
.lineLimit(1)
.truncationMode(.tail)
.allowsHitTesting(false)
}
.draggable(item.id.uuidString)
}
}'''
new_column = ''' TableColumn("File Name", value: \\.fileName) { item in
HStack(alignment: .top, spacing: 8) {
Image(systemName: item.category.symbolName)
.font(.title3)
.foregroundStyle(categoryColor(for: item.category))
.frame(width: 22)
Text(item.fileName)
.font(.headline)
.lineLimit(1)
.truncationMode(.tail)
.allowsHitTesting(false)
}
.draggable(item.id.uuidString)
}'''
content = content.replace(old_column, new_column)
import re
# Remove doubleClickableCell function block
pattern = r'\s*private func doubleClickableCell<Content: View>\(for item: DownloadItem, @ViewBuilder content: \(\) -> Content\) -> some View \{\s*content\(\)\s*\.contentShape\(Rectangle\(\)\)\s*\.simultaneousGesture\(TapGesture\(count: 2\)\.onEnded \{\s*performPrimaryAction\(for: item\)\s*\}\)\s*\}'
content = re.sub(pattern, '', content)
with open("Sources/Firelink/DownloadTable.swift", "w") as f:
f.write(content)
+1
View File
@@ -5,4 +5,5 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR" cd "$ROOT_DIR"
swift build swift build
git diff --check
python3 -m json.tool Extensions/Firefox/manifest.json >/dev/null python3 -m json.tool Extensions/Firefox/manifest.json >/dev/null
+15 -2
View File
@@ -493,9 +493,16 @@ struct AddDownloadsView: View {
let urls = DownloadURLParser.parse(text) let urls = DownloadURLParser.parse(text)
metadataTask?.cancel() metadataTask?.cancel()
if let first = urls.first, MediaDetector.isSupportedMedia(url: first) { let mediaURL: URL? = {
guard urls.count == 1, let first = urls.first, MediaDetector.isSupportedMedia(url: first) else {
return nil
}
return first
}()
if let mediaURL {
withAnimation(.spring(response: 0.4, dampingFraction: 0.7)) { withAnimation(.spring(response: 0.4, dampingFraction: 0.7)) {
detectedMediaURL = first detectedMediaURL = mediaURL
} }
} else { } else {
withAnimation { withAnimation {
@@ -522,6 +529,12 @@ struct AddDownloadsView: View {
saveLogin = false saveLogin = false
} }
guard mediaURL == nil else {
pendingDownloads = []
metadataTask = nil
return
}
metadataTask = Task { metadataTask = Task {
var loaded: [PendingDownload] = [] var loaded: [PendingDownload] = []
for url in urls { for url in urls {
+3 -3
View File
@@ -116,11 +116,11 @@ final class AppSettings: ObservableObject {
@Published var appTheme: AppTheme = .system { @Published var appTheme: AppTheme = .system {
didSet { save() } didSet { save() }
} }
@Published var appFontSize: AppFontSize = .standard { @Published var appFontSize: AppFontSize = .standard {
didSet { save() } didSet { save() }
} }
@Published var listRowDensity: ListRowDensity = .standard { @Published var listRowDensity: ListRowDensity = .standard {
didSet { save() } didSet { save() }
} }
@@ -342,7 +342,7 @@ final class AppSettings: ObservableObject {
let data = await Task.detached(priority: .background) { let data = await Task.detached(priority: .background) {
try? JSONEncoder().encode(stored) try? JSONEncoder().encode(stored)
}.value }.value
guard !Task.isCancelled, let encoded = data else { return } guard !Task.isCancelled, let encoded = data else { return }
defaults.set(encoded, forKey: storageKey) defaults.set(encoded, forKey: storageKey)
} }
+7 -7
View File
@@ -91,10 +91,10 @@ final class Aria2DownloadEngine {
// Close the write file handle in the parent process immediately // Close the write file handle in the parent process immediately
// This guarantees readToEnd() won't hang waiting for the parent itself // This guarantees readToEnd() won't hang waiting for the parent itself
outputPipe.fileHandleForWriting.closeFile() outputPipe.fileHandleForWriting.closeFile()
let data = try? outputPipe.fileHandleForReading.readToEnd() let data = try? outputPipe.fileHandleForReading.readToEnd()
process.waitUntilExit() process.waitUntilExit()
guard process.terminationStatus == 0, let data = data else { return nil } guard process.terminationStatus == 0, let data = data else { return nil }
let output = String(data: data, encoding: .utf8) ?? "" let output = String(data: data, encoding: .utf8) ?? ""
@@ -230,7 +230,7 @@ final class Aria2DownloadEngine {
rpcPort: Int, rpcPort: Int,
rpcSecret: String, rpcSecret: String,
process: Process, process: Process,
completionGate: CompletionGate completionGate: CompletionGate<Void>
) -> Task<Void, Never> { ) -> Task<Void, Never> {
Task.detached { Task.detached {
while !Task.isCancelled && process.isRunning { while !Task.isCancelled && process.isRunning {
@@ -527,16 +527,16 @@ final class LockedDataBuffer: @unchecked Sendable {
} }
} }
final class CompletionGate: @unchecked Sendable { final class CompletionGate<Success>: @unchecked Sendable {
private let lock = NSLock() private let lock = NSLock()
private var didComplete = false private var didComplete = false
private let completion: @Sendable (Result<Void, Error>) -> Void private let completion: @Sendable (Result<Success, Error>) -> Void
init(_ completion: @escaping @Sendable (Result<Void, Error>) -> Void) { init(_ completion: @escaping @Sendable (Result<Success, Error>) -> Void) {
self.completion = completion self.completion = completion
} }
func complete(_ result: Result<Void, Error>) { func complete(_ result: Result<Success, Error>) {
lock.lock() lock.lock()
let shouldComplete = !didComplete let shouldComplete = !didComplete
if shouldComplete { if shouldComplete {
+3
View File
@@ -9,6 +9,7 @@ enum BinaryDownloaderError: LocalizedError {
case permissionFailed(Error) case permissionFailed(Error)
case unzipFailed case unzipFailed
case unsupportedDownloadURL case unsupportedDownloadURL
case missingChecksum
case checksumMismatch case checksumMismatch
var errorDescription: String? { var errorDescription: String? {
@@ -27,6 +28,8 @@ enum BinaryDownloaderError: LocalizedError {
"Could not extract the downloaded add-on archive." "Could not extract the downloaded add-on archive."
case .unsupportedDownloadURL: case .unsupportedDownloadURL:
"The add-on URL must be HTTP or HTTPS." "The add-on URL must be HTTP or HTTPS."
case .missingChecksum:
"The add-on configuration is missing a SHA-256 checksum."
case .checksumMismatch: case .checksumMismatch:
"The downloaded add-on did not match the expected SHA-256 checksum." "The downloaded add-on did not match the expected SHA-256 checksum."
} }
+16 -16
View File
@@ -2,12 +2,12 @@ import SwiftUI
struct ChunkMapView: View { struct ChunkMapView: View {
let item: DownloadItem let item: DownloadItem
@State private var bitfield: String = "" @State private var bitfield: String = ""
@State private var numPieces: Int = 0 @State private var numPieces: Int = 0
@State private var pollTask: Task<Void, Never>? @State private var pollTask: Task<Void, Never>?
@State private var isVisible = false @State private var isVisible = false
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
if numPieces > 0 { if numPieces > 0 {
@@ -34,11 +34,11 @@ struct ChunkMapView: View {
} }
} }
} }
private func startPolling() { private func startPolling() {
pollTask?.cancel() pollTask?.cancel()
guard let port = item.rpcPort, let secret = item.rpcSecret, item.status == .downloading else { return } guard let port = item.rpcPort, let secret = item.rpcSecret, item.status == .downloading else { return }
pollTask = Task { pollTask = Task {
while !Task.isCancelled { while !Task.isCancelled {
await fetchStatus(port: port, secret: secret) await fetchStatus(port: port, secret: secret)
@@ -50,23 +50,23 @@ struct ChunkMapView: View {
} }
} }
} }
private func fetchStatus(port: Int, secret: String) async { private func fetchStatus(port: Int, secret: String) async {
guard let url = URL(string: "http://127.0.0.1:\(port)/jsonrpc") else { return } guard let url = URL(string: "http://127.0.0.1:\(port)/jsonrpc") else { return }
var request = URLRequest(url: url) var request = URLRequest(url: url)
request.httpMethod = "POST" request.httpMethod = "POST"
request.addValue("application/json", forHTTPHeaderField: "Content-Type") request.addValue("application/json", forHTTPHeaderField: "Content-Type")
let payload: [String: Any] = [ let payload: [String: Any] = [
"jsonrpc": "2.0", "jsonrpc": "2.0",
"method": "aria2.tellActive", "method": "aria2.tellActive",
"id": "1", "id": "1",
"params": ["token:\(secret)", ["bitfield", "numPieces"]] "params": ["token:\(secret)", ["bitfield", "numPieces"]]
] ]
guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return } guard let data = try? JSONSerialization.data(withJSONObject: payload) else { return }
request.httpBody = data request.httpBody = data
do { do {
let (responseData, _) = try await URLSession.shared.data(for: request) let (responseData, _) = try await URLSession.shared.data(for: request)
guard let json = try JSONSerialization.jsonObject(with: responseData) as? [String: Any], guard let json = try JSONSerialization.jsonObject(with: responseData) as? [String: Any],
@@ -74,11 +74,11 @@ struct ChunkMapView: View {
let active = result.first else { let active = result.first else {
return return
} }
let fetchedBitfield = active["bitfield"] as? String ?? "" let fetchedBitfield = active["bitfield"] as? String ?? ""
let fetchedNumPiecesStr = active["numPieces"] as? String ?? "0" let fetchedNumPiecesStr = active["numPieces"] as? String ?? "0"
let fetchedNumPieces = Int(fetchedNumPiecesStr) ?? 0 let fetchedNumPieces = Int(fetchedNumPiecesStr) ?? 0
await MainActor.run { await MainActor.run {
self.bitfield = fetchedBitfield self.bitfield = fetchedBitfield
self.numPieces = fetchedNumPieces self.numPieces = fetchedNumPieces
@@ -92,7 +92,7 @@ struct ChunkMapView: View {
struct ChunkGrid: View { struct ChunkGrid: View {
let bitfield: String let bitfield: String
let numPieces: Int let numPieces: Int
private var pieces: [Bool] { private var pieces: [Bool] {
var result = [Bool]() var result = [Bool]()
result.reserveCapacity(numPieces) result.reserveCapacity(numPieces)
@@ -110,7 +110,7 @@ struct ChunkGrid: View {
} }
return result return result
} }
var body: some View { var body: some View {
let itemPieces = pieces let itemPieces = pieces
Canvas { context, size in Canvas { context, size in
@@ -118,10 +118,10 @@ struct ChunkGrid: View {
let spacing: CGFloat = 2 let spacing: CGFloat = 2
let cornerSize = CGSize(width: 2, height: 2) let cornerSize = CGSize(width: 2, height: 2)
let width = size.width let width = size.width
let x: CGFloat = 0 let x: CGFloat = 0
let y: CGFloat = 0 let y: CGFloat = 0
let completedPath = Path { p in let completedPath = Path { p in
var cx = x var cx = x
var cy = y var cy = y
@@ -136,7 +136,7 @@ struct ChunkGrid: View {
} }
} }
} }
let pendingPath = Path { p in let pendingPath = Path { p in
var cx: CGFloat = 0 var cx: CGFloat = 0
var cy: CGFloat = 0 var cy: CGFloat = 0
@@ -151,7 +151,7 @@ struct ChunkGrid: View {
} }
} }
} }
context.fill(pendingPath, with: .color(Color.primary.opacity(0.08))) context.fill(pendingPath, with: .color(Color.primary.opacity(0.08)))
context.fill(completedPath, with: .color(Color.accentColor)) context.fill(completedPath, with: .color(Color.accentColor))
} }
+17 -1
View File
@@ -154,6 +154,14 @@ struct ContentView: View {
} }
.keyboardShortcut("a", modifiers: .command) .keyboardShortcut("a", modifiers: .command)
.opacity(0) .opacity(0)
Button("") {
if let item = selectedItems.first {
performPrimaryAction(for: item)
}
}
.keyboardShortcut(.return, modifiers: [])
.opacity(0)
} }
.confirmationDialog( .confirmationDialog(
"Delete \(selection.count) Download\(selection.count == 1 ? "" : "s")", "Delete \(selection.count) Download\(selection.count == 1 ? "" : "s")",
@@ -181,7 +189,7 @@ struct ContentView: View {
} }
selection.removeAll() selection.removeAll()
} }
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
@@ -194,6 +202,14 @@ struct ContentView: View {
selection = Set(items.map { $0.id }) selection = Set(items.map { $0.id })
} }
private func performPrimaryAction(for item: DownloadItem) {
if item.status == .completed {
NSWorkspace.shared.open(URL(fileURLWithPath: item.destinationPath))
} else {
openWindow(id: "download-properties", value: item.id)
}
}
private func hasActiveDownloads(in queueID: UUID?) -> Bool { private func hasActiveDownloads(in queueID: UUID?) -> Bool {
if let queueID { if let queueID {
return controller.downloads.contains { $0.status == .downloading && $0.queueID == queueID } return controller.downloads.contains { $0.status == .downloading && $0.queueID == queueID }
+121 -21
View File
@@ -61,7 +61,7 @@ final class DownloadController: ObservableObject {
} }
} }
.store(in: &cancellables) .store(in: &cancellables)
$downloads $downloads
.dropFirst() .dropFirst()
.debounce(for: .seconds(2.0), scheduler: RunLoop.main) .debounce(for: .seconds(2.0), scheduler: RunLoop.main)
@@ -69,7 +69,7 @@ final class DownloadController: ObservableObject {
self?.saveDownloads() self?.saveDownloads()
} }
.store(in: &cancellables) .store(in: &cancellables)
NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification) NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)
.sink { [weak self] _ in .sink { [weak self] _ in
self?.saveDownloads() self?.saveDownloads()
@@ -535,15 +535,17 @@ final class DownloadController: ObservableObject {
$0.message = "Checking media add-ons..." $0.message = "Checking media add-ons..."
} }
try await MediaEngineManager.shared.ensureAvailable(addons: [.ytDlp, .ffmpeg]) try await MediaEngineManager.shared.ensureAvailable(addons: [.ytDlp, .ffmpeg])
guard let liveItem = activeDownloadItem(id: item.id) else { return }
update(item.id) { update(item.id) {
guard $0.status == .downloading else { return } guard $0.status == .downloading else { return }
$0.message = "Starting yt-dlp..." $0.message = "Starting yt-dlp..."
} }
let handle = try await mediaEngine.start( let handle = try await mediaEngine.start(
item: item, item: liveItem,
cookieSource: settings.mediaCookieSource, cookieSource: settings.mediaCookieSource,
proxyConfiguration: settings.downloadProxyConfiguration, proxyConfiguration: settings.downloadProxyConfiguration,
speedLimitKiBPerSecond: effectiveSpeedLimitKiBPerSecond(for: item), speedLimitKiBPerSecond: effectiveSpeedLimitKiBPerSecond(for: liveItem),
progress: { [weak self] progress in progress: { [weak self] progress in
Task { @MainActor in Task { @MainActor in
self?.update(item.id) { self?.update(item.id) {
@@ -569,12 +571,20 @@ final class DownloadController: ObservableObject {
}, },
completion: { [weak self] result in completion: { [weak self] result in
Task { @MainActor in Task { @MainActor in
self?.handleCompletion(item: item, result: result, isMedia: true) self?.handleMediaCompletion(item: item, result: result)
} }
} }
) )
guard activeDownloadItem(id: item.id) != nil else {
handle.cancel()
return
}
activeMediaHandles[item.id] = handle activeMediaHandles[item.id] = handle
saveDownloads()
applySpeedLimitsToActiveDownloads()
updateSleepActivity()
} catch { } catch {
guard shouldHandleStartFailure(for: item.id) else { return }
handleDownloadFailure(itemID: item.id, error: error) handleDownloadFailure(itemID: item.id, error: error)
applySpeedLimitsToActiveDownloads() applySpeedLimitsToActiveDownloads()
updateSleepActivity() updateSleepActivity()
@@ -602,7 +612,7 @@ final class DownloadController: ObservableObject {
}, },
completion: { [weak self] result in completion: { [weak self] result in
Task { @MainActor in Task { @MainActor in
self?.handleCompletion(item: item, result: result, isMedia: false) self?.handleCompletion(item: item, result: result)
} }
} }
) )
@@ -624,12 +634,20 @@ final class DownloadController: ObservableObject {
} }
} }
private func handleCompletion(item: DownloadItem, result: Result<Void, Error>, isMedia: Bool) { private func activeDownloadItem(id: UUID) -> DownloadItem? {
if isMedia { downloads.first { $0.id == id && $0.status == .downloading }
activeMediaHandles[item.id] = nil }
} else {
activeHandles[item.id] = nil private func shouldHandleStartFailure(for id: UUID) -> Bool {
guard let item = downloads.first(where: { $0.id == id }) else {
automaticRetryCounts[id] = nil
return false
} }
return item.status != .paused && item.status != .canceled
}
private func handleCompletion(item: DownloadItem, result: Result<Void, Error>) {
activeHandles[item.id] = nil
switch result { switch result {
case .success: case .success:
@@ -641,7 +659,7 @@ final class DownloadController: ObservableObject {
$0.etaText = "-" $0.etaText = "-"
$0.message = "Saved to \($0.destinationPath)" $0.message = "Saved to \($0.destinationPath)"
$0.autoResumeOnLaunch = false $0.autoResumeOnLaunch = false
if let attr = try? FileManager.default.attributesOfItem(atPath: $0.destinationPath), if let attr = try? FileManager.default.attributesOfItem(atPath: $0.destinationPath),
let size = attr[.size] as? Int64 { let size = attr[.size] as? Int64 {
$0.sizeBytes = size $0.sizeBytes = size
@@ -663,6 +681,45 @@ final class DownloadController: ObservableObject {
self.updateSleepActivity() self.updateSleepActivity()
} }
private func handleMediaCompletion(item: DownloadItem, result: Result<URL, Error>) {
activeMediaHandles[item.id] = nil
switch result {
case .success(let outputURL):
self.automaticRetryCounts[item.id] = nil
self.update(item.id) {
$0.status = .completed
$0.progress = 1
$0.speedText = "-"
$0.etaText = "-"
$0.connectionCount = 0
$0.destinationDirectory = outputURL.deletingLastPathComponent()
$0.fileName = outputURL.lastPathComponent
$0.category = FileClassifier.category(forFileName: $0.fileName)
$0.message = "Saved to \(outputURL.path)"
$0.autoResumeOnLaunch = false
if let attr = try? FileManager.default.attributesOfItem(atPath: outputURL.path),
let size = attr[.size] as? Int64 {
$0.sizeBytes = size
$0.bytesText = ByteFormatter.string(size)
}
}
self.saveDownloads()
self.showNotification(title: "Download Completed", body: outputURL.lastPathComponent)
case .failure(let error):
if self.downloads.first(where: { $0.id == item.id })?.status == .paused ||
self.downloads.first(where: { $0.id == item.id })?.status == .canceled {
return
}
self.handleDownloadFailure(itemID: item.id, error: error)
}
self.pumpQueue()
self.applySpeedLimitsToActiveDownloads()
self.updateSleepActivity()
}
private func update(_ id: UUID, mutate: (inout DownloadItem) -> Void) { private func update(_ id: UUID, mutate: (inout DownloadItem) -> Void) {
guard let index = downloads.firstIndex(where: { $0.id == id }) else { return } guard let index = downloads.firstIndex(where: { $0.id == id }) else { return }
mutate(&downloads[index]) mutate(&downloads[index])
@@ -747,6 +804,14 @@ final class DownloadController: ObservableObject {
} }
private func handleDownloadFailure(itemID: UUID, error: Error) { private func handleDownloadFailure(itemID: UUID, error: Error) {
guard let currentItem = downloads.first(where: { $0.id == itemID }) else {
automaticRetryCounts[itemID] = nil
return
}
guard currentItem.status != .paused, currentItem.status != .canceled else {
return
}
let retryCount = automaticRetryCounts[itemID] ?? 0 let retryCount = automaticRetryCounts[itemID] ?? 0
guard isAutomaticallyRecoverable(error), retryCount < maxAutomaticRetries else { guard isAutomaticallyRecoverable(error), retryCount < maxAutomaticRetries else {
@@ -779,16 +844,51 @@ final class DownloadController: ObservableObject {
} }
private func isAutomaticallyRecoverable(_ error: Error) -> Bool { private func isAutomaticallyRecoverable(_ error: Error) -> Bool {
guard let engineError = error as? Aria2DownloadEngine.EngineError else { if let engineError = error as? Aria2DownloadEngine.EngineError {
return true switch engineError {
case .executableNotFound, .unsupportedProxy:
return false
case .launchFailed:
return true
}
} }
switch engineError { if let mediaError = error as? MediaDownloadEngine.EngineError {
case .executableNotFound, .unsupportedProxy: switch mediaError {
return false case .missingEngine:
case .launchFailed: return false
return true case .launchFailed(let message):
return isRecoverableMediaFailure(message)
}
} }
return true
}
private func isRecoverableMediaFailure(_ message: String) -> Bool {
let lowercased = message.lowercased()
let permanentMarkers = [
"requires browser cookies",
"choose a browser",
"challenge solving failed",
"install deno or node",
"requested format is not available",
"unsupported url",
"private video",
"sign in",
"not a bot",
"video unavailable",
"no video formats found",
"no audio formats found",
"ffmpeg is not installed",
"yt-dlp is not installed"
]
if permanentMarkers.contains(where: { lowercased.contains($0) }) {
return false
}
return true
} }
private func isAllowedToStart(_ item: DownloadItem) -> Bool { private func isAllowedToStart(_ item: DownloadItem) -> Bool {
@@ -897,7 +997,7 @@ final class DownloadController: ObservableObject {
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
let state = StoredDownloadState(queues: queuesCopy, downloads: downloadsCopy) let state = StoredDownloadState(queues: queuesCopy, downloads: downloadsCopy)
let data = try JSONEncoder().encode(state) let data = try JSONEncoder().encode(state)
guard !Task.isCancelled else { return } guard !Task.isCancelled else { return }
try data.write(to: storageURL, options: .atomic) try data.write(to: storageURL, options: .atomic)
} catch { } catch {
@@ -937,7 +1037,7 @@ final class DownloadController: ObservableObject {
if isLegacyDownloadList, item.queueID == nil { if isLegacyDownloadList, item.queueID == nil {
adjusted.queueID = DownloadQueue.mainQueueID adjusted.queueID = DownloadQueue.mainQueueID
} }
if adjusted.credentials != nil, let storedPassword = KeychainCredentialStore.password(for: adjusted.id) { if adjusted.credentials != nil, let storedPassword = KeychainCredentialStore.password(for: adjusted.id) {
adjusted.credentials?.password = storedPassword adjusted.credentials?.password = storedPassword
} }
+2 -10
View File
@@ -156,14 +156,6 @@ struct DownloadTable: View {
} }
} }
private func performPrimaryAction(for item: DownloadItem) {
if item.status == .completed {
openFile(item)
} else {
openWindow(id: "download-properties", value: item.id)
}
}
@ViewBuilder @ViewBuilder
private func statusCell(for item: DownloadItem) -> some View { private func statusCell(for item: DownloadItem) -> some View {
let message = item.message.trimmingCharacters(in: .whitespacesAndNewlines) let message = item.message.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -311,12 +303,12 @@ struct DownloadTable: View {
ZStack { ZStack {
RoundedRectangle(cornerRadius: 4) RoundedRectangle(cornerRadius: 4)
.fill(Color.secondary.opacity(0.15)) .fill(Color.secondary.opacity(0.15))
RoundedRectangle(cornerRadius: 4) RoundedRectangle(cornerRadius: 4)
.fill(statusColor(for: item.status)) .fill(statusColor(for: item.status))
.frame(width: max(0, proxy.size.width * item.progress)) .frame(width: max(0, proxy.size.width * item.progress))
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
Text(item.progress.formatted(.percent.precision(.fractionLength(0)))) Text(item.progress.formatted(.percent.precision(.fractionLength(0))))
.font(.system(size: 11, weight: .medium, design: .monospaced)) .font(.system(size: 11, weight: .medium, design: .monospaced))
.foregroundColor(.primary) .foregroundColor(.primary)
+7 -7
View File
@@ -4,7 +4,7 @@ import Sparkle
final class SparkleUpdater: NSObject, ObservableObject, SPUUpdaterDelegate { final class SparkleUpdater: NSObject, ObservableObject, SPUUpdaterDelegate {
private var _controller: SPUStandardUpdaterController? private var _controller: SPUStandardUpdaterController?
var controller: SPUStandardUpdaterController { _controller! } var controller: SPUStandardUpdaterController { _controller! }
@Published var isChecking = false @Published var isChecking = false
@Published var updateStatus: String? @Published var updateStatus: String?
@Published var foundUpdateItem: SUAppcastItem? @Published var foundUpdateItem: SUAppcastItem?
@@ -13,7 +13,7 @@ final class SparkleUpdater: NSObject, ObservableObject, SPUUpdaterDelegate {
super.init() super.init()
self._controller = SPUStandardUpdaterController(startingUpdater: true, updaterDelegate: self, userDriverDelegate: nil) self._controller = SPUStandardUpdaterController(startingUpdater: true, updaterDelegate: self, userDriverDelegate: nil)
} }
func checkForUpdates() { func checkForUpdates() {
guard controller.updater.canCheckForUpdates else { guard controller.updater.canCheckForUpdates else {
isChecking = false isChecking = false
@@ -41,7 +41,7 @@ final class SparkleUpdater: NSObject, ObservableObject, SPUUpdaterDelegate {
self.updateStatus = "You're up to date!" self.updateStatus = "You're up to date!"
} }
} }
func updater(_ updater: SPUUpdater, didAbortWithError error: Error) { func updater(_ updater: SPUUpdater, didAbortWithError error: Error) {
DispatchQueue.main.async { DispatchQueue.main.async {
let nsError = error as NSError let nsError = error as NSError
@@ -49,7 +49,7 @@ final class SparkleUpdater: NSObject, ObservableObject, SPUUpdaterDelegate {
// SUNoUpdateError (1001), handled by updaterDidNotFindUpdate // SUNoUpdateError (1001), handled by updaterDidNotFindUpdate
return return
} }
self.isChecking = false self.isChecking = false
self.updateStatus = "Update check failed: \(error.localizedDescription)" self.updateStatus = "Update check failed: \(error.localizedDescription)"
} }
@@ -70,7 +70,7 @@ struct FirelinkApp: App {
@StateObject private var controller: DownloadController @StateObject private var controller: DownloadController
@StateObject private var schedulerController: SchedulerController @StateObject private var schedulerController: SchedulerController
@AppStorage("showMenuBarIcon") private var showMenuBarIcon = true @AppStorage("showMenuBarIcon") private var showMenuBarIcon = true
// Server must be retained to keep listening // Server must be retained to keep listening
private let extensionServer: LocalExtensionServer? private let extensionServer: LocalExtensionServer?
@@ -82,7 +82,7 @@ struct FirelinkApp: App {
_settings = StateObject(wrappedValue: settings) _settings = StateObject(wrappedValue: settings)
_controller = StateObject(wrappedValue: controller) _controller = StateObject(wrappedValue: controller)
_schedulerController = StateObject(wrappedValue: SchedulerController(downloadController: controller)) _schedulerController = StateObject(wrappedValue: SchedulerController(downloadController: controller))
extensionServer = LocalExtensionServer(downloadController: controller) extensionServer = LocalExtensionServer(downloadController: controller)
extensionServer?.start() extensionServer?.start()
controller.extensionServerPort = extensionServer?.port controller.extensionServerPort = extensionServer?.port
@@ -136,7 +136,7 @@ struct FirelinkApp: App {
NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil) NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil)
} }
.keyboardShortcut("n", modifiers: [.command]) .keyboardShortcut("n", modifiers: [.command])
Divider() Divider()
Button("Start Queue") { Button("Start Queue") {
+3 -3
View File
@@ -6,7 +6,7 @@ struct AddonConfig: Codable, Equatable, Sendable {
let macX64: URL? let macX64: URL?
let macArm64SHA256: String? let macArm64SHA256: String?
let macX64SHA256: String? let macX64SHA256: String?
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case version case version
case macArm64 = "mac-arm64" case macArm64 = "mac-arm64"
@@ -14,7 +14,7 @@ struct AddonConfig: Codable, Equatable, Sendable {
case macArm64SHA256 = "mac-arm64-sha256" case macArm64SHA256 = "mac-arm64-sha256"
case macX64SHA256 = "mac-x64-sha256" case macX64SHA256 = "mac-x64-sha256"
} }
/// Returns the appropriate download URL for the current system architecture /// Returns the appropriate download URL for the current system architecture
var currentArchURL: URL? { var currentArchURL: URL? {
#if arch(arm64) #if arch(arm64)
@@ -40,7 +40,7 @@ struct AddonConfig: Codable, Equatable, Sendable {
struct GatekeeperConfig: Codable, Equatable, Sendable { struct GatekeeperConfig: Codable, Equatable, Sendable {
let ytDlp: AddonConfig? let ytDlp: AddonConfig?
let ffmpeg: AddonConfig? let ffmpeg: AddonConfig?
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case ytDlp = "yt-dlp" case ytDlp = "yt-dlp"
case ffmpeg case ffmpeg
+3 -3
View File
@@ -20,7 +20,7 @@ final class LocalExtensionServer: @unchecked Sendable {
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?
var selectedPort: UInt16? var selectedPort: UInt16?
for portValue in Constants.portRange { for portValue in Constants.portRange {
@@ -33,12 +33,12 @@ final class LocalExtensionServer: @unchecked Sendable {
continue continue
} }
} }
guard let createdListener else { guard let createdListener else {
print("Failed to create listener on ports 6412-6422") print("Failed to create listener on ports 6412-6422")
return nil return nil
} }
self.listener = createdListener self.listener = createdListener
self.port = selectedPort ?? 6412 self.port = selectedPort ?? 6412
} }
+3 -3
View File
@@ -12,13 +12,13 @@ enum MediaDetector {
"reddit.com", "v.redd.it", "reddit.com", "v.redd.it",
"soundcloud.com" "soundcloud.com"
] ]
static func isSupportedMedia(url: URL) -> Bool { static func isSupportedMedia(url: URL) -> Bool {
guard let host = url.host?.lowercased() else { return false } guard let host = url.host?.lowercased() else { return false }
for domain in supportedDomains { for domain in supportedDomains {
if host == domain || host.hasSuffix(".\(domain)") { if host == domain || host.hasSuffix(".\(domain)") {
// Ignore raw files that happen to be hosted on these domains, if any, // Ignore raw files that happen to be hosted on these domains, if any,
// though usually these domains serve web pages for media. // though usually these domains serve web pages for media.
return true return true
} }
+108 -20
View File
@@ -4,11 +4,11 @@ final class MediaDownloadEngine: @unchecked Sendable {
struct Handle { struct Handle {
let cancel: @Sendable () -> Void let cancel: @Sendable () -> Void
} }
enum EngineError: LocalizedError { enum EngineError: LocalizedError {
case missingEngine(String) case missingEngine(String)
case launchFailed(String) case launchFailed(String)
var errorDescription: String? { var errorDescription: String? {
switch self { switch self {
case .missingEngine(let msg): return msg case .missingEngine(let msg): return msg
@@ -16,7 +16,7 @@ final class MediaDownloadEngine: @unchecked Sendable {
} }
} }
} }
func start( func start(
item: DownloadItem, item: DownloadItem,
cookieSource: BrowserCookieSource, cookieSource: BrowserCookieSource,
@@ -24,34 +24,34 @@ final class MediaDownloadEngine: @unchecked Sendable {
speedLimitKiBPerSecond: Int?, speedLimitKiBPerSecond: Int?,
progress: @escaping @Sendable (DownloadProgress) -> Void, progress: @escaping @Sendable (DownloadProgress) -> Void,
messageUpdate: @escaping @Sendable (String) -> Void, messageUpdate: @escaping @Sendable (String) -> Void,
completion: @escaping @Sendable (Result<Void, Error>) -> Void completion: @escaping @Sendable (Result<URL, Error>) -> Void
) async throws -> Handle { ) async throws -> Handle {
let ytDlpURL = await MediaEngineManager.shared.binaryPath(for: .ytDlp) let ytDlpURL = await MediaEngineManager.shared.binaryPath(for: .ytDlp)
let ffmpegURL = await MediaEngineManager.shared.binaryPath(for: .ffmpeg) let ffmpegURL = await MediaEngineManager.shared.binaryPath(for: .ffmpeg)
guard FileManager.default.isExecutableFile(atPath: ytDlpURL.path) else { guard FileManager.default.isExecutableFile(atPath: ytDlpURL.path) else {
throw EngineError.missingEngine("yt-dlp is not installed. Please check Settings > Add-ons.") throw EngineError.missingEngine("yt-dlp is not installed. Please check Settings > Add-ons.")
} }
guard FileManager.default.isExecutableFile(atPath: ffmpegURL.path) else { guard FileManager.default.isExecutableFile(atPath: ffmpegURL.path) else {
throw EngineError.missingEngine("ffmpeg is not installed. Please check Settings > Add-ons.") throw EngineError.missingEngine("ffmpeg is not installed. Please check Settings > Add-ons.")
} }
try FileManager.default.createDirectory(at: item.destinationDirectory, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: item.destinationDirectory, withIntermediateDirectories: true)
let process = Process() let process = Process()
process.executableURL = ytDlpURL process.executableURL = ytDlpURL
var arguments = [ var arguments = [
"--newline", "--newline",
"--ffmpeg-location", ffmpegURL.path, "--ffmpeg-location", ffmpegURL.path,
"--extractor-args", "youtube:player_client=ios,tv", "--extractor-args", "youtube:player_client=ios,tv",
"-o", item.destinationPath "-o", item.destinationPath
] ]
if let format = item.mediaFormatSelector { if let format = item.mediaFormatSelector {
arguments.append("-f") arguments.append("-f")
arguments.append(format) arguments.append(format)
if item.isAudioOnlyMedia == true { if item.isAudioOnlyMedia == true {
let audioFormat = item.fileName.fileExtension(defaultValue: "mp3") let audioFormat = item.fileName.fileExtension(defaultValue: "mp3")
arguments.append(contentsOf: ["-x", "--audio-format", audioFormat, "--audio-quality", "0"]) arguments.append(contentsOf: ["-x", "--audio-format", audioFormat, "--audio-quality", "0"])
@@ -80,7 +80,7 @@ final class MediaDownloadEngine: @unchecked Sendable {
arguments.append(item.url.absoluteString) arguments.append(item.url.absoluteString)
process.arguments = arguments process.arguments = arguments
let outputPipe = Pipe() let outputPipe = Pipe()
let errorPipe = Pipe() let errorPipe = Pipe()
process.standardOutput = outputPipe process.standardOutput = outputPipe
@@ -88,9 +88,11 @@ final class MediaDownloadEngine: @unchecked Sendable {
let parser = YTDLPProgressParser() let parser = YTDLPProgressParser()
let errorBuffer = LockedDataBuffer() let errorBuffer = LockedDataBuffer()
let outputPathTracker = YTDLPOutputPathTracker()
let completionGate = CompletionGate(completion) let completionGate = CompletionGate(completion)
let outputHandler = YTDLPOutputHandler( let outputHandler = YTDLPOutputHandler(
parser: parser, parser: parser,
outputPathTracker: outputPathTracker,
progress: progress, progress: progress,
messageUpdate: messageUpdate messageUpdate: messageUpdate
) )
@@ -109,24 +111,24 @@ final class MediaDownloadEngine: @unchecked Sendable {
outputHandler.handle(text) outputHandler.handle(text)
} }
} }
process.terminationHandler = { finishedProcess in process.terminationHandler = { finishedProcess in
outputPipe.fileHandleForReading.readabilityHandler = nil outputPipe.fileHandleForReading.readabilityHandler = nil
errorPipe.fileHandleForReading.readabilityHandler = nil errorPipe.fileHandleForReading.readabilityHandler = nil
if finishedProcess.terminationStatus == 0 { if finishedProcess.terminationStatus == 0 {
completionGate.complete(.success(())) completionGate.complete(.success(Self.resolvedOutputURL(for: item, tracker: outputPathTracker)))
} else { } else {
let errorString = String(data: errorBuffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "Unknown Error" let errorString = String(data: errorBuffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "Unknown Error"
completionGate.complete(.failure(EngineError.launchFailed(Self.cleanErrorMessage(errorString, status: finishedProcess.terminationStatus)))) completionGate.complete(.failure(EngineError.launchFailed(Self.cleanErrorMessage(errorString, status: finishedProcess.terminationStatus))))
} }
} }
try process.run() try process.run()
messageUpdate("Fetching media data...") messageUpdate("Fetching media data...")
outputPipe.fileHandleForWriting.closeFile() outputPipe.fileHandleForWriting.closeFile()
errorPipe.fileHandleForWriting.closeFile() errorPipe.fileHandleForWriting.closeFile()
return Handle(cancel: { return Handle(cancel: {
if process.isRunning { if process.isRunning {
process.terminate() process.terminate()
@@ -134,6 +136,34 @@ final class MediaDownloadEngine: @unchecked Sendable {
}) })
} }
private static func resolvedOutputURL(for item: DownloadItem, tracker: YTDLPOutputPathTracker) -> URL {
let expectedURL = URL(fileURLWithPath: item.destinationPath)
if FileManager.default.fileExists(atPath: expectedURL.path) {
return expectedURL
}
if let observedURL = tracker.lastExistingOutputURL {
return observedURL
}
let baseName = expectedURL.deletingPathExtension().lastPathComponent
guard let contents = try? FileManager.default.contentsOfDirectory(
at: item.destinationDirectory,
includingPropertiesForKeys: [.contentModificationDateKey],
options: [.skipsHiddenFiles]
) else {
return expectedURL
}
return contents
.filter { $0.deletingPathExtension().lastPathComponent == baseName }
.max { lhs, rhs in
let lhsDate = (try? lhs.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
let rhsDate = (try? rhs.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
return lhsDate < rhsDate
} ?? expectedURL
}
private static func cleanErrorMessage(_ message: String, status: Int32) -> String { private static func cleanErrorMessage(_ message: String, status: Int32) -> String {
guard !message.isEmpty else { guard !message.isEmpty else {
return "Exit code \(status)" return "Exit code \(status)"
@@ -164,15 +194,18 @@ final class MediaDownloadEngine: @unchecked Sendable {
final class YTDLPOutputHandler: @unchecked Sendable { final class YTDLPOutputHandler: @unchecked Sendable {
private let parser: YTDLPProgressParser private let parser: YTDLPProgressParser
private let outputPathTracker: YTDLPOutputPathTracker
private let progress: @Sendable (DownloadProgress) -> Void private let progress: @Sendable (DownloadProgress) -> Void
private let messageUpdate: @Sendable (String) -> Void private let messageUpdate: @Sendable (String) -> Void
init( init(
parser: YTDLPProgressParser, parser: YTDLPProgressParser,
outputPathTracker: YTDLPOutputPathTracker,
progress: @escaping @Sendable (DownloadProgress) -> Void, progress: @escaping @Sendable (DownloadProgress) -> Void,
messageUpdate: @escaping @Sendable (String) -> Void messageUpdate: @escaping @Sendable (String) -> Void
) { ) {
self.parser = parser self.parser = parser
self.outputPathTracker = outputPathTracker
self.progress = progress self.progress = progress
self.messageUpdate = messageUpdate self.messageUpdate = messageUpdate
} }
@@ -180,6 +213,7 @@ final class YTDLPOutputHandler: @unchecked Sendable {
func handle(_ text: String) { func handle(_ text: String) {
for line in text.split(whereSeparator: \.isNewline) { for line in text.split(whereSeparator: \.isNewline) {
let stringLine = String(line) let stringLine = String(line)
outputPathTracker.observe(stringLine)
if let update = parser.parse(stringLine) { if let update = parser.parse(stringLine) {
progress(update) progress(update)
messageUpdate("Downloading Media") messageUpdate("Downloading Media")
@@ -215,6 +249,60 @@ final class YTDLPOutputHandler: @unchecked Sendable {
} }
} }
final class YTDLPOutputPathTracker: @unchecked Sendable {
private let lock = NSLock()
private var observedPaths: [String] = []
private let quotedPathRegex = try? NSRegularExpression(pattern: #""([^"]+)""#)
var lastExistingOutputURL: URL? {
lock.withLock {
observedPaths
.reversed()
.map { URL(fileURLWithPath: $0) }
.first { FileManager.default.fileExists(atPath: $0.path) }
}
}
func observe(_ line: String) {
let candidates = pathCandidates(from: line)
guard !candidates.isEmpty else { return }
lock.withLock {
for candidate in candidates where !observedPaths.contains(candidate) {
observedPaths.append(candidate)
}
}
}
private func pathCandidates(from line: String) -> [String] {
var paths: [String] = []
if line.contains("Destination:"),
let destination = line.components(separatedBy: "Destination:").last?.trimmingCharacters(in: .whitespacesAndNewlines),
destination.hasPrefix("/") {
paths.append(destination.trimmingCharacters(in: CharacterSet(charactersIn: "\"")))
}
for quoted in quotedCaptures(in: line) where quoted.hasPrefix("/") {
paths.append(quoted)
}
return paths
}
private func quotedCaptures(in text: String) -> [String] {
guard let quotedPathRegex else { return [] }
let range = NSRange(text.startIndex..<text.endIndex, in: text)
return quotedPathRegex.matches(in: text, range: range).compactMap { match in
guard match.numberOfRanges > 1,
let captureRange = Range(match.range(at: 1), in: text) else {
return nil
}
return String(text[captureRange])
}
}
}
private extension String { private extension String {
func fileExtension(defaultValue: String) -> String { func fileExtension(defaultValue: String) -> String {
let ext = (self as NSString).pathExtension.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() let ext = (self as NSString).pathExtension.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
@@ -227,14 +315,14 @@ final class YTDLPProgressParser: @unchecked Sendable {
private let speedRegex = try? NSRegularExpression(pattern: #"at\s+([^\s]+)"#) private let speedRegex = try? NSRegularExpression(pattern: #"at\s+([^\s]+)"#)
private let etaRegex = try? NSRegularExpression(pattern: #"ETA\s+([^\s]+)"#) private let etaRegex = try? NSRegularExpression(pattern: #"ETA\s+([^\s]+)"#)
private let sizeRegex = try? NSRegularExpression(pattern: #"of\s+~?([0-9.]+[a-zA-Z]+)"#) private let sizeRegex = try? NSRegularExpression(pattern: #"of\s+~?([0-9.]+[a-zA-Z]+)"#)
func parse(_ line: String) -> DownloadProgress? { func parse(_ line: String) -> DownloadProgress? {
if line.contains("[download]") && line.contains("%") { if line.contains("[download]") && line.contains("%") {
let fraction = (Double(firstCapture(in: line, regex: percentageRegex) ?? "0") ?? 0) / 100.0 let fraction = (Double(firstCapture(in: line, regex: percentageRegex) ?? "0") ?? 0) / 100.0
let speed = firstCapture(in: line, regex: speedRegex) ?? "-" let speed = firstCapture(in: line, regex: speedRegex) ?? "-"
let eta = firstCapture(in: line, regex: etaRegex) ?? "-" let eta = firstCapture(in: line, regex: etaRegex) ?? "-"
let size = firstCapture(in: line, regex: sizeRegex) ?? "-" let size = firstCapture(in: line, regex: sizeRegex) ?? "-"
return DownloadProgress( return DownloadProgress(
fraction: min(max(fraction, 0), 1), fraction: min(max(fraction, 0), 1),
bytesText: size, bytesText: size,
@@ -248,7 +336,7 @@ final class YTDLPProgressParser: @unchecked Sendable {
let eta = firstCapture(in: line, regex: try? NSRegularExpression(pattern: #"ETA:([^\]]+)"#)) ?? "-" let eta = firstCapture(in: line, regex: try? NSRegularExpression(pattern: #"ETA:([^\]]+)"#)) ?? "-"
let size = firstCapture(in: line, regex: try? NSRegularExpression(pattern: #"/([^\s\(]+)\("#)) ?? "-" let size = firstCapture(in: line, regex: try? NSRegularExpression(pattern: #"/([^\s\(]+)\("#)) ?? "-"
let cn = Int(firstCapture(in: line, regex: try? NSRegularExpression(pattern: #"CN:(\d+)"#)) ?? "1") ?? 1 let cn = Int(firstCapture(in: line, regex: try? NSRegularExpression(pattern: #"CN:(\d+)"#)) ?? "1") ?? 1
return DownloadProgress( return DownloadProgress(
fraction: min(max(fraction, 0), 1), fraction: min(max(fraction, 0), 1),
bytesText: size, bytesText: size,
@@ -259,7 +347,7 @@ final class YTDLPProgressParser: @unchecked Sendable {
} }
return nil return nil
} }
private func firstCapture(in text: String, regex: NSRegularExpression?) -> String? { private func firstCapture(in text: String, regex: NSRegularExpression?) -> String? {
guard let regex else { return nil } guard let regex else { return nil }
let range = NSRange(text.startIndex..<text.endIndex, in: text) let range = NSRange(text.startIndex..<text.endIndex, in: text)
+28 -6
View File
@@ -38,6 +38,7 @@ final class MediaEngineManager: ObservableObject {
let bundleID = Bundle.main.bundleIdentifier ?? "com.firelink.app" let bundleID = Bundle.main.bundleIdentifier ?? "com.firelink.app"
return appSupport.appendingPathComponent(bundleID).appendingPathComponent("Addons", isDirectory: true) return appSupport.appendingPathComponent(bundleID).appendingPathComponent("Addons", isDirectory: true)
} }
private var installTasks: [AddonType: Task<Void, Error>] = [:]
private init() { private init() {
checkLocalInstallation() checkLocalInstallation()
@@ -49,6 +50,7 @@ final class MediaEngineManager: ObservableObject {
func checkLocalInstallation() { func checkLocalInstallation() {
for addon in AddonType.allCases { for addon in AddonType.allCases {
guard installTasks[addon] == nil else { continue }
let path = binaryPath(for: addon) let path = binaryPath(for: addon)
if FileManager.default.isExecutableFile(atPath: path.path) { if FileManager.default.isExecutableFile(atPath: path.path) {
if let version = UserDefaults.standard.string(forKey: addon.defaultsKey) { if let version = UserDefaults.standard.string(forKey: addon.defaultsKey) {
@@ -79,9 +81,10 @@ final class MediaEngineManager: ObservableObject {
let config = try await fetchLatestConfig() let config = try await fetchLatestConfig()
try await withThrowingTaskGroup(of: Void.self) { group in try await withThrowingTaskGroup(of: Void.self) { group in
for addon in requiredAddons where shouldInstall(addon: addon, config: config) { for addon in requiredAddons where shouldInstall(addon: addon, config: config) || installTasks[addon] != nil {
let task = installationTask(for: addon, from: config)
group.addTask { group.addTask {
try await self.install(addon: addon, from: config) try await task.value
} }
} }
@@ -93,9 +96,9 @@ final class MediaEngineManager: ObservableObject {
checkLocalInstallation() checkLocalInstallation()
let missingAddons = requiredAddons.filter { addon in let missingAddons = requiredAddons.filter { addon in
switch state(for: addon) { switch state(for: addon) {
case .installed, .downloading: case .installed:
return false return false
case .notInstalled, .failed: case .downloading, .notInstalled, .failed:
return true return true
} }
} }
@@ -120,13 +123,26 @@ final class MediaEngineManager: ObservableObject {
case .notInstalled, .failed: case .notInstalled, .failed:
return true return true
case .downloading: case .downloading:
return false return true
case .installed(let version): case .installed(let version):
guard let configVersion else { return false } guard let configVersion else { return false }
return version != configVersion return version != configVersion
} }
} }
private func installationTask(for addon: AddonType, from config: GatekeeperConfig) -> Task<Void, Error> {
if let task = installTasks[addon] {
return task
}
let task = Task { @MainActor in
defer { self.installTasks[addon] = nil }
try await self.install(addon: addon, from: config)
}
installTasks[addon] = task
return task
}
private func state(for addon: AddonType) -> AddonState { private func state(for addon: AddonType) -> AddonState {
switch addon { switch addon {
case .ytDlp: return ytDlpState case .ytDlp: return ytDlpState
@@ -159,6 +175,12 @@ final class MediaEngineManager: ObservableObject {
throw URLError(.badURL) throw URLError(.badURL)
} }
guard let expectedSHA256 = addonConfig.currentArchSHA256?.trimmingCharacters(in: .whitespacesAndNewlines),
!expectedSHA256.isEmpty else {
setState(for: addon, to: .failed(error: "Missing SHA-256 checksum for add-on"))
throw BinaryDownloaderError.missingChecksum
}
do { do {
try FileManager.default.createDirectory(at: addonsDirectory, withIntermediateDirectories: true, attributes: nil) try FileManager.default.createDirectory(at: addonsDirectory, withIntermediateDirectories: true, attributes: nil)
let destination = binaryPath(for: addon) let destination = binaryPath(for: addon)
@@ -166,7 +188,7 @@ final class MediaEngineManager: ObservableObject {
try await BinaryDownloader.download( try await BinaryDownloader.download(
from: downloadURL, from: downloadURL,
to: destination, to: destination,
expectedSHA256: addonConfig.currentArchSHA256 expectedSHA256: expectedSHA256
) { progress in ) { progress in
Task { @MainActor in Task { @MainActor in
self.setState(for: addon, to: .downloading(progress: progress)) self.setState(for: addon, to: .downloading(progress: progress))
+124 -32
View File
@@ -20,7 +20,7 @@ struct MediaMetadata: Decodable, Sendable {
let thumbnail: URL? let thumbnail: URL?
let duration: Double? let duration: Double?
let formats: [RawMediaFormat]? let formats: [RawMediaFormat]?
var displayUploader: String? { var displayUploader: String? {
channel ?? uploader channel ?? uploader
} }
@@ -34,6 +34,7 @@ struct CleanFormatOption: Identifiable, Equatable, Sendable {
let symbol: String let symbol: String
let outputExtension: String let outputExtension: String
let detail: String let detail: String
let estimatedBytes: Int64?
} }
enum MediaExtractionEngine { enum MediaExtractionEngine {
@@ -44,7 +45,7 @@ enum MediaExtractionEngine {
case invalidOutput case invalidOutput
case parsingFailed(Error) case parsingFailed(Error)
case timedOut case timedOut
var errorDescription: String? { var errorDescription: String? {
switch self { switch self {
case .processFailed(let msg): return "Extraction failed: \(msg)" case .processFailed(let msg): return "Extraction failed: \(msg)"
@@ -54,7 +55,7 @@ enum MediaExtractionEngine {
} }
} }
} }
static func fetchMetadata( static func fetchMetadata(
for url: URL, for url: URL,
cookieSource: BrowserCookieSource, cookieSource: BrowserCookieSource,
@@ -151,9 +152,6 @@ enum MediaExtractionEngine {
var options: [CleanFormatOption] = [] var options: [CleanFormatOption] = []
let rawFormats = metadata.formats ?? [] let rawFormats = metadata.formats ?? []
let heights = rawFormats.compactMap { $0.height }.filter { $0 > 0 }
let maxHeight = heights.max() ?? 0
let standardResolutions = [ let standardResolutions = [
(2160, "4K"), (2160, "4K"),
(1440, "1440p"), (1440, "1440p"),
@@ -164,7 +162,9 @@ enum MediaExtractionEngine {
] ]
let availableResolutions = standardResolutions.filter { resolution, _ in let availableResolutions = standardResolutions.filter { resolution, _ in
maxHeight == 0 || maxHeight >= resolution - 100 rawFormats.contains { format in
isVideo(format) && (format.height ?? 0) > 0 && (format.height ?? 0) <= resolution && (format.height ?? 0) >= resolution - 100
}
} }
let videoQualities = [(nil as Int?, "Best")] + availableResolutions.map { (Optional($0.0), $0.1) } let videoQualities = [(nil as Int?, "Best")] + availableResolutions.map { (Optional($0.0), $0.1) }
let videoContainers = [ let videoContainers = [
@@ -175,47 +175,139 @@ enum MediaExtractionEngine {
for (height, qualityName) in videoQualities { for (height, qualityName) in videoQualities {
for (container, containerName) in videoContainers { for (container, containerName) in videoContainers {
guard hasVideoFormat(rawFormats, height: height, container: container) else { continue }
let estimatedBytes = estimatedVideoBytes(rawFormats, height: height, container: container)
options.append(CleanFormatOption( options.append(CleanFormatOption(
name: "\(qualityName) \(containerName)", name: "\(qualityName) \(containerName)",
formatSelector: videoSelector(height: height, container: container), formatSelector: videoSelector(height: height, container: container),
isAudioOnly: false, isAudioOnly: false,
symbol: "play.tv.fill", symbol: "play.tv.fill",
outputExtension: container, outputExtension: container,
detail: height == nil ? "Best available video" : "Up to \(qualityName)" detail: optionDetail(
base: height == nil ? "Best available video" : "Up to \(qualityName)",
estimatedBytes: estimatedBytes
),
estimatedBytes: estimatedBytes
)) ))
} }
} }
options.append(CleanFormatOption( if hasAudioFormat(rawFormats, preferredExtension: nil) {
name: "Audio MP3", let estimatedBytes = estimatedAudioBytes(rawFormats, preferredExtension: nil)
formatSelector: "bestaudio/best", options.append(CleanFormatOption(
isAudioOnly: true, name: "Audio MP3",
symbol: "music.note", formatSelector: "bestaudio/best",
outputExtension: "mp3", isAudioOnly: true,
detail: "Converted with ffmpeg" symbol: "music.note",
)) outputExtension: "mp3",
detail: optionDetail(base: "Converted with ffmpeg", estimatedBytes: estimatedBytes),
estimatedBytes: estimatedBytes
))
}
options.append(CleanFormatOption( if hasAudioFormat(rawFormats, preferredExtension: "m4a") {
name: "Audio M4A", let estimatedBytes = estimatedAudioBytes(rawFormats, preferredExtension: "m4a")
formatSelector: "bestaudio[ext=m4a]/bestaudio/best", options.append(CleanFormatOption(
isAudioOnly: true, name: "Audio M4A",
symbol: "waveform", formatSelector: "bestaudio[ext=m4a]/bestaudio/best",
outputExtension: "m4a", isAudioOnly: true,
detail: "Prefer native M4A" symbol: "waveform",
)) outputExtension: "m4a",
detail: optionDetail(base: "Prefer native M4A", estimatedBytes: estimatedBytes),
estimatedBytes: estimatedBytes
))
}
options.append(CleanFormatOption( if hasAudioFormat(rawFormats, preferredExtension: "webm") {
name: "Audio Opus", let estimatedBytes = estimatedAudioBytes(rawFormats, preferredExtension: "webm")
formatSelector: "bestaudio[ext=webm]/bestaudio/best", options.append(CleanFormatOption(
isAudioOnly: true, name: "Audio Opus",
symbol: "waveform", formatSelector: "bestaudio[ext=webm]/bestaudio/best",
outputExtension: "opus", isAudioOnly: true,
detail: "Efficient audio" symbol: "waveform",
)) outputExtension: "opus",
detail: optionDetail(base: "Efficient audio", estimatedBytes: estimatedBytes),
estimatedBytes: estimatedBytes
))
}
return options return options
} }
private static func hasVideoFormat(_ formats: [RawMediaFormat], height: Int?, container: String) -> Bool {
formats.contains { format in
guard isVideo(format), matchesHeight(format, height: height) else { return false }
return container == "mkv" || format.ext?.caseInsensitiveCompare(container) == .orderedSame
}
}
private static func hasAudioFormat(_ formats: [RawMediaFormat], preferredExtension: String?) -> Bool {
formats.contains { format in
guard isAudio(format) else { return false }
guard let preferredExtension else { return true }
return format.ext?.caseInsensitiveCompare(preferredExtension) == .orderedSame
}
}
private static func estimatedVideoBytes(_ formats: [RawMediaFormat], height: Int?, container: String) -> Int64? {
let videoBytes = formats
.filter { format in
guard isVideo(format), matchesHeight(format, height: height) else { return false }
return container == "mkv" || format.ext?.caseInsensitiveCompare(container) == .orderedSame
}
.compactMap { formatSize($0) }
.max()
guard let videoBytes else { return nil }
let audioBytes = estimatedAudioBytes(formats, preferredExtension: container == "webm" ? "webm" : "m4a") ??
estimatedAudioBytes(formats, preferredExtension: nil) ??
0
return videoBytes + audioBytes
}
private static func estimatedAudioBytes(_ formats: [RawMediaFormat], preferredExtension: String?) -> Int64? {
let preferred = formats
.filter { format in
guard isAudio(format) else { return false }
guard let preferredExtension else { return true }
return format.ext?.caseInsensitiveCompare(preferredExtension) == .orderedSame
}
.compactMap { formatSize($0) }
.max()
if preferred != nil || preferredExtension == nil {
return preferred
}
return estimatedAudioBytes(formats, preferredExtension: nil)
}
private static func isVideo(_ format: RawMediaFormat) -> Bool {
guard let vcodec = format.vcodec?.lowercased(), vcodec != "none" else { return false }
return true
}
private static func isAudio(_ format: RawMediaFormat) -> Bool {
let acodec = format.acodec?.lowercased()
let vcodec = format.vcodec?.lowercased()
return acodec != nil && acodec != "none" && (vcodec == nil || vcodec == "none")
}
private static func matchesHeight(_ format: RawMediaFormat, height: Int?) -> Bool {
guard let height else { return true }
guard let formatHeight = format.height else { return false }
return formatHeight <= height && formatHeight >= height - 100
}
private static func formatSize(_ format: RawMediaFormat) -> Int64? {
format.filesize ?? format.filesize_approx
}
private static func optionDetail(base: String, estimatedBytes: Int64?) -> String {
guard let estimatedBytes, estimatedBytes > 0 else { return base }
return "\(base) - ~\(ByteFormatter.string(estimatedBytes))"
}
private static func videoSelector(height: Int?, container: String) -> String { private static func videoSelector(height: Int?, container: String) -> String {
let filter = heightFilter(height) let filter = heightFilter(height)
switch container { switch container {
@@ -33,7 +33,7 @@ struct MediaInspectorInlineView: View {
if isLoading { if isLoading {
ProgressView() ProgressView()
.controlSize(.regular) .controlSize(.regular)
let ytState = engineManager.ytDlpState let ytState = engineManager.ytDlpState
let ffState = engineManager.ffmpegState let ffState = engineManager.ffmpegState
@@ -117,6 +117,13 @@ struct MediaInspectorInlineView: View {
.frame(width: 90) .frame(width: 90)
} }
} }
if let selected = resolveSelectedOption() {
Text(selected.detail)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
} }
Spacer(minLength: 16) Spacer(minLength: 16)
@@ -147,6 +154,10 @@ struct MediaInspectorInlineView: View {
loadTask?.cancel() loadTask?.cancel()
loadTask = nil loadTask = nil
} }
.onChange(of: url) { _, _ in loadMetadata() }
.onChange(of: cookieSource) { _, _ in loadMetadata() }
.onChange(of: credentials) { _, _ in loadMetadata() }
.onChange(of: transferOptions) { _, _ in loadMetadata() }
.onChange(of: selectedType) { _, _ in ensureValidSelection() } .onChange(of: selectedType) { _, _ in ensureValidSelection() }
.onChange(of: options) { _, _ in ensureValidSelection() } .onChange(of: options) { _, _ in ensureValidSelection() }
} }
@@ -216,6 +227,8 @@ struct MediaInspectorInlineView: View {
loadTask?.cancel() loadTask?.cancel()
isLoading = true isLoading = true
errorMessage = nil errorMessage = nil
metadata = nil
options = []
loadTask = Task { loadTask = Task {
do { do {
@@ -233,9 +246,13 @@ struct MediaInspectorInlineView: View {
guard !Task.isCancelled else { return } guard !Task.isCancelled else { return }
await MainActor.run { await MainActor.run {
self.metadata = fetchedMetadata if fetchedOptions.isEmpty {
self.options = fetchedOptions self.errorMessage = "No downloadable media formats were found."
self.ensureValidSelection() } else {
self.metadata = fetchedMetadata
self.options = fetchedOptions
self.ensureValidSelection()
}
self.loadTask = nil self.loadTask = nil
withAnimation { self.isLoading = false } withAnimation { self.isLoading = false }
} }
+13 -13
View File
@@ -49,7 +49,7 @@ final class SchedulerController: ObservableObject {
private let defaults = UserDefaults.standard private let defaults = UserDefaults.standard
private let storageKey = "Firelink.SchedulerSettings.v1" private let storageKey = "Firelink.SchedulerSettings.v1"
// We only trigger once per minute to prevent multiple triggers in the same minute // We only trigger once per minute to prevent multiple triggers in the same minute
private var lastTriggeredMinute: Date? private var lastTriggeredMinute: Date?
@@ -68,14 +68,14 @@ final class SchedulerController: ObservableObject {
checkAutomationPermission() checkAutomationPermission()
startTimer() startTimer()
$settings $settings
.dropFirst() .dropFirst()
.sink { _ in .sink { _ in
// We do NOT save instantly here to UserDefaults because the UI will have a "Save" button // We do NOT save instantly here to UserDefaults because the UI will have a "Save" button
} }
.store(in: &cancellables) .store(in: &cancellables)
// Observe downloads to check if we should trigger post-action // Observe downloads to check if we should trigger post-action
downloadController.$downloads downloadController.$downloads
.dropFirst() .dropFirst()
@@ -84,7 +84,7 @@ final class SchedulerController: ObservableObject {
} }
.store(in: &cancellables) .store(in: &cancellables)
} }
func saveSettings() { func saveSettings() {
if let data = try? JSONEncoder().encode(settings) { if let data = try? JSONEncoder().encode(settings) {
defaults.set(data, forKey: storageKey) defaults.set(data, forKey: storageKey)
@@ -112,7 +112,7 @@ final class SchedulerController: ObservableObject {
let startHour = calendar.component(.hour, from: settings.startTime) let startHour = calendar.component(.hour, from: settings.startTime)
let startMinute = calendar.component(.minute, from: settings.startTime) let startMinute = calendar.component(.minute, from: settings.startTime)
let currentHour = calendar.component(.hour, from: now) let currentHour = calendar.component(.hour, from: now)
let currentMinute = calendar.component(.minute, from: now) let currentMinute = calendar.component(.minute, from: now)
let currentWeekday = calendar.component(.weekday, from: now) let currentWeekday = calendar.component(.weekday, from: now)
@@ -141,26 +141,26 @@ final class SchedulerController: ObservableObject {
} }
guard !runnableQueueIDs.isEmpty else { return } guard !runnableQueueIDs.isEmpty else { return }
isRunning = true isRunning = true
for queueID in runnableQueueIDs { for queueID in runnableQueueIDs {
downloadController.startQueue(queueID: queueID) downloadController.startQueue(queueID: queueID)
} }
checkIfRunningFinished() checkIfRunningFinished()
} }
private func checkIfRunningFinished() { private func checkIfRunningFinished() {
guard isRunning else { return } guard isRunning else { return }
let targetQueueIDs = effectiveTargetQueueIDs() let targetQueueIDs = effectiveTargetQueueIDs()
let hasActiveItems = targetQueueIDs.contains { queueID in 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
} }
} }
if !hasActiveItems { if !hasActiveItems {
isRunning = false isRunning = false
performPostAction() performPostAction()
@@ -173,7 +173,7 @@ final class SchedulerController: ObservableObject {
private func performPostAction() { private func performPostAction() {
guard settings.postQueueAction != .doNothing else { return } guard settings.postQueueAction != .doNothing else { return }
var scriptCode = "" var scriptCode = ""
switch settings.postQueueAction { switch settings.postQueueAction {
case .sleep: case .sleep:
@@ -185,9 +185,9 @@ final class SchedulerController: ObservableObject {
case .doNothing: case .doNothing:
break break
} }
guard !scriptCode.isEmpty else { return } guard !scriptCode.isEmpty else { return }
var error: NSDictionary? var error: NSDictionary?
if let script = NSAppleScript(source: scriptCode) { if let script = NSAppleScript(source: scriptCode) {
script.executeAndReturnError(&error) script.executeAndReturnError(&error)
+22 -22
View File
@@ -4,7 +4,7 @@ struct SchedulerView: View {
@EnvironmentObject private var downloadController: DownloadController @EnvironmentObject private var downloadController: DownloadController
@EnvironmentObject private var schedulerController: SchedulerController @EnvironmentObject private var schedulerController: SchedulerController
@State private var showSaveToast: Bool = false @State private var showSaveToast: Bool = false
// Local state to hold edits before saving // Local state to hold edits before saving
@State private var isEnabled: Bool = false @State private var isEnabled: Bool = false
@State private var startTime: Date = Date() @State private var startTime: Date = Date()
@@ -12,12 +12,12 @@ struct SchedulerView: View {
@State private var selectedDays: Set<SchedulerDay> = [] @State private var selectedDays: Set<SchedulerDay> = []
@State private var postQueueAction: PostQueueAction = .doNothing @State private var postQueueAction: PostQueueAction = .doNothing
@State private var targetQueueIDs: Set<UUID> = [] @State private var targetQueueIDs: Set<UUID> = []
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
headerView headerView
Divider() Divider()
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 24) { VStack(alignment: .leading, spacing: 24) {
VStack(alignment: .leading, spacing: 24) { VStack(alignment: .leading, spacing: 24) {
@@ -27,7 +27,7 @@ struct SchedulerView: View {
} }
.opacity(isEnabled ? 1.0 : 0.5) .opacity(isEnabled ? 1.0 : 0.5)
.disabled(!isEnabled) .disabled(!isEnabled)
Divider() Divider()
permissionsSection permissionsSection
} }
@@ -49,7 +49,7 @@ struct SchedulerView: View {
} }
} }
} }
private var headerView: some View { private var headerView: some View {
HStack { HStack {
Toggle(isOn: $isEnabled) { Toggle(isOn: $isEnabled) {
@@ -57,15 +57,15 @@ struct SchedulerView: View {
.font(.title2.weight(.bold)) .font(.title2.weight(.bold))
} }
.toggleStyle(.switch) .toggleStyle(.switch)
Spacer() Spacer()
Button("Save Settings") { Button("Save Settings") {
saveState() saveState()
withAnimation(.spring()) { withAnimation(.spring()) {
showSaveToast = true showSaveToast = true
} }
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) {
withAnimation { withAnimation {
showSaveToast = false showSaveToast = false
@@ -76,18 +76,18 @@ struct SchedulerView: View {
} }
.padding(24) .padding(24)
} }
private var timeSelectionSection: some View { private var timeSelectionSection: some View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
Text("Start Time") Text("Start Time")
.font(.headline) .font(.headline)
DatePicker("Time", selection: $startTime, displayedComponents: [.hourAndMinute]) DatePicker("Time", selection: $startTime, displayedComponents: [.hourAndMinute])
.datePickerStyle(.stepperField) .datePickerStyle(.stepperField)
.labelsHidden() .labelsHidden()
Toggle("Everyday", isOn: $isEveryday) Toggle("Everyday", isOn: $isEveryday)
if !isEveryday { if !isEveryday {
HStack(spacing: 12) { HStack(spacing: 12) {
ForEach(SchedulerDay.allCases) { day in ForEach(SchedulerDay.allCases) { day in
@@ -107,12 +107,12 @@ struct SchedulerView: View {
} }
} }
} }
private var queueSelectionSection: some View { private var queueSelectionSection: some View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
Text("Queues to Start") Text("Queues to Start")
.font(.headline) .font(.headline)
if downloadController.queues.isEmpty { if downloadController.queues.isEmpty {
Text("No queues available") Text("No queues available")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
@@ -134,12 +134,12 @@ struct SchedulerView: View {
} }
} }
} }
private var postActionSection: some View { private var postActionSection: some View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
Text("After Completion") Text("After Completion")
.font(.headline) .font(.headline)
Picker("Action", selection: $postQueueAction) { Picker("Action", selection: $postQueueAction) {
ForEach(PostQueueAction.allCases) { action in ForEach(PostQueueAction.allCases) { action in
Text(action.rawValue).tag(action) Text(action.rawValue).tag(action)
@@ -149,17 +149,17 @@ struct SchedulerView: View {
.pickerStyle(.radioGroup) .pickerStyle(.radioGroup)
} }
} }
private var permissionsSection: some View { private var permissionsSection: some View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
Text("System Permissions") Text("System Permissions")
.font(.headline) .font(.headline)
Text("Firelink needs Automation permission to control Finder in order to automatically sleep, restart, or shut down your Mac after downloads finish.") Text("Firelink needs Automation permission to control Finder in order to automatically sleep, restart, or shut down your Mac after downloads finish.")
.font(.subheadline) .font(.subheadline)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
if schedulerController.hasAutomationPermission { if schedulerController.hasAutomationPermission {
Button("Revoke Permissions") { Button("Revoke Permissions") {
schedulerController.openAutomationPermissionSettings() schedulerController.openAutomationPermissionSettings()
@@ -173,7 +173,7 @@ struct SchedulerView: View {
} }
} }
} }
private var toastView: some View { private var toastView: some View {
VStack { VStack {
Spacer() Spacer()
@@ -192,7 +192,7 @@ struct SchedulerView: View {
} }
.allowsHitTesting(false) .allowsHitTesting(false)
} }
private func loadState() { private func loadState() {
isEnabled = schedulerController.settings.isEnabled isEnabled = schedulerController.settings.isEnabled
startTime = schedulerController.settings.startTime startTime = schedulerController.settings.startTime
@@ -203,7 +203,7 @@ struct SchedulerView: View {
? [DownloadQueue.mainQueueID] ? [DownloadQueue.mainQueueID]
: schedulerController.settings.targetQueueIDs : schedulerController.settings.targetQueueIDs
} }
private func saveState() { private func saveState() {
schedulerController.settings.isEnabled = isEnabled schedulerController.settings.isEnabled = isEnabled
schedulerController.settings.startTime = startTime schedulerController.settings.startTime = startTime
@@ -66,7 +66,7 @@ struct AboutSettingsPane: View {
Label("Check for Updates", systemImage: "arrow.clockwise") Label("Check for Updates", systemImage: "arrow.clockwise")
} }
.disabled(sparkleUpdater.isChecking) .disabled(sparkleUpdater.isChecking)
Button { Button {
NSWorkspace.shared.open(projectURL.appendingPathComponent("releases")) NSWorkspace.shared.open(projectURL.appendingPathComponent("releases"))
} label: { } label: {
@@ -24,7 +24,7 @@ struct DownloadSettingsPane: View {
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
Section { Section {
LabeledContent("Global speed limit") { LabeledContent("Global speed limit") {
HStack { HStack {
@@ -42,25 +42,25 @@ struct EngineSettingsPane: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} }
Section("Media Engine (yt-dlp & ffmpeg)") { Section("Media Engine (yt-dlp & ffmpeg)") {
addonStatusRow(title: "yt-dlp", state: engineManager.ytDlpState) addonStatusRow(title: "yt-dlp", state: engineManager.ytDlpState)
addonStatusRow(title: "ffmpeg", state: engineManager.ffmpegState) addonStatusRow(title: "ffmpeg", state: engineManager.ffmpegState)
HStack(spacing: 12) { HStack(spacing: 12) {
Button("Check for Updates") { Button("Check for Updates") {
Task { Task {
isCheckingForUpdates = true isCheckingForUpdates = true
updateCheckResult = nil updateCheckResult = nil
try? await Task.sleep(nanoseconds: 800_000_000) try? await Task.sleep(nanoseconds: 800_000_000)
do { do {
try await engineManager.ensureInstalled() try await engineManager.ensureInstalled()
updateCheckResult = "Up to date." updateCheckResult = "Up to date."
} catch { } catch {
updateCheckResult = "Update failed." updateCheckResult = "Update failed."
} }
isCheckingForUpdates = false isCheckingForUpdates = false
try? await Task.sleep(nanoseconds: 3_000_000_000) try? await Task.sleep(nanoseconds: 3_000_000_000)
if !isCheckingForUpdates { if !isCheckingForUpdates {
@@ -69,7 +69,7 @@ struct EngineSettingsPane: View {
} }
} }
.disabled(isDownloadingMediaEngines || isCheckingForUpdates) .disabled(isDownloadingMediaEngines || isCheckingForUpdates)
if isCheckingForUpdates { if isCheckingForUpdates {
ProgressView().controlSize(.small) ProgressView().controlSize(.small)
Text("Checking...") Text("Checking...")
@@ -80,10 +80,10 @@ struct EngineSettingsPane: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.font(.subheadline) .font(.subheadline)
} }
Spacer() Spacer()
} }
Picker("Browser Cookies", selection: $settings.mediaCookieSource) { Picker("Browser Cookies", selection: $settings.mediaCookieSource) {
ForEach(BrowserCookieSource.allCases, id: \.self) { source in ForEach(BrowserCookieSource.allCases, id: \.self) { source in
Text(source.rawValue).tag(source) Text(source.rawValue).tag(source)
@@ -110,13 +110,13 @@ struct EngineSettingsPane: View {
version = await Aria2DownloadEngine.versionString() ?? "Unavailable" version = await Aria2DownloadEngine.versionString() ?? "Unavailable"
} }
} }
private var isDownloadingMediaEngines: Bool { private var isDownloadingMediaEngines: Bool {
if case .downloading = engineManager.ytDlpState { return true } if case .downloading = engineManager.ytDlpState { return true }
if case .downloading = engineManager.ffmpegState { return true } if case .downloading = engineManager.ffmpegState { return true }
return false return false
} }
@ViewBuilder @ViewBuilder
private func addonStatusRow(title: String, state: AddonState) -> some View { private func addonStatusRow(title: String, state: AddonState) -> some View {
LabeledContent(title) { LabeledContent(title) {
@@ -23,12 +23,12 @@ struct IntegrationSettingsPane: View {
} }
.padding(.vertical, 4) .padding(.vertical, 4)
} }
Section("Installation") { Section("Installation") {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
Text("Firelink Companion is officially available on the Mozilla Add-on store. Install it to easily intercept downloads and send media directly to Firelink.") Text("Firelink Companion is officially available on the Mozilla Add-on store. Install it to easily intercept downloads and send media directly to Firelink.")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
Button { Button {
if let url = URL(string: "https://addons.mozilla.org/en-US/firefox/addon/firelink-companion/") { if let url = URL(string: "https://addons.mozilla.org/en-US/firefox/addon/firelink-companion/") {
NSWorkspace.shared.open(url) NSWorkspace.shared.open(url)
@@ -57,7 +57,7 @@ struct IntegrationSettingsPane: View {
} }
} }
} }
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.")
.font(.caption) .font(.caption)
@@ -14,29 +14,29 @@ struct LookAndFeelSettingsPane: View {
} }
} }
.pickerStyle(.radioGroup) .pickerStyle(.radioGroup)
Text("Select a color palette for the app's user interface.") Text("Select a color palette for the app's user interface.")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
Section("Display") { Section("Display") {
Picker("Font Size", selection: $settings.appFontSize) { Picker("Font Size", selection: $settings.appFontSize) {
ForEach(AppFontSize.allCases) { size in ForEach(AppFontSize.allCases) { size in
Text(size.rawValue).tag(size) Text(size.rawValue).tag(size)
} }
} }
Picker("List Row Density", selection: $settings.listRowDensity) { Picker("List Row Density", selection: $settings.listRowDensity) {
ForEach(ListRowDensity.allCases) { density in ForEach(ListRowDensity.allCases) { density in
Text(density.rawValue).tag(density) Text(density.rawValue).tag(density)
} }
} }
} }
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.") Text("Provides quick access to downloads and queues from the macOS menu bar.")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
@@ -31,7 +31,7 @@ struct SettingsPaneContainer: View {
} }
.padding(.horizontal, 32) .padding(.horizontal, 32)
.padding(.vertical, 16) .padding(.vertical, 16)
Divider() Divider()
ScrollView { ScrollView {
+1 -1
View File
@@ -98,7 +98,7 @@ struct SidebarView: View {
} }
.buttonStyle(.plain) .buttonStyle(.plain)
} }
Section("Tools") { Section("Tools") {
Label("Scheduler", systemImage: "calendar.badge.clock") Label("Scheduler", systemImage: "calendar.badge.clock")
.tag(SidebarSelection.scheduler) .tag(SidebarSelection.scheduler)
+18 -18
View File
@@ -3,16 +3,16 @@ import SwiftUI
struct SpeedLimiterView: View { struct SpeedLimiterView: View {
@EnvironmentObject private var settings: AppSettings @EnvironmentObject private var settings: AppSettings
@State private var showSaveToast: Bool = false @State private var showSaveToast: Bool = false
// Local state to hold edits before saving // Local state to hold edits before saving
@State private var isEnabled: Bool = false @State private var isEnabled: Bool = false
@State private var speedLimitKiBPerSecond: Int = 1024 @State private var speedLimitKiBPerSecond: Int = 1024
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
headerView headerView
Divider() Divider()
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 24) { VStack(alignment: .leading, spacing: 24) {
limitSelectionSection limitSelectionSection
@@ -33,7 +33,7 @@ struct SpeedLimiterView: View {
} }
} }
} }
private var headerView: some View { private var headerView: some View {
HStack { HStack {
Toggle(isOn: $isEnabled) { Toggle(isOn: $isEnabled) {
@@ -41,15 +41,15 @@ struct SpeedLimiterView: View {
.font(.title2.weight(.bold)) .font(.title2.weight(.bold))
} }
.toggleStyle(.switch) .toggleStyle(.switch)
Spacer() Spacer()
Button("Save Limit") { Button("Save Limit") {
saveState() saveState()
withAnimation(.spring()) { withAnimation(.spring()) {
showSaveToast = true showSaveToast = true
} }
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) { DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) {
withAnimation { withAnimation {
showSaveToast = false showSaveToast = false
@@ -60,29 +60,29 @@ struct SpeedLimiterView: View {
} }
.padding(24) .padding(24)
} }
private var limitSelectionSection: some View { private var limitSelectionSection: some View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
Text("Global Speed Limit") Text("Global Speed Limit")
.font(.headline) .font(.headline)
Text("This limit applies globally to all active downloads. Individual downloads can also have their own specific limits defined in their properties.") Text("This limit applies globally to all active downloads. Individual downloads can also have their own specific limits defined in their properties.")
.font(.subheadline) .font(.subheadline)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
HStack { HStack {
Stepper(value: $speedLimitKiBPerSecond, in: 1...10_485_760, step: 512) { Stepper(value: $speedLimitKiBPerSecond, in: 1...10_485_760, step: 512) {
Text("Maximum Speed:") Text("Maximum Speed:")
} }
TextField("Speed", value: $speedLimitKiBPerSecond, format: .number) TextField("Speed", value: $speedLimitKiBPerSecond, format: .number)
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
.frame(width: 80) .frame(width: 80)
Text("KiB/s") Text("KiB/s")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
// Helpful presets // Helpful presets
HStack(spacing: 12) { HStack(spacing: 12) {
Button("1 MB/s") { speedLimitKiBPerSecond = 1024 } Button("1 MB/s") { speedLimitKiBPerSecond = 1024 }
@@ -93,7 +93,7 @@ struct SpeedLimiterView: View {
.padding(.top, 8) .padding(.top, 8)
} }
} }
private var toastView: some View { private var toastView: some View {
VStack { VStack {
Spacer() Spacer()
@@ -112,20 +112,20 @@ struct SpeedLimiterView: View {
} }
.allowsHitTesting(false) .allowsHitTesting(false)
} }
@AppStorage("lastCustomSpeedLimit") private var lastCustomSpeedLimit: Int = 1024 @AppStorage("lastCustomSpeedLimit") private var lastCustomSpeedLimit: Int = 1024
private func loadState() { private func loadState() {
let currentLimit = settings.globalSpeedLimitKiBPerSecond let currentLimit = settings.globalSpeedLimitKiBPerSecond
isEnabled = currentLimit > 0 isEnabled = currentLimit > 0
speedLimitKiBPerSecond = currentLimit > 0 ? currentLimit : lastCustomSpeedLimit speedLimitKiBPerSecond = currentLimit > 0 ? currentLimit : lastCustomSpeedLimit
} }
private func saveState() { private func saveState() {
// Clamp to ensure it doesn't break aria2 // Clamp to ensure it doesn't break aria2
let clampedSpeed = max(min(speedLimitKiBPerSecond, 10_485_760), 1) let clampedSpeed = max(min(speedLimitKiBPerSecond, 10_485_760), 1)
speedLimitKiBPerSecond = clampedSpeed speedLimitKiBPerSecond = clampedSpeed
lastCustomSpeedLimit = clampedSpeed lastCustomSpeedLimit = clampedSpeed
settings.globalSpeedLimitKiBPerSecond = isEnabled ? clampedSpeed : 0 settings.globalSpeedLimitKiBPerSecond = isEnabled ? clampedSpeed : 0
} }
-63
View File
@@ -1,63 +0,0 @@
import sys
from PIL import Image, ImageDraw
def generate_gradient_preview(src_path, dest_path):
img = Image.open(src_path).convert("RGBA")
width, height = img.size
pixels = img.load()
# Background color from an inner point
bg_color = pixels[100, 100]
# Define gradient colors
# Top color: slightly lighter/richer blue (e.g. #1E2541)
# Bottom color: darker navy (e.g. #0A0D1A)
top_color = (30, 37, 65, 255)
bottom_color = (10, 13, 26, 255)
# Create new image
new_img = Image.new("RGBA", (width, height))
new_pixels = new_img.load()
for y in range(height):
# Interpolate background color for this row
ratio = y / float(height - 1)
grad_r = int(top_color[0] * (1 - ratio) + bottom_color[0] * ratio)
grad_g = int(top_color[1] * (1 - ratio) + bottom_color[1] * ratio)
grad_b = int(top_color[2] * (1 - ratio) + bottom_color[2] * ratio)
grad_color = (grad_r, grad_g, grad_b, 255)
for x in range(width):
p = pixels[x, y]
# distance from original background color
dist = max(abs(p[0]-bg_color[0]), abs(p[1]-bg_color[1]), abs(p[2]-bg_color[2]))
if dist < 15:
# purely background
new_pixels[x, y] = grad_color
elif dist < 60:
# anti-aliased edge, blend
alpha = (dist - 15) / 45.0
r = int(p[0] * alpha + grad_color[0] * (1 - alpha))
g = int(p[1] * alpha + grad_color[1] * (1 - alpha))
b = int(p[2] * alpha + grad_color[2] * (1 - alpha))
new_pixels[x, y] = (r, g, b, 255)
else:
# purely logo
new_pixels[x, y] = p
# Apply rounded rectangle mask
radius = int(width * 0.225)
mask = Image.new("L", (width, height), 0)
draw = ImageDraw.Draw(mask)
draw.rounded_rectangle((0, 0, width, height), radius=radius, fill=255)
new_img.putalpha(mask)
# Resize to something reasonable for preview
preview = new_img.resize((512, 512), Image.Resampling.LANCZOS)
preview.save(dest_path)
print("Preview saved to", dest_path)
if __name__ == '__main__':
generate_gradient_preview(sys.argv[1], sys.argv[2])
+14 -14
View File
@@ -3,32 +3,32 @@ from PIL import Image, ImageDraw
def process_images(src_path): def process_images(src_path):
img = Image.open(src_path).convert("RGBA") img = Image.open(src_path).convert("RGBA")
# Crop the 28px black padding # Crop the 28px black padding
img = img.crop((28, 28, 1226, 1226)) img = img.crop((28, 28, 1226, 1226))
width, height = img.size width, height = img.size
pixels = img.load() pixels = img.load()
# Lighter color (+1) at top, original color (0) at bottom # Lighter color (+1) at top, original color (0) at bottom
bg_color = pixels[100, 100] bg_color = pixels[100, 100]
# Use a 1.9x multiplier for a subtle, modern "lit from above" macOS effect # Use a 1.9x multiplier for a subtle, modern "lit from above" macOS effect
top_color = (min(255, int(bg_color[0] * 1.9)), min(255, int(bg_color[1] * 1.9)), min(255, int(bg_color[2] * 1.9)), 255) top_color = (min(255, int(bg_color[0] * 1.9)), min(255, int(bg_color[1] * 1.9)), min(255, int(bg_color[2] * 1.9)), 255)
bottom_color = (bg_color[0], bg_color[1], bg_color[2], 255) bottom_color = (bg_color[0], bg_color[1], bg_color[2], 255)
new_img = Image.new("RGBA", (width, height)) new_img = Image.new("RGBA", (width, height))
new_pixels = new_img.load() new_pixels = new_img.load()
for y in range(height): for y in range(height):
ratio = y / float(height - 1) ratio = y / float(height - 1)
grad_r = int(top_color[0] * (1 - ratio) + bottom_color[0] * ratio) grad_r = int(top_color[0] * (1 - ratio) + bottom_color[0] * ratio)
grad_g = int(top_color[1] * (1 - ratio) + bottom_color[1] * ratio) grad_g = int(top_color[1] * (1 - ratio) + bottom_color[1] * ratio)
grad_b = int(top_color[2] * (1 - ratio) + bottom_color[2] * ratio) grad_b = int(top_color[2] * (1 - ratio) + bottom_color[2] * ratio)
grad_color = (grad_r, grad_g, grad_b, 255) grad_color = (grad_r, grad_g, grad_b, 255)
for x in range(width): for x in range(width):
p = pixels[x, y] p = pixels[x, y]
dist = max(abs(p[0]-bg_color[0]), abs(p[1]-bg_color[1]), abs(p[2]-bg_color[2])) dist = max(abs(p[0]-bg_color[0]), abs(p[1]-bg_color[1]), abs(p[2]-bg_color[2]))
# Replace pure black corners or background with gradient # Replace pure black corners or background with gradient
if p[0] < 15 and p[1] < 15 and p[2] < 15: if p[0] < 15 and p[1] < 15 and p[2] < 15:
new_pixels[x, y] = grad_color new_pixels[x, y] = grad_color
@@ -42,7 +42,7 @@ def process_images(src_path):
new_pixels[x, y] = (r, g, b, 255) new_pixels[x, y] = (r, g, b, 255)
else: else:
new_pixels[x, y] = p new_pixels[x, y] = p
img = new_img img = new_img
radius = int(width * 0.225) radius = int(width * 0.225)
mask = Image.new("L", (width, height), 0) mask = Image.new("L", (width, height), 0)
@@ -53,17 +53,17 @@ def process_images(src_path):
# Save standard png # Save standard png
img_1024 = img.resize((1024, 1024), Image.Resampling.LANCZOS) img_1024 = img.resize((1024, 1024), Image.Resampling.LANCZOS)
img_1024.save("Resources/AppIcon.png") img_1024.save("Resources/AppIcon.png")
# Save Firefox extension icons # Save Firefox extension icons
img_48 = img.resize((48, 48), Image.Resampling.LANCZOS) img_48 = img.resize((48, 48), Image.Resampling.LANCZOS)
img_48.save("Extensions/Firefox/icons/icon-48.png") img_48.save("Extensions/Firefox/icons/icon-48.png")
img_128 = img.resize((128, 128), Image.Resampling.LANCZOS) img_128 = img.resize((128, 128), Image.Resampling.LANCZOS)
img_128.save("Extensions/Firefox/icons/icon-128.png") img_128.save("Extensions/Firefox/icons/icon-128.png")
# MenuBarIconTemplate (64x64 monochrome) # MenuBarIconTemplate (64x64 monochrome)
data = img.getdata() data = img.getdata()
new_data = [] new_data = []
for item in data: for item in data:
r, g, b, a = item r, g, b, a = item
if r > 100 and r > b * 1.5 and a > 0: if r > 100 and r > b * 1.5 and a > 0:
@@ -71,14 +71,14 @@ def process_images(src_path):
new_data.append((0, 0, 0, alpha)) new_data.append((0, 0, 0, alpha))
else: else:
new_data.append((0, 0, 0, 0)) new_data.append((0, 0, 0, 0))
menu_bar_full = Image.new("RGBA", img.size) menu_bar_full = Image.new("RGBA", img.size)
menu_bar_full.putdata(new_data) menu_bar_full.putdata(new_data)
menu_bar_64 = menu_bar_full.resize((64, 64), Image.Resampling.LANCZOS) menu_bar_64 = menu_bar_full.resize((64, 64), Image.Resampling.LANCZOS)
menu_bar_64.save("Sources/Firelink/Assets.xcassets/MenuBarIcon.imageset/MenuBarIconTemplate.png") menu_bar_64.save("Sources/Firelink/Assets.xcassets/MenuBarIcon.imageset/MenuBarIconTemplate.png")
print("Done generating main PNGs") print("Done generating main PNGs")
if __name__ == '__main__': if __name__ == '__main__':
process_images(sys.argv[1]) process_images(sys.argv[1])
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB