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 import Sparkle
final class SparkleUpdater: NSObject, ObservableObject, SPUUpdaterDelegate { final class SparkleUpdater: NSObject, ObservableObject, SPUUpdaterDelegate {
private var _controller: SPUStandardUpdaterController? private var _updater: SPUUpdater?
var controller: SPUStandardUpdaterController { _controller! } var updater: SPUUpdater { _updater! }
@Published var isChecking = false @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 updateStatus: String?
@Published var foundUpdateItem: SUAppcastItem? @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() { override init() {
super.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() { func checkForUpdates() {
guard controller.updater.canCheckForUpdates else { guard updater.canCheckForUpdates else {
isChecking = false isChecking = false
updateStatus = "Update check is already in progress." updateStatus = "Update check is already in progress."
return return
} }
updater.checkForUpdates()
}
isChecking = true func resetState() {
updateStatus = "Checking for updates..." isChecking = false
isDownloading = false
isExtracting = false
isReadyToInstall = false
downloadProgress = 0.0
extractionProgress = 0.0
updateStatus = nil
foundUpdateItem = nil foundUpdateItem = nil
controller.checkForUpdates(nil) releaseNotes = nil
} expectedContentLength = 0
receivedContentLength = 0
func updater(_ updater: SPUUpdater, didFindValidUpdate item: SUAppcastItem) { cancellation = nil
DispatchQueue.main.async { updateChoiceReply = nil
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)"
}
} }
// 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?) { func updater(_ updater: SPUUpdater, didFinishUpdateCycleFor updateCheck: SPUUpdateCheck, error: Error?) {
DispatchQueue.main.async { DispatchQueue.main.async {
self.isChecking = false self.isChecking = false
+164 -28
View File
@@ -40,41 +40,177 @@ struct AboutSettingsPane: View {
} }
Section("Updates") { Section("Updates") {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 16) {
if let status = sparkleUpdater.updateStatus { if sparkleUpdater.isChecking {
HStack { HStack(spacing: 12) {
if sparkleUpdater.isChecking { ProgressView()
ProgressView() .controlSize(.small)
.controlSize(.small) Text("Checking for updates...")
} else if sparkleUpdater.foundUpdateItem != nil {
Image(systemName: "exclamationmark.circle.fill")
.foregroundStyle(.orange)
} else {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
}
Text(status)
.font(.subheadline)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} } else if sparkleUpdater.isDownloading || sparkleUpdater.isExtracting {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 12) { HStack {
Button { Text(sparkleUpdater.isDownloading ? "Downloading update..." : "Extracting update...")
sparkleUpdater.checkForUpdates() .font(.subheadline)
} label: { .fontWeight(.medium)
Label("Check for Updates", systemImage: "arrow.clockwise") 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 { HStack(spacing: 12) {
NSWorkspace.shared.open(projectURL.appendingPathComponent("releases")) Button {
} label: { sparkleUpdater.checkForUpdates()
Label("Open Releases", systemImage: "arrow.up.right.square") } 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 { Section {
@@ -15,94 +15,100 @@ struct EngineSettingsPane: View {
var body: some View { var body: some View {
Form { Form {
Section("Aria2 (HTTP/FTP)") { Section {
LabeledContent("Status") { LabeledContent("Status") {
Label( if executableURL != nil {
executableURL == nil ? "Missing" : "Ready", Label("Ready", systemImage: "checkmark.seal.fill")
systemImage: executableURL == nil ? "exclamationmark.triangle.fill" : "checkmark.seal.fill" .foregroundStyle(.green)
) } else {
.foregroundStyle(executableURL == nil ? .orange : .green) Label("Missing", systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
}
} }
LabeledContent("Binary") {
Text(executableURL?.path ?? "Not found")
.font(.system(.body, design: .monospaced))
.textSelection(.enabled)
}
LabeledContent("Version") { LabeledContent("Version") {
Text(version) Text(version)
.font(.system(.body, design: .monospaced)) .font(.system(.body, design: .monospaced))
.foregroundStyle(.secondary)
.textSelection(.enabled) .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 { if executableURL == nil {
Text("Install aria2 with Homebrew or bundle aria2c inside the app resources.") Text("Install aria2 with Homebrew or ensure it is bundled inside the app resources.")
.font(.caption) .foregroundStyle(.red)
.foregroundStyle(.secondary) } else {
Text("Handles core HTTP/FTP and BitTorrent downloads.")
} }
} }
Section("Media Engine (yt-dlp & ffmpeg)") { Section {
addonStatusRow(title: "yt-dlp", state: engineManager.ytDlpState) LabeledContent("Updates") {
addonStatusRow(title: "ffmpeg", state: engineManager.ffmpegState) HStack(spacing: 8) {
Button {
checkMediaEngineUpdates()
} label: {
Text("Check for Updates")
}
.disabled(isDownloadingMediaEngines || isCheckingForUpdates)
HStack(spacing: 12) { if isCheckingForUpdates {
Button("Check for Updates") { ProgressView().controlSize(.small)
Task { Text("Checking...")
isCheckingForUpdates = true .foregroundStyle(.secondary)
updateCheckResult = nil .font(.subheadline)
try? await Task.sleep(nanoseconds: 800_000_000) } else if let result = updateCheckResult {
if result == "Up to date" || result == "Updated successfully" {
do { Image(systemName: "checkmark.circle.fill")
try await engineManager.ensureInstalled() .foregroundStyle(.green)
updateCheckResult = "Up to date." Text(result)
} catch { .foregroundStyle(.secondary)
updateCheckResult = "Update failed." .font(.subheadline)
} } else {
Image(systemName: "exclamationmark.triangle.fill")
isCheckingForUpdates = false .foregroundStyle(.red)
try? await Task.sleep(nanoseconds: 3_000_000_000) Text(result)
if !isCheckingForUpdates { .foregroundStyle(.red)
updateCheckResult = nil .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) { addonStatusRow(title: "yt-dlp", state: engineManager.ytDlpState)
ForEach(BrowserCookieSource.allCases, id: \.self) { source in
Text(source.rawValue).tag(source) 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) .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 { private var isDownloadingMediaEngines: Bool {
if case .downloading = engineManager.ytDlpState { return true } if case .downloading = engineManager.ytDlpState { return true }
if case .downloading = engineManager.ffmpegState { return true } if case .downloading = engineManager.ffmpegState { return true }
@@ -136,8 +171,9 @@ struct EngineSettingsPane: View {
.foregroundStyle(.green) .foregroundStyle(.green)
.font(.system(.body, design: .monospaced)) .font(.system(.body, design: .monospaced))
case .failed(let error): case .failed(let error):
Label("Failed: \(error)", systemImage: "exclamationmark.triangle.fill") Label("Failed", systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(.red) .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() {
}
}