From 4acf538d8f64290dd66756015da3ba60573986de Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Fri, 20 Feb 2026 09:55:30 +0100 Subject: [PATCH] Added install instructions, and version check --- README.md | 39 +++++++++-- oAI/Services/UpdateCheckService.swift | 93 +++++++++++++++++++++++++++ oAI/Views/Main/FooterView.swift | 31 +++++++++ oAI/oAIApp.swift | 3 + 4 files changed, 162 insertions(+), 4 deletions(-) create mode 100644 oAI/Services/UpdateCheckService.swift diff --git a/README.md b/README.md index d6a8c32..2e343b5 100644 --- a/README.md +++ b/README.md @@ -71,12 +71,43 @@ Automated email responses powered by AI: ## Installation -Not available. +### Download + +Download the latest release from the [Releases page](https://gitlab.pm/rune/oai-swift/releases). Two builds are available: + +- **oAI-x.x.x-AppleSilicon.dmg** — for Macs with an Apple Silicon chip (M1 and later) +- **oAI-x.x.x-Universal.dmg** — runs natively on both Apple Silicon and Intel Macs + +### Installing from DMG + +1. Open the downloaded `.dmg` file +2. Drag **oAI.app** into the **Applications** folder +3. Eject the DMG +4. Launch oAI from Applications or Spotlight + +### First Launch — Gatekeeper Warning + +oAI is **signed by the developer** but has **not yet been notarized by Apple**. Notarization is Apple's automated malware scan — the app itself is safe, but macOS Gatekeeper may block it on first launch with a message saying the app "cannot be opened because the developer cannot be verified." + +To open the app, you have two options: + +**Option A — Right-click to open (quickest):** +1. Right-click (or Control-click) `oAI.app` in Applications +2. Select **Open** from the context menu +3. Click **Open** in the dialog that appears +4. After doing this once, the app opens normally from then on + +**Option B — Remove the quarantine flag via Terminal:** + +```bash +xattr -dr com.apple.quarantine /Applications/oAI.app +``` + +This command removes the quarantine attribute that macOS attaches to files downloaded from the internet. The `-d` flag deletes the attribute, `-r` applies it recursively to the app bundle. Once removed, macOS no longer blocks the app from launching. ### Requirements -- macOS 14.0+ -- Xcode 15.0+ -- Swift 5.9+ +- macOS 14.0 (Sonoma) or later +- An API key for at least one supported provider (OpenRouter, Anthropic, OpenAI, or Google), or Ollama running locally ## Configuration diff --git a/oAI/Services/UpdateCheckService.swift b/oAI/Services/UpdateCheckService.swift new file mode 100644 index 0000000..d47fe3c --- /dev/null +++ b/oAI/Services/UpdateCheckService.swift @@ -0,0 +1,93 @@ +// +// 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 + } +} diff --git a/oAI/Views/Main/FooterView.swift b/oAI/Views/Main/FooterView.swift index c572787..f72df8e 100644 --- a/oAI/Views/Main/FooterView.swift +++ b/oAI/Views/Main/FooterView.swift @@ -80,6 +80,11 @@ struct FooterView: View { ) } + // Update available badge + #if os(macOS) + UpdateBadge() + #endif + // Shortcuts hint #if os(macOS) Text("⌘N New • ⌘M Model • ⌘⇧S Save") @@ -254,6 +259,32 @@ struct SyncStatusFooter: View { } } +struct UpdateBadge: View { + private let updater = UpdateCheckService.shared + private let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?" + + var body: some View { + if updater.updateAvailable { + Button(action: { updater.openReleasesPage() }) { + HStack(spacing: 4) { + Image(systemName: "arrow.up.circle.fill") + .font(.system(size: 10)) + Text("Update Available\(updater.latestVersion.map { " (v\($0))" } ?? "")") + .font(.caption2) + .fontWeight(.medium) + } + .foregroundColor(.green) + } + .buttonStyle(.plain) + .help("A new version is available — click to open the releases page") + } else { + Text("v\(currentVersion)") + .font(.caption2) + .foregroundColor(.oaiSecondary) + } + } +} + #Preview { let stats = SessionStats( totalInputTokens: 1250, diff --git a/oAI/oAIApp.swift b/oAI/oAIApp.swift index 6df37d8..ee34216 100644 --- a/oAI/oAIApp.swift +++ b/oAI/oAIApp.swift @@ -41,6 +41,9 @@ struct oAIApp: App { Task { await GitSyncService.shared.syncOnStartup() } + + // Check for updates in the background + UpdateCheckService.shared.checkForUpdates() } var body: some Scene {