mirror of
https://github.com/nimbold/Firelink.git
synced 2026-07-26 20:18:37 +00:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d0021e16f5 | |||
| a42c7d9f74 | |||
| a375b2e685 | |||
| 301e337c2e | |||
| 986687631d | |||
| 0714f6e707 | |||
| e25022fbe5 | |||
| 48dfd7c6fa | |||
| 5e442d1691 | |||
| d1fa6dd910 | |||
| 27b9004d99 | |||
| ac8764bb78 | |||
| 41535eca59 | |||
| 2fb851b138 | |||
| 47eb226160 |
@@ -96,5 +96,5 @@ jobs:
|
||||
if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then
|
||||
gh release upload "$GITHUB_REF_NAME" dist/*.dmg --clobber
|
||||
else
|
||||
gh release create "$GITHUB_REF_NAME" dist/*.dmg --notes-file release_notes.md --verify-tag
|
||||
gh release create "$GITHUB_REF_NAME" dist/*.dmg --title "$GITHUB_REF_NAME" --notes-file release_notes.md --verify-tag
|
||||
fi
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
Submodule Extensions/Firefox updated: 84eb776c43...de6bb273a4
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 735 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 883 KiB |
@@ -42,11 +42,24 @@ if [[ -n "$ARIA2C_PATH" && -x "$ARIA2C_PATH" ]]; then
|
||||
fi
|
||||
|
||||
FRAMEWORKS_DIR="$CONTENTS_DIR/Frameworks"
|
||||
mkdir -p "$FRAMEWORKS_DIR"
|
||||
dylibbundler -od -b -x "$RESOURCES_DIR/aria2c" -d "$FRAMEWORKS_DIR" -p "@executable_path/../Frameworks/"
|
||||
else
|
||||
echo "WARNING: aria2c not found! It will not be bundled. Please install it first."
|
||||
fi
|
||||
|
||||
FRAMEWORKS_DIR="$CONTENTS_DIR/Frameworks"
|
||||
mkdir -p "$FRAMEWORKS_DIR"
|
||||
if [ -d ".build/artifacts/sparkle/Sparkle/Sparkle.xcframework/macos-arm64_x86_64/Sparkle.framework" ]; then
|
||||
cp -R ".build/artifacts/sparkle/Sparkle/Sparkle.xcframework/macos-arm64_x86_64/Sparkle.framework" "$FRAMEWORKS_DIR/Sparkle.framework"
|
||||
fi
|
||||
if [ -d ".build/artifacts/sparkle/Sparkle/SparkleCore.xcframework/macos-arm64_x86_64/SparkleCore.framework" ]; then
|
||||
cp -R ".build/artifacts/sparkle/Sparkle/SparkleCore.xcframework/macos-arm64_x86_64/SparkleCore.framework" "$FRAMEWORKS_DIR/SparkleCore.framework"
|
||||
fi
|
||||
|
||||
install_name_tool -add_rpath "@executable_path/../Frameworks" "$MACOS_DIR/$APP_NAME" || true
|
||||
|
||||
|
||||
cat > "$CONTENTS_DIR/Info.plist" <<PLIST
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
@@ -78,6 +91,12 @@ cat > "$CONTENTS_DIR/Info.plist" <<PLIST
|
||||
<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>
|
||||
<key>SUPublicEDKey</key>
|
||||
<string>TnontDdbFQHbKkjpWVlHaMEbMahiCugSHOcUF1MwKE0=</string>
|
||||
<key>SUFeedURL</key>
|
||||
<string>https://raw.githubusercontent.com/nimbold/Firelink/main/appcast.xml</string>
|
||||
<key>SUEnableAutomaticChecks</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
PLIST
|
||||
|
||||
@@ -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..<count {
|
||||
let candidateValue = index < candidateParts.count ? candidateParts[index] : 0
|
||||
let currentValue = index < currentParts.count ? currentParts[index] : 0
|
||||
if candidateValue != currentValue {
|
||||
return candidateValue > 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
|
||||
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 1.4 KiB |
@@ -481,6 +481,7 @@ final class DownloadController: ObservableObject {
|
||||
progress: { [weak self] progress in
|
||||
Task { @MainActor in
|
||||
self?.update(item.id) {
|
||||
guard $0.status == .downloading else { return }
|
||||
$0.progress = progress.fraction
|
||||
$0.bytesText = progress.bytesText
|
||||
$0.speedText = progress.speedText
|
||||
@@ -823,6 +824,15 @@ final class DownloadController: ObservableObject {
|
||||
shouldRewriteStoredDownloads = true
|
||||
}
|
||||
|
||||
if adjusted.status == .completed &&
|
||||
(adjusted.speedText != "-" || adjusted.etaText != "-" || adjusted.connectionCount != 0 || adjusted.autoResumeOnLaunch != false) {
|
||||
adjusted.speedText = "-"
|
||||
adjusted.etaText = "-"
|
||||
adjusted.connectionCount = 0
|
||||
adjusted.autoResumeOnLaunch = false
|
||||
shouldRewriteStoredDownloads = true
|
||||
}
|
||||
|
||||
if adjusted.status == .downloading {
|
||||
adjusted.status = .queued
|
||||
adjusted.message = "Recovered after restart. Resuming from partial file."
|
||||
|
||||
@@ -443,8 +443,8 @@ private struct DownloadSummaryHeader: View {
|
||||
GridRow {
|
||||
summary("Progress", (item.status == .completed ? 1.0 : item.progress).formatted(.percent.precision(.fractionLength(0))))
|
||||
summary("Size", ByteFormatter.string(item.sizeBytes))
|
||||
summary("Speed", item.speedText)
|
||||
summary("ETA", item.etaText)
|
||||
summary("Speed", item.displaySpeedText)
|
||||
summary("ETA", item.displayETAText)
|
||||
}
|
||||
GridRow {
|
||||
summary("Live connections", "\(item.connectionCount)")
|
||||
|
||||
@@ -77,18 +77,18 @@ struct DownloadTable: View {
|
||||
}
|
||||
.width(min: 80, ideal: 105)
|
||||
|
||||
TableColumn("Speed", value: \.speedText) { item in
|
||||
TableColumn("Speed", value: \.displaySpeedText) { item in
|
||||
doubleClickableCell(for: item) {
|
||||
Text(item.speedText)
|
||||
Text(item.displaySpeedText)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
}
|
||||
.width(min: 70, ideal: 90)
|
||||
|
||||
TableColumn("ETA", value: \.etaText) { item in
|
||||
TableColumn("ETA", value: \.displayETAText) { item in
|
||||
doubleClickableCell(for: item) {
|
||||
Text(item.etaText)
|
||||
Text(item.displayETAText)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
@@ -164,9 +164,9 @@ struct DownloadTable: View {
|
||||
content()
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture(count: 2) {
|
||||
.simultaneousGesture(TapGesture(count: 2).onEnded {
|
||||
performPrimaryAction(for: item)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private func performPrimaryAction(for item: DownloadItem) {
|
||||
|
||||
@@ -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,11 +104,12 @@ struct FirelinkApp: App {
|
||||
MenuBarExtra(isInserted: $showMenuBarIcon) {
|
||||
TrayMenuView()
|
||||
.environmentObject(controller)
|
||||
.environmentObject(sparkleUpdater)
|
||||
} label: {
|
||||
if let nsImage = { () -> NSImage? in
|
||||
guard let url = menuBarIconURL(),
|
||||
let img = NSImage(contentsOf: url) else { return nil }
|
||||
img.size = NSSize(width: 21, height: 21)
|
||||
img.size = NSSize(width: 23, height: 23)
|
||||
img.isTemplate = true
|
||||
return img
|
||||
}() {
|
||||
|
||||
@@ -189,6 +189,14 @@ struct DownloadItem: Identifiable, Codable, Equatable, Sendable {
|
||||
var rpcPort: Int?
|
||||
var rpcSecret: String?
|
||||
|
||||
var displaySpeedText: String {
|
||||
status == .downloading ? speedText : "-"
|
||||
}
|
||||
|
||||
var displayETAText: String {
|
||||
status == .downloading ? etaText : "-"
|
||||
}
|
||||
|
||||
var destinationPath: String {
|
||||
destinationDirectory.appendingPathComponent(fileName).path
|
||||
}
|
||||
|
||||
@@ -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")!
|
||||
@@ -30,7 +29,7 @@ struct AboutSettingsPane: View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Firelink")
|
||||
.font(.title2.weight(.bold))
|
||||
Text("Version \(appVersion) (\(buildNumber))")
|
||||
Text("Version \(appVersion)")
|
||||
.foregroundStyle(.secondary)
|
||||
Text("A native macOS download manager for fast, organized, segmented transfers.")
|
||||
.font(.caption)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<channel>
|
||||
<title>Firelink Updates</title>
|
||||
<link>https://github.com/nimbold/Firelink</link>
|
||||
<description>Most recent updates for Firelink</description>
|
||||
<language>en</language>
|
||||
<item>
|
||||
<title>Version 0.5.7</title>
|
||||
<sparkle:minimumSystemVersion>14.0</sparkle:minimumSystemVersion>
|
||||
<sparkle:releaseNotesLink>https://github.com/nimbold/Firelink/releases/tag/v0.5.7</sparkle:releaseNotesLink>
|
||||
<pubDate>Sat, 06 Jun 2026 11:36:06 +0000</pubDate>
|
||||
<enclosure url="https://github.com/nimbold/Firelink/releases/download/v0.5.7/Firelink-0.5.7-mac-arm64.dmg"
|
||||
sparkle:version="0.5.7"
|
||||
sparkle:shortVersionString="0.5.7"
|
||||
length="7855846"
|
||||
type="application/octet-stream"
|
||||
sparkle:edSignature="WQPg1Pv0syLjVehJSn/39gmYxAPwAeHdUgJQxCXnXG4wKpqKWt9F9/UqeMilcxK4Dba0S0cn3GIk1KZJ7vR8Cw==" />
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
@@ -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])
|
||||
Reference in New Issue
Block a user