diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index f5fd72e3989..d6266428ec5 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -2,40 +2,39 @@ # Format: max_linesrelative path # Reduce counts as files shrink. CI fails if tracked files exceed this budget. 33454 CLI/cmux.swift -20034 Sources/Workspace.swift -19272 Sources/ContentView.swift -18130 Sources/AppDelegate.swift -16680 Sources/GhosttyTerminalView.swift -14774 Sources/TerminalController.swift -13611 Sources/Panels/BrowserPanel.swift +19204 Sources/ContentView.swift +17913 Sources/AppDelegate.swift +14610 Sources/TerminalController.swift +13577 Sources/Panels/BrowserPanel.swift +12084 Sources/GhosttyTerminalView.swift 12046 cmuxTests/AppDelegateShortcutRoutingTests.swift -10020 Sources/TabManager.swift +12020 Sources/Workspace.swift 9345 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift 7850 Sources/Panels/BrowserPanelView.swift 7349 cmuxTests/WorkspaceUnitTests.swift -6948 cmuxTests/WorkspaceRemoteConnectionTests.swift -6555 cmuxTests/GhosttyConfigTests.swift -6332 cmuxTests/SessionPersistenceTests.swift -6299 cmuxTests/TerminalAndGhosttyTests.swift +6944 cmuxTests/WorkspaceRemoteConnectionTests.swift +6316 cmuxTests/SessionPersistenceTests.swift +6296 cmuxTests/GhosttyConfigTests.swift 6153 CLI/cmux_open.swift +6074 Sources/TabManager.swift 6074 Sources/TextBoxInput.swift +5969 cmuxTests/TerminalAndGhosttyTests.swift 5500 cmuxTests/BrowserConfigTests.swift 5487 Sources/cmuxApp.swift 4938 Packages/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift 4460 Sources/Panels/FilePreviewPanel.swift 4400 cmuxTests/BrowserPanelTests.swift 4227 Sources/BrowserWindowPortal.swift -4009 cmuxTests/WindowAndDragTests.swift 3937 Sources/Feed/FeedPanelView.swift +3895 cmuxTests/WindowAndDragTests.swift 3764 cmuxTests/TabManagerUnitTests.swift 3699 cmuxTests/CLIGenericHookPersistenceTests.swift -3665 Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift +3664 Packages/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttySurfaceView.swift 3397 Sources/CmuxConfig.swift 3331 cmuxTests/TabManagerSessionSnapshotTests.swift 3202 Sources/Update/UpdateTitlebarAccessory.swift 2877 Sources/SessionIndexView.swift 2871 cmuxTests/CMUXOpenCommandTests.swift -2623 Sources/TerminalNotificationStore.swift 2573 Sources/KeyboardShortcutSettings.swift 2565 Sources/Panels/CmuxWebView.swift 2545 cmuxTests/WorkspaceManualUnreadTests.swift @@ -44,10 +43,11 @@ 2328 cmuxTests/CJKIMEInputTests.swift 2314 Sources/FileExplorerView.swift 2261 Sources/TerminalWindowPortal.swift -2221 Sources/SessionPersistence.swift +2236 Sources/TerminalNotificationStore.swift 2134 cmuxTests/ShortcutAndCommandPaletteTests.swift 2117 cmuxTests/CmuxConfigTests.swift -2031 Sources/KeyboardShortcutSettingsFileStore.swift +2059 Sources/SessionPersistence.swift +2034 Sources/KeyboardShortcutSettingsFileStore.swift 1949 Sources/Panels/BrowserWebAuthnSupport.swift 1860 cmuxTests/NotificationAndMenuBarTests.swift 1794 Sources/SessionIndexStore.swift @@ -61,14 +61,14 @@ 1560 cmuxTests/TextBoxMentionCompletionTests.swift 1498 cmuxTests/OmnibarAndToolsTests.swift 1496 cmuxUITests/MultiWindowNotificationsUITests.swift -1448 Sources/FileExplorerStore.swift +1446 Sources/FileExplorerStore.swift 1410 Sources/CommandPalette/CommandPaletteSearch.swift 1380 cmuxUITests/MenuKeyEquivalentRoutingUITests.swift 1376 cmuxTests/KeyboardShortcutSettingsFileStoreStartupTests.swift 1373 cmuxTests/AppDelegateIssue2907RoutingTests.swift 1366 Sources/Feed/FeedButtonStyleDebugWindowController.swift 1362 Sources/CMUXInstalledExtensionSidebarHostView.swift -1313 cmuxTests/MobileHostAuthorizationTests.swift +1312 cmuxTests/MobileHostAuthorizationTests.swift 1292 Packages/CmuxTerminalCore/Sources/CmuxTerminalCore/Config/GhosttyConfig.swift 1285 cmuxUITests/SidebarHelpMenuUITests.swift 1270 cmuxTests/RestorableAgentSessionIndexTests.swift @@ -81,29 +81,28 @@ 1126 cmuxTests/FileExplorerStoreTests.swift 1120 cmuxTests/AgentHibernationTests.swift 1107 Sources/AppDelegate+CmuxSSHURL.swift -1096 Sources/GhosttyConfig.swift 1093 cmuxUITests/BonsplitTabDragUITests.swift 1021 cmuxUITests/TerminalCmdClickUITests.swift 1006 cmuxTests/CmuxSSHURLRequestTests.swift 1000 cmuxTests/CmuxTopSnapshotScopeTests.swift 947 Sources/TerminalNotificationPolicy.swift 945 Sources/SessionIndexRegisteredAgents.swift -944 Sources/App/ShortcutRoutingSupport.swift 941 Sources/App/TerminalDirectoryOpenSupport.swift 937 Sources/TextBoxMentionIndexStore.swift +934 Sources/App/ShortcutRoutingSupport.swift 926 Sources/DockPanelView.swift 919 Packages/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+RuntimeLifecycle.swift 917 cmuxTests/WorkspaceGroupTests.swift +913 Sources/CommandPalette/CommandPaletteSettingsToggle.swift 905 Sources/CmuxSSHURLRequest.swift -903 Sources/CommandPalette/CommandPaletteSettingsToggle.swift -897 Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift +901 Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift 892 Sources/WorkspaceContentView.swift 868 Sources/Panels/BrowserScreenshotSnapshotter.swift 866 Sources/Panels/TerminalPanel.swift 852 Packages/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlCommandCoordinator+Workspace.swift 847 cmuxTests/AgentSessionAutoResumeSettingsTests.swift 845 cmuxTests/SSHStartupSignalLifecycleTests.swift -842 Sources/Panels/MarkdownWebRenderer.swift +841 Sources/Panels/MarkdownWebRenderer.swift 830 Sources/TaskManagerTypes.swift 810 Packages/CmuxSwiftRender/Tests/CmuxSwiftRenderTests/SwiftViewInterpreterTests.swift 787 Sources/ClosedItemHistory.swift @@ -111,12 +110,12 @@ 770 Sources/MainWindowFocusController.swift 762 Packages/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxNetworkByteTransport.swift 760 Packages/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchSanitizerTests.swift -757 Packages/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator.swift 756 Sources/Panels/AgentSessionWebRendererCoordinator.swift -753 Sources/TerminalController+ControlWorkspaceContext.swift +752 Sources/TerminalController+ControlWorkspaceContext.swift 752 cmuxUITests/CloseWorkspaceCmdDUITests.swift -739 Sources/App/MenuBarExtraController.swift +746 Sources/App/MenuBarExtraController.swift 738 Packages/CMUXProjectModel/Sources/CMUXProjectModel/XcodeProjectAdapter.swift +738 Packages/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator.swift 716 Sources/TaskManagerSnapshot.swift 715 Packages/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Input.swift 715 Sources/AppleScriptSupport.swift @@ -144,7 +143,6 @@ 648 Packages/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator.swift 640 cmuxTests/CommandPaletteNucleoFFITests.swift 630 Packages/CmuxSettings/Sources/CmuxSettings/Values/ShortcutWhenClause.swift -627 Sources/WorkspaceRemoteConfiguration.swift 621 cmuxTests/FinderFileDropRegressionTests.swift 621 cmuxUITests/RightSidebarChromeHeightUITests.swift 620 cmuxTests/TerminalNotificationQueueTests.swift diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/DefaultsValueModel.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/DefaultsValueModel.swift index d63786e41c1..35b915c9b05 100644 --- a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/DefaultsValueModel.swift +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/DefaultsValueModel.swift @@ -86,6 +86,15 @@ public final class DefaultsValueModel { } } + /// Updates ``current`` after another owner has already persisted `value`. + /// + /// Use this for settings whose committed write spans multiple backing keys + /// and must stay in one host-owned mutation path. Unlike ``set(_:)``, this + /// method does not write to ``store``. + public func acceptCommittedValue(_ value: Value) { + current = value + } + /// Removes the override; ``current`` updates when the stream observes /// the reset. public func reset() { diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsHostActions.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsHostActions.swift index 82cf4c55b35..7dd701ec74b 100644 --- a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsHostActions.swift +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Environment/SettingsHostActions.swift @@ -53,6 +53,15 @@ public protocol SettingsHostActions: AnyObject { /// window scene so the package can't open it directly. func openTerminalConfigWindow() + /// Persists an explicit menu-bar-only preference change in the host app. + /// + /// The host pairs the visible `app.menuBarOnly` setting with any hidden + /// safety marker it needs before changing the process activation policy. + /// + /// - Returns: `true` when the host handled persistence for this change. + @discardableResult + func setMenuBarOnly(_ enabled: Bool) -> Bool + /// Opens the iOS pairing window, which shows a scannable QR code for /// pairing an iPhone with this Mac. The host owns the window so the /// package can't open it directly. @@ -142,6 +151,9 @@ public protocol SettingsHostActions: AnyObject { public extension SettingsHostActions { func openMobilePairingWindow() {} + /// Default no-op for package previews and tests that have no activation-policy host. + func setMenuBarOnly(_ enabled: Bool) -> Bool { false } + func browserHistoryEntryCount() -> Int? { nil } /// Default: no status, for hosts without a live mobile service (previews/tests). diff --git a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift index 7b3ca026b39..9d9e86b390a 100644 --- a/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift +++ b/Packages/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift @@ -478,7 +478,11 @@ public struct AppSection: View { String(localized: "settings.app.menuBarOnly", defaultValue: "Menu Bar Only"), subtitle: String(localized: "settings.app.menuBarOnly.subtitle", defaultValue: "Hide the Dock icon and Cmd+Tab entry. Use the menu bar item to show cmux.") ) { - Toggle("", isOn: Binding(get: { menuBarOnly.current }, set: { menuBarOnly.set($0) })) + Toggle("", isOn: Binding(get: { menuBarOnly.current }, set: { enabled in + if hostActions.setMenuBarOnly(enabled) { + menuBarOnly.acceptCommittedValue(enabled) + } + })) .labelsHidden() .controlSize(.small) .accessibilityIdentifier("SettingsMenuBarOnlyToggle") diff --git a/Packages/CmuxSettingsUI/Tests/CmuxSettingsUITests/DefaultsValueModelLifecycleTests.swift b/Packages/CmuxSettingsUI/Tests/CmuxSettingsUITests/DefaultsValueModelLifecycleTests.swift index 070c02088a3..4ae0520e669 100644 --- a/Packages/CmuxSettingsUI/Tests/CmuxSettingsUITests/DefaultsValueModelLifecycleTests.swift +++ b/Packages/CmuxSettingsUI/Tests/CmuxSettingsUITests/DefaultsValueModelLifecycleTests.swift @@ -90,4 +90,24 @@ import Testing model.reset() #expect(model.current == false) } + + @Test func acceptCommittedValueUpdatesCurrentWithoutStoreWrite() { + let suiteName = "defaults-value-model-committed-value" + UserDefaults(suiteName: suiteName)?.removePersistentDomain(forName: suiteName) + let store = UserDefaultsSettingsStore( + defaults: UserDefaults(suiteName: suiteName)! + ) + let key = SettingCatalog().betaFeatures.extensions + let (stream, _) = AsyncStream.makeStream() + let model = DefaultsValueModel( + store: store, + key: key, + makeStream: { stream } + ) + + #expect(model.current == false) + model.acceptCommittedValue(true) + #expect(model.current == true) + #expect(UserDefaults(suiteName: suiteName)?.object(forKey: key.userDefaultsKey) == nil) + } } diff --git a/Sources/App/MenuBarExtraController.swift b/Sources/App/MenuBarExtraController.swift index 91de2113723..a7308f80eea 100644 --- a/Sources/App/MenuBarExtraController.swift +++ b/Sources/App/MenuBarExtraController.swift @@ -519,13 +519,20 @@ enum MenuBarExtraSettings { enum MenuBarOnlySettings { static let menuBarOnlyKey = "menuBarOnly" + static let explicitEnableKey = "menuBarOnlyExplicitlyEnabled.v1" static let defaultMenuBarOnly = false static func isEnabled(defaults: UserDefaults = .standard) -> Bool { - if defaults.object(forKey: menuBarOnlyKey) == nil { - return defaultMenuBarOnly + guard defaults.object(forKey: menuBarOnlyKey) != nil, defaults.bool(forKey: menuBarOnlyKey) else { return defaultMenuBarOnly } + if defaults.object(forKey: explicitEnableKey) != nil { + return defaults.bool(forKey: explicitEnableKey) } - return defaults.bool(forKey: menuBarOnlyKey) + return !legacyCommandPaletteOneShotLikelyEnabledMenuBarOnly(defaults: defaults) + } + + static func setEnabled(_ enabled: Bool, defaults: UserDefaults = .standard) { + defaults.set(enabled, forKey: menuBarOnlyKey) + defaults.set(enabled, forKey: explicitEnableKey) } static func activationPolicy(defaults: UserDefaults = .standard) -> NSApplication.ActivationPolicy { diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 44d1ba3e790..b5438d551f8 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1110,6 +1110,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if isRunningUnderXCTest { NSApp.setActivationPolicy(.regular) } else { + MenuBarOnlySettings.normalizeLegacyStoredPreference() syncActivationPolicy() } StartupBreadcrumbLog.append("appDelegate.didFinish.activationPolicy.synced") @@ -8418,6 +8419,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } private func syncApplicationPresentationPreferences(defaults: UserDefaults = .standard) { + MenuBarOnlySettings.normalizeLegacyStoredPreference(defaults: defaults) syncActivationPolicy(defaults: defaults) syncMenuBarExtraVisibility(defaults: defaults) } diff --git a/Sources/CommandPalette/CommandPaletteSettingsToggle.swift b/Sources/CommandPalette/CommandPaletteSettingsToggle.swift index 1713da28541..0642210cf59 100644 --- a/Sources/CommandPalette/CommandPaletteSettingsToggle.swift +++ b/Sources/CommandPalette/CommandPaletteSettingsToggle.swift @@ -1,6 +1,27 @@ import Foundation import CmuxSettings +extension MenuBarOnlySettings { + static let legacyCommandPaletteUsageKey = "commandPalette.commandUsage.v1" + static let legacyCommandPaletteMenuBarOnlyCommandId = "palette.toggleSetting.menuBarOnly" + + static func normalizeLegacyStoredPreference(defaults: UserDefaults = .standard) { + guard defaults.object(forKey: menuBarOnlyKey) != nil, + defaults.bool(forKey: menuBarOnlyKey), + defaults.object(forKey: explicitEnableKey) == nil else { return } + setEnabled(!legacyCommandPaletteOneShotLikelyEnabledMenuBarOnly(defaults: defaults), defaults: defaults) + } + + static func legacyCommandPaletteOneShotLikelyEnabledMenuBarOnly(defaults: UserDefaults = .standard) -> Bool { + guard let data = defaults.data(forKey: legacyCommandPaletteUsageKey) else { return false } + guard let history = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return true } + guard history.count == 1, let entry = history[legacyCommandPaletteMenuBarOnlyCommandId] else { return false } + guard let usage = entry as? [String: Any] else { return true } + guard (usage["useCount"] as? NSNumber)?.intValue == 1 else { return false } + return ((usage["lastUsedAt"] as? NSNumber)?.doubleValue ?? 0) > 0 + } +} + struct CommandPaletteSettingToggleDescriptor: Sendable { let commandId: String let settingsKey: String @@ -268,17 +289,6 @@ enum CommandPaletteSettingsToggleCommands { defaultValue: NotificationBadgeSettings.defaultDockBadgeEnabled, defaultsKey: NotificationBadgeSettings.dockBadgeEnabledKey ), - CommandPaletteSettingToggleDescriptor( - commandId: commandIdPrefix + "menuBarOnly", - settingsKey: "app.menuBarOnly", - title: { - String(localized: "settings.app.menuBarOnly", defaultValue: "Menu Bar Only") - }, - sectionTitle: app, - keywords: ["app.menuBarOnly", "menu", "bar", "dock", "cmd-tab", "app", "switcher"], - defaultValue: MenuBarOnlySettings.defaultMenuBarOnly, - defaultsKey: MenuBarOnlySettings.menuBarOnlyKey - ), CommandPaletteSettingToggleDescriptor( commandId: commandIdPrefix + "showInMenuBar", settingsKey: "notifications.showInMenuBar", diff --git a/Sources/HostSettingsActions.swift b/Sources/HostSettingsActions.swift index 8e59540a0eb..c744c034697 100644 --- a/Sources/HostSettingsActions.swift +++ b/Sources/HostSettingsActions.swift @@ -141,6 +141,11 @@ final class HostSettingsActions: SettingsHostActions { window.orderFrontRegardless() } + func setMenuBarOnly(_ enabled: Bool) -> Bool { + MenuBarOnlySettings.setEnabled(enabled) + return true + } + func openMobilePairingWindow() { MobilePairingWindowController.shared.show() } diff --git a/Sources/KeyboardShortcutSettingsFileStore.swift b/Sources/KeyboardShortcutSettingsFileStore.swift index 965ab12401a..d8b84f23323 100644 --- a/Sources/KeyboardShortcutSettingsFileStore.swift +++ b/Sources/KeyboardShortcutSettingsFileStore.swift @@ -422,6 +422,9 @@ final class CmuxSettingsFileStore { } if let value = jsonBool(section["menuBarOnly"]) { snapshot.managedUserDefaults[MenuBarOnlySettings.menuBarOnlyKey] = .bool(value) + if value { + snapshot.managedUserDefaults[MenuBarOnlySettings.explicitEnableKey] = .bool(true) + } } if let raw = jsonString(section["windowTitleTemplate"]) { snapshot.managedUserDefaults[WindowTitleTemplate.userDefaultsKey] = .string(raw) } else if section.keys.contains("windowTitleTemplate") { logInvalid("app.windowTitleTemplate", sourcePath: sourcePath) } if let raw = jsonString(section["newWorkspacePlacement"]) { diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 79188647c74..76ea27e113b 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -477,6 +477,7 @@ A50014F5 /* MarkdownWebRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50014F4 /* MarkdownWebRenderer.swift */; }; A50014F9 /* MarkdownWebSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50014F8 /* MarkdownWebSupport.swift */; }; 1A8BEE693C9E4C3190CB7F20 /* MenuBarExtraController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7934BB35B66491B1BCA8064 /* MenuBarExtraController.swift */; }; + 606500010000000000000001 /* MenuBarOnlyActivationPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 606500010000000000000002 /* MenuBarOnlyActivationPolicyTests.swift */; }; 3865A0033865A0033865A003 /* MenubarSearchPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3865B0033865B0033865B003 /* MenubarSearchPopover.swift */; }; E1000000A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */; }; C0DE3150A00000000000001 /* MinimalModeSidebarControls.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE3150A00000000000002 /* MinimalModeSidebarControls.swift */; }; @@ -1327,6 +1328,7 @@ A50014F4 /* MarkdownWebRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/MarkdownWebRenderer.swift; sourceTree = ""; }; A50014F8 /* MarkdownWebSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/MarkdownWebSupport.swift; sourceTree = ""; }; C7934BB35B66491B1BCA8064 /* MenuBarExtraController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/MenuBarExtraController.swift; sourceTree = ""; }; + 606500010000000000000002 /* MenuBarOnlyActivationPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarOnlyActivationPolicyTests.swift; sourceTree = ""; }; 3865B0033865B0033865B003 /* MenubarSearchPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Search/MenubarSearchPopover.swift; sourceTree = ""; }; E1000001A1B2C3D4E5F60718 /* MenuKeyEquivalentRoutingUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuKeyEquivalentRoutingUITests.swift; sourceTree = ""; }; C0DE3150A00000000000002 /* MinimalModeSidebarControls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/MinimalModeSidebarControls.swift; sourceTree = ""; }; @@ -2595,6 +2597,7 @@ C3408A000000000000000004 /* RightSidebarCommandPaletteTests.swift */, C0DE48000000000000000002 /* SidebarProviderMenuRegressionTests.swift */, C0DEF4120000000000000002 /* CommandPaletteSettingsToggleTests.swift */, + 606500010000000000000002 /* MenuBarOnlyActivationPolicyTests.swift */, B37A0000000000000000000C /* FileExplorerStateModePersistenceTests.swift */, BC39DE4B96D1931C52AF7D68 /* SidebarOrderingTests.swift */, 86544CEFA1CA33CA9225FB6E /* MobileWorkspaceListFidelityTests.swift */, @@ -3801,6 +3804,7 @@ 17C9F5BA0DD14EDC8C3E5001 /* MainWindowVisibilityControllerTests.swift in Sources */, D6069002D6069002D6069002 /* MarkdownMermaidZoomTests.swift in Sources */, D3664002D3664002D3664002 /* MarkdownPanelTests.swift in Sources */, + 606500010000000000000001 /* MenuBarOnlyActivationPolicyTests.swift in Sources */, 6AA2F2E8BEB19ED6B8E125DB /* MobileHostAuthorizationTests.swift in Sources */, DE71CE000000000000000006 /* MobileHostNetworkPathRefreshTests.swift in Sources */, C0DE10510000000000000001 /* MobileHostServiceSettingsTests.swift in Sources */, diff --git a/cmuxTests/MenuBarOnlyActivationPolicyTests.swift b/cmuxTests/MenuBarOnlyActivationPolicyTests.swift new file mode 100644 index 00000000000..5ab756a480b --- /dev/null +++ b/cmuxTests/MenuBarOnlyActivationPolicyTests.swift @@ -0,0 +1,232 @@ +import AppKit +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +@Suite(.serialized) +struct MenuBarOnlyActivationPolicyTests { + private let settingsFileBackupsDefaultsKey = "cmux.settingsFile.backups.v1" + private let importedManagedDefaultsKey = "cmux.settingsFile.importedManagedDefaults.v1" + + @Test func commandPaletteToggleIsNotExposedAsInstantToggle() { + #expect( + CommandPaletteSettingsToggleCommands.descriptor( + commandId: "palette.toggleSetting.menuBarOnly" + ) == nil + ) + #expect( + !ContentView.commandPaletteSettingsToggleCommandContributions() + .contains { $0.commandId == "palette.toggleSetting.menuBarOnly" } + ) + } + + @Test func isolatedOneShotCommandHistoryDoesNotEnableAccessoryPolicy() throws { + try withTemporaryDefaults { defaults in + defaults.set(true, forKey: MenuBarOnlySettings.menuBarOnlyKey) + defaults.set( + try JSONSerialization.data(withJSONObject: [ + MenuBarOnlySettings.legacyCommandPaletteMenuBarOnlyCommandId: [ + "useCount": 1, + "lastUsedAt": 1_700_000_000, + ], + ]), + forKey: MenuBarOnlySettings.legacyCommandPaletteUsageKey + ) + + #expect(!MenuBarOnlySettings.isEnabled(defaults: defaults)) + #expect(MenuBarOnlySettings.activationPolicy(defaults: defaults) == .regular) + #expect(!MenuBarOnlySettings.shouldShowMainWindowMenuItem(defaults: defaults)) + + MenuBarOnlySettings.normalizeLegacyStoredPreference(defaults: defaults) + + #expect((defaults.object(forKey: MenuBarOnlySettings.menuBarOnlyKey) as? Bool) == false) + #expect((defaults.object(forKey: MenuBarOnlySettings.explicitEnableKey) as? Bool) == false) + } + } + + @Test func mixedCommandHistoryPreservesLegacyOptIn() throws { + try withTemporaryDefaults { defaults in + defaults.set(true, forKey: MenuBarOnlySettings.menuBarOnlyKey) + defaults.set( + try JSONSerialization.data(withJSONObject: [ + MenuBarOnlySettings.legacyCommandPaletteMenuBarOnlyCommandId: [ + "useCount": 1, + "lastUsedAt": 1_700_000_000, + ], + "palette.toggleSetting.dockBadge": [ + "useCount": 4, + "lastUsedAt": 1_700_000_500, + ], + ]), + forKey: MenuBarOnlySettings.legacyCommandPaletteUsageKey + ) + + #expect(MenuBarOnlySettings.isEnabled(defaults: defaults)) + #expect(MenuBarOnlySettings.activationPolicy(defaults: defaults) == .accessory) + + MenuBarOnlySettings.normalizeLegacyStoredPreference(defaults: defaults) + + #expect((defaults.object(forKey: MenuBarOnlySettings.menuBarOnlyKey) as? Bool) == true) + #expect((defaults.object(forKey: MenuBarOnlySettings.explicitEnableKey) as? Bool) == true) + } + } + + @Test func legacyDefaultWithoutCommandHistoryStillOptsIn() throws { + try withTemporaryDefaults { defaults in + defaults.set(true, forKey: MenuBarOnlySettings.menuBarOnlyKey) + + #expect(MenuBarOnlySettings.isEnabled(defaults: defaults)) + #expect(MenuBarOnlySettings.activationPolicy(defaults: defaults) == .accessory) + + MenuBarOnlySettings.normalizeLegacyStoredPreference(defaults: defaults) + + #expect((defaults.object(forKey: MenuBarOnlySettings.menuBarOnlyKey) as? Bool) == true) + #expect((defaults.object(forKey: MenuBarOnlySettings.explicitEnableKey) as? Bool) == true) + } + } + + @Test func repeatedCommandHistoryPreservesLegacyOptIn() throws { + try withTemporaryDefaults { defaults in + defaults.set(true, forKey: MenuBarOnlySettings.menuBarOnlyKey) + defaults.set( + try JSONSerialization.data(withJSONObject: [ + MenuBarOnlySettings.legacyCommandPaletteMenuBarOnlyCommandId: [ + "useCount": 3, + "lastUsedAt": 1_700_000_000, + ], + ]), + forKey: MenuBarOnlySettings.legacyCommandPaletteUsageKey + ) + + #expect(MenuBarOnlySettings.isEnabled(defaults: defaults)) + #expect(MenuBarOnlySettings.activationPolicy(defaults: defaults) == .accessory) + + MenuBarOnlySettings.normalizeLegacyStoredPreference(defaults: defaults) + + #expect((defaults.object(forKey: MenuBarOnlySettings.menuBarOnlyKey) as? Bool) == true) + #expect((defaults.object(forKey: MenuBarOnlySettings.explicitEnableKey) as? Bool) == true) + } + } + + @Test func unreadableCommandHistoryDoesNotEnableAccessoryPolicy() throws { + try withTemporaryDefaults { defaults in + defaults.set(true, forKey: MenuBarOnlySettings.menuBarOnlyKey) + defaults.set(Data("not-json".utf8), forKey: MenuBarOnlySettings.legacyCommandPaletteUsageKey) + + #expect(!MenuBarOnlySettings.isEnabled(defaults: defaults)) + #expect(MenuBarOnlySettings.activationPolicy(defaults: defaults) == .regular) + + MenuBarOnlySettings.normalizeLegacyStoredPreference(defaults: defaults) + + #expect((defaults.object(forKey: MenuBarOnlySettings.menuBarOnlyKey) as? Bool) == false) + #expect((defaults.object(forKey: MenuBarOnlySettings.explicitEnableKey) as? Bool) == false) + } + } + + @Test func settingsFileStoreMarksConfigTrueAsExplicitOptIn() throws { + let defaults = UserDefaults.standard + let settingKey = MenuBarOnlySettings.menuBarOnlyKey + let explicitKey = MenuBarOnlySettings.explicitEnableKey + + try preservingDefaults(keys: [settingKey, explicitKey, settingsFileBackupsDefaultsKey, importedManagedDefaultsKey]) { + let directoryURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let settingsFileURL = directoryURL.appendingPathComponent("cmux.json", isDirectory: false) + try writeSettingsFile(#"{"app":{"menuBarOnly":true}}"#, to: settingsFileURL) + + _ = KeyboardShortcutSettingsFileStore( + primaryPath: settingsFileURL.path, + fallbackPath: nil, + additionalFallbackPaths: [], + startWatching: false + ) + + #expect((defaults.object(forKey: settingKey) as? Bool) == true) + #expect((defaults.object(forKey: explicitKey) as? Bool) == true) + #expect(MenuBarOnlySettings.isEnabled(defaults: defaults)) + #expect(MenuBarOnlySettings.activationPolicy(defaults: defaults) == .accessory) + } + } + + @Test func defaultConfigFalseDoesNotClearExistingExplicitOptIn() throws { + let defaults = UserDefaults.standard + let settingKey = MenuBarOnlySettings.menuBarOnlyKey + let explicitKey = MenuBarOnlySettings.explicitEnableKey + + try preservingDefaults(keys: [settingKey, explicitKey, settingsFileBackupsDefaultsKey, importedManagedDefaultsKey]) { + let directoryURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + defaults.set(true, forKey: settingKey) + defaults.set(true, forKey: explicitKey) + defaults.set( + Data(#"{"menuBarOnly":{"bool":{"_0":false}}}"#.utf8), + forKey: importedManagedDefaultsKey + ) + + let settingsFileURL = directoryURL.appendingPathComponent("cmux.json", isDirectory: false) + try writeSettingsFile(#"{"app":{"menuBarOnly":false}}"#, to: settingsFileURL) + + _ = KeyboardShortcutSettingsFileStore( + primaryPath: settingsFileURL.path, + fallbackPath: nil, + additionalFallbackPaths: [], + startWatching: false + ) + + #expect((defaults.object(forKey: settingKey) as? Bool) == true) + #expect((defaults.object(forKey: explicitKey) as? Bool) == true) + #expect(MenuBarOnlySettings.isEnabled(defaults: defaults)) + #expect(MenuBarOnlySettings.activationPolicy(defaults: defaults) == .accessory) + } + } + + private func withTemporaryDefaults(_ body: (UserDefaults) throws -> Void) throws { + let suiteName = "cmux.menuBarOnlyActivationPolicy.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { + defaults.removePersistentDomain(forName: suiteName) + } + try body(defaults) + } + + private func makeTemporaryDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent( + "cmux-menu-bar-only-\(UUID().uuidString)", + isDirectory: true + ) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + private func writeSettingsFile(_ contents: String, to url: URL) throws { + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try contents.write(to: url, atomically: true, encoding: .utf8) + } + + private func preservingDefaults(keys: [String], _ body: () throws -> Void) rethrows { + let defaults = UserDefaults.standard + let previous = Dictionary(uniqueKeysWithValues: keys.map { ($0, defaults.object(forKey: $0)) }) + keys.forEach { defaults.removeObject(forKey: $0) } + defer { + for (key, value) in previous { + if let value { + defaults.set(value, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } + } + try body() + } +}