Files
oai-swift/oAI/Services/UpdateCheckService.swift
T
rune 32e6ce3c37 Show release notes in-app instead of opening the web releases page
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".
2026-08-03 13:13:36 +02:00

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)
}
}