// // 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 // for // the full license text. For commercial licensing, contact Rune // Olsen via . 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) ?? "")") 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.. 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 } }