cmux/Sources/cmuxApp.swift
2026-01-28 21:19:48 -08:00

423 lines
15 KiB
Swift

import AppKit
import SwiftUI
import Darwin
@main
struct cmuxApp: App {
@StateObject private var tabManager = TabManager()
@StateObject private var notificationStore = TerminalNotificationStore.shared
@StateObject private var sidebarState = SidebarState()
@AppStorage("appearanceMode") private var appearanceMode = AppearanceMode.dark.rawValue
@AppStorage("titlebarControlsStyle") private var titlebarControlsStyle = TitlebarControlsStyle.classic.rawValue
@AppStorage(SocketControlSettings.appStorageKey) private var socketControlMode = SocketControlSettings.defaultMode.rawValue
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
init() {
configureGhosttyEnvironment()
// Start the terminal controller for programmatic control
// This runs after TabManager is created via @StateObject
let defaults = UserDefaults.standard
if defaults.object(forKey: SocketControlSettings.appStorageKey) == nil,
let legacy = defaults.object(forKey: SocketControlSettings.legacyEnabledKey) as? Bool {
defaults.set(legacy ? SocketControlMode.full.rawValue : SocketControlMode.off.rawValue,
forKey: SocketControlSettings.appStorageKey)
}
}
private func configureGhosttyEnvironment() {
let fileManager = FileManager.default
let ghosttyAppResources = "/Applications/Ghostty.app/Contents/Resources/ghostty"
let bundledGhosttyURL = Bundle.main.resourceURL?.appendingPathComponent("ghostty")
var resolvedResourcesDir: String?
if getenv("GHOSTTY_RESOURCES_DIR") == nil {
if let bundledGhosttyURL,
fileManager.fileExists(atPath: bundledGhosttyURL.path),
fileManager.fileExists(atPath: bundledGhosttyURL.appendingPathComponent("themes").path) {
resolvedResourcesDir = bundledGhosttyURL.path
} else if fileManager.fileExists(atPath: ghosttyAppResources) {
resolvedResourcesDir = ghosttyAppResources
} else if let bundledGhosttyURL, fileManager.fileExists(atPath: bundledGhosttyURL.path) {
resolvedResourcesDir = bundledGhosttyURL.path
}
if let resolvedResourcesDir {
setenv("GHOSTTY_RESOURCES_DIR", resolvedResourcesDir, 1)
}
}
if getenv("TERM") == nil {
setenv("TERM", "xterm-ghostty", 1)
}
if getenv("TERM_PROGRAM") == nil {
setenv("TERM_PROGRAM", "ghostty", 1)
}
if let resourcesDir = getenv("GHOSTTY_RESOURCES_DIR").flatMap({ String(cString: $0) }) {
let resourcesURL = URL(fileURLWithPath: resourcesDir)
let resourcesParent = resourcesURL.deletingLastPathComponent()
let dataDir = resourcesParent.path
let manDir = resourcesParent.appendingPathComponent("man").path
appendEnvPathIfMissing("XDG_DATA_DIRS", path: dataDir)
appendEnvPathIfMissing("MANPATH", path: manDir)
}
}
private func appendEnvPathIfMissing(_ key: String, path: String) {
if path.isEmpty { return }
let current = getenv(key).flatMap { String(cString: $0) } ?? ""
if current.split(separator: ":").contains(Substring(path)) {
return
}
let updated = current.isEmpty ? path : "\(current):\(path)"
setenv(key, updated, 1)
}
var body: some Scene {
WindowGroup {
ContentView(updateViewModel: appDelegate.updateViewModel)
.environmentObject(tabManager)
.environmentObject(notificationStore)
.environmentObject(sidebarState)
.onAppear {
// Start the Unix socket controller for programmatic access
updateSocketController()
appDelegate.configure(tabManager: tabManager, notificationStore: notificationStore, sidebarState: sidebarState)
applyAppearance()
if ProcessInfo.processInfo.environment["CMUX_UI_TEST_SHOW_SETTINGS"] == "1" {
DispatchQueue.main.async {
NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
}
}
}
.onChange(of: appearanceMode) { _ in
applyAppearance()
}
.onChange(of: socketControlMode) { _ in
updateSocketController()
}
}
.windowToolbarStyle(.automatic)
Settings {
SettingsRootView()
}
.defaultSize(width: 460, height: 280)
.windowResizability(.contentMinSize)
.commands {
CommandGroup(replacing: .appInfo) {
Button("About cmuxterm") {
showAboutPanel()
}
Divider()
Button("Check for Updates…") {
appDelegate.checkForUpdates(nil)
}
}
CommandMenu("Update Pill") {
Button("Show Update Pill") {
appDelegate.showUpdatePill(nil)
}
Button("Show Loading State") {
appDelegate.showUpdatePillLoading(nil)
}
Button("Hide Update Pill") {
appDelegate.hideUpdatePill(nil)
}
Button("Automatic Update Pill") {
appDelegate.clearUpdatePillOverride(nil)
}
}
CommandMenu("Update Logs") {
Button("Copy Update Logs") {
appDelegate.copyUpdateLogs(nil)
}
}
#if DEBUG
CommandMenu("Debug") {
Button("New Tab With Large Scrollback") {
appDelegate.openDebugScrollbackTab(nil)
}
Divider()
Picker("Titlebar Controls Style", selection: $titlebarControlsStyle) {
ForEach(TitlebarControlsStyle.allCases) { style in
Text(style.menuTitle).tag(style.rawValue)
}
}
}
#endif
// New tab commands
CommandGroup(replacing: .newItem) {
Button("New Tab") {
tabManager.addTab()
}
.keyboardShortcut("t", modifiers: .command)
Button("New Tab") {
tabManager.addTab()
}
.keyboardShortcut("n", modifiers: .command)
Button("New Tab") {
tabManager.addTab()
}
.keyboardShortcut("`", modifiers: [.control, .shift])
}
// Close tab
CommandGroup(after: .newItem) {
Button("Close Panel") {
closePanelOrWindow()
}
.keyboardShortcut("w", modifiers: .command)
Button("Close Tab") {
tabManager.closeCurrentTabWithConfirmation()
}
.keyboardShortcut("w", modifiers: [.command, .shift])
}
// Tab navigation
CommandGroup(after: .toolbar) {
Button("Toggle Sidebar") {
sidebarState.toggle()
}
.keyboardShortcut("b", modifiers: .command)
Divider()
Button("Next Tab") {
tabManager.selectNextTab()
}
.keyboardShortcut("]", modifiers: [.command, .shift])
Button("Previous Tab") {
tabManager.selectPreviousTab()
}
.keyboardShortcut("[", modifiers: [.command, .shift])
Button("Next Tab") {
tabManager.selectNextTab()
}
.keyboardShortcut(.tab, modifiers: .control)
Button("Previous Tab") {
tabManager.selectPreviousTab()
}
.keyboardShortcut(.tab, modifiers: [.control, .shift])
Divider()
// Cmd+1 through Cmd+9 for tab selection
ForEach(1...9, id: \.self) { number in
Button("Tab \(number)") {
if number == 9 {
tabManager.selectLastTab()
} else {
tabManager.selectTab(at: number - 1)
}
}
.keyboardShortcut(KeyEquivalent(Character("\(number)")), modifiers: .command)
}
Divider()
Button("Jump to Latest Unread") {
jumpToLatestUnread()
}
.keyboardShortcut("u", modifiers: [.command, .shift])
Button("Show Notifications") {
showNotificationsPopover()
}
.keyboardShortcut("i", modifiers: [.command, .shift])
}
}
}
private func showAboutPanel() {
let bundle = Bundle.main
let appName = bundle.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String
?? bundle.object(forInfoDictionaryKey: "CFBundleName") as? String
?? "cmuxterm"
let version = bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0"
let build = bundle.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "1"
NSApp.orderFrontStandardAboutPanel(options: [
.applicationName: appName,
.version: version,
.applicationVersion: build
])
NSApp.activate(ignoringOtherApps: true)
}
private func applyAppearance() {
guard let mode = AppearanceMode(rawValue: appearanceMode) else { return }
switch mode {
case .system:
NSApp.appearance = nil
case .light:
NSApp.appearance = NSAppearance(named: .aqua)
case .dark:
NSApp.appearance = NSAppearance(named: .darkAqua)
case .auto:
// Legacy value; treat like system and migrate.
NSApp.appearance = nil
appearanceMode = AppearanceMode.system.rawValue
}
}
private func jumpToLatestUnread() {
guard let notification = notificationStore.notifications.first(where: { !$0.isRead }) else { return }
tabManager.focusTabFromNotification(notification.tabId, surfaceId: notification.surfaceId)
}
private func updateSocketController() {
let mode = SocketControlSettings.effectiveMode(userMode: currentSocketMode)
if mode != .off {
TerminalController.shared.start(
tabManager: tabManager,
socketPath: SocketControlSettings.socketPath(),
accessMode: mode
)
} else {
TerminalController.shared.stop()
}
}
private var currentSocketMode: SocketControlMode {
SocketControlMode(rawValue: socketControlMode) ?? SocketControlSettings.defaultMode
}
private func closePanelOrWindow() {
if let window = NSApp.keyWindow,
window.identifier?.rawValue == "cmux.settings" {
window.performClose(nil)
return
}
tabManager.closeCurrentPanelWithConfirmation()
}
private func showNotificationsPopover() {
AppDelegate.shared?.toggleNotificationsPopover(animated: false)
}
}
enum AppearanceMode: String, CaseIterable, Identifiable {
case system
case light
case dark
case auto
var id: String { rawValue }
static var visibleCases: [AppearanceMode] {
[.system, .light, .dark]
}
var displayName: String {
switch self {
case .system:
return "System"
case .light:
return "Light"
case .dark:
return "Dark"
case .auto:
return "Auto"
}
}
}
struct SettingsView: View {
@AppStorage("appearanceMode") private var appearanceMode = AppearanceMode.dark.rawValue
@AppStorage(SocketControlSettings.appStorageKey) private var socketControlMode = SocketControlSettings.defaultMode.rawValue
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Theme")
.font(.headline)
Picker("", selection: $appearanceMode) {
ForEach(AppearanceMode.visibleCases) { mode in
Text(mode.displayName).tag(mode.rawValue)
}
}
.pickerStyle(.radioGroup)
.labelsHidden()
Divider()
Text("Automation")
.font(.headline)
Picker("", selection: $socketControlMode) {
ForEach(SocketControlMode.allCases) { mode in
VStack(alignment: .leading, spacing: 2) {
Text(mode.displayName)
Text(mode.description)
.font(.caption)
.foregroundColor(.secondary)
}
.tag(mode.rawValue)
}
}
.pickerStyle(.radioGroup)
.labelsHidden()
.accessibilityIdentifier("AutomationSocketModePicker")
Text("Expose a local Unix socket for programmatic control. This can be a security risk on shared machines.")
.font(.caption)
.foregroundColor(.secondary)
Text("Overrides: CMUX_SOCKET_ENABLE, CMUX_SOCKET_MODE, and CMUX_SOCKET_PATH.")
.font(.caption)
.foregroundColor(.secondary)
}
.padding(20)
.padding(.top, 4)
.frame(minWidth: 360, minHeight: 280)
}
}
private struct SettingsRootView: View {
var body: some View {
SettingsView()
.background(WindowAccessor { window in
configureSettingsWindow(window)
})
}
private func configureSettingsWindow(_ window: NSWindow) {
window.identifier = NSUserInterfaceItemIdentifier("cmux.settings")
window.title = ""
window.titleVisibility = .hidden
window.titlebarAppearsTransparent = false
window.styleMask.remove(.fullSizeContentView)
window.styleMask.insert(.resizable)
window.contentMinSize = NSSize(width: 360, height: 280)
if window.toolbar == nil {
let toolbar = NSToolbar(identifier: NSToolbar.Identifier("cmux.settings.toolbar"))
toolbar.displayMode = .iconOnly
toolbar.sizeMode = .regular
toolbar.allowsUserCustomization = false
toolbar.autosavesConfiguration = false
toolbar.showsBaselineSeparator = false
window.toolbar = toolbar
window.toolbarStyle = .unified
}
let accessories = window.titlebarAccessoryViewControllers
for index in accessories.indices.reversed() {
guard let identifier = accessories[index].view.identifier?.rawValue else { continue }
guard identifier.hasPrefix("cmux.") else { continue }
window.removeTitlebarAccessoryViewController(at: index)
}
}
}