New "Read Release Notes" entries in the Help menu (current installed version) and the "Check for Updates" alert (the new, not-yet-installed version) render a release's markdown notes in a Confab modal. - UpdateCheckService.fetchReleaseNotes(forTag:) fetches a release's title + body from Gitea's public releases-by-tag API, caching the result by version tag in the settings table (a published release's notes don't change, so no need to refetch on every view). - ReleaseNotesView reuses the existing MarkdownContentView renderer; shows a friendly "not available yet" state for versions with no published Gitea release (e.g. a dev build ahead of the last release). - ReleaseNotesRequest carries which version to show atomically via .sheet(item:), per this project's established sheet-timing pattern. - Removed the redundant "Release Page" button from the update alert now that notes show in-app; added an explicit .keyboardShortcut (.cancelAction) to its cancel button so Escape actually closes it — role: .cancel alone didn't do it, since NSAlert only auto-binds Escape to a button literally titled "Cancel".
197 lines
7.7 KiB
Swift
197 lines
7.7 KiB
Swift
//
|
|
// UpdateCheckService.swift
|
|
// Confab
|
|
//
|
|
// Checks for new releases on GitLab and surfaces an update badge in the footer
|
|
//
|
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
|
// Copyright (C) 2026 Rune Olsen
|
|
//
|
|
// This file is part of Confab.
|
|
//
|
|
// Confab is licensed under the PolyForm Noncommercial License 1.0.0.
|
|
// You may use, study, modify, and share it for any noncommercial
|
|
// purpose. Commercial use — including selling Confab or any part of
|
|
// it, standalone or bundled into another product or service —
|
|
// requires a separate commercial license from the copyright holder.
|
|
//
|
|
// See the LICENSE file or
|
|
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
|
|
// the full license text. For commercial licensing, contact Rune
|
|
// Olsen via <https://confab.no>.
|
|
|
|
|
|
import Foundation
|
|
#if os(macOS)
|
|
import AppKit
|
|
#endif
|
|
import Observation
|
|
|
|
/// A single Gitea release's display name + markdown body, for in-app viewing (Help menu, and the
|
|
/// "update available" alert) instead of opening the web releases page.
|
|
struct ReleaseNotes: Sendable, Equatable {
|
|
let versionTag: String
|
|
let title: String
|
|
let body: String
|
|
}
|
|
|
|
enum ReleaseNotesError: Error, Sendable {
|
|
/// No published release exists for this tag — e.g. a development build ahead of the last
|
|
/// published version.
|
|
case notFound
|
|
case networkError
|
|
}
|
|
|
|
@Observable
|
|
final class UpdateCheckService {
|
|
static let shared = UpdateCheckService()
|
|
|
|
var updateAvailable: Bool = false
|
|
var latestVersion: String? = nil
|
|
var downloadURL: URL? = nil
|
|
|
|
// Manual check state — drives the update alert in ContentView
|
|
var isCheckingManually: Bool = false
|
|
var manualCheckMessage: String? = nil
|
|
|
|
private let apiURL = "https://gitlab.pm/api/v1/repos/rune/oai-swift/releases/latest"
|
|
private let releasesURL = URL(string: "https://gitlab.pm/rune/oai-swift/releases")!
|
|
private let releasesByTagBaseURL = "https://gitlab.pm/api/v1/repos/rune/oai-swift/releases/tags/"
|
|
|
|
private init() {}
|
|
|
|
/// Kick off a background update check. Silently does nothing on failure.
|
|
func checkForUpdates() {
|
|
Task.detached(priority: .background) {
|
|
await self.performCheck()
|
|
}
|
|
}
|
|
|
|
/// Manual check triggered from the Help menu. Non-blocking — result surfaces via manualCheckMessage.
|
|
func checkForUpdatesManually() {
|
|
guard !isCheckingManually else { return }
|
|
isCheckingManually = true
|
|
Task.detached(priority: .background) {
|
|
await self.performCheck()
|
|
let current = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
|
|
await MainActor.run {
|
|
if self.updateAvailable, let v = self.latestVersion {
|
|
self.manualCheckMessage = String(localized: "Version \(v) is available.")
|
|
} else {
|
|
self.manualCheckMessage = String(localized: "You're up to date (v\(current)).")
|
|
}
|
|
self.isCheckingManually = false
|
|
}
|
|
}
|
|
}
|
|
|
|
private func performCheck() async {
|
|
guard let url = URL(string: apiURL) else { return }
|
|
|
|
var request = URLRequest(url: url)
|
|
request.timeoutInterval = 10
|
|
|
|
guard let (data, _) = try? await URLSession.shared.data(for: request) else {
|
|
Log.ui.warning("UpdateCheck: network request failed")
|
|
return
|
|
}
|
|
|
|
guard let release = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let tagName = release["tag_name"] as? String else {
|
|
Log.ui.warning("UpdateCheck: unexpected API response — \(String(data: data, encoding: .utf8) ?? "<binary>")")
|
|
return
|
|
}
|
|
|
|
// Strip leading "v" from tag (e.g. "v2.3.1" → "2.3.1")
|
|
let latestVer = tagName.hasPrefix("v") ? String(tagName.dropFirst()) : tagName
|
|
let currentVer = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "0.0.0"
|
|
|
|
// Extract direct DMG download URL from release assets
|
|
let dmgURL: URL? = (release["assets"] as? [[String: Any]])?
|
|
.first { ($0["name"] as? String ?? "").lowercased().hasSuffix(".dmg") }
|
|
.flatMap { $0["browser_download_url"] as? String }
|
|
.flatMap { URL(string: $0) }
|
|
|
|
if isNewer(latestVer, than: currentVer) {
|
|
await MainActor.run {
|
|
self.latestVersion = latestVer
|
|
self.downloadURL = dmgURL
|
|
self.updateAvailable = true
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Semantic version comparison — returns true if `version` is newer than `current`.
|
|
private func isNewer(_ version: String, than current: String) -> Bool {
|
|
let lhs = version.split(separator: ".").compactMap { Int($0) }
|
|
let rhs = current.split(separator: ".").compactMap { Int($0) }
|
|
let count = max(lhs.count, rhs.count)
|
|
for i in 0..<count {
|
|
let l = i < lhs.count ? lhs[i] : 0
|
|
let r = i < rhs.count ? rhs[i] : 0
|
|
if l > r { return true }
|
|
if l < r { return false }
|
|
}
|
|
return false
|
|
}
|
|
|
|
/// Open the GitLab releases page in the default browser.
|
|
func openReleasesPage() {
|
|
#if os(macOS)
|
|
NSWorkspace.shared.open(releasesURL)
|
|
#endif
|
|
}
|
|
|
|
// MARK: - Release Notes
|
|
|
|
private static let releaseNotesCacheKeyPrefix = "releaseNotesCache_"
|
|
|
|
/// Fetches a specific release's title + markdown body by git tag (e.g. "v2.5.0"), for showing
|
|
/// in-app. Checked against a small on-disk cache first — a published release's notes don't
|
|
/// change after the fact, so there's no need to hit the network every time the same version's
|
|
/// notes are viewed again.
|
|
func fetchReleaseNotes(forTag tag: String) async -> Result<ReleaseNotes, ReleaseNotesError> {
|
|
if let cached = Self.cachedReleaseNotes(forTag: tag) {
|
|
return .success(cached)
|
|
}
|
|
|
|
guard let url = URL(string: releasesByTagBaseURL + tag) else {
|
|
return .failure(.networkError)
|
|
}
|
|
var request = URLRequest(url: url)
|
|
request.timeoutInterval = 10
|
|
|
|
guard let (data, response) = try? await URLSession.shared.data(for: request) else {
|
|
return .failure(.networkError)
|
|
}
|
|
if let http = response as? HTTPURLResponse, http.statusCode == 404 {
|
|
return .failure(.notFound)
|
|
}
|
|
guard let release = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
|
let tagName = release["tag_name"] as? String,
|
|
let body = release["body"] as? String else {
|
|
return .failure(.notFound)
|
|
}
|
|
|
|
let notes = ReleaseNotes(versionTag: tagName, title: release["name"] as? String ?? tagName, body: body)
|
|
Self.cacheReleaseNotes(notes)
|
|
return .success(notes)
|
|
}
|
|
|
|
private static func cachedReleaseNotes(forTag tag: String) -> ReleaseNotes? {
|
|
guard let json = DatabaseService.shared.getSetting(key: releaseNotesCacheKeyPrefix + tag),
|
|
let data = json.data(using: .utf8),
|
|
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: String],
|
|
let versionTag = obj["versionTag"], let title = obj["title"], let body = obj["body"]
|
|
else { return nil }
|
|
return ReleaseNotes(versionTag: versionTag, title: title, body: body)
|
|
}
|
|
|
|
private static func cacheReleaseNotes(_ notes: ReleaseNotes) {
|
|
guard let data = try? JSONSerialization.data(withJSONObject: [
|
|
"versionTag": notes.versionTag, "title": notes.title, "body": notes.body,
|
|
]), let json = String(data: data, encoding: .utf8) else { return }
|
|
DatabaseService.shared.setSetting(key: releaseNotesCacheKeyPrefix + notes.versionTag, value: json)
|
|
}
|
|
}
|