feat(updates): replace Sparkle with GitHub release checks

This commit is contained in:
nimbold
2026-06-09 08:26:19 +03:30
parent 4b3c80cda9
commit 683eb45d0e
17 changed files with 620 additions and 568 deletions
+8 -73
View File
@@ -67,8 +67,13 @@ jobs:
run: |
mkdir -p Sources/Firelink
# Download latest yt-dlp for macOS
curl -fsSL https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos -o Sources/Firelink/yt-dlp
# Download the one-folder macOS build. The direct one-file binary is not code-signable.
curl -fsSL https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos.zip -o yt-dlp.zip
mkdir -p yt-dlp-macos
unzip -q -o yt-dlp.zip -d yt-dlp-macos
mv yt-dlp-macos/yt-dlp_macos Sources/Firelink/yt-dlp
cp -R yt-dlp-macos/_internal Sources/Firelink/_internal
rm -rf yt-dlp.zip yt-dlp-macos
chmod +x Sources/Firelink/yt-dlp
# Download latest FFmpeg release build for macOS ARM64 from Martin Riedl's build server
@@ -89,7 +94,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
codesign --verify --deep build/Firelink.app
- name: Create DMG
env:
@@ -120,73 +125,3 @@ jobs:
else
gh release create "$TAG_NAME" dist/*.dmg --title "$TAG_NAME" --notes-file release_notes.md --verify-tag
fi
- name: Update Sparkle appcast
if: github.ref_type == 'tag' || github.event_name == 'workflow_dispatch'
env:
GH_TOKEN: ${{ github.token }}
SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }}
run: |
if [[ -z "${SPARKLE_ED_PRIVATE_KEY}" ]]; then
echo "Missing SPARKLE_ED_PRIVATE_KEY repository secret; cannot update appcast.xml" >&2
exit 1
fi
VERSION="${{ steps.version.outputs.version }}"
TAG_NAME="${{ steps.version.outputs.tag_name }}"
BUILD_NUMBER="${{ github.run_number }}"
DMG_PATH="dist/Firelink-${VERSION}-mac-arm64.dmg"
DMG_URL="https://github.com/nimbold/Firelink/releases/download/${TAG_NAME}/Firelink-${VERSION}-mac-arm64.dmg"
RELEASE_NOTES_URL="https://github.com/nimbold/Firelink/releases/tag/${TAG_NAME}"
PUB_DATE="$(LC_ALL=C date -u '+%a, %d %b %Y %H:%M:%S +0000')"
SIGN_OUTPUT="$(printf '%s' "$SPARKLE_ED_PRIVATE_KEY" | .build/artifacts/sparkle/Sparkle/bin/sign_update --ed-key-file - "$DMG_PATH")"
ED_SIGNATURE="$(printf '%s\n' "$SIGN_OUTPUT" | sed -n 's/.*sparkle:edSignature="\([^"]*\)".*/\1/p' | head -1)"
LENGTH="$(stat -f%z "$DMG_PATH" 2>/dev/null || stat -c%s "$DMG_PATH")"
if [[ -z "$ED_SIGNATURE" || -z "$LENGTH" ]]; then
echo "Failed to extract Sparkle signature or DMG length" >&2
printf '%s\n' "$SIGN_OUTPUT" >&2
exit 1
fi
git fetch origin main
git checkout main
git pull --ff-only origin main
cat > appcast.xml <<XML
<?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 ${VERSION}</title>
<sparkle:minimumSystemVersion>14.0</sparkle:minimumSystemVersion>
<sparkle:hardwareRequirements>arm64</sparkle:hardwareRequirements>
<sparkle:releaseNotesLink>${RELEASE_NOTES_URL}</sparkle:releaseNotesLink>
<pubDate>${PUB_DATE}</pubDate>
<enclosure url="${DMG_URL}"
sparkle:version="${BUILD_NUMBER}"
sparkle:shortVersionString="${VERSION}"
length="${LENGTH}"
type="application/octet-stream"
sparkle:edSignature="${ED_SIGNATURE}" />
</item>
</channel>
</rss>
XML
xmllint --noout appcast.xml
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add appcast.xml
if git diff --cached --quiet; then
echo "appcast.xml already up to date"
else
git commit -m "chore(release): update appcast for ${VERSION}"
git push origin main
fi
+4
View File
@@ -24,3 +24,7 @@ private-key*
private_key*
yt-dlp
ffmpeg
Sources/Firelink/_internal/
!Sources/Firelink/_internal/
Sources/Firelink/_internal/*
!Sources/Firelink/_internal/.gitkeep
+7 -7
View File
@@ -22,7 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fix a bug where confirming a duplicate resolution failed to close the Add Downloads window, misleading users into thinking the download didn't start.
- Fix keyboard shortcut collision that caused the main window to intercept Enter/Escape keys when the duplicate resolution sheet was open.
- Fix UI freeze when checking release notes for an update by parsing HTML asynchronously on a background thread.
- Improve Sparkle changelog formatting by converting HTML tags to clean Markdown instead of stripping them into an unreadable block of text.
- Improve update changelog formatting by converting release note markup to clean Markdown instead of stripping it into an unreadable block of text.
- Change the internal `Process xxxxx` status message to a cleaner `Starting...` message when queueing a new download.
- Fix `EXC_BREAKPOINT` crash on app launch in production builds by prioritizing `Bundle.main` over `Bundle.module` when accessing resources.
@@ -58,7 +58,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Integrate yt-dlp to DownloadController and add global queue support.
- Implement smart progressive disclosure UI and media extraction engine.
- Implement gatekeeper architecture for on-demand media engine binaries.
- Inline Sparkle update checks to avoid unnecessary modals.
- Inline update checks to avoid unnecessary modals.
### Changes
- Add backward compatibility support for extension tokens.
@@ -70,7 +70,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Revert to plain mode without gradient.
- Apply premium gradient to the correct new icon and app icon.
- Remove redundant version string from up-to-date message.
- Update appcast.xml with valid signature for new framework-embedded dmg.
- Update release metadata for the framework-embedded dmg.
### Fixes
- Cap max height of download links text editor.
@@ -86,14 +86,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Block automatic metadata fetch for private IP addresses (security).
- Actually update extension icons with the 1.9x gradient icon.
- Correctly remove black padding and mask corners.
- Harden Sparkle release metadata.
- Correct Sparkle SUNoUpdateError code to prevent false error messages.
- Harden release metadata.
- Correct no-update handling to prevent false error messages.
## [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.
- Replaced the basic in-app update checker with an integrated release-checking flow.
- Added secure update metadata checks before presenting new releases in the app.
## [0.5.6] - 2026-06-05
+5 -1
View File
@@ -1,4 +1,4 @@
.PHONY: build app dmg run verify clean
.PHONY: build app dmg release run verify clean
build:
swift build -c release
@@ -9,6 +9,10 @@ app:
dmg: app
Scripts/create_dmg.sh
release:
Scripts/create_app_bundle.sh
Scripts/create_dmg.sh
run:
swift run Firelink
-15
View File
@@ -1,15 +0,0 @@
{
"originHash" : "c1cb50a392a5949f6fb77fb4800ef2ea6c811268af19d41dd83b3be29f0321a8",
"pins" : [
{
"identity" : "sparkle",
"kind" : "remoteSourceControl",
"location" : "https://github.com/sparkle-project/Sparkle",
"state" : {
"revision" : "d46d456107feacc80711b21847b82b07bd9fb46e",
"version" : "2.9.3"
}
}
],
"version" : 3
}
+5 -6
View File
@@ -10,16 +10,15 @@ let package = Package(
products: [
.executable(name: "Firelink", targets: ["Firelink"])
],
dependencies: [
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.9.3")
],
dependencies: [],
targets: [
.executableTarget(
name: "Firelink",
dependencies: [
.product(name: "Sparkle", package: "Sparkle")
],
dependencies: [],
path: "Sources/Firelink",
exclude: [
"_internal"
],
resources: [
.process("Assets.xcassets"),
.copy("yt-dlp"),
-1
View File
@@ -85,7 +85,6 @@ Firelink stands on the shoulders of giants. A massive thank you to the contribut
- **[aria2](https://aria2.github.io/)** - The legendary multi-protocol download utility driving our core engine.
- **[yt-dlp](https://github.com/yt-dlp/yt-dlp)** - The definitive command-line audio/video downloader.
- **[FFmpeg](https://ffmpeg.org/)** - The industry standard for media stream manipulation and merging.
- **[Sparkle](https://sparkle-project.org/)** - A secure and reliable software update framework for macOS.
---
+92 -13
View File
@@ -8,6 +8,7 @@ DEFAULT_MARKETING_VERSION="$(git describe --tags --abbrev=0 2>/dev/null | sed 's
DEFAULT_BUILD_NUMBER="$(git rev-list --count HEAD 2>/dev/null || true)"
MARKETING_VERSION="${MARKETING_VERSION:-${DEFAULT_MARKETING_VERSION:-0.1.0}}"
BUILD_NUMBER="${BUILD_NUMBER:-${DEFAULT_BUILD_NUMBER:-1}}"
SIGNING_IDENTITY="${CODE_SIGN_IDENTITY:-${SIGNING_IDENTITY:-}}"
APP_DIR="$ROOT_DIR/build/$APP_NAME.app"
CONTENTS_DIR="$APP_DIR/Contents"
MACOS_DIR="$CONTENTS_DIR/MacOS"
@@ -15,6 +16,48 @@ RESOURCES_DIR="$CONTENTS_DIR/Resources"
ICON_NAME="AppIcon"
cd "$ROOT_DIR"
is_valid_mach_o() {
local path="$1"
if ! file "$path" | grep -q 'Mach-O'; then
return 0
fi
lipo -archs "$path" >/dev/null 2>&1
}
ensure_ytdlp() {
local ytdlp_path="$ROOT_DIR/Sources/Firelink/yt-dlp"
local ytdlp_internal_path="$ROOT_DIR/Sources/Firelink/_internal"
if [[ -x "$ytdlp_path" ]] && [[ -d "$ytdlp_internal_path" ]] && is_valid_mach_o "$ytdlp_path"; then
return
fi
if ! command -v curl >/dev/null || ! command -v unzip >/dev/null; then
echo "WARNING: yt-dlp or its runtime is missing or malformed, and curl/unzip are not available to refresh it." >&2
return
fi
echo "Refreshing bundled yt-dlp runtime..."
local temp_dir
temp_dir="$(mktemp -d)"
curl -fsSL https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos.zip -o "$temp_dir/yt-dlp.zip"
unzip -q -o "$temp_dir/yt-dlp.zip" -d "$temp_dir"
cp "$temp_dir/yt-dlp_macos" "$ytdlp_path"
chmod +x "$ytdlp_path"
if [[ -d "$temp_dir/_internal" ]]; then
rm -rf "$ROOT_DIR/Sources/Firelink/_internal"
cp -R "$temp_dir/_internal" "$ROOT_DIR/Sources/Firelink/_internal"
touch "$ROOT_DIR/Sources/Firelink/_internal/.gitkeep"
fi
rm -rf "$temp_dir"
}
ensure_ytdlp
swift build -c "$CONFIGURATION"
rm -rf "$APP_DIR"
@@ -34,6 +77,10 @@ for media_engine in yt-dlp ffmpeg; do
fi
done
if [[ -d "$ROOT_DIR/Sources/Firelink/_internal" ]]; then
cp -R "$ROOT_DIR/Sources/Firelink/_internal" "$RESOURCES_DIR/_internal"
fi
echo "Packaging Firefox extension..."
mkdir -p "$RESOURCES_DIR/FirefoxExtension"
cp "$ROOT_DIR/Extensions/Firefox/background.js" "$RESOURCES_DIR/FirefoxExtension/background.js"
@@ -62,12 +109,6 @@ 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
@@ -103,12 +144,6 @@ 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/>
<key>CFBundleURLTypes</key>
<array>
<dict>
@@ -125,7 +160,51 @@ cat > "$CONTENTS_DIR/Info.plist" <<PLIST
PLIST
if command -v codesign &> /dev/null; then
codesign --force --deep --sign - "$APP_DIR"
if [[ -n "$SIGNING_IDENTITY" ]]; then
CODESIGN_ARGS=(--force --timestamp --options runtime --sign "$SIGNING_IDENTITY")
echo "Signing app bundle with identity: $SIGNING_IDENTITY"
else
CODESIGN_ARGS=(--force --sign -)
echo "Ad-hoc signing app bundle for local use."
fi
sign_path() {
local path="$1"
codesign "${CODESIGN_ARGS[@]}" "$path"
}
sign_mach_o_file() {
local path="$1"
case "$path" in
*/Python.framework/Python|*/Python.framework/Versions/Current/*)
return
;;
esac
if file "$path" | grep -q 'Mach-O'; then
if ! sign_path "$path"; then
echo "WARNING: Could not sign Mach-O executable for local build: $path" >&2
fi
fi
}
if [[ -d "$FRAMEWORKS_DIR" ]]; then
while IFS= read -r -d '' executable_path; do
sign_mach_o_file "$executable_path"
done < <(find "$FRAMEWORKS_DIR" -type f -print0)
while IFS= read -r -d '' bundle_path; do
sign_path "$bundle_path"
done < <(find "$FRAMEWORKS_DIR" \( -name "*.xpc" -o -name "*.framework" \) -type d -depth -print0)
fi
while IFS= read -r -d '' executable_path; do
sign_mach_o_file "$executable_path"
done < <(find "$RESOURCES_DIR" -type f -print0)
sign_path "$MACOS_DIR/$APP_NAME"
sign_path "$APP_DIR"
codesign --verify --deep --verbose=2 "$APP_DIR" || true
fi
echo "Created $APP_DIR"
+12 -2
View File
@@ -3,12 +3,12 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
APP_NAME="Firelink"
VERSION="${VERSION:-0.1.0}"
VERSION="${VERSION:-}"
ARCH="${ARCH:-arm64}"
SIGNING_IDENTITY="${CODE_SIGN_IDENTITY:-${SIGNING_IDENTITY:-}}"
APP_DIR="$ROOT_DIR/build/$APP_NAME.app"
DIST_DIR="$ROOT_DIR/dist"
DMG_STAGING_DIR="$ROOT_DIR/build/dmg"
DMG_PATH="$DIST_DIR/$APP_NAME-$VERSION-mac-$ARCH.dmg"
if [[ ! -d "$APP_DIR" ]]; then
echo "Missing app bundle: $APP_DIR" >&2
@@ -16,6 +16,12 @@ if [[ ! -d "$APP_DIR" ]]; then
exit 1
fi
if [[ -z "$VERSION" ]]; then
VERSION="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$APP_DIR/Contents/Info.plist")"
fi
DMG_PATH="$DIST_DIR/$APP_NAME-$VERSION-mac-$ARCH.dmg"
rm -rf "$DMG_STAGING_DIR"
mkdir -p "$DMG_STAGING_DIR" "$DIST_DIR"
cp -R "$APP_DIR" "$DMG_STAGING_DIR/"
@@ -29,4 +35,8 @@ hdiutil create \
-format UDZO \
"$DMG_PATH"
if [[ -n "$SIGNING_IDENTITY" ]]; then
codesign --force --timestamp --sign "$SIGNING_IDENTITY" "$DMG_PATH"
fi
echo "Created $DMG_PATH"
+6 -77
View File
@@ -1,81 +1,8 @@
import SwiftUI
import Sparkle
final class SparkleUpdater: NSObject, ObservableObject, SPUUpdaterDelegate {
private var _updater: SPUUpdater?
var updater: SPUUpdater { _updater! }
@Published var isChecking = false
@Published var isDownloading = false
@Published var isExtracting = false
@Published var isReadyToInstall = false
@Published var downloadProgress: Double = 0.0
@Published var extractionProgress: Double = 0.0
@Published var updateStatus: String?
@Published var foundUpdateItem: SUAppcastItem?
@Published var releaseNotes: AttributedString?
@Published var automaticallyChecksForUpdates: Bool = true {
didSet {
_updater?.automaticallyChecksForUpdates = automaticallyChecksForUpdates
}
}
var expectedContentLength: UInt64 = 0
var receivedContentLength: UInt64 = 0
var cancellation: (() -> Void)?
var updateChoiceReply: ((SPUUserUpdateChoice) -> Void)?
override init() {
super.init()
let driver = InlineUpdateUserDriver(updater: self)
let hostBundle = Bundle.main
self._updater = SPUUpdater(hostBundle: hostBundle, applicationBundle: hostBundle, userDriver: driver, delegate: self)
do {
try self._updater?.start()
self.automaticallyChecksForUpdates = self._updater?.automaticallyChecksForUpdates ?? true
} catch {
print("Failed to start Sparkle updater: \(error)")
}
}
func checkForUpdates() {
guard updater.canCheckForUpdates else {
isChecking = false
updateStatus = "Update check is already in progress."
return
}
updater.checkForUpdates()
}
func resetState() {
isChecking = false
isDownloading = false
isExtracting = false
isReadyToInstall = false
downloadProgress = 0.0
extractionProgress = 0.0
updateStatus = nil
foundUpdateItem = nil
releaseNotes = nil
expectedContentLength = 0
receivedContentLength = 0
cancellation = nil
updateChoiceReply = nil
}
// Delegate methods can be left mostly empty or minimal since the UserDriver handles the UI state now.
func updater(_ updater: SPUUpdater, didFinishUpdateCycleFor updateCheck: SPUUpdateCheck, error: Error?) {
DispatchQueue.main.async {
self.isChecking = false
}
}
}
@main
struct FirelinkApp: App {
@StateObject private var sparkleUpdater: SparkleUpdater
@StateObject private var updateChecker: ReleaseUpdateChecker
@StateObject private var settings: AppSettings
@StateObject private var controller: DownloadController
@StateObject private var schedulerController: SchedulerController
@@ -85,7 +12,7 @@ struct FirelinkApp: App {
private let extensionServer: LocalExtensionServer?
init() {
self._sparkleUpdater = StateObject(wrappedValue: SparkleUpdater())
self._updateChecker = StateObject(wrappedValue: ReleaseUpdateChecker())
let settings = AppSettings()
let controller = DownloadController(settings: settings)
@@ -104,9 +31,12 @@ struct FirelinkApp: App {
.environmentObject(controller)
.environmentObject(settings)
.environmentObject(schedulerController)
.environmentObject(sparkleUpdater)
.environmentObject(updateChecker)
.modifier(AppThemeModifier(theme: settings.appTheme))
.modifier(AppFontSizeModifier(fontSize: settings.appFontSize))
.task {
updateChecker.checkAutomaticallyIfNeeded()
}
.onOpenURL { url in
if url.scheme == "firelink" {
if url.host == "add",
@@ -180,7 +110,6 @@ struct FirelinkApp: App {
MenuBarExtra(isInserted: $showMenuBarIcon) {
TrayMenuView()
.environmentObject(controller)
.environmentObject(sparkleUpdater)
} label: {
if let nsImage = { () -> NSImage? in
guard let url = menuBarIconURL(),
@@ -8,13 +8,6 @@ final class LocalExtensionServer: @unchecked Sendable {
static let maxRequestBytes = 128 * 1024
static let maxURLCount = 200
static let extensionRequestHeader = "x-firelink-extension"
// Firelink Companion sends this token.
// We now use a dynamic token generated in AppSettings, but fallback to this
// for backward compatibility during the extension rollout if needed, though
// we'll enforce the dynamic token strictly in the processRequest method.
static let legacyExtensionToken = "firelink-extension-v1"
static let allowedSchemes = Set(["http", "https", "ftp", "sftp"])
}
+310
View File
@@ -0,0 +1,310 @@
import Foundation
struct GitHubReleaseCheckService: @unchecked Sendable {
private let owner: String
private let repository: String
private let fetch: @Sendable (URLRequest) async throws -> (Data, URLResponse)
init(
owner: String = "nimbold",
repository: String = "Firelink",
fetch: @escaping @Sendable (URLRequest) async throws -> (Data, URLResponse) = { request in
try await URLSession.shared.data(for: request)
}
) {
self.owner = owner
self.repository = repository
self.fetch = fetch
}
func checkForUpdate(currentVersion: String) async throws -> ReleaseCheckOutcome {
guard let current = AppVersion(currentVersion) else {
throw ReleaseCheckFailure.invalidCurrentVersion(currentVersion)
}
let release = try await latestStableRelease()
guard let latest = AppVersion(release.tagName) else {
throw ReleaseCheckFailure.invalidReleaseVersion(release.tagName)
}
let update = AvailableReleaseUpdate(
version: latest.description,
tagName: release.tagName,
title: release.name?.isEmpty == false ? release.name! : release.tagName,
releaseNotes: release.body?.isEmpty == false ? release.body! : "No release notes were provided for this version.",
releaseURL: release.htmlURL,
publishedAt: release.publishedAt
)
if latest > current {
return .updateAvailable(update)
}
return .upToDate(latestVersion: latest.description, localVersion: current.description)
}
private func latestStableRelease() async throws -> GitHubRelease {
guard let url = URL(string: "https://api.github.com/repos/\(owner)/\(repository)/releases?per_page=30") else {
throw ReleaseCheckFailure.invalidReleaseURL
}
var request = URLRequest(url: url)
request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
request.setValue("Firelink", forHTTPHeaderField: "User-Agent")
let (data, response) = try await fetch(request)
guard let httpResponse = response as? HTTPURLResponse else {
throw ReleaseCheckFailure.invalidResponse
}
guard (200..<300).contains(httpResponse.statusCode) else {
throw ReleaseCheckFailure.httpStatus(httpResponse.statusCode)
}
let releases = try JSONDecoder.githubReleaseDecoder.decode([GitHubRelease].self, from: data)
let stableReleases = releases.filter { !$0.draft && !$0.prerelease }
guard !stableReleases.isEmpty else {
throw ReleaseCheckFailure.noStableRelease
}
let versionedReleases = stableReleases.compactMap { release -> (release: GitHubRelease, version: AppVersion)? in
guard let version = AppVersion(release.tagName) else { return nil }
return (release, version)
}
guard let latest = versionedReleases.max(by: { $0.version < $1.version }) else {
throw ReleaseCheckFailure.invalidReleaseVersion(stableReleases[0].tagName)
}
return latest.release
}
}
struct GitHubRelease: Decodable, Equatable {
let tagName: String
let name: String?
let body: String?
let htmlURL: URL
let draft: Bool
let prerelease: Bool
let publishedAt: Date?
private enum CodingKeys: String, CodingKey {
case tagName = "tag_name"
case name
case body
case htmlURL = "html_url"
case draft
case prerelease
case publishedAt = "published_at"
}
}
struct AvailableReleaseUpdate: Equatable, Identifiable {
var id: String { tagName }
let version: String
let tagName: String
let title: String
let releaseNotes: String
let releaseURL: URL
let publishedAt: Date?
}
enum ReleaseCheckOutcome: Equatable {
case updateAvailable(AvailableReleaseUpdate)
case upToDate(latestVersion: String, localVersion: String)
}
enum ReleaseUpdateState: Equatable {
case idle
case checking
case updateAvailable(AvailableReleaseUpdate)
case upToDate(latestVersion: String, localVersion: String)
case failed(message: String, recovery: String)
}
enum ReleaseCheckFailure: Error, Equatable {
case invalidReleaseURL
case invalidResponse
case httpStatus(Int)
case noStableRelease
case invalidCurrentVersion(String)
case invalidReleaseVersion(String)
}
@MainActor
final class ReleaseUpdateChecker: ObservableObject {
@Published private(set) var state: ReleaseUpdateState = .idle
@Published var automaticallyChecksForUpdates: Bool {
didSet {
UserDefaults.standard.set(automaticallyChecksForUpdates, forKey: Self.automaticChecksKey)
}
}
private let service: GitHubReleaseCheckService
private let bundle: Bundle
private var automaticCheckTask: Task<Void, Never>?
private static let automaticChecksKey = "AutomaticallyCheckForReleaseUpdates"
private static let lastAutomaticCheckKey = "LastReleaseUpdateCheckDate"
private static let automaticCheckInterval: TimeInterval = 24 * 60 * 60
init(service: GitHubReleaseCheckService = GitHubReleaseCheckService(), bundle: Bundle = .main) {
self.service = service
self.bundle = bundle
if UserDefaults.standard.object(forKey: Self.automaticChecksKey) == nil {
self.automaticallyChecksForUpdates = true
} else {
self.automaticallyChecksForUpdates = UserDefaults.standard.bool(forKey: Self.automaticChecksKey)
}
}
var currentVersion: String {
bundle.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.1.0"
}
func checkForUpdates() {
guard state != .checking else { return }
state = .checking
Task {
do {
let outcome = try await service.checkForUpdate(currentVersion: currentVersion)
UserDefaults.standard.set(Date(), forKey: Self.lastAutomaticCheckKey)
apply(outcome)
} catch {
state = Self.failedState(for: error)
}
}
}
func checkAutomaticallyIfNeeded() {
guard automaticallyChecksForUpdates else { return }
guard state == .idle else { return }
if let lastCheck = UserDefaults.standard.object(forKey: Self.lastAutomaticCheckKey) as? Date,
Date().timeIntervalSince(lastCheck) < Self.automaticCheckInterval {
return
}
automaticCheckTask?.cancel()
automaticCheckTask = Task { [weak self] in
self?.checkForUpdates()
}
}
private func apply(_ outcome: ReleaseCheckOutcome) {
switch outcome {
case .updateAvailable(let update):
state = .updateAvailable(update)
case .upToDate(let latestVersion, let localVersion):
state = .upToDate(latestVersion: latestVersion, localVersion: localVersion)
}
}
private static func failedState(for error: Error) -> ReleaseUpdateState {
if let urlError = error as? URLError {
switch urlError.code {
case .notConnectedToInternet, .networkConnectionLost, .cannotFindHost, .cannotConnectToHost, .timedOut:
return .failed(
message: "Couldn't reach GitHub.",
recovery: "Check your internet connection, then try again."
)
default:
return .failed(
message: "The update check couldn't finish.",
recovery: urlError.localizedDescription
)
}
}
if let failure = error as? ReleaseCheckFailure {
switch failure {
case .httpStatus(let statusCode):
return .failed(
message: "GitHub returned HTTP \(statusCode).",
recovery: "Try again in a moment, or open the releases page manually."
)
case .noStableRelease:
return .failed(
message: "No stable release was found.",
recovery: "Open GitHub Releases to check the project manually."
)
case .invalidCurrentVersion(let version):
return .failed(
message: "Firelink's current version could not be read.",
recovery: "The app reported version \(version)."
)
case .invalidReleaseVersion(let tag):
return .failed(
message: "The latest release tag could not be compared.",
recovery: "GitHub reported \(tag)."
)
case .invalidReleaseURL, .invalidResponse:
return .failed(
message: "The GitHub response could not be read.",
recovery: "Try again in a moment."
)
}
}
return .failed(
message: "The update check failed.",
recovery: error.localizedDescription
)
}
}
struct AppVersion: Comparable, CustomStringConvertible {
private let components: [Int]
init?(_ rawValue: String) {
var value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
if value.hasPrefix("v") || value.hasPrefix("V") {
value.removeFirst()
}
let prefix = value.prefix { character in
character.isNumber || character == "."
}
let version = String(prefix).trimmingCharacters(in: CharacterSet(charactersIn: "."))
guard !version.isEmpty else { return nil }
let components = version.split(separator: ".").compactMap { Int($0) }
guard !components.isEmpty, components.count == version.split(separator: ".").count else {
return nil
}
self.components = components
}
var description: String {
components.map(String.init).joined(separator: ".")
}
static func < (lhs: AppVersion, rhs: AppVersion) -> Bool {
let count = max(lhs.components.count, rhs.components.count)
for index in 0..<count {
let left = index < lhs.components.count ? lhs.components[index] : 0
let right = index < rhs.components.count ? rhs.components[index] : 0
if left != right {
return left < right
}
}
return false
}
}
private extension JSONDecoder {
static var githubReleaseDecoder: JSONDecoder {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
return decoder
}
}
+169 -193
View File
@@ -2,14 +2,13 @@ import AppKit
import SwiftUI
struct AboutSettingsPane: View {
@EnvironmentObject var sparkleUpdater: SparkleUpdater
@EnvironmentObject private var updateChecker: ReleaseUpdateChecker
private let developerProfileURL = URL(string: "https://github.com/nimbold")!
private let projectURL = URL(string: "https://github.com/nimbold/Firelink")!
private let releasesURL = URL(string: "https://github.com/nimbold/Firelink/releases")!
private let aria2URL = URL(string: "https://aria2.github.io/")!
private let ytDlpURL = URL(string: "https://github.com/yt-dlp/yt-dlp")!
private let ffmpegURL = URL(string: "https://ffmpeg.org/")!
private let sparkleURL = URL(string: "https://sparkle-project.org/")!
private let licenseURL = URL(string: "https://github.com/nimbold/Firelink/blob/main/LICENSE")!
private var appVersion: String {
@@ -32,7 +31,7 @@ struct AboutSettingsPane: View {
VStack(alignment: .leading, spacing: 4) {
Text("Firelink")
.font(.title2.weight(.bold))
Text("Version \(appVersion)")
Text("Version \(appVersion) (\(buildNumber))")
.foregroundStyle(.secondary)
Text("A native macOS download manager for fast, organized, segmented transfers.")
.font(.caption)
@@ -44,193 +43,15 @@ struct AboutSettingsPane: View {
Section("Updates") {
VStack(alignment: .leading, spacing: 16) {
if sparkleUpdater.isChecking {
HStack(spacing: 12) {
ProgressView()
.controlSize(.small)
Text("Checking for updates...")
.foregroundStyle(.secondary)
}
} else if sparkleUpdater.isDownloading || sparkleUpdater.isExtracting {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(sparkleUpdater.isDownloading ? "Downloading update..." : "Extracting update...")
.font(.subheadline)
.fontWeight(.medium)
Spacer()
if sparkleUpdater.isDownloading && sparkleUpdater.downloadProgress > 0 {
Text("\(Int(sparkleUpdater.downloadProgress * 100))%")
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
}
ProgressView(value: sparkleUpdater.isDownloading ? sparkleUpdater.downloadProgress : sparkleUpdater.extractionProgress)
.tint(.accentColor)
Button("Cancel") {
sparkleUpdater.cancellation?()
}
.controlSize(.small)
}
} else if sparkleUpdater.isReadyToInstall {
VStack(alignment: .leading, spacing: 12) {
HStack {
Image(systemName: "arrow.down.app.fill")
.foregroundStyle(.green)
.font(.title2)
VStack(alignment: .leading) {
Text("Update Ready")
.font(.headline)
Text("The new version is ready to be installed.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
Button {
let reply = sparkleUpdater.updateChoiceReply
sparkleUpdater.updateChoiceReply = nil
reply?(.install)
} label: {
Label("Install and Relaunch", systemImage: "sparkles")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
}
} else if let item = sparkleUpdater.foundUpdateItem {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .top, spacing: 12) {
Image(systemName: "exclamationmark.arrow.circlepath")
.foregroundStyle(.orange)
.font(.title)
VStack(alignment: .leading, spacing: 4) {
Text("Update Available")
.font(.headline)
Text("Version \(item.displayVersionString) is available.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
if let notes = sparkleUpdater.releaseNotes {
DisclosureGroup("What's New") {
ScrollView {
Text(notes)
.font(.caption)
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
}
.frame(maxHeight: 150)
.padding(8)
.background(Color(NSColor.controlBackgroundColor))
.cornerRadius(6)
}
}
HStack(spacing: 12) {
Button {
let reply = sparkleUpdater.updateChoiceReply
sparkleUpdater.updateChoiceReply = nil
reply?(.install)
} label: {
Text("Download & Install")
}
.buttonStyle(.borderedProminent)
Button("Remind Me Later") {
let reply = sparkleUpdater.updateChoiceReply
sparkleUpdater.updateChoiceReply = nil
reply?(.dismiss)
}
Button("Skip This Version") {
let reply = sparkleUpdater.updateChoiceReply
sparkleUpdater.updateChoiceReply = nil
reply?(.skip)
}
}
}
} else {
// Up to date or initial state
if let status = sparkleUpdater.updateStatus, status == "You're up to date!" {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .top, spacing: 12) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
.font(.title)
VStack(alignment: .leading, spacing: 4) {
Text("You're up to date!")
.font(.headline)
Text("Firelink \(appVersion) is the newest version available.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
HStack(spacing: 12) {
Button {
sparkleUpdater.checkForUpdates()
} label: {
Label("Check Again", systemImage: "arrow.clockwise")
}
.buttonStyle(.bordered)
Button {
NSWorkspace.shared.open(projectURL.appendingPathComponent("releases"))
} label: {
Label("Release Notes", systemImage: "doc.text")
}
}
}
} else {
HStack(spacing: 12) {
if let status = sparkleUpdater.updateStatus {
if status.lowercased().contains("failed") || status.lowercased().contains("error") {
Image(systemName: "xmark.octagon.fill")
.foregroundStyle(.red)
} else {
Image(systemName: "info.circle.fill")
.foregroundStyle(.blue)
}
Text(status)
.font(.subheadline)
.foregroundStyle(.secondary)
} else {
Text("Keeping your app up to date ensures you have the latest features and security improvements.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
updateStatusView
HStack(spacing: 12) {
Button {
sparkleUpdater.checkForUpdates()
} label: {
Label("Check for Updates", systemImage: "arrow.clockwise")
}
.buttonStyle(.bordered)
Button {
NSWorkspace.shared.open(projectURL.appendingPathComponent("releases"))
} label: {
Label("Release Notes", systemImage: "doc.text")
}
}
}
}
Divider()
.padding(.vertical, 4)
Toggle("Automatically check for updates", isOn: $sparkleUpdater.automaticallyChecksForUpdates)
.padding(.vertical, 2)
Toggle("Automatically check for updates", isOn: $updateChecker.automaticallyChecksForUpdates)
}
.padding(.vertical, 8)
.animation(.easeInOut, value: sparkleUpdater.isChecking)
.animation(.easeInOut, value: sparkleUpdater.isDownloading)
.animation(.easeInOut, value: sparkleUpdater.isExtracting)
.animation(.easeInOut, value: sparkleUpdater.isReadyToInstall)
.animation(.easeInOut, value: sparkleUpdater.foundUpdateItem != nil)
.animation(.easeInOut, value: updateChecker.state)
}
Section {
@@ -262,8 +83,6 @@ struct AboutSettingsPane: View {
Link("yt-dlp", destination: ytDlpURL)
Text("").foregroundStyle(.secondary)
Link("ffmpeg", destination: ffmpegURL)
Text("").foregroundStyle(.secondary)
Link("Sparkle", destination: sparkleURL)
}
Spacer()
Link("MIT License", destination: licenseURL)
@@ -278,11 +97,168 @@ struct AboutSettingsPane: View {
}
}
.formStyle(.grouped)
.onDisappear {
if let reply = sparkleUpdater.updateChoiceReply {
sparkleUpdater.updateChoiceReply = nil
reply(.dismiss)
}
@ViewBuilder
private var updateStatusView: some View {
switch updateChecker.state {
case .idle:
VStack(alignment: .leading, spacing: 12) {
updateHeader(
systemImage: "arrow.down.circle",
tint: .blue,
title: "Check for Updates",
subtitle: "Firelink checks GitHub Releases and opens the download page when a new version is available."
)
HStack(spacing: 12) {
Button {
updateChecker.checkForUpdates()
} label: {
Label("Check for Updates", systemImage: "arrow.clockwise")
}
.buttonStyle(.bordered)
Button {
NSWorkspace.shared.open(releasesURL)
} label: {
Label("Release Notes", systemImage: "doc.text")
}
}
}
case .checking:
HStack(spacing: 12) {
ProgressView()
.controlSize(.small)
VStack(alignment: .leading, spacing: 3) {
Text("Checking GitHub Releases")
.font(.headline)
Text("Looking for the latest stable Firelink release.")
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
case .updateAvailable(let update):
VStack(alignment: .leading, spacing: 12) {
updateHeader(
systemImage: "arrow.down.circle.fill",
tint: .green,
title: "Firelink \(update.version) Is Available",
subtitle: "You have Firelink \(appVersion). Download the new release from GitHub when you're ready."
)
releaseNotesDisclosure(for: update)
HStack(spacing: 12) {
Button {
NSWorkspace.shared.open(update.releaseURL)
} label: {
Label("Open GitHub Release", systemImage: "arrow.up.forward.app")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
Button {
updateChecker.checkForUpdates()
} label: {
Label("Check Again", systemImage: "arrow.clockwise")
}
.buttonStyle(.bordered)
}
}
case .upToDate(let latestVersion, let localVersion):
VStack(alignment: .leading, spacing: 12) {
let subtitle = latestVersion == localVersion
? "Firelink \(localVersion) is the newest stable release."
: "Firelink \(localVersion) is newer than the latest stable GitHub release, \(latestVersion)."
updateHeader(
systemImage: "checkmark.seal.fill",
tint: .green,
title: "You're Up to Date",
subtitle: subtitle
)
HStack(spacing: 12) {
Button {
updateChecker.checkForUpdates()
} label: {
Label("Check Again", systemImage: "arrow.clockwise")
}
.buttonStyle(.bordered)
Button {
NSWorkspace.shared.open(releasesURL)
} label: {
Label("Release Notes", systemImage: "doc.text")
}
}
}
case .failed(let message, let recovery):
VStack(alignment: .leading, spacing: 12) {
updateHeader(
systemImage: "exclamationmark.triangle.fill",
tint: .orange,
title: message,
subtitle: recovery
)
HStack(spacing: 12) {
Button {
updateChecker.checkForUpdates()
} label: {
Label("Check Again", systemImage: "arrow.clockwise")
}
.buttonStyle(.borderedProminent)
Button {
NSWorkspace.shared.open(releasesURL)
} label: {
Label("Open Releases", systemImage: "safari")
}
}
}
}
}
private func updateHeader(systemImage: String, tint: Color, title: String, subtitle: String) -> some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: systemImage)
.font(.title2)
.foregroundStyle(tint)
.frame(width: 28)
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.headline)
Text(subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
private func releaseNotesDisclosure(for update: AvailableReleaseUpdate) -> some View {
DisclosureGroup("What's New") {
ScrollView {
Text(releaseNotes(from: update.releaseNotes))
.font(.caption)
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
}
.frame(maxHeight: 180)
.padding(8)
.background(Color(NSColor.controlBackgroundColor))
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
}
}
private func releaseNotes(from markdown: String) -> AttributedString {
(try? AttributedString(markdown: markdown)) ?? AttributedString(markdown)
}
}
@@ -1,150 +0,0 @@
import Foundation
import AppKit
import Sparkle
class InlineUpdateUserDriver: NSObject, SPUUserDriver {
weak var updater: SparkleUpdater?
init(updater: SparkleUpdater) {
self.updater = updater
}
func show(_ request: SPUUpdatePermissionRequest, reply: @escaping (SUUpdatePermissionResponse) -> Void) {
reply(SUUpdatePermissionResponse(automaticUpdateChecks: true, sendSystemProfile: false))
}
func showUserInitiatedUpdateCheck(cancellation: @escaping () -> Void) {
DispatchQueue.main.async {
self.updater?.resetState()
self.updater?.isChecking = true
self.updater?.updateStatus = "Checking for updates..."
self.updater?.cancellation = cancellation
}
}
func showUpdateFound(with appcastItem: SUAppcastItem, state: SPUUserUpdateState, reply: @escaping (SPUUserUpdateChoice) -> Void) {
DispatchQueue.main.async {
self.updater?.isChecking = false
self.updater?.foundUpdateItem = appcastItem
self.updater?.updateStatus = "Update available: Version \(appcastItem.displayVersionString)"
self.updater?.updateChoiceReply = reply
}
}
func showUpdateReleaseNotes(with downloadData: SPUDownloadData) {
DispatchQueue.main.async {
let options: [NSAttributedString.DocumentReadingOptionKey: Any] = [
.documentType: NSAttributedString.DocumentType.html,
.characterEncoding: String.Encoding.utf8.rawValue
]
if let nsAttrString = try? NSMutableAttributedString(data: downloadData.data, options: options, documentAttributes: nil) {
let range = NSRange(location: 0, length: nsAttrString.length)
nsAttrString.removeAttribute(.foregroundColor, range: range)
nsAttrString.removeAttribute(.font, range: range)
if let attrString = try? AttributedString(nsAttrString, including: \.appKit) {
self.updater?.releaseNotes = attrString
}
}
}
}
func showUpdateReleaseNotesFailedToDownloadWithError(_ error: Error) {
}
func showUpdateNotFoundWithError(_ error: Error, acknowledgement: @escaping () -> Void) {
DispatchQueue.main.async {
self.updater?.isChecking = false
let nsError = error as NSError
if nsError.domain == SUSparkleErrorDomain && nsError.code == 1001 {
self.updater?.updateStatus = "You're up to date!"
} else {
self.updater?.updateStatus = "Update check failed: \(error.localizedDescription)"
}
acknowledgement()
}
}
func showUpdaterError(_ error: Error, acknowledgement: @escaping () -> Void) {
DispatchQueue.main.async {
self.updater?.isChecking = false
self.updater?.updateStatus = "Updater error: \(error.localizedDescription)"
acknowledgement()
}
}
func showDownloadInitiated(cancellation: @escaping () -> Void) {
DispatchQueue.main.async {
self.updater?.isDownloading = true
self.updater?.downloadProgress = 0.0
self.updater?.cancellation = cancellation
self.updater?.updateStatus = "Downloading update..."
}
}
func showDownloadDidReceiveExpectedContentLength(_ expectedContentLength: UInt64) {
DispatchQueue.main.async {
self.updater?.expectedContentLength = expectedContentLength
self.updater?.receivedContentLength = 0
}
}
func showDownloadDidReceiveData(ofLength length: UInt64) {
DispatchQueue.main.async {
if let updater = self.updater {
updater.receivedContentLength += length
if updater.expectedContentLength > 0 {
updater.downloadProgress = Double(updater.receivedContentLength) / Double(updater.expectedContentLength)
}
}
}
}
func showDownloadDidStartExtractingUpdate() {
DispatchQueue.main.async {
self.updater?.isDownloading = false
self.updater?.isExtracting = true
self.updater?.updateStatus = "Extracting update..."
self.updater?.downloadProgress = 1.0
}
}
func showExtractionReceivedProgress(_ progress: Double) {
DispatchQueue.main.async {
self.updater?.extractionProgress = progress
}
}
func showReady(toInstallAndRelaunch reply: @escaping (SPUUserUpdateChoice) -> Void) {
DispatchQueue.main.async {
self.updater?.isExtracting = false
self.updater?.isReadyToInstall = true
self.updater?.updateStatus = "Ready to install"
self.updater?.updateChoiceReply = reply
}
}
func showInstallingUpdate(withApplicationTerminated applicationTerminated: Bool, retryTerminatingApplication: @escaping () -> Void) {
}
func showUpdateInstalledAndRelaunched(_ relaunched: Bool, acknowledgement: @escaping () -> Void) {
acknowledgement()
}
func dismissUpdateInstallation() {
DispatchQueue.main.async {
self.updater?.isChecking = false
self.updater?.isDownloading = false
self.updater?.isExtracting = false
self.updater?.isReadyToInstall = false
self.updater?.downloadProgress = 0.0
self.updater?.extractionProgress = 0.0
self.updater?.foundUpdateItem = nil
self.updater?.releaseNotes = nil
// Do not clear updateStatus here so success/error messages remain visible.
}
}
func showUpdateInFocus() {
}
}
+1
View File
@@ -0,0 +1 @@
-22
View File
@@ -1,22 +0,0 @@
<?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.6.3</title>
<sparkle:minimumSystemVersion>14.0</sparkle:minimumSystemVersion>
<sparkle:hardwareRequirements>arm64</sparkle:hardwareRequirements>
<sparkle:releaseNotesLink>https://github.com/nimbold/Firelink/releases/tag/v0.6.3</sparkle:releaseNotesLink>
<pubDate>Tue, 09 Jun 2026 03:29:55 +0000</pubDate>
<enclosure url="https://github.com/nimbold/Firelink/releases/download/v0.6.3/Firelink-0.6.3-mac-arm64.dmg"
sparkle:version="28"
sparkle:shortVersionString="0.6.3"
length="75217746"
type="application/octet-stream"
sparkle:edSignature="WQWCpiwvk+HqS80t35HjW3Kid8pYe2jYHpsQG/HSA1sfGsX8FLZFBkTI3kvNlKJ/z2KNEtVAi2IbQG0jMohcDg==" />
</item>
</channel>
</rss>