diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 5b0c9cb2..aaf0fe05 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -9,6 +9,30 @@ import Combine import ObjectiveC.runtime import Darwin +final class MainWindowHostingView: NSHostingView { + private let zeroSafeAreaLayoutGuide = NSLayoutGuide() + + override var safeAreaInsets: NSEdgeInsets { NSEdgeInsetsZero } + override var safeAreaRect: NSRect { bounds } + override var safeAreaLayoutGuide: NSLayoutGuide { zeroSafeAreaLayoutGuide } + + required init(rootView: Content) { + super.init(rootView: rootView) + addLayoutGuide(zeroSafeAreaLayoutGuide) + NSLayoutConstraint.activate([ + zeroSafeAreaLayoutGuide.leadingAnchor.constraint(equalTo: leadingAnchor), + zeroSafeAreaLayoutGuide.trailingAnchor.constraint(equalTo: trailingAnchor), + zeroSafeAreaLayoutGuide.topAnchor.constraint(equalTo: topAnchor), + zeroSafeAreaLayoutGuide.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + private enum CmuxThemeNotifications { static let reloadConfig = Notification.Name("com.cmuxterm.themes.reload-config") } @@ -5457,7 +5481,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } else { window.center() } - window.contentView = NSHostingView(rootView: root) + window.contentView = MainWindowHostingView(rootView: root) // Apply shared window styling. attachUpdateAccessory(to: window) diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift index cd18fb56..0b9c956c 100644 --- a/Sources/Update/UpdateTitlebarAccessory.swift +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -1173,6 +1173,7 @@ private struct NotificationPopoverRow: View { } } +@MainActor final class UpdateTitlebarAccessoryController { private weak var updateViewModel: UpdateViewModel? private var didStart = false @@ -1213,7 +1214,9 @@ final class UpdateTitlebarAccessoryController { queue: .main ) { [weak self] notification in guard let window = notification.object as? NSWindow else { return } - self?.attachIfNeeded(to: window) + Task { @MainActor [weak self] in + self?.attachIfNeeded(to: window) + } }) observers.append(center.addObserver( @@ -1222,7 +1225,9 @@ final class UpdateTitlebarAccessoryController { queue: .main ) { [weak self] notification in guard let window = notification.object as? NSWindow else { return } - self?.attachIfNeeded(to: window) + Task { @MainActor [weak self] in + self?.attachIfNeeded(to: window) + } }) // We intentionally do not rely on "window became visible" notifications here: @@ -1242,7 +1247,9 @@ final class UpdateTitlebarAccessoryController { let delays: [TimeInterval] = [0.05, 0.15, 0.3, 0.6, 1.0, 2.0, 3.0] for delay in delays { let item = DispatchWorkItem { [weak self] in - self?.attachToExistingWindows() + Task { @MainActor [weak self] in + self?.attachToExistingWindows() + } #if DEBUG let env = ProcessInfo.processInfo.environment if env["CMUX_UI_TEST_MODE"] == "1" { @@ -1258,7 +1265,6 @@ final class UpdateTitlebarAccessoryController { } private func attachIfNeeded(to window: NSWindow) { - guard !attachedWindows.contains(window) else { return } guard !isSettingsWindow(window) else { return } // Window identifiers are assigned by SwiftUI via WindowAccessor, which can run @@ -1270,8 +1276,10 @@ final class UpdateTitlebarAccessoryController { if attempts < 40 { pendingAttachRetries[key] = attempts + 1 DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self, weak window] in - guard let self, let window else { return } - self.attachIfNeeded(to: window) + Task { @MainActor [weak self, weak window] in + guard let self, let window else { return } + self.attachIfNeeded(to: window) + } } } else { pendingAttachRetries.removeValue(forKey: key) @@ -1281,6 +1289,13 @@ final class UpdateTitlebarAccessoryController { pendingAttachRetries.removeValue(forKey: ObjectIdentifier(window)) + guard WorkspaceTitlebarSettings.isVisible() else { + removeAccessoryIfPresent(from: window) + return + } + + guard !attachedWindows.contains(window) else { return } + if !window.titlebarAccessoryViewControllers.contains(where: { $0.view.identifier == controlsIdentifier }) { let controls = TitlebarControlsAccessoryViewController( notificationStore: TerminalNotificationStore.shared @@ -1302,6 +1317,40 @@ final class UpdateTitlebarAccessoryController { #endif } + private func removeAccessoryIfPresent(from window: NSWindow) { + let matchingIndices = window.titlebarAccessoryViewControllers.indices.reversed().filter { index in + window.titlebarAccessoryViewControllers[index].view.identifier == controlsIdentifier + } + guard !matchingIndices.isEmpty || attachedWindows.contains(window) else { return } + + for index in matchingIndices { + let accessory = window.titlebarAccessoryViewControllers[index] + if let controls = accessory as? TitlebarControlsAccessoryViewController { + controls.dismissNotificationsPopover() + } + window.removeTitlebarAccessoryViewController(at: index) + } + + attachedWindows.remove(window) + pendingAttachRetries.removeValue(forKey: ObjectIdentifier(window)) + DispatchQueue.main.async { [weak window] in + guard let window else { return } + window.contentView?.needsLayout = true + window.contentView?.superview?.needsLayout = true + window.contentView?.layoutSubtreeIfNeeded() + window.contentView?.superview?.layoutSubtreeIfNeeded() + window.invalidateShadow() + } + +#if DEBUG + let env = ProcessInfo.processInfo.environment + if env["CMUX_UI_TEST_MODE"] == "1" { + let ident = window.identifier?.rawValue ?? "" + UpdateLogStore.shared.append("removed titlebar accessories from window id=\(ident)") + } +#endif + } + private func isSettingsWindow(_ window: NSWindow) -> Bool { if window.identifier?.rawValue == "cmux.settings" { return true diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 3da2656d..ea6c3e35 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -3241,6 +3241,16 @@ struct SettingsView: View { ) } + private var showWorkspaceTitlebarBinding: Binding { + Binding( + get: { showWorkspaceTitlebar }, + set: { newValue in + showWorkspaceTitlebar = newValue + reassertSettingsWindowFocusIfNeeded() + } + ) + } + private var settingsSidebarTintLightBinding: Binding { Binding( get: { @@ -3590,9 +3600,10 @@ struct SettingsView: View { String(localized: "settings.app.showWorkspaceTitlebar", defaultValue: "Show Workspace Title Bar"), subtitle: workspaceTitlebarSubtitle ) { - Toggle("", isOn: $showWorkspaceTitlebar) + Toggle("", isOn: showWorkspaceTitlebarBinding) .labelsHidden() .controlSize(.small) + .accessibilityIdentifier("SettingsShowWorkspaceTitlebarToggle") .accessibilityLabel( String(localized: "settings.app.showWorkspaceTitlebar", defaultValue: "Show Workspace Title Bar") ) @@ -4625,6 +4636,19 @@ struct SettingsView: View { NSApplication.shared.terminate(nil) } + private func reassertSettingsWindowFocusIfNeeded() { + DispatchQueue.main.async { + guard let window = SettingsWindowController.shared.window, window.isVisible else { return } + window.orderFrontRegardless() + window.makeKeyAndOrderFront(nil) + DispatchQueue.main.async { + guard window.isVisible else { return } + window.orderFrontRegardless() + window.makeKeyAndOrderFront(nil) + } + } + } + private func resetAllSettings() { isResettingSettings = true appLanguage = LanguageSettings.defaultLanguage.rawValue