diff --git a/Sources/App/CmuxMainWindow.swift b/Sources/App/CmuxMainWindow.swift index 74f3a4396bb3..c60e7150f62b 100644 --- a/Sources/App/CmuxMainWindow.swift +++ b/Sources/App/CmuxMainWindow.swift @@ -69,6 +69,58 @@ final class CmuxMainWindow: NSWindow { return frame } + /// cmux creates its main window programmatically (never from a nib), so it + /// cannot inherit fullscreen capability from Interface Builder and instead + /// relied on AppKit *implicitly* granting `.fullScreenPrimary` to a + /// resizable, titled window. That implicit grant is not reliable across + /// macOS versions / display arrangements: on macOS 26 (Tahoe) a + /// freshly-created window reports an empty collection behavior + /// (`rawValue == 0`) and AppKit does not treat it as fullscreen-capable, so + /// Toggle Full Screen / ⌃⌘F / the green traffic-light button all fail to + /// enter a native fullscreen Space — the green button only zooms (#5933). + /// + /// Declaring `.fullScreenPrimary` here makes native fullscreen reachable + /// regardless of the OS's implicit default. It is idempotent where AppKit + /// would have granted it anyway, and composes with the temporary + /// `.fullScreenDisallowsTiling` opt-out the window factory applies when + /// spawning a window out of an existing fullscreen Space. + override init( + contentRect: NSRect, + styleMask: NSWindow.StyleMask, + backing: NSWindow.BackingStoreType, + defer flag: Bool + ) { + super.init( + contentRect: contentRect, + styleMask: styleMask, + backing: backing, + defer: flag + ) + collectionBehavior = Self.canonicalCollectionBehavior(collectionBehavior) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// Returns `base` guaranteed to carry `.fullScreenPrimary` (and never + /// `.fullScreenNone`) so a cmux main window can always enter a native + /// fullscreen Space. Pure and `nonisolated` so it can be unit-tested + /// without constructing a window; see ``init(contentRect:styleMask:backing:defer:)`` + /// for why declaring the capability explicitly is required. + nonisolated static func canonicalCollectionBehavior( + _ base: NSWindow.CollectionBehavior + ) -> NSWindow.CollectionBehavior { + var behavior = base + // `.fullScreenNone` and `.fullScreenPrimary` are mutually exclusive; + // drop any stale "none" before declaring primary so fullscreen is not + // suppressed. + behavior.remove(.fullScreenNone) + behavior.insert(.fullScreenPrimary) + return behavior + } + private var isSoftHiddenForVisibilityController = false func setSoftHiddenForVisibilityController(_ isSoftHidden: Bool) { diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 8da4291ed351..e5db319fe1c5 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -302,6 +302,7 @@ E7E00000000000000000000B /* CmuxLifecycleEventPublishing.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7E00000000000000000000C /* CmuxLifecycleEventPublishing.swift */; }; 2F0C07000000000000000002 /* CmuxMainWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F0C07000000000000000001 /* CmuxMainWindow.swift */; }; D36090010000000000000005 /* CmuxMainWindowConstrainFrameTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D36090010000000000000006 /* CmuxMainWindowConstrainFrameTests.swift */; }; + D36090020000000000000005 /* CmuxMainWindowFullScreenCapabilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D36090020000000000000006 /* CmuxMainWindowFullScreenCapabilityTests.swift */; }; 799F1BEF876006EEB8CD1035 /* CMUXMobileCore in Frameworks */ = {isa = PBXBuildFile; productRef = EFB18E3B3099DFE2ECA3C263 /* CMUXMobileCore */; }; EFB18E3C0000000000000001 /* CMUXMobileCore in Frameworks */ = {isa = PBXBuildFile; productRef = EFB18E3B3099DFE2ECA3C263 /* CMUXMobileCore */; }; CA1F0A01CA1F0A01CA1F0A01 /* CmuxModalAlertPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA1F0A02CA1F0A02CA1F0A02 /* CmuxModalAlertPresentation.swift */; }; @@ -1450,6 +1451,7 @@ E7E00000000000000000000C /* CmuxLifecycleEventPublishing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxLifecycleEventPublishing.swift; sourceTree = ""; }; 2F0C07000000000000000001 /* CmuxMainWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/CmuxMainWindow.swift; sourceTree = ""; }; D36090010000000000000006 /* CmuxMainWindowConstrainFrameTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxMainWindowConstrainFrameTests.swift; sourceTree = ""; }; + D36090020000000000000006 /* CmuxMainWindowFullScreenCapabilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxMainWindowFullScreenCapabilityTests.swift; sourceTree = ""; }; CA1F0A02CA1F0A02CA1F0A02 /* CmuxModalAlertPresentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxModalAlertPresentation.swift; sourceTree = ""; }; C0DE31390000000000000102 /* CMUXOpenCommandTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CMUXOpenCommandTests.swift; sourceTree = ""; }; C0DE31390000000000000112 /* CMUXOpenHTMLFocusTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CMUXOpenHTMLFocusTests.swift; sourceTree = ""; }; @@ -3235,6 +3237,7 @@ A50019B3 /* SettingsSearchIndexTests.swift */, D36090010000000000000004 /* SettingsWindowPresenterTests.swift */, D36090010000000000000006 /* CmuxMainWindowConstrainFrameTests.swift */, + D36090020000000000000006 /* CmuxMainWindowFullScreenCapabilityTests.swift */, 970226F3C99D0D937CD00539 /* BrowserConfigTests.swift */, BABA25000000000000000006 /* BrowserHTTPBasicAuthPromptCoordinatorTests.swift */, BABA25000000000000000004 /* BrowserHTTPBasicAuthPromptTests.swift */, @@ -4693,6 +4696,7 @@ C1A2B3C4D5E6F70800000001 /* CmuxConfigTests.swift in Sources */, E7E000000000000000000003 /* CmuxEventBusTests.swift in Sources */, D36090010000000000000005 /* CmuxMainWindowConstrainFrameTests.swift in Sources */, + D36090020000000000000005 /* CmuxMainWindowFullScreenCapabilityTests.swift in Sources */, C0DE31390000000000000101 /* CMUXOpenCommandTests.swift in Sources */, C0DE31390000000000000111 /* CMUXOpenHTMLFocusTests.swift in Sources */, C3677001000000000000001 /* CmuxSSHURLRequestTests.swift in Sources */, diff --git a/cmuxTests/CmuxMainWindowFullScreenCapabilityTests.swift b/cmuxTests/CmuxMainWindowFullScreenCapabilityTests.swift new file mode 100644 index 000000000000..c962b4ca2ca2 --- /dev/null +++ b/cmuxTests/CmuxMainWindowFullScreenCapabilityTests.swift @@ -0,0 +1,79 @@ +import AppKit +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +@Suite("CmuxMainWindow native fullscreen capability") +struct CmuxMainWindowFullScreenCapabilityTests { + // cmux creates its main window programmatically and never loaded fullscreen + // capability from a nib, so it historically relied on AppKit *implicitly* + // granting `.fullScreenPrimary` to a resizable, titled window. That implicit + // grant is not reliable across macOS versions / display arrangements: on + // macOS 26 (Tahoe) a freshly-created CmuxMainWindow reports an empty + // collection behavior (`rawValue == 0`) and AppKit does NOT treat it as + // fullscreen-capable — so `toggleFullScreen(_:)`, ⌃⌘F, and the green + // traffic-light button all fail to enter a native fullscreen Space (the + // green button only zooms). See issue #5933. + // + // A CmuxMainWindow must therefore *declare* `.fullScreenPrimary` itself so + // native fullscreen is reachable regardless of the OS's implicit default. + @Test func mainWindowDeclaresFullScreenPrimaryCapability() { + let window = CmuxMainWindow( + contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), + styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + defer { + window.orderOut(nil) + window.close() + } + + #expect( + window.collectionBehavior.contains(.fullScreenPrimary), + "Main window must declare .fullScreenPrimary so native fullscreen is reachable" + ) + #expect( + !window.collectionBehavior.contains(.fullScreenNone), + "Main window must never carry .fullScreenNone, which suppresses native fullscreen" + ) + } + + // The capability decision is a pure, screen-agnostic transform so it runs + // deterministically on CI regardless of the test host's display setup. + + @Test func canonicalBehaviorAddsFullScreenPrimaryToEmptyBehavior() { + let result = CmuxMainWindow.canonicalCollectionBehavior([]) + #expect(result.contains(.fullScreenPrimary)) + #expect(!result.contains(.fullScreenNone)) + } + + @Test func canonicalBehaviorDropsStaleFullScreenNone() { + let result = CmuxMainWindow.canonicalCollectionBehavior([.fullScreenNone]) + #expect(result.contains(.fullScreenPrimary)) + #expect(!result.contains(.fullScreenNone)) + } + + @Test func canonicalBehaviorPreservesUnrelatedBehaviorBits() { + // The window factory may layer `.fullScreenDisallowsTiling` on top when + // spawning out of an existing fullscreen Space; canonicalization must + // not clobber that (or any other unrelated bit). + let base: NSWindow.CollectionBehavior = [.fullScreenDisallowsTiling, .moveToActiveSpace] + let result = CmuxMainWindow.canonicalCollectionBehavior(base) + #expect(result.contains(.fullScreenPrimary)) + #expect(result.contains(.fullScreenDisallowsTiling)) + #expect(result.contains(.moveToActiveSpace)) + } + + @Test func canonicalBehaviorIsIdempotent() { + let once = CmuxMainWindow.canonicalCollectionBehavior([]) + let twice = CmuxMainWindow.canonicalCollectionBehavior(once) + #expect(once == twice) + } +}