8451db1142
- Replace root VStack with NavigationSplitView (2-column, collapsible sidebar) - Add SidebarView: new chat button, conversation search, list with swipe actions - Slim HeaderView to text-only (provider + model + star); remove all icon rows - Move status pills (Online, MCP, Synced) to footer right side - Remove version number and shortcut hints from footer - Add resizable InputBar with drag handle (persisted height) and globe/network.slash online toggle - Fix Norwegian menu appearing on English systems (CFBundleLocalizations in Info.plist) - Add View menu (Model Info, History, Stats, Credits, Online Mode toggle ⌘⇧O) - Add ⌘L as alias for Search Conversations (muscle memory for /load users) - Add Check for Updates to Help menu with download URL from Gitea API - Add one-time Intel/Rosetta deprecation warning on first launch - Swift 6: fix self.Self.isoString() call sites in DatabaseService Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
131 lines
4.7 KiB
Swift
131 lines
4.7 KiB
Swift
//
|
|
// 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 <https://www.gnu.org/licenses/>.
|
|
|
|
|
|
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
|
|
}
|
|
}
|