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.
This commit is contained in:
nimbold
2026-06-08 02:18:56 +03:30
parent 4e48f1e42c
commit 410059e3de
4 changed files with 457 additions and 133 deletions
+39 -35
View File
@@ -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
+164 -28
View File
@@ -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 {
@@ -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)
}
}
}
@@ -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() {
}
}