Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
b26a2bb
cloud: enable cloud for pro, upgrade for free, keep the button when t…
lucasr1b Oct 3, 2026
a17eb76
cloud: stop waiting on a slow plan check, title enable cloud machines…
lucasr1b Oct 3, 2026
29c29ed
cloud: cloud.fill for the pro-required screen, settings shows only th…
lucasr1b Oct 3, 2026
595b4f4
cloud: drop the debug plan override
lucasr1b Oct 3, 2026
ebde4d2
cloud: keep prominent cloud buttons visible in an inactive window, ke…
lucasr1b Oct 3, 2026
f103c6c
cloud gate: newest plan check wins, drop answers for a switched account
lucasr1b Oct 3, 2026
149e1d4
fix cloud billing plan concurrency and state ownership
austinywang Oct 4, 2026
66fd4d3
Merge remote-tracking branch 'origin/main' into task-17127-cloud-gate
austinywang Oct 4, 2026
1e661b6
fix cloud plan localization parity
austinywang Oct 4, 2026
2f2c4a7
fix cloud entitlement detection for team plans
austinywang Oct 4, 2026
fa19d69
avoid stale cloud entitlements across team changes
austinywang Oct 4, 2026
046f472
report billing refresh success to settings
austinywang Oct 4, 2026
832fe2b
Merge remote-tracking branch 'origin/main' into task-17127-cloud-gate
austinywang Oct 4, 2026
5622f36
preserve account flow billing refresh contract
austinywang Oct 4, 2026
4588838
cloud onboarding: introducing cmux cloud welcome, and cloud enablemen…
lucasr1b Oct 5, 2026
9f976ef
Merge remote-tracking branch 'origin/main' into task-17127-cloud-gate
austinywang Oct 5, 2026
28724d9
Merge remote-tracking branch 'origin/cloud-gate-upgrade' into task-17…
austinywang Oct 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ public enum ProUpgradeSource: String, CaseIterable, Sendable {
case settingsCloudMachines = "mac_settings_cloud_machines"
/// Machines panel empty state: plan does not include Cloud machines.
case machinesPanelRequiresPro = "mac_machines_panel_requires_pro"
/// "Introducing cmux Cloud" welcome window, Free plan.
case cloudWelcome = "mac_cloud_welcome"
/// Machines panel nudge under the create button.
case machinesPanelUpgradeNudge = "mac_machines_panel_upgrade_nudge"
/// Machines panel free-access countdown / expired banner.
Expand Down
104 changes: 104 additions & 0 deletions Packages/macOS/CmuxCloud/Sources/CmuxCloud/BillingPlan.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import Foundation

/// The account-scoped billing entitlement used by Cloud access controls.
public struct BillingPlanState: Sendable, Equatable {
/// The account whose entitlement is known, or `nil` while unknown.
public let accountID: String?
/// Whether the known account includes Cloud access.
public let isPro: Bool
/// Whether the known account can manage billing through the hosted portal.
public let canManageBilling: Bool

/// Creates an entitlement snapshot.
///
/// - Parameters:
/// - accountID: The account owning the snapshot, or `nil` when unknown.
/// - isPro: Whether the account includes Cloud access.
/// - canManageBilling: Whether billing can be managed in the hosted portal.
public init(accountID: String?, isPro: Bool, canManageBilling: Bool) {
self.accountID = accountID
self.isPro = isPro
self.canManageBilling = canManageBilling
}

/// An unknown entitlement that must not be treated as a free plan.
public static var unknown: Self { Self(accountID: nil, isPro: false, canManageBilling: false) }

/// Applies a successful response to the account that requested it.
public func applyingSuccess(for accountID: String, isPro: Bool, canManageBilling: Bool) -> Self {
Self(accountID: accountID, isPro: isPro, canManageBilling: canManageBilling)
}

/// Retains an existing answer for the same account and clears other answers.
public func applyingFailure(for accountID: String) -> Self {
self.accountID == accountID ? self : .unknown
}
}

/// The decoded billing response returned by the Cloud service.
public struct BillingPlanDetails: Sendable, Equatable {
/// Whether the account includes Cloud access.
public let isPro: Bool
/// Whether the account can manage billing through the hosted portal.
public let canManageBilling: Bool

/// Creates decoded billing details.
public init(isPro: Bool, canManageBilling: Bool) {
self.isPro = isPro
self.canManageBilling = canManageBilling
}
}

/// Fetches a billing entitlement without requiring UI isolation.
public struct BillingPlanClient: Sendable {
/// Creates a client that uses the supplied URL session.
public init(session: URLSession = .shared) {
self.session = session
}

/// Fetches and decodes the entitlement for an authenticated account.
///
/// - Parameters:
/// - url: The billing-plan endpoint.
/// - accessToken: An optional bearer token.
/// - refreshToken: An optional session refresh token.
/// - Returns: The decoded entitlement details.
/// - Throws: A URL-loading or decoding error when the request fails.
public func fetch(from url: URL, accessToken: String?, refreshToken: String? = nil) async throws -> BillingPlanDetails {
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.timeoutInterval = 15
request.setValue("application/json", forHTTPHeaderField: "Accept")
if let accessToken {
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
}
if let refreshToken {
request.setValue(refreshToken, forHTTPHeaderField: "X-Stack-Refresh-Token")
}
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
throw URLError(.badServerResponse)
}
let decoded = try JSONDecoder().decode(Response.self, from: data)
// The default endpoint returns both personal and active-team plans.
// A paid team grants Cloud access even when the personal subscription
// is free, which is the normal path for team-owned machines.
let paidPlanIDs = ["go", "pro", "max", "team", "founders"]
let isPro = decoded.isPro == true
|| paidPlanIDs.contains(decoded.planId?.lowercased() ?? "")
|| paidPlanIDs.contains(decoded.teamPlanId?.lowercased() ?? "")
let canManageBilling = decoded.billingManagement == "stripe"
|| decoded.teamBillingManagement == "stripe"
return BillingPlanDetails(isPro: isPro, canManageBilling: canManageBilling)
}

private let session: URLSession

private struct Response: Decodable {
let isPro: Bool?
let planId: String?
let billingManagement: String?
let teamPlanId: String?
let teamBillingManagement: String?
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import CmuxCloud
import Testing

@Suite("Billing plan state")
struct BillingPlanTests {
@Test("successful response is scoped to its account")
func successScopesAccount() {
let state = BillingPlanState.unknown.applyingSuccess(
for: "account-a",
isPro: true,
canManageBilling: true
)
#expect(state.accountID == "account-a")
#expect(state.isPro)
#expect(state.canManageBilling)
}

@Test("same-account failure preserves the last known answer")
func sameAccountFailurePreservesAnswer() {
let state = BillingPlanState(accountID: "account-a", isPro: true, canManageBilling: true)
#expect(state.applyingFailure(for: "account-a") == state)
}

@Test("different-account failure clears the answer")
func differentAccountFailureClearsAnswer() {
let state = BillingPlanState(accountID: "account-a", isPro: true, canManageBilling: true)
#expect(state.applyingFailure(for: "account-b") == .unknown)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ public struct CloudMachinesSection: View {
return
}
let includesCloud = await hostActions.cloudMachinesPlanIncludesCloud()
// A newer check (account switch, app reactivation) owns the row now.
guard !Task.isCancelled else { return }
planIncludesCloud = includesCloud
}
Expand Down
Loading
Loading