"oAI" reads as easily confused with OpenAI, both visually and in casual conversation. Renamed to "Confab" throughout: Xcode target/scheme/bundle ID (com.oai.Confab), Info.plist and Help Book identity, all user-facing UI text, internal Log subsystem and color identifiers, localization catalogs (6 languages, including a proper reworded/retranslated Intel-deprecation notice), Help Book HTML content, and docs (README/DEVELOPMENT/PRIVACY/SECURITY). Deliberately cosmetic-only: the on-disk data folder (~/Library/Application Support/oAI/), database/backup filenames, Keychain service identifiers, and EncryptionService's key-derivation inputs are all left untouched so existing conversations, settings, and stored API keys survive the update with zero migration and no re-entering credentials. Verified live: a real signed build successfully decrypted a stored API key and loaded an existing conversation database after the bundle ID change. Also includes a small already-completed, previously uncommitted model-release-date feature (ModelInfo/OpenRouterModels/ OpenRouterProvider/ModelInfoView) that happened to share several files with this rename. Gitignored on this branch and updated on disk but not part of this commit: CLAUDE.md, RELEASE_NOTES.md, and the build*.sh scripts.
188 lines
7.1 KiB
Swift
188 lines
7.1 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://oai.pm>.
|
|
|
|
|
|
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
|
|
})
|
|
}
|
|
.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)
|
|
}
|
|
}
|
|
Button("Release Page") { updateService.openReleasesPage() }
|
|
Button("Later", role: .cancel) { }
|
|
} else {
|
|
Button("OK", role: .cancel) { }
|
|
}
|
|
} 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())
|
|
}
|