// // UpdateCheckService.swift // oAI // // Checks for new releases on GitLab and surfaces an update badge in the footer // // SPDX-License-Identifier: AGPL-3.0-or-later // Copyright (C) 2026 Rune Olsen // // This file is part of oAI. // // oAI is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // oAI is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General // Public License for more details. // // You should have received a copy of the GNU Affero General Public // License along with oAI. If not, see . 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 private let apiURL = "https://gitlab.pm/api/v4/projects/rune%2Foai-swift/releases" 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 { await performCheck() } } @MainActor 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 { return } guard let releases = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]], let latest = releases.first, let tagName = latest["tag_name"] as? String else { 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" if isNewer(latestVer, than: currentVer) { self.latestVersion = latestVer 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 } }