Files
oai-swift/oAI/Views/Main/ContentView.swift
T
rune 32e6ce3c37 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".
2026-08-03 13:13:36 +02:00

197 lines
7.5 KiB
Swift

//
// ContentView.swift
// Confab
//
// Root navigation container — NavigationSplitView with collapsible sidebar
//
// 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
#if os(macOS)
import Darwin // uname, sysctlbyname
#endif
struct ContentView: View {
@Environment(ChatViewModel.self) var chatViewModel
private var updateService = UpdateCheckService.shared
@State private var columnVisibility: NavigationSplitViewVisibility =
SettingsService.shared.sidebarVisible ? .all : .detailOnly
@State private var showIntelWarning = false
var body: some View {
@Bindable var vm = chatViewModel
NavigationSplitView(columnVisibility: $columnVisibility) {
SidebarView()
.navigationSplitViewColumnWidth(min: 200, ideal: 240, max: 340)
} detail: {
ChatView(
onModelSelect: { chatViewModel.showModelSelector = true },
onProviderChange: { newProvider in
chatViewModel.changeProvider(newProvider)
}
)
}
.frame(minWidth: 860, minHeight: 560)
.onChange(of: columnVisibility) { _, newValue in
SettingsService.shared.sidebarVisible = newValue != .detailOnly
}
#if os(macOS)
.onAppear {
NSApplication.shared.windows.forEach { $0.tabbingMode = .disallowed }
checkIntelWarning()
// Wire the real, environment-injected chatViewModel into the app delegate for Quit
// interception — `oAIApp.init()` used to do this by reading its own `@State`, but
// that returned a throwaway instance distinct from the one actually rendered here
// (confirmed via ObjectIdentifier logging). Deferred one run-loop tick via
// `DispatchQueue.main.async` so the modal alert reliably presents — calling it
// directly from `.onAppear` was tried before and silently failed to ever show it.
// Uses `AppDelegate.shared`, NOT `NSApplication.shared.delegate as? AppDelegate` —
// the latter always fails since `@NSApplicationDelegateAdaptor` registers an internal
// `SwiftUI.AppDelegate` wrapper as the real `NSApp.delegate`, a same-named-but-different
// type (confirmed via logging).
AppDelegate.shared?.chatViewModel = chatViewModel
DispatchQueue.main.async {
chatViewModel.checkForCrashRecoveryDraft()
}
}
.onKeyPress(.return, phases: .down) { press in
if press.modifiers.contains(.command) {
chatViewModel.sendMessage()
return .handled
}
return .ignored
}
#endif
.sheet(isPresented: $vm.showModelSelector) {
ModelSelectorView(
models: chatViewModel.availableModels,
selectedModel: chatViewModel.selectedModel,
onSelect: { model in
chatViewModel.selectModel(model)
chatViewModel.showModelSelector = false
}
)
.task {
if chatViewModel.availableModels.count <= 10 {
await chatViewModel.loadAvailableModels()
}
}
}
.sheet(isPresented: $vm.showSettings, onDismiss: {
chatViewModel.syncFromSettings()
}) {
SettingsView(chatViewModel: chatViewModel)
}
.sheet(isPresented: $vm.showStats) {
StatsView(
stats: chatViewModel.sessionStats,
model: chatViewModel.selectedModel,
provider: chatViewModel.currentProvider
)
}
.sheet(isPresented: $vm.showHelp) {
HelpView()
}
.sheet(isPresented: $vm.showCredits) {
CreditsView(provider: chatViewModel.currentProvider)
}
.sheet(isPresented: $vm.showConversations) {
ConversationListView(
onLoad: { conversation in
chatViewModel.loadConversation(conversation)
},
onRename: { id, newName in
chatViewModel.didRenameConversation(id: id, newName: newName)
}
)
}
.sheet(item: $vm.modelInfoTarget) { model in
ModelInfoView(model: model)
}
.sheet(isPresented: $vm.showHistory) {
HistoryView(onSelect: { input in
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")
}
} message: {
Text("Confab (formerly oAI) v2.4 was the last version to support Intel Macs and Rosetta. Starting with macOS 28, Confab will require Apple Silicon. Consider upgrading your Mac to continue receiving updates.")
}
.alert("Software Update", isPresented: Binding(
get: { updateService.manualCheckMessage != nil },
set: { if !$0 { updateService.manualCheckMessage = nil } }
)) {
if updateService.updateAvailable {
if let url = updateService.downloadURL {
Button("Download v\(updateService.latestVersion ?? "")") {
NSWorkspace.shared.open(url)
}
}
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 ?? "")
}
}
#if os(macOS)
private func checkIntelWarning() {
guard !UserDefaults.standard.bool(forKey: "hasShownIntelWarning") else { return }
guard isIntelNative || isRosetta else { return }
showIntelWarning = true
}
private var isIntelNative: Bool {
var systemInfo = utsname()
uname(&systemInfo)
let machine = withUnsafeBytes(of: &systemInfo.machine) {
String(cString: $0.bindMemory(to: CChar.self).baseAddress!)
}
return machine.contains("x86_64")
}
private var isRosetta: Bool {
var ret: Int32 = 0
var size = MemoryLayout<Int32>.size
sysctlbyname("sysctl.proc_translated", &ret, &size, nil, 0)
return ret == 1
}
#endif
}
#Preview {
ContentView()
.environment(ChatViewModel())
}