Show release notes in-app instead of opening the web releases page
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".
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ?? "")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user