mirror of
https://github.com/nimbold/Firelink.git
synced 2026-08-21 16:36:50 +00:00
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:
@@ -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
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user