From 32e6ce3c37352e5d502f3109bc86c998fd615c11 Mon Sep 17 00:00:00 2001 From: Rune Olsen Date: Mon, 3 Aug 2026 13:13:36 +0200 Subject: [PATCH] Show release notes in-app instead of opening the web releases page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New "Read Release Notes" entries in the Help menu (current installed version) and the "Check for Updates" alert (the new, not-yet-installed version) render a release's markdown notes in a Confab modal. - UpdateCheckService.fetchReleaseNotes(forTag:) fetches a release's title + body from Gitea's public releases-by-tag API, caching the result by version tag in the settings table (a published release's notes don't change, so no need to refetch on every view). - ReleaseNotesView reuses the existing MarkdownContentView renderer; shows a friendly "not available yet" state for versions with no published Gitea release (e.g. a dev build ahead of the last release). - ReleaseNotesRequest carries which version to show atomically via .sheet(item:), per this project's established sheet-timing pattern. - Removed the redundant "Release Page" button from the update alert now that notes show in-app; added an explicit .keyboardShortcut (.cancelAction) to its cancel button so Escape actually closes it — role: .cancel alone didn't do it, since NSAlert only auto-binds Escape to a button literally titled "Cancel". --- oAI/Models/ReleaseNotesRequest.swift | 34 +++++++++ oAI/Services/DatabaseService.swift | 6 ++ oAI/Services/UpdateCheckService.swift | 68 +++++++++++++++++ oAI/ViewModels/ChatViewModel.swift | 1 + oAI/Views/Main/ContentView.swift | 11 ++- oAI/Views/Screens/ReleaseNotesView.swift | 96 ++++++++++++++++++++++++ oAI/oAIApp.swift | 4 + 7 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 oAI/Models/ReleaseNotesRequest.swift create mode 100644 oAI/Views/Screens/ReleaseNotesView.swift diff --git a/oAI/Models/ReleaseNotesRequest.swift b/oAI/Models/ReleaseNotesRequest.swift new file mode 100644 index 0000000..4e22a11 --- /dev/null +++ b/oAI/Models/ReleaseNotesRequest.swift @@ -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 +// for +// the full license text. For commercial licensing, contact Rune +// Olsen via . + +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 +} diff --git a/oAI/Services/DatabaseService.swift b/oAI/Services/DatabaseService.swift index ebf5d04..3efde8b 100644 --- a/oAI/Services/DatabaseService.swift +++ b/oAI/Services/DatabaseService.swift @@ -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) diff --git a/oAI/Services/UpdateCheckService.swift b/oAI/Services/UpdateCheckService.swift index 0c5e89b..43acbc9 100644 --- a/oAI/Services/UpdateCheckService.swift +++ b/oAI/Services/UpdateCheckService.swift @@ -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 { + 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) + } } diff --git a/oAI/ViewModels/ChatViewModel.swift b/oAI/ViewModels/ChatViewModel.swift index f0333c8..0a776c0 100644 --- a/oAI/ViewModels/ChatViewModel.swift +++ b/oAI/ViewModels/ChatViewModel.swift @@ -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 diff --git a/oAI/Views/Main/ContentView.swift b/oAI/Views/Main/ContentView.swift index 9682e9b..b45031b 100644 --- a/oAI/Views/Main/ContentView.swift +++ b/oAI/Views/Main/ContentView.swift @@ -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 ?? "") diff --git a/oAI/Views/Screens/ReleaseNotesView.swift b/oAI/Views/Screens/ReleaseNotesView.swift new file mode 100644 index 0000000..179e1a0 --- /dev/null +++ b/oAI/Views/Screens/ReleaseNotesView.swift @@ -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 +// for +// the full license text. For commercial licensing, contact Rune +// Olsen via . + + +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 + } +} diff --git a/oAI/oAIApp.swift b/oAI/oAIApp.swift index 6dd207a..d93c0a3 100644 --- a/oAI/oAIApp.swift +++ b/oAI/oAIApp.swift @@ -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() }