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])