From e756965cc5118600cb8152ce630459e580d627a6 Mon Sep 17 00:00:00 2001 From: Aziz Albahar Date: Sun, 12 Jul 2026 13:46:02 -0700 Subject: [PATCH 01/11] Add Mac-update-hint decision core to CmuxMobileShell --- .../CmuxMobileShell/MobileMacAppVersion.swift | 63 ++++++++ .../MobileMacUpdateAdvisor.swift | 82 ++++++++++ ...MobileMacUpdateCapabilityRequirement.swift | 61 ++++++++ .../MobileMacUpdateFeature.swift | 28 ++++ .../MobileMacUpdateHintDismissalStore.swift | 44 ++++++ .../MobileMacAppVersionTests.swift | 46 ++++++ .../MobileMacUpdateAdvisorTests.swift | 143 ++++++++++++++++++ ...bileMacUpdateHintDismissalStoreTests.swift | 38 +++++ 8 files changed, 505 insertions(+) create mode 100644 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacAppVersion.swift create mode 100644 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacUpdateAdvisor.swift create mode 100644 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacUpdateCapabilityRequirement.swift create mode 100644 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacUpdateFeature.swift create mode 100644 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacUpdateHintDismissalStore.swift create mode 100644 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacAppVersionTests.swift create mode 100644 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacUpdateAdvisorTests.swift create mode 100644 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacUpdateHintDismissalStoreTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacAppVersion.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacAppVersion.swift new file mode 100644 index 000000000000..fff78b148344 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacAppVersion.swift @@ -0,0 +1,63 @@ +/// A strictly numeric dotted Mac marketing version used by the mobile update advisor. +/// +/// Valid versions contain one to three nonnegative decimal components. Missing trailing +/// components compare as zero, so `0.65` and `0.65.0` are equal. +public struct MobileMacAppVersion: Comparable, Equatable, Sendable, CustomStringConvertible { + /// The one to three numeric components parsed from the marketing version. + public let components: [Int] + + /// Creates a version from a strictly numeric dotted marketing-version string. + /// + /// - Parameter string: A version containing one to three ASCII-decimal components. + /// - Returns: A parsed version, or `nil` for an empty, malformed, signed, suffixed, or oversized version. + public init?(parsing string: String) { + let substrings = string.split(separator: ".", omittingEmptySubsequences: false) + guard (1 ... 3).contains(substrings.count) else { return nil } + + var parsedComponents: [Int] = [] + parsedComponents.reserveCapacity(substrings.count) + for substring in substrings { + guard !substring.isEmpty, + substring.utf8.allSatisfy({ (48 ... 57).contains($0) }), + let component = Int(substring) + else { + return nil + } + parsedComponents.append(component) + } + + components = parsedComponents + } + + /// The canonical dotted representation of the parsed numeric components. + public var description: String { + components.map(String.init).joined(separator: ".") + } + + /// Returns whether two versions have equal numeric components after adding trailing zeroes. + /// + /// - Parameters: + /// - lhs: The first version to compare. + /// - rhs: The second version to compare. + /// - Returns: `true` when both versions represent the same numeric version. + public static func == (lhs: Self, rhs: Self) -> Bool { + !(lhs < rhs) && !(rhs < lhs) + } + + /// Returns whether the first version numerically precedes the second version. + /// + /// - Parameters: + /// - lhs: The first version to compare. + /// - rhs: The second version to compare. + /// - Returns: `true` when `lhs` is numerically older than `rhs`. + public static func < (lhs: Self, rhs: Self) -> Bool { + for index in 0 ..< 3 { + let left = index < lhs.components.count ? lhs.components[index] : 0 + let right = index < rhs.components.count ? rhs.components[index] : 0 + if left != right { + return left < right + } + } + return false + } +} diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacUpdateAdvisor.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacUpdateAdvisor.swift new file mode 100644 index 000000000000..6d2874dbd67f --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacUpdateAdvisor.swift @@ -0,0 +1,82 @@ +/// A truthful recommendation that a released Mac update unlocks mobile-visible features. +public struct MobileMacUpdateHint: Equatable, Sendable { + /// The missing features that a released Mac update unlocks, in registry order without duplicates. + public let features: [MobileMacUpdateFeature] + + /// The minimum Mac marketing version that unlocks every listed feature. + public let minimumMacVersion: MobileMacAppVersion + + /// The connected Mac's current marketing version. + public let macAppVersion: MobileMacAppVersion + + /// The stable missing-capability identifiers used to build the dismissal signature. + private let missingCapabilities: [String] + + /// Creates a Mac update hint from contributing capability requirements. + /// + /// - Parameters: + /// - features: The unique features to present, in registry order. + /// - minimumMacVersion: The minimum Mac version that unlocks the features. + /// - macAppVersion: The connected Mac's current version. + /// - missingCapabilities: The contributing stable capability identifiers. + init( + features: [MobileMacUpdateFeature], + minimumMacVersion: MobileMacAppVersion, + macAppVersion: MobileMacAppVersion, + missingCapabilities: [String] + ) { + self.features = features + self.minimumMacVersion = minimumMacVersion + self.macAppVersion = macAppVersion + self.missingCapabilities = missingCapabilities + } + + /// A stable signature that re-arms dismissal when the capability gap or target version changes. + public var dismissalSignature: String { + "\(Set(missingCapabilities).sorted().joined(separator: ","))>=\(minimumMacVersion)" + } +} + +/// Decides whether a connected Mac has a released update that unlocks known mobile features. +// lint:allow namespace-enum, namespace-type — the Part A specification requires this stateless static API shape. +public enum MobileMacUpdateAdvisor { + /// Builds a truthful update hint for missing capabilities available in a newer released Mac version. + /// + /// - Parameters: + /// - hostCapabilities: Capabilities from a successfully decoded `mobile.host.status` response. + /// - versionString: The connected Mac's reported marketing version. + /// - requirements: The capability release registry known to the iOS build. + /// - Returns: A hint when at least one missing capability shipped after the Mac version, otherwise `nil`. + public static func hint( + hostCapabilities: Set, + macAppVersion versionString: String?, + requirements: [MobileMacUpdateCapabilityRequirement] = MobileMacUpdateCapabilityRequirement.standard + ) -> MobileMacUpdateHint? { + guard let versionString, + let macAppVersion = MobileMacAppVersion(parsing: versionString) + else { + return nil + } + + let contributors = requirements.filter { requirement in + guard let releaseVersion = requirement.firstReleasedMacVersion else { return false } + return !hostCapabilities.contains(requirement.capability) && macAppVersion < releaseVersion + } + guard !contributors.isEmpty, + let minimumMacVersion = contributors.compactMap(\.firstReleasedMacVersion).max() + else { + return nil + } + + var seenFeatures: Set = [] + let features = contributors.compactMap { requirement in + seenFeatures.insert(requirement.feature).inserted ? requirement.feature : nil + } + return MobileMacUpdateHint( + features: features, + minimumMacVersion: minimumMacVersion, + macAppVersion: macAppVersion, + missingCapabilities: contributors.map(\.capability) + ) + } +} diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacUpdateCapabilityRequirement.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacUpdateCapabilityRequirement.swift new file mode 100644 index 000000000000..1ee3e1be116a --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacUpdateCapabilityRequirement.swift @@ -0,0 +1,61 @@ +/// A Mac host capability and the mobile-visible feature it enables. +public struct MobileMacUpdateCapabilityRequirement: Sendable, Equatable { + /// The stable capability identifier advertised by a Mac host. + public let capability: String + + /// The mobile-visible feature enabled by the capability. + public let feature: MobileMacUpdateFeature + + /// The first released Mac marketing version that advertises the capability. + /// + /// A `nil` value means the capability has not shipped in a Mac release, so the + /// update advisor never claims that updating unlocks it. + public let firstReleasedMacVersion: MobileMacAppVersion? + + /// Creates a capability requirement for the update advisor. + /// + /// - Parameters: + /// - capability: The stable capability identifier advertised by a Mac host. + /// - feature: The mobile-visible feature enabled by the capability. + /// - firstReleasedMacVersion: The first released Mac version containing the capability, or `nil` if unreleased. + public init( + capability: String, + feature: MobileMacUpdateFeature, + firstReleasedMacVersion: MobileMacAppVersion? + ) { + self.capability = capability + self.feature = feature + self.firstReleasedMacVersion = firstReleasedMacVersion + } + + /// The capability release registry known to this iOS build. + /// + /// Update an unreleased entry when its capability first ships in a Mac release. + // lint:allow singleton — `standard` is the spec-required immutable declaration registry, not runtime state. + public static let standard: [MobileMacUpdateCapabilityRequirement] = [ + .init( + capability: "workspace.actions.v1", + feature: .workspaceActions, + firstReleasedMacVersion: MobileMacAppVersion(parsing: "0.64.15") + ), + .init( + capability: "workspace.read_state.v1", + feature: .workspaceReadState, + firstReleasedMacVersion: MobileMacAppVersion(parsing: "0.64.16") + ), + .init( + capability: "workspace.close.v1", + feature: .workspaceClose, + firstReleasedMacVersion: MobileMacAppVersion(parsing: "0.64.16") + ), + .init( + capability: "workspace.groups.v1", + feature: .workspaceGroups, + firstReleasedMacVersion: MobileMacAppVersion(parsing: "0.64.16") + ), + .init(capability: "workspace.move.v1", feature: .workspaceMove, firstReleasedMacVersion: nil), + .init(capability: "workspace.group_actions.v1", feature: .workspaceGroupActions, firstReleasedMacVersion: nil), + .init(capability: "workspace.create_in_group.v1", feature: .workspaceCreateInGroup, firstReleasedMacVersion: nil), + .init(capability: "workspace.group_create.v1", feature: .workspaceGroupCreate, firstReleasedMacVersion: nil), + ] +} diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacUpdateFeature.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacUpdateFeature.swift new file mode 100644 index 000000000000..78fbda115f32 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacUpdateFeature.swift @@ -0,0 +1,28 @@ +/// A mobile-visible feature gated on a Mac host capability. +/// +/// Raw values are stable identifiers used in dismissal signatures and analytics. +public enum MobileMacUpdateFeature: String, CaseIterable, Sendable { + /// Renaming and pinning workspaces. + case workspaceActions + + /// Marking workspaces as read or unread. + case workspaceReadState + + /// Closing workspaces. + case workspaceClose + + /// Organizing workspaces into groups. + case workspaceGroups + + /// Reordering workspaces. + case workspaceMove + + /// Moving and grouping workspaces. + case workspaceGroupActions + + /// Creating workspaces inside groups. + case workspaceCreateInGroup + + /// Creating workspace groups. + case workspaceGroupCreate +} diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacUpdateHintDismissalStore.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacUpdateHintDismissalStore.swift new file mode 100644 index 000000000000..ecc0d4982384 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacUpdateHintDismissalStore.swift @@ -0,0 +1,44 @@ +public import Foundation + +/// Persists per-Mac update-hint dismissal signatures in injected user defaults. +public struct MobileMacUpdateHintDismissalStore { + /// The key prefix for per-Mac dismissal signatures. + private static let keyPrefix = "cmux.mobile.macUpdateHint.dismissed." + + /// The injected defaults domain used for persistence. + private let defaults: UserDefaults + + /// Creates a dismissal store backed by the supplied defaults domain. + /// + /// - Parameter defaults: The defaults domain to use; production callers default to `UserDefaults.standard`. + public init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + /// Returns whether a Mac dismissed the exact capability-gap signature. + /// + /// - Parameters: + /// - macDeviceID: The stable identifier of the connected Mac. + /// - signature: The current hint's dismissal signature. + /// - Returns: `true` only when the stored signature exactly matches `signature`. + public func isDismissed(macDeviceID: String, signature: String) -> Bool { + defaults.string(forKey: Self.key(for: macDeviceID)) == signature + } + + /// Persists dismissal of an exact capability gap for one Mac. + /// + /// - Parameters: + /// - macDeviceID: The stable identifier of the connected Mac. + /// - signature: The current hint's dismissal signature. + public func dismiss(macDeviceID: String, signature: String) { + defaults.set(signature, forKey: Self.key(for: macDeviceID)) + } + + /// Builds the persistence key for a Mac device identifier. + /// + /// - Parameter macDeviceID: The stable identifier of the connected Mac. + /// - Returns: The defaults key scoped to that Mac. + private static func key(for macDeviceID: String) -> String { + keyPrefix + macDeviceID + } +} diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacAppVersionTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacAppVersionTests.swift new file mode 100644 index 000000000000..507a92983589 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacAppVersionTests.swift @@ -0,0 +1,46 @@ +import CmuxMobileShell +import Testing + +@Suite +struct MobileMacAppVersionTests { + @Test(arguments: [ + ("0", [0]), + ("0.64", [0, 64]), + ("0.64.16", [0, 64, 16]), + ]) + func parsesValidVersions(input: String, expectedComponents: [Int]) throws { + let version = try #require(MobileMacAppVersion(parsing: input)) + #expect(version.components == expectedComponents) + #expect(version.description == expectedComponents.map(String.init).joined(separator: ".")) + } + + @Test(arguments: [ + "", + "0.65.0-nightly", + "abc", + "1..2", + "1.2.3.4", + "-1", + "1.-2", + " 1.2", + "1.2 ", + "1 .2", + ]) + func rejectsInvalidVersions(input: String) { + #expect(MobileMacAppVersion(parsing: input) == nil) + } + + @Test + func comparesVersionsNumericallyWithMissingTrailingZeroes() throws { + let version06415 = try #require(MobileMacAppVersion(parsing: "0.64.15")) + let version06416 = try #require(MobileMacAppVersion(parsing: "0.64.16")) + let version065 = try #require(MobileMacAppVersion(parsing: "0.65")) + let version0650 = try #require(MobileMacAppVersion(parsing: "0.65.0")) + let version10 = try #require(MobileMacAppVersion(parsing: "1.0")) + + #expect(version06415 < version06416) + #expect(version06416 < version065) + #expect(version065 == version0650) + #expect(version065 < version10) + } +} diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacUpdateAdvisorTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacUpdateAdvisorTests.swift new file mode 100644 index 000000000000..ef222ee279fa --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacUpdateAdvisorTests.swift @@ -0,0 +1,143 @@ +import CmuxMobileShell +import Testing + +@Suite +struct MobileMacUpdateAdvisorTests { + private let requirements: [MobileMacUpdateCapabilityRequirement] = [ + .init(capability: "released.early", feature: .workspaceActions, firstReleasedMacVersion: .init(parsing: "2.0")), + .init(capability: "released.late", feature: .workspaceGroups, firstReleasedMacVersion: .init(parsing: "3.0")), + .init(capability: "unreleased", feature: .workspaceMove, firstReleasedMacVersion: nil), + ] + + @Test + func olderVersionAndMissingReleasedCapabilityProducesHint() throws { + let hint = try #require(MobileMacUpdateAdvisor.hint( + hostCapabilities: ["released.late", "unreleased"], + macAppVersion: "1.5", + requirements: requirements + )) + + #expect(hint.features == [.workspaceActions]) + #expect(hint.minimumMacVersion == MobileMacAppVersion(parsing: "2.0")) + #expect(hint.macAppVersion == MobileMacAppVersion(parsing: "1.5")) + } + + @Test(arguments: ["2.0", "2.1"]) + func currentOrNewerVersionDoesNotProduceHint(version: String) { + let hint = MobileMacUpdateAdvisor.hint( + hostCapabilities: ["released.late", "unreleased"], + macAppVersion: version, + requirements: requirements + ) + #expect(hint == nil) + } + + @Test(arguments: [nil, "unknown", "2.0-nightly"] as [String?]) + func absentOrUnparseableVersionDoesNotProduceHint(version: String?) { + #expect(MobileMacUpdateAdvisor.hint( + hostCapabilities: [], + macAppVersion: version, + requirements: requirements + ) == nil) + } + + @Test + func missingUnreleasedCapabilityDoesNotProduceHint() { + #expect(MobileMacUpdateAdvisor.hint( + hostCapabilities: ["released.early", "released.late"], + macAppVersion: "1.0", + requirements: requirements + ) == nil) + } + + @Test + func mixedReleasedAndUnreleasedGapsIncludesOnlyReleasedFeaturesAtMaximumVersion() throws { + let hint = try #require(MobileMacUpdateAdvisor.hint( + hostCapabilities: [], + macAppVersion: "1.0", + requirements: requirements + )) + + #expect(hint.features == [.workspaceActions, .workspaceGroups]) + #expect(hint.minimumMacVersion == MobileMacAppVersion(parsing: "3.0")) + } + + @Test + func allCapabilitiesPresentDoesNotProduceHint() { + #expect(MobileMacUpdateAdvisor.hint( + hostCapabilities: ["released.early", "released.late", "unreleased"], + macAppVersion: "1.0", + requirements: requirements + ) == nil) + } + + @Test + func emptyCapabilitySetAndOldVersionIncludesAllReleasedFeatures() throws { + let hint = try #require(MobileMacUpdateAdvisor.hint( + hostCapabilities: [], + macAppVersion: "1.0", + requirements: requirements + )) + #expect(hint.features == [.workspaceActions, .workspaceGroups]) + } + + @Test + func duplicateFeatureRequirementsRemainUniqueInRegistryOrder() throws { + let duplicateRequirements = requirements + [ + .init(capability: "released.duplicate", feature: .workspaceActions, firstReleasedMacVersion: .init(parsing: "2.5")), + ] + let hint = try #require(MobileMacUpdateAdvisor.hint( + hostCapabilities: [], + macAppVersion: "1.0", + requirements: duplicateRequirements + )) + #expect(hint.features == [.workspaceActions, .workspaceGroups]) + } + + @Test + func dismissalSignatureIsStableAndChangesWithGapOrMinimumVersion() throws { + let reordered = [requirements[1], requirements[0], requirements[2]] + let first = try #require(MobileMacUpdateAdvisor.hint( + hostCapabilities: [], + macAppVersion: "1.0", + requirements: requirements + )) + let second = try #require(MobileMacUpdateAdvisor.hint( + hostCapabilities: [], + macAppVersion: "1.0", + requirements: reordered + )) + let changedGap = try #require(MobileMacUpdateAdvisor.hint( + hostCapabilities: ["released.early"], + macAppVersion: "1.0", + requirements: requirements + )) + let changedMinimumRequirements = requirements.map { requirement in + requirement.capability == "released.late" + ? .init(capability: requirement.capability, feature: requirement.feature, firstReleasedMacVersion: .init(parsing: "4.0")) + : requirement + } + let changedMinimum = try #require(MobileMacUpdateAdvisor.hint( + hostCapabilities: [], + macAppVersion: "1.0", + requirements: changedMinimumRequirements + )) + + #expect(first.dismissalSignature == second.dismissalSignature) + #expect(first.dismissalSignature != changedGap.dismissalSignature) + #expect(first.dismissalSignature != changedMinimum.dismissalSignature) + } + + @Test + func standardRegistryReportsOnlyMissingGroupsCapability() throws { + let capabilities = Set(MobileMacUpdateCapabilityRequirement.standard.map(\.capability)) + .subtracting(["workspace.groups.v1"]) + let hint = try #require(MobileMacUpdateAdvisor.hint( + hostCapabilities: capabilities, + macAppVersion: "0.64.15" + )) + + #expect(hint.features == [.workspaceGroups]) + #expect(hint.minimumMacVersion == MobileMacAppVersion(parsing: "0.64.16")) + } +} diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacUpdateHintDismissalStoreTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacUpdateHintDismissalStoreTests.swift new file mode 100644 index 000000000000..c2e25825c96a --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacUpdateHintDismissalStoreTests.swift @@ -0,0 +1,38 @@ +import CmuxMobileShell +import Foundation +import Testing + +@Suite +final class MobileMacUpdateHintDismissalStoreTests { + private let suiteName: String + private let defaults: UserDefaults + private let store: MobileMacUpdateHintDismissalStore + + init() { + suiteName = "mac-update-hint-dismissal-\(UUID().uuidString)" + defaults = UserDefaults(suiteName: suiteName)! + store = MobileMacUpdateHintDismissalStore(defaults: defaults) + } + + deinit { + defaults.removePersistentDomain(forName: suiteName) + } + + @Test + func dismissingSignaturePersistsExactMatch() { + store.dismiss(macDeviceID: "mac-a", signature: "cap.a>=2.0") + #expect(store.isDismissed(macDeviceID: "mac-a", signature: "cap.a>=2.0")) + } + + @Test + func differentSignatureRearmsHint() { + store.dismiss(macDeviceID: "mac-a", signature: "cap.a>=2.0") + #expect(!store.isDismissed(macDeviceID: "mac-a", signature: "cap.a,cap.b>=3.0")) + } + + @Test + func dismissalIsScopedToMac() { + store.dismiss(macDeviceID: "mac-a", signature: "cap.a>=2.0") + #expect(!store.isDismissed(macDeviceID: "mac-b", signature: "cap.a>=2.0")) + } +} From df9b4ebc44fa856edeb1fc1942d8e63f944c4cd5 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Sun, 12 Jul 2026 15:56:30 -0700 Subject: [PATCH 02/11] Show iOS indicator when a Mac update unlocks mobile features --- .../MobileShellComposite+MacUpdateHint.swift | 71 ++++++ .../MobileShellComposite.swift | 20 +- .../MobileMacUpdateFeatureDisplay.swift | 25 +++ .../MobileMacUpdateHintBanner.swift | 72 +++++++ .../WorkspaceListConnectionChrome.swift | 10 +- .../CmuxMobileShellUI/WorkspaceListView.swift | 18 +- .../WorkspaceShellView.swift | 6 + .../MobileMacUpdateFeatureDisplayTests.swift | 36 ++++ .../WorkspaceListConnectionChromeTests.swift | 36 +++- Sources/Mobile/MobileHostBuildIdentity.swift | 12 +- .../MobileHostService+Capabilities.swift | 14 +- ios/cmux/Resources/Localizable.xcstrings | 204 ++++++++++++++++++ 12 files changed, 505 insertions(+), 19 deletions(-) create mode 100644 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+MacUpdateHint.swift create mode 100644 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileMacUpdateFeatureDisplay.swift create mode 100644 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileMacUpdateHintBanner.swift create mode 100644 Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileMacUpdateFeatureDisplayTests.swift diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+MacUpdateHint.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+MacUpdateHint.swift new file mode 100644 index 000000000000..c4d67c6c7a38 --- /dev/null +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+MacUpdateHint.swift @@ -0,0 +1,71 @@ +internal import CMUXMobileCore + +extension MobileShellComposite { + /// Whether the Mac supports workspace group sections and collapse/expand RPCs. + public var supportsWorkspaceGroups: Bool { supportedHostCapabilities.contains(Self.workspaceGroupsCapability) } + /// Whether the Mac supports rename/pin workspace actions. + public var supportsWorkspaceActions: Bool { supportedHostCapabilities.contains(Self.workspaceActionsCapability) } + /// Whether the Mac supports mark read/unread workspace actions. + public var supportsWorkspaceReadStateActions: Bool { supportedHostCapabilities.contains(Self.workspaceReadStateCapability) } + + /// Recomputes the visible Mac-update hint from an authoritative host status snapshot. + /// + /// - Parameters: + /// - capabilities: Capabilities decoded from `mobile.host.status`. + /// - statusMacAppVersion: The version carried by that status response, when available. + /// - macDeviceID: The stable identifier of the host that supplied the status. + func refreshMacUpdateHint( + capabilities: Set, + statusMacAppVersion: String?, + macDeviceID: String? + ) { + let version = statusMacAppVersion ?? activeTicket?.macAppVersion + guard let hint = MobileMacUpdateAdvisor.hint( + hostCapabilities: capabilities, + macAppVersion: version + ) else { + clearMacUpdateHint() + return + } + + let resolvedMacDeviceID = macDeviceID ?? "unknown" + guard !MobileMacUpdateHintDismissalStore().isDismissed( + macDeviceID: resolvedMacDeviceID, + signature: hint.dismissalSignature + ) else { + clearMacUpdateHint() + return + } + + macUpdateHint = hint + macUpdateHintMacDeviceID = resolvedMacDeviceID + guard macUpdateHintShownSignatures.insert(hint.dismissalSignature).inserted else { return } + analytics.capture("ios_mac_update_hint_shown", analyticsProperties(for: hint)) + } + + /// Permanently dismisses the currently visible gap for this Mac and version target. + public func dismissMacUpdateHint() { + guard let hint = macUpdateHint else { return } + let macDeviceID = macUpdateHintMacDeviceID ?? "unknown" + MobileMacUpdateHintDismissalStore().dismiss( + macDeviceID: macDeviceID, + signature: hint.dismissalSignature + ) + clearMacUpdateHint() + analytics.capture("ios_mac_update_hint_dismissed", analyticsProperties(for: hint)) + } + + /// Clears connection-scoped hint state without resetting the session analytics gate. + func clearMacUpdateHint() { + macUpdateHint = nil + macUpdateHintMacDeviceID = nil + } + + private func analyticsProperties(for hint: MobileMacUpdateHint) -> [String: AnalyticsValue] { + [ + "mac_app_version": .string(hint.macAppVersion.description), + "minimum_mac_version": .string(hint.minimumMacVersion.description), + "features": .string(hint.features.map(\.rawValue).joined(separator: ",")), + ] + } +} diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 6d932c0c8387..a89558edcb84 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -88,14 +88,14 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { private static let terminalBytesCapability = "terminal.bytes.v1" static let terminalReplayCapability = "terminal.replay.v1" static let maxTerminalReplayBarrierDroppedOutputBeforeFailOpen: UInt64 = 256 - private static let workspaceActionsCapability = "workspace.actions.v1" - private static let workspaceReadStateCapability = "workspace.read_state.v1" + static let workspaceActionsCapability = "workspace.actions.v1" + static let workspaceReadStateCapability = "workspace.read_state.v1" private static let workspaceCloseCapability = "workspace.close.v1" private static let workspaceMoveCapability = "workspace.move.v1" private static let workspaceGroupActionsCapability = "workspace.group_actions.v1" private static let workspaceCreateInGroupCapability = "workspace.create_in_group.v1", workspaceGroupCreateCapability = "workspace.group_create.v1" private static let dogfoodFeedbackCapability = "dogfood.v1" - private static let workspaceGroupsCapability = "workspace.groups.v1" + static let workspaceGroupsCapability = "workspace.groups.v1" private static let terminalOutputCapabilityTimeoutNanoseconds: UInt64 = 750_000_000 /// How long the render-grid stream may stay silent (no event of any topic) /// before the liveness watchdog suspects the push subscription is dead and @@ -307,12 +307,10 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { /// computed from this set so version-skew checks cannot drift from the raw /// host payload. public private(set) var supportedHostCapabilities: Set = [] - /// Whether the Mac supports workspace group sections and collapse/expand RPCs. - public var supportsWorkspaceGroups: Bool { supportedHostCapabilities.contains(Self.workspaceGroupsCapability) } - /// Whether the Mac supports rename/pin workspace actions. - public var supportsWorkspaceActions: Bool { supportedHostCapabilities.contains(Self.workspaceActionsCapability) } - /// Whether the Mac supports mark read/unread workspace actions. - public var supportsWorkspaceReadStateActions: Bool { supportedHostCapabilities.contains(Self.workspaceReadStateCapability) } + /// A truthful released-Mac-update recommendation for the connected host. + public internal(set) var macUpdateHint: MobileMacUpdateHint? + @ObservationIgnored var macUpdateHintMacDeviceID: String? + @ObservationIgnored var macUpdateHintShownSignatures: Set = [] /// Whether the Mac supports workspace close requests. public var supportsWorkspaceCloseActions: Bool { supportedHostCapabilities.contains(Self.workspaceCloseCapability) } /// Whether the Mac supports workspace move/reorder requests. @@ -621,7 +619,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { private let feedbackStampProvider: @MainActor () -> MobileFeedbackStamp /// The injected, fire-and-forget product-analytics emitter. Defaults to /// ``NoopAnalytics`` so previews/tests inject nothing. - private let analytics: any AnalyticsEmitting + let analytics: any AnalyticsEmitting let connectAttemptRegistry = MobileRPCConnectAttemptRegistry() let stackTokenGate = RPCStackTokenGate() let stackTokenForceRefreshGate = RPCStackTokenGate() @@ -5368,6 +5366,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { terminalScrollbackPrefetchStatesBySurfaceID = [:] terminalOutputTransport = .rawBytes supportedHostCapabilities = [] + clearMacUpdateHint() terminalSubscriptionRefreshTask?.cancel() terminalSubscriptionRefreshTask = nil stopRenderGridLivenessWatchdog(listenerID: nil) @@ -6233,6 +6232,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { // the remount generation only on a real change. applyTerminalTheme(payload.theme) updateForegroundWorkspaceActionCapabilities() + refreshMacUpdateHint(capabilities: Set(payload.capabilities), statusMacAppVersion: payload.macAppVersion, macDeviceID: payload.macDeviceID ?? activeTicket?.macDeviceID) await applyHostReportedIdentity( client: client, deviceID: payload.macDeviceID, diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileMacUpdateFeatureDisplay.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileMacUpdateFeatureDisplay.swift new file mode 100644 index 000000000000..b17e6e9af527 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileMacUpdateFeatureDisplay.swift @@ -0,0 +1,25 @@ +import CmuxMobileShell +import CmuxMobileSupport + +struct MobileMacUpdateFeatureDisplay { + static func name(for feature: MobileMacUpdateFeature) -> String { + switch feature { + case .workspaceActions: + L10n.string("mobile.macUpdateHint.feature.workspaceActions", defaultValue: "Rename and pin workspaces") + case .workspaceReadState: + L10n.string("mobile.macUpdateHint.feature.workspaceReadState", defaultValue: "Mark workspaces read or unread") + case .workspaceClose: + L10n.string("mobile.macUpdateHint.feature.workspaceClose", defaultValue: "Close workspaces") + case .workspaceGroups: + L10n.string("mobile.macUpdateHint.feature.workspaceGroups", defaultValue: "Workspace groups") + case .workspaceMove: + L10n.string("mobile.macUpdateHint.feature.workspaceMove", defaultValue: "Reorder workspaces") + case .workspaceGroupActions: + L10n.string("mobile.macUpdateHint.feature.workspaceGroupActions", defaultValue: "Move and group workspaces") + case .workspaceCreateInGroup: + L10n.string("mobile.macUpdateHint.feature.workspaceCreateInGroup", defaultValue: "Create workspaces inside groups") + case .workspaceGroupCreate: + L10n.string("mobile.macUpdateHint.feature.workspaceGroupCreate", defaultValue: "Create workspace groups") + } + } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileMacUpdateHintBanner.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileMacUpdateHintBanner.swift new file mode 100644 index 000000000000..686831177c76 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileMacUpdateHintBanner.swift @@ -0,0 +1,72 @@ +import CmuxMobileShell +import CmuxMobileSupport +import Foundation +import SwiftUI + +struct MobileMacUpdateHintBanner: View { + let hint: MobileMacUpdateHint + let macDisplayName: String? + let dismiss: () -> Void + + var body: some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "arrow.up.circle.fill") + .font(.body.weight(.semibold)) + .foregroundStyle(.teal) + .frame(width: 24) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 2) { + Text(L10n.string("mobile.macUpdateHint.title", defaultValue: "Mac update adds features")) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.primary) + + Text(Self.bodyText(hint: hint, macName: macDisplayName)) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer(minLength: 8) + + Button(action: dismiss) { + Image(systemName: "xmark") + .font(.caption.weight(.semibold)) + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + .accessibilityLabel(L10n.string( + "mobile.macUpdateHint.dismiss", + defaultValue: "Don't show again for this version" + )) + .accessibilityIdentifier("MobileMacUpdateHintDismissButton") + } + .padding(.vertical, 8) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("MobileMacUpdateHintBanner") + } + + static func bodyText(hint: MobileMacUpdateHint, macName: String?) -> String { + let displayName = macName?.trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedName: String + if let displayName, !displayName.isEmpty { + resolvedName = displayName + } else { + resolvedName = L10n.string("mobile.macUpdateHint.genericMacName", defaultValue: "Your Mac") + } + let featureList = ListFormatter.localizedString( + byJoining: hint.features.map(MobileMacUpdateFeatureDisplay.name(for:)) + ) + let format = L10n.string( + "mobile.macUpdateHint.bodyFormat", + defaultValue: "%1$@ is on cmux %2$@. Updating to %3$@ or later adds: %4$@." + ) + return String( + format: format, + resolvedName, + hint.macAppVersion.description, + hint.minimumMacVersion.description, + featureList + ) + } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListConnectionChrome.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListConnectionChrome.swift index fabacfd3041b..1da784238fe2 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListConnectionChrome.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListConnectionChrome.swift @@ -6,12 +6,13 @@ import CmuxMobileShellModel /// /// Reauth renders the banner first because Sign Out is the only useful action. /// Otherwise a non-connected Mac status renders the status row with host-scoped -/// actions. Store-level recovery only renders the banner while the aggregate -/// list status is still connected. +/// actions. Store-level recovery renders next while the aggregate list status is +/// still connected, followed by the non-disruptive Mac-update hint. enum WorkspaceListConnectionChrome: Equatable { case none case recoveryBanner case macStatusRow + case macUpdateHint /// Chooses exactly one connection surface when store recovery and Mac status /// updates overlap during the same real connection drop. @@ -20,7 +21,8 @@ enum WorkspaceListConnectionChrome: Equatable { connectionRequiresReauth: Bool, connectionRecoveryFailed: Bool, isRecoveringConnection: Bool, - connectionStatus: MobileMacConnectionStatus + connectionStatus: MobileMacConnectionStatus, + hasMacUpdateHint: Bool ) { if hasStore && connectionRequiresReauth { self = .recoveryBanner @@ -28,6 +30,8 @@ enum WorkspaceListConnectionChrome: Equatable { self = .macStatusRow } else if hasStore && (connectionRecoveryFailed || isRecoveringConnection) { self = .recoveryBanner + } else if hasMacUpdateHint { + self = .macUpdateHint } else { self = .none } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift index baa9b2ea2d78..abeaf24ba169 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift @@ -16,6 +16,9 @@ struct WorkspaceListView: View { let selectedWorkspaceID: MobileWorkspacePreview.ID? let host: String let connectionStatus: MobileMacConnectionStatus + var macUpdateHint: MobileMacUpdateHint? = nil + var macUpdateHintMacName: String? = nil + var dismissMacUpdateHint: (() -> Void)? = nil let navigationStyle: WorkspaceNavigationStyle var showsNavigationToolbar = true /// Whether workspace-row titles wrap (multi-line) instead of truncating to a @@ -266,6 +269,18 @@ struct WorkspaceListView: View { .listRowInsets(EdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12)) .listRowSeparator(.hidden) } + case .macUpdateHint: + if let macUpdateHint { + Section { + MobileMacUpdateHintBanner( + hint: macUpdateHint, + macDisplayName: macUpdateHintMacName, + dismiss: dismissMacUpdateHint ?? {} + ) + .listRowInsets(EdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12)) + .listRowSeparator(.hidden) + } + } case .none: EmptyView() } @@ -486,7 +501,8 @@ struct WorkspaceListView: View { connectionRequiresReauth: store?.connectionRequiresReauth ?? false, connectionRecoveryFailed: store?.connectionRecoveryFailed ?? false, isRecoveringConnection: store?.isRecoveringConnection ?? false, - connectionStatus: connectionStatus + connectionStatus: connectionStatus, + hasMacUpdateHint: macUpdateHint != nil ) } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift index 87594abc085b..ca10038dbb18 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceShellView.swift @@ -111,6 +111,9 @@ struct WorkspaceShellView: View { selectedWorkspaceID: store.selectedWorkspaceID, host: store.connectedHostName, connectionStatus: listConnectionStatus, + macUpdateHint: store.macUpdateHint, + macUpdateHintMacName: store.connectedHostName, + dismissMacUpdateHint: { store.dismissMacUpdateHint() }, navigationStyle: .push, showsNavigationToolbar: compactNavigationPath.isEmpty, wrapWorkspaceTitles: displaySettings.wrapWorkspaceTitles, @@ -216,6 +219,9 @@ struct WorkspaceShellView: View { selectedWorkspaceID: store.selectedWorkspaceID, host: store.connectedHostName, connectionStatus: listConnectionStatus, + macUpdateHint: store.macUpdateHint, + macUpdateHintMacName: store.connectedHostName, + dismissMacUpdateHint: { store.dismissMacUpdateHint() }, navigationStyle: .sidebar, wrapWorkspaceTitles: displaySettings.wrapWorkspaceTitles, previewLineLimit: displaySettings.workspacePreviewLineCount, diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileMacUpdateFeatureDisplayTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileMacUpdateFeatureDisplayTests.swift new file mode 100644 index 000000000000..abca866afd38 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileMacUpdateFeatureDisplayTests.swift @@ -0,0 +1,36 @@ +import CmuxMobileShell +import Testing +@testable import CmuxMobileShellUI + +@MainActor +@Suite struct MobileMacUpdateFeatureDisplayTests { + @Test func everyFeatureHasADisplayName() { + for feature in MobileMacUpdateFeature.allCases { + #expect(!MobileMacUpdateFeatureDisplay.name(for: feature).isEmpty) + } + } + + @Test func bodyTextIncludesMacVersionsAndEveryFeature() throws { + let requirements = MobileMacUpdateFeature.allCases.enumerated().map { index, feature in + MobileMacUpdateCapabilityRequirement( + capability: "test.capability.\(index)", + feature: feature, + firstReleasedMacVersion: MobileMacAppVersion(parsing: "0.64.16") + ) + } + let hint = try #require(MobileMacUpdateAdvisor.hint( + hostCapabilities: [], + macAppVersion: "0.64.15", + requirements: requirements + )) + + let body = MobileMacUpdateHintBanner.bodyText(hint: hint, macName: "Studio Mac") + + #expect(body.contains("Studio Mac")) + #expect(body.contains("0.64.15")) + #expect(body.contains("0.64.16")) + for feature in MobileMacUpdateFeature.allCases { + #expect(body.contains(MobileMacUpdateFeatureDisplay.name(for: feature))) + } + } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceListConnectionChromeTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceListConnectionChromeTests.swift index 2036a3d56a99..650621abf6ad 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceListConnectionChromeTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceListConnectionChromeTests.swift @@ -51,6 +51,36 @@ import Testing #expect(chrome(connectionStatus: .connected) == .none) } + @Test func healthyConnectionShowsMacUpdateHint() { + #expect(chrome( + connectionStatus: .connected, + hasMacUpdateHint: true + ) == .macUpdateHint) + } + + @Test func reauthSuppressesMacUpdateHint() { + #expect(chrome( + connectionRequiresReauth: true, + connectionStatus: .connected, + hasMacUpdateHint: true + ) == .recoveryBanner) + } + + @Test func offlineStatusSuppressesMacUpdateHint() { + #expect(chrome( + connectionStatus: .unavailable, + hasMacUpdateHint: true + ) == .macStatusRow) + } + + @Test func recoverySuppressesMacUpdateHint() { + #expect(chrome( + isRecoveringConnection: true, + connectionStatus: .connected, + hasMacUpdateHint: true + ) == .recoveryBanner) + } + @Test func noStoreConnectedStatusShowsNoChromeEvenWithStoreFlags() { #expect(chrome( hasStore: false, @@ -92,14 +122,16 @@ import Testing connectionRequiresReauth: Bool = false, connectionRecoveryFailed: Bool = false, isRecoveringConnection: Bool = false, - connectionStatus: MobileMacConnectionStatus + connectionStatus: MobileMacConnectionStatus, + hasMacUpdateHint: Bool = false ) -> WorkspaceListConnectionChrome { WorkspaceListConnectionChrome( hasStore: hasStore, connectionRequiresReauth: connectionRequiresReauth, connectionRecoveryFailed: connectionRecoveryFailed, isRecoveringConnection: isRecoveringConnection, - connectionStatus: connectionStatus + connectionStatus: connectionStatus, + hasMacUpdateHint: hasMacUpdateHint ) } diff --git a/Sources/Mobile/MobileHostBuildIdentity.swift b/Sources/Mobile/MobileHostBuildIdentity.swift index 71ac19dcac6b..697b848210cd 100644 --- a/Sources/Mobile/MobileHostBuildIdentity.swift +++ b/Sources/Mobile/MobileHostBuildIdentity.swift @@ -5,8 +5,16 @@ struct MobileHostBuildIdentity { let appBuild: String? static func current(bundle: Bundle = .main) -> MobileHostBuildIdentity { - MobileHostBuildIdentity( - appVersion: normalized(bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String), + let bundleAppVersion = normalized(bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String) +#if DEBUG + let appVersion = normalized(ProcessInfo.processInfo.environment["CMUX_DEBUG_MOBILE_APP_VERSION"]) + ?? bundleAppVersion +#else + let appVersion = bundleAppVersion +#endif + + return MobileHostBuildIdentity( + appVersion: appVersion, appBuild: normalized(bundle.object(forInfoDictionaryKey: "CFBundleVersion") as? String) ) } diff --git a/Sources/Mobile/MobileHostService+Capabilities.swift b/Sources/Mobile/MobileHostService+Capabilities.swift index b8148ea2fdb2..6e549d8cf81d 100644 --- a/Sources/Mobile/MobileHostService+Capabilities.swift +++ b/Sources/Mobile/MobileHostService+Capabilities.swift @@ -15,7 +15,7 @@ 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 = [ "events.v1", "notification.badge.v1", "notification.dismiss.v1", @@ -38,5 +38,17 @@ extension MobileHostService { // this to render collapsible groups only against a Mac that emits them. "workspace.groups.v1", ] + #if DEBUG + // Lets a dev Mac impersonate an older host while dogfooding the iOS update hint. + let suppressed = Set( + (ProcessInfo.processInfo.environment["CMUX_DEBUG_SUPPRESS_MOBILE_CAPS"] ?? "") + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + ) + return capabilities.filter { !suppressed.contains($0) } + #else + return capabilities + #endif } } diff --git a/ios/cmux/Resources/Localizable.xcstrings b/ios/cmux/Resources/Localizable.xcstrings index 5ac447f041f3..214682e9c8d3 100644 --- a/ios/cmux/Resources/Localizable.xcstrings +++ b/ios/cmux/Resources/Localizable.xcstrings @@ -3843,6 +3843,210 @@ } } }, + "mobile.macUpdateHint.bodyFormat": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ is on cmux %2$@. Updating to %3$@ or later adds: %4$@." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%1$@はcmux %2$@を実行中です。%3$@以降に更新すると次の機能が使えます:%4$@" + } + } + } + }, + "mobile.macUpdateHint.dismiss": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Don't show again for this version" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このバージョンでは再表示しない" + } + } + } + }, + "mobile.macUpdateHint.feature.workspaceActions": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Rename and pin workspaces" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースの名前変更とピン留め" + } + } + } + }, + "mobile.macUpdateHint.feature.workspaceClose": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Close workspaces" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースを閉じる" + } + } + } + }, + "mobile.macUpdateHint.feature.workspaceCreateInGroup": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Create workspaces inside groups" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "グループ内にワークスペースを作成" + } + } + } + }, + "mobile.macUpdateHint.feature.workspaceGroupActions": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Move and group workspaces" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースの移動とグループ化" + } + } + } + }, + "mobile.macUpdateHint.feature.workspaceGroupCreate": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Create workspace groups" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースグループの作成" + } + } + } + }, + "mobile.macUpdateHint.feature.workspaceGroups": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Workspace groups" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースグループ" + } + } + } + }, + "mobile.macUpdateHint.feature.workspaceMove": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reorder workspaces" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースの並べ替え" + } + } + } + }, + "mobile.macUpdateHint.feature.workspaceReadState": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Mark workspaces read or unread" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースの既読・未読の切り替え" + } + } + } + }, + "mobile.macUpdateHint.genericMacName": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your Mac" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "お使いのMac" + } + } + } + }, + "mobile.macUpdateHint.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Mac update adds features" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Macの更新で機能が追加されます" + } + } + } + }, "mobile.recovery.accountMismatch": { "extractionState": "manual", "localizations": { From 2a302149695f78666c0772fb776bbaa2ed3628f2 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Sun, 12 Jul 2026 17:57:47 -0700 Subject: [PATCH 03/11] Log Mac-update-hint decisions to the iOS debug log The banner's inputs (host capabilities, resolved Mac version) and the computed gap signature are otherwise invisible when diagnosing why the indicator did or did not show; sync.transport already sets the precedent. Co-Authored-By: Claude Fable 5 --- .../MobileShellComposite+MacUpdateHint.swift | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+MacUpdateHint.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+MacUpdateHint.swift index c4d67c6c7a38..4ac95311103e 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+MacUpdateHint.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+MacUpdateHint.swift @@ -1,4 +1,5 @@ internal import CMUXMobileCore +internal import CmuxMobileDiagnostics extension MobileShellComposite { /// Whether the Mac supports workspace group sections and collapse/expand RPCs. @@ -20,10 +21,14 @@ extension MobileShellComposite { macDeviceID: String? ) { let version = statusMacAppVersion ?? activeTicket?.macAppVersion - guard let hint = MobileMacUpdateAdvisor.hint( + let hint = MobileMacUpdateAdvisor.hint( hostCapabilities: capabilities, macAppVersion: version - ) else { + ) + MobileDebugLog.anchormux( + "macupdate.hint caps=\(capabilities.count) version=\(version ?? "nil") hint=\(hint?.dismissalSignature ?? "nil")" + ) + guard let hint else { clearMacUpdateHint() return } From b29224a4ba31a461b75e8863f4e59366cb47e02d Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Sun, 12 Jul 2026 19:50:21 -0700 Subject: [PATCH 04/11] Make the Mac-update hint a subtle toolbar indicator with popover --- .../MacUpdateHintIndicatorButton.swift | 92 +++++++++++++++++++ .../MobileMacUpdateFeatureDisplay.swift | 25 +++++ .../MobileMacUpdateHintBanner.swift | 72 --------------- .../WorkspaceListConnectionChrome.swift | 10 +- .../WorkspaceListView+Toolbar.swift | 7 ++ .../CmuxMobileShellUI/WorkspaceListView.swift | 15 +-- .../MobileMacUpdateFeatureDisplayTests.swift | 2 +- .../WorkspaceListConnectionChromeTests.swift | 36 +------- ios/cmux/Resources/Localizable.xcstrings | 17 ++++ 9 files changed, 148 insertions(+), 128 deletions(-) create mode 100644 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MacUpdateHintIndicatorButton.swift delete mode 100644 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileMacUpdateHintBanner.swift diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MacUpdateHintIndicatorButton.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MacUpdateHintIndicatorButton.swift new file mode 100644 index 000000000000..b9e54919638f --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MacUpdateHintIndicatorButton.swift @@ -0,0 +1,92 @@ +import CmuxMobileShell +import CmuxMobileSupport +import SwiftUI + +struct MacUpdateHintIndicatorButton: View { + let hint: MobileMacUpdateHint + let macDisplayName: String? + let dismiss: () -> Void + @State private var isPresentingExplanation = false + + var body: some View { + Button { + isPresentingExplanation = true + } label: { + Label(buttonAccessibilityLabel, systemImage: "arrow.up.circle.fill") + } + .labelStyle(.iconOnly) + .foregroundStyle(.teal) + .accessibilityLabel(buttonAccessibilityLabel) + .accessibilityIdentifier("MobileMacUpdateHintIndicatorButton") + .popover(isPresented: $isPresentingExplanation) { + ViewThatFits(in: .vertical) { + popoverContent + + ScrollView { + popoverContent + } + .scrollBounceBehavior(.basedOnSize) + } + .frame( + idealWidth: AltScreenNoticePresentationSizing.maxWidth, + maxWidth: AltScreenNoticePresentationSizing.maxWidth + ) + .presentationSizing(AltScreenNoticePresentationSizing()) + .presentationCompactAdaptation(.popover) + } + } + + private var popoverContent: some View { + VStack(alignment: .leading, spacing: 12) { + Label { + Text(title) + .fixedSize(horizontal: false, vertical: true) + } icon: { + Image(systemName: "arrow.up.circle.fill") + } + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.teal) + + Text(MobileMacUpdateFeatureDisplay.bodyText(hint: hint, macName: macDisplayName)) + .font(.footnote) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + Button(action: dismissFromPopover) { + Text(dismissActionTitle) + .fixedSize(horizontal: false, vertical: true) + } + .font(.footnote.weight(.medium)) + .accessibilityIdentifier("MobileMacUpdateHintDismissButton") + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .multilineTextAlignment(.leading) + } + + private var buttonAccessibilityLabel: String { + L10n.string( + "mobile.macUpdateHint.button.accessibilityLabel", + defaultValue: "Show what a Mac update adds" + ) + } + + private var title: String { + L10n.string( + "mobile.macUpdateHint.title", + defaultValue: "Mac update adds features" + ) + } + + private var dismissActionTitle: String { + L10n.string( + "mobile.macUpdateHint.dismiss", + defaultValue: "Don't show again for this version" + ) + } + + private func dismissFromPopover() { + dismiss() + isPresentingExplanation = false + } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileMacUpdateFeatureDisplay.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileMacUpdateFeatureDisplay.swift index b17e6e9af527..473692526e72 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileMacUpdateFeatureDisplay.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileMacUpdateFeatureDisplay.swift @@ -1,5 +1,6 @@ import CmuxMobileShell import CmuxMobileSupport +import Foundation struct MobileMacUpdateFeatureDisplay { static func name(for feature: MobileMacUpdateFeature) -> String { @@ -22,4 +23,28 @@ struct MobileMacUpdateFeatureDisplay { L10n.string("mobile.macUpdateHint.feature.workspaceGroupCreate", defaultValue: "Create workspace groups") } } + + static func bodyText(hint: MobileMacUpdateHint, macName: String?) -> String { + let displayName = macName?.trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedName: String + if let displayName, !displayName.isEmpty { + resolvedName = displayName + } else { + resolvedName = L10n.string("mobile.macUpdateHint.genericMacName", defaultValue: "Your Mac") + } + let featureList = ListFormatter.localizedString( + byJoining: hint.features.map(MobileMacUpdateFeatureDisplay.name(for:)) + ) + let format = L10n.string( + "mobile.macUpdateHint.bodyFormat", + defaultValue: "%1$@ is on cmux %2$@. Updating to %3$@ or later adds: %4$@." + ) + return String( + format: format, + resolvedName, + hint.macAppVersion.description, + hint.minimumMacVersion.description, + featureList + ) + } } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileMacUpdateHintBanner.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileMacUpdateHintBanner.swift deleted file mode 100644 index 686831177c76..000000000000 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileMacUpdateHintBanner.swift +++ /dev/null @@ -1,72 +0,0 @@ -import CmuxMobileShell -import CmuxMobileSupport -import Foundation -import SwiftUI - -struct MobileMacUpdateHintBanner: View { - let hint: MobileMacUpdateHint - let macDisplayName: String? - let dismiss: () -> Void - - var body: some View { - HStack(alignment: .top, spacing: 10) { - Image(systemName: "arrow.up.circle.fill") - .font(.body.weight(.semibold)) - .foregroundStyle(.teal) - .frame(width: 24) - .accessibilityHidden(true) - - VStack(alignment: .leading, spacing: 2) { - Text(L10n.string("mobile.macUpdateHint.title", defaultValue: "Mac update adds features")) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(.primary) - - Text(Self.bodyText(hint: hint, macName: macDisplayName)) - .font(.caption) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - - Spacer(minLength: 8) - - Button(action: dismiss) { - Image(systemName: "xmark") - .font(.caption.weight(.semibold)) - } - .buttonStyle(.plain) - .foregroundStyle(.secondary) - .accessibilityLabel(L10n.string( - "mobile.macUpdateHint.dismiss", - defaultValue: "Don't show again for this version" - )) - .accessibilityIdentifier("MobileMacUpdateHintDismissButton") - } - .padding(.vertical, 8) - .accessibilityElement(children: .contain) - .accessibilityIdentifier("MobileMacUpdateHintBanner") - } - - static func bodyText(hint: MobileMacUpdateHint, macName: String?) -> String { - let displayName = macName?.trimmingCharacters(in: .whitespacesAndNewlines) - let resolvedName: String - if let displayName, !displayName.isEmpty { - resolvedName = displayName - } else { - resolvedName = L10n.string("mobile.macUpdateHint.genericMacName", defaultValue: "Your Mac") - } - let featureList = ListFormatter.localizedString( - byJoining: hint.features.map(MobileMacUpdateFeatureDisplay.name(for:)) - ) - let format = L10n.string( - "mobile.macUpdateHint.bodyFormat", - defaultValue: "%1$@ is on cmux %2$@. Updating to %3$@ or later adds: %4$@." - ) - return String( - format: format, - resolvedName, - hint.macAppVersion.description, - hint.minimumMacVersion.description, - featureList - ) - } -} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListConnectionChrome.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListConnectionChrome.swift index 1da784238fe2..fabacfd3041b 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListConnectionChrome.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListConnectionChrome.swift @@ -6,13 +6,12 @@ import CmuxMobileShellModel /// /// Reauth renders the banner first because Sign Out is the only useful action. /// Otherwise a non-connected Mac status renders the status row with host-scoped -/// actions. Store-level recovery renders next while the aggregate list status is -/// still connected, followed by the non-disruptive Mac-update hint. +/// actions. Store-level recovery only renders the banner while the aggregate +/// list status is still connected. enum WorkspaceListConnectionChrome: Equatable { case none case recoveryBanner case macStatusRow - case macUpdateHint /// Chooses exactly one connection surface when store recovery and Mac status /// updates overlap during the same real connection drop. @@ -21,8 +20,7 @@ enum WorkspaceListConnectionChrome: Equatable { connectionRequiresReauth: Bool, connectionRecoveryFailed: Bool, isRecoveringConnection: Bool, - connectionStatus: MobileMacConnectionStatus, - hasMacUpdateHint: Bool + connectionStatus: MobileMacConnectionStatus ) { if hasStore && connectionRequiresReauth { self = .recoveryBanner @@ -30,8 +28,6 @@ enum WorkspaceListConnectionChrome: Equatable { self = .macStatusRow } else if hasStore && (connectionRecoveryFailed || isRecoveringConnection) { self = .recoveryBanner - } else if hasMacUpdateHint { - self = .macUpdateHint } else { self = .none } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView+Toolbar.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView+Toolbar.swift index b143a2d1b2c0..77e0ea66b06c 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView+Toolbar.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView+Toolbar.swift @@ -23,6 +23,13 @@ extension WorkspaceListView { } } ToolbarItemGroup(placement: .topBarTrailing) { + if let macUpdateHint, let dismissMacUpdateHint { + MacUpdateHintIndicatorButton( + hint: macUpdateHint, + macDisplayName: macUpdateHintMacName, + dismiss: dismissMacUpdateHint + ) + } WorkspaceListFilterMenu(filter: $filter, machines: filterMachines) if canCreateWorkspace { newWorkspaceButton diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift index abeaf24ba169..f5bf52eb5e1d 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift @@ -269,18 +269,6 @@ struct WorkspaceListView: View { .listRowInsets(EdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12)) .listRowSeparator(.hidden) } - case .macUpdateHint: - if let macUpdateHint { - Section { - MobileMacUpdateHintBanner( - hint: macUpdateHint, - macDisplayName: macUpdateHintMacName, - dismiss: dismissMacUpdateHint ?? {} - ) - .listRowInsets(EdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12)) - .listRowSeparator(.hidden) - } - } case .none: EmptyView() } @@ -501,8 +489,7 @@ struct WorkspaceListView: View { connectionRequiresReauth: store?.connectionRequiresReauth ?? false, connectionRecoveryFailed: store?.connectionRecoveryFailed ?? false, isRecoveringConnection: store?.isRecoveringConnection ?? false, - connectionStatus: connectionStatus, - hasMacUpdateHint: macUpdateHint != nil + connectionStatus: connectionStatus ) } diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileMacUpdateFeatureDisplayTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileMacUpdateFeatureDisplayTests.swift index abca866afd38..ad65259f4505 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileMacUpdateFeatureDisplayTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileMacUpdateFeatureDisplayTests.swift @@ -24,7 +24,7 @@ import Testing requirements: requirements )) - let body = MobileMacUpdateHintBanner.bodyText(hint: hint, macName: "Studio Mac") + let body = MobileMacUpdateFeatureDisplay.bodyText(hint: hint, macName: "Studio Mac") #expect(body.contains("Studio Mac")) #expect(body.contains("0.64.15")) diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceListConnectionChromeTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceListConnectionChromeTests.swift index 650621abf6ad..2036a3d56a99 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceListConnectionChromeTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceListConnectionChromeTests.swift @@ -51,36 +51,6 @@ import Testing #expect(chrome(connectionStatus: .connected) == .none) } - @Test func healthyConnectionShowsMacUpdateHint() { - #expect(chrome( - connectionStatus: .connected, - hasMacUpdateHint: true - ) == .macUpdateHint) - } - - @Test func reauthSuppressesMacUpdateHint() { - #expect(chrome( - connectionRequiresReauth: true, - connectionStatus: .connected, - hasMacUpdateHint: true - ) == .recoveryBanner) - } - - @Test func offlineStatusSuppressesMacUpdateHint() { - #expect(chrome( - connectionStatus: .unavailable, - hasMacUpdateHint: true - ) == .macStatusRow) - } - - @Test func recoverySuppressesMacUpdateHint() { - #expect(chrome( - isRecoveringConnection: true, - connectionStatus: .connected, - hasMacUpdateHint: true - ) == .recoveryBanner) - } - @Test func noStoreConnectedStatusShowsNoChromeEvenWithStoreFlags() { #expect(chrome( hasStore: false, @@ -122,16 +92,14 @@ import Testing connectionRequiresReauth: Bool = false, connectionRecoveryFailed: Bool = false, isRecoveringConnection: Bool = false, - connectionStatus: MobileMacConnectionStatus, - hasMacUpdateHint: Bool = false + connectionStatus: MobileMacConnectionStatus ) -> WorkspaceListConnectionChrome { WorkspaceListConnectionChrome( hasStore: hasStore, connectionRequiresReauth: connectionRequiresReauth, connectionRecoveryFailed: connectionRecoveryFailed, isRecoveringConnection: isRecoveringConnection, - connectionStatus: connectionStatus, - hasMacUpdateHint: hasMacUpdateHint + connectionStatus: connectionStatus ) } diff --git a/ios/cmux/Resources/Localizable.xcstrings b/ios/cmux/Resources/Localizable.xcstrings index 214682e9c8d3..0454e80f9298 100644 --- a/ios/cmux/Resources/Localizable.xcstrings +++ b/ios/cmux/Resources/Localizable.xcstrings @@ -3860,6 +3860,23 @@ } } }, + "mobile.macUpdateHint.button.accessibilityLabel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show what a Mac update adds" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Macの更新で追加される機能を表示" + } + } + } + }, "mobile.macUpdateHint.dismiss": { "extractionState": "manual", "localizations": { From f825a8220afeef37df1dc58e1ac875ab2818838d Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Sun, 12 Jul 2026 20:15:41 -0700 Subject: [PATCH 05/11] Tint the Mac-update toolbar glyph teal The workspace-list toolbar renders items with its own monochrome tint, so without an explicit .tint the indicator loses the color that marks it as an update hint rather than a neighboring control. Co-Authored-By: Claude Fable 5 --- .../CmuxMobileShellUI/MacUpdateHintIndicatorButton.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MacUpdateHintIndicatorButton.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MacUpdateHintIndicatorButton.swift index b9e54919638f..b106b5e334ce 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MacUpdateHintIndicatorButton.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MacUpdateHintIndicatorButton.swift @@ -16,6 +16,10 @@ struct MacUpdateHintIndicatorButton: View { } .labelStyle(.iconOnly) .foregroundStyle(.teal) + // The workspace-list toolbar tints items monochrome; an explicit tint + // keeps the teal that marks this as good news, matching the orange the + // alt-screen notice gets in the detail toolbar. + .tint(.teal) .accessibilityLabel(buttonAccessibilityLabel) .accessibilityIdentifier("MobileMacUpdateHintIndicatorButton") .popover(isPresented: $isPresentingExplanation) { From 3644dfd9f2292d6fb22f98710d233e56044359bd Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Sun, 12 Jul 2026 21:21:28 -0700 Subject: [PATCH 06/11] Address PR review: identity-gated dismissal, per-Mac analytics gate, chrome-gated indicator Fail closed when neither the status payload nor the attach ticket carries a Mac device id, so anonymous hosts cannot share a dismissal record. Key the shown-analytics session gate by mac id + signature so two Macs with the same gap each count. Hide the toolbar indicator while reauth/recovery/offline chrome is active (new WorkspaceListConnectionChrome.showsMacUpdateHintIndicator, tested). Soften the dismiss copy to "Don't Show Again" since a changed gap re-arms the hint by design. Co-Authored-By: Claude Fable 5 --- .../MobileShellComposite+MacUpdateHint.swift | 21 +++++++++++++------ .../MacUpdateHintIndicatorButton.swift | 4 +++- .../WorkspaceListConnectionChrome.swift | 6 ++++++ .../WorkspaceListView+Toolbar.swift | 3 ++- .../WorkspaceListConnectionChromeTests.swift | 10 +++++++++ ios/cmux/Resources/Localizable.xcstrings | 4 ++-- 6 files changed, 38 insertions(+), 10 deletions(-) diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+MacUpdateHint.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+MacUpdateHint.swift index 4ac95311103e..c4dc91ddfb17 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+MacUpdateHint.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+MacUpdateHint.swift @@ -21,6 +21,15 @@ extension MobileShellComposite { macDeviceID: String? ) { let version = statusMacAppVersion ?? activeTicket?.macAppVersion + // Fail closed without a stable Mac identity: a shared fallback key + // would let a dismissal on one anonymous Mac suppress the hint on + // another. Identity-free status replies usually lack the version too, + // so this hides nothing that could have been shown truthfully. + guard let macDeviceID, !macDeviceID.isEmpty else { + MobileDebugLog.anchormux("macupdate.hint skipped reason=no_mac_device_id") + clearMacUpdateHint() + return + } let hint = MobileMacUpdateAdvisor.hint( hostCapabilities: capabilities, macAppVersion: version @@ -33,9 +42,8 @@ extension MobileShellComposite { return } - let resolvedMacDeviceID = macDeviceID ?? "unknown" guard !MobileMacUpdateHintDismissalStore().isDismissed( - macDeviceID: resolvedMacDeviceID, + macDeviceID: macDeviceID, signature: hint.dismissalSignature ) else { clearMacUpdateHint() @@ -43,15 +51,16 @@ extension MobileShellComposite { } macUpdateHint = hint - macUpdateHintMacDeviceID = resolvedMacDeviceID - guard macUpdateHintShownSignatures.insert(hint.dismissalSignature).inserted else { return } + macUpdateHintMacDeviceID = macDeviceID + // Keyed per Mac so two hosts sharing one gap signature each emit a + // shown event, while reconnects to the same host stay deduplicated. + guard macUpdateHintShownSignatures.insert("\(macDeviceID)|\(hint.dismissalSignature)").inserted else { return } analytics.capture("ios_mac_update_hint_shown", analyticsProperties(for: hint)) } /// Permanently dismisses the currently visible gap for this Mac and version target. public func dismissMacUpdateHint() { - guard let hint = macUpdateHint else { return } - let macDeviceID = macUpdateHintMacDeviceID ?? "unknown" + guard let hint = macUpdateHint, let macDeviceID = macUpdateHintMacDeviceID else { return } MobileMacUpdateHintDismissalStore().dismiss( macDeviceID: macDeviceID, signature: hint.dismissalSignature diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MacUpdateHintIndicatorButton.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MacUpdateHintIndicatorButton.swift index b106b5e334ce..7edcc488b08a 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MacUpdateHintIndicatorButton.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MacUpdateHintIndicatorButton.swift @@ -85,7 +85,9 @@ struct MacUpdateHintIndicatorButton: View { private var dismissActionTitle: String { L10n.string( "mobile.macUpdateHint.dismiss", - defaultValue: "Don't show again for this version" + // Deliberately unqualified: a changed capability gap re-arms the + // hint by design, so promising "for this version" would be wrong. + defaultValue: "Don't Show Again" ) } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListConnectionChrome.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListConnectionChrome.swift index fabacfd3041b..54da62af930d 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListConnectionChrome.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListConnectionChrome.swift @@ -32,4 +32,10 @@ enum WorkspaceListConnectionChrome: Equatable { self = .none } } + + /// Whether the toolbar shows the Mac-update hint indicator. The hint is a + /// healthy-connection affordance: while reauth, recovery, or offline chrome + /// is on screen, an update suggestion would compete with the recovery + /// actions (and could describe a Mac we are no longer talking to). + var showsMacUpdateHintIndicator: Bool { self == .none } } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView+Toolbar.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView+Toolbar.swift index 77e0ea66b06c..55ea4456d02f 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView+Toolbar.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView+Toolbar.swift @@ -23,7 +23,8 @@ extension WorkspaceListView { } } ToolbarItemGroup(placement: .topBarTrailing) { - if let macUpdateHint, let dismissMacUpdateHint { + if let macUpdateHint, let dismissMacUpdateHint, + connectionChrome.showsMacUpdateHintIndicator { MacUpdateHintIndicatorButton( hint: macUpdateHint, macDisplayName: macUpdateHintMacName, diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceListConnectionChromeTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceListConnectionChromeTests.swift index 2036a3d56a99..173ab1c952e0 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceListConnectionChromeTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/WorkspaceListConnectionChromeTests.swift @@ -87,6 +87,16 @@ import Testing #expect(view.connectionChrome == .macStatusRow) } + @Test + func macUpdateHintIndicatorShowsOnlyWithoutConnectionChrome() { + #expect(chrome(connectionStatus: .connected).showsMacUpdateHintIndicator) + #expect(!chrome(connectionRequiresReauth: true, connectionStatus: .connected).showsMacUpdateHintIndicator) + #expect(!chrome(isRecoveringConnection: true, connectionStatus: .connected).showsMacUpdateHintIndicator) + #expect(!chrome(connectionRecoveryFailed: true, connectionStatus: .connected).showsMacUpdateHintIndicator) + #expect(!chrome(connectionStatus: .unavailable).showsMacUpdateHintIndicator) + #expect(!chrome(connectionStatus: .reconnecting).showsMacUpdateHintIndicator) + } + private func chrome( hasStore: Bool = true, connectionRequiresReauth: Bool = false, diff --git a/ios/cmux/Resources/Localizable.xcstrings b/ios/cmux/Resources/Localizable.xcstrings index 634a95a5f7ce..a1034eb449fa 100644 --- a/ios/cmux/Resources/Localizable.xcstrings +++ b/ios/cmux/Resources/Localizable.xcstrings @@ -3883,13 +3883,13 @@ "en": { "stringUnit": { "state": "translated", - "value": "Don't show again for this version" + "value": "Don't Show Again" } }, "ja": { "stringUnit": { "state": "translated", - "value": "このバージョンでは再表示しない" + "value": "再表示しない" } } } From f52fcc8da0c0f61a5753f313fff0c0075584b53a Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Sun, 12 Jul 2026 21:46:54 -0700 Subject: [PATCH 07/11] Infer legacy Mac versions from capabilities; refresh hint on status recovery Structured review caught that the registry's target releases predate the version fields themselves: mobile.host.status gained mac_app_version in 0.64.16 and attach tickets gained macAppVersion in 0.64.17, so a released 0.64.15 Mac reports no version anywhere and the production hint could never fire. When no explicit version exists, the advisor now infers the version as the newest firstReleasedMacVersion among registry capabilities the host DOES advertise (a released Mac advertising a 0.64.15 capability is at least 0.64.15); hosts advertising no registered capability stay silent, and an unparseable explicit version still suppresses inference. Inferred versions use a body copy that names only the target version, never asserting the Mac's current version. Also refresh the hint from the full-timeout status recovery path (scheduleHostIdentityAdoptionIfNeeded), which decodes a complete status payload but previously applied only theme and identity, leaving the hint absent or stale after a slow transport probe. Co-Authored-By: Claude Fable 5 --- .../MobileMacUpdateAdvisor.swift | 48 +++++++++++++-- .../MobileShellComposite.swift | 9 +++ .../MobileMacUpdateAdvisorTests.swift | 59 +++++++++++++++++++ .../MobileMacUpdateFeatureDisplay.swift | 15 +++++ .../MobileMacUpdateFeatureDisplayTests.swift | 28 +++++++++ ios/cmux/Resources/Localizable.xcstrings | 17 ++++++ 6 files changed, 170 insertions(+), 6 deletions(-) diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacUpdateAdvisor.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacUpdateAdvisor.swift index 6d2874dbd67f..ea4a27a0ae03 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacUpdateAdvisor.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacUpdateAdvisor.swift @@ -6,9 +6,17 @@ public struct MobileMacUpdateHint: Equatable, Sendable { /// The minimum Mac marketing version that unlocks every listed feature. public let minimumMacVersion: MobileMacAppVersion - /// The connected Mac's current marketing version. + /// The connected Mac's current marketing version, explicit or inferred. public let macAppVersion: MobileMacAppVersion + /// Whether ``macAppVersion`` was inferred from the advertised capability set + /// rather than reported by the Mac. Released Macs older than 0.64.16 predate + /// the `mac_app_version` status field (and pre-0.64.17 attach tickets carry + /// no version either), so the versions this registry targets can only be + /// established by inference. UI copy must not state an inferred version as + /// the Mac's current version. + public let isVersionInferred: Bool + /// The stable missing-capability identifiers used to build the dismissal signature. private let missingCapabilities: [String] @@ -17,17 +25,20 @@ public struct MobileMacUpdateHint: Equatable, Sendable { /// - Parameters: /// - features: The unique features to present, in registry order. /// - minimumMacVersion: The minimum Mac version that unlocks the features. - /// - macAppVersion: The connected Mac's current version. + /// - macAppVersion: The connected Mac's current version, explicit or inferred. + /// - isVersionInferred: Whether the version was derived from advertised capabilities. /// - missingCapabilities: The contributing stable capability identifiers. init( features: [MobileMacUpdateFeature], minimumMacVersion: MobileMacAppVersion, macAppVersion: MobileMacAppVersion, + isVersionInferred: Bool, missingCapabilities: [String] ) { self.features = features self.minimumMacVersion = minimumMacVersion self.macAppVersion = macAppVersion + self.isVersionInferred = isVersionInferred self.missingCapabilities = missingCapabilities } @@ -42,9 +53,19 @@ public struct MobileMacUpdateHint: Equatable, Sendable { public enum MobileMacUpdateAdvisor { /// Builds a truthful update hint for missing capabilities available in a newer released Mac version. /// + /// The Mac's version comes from `mobile.host.status` or the attach ticket + /// when present. Both fields postdate the releases this registry targets + /// (status gained `mac_app_version` in 0.64.16, tickets in 0.64.17), so + /// when neither is available the version is inferred as the newest + /// `firstReleasedMacVersion` among registry capabilities the host DOES + /// advertise: a released Mac advertising a capability that first shipped + /// in X is at least X, and a released Mac at X missing a capability that + /// shipped in Y > X is older than Y. A host advertising no registered + /// capability yields no inference and no hint. + /// /// - Parameters: /// - hostCapabilities: Capabilities from a successfully decoded `mobile.host.status` response. - /// - versionString: The connected Mac's reported marketing version. + /// - versionString: The connected Mac's reported marketing version, when available. /// - requirements: The capability release registry known to the iOS build. /// - Returns: A hint when at least one missing capability shipped after the Mac version, otherwise `nil`. public static func hint( @@ -52,9 +73,23 @@ public enum MobileMacUpdateAdvisor { macAppVersion versionString: String?, requirements: [MobileMacUpdateCapabilityRequirement] = MobileMacUpdateCapabilityRequirement.standard ) -> MobileMacUpdateHint? { - guard let versionString, - let macAppVersion = MobileMacAppVersion(parsing: versionString) - else { + let explicitVersion = versionString.flatMap { MobileMacAppVersion(parsing: $0) } + // An unparseable non-empty version (nightly/prerelease marker) stays + // conservative: it is an explicit report we cannot compare, so no + // inference and no hint, rather than second-guessing a custom build. + if let versionString, !versionString.isEmpty, explicitVersion == nil { + return nil + } + let inferredVersion = explicitVersion == nil + ? requirements + .filter { requirement in + requirement.firstReleasedMacVersion != nil + && hostCapabilities.contains(requirement.capability) + } + .compactMap(\.firstReleasedMacVersion) + .max() + : nil + guard let macAppVersion = explicitVersion ?? inferredVersion else { return nil } @@ -76,6 +111,7 @@ public enum MobileMacUpdateAdvisor { features: features, minimumMacVersion: minimumMacVersion, macAppVersion: macAppVersion, + isVersionInferred: explicitVersion == nil, missingCapabilities: contributors.map(\.capability) ) } diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index f56c83aad191..377533d204f1 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -2610,6 +2610,15 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { // free in the common case and keeps the phone's colors in sync with // the Mac even when the probe could not. self.applyTerminalTheme(payload.theme) + // Same recovery reasoning for the update hint: when the fast probe + // timed out, this is the only path that sees a full status payload, + // and skipping it would leave the hint absent (or a previous Mac's + // hint stale) until the next reconnect. + self.refreshMacUpdateHint( + capabilities: Set(payload.capabilities), + statusMacAppVersion: payload.macAppVersion, + macDeviceID: payload.macDeviceID ?? self.activeTicket?.macDeviceID + ) await self.applyHostReportedIdentity( client: client, deviceID: payload.macDeviceID, diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacUpdateAdvisorTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacUpdateAdvisorTests.swift index ef222ee279fa..aa7f94a8f541 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacUpdateAdvisorTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacUpdateAdvisorTests.swift @@ -41,6 +41,65 @@ struct MobileMacUpdateAdvisorTests { ) == nil) } + @Test + func absentVersionInfersFromAdvertisedRegisteredCapabilities() throws { + let hint = try #require(MobileMacUpdateAdvisor.hint( + hostCapabilities: ["released.early"], + macAppVersion: nil, + requirements: requirements + )) + + #expect(hint.features == [.workspaceGroups]) + #expect(hint.minimumMacVersion == MobileMacAppVersion(parsing: "3.0")) + #expect(hint.macAppVersion == MobileMacAppVersion(parsing: "2.0")) + #expect(hint.isVersionInferred) + } + + @Test + func absentVersionWithoutRegisteredCapabilitiesStaysSilent() { + #expect(MobileMacUpdateAdvisor.hint( + hostCapabilities: ["unreleased", "unregistered.cap"], + macAppVersion: nil, + requirements: requirements + ) == nil) + } + + @Test + func explicitVersionIsNotInferredAndWinsOverCapabilityInference() throws { + let hint = try #require(MobileMacUpdateAdvisor.hint( + hostCapabilities: ["released.early"], + macAppVersion: "2.5", + requirements: requirements + )) + + #expect(hint.macAppVersion == MobileMacAppVersion(parsing: "2.5")) + #expect(!hint.isVersionInferred) + } + + @Test + func unparseableVersionSuppressesCapabilityInference() { + #expect(MobileMacUpdateAdvisor.hint( + hostCapabilities: ["released.early"], + macAppVersion: "2.0-nightly", + requirements: requirements + ) == nil) + } + + @Test + func standardRegistryInfersLegacyMacFromActionsCapability() throws { + // The production case the registry targets: a released 0.64.15 Mac + // predates both version fields, so it reports no version at all but + // does advertise workspace.actions.v1. + let hint = try #require(MobileMacUpdateAdvisor.hint( + hostCapabilities: ["workspace.actions.v1", "terminal.bytes.v1"], + macAppVersion: nil + )) + + #expect(hint.features == [.workspaceReadState, .workspaceClose, .workspaceGroups]) + #expect(hint.minimumMacVersion == MobileMacAppVersion(parsing: "0.64.16")) + #expect(hint.isVersionInferred) + } + @Test func missingUnreleasedCapabilityDoesNotProduceHint() { #expect(MobileMacUpdateAdvisor.hint( diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileMacUpdateFeatureDisplay.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileMacUpdateFeatureDisplay.swift index 473692526e72..1a7a1e63f0d7 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileMacUpdateFeatureDisplay.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileMacUpdateFeatureDisplay.swift @@ -35,6 +35,21 @@ struct MobileMacUpdateFeatureDisplay { let featureList = ListFormatter.localizedString( byJoining: hint.features.map(MobileMacUpdateFeatureDisplay.name(for:)) ) + // An inferred version proves the Mac is old enough to lack the + // features, but stating it as the Mac's current version could be + // wrong, so that variant only names the target version. + if hint.isVersionInferred { + let format = L10n.string( + "mobile.macUpdateHint.bodyFormatUnknownVersion", + defaultValue: "Updating %1$@ to cmux %2$@ or later adds: %3$@." + ) + return String( + format: format, + resolvedName, + hint.minimumMacVersion.description, + featureList + ) + } let format = L10n.string( "mobile.macUpdateHint.bodyFormat", defaultValue: "%1$@ is on cmux %2$@. Updating to %3$@ or later adds: %4$@." diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileMacUpdateFeatureDisplayTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileMacUpdateFeatureDisplayTests.swift index ad65259f4505..9dc683bb4dc9 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileMacUpdateFeatureDisplayTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileMacUpdateFeatureDisplayTests.swift @@ -33,4 +33,32 @@ import Testing #expect(body.contains(MobileMacUpdateFeatureDisplay.name(for: feature))) } } + + @Test func bodyTextForInferredVersionNamesOnlyTheTargetVersion() throws { + let requirements = [ + MobileMacUpdateCapabilityRequirement( + capability: "test.present", + feature: .workspaceActions, + firstReleasedMacVersion: MobileMacAppVersion(parsing: "0.64.15") + ), + MobileMacUpdateCapabilityRequirement( + capability: "test.missing", + feature: .workspaceGroups, + firstReleasedMacVersion: MobileMacAppVersion(parsing: "0.64.16") + ), + ] + let hint = try #require(MobileMacUpdateAdvisor.hint( + hostCapabilities: ["test.present"], + macAppVersion: nil, + requirements: requirements + )) + #expect(hint.isVersionInferred) + + let body = MobileMacUpdateFeatureDisplay.bodyText(hint: hint, macName: "Studio Mac") + + #expect(body.contains("Studio Mac")) + #expect(body.contains("0.64.16")) + // The inferred current version must not be presented as fact. + #expect(!body.contains("0.64.15")) + } } diff --git a/ios/cmux/Resources/Localizable.xcstrings b/ios/cmux/Resources/Localizable.xcstrings index a1034eb449fa..f43d08da88b6 100644 --- a/ios/cmux/Resources/Localizable.xcstrings +++ b/ios/cmux/Resources/Localizable.xcstrings @@ -3860,6 +3860,23 @@ } } }, + "mobile.macUpdateHint.bodyFormatUnknownVersion" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Updating %1$@ to cmux %2$@ or later adds: %3$@." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@をcmux %2$@以降に更新すると次の機能が使えます:%3$@。" + } + } + } + }, "mobile.macUpdateHint.button.accessibilityLabel": { "extractionState": "manual", "localizations": { From e4d64c012a55584ab1b19bee663ebaa8d7773957 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Sun, 12 Jul 2026 22:05:41 -0700 Subject: [PATCH 08/11] Address review round 2: honest analytics, budget-neutral composite Rename the shown event to ios_mac_update_hint_eligible (it fires when the model computes a visible hint, not when the toolbar indicator renders), tag both events with mac_app_version_inferred so inferred lower bounds cannot pollute version-segmented metrics, and move the recovery-path refresh into the MacUpdateHint extension so the over-budget composite stays at its recorded length. Co-Authored-By: Claude Fable 5 --- .../MobileShellComposite+MacUpdateHint.swift | 24 ++++++++++++++++--- .../MobileShellComposite.swift | 13 ++-------- 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+MacUpdateHint.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+MacUpdateHint.swift index c4dc91ddfb17..6d242ef5d06c 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+MacUpdateHint.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+MacUpdateHint.swift @@ -1,5 +1,6 @@ internal import CMUXMobileCore internal import CmuxMobileDiagnostics +internal import CmuxMobileRPC extension MobileShellComposite { /// Whether the Mac supports workspace group sections and collapse/expand RPCs. @@ -52,10 +53,25 @@ extension MobileShellComposite { macUpdateHint = hint macUpdateHintMacDeviceID = macDeviceID - // Keyed per Mac so two hosts sharing one gap signature each emit a - // shown event, while reconnects to the same host stay deduplicated. + // Keyed per Mac so two hosts sharing one gap signature each emit an + // event, while reconnects to the same host stay deduplicated. Named + // "eligible" deliberately: this fires when the model computes a + // visible hint, not when the toolbar indicator actually renders + // (chrome, navigation state, or backgrounding can defer that). guard macUpdateHintShownSignatures.insert("\(macDeviceID)|\(hint.dismissalSignature)").inserted else { return } - analytics.capture("ios_mac_update_hint_shown", analyticsProperties(for: hint)) + analytics.capture("ios_mac_update_hint_eligible", analyticsProperties(for: hint)) + } + + /// Recovery-path refresh: when the fast transport probe times out, + /// `scheduleHostIdentityAdoptionIfNeeded` is the only path that sees a + /// full status payload, and skipping the hint there would leave it absent + /// (or a previous Mac's hint stale) until the next reconnect. + func refreshMacUpdateHintFromRecoveredStatus(_ payload: MobileHostStatusResponse) { + refreshMacUpdateHint( + capabilities: Set(payload.capabilities), + statusMacAppVersion: payload.macAppVersion, + macDeviceID: payload.macDeviceID ?? activeTicket?.macDeviceID + ) } /// Permanently dismisses the currently visible gap for this Mac and version target. @@ -78,6 +94,8 @@ extension MobileShellComposite { private func analyticsProperties(for hint: MobileMacUpdateHint) -> [String: AnalyticsValue] { [ "mac_app_version": .string(hint.macAppVersion.description), + // Inferred lower bounds must not pollute version-segmented metrics. + "mac_app_version_inferred": .bool(hint.isVersionInferred), "minimum_mac_version": .string(hint.minimumMacVersion.description), "features": .string(hint.features.map(\.rawValue).joined(separator: ",")), ] diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 377533d204f1..41ee6adf6d04 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -312,8 +312,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { public internal(set) var supportedHostCapabilities: Set = [] /// A truthful released-Mac-update recommendation for the connected host. public internal(set) var macUpdateHint: MobileMacUpdateHint? - @ObservationIgnored var macUpdateHintMacDeviceID: String? - @ObservationIgnored var macUpdateHintShownSignatures: Set = [] + @ObservationIgnored var macUpdateHintMacDeviceID: String?, macUpdateHintShownSignatures: Set = [] /// Whether the Mac supports workspace close requests. public var supportsWorkspaceCloseActions: Bool { supportedHostCapabilities.contains(Self.workspaceCloseCapability) } /// Whether the Mac supports workspace move/reorder requests. @@ -2610,15 +2609,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { // free in the common case and keeps the phone's colors in sync with // the Mac even when the probe could not. self.applyTerminalTheme(payload.theme) - // Same recovery reasoning for the update hint: when the fast probe - // timed out, this is the only path that sees a full status payload, - // and skipping it would leave the hint absent (or a previous Mac's - // hint stale) until the next reconnect. - self.refreshMacUpdateHint( - capabilities: Set(payload.capabilities), - statusMacAppVersion: payload.macAppVersion, - macDeviceID: payload.macDeviceID ?? self.activeTicket?.macDeviceID - ) + self.refreshMacUpdateHintFromRecoveredStatus(payload) await self.applyHostReportedIdentity( client: client, deviceID: payload.macDeviceID, From 19e6ae9343d634227742b63e166526b6e8c366c6 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Sun, 12 Jul 2026 22:08:51 -0700 Subject: [PATCH 09/11] Group Mac-update hint session state behind one stored property @ObservationIgnored cannot annotate a multi-variable declaration (the previous commit failed to compile), so the per-session bookkeeping moves into a MacUpdateHintSessionState reference type owned by the extension, which also keeps the over-budget composite at its recorded length. Co-Authored-By: Claude Fable 5 --- .../MobileShellComposite+MacUpdateHint.swift | 18 ++++++++++++++---- .../CmuxMobileShell/MobileShellComposite.swift | 2 +- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+MacUpdateHint.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+MacUpdateHint.swift index 6d242ef5d06c..27ed0617186f 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+MacUpdateHint.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+MacUpdateHint.swift @@ -2,6 +2,16 @@ internal import CMUXMobileCore internal import CmuxMobileDiagnostics internal import CmuxMobileRPC +/// Session-scoped bookkeeping for the Mac-update hint: which Mac the visible +/// hint belongs to, and which (mac, gap) pairs already emitted the eligible +/// analytics event this session. A reference type so the composite can hold it +/// behind one `@ObservationIgnored` stored property (views observe only +/// `macUpdateHint` itself). +final class MacUpdateHintSessionState { + var macDeviceID: String? + var shownSignatures: Set = [] +} + extension MobileShellComposite { /// Whether the Mac supports workspace group sections and collapse/expand RPCs. public var supportsWorkspaceGroups: Bool { supportedHostCapabilities.contains(Self.workspaceGroupsCapability) } @@ -52,13 +62,13 @@ extension MobileShellComposite { } macUpdateHint = hint - macUpdateHintMacDeviceID = macDeviceID + macUpdateHintSessionState.macDeviceID = macDeviceID // Keyed per Mac so two hosts sharing one gap signature each emit an // event, while reconnects to the same host stay deduplicated. Named // "eligible" deliberately: this fires when the model computes a // visible hint, not when the toolbar indicator actually renders // (chrome, navigation state, or backgrounding can defer that). - guard macUpdateHintShownSignatures.insert("\(macDeviceID)|\(hint.dismissalSignature)").inserted else { return } + guard macUpdateHintSessionState.shownSignatures.insert("\(macDeviceID)|\(hint.dismissalSignature)").inserted else { return } analytics.capture("ios_mac_update_hint_eligible", analyticsProperties(for: hint)) } @@ -76,7 +86,7 @@ extension MobileShellComposite { /// Permanently dismisses the currently visible gap for this Mac and version target. public func dismissMacUpdateHint() { - guard let hint = macUpdateHint, let macDeviceID = macUpdateHintMacDeviceID else { return } + guard let hint = macUpdateHint, let macDeviceID = macUpdateHintSessionState.macDeviceID else { return } MobileMacUpdateHintDismissalStore().dismiss( macDeviceID: macDeviceID, signature: hint.dismissalSignature @@ -88,7 +98,7 @@ extension MobileShellComposite { /// Clears connection-scoped hint state without resetting the session analytics gate. func clearMacUpdateHint() { macUpdateHint = nil - macUpdateHintMacDeviceID = nil + macUpdateHintSessionState.macDeviceID = nil } private func analyticsProperties(for hint: MobileMacUpdateHint) -> [String: AnalyticsValue] { diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift index 41ee6adf6d04..991b86c5b35b 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite.swift @@ -312,7 +312,7 @@ public final class MobileShellComposite: MobileTerminalOutputSinking { public internal(set) var supportedHostCapabilities: Set = [] /// A truthful released-Mac-update recommendation for the connected host. public internal(set) var macUpdateHint: MobileMacUpdateHint? - @ObservationIgnored var macUpdateHintMacDeviceID: String?, macUpdateHintShownSignatures: Set = [] + @ObservationIgnored var macUpdateHintSessionState = MacUpdateHintSessionState() /// Whether the Mac supports workspace close requests. public var supportsWorkspaceCloseActions: Bool { supportedHostCapabilities.contains(Self.workspaceCloseCapability) } /// Whether the Mac supports workspace move/reorder requests. From 542e5ffd1d158df0ff3f02097d72164dcb1041ba Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Sun, 12 Jul 2026 22:25:01 -0700 Subject: [PATCH 10/11] Apply Aziz policy pass to the Mac-update hint types Split MobileMacUpdateHint into its own file and fold the caseless MobileMacUpdateAdvisor namespace enum into a failable initializer on the owning type. Convert the MobileMacUpdateFeatureDisplay static namespace into displayName/bodyText extensions on the owning types. Consciously kept: the immutable .standard registry constant (declaration data with an explicit lint allowance, not runtime state) and the dismissal store's private static helpers (per the no-free-functions ruling; the store itself carries injected UserDefaults state). Co-Authored-By: Claude Fable 5 --- ...dvisor.swift => MobileMacUpdateHint.swift} | 33 +++++++--------- .../MobileShellComposite+MacUpdateHint.swift | 2 +- ...s.swift => MobileMacUpdateHintTests.swift} | 38 +++++++++---------- .../MacUpdateHintIndicatorButton.swift | 2 +- ...wift => MobileMacUpdateHint+Display.swift} | 24 +++++++----- ... => MobileMacUpdateHintDisplayTests.swift} | 14 +++---- 6 files changed, 57 insertions(+), 56 deletions(-) rename Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/{MobileMacUpdateAdvisor.swift => MobileMacUpdateHint.swift} (81%) rename Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/{MobileMacUpdateAdvisorTests.swift => MobileMacUpdateHintTests.swift} (86%) rename Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/{MobileMacUpdateFeatureDisplay.swift => MobileMacUpdateHint+Display.swift} (80%) rename Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/{MobileMacUpdateFeatureDisplayTests.swift => MobileMacUpdateHintDisplayTests.swift} (78%) diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacUpdateAdvisor.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacUpdateHint.swift similarity index 81% rename from Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacUpdateAdvisor.swift rename to Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacUpdateHint.swift index ea4a27a0ae03..054eb79d5529 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacUpdateAdvisor.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacUpdateHint.swift @@ -46,33 +46,29 @@ public struct MobileMacUpdateHint: Equatable, Sendable { public var dismissalSignature: String { "\(Set(missingCapabilities).sorted().joined(separator: ","))>=\(minimumMacVersion)" } -} -/// Decides whether a connected Mac has a released update that unlocks known mobile features. -// lint:allow namespace-enum, namespace-type — the Part A specification requires this stateless static API shape. -public enum MobileMacUpdateAdvisor { /// Builds a truthful update hint for missing capabilities available in a newer released Mac version. /// /// The Mac's version comes from `mobile.host.status` or the attach ticket - /// when present. Both fields postdate the releases this registry targets - /// (status gained `mac_app_version` in 0.64.16, tickets in 0.64.17), so - /// when neither is available the version is inferred as the newest - /// `firstReleasedMacVersion` among registry capabilities the host DOES - /// advertise: a released Mac advertising a capability that first shipped - /// in X is at least X, and a released Mac at X missing a capability that - /// shipped in Y > X is older than Y. A host advertising no registered - /// capability yields no inference and no hint. + /// when present. Both fields postdate the releases the standard registry + /// targets (status gained `mac_app_version` in 0.64.16, tickets in + /// 0.64.17), so when neither is available the version is inferred as the + /// newest `firstReleasedMacVersion` among registry capabilities the host + /// DOES advertise: a released Mac advertising a capability that first + /// shipped in X is at least X, and a released Mac at X missing a + /// capability that shipped in Y > X is older than Y. A host advertising + /// no registered capability yields no inference and no hint. /// /// - Parameters: /// - hostCapabilities: Capabilities from a successfully decoded `mobile.host.status` response. /// - versionString: The connected Mac's reported marketing version, when available. /// - requirements: The capability release registry known to the iOS build. /// - Returns: A hint when at least one missing capability shipped after the Mac version, otherwise `nil`. - public static func hint( + public init?( hostCapabilities: Set, macAppVersion versionString: String?, requirements: [MobileMacUpdateCapabilityRequirement] = MobileMacUpdateCapabilityRequirement.standard - ) -> MobileMacUpdateHint? { + ) { let explicitVersion = versionString.flatMap { MobileMacAppVersion(parsing: $0) } // An unparseable non-empty version (nightly/prerelease marker) stays // conservative: it is an explicit report we cannot compare, so no @@ -104,11 +100,10 @@ public enum MobileMacUpdateAdvisor { } var seenFeatures: Set = [] - let features = contributors.compactMap { requirement in - seenFeatures.insert(requirement.feature).inserted ? requirement.feature : nil - } - return MobileMacUpdateHint( - features: features, + self.init( + features: contributors.compactMap { requirement in + seenFeatures.insert(requirement.feature).inserted ? requirement.feature : nil + }, minimumMacVersion: minimumMacVersion, macAppVersion: macAppVersion, isVersionInferred: explicitVersion == nil, diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+MacUpdateHint.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+MacUpdateHint.swift index 27ed0617186f..3dccbe9f95c8 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+MacUpdateHint.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+MacUpdateHint.swift @@ -41,7 +41,7 @@ extension MobileShellComposite { clearMacUpdateHint() return } - let hint = MobileMacUpdateAdvisor.hint( + let hint = MobileMacUpdateHint( hostCapabilities: capabilities, macAppVersion: version ) diff --git a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacUpdateAdvisorTests.swift b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacUpdateHintTests.swift similarity index 86% rename from Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacUpdateAdvisorTests.swift rename to Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacUpdateHintTests.swift index aa7f94a8f541..89e7cb9405df 100644 --- a/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacUpdateAdvisorTests.swift +++ b/Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileMacUpdateHintTests.swift @@ -2,7 +2,7 @@ import CmuxMobileShell import Testing @Suite -struct MobileMacUpdateAdvisorTests { +struct MobileMacUpdateHintTests { private let requirements: [MobileMacUpdateCapabilityRequirement] = [ .init(capability: "released.early", feature: .workspaceActions, firstReleasedMacVersion: .init(parsing: "2.0")), .init(capability: "released.late", feature: .workspaceGroups, firstReleasedMacVersion: .init(parsing: "3.0")), @@ -11,7 +11,7 @@ struct MobileMacUpdateAdvisorTests { @Test func olderVersionAndMissingReleasedCapabilityProducesHint() throws { - let hint = try #require(MobileMacUpdateAdvisor.hint( + let hint = try #require(MobileMacUpdateHint( hostCapabilities: ["released.late", "unreleased"], macAppVersion: "1.5", requirements: requirements @@ -24,7 +24,7 @@ struct MobileMacUpdateAdvisorTests { @Test(arguments: ["2.0", "2.1"]) func currentOrNewerVersionDoesNotProduceHint(version: String) { - let hint = MobileMacUpdateAdvisor.hint( + let hint = MobileMacUpdateHint( hostCapabilities: ["released.late", "unreleased"], macAppVersion: version, requirements: requirements @@ -34,7 +34,7 @@ struct MobileMacUpdateAdvisorTests { @Test(arguments: [nil, "unknown", "2.0-nightly"] as [String?]) func absentOrUnparseableVersionDoesNotProduceHint(version: String?) { - #expect(MobileMacUpdateAdvisor.hint( + #expect(MobileMacUpdateHint( hostCapabilities: [], macAppVersion: version, requirements: requirements @@ -43,7 +43,7 @@ struct MobileMacUpdateAdvisorTests { @Test func absentVersionInfersFromAdvertisedRegisteredCapabilities() throws { - let hint = try #require(MobileMacUpdateAdvisor.hint( + let hint = try #require(MobileMacUpdateHint( hostCapabilities: ["released.early"], macAppVersion: nil, requirements: requirements @@ -57,7 +57,7 @@ struct MobileMacUpdateAdvisorTests { @Test func absentVersionWithoutRegisteredCapabilitiesStaysSilent() { - #expect(MobileMacUpdateAdvisor.hint( + #expect(MobileMacUpdateHint( hostCapabilities: ["unreleased", "unregistered.cap"], macAppVersion: nil, requirements: requirements @@ -66,7 +66,7 @@ struct MobileMacUpdateAdvisorTests { @Test func explicitVersionIsNotInferredAndWinsOverCapabilityInference() throws { - let hint = try #require(MobileMacUpdateAdvisor.hint( + let hint = try #require(MobileMacUpdateHint( hostCapabilities: ["released.early"], macAppVersion: "2.5", requirements: requirements @@ -78,7 +78,7 @@ struct MobileMacUpdateAdvisorTests { @Test func unparseableVersionSuppressesCapabilityInference() { - #expect(MobileMacUpdateAdvisor.hint( + #expect(MobileMacUpdateHint( hostCapabilities: ["released.early"], macAppVersion: "2.0-nightly", requirements: requirements @@ -90,7 +90,7 @@ struct MobileMacUpdateAdvisorTests { // The production case the registry targets: a released 0.64.15 Mac // predates both version fields, so it reports no version at all but // does advertise workspace.actions.v1. - let hint = try #require(MobileMacUpdateAdvisor.hint( + let hint = try #require(MobileMacUpdateHint( hostCapabilities: ["workspace.actions.v1", "terminal.bytes.v1"], macAppVersion: nil )) @@ -102,7 +102,7 @@ struct MobileMacUpdateAdvisorTests { @Test func missingUnreleasedCapabilityDoesNotProduceHint() { - #expect(MobileMacUpdateAdvisor.hint( + #expect(MobileMacUpdateHint( hostCapabilities: ["released.early", "released.late"], macAppVersion: "1.0", requirements: requirements @@ -111,7 +111,7 @@ struct MobileMacUpdateAdvisorTests { @Test func mixedReleasedAndUnreleasedGapsIncludesOnlyReleasedFeaturesAtMaximumVersion() throws { - let hint = try #require(MobileMacUpdateAdvisor.hint( + let hint = try #require(MobileMacUpdateHint( hostCapabilities: [], macAppVersion: "1.0", requirements: requirements @@ -123,7 +123,7 @@ struct MobileMacUpdateAdvisorTests { @Test func allCapabilitiesPresentDoesNotProduceHint() { - #expect(MobileMacUpdateAdvisor.hint( + #expect(MobileMacUpdateHint( hostCapabilities: ["released.early", "released.late", "unreleased"], macAppVersion: "1.0", requirements: requirements @@ -132,7 +132,7 @@ struct MobileMacUpdateAdvisorTests { @Test func emptyCapabilitySetAndOldVersionIncludesAllReleasedFeatures() throws { - let hint = try #require(MobileMacUpdateAdvisor.hint( + let hint = try #require(MobileMacUpdateHint( hostCapabilities: [], macAppVersion: "1.0", requirements: requirements @@ -145,7 +145,7 @@ struct MobileMacUpdateAdvisorTests { let duplicateRequirements = requirements + [ .init(capability: "released.duplicate", feature: .workspaceActions, firstReleasedMacVersion: .init(parsing: "2.5")), ] - let hint = try #require(MobileMacUpdateAdvisor.hint( + let hint = try #require(MobileMacUpdateHint( hostCapabilities: [], macAppVersion: "1.0", requirements: duplicateRequirements @@ -156,17 +156,17 @@ struct MobileMacUpdateAdvisorTests { @Test func dismissalSignatureIsStableAndChangesWithGapOrMinimumVersion() throws { let reordered = [requirements[1], requirements[0], requirements[2]] - let first = try #require(MobileMacUpdateAdvisor.hint( + let first = try #require(MobileMacUpdateHint( hostCapabilities: [], macAppVersion: "1.0", requirements: requirements )) - let second = try #require(MobileMacUpdateAdvisor.hint( + let second = try #require(MobileMacUpdateHint( hostCapabilities: [], macAppVersion: "1.0", requirements: reordered )) - let changedGap = try #require(MobileMacUpdateAdvisor.hint( + let changedGap = try #require(MobileMacUpdateHint( hostCapabilities: ["released.early"], macAppVersion: "1.0", requirements: requirements @@ -176,7 +176,7 @@ struct MobileMacUpdateAdvisorTests { ? .init(capability: requirement.capability, feature: requirement.feature, firstReleasedMacVersion: .init(parsing: "4.0")) : requirement } - let changedMinimum = try #require(MobileMacUpdateAdvisor.hint( + let changedMinimum = try #require(MobileMacUpdateHint( hostCapabilities: [], macAppVersion: "1.0", requirements: changedMinimumRequirements @@ -191,7 +191,7 @@ struct MobileMacUpdateAdvisorTests { func standardRegistryReportsOnlyMissingGroupsCapability() throws { let capabilities = Set(MobileMacUpdateCapabilityRequirement.standard.map(\.capability)) .subtracting(["workspace.groups.v1"]) - let hint = try #require(MobileMacUpdateAdvisor.hint( + let hint = try #require(MobileMacUpdateHint( hostCapabilities: capabilities, macAppVersion: "0.64.15" )) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MacUpdateHintIndicatorButton.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MacUpdateHintIndicatorButton.swift index 7edcc488b08a..d2f5e86838e7 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MacUpdateHintIndicatorButton.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MacUpdateHintIndicatorButton.swift @@ -51,7 +51,7 @@ struct MacUpdateHintIndicatorButton: View { .font(.subheadline.weight(.semibold)) .foregroundStyle(.teal) - Text(MobileMacUpdateFeatureDisplay.bodyText(hint: hint, macName: macDisplayName)) + Text(hint.bodyText(macName: macDisplayName)) .font(.footnote) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileMacUpdateFeatureDisplay.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileMacUpdateHint+Display.swift similarity index 80% rename from Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileMacUpdateFeatureDisplay.swift rename to Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileMacUpdateHint+Display.swift index 1a7a1e63f0d7..0e9512b72605 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileMacUpdateFeatureDisplay.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileMacUpdateHint+Display.swift @@ -2,9 +2,10 @@ import CmuxMobileShell import CmuxMobileSupport import Foundation -struct MobileMacUpdateFeatureDisplay { - static func name(for feature: MobileMacUpdateFeature) -> String { - switch feature { +extension MobileMacUpdateFeature { + /// The localized user-facing name shown in the Mac-update hint popover. + var displayName: String { + switch self { case .workspaceActions: L10n.string("mobile.macUpdateHint.feature.workspaceActions", defaultValue: "Rename and pin workspaces") case .workspaceReadState: @@ -24,7 +25,12 @@ struct MobileMacUpdateFeatureDisplay { } } - static func bodyText(hint: MobileMacUpdateHint, macName: String?) -> String { +} + +extension MobileMacUpdateHint { + /// The localized popover body: the Mac, its version (only when reported, + /// never when inferred), the minimum update version, and the feature list. + func bodyText(macName: String?) -> String { let displayName = macName?.trimmingCharacters(in: .whitespacesAndNewlines) let resolvedName: String if let displayName, !displayName.isEmpty { @@ -33,12 +39,12 @@ struct MobileMacUpdateFeatureDisplay { resolvedName = L10n.string("mobile.macUpdateHint.genericMacName", defaultValue: "Your Mac") } let featureList = ListFormatter.localizedString( - byJoining: hint.features.map(MobileMacUpdateFeatureDisplay.name(for:)) + byJoining: features.map(\.displayName) ) // An inferred version proves the Mac is old enough to lack the // features, but stating it as the Mac's current version could be // wrong, so that variant only names the target version. - if hint.isVersionInferred { + if isVersionInferred { let format = L10n.string( "mobile.macUpdateHint.bodyFormatUnknownVersion", defaultValue: "Updating %1$@ to cmux %2$@ or later adds: %3$@." @@ -46,7 +52,7 @@ struct MobileMacUpdateFeatureDisplay { return String( format: format, resolvedName, - hint.minimumMacVersion.description, + minimumMacVersion.description, featureList ) } @@ -57,8 +63,8 @@ struct MobileMacUpdateFeatureDisplay { return String( format: format, resolvedName, - hint.macAppVersion.description, - hint.minimumMacVersion.description, + macAppVersion.description, + minimumMacVersion.description, featureList ) } diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileMacUpdateFeatureDisplayTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileMacUpdateHintDisplayTests.swift similarity index 78% rename from Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileMacUpdateFeatureDisplayTests.swift rename to Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileMacUpdateHintDisplayTests.swift index 9dc683bb4dc9..4362dfff2a42 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileMacUpdateFeatureDisplayTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobileMacUpdateHintDisplayTests.swift @@ -3,10 +3,10 @@ import Testing @testable import CmuxMobileShellUI @MainActor -@Suite struct MobileMacUpdateFeatureDisplayTests { +@Suite struct MobileMacUpdateHintDisplayTests { @Test func everyFeatureHasADisplayName() { for feature in MobileMacUpdateFeature.allCases { - #expect(!MobileMacUpdateFeatureDisplay.name(for: feature).isEmpty) + #expect(!feature.displayName.isEmpty) } } @@ -18,19 +18,19 @@ import Testing firstReleasedMacVersion: MobileMacAppVersion(parsing: "0.64.16") ) } - let hint = try #require(MobileMacUpdateAdvisor.hint( + let hint = try #require(MobileMacUpdateHint( hostCapabilities: [], macAppVersion: "0.64.15", requirements: requirements )) - let body = MobileMacUpdateFeatureDisplay.bodyText(hint: hint, macName: "Studio Mac") + let body = hint.bodyText(macName: "Studio Mac") #expect(body.contains("Studio Mac")) #expect(body.contains("0.64.15")) #expect(body.contains("0.64.16")) for feature in MobileMacUpdateFeature.allCases { - #expect(body.contains(MobileMacUpdateFeatureDisplay.name(for: feature))) + #expect(body.contains(feature.displayName)) } } @@ -47,14 +47,14 @@ import Testing firstReleasedMacVersion: MobileMacAppVersion(parsing: "0.64.16") ), ] - let hint = try #require(MobileMacUpdateAdvisor.hint( + let hint = try #require(MobileMacUpdateHint( hostCapabilities: ["test.present"], macAppVersion: nil, requirements: requirements )) #expect(hint.isVersionInferred) - let body = MobileMacUpdateFeatureDisplay.bodyText(hint: hint, macName: "Studio Mac") + let body = hint.bodyText(macName: "Studio Mac") #expect(body.contains("Studio Mac")) #expect(body.contains("0.64.16")) From 8de2ae179cf759a9413771b0f44a91fad90fdd50 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Sun, 12 Jul 2026 22:40:36 -0700 Subject: [PATCH 11/11] Share one injectable dismissal store across hint lookup and dismissal Both paths constructed MobileMacUpdateHintDismissalStore() ad hoc, binding the composite to process-wide UserDefaults.standard despite the store's injection seam. The store now lives on MacUpdateHintSessionState so lookup and dismissal share one instance and tests/previews can swap in a suite-scoped store. Co-Authored-By: Claude Fable 5 --- .../MobileShellComposite+MacUpdateHint.swift | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+MacUpdateHint.swift b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+MacUpdateHint.swift index 3dccbe9f95c8..65d7eb992bad 100644 --- a/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+MacUpdateHint.swift +++ b/Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileShellComposite+MacUpdateHint.swift @@ -10,6 +10,10 @@ internal import CmuxMobileRPC final class MacUpdateHintSessionState { var macDeviceID: String? var shownSignatures: Set = [] + /// The persisted dismissal store, carried here so both the lookup and the + /// dismissal path share one instance and tests/previews can swap in a + /// suite-scoped store instead of touching the user's real defaults. + var dismissalStore = MobileMacUpdateHintDismissalStore() } extension MobileShellComposite { @@ -53,7 +57,7 @@ extension MobileShellComposite { return } - guard !MobileMacUpdateHintDismissalStore().isDismissed( + guard !macUpdateHintSessionState.dismissalStore.isDismissed( macDeviceID: macDeviceID, signature: hint.dismissalSignature ) else { @@ -87,7 +91,7 @@ extension MobileShellComposite { /// Permanently dismisses the currently visible gap for this Mac and version target. public func dismissMacUpdateHint() { guard let hint = macUpdateHint, let macDeviceID = macUpdateHintSessionState.macDeviceID else { return } - MobileMacUpdateHintDismissalStore().dismiss( + macUpdateHintSessionState.dismissalStore.dismiss( macDeviceID: macDeviceID, signature: hint.dismissalSignature )