mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-27 04:19:19 +00:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| facab68237 | |||
| 3a7c594d7c | |||
| 0ace3e4111 | |||
| 7e2e0eeb77 | |||
| 2a5452b7c6 | |||
| fdbacb8a7f | |||
| ffba883961 | |||
| 3b695169d6 | |||
| a5828ec586 | |||
| efb4446739 | |||
| fd90055ccd | |||
| 14924cf254 | |||
| 85f55db66f | |||
| cc89f48221 | |||
| 22cc11f751 | |||
| 88e99a8528 | |||
| 07e4a586a1 | |||
| f93f0daef5 | |||
| 3b4402def3 | |||
| 62297f61ac | |||
| 17dc080a20 | |||
| 709f189f27 | |||
| ce9fb4a072 | |||
| b7380bea35 | |||
| 0ec2213a4c |
@@ -49,14 +49,14 @@ jobs:
|
||||
xcode-select -p
|
||||
xcrun --sdk macosx --show-sdk-version
|
||||
|
||||
- name: Verify macOS 26 SDK
|
||||
- name: Verify macOS 26+ SDK
|
||||
shell: bash
|
||||
run: |
|
||||
SDK_VERSION="$(xcrun --sdk macosx --show-sdk-version)"
|
||||
SDK_MAJOR="${SDK_VERSION%%.*}"
|
||||
|
||||
if [[ "$SDK_MAJOR" != "26" ]]; then
|
||||
echo "Expected macOS 26 SDK, got macOS $SDK_VERSION" >&2
|
||||
if [[ "$SDK_MAJOR" -lt 26 ]]; then
|
||||
echo "Expected at least macOS 26 SDK, got macOS $SDK_VERSION" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -123,5 +123,9 @@ jobs:
|
||||
gh release upload "$TAG_NAME" dist/*.dmg --clobber
|
||||
gh release edit "$TAG_NAME" --notes-file release_notes.md
|
||||
else
|
||||
gh release create "$TAG_NAME" dist/*.dmg --title "$TAG_NAME" --notes-file release_notes.md --verify-tag
|
||||
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||
gh release create "$TAG_NAME" dist/*.dmg --title "$TAG_NAME" --notes-file release_notes.md
|
||||
else
|
||||
gh release create "$TAG_NAME" dist/*.dmg --title "$TAG_NAME" --notes-file release_notes.md --verify-tag
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -5,6 +5,38 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.7.0] - 2026-06-11
|
||||
|
||||
### New Features & Improvements
|
||||
- Complete UI modernization for the context menu, toolbar, download list, and sidebar to adhere strictly to Apple's Human Interface Guidelines (HIG).
|
||||
- Overhaul of the Settings panes including Site Logins, Engine, About, Locations, and Downloads for a unified, cleaner look.
|
||||
- Introduce an "Ask where to save" global configuration option for manual location picking per download.
|
||||
- Add "Stop Time" option to the Scheduler and unit picker for the global Speed Limiter.
|
||||
- Enhance the Integration pane with a visible step counter and an up-to-date status icon.
|
||||
- Optimize `yt-dlp` execution for noticeably faster media extraction speeds.
|
||||
- Defer Keychain access prompts and track executable modification dates for a more secure "priming" mechanism.
|
||||
|
||||
### Fixes
|
||||
- Fix issues regarding proxy environment propagation into media download processes.
|
||||
- Resolve multiple critical bugs related to configuration storage and download stability.
|
||||
- Address multiple underlying issues identified during comprehensive code reviews to improve overall resilience.
|
||||
|
||||
## [0.6.6] - 2026-06-10
|
||||
|
||||
### New Features
|
||||
- Add cascading media format pickers with inline loading states during metadata extraction.
|
||||
- Redesign the Integration settings pane for a more modern experience.
|
||||
- Overhaul the built-in update checker UI to integrate seamlessly into the settings.
|
||||
|
||||
### Improvements
|
||||
- Implement keychain permission priming to defer secure access until explicitly granted, preventing unexpected macOS prompts.
|
||||
- Optimize core UI components to significantly improve rendering performance and overall app stability.
|
||||
|
||||
### Fixes
|
||||
- Fix layout and dynamic sizing bugs in the Add Downloads window.
|
||||
- Fix formatting inconsistencies in media options selection.
|
||||
- Fix toast notification rendering glitches.
|
||||
|
||||
## [0.6.5] - 2026-06-09
|
||||
|
||||
### Fixes
|
||||
|
||||
+1
-1
Submodule Extensions/Firefox updated: 1636855c3f...fc078ef6ad
@@ -43,14 +43,13 @@
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- ⚡ **Multi-Segmented Engine:** Ultra-fast parallel downloading powered by `aria2c`.
|
||||
- 🪄 **Media Downloader:** Instantly extract high-quality audio and video formats (4K, 1080p, MP3) from sites like YouTube and Twitter, backed by bundled `yt-dlp` and `ffmpeg` engines.
|
||||
- 🎨 **Premium Native UI:** Responsive, frosted-glass SwiftUI design tailor-made for Apple Silicon.
|
||||
- 🌐 **Seamless Integration:** Send links directly from your browser with the Firelink Companion extension.
|
||||
- 🎯 **Visual Chunk Map:** Monitor active segment connections and download progress in real time.
|
||||
- 🗂️ **Smart Organization:** Auto-categorizes files into `Musics`, `Movies`, `Compressed`, and more.
|
||||
- 🛡️ **Reliable & Secure:** Deep Keychain integration for authenticated downloads, zero-configuration setup, and automatic recovery.
|
||||
- 🔄 **Native Updater:** Built-in seamless GitHub release checking for lightweight and transparent app updates without third-party frameworks.
|
||||
- ⚡ **Multi-Segmented Engine:** Ultra-fast parallel downloading powered by `aria2c` with configurable speed limits and a built-in download scheduler.
|
||||
- 🪄 **Media Downloader:** Instantly extract high-quality media (4K, 1080p, MP3) with smart cascading format pickers, powered by bundled `yt-dlp` and `ffmpeg`.
|
||||
- 🎨 **Premium Native UI:** A responsive, frosted-glass SwiftUI interface strictly adhering to Apple Human Interface Guidelines, featuring a visual chunk map and dynamic progress tracking.
|
||||
- 🌐 **Seamless Browser Integration:** Send downloads directly from your browser via the secure Firelink Companion extension.
|
||||
- 🛡️ **Privacy & Security:** Zero-configuration setup with deferred Keychain integration ensures your credentials are kept safe and only accessed when strictly necessary.
|
||||
- 🗂️ **Smart Organization:** Auto-categorizes incoming files and remembers your preferred download locations.
|
||||
- 🔄 **Native Updater:** Built-in seamless GitHub release checking for lightweight and transparent app updates.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ cd "$ROOT_DIR"
|
||||
is_valid_mach_o() {
|
||||
local path="$1"
|
||||
if ! file "$path" | grep -q 'Mach-O'; then
|
||||
return 0
|
||||
return 1
|
||||
fi
|
||||
|
||||
lipo -archs "$path" >/dev/null 2>&1
|
||||
|
||||
@@ -32,25 +32,37 @@ struct AddDownloadsView: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
linkSection
|
||||
|
||||
|
||||
optionsSection
|
||||
advancedTransferSection
|
||||
|
||||
summarySection
|
||||
previewSection
|
||||
HStack(spacing: 0) {
|
||||
// Left Column
|
||||
VStack(spacing: 0) {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
linkSection
|
||||
summarySection
|
||||
previewSection
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
Divider()
|
||||
|
||||
// Right Column
|
||||
VStack(spacing: 0) {
|
||||
Form {
|
||||
optionsSection
|
||||
advancedTransferSection
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
.frame(width: 320)
|
||||
.background(Color(NSColor.controlBackgroundColor))
|
||||
}
|
||||
Divider()
|
||||
actionBar
|
||||
.padding(16)
|
||||
.background(.background)
|
||||
}
|
||||
.frame(minWidth: 640, idealWidth: 680, minHeight: 620, idealHeight: 680)
|
||||
.frame(minWidth: 720, idealWidth: 800, minHeight: 620, idealHeight: 680)
|
||||
.sheet(isPresented: $showingDuplicates) {
|
||||
DuplicateResolutionView(
|
||||
conflicts: $conflictingDownloads,
|
||||
@@ -107,7 +119,7 @@ struct AddDownloadsView: View {
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(.quaternary.opacity(0.35))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.frame(height: 72)
|
||||
.frame(minHeight: 72, idealHeight: 100, maxHeight: 160)
|
||||
|
||||
HStack {
|
||||
Text("\(pendingDownloads.count) valid link\(pendingDownloads.count == 1 ? "" : "s") detected")
|
||||
@@ -125,95 +137,68 @@ struct AddDownloadsView: View {
|
||||
}
|
||||
|
||||
private var optionsSection: some View {
|
||||
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
|
||||
GridRow {
|
||||
Label("Save Location", systemImage: "folder")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Toggle("Use one folder for all files", isOn: $overrideDestination)
|
||||
Group {
|
||||
if let firstMedia = pendingDownloads.first(where: { $0.isMedia }) {
|
||||
mediaFormatSection(for: firstMedia)
|
||||
}
|
||||
|
||||
GridRow {
|
||||
Text("")
|
||||
HStack(spacing: 8) {
|
||||
TextField("Automatic by file type", text: $destinationPath)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
.disabled(!overrideDestination)
|
||||
|
||||
Button {
|
||||
selectDestination()
|
||||
} label: {
|
||||
Label("Select...", systemImage: "folder.badge.plus")
|
||||
Section("Save Location") {
|
||||
Toggle("Use one folder for all files", isOn: $overrideDestination)
|
||||
if overrideDestination {
|
||||
HStack(spacing: 8) {
|
||||
TextField("Automatic by file type", text: $destinationPath)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
Button {
|
||||
selectDestination()
|
||||
} label: {
|
||||
Label("Select...", systemImage: "folder.badge.plus")
|
||||
}
|
||||
}
|
||||
.disabled(!overrideDestination)
|
||||
}
|
||||
}
|
||||
|
||||
GridRow(alignment: .firstTextBaseline) {
|
||||
Label("Queue", systemImage: "tray.full")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Picker("Queue", selection: $targetQueueID) {
|
||||
Section("Queue") {
|
||||
Picker("Target Queue", selection: $targetQueueID) {
|
||||
ForEach(controller.queues) { queue in
|
||||
Text(queue.name).tag(queue.id)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.frame(maxWidth: 220, alignment: .leading)
|
||||
}
|
||||
|
||||
GridRow(alignment: .firstTextBaseline) {
|
||||
Label("Connections per File", systemImage: "point.3.connected.trianglepath.dotted")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Section("Transfer Settings") {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Connections per File")
|
||||
HStack {
|
||||
Slider(value: $connectionsPerServer, in: 1...16, step: 1)
|
||||
.frame(width: 145)
|
||||
Text("\(Int(connectionsPerServer)) segments")
|
||||
Text("\(Int(connectionsPerServer))")
|
||||
.monospacedDigit()
|
||||
.frame(width: 98, alignment: .leading)
|
||||
.frame(width: 24, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
|
||||
Toggle("Limit speed per file", isOn: $speedLimitEnabled)
|
||||
if speedLimitEnabled {
|
||||
Stepper(
|
||||
"\(speedLimitKiBPerSecond) KiB/s",
|
||||
value: $speedLimitKiBPerSecond,
|
||||
in: 1...10_485_760,
|
||||
step: 128
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
GridRow(alignment: .firstTextBaseline) {
|
||||
Label("Speed Limit per File", systemImage: "speedometer")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 8) {
|
||||
Toggle("Limit each file", isOn: $speedLimitEnabled)
|
||||
.toggleStyle(.switch)
|
||||
Stepper(
|
||||
"\(speedLimitKiBPerSecond) KiB/s",
|
||||
value: $speedLimitKiBPerSecond,
|
||||
in: 1...10_485_760,
|
||||
step: 128
|
||||
)
|
||||
.disabled(!speedLimitEnabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GridRow(alignment: .top) {
|
||||
Label("Authorization", systemImage: "lock.shield")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.padding(.top, 4)
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Toggle("Use authorization", isOn: $useAuthorization)
|
||||
.toggleStyle(.switch)
|
||||
|
||||
if useAuthorization {
|
||||
HStack(spacing: 8) {
|
||||
TextField("Username", text: $authUsername)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 145)
|
||||
SecureField("Password", text: $authPassword)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 145)
|
||||
}
|
||||
Toggle("Save login for this website", isOn: $saveLogin)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Section("Authorization") {
|
||||
Toggle("Use authorization", isOn: $useAuthorization)
|
||||
if useAuthorization {
|
||||
TextField("Username", text: $authUsername)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
SecureField("Password", text: $authPassword)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
Toggle("Save login for this website", isOn: $saveLogin)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -261,36 +246,11 @@ struct AddDownloadsView: View {
|
||||
}
|
||||
|
||||
TableColumn("Status") { $item in
|
||||
if item.isMedia {
|
||||
if !item.mediaOptions.isEmpty {
|
||||
Menu {
|
||||
ForEach(item.mediaOptions) { option in
|
||||
Button {
|
||||
item.selectedMediaOption = option
|
||||
if let metadata = item.mediaMetadata {
|
||||
let cleanTitle = FileClassifier.sanitizedFileName(metadata.title ?? "Media")
|
||||
item.fileName = "\(cleanTitle).\(option.outputExtension)"
|
||||
item.category = FileClassifier.category(forFileName: item.fileName)
|
||||
}
|
||||
} label: {
|
||||
Text(option.name)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Text(item.selectedMediaOption?.name ?? "Select Format")
|
||||
.lineLimit(1)
|
||||
}
|
||||
.menuStyle(.borderlessButton)
|
||||
.buttonStyle(.plain)
|
||||
.fixedSize()
|
||||
} else if case .loading = item.state {
|
||||
HStack {
|
||||
ProgressView().controlSize(.small)
|
||||
Text("Checking")
|
||||
}.foregroundStyle(.secondary)
|
||||
} else {
|
||||
MetadataStatusView(state: item.state)
|
||||
}
|
||||
if item.isMedia, case .loading = item.state {
|
||||
HStack {
|
||||
ProgressView().controlSize(.small)
|
||||
Text("Checking")
|
||||
}.foregroundStyle(.secondary)
|
||||
} else {
|
||||
MetadataStatusView(state: item.state)
|
||||
}
|
||||
@@ -340,62 +300,155 @@ struct AddDownloadsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func updateMediaOption(for firstMedia: PendingDownload, newId: String) {
|
||||
for index in pendingDownloads.indices where pendingDownloads[index].isMedia {
|
||||
if let option = pendingDownloads[index].mediaOptions.first(where: { $0.id == newId }) {
|
||||
pendingDownloads[index].selectedMediaOption = option
|
||||
if let metadata = pendingDownloads[index].mediaMetadata {
|
||||
let cleanTitle = FileClassifier.sanitizedFileName(metadata.title ?? "Media")
|
||||
pendingDownloads[index].fileName = "\(cleanTitle).\(option.outputExtension)"
|
||||
pendingDownloads[index].category = FileClassifier.category(forFileName: pendingDownloads[index].fileName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func mediaFormatSection(for firstMedia: PendingDownload) -> some View {
|
||||
Section {
|
||||
if firstMedia.mediaOptions.isEmpty {
|
||||
HStack(spacing: 8) {
|
||||
if case .loading = firstMedia.state {
|
||||
ProgressView().controlSize(.small)
|
||||
Text("Fetching media options...")
|
||||
.foregroundStyle(.secondary)
|
||||
} else if case .failed(_) = firstMedia.state {
|
||||
Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.red)
|
||||
Text("Failed to load options.")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ProgressView().controlSize(.small)
|
||||
Text("Waiting for metadata...")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
} else {
|
||||
let currentOption = firstMedia.selectedMediaOption ?? firstMedia.mediaOptions.first!
|
||||
let availableTypes = Array(Set(firstMedia.mediaOptions.map(\.mediaType))).sorted(by: { $0.rawValue > $1.rawValue })
|
||||
let optionsForType = firstMedia.mediaOptions.filter { $0.mediaType == currentOption.mediaType }
|
||||
let availableFormats = Array(Set(optionsForType.map(\.containerName))).sorted()
|
||||
let optionsForFormat = optionsForType.filter { $0.containerName == currentOption.containerName }
|
||||
|
||||
let availableQualities = Array(Set(optionsForFormat.map(\.qualityName))).sorted(by: {
|
||||
if $0 == "Best" { return true }
|
||||
if $1 == "Best" { return false }
|
||||
return $0 > $1
|
||||
})
|
||||
|
||||
Picker("Type", selection: Binding(
|
||||
get: { currentOption.mediaType },
|
||||
set: { newType in
|
||||
if let firstOfNewType = firstMedia.mediaOptions.first(where: { $0.mediaType == newType }) {
|
||||
updateMediaOption(for: firstMedia, newId: firstOfNewType.id)
|
||||
}
|
||||
}
|
||||
)) {
|
||||
ForEach(availableTypes, id: \.self) { type in
|
||||
Text(type.rawValue).tag(type)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.padding(.bottom, 4)
|
||||
|
||||
Picker("Format", selection: Binding(
|
||||
get: { currentOption.containerName },
|
||||
set: { newFormat in
|
||||
let matching = optionsForType.first(where: { $0.containerName == newFormat && $0.qualityName == currentOption.qualityName })
|
||||
let fallback = optionsForType.first(where: { $0.containerName == newFormat })
|
||||
if let newOption = matching ?? fallback {
|
||||
updateMediaOption(for: firstMedia, newId: newOption.id)
|
||||
}
|
||||
}
|
||||
)) {
|
||||
ForEach(availableFormats, id: \.self) { format in
|
||||
Text(format).tag(format)
|
||||
}
|
||||
}
|
||||
|
||||
if currentOption.mediaType == .video {
|
||||
Picker("Quality", selection: Binding(
|
||||
get: { currentOption.qualityName },
|
||||
set: { newQuality in
|
||||
if let newOption = optionsForFormat.first(where: { $0.qualityName == newQuality }) {
|
||||
updateMediaOption(for: firstMedia, newId: newOption.id)
|
||||
}
|
||||
}
|
||||
)) {
|
||||
ForEach(availableQualities, id: \.self) { quality in
|
||||
Text(quality).tag(quality)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Picker("Quality", selection: .constant("Best")) {
|
||||
Text("Best").tag("Best")
|
||||
}
|
||||
.disabled(true)
|
||||
}
|
||||
} // End of else block
|
||||
} header: {
|
||||
Text("Media Format").foregroundStyle(.blue)
|
||||
}
|
||||
}
|
||||
|
||||
private var advancedTransferSection: some View {
|
||||
DisclosureGroup(isExpanded: $showsAdvancedTransfer) {
|
||||
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
|
||||
GridRow(alignment: .firstTextBaseline) {
|
||||
Toggle("Checksum", isOn: $checksumEnabled)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
HStack(spacing: 8) {
|
||||
Section {
|
||||
DisclosureGroup(isExpanded: $showsAdvancedTransfer) {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Toggle("Verify Checksum", isOn: $checksumEnabled)
|
||||
if checksumEnabled {
|
||||
Picker("Algorithm", selection: $checksumAlgorithm) {
|
||||
ForEach(ChecksumAlgorithm.allCases) { algorithm in
|
||||
Text(algorithm.title).tag(algorithm)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.frame(width: 130)
|
||||
|
||||
TextField("Expected digest", text: $checksumValue)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
}
|
||||
.disabled(!checksumEnabled)
|
||||
}
|
||||
|
||||
GridRow(alignment: .top) {
|
||||
Label("Headers", systemImage: "text.quote")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
TextEditor(text: $headerText)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(.quaternary.opacity(0.35))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.frame(minHeight: 48)
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Headers")
|
||||
TextEditor(text: $headerText)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(.quaternary.opacity(0.35))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.frame(minHeight: 48)
|
||||
}
|
||||
|
||||
GridRow(alignment: .firstTextBaseline) {
|
||||
Label("Cookies", systemImage: "circle.hexagongrid.circle")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
TextField("name=value; other=value", text: $cookieText)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Cookies")
|
||||
TextField("name=value; other=value", text: $cookieText)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
}
|
||||
|
||||
GridRow(alignment: .top) {
|
||||
Label("Mirrors", systemImage: "point.3.filled.connected.trianglepath.dotted")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
TextEditor(text: $mirrorText)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(.quaternary.opacity(0.35))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.frame(minHeight: 48)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Mirrors")
|
||||
TextEditor(text: $mirrorText)
|
||||
.font(.system(.callout, design: .monospaced))
|
||||
.scrollContentBackground(.hidden)
|
||||
.background(.quaternary.opacity(0.35))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.frame(minHeight: 48)
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
} label: {
|
||||
Label("Advanced Transfer", systemImage: "slider.horizontal.3")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
.padding(.top, 8)
|
||||
} label: {
|
||||
Label("Advanced Transfer", systemImage: "slider.horizontal.3")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -550,7 +603,8 @@ struct AddDownloadsView: View {
|
||||
for: item.url,
|
||||
cookieSource: settings.mediaCookieSource,
|
||||
credentials: metadataCredentials(for: item.url),
|
||||
transferOptions: transferOptions
|
||||
transferOptions: transferOptions,
|
||||
proxyConfiguration: settings.downloadProxyConfiguration
|
||||
)
|
||||
fetchedItem.mediaMetadata = metadata
|
||||
fetchedItem.mediaOptions = options
|
||||
@@ -647,7 +701,7 @@ struct AddDownloadsView: View {
|
||||
var count = 1
|
||||
let base = URL(fileURLWithPath: newName).deletingPathExtension().lastPathComponent
|
||||
let ext = URL(fileURLWithPath: newName).pathExtension
|
||||
while controller.downloads.contains(where: { $0.destinationDirectory == destURL && $0.fileName == newName }) || FileManager.default.fileExists(atPath: destURL.appendingPathComponent(newName).path) {
|
||||
while controller.downloads.contains(where: { $0.destinationDirectory == destURL && $0.fileName == newName }) || FileManager.default.fileExists(atPath: destURL.appendingPathComponent(newName).path) || finalDownloads.prefix(upTo: index).contains(where: { (overrideDirectory ?? $0.defaultDirectory) == destURL && $0.fileName == newName }) {
|
||||
newName = "\(base) (\(count))" + (ext.isEmpty ? "" : ".\(ext)")
|
||||
count += 1
|
||||
}
|
||||
|
||||
@@ -93,9 +93,6 @@ struct ProxySettings: Codable, Equatable, Sendable {
|
||||
var copy = self
|
||||
copy.host = copy.host.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
copy.port = min(max(copy.port, 1), 65_535)
|
||||
if copy.type != .http {
|
||||
copy.type = .http
|
||||
}
|
||||
return copy
|
||||
}
|
||||
|
||||
@@ -160,6 +157,33 @@ final class AppSettings: ObservableObject {
|
||||
didSet { save() }
|
||||
}
|
||||
|
||||
@Published var showNotifications: Bool {
|
||||
didSet { save() }
|
||||
}
|
||||
|
||||
@Published var customUserAgent: String {
|
||||
didSet { save() }
|
||||
}
|
||||
|
||||
@Published var playCompletionSound: Bool {
|
||||
didSet { save() }
|
||||
}
|
||||
|
||||
@Published var showDockBadge: Bool {
|
||||
didSet { save() }
|
||||
}
|
||||
|
||||
@Published var maxAutomaticRetries: Int {
|
||||
didSet {
|
||||
let clamped = min(max(maxAutomaticRetries, 0), 10)
|
||||
if maxAutomaticRetries != clamped {
|
||||
maxAutomaticRetries = clamped
|
||||
return
|
||||
}
|
||||
save()
|
||||
}
|
||||
}
|
||||
|
||||
@Published var proxySettings: ProxySettings {
|
||||
didSet {
|
||||
let normalized = proxySettings.normalized
|
||||
@@ -185,10 +209,22 @@ final class AppSettings: ObservableObject {
|
||||
|
||||
@Published var extensionPairingToken: String {
|
||||
didSet {
|
||||
KeychainCredentialStore.setExtensionToken(extensionPairingToken)
|
||||
if isKeychainAccessGranted {
|
||||
KeychainCredentialStore.setExtensionToken(extensionPairingToken)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Published var isKeychainAccessGranted: Bool {
|
||||
didSet { save() }
|
||||
}
|
||||
|
||||
@Published var showKeychainPrimer = false
|
||||
|
||||
@Published var askWhereToSaveEachFile: Bool {
|
||||
didSet { save() }
|
||||
}
|
||||
|
||||
@Published var message = ""
|
||||
|
||||
private let defaults: UserDefaults
|
||||
@@ -198,6 +234,7 @@ final class AppSettings: ObservableObject {
|
||||
init(defaults: UserDefaults = .standard) {
|
||||
self.defaults = defaults
|
||||
|
||||
let granted: Bool
|
||||
if let data = defaults.data(forKey: storageKey),
|
||||
let stored = try? JSONDecoder().decode(StoredSettings.self, from: data) {
|
||||
appTheme = stored.appTheme ?? .system
|
||||
@@ -207,10 +244,18 @@ final class AppSettings: ObservableObject {
|
||||
maxConcurrentDownloads = min(max(stored.maxConcurrentDownloads ?? 3, 1), 12)
|
||||
globalSpeedLimitKiBPerSecond = min(max(stored.globalSpeedLimitKiBPerSecond ?? 0, 0), 10_485_760)
|
||||
preventsSleepWhileDownloading = stored.preventsSleepWhileDownloading
|
||||
showNotifications = stored.showNotifications ?? true
|
||||
playCompletionSound = stored.playCompletionSound ?? true
|
||||
showDockBadge = stored.showDockBadge ?? true
|
||||
customUserAgent = stored.customUserAgent ?? ""
|
||||
maxAutomaticRetries = min(max(stored.maxAutomaticRetries ?? 3, 0), 10)
|
||||
proxySettings = stored.proxySettings?.normalized ?? ProxySettings()
|
||||
siteLogins = stored.siteLogins
|
||||
mediaCookieSource = stored.mediaCookieSource ?? .none
|
||||
downloadDirectories = Self.decodeDirectories(stored.downloadDirectories)
|
||||
granted = stored.isKeychainAccessGranted ?? false
|
||||
isKeychainAccessGranted = granted
|
||||
askWhereToSaveEachFile = stored.askWhereToSaveEachFile ?? false
|
||||
} else {
|
||||
appTheme = .system
|
||||
appFontSize = .standard
|
||||
@@ -219,17 +264,50 @@ final class AppSettings: ObservableObject {
|
||||
maxConcurrentDownloads = 3
|
||||
globalSpeedLimitKiBPerSecond = 0
|
||||
preventsSleepWhileDownloading = true
|
||||
showNotifications = true
|
||||
playCompletionSound = true
|
||||
showDockBadge = true
|
||||
customUserAgent = ""
|
||||
maxAutomaticRetries = 3
|
||||
proxySettings = ProxySettings()
|
||||
siteLogins = []
|
||||
mediaCookieSource = .none
|
||||
downloadDirectories = Self.defaultDirectories()
|
||||
granted = false
|
||||
isKeychainAccessGranted = granted
|
||||
askWhereToSaveEachFile = false
|
||||
}
|
||||
|
||||
if let token = KeychainCredentialStore.extensionToken() {
|
||||
extensionPairingToken = token
|
||||
let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
|
||||
let currentBuild = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "unknown"
|
||||
var execHash = "unknown"
|
||||
if let execPath = Bundle.main.executablePath,
|
||||
let attr = try? FileManager.default.attributesOfItem(atPath: execPath),
|
||||
let modDate = attr[.modificationDate] as? Date {
|
||||
execHash = String(modDate.timeIntervalSince1970)
|
||||
}
|
||||
let fullVersion = "\(currentVersion).\(currentBuild).\(execHash)"
|
||||
let lastVersion = defaults.string(forKey: "Firelink.lastLaunchedVersion")
|
||||
defaults.set(fullVersion, forKey: "Firelink.lastLaunchedVersion")
|
||||
|
||||
var needsPrimer = false
|
||||
if granted {
|
||||
if let lastVersion, lastVersion != fullVersion {
|
||||
needsPrimer = true
|
||||
}
|
||||
}
|
||||
|
||||
if granted {
|
||||
if let token = KeychainCredentialStore.extensionToken() {
|
||||
extensionPairingToken = token
|
||||
} else {
|
||||
extensionPairingToken = Self.generateSecureToken()
|
||||
}
|
||||
if needsPrimer {
|
||||
showKeychainPrimer = true
|
||||
}
|
||||
} else {
|
||||
extensionPairingToken = Self.generateSecureToken()
|
||||
KeychainCredentialStore.setExtensionToken(extensionPairingToken)
|
||||
extensionPairingToken = ""
|
||||
}
|
||||
|
||||
for category in DownloadCategory.allCases where downloadDirectories[category] == nil {
|
||||
@@ -333,6 +411,38 @@ final class AppSettings: ObservableObject {
|
||||
return DownloadCredentials(username: login.username, password: password)
|
||||
}
|
||||
|
||||
func grantKeychainAccess() {
|
||||
isKeychainAccessGranted = true
|
||||
if let token = KeychainCredentialStore.extensionToken() {
|
||||
extensionPairingToken = token
|
||||
} else {
|
||||
extensionPairingToken = Self.generateSecureToken()
|
||||
}
|
||||
}
|
||||
|
||||
func revokeKeychainAccess() {
|
||||
KeychainCredentialStore.deleteExtensionToken()
|
||||
for login in siteLogins {
|
||||
KeychainCredentialStore.deletePassword(for: login.id)
|
||||
}
|
||||
siteLogins.removeAll()
|
||||
extensionPairingToken = ""
|
||||
isKeychainAccessGranted = false
|
||||
}
|
||||
|
||||
func resolveKeychainPrimer(grantAccess: Bool) {
|
||||
showKeychainPrimer = false
|
||||
if grantAccess {
|
||||
if let token = KeychainCredentialStore.extensionToken() {
|
||||
extensionPairingToken = token
|
||||
} else {
|
||||
extensionPairingToken = Self.generateSecureToken()
|
||||
}
|
||||
} else {
|
||||
revokeKeychainAccess()
|
||||
}
|
||||
}
|
||||
|
||||
private func save() {
|
||||
let stored = StoredSettings(
|
||||
appTheme: appTheme,
|
||||
@@ -342,10 +452,17 @@ final class AppSettings: ObservableObject {
|
||||
maxConcurrentDownloads: maxConcurrentDownloads,
|
||||
globalSpeedLimitKiBPerSecond: globalSpeedLimitKiBPerSecond,
|
||||
preventsSleepWhileDownloading: preventsSleepWhileDownloading,
|
||||
showNotifications: showNotifications,
|
||||
playCompletionSound: playCompletionSound,
|
||||
showDockBadge: showDockBadge,
|
||||
customUserAgent: customUserAgent,
|
||||
maxAutomaticRetries: maxAutomaticRetries,
|
||||
proxySettings: proxySettings.normalized,
|
||||
downloadDirectories: Dictionary(uniqueKeysWithValues: downloadDirectories.map { ($0.key.rawValue, $0.value) }),
|
||||
siteLogins: siteLogins,
|
||||
mediaCookieSource: mediaCookieSource
|
||||
mediaCookieSource: mediaCookieSource,
|
||||
isKeychainAccessGranted: isKeychainAccessGranted,
|
||||
askWhereToSaveEachFile: askWhereToSaveEachFile
|
||||
)
|
||||
let defaults = self.defaults
|
||||
let storageKey = self.storageKey
|
||||
@@ -422,8 +539,15 @@ private struct StoredSettings: Codable {
|
||||
var maxConcurrentDownloads: Int?
|
||||
var globalSpeedLimitKiBPerSecond: Int?
|
||||
var preventsSleepWhileDownloading: Bool
|
||||
var showNotifications: Bool?
|
||||
var playCompletionSound: Bool?
|
||||
var showDockBadge: Bool?
|
||||
var customUserAgent: String?
|
||||
var maxAutomaticRetries: Int?
|
||||
var proxySettings: ProxySettings?
|
||||
var downloadDirectories: [String: String]
|
||||
var siteLogins: [SiteLogin]
|
||||
var mediaCookieSource: BrowserCookieSource?
|
||||
var isKeychainAccessGranted: Bool?
|
||||
var askWhereToSaveEachFile: Bool?
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import Foundation
|
||||
import CFNetwork
|
||||
import Network
|
||||
|
||||
final class Aria2DownloadEngine {
|
||||
final class Aria2DownloadEngine: Sendable {
|
||||
struct Handle {
|
||||
let processIdentifier: Int32
|
||||
let rpcPort: Int
|
||||
@@ -10,7 +10,7 @@ final class Aria2DownloadEngine {
|
||||
let cancel: @Sendable () -> Void
|
||||
}
|
||||
|
||||
static func findFreePort() -> Int {
|
||||
static func findFreePort() -> UInt16 {
|
||||
var port: UInt16 = 6800
|
||||
let parameters = NWParameters.tcp
|
||||
for p in 6800...6900 {
|
||||
@@ -21,7 +21,7 @@ final class Aria2DownloadEngine {
|
||||
break
|
||||
}
|
||||
}
|
||||
return Int(port)
|
||||
return port
|
||||
}
|
||||
|
||||
enum EngineError: LocalizedError {
|
||||
@@ -107,7 +107,7 @@ final class Aria2DownloadEngine {
|
||||
speedLimitKiBPerSecond: Int?,
|
||||
progress: @escaping @Sendable (DownloadProgress) -> Void,
|
||||
completion: @escaping @Sendable (Result<Void, Error>) -> Void
|
||||
) throws -> Handle {
|
||||
) async throws -> Handle {
|
||||
guard let executableURL else {
|
||||
throw EngineError.executableNotFound
|
||||
}
|
||||
@@ -117,30 +117,48 @@ final class Aria2DownloadEngine {
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
|
||||
let rpcPort = Self.findFreePort()
|
||||
let rpcSecret = UUID().uuidString
|
||||
let confURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("firelink-aria2-\(UUID().uuidString).conf")
|
||||
do {
|
||||
let confContent = "rpc-secret=\(rpcSecret)\n"
|
||||
try confContent.write(to: confURL, atomically: true, encoding: .utf8)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: confURL.path)
|
||||
} catch {
|
||||
throw EngineError.launchFailed("Could not write secure configuration file: \(error.localizedDescription)")
|
||||
}
|
||||
var lastError: Error?
|
||||
|
||||
let process = Process()
|
||||
process.executableURL = executableURL
|
||||
process.arguments = try arguments(
|
||||
for: item,
|
||||
proxyConfiguration: proxyConfiguration,
|
||||
speedLimitKiBPerSecond: speedLimitKiBPerSecond,
|
||||
rpcPort: rpcPort,
|
||||
confURL: confURL
|
||||
)
|
||||
for _ in 1...5 {
|
||||
let rpcPort = Int(Self.findFreePort())
|
||||
let rpcSecret = UUID().uuidString
|
||||
let tempDir = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("firelink-aria2-\(UUID().uuidString)")
|
||||
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true, attributes: [.posixPermissions: 0o700])
|
||||
} catch {
|
||||
lastError = EngineError.launchFailed("Could not create secure temporary directory: \(error.localizedDescription)")
|
||||
continue
|
||||
}
|
||||
|
||||
let confURL = tempDir.appendingPathComponent("aria2.conf")
|
||||
do {
|
||||
let confContent = "rpc-secret=\(rpcSecret)\n"
|
||||
try confContent.write(to: confURL, atomically: true, encoding: .utf8)
|
||||
} catch {
|
||||
lastError = EngineError.launchFailed("Could not write secure configuration file: \(error.localizedDescription)")
|
||||
continue
|
||||
}
|
||||
|
||||
let inputPipe = Pipe()
|
||||
let outputPipe = Pipe()
|
||||
let errorPipe = Pipe()
|
||||
let process = Process()
|
||||
process.executableURL = executableURL
|
||||
|
||||
do {
|
||||
process.arguments = try arguments(
|
||||
for: item,
|
||||
proxyConfiguration: proxyConfiguration,
|
||||
speedLimitKiBPerSecond: speedLimitKiBPerSecond,
|
||||
rpcPort: rpcPort,
|
||||
confURL: confURL
|
||||
)
|
||||
} catch {
|
||||
lastError = error
|
||||
break
|
||||
}
|
||||
|
||||
let inputPipe = Pipe()
|
||||
let outputPipe = Pipe()
|
||||
let errorPipe = Pipe()
|
||||
process.standardInput = inputPipe
|
||||
process.standardOutput = outputPipe
|
||||
process.standardError = errorPipe
|
||||
@@ -169,7 +187,7 @@ final class Aria2DownloadEngine {
|
||||
}
|
||||
|
||||
process.terminationHandler = { finishedProcess in
|
||||
try? FileManager.default.removeItem(at: confURL)
|
||||
try? FileManager.default.removeItem(at: tempDir)
|
||||
completionMonitor.cancel()
|
||||
outputPipe.fileHandleForReading.readabilityHandler = nil
|
||||
errorPipe.fileHandleForReading.readabilityHandler = nil
|
||||
@@ -190,33 +208,56 @@ final class Aria2DownloadEngine {
|
||||
completionGate.complete(.failure(EngineError.launchFailed(message.isEmpty ? "exit code \(finishedProcess.terminationStatus)" : message)))
|
||||
}
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
if let input = inputFileContent(for: item).data(using: .utf8) {
|
||||
inputPipe.fileHandleForWriting.write(input)
|
||||
var didThrow = false
|
||||
do {
|
||||
try process.run()
|
||||
if let input = inputFileContent(for: item).data(using: .utf8) {
|
||||
inputPipe.fileHandleForWriting.write(input)
|
||||
}
|
||||
inputPipe.fileHandleForWriting.closeFile()
|
||||
} catch {
|
||||
didThrow = true
|
||||
lastError = EngineError.launchFailed(error.localizedDescription)
|
||||
}
|
||||
|
||||
if didThrow {
|
||||
outputPipe.fileHandleForReading.readabilityHandler = nil
|
||||
errorPipe.fileHandleForReading.readabilityHandler = nil
|
||||
try? FileManager.default.removeItem(at: tempDir)
|
||||
continue
|
||||
}
|
||||
|
||||
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||
|
||||
if !process.isRunning {
|
||||
let stderr = String(data: errorBuffer.data, encoding: .utf8) ?? ""
|
||||
if stderr.contains("Address already in use") || stderr.contains("Failed to bind") || stderr.contains("bind: Address") {
|
||||
try? FileManager.default.removeItem(at: tempDir)
|
||||
continue
|
||||
}
|
||||
// If it exited for another reason, we might still want to fail or let the terminationHandler process it.
|
||||
// But the terminationHandler will hit completionGate, so we just return the handle.
|
||||
}
|
||||
inputPipe.fileHandleForWriting.closeFile()
|
||||
} catch {
|
||||
try? FileManager.default.removeItem(at: confURL)
|
||||
throw EngineError.launchFailed(error.localizedDescription)
|
||||
}
|
||||
|
||||
completionMonitor.set(
|
||||
Self.monitorCompletion(
|
||||
rpcPort: rpcPort,
|
||||
rpcSecret: rpcSecret,
|
||||
process: process,
|
||||
completionGate: completionGate
|
||||
completionMonitor.set(
|
||||
Self.monitorCompletion(
|
||||
rpcPort: rpcPort,
|
||||
rpcSecret: rpcSecret,
|
||||
process: process,
|
||||
completionGate: completionGate
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
return Handle(processIdentifier: process.processIdentifier, rpcPort: rpcPort, rpcSecret: rpcSecret) {
|
||||
completionMonitor.cancel()
|
||||
if process.isRunning {
|
||||
process.terminate()
|
||||
return Handle(processIdentifier: process.processIdentifier, rpcPort: rpcPort, rpcSecret: rpcSecret) {
|
||||
completionMonitor.cancel()
|
||||
if process.isRunning {
|
||||
process.terminate()
|
||||
}
|
||||
try? FileManager.default.removeItem(at: tempDir)
|
||||
}
|
||||
try? FileManager.default.removeItem(at: confURL)
|
||||
}
|
||||
|
||||
throw lastError ?? EngineError.launchFailed("Failed to start aria2c after 5 attempts.")
|
||||
}
|
||||
|
||||
private static func monitorCompletion(
|
||||
|
||||
@@ -21,13 +21,27 @@ struct ContentView: View {
|
||||
.onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("OpenAddDownloadsWindow"))) { _ in
|
||||
openWindow(id: "add-downloads")
|
||||
}
|
||||
.onChange(of: controller.activeCount) { _, newCount in
|
||||
updateDockBadge(count: newCount, show: settings.showDockBadge)
|
||||
}
|
||||
.onChange(of: settings.showDockBadge) { _, show in
|
||||
updateDockBadge(count: controller.activeCount, show: show)
|
||||
}
|
||||
.onAppear {
|
||||
updateDockBadge(count: controller.activeCount, show: settings.showDockBadge)
|
||||
}
|
||||
.onDrop(of: [.url, .fileURL, .plainText], isTargeted: nil) { providers in
|
||||
for provider in providers {
|
||||
if provider.canLoadObject(ofClass: URL.self) {
|
||||
_ = provider.loadObject(ofClass: URL.self) { url, _ in
|
||||
if let url = url {
|
||||
DispatchQueue.main.async {
|
||||
controller.pendingPasteboardText = url.absoluteString
|
||||
let newText = url.absoluteString
|
||||
if let existing = controller.pendingPasteboardText, !existing.isEmpty {
|
||||
controller.pendingPasteboardText = existing + "\n" + newText
|
||||
} else {
|
||||
controller.pendingPasteboardText = newText
|
||||
}
|
||||
controller.pendingReferer = nil
|
||||
openWindow(id: "add-downloads")
|
||||
}
|
||||
@@ -37,7 +51,11 @@ struct ContentView: View {
|
||||
_ = provider.loadObject(ofClass: String.self) { text, _ in
|
||||
if let text = text {
|
||||
DispatchQueue.main.async {
|
||||
controller.pendingPasteboardText = text
|
||||
if let existing = controller.pendingPasteboardText, !existing.isEmpty {
|
||||
controller.pendingPasteboardText = existing + "\n" + text
|
||||
} else {
|
||||
controller.pendingPasteboardText = text
|
||||
}
|
||||
controller.pendingReferer = nil
|
||||
openWindow(id: "add-downloads")
|
||||
}
|
||||
@@ -47,6 +65,10 @@ struct ContentView: View {
|
||||
}
|
||||
return true
|
||||
}
|
||||
.sheet(isPresented: $settings.showKeychainPrimer) {
|
||||
KeychainPrimerView()
|
||||
.environmentObject(settings)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -95,29 +117,14 @@ struct ContentView: View {
|
||||
StatusBar()
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
ToolbarItemGroup {
|
||||
Button {
|
||||
controller.pendingAddQueueID = queueID
|
||||
openWindow(id: "add-downloads")
|
||||
} label: {
|
||||
Label("Add", systemImage: "plus")
|
||||
Label("Add Download", systemImage: "plus")
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItemGroup {
|
||||
let canStop = selectedItems.isEmpty ? hasActiveDownloads(in: queueID) : selectedItems.contains(where: { $0.status == .downloading })
|
||||
Button {
|
||||
if selectedItems.isEmpty {
|
||||
controller.pauseActiveDownloads(queueID: queueID)
|
||||
} else {
|
||||
for item in selectedItems where item.status == .downloading {
|
||||
controller.pause(item)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label(selectedItems.isEmpty ? "Stop All" : "Stop", systemImage: "stop.fill")
|
||||
}
|
||||
.disabled(!canStop)
|
||||
.help("Add a new download")
|
||||
|
||||
let canStart = selectedItems.isEmpty ? hasQueuedDownloads(in: queueID) : selectedItems.contains(where: { $0.status == .paused || $0.status == .failed || $0.status == .canceled })
|
||||
Button {
|
||||
@@ -129,9 +136,25 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label(selectedItems.isEmpty ? "Start Queue" : "Start", systemImage: "play.fill")
|
||||
Label(selectedItems.isEmpty ? "Resume All" : "Resume", systemImage: "play.fill")
|
||||
}
|
||||
.help(selectedItems.isEmpty ? "Resume all downloads" : "Resume selected downloads")
|
||||
.disabled(!canStart)
|
||||
|
||||
let canStop = selectedItems.isEmpty ? hasActiveDownloads(in: queueID) : selectedItems.contains(where: { $0.status == .downloading || $0.status == .queued })
|
||||
Button {
|
||||
if selectedItems.isEmpty {
|
||||
controller.pauseActiveDownloads(queueID: queueID)
|
||||
} else {
|
||||
for item in selectedItems where item.status == .downloading || item.status == .queued {
|
||||
controller.pause(item)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label(selectedItems.isEmpty ? "Pause All" : "Pause", systemImage: "pause.fill")
|
||||
}
|
||||
.help(selectedItems.isEmpty ? "Pause all active downloads" : "Pause selected downloads")
|
||||
.disabled(!canStop)
|
||||
}
|
||||
}
|
||||
.background {
|
||||
@@ -142,18 +165,24 @@ struct ContentView: View {
|
||||
}
|
||||
.keyboardShortcut(.delete, modifiers: [])
|
||||
.opacity(0)
|
||||
.buttonStyle(.plain)
|
||||
.focusable(false)
|
||||
|
||||
Button("") {
|
||||
handlePaste(queueID: queueID)
|
||||
}
|
||||
.keyboardShortcut("v", modifiers: .command)
|
||||
.opacity(0)
|
||||
.buttonStyle(.plain)
|
||||
.focusable(false)
|
||||
|
||||
Button("") {
|
||||
selectAll(items: items)
|
||||
}
|
||||
.keyboardShortcut("a", modifiers: .command)
|
||||
.opacity(0)
|
||||
.buttonStyle(.plain)
|
||||
.focusable(false)
|
||||
|
||||
Button("") {
|
||||
if let item = selectedItems.first {
|
||||
@@ -162,6 +191,8 @@ struct ContentView: View {
|
||||
}
|
||||
.keyboardShortcut(.return, modifiers: [])
|
||||
.opacity(0)
|
||||
.buttonStyle(.plain)
|
||||
.focusable(false)
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Delete \(selection.count) Download\(selection.count == 1 ? "" : "s")",
|
||||
@@ -179,6 +210,14 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func updateDockBadge(count: Int, show: Bool) {
|
||||
if show && count > 0 {
|
||||
NSApp.dockTile.badgeLabel = "\(count)"
|
||||
} else {
|
||||
NSApp.dockTile.badgeLabel = nil
|
||||
}
|
||||
}
|
||||
|
||||
private var selectedItems: [DownloadItem] {
|
||||
controller.downloads.filter { selection.contains($0.id) }
|
||||
}
|
||||
@@ -243,3 +282,55 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct KeychainPrimerView: View {
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 24) {
|
||||
Image(systemName: "lock.shield")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: 64, height: 64)
|
||||
.foregroundStyle(settings.appTheme.theme.accent ?? Color.accentColor)
|
||||
.padding(.top, 16)
|
||||
|
||||
VStack(spacing: 8) {
|
||||
Text("Security Update")
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
|
||||
Text("Firelink has been updated. To keep your browser extension running smoothly and your site logins secure, please re-authorize access to your Mac's Keychain on the next prompt.")
|
||||
.font(.body)
|
||||
.multilineTextAlignment(.center)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
VStack(spacing: 12) {
|
||||
Button {
|
||||
settings.resolveKeychainPrimer(grantAccess: true)
|
||||
} label: {
|
||||
Text("Grant Secure Access")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
|
||||
Button {
|
||||
settings.resolveKeychainPrimer(grantAccess: false)
|
||||
} label: {
|
||||
Text("Not Now")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.controlSize(.large)
|
||||
}
|
||||
.padding(.horizontal, 32)
|
||||
.padding(.bottom, 16)
|
||||
}
|
||||
.padding(24)
|
||||
.frame(width: 400)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,12 +23,14 @@ final class DownloadController: ObservableObject {
|
||||
private var queuePumpScope: QueuePumpScope = .idle
|
||||
private var sleepActivity: SleepActivityHandle?
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private let maxAutomaticRetries = 3
|
||||
private lazy var storageURL: URL = {
|
||||
let supportDir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first ?? URL(fileURLWithPath: NSHomeDirectory())
|
||||
return supportDir.appendingPathComponent("Firelink").appendingPathComponent("downloads.json")
|
||||
}()
|
||||
private var saveTask: Task<Void, Never>?
|
||||
private var pendingNotifications: [(title: String, body: String)] = []
|
||||
private var notificationDebounceTask: Task<Void, Never>?
|
||||
private var lastProgressUpdateTimes: [UUID: Date] = [:]
|
||||
|
||||
init(settings: AppSettings) {
|
||||
self.settings = settings
|
||||
@@ -72,7 +74,7 @@ final class DownloadController: ObservableObject {
|
||||
|
||||
NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)
|
||||
.sink { [weak self] _ in
|
||||
self?.saveDownloads()
|
||||
self?.saveDownloadsSync()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
@@ -230,6 +232,7 @@ final class DownloadController: ObservableObject {
|
||||
}
|
||||
|
||||
func pause(_ item: DownloadItem) {
|
||||
lastProgressUpdateTimes[item.id] = nil
|
||||
activeHandles[item.id]?.cancel()
|
||||
activeHandles[item.id] = nil
|
||||
activeMediaHandles[item.id]?.cancel()
|
||||
@@ -368,12 +371,7 @@ final class DownloadController: ObservableObject {
|
||||
pumpQueue()
|
||||
}
|
||||
|
||||
func remove(at offsets: IndexSet, deleteFiles: Bool = false) {
|
||||
for index in offsets {
|
||||
let item = downloads[index]
|
||||
delete(item, deleteFiles: deleteFiles)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func delete(_ item: DownloadItem, deleteFiles: Bool = false) {
|
||||
activeHandles[item.id]?.cancel()
|
||||
@@ -498,6 +496,22 @@ final class DownloadController: ObservableObject {
|
||||
pruneActiveQueueScopes()
|
||||
}
|
||||
|
||||
private func injectedEngineItem(from item: DownloadItem) -> DownloadItem {
|
||||
var engineItem = item
|
||||
if engineItem.credentials != nil {
|
||||
if let storedPassword = KeychainCredentialStore.password(for: engineItem.id) {
|
||||
engineItem.credentials?.password = storedPassword
|
||||
}
|
||||
}
|
||||
let ua = settings.customUserAgent.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !ua.isEmpty, !(engineItem.requestHeaders?.contains(where: { $0.name.caseInsensitiveCompare("user-agent") == .orderedSame }) ?? false) {
|
||||
var headers = engineItem.requestHeaders ?? []
|
||||
headers.append(DownloadRequestHeader(name: "User-Agent", value: ua))
|
||||
engineItem.requestHeaders = headers
|
||||
}
|
||||
return engineItem
|
||||
}
|
||||
|
||||
private func start(_ item: DownloadItem) {
|
||||
update(item.id) {
|
||||
$0.status = .downloading
|
||||
@@ -524,12 +538,17 @@ final class DownloadController: ObservableObject {
|
||||
$0.message = "Starting yt-dlp..."
|
||||
}
|
||||
let handle = try await mediaEngine.start(
|
||||
item: liveItem,
|
||||
item: injectedEngineItem(from: liveItem),
|
||||
cookieSource: settings.mediaCookieSource,
|
||||
proxyConfiguration: settings.downloadProxyConfiguration,
|
||||
speedLimitKiBPerSecond: effectiveSpeedLimitKiBPerSecond(for: liveItem),
|
||||
progress: { [weak self] progress in
|
||||
Task { @MainActor in
|
||||
let now = Date()
|
||||
if let last = self?.lastProgressUpdateTimes[item.id], now.timeIntervalSince(last) < 0.25 {
|
||||
return
|
||||
}
|
||||
self?.lastProgressUpdateTimes[item.id] = now
|
||||
self?.update(item.id) {
|
||||
guard $0.status == .downloading else { return }
|
||||
$0.progress = progress.fraction
|
||||
@@ -574,44 +593,61 @@ final class DownloadController: ObservableObject {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
do {
|
||||
let handle = try engine.start(
|
||||
item: item,
|
||||
proxyConfiguration: settings.downloadProxyConfiguration,
|
||||
speedLimitKiBPerSecond: effectiveSpeedLimitKiBPerSecond(for: item),
|
||||
progress: { [weak self] progress in
|
||||
Task { @MainActor in
|
||||
self?.update(item.id) {
|
||||
guard $0.status == .downloading else { return }
|
||||
$0.progress = progress.fraction
|
||||
$0.bytesText = progress.bytesText
|
||||
$0.speedText = progress.speedText
|
||||
$0.etaText = progress.etaText
|
||||
$0.connectionCount = progress.connectionCount
|
||||
$0.message = "Downloading"
|
||||
Task {
|
||||
do {
|
||||
let liveItem = injectedEngineItem(from: item)
|
||||
let handle = try await engine.start(
|
||||
item: liveItem,
|
||||
proxyConfiguration: settings.downloadProxyConfiguration,
|
||||
speedLimitKiBPerSecond: effectiveSpeedLimitKiBPerSecond(for: liveItem),
|
||||
progress: { [weak self] progress in
|
||||
Task { @MainActor in
|
||||
let now = Date()
|
||||
if let last = self?.lastProgressUpdateTimes[item.id], now.timeIntervalSince(last) < 0.25 {
|
||||
return
|
||||
}
|
||||
self?.lastProgressUpdateTimes[item.id] = now
|
||||
self?.update(item.id) {
|
||||
guard $0.status == .downloading else { return }
|
||||
$0.progress = progress.fraction
|
||||
$0.bytesText = progress.bytesText
|
||||
$0.speedText = progress.speedText
|
||||
$0.etaText = progress.etaText
|
||||
$0.connectionCount = progress.connectionCount
|
||||
$0.message = "Downloading"
|
||||
}
|
||||
}
|
||||
},
|
||||
completion: { [weak self] result in
|
||||
Task { @MainActor in
|
||||
self?.handleCompletion(item: item, result: result)
|
||||
}
|
||||
}
|
||||
},
|
||||
completion: { [weak self] result in
|
||||
Task { @MainActor in
|
||||
self?.handleCompletion(item: item, result: result)
|
||||
)
|
||||
|
||||
await MainActor.run {
|
||||
guard activeDownloadItem(id: item.id) != nil else {
|
||||
handle.cancel()
|
||||
return
|
||||
}
|
||||
activeHandles[item.id] = handle
|
||||
update(item.id) {
|
||||
$0.rpcPort = handle.rpcPort
|
||||
$0.rpcSecret = handle.rpcSecret
|
||||
$0.message = "Starting..."
|
||||
}
|
||||
saveDownloads()
|
||||
applySpeedLimitsToActiveDownloads()
|
||||
updateSleepActivity()
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
handleDownloadFailure(itemID: item.id, error: error)
|
||||
applySpeedLimitsToActiveDownloads()
|
||||
updateSleepActivity()
|
||||
pumpQueue()
|
||||
}
|
||||
)
|
||||
activeHandles[item.id] = handle
|
||||
update(item.id) {
|
||||
$0.rpcPort = handle.rpcPort
|
||||
$0.rpcSecret = handle.rpcSecret
|
||||
$0.message = "Starting..."
|
||||
}
|
||||
saveDownloads()
|
||||
applySpeedLimitsToActiveDownloads()
|
||||
updateSleepActivity()
|
||||
} catch {
|
||||
handleDownloadFailure(itemID: item.id, error: error)
|
||||
applySpeedLimitsToActiveDownloads()
|
||||
updateSleepActivity()
|
||||
pumpQueue()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -796,7 +832,7 @@ final class DownloadController: ObservableObject {
|
||||
|
||||
let retryCount = automaticRetryCounts[itemID] ?? 0
|
||||
|
||||
guard isAutomaticallyRecoverable(error), retryCount < maxAutomaticRetries else {
|
||||
guard isAutomaticallyRecoverable(error), retryCount < settings.maxAutomaticRetries else {
|
||||
automaticRetryCounts[itemID] = nil
|
||||
update(itemID) {
|
||||
$0.status = .failed
|
||||
@@ -819,7 +855,7 @@ final class DownloadController: ObservableObject {
|
||||
$0.speedText = "-"
|
||||
$0.etaText = "-"
|
||||
$0.connectionCount = 0
|
||||
$0.message = "Connection interrupted. Retrying from partial file (\(retryCount + 1)/\(maxAutomaticRetries))."
|
||||
$0.message = "Connection interrupted. Retrying from partial file (\(retryCount + 1)/\(settings.maxAutomaticRetries))."
|
||||
$0.autoResumeOnLaunch = true
|
||||
}
|
||||
saveDownloads()
|
||||
@@ -967,6 +1003,22 @@ final class DownloadController: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func saveDownloadsSync() {
|
||||
let queuesCopy = queues
|
||||
let downloadsCopy = downloads.map(\.redactedForPersistence)
|
||||
let storageURL = self.storageURL
|
||||
|
||||
do {
|
||||
let directory = storageURL.deletingLastPathComponent()
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
|
||||
let state = StoredDownloadState(queues: queuesCopy, downloads: downloadsCopy)
|
||||
let data = try JSONEncoder().encode(state)
|
||||
try data.write(to: storageURL, options: .atomic)
|
||||
} catch {
|
||||
print("Failed to synchronously save downloads: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func saveDownloads() {
|
||||
let queuesCopy = queues
|
||||
let downloadsCopy = downloads.map(\.redactedForPersistence)
|
||||
@@ -1020,10 +1072,6 @@ final class DownloadController: ObservableObject {
|
||||
adjusted.queueID = DownloadQueue.mainQueueID
|
||||
}
|
||||
|
||||
if adjusted.credentials != nil, let storedPassword = KeychainCredentialStore.password(for: adjusted.id) {
|
||||
adjusted.credentials?.password = storedPassword
|
||||
}
|
||||
|
||||
if adjusted.status == .completed && adjusted.progress != 1 {
|
||||
adjusted.progress = 1
|
||||
shouldRewriteStoredDownloads = true
|
||||
@@ -1088,14 +1136,49 @@ final class DownloadController: ObservableObject {
|
||||
}
|
||||
|
||||
private func showNotification(title: String, body: String) {
|
||||
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { granted, _ in
|
||||
guard granted else { return }
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = title
|
||||
content.body = body
|
||||
content.sound = .default
|
||||
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
|
||||
UNUserNotificationCenter.current().add(request)
|
||||
guard settings.showNotifications || settings.playCompletionSound else { return }
|
||||
|
||||
pendingNotifications.append((title: title, body: body))
|
||||
notificationDebounceTask?.cancel()
|
||||
|
||||
notificationDebounceTask = Task { @MainActor in
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
guard !Task.isCancelled else { return }
|
||||
|
||||
var options: UNAuthorizationOptions = []
|
||||
if self.settings.showNotifications { options.insert(.alert) }
|
||||
if self.settings.playCompletionSound { options.insert(.sound) }
|
||||
|
||||
UNUserNotificationCenter.current().requestAuthorization(options: options) { [weak self] granted, _ in
|
||||
guard granted, let self else { return }
|
||||
Task { @MainActor in
|
||||
let items = self.pendingNotifications
|
||||
self.pendingNotifications.removeAll()
|
||||
guard !items.isEmpty else { return }
|
||||
|
||||
let content = UNMutableNotificationContent()
|
||||
if self.settings.showNotifications {
|
||||
if items.count == 1 {
|
||||
content.title = items[0].title
|
||||
content.body = items[0].body
|
||||
} else {
|
||||
content.title = "\(items.count) Downloads Completed"
|
||||
content.body = "Multiple items have finished downloading."
|
||||
}
|
||||
} else {
|
||||
content.title = "Download Completed"
|
||||
}
|
||||
|
||||
if self.settings.playCompletionSound {
|
||||
content.sound = .default
|
||||
}
|
||||
|
||||
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
|
||||
UNUserNotificationCenter.current().add(request)
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,9 +54,14 @@ enum DownloadMetadataFetcher {
|
||||
return pending
|
||||
}
|
||||
|
||||
if isAutoFetch, let host = url.host, isPrivateHost(host) {
|
||||
pending.state = .loaded
|
||||
return pending
|
||||
if isAutoFetch, let host = url.host {
|
||||
let isPrivate = await Task.detached {
|
||||
isPrivateHost(host)
|
||||
}.value
|
||||
if isPrivate {
|
||||
pending.state = .loaded
|
||||
return pending
|
||||
}
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
|
||||
@@ -60,7 +60,8 @@ struct DownloadPropertiesView: View {
|
||||
if let credentials = item.credentials {
|
||||
_loginMode = State(initialValue: .custom)
|
||||
_username = State(initialValue: credentials.username)
|
||||
_password = State(initialValue: credentials.password)
|
||||
let storedPassword = KeychainCredentialStore.password(for: item.id) ?? credentials.password
|
||||
_password = State(initialValue: storedPassword)
|
||||
} else {
|
||||
_loginMode = State(initialValue: .matching)
|
||||
_username = State(initialValue: "")
|
||||
|
||||
@@ -13,117 +13,131 @@ struct DownloadTable: View {
|
||||
|
||||
@State private var sortOrder = [KeyPathComparator(\DownloadItem.createdAt, order: .reverse)]
|
||||
@State private var pendingDeleteItems: Set<DownloadItem.ID>?
|
||||
|
||||
var sortedItems: [DownloadItem] {
|
||||
items.sorted(using: sortOrder)
|
||||
}
|
||||
@State private var sortedItems: [DownloadItem] = []
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
Text("\(items.count)")
|
||||
.font(.caption)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 7)
|
||||
.padding(.vertical, 3)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 5))
|
||||
.clipShape(Capsule())
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
|
||||
Table(sortedItems, selection: $selection, sortOrder: $sortOrder) {
|
||||
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)
|
||||
if items.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Downloads",
|
||||
systemImage: "arrow.down.circle",
|
||||
description: Text("Use Add or press \(Image(systemName: "command"))V to paste one or more links.")
|
||||
)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else {
|
||||
Table(sortedItems, selection: $selection, sortOrder: $sortOrder) {
|
||||
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)
|
||||
}
|
||||
.draggable(item.id.uuidString)
|
||||
}
|
||||
.width(min: 200, ideal: 340)
|
||||
.width(min: 200, ideal: 340)
|
||||
|
||||
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()
|
||||
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)
|
||||
}
|
||||
}
|
||||
.width(min: 80, ideal: 100)
|
||||
|
||||
TableColumn("Status", value: \.status.rawValue) { item in
|
||||
combinedStatusCell(for: item)
|
||||
}
|
||||
.width(min: 160, ideal: 200)
|
||||
|
||||
TableColumn("Speed", value: \.displaySpeedText) { item in
|
||||
if item.status == .downloading {
|
||||
formattedSpeedCell(for: item.displaySpeedText)
|
||||
} else {
|
||||
Text("-")
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.width(min: 80, ideal: 100)
|
||||
|
||||
TableColumn("ETA", value: \.displayETAText) { item in
|
||||
if item.status == .downloading {
|
||||
formattedETACell(for: item.displayETAText)
|
||||
} else {
|
||||
Text("-")
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
.width(min: 80, ideal: 100)
|
||||
|
||||
TableColumn("Date Added", value: \.createdAt) { item in
|
||||
Text(formatted(item.createdAt))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
.width(min: 100, ideal: 155)
|
||||
}
|
||||
.width(min: 80, ideal: 100)
|
||||
|
||||
TableColumn("Progress", value: \.progress) { item in
|
||||
progressBarCell(for: item)
|
||||
}
|
||||
.width(min: 100, ideal: 115)
|
||||
|
||||
TableColumn("Status", value: \.status.rawValue) { item in
|
||||
statusCell(for: item)
|
||||
}
|
||||
.width(min: 115, ideal: 170)
|
||||
|
||||
TableColumn("Speed", value: \.displaySpeedText) { item in
|
||||
Text(item.displaySpeedText)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
.width(min: 70, ideal: 90)
|
||||
|
||||
TableColumn("ETA", value: \.displayETAText) { item in
|
||||
Text(item.displayETAText)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
.width(min: 70, ideal: 90)
|
||||
|
||||
TableColumn("Date Added", value: \.createdAt) { item in
|
||||
Text(formatted(item.createdAt))
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
.width(min: 100, ideal: 155)
|
||||
}
|
||||
.environment(\.defaultMinListRowHeight, settings.listRowDensity.minRowHeight)
|
||||
.contextMenu(forSelectionType: DownloadItem.ID.self) { itemIDs in
|
||||
rowContextMenu(for: itemIDs)
|
||||
} primaryAction: { itemIDs in
|
||||
let targetItems = controller.downloads.filter { itemIDs.contains($0.id) }
|
||||
for target in targetItems {
|
||||
if target.status == .completed {
|
||||
openFile(target)
|
||||
.environment(\.defaultMinListRowHeight, settings.listRowDensity.minRowHeight)
|
||||
.animation(.default, value: sortedItems)
|
||||
.contextMenu(forSelectionType: DownloadItem.ID.self) { itemIDs in
|
||||
rowContextMenu(for: itemIDs)
|
||||
} primaryAction: { itemIDs in
|
||||
let targetItems = controller.downloads.filter { itemIDs.contains($0.id) }
|
||||
for target in targetItems {
|
||||
if target.status == .completed {
|
||||
openFile(target)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
if items.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"No Downloads",
|
||||
systemImage: "arrow.down.circle",
|
||||
description: Text("Use Add or press \(Image(systemName: "command"))V to paste one or more links.")
|
||||
)
|
||||
}
|
||||
}
|
||||
.onAppear { sortedItems = items.sorted(using: sortOrder) }
|
||||
.onChange(of: items) { _, newItems in
|
||||
let existingIDs = Set(sortedItems.map(\.id))
|
||||
let newIDs = Set(newItems.map(\.id))
|
||||
if existingIDs != newIDs {
|
||||
sortedItems = newItems.sorted(using: sortOrder)
|
||||
} else {
|
||||
let itemsDict = Dictionary(uniqueKeysWithValues: newItems.map { ($0.id, $0) })
|
||||
sortedItems = sortedItems.compactMap { itemsDict[$0.id] }
|
||||
}
|
||||
}
|
||||
.onChange(of: sortOrder) { _, newOrder in
|
||||
sortedItems = items.sorted(using: newOrder)
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Delete Download",
|
||||
isPresented: Binding(
|
||||
@@ -164,19 +178,81 @@ struct DownloadTable: View {
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func statusCell(for item: DownloadItem) -> some View {
|
||||
let message = item.message.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if item.status == .downloading, !message.isEmpty {
|
||||
Text(message)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
private func combinedStatusCell(for item: DownloadItem) -> some View {
|
||||
if item.status == .completed {
|
||||
Text("Completed")
|
||||
.foregroundStyle(.green)
|
||||
.fontWeight(.medium)
|
||||
} else {
|
||||
Text(item.status.rawValue)
|
||||
HStack(spacing: 8) {
|
||||
ProgressView(value: item.progress)
|
||||
.progressViewStyle(.linear)
|
||||
.tint(statusColor(for: item.status))
|
||||
|
||||
if item.status == .downloading {
|
||||
Text(item.progress.formatted(.percent.precision(.fractionLength(0))))
|
||||
.font(.system(size: 11, weight: .bold, design: .rounded))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 35, alignment: .trailing)
|
||||
} else {
|
||||
Text(item.status.rawValue.capitalized)
|
||||
.font(.system(size: 11, weight: .medium, design: .rounded))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func parseSpeed(_ text: String) -> [String] {
|
||||
var display = text
|
||||
|
||||
if let index = display.firstIndex(where: { $0.isLetter }) {
|
||||
if display.distance(from: display.startIndex, to: index) > 0 {
|
||||
let prevIndex = display.index(before: index)
|
||||
if display[prevIndex] != " " {
|
||||
display.insert(" ", at: index)
|
||||
}
|
||||
}
|
||||
}
|
||||
return display.split(separator: " ", maxSplits: 1).map(String.init)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func formattedSpeedCell(for text: String) -> some View {
|
||||
let components = parseSpeed(text)
|
||||
if components.count == 2 {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 1) {
|
||||
Text(components[0])
|
||||
.font(.system(size: 13, weight: .semibold, design: .rounded))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.primary)
|
||||
Text(components[1])
|
||||
.font(.system(size: 10, weight: .medium, design: .rounded))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
} else {
|
||||
Text(components.joined(separator: " "))
|
||||
.font(.system(size: 13, weight: .semibold, design: .rounded))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func formattedETACell(for text: String) -> some View {
|
||||
Text(text)
|
||||
.font(.system(size: 13, weight: .medium, design: .rounded))
|
||||
.monospacedDigit()
|
||||
.foregroundStyle(.primary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func rowContextMenu(for itemIDs: Set<DownloadItem.ID>) -> some View {
|
||||
let targetItems = controller.downloads.filter { itemIDs.contains($0.id) }
|
||||
@@ -197,7 +273,7 @@ struct DownloadTable: View {
|
||||
showInFinder(target)
|
||||
}
|
||||
} label: {
|
||||
Label(targetItems.count > 1 ? "Show in Finder (\(targetItems.count))" : "Show in Finder", systemImage: "finder")
|
||||
Label(targetItems.count > 1 ? "Show in Finder (\(targetItems.count))" : "Show in Finder", systemImage: "magnifyingglass")
|
||||
}
|
||||
|
||||
Divider()
|
||||
@@ -208,7 +284,7 @@ struct DownloadTable: View {
|
||||
controller.resume(target)
|
||||
}
|
||||
} label: {
|
||||
Label("Start", systemImage: "play.fill")
|
||||
Label("Resume", systemImage: "play.fill")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,7 +294,7 @@ struct DownloadTable: View {
|
||||
controller.pause(target)
|
||||
}
|
||||
} label: {
|
||||
Label("Stop", systemImage: "stop.fill")
|
||||
Label("Pause", systemImage: "pause.fill")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,12 +334,16 @@ struct DownloadTable: View {
|
||||
Label(targetItems.count > 1 ? "Copy Addresses" : "Copy Address", systemImage: "link")
|
||||
}
|
||||
|
||||
Button {
|
||||
for target in targetItems {
|
||||
openWindow(id: "download-properties", value: target.id)
|
||||
if targetItems.allSatisfy({ $0.status == .completed }) {
|
||||
Button {
|
||||
NSPasteboard.general.clearContents()
|
||||
let paths = targetItems.map { $0.destinationPath }.joined(separator: "\n")
|
||||
if !paths.isEmpty {
|
||||
NSPasteboard.general.setString(paths, forType: .string)
|
||||
}
|
||||
} label: {
|
||||
Label(targetItems.count > 1 ? "Copy File Paths" : "Copy File Path", systemImage: "doc.on.doc")
|
||||
}
|
||||
} label: {
|
||||
Label(targetItems.count > 1 ? "Properties (\(targetItems.count))" : "Properties", systemImage: "info.circle")
|
||||
}
|
||||
|
||||
Divider()
|
||||
@@ -271,7 +351,17 @@ struct DownloadTable: View {
|
||||
Button(role: .destructive) {
|
||||
pendingDeleteItems = itemIDs
|
||||
} label: {
|
||||
Label("Remove", systemImage: "trash")
|
||||
Label("Remove from List", systemImage: "trash")
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Button {
|
||||
for target in targetItems {
|
||||
openWindow(id: "download-properties", value: target.id)
|
||||
}
|
||||
} label: {
|
||||
Label(targetItems.count > 1 ? "Properties (\(targetItems.count))" : "Properties", systemImage: "info.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -298,32 +388,7 @@ struct DownloadTable: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func progressBarCell(for item: DownloadItem) -> some View {
|
||||
if item.status == .completed {
|
||||
Text("Completed")
|
||||
.foregroundStyle(.green)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
} else {
|
||||
GeometryReader { proxy in
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color.secondary.opacity(0.15))
|
||||
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(statusColor(for: item.status))
|
||||
.frame(width: max(0, proxy.size.width * item.progress))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
Text(item.progress.formatted(.percent.precision(.fractionLength(0))))
|
||||
.font(.system(size: 11, weight: .medium, design: .monospaced))
|
||||
.foregroundColor(.primary)
|
||||
}
|
||||
}
|
||||
.frame(height: 16)
|
||||
}
|
||||
}
|
||||
|
||||
private func formatted(_ date: Date?) -> String {
|
||||
guard let date else { return "-" }
|
||||
|
||||
@@ -10,7 +10,8 @@ enum KeychainCredentialStore {
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: id.uuidString,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
kSecUseAuthenticationUI as String: kSecUseAuthenticationUIFail
|
||||
]
|
||||
|
||||
var result: CFTypeRef?
|
||||
@@ -57,7 +58,8 @@ enum KeychainCredentialStore {
|
||||
kSecAttrService as String: extensionTokenService,
|
||||
kSecAttrAccount as String: extensionTokenAccount,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
kSecUseAuthenticationUI as String: kSecUseAuthenticationUIFail
|
||||
]
|
||||
|
||||
var result: CFTypeRef?
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import AppKit
|
||||
import Combine
|
||||
|
||||
final class LocalExtensionServer: @unchecked Sendable {
|
||||
private enum Constants {
|
||||
@@ -16,7 +17,12 @@ final class LocalExtensionServer: @unchecked Sendable {
|
||||
private let settings: AppSettings
|
||||
private let queue = DispatchQueue(label: "local.firelink.server")
|
||||
let port: UInt16
|
||||
|
||||
private let tokenLock = NSLock()
|
||||
private var _pairingToken: String = ""
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
@MainActor
|
||||
init?(downloadController: DownloadController, settings: AppSettings) {
|
||||
self.downloadController = downloadController
|
||||
self.settings = settings
|
||||
@@ -42,6 +48,16 @@ final class LocalExtensionServer: @unchecked Sendable {
|
||||
|
||||
self.listener = createdListener
|
||||
self.port = selectedPort ?? 6412
|
||||
|
||||
settings.$extensionPairingToken
|
||||
.sink { [weak self] token in
|
||||
self?.tokenLock.withLock { self?._pairingToken = token }
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private var currentPairingToken: String {
|
||||
tokenLock.withLock { _pairingToken }
|
||||
}
|
||||
|
||||
func start() {
|
||||
@@ -56,12 +72,17 @@ final class LocalExtensionServer: @unchecked Sendable {
|
||||
|
||||
private func handleConnection(_ connection: NWConnection) {
|
||||
connection.start(queue: queue)
|
||||
receiveRequest(from: connection, accumulatedData: Data())
|
||||
let timeoutItem = DispatchWorkItem { [weak connection] in
|
||||
connection?.cancel()
|
||||
}
|
||||
queue.asyncAfter(deadline: .now() + 5.0, execute: timeoutItem)
|
||||
receiveRequest(from: connection, accumulatedData: Data(), timeoutItem: timeoutItem)
|
||||
}
|
||||
|
||||
private func receiveRequest(from connection: NWConnection, accumulatedData: Data) {
|
||||
private func receiveRequest(from connection: NWConnection, accumulatedData: Data, timeoutItem: DispatchWorkItem) {
|
||||
connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] data, _, isComplete, error in
|
||||
guard let self else {
|
||||
timeoutItem.cancel()
|
||||
connection.cancel()
|
||||
return
|
||||
}
|
||||
@@ -72,22 +93,25 @@ final class LocalExtensionServer: @unchecked Sendable {
|
||||
}
|
||||
|
||||
guard error == nil, requestData.count <= Constants.maxRequestBytes else {
|
||||
timeoutItem.cancel()
|
||||
self.sendResponse(.payloadTooLarge, connection: connection, origin: nil)
|
||||
return
|
||||
}
|
||||
|
||||
if let request = HTTPRequest(data: requestData) {
|
||||
timeoutItem.cancel()
|
||||
let status = self.processRequest(request)
|
||||
self.sendResponse(status, connection: connection, origin: request.header(named: "origin"))
|
||||
return
|
||||
}
|
||||
|
||||
if isComplete {
|
||||
timeoutItem.cancel()
|
||||
self.sendResponse(.badRequest, connection: connection, origin: nil)
|
||||
return
|
||||
}
|
||||
|
||||
self.receiveRequest(from: connection, accumulatedData: requestData)
|
||||
self.receiveRequest(from: connection, accumulatedData: requestData, timeoutItem: timeoutItem)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,7 +155,7 @@ final class LocalExtensionServer: @unchecked Sendable {
|
||||
return isAllowedExtensionOrigin(request.header(named: "origin") ?? "") ? .noContent : .forbidden
|
||||
}
|
||||
|
||||
let expectedToken = DispatchQueue.main.sync { settings.extensionPairingToken }
|
||||
let expectedToken = currentPairingToken
|
||||
guard let token = request.header(named: Constants.extensionRequestHeader),
|
||||
token == expectedToken else {
|
||||
return .forbidden
|
||||
@@ -177,10 +201,17 @@ final class LocalExtensionServer: @unchecked Sendable {
|
||||
}
|
||||
|
||||
Task { @MainActor in
|
||||
self.downloadController.pendingPasteboardText = validURLs.joined(separator: "\n")
|
||||
self.downloadController.pendingReferer = payload.referer
|
||||
NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
if self.settings.askWhereToSaveEachFile {
|
||||
self.downloadController.pendingPasteboardText = validURLs.joined(separator: "\n")
|
||||
self.downloadController.pendingReferer = payload.referer
|
||||
NotificationCenter.default.post(name: NSNotification.Name("OpenAddDownloadsWindow"), object: nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
} else {
|
||||
for validURL in validURLs {
|
||||
self.downloadController.add(urlText: validURL)
|
||||
}
|
||||
self.downloadController.startQueue()
|
||||
}
|
||||
}
|
||||
|
||||
return .ok
|
||||
|
||||
@@ -45,6 +45,9 @@ final class MediaDownloadEngine: @unchecked Sendable {
|
||||
"--newline",
|
||||
"--ffmpeg-location", ffmpegURL.path,
|
||||
"--force-ipv4",
|
||||
"--live-from-start",
|
||||
"--extractor-args", "youtube:player_client=ios,web",
|
||||
"--compat-options", "no-youtube-unavailable-videos",
|
||||
"-o", item.destinationPath
|
||||
]
|
||||
|
||||
@@ -97,16 +100,10 @@ final class MediaDownloadEngine: @unchecked Sendable {
|
||||
messageUpdate: messageUpdate
|
||||
)
|
||||
|
||||
let group = DispatchGroup()
|
||||
group.enter() // output
|
||||
group.enter() // error
|
||||
group.enter() // process
|
||||
|
||||
outputPipe.fileHandleForReading.readabilityHandler = { handle in
|
||||
let data = handle.availableData
|
||||
if data.isEmpty {
|
||||
handle.readabilityHandler = nil
|
||||
group.leave()
|
||||
} else if let text = String(data: data, encoding: .utf8) {
|
||||
outputHandler.handle(text)
|
||||
}
|
||||
@@ -116,7 +113,6 @@ final class MediaDownloadEngine: @unchecked Sendable {
|
||||
let data = handle.availableData
|
||||
if data.isEmpty {
|
||||
handle.readabilityHandler = nil
|
||||
group.leave()
|
||||
} else {
|
||||
errorBuffer.append(data)
|
||||
if let text = String(data: data, encoding: .utf8) {
|
||||
@@ -125,16 +121,15 @@ final class MediaDownloadEngine: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
process.terminationHandler = { _ in
|
||||
group.leave()
|
||||
}
|
||||
|
||||
group.notify(queue: .global()) {
|
||||
if process.terminationStatus == 0 {
|
||||
process.terminationHandler = { finishedProcess in
|
||||
outputPipe.fileHandleForReading.readabilityHandler = nil
|
||||
errorPipe.fileHandleForReading.readabilityHandler = nil
|
||||
|
||||
if finishedProcess.terminationStatus == 0 {
|
||||
completionGate.complete(.success(Self.resolvedOutputURL(for: item, tracker: outputPathTracker)))
|
||||
} else {
|
||||
let errorString = String(data: errorBuffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "Unknown Error"
|
||||
completionGate.complete(.failure(EngineError.launchFailed(Self.cleanErrorMessage(errorString, status: process.terminationStatus))))
|
||||
completionGate.complete(.failure(EngineError.launchFailed(Self.cleanErrorMessage(errorString, status: finishedProcess.terminationStatus))))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,21 +156,23 @@ final class MediaDownloadEngine: @unchecked Sendable {
|
||||
}
|
||||
|
||||
let baseName = expectedURL.deletingPathExtension().lastPathComponent
|
||||
guard let contents = try? FileManager.default.contentsOfDirectory(
|
||||
at: item.destinationDirectory,
|
||||
includingPropertiesForKeys: [.contentModificationDateKey],
|
||||
options: [.skipsHiddenFiles]
|
||||
) else {
|
||||
return expectedURL
|
||||
let commonExtensions = ["mp4", "mkv", "webm", "mp3", "m4a", "opus", "m4v", "aac", "wav", "flac"]
|
||||
|
||||
var mostRecent: URL?
|
||||
var mostRecentDate: Date = .distantPast
|
||||
|
||||
for ext in commonExtensions {
|
||||
let candidate = item.destinationDirectory.appendingPathComponent("\(baseName).\(ext)")
|
||||
if FileManager.default.fileExists(atPath: candidate.path) {
|
||||
let date = (try? candidate.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast
|
||||
if date >= mostRecentDate {
|
||||
mostRecentDate = date
|
||||
mostRecent = candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
return mostRecent ?? expectedURL
|
||||
}
|
||||
|
||||
private static func cleanErrorMessage(_ message: String, status: Int32) -> String {
|
||||
|
||||
@@ -26,6 +26,11 @@ struct MediaMetadata: Decodable, Sendable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
enum MediaType: String, Sendable, Equatable {
|
||||
case video = "Video"
|
||||
case audio = "Audio"
|
||||
}
|
||||
|
||||
struct CleanFormatOption: Identifiable, Equatable, Sendable {
|
||||
var id: String { name }
|
||||
let name: String
|
||||
@@ -35,6 +40,10 @@ struct CleanFormatOption: Identifiable, Equatable, Sendable {
|
||||
let outputExtension: String
|
||||
let detail: String
|
||||
let estimatedBytes: Int64?
|
||||
|
||||
let mediaType: MediaType
|
||||
let qualityName: String
|
||||
let containerName: String
|
||||
}
|
||||
|
||||
enum MediaExtractionEngine {
|
||||
@@ -60,7 +69,8 @@ enum MediaExtractionEngine {
|
||||
for url: URL,
|
||||
cookieSource: BrowserCookieSource,
|
||||
credentials: DownloadCredentials?,
|
||||
transferOptions: DownloadTransferOptions
|
||||
transferOptions: DownloadTransferOptions,
|
||||
proxyConfiguration: DownloadProxyConfiguration
|
||||
) async throws -> (MediaMetadata, [CleanFormatOption]) {
|
||||
guard let ytDlpURL = await MediaEngineManager.shared.binaryPath(for: .ytDlp),
|
||||
FileManager.default.isExecutableFile(atPath: ytDlpURL.path) else {
|
||||
@@ -68,7 +78,20 @@ enum MediaExtractionEngine {
|
||||
}
|
||||
let ytDlpPath = ytDlpURL.path
|
||||
|
||||
var args = ["-J", "--no-warnings", "--ignore-no-formats-error", "--no-playlist", "--force-ipv4"]
|
||||
var args = [
|
||||
"-J",
|
||||
"--no-warnings",
|
||||
"--ignore-no-formats-error",
|
||||
"--no-playlist",
|
||||
"--force-ipv4",
|
||||
"--extractor-args", "youtube:player_client=ios,web",
|
||||
"--compat-options", "no-youtube-unavailable-videos"
|
||||
]
|
||||
|
||||
if let proxyURI = proxyConfiguration.customProxyURI, proxyConfiguration.mode == .custom {
|
||||
args.append(contentsOf: ["--proxy", proxyURI])
|
||||
}
|
||||
|
||||
appendCommonArguments(to: &args, cookieSource: cookieSource, credentials: credentials, transferOptions: transferOptions)
|
||||
args.append(url.absoluteString)
|
||||
|
||||
@@ -194,7 +217,10 @@ enum MediaExtractionEngine {
|
||||
base: height == nil ? "Best available video" : "Up to \(qualityName)",
|
||||
estimatedBytes: estimatedBytes
|
||||
),
|
||||
estimatedBytes: estimatedBytes
|
||||
estimatedBytes: estimatedBytes,
|
||||
mediaType: .video,
|
||||
qualityName: qualityName,
|
||||
containerName: containerName
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -208,7 +234,10 @@ enum MediaExtractionEngine {
|
||||
symbol: "music.note",
|
||||
outputExtension: "mp3",
|
||||
detail: optionDetail(base: "Converted with ffmpeg", estimatedBytes: estimatedBytes),
|
||||
estimatedBytes: estimatedBytes
|
||||
estimatedBytes: estimatedBytes,
|
||||
mediaType: .audio,
|
||||
qualityName: "Best",
|
||||
containerName: "MP3"
|
||||
))
|
||||
}
|
||||
|
||||
@@ -221,7 +250,10 @@ enum MediaExtractionEngine {
|
||||
symbol: "waveform",
|
||||
outputExtension: "m4a",
|
||||
detail: optionDetail(base: "Prefer native M4A", estimatedBytes: estimatedBytes),
|
||||
estimatedBytes: estimatedBytes
|
||||
estimatedBytes: estimatedBytes,
|
||||
mediaType: .audio,
|
||||
qualityName: "Best",
|
||||
containerName: "M4A"
|
||||
))
|
||||
}
|
||||
|
||||
@@ -234,7 +266,10 @@ enum MediaExtractionEngine {
|
||||
symbol: "waveform",
|
||||
outputExtension: "opus",
|
||||
detail: optionDetail(base: "Efficient audio", estimatedBytes: estimatedBytes),
|
||||
estimatedBytes: estimatedBytes
|
||||
estimatedBytes: estimatedBytes,
|
||||
mediaType: .audio,
|
||||
qualityName: "Best",
|
||||
containerName: "Opus"
|
||||
))
|
||||
}
|
||||
|
||||
@@ -381,16 +416,10 @@ private final class YTDLPMetadataProcess: @unchecked Sendable {
|
||||
process.standardError = errorPipe
|
||||
process.standardInput = nil
|
||||
|
||||
let group = DispatchGroup()
|
||||
group.enter() // output
|
||||
group.enter() // error
|
||||
group.enter() // process
|
||||
|
||||
outputPipe.fileHandleForReading.readabilityHandler = { handle in
|
||||
let data = handle.availableData
|
||||
if data.isEmpty {
|
||||
handle.readabilityHandler = nil
|
||||
group.leave()
|
||||
} else {
|
||||
outputBuffer.append(data)
|
||||
}
|
||||
@@ -400,7 +429,6 @@ private final class YTDLPMetadataProcess: @unchecked Sendable {
|
||||
let data = handle.availableData
|
||||
if data.isEmpty {
|
||||
handle.readabilityHandler = nil
|
||||
group.leave()
|
||||
} else {
|
||||
errorBuffer.append(data)
|
||||
}
|
||||
@@ -410,12 +438,11 @@ private final class YTDLPMetadataProcess: @unchecked Sendable {
|
||||
self.process = process
|
||||
}
|
||||
|
||||
process.terminationHandler = { _ in
|
||||
group.leave()
|
||||
}
|
||||
|
||||
group.notify(queue: .global()) {
|
||||
if process.terminationStatus == 0 {
|
||||
process.terminationHandler = { finishedProcess in
|
||||
outputPipe.fileHandleForReading.readabilityHandler = nil
|
||||
errorPipe.fileHandleForReading.readabilityHandler = nil
|
||||
|
||||
if finishedProcess.terminationStatus == 0 {
|
||||
continuation.resume(returning: outputBuffer.data)
|
||||
} else {
|
||||
let stderr = String(data: errorBuffer.data, encoding: .utf8)?
|
||||
@@ -428,7 +455,7 @@ private final class YTDLPMetadataProcess: @unchecked Sendable {
|
||||
.joined(separator: "\n")
|
||||
continuation.resume(
|
||||
throwing: MediaExtractionEngine.ExtractionError.processFailed(
|
||||
message.isEmpty ? "Exit code \(process.terminationStatus)" : message
|
||||
message.isEmpty ? "Exit code \(finishedProcess.terminationStatus)" : message
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,13 +18,13 @@ enum SchedulerDay: Int, Codable, CaseIterable, Identifiable {
|
||||
|
||||
var shortName: String {
|
||||
switch self {
|
||||
case .sunday: "S"
|
||||
case .monday: "M"
|
||||
case .tuesday: "T"
|
||||
case .wednesday: "W"
|
||||
case .thursday: "T"
|
||||
case .friday: "F"
|
||||
case .saturday: "S"
|
||||
case .sunday: "Su"
|
||||
case .monday: "Mo"
|
||||
case .tuesday: "Tu"
|
||||
case .wednesday: "We"
|
||||
case .thursday: "Th"
|
||||
case .friday: "Fr"
|
||||
case .saturday: "Sa"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,8 @@ enum SchedulerDay: Int, Codable, CaseIterable, Identifiable {
|
||||
struct SchedulerSettings: Codable, Equatable {
|
||||
var isEnabled: Bool = false
|
||||
var startTime: Date = Calendar.current.date(bySettingHour: 0, minute: 0, second: 0, of: Date()) ?? Date()
|
||||
var stopTimeEnabled: Bool = false
|
||||
var stopTime: Date = Calendar.current.date(bySettingHour: 8, minute: 0, second: 0, of: Date()) ?? Date()
|
||||
var isEveryday: Bool = true
|
||||
var selectedDays: Set<SchedulerDay> = Set(SchedulerDay.allCases)
|
||||
var postQueueAction: PostQueueAction = .doNothing
|
||||
@@ -117,19 +119,36 @@ final class SchedulerController: ObservableObject {
|
||||
let currentMinute = calendar.component(.minute, from: now)
|
||||
let currentWeekday = calendar.component(.weekday, from: now)
|
||||
|
||||
if startHour == currentHour && startMinute == currentMinute {
|
||||
let shouldRun: Bool
|
||||
if settings.isEveryday {
|
||||
shouldRun = true
|
||||
} else {
|
||||
let day = SchedulerDay(rawValue: currentWeekday)
|
||||
shouldRun = day.map { settings.selectedDays.contains($0) } ?? false
|
||||
}
|
||||
let shouldRunToday: Bool
|
||||
if settings.isEveryday {
|
||||
shouldRunToday = true
|
||||
} else {
|
||||
let day = SchedulerDay(rawValue: currentWeekday)
|
||||
shouldRunToday = day.map { settings.selectedDays.contains($0) } ?? false
|
||||
}
|
||||
|
||||
if shouldRun {
|
||||
if shouldRunToday {
|
||||
if startHour == currentHour && startMinute == currentMinute {
|
||||
lastTriggeredMinute = now
|
||||
triggerQueues()
|
||||
}
|
||||
|
||||
if settings.stopTimeEnabled {
|
||||
let stopHour = calendar.component(.hour, from: settings.stopTime)
|
||||
let stopMinute = calendar.component(.minute, from: settings.stopTime)
|
||||
|
||||
if stopHour == currentHour && stopMinute == currentMinute {
|
||||
lastTriggeredMinute = now
|
||||
pauseQueues()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func pauseQueues() {
|
||||
let targetQueueIDs = effectiveTargetQueueIDs()
|
||||
for queueID in targetQueueIDs {
|
||||
downloadController.pauseActiveDownloads(queueID: queueID)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ struct SchedulerView: View {
|
||||
// Local state to hold edits before saving
|
||||
@State private var isEnabled: Bool = false
|
||||
@State private var startTime: Date = Date()
|
||||
@State private var stopTimeEnabled: Bool = false
|
||||
@State private var stopTime: Date = Date()
|
||||
@State private var isEveryday: Bool = true
|
||||
@State private var selectedDays: Set<SchedulerDay> = []
|
||||
@State private var postQueueAction: PostQueueAction = .doNothing
|
||||
@@ -19,8 +21,8 @@ struct SchedulerView: View {
|
||||
Divider()
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
Group {
|
||||
timeSelectionSection
|
||||
queueSelectionSection
|
||||
postActionSection
|
||||
@@ -28,7 +30,6 @@ struct SchedulerView: View {
|
||||
.opacity(isEnabled ? 1.0 : 0.5)
|
||||
.disabled(!isEnabled)
|
||||
|
||||
Divider()
|
||||
permissionsSection
|
||||
}
|
||||
.padding(24)
|
||||
@@ -78,99 +79,149 @@ struct SchedulerView: View {
|
||||
}
|
||||
|
||||
private var timeSelectionSection: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Start Time")
|
||||
.font(.headline)
|
||||
GroupBox {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack {
|
||||
Label("Timing", systemImage: "clock")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
DatePicker("Time", selection: $startTime, displayedComponents: [.hourAndMinute])
|
||||
.datePickerStyle(.stepperField)
|
||||
.labelsHidden()
|
||||
HStack(spacing: 24) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Start Time")
|
||||
.foregroundStyle(.secondary)
|
||||
DatePicker("Start", selection: $startTime, displayedComponents: [.hourAndMinute])
|
||||
.datePickerStyle(.stepperField)
|
||||
.labelsHidden()
|
||||
}
|
||||
|
||||
Toggle("Everyday", isOn: $isEveryday)
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Toggle("Stop Time", isOn: $stopTimeEnabled)
|
||||
.foregroundStyle(.secondary)
|
||||
DatePicker("Stop", selection: $stopTime, displayedComponents: [.hourAndMinute])
|
||||
.datePickerStyle(.stepperField)
|
||||
.labelsHidden()
|
||||
.disabled(!stopTimeEnabled)
|
||||
.opacity(stopTimeEnabled ? 1.0 : 0.5)
|
||||
}
|
||||
}
|
||||
|
||||
if !isEveryday {
|
||||
HStack(spacing: 12) {
|
||||
ForEach(SchedulerDay.allCases) { day in
|
||||
Toggle(day.shortName, isOn: Binding(
|
||||
get: { selectedDays.contains(day) },
|
||||
set: { isSelected in
|
||||
if isSelected {
|
||||
selectedDays.insert(day)
|
||||
} else {
|
||||
Divider()
|
||||
|
||||
Toggle("Run Every Day", isOn: $isEveryday)
|
||||
|
||||
if !isEveryday {
|
||||
HStack(spacing: 8) {
|
||||
ForEach(SchedulerDay.allCases) { day in
|
||||
Button(action: {
|
||||
if selectedDays.contains(day) {
|
||||
selectedDays.remove(day)
|
||||
} else {
|
||||
selectedDays.insert(day)
|
||||
}
|
||||
}) {
|
||||
Text(day.shortName)
|
||||
.fontWeight(.medium)
|
||||
.frame(width: 28, height: 28)
|
||||
}
|
||||
))
|
||||
.toggleStyle(.button)
|
||||
.buttonStyle(.borderless)
|
||||
.background(selectedDays.contains(day) ? Color.accentColor : Color(nsColor: .controlBackgroundColor))
|
||||
.foregroundStyle(selectedDays.contains(day) ? Color.white : Color.primary)
|
||||
.clipShape(Circle())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
}
|
||||
}
|
||||
|
||||
private var queueSelectionSection: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Queues to Start")
|
||||
.font(.headline)
|
||||
GroupBox {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack {
|
||||
Label("Queues to Schedule", systemImage: "list.bullet.rectangle")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
if downloadController.queues.isEmpty {
|
||||
Text("No queues available")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(downloadController.queues) { queue in
|
||||
Toggle(queue.name, isOn: Binding(
|
||||
get: { targetQueueIDs.contains(queue.id) },
|
||||
set: { isSelected in
|
||||
if isSelected {
|
||||
targetQueueIDs.insert(queue.id)
|
||||
} else {
|
||||
targetQueueIDs.remove(queue.id)
|
||||
if downloadController.queues.isEmpty {
|
||||
Text("No queues available")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
ForEach(downloadController.queues) { queue in
|
||||
Toggle(queue.name, isOn: Binding(
|
||||
get: { targetQueueIDs.contains(queue.id) },
|
||||
set: { isSelected in
|
||||
if isSelected {
|
||||
targetQueueIDs.insert(queue.id)
|
||||
} else {
|
||||
targetQueueIDs.remove(queue.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
))
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
}
|
||||
}
|
||||
|
||||
private var postActionSection: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("After Completion")
|
||||
.font(.headline)
|
||||
|
||||
Picker("Action", selection: $postQueueAction) {
|
||||
ForEach(PostQueueAction.allCases) { action in
|
||||
Text(action.rawValue).tag(action)
|
||||
GroupBox {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack {
|
||||
Label("After Completion", systemImage: "powersleep")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
Text("Choose what happens after scheduled downloads finish.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Picker("Action", selection: $postQueueAction) {
|
||||
ForEach(PostQueueAction.allCases) { action in
|
||||
Text(action.rawValue).tag(action)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.radioGroup)
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.radioGroup)
|
||||
.padding(8)
|
||||
}
|
||||
}
|
||||
|
||||
private var permissionsSection: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("System Permissions")
|
||||
.font(.headline)
|
||||
|
||||
Text("Firelink needs Automation permission to control Finder in order to automatically sleep, restart, or shut down your Mac after downloads finish.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
if schedulerController.hasAutomationPermission {
|
||||
Button("Revoke Permissions") {
|
||||
schedulerController.openAutomationPermissionSettings()
|
||||
GroupBox {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
HStack {
|
||||
Label("System Permissions", systemImage: "lock.shield")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
} else {
|
||||
Button("Grant Permission") {
|
||||
schedulerController.requestAutomationPermission()
|
||||
|
||||
Text("Firelink needs Automation permission to control Finder in order to automatically sleep, restart, or shut down your Mac after downloads finish.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
if schedulerController.hasAutomationPermission {
|
||||
Button("Revoke Permissions") {
|
||||
schedulerController.openAutomationPermissionSettings()
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
} else {
|
||||
Button("Grant Permission") {
|
||||
schedulerController.requestAutomationPermission()
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.padding(8)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,6 +247,8 @@ struct SchedulerView: View {
|
||||
private func loadState() {
|
||||
isEnabled = schedulerController.settings.isEnabled
|
||||
startTime = schedulerController.settings.startTime
|
||||
stopTimeEnabled = schedulerController.settings.stopTimeEnabled
|
||||
stopTime = schedulerController.settings.stopTime
|
||||
isEveryday = schedulerController.settings.isEveryday
|
||||
selectedDays = schedulerController.settings.selectedDays
|
||||
postQueueAction = schedulerController.settings.postQueueAction
|
||||
@@ -207,6 +260,8 @@ struct SchedulerView: View {
|
||||
private func saveState() {
|
||||
schedulerController.settings.isEnabled = isEnabled
|
||||
schedulerController.settings.startTime = startTime
|
||||
schedulerController.settings.stopTimeEnabled = stopTimeEnabled
|
||||
schedulerController.settings.stopTime = stopTime
|
||||
schedulerController.settings.isEveryday = isEveryday
|
||||
schedulerController.settings.selectedDays = selectedDays
|
||||
schedulerController.settings.postQueueAction = postQueueAction
|
||||
|
||||
@@ -15,9 +15,6 @@ struct AboutSettingsPane: View {
|
||||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.1.0"
|
||||
}
|
||||
|
||||
private var buildNumber: String {
|
||||
Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "Development"
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
@@ -31,7 +28,7 @@ struct AboutSettingsPane: View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Firelink")
|
||||
.font(.title2.weight(.bold))
|
||||
Text("Version \(appVersion) (\(buildNumber))")
|
||||
Text("Version \(appVersion)")
|
||||
.foregroundStyle(.secondary)
|
||||
Text("A native macOS download manager for fast, organized, segmented transfers.")
|
||||
.font(.caption)
|
||||
@@ -101,164 +98,84 @@ struct AboutSettingsPane: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var updateStatusView: some View {
|
||||
switch updateChecker.state {
|
||||
case .idle:
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
updateHeader(
|
||||
systemImage: "arrow.down.circle",
|
||||
tint: .blue,
|
||||
title: "Check for Updates",
|
||||
subtitle: "Firelink checks GitHub Releases and opens the download page when a new version is available."
|
||||
)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
updateChecker.checkForUpdates()
|
||||
} label: {
|
||||
Label("Check for Updates", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button {
|
||||
NSWorkspace.shared.open(releasesURL)
|
||||
} label: {
|
||||
Label("Release Notes", systemImage: "doc.text")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case .checking:
|
||||
HStack(spacing: 12) {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("Checking GitHub Releases")
|
||||
HStack(alignment: .center) {
|
||||
switch updateChecker.state {
|
||||
case .idle:
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Check for Updates")
|
||||
.font(.headline)
|
||||
Text("Looking for the latest stable Firelink release.")
|
||||
Text("Firelink checks GitHub Releases for new versions.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
case .updateAvailable(let update):
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
updateHeader(
|
||||
systemImage: "arrow.down.circle.fill",
|
||||
tint: .green,
|
||||
title: "Firelink \(update.version) Is Available",
|
||||
subtitle: "You have Firelink \(appVersion). Download the new release from GitHub when you're ready."
|
||||
)
|
||||
|
||||
releaseNotesDisclosure(for: update)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
NSWorkspace.shared.open(update.releaseURL)
|
||||
} label: {
|
||||
Label("Open GitHub Release", systemImage: "arrow.up.forward.app")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
Button {
|
||||
updateChecker.checkForUpdates()
|
||||
} label: {
|
||||
Label("Check Again", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
Spacer()
|
||||
Button("Check Now") {
|
||||
updateChecker.checkForUpdates()
|
||||
}
|
||||
}
|
||||
|
||||
case .upToDate(let latestVersion, let localVersion):
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
let subtitle = latestVersion == localVersion
|
||||
? "Firelink \(localVersion) is the newest stable release."
|
||||
: "Firelink \(localVersion) is newer than the latest stable GitHub release, \(latestVersion)."
|
||||
|
||||
updateHeader(
|
||||
systemImage: "checkmark.seal.fill",
|
||||
tint: .green,
|
||||
title: "You're Up to Date",
|
||||
subtitle: subtitle
|
||||
)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
updateChecker.checkForUpdates()
|
||||
} label: {
|
||||
Label("Check Again", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button {
|
||||
NSWorkspace.shared.open(releasesURL)
|
||||
} label: {
|
||||
Label("Release Notes", systemImage: "doc.text")
|
||||
}
|
||||
case .checking:
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Checking for updates...")
|
||||
.font(.headline)
|
||||
Text("Connecting to GitHub...")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.padding(.trailing, 16)
|
||||
|
||||
case .failed(let message, let recovery):
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
updateHeader(
|
||||
systemImage: "exclamationmark.triangle.fill",
|
||||
tint: .orange,
|
||||
title: message,
|
||||
subtitle: recovery
|
||||
)
|
||||
case .updateAvailable(let update):
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Firelink \(update.version) is available!")
|
||||
.font(.headline)
|
||||
Text("You currently have version \(appVersion).")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Link("View Release Notes", destination: update.releaseURL)
|
||||
.font(.caption)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
Spacer()
|
||||
Button("Download Update") {
|
||||
NSWorkspace.shared.open(update.releaseURL)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
updateChecker.checkForUpdates()
|
||||
} label: {
|
||||
Label("Check Again", systemImage: "arrow.clockwise")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
case .upToDate(let latestVersion, _):
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.green)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Firelink is up to date")
|
||||
.font(.headline)
|
||||
Text("Version \(latestVersion) is the newest stable release.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Button("Check Again") {
|
||||
updateChecker.checkForUpdates()
|
||||
}
|
||||
|
||||
Button {
|
||||
NSWorkspace.shared.open(releasesURL)
|
||||
} label: {
|
||||
Label("Open Releases", systemImage: "safari")
|
||||
}
|
||||
case .failed(let message, let recovery):
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(message)
|
||||
.font(.headline)
|
||||
Text(recovery)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
Spacer()
|
||||
Button("Try Again") {
|
||||
updateChecker.checkForUpdates()
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
private func updateHeader(systemImage: String, tint: Color, title: String, subtitle: String) -> some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: systemImage)
|
||||
.font(.title2)
|
||||
.foregroundStyle(tint)
|
||||
.frame(width: 28)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
Text(subtitle)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func releaseNotesDisclosure(for update: AvailableReleaseUpdate) -> some View {
|
||||
DisclosureGroup("What's New") {
|
||||
ScrollView {
|
||||
Text(releaseNotes(from: update.releaseNotes))
|
||||
.font(.caption)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
.frame(maxHeight: 180)
|
||||
.padding(8)
|
||||
.background(Color(NSColor.controlBackgroundColor))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
private func releaseNotes(from markdown: String) -> AttributedString {
|
||||
(try? AttributedString(markdown: markdown)) ?? AttributedString(markdown)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,27 +6,27 @@ struct DownloadSettingsPane: View {
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
Stepper(
|
||||
"Default connections per server: \(settings.perServerConnections)",
|
||||
value: $settings.perServerConnections,
|
||||
in: 1...16
|
||||
)
|
||||
Text("Used as the default for new downloads. The Add Downloads window can override it per batch.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Stepper(
|
||||
"Parallel downloads: \(settings.maxConcurrentDownloads)",
|
||||
value: $settings.maxConcurrentDownloads,
|
||||
in: 1...12
|
||||
)
|
||||
Text("Controls how many files Firelink downloads at the same time.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section {
|
||||
LabeledContent("Global speed limit") {
|
||||
LabeledContent {
|
||||
Stepper("\(settings.perServerConnections)", value: $settings.perServerConnections, in: 1...16)
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Default connections:")
|
||||
Text("For new downloads")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
LabeledContent {
|
||||
Stepper("\(settings.maxConcurrentDownloads)", value: $settings.maxConcurrentDownloads, in: 1...12)
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Parallel downloads:")
|
||||
Text("Max simultaneous active files")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
LabeledContent {
|
||||
HStack {
|
||||
TextField("0", value: $settings.globalSpeedLimitKiBPerSecond, format: .number)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
@@ -35,10 +35,36 @@ struct DownloadSettingsPane: View {
|
||||
Text("KiB/s")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Global speed limit:")
|
||||
Text("0 = unlimited speed")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Text("Set to 0 for unlimited speed. This limit is divided across currently active downloads.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
LabeledContent {
|
||||
Stepper("\(settings.maxAutomaticRetries)", value: $settings.maxAutomaticRetries, in: 0...10)
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Automatic retries:")
|
||||
Text("If a connection fails")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Toggle(isOn: $settings.showNotifications) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Show notification when download completes")
|
||||
Text("Alerts you in Notification Center")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Toggle(isOn: $settings.playCompletionSound) {
|
||||
Text("Play sound when download completes")
|
||||
}
|
||||
.disabled(!settings.showNotifications)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
|
||||
@@ -20,15 +20,16 @@ struct EngineSettingsPane: View {
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
|
||||
LabeledContent("Binary Path") {
|
||||
Text(executableURL?.path ?? "Not found")
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(.tertiary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.textSelection(.enabled)
|
||||
LabeledContent("Binary") {
|
||||
if let url = executableURL {
|
||||
Button("Reveal in Finder") {
|
||||
NSWorkspace.shared.selectFile(url.path, inFileViewerRootedAtPath: "")
|
||||
}
|
||||
} else {
|
||||
Text("Not found")
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
.help(executableURL?.path ?? "")
|
||||
} header: {
|
||||
Text("Core Downloader (Aria2)")
|
||||
} footer: {
|
||||
@@ -46,13 +47,16 @@ struct EngineSettingsPane: View {
|
||||
addonStatusRow(title: "FFmpeg", state: engineManager.ffmpegState, path: engineManager.binaryPath(for: .ffmpeg))
|
||||
|
||||
LabeledContent("Browser Cookies") {
|
||||
Picker("", selection: $settings.mediaCookieSource) {
|
||||
ForEach(BrowserCookieSource.allCases, id: \.self) { source in
|
||||
Text(source.rawValue).tag(source)
|
||||
HStack {
|
||||
Spacer()
|
||||
Picker("", selection: $settings.mediaCookieSource) {
|
||||
ForEach(BrowserCookieSource.allCases, id: \.self) { source in
|
||||
Text(source.rawValue).tag(source)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.fixedSize()
|
||||
}
|
||||
.labelsHidden()
|
||||
.frame(maxWidth: 200)
|
||||
}
|
||||
} header: {
|
||||
Text("Media Extractors")
|
||||
@@ -75,7 +79,7 @@ struct EngineSettingsPane: View {
|
||||
@ViewBuilder
|
||||
private func addonStatusRow(title: String, state: AddonState, path: URL?) -> some View {
|
||||
LabeledContent(title) {
|
||||
VStack(alignment: .trailing) {
|
||||
HStack(spacing: 8) {
|
||||
switch state {
|
||||
case .notInstalled:
|
||||
Text("Missing")
|
||||
@@ -89,13 +93,12 @@ struct EngineSettingsPane: View {
|
||||
.foregroundStyle(.red)
|
||||
.help(error)
|
||||
}
|
||||
|
||||
Text(path?.path ?? "Not found")
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(.tertiary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
.textSelection(.enabled)
|
||||
|
||||
if let path {
|
||||
Button("Reveal") {
|
||||
NSWorkspace.shared.selectFile(path.path, inFileViewerRootedAtPath: "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,110 +7,143 @@ struct IntegrationSettingsPane: View {
|
||||
@State private var showToast = false
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 24) {
|
||||
// Header
|
||||
HStack(alignment: .center, spacing: 16) {
|
||||
Form {
|
||||
Section {
|
||||
HStack(alignment: .center, spacing: 12) {
|
||||
Image(systemName: "puzzlepiece.extension.fill")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fit)
|
||||
.frame(width: 48, height: 48)
|
||||
.frame(width: 32, height: 32)
|
||||
.foregroundStyle(Color(nsColor: NSColor(red: 1.0, green: 0.44, blue: 0.22, alpha: 1.0)))
|
||||
.accessibilityHidden(true)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Connect Browser Extension")
|
||||
.font(.title.weight(.bold))
|
||||
.font(.title3.weight(.bold))
|
||||
Text("Capture downloads directly from your browser in three easy steps.")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.body)
|
||||
.font(.subheadline)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.bottom, 8)
|
||||
|
||||
// Step 1: Copy Token
|
||||
StepCardView(
|
||||
stepNumber: 1,
|
||||
title: "Copy Pairing Token",
|
||||
description: "This secure token authorizes your browser extension.",
|
||||
icon: "doc.on.clipboard.fill",
|
||||
iconColor: .blue,
|
||||
actionText: "Copy Token",
|
||||
action: {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(settings.extensionPairingToken, forType: .string)
|
||||
withAnimation {
|
||||
showToast = true
|
||||
}
|
||||
},
|
||||
secondaryActionText: "Regenerate",
|
||||
secondaryAction: {
|
||||
var bytes = [UInt8](repeating: 0, count: 32)
|
||||
let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
|
||||
settings.extensionPairingToken = status == errSecSuccess ? Data(bytes).base64EncodedString() : UUID().uuidString
|
||||
}
|
||||
)
|
||||
|
||||
// Step 2: Get Extension
|
||||
StepCardView(
|
||||
stepNumber: 2,
|
||||
title: "Get Extension",
|
||||
description: "Install the Firelink Companion extension on your favorite browser.",
|
||||
icon: "globe",
|
||||
iconColor: .orange,
|
||||
actionText: "Firefox Add-ons",
|
||||
action: {
|
||||
if let url = URL(string: "https://addons.mozilla.org/en-US/firefox/addon/firelink-companion/") {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
},
|
||||
secondaryActionText: "Releases",
|
||||
secondaryAction: {
|
||||
if let url = URL(string: "https://github.com/nimbold/Firelink-Extension/releases") {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Step 3: Paste and Save
|
||||
StepCardView(
|
||||
stepNumber: 3,
|
||||
title: "Paste & Connect",
|
||||
description: "Click the Firelink icon in your browser's toolbar and paste the token into the App Pairing Token field.",
|
||||
icon: "arrow.down.doc.fill",
|
||||
iconColor: .green,
|
||||
actionText: nil,
|
||||
action: nil
|
||||
)
|
||||
|
||||
Divider()
|
||||
|
||||
// Diagnostics
|
||||
HStack {
|
||||
Text("Diagnostics:")
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
if let port = controller.extensionServerPort {
|
||||
Label("Listening on 127.0.0.1:\(port)", systemImage: "checkmark.seal.fill")
|
||||
.foregroundStyle(.green)
|
||||
} else {
|
||||
Label("Not listening", systemImage: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
.font(.footnote)
|
||||
.padding(.top, 8)
|
||||
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowInsets(EdgeInsets())
|
||||
|
||||
Section {
|
||||
KeychainAccessCard()
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowInsets(EdgeInsets())
|
||||
|
||||
if settings.isKeychainAccessGranted {
|
||||
Section {
|
||||
HStack(spacing: 8) {
|
||||
// Step 1
|
||||
CompactStepCardView(
|
||||
stepNumber: 1,
|
||||
title: "Copy Token",
|
||||
description: "This secure token authorizes your browser extension.",
|
||||
icon: "doc.on.clipboard.fill",
|
||||
iconColor: .blue,
|
||||
actionText: "Copy Token",
|
||||
action: {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(settings.extensionPairingToken, forType: .string)
|
||||
withAnimation {
|
||||
showToast = true
|
||||
}
|
||||
},
|
||||
secondaryActionText: "Regenerate",
|
||||
secondaryAction: {
|
||||
var bytes = [UInt8](repeating: 0, count: 32)
|
||||
let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
|
||||
settings.extensionPairingToken = status == errSecSuccess ? Data(bytes).base64EncodedString() : UUID().uuidString
|
||||
}
|
||||
)
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.title3.weight(.bold))
|
||||
.foregroundStyle(Color(nsColor: .tertiaryLabelColor))
|
||||
|
||||
// Step 2
|
||||
CompactStepCardView(
|
||||
stepNumber: 2,
|
||||
title: "Get Extension",
|
||||
description: "Install the Firelink Companion extension on your browser.",
|
||||
icon: "globe",
|
||||
iconColor: .orange,
|
||||
actionText: "Firefox Add-ons",
|
||||
action: {
|
||||
if let url = URL(string: "https://addons.mozilla.org/en-US/firefox/addon/firelink-companion/") {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
},
|
||||
secondaryActionText: "Releases",
|
||||
secondaryAction: {
|
||||
if let url = URL(string: "https://github.com/nimbold/Firelink-Extension/releases") {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.title3.weight(.bold))
|
||||
.foregroundStyle(Color(nsColor: .tertiaryLabelColor))
|
||||
|
||||
// Step 3
|
||||
CompactStepCardView(
|
||||
stepNumber: 3,
|
||||
title: "Paste & Connect",
|
||||
description: "Click the Firelink icon in your browser's toolbar and paste the token.",
|
||||
icon: "arrow.down.doc.fill",
|
||||
iconColor: .green,
|
||||
actionText: nil,
|
||||
action: nil
|
||||
)
|
||||
}
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
} header: {
|
||||
Text("Setup Guide")
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowInsets(EdgeInsets())
|
||||
|
||||
Section {
|
||||
HStack {
|
||||
Text("Diagnostics:")
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
if let port = controller.extensionServerPort {
|
||||
Label("Listening on 127.0.0.1:\(port)", systemImage: "checkmark.seal.fill")
|
||||
.foregroundStyle(.green)
|
||||
} else {
|
||||
Label("Not listening", systemImage: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
}
|
||||
.font(.footnote)
|
||||
.padding(8)
|
||||
.background(Color(NSColor.controlBackgroundColor))
|
||||
.cornerRadius(8)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Color.secondary.opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
} header: {
|
||||
Text("Status")
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowInsets(EdgeInsets())
|
||||
}
|
||||
.padding(32)
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.toast(isShowing: $showToast, message: "Token copied to clipboard!")
|
||||
.background(Color(NSColor.windowBackgroundColor))
|
||||
}
|
||||
}
|
||||
|
||||
struct StepCardView: View {
|
||||
struct CompactStepCardView: View {
|
||||
let stepNumber: Int
|
||||
let title: String
|
||||
let description: String
|
||||
@@ -122,83 +155,87 @@ struct StepCardView: View {
|
||||
var secondaryAction: (() -> Void)? = nil
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 16) {
|
||||
// Step Number Badge
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color(nsColor: .controlBackgroundColor))
|
||||
.frame(width: 32, height: 32)
|
||||
.shadow(color: .black.opacity(0.1), radius: 2, y: 1)
|
||||
|
||||
Text("\(stepNumber)")
|
||||
.font(.system(.headline, design: .rounded).weight(.bold))
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
|
||||
// Icon
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(iconColor.opacity(0.15))
|
||||
.frame(width: 48, height: 48)
|
||||
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 24))
|
||||
.foregroundStyle(iconColor)
|
||||
VStack(spacing: 8) {
|
||||
HStack(alignment: .top) {
|
||||
// Step Number Badge
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(Color.secondary.opacity(0.1))
|
||||
.frame(width: 20, height: 20)
|
||||
|
||||
Text("\(stepNumber)")
|
||||
.font(.system(.caption2, design: .rounded).weight(.bold))
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
Spacer()
|
||||
// Icon
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(iconColor.opacity(0.15))
|
||||
.frame(width: 28, height: 28)
|
||||
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 14))
|
||||
.foregroundStyle(iconColor)
|
||||
}
|
||||
}
|
||||
|
||||
// Text Content
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(title)
|
||||
.font(.headline)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
Text(description)
|
||||
.font(.subheadline)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
Spacer(minLength: 4)
|
||||
|
||||
// Action Button
|
||||
HStack(spacing: 8) {
|
||||
VStack(spacing: 6) {
|
||||
if let actionText = actionText, let action = action {
|
||||
Button(action: action) {
|
||||
Text(actionText)
|
||||
.font(.caption2.weight(.medium))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color.accentColor)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(4)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
if let secondaryActionText = secondaryActionText, let secondaryAction = secondaryAction {
|
||||
Button(action: secondaryAction) {
|
||||
Text(secondaryActionText)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.font(.caption2.weight(.medium))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color(nsColor: .controlBackgroundColor))
|
||||
.foregroundColor(.primary)
|
||||
.cornerRadius(8)
|
||||
.cornerRadius(4)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.strokeBorder(Color(nsColor: .separatorColor).opacity(0.5), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
if let actionText = actionText, let action = action {
|
||||
Button(action: action) {
|
||||
Text(actionText)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.accentColor)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(8)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.padding(10)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(Color(nsColor: .controlBackgroundColor))
|
||||
.shadow(color: .black.opacity(0.05), radius: 8, y: 2)
|
||||
.shadow(color: .black.opacity(0.05), radius: 4, y: 1)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 16)
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.strokeBorder(Color(nsColor: .separatorColor).opacity(0.5), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import SwiftUI
|
||||
|
||||
struct KeychainAccessCard: View {
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: settings.isKeychainAccessGranted ? 12 : 16) {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: settings.isKeychainAccessGranted ? 6 : 10)
|
||||
.fill(settings.isKeychainAccessGranted ? Color.green.opacity(0.15) : Color.blue.opacity(0.15))
|
||||
.frame(width: settings.isKeychainAccessGranted ? 28 : 36, height: settings.isKeychainAccessGranted ? 28 : 36)
|
||||
|
||||
Image(systemName: settings.isKeychainAccessGranted ? "lock.open.fill" : "lock.fill")
|
||||
.font(.system(size: settings.isKeychainAccessGranted ? 14 : 18))
|
||||
.foregroundStyle(settings.isKeychainAccessGranted ? .green : .blue)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(settings.isKeychainAccessGranted ? "Keychain Access Granted" : "Keychain Access")
|
||||
.font(settings.isKeychainAccessGranted ? .subheadline.weight(.medium) : .headline)
|
||||
.foregroundStyle(settings.isKeychainAccessGranted ? .green : .primary)
|
||||
|
||||
if !settings.isKeychainAccessGranted {
|
||||
Text("Firelink needs Keychain access to securely store your browser extension pairing token.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: settings.isKeychainAccessGranted ? 8 : 16)
|
||||
|
||||
if settings.isKeychainAccessGranted {
|
||||
Button(role: .destructive) {
|
||||
settings.revokeKeychainAccess()
|
||||
} label: {
|
||||
Text("Revoke")
|
||||
}
|
||||
.controlSize(.small)
|
||||
} else {
|
||||
Button {
|
||||
settings.grantKeychainAccess()
|
||||
} label: {
|
||||
Text("Grant Access")
|
||||
.font(.subheadline.weight(.medium))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.accentColor)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(6)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(settings.isKeychainAccessGranted ? 8 : 12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: settings.isKeychainAccessGranted ? 8 : 12)
|
||||
.fill(Color(nsColor: .controlBackgroundColor))
|
||||
.shadow(color: .black.opacity(0.05), radius: 4, y: 1)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: settings.isKeychainAccessGranted ? 8 : 12)
|
||||
.strokeBorder(Color(nsColor: .separatorColor).opacity(0.5), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -6,18 +6,15 @@ struct LocationsSettingsPane: View {
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) {
|
||||
BulkDirectoryPickerRow()
|
||||
|
||||
GridRow {
|
||||
Divider()
|
||||
.gridCellColumns(2)
|
||||
}
|
||||
|
||||
ForEach(DownloadCategory.allCases, id: \.self) { category in
|
||||
DirectoryPickerRow(category: category)
|
||||
}
|
||||
Section(footer: Text("When enabled, you can choose the download location each time you add a download. Otherwise, files are saved automatically.")) {
|
||||
Toggle("Ask where to save each file before downloading", isOn: $settings.askWhereToSaveEachFile)
|
||||
}
|
||||
|
||||
Section(footer: Text("Folders will be created automatically when saving.")) {
|
||||
BulkDirectoryPickerRow()
|
||||
|
||||
ForEach(DownloadCategory.allCases, id: \.self) { category in
|
||||
DirectoryPickerRow(category: category)
|
||||
}
|
||||
|
||||
HStack {
|
||||
@@ -26,8 +23,6 @@ struct LocationsSettingsPane: View {
|
||||
settings.resetDirectories()
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
Text("Folders will be created automatically when applied.")
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
@@ -42,31 +37,20 @@ struct DirectoryPickerRow: View {
|
||||
@State private var message = ""
|
||||
|
||||
var body: some View {
|
||||
GridRow(alignment: .firstTextBaseline) {
|
||||
Label(category.rawValue, systemImage: category.symbolName)
|
||||
.gridColumnAlignment(.leading)
|
||||
|
||||
LabeledContent {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 8) {
|
||||
TextField("", text: $path, prompt: Text("Folder path"))
|
||||
TextField("Folder path", text: $path, prompt: Text("Folder path"))
|
||||
.labelsHidden()
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.multilineTextAlignment(.leading)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.onSubmit {
|
||||
applyPath()
|
||||
}
|
||||
|
||||
Button {
|
||||
applyPath()
|
||||
} label: {
|
||||
Label("Apply", systemImage: "checkmark")
|
||||
}
|
||||
.disabled(path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
|
||||
Button {
|
||||
Button("Choose...") {
|
||||
selectFolder()
|
||||
} label: {
|
||||
Label("Select", systemImage: "folder.badge.plus")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +60,8 @@ struct DirectoryPickerRow: View {
|
||||
.foregroundStyle(isErrorMessage(displayMessage) ? .red : .secondary)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label(category.rawValue, systemImage: category.symbolName)
|
||||
}
|
||||
.onAppear {
|
||||
syncPathFromSettings()
|
||||
@@ -175,31 +161,20 @@ struct BulkDirectoryPickerRow: View {
|
||||
@State private var message = ""
|
||||
|
||||
var body: some View {
|
||||
GridRow(alignment: .firstTextBaseline) {
|
||||
Label("All Categories", systemImage: "folder.fill.badge.plus")
|
||||
.gridColumnAlignment(.leading)
|
||||
|
||||
LabeledContent {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack(spacing: 8) {
|
||||
TextField("", text: $path, prompt: Text("Base folder path"))
|
||||
TextField("Base folder path", text: $path, prompt: Text("Base folder path"))
|
||||
.labelsHidden()
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.multilineTextAlignment(.leading)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.onSubmit {
|
||||
applyPath()
|
||||
}
|
||||
|
||||
Button {
|
||||
applyPath()
|
||||
} label: {
|
||||
Label("Apply", systemImage: "checkmark")
|
||||
}
|
||||
.disabled(path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
|
||||
Button {
|
||||
Button("Choose...") {
|
||||
selectFolder()
|
||||
} label: {
|
||||
Label("Select", systemImage: "folder.badge.plus")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,12 +182,10 @@ struct BulkDirectoryPickerRow: View {
|
||||
Text(message)
|
||||
.font(.caption)
|
||||
.foregroundStyle(isErrorMessage(message) ? .red : .secondary)
|
||||
} else {
|
||||
Text("Automatically creates all category folders in the selected path.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label("All Categories", systemImage: "folder.fill.badge.plus")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,9 +34,13 @@ struct LookAndFeelSettingsPane: View {
|
||||
}
|
||||
}
|
||||
|
||||
Section("Menu Bar") {
|
||||
Toggle("Show menu bar icon", isOn: $showMenuBarIcon)
|
||||
Section("macOS Integration") {
|
||||
Toggle("Show badge on Dock icon", isOn: $settings.showDockBadge)
|
||||
Text("Displays the number of active downloads on the Firelink Dock icon.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Toggle("Show menu bar icon", isOn: $showMenuBarIcon)
|
||||
Text("Provides quick access to downloads and queues from the macOS menu bar.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
@@ -5,8 +5,8 @@ struct NetworkSettingsPane: View {
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
Picker("Proxy", selection: proxyBinding(\.mode)) {
|
||||
Section("Proxy") {
|
||||
Picker("Mode", selection: proxyBinding(\.mode)) {
|
||||
ForEach(ProxyMode.allCases, id: \.self) { mode in
|
||||
Text(mode.title)
|
||||
.tag(mode)
|
||||
@@ -42,6 +42,16 @@ struct NetworkSettingsPane: View {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Identity") {
|
||||
TextField("User Agent", text: $settings.customUserAgent, prompt: Text("e.g. Mozilla/5.0..."))
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
|
||||
Text("Spoofs the browser User-Agent to bypass download restrictions. Leave blank for default.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
}
|
||||
@@ -49,14 +59,14 @@ struct NetworkSettingsPane: View {
|
||||
private var networkSummary: String {
|
||||
switch settings.proxySettings.mode {
|
||||
case .none:
|
||||
"Downloads ignore configured proxies."
|
||||
return "Downloads ignore configured proxies."
|
||||
case .system:
|
||||
"Downloads use the matching macOS system proxy when one is configured."
|
||||
return "Downloads use the matching macOS system proxy when one is configured."
|
||||
case .custom:
|
||||
if let proxyURI = settings.proxySettings.customProxyURI {
|
||||
"Downloads use \(proxyURI)."
|
||||
return "Downloads use \(proxyURI)."
|
||||
} else {
|
||||
"Enter a proxy host and port to enable the custom proxy."
|
||||
return "Enter a proxy host and port to enable the custom proxy."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,32 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
enum SettingsSidebarFilter: String, CaseIterable, Hashable {
|
||||
case downloads = "Downloads"
|
||||
case lookAndFeel = "Look and feel"
|
||||
case network = "Network"
|
||||
case locations = "Locations"
|
||||
case siteLogins = "Site Logins"
|
||||
case power = "Power"
|
||||
case engine = "Engine"
|
||||
case integration = "Integrations"
|
||||
case about = "About"
|
||||
|
||||
var symbolName: String {
|
||||
switch self {
|
||||
case .downloads: "arrow.down.circle"
|
||||
case .lookAndFeel: "paintpalette"
|
||||
case .network: "network"
|
||||
case .locations: "folder"
|
||||
case .siteLogins: "key.fill"
|
||||
case .power: "moon.zzz"
|
||||
case .engine: "terminal"
|
||||
case .integration: "puzzlepiece.extension"
|
||||
case .about: "info.circle"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SettingsPaneContainer: View {
|
||||
@AppStorage("lastSettingsTab") private var activeTab: SettingsSidebarFilter = .downloads
|
||||
|
||||
|
||||
@@ -1,92 +1,231 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
struct SiteLoginsSettingsPane: View {
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
@State private var urlPattern = ""
|
||||
@State private var username = ""
|
||||
@State private var password = ""
|
||||
@State private var editingLoginID: UUID?
|
||||
@State private var searchText = ""
|
||||
@State private var selection = Set<SiteLogin.ID>()
|
||||
@State private var editingLogin: SiteLogin?
|
||||
@State private var showEditor = false
|
||||
|
||||
var filteredLogins: [SiteLogin] {
|
||||
if searchText.isEmpty {
|
||||
return settings.siteLogins
|
||||
} else {
|
||||
return settings.siteLogins.filter {
|
||||
$0.urlPattern.localizedCaseInsensitiveContains(searchText) ||
|
||||
$0.username.localizedCaseInsensitiveContains(searchText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section(editingLoginID == nil ? "Add Login" : "Edit Login") {
|
||||
TextField("URL Pattern (e.g., *.github.com)", text: $urlPattern)
|
||||
TextField("Username", text: $username)
|
||||
SecureField(editingLoginID == nil ? "Password" : "Password (leave blank to keep current)", text: $password)
|
||||
|
||||
HStack {
|
||||
Text(settings.message)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
if editingLoginID != nil {
|
||||
Button("Cancel Edit") {
|
||||
resetForm()
|
||||
}
|
||||
}
|
||||
Button {
|
||||
settings.saveSiteLogin(
|
||||
id: editingLoginID,
|
||||
urlPattern: urlPattern,
|
||||
username: username,
|
||||
password: password
|
||||
)
|
||||
if settings.message.hasPrefix("Added") || settings.message.hasPrefix("Updated") {
|
||||
resetForm()
|
||||
}
|
||||
} label: {
|
||||
Label(editingLoginID == nil ? "Add Login" : "Save Login", systemImage: editingLoginID == nil ? "plus" : "checkmark")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
Section {
|
||||
KeychainAccessCard()
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowInsets(EdgeInsets())
|
||||
|
||||
Section("Saved Logins") {
|
||||
if settings.siteLogins.isEmpty {
|
||||
Text("No saved logins.")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
List {
|
||||
ForEach(settings.siteLogins) { login in
|
||||
HStack {
|
||||
Image(systemName: "key.horizontal")
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityHidden(true)
|
||||
Text(login.urlPattern)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
Spacer()
|
||||
Text(login.username)
|
||||
.foregroundStyle(.secondary)
|
||||
if settings.isKeychainAccessGranted {
|
||||
Section {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundColor(.secondary)
|
||||
TextField("Search Logins", text: $searchText)
|
||||
.textFieldStyle(.plain)
|
||||
if !searchText.isEmpty {
|
||||
Button {
|
||||
edit(login)
|
||||
searchText = ""
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.labelStyle(.iconOnly)
|
||||
.buttonStyle(.borderless)
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.onDelete(perform: settings.deleteSiteLogins)
|
||||
.padding(8)
|
||||
.background(Color(NSColor.textBackgroundColor))
|
||||
|
||||
Divider()
|
||||
|
||||
if settings.siteLogins.isEmpty {
|
||||
ContentUnavailableView("No saved logins", systemImage: "key")
|
||||
.frame(minHeight: 250)
|
||||
} else if filteredLogins.isEmpty {
|
||||
ContentUnavailableView.search(text: searchText)
|
||||
.frame(minHeight: 250)
|
||||
} else {
|
||||
Table(filteredLogins, selection: $selection) {
|
||||
TableColumn("URL Pattern") { login in
|
||||
Text(login.urlPattern)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.contextMenu {
|
||||
contextMenuActions(for: login)
|
||||
}
|
||||
}
|
||||
TableColumn("Username") { login in
|
||||
Text(login.username)
|
||||
.contextMenu {
|
||||
contextMenuActions(for: login)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(minHeight: 200, idealHeight: 250, maxHeight: 300)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
HStack {
|
||||
Button {
|
||||
editingLogin = nil
|
||||
showEditor = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
.frame(width: 24, height: 24)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.horizontal, 4)
|
||||
|
||||
Divider()
|
||||
.frame(height: 16)
|
||||
|
||||
Button {
|
||||
deleteSelected()
|
||||
} label: {
|
||||
Image(systemName: "minus")
|
||||
.frame(width: 24, height: 24)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.horizontal, 4)
|
||||
.disabled(selection.isEmpty)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.padding(.horizontal, 4)
|
||||
.background(Color(NSColor.windowBackgroundColor))
|
||||
}
|
||||
.frame(minHeight: 180)
|
||||
.background(Color(NSColor.controlBackgroundColor))
|
||||
.cornerRadius(8)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.stroke(Color.secondary.opacity(0.2), lineWidth: 1)
|
||||
)
|
||||
} header: {
|
||||
Text("Saved Logins")
|
||||
}
|
||||
.listRowBackground(Color.clear)
|
||||
.listRowInsets(EdgeInsets())
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.sheet(isPresented: $showEditor) {
|
||||
LoginEditorSheet(login: editingLogin)
|
||||
}
|
||||
}
|
||||
|
||||
private func edit(_ login: SiteLogin) {
|
||||
editingLoginID = login.id
|
||||
urlPattern = login.urlPattern
|
||||
username = login.username
|
||||
password = ""
|
||||
|
||||
@ViewBuilder
|
||||
private func contextMenuActions(for login: SiteLogin) -> some View {
|
||||
Button("Edit") {
|
||||
editingLogin = login
|
||||
showEditor = true
|
||||
}
|
||||
Divider()
|
||||
Button("Copy Username") {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(login.username, forType: .string)
|
||||
}
|
||||
Button("Copy Password") {
|
||||
if let password = KeychainCredentialStore.password(for: login.id) {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(password, forType: .string)
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
Button("Delete", role: .destructive) {
|
||||
if let index = settings.siteLogins.firstIndex(where: { $0.id == login.id }) {
|
||||
settings.deleteSiteLogins(at: IndexSet(integer: index))
|
||||
}
|
||||
selection.remove(login.id)
|
||||
}
|
||||
}
|
||||
|
||||
private func resetForm() {
|
||||
editingLoginID = nil
|
||||
urlPattern = ""
|
||||
username = ""
|
||||
password = ""
|
||||
|
||||
private func deleteSelected() {
|
||||
let indices = settings.siteLogins.enumerated().compactMap { index, login in
|
||||
selection.contains(login.id) ? index : nil
|
||||
}
|
||||
settings.deleteSiteLogins(at: IndexSet(indices))
|
||||
selection.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
struct LoginEditorSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
|
||||
let login: SiteLogin?
|
||||
|
||||
@State private var urlPattern = ""
|
||||
@State private var username = ""
|
||||
@State private var password = ""
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
Text(login == nil ? "Add Login" : "Edit Login")
|
||||
.font(.headline)
|
||||
.padding()
|
||||
|
||||
Form {
|
||||
TextField("URL Pattern (e.g., *.github.com)", text: $urlPattern)
|
||||
TextField("Username", text: $username)
|
||||
SecureField(login == nil ? "Password" : "Password (leave blank to keep current)", text: $password)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom)
|
||||
|
||||
if !settings.message.isEmpty && (settings.message.hasPrefix("Add a") || settings.message.hasPrefix("A login") || settings.message.hasPrefix("Could not")) {
|
||||
Text(settings.message)
|
||||
.foregroundColor(.red)
|
||||
.font(.caption)
|
||||
.padding(.bottom)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
.keyboardShortcut(.cancelAction)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(login == nil ? "Add" : "Save") {
|
||||
settings.message = ""
|
||||
settings.saveSiteLogin(
|
||||
id: login?.id,
|
||||
urlPattern: urlPattern,
|
||||
username: username,
|
||||
password: password
|
||||
)
|
||||
|
||||
if settings.message.hasPrefix("Added") || settings.message.hasPrefix("Updated") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
.keyboardShortcut(.defaultAction)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.frame(width: 400)
|
||||
.onAppear {
|
||||
if let login = login {
|
||||
urlPattern = login.urlPattern
|
||||
username = login.username
|
||||
}
|
||||
settings.message = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,11 +26,10 @@ struct ToastNotification: ViewModifier {
|
||||
}
|
||||
.zIndex(1)
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.7), value: isShowing)
|
||||
.onAppear {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
||||
withAnimation {
|
||||
isShowing = false
|
||||
}
|
||||
.task {
|
||||
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||
withAnimation {
|
||||
isShowing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,31 +21,6 @@ enum DownloadSidebarFilter: Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
enum SettingsSidebarFilter: String, CaseIterable, Hashable {
|
||||
case downloads = "Downloads"
|
||||
case lookAndFeel = "Look and feel"
|
||||
case network = "Network"
|
||||
case locations = "Locations"
|
||||
case siteLogins = "Site Logins"
|
||||
case power = "Power"
|
||||
case engine = "Engine"
|
||||
case integration = "Integrations"
|
||||
case about = "About"
|
||||
|
||||
var symbolName: String {
|
||||
switch self {
|
||||
case .downloads: "arrow.down.circle"
|
||||
case .lookAndFeel: "paintpalette"
|
||||
case .network: "network"
|
||||
case .locations: "folder"
|
||||
case .siteLogins: "key.fill"
|
||||
case .power: "moon.zzz"
|
||||
case .engine: "terminal"
|
||||
case .integration: "puzzlepiece.extension"
|
||||
case .about: "info.circle"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum SidebarSelection: Hashable {
|
||||
case downloads(DownloadSidebarFilter)
|
||||
@@ -57,6 +32,7 @@ enum SidebarSelection: Hashable {
|
||||
|
||||
struct SidebarView: View {
|
||||
@EnvironmentObject private var controller: DownloadController
|
||||
@Environment(\.controlActiveState) private var controlActiveState
|
||||
@Binding var selection: SidebarSelection
|
||||
@State private var queueBeingRenamed: DownloadQueue?
|
||||
@State private var queueBeingRemoved: DownloadQueue?
|
||||
@@ -95,6 +71,7 @@ struct SidebarView: View {
|
||||
selection = .queue(queue.id)
|
||||
} label: {
|
||||
Label("Add new queue", systemImage: "plus")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
@@ -121,8 +98,8 @@ struct SidebarView: View {
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.background(selection == .settings ? Color.accentColor : Color.clear)
|
||||
.foregroundStyle(selection == .settings ? Color.white : Color.primary)
|
||||
.background(selection == .settings ? (controlActiveState == .key || controlActiveState == .active ? Color.accentColor : Color.secondary.opacity(0.5)) : Color.clear)
|
||||
.foregroundStyle(selection == .settings ? (controlActiveState == .key || controlActiveState == .active ? Color.white : Color.primary) : Color.primary)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 8)
|
||||
@@ -163,7 +140,7 @@ struct SidebarView: View {
|
||||
Button("Delete Queue", role: .destructive) {
|
||||
controller.removeQueue(id: queue.id)
|
||||
if selection == .queue(queue.id) {
|
||||
selection = .downloads(.unfinished)
|
||||
selection = .downloads(.all)
|
||||
}
|
||||
queueBeingRemoved = nil
|
||||
}
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import SwiftUI
|
||||
|
||||
enum SpeedUnit: String, CaseIterable, Identifiable {
|
||||
case kbs = "KB/s"
|
||||
case mbs = "MB/s"
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
struct SpeedLimiterView: View {
|
||||
@EnvironmentObject private var settings: AppSettings
|
||||
@State private var showSaveToast: Bool = false
|
||||
|
||||
// Local state to hold edits before saving
|
||||
@State private var isEnabled: Bool = false
|
||||
@State private var speedLimitKiBPerSecond: Int = 1024
|
||||
@State private var displayedSpeedValue: Int = 1
|
||||
@State private var limitUnit: SpeedUnit = .mbs
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
@@ -14,10 +21,13 @@ struct SpeedLimiterView: View {
|
||||
Divider()
|
||||
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 24) {
|
||||
limitSelectionSection
|
||||
.opacity(isEnabled ? 1.0 : 0.5)
|
||||
.disabled(!isEnabled)
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
GroupBox {
|
||||
limitSelectionSection
|
||||
.padding(8)
|
||||
}
|
||||
.opacity(isEnabled ? 1.0 : 0.5)
|
||||
.disabled(!isEnabled)
|
||||
}
|
||||
.padding(24)
|
||||
}
|
||||
@@ -62,38 +72,62 @@ struct SpeedLimiterView: View {
|
||||
}
|
||||
|
||||
private var limitSelectionSection: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Global Speed Limit")
|
||||
.font(.headline)
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
HStack {
|
||||
Label("Global Speed Limit", systemImage: "speedometer")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
Text("This limit applies globally to all active downloads. Individual downloads can also have their own specific limits defined in their properties.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
HStack {
|
||||
Stepper(value: $speedLimitKiBPerSecond, in: 1...10_485_760, step: 512) {
|
||||
Text("Maximum Speed:")
|
||||
}
|
||||
|
||||
TextField("Speed", value: $speedLimitKiBPerSecond, format: .number)
|
||||
HStack(spacing: 16) {
|
||||
TextField("Speed", value: $displayedSpeedValue, format: .number)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 80)
|
||||
.multilineTextAlignment(.trailing)
|
||||
|
||||
Text("KiB/s")
|
||||
Picker("Unit", selection: $limitUnit) {
|
||||
ForEach(SpeedUnit.allCases) { unit in
|
||||
Text(unit.rawValue).tag(unit)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.frame(width: 140)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Quick Presets")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
presetButton(title: "1 MB/s", value: 1, unit: .mbs)
|
||||
presetButton(title: "5 MB/s", value: 5, unit: .mbs)
|
||||
presetButton(title: "10 MB/s", value: 10, unit: .mbs)
|
||||
}
|
||||
}
|
||||
|
||||
// Helpful presets
|
||||
HStack(spacing: 12) {
|
||||
Button("1 MB/s") { speedLimitKiBPerSecond = 1024 }
|
||||
Button("5 MB/s") { speedLimitKiBPerSecond = 5120 }
|
||||
Button("10 MB/s") { speedLimitKiBPerSecond = 10240 }
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
}
|
||||
|
||||
private func presetButton(title: String, value: Int, unit: SpeedUnit) -> some View {
|
||||
Button(action: {
|
||||
displayedSpeedValue = value
|
||||
limitUnit = unit
|
||||
}) {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.medium))
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.controlSize(.regular)
|
||||
}
|
||||
|
||||
private var toastView: some View {
|
||||
VStack {
|
||||
Spacer()
|
||||
@@ -118,13 +152,20 @@ struct SpeedLimiterView: View {
|
||||
private func loadState() {
|
||||
let currentLimit = settings.globalSpeedLimitKiBPerSecond
|
||||
isEnabled = currentLimit > 0
|
||||
speedLimitKiBPerSecond = currentLimit > 0 ? currentLimit : lastCustomSpeedLimit
|
||||
let effectiveLimit = currentLimit > 0 ? currentLimit : lastCustomSpeedLimit
|
||||
|
||||
if effectiveLimit % 1024 == 0 && effectiveLimit >= 1024 {
|
||||
displayedSpeedValue = effectiveLimit / 1024
|
||||
limitUnit = .mbs
|
||||
} else {
|
||||
displayedSpeedValue = effectiveLimit
|
||||
limitUnit = .kbs
|
||||
}
|
||||
}
|
||||
|
||||
private func saveState() {
|
||||
// Clamp to ensure it doesn't break aria2
|
||||
let clampedSpeed = max(min(speedLimitKiBPerSecond, 10_485_760), 1)
|
||||
speedLimitKiBPerSecond = clampedSpeed
|
||||
let valueInKbs = limitUnit == .mbs ? displayedSpeedValue * 1024 : displayedSpeedValue
|
||||
let clampedSpeed = max(min(valueInKbs, 10_485_760), 1)
|
||||
|
||||
lastCustomSpeedLimit = clampedSpeed
|
||||
settings.globalSpeedLimitKiBPerSecond = isEnabled ? clampedSpeed : 0
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
|
||||
Reference in New Issue
Block a user