diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5794800..2f5b9ad 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,12 @@ 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.5.7] - 2026-06-06
+
+### New features
+- Replaced the basic in-app update checker with the industry-standard Sparkle 2 framework.
+- Added secure, automatic in-app updates using EdDSA cryptographic signatures.
+
## [0.5.6] - 2026-06-05
### New features
diff --git a/Package.resolved b/Package.resolved
new file mode 100644
index 0000000..cccaae6
--- /dev/null
+++ b/Package.resolved
@@ -0,0 +1,15 @@
+{
+ "originHash" : "048cca0a42e966dd91de6a4753f25d908574338fda8bf9b8bcae473cf159ebf4",
+ "pins" : [
+ {
+ "identity" : "sparkle",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/sparkle-project/Sparkle",
+ "state" : {
+ "revision" : "6276ba2b404829d139c45ff98427cf90e2efc59b",
+ "version" : "2.9.2"
+ }
+ }
+ ],
+ "version" : 3
+}
diff --git a/Package.swift b/Package.swift
index 6dc54a6..111a684 100644
--- a/Package.swift
+++ b/Package.swift
@@ -10,9 +10,15 @@ let package = Package(
products: [
.executable(name: "Firelink", targets: ["Firelink"])
],
+ dependencies: [
+ .package(url: "https://github.com/sparkle-project/Sparkle", from: "2.6.4")
+ ],
targets: [
.executableTarget(
name: "Firelink",
+ dependencies: [
+ .product(name: "Sparkle", package: "Sparkle")
+ ],
path: "Sources/Firelink",
resources: [
.process("Assets.xcassets")
diff --git a/README.md b/README.md
index 5f54dff..c99f5f8 100644
--- a/README.md
+++ b/README.md
@@ -50,6 +50,7 @@ Dark mode is shown by default with privacy-safe example downloads. Light mode is
- 🗂️ **Smart Categories:** Automatic file organization (`Musics`, `Movies`, `Compressed`, etc.).
- 🖱️ **Drag-and-Drop:** Import URLs, text files, and move queued downloads between queues.
- 🛡️ **Reliability:** Automatic download recovery and retry handling.
+- 🔄 **Sparkle Updates:** Secure, automatic in-app updates using EdDSA cryptographic signatures.
- 🔒 **Keychain Security:** Local macOS Keychain integration for site credentials.
- ⚙️ **Power & Settings:** Cross-platform styled Settings UI, live Speed Limiter, and system sleep prevention during active downloads.
diff --git a/Resources/new_icon_source.png b/Resources/new_icon_source.png
new file mode 100644
index 0000000..c6b4271
Binary files /dev/null and b/Resources/new_icon_source.png differ
diff --git a/Scripts/create_app_bundle.sh b/Scripts/create_app_bundle.sh
index 4744fb0..bd2a7eb 100755
--- a/Scripts/create_app_bundle.sh
+++ b/Scripts/create_app_bundle.sh
@@ -78,6 +78,12 @@ cat > "$CONTENTS_DIR/Info.plist" <
NSAppleEventsUsageDescription
Firelink needs permission to control Finder so it can sleep, restart, or shut down your Mac after scheduled downloads finish.
+ SUPublicEDKey
+ TnontDdbFQHbKkjpWVlHaMEbMahiCugSHOcUF1MwKE0=
+ SUFeedURL
+ https://raw.githubusercontent.com/nimbold/Firelink/main/appcast.xml
+ SUEnableAutomaticChecks
+
PLIST
diff --git a/Sources/Firelink/AppUpdateChecker.swift b/Sources/Firelink/AppUpdateChecker.swift
deleted file mode 100644
index 0252b0d..0000000
--- a/Sources/Firelink/AppUpdateChecker.swift
+++ /dev/null
@@ -1,113 +0,0 @@
-import Foundation
-
-@MainActor
-final class AppUpdateChecker: ObservableObject {
- enum Status: Equatable {
- case idle
- case checking
- case upToDate(String)
- case updateAvailable(latestVersion: String, releaseURL: URL)
- case unavailable(String)
-
- var message: String {
- switch self {
- case .idle:
- "Check GitHub Releases for the latest Firelink build."
- case .checking:
- "Checking for updates..."
- case .upToDate(let version):
- "Firelink is up to date. Latest version: \(version)."
- case .updateAvailable(let latestVersion, _):
- "Version \(latestVersion) is available."
- case .unavailable(let message):
- message
- }
- }
- }
-
- @Published private(set) var status: Status = .idle
- @Published private(set) var lastChecked: Date?
-
- let releasesURL = URL(string: "https://github.com/nimbold/Firelink/releases")!
-
- private let latestReleaseURL = URL(string: "https://api.github.com/repos/nimbold/Firelink/releases/latest")!
- private let session: URLSession
-
- init(session: URLSession = .shared) {
- self.session = session
- }
-
- func checkForUpdates(currentVersion: String) async {
- status = .checking
- lastChecked = Date()
-
- do {
- var request = URLRequest(url: latestReleaseURL)
- request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
- request.setValue("Firelink", forHTTPHeaderField: "User-Agent")
-
- let (data, response) = try await session.data(for: request)
- guard let httpResponse = response as? HTTPURLResponse else {
- status = .unavailable("Could not read the update server response.")
- return
- }
-
- guard httpResponse.statusCode == 200 else {
- status = .unavailable("No published Firelink release was found.")
- return
- }
-
- let release = try JSONDecoder().decode(GitHubRelease.self, from: data)
- let latestVersion = release.version
- if VersionComparator.isVersion(latestVersion, newerThan: currentVersion) {
- status = .updateAvailable(latestVersion: latestVersion, releaseURL: release.htmlURL)
- } else {
- status = .upToDate(latestVersion)
- }
- } catch {
- status = .unavailable("Could not check for updates. Try again later.")
- }
- }
-}
-
-private struct GitHubRelease: Decodable {
- let tagName: String
- let htmlURL: URL
-
- var version: String {
- tagName.trimmingCharacters(in: CharacterSet(charactersIn: "vV"))
- }
-
- enum CodingKeys: String, CodingKey {
- case tagName = "tag_name"
- case htmlURL = "html_url"
- }
-}
-
-private enum VersionComparator {
- static func isVersion(_ candidate: String, newerThan current: String) -> Bool {
- let candidateParts = parts(from: candidate)
- let currentParts = parts(from: current)
- let count = max(candidateParts.count, currentParts.count)
-
- for index in 0.. currentValue
- }
- }
-
- return false
- }
-
- private static func parts(from version: String) -> [Int] {
- version
- .trimmingCharacters(in: CharacterSet(charactersIn: "vV"))
- .split(separator: ".")
- .map { component in
- let digits = component.prefix(while: \.isNumber)
- return Int(digits) ?? 0
- }
- }
-}
diff --git a/Sources/Firelink/Aria2DownloadEngine.swift b/Sources/Firelink/Aria2DownloadEngine.swift
index ec46098..aa437e7 100644
--- a/Sources/Firelink/Aria2DownloadEngine.swift
+++ b/Sources/Firelink/Aria2DownloadEngine.swift
@@ -126,6 +126,14 @@ final class Aria2DownloadEngine {
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)")
+ }
let process = Process()
process.executableURL = executableURL
@@ -134,7 +142,7 @@ final class Aria2DownloadEngine {
proxyConfiguration: proxyConfiguration,
speedLimitKiBPerSecond: speedLimitKiBPerSecond,
rpcPort: rpcPort,
- rpcSecret: rpcSecret
+ confURL: confURL
)
let inputPipe = Pipe()
@@ -168,6 +176,7 @@ final class Aria2DownloadEngine {
}
process.terminationHandler = { finishedProcess in
+ try? FileManager.default.removeItem(at: confURL)
completionMonitor.cancel()
outputPipe.fileHandleForReading.readabilityHandler = nil
errorPipe.fileHandleForReading.readabilityHandler = nil
@@ -195,6 +204,7 @@ final class Aria2DownloadEngine {
}
inputPipe.fileHandleForWriting.closeFile()
} catch {
+ try? FileManager.default.removeItem(at: confURL)
throw EngineError.launchFailed(error.localizedDescription)
}
@@ -212,6 +222,7 @@ final class Aria2DownloadEngine {
if process.isRunning {
process.terminate()
}
+ try? FileManager.default.removeItem(at: confURL)
}
}
@@ -325,9 +336,10 @@ final class Aria2DownloadEngine {
proxyConfiguration: DownloadProxyConfiguration,
speedLimitKiBPerSecond: Int?,
rpcPort: Int,
- rpcSecret: String
+ confURL: URL
) throws -> [String] {
var arguments = [
+ "--conf-path=\(confURL.path)",
"--continue=true",
"--allow-overwrite=false",
"--auto-file-renaming=true",
@@ -344,7 +356,6 @@ final class Aria2DownloadEngine {
"--input-file=-",
"--enable-rpc=true",
"--rpc-listen-port=\(rpcPort)",
- "--rpc-secret=\(rpcSecret)",
"--rpc-listen-all=false"
]
diff --git a/Sources/Firelink/FirelinkApp.swift b/Sources/Firelink/FirelinkApp.swift
index b1997b8..e765f80 100644
--- a/Sources/Firelink/FirelinkApp.swift
+++ b/Sources/Firelink/FirelinkApp.swift
@@ -1,7 +1,18 @@
import SwiftUI
+import Sparkle
+
+final class SparkleUpdater: ObservableObject {
+ let controller: SPUStandardUpdaterController
+ init(controller: SPUStandardUpdaterController) {
+ self.controller = controller
+ }
+}
@main
struct FirelinkApp: App {
+ private let updaterController: SPUStandardUpdaterController
+ @StateObject private var sparkleUpdater: SparkleUpdater
+
@StateObject private var settings: AppSettings
@StateObject private var controller: DownloadController
@StateObject private var schedulerController: SchedulerController
@@ -11,6 +22,11 @@ struct FirelinkApp: App {
private let extensionServer: LocalExtensionServer?
init() {
+ // Initialize Sparkle updater
+ let updaterController = SPUStandardUpdaterController(startingUpdater: true, updaterDelegate: nil, userDriverDelegate: nil)
+ self.updaterController = updaterController
+ self._sparkleUpdater = StateObject(wrappedValue: SparkleUpdater(controller: updaterController))
+
let settings = AppSettings()
let controller = DownloadController(settings: settings)
_settings = StateObject(wrappedValue: settings)
@@ -28,6 +44,7 @@ struct FirelinkApp: App {
.environmentObject(controller)
.environmentObject(settings)
.environmentObject(schedulerController)
+ .environmentObject(sparkleUpdater)
.modifier(AppThemeModifier(theme: settings.appTheme))
.modifier(AppFontSizeModifier(fontSize: settings.appFontSize))
.onOpenURL { url in
@@ -87,6 +104,7 @@ struct FirelinkApp: App {
MenuBarExtra(isInserted: $showMenuBarIcon) {
TrayMenuView()
.environmentObject(controller)
+ .environmentObject(sparkleUpdater)
} label: {
if let nsImage = { () -> NSImage? in
guard let url = menuBarIconURL(),
diff --git a/Sources/Firelink/Settings/AboutSettingsPane.swift b/Sources/Firelink/Settings/AboutSettingsPane.swift
index 6823160..91237af 100644
--- a/Sources/Firelink/Settings/AboutSettingsPane.swift
+++ b/Sources/Firelink/Settings/AboutSettingsPane.swift
@@ -2,8 +2,7 @@ import AppKit
import SwiftUI
struct AboutSettingsPane: View {
- @StateObject private var updateChecker = AppUpdateChecker()
- @State private var availableUpdate: AvailableUpdate?
+ @EnvironmentObject var sparkleUpdater: SparkleUpdater
private let developerProfileURL = URL(string: "https://github.com/nimbold")!
private let projectURL = URL(string: "https://github.com/nimbold/Firelink")!
@@ -42,42 +41,17 @@ struct AboutSettingsPane: View {
Section("Updates") {
VStack(alignment: .leading, spacing: 12) {
- HStack {
- Label(updateChecker.status.message, systemImage: updateStatusSymbol)
- .foregroundStyle(updateStatusColor)
-
- Spacer()
-
- if let lastChecked = updateChecker.lastChecked {
- Text("Last checked: \(lastChecked, format: .dateTime.month().day().hour().minute())")
- .font(.caption)
- .foregroundStyle(.secondary)
- }
- }
-
HStack(spacing: 12) {
Button {
- Task {
- await updateChecker.checkForUpdates(currentVersion: appVersion)
- }
+ sparkleUpdater.controller.checkForUpdates(nil)
} label: {
Label("Check for Updates", systemImage: "arrow.clockwise")
}
- .disabled(updateChecker.status == .checking)
-
- if case .updateAvailable(_, let releaseURL) = updateChecker.status {
- Button {
- NSWorkspace.shared.open(releaseURL)
- } label: {
- Label("Download Latest Version", systemImage: "square.and.arrow.down")
- }
- .buttonStyle(.borderedProminent)
- } else {
- Button {
- openReleasesPage()
- } label: {
- Label("Open Releases", systemImage: "arrow.up.right.square")
- }
+
+ Button {
+ NSWorkspace.shared.open(projectURL.appendingPathComponent("releases"))
+ } label: {
+ Label("Open Releases", systemImage: "arrow.up.right.square")
}
}
}
@@ -121,67 +95,5 @@ struct AboutSettingsPane: View {
}
}
.formStyle(.grouped)
- .onChange(of: updateChecker.status) { _, status in
- if case .updateAvailable(let latestVersion, let releaseURL) = status {
- availableUpdate = AvailableUpdate(version: latestVersion, url: releaseURL)
- }
- }
- .alert("Update Available", isPresented: Binding(
- get: { availableUpdate != nil },
- set: { isPresented in
- if !isPresented {
- availableUpdate = nil
- }
- }
- )) {
- Button("Not Now", role: .cancel) {
- availableUpdate = nil
- }
- Button("Yes") {
- if let releaseURL = availableUpdate?.url {
- NSWorkspace.shared.open(releaseURL)
- }
- availableUpdate = nil
- }
- } message: {
- Text("Firelink version \(availableUpdate?.version ?? "") is available. Do you want to open the download page?")
- }
- }
-
- private var updateStatusSymbol: String {
- switch updateChecker.status {
- case .idle:
- "sparkle.magnifyingglass"
- case .checking:
- "arrow.clockwise"
- case .upToDate:
- "checkmark.seal.fill"
- case .updateAvailable:
- "arrow.down.circle.fill"
- case .unavailable:
- "exclamationmark.triangle.fill"
- }
- }
-
- private var updateStatusColor: Color {
- switch updateChecker.status {
- case .idle, .checking:
- .secondary
- case .upToDate:
- .green
- case .updateAvailable:
- .accentColor
- case .unavailable:
- .orange
- }
- }
-
- private func openReleasesPage() {
- NSWorkspace.shared.open(updateChecker.releasesURL)
- }
-
- private struct AvailableUpdate: Equatable {
- var version: String
- var url: URL
}
}
diff --git a/process_icon.py b/process_icon.py
new file mode 100644
index 0000000..d7aaac9
--- /dev/null
+++ b/process_icon.py
@@ -0,0 +1,50 @@
+import sys
+from PIL import Image, ImageDraw
+
+def process_images(src_path):
+ img = Image.open(src_path).convert("RGBA")
+ width, height = img.size
+
+ # Apply a standard macOS rounded rectangle mask
+ # macOS standard radius is approx 22.5% of the width
+ radius = int(width * 0.225)
+
+ mask = Image.new("L", (width, height), 0)
+ draw = ImageDraw.Draw(mask)
+ draw.rounded_rectangle((0, 0, width, height), radius=radius, fill=255)
+
+ # Apply mask
+ img.putalpha(mask)
+
+ # Save standard png
+ img_1024 = img.resize((1024, 1024), Image.Resampling.LANCZOS)
+ img_1024.save("Resources/AppIcon.png")
+
+ # Save Firefox extension icons
+ img_48 = img.resize((48, 48), Image.Resampling.LANCZOS)
+ img_48.save("Extensions/Firefox/icons/icon-48.png")
+ img_128 = img.resize((128, 128), Image.Resampling.LANCZOS)
+ img_128.save("Extensions/Firefox/icons/icon-128.png")
+
+ # MenuBarIconTemplate (64x64 monochrome)
+ data = img.getdata()
+ new_data = []
+
+ for item in data:
+ r, g, b, a = item
+ if r > 100 and r > b * 1.5 and a > 0:
+ alpha = min(255, max(0, int((r - 40) * 1.2)))
+ new_data.append((0, 0, 0, alpha))
+ else:
+ new_data.append((0, 0, 0, 0))
+
+ menu_bar_full = Image.new("RGBA", img.size)
+ menu_bar_full.putdata(new_data)
+
+ menu_bar_64 = menu_bar_full.resize((64, 64), Image.Resampling.LANCZOS)
+ menu_bar_64.save("Sources/Firelink/Assets.xcassets/MenuBarIcon.imageset/MenuBarIconTemplate.png")
+
+ print("Done generating main PNGs")
+
+if __name__ == '__main__':
+ process_images(sys.argv[1])