diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 85a28e01..f407c814 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -133,8 +133,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent titlebarAccessoryController.attach(to: window) } - func toggleNotificationsPopover() { - titlebarAccessoryController.toggleNotificationsPopover() + func toggleNotificationsPopover(animated: Bool = true) { + titlebarAccessoryController.toggleNotificationsPopover(animated: animated) } func validateMenuItem(_ item: NSMenuItem) -> Bool { diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift index d6591cf8..9d46f1a7 100644 --- a/Sources/Update/UpdateTitlebarAccessory.swift +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -193,7 +193,25 @@ struct TitlebarControlsStyleConfig { } final class TitlebarControlsViewModel: ObservableObject { - @Published var isShowingNotifications = false + weak var notificationsAnchorView: NSView? +} + +private struct NotificationsAnchorView: NSViewRepresentable { + let onResolve: (NSView) -> Void + + func makeNSView(context: Context) -> NSView { + let view = NSView(frame: .zero) + DispatchQueue.main.async { + onResolve(view) + } + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + DispatchQueue.main.async { + onResolve(nsView) + } + } } private struct TitlebarControlButton: View { @@ -224,6 +242,7 @@ private struct TitlebarControlsView: View { @ObservedObject var notificationStore: TerminalNotificationStore @ObservedObject var viewModel: TitlebarControlsViewModel let onToggleSidebar: () -> Void + let onToggleNotifications: () -> Void let onNewTab: () -> Void @AppStorage("titlebarControlsStyle") private var styleRawValue = TitlebarControlsStyle.classic.rawValue @@ -241,8 +260,9 @@ private struct TitlebarControlsView: View { iconLabel(systemName: "sidebar.left", config: config) } .accessibilityLabel("Toggle Sidebar") + .help("Show or hide the sidebar (Cmd+B)") - TitlebarControlButton(config: config, action: { viewModel.isShowingNotifications.toggle() }) { + TitlebarControlButton(config: config, action: onToggleNotifications) { ZStack(alignment: .topTrailing) { iconLabel(systemName: "bell", config: config) @@ -259,15 +279,15 @@ private struct TitlebarControlsView: View { } .frame(width: config.buttonSize, height: config.buttonSize) } + .background(NotificationsAnchorView { viewModel.notificationsAnchorView = $0 }) .accessibilityLabel("Notifications") - .popover(isPresented: $viewModel.isShowingNotifications, arrowEdge: .top) { - NotificationsPopoverView(notificationStore: notificationStore) - } + .help("Show notifications (Cmd+Shift+I)") TitlebarControlButton(config: config, action: onNewTab) { iconLabel(systemName: "plus", config: config) } .accessibilityLabel("New Tab") + .help("Open a new tab (Cmd+T or Cmd+N)") } let paddedContent = content.padding(config.groupPadding) @@ -305,15 +325,19 @@ private struct TitlebarControlsView: View { } } -final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewController { +final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewController, NSPopoverDelegate { private let hostingView: NonDraggableHostingView private let containerView = NSView() + private let notificationStore: TerminalNotificationStore + private lazy var notificationsPopover: NSPopover = makeNotificationsPopover() private var pendingSizeUpdate = false private let viewModel = TitlebarControlsViewModel() private var userDefaultsObserver: NSObjectProtocol? init(notificationStore: TerminalNotificationStore) { + self.notificationStore = notificationStore let toggleSidebar = { _ = AppDelegate.shared?.sidebarState?.toggle() } + let toggleNotifications: () -> Void = { _ = AppDelegate.shared?.toggleNotificationsPopover(animated: true) } let newTab = { _ = AppDelegate.shared?.tabManager?.addTab() } hostingView = NonDraggableHostingView( @@ -321,6 +345,7 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont notificationStore: notificationStore, viewModel: viewModel, onToggleSidebar: toggleSidebar, + onToggleNotifications: toggleNotifications, onNewTab: newTab ) ) @@ -387,8 +412,25 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont hostingView.frame = NSRect(x: 0, y: yOffset, width: contentSize.width, height: contentSize.height) } - func toggleNotificationsPopover() { - viewModel.isShowingNotifications.toggle() + func toggleNotificationsPopover(animated: Bool = true) { + if notificationsPopover.isShown { + notificationsPopover.performClose(nil) + return + } + let anchorView = viewModel.notificationsAnchorView ?? hostingView + notificationsPopover.animates = animated + notificationsPopover.show(relativeTo: anchorView.bounds, of: anchorView, preferredEdge: .maxY) + } + + private func makeNotificationsPopover() -> NSPopover { + let popover = NSPopover() + popover.behavior = .semitransient + popover.animates = true + popover.delegate = self + popover.contentViewController = NSHostingController( + rootView: NotificationsPopoverView(notificationStore: notificationStore) + ) + return popover } } @@ -731,9 +773,9 @@ final class UpdateTitlebarAccessoryController { } } - func toggleNotificationsPopover() { + func toggleNotificationsPopover(animated: Bool = true) { for controller in controlsControllers.allObjects { - controller.toggleNotificationsPopover() + controller.toggleNotificationsPopover(animated: animated) } } } diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 18263607..fbef0a2c 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -8,11 +8,18 @@ struct cmuxApp: App { @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() { // 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) + } } var body: some Scene { @@ -23,18 +30,27 @@ struct cmuxApp: App { .environmentObject(sidebarState) .onAppear { // Start the Unix socket controller for programmatic access - TerminalController.shared.start(tabManager: tabManager) + 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() } + .windowResizability(.contentMinSize) .commands { CommandGroup(replacing: .appInfo) { Button("About cmuxterm") { @@ -104,7 +120,7 @@ struct cmuxApp: App { // Close tab CommandGroup(after: .newItem) { Button("Close Panel") { - tabManager.closeCurrentPanelWithConfirmation() + closePanelOrWindow() } .keyboardShortcut("w", modifiers: .command) @@ -190,13 +206,16 @@ struct cmuxApp: App { private func applyAppearance() { guard let mode = AppearanceMode(rawValue: appearanceMode) else { return } switch mode { - case .auto: - NSApp.appearance = nil case .system: - let match = NSApp.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) ?? .aqua - NSApp.appearance = NSAppearance(named: match) + 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 } } @@ -205,36 +224,110 @@ struct cmuxApp: App { 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() + AppDelegate.shared?.toggleNotificationsPopover(animated: false) } } enum AppearanceMode: String, CaseIterable, Identifiable { - case auto 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("Theme", selection: $appearanceMode) { - Text("Auto").tag(AppearanceMode.auto.rawValue) - Text("System").tag(AppearanceMode.system.rawValue) - Text("Dark").tag(AppearanceMode.dark.rawValue) + 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) - .frame(minWidth: 360, minHeight: 180) + .frame(minWidth: 360, minHeight: 280) } } @@ -242,7 +335,25 @@ private struct SettingsRootView: View { var body: some View { SettingsView() .background(WindowAccessor { window in - window.identifier = NSUserInterfaceItemIdentifier("cmux.settings") + configureSettingsWindow(window) }) } + + private func configureSettingsWindow(_ window: NSWindow) { + window.identifier = NSUserInterfaceItemIdentifier("cmux.settings") + window.titleVisibility = .hidden + window.titlebarAppearsTransparent = true + window.styleMask.insert(.resizable) + window.contentMinSize = NSSize(width: 360, height: 280) + if window.frame.width > 520 { + window.setContentSize(NSSize(width: 460, height: max(280, window.contentView?.frame.height ?? 280))) + } + + 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) + } + } }