Files
oai-swift/oAI/Services/UpdateCheckService.swift
T
runeandClaude Sonnet 5 0af644456e Update commercial licensing contact URL to oai.pm
Consolidates the mac.oai.pm subdomain references (introduced in the
PolyForm Noncommercial relicense) to the root oai.pm domain, across
the LICENSE file, README, and all Swift source file headers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 11:34:04 +02:00

129 lines
4.6 KiB
Swift

//
// UpdateCheckService.swift
// oAI
//
// 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 oAI.
//
// oAI 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 oAI 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://oai.pm>.
import Foundation
#if os(macOS)
import AppKit
#endif
import Observation
@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 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
}
}