Added install instructions, and version check
This commit is contained in:
39
README.md
39
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
|
||||
|
||||
|
||||
93
oAI/Services/UpdateCheckService.swift
Normal file
93
oAI/Services/UpdateCheckService.swift
Normal file
@@ -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 <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
|
||||
|
||||
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..<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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user