Compare commits

...

23 Commits

Author SHA1 Message Date
nimbold 93e6e11939 docs: update changelog for 0.4.3 release 2026-06-03 07:07:28 +03:30
nimbold 6750cce6aa perf: reduce main thread CPU usage and SSD wear from frequent JSON serialization 2026-06-03 07:03:18 +03:30
nimbold 1d22b16119 style: refine about page UI and simplify delete confirmation dialog 2026-06-03 06:48:53 +03:30
nimbold deb106f7af docs: update changelog and roadmap for 0.4.2 release 2026-06-03 06:30:28 +03:30
nimbold 1093d73b0e feat: add monochrome tray icon and download table enhancements
- Added proper monochrome template tray icon loaded explicitly with precise dimensions
- Added redownload functionality for completed or failed items
- Added double-click to open completed files directly from the table
- Added 'Copy Address' context menu action
- Improved context menu organization and conditionally displayed actions
2026-06-03 06:23:03 +03:30
nimbold 3d117fd6b0 chore: prepare release 0.4.1 2026-06-03 05:36:57 +03:30
nimbold 21b30a9369 feat: add site logins integration to Add Downloads window 2026-06-03 05:32:39 +03:30
nimbold c4f02ff78f fix: remove unused showMenuBarIcon from AppSettings and conditionally apply theme backgrounds 2026-06-03 05:17:25 +03:30
nimbold f6d34caaf1 style: use visual command icon instead of text for paste hint 2026-06-03 05:11:18 +03:30
nimbold 7e4915f31d fix: resolve SwiftUI infinite layout freeze caused by MenuBarExtra isInserted binding to StateObject by using AppStorage 2026-06-03 05:07:55 +03:30
nimbold ff03a4cda5 feat: add Font Size, List Row Density, and Menu Bar Icon settings 2026-06-03 05:03:31 +03:30
nimbold 1f82d342e3 fix: resolve Light/System theme bug by using NSApp.appearance and replace Modern Dark with standard Dark 2026-06-03 04:54:31 +03:30
nimbold c653f7e733 feat: add app theming engine with Look and Feel settings 2026-06-03 04:44:03 +03:30
nimbold 992d448e60 feat: add tray icon and context menu for main window and queues 2026-06-03 04:35:18 +03:30
nimbold 59d0c2dec1 chore: prepare release 0.4.0 2026-06-03 04:21:26 +03:30
nimbold c4307fbaf1 chore: organize settings sections 2026-06-03 04:20:41 +03:30
nimbold 0c5e031f49 fix: harden release builds and queue scheduling 2026-06-03 04:15:53 +03:30
nimbold 8c2dee2c13 feat: add bandwidth speed limits 2026-06-03 03:58:17 +03:30
nimbold 8f17fa1e47 feat: add advanced transfer options 2026-06-03 03:36:06 +03:30
nimbold 343fe17111 fix: trigger scheduler automation permission prompt 2026-06-03 03:23:47 +03:30
nimbold e920eb544d feat: add robust scheduler with modern explicit permissions UI 2026-06-02 10:48:41 +03:30
nimbold 2dbb169c39 docs: check off data persistence from roadmap 2026-06-02 09:03:44 +03:30
nimbold 108ea1297c fix(ui): align connections row and clarify server connections behavior 2026-06-02 07:08:22 +03:30
22 changed files with 1661 additions and 122 deletions
+17 -2
View File
@@ -21,7 +21,7 @@ jobs:
steps:
- name: Check out repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Resolve version
id: version
@@ -40,6 +40,20 @@ jobs:
run: |
uname -a
swift --version
xcodebuild -version
xcode-select -p
xcrun --sdk macosx --show-sdk-version
- 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
exit 1
fi
- name: Install dependencies
run: brew install aria2 dylibbundler
@@ -54,6 +68,7 @@ jobs:
run: |
file build/Firelink.app/Contents/MacOS/Firelink
lipo -archs build/Firelink.app/Contents/MacOS/Firelink | grep -qx arm64
codesign --verify --deep --strict build/Firelink.app
- name: Create DMG
env:
@@ -62,7 +77,7 @@ jobs:
run: Scripts/create_dmg.sh
- name: Upload workflow artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: Firelink-${{ steps.version.outputs.version }}-mac-arm64-dmg
path: dist/*.dmg
+53
View File
@@ -5,6 +5,59 @@ 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.4.3] - 2026-06-03
### Changes
- Refined About page UI and simplified the delete confirmation dialog.
### Fixes
- Optimized disk writes and UI state updates to significantly reduce main thread CPU usage and SSD wear during concurrent downloads and table resizing.
## [0.4.2] - 2026-06-03
### Features added
- Added double-click to open completed files directly from the download table.
- Added redownload functionality for completed or failed items.
- Added 'Copy Address' context menu action.
- Added a monochrome template tray icon loaded explicitly with precise dimensions.
### Changes
- Improved context menu organization and conditionally displayed actions based on download status.
## [0.4.1] - 2026-06-03
### Features added
- Added app theming engine with Look and Feel settings.
- Added Font Size, List Row Density, and Menu Bar Icon settings.
- Added tray icon and context menu for main window and queues.
- Added site logins integration directly into the Add Downloads window.
### Changes
- Updated the paste hint to use a visual Command icon.
### Fixes
- Resolved SwiftUI infinite layout freeze caused by MenuBarExtra binding.
- Fixed a bug with Light/System theme appearance.
- Fixed phantom state issues with Menu Bar Icon setting and conditionally applied theme backgrounds to preserve native macOS translucency.
## [0.4.0] - 2026-06-03
### Changes
- Reorganized Settings sections so related download preferences sit together and app diagnostics live under App.
- Hardened the release workflow with explicit macOS 26 SDK checks, newer GitHub Actions, and app signature verification.
- Prefer the bundled `aria2c` binary inside release builds.
### Fixes
- Fixed queue-specific starts so one queue no longer starts unrelated queued downloads.
- Fixed scheduler completion handling so empty queues do not trigger post-download system actions.
- Fixed queue drag reordering when moving items downward.
- Fixed scheduler Automation permission prompting.
### Features added
- Added scheduler controls with explicit Automation permission UI.
- Added global and per-download speed limits.
- Added advanced transfer options for checksums, headers, cookies, and mirror URLs.
## [0.3.0] - 2026-06-02
### Added
+11 -5
View File
@@ -92,24 +92,30 @@ make dmg
### Release
GitHub Actions builds and publishes the macOS ARM64 DMG when a version tag is pushed:
```bash
git tag v0.1.0
git push origin v0.1.0
git tag v0.4.1
git push origin v0.4.1
```
---
## 🗺️ Roadmap
- [ ] **Data Persistence:** Store history, column layout preferences, and active queues across restarts.
- [x] **Data Persistence:** Store history, column layout preferences, and active queues across restarts.
- [x] **Zero-Config Setup:** Automatically bundle and configure `aria2c` inside the `.app` bundle.
- [ ] **Bandwidth Limits:** Add global and per-download speed caps and calendar schedules.
- [x] **Bandwidth Limits:** Add global and per-download speed caps and calendar schedules.
- [ ] **Browser Extensions:** Capture links directly from Safari, Chrome, and Firefox.
- [ ] **Advanced Transfer Features:** Checksum validation, cookie/header ingestion, and smart mirror failovers.
- [x] **Advanced Transfer Features:** Checksum validation, cookie/header ingestion, and smart mirror failovers.
- [x] **Updates & Releases:** GitHub Actions DMG release pipeline and built-in update checker.
- [ ] **Distribution:** Notarized `.app` releases and Homebrew formulae.
---
## 🏆 Credits
Firelink relies on [aria2](https://aria2.github.io/) as its underlying multi-protocol and multi-source command-line download utility. Special thanks to the aria2 contributors for their excellent engine.
---
## 📄 License
Firelink is released under the MIT License. See [LICENSE](LICENSE) for details.
+7
View File
@@ -19,6 +19,7 @@ rm -rf "$APP_DIR"
mkdir -p "$MACOS_DIR" "$RESOURCES_DIR"
cp ".build/$CONFIGURATION/$APP_NAME" "$MACOS_DIR/$APP_NAME"
cp "$ROOT_DIR/Resources/$ICON_NAME.icns" "$RESOURCES_DIR/$ICON_NAME.icns"
cp "$ROOT_DIR/Sources/Firelink/Assets.xcassets/MenuBarIcon.imageset/MenuBarIconTemplate.png" "$RESOURCES_DIR/MenuBarIconTemplate.png"
ARIA2C_PATH=$(which aria2c || true)
if [[ -n "$ARIA2C_PATH" && -x "$ARIA2C_PATH" ]]; then
@@ -65,8 +66,14 @@ cat > "$CONTENTS_DIR/Info.plist" <<PLIST
<string>public.app-category.utilities</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSAppleEventsUsageDescription</key>
<string>Firelink needs permission to control Finder so it can sleep, restart, or shut down your Mac after scheduled downloads finish.</string>
</dict>
</plist>
PLIST
if command -v codesign &> /dev/null; then
codesign --force --deep --sign - "$APP_DIR"
fi
echo "Created $APP_DIR"
+192 -26
View File
@@ -12,6 +12,19 @@ struct AddDownloadsView: View {
@State private var destinationPath = ""
@State private var metadataTask: Task<Void, Never>?
@State private var targetQueueID = DownloadQueue.mainQueueID
@State private var speedLimitEnabled = false
@State private var speedLimitKiBPerSecond = 1024
@State private var showsAdvancedTransfer = false
@State private var checksumEnabled = false
@State private var checksumAlgorithm: ChecksumAlgorithm = .sha256
@State private var checksumValue = ""
@State private var headerText = ""
@State private var cookieText = ""
@State private var mirrorText = ""
@State private var useAuthorization = false
@State private var authUsername = ""
@State private var authPassword = ""
@State private var saveLogin = false
var body: some View {
VStack(spacing: 0) {
@@ -19,16 +32,17 @@ struct AddDownloadsView: View {
VStack(alignment: .leading, spacing: 18) {
linkSection
optionsSection
advancedTransferSection
summarySection
previewSection
}
.padding(20)
.padding(16)
}
Divider()
actionBar
}
.frame(minWidth: 820, idealWidth: 900, minHeight: 680, idealHeight: 740)
.frame(minWidth: 720, idealWidth: 780, minHeight: 560, idealHeight: 620)
.onChange(of: linkText) { _, newValue in
scheduleMetadataRefresh(for: newValue)
}
@@ -56,7 +70,7 @@ struct AddDownloadsView: View {
.scrollContentBackground(.hidden)
.background(.quaternary.opacity(0.35))
.clipShape(RoundedRectangle(cornerRadius: 8))
.frame(minHeight: 140)
.frame(minHeight: 96)
HStack {
Text("\(pendingDownloads.count) valid link\(pendingDownloads.count == 1 ? "" : "s") detected")
@@ -98,33 +112,70 @@ struct AddDownloadsView: View {
}
}
GridRow {
GridRow(alignment: .firstTextBaseline) {
Label("Connections per File", systemImage: "point.3.connected.trianglepath.dotted")
.font(.headline)
VStack(alignment: .leading, spacing: 4) {
HStack {
Slider(value: $connectionsPerServer, in: 1...16, step: 1)
.frame(width: 220)
.frame(width: 170)
Text("\(Int(connectionsPerServer)) segments")
.monospacedDigit()
.frame(width: 130, alignment: .leading)
.frame(width: 110, alignment: .leading)
}
}
}
GridRow(alignment: .firstTextBaseline) {
Label("Speed Limit", systemImage: "speedometer")
.font(.headline)
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 10) {
Toggle("Limit this batch", 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(.headline)
.padding(.top, 4)
VStack(alignment: .leading, spacing: 10) {
Toggle("Use authorization", isOn: $useAuthorization)
.toggleStyle(.switch)
if useAuthorization {
HStack(spacing: 10) {
TextField("Username", text: $authUsername)
.textFieldStyle(.roundedBorder)
.frame(width: 160)
SecureField("Password", text: $authPassword)
.textFieldStyle(.roundedBorder)
.frame(width: 160)
}
Toggle("Save login for this website", isOn: $saveLogin)
.font(.caption)
.foregroundStyle(.secondary)
}
Text("Firelink splits each file into this many parallel segments.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
private var summarySection: some View {
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 10) {
GridRow {
SummaryTile(title: "Files", value: "\(pendingDownloads.count)", symbolName: "doc.on.doc")
SummaryTile(title: "Required", value: requiredSpaceText, symbolName: "externaldrive")
SummaryTile(title: "Free", value: freeSpaceText, symbolName: "internaldrive")
SummaryTile(title: "Unknown Sizes", value: "\(unknownSizeCount)", symbolName: "questionmark.circle")
}
HStack(spacing: 10) {
SummaryTile(title: "Files", value: "\(pendingDownloads.count)", symbolName: "doc.on.doc")
SummaryTile(title: "Required", value: requiredSpaceText, symbolName: "externaldrive")
SummaryTile(title: "Free", value: freeSpaceText, symbolName: "internaldrive")
SummaryTile(title: "Unknown Sizes", value: "\(unknownSizeCount)", symbolName: "questionmark.circle")
}
}
@@ -141,10 +192,6 @@ struct AddDownloadsView: View {
VStack(alignment: .leading, spacing: 2) {
Text(item.fileName)
.lineLimit(1)
Text(item.url.absoluteString)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
}
@@ -167,7 +214,7 @@ struct AddDownloadsView: View {
}
.width(130)
}
.frame(minHeight: 230)
.frame(minHeight: 170)
}
}
@@ -186,7 +233,7 @@ struct AddDownloadsView: View {
} label: {
Label("Add to Queue", systemImage: "list.bullet")
}
.disabled(pendingDownloads.isEmpty)
.disabled(!canAddDownloads)
Button {
addDownloads(start: true)
@@ -194,12 +241,71 @@ struct AddDownloadsView: View {
Label("Start Downloads", systemImage: "play.fill")
}
.buttonStyle(.borderedProminent)
.disabled(pendingDownloads.isEmpty)
.disabled(!canAddDownloads)
}
.padding(14)
.background(.bar)
}
private var advancedTransferSection: some View {
DisclosureGroup(isExpanded: $showsAdvancedTransfer) {
Grid(alignment: .leading, horizontalSpacing: 14, verticalSpacing: 12) {
GridRow(alignment: .firstTextBaseline) {
Toggle("Checksum", isOn: $checksumEnabled)
.font(.headline)
HStack(spacing: 10) {
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(.body, design: .monospaced))
}
.disabled(!checksumEnabled)
}
GridRow(alignment: .top) {
Label("Headers", systemImage: "text.quote")
.font(.headline)
TextEditor(text: $headerText)
.font(.system(.body, design: .monospaced))
.scrollContentBackground(.hidden)
.background(.quaternary.opacity(0.35))
.clipShape(RoundedRectangle(cornerRadius: 8))
.frame(minHeight: 60)
}
GridRow(alignment: .firstTextBaseline) {
Label("Cookies", systemImage: "circle.hexagongrid.circle")
.font(.headline)
TextField("name=value; other=value", text: $cookieText)
.textFieldStyle(.roundedBorder)
.font(.system(.body, design: .monospaced))
}
GridRow(alignment: .top) {
Label("Mirrors", systemImage: "point.3.filled.connected.trianglepath.dotted")
.font(.headline)
TextEditor(text: $mirrorText)
.font(.system(.body, design: .monospaced))
.scrollContentBackground(.hidden)
.background(.quaternary.opacity(0.35))
.clipShape(RoundedRectangle(cornerRadius: 8))
.frame(minHeight: 60)
}
}
.padding(.top, 10)
} label: {
Label("Advanced Transfer", systemImage: "slider.horizontal.3")
.font(.headline)
}
}
private var requiredSpaceText: String {
let knownBytes = pendingDownloads.compactMap(\.sizeBytes).reduce(Int64(0), +)
guard knownBytes > 0 else { return "Unknown" }
@@ -220,6 +326,10 @@ struct AddDownloadsView: View {
return "Paste one or more HTTP, HTTPS, FTP, or SFTP links."
}
if let validationMessage {
return validationMessage
}
if unknownSizeCount > 0 {
return "Some servers did not report file size before download."
}
@@ -227,6 +337,35 @@ struct AddDownloadsView: View {
return "Ready to add \(pendingDownloads.count) download\(pendingDownloads.count == 1 ? "" : "s")."
}
private var canAddDownloads: Bool {
!pendingDownloads.isEmpty && validationMessage == nil
}
private var validationMessage: String? {
if checksumEnabled && checksumValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return "Add the expected checksum digest, or turn checksum off."
}
if DownloadTransferOptionParser.invalidHeaderLines(headerText).isEmpty == false {
return "Headers must use Name: Value lines."
}
if DownloadTransferOptionParser.invalidMirrorLines(mirrorText).isEmpty == false {
return "Mirrors must be valid HTTP, HTTPS, FTP, or SFTP URLs."
}
return nil
}
private var transferOptions: DownloadTransferOptions {
DownloadTransferOptions(
checksum: checksumEnabled ? DownloadChecksum(algorithm: checksumAlgorithm, value: checksumValue).normalized : nil,
requestHeaders: DownloadTransferOptionParser.parseHeaders(headerText),
cookieHeader: DownloadTransferOptionParser.cleanCookieHeader(cookieText),
mirrorURLs: DownloadTransferOptionParser.parseMirrorURLs(mirrorText)
)
}
private func scheduleMetadataRefresh(for text: String) {
metadataTask?.cancel()
metadataTask = Task {
@@ -254,11 +393,18 @@ struct AddDownloadsView: View {
)
}
if let firstURL = urls.first, let creds = settings.credentials(for: firstURL) {
useAuthorization = true
authUsername = creds.username
authPassword = creds.password
saveLogin = false
}
metadataTask = Task {
var loaded: [PendingDownload] = []
for url in urls {
guard !Task.isCancelled else { return }
let item = await DownloadMetadataFetcher.fetch(for: url, settings: settings)
let item = await DownloadMetadataFetcher.fetch(for: url, settings: settings, transferOptions: transferOptions)
loaded.append(item)
await MainActor.run {
for loadedItem in loaded {
@@ -289,12 +435,32 @@ struct AddDownloadsView: View {
}
private func addDownloads(start: Bool) {
var explicitCredentials: DownloadCredentials? = nil
if useAuthorization {
let cleanUsername = authUsername.trimmingCharacters(in: .whitespacesAndNewlines)
if !cleanUsername.isEmpty {
explicitCredentials = DownloadCredentials(username: cleanUsername, password: authPassword)
if saveLogin {
var savedHosts = Set<String>()
for item in pendingDownloads {
if let host = item.url.host, !savedHosts.contains(host) {
settings.addSiteLogin(urlPattern: "*.\(host)", username: cleanUsername, password: authPassword)
savedHosts.insert(host)
}
}
}
}
}
controller.addPendingDownloads(
pendingDownloads,
connectionsPerServer: Int(connectionsPerServer),
overrideDirectory: overrideDirectory,
startImmediately: start,
queueID: targetQueueID
queueID: targetQueueID,
credentials: explicitCredentials,
transferOptions: transferOptions,
speedLimitKiBPerSecond: speedLimitEnabled ? speedLimitKiBPerSecond : nil
)
dismiss()
}
@@ -377,7 +543,7 @@ private struct SummaryTile: View {
Spacer(minLength: 0)
}
.padding(12)
.frame(width: 190)
.frame(maxWidth: .infinity)
.frame(minHeight: 64)
.background(.quaternary.opacity(0.35))
.clipShape(RoundedRectangle(cornerRadius: 8))
+50 -2
View File
@@ -65,6 +65,18 @@ struct DownloadProxyConfiguration: Equatable, Sendable {
@MainActor
final class AppSettings: ObservableObject {
@Published var appTheme: AppTheme = .system {
didSet { save() }
}
@Published var appFontSize: AppFontSize = .standard {
didSet { save() }
}
@Published var listRowDensity: ListRowDensity = .standard {
didSet { save() }
}
@Published var perServerConnections: Int {
didSet {
let clamped = min(max(perServerConnections, 1), 16)
@@ -85,6 +97,17 @@ final class AppSettings: ObservableObject {
}
}
@Published var globalSpeedLimitKiBPerSecond: Int {
didSet {
let clamped = min(max(globalSpeedLimitKiBPerSecond, 0), 10_485_760)
if globalSpeedLimitKiBPerSecond != clamped {
globalSpeedLimitKiBPerSecond = clamped
return
}
save()
}
}
@Published var preventsSleepWhileDownloading: Bool {
didSet { save() }
}
@@ -112,21 +135,30 @@ final class AppSettings: ObservableObject {
private let defaults: UserDefaults
private let storageKey = "Firelink.AppSettings.v1"
private var saveTask: Task<Void, Never>?
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
if let data = defaults.data(forKey: storageKey),
let stored = try? JSONDecoder().decode(StoredSettings.self, from: data) {
appTheme = stored.appTheme ?? .system
appFontSize = stored.appFontSize ?? .standard
listRowDensity = stored.listRowDensity ?? .standard
perServerConnections = min(max(stored.perServerConnections, 1), 16)
maxConcurrentDownloads = min(max(stored.maxConcurrentDownloads ?? 3, 1), 12)
globalSpeedLimitKiBPerSecond = min(max(stored.globalSpeedLimitKiBPerSecond ?? 0, 0), 10_485_760)
preventsSleepWhileDownloading = stored.preventsSleepWhileDownloading
proxySettings = stored.proxySettings?.normalized ?? ProxySettings()
siteLogins = stored.siteLogins
downloadDirectories = Self.decodeDirectories(stored.downloadDirectories)
} else {
appTheme = .system
appFontSize = .standard
listRowDensity = .standard
perServerConnections = 16
maxConcurrentDownloads = 3
globalSpeedLimitKiBPerSecond = 0
preventsSleepWhileDownloading = true
proxySettings = ProxySettings()
siteLogins = []
@@ -205,16 +237,28 @@ final class AppSettings: ObservableObject {
private func save() {
let stored = StoredSettings(
appTheme: appTheme,
appFontSize: appFontSize,
listRowDensity: listRowDensity,
perServerConnections: perServerConnections,
maxConcurrentDownloads: maxConcurrentDownloads,
globalSpeedLimitKiBPerSecond: globalSpeedLimitKiBPerSecond,
preventsSleepWhileDownloading: preventsSleepWhileDownloading,
proxySettings: proxySettings.normalized,
downloadDirectories: Dictionary(uniqueKeysWithValues: downloadDirectories.map { ($0.key.rawValue, $0.value) }),
siteLogins: siteLogins
)
let defaults = self.defaults
let storageKey = self.storageKey
if let data = try? JSONEncoder().encode(stored) {
defaults.set(data, forKey: storageKey)
saveTask?.cancel()
saveTask = Task { @MainActor [defaults, storageKey] in
let data = await Task.detached(priority: .background) {
try? JSONEncoder().encode(stored)
}.value
guard !Task.isCancelled, let encoded = data else { return }
defaults.set(encoded, forKey: storageKey)
}
}
@@ -263,8 +307,12 @@ final class AppSettings: ObservableObject {
}
private struct StoredSettings: Codable {
var appTheme: AppTheme?
var appFontSize: AppFontSize?
var listRowDensity: ListRowDensity?
var perServerConnections: Int
var maxConcurrentDownloads: Int?
var globalSpeedLimitKiBPerSecond: Int?
var preventsSleepWhileDownloading: Bool
var proxySettings: ProxySettings?
var downloadDirectories: [String: String]
+37 -8
View File
@@ -31,6 +31,11 @@ final class Aria2DownloadEngine {
}
static func findExecutable() -> URL? {
if let bundled = Bundle.main.url(forResource: "aria2c", withExtension: nil),
FileManager.default.isExecutableFile(atPath: bundled.path) {
return bundled
}
let candidates = [
"/opt/homebrew/bin/aria2c",
"/usr/local/bin/aria2c",
@@ -49,11 +54,6 @@ final class Aria2DownloadEngine {
}
}
if let bundled = Bundle.main.url(forResource: "aria2c", withExtension: nil),
FileManager.default.isExecutableFile(atPath: bundled.path) {
return bundled
}
return nil
}
@@ -86,6 +86,7 @@ final class Aria2DownloadEngine {
func start(
item: DownloadItem,
proxyConfiguration: DownloadProxyConfiguration,
speedLimitKiBPerSecond: Int?,
progress: @escaping @Sendable (DownloadProgress) -> Void,
completion: @escaping @Sendable (Result<Void, Error>) -> Void
) throws -> Handle {
@@ -100,7 +101,11 @@ final class Aria2DownloadEngine {
let process = Process()
process.executableURL = executableURL
process.arguments = try arguments(for: item, proxyConfiguration: proxyConfiguration)
process.arguments = try arguments(
for: item,
proxyConfiguration: proxyConfiguration,
speedLimitKiBPerSecond: speedLimitKiBPerSecond
)
let inputPipe = Pipe()
let outputPipe = Pipe()
@@ -167,7 +172,11 @@ final class Aria2DownloadEngine {
}
}
private func arguments(for item: DownloadItem, proxyConfiguration: DownloadProxyConfiguration) throws -> [String] {
private func arguments(
for item: DownloadItem,
proxyConfiguration: DownloadProxyConfiguration,
speedLimitKiBPerSecond: Int?
) throws -> [String] {
var arguments = [
"--continue=true",
"--allow-overwrite=false",
@@ -181,9 +190,14 @@ final class Aria2DownloadEngine {
"--retry-wait=5",
"--connect-timeout=30",
"--timeout=60",
"--uri-selector=adaptive",
"--input-file=-"
]
if let speedLimitKiBPerSecond, speedLimitKiBPerSecond > 0 {
arguments.append("--max-download-limit=\(speedLimitKiBPerSecond)K")
}
arguments.append(contentsOf: try proxyArguments(for: item, configuration: proxyConfiguration))
return arguments
}
@@ -281,14 +295,29 @@ final class Aria2DownloadEngine {
private func inputFileContent(for item: DownloadItem) -> String {
let connections = min(max(item.connectionsPerServer, 1), 16)
let urls = ([item.url] + (item.mirrorURLs ?? []))
.map { sanitizedOptionValue($0.absoluteString) }
.joined(separator: "\t")
var lines = [
sanitizedOptionValue(item.url.absoluteString),
urls,
" dir=\(sanitizedOptionValue(item.destinationDirectory.path))",
" out=\(sanitizedOptionValue(item.fileName))",
" split=\(connections)",
" max-connection-per-server=\(connections)"
]
if let checksum = item.checksum?.normalized, !checksum.isEmpty {
lines.append(" checksum=\(checksum.algorithm.rawValue)=\(sanitizedOptionValue(checksum.value))")
}
for header in (item.requestHeaders ?? []).map(\.normalized) where !header.isEmpty {
lines.append(" header=\(sanitizedOptionValue(header.headerLine))")
}
if let cookieHeader = item.cookieHeader?.trimmingCharacters(in: .whitespacesAndNewlines), !cookieHeader.isEmpty {
lines.append(" header=Cookie: \(sanitizedOptionValue(cookieHeader))")
}
if let credentials = item.credentials, !credentials.isEmpty {
let scheme = item.url.scheme?.lowercased()
if scheme == "ftp" || scheme == "sftp" {
@@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "MenuBarIconTemplate.png",
"idiom" : "mac",
"scale" : "2x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"template-rendering-intent" : "template"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

+5
View File
@@ -3,6 +3,7 @@ import SwiftUI
struct ContentView: View {
@EnvironmentObject private var controller: DownloadController
@EnvironmentObject private var settings: AppSettings
@Environment(\.openWindow) private var openWindow
@State private var selection: Set<DownloadItem.ID> = []
@State private var sidebarSelection: SidebarSelection = .downloads(.all)
@@ -12,8 +13,10 @@ struct ContentView: View {
NavigationSplitView {
SidebarView(selection: $sidebarSelection)
.navigationSplitViewColumnWidth(min: 190, ideal: 220, max: 260)
.themeBackground(settings.appTheme.theme.secondaryBackground)
} detail: {
detailView
.themeBackground(settings.appTheme.theme.background)
}
}
@@ -24,6 +27,8 @@ struct ContentView: View {
downloadsView(filter: filter)
case .queue(let queueID):
queueView(queueID: queueID)
case .scheduler:
SchedulerView()
case .settings:
SettingsView()
}
+173 -17
View File
@@ -15,6 +15,7 @@ final class DownloadController: ObservableObject {
private var activeHandles: [UUID: Aria2DownloadEngine.Handle] = [:]
private var automaticRetryCounts: [UUID: Int] = [:]
private var restrictQueueToAutoResume = false
private var queuePumpScope: QueuePumpScope = .idle
private var sleepActivity: SleepActivityHandle?
private var cancellables = Set<AnyCancellable>()
private let maxAutomaticRetries = 3
@@ -22,6 +23,7 @@ final class DownloadController: ObservableObject {
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>?
init(settings: AppSettings) {
self.settings = settings
@@ -54,6 +56,7 @@ final class DownloadController: ObservableObject {
Task { @MainActor in
self.engineMessage = "Recovered downloads from the previous session."
self.restrictQueueToAutoResume = true
self.queuePumpScope = .all
self.pumpQueue()
}
}
@@ -113,10 +116,14 @@ final class DownloadController: ObservableObject {
connectionsPerServer: Int,
overrideDirectory: URL?,
startImmediately: Bool,
queueID: UUID = DownloadQueue.mainQueueID
queueID: UUID = DownloadQueue.mainQueueID,
credentials: DownloadCredentials? = nil,
transferOptions: DownloadTransferOptions = DownloadTransferOptions(),
speedLimitKiBPerSecond: Int? = nil
) {
let clampedConnections = min(max(connectionsPerServer, 1), 16)
let targetQueueID = normalizedQueueID(queueID)
let speedLimitKiBPerSecond = normalizedSpeedLimit(speedLimitKiBPerSecond)
let items = pendingDownloads.map { pending in
DownloadItem(
@@ -125,7 +132,12 @@ final class DownloadController: ObservableObject {
category: pending.category,
destinationDirectory: overrideDirectory ?? pending.defaultDirectory,
connectionsPerServer: clampedConnections,
credentials: settings.credentials(for: pending.url),
credentials: credentials ?? settings.credentials(for: pending.url),
checksum: transferOptions.checksum,
requestHeaders: transferOptions.requestHeaders,
cookieHeader: transferOptions.cookieHeader,
mirrorURLs: transferOptions.mirrorURLs,
speedLimitKiBPerSecond: speedLimitKiBPerSecond,
sizeBytes: pending.sizeBytes,
bytesText: ByteFormatter.string(pending.sizeBytes),
message: startImmediately ? "Queued to start" : "Added to queue",
@@ -145,6 +157,20 @@ final class DownloadController: ObservableObject {
func startQueue(queueID: UUID? = nil) {
engineMessage = ""
restrictQueueToAutoResume = false
if let queueID {
let queueID = normalizedQueueID(queueID)
switch queuePumpScope {
case .all:
break
case .idle:
queuePumpScope = .scoped(queueIDs: [queueID], itemIDs: [])
case .scoped(var queueIDs, let itemIDs):
queueIDs.insert(queueID)
queuePumpScope = .scoped(queueIDs: queueIDs, itemIDs: itemIDs)
}
} else {
queuePumpScope = .all
}
markQueuedDownloadsForAutoResume(queueID: queueID)
pumpQueue()
}
@@ -213,6 +239,25 @@ final class DownloadController: ObservableObject {
$0.message = ""
$0.autoResumeOnLaunch = true
}
queuePumpScope = queuePumpScope.includingItem(item.id)
automaticRetryCounts[item.id] = nil
saveDownloads()
pumpQueue()
}
func redownload(_ item: DownloadItem) {
trashFiles(for: item)
restrictQueueToAutoResume = false
update(item.id) {
$0.status = .queued
$0.progress = 0
$0.speedText = "-"
$0.etaText = "-"
$0.connectionCount = 0
$0.message = "Redownloading"
$0.autoResumeOnLaunch = true
}
queuePumpScope = queuePumpScope.includingItem(item.id)
automaticRetryCounts[item.id] = nil
saveDownloads()
pumpQueue()
@@ -322,7 +367,8 @@ final class DownloadController: ObservableObject {
}
let item = downloads.remove(at: source)
downloads.insert(item, at: target)
let insertionIndex = source < target ? target - 1 : target
downloads.insert(item, at: insertionIndex)
saveDownloads()
}
@@ -332,9 +378,13 @@ final class DownloadController: ObservableObject {
return
}
pruneActiveQueueScopes()
while activeCount < settings.maxConcurrentDownloads,
let next = downloads.first(where: { item in
item.status == .queued && (!restrictQueueToAutoResume || item.autoResumeOnLaunch == true)
item.status == .queued &&
(!restrictQueueToAutoResume || item.autoResumeOnLaunch == true) &&
isAllowedToStart(item)
}) {
start(next)
}
@@ -344,6 +394,8 @@ final class DownloadController: ObservableObject {
!downloads.contains(where: { $0.status == .queued && $0.autoResumeOnLaunch == true }) {
restrictQueueToAutoResume = false
}
pruneActiveQueueScopes()
}
private func start(_ item: DownloadItem) {
@@ -361,6 +413,7 @@ final class DownloadController: ObservableObject {
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) {
@@ -427,7 +480,9 @@ final class DownloadController: ObservableObject {
fileName: String,
destinationDirectory: URL,
connectionsPerServer: Int,
credentials: DownloadCredentials?
credentials: DownloadCredentials?,
transferOptions: DownloadTransferOptions,
speedLimitKiBPerSecond: Int?
) {
update(id) {
$0.url = url
@@ -436,16 +491,49 @@ final class DownloadController: ObservableObject {
$0.destinationDirectory = destinationDirectory
$0.connectionsPerServer = min(max(connectionsPerServer, 1), 16)
$0.credentials = credentials
$0.checksum = transferOptions.checksum
$0.requestHeaders = transferOptions.requestHeaders
$0.cookieHeader = transferOptions.cookieHeader
$0.mirrorURLs = transferOptions.mirrorURLs
$0.speedLimitKiBPerSecond = normalizedSpeedLimit(speedLimitKiBPerSecond)
$0.message = "Properties updated"
}
saveDownloads()
}
private func normalizedSpeedLimit(_ value: Int?) -> Int? {
guard let value, value > 0 else { return nil }
return min(value, 10_485_760)
}
private func effectiveSpeedLimitKiBPerSecond(for item: DownloadItem) -> Int? {
let itemLimit = normalizedSpeedLimit(item.speedLimitKiBPerSecond)
let globalLimit = normalizedSpeedLimit(settings.globalSpeedLimitKiBPerSecond)
.map { max(1, $0 / max(settings.maxConcurrentDownloads, 1)) }
switch (itemLimit, globalLimit) {
case let (.some(itemLimit), .some(globalLimit)):
return min(itemLimit, globalLimit)
case let (.some(itemLimit), .none):
return itemLimit
case let (.none, .some(globalLimit)):
return globalLimit
case (.none, .none):
return nil
}
}
private func markQueuedDownloadsForAutoResume(queueID: UUID?) {
let normalizedID = queueID.map(normalizedQueueID)
for index in downloads.indices where downloads[index].status == .queued &&
(normalizedID == nil || normalizedQueueID(downloads[index].queueID) == normalizedID) {
downloads[index].autoResumeOnLaunch = true
if let queueID {
let normalizedID = normalizedQueueID(queueID)
for index in downloads.indices where downloads[index].status == .queued &&
validQueueID(downloads[index].queueID) == normalizedID {
downloads[index].autoResumeOnLaunch = true
}
} else {
for index in downloads.indices where downloads[index].status == .queued {
downloads[index].autoResumeOnLaunch = true
}
}
saveDownloads()
}
@@ -492,6 +580,65 @@ final class DownloadController: ObservableObject {
}
}
private func isAllowedToStart(_ item: DownloadItem) -> Bool {
switch queuePumpScope {
case .idle:
return false
case .all:
return true
case .scoped(let queueIDs, let itemIDs):
if itemIDs.contains(item.id) {
return true
}
guard let queueID = validQueueID(item.queueID) else { return false }
return queueIDs.contains(queueID)
}
}
private func pruneActiveQueueScopes() {
switch queuePumpScope {
case .idle:
return
case .all:
if !downloads.contains(where: { $0.status == .queued || $0.status == .downloading }) {
queuePumpScope = .idle
}
case .scoped(let queueIDs, let itemIDs):
let activeQueueIDs = queueIDs.filter { queueID in
downloads.contains { item in
validQueueID(item.queueID) == queueID &&
(item.status == .queued || item.status == .downloading)
}
}
let activeItemIDs = itemIDs.filter { itemID in
downloads.contains { item in
item.id == itemID && (item.status == .queued || item.status == .downloading)
}
}
queuePumpScope = activeQueueIDs.isEmpty && activeItemIDs.isEmpty
? .idle
: .scoped(queueIDs: activeQueueIDs, itemIDs: activeItemIDs)
}
}
private enum QueuePumpScope {
case idle
case all
case scoped(queueIDs: Set<UUID>, itemIDs: Set<UUID>)
func includingItem(_ itemID: UUID) -> QueuePumpScope {
switch self {
case .idle:
return .scoped(queueIDs: [], itemIDs: [itemID])
case .all:
return .all
case .scoped(let queueIDs, var itemIDs):
itemIDs.insert(itemID)
return .scoped(queueIDs: queueIDs, itemIDs: itemIDs)
}
}
}
private func updateSleepActivity() {
let shouldPreventSleep = settings.preventsSleepWhileDownloading && activeCount > 0
@@ -528,14 +675,23 @@ final class DownloadController: ObservableObject {
}
private func saveDownloads() {
do {
let directory = storageURL.deletingLastPathComponent()
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
let state = StoredDownloadState(queues: queues, downloads: downloads)
let data = try JSONEncoder().encode(state)
try data.write(to: storageURL, options: .atomic)
} catch {
print("Failed to save downloads: \(error)")
let queuesCopy = queues
let downloadsCopy = downloads
let storageURL = self.storageURL
saveTask?.cancel()
saveTask = Task.detached(priority: .background) {
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)
guard !Task.isCancelled else { return }
try data.write(to: storageURL, options: .atomic)
} catch {
print("Failed to save downloads: \(error)")
}
}
}
+11 -1
View File
@@ -30,7 +30,11 @@ enum DownloadURLParser {
}
enum DownloadMetadataFetcher {
static func fetch(for url: URL, settings: AppSettings) async -> PendingDownload {
static func fetch(
for url: URL,
settings: AppSettings,
transferOptions: DownloadTransferOptions = DownloadTransferOptions()
) async -> PendingDownload {
let initialName = FileClassifier.fileName(from: url)
let initialCategory = FileClassifier.category(forFileName: initialName)
let initialDirectory = await settings.destinationDirectory(for: initialCategory)
@@ -51,6 +55,12 @@ enum DownloadMetadataFetcher {
request.httpMethod = "HEAD"
request.timeoutInterval = 12
request.setValue("Firelink/0.1", forHTTPHeaderField: "User-Agent")
for header in transferOptions.requestHeaders.map(\.normalized) where !header.isEmpty {
request.setValue(header.value, forHTTPHeaderField: header.name)
}
if let cookieHeader = transferOptions.cookieHeader?.trimmingCharacters(in: .whitespacesAndNewlines), !cookieHeader.isEmpty {
request.setValue(cookieHeader, forHTTPHeaderField: "Cookie")
}
do {
let (_, response) = try await URLSession.shared.data(for: request)
+94 -2
View File
@@ -37,6 +37,14 @@ struct DownloadPropertiesView: View {
@State private var loginMode: LoginMode
@State private var username: String
@State private var password: String
@State private var speedLimitEnabled: Bool
@State private var speedLimitKiBPerSecond: Int
@State private var checksumEnabled: Bool
@State private var checksumAlgorithm: ChecksumAlgorithm
@State private var checksumValue: String
@State private var headerText: String
@State private var cookieText: String
@State private var mirrorText: String
@State private var errorMessage = ""
init(item: DownloadItem) {
@@ -45,6 +53,8 @@ struct DownloadPropertiesView: View {
_fileName = State(initialValue: item.fileName)
_destinationPath = State(initialValue: item.destinationDirectory.path)
_connections = State(initialValue: item.connectionsPerServer)
_speedLimitEnabled = State(initialValue: (item.speedLimitKiBPerSecond ?? 0) > 0)
_speedLimitKiBPerSecond = State(initialValue: max(item.speedLimitKiBPerSecond ?? 1024, 1))
if let credentials = item.credentials {
_loginMode = State(initialValue: .custom)
_username = State(initialValue: credentials.username)
@@ -54,6 +64,18 @@ struct DownloadPropertiesView: View {
_username = State(initialValue: "")
_password = State(initialValue: "")
}
if let checksum = item.checksum {
_checksumEnabled = State(initialValue: true)
_checksumAlgorithm = State(initialValue: checksum.algorithm)
_checksumValue = State(initialValue: checksum.value)
} else {
_checksumEnabled = State(initialValue: false)
_checksumAlgorithm = State(initialValue: .sha256)
_checksumValue = State(initialValue: "")
}
_headerText = State(initialValue: (item.requestHeaders ?? []).map(\.headerLine).joined(separator: "\n"))
_cookieText = State(initialValue: item.cookieHeader ?? "")
_mirrorText = State(initialValue: (item.mirrorURLs ?? []).map(\.absoluteString).joined(separator: "\n"))
}
var body: some View {
@@ -73,6 +95,15 @@ struct DownloadPropertiesView: View {
}
}
Stepper("Connections per file: \(connections)", value: $connections, in: 1...16)
Toggle("Limit speed", isOn: $speedLimitEnabled)
if speedLimitEnabled {
Stepper(
"Speed cap: \(speedLimitKiBPerSecond) KiB/s",
value: $speedLimitKiBPerSecond,
in: 1...10_485_760,
step: 128
)
}
}
Section("Site Login") {
@@ -93,6 +124,36 @@ struct DownloadPropertiesView: View {
}
}
Section("Advanced Transfer") {
Toggle("Checksum", isOn: $checksumEnabled)
if checksumEnabled {
Picker("Algorithm", selection: $checksumAlgorithm) {
ForEach(ChecksumAlgorithm.allCases) { algorithm in
Text(algorithm.title).tag(algorithm)
}
}
TextField("Expected digest", text: $checksumValue)
.font(.system(.body, design: .monospaced))
}
VStack(alignment: .leading, spacing: 6) {
Text("Headers")
TextEditor(text: $headerText)
.font(.system(.body, design: .monospaced))
.frame(minHeight: 70)
}
TextField("Cookies", text: $cookieText)
.font(.system(.body, design: .monospaced))
VStack(alignment: .leading, spacing: 6) {
Text("Mirrors")
TextEditor(text: $mirrorText)
.font(.system(.body, design: .monospaced))
.frame(minHeight: 70)
}
}
Section("Progress") {
ProgressView(value: item.progress)
InfoGrid(item: item)
@@ -120,7 +181,7 @@ struct DownloadPropertiesView: View {
.padding(14)
.background(.bar)
}
.frame(width: 620, height: 560)
.frame(width: 620, height: 760)
}
private var matchingLoginText: String {
@@ -175,16 +236,46 @@ struct DownloadPropertiesView: View {
credentials = nil
}
guard let transferOptions = validatedTransferOptions else {
return
}
controller.updateDownload(
id: item.id,
url: url,
fileName: cleanFileName,
destinationDirectory: destination,
connectionsPerServer: connections,
credentials: credentials
credentials: credentials,
transferOptions: transferOptions,
speedLimitKiBPerSecond: speedLimitEnabled ? speedLimitKiBPerSecond : nil
)
dismiss()
}
private var validatedTransferOptions: DownloadTransferOptions? {
if checksumEnabled && checksumValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
errorMessage = "Add the expected checksum digest, or turn checksum off."
return nil
}
if DownloadTransferOptionParser.invalidHeaderLines(headerText).isEmpty == false {
errorMessage = "Headers must use Name: Value lines."
return nil
}
if DownloadTransferOptionParser.invalidMirrorLines(mirrorText).isEmpty == false {
errorMessage = "Mirrors must be valid HTTP, HTTPS, FTP, or SFTP URLs."
return nil
}
return DownloadTransferOptions(
checksum: checksumEnabled ? DownloadChecksum(algorithm: checksumAlgorithm, value: checksumValue).normalized : nil,
requestHeaders: DownloadTransferOptionParser.parseHeaders(headerText),
cookieHeader: DownloadTransferOptionParser.cleanCookieHeader(cookieText),
mirrorURLs: DownloadTransferOptionParser.parseMirrorURLs(mirrorText)
)
}
}
private struct InfoGrid: View {
@@ -198,6 +289,7 @@ private struct InfoGrid: View {
info("Speed", item.speedText)
info("ETA", item.etaText)
info("Live connections", "\(item.connectionCount)")
info("Speed cap", item.speedLimitText)
info("Date added", item.createdAt.formatted(date: .abbreviated, time: .shortened))
info("Last try", item.lastTryAt?.formatted(date: .abbreviated, time: .shortened) ?? "-")
info("Category", item.category.rawValue)
+85 -28
View File
@@ -64,16 +64,20 @@ final class TableSettings: ObservableObject {
private let defaults = UserDefaults.standard
private let storageKey = "Firelink.TableSettings.v1"
private var saveTask: Task<Void, Never>?
init() {
let defaultVisibleColumns: Set<DownloadColumn> = [.fileName, .size, .progress, .speed, .eta, .dateAdded]
let legacyDefaultVisibleColumns: Set<DownloadColumn> = [.fileName, .size, .progress, .eta, .lastTry, .dateAdded]
if let data = defaults.data(forKey: storageKey),
let stored = try? JSONDecoder().decode(StoredTableSettings.self, from: data) {
visibleColumns = stored.visibleColumns
visibleColumns = stored.visibleColumns == legacyDefaultVisibleColumns ? defaultVisibleColumns : stored.visibleColumns
columnWidths = stored.columnWidths
sortColumn = stored.sortColumn
sortDirection = stored.sortDirection
} else {
visibleColumns = [.fileName, .size, .progress, .eta, .lastTry, .dateAdded]
visibleColumns = defaultVisibleColumns
columnWidths = Dictionary(uniqueKeysWithValues: DownloadColumn.allCases.map { ($0, $0.width) })
sortColumn = .dateAdded
sortDirection = .descending
@@ -87,8 +91,16 @@ final class TableSettings: ObservableObject {
sortColumn: sortColumn,
sortDirection: sortDirection
)
if let data = try? JSONEncoder().encode(stored) {
defaults.set(data, forKey: storageKey)
let storageKey = self.storageKey
saveTask?.cancel()
saveTask = Task { @MainActor in
let data = await Task.detached(priority: .background) {
try? JSONEncoder().encode(stored)
}.value
guard !Task.isCancelled, let encoded = data else { return }
UserDefaults.standard.set(encoded, forKey: storageKey)
}
}
}
@@ -111,7 +123,7 @@ struct DownloadTable: View {
@StateObject private var tableSettings = TableSettings()
@State private var pendingDeleteItems: Set<DownloadItem.ID>?
@State private var resizeBaseWidths: [DownloadColumn: CGFloat] = [:]
@State private var dragOffsets: [DownloadColumn: CGFloat] = [:]
@State private var lastSelectedIndex: Int?
@State private var draggedItemID: DownloadItem.ID?
@@ -158,7 +170,7 @@ struct DownloadTable: View {
ContentUnavailableView(
"No Downloads",
systemImage: "arrow.down.circle",
description: Text("Use Add to paste one or more links.")
description: Text("Use Add or press \(Image(systemName: "command"))V to paste one or more links.")
)
}
}
@@ -183,7 +195,7 @@ struct DownloadTable: View {
pendingDeleteItems = nil
}
Button("Move File and Cache to Trash", role: .destructive) {
Button("Move to Trash", role: .destructive) {
let items = controller.downloads.filter { ids.contains($0.id) }
for item in items { controller.delete(item, deleteFiles: true) }
selection.subtract(ids)
@@ -215,6 +227,13 @@ struct DownloadTable: View {
.frame(width: tableWidth, alignment: .leading)
.background(selection.contains(item.id) ? Color.accentColor.opacity(0.12) : Color.clear)
.contentShape(Rectangle())
.onTapGesture(count: 2) {
if item.status == .completed {
openFile(item)
} else {
openWindow(value: item.id)
}
}
.onTapGesture {
let index = sortedItems.firstIndex(where: { $0.id == item.id })
@@ -273,12 +292,12 @@ struct DownloadTable: View {
.gesture(
DragGesture(minimumDistance: 1)
.onChanged { value in
let baseWidth = resizeBaseWidths[column] ?? width(for: column)
resizeBaseWidths[column] = baseWidth
tableSettings.columnWidths[column] = max(70, baseWidth + value.translation.width)
dragOffsets[column] = value.translation.width
}
.onEnded { _ in
resizeBaseWidths[column] = nil
.onEnded { value in
let baseWidth = tableSettings.columnWidths[column] ?? column.width
tableSettings.columnWidths[column] = max(70, baseWidth + value.translation.width)
dragOffsets[column] = nil
}
)
}
@@ -323,12 +342,14 @@ struct DownloadTable: View {
private func rowContextMenu(for item: DownloadItem) -> some View {
let targetItems = selection.contains(item.id) ? controller.downloads.filter { selection.contains($0.id) } : [item]
Button {
for target in targetItems {
openWindow(value: target.id)
if targetItems.allSatisfy({ $0.status == .completed }) {
Button {
for target in targetItems {
openFile(target)
}
} label: {
Label(targetItems.count > 1 ? "Open (\(targetItems.count))" : "Open", systemImage: "doc")
}
} label: {
Label(targetItems.count > 1 ? "Properties (\(targetItems.count))" : "Properties", systemImage: "info.circle")
}
Button {
@@ -351,9 +372,9 @@ struct DownloadTable: View {
}
}
if targetItems.contains(where: { $0.status == .downloading }) {
if targetItems.contains(where: { $0.status == .downloading || $0.status == .queued }) {
Button {
for target in targetItems where target.status == .downloading {
for target in targetItems where target.status == .downloading || target.status == .queued {
controller.pause(target)
}
} label: {
@@ -361,6 +382,18 @@ struct DownloadTable: View {
}
}
if targetItems.contains(where: { $0.status == .completed || $0.status == .failed || $0.status == .canceled }) {
Button {
for target in targetItems where target.status == .completed || target.status == .failed || target.status == .canceled {
controller.redownload(target)
}
} label: {
Label("Redownload", systemImage: "arrow.clockwise")
}
}
Divider()
if targetItems.contains(where: { $0.status != .completed && $0.status != .downloading }) {
Menu {
ForEach(controller.queues) { queue in
@@ -372,8 +405,25 @@ struct DownloadTable: View {
}
}
} label: {
Label("Add to Queue", systemImage: "list.bullet")
Label("Move to Queue", systemImage: "list.bullet")
}
Divider()
}
Button {
NSPasteboard.general.clearContents()
let urls = targetItems.map { $0.url.absoluteString }.joined(separator: "\n")
NSPasteboard.general.setString(urls, forType: .string)
} label: {
Label(targetItems.count > 1 ? "Copy Addresses" : "Copy Address", systemImage: "link")
}
Button {
for target in targetItems {
openWindow(value: target.id)
}
} label: {
Label(targetItems.count > 1 ? "Properties (\(targetItems.count))" : "Properties", systemImage: "info.circle")
}
Divider()
@@ -381,7 +431,7 @@ struct DownloadTable: View {
Button(role: .destructive) {
pendingDeleteItems = Set(targetItems.map(\.id))
} label: {
Label("Delete", systemImage: "trash")
Label("Remove", systemImage: "trash")
}
}
@@ -404,7 +454,9 @@ struct DownloadTable: View {
}
private func width(for column: DownloadColumn) -> CGFloat {
max(70, tableSettings.columnWidths[column] ?? column.width)
let baseWidth = tableSettings.columnWidths[column] ?? column.width
let offset = dragOffsets[column] ?? 0
return max(70, baseWidth + offset)
}
private var sortedItems: [DownloadItem] {
@@ -520,9 +572,19 @@ struct DownloadTable: View {
}
return candidate
}
private func openFile(_ item: DownloadItem) {
let fileURL = item.destinationDirectory.appendingPathComponent(item.fileName)
if FileManager.default.fileExists(atPath: fileURL.path) {
NSWorkspace.shared.open(fileURL)
} else {
NSWorkspace.shared.open(existingFolder(for: item.destinationDirectory))
}
}
}
private struct DownloadRow: View {
@EnvironmentObject private var settings: AppSettings
let item: DownloadItem
let priorityNumber: Int?
let visibleColumns: [DownloadColumn]
@@ -534,7 +596,7 @@ private struct DownloadRow: View {
ForEach(visibleColumns) { column in
cell(for: column)
.padding(.horizontal, 8)
.padding(.vertical, 8)
.padding(.vertical, settings.listRowDensity.verticalPadding)
.frame(width: columnWidth(column), alignment: alignment(for: column))
.clipped()
}
@@ -565,11 +627,6 @@ private struct DownloadRow: View {
.font(.headline)
.lineLimit(1)
.truncationMode(.tail)
Text(item.url.absoluteString)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
.truncationMode(.tail)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
+32 -2
View File
@@ -4,18 +4,25 @@ import SwiftUI
struct FirelinkApp: App {
@StateObject private var settings: AppSettings
@StateObject private var controller: DownloadController
@StateObject private var schedulerController: SchedulerController
@AppStorage("showMenuBarIcon") private var showMenuBarIcon = true
init() {
let settings = AppSettings()
let controller = DownloadController(settings: settings)
_settings = StateObject(wrappedValue: settings)
_controller = StateObject(wrappedValue: DownloadController(settings: settings))
_controller = StateObject(wrappedValue: controller)
_schedulerController = StateObject(wrappedValue: SchedulerController(downloadController: controller))
}
var body: some Scene {
WindowGroup {
WindowGroup(id: "main") {
ContentView()
.environmentObject(controller)
.environmentObject(settings)
.environmentObject(schedulerController)
.modifier(AppThemeModifier(theme: settings.appTheme))
.dynamicTypeSize(settings.appFontSize.dynamicTypeSize)
.frame(minWidth: 1180, idealWidth: 1280, minHeight: 720, idealHeight: 760)
}
.windowStyle(.titleBar)
@@ -24,6 +31,8 @@ struct FirelinkApp: App {
AddDownloadsView()
.environmentObject(controller)
.environmentObject(settings)
.modifier(AppThemeModifier(theme: settings.appTheme))
.dynamicTypeSize(settings.appFontSize.dynamicTypeSize)
}
.windowResizability(.contentSize)
@@ -32,8 +41,12 @@ struct FirelinkApp: App {
DownloadPropertiesWindow(downloadID: downloadID)
.environmentObject(controller)
.environmentObject(settings)
.modifier(AppThemeModifier(theme: settings.appTheme))
.dynamicTypeSize(settings.appFontSize.dynamicTypeSize)
} else {
ContentUnavailableView("Download Not Found", systemImage: "questionmark.circle")
.modifier(AppThemeModifier(theme: settings.appTheme))
.dynamicTypeSize(settings.appFontSize.dynamicTypeSize)
}
}
.windowResizability(.contentSize)
@@ -46,5 +59,22 @@ struct FirelinkApp: App {
.keyboardShortcut("r", modifiers: [.command])
}
}
MenuBarExtra(isInserted: $showMenuBarIcon) {
TrayMenuView()
.environmentObject(controller)
} label: {
if let nsImage = { () -> NSImage? in
guard let url = Bundle.main.url(forResource: "MenuBarIconTemplate", withExtension: "png"),
let img = NSImage(contentsOf: url) else { return nil }
img.size = NSSize(width: 21, height: 21)
img.isTemplate = true
return img
}() {
Image(nsImage: nsImage)
} else {
Image(systemName: "arrow.down.circle")
}
}
}
}
+128
View File
@@ -54,6 +54,113 @@ struct DownloadCredentials: Codable, Equatable, Sendable {
}
}
enum ChecksumAlgorithm: String, Codable, CaseIterable, Identifiable, Sendable {
case md5
case sha1 = "sha-1"
case sha256 = "sha-256"
case sha512 = "sha-512"
var id: String { rawValue }
var title: String {
switch self {
case .md5: "MD5"
case .sha1: "SHA-1"
case .sha256: "SHA-256"
case .sha512: "SHA-512"
}
}
}
struct DownloadChecksum: Codable, Equatable, Sendable {
var algorithm: ChecksumAlgorithm
var value: String
var normalized: DownloadChecksum {
DownloadChecksum(
algorithm: algorithm,
value: value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
)
}
var isEmpty: Bool {
value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
}
struct DownloadRequestHeader: Codable, Equatable, Sendable {
var name: String
var value: String
var normalized: DownloadRequestHeader {
DownloadRequestHeader(
name: name.trimmingCharacters(in: .whitespacesAndNewlines),
value: value.trimmingCharacters(in: .whitespacesAndNewlines)
)
}
var isEmpty: Bool {
let clean = normalized
return clean.name.isEmpty && clean.value.isEmpty
}
var headerLine: String {
let clean = normalized
return "\(clean.name): \(clean.value)"
}
}
struct DownloadTransferOptions: Equatable, Sendable {
var checksum: DownloadChecksum?
var requestHeaders: [DownloadRequestHeader] = []
var cookieHeader: String?
var mirrorURLs: [URL] = []
}
enum DownloadTransferOptionParser {
static func parseHeaders(_ text: String) -> [DownloadRequestHeader] {
headerLines(text).compactMap { line in
guard let colonIndex = line.firstIndex(of: ":") else { return nil }
let name = String(line[..<colonIndex]).trimmingCharacters(in: .whitespacesAndNewlines)
let value = String(line[line.index(after: colonIndex)...]).trimmingCharacters(in: .whitespacesAndNewlines)
let header = DownloadRequestHeader(name: name, value: value).normalized
return header.isEmpty || header.name.isEmpty ? nil : header
}
}
static func invalidHeaderLines(_ text: String) -> [String] {
headerLines(text).filter { line in
guard let colonIndex = line.firstIndex(of: ":") else { return true }
let name = String(line[..<colonIndex]).trimmingCharacters(in: .whitespacesAndNewlines)
return name.isEmpty
}
}
static func cleanCookieHeader(_ text: String) -> String? {
var value = text.trimmingCharacters(in: .whitespacesAndNewlines)
if value.lowercased().hasPrefix("cookie:") {
value = String(value.dropFirst("cookie:".count)).trimmingCharacters(in: .whitespacesAndNewlines)
}
return value.isEmpty ? nil : value
}
static func parseMirrorURLs(_ text: String) -> [URL] {
DownloadURLParser.parse(text)
}
static func invalidMirrorLines(_ text: String) -> [String] {
text.split(whereSeparator: { $0 == "\n" || $0 == "\r" })
.map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty && DownloadURLParser.parse($0).isEmpty }
}
private static func headerLines(_ text: String) -> [String] {
text.split(whereSeparator: { $0 == "\n" || $0 == "\r" })
.map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
}
}
struct DownloadItem: Identifiable, Codable, Equatable, Sendable {
var id = UUID()
var url: URL
@@ -62,6 +169,11 @@ struct DownloadItem: Identifiable, Codable, Equatable, Sendable {
var destinationDirectory: URL
var connectionsPerServer: Int
var credentials: DownloadCredentials?
var checksum: DownloadChecksum?
var requestHeaders: [DownloadRequestHeader]?
var cookieHeader: String?
var mirrorURLs: [URL]?
var speedLimitKiBPerSecond: Int?
var status: DownloadStatus = .queued
var progress: Double = 0
var speedText: String = "-"
@@ -78,6 +190,22 @@ struct DownloadItem: Identifiable, Codable, Equatable, Sendable {
var destinationPath: String {
destinationDirectory.appendingPathComponent(fileName).path
}
var transferOptions: DownloadTransferOptions {
DownloadTransferOptions(
checksum: checksum,
requestHeaders: requestHeaders ?? [],
cookieHeader: cookieHeader,
mirrorURLs: mirrorURLs ?? []
)
}
var speedLimitText: String {
guard let speedLimitKiBPerSecond, speedLimitKiBPerSecond > 0 else {
return "No limit"
}
return "\(speedLimitKiBPerSecond) KiB/s"
}
}
struct DownloadProgress: Equatable, Sendable {
+231
View File
@@ -0,0 +1,231 @@
import AppKit
import Combine
import Foundation
enum PostQueueAction: String, Codable, CaseIterable, Identifiable {
case doNothing = "Do nothing"
case sleep = "Sleep"
case restart = "Restart"
case shutdown = "Shut down"
var id: String { rawValue }
}
enum SchedulerDay: Int, Codable, CaseIterable, Identifiable {
case sunday = 1, monday, tuesday, wednesday, thursday, friday, saturday
var id: Int { rawValue }
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"
}
}
}
struct SchedulerSettings: Codable, Equatable {
var isEnabled: Bool = false
var startTime: Date = Calendar.current.date(bySettingHour: 0, minute: 0, second: 0, of: Date()) ?? Date()
var isEveryday: Bool = true
var selectedDays: Set<SchedulerDay> = Set(SchedulerDay.allCases)
var postQueueAction: PostQueueAction = .doNothing
var targetQueueIDs: Set<UUID> = []
}
@MainActor
final class SchedulerController: ObservableObject {
@Published var settings: SchedulerSettings
@Published var isRunning: Bool = false
@Published var hasAutomationPermission: Bool = false
private let downloadController: DownloadController
private var cancellables = Set<AnyCancellable>()
private var timer: Timer?
private let defaults = UserDefaults.standard
private let storageKey = "Firelink.SchedulerSettings.v1"
// We only trigger once per minute to prevent multiple triggers in the same minute
private var lastTriggeredMinute: Date?
init(downloadController: DownloadController) {
self.downloadController = downloadController
if let data = defaults.data(forKey: "Firelink.SchedulerSettings.v1"),
let stored = try? JSONDecoder().decode(SchedulerSettings.self, from: data) {
self.settings = stored
} else {
self.settings = SchedulerSettings()
}
checkAutomationPermission()
startTimer()
$settings
.dropFirst()
.sink { _ in
// We do NOT save instantly here to UserDefaults because the UI will have a "Save" button
}
.store(in: &cancellables)
// Observe downloads to check if we should trigger post-action
downloadController.$downloads
.dropFirst()
.sink { [weak self] _ in
self?.checkIfRunningFinished()
}
.store(in: &cancellables)
}
func saveSettings() {
if let data = try? JSONEncoder().encode(settings) {
defaults.set(data, forKey: storageKey)
}
}
private func startTimer() {
timer = Timer.scheduledTimer(withTimeInterval: 10, repeats: true) { [weak self] _ in
Task { @MainActor in
self?.checkSchedule()
}
}
}
private func checkSchedule() {
guard settings.isEnabled else { return }
let now = Date()
let calendar = Calendar.current
// Check if we already triggered in this exact minute
if let last = lastTriggeredMinute, calendar.isDate(last, equalTo: now, toGranularity: .minute) {
return
}
let startHour = calendar.component(.hour, from: settings.startTime)
let startMinute = calendar.component(.minute, from: settings.startTime)
let currentHour = calendar.component(.hour, from: now)
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
}
if shouldRun {
lastTriggeredMinute = now
triggerQueues()
}
}
}
private func triggerQueues() {
let runnableQueueIDs = settings.targetQueueIDs.filter { queueID in
downloadController.queues.contains(where: { $0.id == queueID }) &&
downloadController.queueItems(for: queueID).contains(where: { $0.status == .queued })
}
guard !runnableQueueIDs.isEmpty else { return }
isRunning = true
for queueID in runnableQueueIDs {
downloadController.startQueue(queueID: queueID)
}
checkIfRunningFinished()
}
private func checkIfRunningFinished() {
guard isRunning else { return }
let hasActiveItems = settings.targetQueueIDs.contains { queueID in
downloadController.queueItems(for: queueID).contains {
$0.status == .queued || $0.status == .downloading
}
}
if !hasActiveItems {
isRunning = false
performPostAction()
}
}
private func performPostAction() {
guard settings.postQueueAction != .doNothing else { return }
var scriptCode = ""
switch settings.postQueueAction {
case .sleep:
scriptCode = "tell application \"Finder\" to sleep"
case .restart:
scriptCode = "tell application \"Finder\" to restart"
case .shutdown:
scriptCode = "tell application \"Finder\" to shut down"
case .doNothing:
break
}
guard !scriptCode.isEmpty else { return }
var error: NSDictionary?
if let script = NSAppleScript(source: scriptCode) {
script.executeAndReturnError(&error)
if let error {
print("Failed to perform scheduler post action: \(error)")
}
}
}
func checkAutomationPermission() {
let target = NSAppleEventDescriptor(bundleIdentifier: "com.apple.finder")
let status = AEDeterminePermissionToAutomateTarget(target.aeDesc, typeWildCard, typeWildCard, false)
hasAutomationPermission = (status == noErr)
}
func requestAutomationPermission() {
let target = NSAppleEventDescriptor(bundleIdentifier: "com.apple.finder")
let status = AEDeterminePermissionToAutomateTarget(target.aeDesc, typeWildCard, typeWildCard, true)
if status != noErr {
triggerAutomationConsentPrompt()
}
checkAutomationPermission()
if !hasAutomationPermission {
openAutomationPermissionSettings()
}
}
func openAutomationPermissionSettings() {
guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation") else {
return
}
NSWorkspace.shared.open(url)
}
private func triggerAutomationConsentPrompt() {
let scriptCode = "tell application \"Finder\" to get name"
var error: NSDictionary?
if let script = NSAppleScript(source: scriptCode) {
script.executeAndReturnError(&error)
if let error {
print("Failed to trigger Automation permission prompt: \(error)")
}
}
}
}
+214
View File
@@ -0,0 +1,214 @@
import SwiftUI
struct SchedulerView: View {
@EnvironmentObject private var downloadController: DownloadController
@EnvironmentObject private var schedulerController: SchedulerController
@State private var showSaveToast: Bool = false
// Local state to hold edits before saving
@State private var isEnabled: Bool = false
@State private var startTime: Date = Date()
@State private var isEveryday: Bool = true
@State private var selectedDays: Set<SchedulerDay> = []
@State private var postQueueAction: PostQueueAction = .doNothing
@State private var targetQueueIDs: Set<UUID> = []
var body: some View {
VStack(spacing: 0) {
headerView
Divider()
ScrollView {
VStack(alignment: .leading, spacing: 24) {
VStack(alignment: .leading, spacing: 24) {
timeSelectionSection
queueSelectionSection
postActionSection
}
.opacity(isEnabled ? 1.0 : 0.5)
.disabled(!isEnabled)
Divider()
permissionsSection
}
.padding(24)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.onAppear {
loadState()
schedulerController.checkAutomationPermission()
}
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
schedulerController.checkAutomationPermission()
}
.overlay {
if showSaveToast {
toastView
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
}
private var headerView: some View {
HStack {
Toggle(isOn: $isEnabled) {
Text("Scheduler")
.font(.title2.weight(.bold))
}
.toggleStyle(.switch)
Spacer()
Button("Save Settings") {
saveState()
withAnimation(.spring()) {
showSaveToast = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 2.5) {
withAnimation {
showSaveToast = false
}
}
}
.buttonStyle(.borderedProminent)
}
.padding(24)
}
private var timeSelectionSection: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Start Time")
.font(.headline)
DatePicker("Time", selection: $startTime, displayedComponents: [.hourAndMinute])
.datePickerStyle(.stepperField)
.labelsHidden()
Toggle("Everyday", isOn: $isEveryday)
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 {
selectedDays.remove(day)
}
}
))
.toggleStyle(.button)
}
}
}
}
}
private var queueSelectionSection: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Queues to Start")
.font(.headline)
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)
}
}
))
}
}
}
}
}
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)
}
}
.labelsHidden()
.pickerStyle(.radioGroup)
}
}
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()
}
.buttonStyle(.bordered)
} else {
Button("Grant Permission") {
schedulerController.requestAutomationPermission()
}
.buttonStyle(.borderedProminent)
}
}
}
private var toastView: some View {
VStack {
Spacer()
HStack(spacing: 10) {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
Text("Settings Saved")
.fontWeight(.medium)
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(.regularMaterial)
.clipShape(Capsule())
.shadow(radius: 4, y: 2)
.padding(.bottom, 30)
}
.allowsHitTesting(false)
}
private func loadState() {
isEnabled = schedulerController.settings.isEnabled
startTime = schedulerController.settings.startTime
isEveryday = schedulerController.settings.isEveryday
selectedDays = schedulerController.settings.selectedDays
postQueueAction = schedulerController.settings.postQueueAction
targetQueueIDs = schedulerController.settings.targetQueueIDs
}
private func saveState() {
schedulerController.settings.isEnabled = isEnabled
schedulerController.settings.startTime = startTime
schedulerController.settings.isEveryday = isEveryday
schedulerController.settings.selectedDays = selectedDays
schedulerController.settings.postQueueAction = postQueueAction
schedulerController.settings.targetQueueIDs = targetQueueIDs
schedulerController.saveSettings()
}
}
+121 -29
View File
@@ -3,16 +3,29 @@ import SwiftUI
private enum SettingsSection: String, CaseIterable, Hashable {
case downloads = "Downloads"
case network = "Network"
case locations = "Locations"
case lookAndFeel = "Look and feel"
case network = "Network"
case siteLogins = "Site Logins"
case power = "Power"
case engine = "Engine"
case about = "About"
static let orderedCases: [SettingsSection] = [
.downloads,
.locations,
.lookAndFeel,
.network,
.siteLogins,
.power,
.engine,
.about
]
var symbolName: String {
switch self {
case .downloads: "arrow.down.circle"
case .lookAndFeel: "paintpalette"
case .network: "network"
case .locations: "folder"
case .siteLogins: "key.fill"
@@ -24,7 +37,7 @@ private enum SettingsSection: String, CaseIterable, Hashable {
var groupTitle: String {
switch self {
case .about:
case .engine, .about:
"App"
default:
"Preferences"
@@ -33,6 +46,7 @@ private enum SettingsSection: String, CaseIterable, Hashable {
}
struct SettingsView: View {
@EnvironmentObject private var settings: AppSettings
@State private var selection: SettingsSection = .downloads
var body: some View {
@@ -59,19 +73,20 @@ struct SettingsView: View {
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.themeBackground(settings.appTheme.theme.background)
}
private var settingsSidebar: some View {
List(selection: $selection) {
Section("Preferences") {
ForEach(SettingsSection.allCases.filter { $0.groupTitle == "Preferences" }, id: \.self) { section in
ForEach(SettingsSection.orderedCases.filter { $0.groupTitle == "Preferences" }, id: \.self) { section in
Label(section.rawValue, systemImage: section.symbolName)
.tag(section)
}
}
Section("App") {
ForEach(SettingsSection.allCases.filter { $0.groupTitle == "App" }, id: \.self) { section in
ForEach(SettingsSection.orderedCases.filter { $0.groupTitle == "App" }, id: \.self) { section in
Label(section.rawValue, systemImage: section.symbolName)
.tag(section)
}
@@ -86,6 +101,8 @@ struct SettingsView: View {
switch selection {
case .downloads:
DownloadSettingsPane()
case .lookAndFeel:
LookAndFeelSettingsPane()
case .network:
NetworkSettingsPane()
case .locations:
@@ -102,6 +119,52 @@ struct SettingsView: View {
}
}
private struct LookAndFeelSettingsPane: View {
@EnvironmentObject private var settings: AppSettings
@AppStorage("showMenuBarIcon") private var showMenuBarIcon = true
var body: some View {
Form {
Section("App Theme") {
Picker("Theme", selection: $settings.appTheme) {
ForEach(AppTheme.allCases) { theme in
Text(theme.rawValue)
.tag(theme)
}
}
.pickerStyle(.radioGroup)
Text("Select a color palette for the app's user interface.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Display") {
Picker("Font Size", selection: $settings.appFontSize) {
ForEach(AppFontSize.allCases) { size in
Text(size.rawValue).tag(size)
}
}
Picker("List Row Density", selection: $settings.listRowDensity) {
ForEach(ListRowDensity.allCases) { density in
Text(density.rawValue).tag(density)
}
}
}
Section("Menu Bar") {
Toggle("Show menu bar icon", isOn: $showMenuBarIcon)
Text("Provides quick access to downloads and queues from the macOS menu bar. Restart required if hiding.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
}
}
private struct AboutSettingsPane: View {
@StateObject private var updateChecker = AppUpdateChecker()
@State private var availableUpdate: AvailableUpdate?
@@ -131,7 +194,7 @@ private struct AboutSettingsPane: View {
VStack(alignment: .leading, spacing: 4) {
Text("Firelink")
.font(.title2.weight(.semibold))
Text("Version \(appVersion) (\(buildNumber))")
Text("Version \(appVersion)")
.foregroundStyle(.secondary)
Text("A native macOS download manager for fast, organized, segmented transfers.")
.font(.caption)
@@ -141,30 +204,6 @@ private struct AboutSettingsPane: View {
.padding(.vertical, 4)
}
Section("Developer") {
LabeledContent("Created by") {
Text("NimBold")
}
LabeledContent("GitHub") {
Link("@nimbold", destination: developerProfileURL)
}
LabeledContent("Source") {
Link("nimbold/Firelink", destination: projectURL)
}
}
Section("Credits") {
LabeledContent("Download engine") {
Link("aria2", destination: aria2URL)
}
Text("Firelink uses aria2c for segmented HTTP, HTTPS, FTP, and SFTP downloads.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Updates") {
LabeledContent("Status") {
Label(updateChecker.status.message, systemImage: updateStatusSymbol)
@@ -207,6 +246,26 @@ private struct AboutSettingsPane: View {
}
}
Section("Developer") {
LabeledContent("Created by") {
Text("NimBold")
}
LabeledContent("Source") {
Link("nimbold/Firelink", destination: projectURL)
}
}
Section("Credits") {
LabeledContent("Download engine") {
Link("aria2", destination: aria2URL)
}
Text("Firelink uses aria2c for segmented HTTP, HTTPS, FTP, and SFTP downloads.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Legal") {
LabeledContent("License") {
Link("MIT License", destination: licenseURL)
@@ -423,9 +482,42 @@ private struct DownloadSettingsPane: View {
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Bandwidth") {
Toggle("Limit total download speed", isOn: globalSpeedLimitEnabled)
.toggleStyle(.switch)
Stepper(
"Global cap: \(settings.globalSpeedLimitKiBPerSecond) KiB/s",
value: globalSpeedLimitValue,
in: 1...10_485_760,
step: 128
)
.disabled(settings.globalSpeedLimitKiBPerSecond == 0)
Text("Firelink splits this cap across the configured parallel download slots. Per-download limits can still be set lower in Add Downloads or Properties.")
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
}
private var globalSpeedLimitEnabled: Binding<Bool> {
Binding {
settings.globalSpeedLimitKiBPerSecond > 0
} set: { isEnabled in
settings.globalSpeedLimitKiBPerSecond = isEnabled ? max(settings.globalSpeedLimitKiBPerSecond, 1024) : 0
}
}
private var globalSpeedLimitValue: Binding<Int> {
Binding {
max(settings.globalSpeedLimitKiBPerSecond, 1)
} set: { newValue in
settings.globalSpeedLimitKiBPerSecond = newValue
}
}
}
private struct LocationsSettingsPane: View {
+6
View File
@@ -24,6 +24,7 @@ enum DownloadSidebarFilter: Hashable {
enum SidebarSelection: Hashable {
case downloads(DownloadSidebarFilter)
case queue(UUID)
case scheduler
case settings
}
@@ -70,6 +71,11 @@ struct SidebarView: View {
}
.buttonStyle(.plain)
}
Section("Tools") {
Label("Scheduler", systemImage: "calendar.badge.clock")
.tag(SidebarSelection.scheduler)
}
}
.listStyle(.sidebar)
.alert("Rename Queue", isPresented: Binding(
+140
View File
@@ -0,0 +1,140 @@
import SwiftUI
import AppKit
enum AppFontSize: String, Codable, CaseIterable, Identifiable, Sendable {
case small = "Small"
case standard = "Standard"
case large = "Large"
var id: String { rawValue }
var dynamicTypeSize: DynamicTypeSize {
switch self {
case .small: return .small
case .standard: return .medium
case .large: return .xLarge
}
}
}
enum ListRowDensity: String, Codable, CaseIterable, Identifiable, Sendable {
case compact = "Compact"
case standard = "Standard"
case relaxed = "Relaxed"
var id: String { rawValue }
var verticalPadding: CGFloat {
switch self {
case .compact: return 4
case .standard: return 8
case .relaxed: return 14
}
}
}
enum AppTheme: String, Codable, CaseIterable, Identifiable, Sendable {
case system = "System Default"
case light = "Light"
case dark = "Dark"
case dracula = "Dracula"
case nord = "Nord"
var id: String { rawValue }
var theme: Theme {
switch self {
case .system, .dark:
return Theme.system
case .light:
return Theme.modernLight
case .dracula:
return Theme.dracula
case .nord:
return Theme.nord
}
}
var appearance: NSAppearance? {
switch self {
case .system: return nil
case .light: return NSAppearance(named: .aqua)
case .dark, .dracula, .nord: return NSAppearance(named: .darkAqua)
}
}
}
struct Theme: Equatable, Sendable {
var background: Color?
var secondaryBackground: Color?
var text: Color?
var secondaryText: Color?
var accent: Color?
static let system = Theme(
background: nil,
secondaryBackground: nil,
text: nil,
secondaryText: nil,
accent: nil
)
static let modernLight = Theme(
background: Color(nsColor: NSColor(calibratedRed: 0.98, green: 0.98, blue: 0.99, alpha: 1.0)),
secondaryBackground: Color(nsColor: NSColor(calibratedRed: 0.94, green: 0.94, blue: 0.96, alpha: 1.0)),
text: Color.primary,
secondaryText: Color.secondary,
accent: Color.accentColor
)
static let dracula = Theme(
background: Color(nsColor: NSColor(calibratedRed: 0.16, green: 0.16, blue: 0.21, alpha: 1.0)),
secondaryBackground: Color(nsColor: NSColor(calibratedRed: 0.27, green: 0.28, blue: 0.35, alpha: 1.0)),
text: Color(nsColor: NSColor(calibratedRed: 0.97, green: 0.97, blue: 0.95, alpha: 1.0)),
secondaryText: Color(nsColor: NSColor(calibratedRed: 0.38, green: 0.44, blue: 0.58, alpha: 1.0)),
accent: Color(nsColor: NSColor(calibratedRed: 1.00, green: 0.47, blue: 0.65, alpha: 1.0)) // Pink
)
static let nord = Theme(
background: Color(nsColor: NSColor(calibratedRed: 0.18, green: 0.20, blue: 0.25, alpha: 1.0)), // nord0
secondaryBackground: Color(nsColor: NSColor(calibratedRed: 0.23, green: 0.26, blue: 0.32, alpha: 1.0)), // nord1
text: Color(nsColor: NSColor(calibratedRed: 0.85, green: 0.87, blue: 0.91, alpha: 1.0)), // nord4
secondaryText: Color(nsColor: NSColor(calibratedRed: 0.57, green: 0.63, blue: 0.70, alpha: 1.0)), // nord3
accent: Color(nsColor: NSColor(calibratedRed: 0.53, green: 0.75, blue: 0.82, alpha: 1.0)) // nord8
)
}
struct AppThemeModifier: ViewModifier {
let theme: AppTheme
func body(content: Content) -> some View {
content
.tint(theme.theme.accent)
.onAppear {
NSApp.appearance = theme.appearance
}
.onChange(of: theme) { _, newTheme in
NSApp.appearance = newTheme.appearance
}
}
}
struct ThemeBackgroundModifier: ViewModifier {
let color: Color?
func body(content: Content) -> some View {
if let color {
content
.scrollContentBackground(.hidden)
.background(color)
} else {
content
}
}
}
extension View {
func themeBackground(_ color: Color?) -> some View {
modifier(ThemeBackgroundModifier(color: color))
}
}
+38
View File
@@ -0,0 +1,38 @@
import SwiftUI
struct TrayMenuView: View {
@Environment(\.openWindow) private var openWindow
@EnvironmentObject private var controller: DownloadController
var body: some View {
Button("Show main window") {
openWindow(id: "main")
NSApp.activate(ignoringOtherApps: true)
}
Button("Add downloads") {
openWindow(id: "add-downloads")
NSApp.activate(ignoringOtherApps: true)
}
Menu("Start queue") {
ForEach(controller.queues) { queue in
Button(queue.name) {
controller.startQueue(queueID: queue.id)
}
}
}
Button("Stop downloads") {
for download in controller.downloads where download.status == .downloading {
controller.pause(download)
}
}
Divider()
Button("Exit") {
NSApplication.shared.terminate(nil)
}
}
}