From 410059e3de91810d2ee10839b360cb2a60cd24a4 Mon Sep 17 00:00:00 2001 From: nimbold <11913706+nimbold@users.noreply.github.com> Date: Mon, 8 Jun 2026 02:18:56 +0330 Subject: [PATCH] feat(ui): redesign settings panes and enhance update flows - Implemented `InlineUpdateUserDriver` to handle inline update checks inside Settings panes. - Redesigned `AboutSettingsPane` with a modern inline update UI, release notes DisclosureGroup, and clear visual feedback states. - Rebuilt `EngineSettingsPane` using native macOS layouts, removing custom stacks for better native alignment. - Relocated browser cookie selection and media engine updates in `EngineSettingsPane` for better contextual grouping. --- Sources/Firelink/FirelinkApp.swift | 74 +++---- .../Firelink/Settings/AboutSettingsPane.swift | 192 +++++++++++++++--- .../Settings/EngineSettingsPane.swift | 176 +++++++++------- .../Settings/InlineUpdateUserDriver.swift | 148 ++++++++++++++ 4 files changed, 457 insertions(+), 133 deletions(-) create mode 100644 Sources/Firelink/Settings/InlineUpdateUserDriver.swift diff --git a/Sources/Firelink/FirelinkApp.swift b/Sources/Firelink/FirelinkApp.swift index da7212f..27d571a 100644 --- a/Sources/Firelink/FirelinkApp.swift +++ b/Sources/Firelink/FirelinkApp.swift @@ -2,59 +2,63 @@ import SwiftUI import Sparkle final class SparkleUpdater: NSObject, ObservableObject, SPUUpdaterDelegate { - private var _controller: SPUStandardUpdaterController? - var controller: SPUStandardUpdaterController { _controller! } + 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: String? + + var expectedContentLength: UInt64 = 0 + var receivedContentLength: UInt64 = 0 + var cancellation: (() -> Void)? + var updateChoiceReply: ((SPUUserUpdateChoice) -> Void)? override init() { super.init() - self._controller = SPUStandardUpdaterController(startingUpdater: true, updaterDelegate: self, userDriverDelegate: nil) + 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() + } catch { + print("Failed to start Sparkle updater: \(error)") + } } func checkForUpdates() { - guard controller.updater.canCheckForUpdates else { + guard updater.canCheckForUpdates else { isChecking = false updateStatus = "Update check is already in progress." return } + updater.checkForUpdates() + } - isChecking = true - updateStatus = "Checking for updates..." + func resetState() { + isChecking = false + isDownloading = false + isExtracting = false + isReadyToInstall = false + downloadProgress = 0.0 + extractionProgress = 0.0 + updateStatus = nil foundUpdateItem = nil - controller.checkForUpdates(nil) - } - - func updater(_ updater: SPUUpdater, didFindValidUpdate item: SUAppcastItem) { - DispatchQueue.main.async { - self.isChecking = false - self.foundUpdateItem = item - self.updateStatus = "Update available: Version \(item.displayVersionString)" - } - } - - func updaterDidNotFindUpdate(_ updater: SPUUpdater, error: Error) { - DispatchQueue.main.async { - self.isChecking = false - self.updateStatus = "You're up to date!" - } - } - - func updater(_ updater: SPUUpdater, didAbortWithError error: Error) { - DispatchQueue.main.async { - let nsError = error as NSError - if nsError.domain == "SUSparkleErrorDomain" && nsError.code == 1001 { - // SUNoUpdateError (1001), handled by updaterDidNotFindUpdate - return - } - - self.isChecking = false - self.updateStatus = "Update check failed: \(error.localizedDescription)" - } + 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 diff --git a/Sources/Firelink/Settings/AboutSettingsPane.swift b/Sources/Firelink/Settings/AboutSettingsPane.swift index ae61099..337ea9c 100644 --- a/Sources/Firelink/Settings/AboutSettingsPane.swift +++ b/Sources/Firelink/Settings/AboutSettingsPane.swift @@ -40,41 +40,177 @@ struct AboutSettingsPane: View { } Section("Updates") { - VStack(alignment: .leading, spacing: 12) { - if let status = sparkleUpdater.updateStatus { - HStack { - if sparkleUpdater.isChecking { - ProgressView() - .controlSize(.small) - } else if sparkleUpdater.foundUpdateItem != nil { - Image(systemName: "exclamationmark.circle.fill") - .foregroundStyle(.orange) - } else { - Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) - } - Text(status) - .font(.subheadline) + VStack(alignment: .leading, spacing: 16) { + if sparkleUpdater.isChecking { + HStack(spacing: 12) { + ProgressView() + .controlSize(.small) + Text("Checking for updates...") .foregroundStyle(.secondary) } - } - - HStack(spacing: 12) { - Button { - sparkleUpdater.checkForUpdates() - } label: { - Label("Check for Updates", systemImage: "arrow.clockwise") + } 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) } - .disabled(sparkleUpdater.isChecking) + } 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 { + sparkleUpdater.updateChoiceReply?(.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, !notes.isEmpty { + 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 { + sparkleUpdater.updateChoiceReply?(.install) + } label: { + Text("Download & Install") + } + .buttonStyle(.borderedProminent) + + Button("Skip This Version") { + sparkleUpdater.updateChoiceReply?(.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) + } + } - Button { - NSWorkspace.shared.open(projectURL.appendingPathComponent("releases")) - } label: { - Label("Open Releases", systemImage: "arrow.up.right.square") + 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") + } + } } } } - .padding(.vertical, 4) + .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) } Section { diff --git a/Sources/Firelink/Settings/EngineSettingsPane.swift b/Sources/Firelink/Settings/EngineSettingsPane.swift index 5a4968c..db27d20 100644 --- a/Sources/Firelink/Settings/EngineSettingsPane.swift +++ b/Sources/Firelink/Settings/EngineSettingsPane.swift @@ -15,94 +15,100 @@ struct EngineSettingsPane: View { var body: some View { Form { - Section("Aria2 (HTTP/FTP)") { + Section { LabeledContent("Status") { - Label( - executableURL == nil ? "Missing" : "Ready", - systemImage: executableURL == nil ? "exclamationmark.triangle.fill" : "checkmark.seal.fill" - ) - .foregroundStyle(executableURL == nil ? .orange : .green) + if executableURL != nil { + Label("Ready", systemImage: "checkmark.seal.fill") + .foregroundStyle(.green) + } else { + Label("Missing", systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + } } - - LabeledContent("Binary") { - Text(executableURL?.path ?? "Not found") - .font(.system(.body, design: .monospaced)) - .textSelection(.enabled) - } - + LabeledContent("Version") { Text(version) .font(.system(.body, design: .monospaced)) + .foregroundStyle(.secondary) .textSelection(.enabled) } - + + LabeledContent("Binary Path") { + Text(executableURL?.path ?? "Not found") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.tertiary) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + } + .help(executableURL?.path ?? "") + } header: { + Text("Core Downloader (Aria2)") + } footer: { if executableURL == nil { - Text("Install aria2 with Homebrew or bundle aria2c inside the app resources.") - .font(.caption) - .foregroundStyle(.secondary) + Text("Install aria2 with Homebrew or ensure it is bundled inside the app resources.") + .foregroundStyle(.red) + } else { + Text("Handles core HTTP/FTP and BitTorrent downloads.") } } - Section("Media Engine (yt-dlp & ffmpeg)") { - addonStatusRow(title: "yt-dlp", state: engineManager.ytDlpState) - addonStatusRow(title: "ffmpeg", state: engineManager.ffmpegState) + Section { + LabeledContent("Updates") { + HStack(spacing: 8) { + Button { + checkMediaEngineUpdates() + } label: { + Text("Check for Updates") + } + .disabled(isDownloadingMediaEngines || isCheckingForUpdates) - HStack(spacing: 12) { - Button("Check for Updates") { - Task { - isCheckingForUpdates = true - updateCheckResult = nil - try? await Task.sleep(nanoseconds: 800_000_000) - - do { - try await engineManager.ensureInstalled() - updateCheckResult = "Up to date." - } catch { - updateCheckResult = "Update failed." - } - - isCheckingForUpdates = false - try? await Task.sleep(nanoseconds: 3_000_000_000) - if !isCheckingForUpdates { - updateCheckResult = nil + if isCheckingForUpdates { + ProgressView().controlSize(.small) + Text("Checking...") + .foregroundStyle(.secondary) + .font(.subheadline) + } else if let result = updateCheckResult { + if result == "Up to date" || result == "Updated successfully" { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + Text(result) + .foregroundStyle(.secondary) + .font(.subheadline) + } else { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + Text(result) + .foregroundStyle(.red) + .font(.subheadline) } } } - .disabled(isDownloadingMediaEngines || isCheckingForUpdates) - - if isCheckingForUpdates { - ProgressView().controlSize(.small) - Text("Checking...") - .foregroundStyle(.secondary) - .font(.subheadline) - } else if let result = updateCheckResult { - Text(result) - .foregroundStyle(.secondary) - .font(.subheadline) - } - - Spacer() } - Picker("Browser Cookies", selection: $settings.mediaCookieSource) { - ForEach(BrowserCookieSource.allCases, id: \.self) { source in - Text(source.rawValue).tag(source) + addonStatusRow(title: "yt-dlp", state: engineManager.ytDlpState) + + LabeledContent("Browser Cookies") { + Picker("", selection: $settings.mediaCookieSource) { + ForEach(BrowserCookieSource.allCases, id: \.self) { source in + Text(source.rawValue).tag(source) + } + } + .labelsHidden() + .frame(maxWidth: 200) + } + + addonStatusRow(title: "FFmpeg", state: engineManager.ffmpegState) + } header: { + Text("Media Extractors") + } footer: { + VStack(alignment: .leading, spacing: 8) { + Text("Powers video and audio extraction from supported sites.") + + if settings.mediaCookieSource != .none { + Text(settings.mediaCookieSource.statusDetail) } } - - LabeledContent("Cookie Status") { - if settings.mediaCookieSource == .none { - Label(settings.mediaCookieSource.statusTitle, systemImage: "circle") - .foregroundStyle(.secondary) - } else { - Label(settings.mediaCookieSource.statusTitle, systemImage: "checkmark.circle.fill") - .foregroundStyle(.green) - } - } - - Text(settings.mediaCookieSource.statusDetail) - .font(.caption) - .foregroundStyle(.secondary) } } .formStyle(.grouped) @@ -111,6 +117,35 @@ struct EngineSettingsPane: View { } } + private func checkMediaEngineUpdates() { + Task { + isCheckingForUpdates = true + updateCheckResult = nil + // Brief visual feedback delay + try? await Task.sleep(nanoseconds: 800_000_000) + + do { + let wasDownloading = isDownloadingMediaEngines + try await engineManager.ensureInstalled() + if wasDownloading || isDownloadingMediaEngines { + updateCheckResult = "Updated successfully" + } else { + updateCheckResult = "Up to date" + } + } catch { + updateCheckResult = "Update failed: \(error.localizedDescription)" + } + + isCheckingForUpdates = false + try? await Task.sleep(nanoseconds: 4_000_000_000) + if !isCheckingForUpdates { + withAnimation { + updateCheckResult = nil + } + } + } + } + private var isDownloadingMediaEngines: Bool { if case .downloading = engineManager.ytDlpState { return true } if case .downloading = engineManager.ffmpegState { return true } @@ -136,8 +171,9 @@ struct EngineSettingsPane: View { .foregroundStyle(.green) .font(.system(.body, design: .monospaced)) case .failed(let error): - Label("Failed: \(error)", systemImage: "exclamationmark.triangle.fill") + Label("Failed", systemImage: "exclamationmark.triangle.fill") .foregroundStyle(.red) + .help(error) } } } diff --git a/Sources/Firelink/Settings/InlineUpdateUserDriver.swift b/Sources/Firelink/Settings/InlineUpdateUserDriver.swift new file mode 100644 index 0000000..3f7e31a --- /dev/null +++ b/Sources/Firelink/Settings/InlineUpdateUserDriver.swift @@ -0,0 +1,148 @@ +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 { + if let htmlString = String(data: downloadData.data, encoding: .utf8) { + self.updater?.releaseNotes = self.stripHTML(htmlString) + } + } + } + + private func stripHTML(_ string: String) -> String { + guard let data = string.data(using: .utf8) else { return string } + if let attributedString = try? NSAttributedString(data: data, options: [.documentType: NSAttributedString.DocumentType.html, .characterEncoding: String.Encoding.utf8.rawValue], documentAttributes: nil) { + return attributedString.string.trimmingCharacters(in: .whitespacesAndNewlines) + } + return string + } + + 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() { + } +}