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 {