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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -107603,6 +107603,40 @@
}
}
},
"featureFlags.mobileWorkspaceChanges.description": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Serves workspace diffs to paired phones: the iOS changes chip, toolbar button, and Changes sheet."
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "ペアリング済みのスマートフォンにワークスペースの差分を提供します(iOSの変更チップ、ツールバーボタン、変更シート)。"
}
}
}
},
"featureFlags.mobileWorkspaceChanges.title": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Mobile diff viewer"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "モバイル差分ビューア"
}
}
}
},
"featureFlags.override.none": {
"extractionState": "manual",
"localizations": {
Expand Down Expand Up @@ -131497,6 +131531,23 @@
}
}
},
"mobile.workspaceChanges.error.capabilityDisabled": {
"extractionState": "manual",
"localizations": {
"en": {
"stringUnit": {
"state": "translated",
"value": "Workspace changes are not enabled on this Mac"
}
},
"ja": {
"stringUnit": {
"state": "translated",
"value": "このMacではワークスペースの変更機能が有効になっていません"
}
}
}
},
"mobile.workspaceChanges.error.invalidWorkspaceIDs": {
"extractionState": "manual",
"localizations": {
Expand Down
62 changes: 60 additions & 2 deletions Sources/FeatureFlags.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Foundation
import Observation
import PostHog
import os

struct CmuxFeatureFlagDefinition: Identifiable, Equatable {
var id: String { key }
Expand Down Expand Up @@ -33,7 +34,7 @@ struct CmuxFeatureFlagDefinition: Identifiable, Equatable {
@MainActor
@Observable
final class CmuxFeatureFlags {
static let shared = CmuxFeatureFlags()
static let shared = CmuxFeatureFlags(publishesOffMainSnapshot: true)

#if DEBUG
private static let proUpgradeUIDefault = true
Expand All @@ -49,6 +50,11 @@ final class CmuxFeatureFlags {
private static let cloudVMUIDefault = false
#endif
private static let agentChatUIDefault = false
#if DEBUG
private static let mobileWorkspaceChangesDefault = true
#else
private static let mobileWorkspaceChangesDefault = false
#endif
private static let sidebarWorkspaceAgentSpinnerDefault = false
private static let simulatorDefault = true
private static let workspaceTodoControlsDefault = false
Expand Down Expand Up @@ -80,6 +86,28 @@ final class CmuxFeatureFlags {
defaultWhenUnavailable: CmuxFeatureFlags.appKitSidebarListDefault
)

// FLAG(key: mobile-workspace-changes-enabled-release, owner: lawrencecchen,
// reviewBy: 2026-10-01, defaultWhenUnavailable: false)
// Serves the iOS diff viewer: advertises workspace.changes.v1 to phones
// and answers the mobile.workspace.changes.* RPCs behind it. Every iOS
// entry point (workspace-row chip, toolbar button, one-time hint, Changes
// sheet, summary polling) feature-detects on that capability, so this one
// Mac-side flag turns the whole feature off end to end. Release builds
// keep it off until the PostHog flag enables it; DEBUG keeps it on for
// dogfood.
static let mobileWorkspaceChangesFlag = CmuxFeatureFlagDefinition(
key: "mobile-workspace-changes-enabled-release",
title: String(
localized: "featureFlags.mobileWorkspaceChanges.title",
defaultValue: "Mobile diff viewer"
),
flagDescription: String(
localized: "featureFlags.mobileWorkspaceChanges.description",
defaultValue: "Serves workspace diffs to paired phones: the iOS changes chip, toolbar button, and Changes sheet."
),
defaultWhenUnavailable: CmuxFeatureFlags.mobileWorkspaceChangesDefault
)

// 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.
Expand Down Expand Up @@ -202,6 +230,8 @@ final class CmuxFeatureFlags {
),

CmuxFeatureFlags.appKitSidebarListFlag,

CmuxFeatureFlags.mobileWorkspaceChangesFlag,
]
}()

Expand Down Expand Up @@ -237,6 +267,28 @@ final class CmuxFeatureFlags {
effectiveValue(for: Self.appKitSidebarListFlag)
}

var isMobileWorkspaceChangesEnabled: Bool {
effectiveValue(for: Self.mobileWorkspaceChangesFlag)
}

/// Effective values mirrored for nonisolated readers: the mobile host
/// serves status payloads (which carry the capability list) off the main
/// actor. Written only by the shared instance so test instances cannot
/// stomp process-wide state. Before the shared instance exists, readers
/// get the per-flag compile-time default (fail-closed for release flags).
private nonisolated static let offMainEffectiveValues = OSAllocatedUnfairLock(
initialState: [String: Bool]()
)

nonisolated static func offMainEffectiveValue(
for definition: CmuxFeatureFlagDefinition
) -> Bool {
offMainEffectiveValues.withLock { $0[definition.key] }
?? definition.defaultWhenUnavailable
}

@ObservationIgnored
private let publishesOffMainSnapshot: Bool
@ObservationIgnored
private let defaults: UserDefaults
@ObservationIgnored
Expand All @@ -256,9 +308,11 @@ final class CmuxFeatureFlags {
defaults: UserDefaults = .standard,
telemetryEnabled: Bool = TelemetrySettings.enabledForCurrentLaunch,
remoteFlagValueProvider: @escaping (String) -> Any? = { PostHogSDK.shared.getFeatureFlag($0) },
remoteFlagLoader: (@Sendable () async -> [String: Bool]?)? = nil
remoteFlagLoader: (@Sendable () async -> [String: Bool]?)? = nil,
publishesOffMainSnapshot: Bool = false
) {
self.defaults = defaults
self.publishesOffMainSnapshot = publishesOffMainSnapshot
self.remoteFlagValueProvider = remoteFlagValueProvider
if let remoteFlagLoader {
self.remoteFlagLoader = remoteFlagLoader
Expand Down Expand Up @@ -552,6 +606,10 @@ final class CmuxFeatureFlags {
defaultValue: definition.defaultWhenUnavailable
)
}
if publishesOffMainSnapshot {
let effectiveValues = resolutionsByKey.mapValues(\.effectiveValue)
Self.offMainEffectiveValues.withLock { $0 = effectiveValues }
}
}

private func postChangeIfNeeded(previousResolutions: [String: CmuxFeatureFlagResolution]) {
Expand Down
23 changes: 21 additions & 2 deletions Sources/Mobile/MobileHostService+Capabilities.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import Foundation

extension MobileHostService {
nonisolated static let irohArtifactLaneCapability = "iroh.artifact_lane.v1"
nonisolated static let workspaceChangesCapability = "workspace.changes.v1"

/// The single source of truth for the capabilities advertised to mobile
/// clients via `mobile.host.status`. Every status path (the public-status
Expand All @@ -17,7 +18,22 @@ extension MobileHostService {
/// still gated by the same-account Stack-auth check the rest of the mobile
/// data plane enforces.
nonisolated static var mobileHostCapabilities: [String] {
let capabilities = [
mobileHostCapabilities(
includingWorkspaceChanges: CmuxFeatureFlags.offMainEffectiveValue(
for: CmuxFeatureFlags.mobileWorkspaceChangesFlag
)
)
}

/// The mobile diff viewer ships behind a remote feature flag: when the
/// flag is off this list omits `workspace.changes.v1`, and every iOS
/// entry point (chip, toolbar button, hint, sheet, summary polling)
/// feature-detects itself away. The RPC dispatch applies the same flag,
/// so a phone holding a stale capability list cannot call through.
nonisolated static func mobileHostCapabilities(
includingWorkspaceChanges: Bool
) -> [String] {
var capabilities = [
"events.v1",
"notification.badge.v1",
"notification.dismiss.v1",
Expand All @@ -36,7 +52,7 @@ extension MobileHostService {
"terminal.artifact.v1",
"terminal.artifact.list.v1",
"workspace.actions.v1",
"workspace.changes.v1",
Self.workspaceChangesCapability,
"workspace.metadata.v1",
"workspace.read_state.v1",
"workspace.close.v1",
Expand All @@ -58,6 +74,9 @@ extension MobileHostService {
// this to render collapsible groups only against a Mac that emits them.
"workspace.groups.v1",
]
if !includingWorkspaceChanges {
capabilities.removeAll { $0 == Self.workspaceChangesCapability }
}
#if DEBUG
// Lets a dev Mac impersonate an older host while dogfooding the iOS update hint.
let suppressed = Set(
Expand Down
35 changes: 35 additions & 0 deletions Sources/TerminalController+MobileWorkspaceChanges.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,41 @@ import CmuxGit
import Foundation
// MARK: - Mobile workspace changes
extension TerminalController {
/// Routes every `mobile.workspace.changes.*` method through the remote
/// feature flag, mirroring the `workspace.changes.v1` capability gate, so
/// the RPCs and the advertised capability cannot drift: a phone that
/// cached the capability from an earlier status reply still gets a clean
/// error once the flag turns off.
@MainActor
func v2MobileWorkspaceChanges(method: String, params: [String: Any]) async -> V2CallResult {
guard CmuxFeatureFlags.shared.isMobileWorkspaceChangesEnabled else {
return .err(
code: "capability_disabled",
message: String(
localized: "mobile.workspaceChanges.error.capabilityDisabled",
defaultValue: "Workspace changes are not enabled on this Mac"
),
data: ["capability": MobileHostService.workspaceChangesCapability]
)
}
switch method {
case "mobile.workspace.changes.summary":
return await v2MobileWorkspaceChangesSummary(params: params)
case "mobile.workspace.changes.files":
return await v2MobileWorkspaceChangesFiles(params: params)
case "mobile.workspace.changes.file_diff":
return await v2MobileWorkspaceChangesFileDiff(params: params)
case "mobile.workspace.changes.file_stat":
return await v2MobileWorkspaceChangesFileStat(params: params)
case "mobile.workspace.changes.file_fetch":
return await v2MobileWorkspaceChangesFileFetch(params: params)
default:
return .err(code: "method_not_found", message: "Unknown mobile method", data: [
"method": method
])
}
}

/// Returns cached change totals for 1...64 explicit workspaces.
///
/// IDs never fall back to the current selection. UI-owned workspace lookup
Expand Down
16 changes: 6 additions & 10 deletions Sources/TerminalController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14084,16 +14084,12 @@ class TerminalController {
result = await v2MobileAttachTicketCreate(params: request.params)
case "mobile.workspace.list", "workspace.list":
result = v2MobileWorkspaceList(params: request.params)
case "mobile.workspace.changes.summary":
result = await v2MobileWorkspaceChangesSummary(params: request.params)
case "mobile.workspace.changes.files":
result = await v2MobileWorkspaceChangesFiles(params: request.params)
case "mobile.workspace.changes.file_diff":
result = await v2MobileWorkspaceChangesFileDiff(params: request.params)
case "mobile.workspace.changes.file_stat":
result = await v2MobileWorkspaceChangesFileStat(params: request.params)
case "mobile.workspace.changes.file_fetch":
result = await v2MobileWorkspaceChangesFileFetch(params: request.params)
case "mobile.workspace.changes.summary",
"mobile.workspace.changes.files",
"mobile.workspace.changes.file_diff",
"mobile.workspace.changes.file_stat",
"mobile.workspace.changes.file_fetch":
result = await v2MobileWorkspaceChanges(method: request.method, params: request.params)
case "mobile.directory.search":
result = await v2MobileDirectorySearch(
params: request.params,
Expand Down
36 changes: 36 additions & 0 deletions cmuxTests/MobileHostConnectionLifecycleTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,42 @@ extension MobileHostAuthorizationTests {
"notification.feed.v1",
]))
}
@Test func testWorkspaceChangesCapabilityFollowsFeatureFlag() {
let enabled = MobileHostService.mobileHostCapabilities(includingWorkspaceChanges: true)
let disabled = MobileHostService.mobileHostCapabilities(includingWorkspaceChanges: false)

#expect(enabled.contains(MobileHostService.workspaceChangesCapability))
#expect(!disabled.contains(MobileHostService.workspaceChangesCapability))
// The flag removes exactly the one capability and nothing else.
#expect(
enabled.filter { $0 != MobileHostService.workspaceChangesCapability } == disabled
)
}

@Test @MainActor func testMobileWorkspaceChangesFlagDefaultsAndRemoteValue() {
let suiteName = "cmux-tests-mobile-changes-flag-\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suiteName)!
defer { defaults.removePersistentDomain(forName: suiteName) }

var remoteValue: Any?
let flags = CmuxFeatureFlags(
defaults: defaults,
remoteFlagValueProvider: { _ in remoteValue }
)

// Without a remote value the per-build default applies (DEBUG on for
// dogfood, Release off); tests compile DEBUG.
#expect(flags.isMobileWorkspaceChangesEnabled)

remoteValue = false
flags.applyLoadedFlags()
#expect(!flags.isMobileWorkspaceChangesEnabled)

remoteValue = true
flags.applyLoadedFlags()
#expect(flags.isMobileWorkspaceChangesEnabled)
}

// MARK: - Mobile workspace.action sub-action gate
@Test func testMobileWorkspaceActionGateAllowsIdentityAndReadStateActions() {
for action in [
Expand Down