2.5.1 #11

Merged
rune merged 10 commits from 2.5.1 into main 2026-08-04 14:12:17 +02:00
7 changed files with 219 additions and 1 deletions
Showing only changes of commit 32e6ce3c37 - Show all commits
+34
View File
@@ -0,0 +1,34 @@
//
// ReleaseNotesRequest.swift
// Confab
//
// Sheet-presentation payload for ReleaseNotesView
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
//
// This file is part of Confab.
//
// Confab is licensed under the PolyForm Noncommercial License 1.0.0.
// You may use, study, modify, and share it for any noncommercial
// purpose. Commercial use including selling Confab or any part of
// it, standalone or bundled into another product or service
// requires a separate commercial license from the copyright holder.
//
// See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune
// Olsen via <https://confab.no>.
import Foundation
/// Carries which version's release notes to show atomically to `.sheet(item:)` avoids the
/// two-sequential-@State-mutations race documented for sheets that show existing data.
struct ReleaseNotesRequest: Identifiable {
let id = UUID()
/// Git tag form, e.g. "v2.5.0".
let versionTag: String
/// true: opened from the Help menu for the currently-installed version.
/// false: opened from the "update available" alert for a not-yet-installed version.
let isCurrentlyInstalled: Bool
}
+6
View File
@@ -404,6 +404,12 @@ final class DatabaseService: Sendable {
}
}
nonisolated func getSetting(key: String) -> String? {
try? dbQueue.read { db in
try SettingRecord.fetchOne(db, key: key)?.value
}
}
nonisolated func setSetting(key: String, value: String) {
try? dbQueue.write { db in
let record = SettingRecord(key: key, value: value)
+68
View File
@@ -27,6 +27,21 @@ import AppKit
#endif
import Observation
/// A single Gitea release's display name + markdown body, for in-app viewing (Help menu, and the
/// "update available" alert) instead of opening the web releases page.
struct ReleaseNotes: Sendable, Equatable {
let versionTag: String
let title: String
let body: String
}
enum ReleaseNotesError: Error, Sendable {
/// No published release exists for this tag e.g. a development build ahead of the last
/// published version.
case notFound
case networkError
}
@Observable
final class UpdateCheckService {
static let shared = UpdateCheckService()
@@ -41,6 +56,7 @@ final class UpdateCheckService {
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 let releasesByTagBaseURL = "https://gitlab.pm/api/v1/repos/rune/oai-swift/releases/tags/"
private init() {}
@@ -125,4 +141,56 @@ final class UpdateCheckService {
NSWorkspace.shared.open(releasesURL)
#endif
}
// MARK: - Release Notes
private static let releaseNotesCacheKeyPrefix = "releaseNotesCache_"
/// Fetches a specific release's title + markdown body by git tag (e.g. "v2.5.0"), for showing
/// in-app. Checked against a small on-disk cache first a published release's notes don't
/// change after the fact, so there's no need to hit the network every time the same version's
/// notes are viewed again.
func fetchReleaseNotes(forTag tag: String) async -> Result<ReleaseNotes, ReleaseNotesError> {
if let cached = Self.cachedReleaseNotes(forTag: tag) {
return .success(cached)
}
guard let url = URL(string: releasesByTagBaseURL + tag) else {
return .failure(.networkError)
}
var request = URLRequest(url: url)
request.timeoutInterval = 10
guard let (data, response) = try? await URLSession.shared.data(for: request) else {
return .failure(.networkError)
}
if let http = response as? HTTPURLResponse, http.statusCode == 404 {
return .failure(.notFound)
}
guard let release = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let tagName = release["tag_name"] as? String,
let body = release["body"] as? String else {
return .failure(.notFound)
}
let notes = ReleaseNotes(versionTag: tagName, title: release["name"] as? String ?? tagName, body: body)
Self.cacheReleaseNotes(notes)
return .success(notes)
}
private static func cachedReleaseNotes(forTag tag: String) -> ReleaseNotes? {
guard let json = DatabaseService.shared.getSetting(key: releaseNotesCacheKeyPrefix + tag),
let data = json.data(using: .utf8),
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: String],
let versionTag = obj["versionTag"], let title = obj["title"], let body = obj["body"]
else { return nil }
return ReleaseNotes(versionTag: versionTag, title: title, body: body)
}
private static func cacheReleaseNotes(_ notes: ReleaseNotes) {
guard let data = try? JSONSerialization.data(withJSONObject: [
"versionTag": notes.versionTag, "title": notes.title, "body": notes.body,
]), let json = String(data: data, encoding: .utf8) else { return }
DatabaseService.shared.setSetting(key: releaseNotesCacheKeyPrefix + notes.versionTag, value: json)
}
}
+1
View File
@@ -136,6 +136,7 @@ class ChatViewModel {
var showHelp: Bool = false
var showCredits: Bool = false
var showHistory: Bool = false
var releaseNotesRequest: ReleaseNotesRequest? = nil
var showShortcuts: Bool = false
var showSkills: Bool = false
var showJarvis: Bool = false
+10 -1
View File
@@ -129,6 +129,9 @@ struct ContentView: View {
chatViewModel.inputText = input
})
}
.sheet(item: $vm.releaseNotesRequest) { request in
ReleaseNotesView(request: request)
}
.alert("Intel Mac Support Ending", isPresented: $showIntelWarning) {
Button("Got It") {
UserDefaults.standard.set(true, forKey: "hasShownIntelWarning")
@@ -146,10 +149,16 @@ struct ContentView: View {
NSWorkspace.shared.open(url)
}
}
Button("Release Page") { updateService.openReleasesPage() }
if let latest = updateService.latestVersion {
Button("Read Release Notes") {
vm.releaseNotesRequest = ReleaseNotesRequest(versionTag: "v\(latest)", isCurrentlyInstalled: false)
}
}
Button("Later", role: .cancel) { }
.keyboardShortcut(.cancelAction)
} else {
Button("OK", role: .cancel) { }
.keyboardShortcut(.cancelAction)
}
} message: {
Text(updateService.manualCheckMessage ?? "")
+96
View File
@@ -0,0 +1,96 @@
//
// ReleaseNotesView.swift
// Confab
//
// Shows a Gitea release's markdown notes in-app, instead of opening the web releases page
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
//
// This file is part of Confab.
//
// Confab is licensed under the PolyForm Noncommercial License 1.0.0.
// You may use, study, modify, and share it for any noncommercial
// purpose. Commercial use including selling Confab or any part of
// it, standalone or bundled into another product or service
// requires a separate commercial license from the copyright holder.
//
// See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune
// Olsen via <https://confab.no>.
import SwiftUI
struct ReleaseNotesView: View {
let request: ReleaseNotesRequest
@Environment(\.dismiss) var dismiss
@State private var notes: ReleaseNotes?
@State private var isLoading = true
@State private var error: ReleaseNotesError?
var body: some View {
NavigationStack {
Group {
if isLoading {
ProgressView("Loading release notes...")
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if let error {
errorView(error)
} else if let notes {
ScrollView {
MarkdownContentView(content: notes.body, fontSize: 13)
.padding()
}
}
}
.navigationTitle(request.isCurrentlyInstalled ? "Release Notes" : "What's New")
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { dismiss() }
}
}
}
.frame(minWidth: 520, idealWidth: 600, minHeight: 480, idealHeight: 620)
.task {
await fetchNotes()
}
}
@ViewBuilder
private func errorView(_ error: ReleaseNotesError) -> some View {
VStack(spacing: 12) {
Image(systemName: "doc.text.magnifyingglass")
.font(.system(size: 40))
.foregroundStyle(.secondary)
switch error {
case .notFound:
Text("No release notes are available yet for \(request.versionTag).")
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
case .networkError:
Text("Couldn't load release notes. Check your internet connection and try again.")
.multilineTextAlignment(.center)
.foregroundStyle(.secondary)
Button("Retry") {
Task { await fetchNotes() }
}
}
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private func fetchNotes() async {
isLoading = true
error = nil
switch await UpdateCheckService.shared.fetchReleaseNotes(forTag: request.versionTag) {
case .success(let fetched):
notes = fetched
case .failure(let fetchError):
error = fetchError
}
isLoading = false
}
}
+4
View File
@@ -209,6 +209,10 @@ struct oAIApp: App {
Button("Confab Help") { openHelp() }
.keyboardShortcut("?", modifiers: .command)
Divider()
Button("Read Release Notes") {
let current = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
chatViewModel.releaseNotesRequest = ReleaseNotesRequest(versionTag: "v\(current)", isCurrentlyInstalled: true)
}
Button(UpdateCheckService.shared.isCheckingManually ? "Checking…" : "Check for Updates…") {
UpdateCheckService.shared.checkForUpdatesManually()
}