From f55f7f8959e8b48a29ea0d1368f4ea600c5f48df Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:09:20 -0700 Subject: [PATCH 1/6] test: prove explicit todo setting beats remote default --- cmuxTests/WorkspaceTodoSidebarModelTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/cmuxTests/WorkspaceTodoSidebarModelTests.swift b/cmuxTests/WorkspaceTodoSidebarModelTests.swift index 0e4b11b8bf0..2f23c65c319 100644 --- a/cmuxTests/WorkspaceTodoSidebarModelTests.swift +++ b/cmuxTests/WorkspaceTodoSidebarModelTests.swift @@ -96,6 +96,7 @@ struct WorkspaceTodoSidebarModelTests { defaults.set(false, forKey: key.userDefaultsKey) #expect(!WorkspaceTodoFeature.localControlsOptIn(defaults: defaults)) #expect(!WorkspaceTodoFeature.isEnabled(defaults: defaults, remoteEnabled: false)) + #expect(!WorkspaceTodoFeature.isEnabled(defaults: defaults, remoteEnabled: true)) } @Test From 60f45f8d6dab39fa3b6eee6ca5c8b06571b5f3d1 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Fri, 31 Jul 2026 02:37:12 -0700 Subject: [PATCH 2/6] feat: add inherited remote defaults for beta settings --- CLI/CMUXCLI+DocsSettings.swift | 3 + .../ControlCommandCoordinator+Debug.swift | 4 + ...dCoordinator+DebugBetaRemoteDefaults.swift | 62 +++++ ...ontrolDebugBetaRemoteDefaultSnapshot.swift | 30 +++ .../Debug/ControlDebugContext.swift | 12 + ...ControlCommandContextTestStubs+Debug.swift | 7 + ...rdinatorDebugBetaRemoteDefaultsTests.swift | 105 ++++++++ .../CmuxSettings/Codable/SettingCodable.swift | 4 +- .../CmuxSettings/Keys/AnySettingKey.swift | 9 + .../Keys/BetaFeaturesCatalogSection.swift | 22 +- .../Keys/DefaultsKey+DirectAccess.swift | 103 +++++++- .../CmuxSettings/Keys/DefaultsKey.swift | 15 +- .../Keys/DefaultsValueResolution.swift | 17 ++ .../Stores/NotificationObserverToken.swift | 13 +- .../Stores/UserDefaultsSettingsClient.swift | 2 +- ...tsSettingsObservedMutationWatermarks.swift | 2 + .../Stores/UserDefaultsSettingsStorage.swift | 88 ++++++- ...SettingsStore+LegacyShortcutBindings.swift | 2 +- ...serDefaultsSettingsStore+Observation.swift | 38 ++- .../Stores/UserDefaultsSettingsStore.swift | 27 +- .../UserDefaultsSettingsStoreSignals.swift | 1 + .../UserDefaultsSettingsValueEvent.swift | 10 +- .../DefaultsKeyRemoteDefaultTests.swift | 151 ++++++++++++ ...efaultsSettingsStoreObservationTests.swift | 71 +++++- .../Bindings/DefaultsValueModel.swift | 43 +++- .../CuratedSettingEntry+Default.swift | 2 +- .../Sections/BetaFeaturesSection.swift | 2 +- .../Sections/ResetSection.swift | 1 + ...DefaultsValueModelRemoteDefaultTests.swift | 202 +++++++++++++++ Resources/Localizable.xcstrings | 4 +- Sources/App/WorkspaceRuntimeSettings.swift | 13 +- Sources/AppDelegate.swift | 3 + .../CommandPaletteSettingsToggle.swift | 29 ++- Sources/ContentView.swift | 53 +++- Sources/FeatureFlags.swift | 231 ++++++++++++++---- Sources/Panels/WorkspaceTodoPanelView.swift | 8 +- Sources/RemoteTmuxController.swift | 2 +- Sources/RightSidebarPanelView.swift | 6 +- .../Cells/SidebarWorkspaceRowCommands.swift | 3 +- .../Cells/SidebarWorkspaceRowModel.swift | 7 +- Sources/SidebarWorkspaceRowInput.swift | 2 + Sources/SidebarWorkspaceRowSnapshot.swift | 1 + Sources/SidebarWorkspaceSnapshotBuilder.swift | 7 +- Sources/SidebarWorkspaceSnapshotFactory.swift | 12 +- Sources/TabItemView+WorkspaceTodo.swift | 5 +- ...rminalController+ControlDebugContext.swift | 40 +++ .../TerminalController+DebugMethodNames.swift | 2 + Sources/WorkspaceTodoFeature.swift | 26 +- .../CommandPaletteSettingsToggleTests.swift | 21 ++ .../PostHogAnalyticsPropertiesTests.swift | 137 +++++++++-- cmuxTests/SidebarAppKitRowCellTests.swift | 5 +- ...rkspaceContextMenuWindowTargetsTests.swift | 1 + ...debarWorkspaceNotificationIndexTests.swift | 1 + .../SidebarWorkspaceRowSuspensionTests.swift | 4 +- ...rWorkspaceSnapshotRefreshPolicyTests.swift | 2 + .../WorkspaceTodoSidebarModelTests.swift | 55 ++++- .../SettingsSidebarBetaBehaviorUITests.swift | 104 +++++--- cmuxUITests/SettingsUITestSupport.swift | 59 ++++- 58 files changed, 1672 insertions(+), 219 deletions(-) create mode 100644 Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlCommandCoordinator+DebugBetaRemoteDefaults.swift create mode 100644 Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlDebugBetaRemoteDefaultSnapshot.swift create mode 100644 Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorDebugBetaRemoteDefaultsTests.swift create mode 100644 Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/DefaultsValueResolution.swift create mode 100644 Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/DefaultsKeyRemoteDefaultTests.swift create mode 100644 Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/DefaultsValueModelRemoteDefaultTests.swift diff --git a/CLI/CMUXCLI+DocsSettings.swift b/CLI/CMUXCLI+DocsSettings.swift index 05ce0341b8a..c3f6a0445c5 100644 --- a/CLI/CMUXCLI+DocsSettings.swift +++ b/CLI/CMUXCLI+DocsSettings.swift @@ -370,6 +370,7 @@ extension CMUXCLI { Targets: account, app, terminal, sidebar-appearance, custom-sidebars, + beta-features, automation, browser, browser-import, global-hotkey, keyboard-shortcuts, shortcuts, workspace-colors, cmux-json, json, reset @@ -407,6 +408,8 @@ extension CMUXCLI { return "sidebarAppearance" case "custom-sidebars", "customsidebars": return "customSidebars" + case "beta-features", "betafeatures", "beta": + return "betaFeatures" case "automation": return "automation" case "browser": diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlCommandCoordinator+Debug.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlCommandCoordinator+Debug.swift index b15c2196cdc..9a5a0fecc7f 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlCommandCoordinator+Debug.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlCommandCoordinator+Debug.swift @@ -28,6 +28,10 @@ extension ControlCommandCoordinator { return debugShortcutSimulate(request.params) case "debug.type": return debugType(request.params) + case "debug.beta_remote_defaults.get": + return debugBetaRemoteDefaultGet(request.params) + case "debug.beta_remote_defaults.set": + return debugBetaRemoteDefaultSet(request.params) case "debug.textbox.inline_fixture": return debugTextBoxInlineFixture(request.params) case "debug.textbox.interact": diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlCommandCoordinator+DebugBetaRemoteDefaults.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlCommandCoordinator+DebugBetaRemoteDefaults.swift new file mode 100644 index 00000000000..952a7c6b69a --- /dev/null +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlCommandCoordinator+DebugBetaRemoteDefaults.swift @@ -0,0 +1,62 @@ +#if DEBUG +extension ControlCommandCoordinator { + func debugBetaRemoteDefaultGet( + _ params: [String: JSONValue] + ) -> ControlCallResult { + guard let key = string(params, "key") else { + return .err(code: "invalid_params", message: "Missing key", data: nil) + } + guard let snapshot = debugContext?.controlDebugBetaRemoteDefaultSnapshot( + identifier: key + ) else { + return .err(code: "not_found", message: "Beta remote default not found", data: nil) + } + return debugBetaRemoteDefaultResult(snapshot) + } + + func debugBetaRemoteDefaultSet( + _ params: [String: JSONValue] + ) -> ControlCallResult { + guard let key = string(params, "key") else { + return .err(code: "invalid_params", message: "Missing key", data: nil) + } + guard let rawValue = params["value"] else { + return .err(code: "invalid_params", message: "Missing value", data: nil) + } + let value: Bool? + switch rawValue { + case .bool(let enabled): + value = enabled + case .null: + value = nil + default: + return .err( + code: "invalid_params", + message: "value must be a bool or null", + data: .object(["value": rawValue]) + ) + } + guard let snapshot = debugContext?.controlDebugSetBetaRemoteDefault( + identifier: key, + value: value + ) else { + return .err(code: "not_found", message: "Beta remote default not found", data: nil) + } + return debugBetaRemoteDefaultResult(snapshot) + } + + private func debugBetaRemoteDefaultResult( + _ snapshot: ControlDebugBetaRemoteDefaultSnapshot + ) -> ControlCallResult { + .ok(.object([ + "setting_id": .string(snapshot.settingID), + "flag_key": .string(snapshot.flagKey), + "user_key_present": .bool(snapshot.userKeyPresent), + "user_value": snapshot.userValue.map { .bool($0) } ?? .null, + "remote_default": snapshot.remoteDefault.map { .bool($0) } ?? .null, + "effective_value": .bool(snapshot.effectiveValue), + "source": .string(snapshot.source), + ])) + } +} +#endif diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlDebugBetaRemoteDefaultSnapshot.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlDebugBetaRemoteDefaultSnapshot.swift new file mode 100644 index 00000000000..2ecc47660c2 --- /dev/null +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlDebugBetaRemoteDefaultSnapshot.swift @@ -0,0 +1,30 @@ +#if DEBUG +/// A DEBUG readback of one Beta Features remote-default resolution. +public struct ControlDebugBetaRemoteDefaultSnapshot: Sendable, Equatable { + public let settingID: String + public let flagKey: String + public let userKeyPresent: Bool + public let userValue: Bool? + public let remoteDefault: Bool? + public let effectiveValue: Bool + public let source: String + + public init( + settingID: String, + flagKey: String, + userKeyPresent: Bool, + userValue: Bool?, + remoteDefault: Bool?, + effectiveValue: Bool, + source: String + ) { + self.settingID = settingID + self.flagKey = flagKey + self.userKeyPresent = userKeyPresent + self.userValue = userValue + self.remoteDefault = remoteDefault + self.effectiveValue = effectiveValue + self.source = source + } +} +#endif diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlDebugContext.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlDebugContext.swift index 118c7c7bf19..8cd0ff80d17 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlDebugContext.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlDebugContext.swift @@ -164,6 +164,18 @@ public protocol ControlDebugContext: AnyObject { // MARK: - Live app/UI state + /// Reads one beta remote-default setting by setting id or PostHog key. + func controlDebugBetaRemoteDefaultSnapshot( + identifier: String + ) -> ControlDebugBetaRemoteDefaultSnapshot? + + /// Changes only one beta setting's remote-default cache layer, then reads + /// back its effective value and provenance. + func controlDebugSetBetaRemoteDefault( + identifier: String, + value: Bool? + ) -> ControlDebugBetaRemoteDefaultSnapshot? + /// Inserts text at the key window's first responder for `debug.type` /// (activating the app first when the focus policy allows it). /// diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs+Debug.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs+Debug.swift index 6ef7a8b3e93..f5d4fd88cff 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs+Debug.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs+Debug.swift @@ -33,6 +33,13 @@ extension ControlDebugContext { func controlDebugShowCanvasCommandScrollHint( routing: ControlRoutingSelectors ) -> ControlCanvasActionResolution { .tabManagerUnavailable } + func controlDebugBetaRemoteDefaultSnapshot( + identifier: String + ) -> ControlDebugBetaRemoteDefaultSnapshot? { nil } + func controlDebugSetBetaRemoteDefault( + identifier: String, + value: Bool? + ) -> ControlDebugBetaRemoteDefaultSnapshot? { nil } func controlDebugTypeText(_ text: String) -> ControlDebugTypeResolution { .noWindow } func controlDebugTabManagerAvailable() -> Bool { false } func controlDebugTextBoxInlineFixture( diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorDebugBetaRemoteDefaultsTests.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorDebugBetaRemoteDefaultsTests.swift new file mode 100644 index 00000000000..b6561389c29 --- /dev/null +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorDebugBetaRemoteDefaultsTests.swift @@ -0,0 +1,105 @@ +import Foundation +import Testing + +@testable import CmuxControlSocket + +#if DEBUG +@MainActor +@Suite("ControlCommandCoordinator beta remote-default debug dispatch") +struct ControlCommandCoordinatorDebugBetaRemoteDefaultsTests { + @Test func getReturnsTypedResolutionReadback() { + let context = FakeBetaRemoteDefaultControlCommandContext() + let coordinator = ControlCommandCoordinator(context: context) + + let result = coordinator.handle( + ControlRequest( + id: .int(1), + method: "debug.beta_remote_defaults.get", + params: ["key": .string("tests.beta.enabled")] + ) + ) + + #expect(result == .ok(.object([ + "setting_id": .string("tests.beta.enabled"), + "flag_key": .string("tests-beta-default-experiment"), + "user_key_present": .bool(false), + "user_value": .null, + "remote_default": .bool(true), + "effective_value": .bool(true), + "source": .string("remoteDefault"), + ]))) + } + + @Test func setAcceptsNullAsCacheClearAndRejectsOtherTypes() { + let context = FakeBetaRemoteDefaultControlCommandContext() + let coordinator = ControlCommandCoordinator(context: context) + + let cleared = coordinator.handle( + ControlRequest( + id: .int(1), + method: "debug.beta_remote_defaults.set", + params: [ + "key": .string("tests.beta.enabled"), + "value": .null, + ] + ) + ) + #expect(context.lastValueWasSet) + #expect(context.lastValue == nil) + guard case .ok = cleared else { + Issue.record("expected cache clear to succeed") + return + } + + guard case .err(let code, _, _) = coordinator.handle( + ControlRequest( + id: .int(2), + method: "debug.beta_remote_defaults.set", + params: [ + "key": .string("tests.beta.enabled"), + "value": .string("true"), + ] + ) + ) else { + Issue.record("expected invalid value to fail") + return + } + #expect(code == "invalid_params") + } +} + +@MainActor +private final class FakeBetaRemoteDefaultControlCommandContext: ControlCommandContext { + var lastValueWasSet = false + var lastValue: Bool? + + func controlDebugBetaRemoteDefaultSnapshot( + identifier: String + ) -> ControlDebugBetaRemoteDefaultSnapshot? { + snapshot(remoteDefault: true) + } + + func controlDebugSetBetaRemoteDefault( + identifier: String, + value: Bool? + ) -> ControlDebugBetaRemoteDefaultSnapshot? { + lastValueWasSet = true + lastValue = value + return snapshot(remoteDefault: value) + } + + private func snapshot( + remoteDefault: Bool? + ) -> ControlDebugBetaRemoteDefaultSnapshot { + ControlDebugBetaRemoteDefaultSnapshot( + settingID: "tests.beta.enabled", + flagKey: "tests-beta-default-experiment", + userKeyPresent: false, + userValue: nil, + remoteDefault: remoteDefault, + effectiveValue: remoteDefault ?? false, + source: remoteDefault == nil ? "compileDefault" : "remoteDefault" + ) + } +} +#endif diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Codable/SettingCodable.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Codable/SettingCodable.swift index 3d9f2764857..15cea724408 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Codable/SettingCodable.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Codable/SettingCodable.swift @@ -26,8 +26,8 @@ public protocol SettingCodable: Sendable, Equatable { /// - Parameter raw: A value returned by `UserDefaults.object(forKey:)`, or /// `nil` when the key has no override. /// - Returns: The decoded value, or `nil` when `raw` is `nil` or has an - /// unexpected shape. The store falls back to the key's default value on - /// `nil`. + /// unexpected shape. `DefaultsKey` then continues through inherited + /// remote-default and compile-default resolution. static func decodeFromUserDefaults(_ raw: Any?) -> Self? /// Encodes a value for storage in `UserDefaults`. diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/AnySettingKey.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/AnySettingKey.swift index ca0b27e7611..0d16bdf1a0f 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/AnySettingKey.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/AnySettingKey.swift @@ -59,6 +59,10 @@ public struct AnySettingKey: Sendable { /// The UserDefaults fallback value, type-erased for batch reset bookkeeping. public let userDefaultsDefaultValue: (any Sendable)? + /// Resolves the value inherited after removing the user key. + public let userDefaultsInheritedValue: + @Sendable (UserDefaults) -> (any Sendable)? + /// Wraps a UserDefaults-backed key. public init(_ key: DefaultsKey) { self.id = key.id @@ -72,6 +76,9 @@ public struct AnySettingKey: Sendable { } self.resetInJSON = { _ in } self.userDefaultsDefaultValue = key.defaultValue + self.userDefaultsInheritedValue = { defaults in + key.inheritedValue(in: defaults) + } } /// Wraps a JSON-backed key. @@ -83,6 +90,7 @@ public struct AnySettingKey: Sendable { try? await store.reset(key) } self.userDefaultsDefaultValue = nil + self.userDefaultsInheritedValue = { _ in nil } } /// Wraps a secret-file-backed key. Secrets are reset through @@ -94,6 +102,7 @@ public struct AnySettingKey: Sendable { self.migrateUserDefaultsLegacyKeys = { _ in } self.resetInJSON = { _ in } self.userDefaultsDefaultValue = nil + self.userDefaultsInheritedValue = { _ in nil } } private static func migrateLegacyDefaultsKey( diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/BetaFeaturesCatalogSection.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/BetaFeaturesCatalogSection.swift index b0f3bda7e48..ee1399f8311 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/BetaFeaturesCatalogSection.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/BetaFeaturesCatalogSection.swift @@ -13,7 +13,8 @@ public struct BetaFeaturesCatalogSection: SettingCatalogSection { public let rightSidebarFeed = DefaultsKey( id: "rightSidebar.beta.feed.enabled", defaultValue: false, - userDefaultsKey: "rightSidebar.beta.feed.enabled" + userDefaultsKey: "rightSidebar.beta.feed.enabled", + remoteDefaultUserDefaultsKey: "cmux.beta.remoteDefault.rightSidebar.feed.enabled" ) /// Right-sidebar Dock: an experimental terminal-controls dock that @@ -22,7 +23,8 @@ public struct BetaFeaturesCatalogSection: SettingCatalogSection { public let rightSidebarDock = DefaultsKey( id: "rightSidebar.beta.dock.enabled", defaultValue: false, - userDefaultsKey: "rightSidebar.beta.dock.enabled" + userDefaultsKey: "rightSidebar.beta.dock.enabled", + remoteDefaultUserDefaultsKey: "cmux.beta.remoteDefault.rightSidebar.dock.enabled" ) /// Extensions: the experimental ExtensionKit sidebar-extension surface @@ -33,7 +35,8 @@ public struct BetaFeaturesCatalogSection: SettingCatalogSection { public let extensions = DefaultsKey( id: "extensions.beta.enabled", defaultValue: false, - userDefaultsKey: "extensions.beta.enabled" + userDefaultsKey: "extensions.beta.enabled", + remoteDefaultUserDefaultsKey: "cmux.beta.remoteDefault.extensions.enabled" ) /// Custom sidebars: user/agent-authored sidebars (interpreted Swift or @@ -44,17 +47,19 @@ public struct BetaFeaturesCatalogSection: SettingCatalogSection { public let customSidebars = DefaultsKey( id: "customSidebars.beta.enabled", defaultValue: true, - userDefaultsKey: "customSidebars.beta.enabled" + userDefaultsKey: "customSidebars.beta.enabled", + remoteDefaultUserDefaultsKey: "cmux.beta.remoteDefault.customSidebars.enabled" ) /// Workspace todo controls: the experimental UI that lets users add /// checklist items and set workspace completion/status lanes. Defaults off - /// so the todo summary remains read-only unless the user opts in or the - /// remote rollout flag enables it. + /// so the todo summary remains read-only unless a user value or inherited + /// remote default enables it. public let workspaceTodoControls = DefaultsKey( id: "sidebar.beta.workspaceTodos.controls.enabled", defaultValue: false, - userDefaultsKey: "sidebar.beta.workspaceTodos.controls.enabled" + userDefaultsKey: "sidebar.beta.workspaceTodos.controls.enabled", + remoteDefaultUserDefaultsKey: "cmux.beta.remoteDefault.workspaceTodos.controls.enabled" ) /// How a workspace row's checklist opens from its summary line while the @@ -76,7 +81,8 @@ public struct BetaFeaturesCatalogSection: SettingCatalogSection { public let remoteTmux = DefaultsKey( id: "remoteTmux.beta.enabled", defaultValue: false, - userDefaultsKey: "remoteTmux.beta.enabled" + userDefaultsKey: "remoteTmux.beta.enabled", + remoteDefaultUserDefaultsKey: "cmux.beta.remoteDefault.remoteTmux.enabled" ) public init() {} diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/DefaultsKey+DirectAccess.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/DefaultsKey+DirectAccess.swift index 007e5cb99a7..f2509fccc6c 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/DefaultsKey+DirectAccess.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/DefaultsKey+DirectAccess.swift @@ -18,11 +18,39 @@ import Foundation /// `UserDefaults` is documented thread-safe, so these calls are safe from any /// thread; they provide no change observation. extension DefaultsKey { - /// Returns the current value for this key in `defaults`, falling back to - /// ``defaultValue`` when no override is stored or the stored value does - /// not decode as `Value`. + /// Returns the current value for this key in `defaults`. + /// + /// A decodable user value wins over a decodable cached remote default, + /// followed by ``defaultValue``. public func value(in defaults: UserDefaults) -> Value { - Value.decodeFromUserDefaults(defaults.object(forKey: userDefaultsKey)) ?? defaultValue + resolution(in: defaults).value + } + + /// Returns the value and the layer that supplied it. + public func resolution(in defaults: UserDefaults) -> DefaultsValueResolution { + if let value = Value.decodeFromUserDefaults(defaults.object(forKey: userDefaultsKey)) { + return DefaultsValueResolution(value: value, source: .user) + } + if let remoteDefaultUserDefaultsKey, + let value = Value.decodeFromUserDefaults(defaults.object(forKey: remoteDefaultUserDefaultsKey)) { + return DefaultsValueResolution(value: value, source: .remoteDefault) + } + return DefaultsValueResolution(value: defaultValue, source: .compileDefault) + } + + /// Returns the value inherited after removing the explicit user choice. + public func inheritedValue(in defaults: UserDefaults) -> Value { + if let remoteDefaultUserDefaultsKey, + let value = Value.decodeFromUserDefaults(defaults.object(forKey: remoteDefaultUserDefaultsKey)) { + return value + } + return defaultValue + } + + /// Returns the decodable cached remote default, if this key has one. + public func remoteDefaultValue(in defaults: UserDefaults) -> Value? { + guard let remoteDefaultUserDefaultsKey else { return nil } + return Value.decodeFromUserDefaults(defaults.object(forKey: remoteDefaultUserDefaultsKey)) } /// Writes `value` for this key into `defaults`. @@ -30,16 +58,77 @@ extension DefaultsKey { defaults.set(value.encodeForUserDefaults(), forKey: userDefaultsKey) } - /// Removes the stored override for this key from `defaults`. After this - /// call ``value(in:)`` returns ``defaultValue`` until something writes a - /// new override. + /// Removes the stored user override for this key from `defaults`. After + /// this call ``value(in:)`` resolves the cached remote default when one + /// exists, otherwise ``defaultValue``. public func removeValue(in defaults: UserDefaults) { defaults.removeObject(forKey: userDefaultsKey) } + /// Updates only the cached remote-default layer. + /// + /// Returns whether storage changed. An unchanged value is silent, which + /// keeps periodic remote refreshes from invalidating settings views. + @discardableResult + public func setRemoteDefault( + _ value: Value?, + in defaults: UserDefaults, + notificationCenter: NotificationCenter = .default + ) -> Bool { + guard let remoteDefaultUserDefaultsKey else { return false } + if let value { + guard Value.decodeFromUserDefaults( + defaults.object(forKey: remoteDefaultUserDefaultsKey) + ) != value else { + return false + } + } else { + guard defaults.object(forKey: remoteDefaultUserDefaultsKey) != nil else { + return false + } + } + + notificationCenter.post( + name: .cmuxSettingsRemoteDefaultWillChange, + object: defaults, + userInfo: [ + CmuxSettingsRemoteDefaultNotification.storageKeyUserInfoKey: userDefaultsKey, + ] + ) + + if let value { + defaults.set(value.encodeForUserDefaults(), forKey: remoteDefaultUserDefaultsKey) + } else { + defaults.removeObject(forKey: remoteDefaultUserDefaultsKey) + } + + notificationCenter.post( + name: .cmuxSettingsRemoteDefaultDidChange, + object: defaults, + userInfo: [ + CmuxSettingsRemoteDefaultNotification.storageKeyUserInfoKey: userDefaultsKey, + ] + ) + return true + } + /// Whether `defaults` holds any stored object for this key, decodable or /// not. Lets legacy fallback chains distinguish "never set" from "set". public func hasStoredValue(in defaults: UserDefaults) -> Bool { defaults.object(forKey: userDefaultsKey) != nil } } + +public extension Notification.Name { + /// Posted immediately before a typed remote-default cache mutation. + static let cmuxSettingsRemoteDefaultWillChange = + Notification.Name("cmux.settings.remoteDefaultWillChange") + + /// Posted after a typed remote-default cache value changes. + static let cmuxSettingsRemoteDefaultDidChange = + Notification.Name("cmux.settings.remoteDefaultDidChange") +} + +enum CmuxSettingsRemoteDefaultNotification { + static let storageKeyUserInfoKey = "storageKey" +} diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/DefaultsKey.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/DefaultsKey.swift index c8842502fa0..4b01895c34a 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/DefaultsKey.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/DefaultsKey.swift @@ -25,12 +25,20 @@ public struct DefaultsKey: Sendable, Equatable { /// storage key — they may differ for legacy reasons. public let id: String - /// The value returned when no override is stored in the suite. + /// The fallback used when neither a user value nor a remote default exists. public let defaultValue: Value /// The actual `UserDefaults` key the value is stored under. public let userDefaultsKey: String + /// Optional backing key for a cached remote default. + /// + /// This storage is deliberately separate from ``userDefaultsKey`` so a + /// remote rollout never masquerades as an explicit user choice. Reads + /// resolve a decodable user value first, then this cached value, then + /// ``defaultValue``. + public let remoteDefaultUserDefaultsKey: String? + /// Optional `UserDefaults` suite name. `nil` means `UserDefaults.standard`. public let suite: String? @@ -47,20 +55,23 @@ public struct DefaultsKey: Sendable, Equatable { /// - Parameters: /// - id: The dotted identifier (used for diagnostics; usually mirrors /// `userDefaultsKey`, but is allowed to differ). - /// - defaultValue: The fallback when no override is stored. + /// - defaultValue: The fallback when neither a user value nor a remote default is stored. /// - userDefaultsKey: The actual UserDefaults key. + /// - remoteDefaultUserDefaultsKey: Optional cached remote-default key. /// - suite: Optional suite name. `nil` is `UserDefaults.standard`. /// - legacyUserDefaultsKeys: Renamed keys to migrate from on first read. public init( id: String, defaultValue: Value, userDefaultsKey: String, + remoteDefaultUserDefaultsKey: String? = nil, suite: String? = nil, legacyUserDefaultsKeys: [String] = [] ) { self.id = id self.defaultValue = defaultValue self.userDefaultsKey = userDefaultsKey + self.remoteDefaultUserDefaultsKey = remoteDefaultUserDefaultsKey self.suite = suite self.legacyUserDefaultsKeys = legacyUserDefaultsKeys } diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/DefaultsValueResolution.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/DefaultsValueResolution.swift new file mode 100644 index 00000000000..0c5ff357be9 --- /dev/null +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/DefaultsValueResolution.swift @@ -0,0 +1,17 @@ +/// The layer that supplied a resolved `DefaultsKey` value. +public enum DefaultsValueSource: String, Sendable, Equatable { + case user + case remoteDefault + case compileDefault +} + +/// A resolved `DefaultsKey` value together with its provenance. +public struct DefaultsValueResolution: Sendable, Equatable { + public let value: Value + public let source: DefaultsValueSource + + public init(value: Value, source: DefaultsValueSource) { + self.value = value + self.source = source + } +} diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/NotificationObserverToken.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/NotificationObserverToken.swift index 0aacf76ff96..45c696cd281 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/NotificationObserverToken.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/NotificationObserverToken.swift @@ -8,15 +8,22 @@ import Foundation /// NotificationCenter's thread-safe observer-removal API. Callers own the /// lifecycle and must call `remove()` when their stream terminates. final class NotificationObserverToken: @unchecked Sendable { - private let token: NSObjectProtocol + private let tokens: [NSObjectProtocol] private let notificationCenter: NotificationCenter init(_ token: NSObjectProtocol, notificationCenter: NotificationCenter = .default) { - self.token = token + self.tokens = [token] + self.notificationCenter = notificationCenter + } + + init(_ tokens: [NSObjectProtocol], notificationCenter: NotificationCenter = .default) { + self.tokens = tokens self.notificationCenter = notificationCenter } func remove() { - notificationCenter.removeObserver(token) + for token in tokens { + notificationCenter.removeObserver(token) + } } } diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/UserDefaultsSettingsClient.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/UserDefaultsSettingsClient.swift index 0014ba68c91..0350eccf041 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/UserDefaultsSettingsClient.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/UserDefaultsSettingsClient.swift @@ -31,7 +31,7 @@ public struct UserDefaultsSettingsClient: SettingsWriting { } public func value(for key: DefaultsKey) -> Value { - Value.decodeFromUserDefaults(defaults.object(forKey: key.userDefaultsKey)) ?? key.defaultValue + key.value(in: defaults) } public func valueIfPresent(for key: DefaultsKey) -> Value? { diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/UserDefaultsSettingsObservedMutationWatermarks.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/UserDefaultsSettingsObservedMutationWatermarks.swift index 45462807afa..fb8fa4ec7f9 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/UserDefaultsSettingsObservedMutationWatermarks.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/UserDefaultsSettingsObservedMutationWatermarks.swift @@ -37,9 +37,11 @@ final class UserDefaultsSettingsObservedMutationWatermarks: @unchecked Sendable logicalOrder: UInt64, isBackingDefaultsNotification: Bool, canCarryActiveMutationSource: Bool, + isInheritedDefaultNotification: Bool = false, for storageKey: String ) -> UserDefaultsSettingsMutationSource? { return state.withLock { state in + guard !isInheritedDefaultNotification else { return nil } let mutationSource = canCarryActiveMutationSource ? state.activeMutationSources[storageKey] : nil diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/UserDefaultsSettingsStorage.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/UserDefaultsSettingsStorage.swift index 72d02b1a4c5..128ac512b6e 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/UserDefaultsSettingsStorage.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/UserDefaultsSettingsStorage.swift @@ -1,4 +1,24 @@ import Foundation +import os + +// Safety: NotificationCenter callbacks may arrive concurrently. Every read and +// mutation of the nesting depth is serialized by `OSAllocatedUnfairLock`, and +// the class exposes no unprotected state. +private final class RemoteDefaultNotificationState: @unchecked Sendable { + private let depth = OSAllocatedUnfairLock(initialState: 0) + + func begin() { + depth.withLock { $0 += 1 } + } + + func end() { + depth.withLock { $0 = max(0, $0 - 1) } + } + + var isActive: Bool { + depth.withLock { $0 > 0 } + } +} // Safety: `UserDefaults` documents thread-safe access. This wrapper exposes // only typed read/write/remove operations and never hands out the defaults @@ -16,10 +36,22 @@ final class UserDefaultsSettingsStorage: @unchecked Sendable { key.value(in: defaults) } + func resolution(for key: DefaultsKey) -> DefaultsValueResolution { + key.resolution(in: defaults) + } + func valueIfPresent(for key: DefaultsKey) -> Value? { Value.decodeFromUserDefaults(defaults.object(forKey: key.userDefaultsKey)) } + func inheritedValue(for key: DefaultsKey) -> Value { + key.inheritedValue(in: defaults) + } + + func inheritedValue(for key: AnySettingKey) -> (any Sendable)? { + key.userDefaultsInheritedValue(defaults) + } + func hasStoredValue(for key: String) -> Bool { defaults.object(forKey: key) != nil } @@ -33,13 +65,23 @@ final class UserDefaultsSettingsStorage: @unchecked Sendable { } func addDidChangeObserver( + for storageKey: String? = nil, _ handler: @escaping @Sendable ( _ isBackingDefaultsNotification: Bool, - _ canCarryActiveMutationSource: Bool + _ canCarryActiveMutationSource: Bool, + _ isInheritedDefaultNotification: Bool ) -> Void ) -> NotificationObserverToken { let defaultsID = ObjectIdentifier(defaults) - return NotificationObserverToken( + let remoteDefaultState = RemoteDefaultNotificationState() + var tokens = [ + notificationCenter.addObserver( + forName: .cmuxSettingsRemoteDefaultWillChange, + object: defaults, + queue: nil + ) { _ in + remoteDefaultState.begin() + }, notificationCenter.addObserver( forName: UserDefaults.didChangeNotification, object: nil, @@ -47,9 +89,43 @@ final class UserDefaultsSettingsStorage: @unchecked Sendable { ) { notification in let objectID = notification.object.map { ObjectIdentifier($0 as AnyObject) } let isBackingDefaultsNotification = objectID == defaultsID - handler(isBackingDefaultsNotification, objectID == nil || isBackingDefaultsNotification) - }, - notificationCenter: notificationCenter - ) + let isInheritedDefaultNotification = remoteDefaultState.isActive + && (objectID == nil || isBackingDefaultsNotification) + handler( + isBackingDefaultsNotification, + !isInheritedDefaultNotification + && (objectID == nil || isBackingDefaultsNotification), + isInheritedDefaultNotification + ) + } + ] + if let storageKey { + tokens.append( + notificationCenter.addObserver( + forName: .cmuxSettingsRemoteDefaultDidChange, + object: defaults, + queue: nil + ) { notification in + let changedStorageKey = notification.userInfo?[ + CmuxSettingsRemoteDefaultNotification.storageKeyUserInfoKey + ] as? String + if changedStorageKey == storageKey { + handler(true, false, true) + } + remoteDefaultState.end() + } + ) + } else { + tokens.append( + notificationCenter.addObserver( + forName: .cmuxSettingsRemoteDefaultDidChange, + object: defaults, + queue: nil + ) { _ in + remoteDefaultState.end() + } + ) + } + return NotificationObserverToken(tokens, notificationCenter: notificationCenter) } } diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/UserDefaultsSettingsStore+LegacyShortcutBindings.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/UserDefaultsSettingsStore+LegacyShortcutBindings.swift index 3e3efd6922b..5f177d96f28 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/UserDefaultsSettingsStore+LegacyShortcutBindings.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/UserDefaultsSettingsStore+LegacyShortcutBindings.swift @@ -24,7 +24,7 @@ extension UserDefaultsSettingsStore { let (signals, signalContinuation) = AsyncStream.makeStream( bufferingPolicy: .bufferingNewest(1) ) - let observer = storage.addDidChangeObserver { _, _ in signalContinuation.yield() } + let observer = storage.addDidChangeObserver { _, _, _ in signalContinuation.yield() } let drainTask = Task { [weak self] in guard let initial = self?.initialLegacyShortcutBindings() else { continuation.finish() diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/UserDefaultsSettingsStore+Observation.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/UserDefaultsSettingsStore+Observation.swift index 43892889b94..788d33372c3 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/UserDefaultsSettingsStore+Observation.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/UserDefaultsSettingsStore+Observation.swift @@ -13,19 +13,24 @@ extension UserDefaultsSettingsStore { bufferingPolicy: .bufferingNewest(1) ) - let observer = storage.addDidChangeObserver { [weak self] isBackingDefaultsNotification, canCarryActiveMutationSource in + let observer = storage.addDidChangeObserver(for: storageKey) { + [weak self] isBackingDefaultsNotification, + canCarryActiveMutationSource, + isInheritedDefaultNotification in guard self != nil else { return } let logicalOrder = DispatchTime.now().uptimeNanoseconds _ = observedMutationWatermarks.recordNotification( logicalOrder: logicalOrder, isBackingDefaultsNotification: isBackingDefaultsNotification, canCarryActiveMutationSource: canCarryActiveMutationSource, + isInheritedDefaultNotification: isInheritedDefaultNotification, for: storageKey ) signalContinuation.yield( UserDefaultsSettingsStoreSignal( isBackingDefaultsNotification: isBackingDefaultsNotification, canCarryActiveMutationSource: canCarryActiveMutationSource, + isInheritedDefaultNotification: isInheritedDefaultNotification, logicalOrder: logicalOrder, deliveredMutationSource: nil ) @@ -85,19 +90,24 @@ extension UserDefaultsSettingsStore { bufferingPolicy: .unbounded ) - let observer = storage.addDidChangeObserver { [weak self] isBackingDefaultsNotification, canCarryActiveMutationSource in + let observer = storage.addDidChangeObserver(for: storageKey) { + [weak self] isBackingDefaultsNotification, + canCarryActiveMutationSource, + isInheritedDefaultNotification in guard self != nil else { return } let logicalOrder = DispatchTime.now().uptimeNanoseconds let deliveredMutationSource = observedMutationWatermarks.recordNotification( logicalOrder: logicalOrder, isBackingDefaultsNotification: isBackingDefaultsNotification, canCarryActiveMutationSource: canCarryActiveMutationSource, + isInheritedDefaultNotification: isInheritedDefaultNotification, for: storageKey ) signalContinuation.yield( UserDefaultsSettingsStoreSignal( isBackingDefaultsNotification: isBackingDefaultsNotification, canCarryActiveMutationSource: canCarryActiveMutationSource, + isInheritedDefaultNotification: isInheritedDefaultNotification, logicalOrder: logicalOrder, deliveredMutationSource: deliveredMutationSource ) @@ -122,7 +132,8 @@ extension UserDefaultsSettingsStore { deliverPendingMutationSourceWhenUnobserved: initialBackingNotification == nil, supersedesPendingMutationSource: initialBackingNotification != nil && initialBackingNotification?.mutationSource == nil, - isInitialSnapshot: true + isInitialSnapshot: true, + classifyInitialSnapshotAsInheritedDefault: true ) consumedSourceSequence = initialSnapshot.consumedSourceSequence var lastYieldedEvent = initialSnapshot.event @@ -142,15 +153,21 @@ extension UserDefaultsSettingsStore { for: key, consumedSourceSequence: consumedSourceSequence, deliveredMutationSource: signal.deliveredMutationSource, - deliverPendingMutationSourceWhenUnobserved: signal.isBackingDefaultsNotification, + deliverPendingMutationSourceWhenUnobserved: + signal.isBackingDefaultsNotification + && !signal.isInheritedDefaultNotification, deliverPendingMutationSourceWhenValueDiffersFrom: lastYieldedEvent.value, - includeMutationSourceMetadata: signal.isBackingDefaultsNotification - || signal.deliveredMutationSource != nil, - includeMutationSourceMetadataWhenValueDiffersFrom: lastYieldedEvent.value + includeMutationSourceMetadata: + (signal.isBackingDefaultsNotification + && !signal.isInheritedDefaultNotification) + || signal.deliveredMutationSource != nil, + includeMutationSourceMetadataWhenValueDiffersFrom: lastYieldedEvent.value, + isInheritedDefaultChange: signal.isInheritedDefaultNotification ) consumedSourceSequence = snapshot.consumedSourceSequence var currentEvent = snapshot.event if signal.isBackingDefaultsNotification, + !signal.isInheritedDefaultNotification, signal.deliveredMutationSource == nil, currentEvent.value == lastYieldedEvent.value, currentEvent.mutationSource == nil, @@ -161,10 +178,13 @@ extension UserDefaultsSettingsStore { supersededMutationSources: lastYieldedEvent.deliveryMutationSources ) } - let recordsSourceLessFence = signal.isBackingDefaultsNotification && signal.deliveredMutationSource == nil + let recordsSourceLessFence = signal.isBackingDefaultsNotification + && !signal.isInheritedDefaultNotification + && signal.deliveredMutationSource == nil && currentEvent.value == lastYieldedEvent.value && currentEvent.mutationSource == nil && currentEvent.supersededMutationSource == nil - if currentEvent.mutationSource == nil, + if !signal.isInheritedDefaultNotification, + currentEvent.mutationSource == nil, (currentEvent.value != lastYieldedEvent.value || currentEvent.supersededMutationSource != nil || recordsSourceLessFence) { diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/UserDefaultsSettingsStore.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/UserDefaultsSettingsStore.swift index 15870f41fcf..6e5902a7e8a 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/UserDefaultsSettingsStore.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/UserDefaultsSettingsStore.swift @@ -44,6 +44,11 @@ public actor UserDefaultsSettingsStore { storage.value(for: key) } + /// Synchronously resolves the value inherited after resetting a user key. + public nonisolated func initialResetValue(for key: DefaultsKey) -> Value { + storage.inheritedValue(for: key) + } + /// Writes a value for the key. @discardableResult public func set( @@ -79,7 +84,11 @@ public actor UserDefaultsSettingsStore { return nil } recordAcceptedMutation(source, for: key.userDefaultsKey) - recordMutationSource(source, value: key.defaultValue, for: key.userDefaultsKey) + recordMutationSource( + source, + value: storage.inheritedValue(for: key), + for: key.userDefaultsKey + ) if let source { observedMutationWatermarks.beginMutationSource(source, for: key.userDefaultsKey) } @@ -98,12 +107,13 @@ public actor UserDefaultsSettingsStore { for entry in keys { guard case let .userDefaults(storageKey, suite, _) = entry.kind else { continue } recordSourceLessMutation(for: storageKey) - knownValues[storageKey] = entry.userDefaultsDefaultValue knownValueLogicalOrders[storageKey] = DispatchTime.now().uptimeNanoseconds let defaults: UserDefaults if let suite, let custom = UserDefaults(suiteName: suite) { defaults = custom + knownValues[storageKey] = entry.userDefaultsInheritedValue(defaults) } else { + knownValues[storageKey] = storage.inheritedValue(for: entry) storage.removeObject(forKey: storageKey) emitStoreOwnedSourceLessMutation(for: storageKey) continue @@ -125,6 +135,7 @@ public actor UserDefaultsSettingsStore { UserDefaultsSettingsStoreSignal( isBackingDefaultsNotification: true, canCarryActiveMutationSource: false, + isInheritedDefaultNotification: false, logicalOrder: logicalOrder, deliveredMutationSource: deliveredMutationSource ), @@ -280,12 +291,15 @@ public actor UserDefaultsSettingsStore { includeMutationSourceMetadata: Bool = true, includeMutationSourceMetadataWhenValueDiffersFrom previousMetadataValue: Value? = nil, supersedesPendingMutationSource: Bool = false, - isInitialSnapshot: Bool = false + isInitialSnapshot: Bool = false, + isInheritedDefaultChange: Bool = false, + classifyInitialSnapshotAsInheritedDefault: Bool = false ) -> ( event: UserDefaultsSettingsValueEvent, consumedSourceSequence: UInt64 ) { - let value = storage.value(for: key) + let resolution = storage.resolution(for: key) + let value = resolution.value let valueDiffersForPendingDelivery = previousValue.map { value != $0 } ?? false let valueDiffersForMetadata = previousMetadataValue.map { value != $0 } ?? false let shouldIncludeMutationSourceMetadata = includeMutationSourceMetadata || valueDiffersForMetadata @@ -330,7 +344,10 @@ public actor UserDefaultsSettingsStore { value: value, mutationSource: source, supersededMutationSource: supersededSource, - isInitialSnapshot: isInitialSnapshot + isInitialSnapshot: isInitialSnapshot, + isInheritedDefaultChange: isInheritedDefaultChange + || (classifyInitialSnapshotAsInheritedDefault + && resolution.source != .user) ), nextConsumedSourceSequence ) diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/UserDefaultsSettingsStoreSignals.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/UserDefaultsSettingsStoreSignals.swift index 08ed00faf66..eec485d40b0 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/UserDefaultsSettingsStoreSignals.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/UserDefaultsSettingsStoreSignals.swift @@ -4,6 +4,7 @@ import os struct UserDefaultsSettingsStoreSignal: Sendable { let isBackingDefaultsNotification: Bool let canCarryActiveMutationSource: Bool + let isInheritedDefaultNotification: Bool let logicalOrder: UInt64 let deliveredMutationSource: UserDefaultsSettingsMutationSource? } diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/UserDefaultsSettingsValueEvent.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/UserDefaultsSettingsValueEvent.swift index 7184d5f3d16..de779ba765c 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/UserDefaultsSettingsValueEvent.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/UserDefaultsSettingsValueEvent.swift @@ -17,13 +17,17 @@ public struct UserDefaultsSettingsValueEvent: Sendable, E /// Whether this event is the stream's initial store snapshot. public let isInitialSnapshot: Bool + /// Whether this event was triggered by the lower-precedence inherited layer. + public let isInheritedDefaultChange: Bool + /// Creates an observed value event. public init( value: Value, mutationSource: UserDefaultsSettingsMutationSource? = nil, supersededMutationSource: UserDefaultsSettingsMutationSource? = nil, supersededMutationSources: [UserDefaultsSettingsMutationSource] = [], - isInitialSnapshot: Bool = false + isInitialSnapshot: Bool = false, + isInheritedDefaultChange: Bool = false ) { self.value = value self.mutationSource = mutationSource @@ -33,6 +37,7 @@ public struct UserDefaultsSettingsValueEvent: Sendable, E } self.supersededMutationSources = sources self.isInitialSnapshot = isInitialSnapshot + self.isInheritedDefaultChange = isInheritedDefaultChange } } @@ -62,7 +67,8 @@ extension UserDefaultsSettingsValueEvent { value: value, mutationSource: mutationSource, supersededMutationSources: mergedSupersededSources, - isInitialSnapshot: isInitialSnapshot + isInitialSnapshot: isInitialSnapshot, + isInheritedDefaultChange: isInheritedDefaultChange ) } } diff --git a/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/DefaultsKeyRemoteDefaultTests.swift b/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/DefaultsKeyRemoteDefaultTests.swift new file mode 100644 index 00000000000..7c079a09727 --- /dev/null +++ b/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/DefaultsKeyRemoteDefaultTests.swift @@ -0,0 +1,151 @@ +import Foundation +import Testing + +@testable import CmuxSettings + +@Suite("DefaultsKey remote defaults", .serialized) +struct DefaultsKeyRemoteDefaultTests { + private let key = DefaultsKey( + id: "tests.beta.enabled", + defaultValue: false, + userDefaultsKey: "tests.beta.enabled", + remoteDefaultUserDefaultsKey: "tests.beta.remoteDefault.enabled" + ) + + @Test func resolutionPrefersUserThenRemoteThenCompileDefault() throws { + let defaults = try makeDefaults() + + #expect(key.resolution(in: defaults) == .init(value: false, source: .compileDefault)) + + key.setRemoteDefault(true, in: defaults) + #expect(key.resolution(in: defaults) == .init(value: true, source: .remoteDefault)) + + key.set(true, in: defaults) + #expect(key.resolution(in: defaults) == .init(value: true, source: .user)) + + key.setRemoteDefault(false, in: defaults) + #expect(key.resolution(in: defaults) == .init(value: true, source: .user)) + + key.set(false, in: defaults) + #expect(key.resolution(in: defaults) == .init(value: false, source: .user)) + + key.removeValue(in: defaults) + #expect(key.resolution(in: defaults) == .init(value: false, source: .remoteDefault)) + } + + @Test func invalidLayersFallThroughWithoutBecomingUserIntent() throws { + let defaults = try makeDefaults() + defaults.set("invalid-user", forKey: key.userDefaultsKey) + key.setRemoteDefault(true, in: defaults) + + #expect(key.hasStoredValue(in: defaults)) + #expect(key.resolution(in: defaults) == .init(value: true, source: .remoteDefault)) + + defaults.set("invalid-remote", forKey: try #require(key.remoteDefaultUserDefaultsKey)) + #expect(key.resolution(in: defaults) == .init(value: false, source: .compileDefault)) + } + + @Test func sameEffectiveUserWritePersistsIntentAcrossRemoteChanges() throws { + let defaults = try makeDefaults() + key.setRemoteDefault(true, in: defaults) + + key.set(true, in: defaults) + #expect(defaults.object(forKey: key.userDefaultsKey) != nil) + + key.setRemoteDefault(false, in: defaults) + #expect(key.value(in: defaults)) + #expect(key.resolution(in: defaults).source == .user) + } + + @Test func cachedRemoteDefaultSurvivesKeyAndStoreReconstruction() async throws { + nonisolated(unsafe) let defaults = try makeDefaults() + key.setRemoteDefault(true, in: defaults) + + let reconstructed = DefaultsKey( + id: key.id, + defaultValue: false, + userDefaultsKey: key.userDefaultsKey, + remoteDefaultUserDefaultsKey: key.remoteDefaultUserDefaultsKey + ) + let store = UserDefaultsSettingsStore(defaults: defaults) + + #expect(reconstructed.value(in: defaults)) + #expect(await store.value(for: reconstructed)) + #expect(defaults.object(forKey: reconstructed.userDefaultsKey) == nil) + } + + @Test func resetRemovesOnlyUserChoiceAndInheritsRemoteDefault() async throws { + nonisolated(unsafe) let defaults = try makeDefaults() + key.setRemoteDefault(true, in: defaults) + key.set(false, in: defaults) + let store = UserDefaultsSettingsStore(defaults: defaults) + + await store.reset(key) + + #expect(defaults.object(forKey: key.userDefaultsKey) == nil) + #expect(key.remoteDefaultValue(in: defaults) == true) + #expect(await store.value(for: key)) + } + + @Test func resetAllRemovesOnlyUserChoiceAndInheritsRemoteDefault() async throws { + nonisolated(unsafe) let defaults = try makeDefaults() + key.setRemoteDefault(true, in: defaults) + key.set(false, in: defaults) + let store = UserDefaultsSettingsStore(defaults: defaults) + + await store.resetAll([AnySettingKey(key)]) + + #expect(defaults.object(forKey: key.userDefaultsKey) == nil) + #expect(key.remoteDefaultValue(in: defaults) == true) + #expect(await store.value(for: key)) + } + + @Test func liveStoreObservationSeesRemoteChangeWithoutUserWrite() async throws { + nonisolated(unsafe) let defaults = try makeDefaults() + let store = UserDefaultsSettingsStore(defaults: defaults) + var iterator = store.values(for: key).makeAsyncIterator() + + #expect(await iterator.next() == false) + key.setRemoteDefault(true, in: defaults) + #expect(await iterator.next() == true) + #expect(defaults.object(forKey: key.userDefaultsKey) == nil) + } + + @Test func initialValueEventClassifiesInheritedAndPrimaryLayers() async throws { + nonisolated(unsafe) let inheritedDefaults = try makeDefaults() + key.setRemoteDefault(true, in: inheritedDefaults) + let inheritedStore = UserDefaultsSettingsStore(defaults: inheritedDefaults) + let inheritedStream = await inheritedStore.valueEvents(for: key) + var inheritedIterator = inheritedStream.makeAsyncIterator() + let inheritedEvent = await inheritedIterator.next() + + #expect(inheritedEvent?.value == true) + #expect(inheritedEvent?.isInitialSnapshot == true) + #expect(inheritedEvent?.isInheritedDefaultChange == true) + + nonisolated(unsafe) let primaryDefaults = try makeDefaults() + key.set(true, in: primaryDefaults) + let primaryStore = UserDefaultsSettingsStore(defaults: primaryDefaults) + let primaryStream = await primaryStore.valueEvents(for: key) + var primaryIterator = primaryStream.makeAsyncIterator() + let primaryEvent = await primaryIterator.next() + + #expect(primaryEvent?.value == true) + #expect(primaryEvent?.isInitialSnapshot == true) + #expect(primaryEvent?.isInheritedDefaultChange == false) + } + + @Test func unchangedRemoteRefreshReportsNoStorageChange() throws { + let defaults = try makeDefaults() + + #expect(key.setRemoteDefault(true, in: defaults)) + #expect(!key.setRemoteDefault(true, in: defaults)) + } + + private func makeDefaults() throws -> UserDefaults { + let suiteName = "cmux.tests.beta.remote.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defaults.removePersistentDomain(forName: suiteName) + return defaults + } +} diff --git a/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/UserDefaultsSettingsStoreObservationTests.swift b/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/UserDefaultsSettingsStoreObservationTests.swift index 4d7dfc6e8ec..4fefc0329da 100644 --- a/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/UserDefaultsSettingsStoreObservationTests.swift +++ b/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/UserDefaultsSettingsStoreObservationTests.swift @@ -13,9 +13,16 @@ struct UserDefaultsSettingsStoreObservationTests { defaults: observedDefaults, notificationCenter: notificationCenter ) - let (stream, continuation) = AsyncStream<(Bool, Bool)>.makeStream(bufferingPolicy: .unbounded) - let token = storage.addDidChangeObserver { isBackingDefaultsNotification, canCarryActiveMutationSource in - continuation.yield((isBackingDefaultsNotification, canCarryActiveMutationSource)) + let (stream, continuation) = AsyncStream<(Bool, Bool, Bool)>.makeStream(bufferingPolicy: .unbounded) + let token = storage.addDidChangeObserver { + isBackingDefaultsNotification, + canCarryActiveMutationSource, + isInheritedDefaultNotification in + continuation.yield(( + isBackingDefaultsNotification, + canCarryActiveMutationSource, + isInheritedDefaultNotification + )) } defer { token.remove() @@ -32,10 +39,68 @@ struct UserDefaultsSettingsStoreObservationTests { let thirdEvent = await iterator.next() #expect(firstEvent?.0 == false) #expect(firstEvent?.1 == false) + #expect(firstEvent?.2 == false) #expect(secondEvent?.0 == false) #expect(secondEvent?.1 == true) + #expect(secondEvent?.2 == false) #expect(thirdEvent?.0 == true) #expect(thirdEvent?.1 == true) + #expect(thirdEvent?.2 == false) + } + + @Test func storageChangeObserverClassifiesRemoteCacheMutationAsInherited() async { + let observedDefaults = UserDefaults(suiteName: "cmux.tests.\(UUID().uuidString)")! + let notificationCenter = NotificationCenter() + let storage = UserDefaultsSettingsStorage( + defaults: observedDefaults, + notificationCenter: notificationCenter + ) + let (stream, continuation) = AsyncStream<(Bool, Bool, Bool)>.makeStream( + bufferingPolicy: .unbounded + ) + let storageKey = "tests.beta.enabled" + let token = storage.addDidChangeObserver(for: storageKey) { + isBackingDefaultsNotification, + canCarryActiveMutationSource, + isInheritedDefaultNotification in + continuation.yield(( + isBackingDefaultsNotification, + canCarryActiveMutationSource, + isInheritedDefaultNotification + )) + } + defer { + token.remove() + continuation.finish() + } + + let userInfo = [ + CmuxSettingsRemoteDefaultNotification.storageKeyUserInfoKey: storageKey, + ] + notificationCenter.post( + name: .cmuxSettingsRemoteDefaultWillChange, + object: observedDefaults, + userInfo: userInfo + ) + notificationCenter.post( + name: UserDefaults.didChangeNotification, + object: observedDefaults + ) + notificationCenter.post( + name: .cmuxSettingsRemoteDefaultDidChange, + object: observedDefaults, + userInfo: userInfo + ) + + var iterator = stream.makeAsyncIterator() + let broadEvent = await iterator.next() + let targetedEvent = await iterator.next() + #expect(broadEvent?.0 == true) + #expect(broadEvent?.1 == false) + #expect(broadEvent?.2 == true) + #expect(targetedEvent?.0 == true) + #expect(targetedEvent?.1 == false) + #expect(targetedEvent?.2 == true) } @Test func valueEventBufferCarriesDroppedSourcesOntoSourceTaggedSurvivor() async { diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/DefaultsValueModel.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/DefaultsValueModel.swift index b3d27a9cf41..7e5a2d56fb2 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/DefaultsValueModel.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/DefaultsValueModel.swift @@ -35,13 +35,18 @@ public final class DefaultsValueModel { /// The most recently observed value. SwiftUI views read this synchronously. public private(set) var current: Value private(set) var revision = 0 + var pendingStoreEchoCount: Int { pendingStoreEchoes.count } private let store: UserDefaultsSettingsStore private let key: DefaultsKey private let initialStoreValue: Value @ObservationIgnored private let makeStream: @MainActor @Sendable (Set) async -> AsyncStream> - @ObservationIgnored private var pendingStoreEchoes: [(source: UserDefaultsSettingsMutationSource, value: Value)] = [] + @ObservationIgnored private var pendingStoreEchoes: [( + source: UserDefaultsSettingsMutationSource, + value: Value, + protectsExplicitValueFromInheritedChanges: Bool + )] = [] @ObservationIgnored private let maximumPendingStoreEchoes = 16 @ObservationIgnored private let mutationOwnerID = UUID() @ObservationIgnored private var nextMutationSequence: UInt64 = 0 @@ -122,7 +127,10 @@ public final class DefaultsValueModel { /// fire-and-forget `Task`. @discardableResult public func set(_ value: Value) -> UserDefaultsSettingsMutationSource { - let source = recordPendingStoreEcho(value) + let source = recordPendingStoreEcho( + value, + protectsExplicitValueFromInheritedChanges: true + ) updateCurrent(value) Task { @MainActor [self, store, key, source, value] in guard await store.set(value, for: key, source: source) != nil else { @@ -149,7 +157,10 @@ public final class DefaultsValueModel { _ value: Value, afterCommit: @escaping @MainActor @Sendable () -> Void ) -> UserDefaultsSettingsMutationSource { - let source = recordPendingStoreEcho(value) + let source = recordPendingStoreEcho( + value, + protectsExplicitValueFromInheritedChanges: true + ) updateCurrent(value) Task { @MainActor [self, store, key, source, value, afterCommit] in guard await store.set(value, for: key, source: source) != nil else { @@ -176,9 +187,12 @@ public final class DefaultsValueModel { /// async store write is in flight. @discardableResult public func reset() -> UserDefaultsSettingsMutationSource { - let defaultValue = key.defaultValue - let source = recordPendingStoreEcho(defaultValue) - updateCurrent(defaultValue) + let inheritedValue = store.initialResetValue(for: key) + let source = recordPendingStoreEcho( + inheritedValue, + protectsExplicitValueFromInheritedChanges: false + ) + updateCurrent(inheritedValue) Task { @MainActor [self, store, key, source] in guard await store.reset(key, source: source) != nil else { let committedValue = await store.value(for: key) @@ -200,6 +214,10 @@ public final class DefaultsValueModel { } return } + if event.isInheritedDefaultChange, + pendingStoreEchoes.last?.protectsExplicitValueFromInheritedChanges == true { + return + } if isInitialStoreEvent, event.isInitialSnapshot, event.mutationSource == nil, @@ -219,13 +237,20 @@ public final class DefaultsValueModel { revision &+= 1 } - private func recordPendingStoreEcho(_ value: Value) -> UserDefaultsSettingsMutationSource { + private func recordPendingStoreEcho( + _ value: Value, + protectsExplicitValueFromInheritedChanges: Bool + ) -> UserDefaultsSettingsMutationSource { nextMutationSequence &+= 1 let source = UserDefaultsSettingsMutationSource( ownerID: mutationOwnerID, sequence: nextMutationSequence ) - pendingStoreEchoes.append((source, value)) + pendingStoreEchoes.append(( + source, + value, + protectsExplicitValueFromInheritedChanges + )) let overflow = pendingStoreEchoes.count - maximumPendingStoreEchoes if overflow > 0 { if let lastRemoved = pendingStoreEchoes.prefix(overflow).last { @@ -244,7 +269,7 @@ public final class DefaultsValueModel { return (false, false) } - guard let matchingIndex = pendingStoreEchoes.firstIndex(where: { $0.source == source && $0.value == value }) else { + guard let matchingIndex = pendingStoreEchoes.firstIndex(where: { $0.source == source }) else { return (source.sequence < minimumRetainedMutationSequence, false) } diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift index e21cac1e3b3..939d1a9ac3e 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift @@ -231,7 +231,7 @@ extension Array where Element == CuratedSettingEntry { title: String(localized: "settings.betaFeatures.workspaceTodoControls", defaultValue: "Workspace Todo Controls"), detailText: [ String(localized: "settings.betaFeatures.workspaceTodoControls.subtitleOn", defaultValue: "Shows Add Checklist Item and workspace status controls."), - String(localized: "settings.betaFeatures.workspaceTodoControls.subtitleOff", defaultValue: "Keeps workspace todo summaries read-only unless remote rollout enables the controls."), + String(localized: "settings.betaFeatures.workspaceTodoControls.subtitleOff", defaultValue: "Keeps workspace todo summaries read-only until you enable the controls here."), ].joined(separator: " "), paths: ["sidebar.beta.workspaceTodos.controls.enabled"], synonyms: String(localized: "settings.search.alias.setting.betaFeatures.workspace-todo-controls", defaultValue: "sidebar.beta.workspaceTodos.controls.enabled workspace todo todos task status checklist add item controls beta") diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BetaFeaturesSection.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BetaFeaturesSection.swift index fbefd9455f0..a4cdade57f3 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BetaFeaturesSection.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BetaFeaturesSection.swift @@ -72,7 +72,7 @@ public struct BetaFeaturesSection: View { String(localized: "settings.betaFeatures.workspaceTodoControls", defaultValue: "Workspace Todo Controls"), subtitle: workspaceTodoControls.current ? String(localized: "settings.betaFeatures.workspaceTodoControls.subtitleOn", defaultValue: "Shows Add Checklist Item and workspace status controls.") - : String(localized: "settings.betaFeatures.workspaceTodoControls.subtitleOff", defaultValue: "Keeps workspace todo summaries read-only unless remote rollout enables the controls.") + : String(localized: "settings.betaFeatures.workspaceTodoControls.subtitleOff", defaultValue: "Keeps workspace todo summaries read-only until you enable the controls here.") ) { Toggle("", isOn: Binding(get: { workspaceTodoControls.current }, set: { workspaceTodoControls.set($0) })) .labelsHidden() diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ResetSection.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ResetSection.swift index 34fb592f4de..96d9be16b6b 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ResetSection.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/ResetSection.swift @@ -43,6 +43,7 @@ public struct ResetSection: View { } .buttonStyle(.bordered) .controlSize(.regular) + .accessibilityIdentifier("SettingsResetAllButton") Spacer(minLength: 0) } .padding(.horizontal, 14) diff --git a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/DefaultsValueModelRemoteDefaultTests.swift b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/DefaultsValueModelRemoteDefaultTests.swift new file mode 100644 index 00000000000..eb2e6cc661c --- /dev/null +++ b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/DefaultsValueModelRemoteDefaultTests.swift @@ -0,0 +1,202 @@ +import CmuxSettings +import Foundation +import Testing + +@testable import CmuxSettingsUI + +@Suite("DefaultsValueModel remote defaults", .serialized) +struct DefaultsValueModelRemoteDefaultTests { + @MainActor + @Test func resetOptimisticallyInheritsRemoteDefault() async throws { + let suiteName = "cmux.settings.ui.remote.\(UUID().uuidString)" + nonisolated(unsafe) let defaults = try #require(UserDefaults(suiteName: suiteName)) + defaults.removePersistentDomain(forName: suiteName) + let key = DefaultsKey( + id: "tests.beta.enabled", + defaultValue: false, + userDefaultsKey: "tests.beta.enabled", + remoteDefaultUserDefaultsKey: "tests.beta.remoteDefault.enabled" + ) + key.setRemoteDefault(true, in: defaults) + key.set(false, in: defaults) + let store = UserDefaultsSettingsStore(defaults: defaults) + let model = DefaultsValueModel(store: store, key: key) + + #expect(model.current == false) + model.reset() + #expect(model.current == true) + for _ in 0..<1_000 where defaults.object(forKey: key.userDefaultsKey) != nil { + await Task.yield() + } + #expect(defaults.object(forKey: key.userDefaultsKey) == nil) + } + + @MainActor + @Test func pendingSameValueUserIntentSurvivesSynchronousRemoteChange() async throws { + let suiteName = "cmux.settings.ui.remote.race.\(UUID().uuidString)" + nonisolated(unsafe) let defaults = try #require(UserDefaults(suiteName: suiteName)) + defaults.removePersistentDomain(forName: suiteName) + defer { defaults.removePersistentDomain(forName: suiteName) } + let key = makeKey() + let store = UserDefaultsSettingsStore(defaults: defaults) + let model = DefaultsValueModel(store: store, key: key) + model.startObserving() + + for _ in 0..<100_000 where model.revision == 0 { + await Task.yield() + } + #expect(model.revision > 0) + let revisionBeforeClick = model.revision + + model.set(false) + let revisionAfterClick = model.revision + #expect(revisionAfterClick == revisionBeforeClick + 1) + #expect(model.current == false) + + // The remote cache mutation is synchronous and intentionally happens + // before the model's fire-and-forget primary write can yield. + key.setRemoteDefault(true, in: defaults) + + var everyObservedValueStayedFalse = model.current == false + for _ in 0..<100_000 { + everyObservedValueStayedFalse = everyObservedValueStayedFalse && model.current == false + if Bool.decodeFromUserDefaults( + defaults.object(forKey: key.userDefaultsKey) + ) == false { + break + } + await Task.yield() + } + for _ in 0..<100 { + await Task.yield() + everyObservedValueStayedFalse = everyObservedValueStayedFalse && model.current == false + } + + #expect(everyObservedValueStayedFalse) + #expect(Bool.decodeFromUserDefaults( + defaults.object(forKey: key.userDefaultsKey) + ) == false) + #expect(model.current == false) + #expect(model.revision == revisionAfterClick) + } + + @MainActor + @Test func remoteChangeBelowEstablishedUserOverridePublishesNoUIRevision() async throws { + let suiteName = "cmux.settings.ui.remote.override.\(UUID().uuidString)" + nonisolated(unsafe) let defaults = try #require(UserDefaults(suiteName: suiteName)) + defaults.removePersistentDomain(forName: suiteName) + defer { defaults.removePersistentDomain(forName: suiteName) } + let key = makeKey() + key.set(false, in: defaults) + let store = UserDefaultsSettingsStore(defaults: defaults) + let model = DefaultsValueModel(store: store, key: key) + model.startObserving() + + for _ in 0..<100_000 where model.revision == 0 { + await Task.yield() + } + #expect(model.revision > 0) + let revisionBeforeRemoteChange = model.revision + + key.setRemoteDefault(true, in: defaults) + var everyObservedValueStayedFalse = model.current == false + for _ in 0..<1_000 { + await Task.yield() + everyObservedValueStayedFalse = everyObservedValueStayedFalse && model.current == false + } + + #expect(everyObservedValueStayedFalse) + #expect(model.current == false) + #expect(model.revision == revisionBeforeRemoteChange) + #expect(key.resolution(in: defaults).source == .user) + } + + @MainActor + @Test func resetConsumesOwnedEchoWhenRemoteInheritedValueChanges() async throws { + let suiteName = "cmux.settings.ui.remote.reset-race.\(UUID().uuidString)" + nonisolated(unsafe) let defaults = try #require(UserDefaults(suiteName: suiteName)) + defaults.removePersistentDomain(forName: suiteName) + defer { defaults.removePersistentDomain(forName: suiteName) } + let key = makeKey() + key.set(true, in: defaults) + let store = UserDefaultsSettingsStore(defaults: defaults) + let model = DefaultsValueModel(store: store, key: key) + model.startObserving() + + for _ in 0..<100_000 where model.revision == 0 { + await Task.yield() + } + #expect(model.current) + + model.reset() + #expect(model.current == false) + key.setRemoteDefault(true, in: defaults) + + for _ in 0..<100_000 where + defaults.object(forKey: key.userDefaultsKey) != nil + || model.pendingStoreEchoCount != 0 + || model.current != true { + await Task.yield() + } + + #expect(defaults.object(forKey: key.userDefaultsKey) == nil) + #expect(model.current) + #expect(model.pendingStoreEchoCount == 0) + #expect(key.resolution(in: defaults) == .init(value: true, source: .remoteDefault)) + } + + @MainActor + @Test func inheritedInitialSnapshotCannotOverwriteClickMadeBeforeObservationStarts() async throws { + let suiteName = "cmux.settings.ui.remote.startup-race.\(UUID().uuidString)" + nonisolated(unsafe) let defaults = try #require(UserDefaults(suiteName: suiteName)) + defaults.removePersistentDomain(forName: suiteName) + defer { defaults.removePersistentDomain(forName: suiteName) } + let key = makeKey() + let store = UserDefaultsSettingsStore(defaults: defaults) + let (stream, continuation) = + AsyncStream>.makeStream( + bufferingPolicy: .unbounded + ) + let model = DefaultsValueModel( + store: store, + key: key, + initialValue: false, + makeStream: { _ in stream } + ) + + let source = model.set(false) + key.setRemoteDefault(true, in: defaults) + continuation.yield(UserDefaultsSettingsValueEvent( + value: true, + isInitialSnapshot: true, + isInheritedDefaultChange: true + )) + continuation.yield(UserDefaultsSettingsValueEvent( + value: false, + mutationSource: source + )) + model.startObserving() + + let revisionAfterClick = model.revision + var everyObservedValueStayedFalse = model.current == false + for _ in 0..<1_000 { + await Task.yield() + everyObservedValueStayedFalse = everyObservedValueStayedFalse && model.current == false + if model.pendingStoreEchoCount == 0 { break } + } + + #expect(everyObservedValueStayedFalse) + #expect(model.current == false) + #expect(model.revision == revisionAfterClick) + #expect(model.pendingStoreEchoCount == 0) + } + + private func makeKey() -> DefaultsKey { + DefaultsKey( + id: "tests.beta.enabled", + defaultValue: false, + userDefaultsKey: "tests.beta.enabled", + remoteDefaultUserDefaultsKey: "tests.beta.remoteDefault.enabled" + ) + } +} diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 344e18af7c7..07620ce041b 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -162638,13 +162638,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Keeps workspace todo summaries read-only unless remote rollout enables the controls." + "value": "Keeps workspace todo summaries read-only until you enable the controls here." } }, "ja": { "stringUnit": { "state": "translated", - "value": "リモートロールアウトでコントロールが有効になるまで、ワークスペース Todo の概要を読み取り専用にします。" + "value": "ここでコントロールを有効にするまで、ワークスペース Todo の概要を読み取り専用にします。" } } } diff --git a/Sources/App/WorkspaceRuntimeSettings.swift b/Sources/App/WorkspaceRuntimeSettings.swift index 499e276ab10..b54f7af7087 100644 --- a/Sources/App/WorkspaceRuntimeSettings.swift +++ b/Sources/App/WorkspaceRuntimeSettings.swift @@ -477,20 +477,17 @@ enum AgentHibernationTrackingGate { } enum RightSidebarBetaFeatureSettings { - static let feedEnabledKey = "rightSidebar.beta.feed.enabled" - static let dockEnabledKey = "rightSidebar.beta.dock.enabled" + private static let beta = BetaFeaturesCatalogSection() - static let defaultFeedEnabled = false - static let defaultDockEnabled = false + static let feedEnabledKey = beta.rightSidebarFeed.userDefaultsKey + static let dockEnabledKey = beta.rightSidebarDock.userDefaultsKey nonisolated static func isFeedEnabled(defaults: UserDefaults = .standard) -> Bool { - guard defaults.object(forKey: feedEnabledKey) != nil else { return defaultFeedEnabled } - return defaults.bool(forKey: feedEnabledKey) + BetaFeaturesCatalogSection().rightSidebarFeed.value(in: defaults) } nonisolated static func isDockEnabled(defaults: UserDefaults = .standard) -> Bool { - guard defaults.object(forKey: dockEnabledKey) != nil else { return defaultDockEnabled } - return defaults.bool(forKey: dockEnabledKey) + BetaFeaturesCatalogSection().rightSidebarDock.value(in: defaults) } } diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index f5faddec3e9..847d3f0cea6 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1272,6 +1272,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent func applicationDidFinishLaunching(_ notification: Notification) { let env = ProcessInfo.processInfo.environment +#if DEBUG + CmuxFeatureFlags.applyUITestBetaRemoteDefaultsIfPresent(environment: env) +#endif let telemetryEnabled = TelemetrySettings.enabledForCurrentLaunch let sentryStartupPolicy = MacSentryStartupPolicy( environment: env, diff --git a/Sources/CommandPalette/CommandPaletteSettingsToggle.swift b/Sources/CommandPalette/CommandPaletteSettingsToggle.swift index f3b739adf34..2d77bdb72a1 100644 --- a/Sources/CommandPalette/CommandPaletteSettingsToggle.swift +++ b/Sources/CommandPalette/CommandPaletteSettingsToggle.swift @@ -62,6 +62,29 @@ struct CommandPaletteSettingToggleDescriptor: Sendable { self.isAvailable = isAvailable } + init( + commandId: String, + settingsKey: String, + title: @escaping @Sendable () -> String, + sectionTitle: @escaping @Sendable () -> String, + keywords: [String], + key: DefaultsKey, + isAvailable: @escaping @Sendable (UserDefaults) -> Bool = { _ in true }, + didSet: @escaping @Sendable (Bool, UserDefaults, NotificationCenter) -> Void = { _, _, _ in } + ) { + self.commandId = commandId + self.settingsKey = settingsKey + self.title = title + self.sectionTitle = sectionTitle + self.keywords = keywords + self.isOn = { defaults in key.value(in: defaults) } + self.setOn = { newValue, defaults, notificationCenter in + key.set(newValue, in: defaults) + didSet(newValue, defaults, notificationCenter) + } + self.isAvailable = isAvailable + } + init( commandId: String, settingsKey: String, @@ -734,8 +757,7 @@ enum CommandPaletteSettingsToggleCommands { }, sectionTitle: beta, keywords: ["betaFeatures.feed", "feed", "right", "sidebar", "beta", "agent", "decisions", "permissions"], - defaultValue: RightSidebarBetaFeatureSettings.defaultFeedEnabled, - defaultsKey: RightSidebarBetaFeatureSettings.feedEnabledKey + key: BetaFeaturesCatalogSection().rightSidebarFeed ), CommandPaletteSettingToggleDescriptor( commandId: commandIdPrefix + "rightSidebarDock", @@ -745,8 +767,7 @@ enum CommandPaletteSettingsToggleCommands { }, sectionTitle: beta, keywords: ["betaFeatures.dock", "dock", "right", "sidebar", "beta", "terminal", "controls"], - defaultValue: RightSidebarBetaFeatureSettings.defaultDockEnabled, - defaultsKey: RightSidebarBetaFeatureSettings.dockEnabledKey + key: BetaFeaturesCatalogSection().rightSidebarDock ), CommandPaletteSettingToggleDescriptor( commandId: commandIdPrefix + "claudeCodeIntegration", diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 69d0b70ed26..b0c4d6d862d 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -848,6 +848,7 @@ struct ContentView: View { private var selectedLeftSidebarProviderId = CmuxExtensionSidebarSelection.defaultProviderId @LiveSetting(\.betaFeatures.extensions) private var leftSidebarExtensionsExperimentalEnabled @LiveSetting(\.betaFeatures.customSidebars) private var leftSidebarCustomSidebarsExperimentalEnabled + @LiveSetting(\.betaFeatures.workspaceTodoControls) private var workspaceTodoControlsEnabled @LiveSetting(\.shortcuts.showModifierHoldHints) private var showModifierHoldHints @LiveSetting(\.customSidebars.renderer) private var customSidebarRenderer /// Canonical sidebar width, deliberately NOT observed by ContentView: @@ -3024,6 +3025,17 @@ struct ContentView: View { refreshCachedDefaultTerminalStatus() }) + view = AnyView(view.onChange(of: workspaceTodoControlsEnabled) { _, _ in + cachedCommandPaletteFingerprint = nil + guard isCommandPalettePresented else { return } + scheduleCommandPaletteResultsRefresh( + forceSearchCorpusRefresh: true, + preservePendingActivation: true + ) + syncCommandPaletteOverlayCommandListState() + syncCommandPaletteDebugStateForObservedWindow() + }) + view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .commandPaletteSubmitRequested)) { notification in guard isCommandPalettePresented else { return } let requestedWindow = notification.object as? NSWindow @@ -7362,7 +7374,10 @@ struct ContentView: View { when: { $0.bool(CommandPaletteContextKeys.hasWorkspace) } ) ) - contributions.append(contentsOf: WorkspaceTodoPaletteCommands.contributions(workspaceSubtitle: workspaceSubtitle)) + contributions.append(contentsOf: WorkspaceTodoPaletteCommands.contributions( + todoControlsEnabled: workspaceTodoControlsEnabled, + workspaceSubtitle: workspaceSubtitle + )) for entry in WorkspaceTabColorSettings.palette() { contributions.append( CommandPaletteCommandContribution( @@ -10106,7 +10121,7 @@ enum CmuxExtensionSidebarSelection { // doing that on the SwiftUI body's hot path turned the sidebar // re-render into a CPU catastrophe (issue #5970). let key = BetaFeaturesCatalogSection().extensions - return Bool.decodeFromUserDefaults(UserDefaults.standard.object(forKey: key.userDefaultsKey)) ?? key.defaultValue + return key.value(in: .standard) } static var providers: [any CmuxSidebarProvider] { @@ -10125,7 +10140,7 @@ enum CmuxExtensionSidebarSelection { // See ``isEnabled``: read only the beta-features section so a body-path // access does not allocate the entire `SettingCatalog` (issue #5970). let key = BetaFeaturesCatalogSection().customSidebars - return Bool.decodeFromUserDefaults(UserDefaults.standard.object(forKey: key.userDefaultsKey)) ?? key.defaultValue + return key.value(in: .standard) } /// Directory custom sidebars are authored into. @@ -10575,6 +10590,7 @@ struct VerticalTabsSidebar: View, Equatable { private var selectedExtensionSidebarProviderId = CmuxExtensionSidebarSelection.defaultProviderId @LiveSetting(\.betaFeatures.extensions) private var extensionsExperimentalEnabled @LiveSetting(\.betaFeatures.customSidebars) private var customSidebarsExperimentalEnabled + @LiveSetting(\.betaFeatures.workspaceTodoControls) private var workspaceTodoControlsEnabled @LiveSetting(\.customSidebars.renderer) private var customSidebarRenderer @LiveSetting(\.shortcuts.showModifierHoldHints) private var showModifierHoldHints #if DEBUG @@ -10809,6 +10825,7 @@ struct VerticalTabsSidebar: View, Equatable { let workspaceNumberShortcut: StoredShortcut let tabItemSettings: SidebarTabItemSettingsSnapshot let showsAgentActivity: Bool + let todoControlsEnabled: Bool let pinResolutionContext: WorkspaceActionDispatcher.PinResolutionContext let tabIndexById: [UUID: Int] let numberedWorkspaceIndexById: [UUID: Int] @@ -10961,6 +10978,7 @@ struct VerticalTabsSidebar: View, Equatable { tabItemSettings: tabItemSettings, showsAgentActivity: tabItemSettings.details.showAgentActivity && CmuxFeatureFlags.shared.isSidebarWorkspaceAgentSpinnerEnabled, + todoControlsEnabled: workspaceTodoControlsEnabled, pinResolutionContext: pinResolutionContext, tabIndexById: tabIndexById, numberedWorkspaceIndexById: numberedWorkspaceIndexById, @@ -11161,6 +11179,11 @@ struct VerticalTabsSidebar: View, Equatable { refreshWorkspaceSnapshots() } } + .onChange(of: renderContext.todoControlsEnabled) { _, _ in + if isPresented, !featureFlags.isAppKitSidebarListEnabled { + refreshWorkspaceSnapshots() + } + } .onDisappear { workspaceSnapshotRefreshCoalescer.cancel() } @@ -11723,7 +11746,7 @@ struct VerticalTabsSidebar: View, Equatable { checklistAddFieldActivationToken: input.checklistAddFieldActivationToken, isChecklistPopoverPresented: input.isChecklistPopoverPresented, editingChecklistItemId: editingChecklistItemIds[tab.id], - todoControlsEnabled: WorkspaceTodoFeature.isEnabled, + todoControlsEnabled: input.todoControlsEnabled, isMetadataExpanded: expandedMetadataWorkspaceIds.contains(tab.id), isMarkdownExpanded: expandedMarkdownWorkspaceIds.contains(tab.id) ) @@ -11738,6 +11761,7 @@ struct VerticalTabsSidebar: View, Equatable { allRemoteContextMenuTargetsDisconnected: rowSnapshot.contextMenu.allRemoteTargetsDisconnected, contextMenuPinState: rowSnapshot.contextMenu.pinState, workspaceGroupMenuSnapshot: rowSnapshot.contextMenu.groupMenuSnapshot, + todoControlsEnabled: input.todoControlsEnabled, refreshSnapshot: { [workspaceId = tab.id] in scheduleWorkspaceSnapshotRefresh(workspaceId: workspaceId) }, @@ -12076,7 +12100,8 @@ struct VerticalTabsSidebar: View, Equatable { let snapshot = makeWorkspaceSnapshot( workspace: workspace, settings: settings, - showsAgentActivity: showsAgentActivity + showsAgentActivity: showsAgentActivity, + todoControlsEnabled: workspaceTodoControlsEnabled ) if featureFlags.isAppKitSidebarListEnabled { guard appKitRowSnapshotCache.value(for: workspaceId) != snapshot else { @@ -12108,7 +12133,8 @@ struct VerticalTabsSidebar: View, Equatable { next[workspace.id] = makeWorkspaceSnapshot( workspace: workspace, settings: settings, - showsAgentActivity: showsAgentActivity + showsAgentActivity: showsAgentActivity, + todoControlsEnabled: workspaceTodoControlsEnabled ) } guard next != workspaceSnapshotsById || Set(workspaceSnapshotsById.keys) != liveIds else { return } @@ -12118,7 +12144,8 @@ struct VerticalTabsSidebar: View, Equatable { private func makeWorkspaceSnapshot( workspace: Workspace, settings: SidebarTabItemSettingsSnapshot, - showsAgentActivity: Bool + showsAgentActivity: Bool, + todoControlsEnabled: Bool ) -> SidebarWorkspaceSnapshotBuilder.Snapshot { #if DEBUG sidebarLazyContractProbe.workspaceSnapshotBuild?() @@ -12126,7 +12153,8 @@ struct VerticalTabsSidebar: View, Equatable { return SidebarWorkspaceSnapshotFactory( workspace: workspace, settings: settings, - showsAgentActivity: showsAgentActivity + showsAgentActivity: showsAgentActivity, + todoControlsEnabled: todoControlsEnabled ).makeSnapshot() } @@ -13849,7 +13877,8 @@ struct VerticalTabsSidebar: View, Equatable { let settings = renderContext.tabItemSettings let expectedPresentationKey = SidebarWorkspaceSnapshotFactory.presentationKey( settings: settings, - showsAgentActivity: renderContext.showsAgentActivity + showsAgentActivity: renderContext.showsAgentActivity, + todoControlsEnabled: renderContext.todoControlsEnabled ) let cachedWorkspaceSnapshot = featureFlags.isAppKitSidebarListEnabled ? appKitRowSnapshotCache.value(for: tab.id) @@ -13862,7 +13891,8 @@ struct VerticalTabsSidebar: View, Equatable { workspaceSnapshot = makeWorkspaceSnapshot( workspace: tab, settings: settings, - showsAgentActivity: renderContext.showsAgentActivity + showsAgentActivity: renderContext.showsAgentActivity, + todoControlsEnabled: renderContext.todoControlsEnabled ) if featureFlags.isAppKitSidebarListEnabled { appKitRowSnapshotCache.store(workspaceSnapshot, for: tab.id) @@ -13903,6 +13933,7 @@ struct VerticalTabsSidebar: View, Equatable { unreadCount: unreadSummary.unreadCount, latestNotificationText: liveLatestNotificationText, showsAgentActivity: renderContext.showsAgentActivity, + todoControlsEnabled: renderContext.todoControlsEnabled, rowSpacing: tabRowSpacing, showsModifierShortcutHints: resolvedShowsModifierShortcutHints, isPointerHovering: isPointerHovering, @@ -15169,7 +15200,7 @@ struct TabItemView: View, Equatable { scaledFontSize(12.5), percent: globalFontMagnificationPercent ) * 0.6 - let todoControlsEnabled = WorkspaceTodoFeature.isEnabled + let todoControlsEnabled = snapshot.todoControlsEnabled let scaledCloseButtonHitSize = max(16, 16 * fontScale) let scaledCloseButtonWidth = max( SidebarTrailingAccessoryWidthPolicy().closeButtonWidth, diff --git a/Sources/FeatureFlags.swift b/Sources/FeatureFlags.swift index 7041b203422..b2af5ea8c5e 100644 --- a/Sources/FeatureFlags.swift +++ b/Sources/FeatureFlags.swift @@ -1,6 +1,7 @@ import Foundation import Observation import PostHog +import CmuxSettings import os struct CmuxFeatureFlagDefinition: Identifiable, Equatable, Sendable { @@ -12,6 +13,30 @@ struct CmuxFeatureFlagDefinition: Identifiable, Equatable, Sendable { let defaultWhenUnavailable: Bool } +struct CmuxBetaRemoteDefaultDefinition: Identifiable, Sendable { + var id: String { settingKey.id } + + let flagKey: String + let settingKey: DefaultsKey +} + +struct CmuxRemoteFlagSnapshot: Sendable, Equatable { + let values: [String: Bool] + let invalidKeys: Set +} + +#if DEBUG +struct CmuxBetaRemoteDefaultState: Sendable, Equatable { + let settingID: String + let flagKey: String + let userKeyPresent: Bool + let userValue: Bool? + let remoteDefault: Bool? + let effectiveValue: Bool + let source: DefaultsValueSource +} +#endif + /// PostHog-backed runtime feature flags for the macOS app (PostHog project /// 244066, same public key analytics uses). Values are cached in memory and /// refreshed when the SDK reports a flag payload, so gated UI can be toggled @@ -57,7 +82,6 @@ final class CmuxFeatureFlags { #endif private static let sidebarWorkspaceAgentSpinnerDefault = false private static let simulatorDefault = true - private static let workspaceTodoControlsDefault = false private static let appKitSidebarListDefault = true private static let overrideKeyPrefix = "cmux.flags.override." @@ -108,6 +132,80 @@ final class CmuxFeatureFlags { defaultWhenUnavailable: CmuxFeatureFlags.mobileWorkspaceChangesDefault ) + /// Remote defaults for user-facing Beta Features toggles. + /// + /// These are separate from ``allFlags`` because their remote value is an + /// inherited default. A persisted user choice remains authoritative. + static let betaRemoteDefaults: [CmuxBetaRemoteDefaultDefinition] = { + let beta = BetaFeaturesCatalogSection() + return [ + // FLAG(key: right-sidebar-feed-default-experiment, owner: lawrencecchen, + // reviewBy: 2026-10-01, defaultWhenUnavailable: false) + CmuxBetaRemoteDefaultDefinition( + flagKey: "right-sidebar-feed-default-experiment", + settingKey: beta.rightSidebarFeed + ), + + // FLAG(key: right-sidebar-dock-default-experiment, owner: lawrencecchen, + // reviewBy: 2026-10-01, defaultWhenUnavailable: false) + CmuxBetaRemoteDefaultDefinition( + flagKey: "right-sidebar-dock-default-experiment", + settingKey: beta.rightSidebarDock + ), + + // FLAG(key: extensions-default-experiment, owner: lawrencecchen, + // reviewBy: 2026-10-01, defaultWhenUnavailable: false) + CmuxBetaRemoteDefaultDefinition( + flagKey: "extensions-default-experiment", + settingKey: beta.extensions + ), + + // FLAG(key: custom-sidebars-default-experiment, owner: lawrencecchen, + // reviewBy: 2026-10-01, defaultWhenUnavailable: true) + CmuxBetaRemoteDefaultDefinition( + flagKey: "custom-sidebars-default-experiment", + settingKey: beta.customSidebars + ), + + // FLAG(key: workspace-todo-controls-enabled-release, owner: lawrencecchen, + // reviewBy: 2026-10-01, defaultWhenUnavailable: false) + CmuxBetaRemoteDefaultDefinition( + flagKey: "workspace-todo-controls-enabled-release", + settingKey: beta.workspaceTodoControls + ), + + // FLAG(key: remote-tmux-default-experiment, owner: lawrencecchen, + // reviewBy: 2026-10-01, defaultWhenUnavailable: false) + CmuxBetaRemoteDefaultDefinition( + flagKey: "remote-tmux-default-experiment", + settingKey: beta.remoteTmux + ), + ] + }() + +#if DEBUG + static func applyUITestBetaRemoteDefaultsIfPresent( + environment: [String: String], + defaults: UserDefaults = .standard + ) { + guard environment["CMUX_UI_TEST_MODE"] == "1", + let rawPayload = environment["CMUX_UI_TEST_BETA_REMOTE_DEFAULTS"], + let data = rawPayload.data(using: .utf8), + let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + return + } + + for definition in betaRemoteDefaults { + let rawValue = payload[definition.flagKey] + ?? payload[definition.settingKey.id] + definition.settingKey.setRemoteDefault( + Bool.decodeFromJSON(rawValue), + in: defaults + ) + } + } +#endif + // Order is load-bearing for the positional typed accessors below. Flags // that need a stable public definition are declared independently and // included here without repeating their key literal. @@ -211,24 +309,6 @@ final class CmuxFeatureFlags { defaultWhenUnavailable: CmuxFeatureFlags.simulatorDefault ), - // FLAG(key: workspace-todo-controls-enabled-release, owner: lawrencecchen, - // reviewBy: 2026-10-01, defaultWhenUnavailable: false) - // Shows user-facing workspace todo controls that create checklist - // items or set completion/status lanes. Hidden until the local - // beta setting opts in or the PostHog flag is enabled. - CmuxFeatureFlagDefinition( - key: "workspace-todo-controls-enabled-release", - title: String( - localized: "featureFlags.workspaceTodoControls.title", - defaultValue: "Workspace todo controls" - ), - flagDescription: String( - localized: "featureFlags.workspaceTodoControls.description", - defaultValue: "Shows Add Checklist Item and workspace completion status controls." - ), - defaultWhenUnavailable: CmuxFeatureFlags.workspaceTodoControlsDefault - ), - CmuxFeatureFlags.appKitSidebarListFlag, CmuxFeatureFlags.mobileWorkspaceChangesFlag, @@ -259,10 +339,6 @@ final class CmuxFeatureFlags { effectiveValue(for: Self.allFlags[5]) } - var isWorkspaceTodoControlsEnabled: Bool { - effectiveValue(for: Self.allFlags[6]) - } - var isAppKitSidebarListEnabled: Bool { effectiveValue(for: Self.appKitSidebarListFlag) } @@ -294,7 +370,7 @@ final class CmuxFeatureFlags { @ObservationIgnored private let remoteFlagValueProvider: (String) -> Any? @ObservationIgnored - private let remoteFlagLoader: @Sendable () async -> [String: Bool]? + private let remoteFlagLoader: @Sendable () async -> CmuxRemoteFlagSnapshot? @ObservationIgnored private var refreshTask: Task? @ObservationIgnored @@ -315,7 +391,11 @@ final class CmuxFeatureFlags { self.publishesOffMainSnapshot = publishesOffMainSnapshot self.remoteFlagValueProvider = remoteFlagValueProvider if let remoteFlagLoader { - self.remoteFlagLoader = remoteFlagLoader + self.remoteFlagLoader = { + await remoteFlagLoader().map { + CmuxRemoteFlagSnapshot(values: $0, invalidKeys: []) + } + } } else { let target = Self.releaseControlTarget( telemetryEnabled: telemetryEnabled, @@ -363,22 +443,46 @@ final class CmuxFeatureFlags { let values = await loader() guard let self else { return } self.refreshTask = nil - guard let values, !Task.isCancelled else { return } - self.applyRemoteFlagValues(values) + guard let snapshot = values, !Task.isCancelled else { return } + self.applyRemoteFlagSnapshot(snapshot) } } - private func applyRemoteFlagValues(_ values: [String: Bool]) { + func applyRemoteFlagValues(_ values: [String: Bool]) { + applyRemoteFlagSnapshot( + CmuxRemoteFlagSnapshot(values: values, invalidKeys: []) + ) + } + + func applyRemoteFlagSnapshot(_ snapshot: CmuxRemoteFlagSnapshot) { let previousResolutions = resolutionsByKey for definition in Self.allFlags { - if let value = values[definition.key] { + guard !snapshot.invalidKeys.contains(definition.key) else { + continue + } + if let value = snapshot.values[definition.key] { remoteValuesByKey[definition.key] = value - defaults.set(value, forKey: Self.remoteCacheKey(for: definition.key)) + let cacheKey = Self.remoteCacheKey(for: definition.key) + if Self.storedBoolValue(forKey: cacheKey, defaults: defaults) != value { + defaults.set(value, forKey: cacheKey) + } } else { remoteValuesByKey.removeValue(forKey: definition.key) - defaults.removeObject(forKey: Self.remoteCacheKey(for: definition.key)) + let cacheKey = Self.remoteCacheKey(for: definition.key) + if defaults.object(forKey: cacheKey) != nil { + defaults.removeObject(forKey: cacheKey) + } } } + for definition in Self.betaRemoteDefaults { + guard !snapshot.invalidKeys.contains(definition.flagKey) else { + continue + } + definition.settingKey.setRemoteDefault( + snapshot.values[definition.flagKey], + in: defaults + ) + } recomputeEffectiveValues() postChangeIfNeeded(previousResolutions: previousResolutions) } @@ -476,7 +580,7 @@ final class CmuxFeatureFlags { nonisolated private static func loadPostHogControlPlaneFlags( distinctID: String, personProperties: [String: String] - ) async -> [String: Bool]? { + ) async -> CmuxRemoteFlagSnapshot? { guard let request = postHogControlPlaneRequest( distinctID: distinctID, personProperties: personProperties @@ -496,7 +600,7 @@ final class CmuxFeatureFlags { maximumByteCount: maximumPostHogControlPlaneResponseBytes ) else { return nil } - return postHogControlPlaneFlagValues(from: data) + return postHogControlPlaneFlagSnapshot(from: data) } nonisolated static func boundedPostHogControlPlaneData( @@ -516,22 +620,25 @@ final class CmuxFeatureFlags { nonisolated static func postHogControlPlaneFlagValues( from data: Data ) -> [String: Bool]? { + postHogControlPlaneFlagSnapshot(from: data)?.values + } + + nonisolated static func postHogControlPlaneFlagSnapshot( + from data: Data + ) -> CmuxRemoteFlagSnapshot? { guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], object["errorsWhileComputingFlags"] as? Bool == false, let values = object["featureFlags"] as? [String: Any] else { return nil } - return values.reduce(into: [String: Bool]()) { result, entry in - if let value = entry.value as? Bool { - result[entry.key] = value - } else if let value = entry.value as? NSNumber { - result[entry.key] = value.boolValue - } else if let value = entry.value as? String { - switch value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { - case "true", "1", "yes", "on": result[entry.key] = true - case "false", "0", "no", "off": result[entry.key] = false - default: break - } + var parsed: [String: Bool] = [:] + var invalidKeys: Set = [] + for (key, rawValue) in values { + if let value = Bool.decodeFromJSON(rawValue) { + parsed[key] = value + } else { + invalidKeys.insert(key) } } + return CmuxRemoteFlagSnapshot(values: parsed, invalidKeys: invalidKeys) } func effectiveValue(for definition: CmuxFeatureFlagDefinition) -> Bool { @@ -598,6 +705,42 @@ final class CmuxFeatureFlags { postChangeIfNeeded(previousResolutions: previousResolutions) } +#if DEBUG + func betaRemoteDefaultState(identifier: String) -> CmuxBetaRemoteDefaultState? { + guard let definition = Self.betaRemoteDefaults.first(where: { + $0.settingKey.id == identifier || $0.flagKey == identifier + }) else { + return nil + } + let key = definition.settingKey + let resolution = key.resolution(in: defaults) + return CmuxBetaRemoteDefaultState( + settingID: key.id, + flagKey: definition.flagKey, + userKeyPresent: defaults.object(forKey: key.userDefaultsKey) != nil, + userValue: Bool.decodeFromUserDefaults( + defaults.object(forKey: key.userDefaultsKey) + ), + remoteDefault: key.remoteDefaultValue(in: defaults), + effectiveValue: resolution.value, + source: resolution.source + ) + } + + func setBetaRemoteDefaultForDebug( + identifier: String, + value: Bool? + ) -> CmuxBetaRemoteDefaultState? { + guard let definition = Self.betaRemoteDefaults.first(where: { + $0.settingKey.id == identifier || $0.flagKey == identifier + }) else { + return nil + } + definition.settingKey.setRemoteDefault(value, in: defaults) + return betaRemoteDefaultState(identifier: identifier) + } +#endif + private func recomputeEffectiveValues() { resolutionsByKey = Self.allFlags.reduce(into: [:]) { values, definition in values[definition.key] = CmuxFeatureFlagResolution( diff --git a/Sources/Panels/WorkspaceTodoPanelView.swift b/Sources/Panels/WorkspaceTodoPanelView.swift index fd7dd025082..e1189a1bb03 100644 --- a/Sources/Panels/WorkspaceTodoPanelView.swift +++ b/Sources/Panels/WorkspaceTodoPanelView.swift @@ -1,4 +1,6 @@ import AppKit +import CmuxSettings +import CmuxSettingsUI import CmuxWorkspaces import SwiftUI @@ -15,6 +17,7 @@ import SwiftUI /// NSPopover host, anchored in-pane. struct WorkspaceTodoPanelView: View { @ObservedObject var panel: WorkspaceTodoPanel + @LiveSetting(\.betaFeatures.workspaceTodoControls) private var todoControlsEnabled let isFocused: Bool let onRequestPanelFocus: () -> Void @@ -29,7 +32,8 @@ struct WorkspaceTodoPanelView: View { todoState: workspace.todoState, paneTitle: panel.displayTitle, isFocused: isFocused, - addFieldArmToken: panel.addFieldArmToken + addFieldArmToken: panel.addFieldArmToken, + todoControlsEnabled: todoControlsEnabled ) } else { Text(String( @@ -115,6 +119,7 @@ private struct WorkspaceTodoPaneContent: View { let isFocused: Bool /// Open-or-focus bump; re-arms the add field when `isFocused` doesn't transition. let addFieldArmToken: Int + let todoControlsEnabled: Bool @State private var isStatusPopoverPresented = false @State private var pendingItemText = "" @@ -140,7 +145,6 @@ private struct WorkspaceTodoPaneContent: View { override: todoState.statusOverride, inferred: inferred ) - let todoControlsEnabled = WorkspaceTodoFeature.isEnabled let hasOverride = todoControlsEnabled && todoState.statusOverride != nil && !resolution.shouldClearOverride let progress = todoState.checklist.checklistProgressSummary let headerTitle = WorkspaceTodoPaneHeaderTitle.title(paneTitle: paneTitle) diff --git a/Sources/RemoteTmuxController.swift b/Sources/RemoteTmuxController.swift index 7acace72f95..75cc34616e9 100644 --- a/Sources/RemoteTmuxController.swift +++ b/Sources/RemoteTmuxController.swift @@ -40,7 +40,7 @@ final class RemoteTmuxController { /// `@LiveSetting(\.betaFeatures.remoteTmux)`. nonisolated static var isEnabled: Bool { let key = SettingCatalog().betaFeatures.remoteTmux - return Bool.decodeFromUserDefaults(UserDefaults.standard.object(forKey: key.userDefaultsKey)) ?? key.defaultValue + return key.value(in: .standard) } /// Returns (creating if needed) the transport for a host. diff --git a/Sources/RightSidebarPanelView.swift b/Sources/RightSidebarPanelView.swift index aa6dd66c2f9..4c88609a11b 100644 --- a/Sources/RightSidebarPanelView.swift +++ b/Sources/RightSidebarPanelView.swift @@ -132,10 +132,8 @@ struct RightSidebarPanelView: View { private let focusShortcutHintXOffset = ShortcutHintDebugSettings.defaultRightSidebarFocusHintX private let focusShortcutHintYOffset = ShortcutHintDebugSettings.defaultRightSidebarFocusHintY @LiveSetting(\.shortcuts.showModifierHoldHints) private var showModifierHoldHints - @AppStorage(RightSidebarBetaFeatureSettings.feedEnabledKey) - private var feedEnabled = RightSidebarBetaFeatureSettings.defaultFeedEnabled - @AppStorage(RightSidebarBetaFeatureSettings.dockEnabledKey) - private var dockEnabled = RightSidebarBetaFeatureSettings.defaultDockEnabled + @LiveSetting(\.betaFeatures.rightSidebarFeed) private var feedEnabled + @LiveSetting(\.betaFeatures.rightSidebarDock) private var dockEnabled // Re-reading the observable store inside modeBar causes SwiftUI to // track the pending count so the badge updates live when hooks push diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCommands.swift b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCommands.swift index 3f0b0881955..71665ba3d52 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCommands.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCommands.swift @@ -22,6 +22,7 @@ struct SidebarWorkspaceRowCommands { let allRemoteContextMenuTargetsDisconnected: Bool let contextMenuPinState: WorkspaceActionDispatcher.PinState? let workspaceGroupMenuSnapshot: WorkspaceGroupMenuSnapshot + let todoControlsEnabled: Bool /// Re-runs the row's snapshot pump (pin/notification mutations that don't /// flow through the observation publishers). let refreshSnapshot: () -> Void @@ -310,7 +311,7 @@ struct SidebarWorkspaceRowMenuBuilder { menu.addItem(.separator()) // Legacy parity: the todo section renders only while the feature is // enabled (SwiftUI merges the surrounding dividers when it is not). - if WorkspaceTodoFeature.isEnabled { + if commands.todoControlsEnabled { addTodoSection(to: menu, tabManager: tabManager) menu.addItem(.separator()) } diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowModel.swift b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowModel.swift index 718f2224507..11ddb899787 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowModel.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowModel.swift @@ -46,10 +46,9 @@ struct SidebarWorkspaceRowModel: Equatable { /// prototype height measurement sees the same edit-field swap as the /// live cell. let editingChecklistItemId: UUID? - /// `WorkspaceTodoFeature.isEnabled`, projected at model-build time so a - /// remote rollout / local opt-in flip changes the model (and therefore - /// reconfigures and re-measures rows) instead of being read as a live - /// global inside the cell where nothing observes it. + /// The effective Todo Controls setting, projected at model-build time so + /// an inherited default or user-value change reconfigures and re-measures + /// rows instead of reading an unobserved global inside the cell. let todoControlsEnabled: Bool /// Parity with legacy SidebarMetadataRows / markdown blocks: collapsed /// shows 3 entries / 1 block with a Show more toggle; expansion state is diff --git a/Sources/SidebarWorkspaceRowInput.swift b/Sources/SidebarWorkspaceRowInput.swift index 2b805433009..760e10296ee 100644 --- a/Sources/SidebarWorkspaceRowInput.swift +++ b/Sources/SidebarWorkspaceRowInput.swift @@ -27,6 +27,7 @@ struct SidebarWorkspaceRowInput { let unreadCount: Int let latestNotificationText: String? let showsAgentActivity: Bool + let todoControlsEnabled: Bool let rowSpacing: CGFloat let showsModifierShortcutHints: Bool let isPointerHovering: Bool @@ -66,6 +67,7 @@ struct SidebarWorkspaceRowInput { unreadCount: unreadCount, latestNotificationText: latestNotificationText, showsAgentActivity: showsAgentActivity, + todoControlsEnabled: todoControlsEnabled, rowSpacing: rowSpacing, showsModifierShortcutHints: showsModifierShortcutHints, isPointerHovering: isPointerHovering, diff --git a/Sources/SidebarWorkspaceRowSnapshot.swift b/Sources/SidebarWorkspaceRowSnapshot.swift index 57a9bbf7395..35cb9e260f5 100644 --- a/Sources/SidebarWorkspaceRowSnapshot.swift +++ b/Sources/SidebarWorkspaceRowSnapshot.swift @@ -24,6 +24,7 @@ struct SidebarWorkspaceRowSnapshot: Equatable { let unreadCount: Int let latestNotificationText: String? let showsAgentActivity: Bool + let todoControlsEnabled: Bool let rowSpacing: CGFloat let showsModifierShortcutHints: Bool let isPointerHovering: Bool diff --git a/Sources/SidebarWorkspaceSnapshotBuilder.swift b/Sources/SidebarWorkspaceSnapshotBuilder.swift index 5da5a3ae4f9..9b76027ee73 100644 --- a/Sources/SidebarWorkspaceSnapshotBuilder.swift +++ b/Sources/SidebarWorkspaceSnapshotBuilder.swift @@ -10,6 +10,7 @@ struct SidebarWorkspaceSnapshotBuilder { let showsGitBranch: Bool let usesViewportAwarePath: Bool let showsAgentActivity: Bool + let todoControlsEnabled: Bool let visibleAuxiliaryDetails: SidebarWorkspaceAuxiliaryDetailVisibility } @@ -58,9 +59,9 @@ struct SidebarWorkspaceSnapshotBuilder { let finderDirectoryPath: String? let mediaActivity: BrowserMediaActivity // Workspace todo status/checklist; taskStatus is nil when the - // workspace opted out of status display or the remote todo-controls - // flag is off. Manual status draws a compact row indicator, while - // automatic status still only drives the done-row dim. + // workspace opted out of status display or the effective Todo + // Controls beta setting is off. Manual status draws a compact row + // indicator, while automatic status only drives the done-row dim. let taskStatus: WorkspaceTaskStatus? let todoStatusMenuModel: SidebarWorkspaceCompactStatusMenuModel? let hasManualTaskStatus: Bool diff --git a/Sources/SidebarWorkspaceSnapshotFactory.swift b/Sources/SidebarWorkspaceSnapshotFactory.swift index 835a6c09867..9f520ae3495 100644 --- a/Sources/SidebarWorkspaceSnapshotFactory.swift +++ b/Sources/SidebarWorkspaceSnapshotFactory.swift @@ -15,6 +15,7 @@ struct SidebarWorkspaceSnapshotFactory { let workspace: Workspace let settings: SidebarTabItemSettingsSnapshot let showsAgentActivity: Bool + let todoControlsEnabled: Bool func makeSnapshot() -> SidebarWorkspaceSnapshotBuilder.Snapshot { let detailVisibility = settings.visibleAuxiliaryDetails @@ -55,7 +56,6 @@ struct SidebarWorkspaceSnapshotFactory { guard detailVisibility.showsPullRequests, let orderedPanelIds else { return [] } return pullRequestDisplays(orderedPanelIds: orderedPanelIds) }() - let todoControlsEnabled = WorkspaceTodoFeature.isEnabled let workspaceStatusVisible = todoControlsEnabled && !workspace.todoState.statusHidden let inferredTaskStatus = workspaceStatusVisible ? workspace.inferredTaskStatus : nil let taskStatusResolution: WorkspaceTaskStatusOverride.Resolution? = inferredTaskStatus.map { inferred in @@ -122,12 +122,17 @@ struct SidebarWorkspaceSnapshotFactory { } private var presentationKey: SidebarWorkspaceSnapshotBuilder.PresentationKey { - Self.presentationKey(settings: settings, showsAgentActivity: showsAgentActivity) + Self.presentationKey( + settings: settings, + showsAgentActivity: showsAgentActivity, + todoControlsEnabled: todoControlsEnabled + ) } static func presentationKey( settings: SidebarTabItemSettingsSnapshot, - showsAgentActivity: Bool + showsAgentActivity: Bool, + todoControlsEnabled: Bool ) -> SidebarWorkspaceSnapshotBuilder.PresentationKey { SidebarWorkspaceSnapshotBuilder.PresentationKey( showsWorkspaceDescription: settings.showsWorkspaceDescription, @@ -135,6 +140,7 @@ struct SidebarWorkspaceSnapshotFactory { showsGitBranch: settings.showsGitBranch, usesViewportAwarePath: settings.usesLastSegmentPath, showsAgentActivity: showsAgentActivity, + todoControlsEnabled: todoControlsEnabled, visibleAuxiliaryDetails: settings.visibleAuxiliaryDetails ) } diff --git a/Sources/TabItemView+WorkspaceTodo.swift b/Sources/TabItemView+WorkspaceTodo.swift index f9f828ecedc..4444b35696d 100644 --- a/Sources/TabItemView+WorkspaceTodo.swift +++ b/Sources/TabItemView+WorkspaceTodo.swift @@ -12,7 +12,7 @@ import SwiftUI extension TabItemView { @ViewBuilder var workspaceTodoContextMenuSection: some View { - if WorkspaceTodoFeature.isEnabled { + if snapshot.todoControlsEnabled { let isMulti = contextMenuWorkspaceIds.count > 1 let markDoneLabel = isMulti ? String(localized: "contextMenu.markWorkspacesDone", defaultValue: "Mark Workspaces as Done") @@ -105,13 +105,14 @@ enum WorkspaceTodoPaletteCommands { } static func contributions( + todoControlsEnabled: Bool, workspaceSubtitle: @escaping (CommandPaletteContextSnapshot) -> String ) -> [CommandPaletteCommandContribution] { let hasWorkspace: (CommandPaletteContextSnapshot) -> Bool = { $0.bool(CommandPaletteContextKeys.hasWorkspace) } var contributions: [CommandPaletteCommandContribution] = [] - if WorkspaceTodoFeature.isEnabled { + if todoControlsEnabled { contributions.append( CommandPaletteCommandContribution( commandId: statusAutoCommandId, diff --git a/Sources/TerminalController+ControlDebugContext.swift b/Sources/TerminalController+ControlDebugContext.swift index 8d38667ab5b..863ceff9abf 100644 --- a/Sources/TerminalController+ControlDebugContext.swift +++ b/Sources/TerminalController+ControlDebugContext.swift @@ -38,6 +38,46 @@ func debugShowCanvasCommandScrollHint(in workspace: Workspace) -> Bool { extension TerminalController: ControlDebugContext { #if DEBUG + func controlDebugBetaRemoteDefaultSnapshot( + identifier: String + ) -> ControlDebugBetaRemoteDefaultSnapshot? { + guard let state = CmuxFeatureFlags.shared.betaRemoteDefaultState( + identifier: identifier + ) else { + return nil + } + return ControlDebugBetaRemoteDefaultSnapshot( + settingID: state.settingID, + flagKey: state.flagKey, + userKeyPresent: state.userKeyPresent, + userValue: state.userValue, + remoteDefault: state.remoteDefault, + effectiveValue: state.effectiveValue, + source: state.source.rawValue + ) + } + + func controlDebugSetBetaRemoteDefault( + identifier: String, + value: Bool? + ) -> ControlDebugBetaRemoteDefaultSnapshot? { + guard let state = CmuxFeatureFlags.shared.setBetaRemoteDefaultForDebug( + identifier: identifier, + value: value + ) else { + return nil + } + return ControlDebugBetaRemoteDefaultSnapshot( + settingID: state.settingID, + flagKey: state.flagKey, + userKeyPresent: state.userKeyPresent, + userValue: state.userValue, + remoteDefault: state.remoteDefault, + effectiveValue: state.effectiveValue, + source: state.source.rawValue + ) + } + // MARK: - Session-snapshot benchmarks func controlDebugSessionSnapshotBenchmark(includeScrollback: Bool, persist: Bool) -> JSONValue? { diff --git a/Sources/TerminalController+DebugMethodNames.swift b/Sources/TerminalController+DebugMethodNames.swift index 7d574113e0b..9c03d9812b1 100644 --- a/Sources/TerminalController+DebugMethodNames.swift +++ b/Sources/TerminalController+DebugMethodNames.swift @@ -8,6 +8,8 @@ extension TerminalController { "debug.shortcut.set", "debug.shortcut.simulate", "debug.type", + "debug.beta_remote_defaults.get", + "debug.beta_remote_defaults.set", "debug.textbox.inline_fixture", "debug.textbox.interact", "debug.app.activate", diff --git a/Sources/WorkspaceTodoFeature.swift b/Sources/WorkspaceTodoFeature.swift index 028efa46342..89107a7e7f8 100644 --- a/Sources/WorkspaceTodoFeature.swift +++ b/Sources/WorkspaceTodoFeature.swift @@ -4,32 +4,25 @@ import CmuxWorkspaces import Foundation import UniformTypeIdentifiers -/// The remote workspace todo controls feature gate and the shared UI +/// The effective Workspace Todo Controls beta setting and the shared UI /// entry points for mutating a workspace's todo state. Every UI surface /// (sidebar row, context menu, command palette, keyboard shortcut) funnels /// through ``WorkspaceTodoActions`` so gated status/add-item mutations and the /// backend caps/anti-rot apply identically everywhere. enum WorkspaceTodoFeature { - /// Synchronous read of the local beta opt-in plus remote-enabled feature - /// flag for status and add-item controls. Existing checklist items stay - /// visible/usable when this is off; only the controls that create items or - /// set workspace completion/status lanes are hidden. - @MainActor + /// Synchronous typed read of the workspace-todo beta setting. static var isEnabled: Bool { - isEnabled( - defaults: .standard, - remoteEnabled: CmuxFeatureFlags.shared.isWorkspaceTodoControlsEnabled - ) + BetaFeaturesCatalogSection().workspaceTodoControls.value(in: .standard) } static func isEnabled(defaults: UserDefaults, remoteEnabled: Bool) -> Bool { - remoteEnabled || localControlsOptIn(defaults: defaults) + let key = BetaFeaturesCatalogSection().workspaceTodoControls + return Bool.decodeFromUserDefaults(defaults.object(forKey: key.userDefaultsKey)) + ?? remoteEnabled } - static func localControlsOptIn(defaults: UserDefaults) -> Bool { - let key = BetaFeaturesCatalogSection().workspaceTodoControls - guard defaults.object(forKey: key.userDefaultsKey) != nil else { return key.defaultValue } - return defaults.bool(forKey: key.userDefaultsKey) + static func effectiveControlsValue(defaults: UserDefaults) -> Bool { + BetaFeaturesCatalogSection().workspaceTodoControls.value(in: defaults) } /// The checklist presentation style (popover or inline), user-selectable. @@ -40,8 +33,7 @@ enum WorkspaceTodoFeature { ) ?? key.defaultValue } - /// No-op now that the feature is always on (kept so existing call sites - /// stay unchanged). + /// No-op retained so every shared mutation keeps one common action path. @MainActor static func markUsed() {} } diff --git a/cmuxTests/CommandPaletteSettingsToggleTests.swift b/cmuxTests/CommandPaletteSettingsToggleTests.swift index a344bb0675e..7edf2042495 100644 --- a/cmuxTests/CommandPaletteSettingsToggleTests.swift +++ b/cmuxTests/CommandPaletteSettingsToggleTests.swift @@ -304,6 +304,27 @@ final class CommandPaletteSettingsToggleTests: XCTestCase { XCTAssertEqual(contributionIds, descriptorIds) } + func testBetaToggleReadsRemoteDefaultThenPersistsExplicitUserChoice() throws { + try withTemporaryDefaults { defaults in + let key = BetaFeaturesCatalogSection().rightSidebarFeed + let descriptor = try XCTUnwrap( + CommandPaletteSettingsToggleCommands.descriptor( + commandId: "palette.toggleSetting.rightSidebarFeed" + ) + ) + key.setRemoteDefault(true, in: defaults) + + XCTAssertTrue(descriptor.isOn(defaults)) + XCTAssertNil(defaults.object(forKey: key.userDefaultsKey)) + + descriptor.toggle(defaults: defaults, notificationCenter: NotificationCenter()) + + XCTAssertEqual(defaults.object(forKey: key.userDefaultsKey) as? Bool, false) + XCTAssertEqual(key.remoteDefaultValue(in: defaults), true) + XCTAssertFalse(descriptor.isOn(defaults)) + } + } + func testSettingsToggleCommandIdsAreUnique() { let commandIds = CommandPaletteSettingsToggleCommands.descriptors.map(\.commandId) XCTAssertEqual(Set(commandIds).count, commandIds.count) diff --git a/cmuxTests/PostHogAnalyticsPropertiesTests.swift b/cmuxTests/PostHogAnalyticsPropertiesTests.swift index 78c79ecef29..1b5a7e04c9f 100644 --- a/cmuxTests/PostHogAnalyticsPropertiesTests.swift +++ b/cmuxTests/PostHogAnalyticsPropertiesTests.swift @@ -291,10 +291,10 @@ struct PostHogAnalyticsPropertiesTests { } @MainActor - @Test("workspace todo controls feature flag follows remote values") - func workspaceTodoControlsFeatureFlagFollowsRemoteValues() throws { - let flag = try #require(CmuxFeatureFlags.allFlags.first { - $0.key == "workspace-todo-controls-enabled-release" + @Test("workspace todo controls remote default yields to explicit user choice") + func workspaceTodoControlsRemoteDefaultYieldsToUserChoice() throws { + let definition = try #require(CmuxFeatureFlags.betaRemoteDefaults.first { + $0.settingKey.id == "sidebar.beta.workspaceTodos.controls.enabled" }) let suiteName = "cmux.workspace.todo.controls.flag.\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suiteName)) @@ -302,20 +302,122 @@ struct PostHogAnalyticsPropertiesTests { defaults.removePersistentDomain(forName: suiteName) } - var remoteValues: [String: Any] = [:] - let flags = CmuxFeatureFlags(defaults: defaults) { key in - remoteValues[key] + let flags = CmuxFeatureFlags(defaults: defaults) { _ in nil } + #expect(!definition.settingKey.value(in: defaults)) + + flags.applyRemoteFlagValues([definition.flagKey: true]) + #expect(definition.settingKey.value(in: defaults)) + #expect(defaults.object(forKey: definition.settingKey.userDefaultsKey) == nil) + + definition.settingKey.set(false, in: defaults) + #expect(!definition.settingKey.value(in: defaults)) + + flags.applyRemoteFlagValues([definition.flagKey: false]) + #expect(!definition.settingKey.value(in: defaults)) + flags.applyRemoteFlagValues([definition.flagKey: true]) + #expect(!definition.settingKey.value(in: defaults)) + } + + @MainActor + @Test("beta remote-default registry covers only the six Boolean macOS toggles") + func betaRemoteDefaultRegistryCoverage() { + let definitions = CmuxFeatureFlags.betaRemoteDefaults + let settingIDs = Set(definitions.map(\.settingKey.id)) + let userKeys = definitions.map(\.settingKey.userDefaultsKey) + let remoteKeys = definitions.compactMap(\.settingKey.remoteDefaultUserDefaultsKey) + + #expect(settingIDs == [ + "rightSidebar.beta.feed.enabled", + "rightSidebar.beta.dock.enabled", + "extensions.beta.enabled", + "customSidebars.beta.enabled", + "sidebar.beta.workspaceTodos.controls.enabled", + "remoteTmux.beta.enabled", + ]) + #expect(!settingIDs.contains("sidebar.beta.workspaceTodos.checklistStyle")) + #expect(!CmuxFeatureFlags.allFlags.contains { + $0.key == "workspace-todo-controls-enabled-release" + }) + #expect(remoteKeys.count == definitions.count) + #expect(Set(remoteKeys).count == definitions.count) + #expect(zip(userKeys, remoteKeys).allSatisfy { $0.0 != $0.1 }) + } + + @MainActor + @Test("invalid known value preserves cache while complete omission clears it") + func invalidKnownValueAndOmissionHaveDistinctSemantics() throws { + let definition = try #require(CmuxFeatureFlags.betaRemoteDefaults.first) + let suiteName = "cmux.beta.flags.invalid.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let flags = CmuxFeatureFlags(defaults: defaults) { _ in nil } + + flags.applyRemoteFlagValues([definition.flagKey: true]) + #expect(definition.settingKey.remoteDefaultValue(in: defaults) == true) + + flags.applyRemoteFlagSnapshot( + CmuxRemoteFlagSnapshot( + values: [:], + invalidKeys: [definition.flagKey, "unknown-multivariate-flag"] + ) + ) + #expect(definition.settingKey.remoteDefaultValue(in: defaults) == true) + + flags.applyRemoteFlagValues([definition.flagKey: false]) + let remoteCacheKey = try #require( + definition.settingKey.remoteDefaultUserDefaultsKey + ) + #expect(definition.settingKey.remoteDefaultValue(in: defaults) == false) + #expect(defaults.object(forKey: remoteCacheKey) != nil) + + flags.applyRemoteFlagValues([:]) + #expect(definition.settingKey.remoteDefaultValue(in: defaults) == nil) + #expect(defaults.object(forKey: remoteCacheKey) == nil) + } + + @MainActor + @Test("failed loader preserves an existing beta remote cache") + func failedLoaderPreservesExistingBetaRemoteCache() async throws { + let definition = try #require(CmuxFeatureFlags.betaRemoteDefaults.first) + let suiteName = "cmux.beta.flags.failed-loader.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + definition.settingKey.setRemoteDefault(true, in: defaults) + let probe = FeatureFlagRemoteLoaderProbe(result: nil) + let flags = CmuxFeatureFlags( + defaults: defaults, + remoteFlagValueProvider: { _ in nil }, + remoteFlagLoader: { await probe.load() } + ) + + flags.start() + await probe.waitUntilCalled() + for _ in 0..<100 { + await Task.yield() } - #expect(!flags.isWorkspaceTodoControlsEnabled) + #expect(definition.settingKey.remoteDefaultValue(in: defaults) == true) + #expect(definition.settingKey.resolution(in: defaults).source == .remoteDefault) + } - remoteValues[flag.key] = false - flags.applyLoadedFlags() - #expect(!flags.isWorkspaceTodoControlsEnabled) + @Test("control plane distinguishes Boolean values from invalid entries") + func controlPlaneStrictBooleanSnapshot() throws { + let payload = try JSONSerialization.data(withJSONObject: [ + "featureFlags": [ + "bool-true": true, + "bool-false": false, + "numeric": 1, + "string": "true", + "multivariate": "variant-a", + ], + "errorsWhileComputingFlags": false, + ]) + let snapshot = try #require( + CmuxFeatureFlags.postHogControlPlaneFlagSnapshot(from: payload) + ) - remoteValues[flag.key] = true - flags.applyLoadedFlags() - #expect(flags.isWorkspaceTodoControlsEnabled) + #expect(snapshot.values == ["bool-true": true, "bool-false": false]) + #expect(snapshot.invalidKeys == ["numeric", "string", "multivariate"]) } @MainActor @@ -580,12 +682,17 @@ struct PostHogAnalyticsPropertiesTests { private actor FeatureFlagRemoteLoaderProbe { private(set) var callCount = 0 private var waiter: CheckedContinuation? + private let result: [String: Bool]? + + init(result: [String: Bool]? = [:]) { + self.result = result + } func load() -> [String: Bool]? { callCount += 1 waiter?.resume() waiter = nil - return [:] + return result } func waitUntilCalled() async { diff --git a/cmuxTests/SidebarAppKitRowCellTests.swift b/cmuxTests/SidebarAppKitRowCellTests.swift index d78aefeab73..d399b7af21c 100644 --- a/cmuxTests/SidebarAppKitRowCellTests.swift +++ b/cmuxTests/SidebarAppKitRowCellTests.swift @@ -15,7 +15,8 @@ struct SidebarAppKitRowCellTests { SidebarWorkspaceSnapshotBuilder.Snapshot( presentationKey: SidebarWorkspaceSnapshotFactory.presentationKey( settings: SidebarTabItemSettingsSnapshot(defaults: UserDefaults(suiteName: UUID().uuidString)!), - showsAgentActivity: false + showsAgentActivity: false, + todoControlsEnabled: false ), title: title, customDescription: nil, @@ -114,6 +115,7 @@ struct SidebarAppKitRowCellTests { unreadCount: 0, latestNotificationText: nil, showsAgentActivity: settings.details.showAgentActivity, + todoControlsEnabled: false, rowSpacing: 8, showsModifierShortcutHints: false, isPointerHovering: false, @@ -167,6 +169,7 @@ struct SidebarAppKitRowCellTests { allRemoteContextMenuTargetsDisconnected: false, contextMenuPinState: nil, workspaceGroupMenuSnapshot: WorkspaceGroupMenuSnapshot(items: []), + todoControlsEnabled: model.todoControlsEnabled, refreshSnapshot: {}, readSelectedTabIds: { [] }, writeSelectedTabIds: { _ in }, diff --git a/cmuxTests/SidebarWorkspaceContextMenuWindowTargetsTests.swift b/cmuxTests/SidebarWorkspaceContextMenuWindowTargetsTests.swift index 9e82f25fa25..648848b0d37 100644 --- a/cmuxTests/SidebarWorkspaceContextMenuWindowTargetsTests.swift +++ b/cmuxTests/SidebarWorkspaceContextMenuWindowTargetsTests.swift @@ -78,6 +78,7 @@ struct SidebarWorkspaceContextMenuWindowTargetsTests { unreadCount: 0, latestNotificationText: nil, showsAgentActivity: false, + todoControlsEnabled: false, rowSpacing: 0, showsModifierShortcutHints: false, isPointerHovering: false, diff --git a/cmuxTests/SidebarWorkspaceNotificationIndexTests.swift b/cmuxTests/SidebarWorkspaceNotificationIndexTests.swift index 4b8eebe3de0..586c407a916 100644 --- a/cmuxTests/SidebarWorkspaceNotificationIndexTests.swift +++ b/cmuxTests/SidebarWorkspaceNotificationIndexTests.swift @@ -226,6 +226,7 @@ struct SidebarWorkspaceContextMenuTargetAggregateTests { unreadCount: unreadCount, latestNotificationText: nil, showsAgentActivity: false, + todoControlsEnabled: false, rowSpacing: 0, showsModifierShortcutHints: false, isPointerHovering: false, diff --git a/cmuxTests/SidebarWorkspaceRowSuspensionTests.swift b/cmuxTests/SidebarWorkspaceRowSuspensionTests.swift index d2ab60e17c1..d8f4c679e0b 100644 --- a/cmuxTests/SidebarWorkspaceRowSuspensionTests.swift +++ b/cmuxTests/SidebarWorkspaceRowSuspensionTests.swift @@ -15,7 +15,8 @@ struct SidebarWorkspaceRowSuspensionTests { SidebarWorkspaceSnapshotBuilder.Snapshot( presentationKey: SidebarWorkspaceSnapshotFactory.presentationKey( settings: SidebarTabItemSettingsSnapshot(defaults: UserDefaults(suiteName: UUID().uuidString)!), - showsAgentActivity: false + showsAgentActivity: false, + todoControlsEnabled: false ), title: "Workspace", customDescription: nil, @@ -134,6 +135,7 @@ struct SidebarWorkspaceRowSuspensionTests { allRemoteContextMenuTargetsDisconnected: false, contextMenuPinState: nil, workspaceGroupMenuSnapshot: WorkspaceGroupMenuSnapshot(items: []), + todoControlsEnabled: model.todoControlsEnabled, refreshSnapshot: {}, readSelectedTabIds: { [] }, writeSelectedTabIds: { _ in }, diff --git a/cmuxTests/SidebarWorkspaceSnapshotRefreshPolicyTests.swift b/cmuxTests/SidebarWorkspaceSnapshotRefreshPolicyTests.swift index e4bec409fd5..351a80f9abf 100644 --- a/cmuxTests/SidebarWorkspaceSnapshotRefreshPolicyTests.swift +++ b/cmuxTests/SidebarWorkspaceSnapshotRefreshPolicyTests.swift @@ -174,6 +174,7 @@ import Testing showsGitBranch: Bool = true, usesViewportAwarePath: Bool = false, showsAgentActivity: Bool = true, + todoControlsEnabled: Bool = false, visibleAuxiliaryDetails: SidebarWorkspaceAuxiliaryDetailVisibility = SidebarWorkspaceAuxiliaryDetailVisibility( showsMetadata: true, showsLog: true, @@ -189,6 +190,7 @@ import Testing showsGitBranch: showsGitBranch, usesViewportAwarePath: usesViewportAwarePath, showsAgentActivity: showsAgentActivity, + todoControlsEnabled: todoControlsEnabled, visibleAuxiliaryDetails: visibleAuxiliaryDetails ) } diff --git a/cmuxTests/WorkspaceTodoSidebarModelTests.swift b/cmuxTests/WorkspaceTodoSidebarModelTests.swift index 2f23c65c319..6fdac331c37 100644 --- a/cmuxTests/WorkspaceTodoSidebarModelTests.swift +++ b/cmuxTests/WorkspaceTodoSidebarModelTests.swift @@ -1,4 +1,5 @@ import CmuxSettings +import CmuxSettingsUI import CmuxWorkspaces import CoreGraphics import Foundation @@ -76,7 +77,7 @@ struct WorkspaceTodoSidebarModelTests { // MARK: - Minimal todo visibility @Test - func workspaceTodoControlsGateDefaultsOffAndAllowsLocalOrRemoteOptIn() throws { + func workspaceTodoControlsResolvesEffectiveUserAndRemoteDefaults() throws { let suiteName = "cmux.workspace.todo.controls.setting.\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suiteName)) defer { @@ -85,20 +86,66 @@ struct WorkspaceTodoSidebarModelTests { let key = BetaFeaturesCatalogSection().workspaceTodoControls #expect(key.defaultValue == false) - #expect(!WorkspaceTodoFeature.localControlsOptIn(defaults: defaults)) + #expect(!WorkspaceTodoFeature.effectiveControlsValue(defaults: defaults)) #expect(!WorkspaceTodoFeature.isEnabled(defaults: defaults, remoteEnabled: false)) #expect(WorkspaceTodoFeature.isEnabled(defaults: defaults, remoteEnabled: true)) defaults.set(true, forKey: key.userDefaultsKey) - #expect(WorkspaceTodoFeature.localControlsOptIn(defaults: defaults)) + #expect(WorkspaceTodoFeature.effectiveControlsValue(defaults: defaults)) #expect(WorkspaceTodoFeature.isEnabled(defaults: defaults, remoteEnabled: false)) defaults.set(false, forKey: key.userDefaultsKey) - #expect(!WorkspaceTodoFeature.localControlsOptIn(defaults: defaults)) + #expect(!WorkspaceTodoFeature.effectiveControlsValue(defaults: defaults)) #expect(!WorkspaceTodoFeature.isEnabled(defaults: defaults, remoteEnabled: false)) #expect(!WorkspaceTodoFeature.isEnabled(defaults: defaults, remoteEnabled: true)) } + @MainActor + @Test + func liveRemoteDefaultUpdatesSidebarConsumerUntilUserOverrides() async throws { + let suiteName = "cmux.workspace.todo.controls.live.\(UUID().uuidString)" + nonisolated(unsafe) let defaults = try #require(UserDefaults(suiteName: suiteName)) + defaults.removePersistentDomain(forName: suiteName) + defer { defaults.removePersistentDomain(forName: suiteName) } + let key = BetaFeaturesCatalogSection().workspaceTodoControls + let store = UserDefaultsSettingsStore(defaults: defaults) + let model = DefaultsValueModel(store: store, key: key) + model.startObserving() + + key.setRemoteDefault(true, in: defaults) + for _ in 0..<100_000 where model.current == false { + await Task.yield() + } + #expect(model.current) + #expect(SidebarWorkspaceManualTaskStatusIndicatorModel( + featureEnabled: model.current, + taskStatus: .review, + hasManualOverride: true + ).showsIndicator) + + model.set(false) + for _ in 0..<100_000 where Bool.decodeFromUserDefaults( + defaults.object(forKey: key.userDefaultsKey) + ) != false { + await Task.yield() + } + key.setRemoteDefault(false, in: defaults) + key.setRemoteDefault(true, in: defaults) + for _ in 0..<100 { + await Task.yield() + } + + #expect(model.current == false) + #expect(Bool.decodeFromUserDefaults( + defaults.object(forKey: key.userDefaultsKey) + ) == false) + #expect(!SidebarWorkspaceManualTaskStatusIndicatorModel( + featureEnabled: model.current, + taskStatus: .review, + hasManualOverride: true + ).showsIndicator) + } + @Test func compactStatusOnlyShowsWhenDetailsAreHiddenAndStatusIsEngaged() { #expect(!SidebarWorkspaceTodoMinimalVisibility( diff --git a/cmuxUITests/SettingsSidebarBetaBehaviorUITests.swift b/cmuxUITests/SettingsSidebarBetaBehaviorUITests.swift index e09a8317242..65afe260fd2 100644 --- a/cmuxUITests/SettingsSidebarBetaBehaviorUITests.swift +++ b/cmuxUITests/SettingsSidebarBetaBehaviorUITests.swift @@ -1,37 +1,12 @@ import XCTest -/// Behavioral UI tests for the Settings **Sidebar** + **Beta Features** -/// section, scoped to the controls called out for this section: -/// the *Sidebar Branch Layout* picker (vertical vs inline), the active-tab -/// *indicator style*, and the *beta Feed* / *beta Dock* toggles. +/// Behavioral UI tests for Sidebar and Beta Features settings. /// -/// What is actually assertable through XCUITest here, and why: -/// -/// The real runtime consumers of these settings render *inside the -/// workspace sidebar rows and the right-sidebar mode bar* — surfaces that -/// only exist once a workspace has been materialized. The shared -/// `SettingsUITestCase` harness launches the app with `makeLaunchedApp()` -/// (no `CMUX_UI_TEST_BONSPLIT_TAB_DRAG_SETUP` / `_SHOW_RIGHT_SIDEBAR` -/// launch env), so the app comes up with an empty main window: no -/// workspace rows, and the right sidebar mode bar is not populated. That -/// means the *downstream* render effects (branch text stacked vs inline in -/// a workspace row; the `RightSidebarModeButton.dock` button appearing in -/// the mode bar) are NOT reachable without modifying the harness or adding -/// a launch-time setup seam, which this task forbids. -/// -/// What *is* reachable and genuinely behavioral: each of these controls is -/// wired through a live `@AppStorage` / `@Setting` binding whose value -/// drives a *derived, reactive subtitle* in the same Settings window. The -/// subtitle text is computed from the current setting value -/// (`sidebarBranchVerticalLayout ? "Vertical: …" : "Inline: …"`, -/// `dockEnabled ? "Shows Dock …" : "Hides Dock …"`). Asserting that the -/// subtitle label flips when the control changes verifies the full -/// binding → store → dependent-view path, not merely that the control's -/// own state toggled. These subtitle strings are surfaced as `staticText` -/// in the Settings window and are unique, so they are stable to query. -/// -/// Tiering for this section is recorded in the structured output. The -/// downstream consumer effects are documented in the TIER 2 block below. +/// Branch layout, Feed, and Dock assert their reactive derived subtitles. +/// Workspace Todo uses the deterministic remote-default launch seam plus +/// debug-domain readback to verify inherited-on, explicit-off, relaunch, and +/// Reset All behavior through `LiveSetting`. Runtime row and mode-bar effects +/// that require a materialized workspace remain documented as Tier 2 below. final class SettingsSidebarBetaBehaviorUITests: SettingsUITestCase { // userDefaultsKeys for the in-scope settings, reset before/after each @@ -45,6 +20,10 @@ final class SettingsSidebarBetaBehaviorUITests: SettingsUITestCase { "sidebarActiveTabIndicatorStyle", "rightSidebar.beta.feed.enabled", "rightSidebar.beta.dock.enabled", + "cmux.beta.remoteDefault.rightSidebar.feed.enabled", + "cmux.beta.remoteDefault.rightSidebar.dock.enabled", + "sidebar.beta.workspaceTodos.controls.enabled", + "cmux.beta.remoteDefault.workspaceTodos.controls.enabled", ] // Branch-layout subtitle strings (exact defaultValue copy from @@ -61,6 +40,8 @@ final class SettingsSidebarBetaBehaviorUITests: SettingsUITestCase { // BetaFeaturesSection.dockRow). private let dockOffSubtitle = "Hides Dock from the right sidebar until you enable it here." private let dockOnSubtitle = "Shows Dock in the right sidebar mode switcher for custom terminal controls." + private let workspaceTodoOffSubtitle = "Keeps workspace todo summaries read-only until you enable the controls here." + private let workspaceTodoOnSubtitle = "Shows Add Checklist Item and workspace status controls." override func setUp() { super.setUp() @@ -218,6 +199,67 @@ final class SettingsSidebarBetaBehaviorUITests: SettingsUITestCase { XCTAssertFalse(onSubtitle.exists, "On subtitle should be gone once Dock is disabled again") } + func testWorkspaceTodoRemoteDefaultYieldsToUserChoiceAndResetAll() { + let remoteEnvironment = [ + "CMUX_UI_TEST_BETA_REMOTE_DEFAULTS": + #"{"workspace-todo-controls-enabled-release":true}"#, + ] + var app = makeLaunchedApp(environment: remoteEnvironment) + var window = openSettings(app) + navigate(window, to: "Beta Features") + + let onSubtitle = window.staticTexts[workspaceTodoOnSubtitle] + let offSubtitle = window.staticTexts[workspaceTodoOffSubtitle] + XCTAssertTrue( + poll(timeout: 5.0) { onSubtitle.exists }, + "Expected the remote default to enable Workspace Todo Controls" + ) + + toggle(window, id: "SettingsBetaWorkspaceTodoControlsToggle").click() + XCTAssertTrue( + poll(timeout: 5.0) { offSubtitle.exists }, + "Expected an explicit user disable to override the remote default" + ) + XCTAssertTrue( + poll(timeout: 5.0) { + self.debugDefaultBool("sidebar.beta.workspaceTodos.controls.enabled") == false + }, + "Expected the explicit user disable to be durable before relaunch" + ) + app.terminate() + + app = makeLaunchedApp(environment: remoteEnvironment) + window = openSettings(app) + navigate(window, to: "Beta Features") + XCTAssertTrue( + poll(timeout: 5.0) { window.staticTexts[workspaceTodoOffSubtitle].exists }, + "Expected the explicit disable to survive relaunch" + ) + + navigate(window, to: "Reset") + let resetButton = requireElement( + candidates: [ + window.buttons["SettingsResetAllButton"], + window.buttons["Reset All Settings"], + ], + timeout: 5.0, + description: "Reset All Settings button" + ) + resetButton.click() + navigate(window, to: "Beta Features") + XCTAssertTrue( + poll(timeout: 5.0) { window.staticTexts[workspaceTodoOnSubtitle].exists }, + "Expected Reset All to remove only the user choice and inherit the remote default" + ) + XCTAssertTrue( + poll(timeout: 5.0) { + !self.debugDefaultExists("sidebar.beta.workspaceTodos.controls.enabled") + }, + "Expected Reset All to remove the primary Workspace Todo user key" + ) + closeSettings(app, window) + } + // MARK: - Tiering documentation for this section // // TIER 2 (needs runtime seam): Sidebar Branch Layout downstream render — diff --git a/cmuxUITests/SettingsUITestSupport.swift b/cmuxUITests/SettingsUITestSupport.swift index 8f07b92ec92..7442d63a72e 100644 --- a/cmuxUITests/SettingsUITestSupport.swift +++ b/cmuxUITests/SettingsUITestSupport.swift @@ -36,10 +36,13 @@ class SettingsUITestCase: XCTestCase { // MARK: - Launch / window - func makeLaunchedApp() -> XCUIApplication { + func makeLaunchedApp(environment: [String: String] = [:]) -> XCUIApplication { let app = XCUIApplication.cmuxTestApplication() app.launchArguments += settingsLaunchArguments app.launchEnvironment["CMUX_UI_TEST_MODE"] = "1" + for (key, value) in environment { + app.launchEnvironment[key] = value + } launchAndActivate(app) XCTAssertTrue(waitForWindowCount(atLeast: 1, app: app, timeout: 8.0), "main window did not appear") return app @@ -117,6 +120,60 @@ class SettingsUITestCase: XCTestCase { } } + /// Reads one Boolean from the app's isolated debug defaults domain. + /// + /// A nonnil result proves the primary user key is durable, rather than + /// merely reflecting the Settings model's optimistic in-memory value. + func debugDefaultBool( + _ key: String, + suite: String = "com.cmuxterm.app.debug" + ) -> Bool? { + let process = Process() + let output = Pipe() + process.executableURL = URL(fileURLWithPath: "/usr/bin/defaults") + process.arguments = ["read", suite, key] + process.standardOutput = output + process.standardError = Pipe() + do { + try process.run() + } catch { + return nil + } + process.waitUntilExit() + guard process.terminationStatus == 0 else { return nil } + let raw = String( + data: output.fileHandleForReading.readDataToEndOfFile(), + encoding: .utf8 + )?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + switch raw { + case "1", "true", "yes": + return true + case "0", "false", "no": + return false + default: + return nil + } + } + + /// Whether the app's isolated debug defaults domain contains `key`. + func debugDefaultExists( + _ key: String, + suite: String = "com.cmuxterm.app.debug" + ) -> Bool { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/defaults") + process.arguments = ["read", suite, key] + process.standardOutput = Pipe() + process.standardError = Pipe() + do { + try process.run() + } catch { + return false + } + process.waitUntilExit() + return process.terminationStatus == 0 + } + // MARK: - Launch implementation func launchAndActivate(_ app: XCUIApplication, activateTimeout: TimeInterval = 2.0) { From caeb06f7496db30523d03ab233cd9bdbc214c499 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Fri, 31 Jul 2026 04:42:43 -0700 Subject: [PATCH 3/6] fix: address beta remote default review feedback --- ...dCoordinator+DebugBetaRemoteDefaults.swift | 24 +++-- ...ControlDebugBetaRemoteDefaultStrings.swift | 21 ++++ .../Debug/ControlDebugContext.swift | 4 + ...ControlCommandContextTestStubs+Debug.swift | 8 ++ ...rdinatorDebugBetaRemoteDefaultsTests.swift | 47 +++++++++ .../Stores/UserDefaultsSettingsStorage.swift | 19 ++-- .../DefaultsKeyRemoteDefaultTests.swift | 44 ++++++--- ...efaultsSettingsStoreObservationTests.swift | 96 +++++++++++++++++++ .../Bindings/DefaultsValueModel.swift | 3 +- ...DefaultsValueModelRemoteDefaultTests.swift | 9 +- Resources/Localizable.xcstrings | 68 +++++++++++++ Sources/AppDelegate.swift | 3 - Sources/ContentView.swift | 6 +- Sources/FeatureFlags.swift | 71 -------------- Sources/Panels/WorkspaceTodoPanelView.swift | 2 +- Sources/TabItemView+WorkspaceTodo.swift | 11 ++- ...rminalController+ControlDebugContext.swift | 74 +++++++++----- Sources/WorkspaceTodoFeature.swift | 10 -- cmuxTests/CLIAuthAliasTests.swift | 65 +++++++++++++ .../WorkspaceTodoSidebarModelTests.swift | 57 +++++++---- .../SettingsSidebarBetaBehaviorUITests.swift | 20 ++-- cmuxUITests/SettingsUITestSupport.swift | 21 ++++ 22 files changed, 501 insertions(+), 182 deletions(-) create mode 100644 Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlDebugBetaRemoteDefaultStrings.swift diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlCommandCoordinator+DebugBetaRemoteDefaults.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlCommandCoordinator+DebugBetaRemoteDefaults.swift index 952a7c6b69a..56607ddd4ee 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlCommandCoordinator+DebugBetaRemoteDefaults.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlCommandCoordinator+DebugBetaRemoteDefaults.swift @@ -3,13 +3,17 @@ extension ControlCommandCoordinator { func debugBetaRemoteDefaultGet( _ params: [String: JSONValue] ) -> ControlCallResult { + guard let debugContext else { + return .err(code: "unavailable", message: Self.debugContextUnavailableResponse, data: nil) + } + let strings = debugContext.controlDebugBetaRemoteDefaultStrings() guard let key = string(params, "key") else { - return .err(code: "invalid_params", message: "Missing key", data: nil) + return .err(code: "invalid_params", message: strings.missingKey, data: nil) } - guard let snapshot = debugContext?.controlDebugBetaRemoteDefaultSnapshot( + guard let snapshot = debugContext.controlDebugBetaRemoteDefaultSnapshot( identifier: key ) else { - return .err(code: "not_found", message: "Beta remote default not found", data: nil) + return .err(code: "not_found", message: strings.notFound, data: nil) } return debugBetaRemoteDefaultResult(snapshot) } @@ -17,11 +21,15 @@ extension ControlCommandCoordinator { func debugBetaRemoteDefaultSet( _ params: [String: JSONValue] ) -> ControlCallResult { + guard let debugContext else { + return .err(code: "unavailable", message: Self.debugContextUnavailableResponse, data: nil) + } + let strings = debugContext.controlDebugBetaRemoteDefaultStrings() guard let key = string(params, "key") else { - return .err(code: "invalid_params", message: "Missing key", data: nil) + return .err(code: "invalid_params", message: strings.missingKey, data: nil) } guard let rawValue = params["value"] else { - return .err(code: "invalid_params", message: "Missing value", data: nil) + return .err(code: "invalid_params", message: strings.missingValue, data: nil) } let value: Bool? switch rawValue { @@ -32,15 +40,15 @@ extension ControlCommandCoordinator { default: return .err( code: "invalid_params", - message: "value must be a bool or null", + message: strings.invalidValue, data: .object(["value": rawValue]) ) } - guard let snapshot = debugContext?.controlDebugSetBetaRemoteDefault( + guard let snapshot = debugContext.controlDebugSetBetaRemoteDefault( identifier: key, value: value ) else { - return .err(code: "not_found", message: "Beta remote default not found", data: nil) + return .err(code: "not_found", message: strings.notFound, data: nil) } return debugBetaRemoteDefaultResult(snapshot) } diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlDebugBetaRemoteDefaultStrings.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlDebugBetaRemoteDefaultStrings.swift new file mode 100644 index 00000000000..0f860733bb0 --- /dev/null +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlDebugBetaRemoteDefaultStrings.swift @@ -0,0 +1,21 @@ +/// App-bundle-resolved validation messages for the beta remote-default debug +/// RPCs. The coordinator package has no copy of the app's localization catalog, +/// so its context supplies these strings. +public struct ControlDebugBetaRemoteDefaultStrings: Sendable, Equatable { + public let missingKey: String + public let notFound: String + public let missingValue: String + public let invalidValue: String + + public init( + missingKey: String, + notFound: String, + missingValue: String, + invalidValue: String + ) { + self.missingKey = missingKey + self.notFound = notFound + self.missingValue = missingValue + self.invalidValue = invalidValue + } +} diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlDebugContext.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlDebugContext.swift index 8cd0ff80d17..8a2eb7f5124 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlDebugContext.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlDebugContext.swift @@ -164,6 +164,10 @@ public protocol ControlDebugContext: AnyObject { // MARK: - Live app/UI state + /// App-bundle-localized validation messages for the beta remote-default + /// debug RPCs. + func controlDebugBetaRemoteDefaultStrings() -> ControlDebugBetaRemoteDefaultStrings + /// Reads one beta remote-default setting by setting id or PostHog key. func controlDebugBetaRemoteDefaultSnapshot( identifier: String diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs+Debug.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs+Debug.swift index f5d4fd88cff..a3b9ed4f517 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs+Debug.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs+Debug.swift @@ -33,6 +33,14 @@ extension ControlDebugContext { func controlDebugShowCanvasCommandScrollHint( routing: ControlRoutingSelectors ) -> ControlCanvasActionResolution { .tabManagerUnavailable } + func controlDebugBetaRemoteDefaultStrings() -> ControlDebugBetaRemoteDefaultStrings { + ControlDebugBetaRemoteDefaultStrings( + missingKey: "Missing key", + notFound: "Beta remote default not found", + missingValue: "Missing value", + invalidValue: "value must be a bool or null" + ) + } func controlDebugBetaRemoteDefaultSnapshot( identifier: String ) -> ControlDebugBetaRemoteDefaultSnapshot? { nil } diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorDebugBetaRemoteDefaultsTests.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorDebugBetaRemoteDefaultsTests.swift index b6561389c29..feb404a2bbb 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorDebugBetaRemoteDefaultsTests.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorDebugBetaRemoteDefaultsTests.swift @@ -66,12 +66,59 @@ struct ControlCommandCoordinatorDebugBetaRemoteDefaultsTests { } #expect(code == "invalid_params") } + + @Test func validationErrorsUseContextProvidedStrings() { + let context = FakeBetaRemoteDefaultControlCommandContext() + context.strings = ControlDebugBetaRemoteDefaultStrings( + missingKey: "localized missing key", + notFound: "localized not found", + missingValue: "localized missing value", + invalidValue: "localized invalid value" + ) + let coordinator = ControlCommandCoordinator(context: context) + + guard case .err(_, let missingKey, _) = coordinator.handle( + ControlRequest( + id: .int(1), + method: "debug.beta_remote_defaults.get", + params: [:] + ) + ) else { + Issue.record("expected missing-key error") + return + } + guard case .err(_, let invalidValue, _) = coordinator.handle( + ControlRequest( + id: .int(2), + method: "debug.beta_remote_defaults.set", + params: [ + "key": .string("tests.beta.enabled"), + "value": .string("true"), + ] + ) + ) else { + Issue.record("expected invalid-value error") + return + } + #expect(missingKey == "localized missing key") + #expect(invalidValue == "localized invalid value") + } } @MainActor private final class FakeBetaRemoteDefaultControlCommandContext: ControlCommandContext { var lastValueWasSet = false var lastValue: Bool? + var strings = ControlDebugBetaRemoteDefaultStrings( + missingKey: "Missing key", + notFound: "Beta remote default not found", + missingValue: "Missing value", + invalidValue: "value must be a bool or null" + ) + + func controlDebugBetaRemoteDefaultStrings() -> ControlDebugBetaRemoteDefaultStrings { + strings + } func controlDebugBetaRemoteDefaultSnapshot( identifier: String diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/UserDefaultsSettingsStorage.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/UserDefaultsSettingsStorage.swift index 128ac512b6e..c646ed8a9a9 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/UserDefaultsSettingsStorage.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Stores/UserDefaultsSettingsStorage.swift @@ -74,12 +74,19 @@ final class UserDefaultsSettingsStorage: @unchecked Sendable { ) -> NotificationObserverToken { let defaultsID = ObjectIdentifier(defaults) let remoteDefaultState = RemoteDefaultNotificationState() + let matchesStorageKey: @Sendable (Notification) -> Bool = { notification in + guard let storageKey else { return true } + return notification.userInfo?[ + CmuxSettingsRemoteDefaultNotification.storageKeyUserInfoKey + ] as? String == storageKey + } var tokens = [ notificationCenter.addObserver( forName: .cmuxSettingsRemoteDefaultWillChange, object: defaults, queue: nil - ) { _ in + ) { notification in + guard matchesStorageKey(notification) else { return } remoteDefaultState.begin() }, notificationCenter.addObserver( @@ -99,19 +106,15 @@ final class UserDefaultsSettingsStorage: @unchecked Sendable { ) } ] - if let storageKey { + if storageKey != nil { tokens.append( notificationCenter.addObserver( forName: .cmuxSettingsRemoteDefaultDidChange, object: defaults, queue: nil ) { notification in - let changedStorageKey = notification.userInfo?[ - CmuxSettingsRemoteDefaultNotification.storageKeyUserInfoKey - ] as? String - if changedStorageKey == storageKey { - handler(true, false, true) - } + guard matchesStorageKey(notification) else { return } + handler(true, false, true) remoteDefaultState.end() } ) diff --git a/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/DefaultsKeyRemoteDefaultTests.swift b/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/DefaultsKeyRemoteDefaultTests.swift index 7c079a09727..1093fc1b232 100644 --- a/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/DefaultsKeyRemoteDefaultTests.swift +++ b/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/DefaultsKeyRemoteDefaultTests.swift @@ -13,7 +13,9 @@ struct DefaultsKeyRemoteDefaultTests { ) @Test func resolutionPrefersUserThenRemoteThenCompileDefault() throws { - let defaults = try makeDefaults() + let isolated = try makeDefaults() + let defaults = isolated.defaults + defer { defaults.removePersistentDomain(forName: isolated.suiteName) } #expect(key.resolution(in: defaults) == .init(value: false, source: .compileDefault)) @@ -34,7 +36,9 @@ struct DefaultsKeyRemoteDefaultTests { } @Test func invalidLayersFallThroughWithoutBecomingUserIntent() throws { - let defaults = try makeDefaults() + let isolated = try makeDefaults() + let defaults = isolated.defaults + defer { defaults.removePersistentDomain(forName: isolated.suiteName) } defaults.set("invalid-user", forKey: key.userDefaultsKey) key.setRemoteDefault(true, in: defaults) @@ -46,7 +50,9 @@ struct DefaultsKeyRemoteDefaultTests { } @Test func sameEffectiveUserWritePersistsIntentAcrossRemoteChanges() throws { - let defaults = try makeDefaults() + let isolated = try makeDefaults() + let defaults = isolated.defaults + defer { defaults.removePersistentDomain(forName: isolated.suiteName) } key.setRemoteDefault(true, in: defaults) key.set(true, in: defaults) @@ -58,7 +64,9 @@ struct DefaultsKeyRemoteDefaultTests { } @Test func cachedRemoteDefaultSurvivesKeyAndStoreReconstruction() async throws { - nonisolated(unsafe) let defaults = try makeDefaults() + let isolated = try makeDefaults() + nonisolated(unsafe) let defaults = isolated.defaults + defer { defaults.removePersistentDomain(forName: isolated.suiteName) } key.setRemoteDefault(true, in: defaults) let reconstructed = DefaultsKey( @@ -75,7 +83,9 @@ struct DefaultsKeyRemoteDefaultTests { } @Test func resetRemovesOnlyUserChoiceAndInheritsRemoteDefault() async throws { - nonisolated(unsafe) let defaults = try makeDefaults() + let isolated = try makeDefaults() + nonisolated(unsafe) let defaults = isolated.defaults + defer { defaults.removePersistentDomain(forName: isolated.suiteName) } key.setRemoteDefault(true, in: defaults) key.set(false, in: defaults) let store = UserDefaultsSettingsStore(defaults: defaults) @@ -88,7 +98,9 @@ struct DefaultsKeyRemoteDefaultTests { } @Test func resetAllRemovesOnlyUserChoiceAndInheritsRemoteDefault() async throws { - nonisolated(unsafe) let defaults = try makeDefaults() + let isolated = try makeDefaults() + nonisolated(unsafe) let defaults = isolated.defaults + defer { defaults.removePersistentDomain(forName: isolated.suiteName) } key.setRemoteDefault(true, in: defaults) key.set(false, in: defaults) let store = UserDefaultsSettingsStore(defaults: defaults) @@ -101,7 +113,9 @@ struct DefaultsKeyRemoteDefaultTests { } @Test func liveStoreObservationSeesRemoteChangeWithoutUserWrite() async throws { - nonisolated(unsafe) let defaults = try makeDefaults() + let isolated = try makeDefaults() + nonisolated(unsafe) let defaults = isolated.defaults + defer { defaults.removePersistentDomain(forName: isolated.suiteName) } let store = UserDefaultsSettingsStore(defaults: defaults) var iterator = store.values(for: key).makeAsyncIterator() @@ -112,7 +126,9 @@ struct DefaultsKeyRemoteDefaultTests { } @Test func initialValueEventClassifiesInheritedAndPrimaryLayers() async throws { - nonisolated(unsafe) let inheritedDefaults = try makeDefaults() + let inherited = try makeDefaults() + nonisolated(unsafe) let inheritedDefaults = inherited.defaults + defer { inheritedDefaults.removePersistentDomain(forName: inherited.suiteName) } key.setRemoteDefault(true, in: inheritedDefaults) let inheritedStore = UserDefaultsSettingsStore(defaults: inheritedDefaults) let inheritedStream = await inheritedStore.valueEvents(for: key) @@ -123,7 +139,9 @@ struct DefaultsKeyRemoteDefaultTests { #expect(inheritedEvent?.isInitialSnapshot == true) #expect(inheritedEvent?.isInheritedDefaultChange == true) - nonisolated(unsafe) let primaryDefaults = try makeDefaults() + let primary = try makeDefaults() + nonisolated(unsafe) let primaryDefaults = primary.defaults + defer { primaryDefaults.removePersistentDomain(forName: primary.suiteName) } key.set(true, in: primaryDefaults) let primaryStore = UserDefaultsSettingsStore(defaults: primaryDefaults) let primaryStream = await primaryStore.valueEvents(for: key) @@ -136,16 +154,18 @@ struct DefaultsKeyRemoteDefaultTests { } @Test func unchangedRemoteRefreshReportsNoStorageChange() throws { - let defaults = try makeDefaults() + let isolated = try makeDefaults() + let defaults = isolated.defaults + defer { defaults.removePersistentDomain(forName: isolated.suiteName) } #expect(key.setRemoteDefault(true, in: defaults)) #expect(!key.setRemoteDefault(true, in: defaults)) } - private func makeDefaults() throws -> UserDefaults { + private func makeDefaults() throws -> (suiteName: String, defaults: UserDefaults) { let suiteName = "cmux.tests.beta.remote.\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: suiteName)) defaults.removePersistentDomain(forName: suiteName) - return defaults + return (suiteName, defaults) } } diff --git a/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/UserDefaultsSettingsStoreObservationTests.swift b/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/UserDefaultsSettingsStoreObservationTests.swift index 4fefc0329da..524a9a3ddcf 100644 --- a/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/UserDefaultsSettingsStoreObservationTests.swift +++ b/Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/UserDefaultsSettingsStoreObservationTests.swift @@ -103,6 +103,59 @@ struct UserDefaultsSettingsStoreObservationTests { #expect(targetedEvent?.2 == true) } + @Test func remoteNotificationDepthPairsOnlyForObservedStorageKey() { + let suiteName = "cmux.tests.\(UUID().uuidString)" + let observedDefaults = UserDefaults(suiteName: suiteName)! + observedDefaults.removePersistentDomain(forName: suiteName) + defer { observedDefaults.removePersistentDomain(forName: suiteName) } + let notificationCenter = NotificationCenter() + let storage = UserDefaultsSettingsStorage( + defaults: observedDefaults, + notificationCenter: notificationCenter + ) + let recorder = NotificationClassificationRecorder() + let observedKey = "tests.beta.observed" + let token = storage.addDidChangeObserver(for: observedKey) { + isBackingDefaultsNotification, + canCarryActiveMutationSource, + isInheritedDefaultNotification in + recorder.append(NotificationClassification( + isBackingDefaultsNotification: isBackingDefaultsNotification, + canCarryActiveMutationSource: canCarryActiveMutationSource, + isInheritedDefaultNotification: isInheritedDefaultNotification + )) + } + defer { token.remove() } + + func postRemote(_ name: Notification.Name, storageKey: String) { + notificationCenter.post( + name: name, + object: observedDefaults, + userInfo: [ + CmuxSettingsRemoteDefaultNotification.storageKeyUserInfoKey: storageKey, + ] + ) + } + + postRemote(.cmuxSettingsRemoteDefaultWillChange, storageKey: "tests.beta.other") + notificationCenter.post(name: UserDefaults.didChangeNotification, object: observedDefaults) + + postRemote(.cmuxSettingsRemoteDefaultWillChange, storageKey: observedKey) + notificationCenter.post(name: UserDefaults.didChangeNotification, object: observedDefaults) + postRemote(.cmuxSettingsRemoteDefaultDidChange, storageKey: "tests.beta.other") + notificationCenter.post(name: UserDefaults.didChangeNotification, object: observedDefaults) + postRemote(.cmuxSettingsRemoteDefaultDidChange, storageKey: observedKey) + notificationCenter.post(name: UserDefaults.didChangeNotification, object: observedDefaults) + + #expect(recorder.snapshot() == [ + NotificationClassification(true, true, false), + NotificationClassification(true, false, true), + NotificationClassification(true, false, true), + NotificationClassification(true, false, true), + NotificationClassification(true, true, false), + ]) + } + @Test func valueEventBufferCarriesDroppedSourcesOntoSourceTaggedSurvivor() async { let firstSource = UserDefaultsSettingsMutationSource() let secondSource = UserDefaultsSettingsMutationSource() @@ -129,3 +182,46 @@ struct UserDefaultsSettingsStoreObservationTests { #expect(event?.supersededMutationSources.contains(secondSource) == true) } } + +private struct NotificationClassification: Sendable, Equatable { + let isBackingDefaultsNotification: Bool + let canCarryActiveMutationSource: Bool + let isInheritedDefaultNotification: Bool + + init( + _ isBackingDefaultsNotification: Bool, + _ canCarryActiveMutationSource: Bool, + _ isInheritedDefaultNotification: Bool + ) { + self.init( + isBackingDefaultsNotification: isBackingDefaultsNotification, + canCarryActiveMutationSource: canCarryActiveMutationSource, + isInheritedDefaultNotification: isInheritedDefaultNotification + ) + } + + init( + isBackingDefaultsNotification: Bool, + canCarryActiveMutationSource: Bool, + isInheritedDefaultNotification: Bool + ) { + self.isBackingDefaultsNotification = isBackingDefaultsNotification + self.canCarryActiveMutationSource = canCarryActiveMutationSource + self.isInheritedDefaultNotification = isInheritedDefaultNotification + } +} + +private final class NotificationClassificationRecorder: @unchecked Sendable { + private let lock = NSLock() + private var values: [NotificationClassification] = [] + + func append(_ value: NotificationClassification) { + lock.withLock { + values.append(value) + } + } + + func snapshot() -> [NotificationClassification] { + lock.withLock { values } + } +} diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/DefaultsValueModel.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/DefaultsValueModel.swift index 7e5a2d56fb2..76e8256dba5 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/DefaultsValueModel.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Bindings/DefaultsValueModel.swift @@ -35,14 +35,13 @@ public final class DefaultsValueModel { /// The most recently observed value. SwiftUI views read this synchronously. public private(set) var current: Value private(set) var revision = 0 - var pendingStoreEchoCount: Int { pendingStoreEchoes.count } private let store: UserDefaultsSettingsStore private let key: DefaultsKey private let initialStoreValue: Value @ObservationIgnored private let makeStream: @MainActor @Sendable (Set) async -> AsyncStream> - @ObservationIgnored private var pendingStoreEchoes: [( + @ObservationIgnored private(set) var pendingStoreEchoes: [( source: UserDefaultsSettingsMutationSource, value: Value, protectsExplicitValueFromInheritedChanges: Bool diff --git a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/DefaultsValueModelRemoteDefaultTests.swift b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/DefaultsValueModelRemoteDefaultTests.swift index eb2e6cc661c..6118339ed97 100644 --- a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/DefaultsValueModelRemoteDefaultTests.swift +++ b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/DefaultsValueModelRemoteDefaultTests.swift @@ -11,6 +11,7 @@ struct DefaultsValueModelRemoteDefaultTests { let suiteName = "cmux.settings.ui.remote.\(UUID().uuidString)" nonisolated(unsafe) let defaults = try #require(UserDefaults(suiteName: suiteName)) defaults.removePersistentDomain(forName: suiteName) + defer { defaults.removePersistentDomain(forName: suiteName) } let key = DefaultsKey( id: "tests.beta.enabled", defaultValue: false, @@ -134,14 +135,14 @@ struct DefaultsValueModelRemoteDefaultTests { for _ in 0..<100_000 where defaults.object(forKey: key.userDefaultsKey) != nil - || model.pendingStoreEchoCount != 0 + || !model.pendingStoreEchoes.isEmpty || model.current != true { await Task.yield() } #expect(defaults.object(forKey: key.userDefaultsKey) == nil) #expect(model.current) - #expect(model.pendingStoreEchoCount == 0) + #expect(model.pendingStoreEchoes.isEmpty) #expect(key.resolution(in: defaults) == .init(value: true, source: .remoteDefault)) } @@ -182,13 +183,13 @@ struct DefaultsValueModelRemoteDefaultTests { for _ in 0..<1_000 { await Task.yield() everyObservedValueStayedFalse = everyObservedValueStayedFalse && model.current == false - if model.pendingStoreEchoCount == 0 { break } + if model.pendingStoreEchoes.isEmpty { break } } #expect(everyObservedValueStayedFalse) #expect(model.current == false) #expect(model.revision == revisionAfterClick) - #expect(model.pendingStoreEchoCount == 0) + #expect(model.pendingStoreEchoes.isEmpty) } private func makeKey() -> DefaultsKey { diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 07620ce041b..10447b31974 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -226992,6 +226992,74 @@ } } }, + "socket.debug.betaRemoteDefault.error.invalidValue": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "value must be a bool or null" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "value は bool または null である必要があります" + } + } + } + }, + "socket.debug.betaRemoteDefault.error.missingKey": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Missing key" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "key が指定されていません" + } + } + } + }, + "socket.debug.betaRemoteDefault.error.missingValue": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Missing value" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "value が指定されていません" + } + } + } + }, + "socket.debug.betaRemoteDefault.error.notFound": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Beta remote default not found" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ベータ版のリモートデフォルトが見つかりません" + } + } + } + }, "socket.notification.dismissSelectorRequired": { "extractionState": "manual", "localizations": { diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 1b1c55256e5..5e4cb50991e 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -1272,9 +1272,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent func applicationDidFinishLaunching(_ notification: Notification) { let env = ProcessInfo.processInfo.environment -#if DEBUG - CmuxFeatureFlags.applyUITestBetaRemoteDefaultsIfPresent(environment: env) -#endif let telemetryEnabled = TelemetrySettings.enabledForCurrentLaunch let sentryStartupPolicy = MacSentryStartupPolicy( environment: env, diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 8b473336773..ca3c1ccebe4 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -8462,7 +8462,11 @@ struct ContentView: View { tabManager.moveTabsToTop([workspace.id]) tabManager.selectWorkspace(workspace) } - WorkspaceTodoPaletteCommands.registerHandlers(in: ®istry, tabManager: tabManager) + WorkspaceTodoPaletteCommands.registerHandlers( + in: ®istry, + tabManager: tabManager, + todoControlsEnabled: workspaceTodoControlsEnabled + ) registry.register(commandId: "palette.closeOtherWorkspaces") { closeOtherSelectedWorkspaces() } diff --git a/Sources/FeatureFlags.swift b/Sources/FeatureFlags.swift index b2af5ea8c5e..c9d9b45b83e 100644 --- a/Sources/FeatureFlags.swift +++ b/Sources/FeatureFlags.swift @@ -25,18 +25,6 @@ struct CmuxRemoteFlagSnapshot: Sendable, Equatable { let invalidKeys: Set } -#if DEBUG -struct CmuxBetaRemoteDefaultState: Sendable, Equatable { - let settingID: String - let flagKey: String - let userKeyPresent: Bool - let userValue: Bool? - let remoteDefault: Bool? - let effectiveValue: Bool - let source: DefaultsValueSource -} -#endif - /// PostHog-backed runtime feature flags for the macOS app (PostHog project /// 244066, same public key analytics uses). Values are cached in memory and /// refreshed when the SDK reports a flag payload, so gated UI can be toggled @@ -183,29 +171,6 @@ final class CmuxFeatureFlags { ] }() -#if DEBUG - static func applyUITestBetaRemoteDefaultsIfPresent( - environment: [String: String], - defaults: UserDefaults = .standard - ) { - guard environment["CMUX_UI_TEST_MODE"] == "1", - let rawPayload = environment["CMUX_UI_TEST_BETA_REMOTE_DEFAULTS"], - let data = rawPayload.data(using: .utf8), - let payload = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - return - } - - for definition in betaRemoteDefaults { - let rawValue = payload[definition.flagKey] - ?? payload[definition.settingKey.id] - definition.settingKey.setRemoteDefault( - Bool.decodeFromJSON(rawValue), - in: defaults - ) - } - } -#endif - // Order is load-bearing for the positional typed accessors below. Flags // that need a stable public definition are declared independently and // included here without repeating their key literal. @@ -705,42 +670,6 @@ final class CmuxFeatureFlags { postChangeIfNeeded(previousResolutions: previousResolutions) } -#if DEBUG - func betaRemoteDefaultState(identifier: String) -> CmuxBetaRemoteDefaultState? { - guard let definition = Self.betaRemoteDefaults.first(where: { - $0.settingKey.id == identifier || $0.flagKey == identifier - }) else { - return nil - } - let key = definition.settingKey - let resolution = key.resolution(in: defaults) - return CmuxBetaRemoteDefaultState( - settingID: key.id, - flagKey: definition.flagKey, - userKeyPresent: defaults.object(forKey: key.userDefaultsKey) != nil, - userValue: Bool.decodeFromUserDefaults( - defaults.object(forKey: key.userDefaultsKey) - ), - remoteDefault: key.remoteDefaultValue(in: defaults), - effectiveValue: resolution.value, - source: resolution.source - ) - } - - func setBetaRemoteDefaultForDebug( - identifier: String, - value: Bool? - ) -> CmuxBetaRemoteDefaultState? { - guard let definition = Self.betaRemoteDefaults.first(where: { - $0.settingKey.id == identifier || $0.flagKey == identifier - }) else { - return nil - } - definition.settingKey.setRemoteDefault(value, in: defaults) - return betaRemoteDefaultState(identifier: identifier) - } -#endif - private func recomputeEffectiveValues() { resolutionsByKey = Self.allFlags.reduce(into: [:]) { values, definition in values[definition.key] = CmuxFeatureFlagResolution( diff --git a/Sources/Panels/WorkspaceTodoPanelView.swift b/Sources/Panels/WorkspaceTodoPanelView.swift index e1189a1bb03..45cbc5ba9dc 100644 --- a/Sources/Panels/WorkspaceTodoPanelView.swift +++ b/Sources/Panels/WorkspaceTodoPanelView.swift @@ -441,7 +441,7 @@ private struct WorkspaceTodoPaneContent: View { /// Enter commits the trimmed text and re-arms the field for the next item. private func commitPendingItem() { - guard WorkspaceTodoFeature.isEnabled else { return } + guard todoControlsEnabled else { return } let text = pendingItemText pendingItemText = "" guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } diff --git a/Sources/TabItemView+WorkspaceTodo.swift b/Sources/TabItemView+WorkspaceTodo.swift index 4444b35696d..3048f1e0292 100644 --- a/Sources/TabItemView+WorkspaceTodo.swift +++ b/Sources/TabItemView+WorkspaceTodo.swift @@ -186,7 +186,8 @@ enum WorkspaceTodoPaletteCommands { static func registerHandlers( in registry: inout CommandPaletteHandlerRegistry, - tabManager: TabManager + tabManager: TabManager, + todoControlsEnabled: Bool ) { func withSelectedWorkspace(_ body: @escaping (Workspace) -> Void) -> () -> Void { { @@ -200,7 +201,7 @@ enum WorkspaceTodoPaletteCommands { registry.register( commandId: statusAutoCommandId, handler: withSelectedWorkspace { workspace in - guard WorkspaceTodoFeature.isEnabled else { + guard todoControlsEnabled else { NSSound.beep() return } @@ -211,7 +212,7 @@ enum WorkspaceTodoPaletteCommands { registry.register( commandId: statusCommandId(status), handler: withSelectedWorkspace { workspace in - guard WorkspaceTodoFeature.isEnabled else { + guard todoControlsEnabled else { NSSound.beep() return } @@ -222,7 +223,7 @@ enum WorkspaceTodoPaletteCommands { registry.register( commandId: markWorkspaceDoneCommandId, handler: withSelectedWorkspace { workspace in - guard WorkspaceTodoFeature.isEnabled else { + guard todoControlsEnabled else { NSSound.beep() return } @@ -232,7 +233,7 @@ enum WorkspaceTodoPaletteCommands { registry.register( commandId: addChecklistItemCommandId, handler: withSelectedWorkspace { workspace in - guard WorkspaceTodoFeature.isEnabled else { + guard todoControlsEnabled else { NSSound.beep() return } diff --git a/Sources/TerminalController+ControlDebugContext.swift b/Sources/TerminalController+ControlDebugContext.swift index 863ceff9abf..e86f1cd2f6d 100644 --- a/Sources/TerminalController+ControlDebugContext.swift +++ b/Sources/TerminalController+ControlDebugContext.swift @@ -38,43 +38,65 @@ func debugShowCanvasCommandScrollHint(in workspace: Workspace) -> Bool { extension TerminalController: ControlDebugContext { #if DEBUG + func controlDebugBetaRemoteDefaultStrings() -> ControlDebugBetaRemoteDefaultStrings { + ControlDebugBetaRemoteDefaultStrings( + missingKey: String( + localized: "socket.debug.betaRemoteDefault.error.missingKey", + defaultValue: "Missing key" + ), + notFound: String( + localized: "socket.debug.betaRemoteDefault.error.notFound", + defaultValue: "Beta remote default not found" + ), + missingValue: String( + localized: "socket.debug.betaRemoteDefault.error.missingValue", + defaultValue: "Missing value" + ), + invalidValue: String( + localized: "socket.debug.betaRemoteDefault.error.invalidValue", + defaultValue: "value must be a bool or null" + ) + ) + } + func controlDebugBetaRemoteDefaultSnapshot( identifier: String ) -> ControlDebugBetaRemoteDefaultSnapshot? { - guard let state = CmuxFeatureFlags.shared.betaRemoteDefaultState( - identifier: identifier - ) else { - return nil - } - return ControlDebugBetaRemoteDefaultSnapshot( - settingID: state.settingID, - flagKey: state.flagKey, - userKeyPresent: state.userKeyPresent, - userValue: state.userValue, - remoteDefault: state.remoteDefault, - effectiveValue: state.effectiveValue, - source: state.source.rawValue - ) + guard let definition = betaRemoteDefaultDefinition(identifier: identifier) else { return nil } + return betaRemoteDefaultSnapshot(definition: definition) } func controlDebugSetBetaRemoteDefault( identifier: String, value: Bool? ) -> ControlDebugBetaRemoteDefaultSnapshot? { - guard let state = CmuxFeatureFlags.shared.setBetaRemoteDefaultForDebug( - identifier: identifier, - value: value - ) else { - return nil + guard let definition = betaRemoteDefaultDefinition(identifier: identifier) else { return nil } + definition.settingKey.setRemoteDefault(value, in: .standard) + return betaRemoteDefaultSnapshot(definition: definition) + } + + private func betaRemoteDefaultDefinition( + identifier: String + ) -> CmuxBetaRemoteDefaultDefinition? { + CmuxFeatureFlags.betaRemoteDefaults.first { + $0.settingKey.id == identifier || $0.flagKey == identifier } + } + + private func betaRemoteDefaultSnapshot( + definition: CmuxBetaRemoteDefaultDefinition + ) -> ControlDebugBetaRemoteDefaultSnapshot { + let key = definition.settingKey + let defaults = UserDefaults.standard + let resolution = key.resolution(in: defaults) return ControlDebugBetaRemoteDefaultSnapshot( - settingID: state.settingID, - flagKey: state.flagKey, - userKeyPresent: state.userKeyPresent, - userValue: state.userValue, - remoteDefault: state.remoteDefault, - effectiveValue: state.effectiveValue, - source: state.source.rawValue + settingID: key.id, + flagKey: definition.flagKey, + userKeyPresent: defaults.object(forKey: key.userDefaultsKey) != nil, + userValue: Bool.decodeFromUserDefaults(defaults.object(forKey: key.userDefaultsKey)), + remoteDefault: key.remoteDefaultValue(in: defaults), + effectiveValue: resolution.value, + source: resolution.source.rawValue ) } diff --git a/Sources/WorkspaceTodoFeature.swift b/Sources/WorkspaceTodoFeature.swift index 89107a7e7f8..65fef48302e 100644 --- a/Sources/WorkspaceTodoFeature.swift +++ b/Sources/WorkspaceTodoFeature.swift @@ -15,16 +15,6 @@ enum WorkspaceTodoFeature { BetaFeaturesCatalogSection().workspaceTodoControls.value(in: .standard) } - static func isEnabled(defaults: UserDefaults, remoteEnabled: Bool) -> Bool { - let key = BetaFeaturesCatalogSection().workspaceTodoControls - return Bool.decodeFromUserDefaults(defaults.object(forKey: key.userDefaultsKey)) - ?? remoteEnabled - } - - static func effectiveControlsValue(defaults: UserDefaults) -> Bool { - BetaFeaturesCatalogSection().workspaceTodoControls.value(in: defaults) - } - /// The checklist presentation style (popover or inline), user-selectable. static var checklistStyle: WorkspaceTodoChecklistStyle { let key = BetaFeaturesCatalogSection().workspaceTodosChecklistStyle diff --git a/cmuxTests/CLIAuthAliasTests.swift b/cmuxTests/CLIAuthAliasTests.swift index 547da551833..fdaf5abf8a7 100644 --- a/cmuxTests/CLIAuthAliasTests.swift +++ b/cmuxTests/CLIAuthAliasTests.swift @@ -2,6 +2,71 @@ import XCTest import Darwin extension CLINotifyProcessIntegrationRegressionTests { + func testSettingsBetaFeatureAliasesShareSocketContract() throws { + let aliases = ["beta-features", "betafeatures", "beta"] + let cliPath = try bundledCLIPath() + let socketPath = makeSocketPath("settings-beta") + let listenerFD = try bindUnixSocket(at: socketPath) + let state = MockSocketServerState() + + defer { + Darwin.close(listenerFD) + unlink(socketPath) + } + + let serverHandled = startMockServer( + listenerFD: listenerFD, + state: state, + connectionCount: aliases.count + ) { line in + guard let payload = self.jsonObject(line), + let id = payload["id"] as? String, + let method = payload["method"] as? String else { + return self.malformedRequestResponse(raw: line) + } + guard method == "settings.open" else { + return self.v2Response( + id: id, + ok: false, + error: ["code": "unexpected", "message": "Unexpected method \(method)"] + ) + } + return self.v2Response( + id: id, + ok: true, + result: ["target": "betaFeatures"] + ) + } + + var environment = ProcessInfo.processInfo.environment + environment["CMUX_SOCKET_PATH"] = socketPath + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + + for alias in aliases { + let result = runProcess( + executablePath: cliPath, + arguments: ["settings", alias], + environment: environment, + timeout: 5 + ) + XCTAssertFalse(result.timedOut, "\(alias): \(result.stderr)") + XCTAssertEqual(result.status, 0, "\(alias): \(result.stderr)") + XCTAssertEqual(result.stdout, "OK target=betaFeatures\n", alias) + } + + wait(for: [serverHandled], timeout: 5) + let requests = state.snapshot().compactMap(jsonObject) + XCTAssertEqual(requests.count, aliases.count) + XCTAssertTrue(requests.allSatisfy { request in + guard request["method"] as? String == "settings.open", + let params = request["params"] as? [String: Any] else { + return false + } + return params["target"] as? String == "betaFeatures" + && params["activate"] as? Bool == true + }) + } + func testTopLevelLoginAliasesAuthLogin() throws { let cliPath = try bundledCLIPath() let socketPath = makeSocketPath("auth-login") diff --git a/cmuxTests/WorkspaceTodoSidebarModelTests.swift b/cmuxTests/WorkspaceTodoSidebarModelTests.swift index 6fdac331c37..c05fd9e051a 100644 --- a/cmuxTests/WorkspaceTodoSidebarModelTests.swift +++ b/cmuxTests/WorkspaceTodoSidebarModelTests.swift @@ -85,19 +85,23 @@ struct WorkspaceTodoSidebarModelTests { } let key = BetaFeaturesCatalogSection().workspaceTodoControls - #expect(key.defaultValue == false) - #expect(!WorkspaceTodoFeature.effectiveControlsValue(defaults: defaults)) - #expect(!WorkspaceTodoFeature.isEnabled(defaults: defaults, remoteEnabled: false)) - #expect(WorkspaceTodoFeature.isEnabled(defaults: defaults, remoteEnabled: true)) + #expect(key.resolution(in: defaults) == .init(value: false, source: .compileDefault)) + + key.setRemoteDefault(true, in: defaults) + #expect(key.resolution(in: defaults) == .init(value: true, source: .remoteDefault)) defaults.set(true, forKey: key.userDefaultsKey) - #expect(WorkspaceTodoFeature.effectiveControlsValue(defaults: defaults)) - #expect(WorkspaceTodoFeature.isEnabled(defaults: defaults, remoteEnabled: false)) + #expect(key.resolution(in: defaults) == .init(value: true, source: .user)) defaults.set(false, forKey: key.userDefaultsKey) - #expect(!WorkspaceTodoFeature.effectiveControlsValue(defaults: defaults)) - #expect(!WorkspaceTodoFeature.isEnabled(defaults: defaults, remoteEnabled: false)) - #expect(!WorkspaceTodoFeature.isEnabled(defaults: defaults, remoteEnabled: true)) + #expect(key.resolution(in: defaults) == .init(value: false, source: .user)) + + key.setRemoteDefault(false, in: defaults) + key.setRemoteDefault(true, in: defaults) + #expect(key.resolution(in: defaults) == .init(value: false, source: .user)) + + key.removeValue(in: defaults) + #expect(key.resolution(in: defaults) == .init(value: true, source: .remoteDefault)) } @MainActor @@ -113,10 +117,7 @@ struct WorkspaceTodoSidebarModelTests { model.startObserving() key.setRemoteDefault(true, in: defaults) - for _ in 0..<100_000 where model.current == false { - await Task.yield() - } - #expect(model.current) + #expect(await waitUntil { model.current }) #expect(SidebarWorkspaceManualTaskStatusIndicatorModel( featureEnabled: model.current, taskStatus: .review, @@ -124,16 +125,16 @@ struct WorkspaceTodoSidebarModelTests { ).showsIndicator) model.set(false) - for _ in 0..<100_000 where Bool.decodeFromUserDefaults( - defaults.object(forKey: key.userDefaultsKey) - ) != false { - await Task.yield() - } + #expect(await waitUntil { + Bool.decodeFromUserDefaults(defaults.object(forKey: key.userDefaultsKey)) == false + }) key.setRemoteDefault(false, in: defaults) key.setRemoteDefault(true, in: defaults) - for _ in 0..<100 { - await Task.yield() - } + #expect(await waitUntil { + key.remoteDefaultValue(in: defaults) == true + && key.resolution(in: defaults) == .init(value: false, source: .user) + && model.current == false + }) #expect(model.current == false) #expect(Bool.decodeFromUserDefaults( @@ -146,6 +147,20 @@ struct WorkspaceTodoSidebarModelTests { ).showsIndicator) } + @MainActor + private func waitUntil( + timeout: Duration = .seconds(2), + _ predicate: @MainActor () -> Bool + ) async -> Bool { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while !predicate() { + guard clock.now < deadline else { return false } + await Task.yield() + } + return true + } + @Test func compactStatusOnlyShowsWhenDetailsAreHiddenAndStatusIsEngaged() { #expect(!SidebarWorkspaceTodoMinimalVisibility( diff --git a/cmuxUITests/SettingsSidebarBetaBehaviorUITests.swift b/cmuxUITests/SettingsSidebarBetaBehaviorUITests.swift index 65afe260fd2..9c4e4c18963 100644 --- a/cmuxUITests/SettingsSidebarBetaBehaviorUITests.swift +++ b/cmuxUITests/SettingsSidebarBetaBehaviorUITests.swift @@ -3,10 +3,10 @@ import XCTest /// Behavioral UI tests for Sidebar and Beta Features settings. /// /// Branch layout, Feed, and Dock assert their reactive derived subtitles. -/// Workspace Todo uses the deterministic remote-default launch seam plus -/// debug-domain readback to verify inherited-on, explicit-off, relaunch, and -/// Reset All behavior through `LiveSetting`. Runtime row and mode-bar effects -/// that require a materialized workspace remain documented as Tier 2 below. +/// Workspace Todo seeds the production remote-cache layer plus debug-domain +/// readback to verify inherited-on, explicit-off, relaunch, and Reset All +/// behavior through `LiveSetting`. Runtime row and mode-bar effects that +/// require a materialized workspace remain documented as Tier 2 below. final class SettingsSidebarBetaBehaviorUITests: SettingsUITestCase { // userDefaultsKeys for the in-scope settings, reset before/after each @@ -200,11 +200,11 @@ final class SettingsSidebarBetaBehaviorUITests: SettingsUITestCase { } func testWorkspaceTodoRemoteDefaultYieldsToUserChoiceAndResetAll() { - let remoteEnvironment = [ - "CMUX_UI_TEST_BETA_REMOTE_DEFAULTS": - #"{"workspace-todo-controls-enabled-release":true}"#, - ] - var app = makeLaunchedApp(environment: remoteEnvironment) + writeDebugDefaultBool( + true, + forKey: "cmux.beta.remoteDefault.workspaceTodos.controls.enabled" + ) + var app = makeLaunchedApp() var window = openSettings(app) navigate(window, to: "Beta Features") @@ -228,7 +228,7 @@ final class SettingsSidebarBetaBehaviorUITests: SettingsUITestCase { ) app.terminate() - app = makeLaunchedApp(environment: remoteEnvironment) + app = makeLaunchedApp() window = openSettings(app) navigate(window, to: "Beta Features") XCTAssertTrue( diff --git a/cmuxUITests/SettingsUITestSupport.swift b/cmuxUITests/SettingsUITestSupport.swift index 7442d63a72e..3147cd5cd20 100644 --- a/cmuxUITests/SettingsUITestSupport.swift +++ b/cmuxUITests/SettingsUITestSupport.swift @@ -120,6 +120,27 @@ class SettingsUITestCase: XCTestCase { } } + /// Seeds a Boolean in the app's isolated debug defaults domain. + /// + /// UI tests use the same persisted layers production reads, so launch does + /// not need a test-specific environment parser in the app target. + func writeDebugDefaultBool( + _ value: Bool, + forKey key: String, + suite: String = "com.cmuxterm.app.debug" + ) { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/defaults") + process.arguments = ["write", suite, key, "-bool", value ? "true" : "false"] + do { + try process.run() + process.waitUntilExit() + XCTAssertEqual(process.terminationStatus, 0, "Failed to seed \(key) in \(suite)") + } catch { + XCTFail("Failed to seed \(key) in \(suite): \(error)") + } + } + /// Reads one Boolean from the app's isolated debug defaults domain. /// /// A nonnil result proves the primary user key is durable, rather than From cad2aebf783e8a589e89e543e71cb7e455f70cee Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:08:32 -0700 Subject: [PATCH 4/6] fix: harden beta default debug responses --- ...dCoordinator+DebugBetaRemoteDefaults.swift | 2 +- ...rdinatorDebugBetaRemoteDefaultsTests.swift | 3 +- Resources/Localizable.xcstrings | 540 ++++++++++++++++++ 3 files changed, 543 insertions(+), 2 deletions(-) diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlCommandCoordinator+DebugBetaRemoteDefaults.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlCommandCoordinator+DebugBetaRemoteDefaults.swift index 56607ddd4ee..682b996735b 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlCommandCoordinator+DebugBetaRemoteDefaults.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Debug/ControlCommandCoordinator+DebugBetaRemoteDefaults.swift @@ -41,7 +41,7 @@ extension ControlCommandCoordinator { return .err( code: "invalid_params", message: strings.invalidValue, - data: .object(["value": rawValue]) + data: nil ) } guard let snapshot = debugContext.controlDebugSetBetaRemoteDefault( diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorDebugBetaRemoteDefaultsTests.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorDebugBetaRemoteDefaultsTests.swift index feb404a2bbb..346cbe30d4a 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorDebugBetaRemoteDefaultsTests.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandCoordinatorDebugBetaRemoteDefaultsTests.swift @@ -51,7 +51,7 @@ struct ControlCommandCoordinatorDebugBetaRemoteDefaultsTests { return } - guard case .err(let code, _, _) = coordinator.handle( + guard case .err(let code, _, let data) = coordinator.handle( ControlRequest( id: .int(2), method: "debug.beta_remote_defaults.set", @@ -65,6 +65,7 @@ struct ControlCommandCoordinatorDebugBetaRemoteDefaultsTests { return } #expect(code == "invalid_params") + #expect(data == nil) } @Test func validationErrorsUseContextProvidedStrings() { diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 10447b31974..3d9582ff8d9 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -162635,17 +162635,125 @@ "settings.betaFeatures.workspaceTodoControls.subtitleOff": { "extractionState": "manual", "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يبقي ملخصات مهام مساحة العمل للقراءة فقط حتى تفعّل عناصر التحكم هنا." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Sažeci zadataka radnog prostora ostaju samo za čitanje dok ovdje ne omogućite kontrole." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Holder arbejdsområdets opgaveoversigter skrivebeskyttede, indtil du aktiverer betjeningen her." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Hält die Aufgabenübersichten des Arbeitsbereichs schreibgeschützt, bis du die Steuerelemente hier aktivierst." + } + }, "en": { "stringUnit": { "state": "translated", "value": "Keeps workspace todo summaries read-only until you enable the controls here." } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mantiene los resúmenes de tareas del espacio de trabajo en modo de solo lectura hasta que actives aquí los controles." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Maintient les résumés des tâches de l’espace de travail en lecture seule jusqu’à l’activation des commandes ici." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Mantiene i riepiloghi delle attività dello spazio di lavoro in sola lettura finché non abiliti qui i controlli." + } + }, "ja": { "stringUnit": { "state": "translated", "value": "ここでコントロールを有効にするまで、ワークスペース Todo の概要を読み取り専用にします。" } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "រក្សាសេចក្តីសង្ខេបការងារត្រូវធ្វើរបស់កន្លែងធ្វើការឱ្យអាចអានបានតែប៉ុណ្ណោះ រហូតដល់អ្នកបើកការគ្រប់គ្រងនៅទីនេះ។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "여기에서 컨트롤을 활성화할 때까지 작업 공간 할 일 요약을 읽기 전용으로 유지합니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Holder gjøremålsoversikter for arbeidsområdet skrivebeskyttet til du aktiverer kontrollene her." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zachowuje podsumowania zadań obszaru roboczego w trybie tylko do odczytu, dopóki nie włączysz tutaj elementów sterujących." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Mantém os resumos de tarefas do espaço de trabalho como somente leitura até você ativar os controles aqui." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сводки задач рабочего пространства остаются доступными только для чтения, пока вы не включите здесь элементы управления." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "คงสรุปรายการสิ่งที่ต้องทำของพื้นที่ทำงานไว้เป็นแบบอ่านอย่างเดียวจนกว่าคุณจะเปิดใช้ตัวควบคุมที่นี่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Buradaki denetimleri etkinleştirene kadar çalışma alanı yapılacaklar özetlerini salt okunur tutar." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Зберігає зведення завдань робочого простору доступними лише для читання, доки ви не ввімкнете тут елементи керування." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在此启用控件之前,工作区待办事项摘要将保持只读。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在此啟用控制項之前,工作區待辦事項摘要會維持唯讀。" + } } } }, @@ -226995,68 +227103,500 @@ "socket.debug.betaRemoteDefault.error.invalidValue": { "extractionState": "manual", "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يجب أن تكون value من النوع bool أو null" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "value mora biti bool ili null" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "value skal være en bool eller null" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "value muss ein bool-Wert oder null sein" + } + }, "en": { "stringUnit": { "state": "translated", "value": "value must be a bool or null" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "value debe ser un bool o null" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "value doit être de type bool ou null" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "value deve essere un valore bool o null" + } + }, "ja": { "stringUnit": { "state": "translated", "value": "value は bool または null である必要があります" } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "value ត្រូវតែជា bool ឬ null" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "value는 bool 또는 null이어야 합니다" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "value må være en bool eller null" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "value musi być typu bool albo null" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "value deve ser bool ou null" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "value должно иметь тип bool или значение null" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "value ต้องเป็น bool หรือ null" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "value, bool veya null olmalıdır" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "value має мати тип bool або значення null" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "value 必须是 bool 或 null" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "value 必須是 bool 或 null" + } } } }, "socket.debug.betaRemoteDefault.error.missingKey": { "extractionState": "manual", "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم يتم تحديد key" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "key nije naveden" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "key mangler" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "key fehlt" + } + }, "en": { "stringUnit": { "state": "translated", "value": "Missing key" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Falta key" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le paramètre key est manquant" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "key mancante" + } + }, "ja": { "stringUnit": { "state": "translated", "value": "key が指定されていません" } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "មិនបានបញ្ជាក់ key" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "key가 지정되지 않았습니다" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "key mangler" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Brak parametru key" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "key ausente" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Параметр key не указан" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่ได้ระบุ key" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "key belirtilmedi" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Параметр key не вказано" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未指定 key" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未指定 key" + } } } }, "socket.debug.betaRemoteDefault.error.missingValue": { "extractionState": "manual", "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم يتم تحديد value" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "value nije naveden" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "value mangler" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "value fehlt" + } + }, "en": { "stringUnit": { "state": "translated", "value": "Missing value" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Falta value" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le paramètre value est manquant" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "value mancante" + } + }, "ja": { "stringUnit": { "state": "translated", "value": "value が指定されていません" } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "មិនបានបញ្ជាក់ value" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "value가 지정되지 않았습니다" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "value mangler" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Brak parametru value" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "value ausente" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Параметр value не указан" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่ได้ระบุ value" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "value belirtilmedi" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Параметр value не вказано" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未指定 value" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未指定 value" + } } } }, "socket.debug.betaRemoteDefault.error.notFound": { "extractionState": "manual", "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم يتم العثور على الإعداد الافتراضي البعيد للميزة التجريبية" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Zadana udaljena vrijednost beta funkcije nije pronađena" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fjernstandardværdien for betafunktionen blev ikke fundet" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Remote-Standardwert der Betafunktion wurde nicht gefunden" + } + }, "en": { "stringUnit": { "state": "translated", "value": "Beta remote default not found" } }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se encontró el valor predeterminado remoto de la función beta" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Valeur par défaut distante de la fonctionnalité bêta introuvable" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Valore predefinito remoto della funzionalità beta non trovato" + } + }, "ja": { "stringUnit": { "state": "translated", "value": "ベータ版のリモートデフォルトが見つかりません" } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "រកមិនឃើញតម្លៃលំនាំដើមពីចម្ងាយរបស់មុខងារបេតាទេ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "베타 기능의 원격 기본값을 찾을 수 없습니다" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Ekstern standardverdi for betafunksjonen ble ikke funnet" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nie znaleziono zdalnej wartości domyślnej funkcji beta" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O valor padrão remoto do recurso beta não foi encontrado" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Удалённое значение по умолчанию для бета-функции не найдено" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่พบค่าเริ่มต้นระยะไกลของฟีเจอร์เบต้า" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Beta özelliğinin uzaktan varsayılan değeri bulunamadı" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Віддалене типове значення бета-функції не знайдено" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未找到 Beta 功能的远程默认值" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "找不到 Beta 功能的遠端預設值" + } } } }, From 63a11c39a77fac6a8f9c94b444b31c162660feb1 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Mon, 17 Aug 2026 11:49:59 -0700 Subject: [PATCH 5/6] fix: declare panelArtifactAuthorizationStore and accept workspaceID in cleanupSurfaceState Main commit 04ff18eea6 added a cleanupSurfaceState(workspaceID:) call and panelArtifactAuthorizationStore uses without declaring either on TerminalController. Wire the store up and invalidate per-panel artifact grants on surface teardown. Co-Authored-By: Claude Fable 5 --- Sources/TerminalController.swift | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 571fbfd747e..f02290ee519 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -139,6 +139,8 @@ class TerminalController { @MainActor private(set) var accountFlow: HostAccountFlow? @MainActor private(set) var caffeineController: CaffeineController? @MainActor var agentChatTranscriptService: AgentChatTranscriptService? + /// Per-panel single-file grants backing mobile artifact reads. + @MainActor let panelArtifactAuthorizationStore = PanelArtifactAuthorizationStore() nonisolated let terminalArtifactAuthorizationStore: TerminalArtifactAuthorizationStore // Sendable value type; injected at construction so socket auth never reaches a global. nonisolated let passwordStore: SocketControlPasswordStore @@ -356,9 +358,17 @@ class TerminalController { ) private var browserDownloadObserver: NSObjectProtocol? - func cleanupSurfaceState(surfaceIds: [UUID], paneIds: [UUID] = []) { + func cleanupSurfaceState(surfaceIds: [UUID], paneIds: [UUID] = [], workspaceID: UUID? = nil) { let uniqueSurfaceIds = Set(surfaceIds) socketFastPathState.removeShellActivity(panelIds: uniqueSurfaceIds) + if let workspaceID { + for surfaceId in uniqueSurfaceIds { + panelArtifactAuthorizationStore.invalidate( + workspaceID: workspaceID.uuidString, + surfaceID: surfaceId.uuidString + ) + } + } for surfaceId in uniqueSurfaceIds { v2BrowserFrameSelectorBySurface.removeValue(forKey: surfaceId) v2BrowserDialogQueueBySurface.removeValue(forKey: surfaceId) From ffd2cf579bfdebc0c928016c50914e98cd83387c Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Mon, 17 Aug 2026 12:06:06 -0700 Subject: [PATCH 6/6] fix: make MobileSurfaces switches exhaustive The merged main added PanelType.notifications, a dockUnavailable focus resolution, and richer artifact-transfer issue failures; map each to the existing wire vocabulary and error helpers. Co-Authored-By: Claude Fable 5 --- Sources/TerminalController+MobileSurfaces.swift | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/Sources/TerminalController+MobileSurfaces.swift b/Sources/TerminalController+MobileSurfaces.swift index 468afc1e72e..d70332b804b 100644 --- a/Sources/TerminalController+MobileSurfaces.swift +++ b/Sources/TerminalController+MobileSurfaces.swift @@ -38,6 +38,8 @@ extension TerminalController { return MobileSurfaceKind(rawValue: "mobilePairing") case .accountSignIn: return MobileSurfaceKind(rawValue: "accountSignIn") + case .notifications: + return MobileSurfaceKind(rawValue: "notifications") } } @@ -154,6 +156,12 @@ extension TerminalController { message: "Surface not found", data: ["surface_id": id.uuidString] ) + case let .dockUnavailable(message): + return .err( + code: "unavailable", + message: message, + data: ["surface_id": surfaceID.uuidString] + ) case let .focused(windowID, focusedWorkspaceID, focusedSurfaceID): return .ok([ "workspace_id": focusedWorkspaceID.uuidString, @@ -269,6 +277,12 @@ extension TerminalController { defaultValue: "That file is no longer available on the Mac.", path: v2RawString(params, "path") ) + case .permissionDenied: + return mobileArtifactReadFailure(.permissionDenied, path: v2RawString(params, "path")) + case .notRegularFile: + return mobileArtifactReadFailure(.notRegularFile, path: v2RawString(params, "path")) + case .readFailed: + return mobileArtifactReadFailure(.readFailed, path: v2RawString(params, "path")) case .unavailable: return mobilePanelArtifactFileError( code: "unavailable",