Repository navigation
iOS: show which features a Mac update unlocks when the connected Mac is older #7960
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
e756965
Add Mac-update-hint decision core to CmuxMobileShell
azooz2003-bit df9b4eb
Show iOS indicator when a Mac update unlocks mobile features
2a30214
Log Mac-update-hint decisions to the iOS debug log
b29224a
Make the Mac-update hint a subtle toolbar indicator with popover
f825a82
Tint the Mac-update toolbar glyph teal
06b3368
Merge origin/main into feat-ios-mac-update-indicator
fc3c619
Merge remote-tracking branch 'origin/main' into feat-ios-mac-update-i…
3644dfd
Address PR review: identity-gated dismissal, per-Mac analytics gate, …
f52fcc8
Infer legacy Mac versions from capabilities; refresh hint on status r…
e4d64c0
Address review round 2: honest analytics, budget-neutral composite
19e6ae9
Group Mac-update hint session state behind one stored property
542e5ff
Apply Aziz policy pass to the Mac-update hint types
8de2ae1
Share one injectable dismissal store across hint lookup and dismissal
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
63 changes: 63 additions & 0 deletions
63
Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacAppVersion.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| } | ||
| } |
61 changes: 61 additions & 0 deletions
61
...es/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacUpdateCapabilityRequirement.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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), | ||
| ] | ||
| } | ||
28 changes: 28 additions & 0 deletions
28
Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacUpdateFeature.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| } |
113 changes: 113 additions & 0 deletions
113
Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacUpdateHint.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,113 @@ | ||
| /// 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, 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] | ||
|
|
||
| /// 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, 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 | ||
| } | ||
|
|
||
| /// 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)" | ||
| } | ||
|
|
||
| /// 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 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 init?( | ||
| hostCapabilities: Set<String>, | ||
| macAppVersion versionString: String?, | ||
| requirements: [MobileMacUpdateCapabilityRequirement] = MobileMacUpdateCapabilityRequirement.standard | ||
| ) { | ||
| 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 | ||
| } | ||
|
|
||
| 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<MobileMacUpdateFeature> = [] | ||
| self.init( | ||
| features: contributors.compactMap { requirement in | ||
| seenFeatures.insert(requirement.feature).inserted ? requirement.feature : nil | ||
| }, | ||
| minimumMacVersion: minimumMacVersion, | ||
| macAppVersion: macAppVersion, | ||
| isVersionInferred: explicitVersion == nil, | ||
| missingCapabilities: contributors.map(\.capability) | ||
| ) | ||
| } | ||
| } |
44 changes: 44 additions & 0 deletions
44
Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/MobileMacUpdateHintDismissalStore.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: manaflow-ai/cmux
Length of output: 329
🏁 Script executed:
Repository: manaflow-ai/cmux
Length of output: 3241
🏁 Script executed:
Repository: manaflow-ai/cmux
Length of output: 1623
Add a registry invariant test for released capabilities
MobileMacAppVersion(parsing:)can returnnil, and the current advisor test only covers the.workspaceGroupshint path. Add a test that asserts everystandardentry with a declared release version still has a non-nilfirstReleasedMacVersion, so a typo in one of the literals fails fast instead of silently making that capability look unreleased.🤖 Prompt for AI Agents