Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions Sources/App/CmuxMainWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
4 changes: 4 additions & 0 deletions cmux.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -1450,6 +1451,7 @@
E7E00000000000000000000C /* CmuxLifecycleEventPublishing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxLifecycleEventPublishing.swift; sourceTree = "<group>"; };
2F0C07000000000000000001 /* CmuxMainWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/CmuxMainWindow.swift; sourceTree = "<group>"; };
D36090010000000000000006 /* CmuxMainWindowConstrainFrameTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxMainWindowConstrainFrameTests.swift; sourceTree = "<group>"; };
D36090020000000000000006 /* CmuxMainWindowFullScreenCapabilityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxMainWindowFullScreenCapabilityTests.swift; sourceTree = "<group>"; };
CA1F0A02CA1F0A02CA1F0A02 /* CmuxModalAlertPresentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxModalAlertPresentation.swift; sourceTree = "<group>"; };
C0DE31390000000000000102 /* CMUXOpenCommandTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CMUXOpenCommandTests.swift; sourceTree = "<group>"; };
C0DE31390000000000000112 /* CMUXOpenHTMLFocusTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CMUXOpenHTMLFocusTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
79 changes: 79 additions & 0 deletions cmuxTests/CmuxMainWindowFullScreenCapabilityTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading