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, defaultValue: "/usr/local/share:/usr/share" ) appendEnvPathIfMissing("MANPATH", path: manDir) } } private func appendEnvPathIfMissing(_ key: String, path: String, defaultValue: String? = nil) { if path.isEmpty { return } var current = getenv(key).flatMap { String(cString: $0) } ?? "" if current.isEmpty, let defaultValue { current = defaultValue } 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) } } #if DEBUG 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) } } #endif #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) } } Divider() Button("Trigger Sentry Test Crash") { appDelegate.triggerSentryTestCrash(nil) } } #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() { AboutWindowController.shared.show() 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) } } private final class AboutWindowController: NSWindowController, NSWindowDelegate { static let shared = AboutWindowController() private init() { let window = NSPanel( contentRect: NSRect(x: 0, y: 0, width: 360, height: 520), styleMask: [.titled, .closable, .utilityWindow], backing: .buffered, defer: false ) window.title = "" window.titleVisibility = .hidden window.titlebarAppearsTransparent = true window.isMovableByWindowBackground = true window.isReleasedWhenClosed = false window.identifier = NSUserInterfaceItemIdentifier("cmux.about") window.center() window.contentView = NSHostingView(rootView: AboutPanelView()) AppDelegate.shared?.applyWindowDecorations(to: window) super.init(window: window) window.delegate = self } @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func show() { window?.center() window?.makeKeyAndOrderFront(nil) } } private struct AboutPanelView: View { @Environment(\.openURL) private var openURL private let githubURL = URL(string: "https://github.com/manaflow-ai/cmuxterm") private let docsURL = URL(string: "https://github.com/manaflow-ai/cmuxterm#readme") private var version: String? { Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String } private var build: String? { Bundle.main.infoDictionary?["CFBundleVersion"] as? String } private var commit: String? { if let value = Bundle.main.infoDictionary?["CMUXCommit"] as? String, !value.isEmpty { return value } let env = ProcessInfo.processInfo.environment["CMUX_COMMIT"] ?? "" return env.isEmpty ? nil : env } private var copyright: String? { Bundle.main.infoDictionary?["NSHumanReadableCopyright"] as? String } var body: some View { VStack(alignment: .center) { Image(nsImage: NSApplication.shared.applicationIconImage) .resizable() .renderingMode(.original) .frame(width: 96, height: 96) .shadow(color: .black.opacity(0.18), radius: 8, x: 0, y: 3) VStack(alignment: .center, spacing: 32) { VStack(alignment: .center, spacing: 8) { Text("cmuxterm") .bold() .font(.title) Text("A Ghostty-based terminal with vertical tabs\nand a notification panel for macOS.") .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) .font(.caption) .tint(.secondary) .opacity(0.8) } .textSelection(.enabled) VStack(spacing: 2) { if let version { AboutPropertyRow(label: "Version", text: version) } if let build { AboutPropertyRow(label: "Build", text: build) } let commitText = commit ?? "—" let commitURL = commit.flatMap { hash in URL(string: "https://github.com/manaflow-ai/cmuxterm/commit/\(hash)") } AboutPropertyRow(label: "Commit", text: commitText, url: commitURL) } .frame(maxWidth: .infinity) HStack(spacing: 8) { if let url = docsURL { Button("Docs") { openURL(url) } } if let url = githubURL { Button("GitHub") { openURL(url) } } } if let copy = copyright, !copy.isEmpty { Text(copy) .font(.caption) .textSelection(.enabled) .tint(.secondary) .opacity(0.8) .multilineTextAlignment(.center) .frame(maxWidth: .infinity) } } .frame(maxWidth: .infinity) } .padding(.top, 8) .padding(32) .frame(minWidth: 280) .background(AboutVisualEffectBackground(material: .underWindowBackground).ignoresSafeArea()) } } private struct AboutPropertyRow: View { private let label: String private let text: String private let url: URL? init(label: String, text: String, url: URL? = nil) { self.label = label self.text = text self.url = url } @ViewBuilder private var textView: some View { Text(text) .frame(width: 140, alignment: .leading) .padding(.leading, 2) .tint(.secondary) .opacity(0.8) .monospaced() } var body: some View { HStack(spacing: 4) { Text(label) .frame(width: 126, alignment: .trailing) .padding(.trailing, 2) if let url { Link(destination: url) { textView } } else { textView } } .font(.callout) .textSelection(.enabled) .frame(maxWidth: .infinity) } } private struct AboutVisualEffectBackground: NSViewRepresentable { let material: NSVisualEffectView.Material let blendingMode: NSVisualEffectView.BlendingMode let isEmphasized: Bool init( material: NSVisualEffectView.Material, blendingMode: NSVisualEffectView.BlendingMode = .behindWindow, isEmphasized: Bool = false ) { self.material = material self.blendingMode = blendingMode self.isEmphasized = isEmphasized } func updateNSView(_ nsView: NSVisualEffectView, context: Context) { nsView.material = material nsView.blendingMode = blendingMode nsView.isEmphasized = isEmphasized } func makeNSView(context: Context) -> NSVisualEffectView { let visualEffect = NSVisualEffectView() visualEffect.autoresizingMask = [.width, .height] return visualEffect } } 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) } AppDelegate.shared?.applyWindowDecorations(to: window) } }