diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bf978c0..c324dc7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 < - - - Firelink Updates - https://github.com/nimbold/Firelink - Most recent updates for Firelink - en - - Version ${VERSION} - 14.0 - arm64 - ${RELEASE_NOTES_URL} - ${PUB_DATE} - - - - - 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 diff --git a/.gitignore b/.gitignore index 7cc03ef..e02dde7 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,7 @@ private-key* private_key* yt-dlp ffmpeg +Sources/Firelink/_internal/ +!Sources/Firelink/_internal/ +Sources/Firelink/_internal/* +!Sources/Firelink/_internal/.gitkeep diff --git a/CHANGELOG.md b/CHANGELOG.md index d85cfc9..8db65ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Extensions/Firefox b/Extensions/Firefox index 6951259..1636855 160000 --- a/Extensions/Firefox +++ b/Extensions/Firefox @@ -1 +1 @@ -Subproject commit 6951259752555aef6b1a6dd0bf5e8e69f5fda6c9 +Subproject commit 1636855c3faef2910d220306e9989ed63ab9c0f4 diff --git a/Makefile b/Makefile index 52c80ac..afd7dc0 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/Package.resolved b/Package.resolved deleted file mode 100644 index 410effa..0000000 --- a/Package.resolved +++ /dev/null @@ -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 -} diff --git a/Package.swift b/Package.swift index 366df71..1200cf9 100644 --- a/Package.swift +++ b/Package.swift @@ -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"), diff --git a/README.md b/README.md index 8aca927..5ce8615 100644 --- a/README.md +++ b/README.md @@ -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. --- diff --git a/Scripts/create_app_bundle.sh b/Scripts/create_app_bundle.sh index 092149f..405c56d 100755 --- a/Scripts/create_app_bundle.sh +++ b/Scripts/create_app_bundle.sh @@ -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" < 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 - CFBundleURLTypes @@ -125,7 +160,51 @@ cat > "$CONTENTS_DIR/Info.plist" < /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" diff --git a/Scripts/create_dmg.sh b/Scripts/create_dmg.sh index 94bc65c..253cb1c 100755 --- a/Scripts/create_dmg.sh +++ b/Scripts/create_dmg.sh @@ -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" diff --git a/Sources/Firelink/FirelinkApp.swift b/Sources/Firelink/FirelinkApp.swift index 85caa96..ceb9365 100644 --- a/Sources/Firelink/FirelinkApp.swift +++ b/Sources/Firelink/FirelinkApp.swift @@ -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(), diff --git a/Sources/Firelink/LocalExtensionServer.swift b/Sources/Firelink/LocalExtensionServer.swift index 20217f7..f505d8b 100644 --- a/Sources/Firelink/LocalExtensionServer.swift +++ b/Sources/Firelink/LocalExtensionServer.swift @@ -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"]) } diff --git a/Sources/Firelink/ReleaseUpdateChecker.swift b/Sources/Firelink/ReleaseUpdateChecker.swift new file mode 100644 index 0000000..342f9e8 --- /dev/null +++ b/Sources/Firelink/ReleaseUpdateChecker.swift @@ -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? + + 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.. 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) + } } diff --git a/Sources/Firelink/Settings/InlineUpdateUserDriver.swift b/Sources/Firelink/Settings/InlineUpdateUserDriver.swift deleted file mode 100644 index 5213c11..0000000 --- a/Sources/Firelink/Settings/InlineUpdateUserDriver.swift +++ /dev/null @@ -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() { - } -} diff --git a/Sources/Firelink/_internal/.gitkeep b/Sources/Firelink/_internal/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/Sources/Firelink/_internal/.gitkeep @@ -0,0 +1 @@ + diff --git a/appcast.xml b/appcast.xml deleted file mode 100644 index cee4c76..0000000 --- a/appcast.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - Firelink Updates - https://github.com/nimbold/Firelink - Most recent updates for Firelink - en - - Version 0.6.3 - 14.0 - arm64 - https://github.com/nimbold/Firelink/releases/tag/v0.6.3 - Tue, 09 Jun 2026 03:29:55 +0000 - - - -