423 lines
15 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|