diff --git a/.agents/notes/implemented/bug-fix/2026-10-21-macos-hidden-titlebar-native-fullscreen.md b/.agents/notes/implemented/bug-fix/2026-10-21-macos-hidden-titlebar-native-fullscreen.md new file mode 100644 index 0000000000..0a9e3b97cc --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-10-21-macos-hidden-titlebar-native-fullscreen.md @@ -0,0 +1,98 @@ +# Agent 笔记:macOS 隐藏标题栏窗口的原生全屏能力守卫 + +状态:已实现 + +## 先说结论 + +SwiftUI 的 `.windowStyle(.hiddenTitleBar)` 窗口在当前 macOS 工具链下默认被系统标记为 +`fullScreenNone`(禁止原生全屏)。表现为:点击绿色交通灯按钮不会进入独立全屏 +Space,而是在主桌面直接最大化;程序调用 `toggleFullScreen(_:)` 也会被静默拒绝。 + +修复位于 `macos/Sources/Lithe/Views/App/RootView.swift` 的 +`WindowFullScreenCapabilityGuard`(NSViewRepresentable)与 +`WindowFullScreenCapabilityView`(宿主 NSView)。它们挂在 `RootView` 的 +background 上,因此欢迎页、项目工作台和独立文件三类主窗口都被覆盖。开发者新增 +隐藏标题栏主窗口时不需要额外操作;如果将来绿色按钮又表现为"最大化",先检查该 +守卫是否仍然挂在窗口根视图上。 + +## 问题 + +用户报告:从 GitHub 下载的 v0.6.3 在项目工作台窗口点击绿色按钮后,窗口只在主 +桌面最大化,无法进入 macOS 标准的独立全屏桌面(Space)。本地从源码构建的 +debug 版本同样复现。 + +进程内探针确认了三个事实: + +1. 窗口的 `collectionBehavior` 含 `fullScreenNone`(原始值 0x10200 = + `FullScreenNone | Primary`),而全仓库源码从未设置过该标志,它来自 + SwiftUI 对 hiddenTitleBar 窗口的默认行为。 +2. 绿色按钮的 target/action 在窗口创建时就被 AppKit 固化为私有的 + `_setNeedsZoom:`(最大化),而不是全屏窗口应有的 `toggleFullScreen:`。 +3. 只要 `fullScreenNone` 还在,`toggleFullScreen(_:)` 会被静默拒绝。 + +一次性剥离 `fullScreenNone` 不够:SwiftUI 会在后续场景更新中反复重新设置该 +标志。 + +## 决策 + +`WindowFullScreenCapabilityView` 挂在 `RootView` 的 background 上,承担三件事: + +1. `viewDidMoveToWindow` 时订阅宿主窗口的 + `NSWindow.didUpdateNotification`。 +2. 每个窗口 update 周期检查一次 `collectionBehavior`:若含 `fullScreenNone` + 则移除之,并补上 `fullScreenPrimary`。SwiftUI 反复设置,守卫就反复剥离; + 用户点击的瞬间标志位是干净的。 +3. 剥离的同时把绿色按钮重接到 + `zoomButtonClicked(_:)`(先剥离再调用 `toggleFullScreen(_:)`),消除 + "按钮 action 已固化为最大化"与"点击瞬间标志位又被设置"两种竞争。 + +`viewDidMoveToWindow` 与 `NSWindow.didUpdateNotification` 的组合是刻意的: +不使用轮询,也不阻塞主线程;`contains(_:)` 位检查在每次窗口 update 中的开销 +可以忽略。 + +进入原生全屏后 macOS 会隐藏红绿灯按钮,但工作台顶栏仍按普通窗口预留 83pt +(`WorkbenchTopBarMetrics.leadingInset`)的按钮空位,左上角出现一段空白。 +`WindowFullScreenStateReader` 用同一套 `didUpdateNotification` 机制把窗口的 +全屏状态同步到 SwiftUI(`@State isWindowFullScreen`),顶栏的全屏态下将 +leading padding 降为 0,退出全屏后自动恢复预留,行为对齐 IDEA。 + +## 考虑过的备选方案 + +- 在 `LitheWindowCoordinator.attach` 中做一次性归一化:已否决。实测 SwiftUI + 会反复重设标志,一次性剥离无效;而且欢迎页窗口没有活跃 session,根本不会 + 创建 coordinator。 +- 剥离标志但不重接绿色按钮:已否决。按钮的 target/action 是窗口创建时由 + AppKit 配置并缓存的,`collectionBehavior` 变化不会触发重新求值,点击仍然是 + 最大化。 +- 放弃 `.windowStyle(.hiddenTitleBar)` 改用普通标题栏:已否决。会破坏整套 + IDEA 风格的窗口视觉与自定义顶栏布局,代价远大于收益。 + +## 后果 + +收益:主窗口恢复 macOS 标准全屏体验(独立 Space、菜单栏/Dock 自动隐藏), +行为对齐 IntelliJ IDEA;对系统未来行为变化保持钝感——若 SwiftUI 不再设置 +`fullScreenNone`,守卫的 `contains` 检查自然变成空操作。 + +代价与风险:绿色按钮的 target/action 被接管为 +`WindowFullScreenCapabilityView.zoomButtonClicked(_:)`。视图与按钮共享窗口的 +生命周期,非零引用的 `NSButton.target` 不会悬垂;option 点击绿色按钮的窗口 +缩放行为仍由按钮子类在 mouseDown 中处理,不受影响。 + +## 验证 + +- 单元测试 `macos/Tests/LitheTests/WindowFullScreenCapabilityGuardTests.swift` + 覆盖:安装即剥离、按钮重接、SwiftUI 重设后再次剥离(通过 + `NSWindow.didUpdateNotification` 同步触发,无轮询、无实时等待)。 +- 手工验证:启动应用,点击主窗口绿色按钮,确认进入独立全屏 Space(窗口 + `styleMask` 出现 `.fullScreen`、frame 覆盖整屏),再次点击或按 Esc 退出后 + 窗口回到原位置。 +- 回归检查:`./scripts/test-macos.sh`(CI 执行);本地受 Swift 6.2.4 工具链 + 类型检查超时限制无法完整编译时,以 CI 结果为准。 + +## 适用范围 + +- `macos/Sources/Lithe/Views/App/RootView.swift` +- `macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift`(工作台顶栏的全屏态留白) +- `macos/Tests/LitheTests/WindowFullScreenCapabilityGuardTests.swift` +- 仅覆盖经由 `RootView` 呈现的主窗口(欢迎页、项目工作台、独立文件窗口)。 + 设置窗口与软件更新窗口不经过 `RootView`,不受本守卫影响。 diff --git a/macos/Sources/Lithe/Views/App/RootView.swift b/macos/Sources/Lithe/Views/App/RootView.swift index c6b81e2db5..3382aa8b10 100644 --- a/macos/Sources/Lithe/Views/App/RootView.swift +++ b/macos/Sources/Lithe/Views/App/RootView.swift @@ -60,6 +60,73 @@ enum ProjectWindowAppKitDismisser { } } +/// Restores native fullscreen capability on the hosting window. SwiftUI marks +/// hiddenTitleBar windows `fullScreenNone` by default on current macOS, which +/// turns the zoom button into a plain maximize and refuses +/// `toggleFullScreen:`; Lithe follows IDEA and keeps its main windows in the +/// native fullscreen Space. SwiftUI reapplies the flag during scene updates, +/// so the host view keeps re-stripping it for as long as this window lives. +struct WindowFullScreenCapabilityGuard: NSViewRepresentable { + func makeNSView(context: Context) -> NSView { + WindowFullScreenCapabilityView() + } + + func updateNSView(_ view: NSView, context: Context) {} +} + +final class WindowFullScreenCapabilityView: NSView { + override func viewDidMoveToWindow() { + NotificationCenter.default.removeObserver( + self, + name: NSWindow.didUpdateNotification, + object: nil + ) + guard window != nil else { return } + NotificationCenter.default.addObserver( + self, + selector: #selector(windowDidUpdate(_:)), + name: NSWindow.didUpdateNotification, + object: window + ) + refreshFullScreenCapability() + } + + @objc private func windowDidUpdate(_ notification: Notification) { + refreshFullScreenCapability() + } + + /// Zoom-button entry point: clears the refusal, then toggles native + /// fullscreen, so the click cannot land while SwiftUI's flag is set. + /// The view and the button share the window's lifetime, which keeps the + /// unretained NSButton.target valid. + @objc func zoomButtonClicked(_ sender: NSButton?) { + stripFullScreenRefusal(from: window) + window?.toggleFullScreen(sender) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + private func refreshFullScreenCapability() { + guard let window, window.collectionBehavior.contains(.fullScreenNone) else { return } + stripFullScreenRefusal(from: window) + // AppKit wires the zoom button's target and action while the window is + // created, so a button configured while the refusal was still set keeps + // maximizing until it is rewired to the guarded fullscreen toggle. + if let zoomButton = window.standardWindowButton(.zoomButton) { + zoomButton.target = self + zoomButton.action = #selector(zoomButtonClicked(_:)) + } + } + + private func stripFullScreenRefusal(from window: NSWindow?) { + guard let window, window.collectionBehavior.contains(.fullScreenNone) else { return } + window.collectionBehavior.remove(.fullScreenNone) + window.collectionBehavior.insert(.fullScreenPrimary) + } +} + struct RootView: View { let scope: ProjectWindowScope @EnvironmentObject private var projectSessions: ProjectSessionManager @@ -91,6 +158,7 @@ struct RootView: View { minWidth: windowLayout.minimumContentSize.width, minHeight: windowLayout.minimumContentSize.height ) + .background(WindowFullScreenCapabilityGuard()) .background(LitheTheme.window) .tint(LitheTheme.accent) .background { @@ -423,6 +491,12 @@ final class LitheWindowCoordinator: NSObject, NSWindowDelegate { private var nativeWindowCloseTask: Task? private let confirmUnsavedDocuments: @MainActor (any UnsavedDocumentHandling) async -> Bool private var isDetached = false + /// Set when a layout transition was deferred because the window was in + /// native fullscreen; the frame is reconciled on exit. + private var needsFrameReconcileAfterFullScreen = false + /// Injectable for tests: real transitions set `.fullScreen` on the style + /// mask, and AppKit throws when tests write it outside its own transition. + var isInFullScreen: (NSWindow) -> Bool = { $0.styleMask.contains(.fullScreen) } init( projectSessions: any ProjectWindowSessionHandling, @@ -489,6 +563,12 @@ final class LitheWindowCoordinator: NSObject, NSWindowDelegate { func windowDidExitFullScreen(_ notification: Notification) { guard let window, notification.object as? NSWindow === window, let layout else { return } + if needsFrameReconcileAfterFullScreen { + needsFrameReconcileAfterFullScreen = false + // AppKit restored the pre-fullscreen frame of the old layout; + // reapply the current layout's frame before the buttons align. + applyFrame(for: layout, to: window, animate: false) + } alignWindowButtons(in: window, for: layout) } @@ -625,6 +705,19 @@ final class LitheWindowCoordinator: NSObject, NSWindowDelegate { // only for a new window/role, not every model update during layout. window.contentMinSize = layout.minimumContentSize + // setFrame on a native-fullscreen window shrinks it to a centered box + // inside its Space (content surrounded by black), so a layout + // transition that happens while fullscreen defers the frame swap + // until the user leaves fullscreen. + if isInFullScreen(window) { + needsFrameReconcileAfterFullScreen = true + return + } + applyFrame(for: layout, to: window, animate: shouldAnimate) + alignWindowButtons(in: window, for: layout) + } + + private func applyFrame(for layout: LitheWindowLayout, to window: NSWindow, animate: Bool) { let currentFrame = window.frame let visibleFrame = (window.screen ?? NSScreen.main)?.visibleFrame let targetContentSize: NSSize @@ -642,8 +735,7 @@ final class LitheWindowCoordinator: NSObject, NSWindowDelegate { if let visibleFrame { targetFrame = LitheWindowLayout.frame(targetFrame, fitting: visibleFrame) } - window.setFrame(targetFrame, display: true, animate: shouldAnimate) - alignWindowButtons(in: window, for: layout) + window.setFrame(targetFrame, display: true, animate: animate) } private func alignWindowButtons(in window: NSWindow, for layout: LitheWindowLayout) { diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift index d4e5b2f792..4a325862ff 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift @@ -67,6 +67,65 @@ private enum WorkbenchTopBarMetrics { static let projectAvatarCenterX = leadingInset + projectAvatarLeadingInset + projectAvatarSize / 2 } +/// Reports whether the hosting window is in native fullscreen. macOS hides +/// the traffic-light controls there, so the workbench toolbar must not +/// reserve their leading space. The window update pass drives the binding, +/// which keeps the transition deterministic without polling. +struct WindowFullScreenStateReader: NSViewRepresentable { + @Binding var isFullScreen: Bool + + func makeNSView(context: Context) -> ReaderView { + let view = ReaderView() + view.onRefresh = { value in + if isFullScreen != value { isFullScreen = value } + } + return view + } + + func updateNSView(_ view: ReaderView, context: Context) { + view.onRefresh = { value in + if isFullScreen != value { isFullScreen = value } + } + view.refresh() + } + + final class ReaderView: NSView { + var onRefresh: ((Bool) -> Void)? + /// Injectable so tests can drive fullscreen state without starting a + /// real transition: writing the style mask directly throws outside + /// AppKit's own fullscreen flow. + var isFullScreen: (NSWindow) -> Bool = { $0.styleMask.contains(.fullScreen) } + + override func viewDidMoveToWindow() { + NotificationCenter.default.removeObserver( + self, + name: NSWindow.didUpdateNotification, + object: nil + ) + guard window != nil else { return } + NotificationCenter.default.addObserver( + self, + selector: #selector(windowDidUpdate(_:)), + name: NSWindow.didUpdateNotification, + object: window + ) + refresh() + } + + @objc private func windowDidUpdate(_ notification: Notification) { + refresh() + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + func refresh() { + onRefresh?(window.map(isFullScreen) ?? false) + } + } +} + enum WorkbenchFrameGradient { static let coordinateSpace = "workbenchFrame" static let width: CGFloat = 600 @@ -242,6 +301,7 @@ struct WorkbenchView: View { @State private var isBackgroundPickerPresented = false @State private var isRunConfigurationPickerPresented = false @State private var isSettingsMenuPresented = false + @State private var isWindowFullScreen = false var body: some View { let encodingRequest = model.pendingEncodingReopen @@ -794,7 +854,7 @@ struct WorkbenchView: View { settingsMenuButton } } - .padding(.leading, WorkbenchTopBarMetrics.leadingInset) + .padding(.leading, isWindowFullScreen ? 0 : WorkbenchTopBarMetrics.leadingInset) .frame(height: LitheTheme.Metrics.toolbarHeight) .background { frameChromeBackground @@ -804,6 +864,7 @@ struct WorkbenchView: View { .toggleWorkspaceZoom() } } + .background(WindowFullScreenStateReader(isFullScreen: $isWindowFullScreen)) } private func registeredToolbarButton(_ contribution: ModuleContribution) -> some View { diff --git a/macos/Tests/LitheTests/WindowFullScreenCapabilityGuardTests.swift b/macos/Tests/LitheTests/WindowFullScreenCapabilityGuardTests.swift new file mode 100644 index 0000000000..74c5033a70 --- /dev/null +++ b/macos/Tests/LitheTests/WindowFullScreenCapabilityGuardTests.swift @@ -0,0 +1,184 @@ +import AppKit +import Testing +@testable import Lithe + +@Suite("Window fullscreen capability guard") +@MainActor +struct WindowFullScreenCapabilityGuardTests { + @Test + func guardStripsSwiftUIFullScreenRefusalWhenInstalled() throws { + let window = Self.makeWindow() + defer { window.close() } + window.collectionBehavior.insert(.fullScreenNone) + + window.contentView?.addSubview(WindowFullScreenCapabilityView()) + + #expect(!window.collectionBehavior.contains(.fullScreenNone)) + #expect(window.collectionBehavior.contains(.fullScreenPrimary)) + } + + @Test + func guardRewiresZoomButtonToTheGuardedFullscreenToggle() throws { + let window = Self.makeWindow() + defer { window.close() } + window.collectionBehavior.insert(.fullScreenNone) + + let guardView = WindowFullScreenCapabilityView() + window.contentView?.addSubview(guardView) + + // AppKit configures the zoom button while the window is created, so a + // button configured under the refusal would keep maximizing until the + // guard rewires it. + let zoomButton = try #require(window.standardWindowButton(.zoomButton)) + #expect(zoomButton.target as? WindowFullScreenCapabilityView === guardView) + #expect(zoomButton.action == #selector(WindowFullScreenCapabilityView.zoomButtonClicked(_:))) + } + + @Test + func guardRestripsTheRefusalWhenSwiftUIReappliesIt() throws { + let window = Self.makeWindow() + defer { window.close() } + window.collectionBehavior.insert(.fullScreenNone) + + window.contentView?.addSubview(WindowFullScreenCapabilityView()) + #expect(!window.collectionBehavior.contains(.fullScreenNone)) + + // SwiftUI reapplies the flag during scene updates; the next window + // update pass must strip it again so the zoom button stays usable. + window.collectionBehavior.insert(.fullScreenNone) + NotificationCenter.default.post(name: NSWindow.didUpdateNotification, object: window) + + #expect(!window.collectionBehavior.contains(.fullScreenNone)) + #expect(window.collectionBehavior.contains(.fullScreenPrimary)) + } + + private static func makeWindow() -> NSWindow { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 400, height: 300), + styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + return window + } +} + +@Suite("Window fullscreen state reader") +@MainActor +struct WindowFullScreenStateReaderTests { + @Test + func readerTracksFullScreenTransitionsThroughWindowUpdates() throws { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 400, height: 300), + styleMask: [.titled, .closable, .miniaturizable, .resizable], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + defer { window.close() } + + var reported: [Bool] = [] + let reader = WindowFullScreenStateReader.ReaderView() + reader.onRefresh = { value in reported.append(value) } + window.contentView?.addSubview(reader) + #expect(reported == [false]) + + // AppKit throws when tests write the style mask outside a real + // fullscreen transition, so the state is driven through the + // injectable predicate instead. + reader.isFullScreen = { _ in true } + NotificationCenter.default.post(name: NSWindow.didUpdateNotification, object: window) + #expect(reported == [false, true]) + + reader.isFullScreen = { _ in false } + NotificationCenter.default.post(name: NSWindow.didUpdateNotification, object: window) + #expect(reported == [false, true, false]) + } +} + +@Suite("Window layout transition while fullscreen") +@MainActor +struct WindowLayoutFullScreenTransitionTests { + private final class Sessions: ProjectWindowSessionHandling { + var hasUnsavedDocuments = false + var closingDocuments: [EditorDocument] = [] + var unsavedDocumentNames: [String] = [] + var hasActiveProject = false + var hasActiveStandaloneFile = false + var shouldDismissWindowWhenClosingActiveSession = false + var windowScope: ProjectWindowScope = .primary + + @discardableResult + func saveAllDocuments() async -> Bool { true } + func closeActiveProject() {} + func requestCloseActiveWorkbenchItem() -> Bool { false } + func requestCloseActiveSession() -> Bool { true } + func resetForProjectWindowClose() async {} + func noteWindowBecameKey() {} + } + + @Test + func layoutTransitionWhileFullScreenDefersFrameSwapUntilExit() throws { + let window = NSWindow( + contentRect: NSRect(x: 100, y: 100, width: 800, height: 650), + styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + defer { window.close() } + + let coordinator = LitheWindowCoordinator(projectSessions: Sessions()) + coordinator.attach(to: window, layout: .welcome, title: "Welcome") + let welcomeWidth = window.frame.width + #expect(abs(welcomeWidth - LitheWindowLayout.welcomeContentSize.width) < 1) + + // 欢迎页全屏后打开项目:全屏期间不得把窗口缩成居中的工作台默认尺寸 + coordinator.isInFullScreen = { _ in true } + coordinator.attach(to: window, layout: .workspace, title: "Project") + #expect(abs(window.frame.width - welcomeWidth) < 1) + + // 退出全屏后补一次换框,落回工作台默认尺寸。期望值必须经过与 + // applyFrame 相同的屏幕钳制:CI 的虚拟显示器只有 1024pt 宽, + // 不能直接断言 1440。 + coordinator.isInFullScreen = { _ in false } + coordinator.windowDidExitFullScreen( + Notification(name: NSWindow.didExitFullScreenNotification, object: window) + ) + let expectedWorkspaceWidth: CGFloat + if let visibleFrame = (window.screen ?? NSScreen.main)?.visibleFrame { + expectedWorkspaceWidth = LitheWindowLayout.frame( + NSRect(origin: .zero, size: LitheWindowLayout.workspaceContentSize), + fitting: visibleFrame + ).width + } else { + // applyFrame 只在有可见屏幕时钳制;无屏幕环境不缩放。 + expectedWorkspaceWidth = LitheWindowLayout.workspaceContentSize.width + } + #expect(abs(window.frame.width - expectedWorkspaceWidth) < 1) + } + + @Test + func directFullScreenExitWithoutLayoutChangeKeepsRestoredFrame() throws { + let window = NSWindow( + contentRect: NSRect(x: 100, y: 100, width: 800, height: 650), + styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + defer { window.close() } + + let coordinator = LitheWindowCoordinator(projectSessions: Sessions()) + coordinator.attach(to: window, layout: .welcome, title: "Welcome") + let welcomeWidth = window.frame.width + + // 全屏前全屏后布局未变:退出时不做换框,保留退出前的窗口尺寸 + coordinator.isInFullScreen = { _ in true } + coordinator.windowDidExitFullScreen( + Notification(name: NSWindow.didExitFullScreenNotification, object: window) + ) + #expect(abs(window.frame.width - welcomeWidth) < 1) + } +} diff --git a/shared/platform-feature-matrix/features/window-controls.json b/shared/platform-feature-matrix/features/window-controls.json index fe1e0c181b..a5e2878b6e 100644 --- a/shared/platform-feature-matrix/features/window-controls.json +++ b/shared/platform-feature-matrix/features/window-controls.json @@ -2,12 +2,13 @@ "id": "window-controls", "area": "工作台", "group": "窗口管理", - "capability": "标题栏控制窗口最小化、最大化/还原与关闭;命令面板可最小化、最大化及切换全屏", + "capability": "标题栏控制窗口最小化、最大化/还原与关闭;macOS 绿色按钮可进入原生全屏 Space(而非主桌面最大化);命令面板可最小化、最大化及切换全屏", "macos": { "evidence": [ "macos/Sources/Lithe/LitheApp.swift", "macos/Sources/Lithe/Views/App/RootView.swift", - "macos/Tests/LitheTests/LitheCoreLogicTests.swift" + "macos/Tests/LitheTests/LitheCoreLogicTests.swift", + "macos/Tests/LitheTests/WindowFullScreenCapabilityGuardTests.swift" ], "implementationStatus": "implemented", "verificationStatus": "pending" @@ -22,5 +23,5 @@ "verificationStatus": "pending" }, "owner": "Workbench", - "verification": "在 macOS 检查系统标题栏控制及全屏入口;在 Windows 验证标题栏最小化、最大化/还原和关闭,以及命令面板的窗口操作,确认图标语义、最大化/还原状态与按钮标签一致,并确认悬停关闭按钮背景贴合窗口右边缘。 macOS 同一窗口角色下更新模型和标题时保留 SwiftUI 宿主测量的最小内容尺寸,切换欢迎页/工作台角色仍应用对应尺寸,避免重复覆盖引发窗口布局反馈。" + "verification": "在 macOS 检查系统标题栏控制及全屏入口,并确认绿色按钮进入独立全屏 Space(窗口 styleMask 出现 fullScreen、frame 覆盖整屏)而不是在主桌面最大化,退出后窗口回到原位置;进入全屏后确认工作台顶栏内容从左缘开始、不再保留红绿灯按钮空位,退出全屏后恢复按钮预留;在 Windows 验证标题栏最小化、最大化/还原和关闭,以及命令面板的窗口操作,确认图标语义、最大化/还原状态与按钮标签一致,并确认悬停关闭按钮背景贴合窗口右边缘。 macOS 同一窗口角色下更新模型和标题时保留 SwiftUI 宿主测量的最小内容尺寸,切换欢迎页/工作台角色仍应用对应尺寸,避免重复覆盖引发窗口布局反馈。" }