feat(updater): upgrade sparkle to 2.9.3 and enhance integration

- Update Sparkle dependency to 2.9.3

- Replace brittle HTML parsing with native NSAttributedString parsing

- Fix dangling updater callbacks by handling view disappear events

- Add 'Remind Me Later' button in update prompt

- Add toggle for automatic background update checks
This commit is contained in:
nimbold
2026-06-08 21:17:44 +03:30
parent 9261385d59
commit 6b2901bd50
5 changed files with 50 additions and 53 deletions
+3 -3
View File
@@ -1,13 +1,13 @@
{
"originHash" : "048cca0a42e966dd91de6a4753f25d908574338fda8bf9b8bcae473cf159ebf4",
"originHash" : "c1cb50a392a5949f6fb77fb4800ef2ea6c811268af19d41dd83b3be29f0321a8",
"pins" : [
{
"identity" : "sparkle",
"kind" : "remoteSourceControl",
"location" : "https://github.com/sparkle-project/Sparkle",
"state" : {
"revision" : "6276ba2b404829d139c45ff98427cf90e2efc59b",
"version" : "2.9.2"
"revision" : "d46d456107feacc80711b21847b82b07bd9fb46e",
"version" : "2.9.3"
}
}
],
+1 -1
View File
@@ -11,7 +11,7 @@ let package = Package(
.executable(name: "Firelink", targets: ["Firelink"])
],
dependencies: [
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.6.4")
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.9.3")
],
targets: [
.executableTarget(
+7 -1
View File
@@ -14,7 +14,12 @@ final class SparkleUpdater: NSObject, ObservableObject, SPUUpdaterDelegate {
@Published var updateStatus: String?
@Published var foundUpdateItem: SUAppcastItem?
@Published var releaseNotes: String?
@Published var releaseNotes: AttributedString?
@Published var automaticallyChecksForUpdates: Bool = true {
didSet {
_updater?.automaticallyChecksForUpdates = automaticallyChecksForUpdates
}
}
var expectedContentLength: UInt64 = 0
var receivedContentLength: UInt64 = 0
@@ -28,6 +33,7 @@ final class SparkleUpdater: NSObject, ObservableObject, SPUUpdaterDelegate {
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)")
}
@@ -85,7 +85,9 @@ struct AboutSettingsPane: View {
}
Button {
sparkleUpdater.updateChoiceReply?(.install)
let reply = sparkleUpdater.updateChoiceReply
sparkleUpdater.updateChoiceReply = nil
reply?(.install)
} label: {
Label("Install and Relaunch", systemImage: "sparkles")
.frame(maxWidth: .infinity)
@@ -108,7 +110,7 @@ struct AboutSettingsPane: View {
}
}
if let notes = sparkleUpdater.releaseNotes, !notes.isEmpty {
if let notes = sparkleUpdater.releaseNotes {
DisclosureGroup("What's New") {
ScrollView {
Text(notes)
@@ -125,14 +127,24 @@ struct AboutSettingsPane: View {
HStack(spacing: 12) {
Button {
sparkleUpdater.updateChoiceReply?(.install)
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") {
sparkleUpdater.updateChoiceReply?(.skip)
let reply = sparkleUpdater.updateChoiceReply
sparkleUpdater.updateChoiceReply = nil
reply?(.skip)
}
}
}
@@ -204,6 +216,11 @@ struct AboutSettingsPane: View {
}
}
}
Divider()
.padding(.vertical, 4)
Toggle("Automatically check for updates", isOn: $sparkleUpdater.automaticallyChecksForUpdates)
}
.padding(.vertical, 8)
.animation(.easeInOut, value: sparkleUpdater.isChecking)
@@ -250,5 +267,11 @@ struct AboutSettingsPane: View {
}
}
.formStyle(.grouped)
.onDisappear {
if let reply = sparkleUpdater.updateChoiceReply {
sparkleUpdater.updateChoiceReply = nil
reply(.dismiss)
}
}
}
}
@@ -32,55 +32,23 @@ class InlineUpdateUserDriver: NSObject, SPUUserDriver {
}
func showUpdateReleaseNotes(with downloadData: SPUDownloadData) {
DispatchQueue.global(qos: .userInitiated).async {
if let htmlString = String(data: downloadData.data, encoding: .utf8) {
let parsedText = self.fastHTMLToMarkdown(htmlString)
DispatchQueue.main.async {
self.updater?.releaseNotes = parsedText
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
}
}
}
}
nonisolated private func fastHTMLToMarkdown(_ html: String) -> String {
var text = html
text = text.replacingOccurrences(of: "<br>", with: "\n", options: .caseInsensitive)
text = text.replacingOccurrences(of: "<br/>", with: "\n", options: .caseInsensitive)
text = text.replacingOccurrences(of: "<br />", with: "\n", options: .caseInsensitive)
text = text.replacingOccurrences(of: "</p>", with: "\n\n", options: .caseInsensitive)
text = text.replacingOccurrences(of: "<li>", with: "- ", options: .caseInsensitive)
text = text.replacingOccurrences(of: "</li>", with: "\n", options: .caseInsensitive)
text = text.replacingOccurrences(of: "<h1>", with: "# ", options: .caseInsensitive)
text = text.replacingOccurrences(of: "</h1>", with: "\n\n", options: .caseInsensitive)
text = text.replacingOccurrences(of: "<h2>", with: "## ", options: .caseInsensitive)
text = text.replacingOccurrences(of: "</h2>", with: "\n\n", options: .caseInsensitive)
text = text.replacingOccurrences(of: "<h3>", with: "### ", options: .caseInsensitive)
text = text.replacingOccurrences(of: "</h3>", with: "\n\n", options: .caseInsensitive)
text = text.replacingOccurrences(of: "<b>", with: "**", options: .caseInsensitive)
text = text.replacingOccurrences(of: "</b>", with: "**", options: .caseInsensitive)
text = text.replacingOccurrences(of: "<strong>", with: "**", options: .caseInsensitive)
text = text.replacingOccurrences(of: "</strong>", with: "**", options: .caseInsensitive)
if let regex = try? NSRegularExpression(pattern: "<[^>]+>", options: .caseInsensitive) {
let range = NSRange(location: 0, length: text.utf16.count)
text = regex.stringByReplacingMatches(in: text, options: [], range: range, withTemplate: "")
}
text = text.replacingOccurrences(of: "&nbsp;", with: " ")
text = text.replacingOccurrences(of: "&amp;", with: "&")
text = text.replacingOccurrences(of: "&lt;", with: "<")
text = text.replacingOccurrences(of: "&gt;", with: ">")
text = text.replacingOccurrences(of: "&quot;", with: "\"")
text = text.replacingOccurrences(of: "&#39;", with: "'")
if let regex = try? NSRegularExpression(pattern: "\\n{3,}", options: []) {
let range = NSRange(location: 0, length: text.utf16.count)
text = regex.stringByReplacingMatches(in: text, options: [], range: range, withTemplate: "\n\n")
}
return text.trimmingCharacters(in: .whitespacesAndNewlines)
}
func showUpdateReleaseNotesFailedToDownloadWithError(_ error: Error) {
}