Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
29fdeeb5fa | ||
|
|
45a613b994 | ||
|
|
3c29727fad | ||
|
|
71c0f315a3 | ||
|
|
fdbc13b184 | ||
|
|
07c94807f5 |
@@ -1,7 +1,7 @@
|
||||
# cmux-owned Swift file length budget.
|
||||
# Format: max_lines<TAB>relative path
|
||||
# Reduce counts as files shrink. CI fails if tracked files exceed this budget.
|
||||
35600 CLI/cmux.swift
|
||||
35598 CLI/cmux.swift
|
||||
18229 Sources/AppDelegate.swift
|
||||
16443 Sources/ContentView.swift
|
||||
15163 Sources/TerminalController.swift
|
||||
@@ -242,7 +242,7 @@
|
||||
519 Packages/macOS/CmuxSwiftRender/Tests/CmuxSwiftRenderTests/Corpus/stress-two-column-cockpit-sidebar.swift
|
||||
518 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift
|
||||
518 Packages/macOS/CmuxSwiftRender/Tests/CmuxSwiftRenderTests/Corpus/stress-git-review-queue-command-deck.swift
|
||||
516 Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift
|
||||
530 Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift
|
||||
516 Sources/TerminalImageTransfer.swift
|
||||
514 Packages/macOS/CmuxSwiftRender/Sources/CmuxSwiftRender/ExpressionEvaluator.swift
|
||||
514 cmuxUITests/UpdatePillUITests.swift
|
||||
@@ -255,7 +255,7 @@
|
||||
506 Sources/App/MainWindowVisibilityController.swift
|
||||
504 Packages/macOS/CmuxSettings/Tests/CmuxSettingsTests/UserDefaultsSettingsStoreTests.swift
|
||||
504 cmuxTests/TerminalNotificationSocketActionTests.swift
|
||||
503 Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift
|
||||
505 Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift
|
||||
503 Sources/Settings/ConfigSource.swift
|
||||
503 Sources/TerminalNotificationQueue.swift
|
||||
502 Sources/CmuxEventPublishing.swift
|
||||
|
||||
|
@@ -0,0 +1,226 @@
|
||||
import Foundation
|
||||
|
||||
extension CMUXCLI {
|
||||
static let billingUsage = """
|
||||
Usage: cmux billing <status|checkout|portal> [options]
|
||||
|
||||
Fetch live billing data from the app's configured web API origin
|
||||
(cmux.com in production) for the currently signed-in user. The CLI talks
|
||||
to the running cmux app over the control socket; the app makes the
|
||||
authenticated request with its Stack session.
|
||||
|
||||
cmux billing status [--json]
|
||||
Print the live plan summary. --json prints the server JSON body.
|
||||
|
||||
cmux billing checkout [--plan pro|team] [--url | --open]
|
||||
Start checkout for the signed-in user. Default: --plan pro --open.
|
||||
|
||||
cmux billing portal [--url | --open]
|
||||
Open the Stripe customer portal for the signed-in user. Default: --open.
|
||||
"""
|
||||
|
||||
func runBillingCommand(commandArgs: [String], client: SocketClient, jsonOutput globalJSONOutput: Bool) throws {
|
||||
let sub = commandArgs.first?.lowercased() ?? "status"
|
||||
let rest = Array(commandArgs.dropFirst())
|
||||
|
||||
switch sub {
|
||||
case "help", "--help", "-h":
|
||||
print(Self.billingUsage)
|
||||
|
||||
case "status":
|
||||
let jsonOutput = globalJSONOutput || rest.contains("--json")
|
||||
let remaining = rest.filter { $0 != "--json" }
|
||||
try rejectUnexpectedBillingArguments(remaining, command: "billing status")
|
||||
let response = try client.sendV2(method: "billing.status", responseTimeout: 75)
|
||||
try handleBillingStructuredError(response)
|
||||
let plan = (response["plan"] as? [String: Any]) ?? response
|
||||
let source = response["source"] as? String
|
||||
if jsonOutput {
|
||||
var out = plan
|
||||
if out["source"] == nil, let source { out["source"] = source }
|
||||
print(jsonString(out))
|
||||
return
|
||||
}
|
||||
printBillingStatus(plan, source: source)
|
||||
|
||||
case "checkout":
|
||||
let (planOpt, rem0) = parseOption(rest, name: "--plan")
|
||||
let mode = try billingURLMode(rem0, command: "billing checkout")
|
||||
let plan = (planOpt ?? "pro").lowercased()
|
||||
guard plan == "pro" || plan == "team" else {
|
||||
throw CLIError(message: "billing checkout: --plan must be pro or team.")
|
||||
}
|
||||
let response = try client.sendV2(
|
||||
method: "billing.checkout",
|
||||
params: ["plan": plan],
|
||||
responseTimeout: 75
|
||||
)
|
||||
try handleBillingURLResponse(response, mode: mode, noun: "checkout")
|
||||
|
||||
case "portal":
|
||||
let mode = try billingURLMode(rest, command: "billing portal")
|
||||
let response = try client.sendV2(method: "billing.portal", responseTimeout: 75)
|
||||
try handleBillingURLResponse(response, mode: mode, noun: "portal")
|
||||
|
||||
default:
|
||||
throw CLIError(message: """
|
||||
Unknown billing subcommand: \(sub)
|
||||
|
||||
\(Self.billingUsage)
|
||||
""")
|
||||
}
|
||||
}
|
||||
|
||||
private enum BillingURLMode {
|
||||
case open
|
||||
case url
|
||||
}
|
||||
|
||||
private func billingURLMode(_ args: [String], command: String) throws -> BillingURLMode {
|
||||
var mode = BillingURLMode.open
|
||||
for arg in args {
|
||||
switch arg {
|
||||
case "--open":
|
||||
mode = .open
|
||||
case "--url":
|
||||
mode = .url
|
||||
default:
|
||||
throw CLIError(message: "\(command): unknown argument '\(arg)'.\n\n\(Self.billingUsage)")
|
||||
}
|
||||
}
|
||||
return mode
|
||||
}
|
||||
|
||||
private func rejectUnexpectedBillingArguments(_ args: [String], command: String) throws {
|
||||
if let first = args.first {
|
||||
throw CLIError(message: "\(command): unknown argument '\(first)'.\n\n\(Self.billingUsage)")
|
||||
}
|
||||
}
|
||||
|
||||
private func printBillingStatus(_ plan: [String: Any], source: String?) {
|
||||
let planId = billingString(plan["planId"]) ?? "free"
|
||||
let teamPlanId = billingString(plan["teamPlanId"]) ?? "free"
|
||||
let planName = billingDisplayPlanName(planId: planId, isPro: billingBool(plan["isPro"]))
|
||||
let isPro = billingBool(plan["isPro"]) ?? false
|
||||
let billingManagement = billingString(plan["billingManagement"]) ?? "none"
|
||||
let teamBillingManagement = billingString(plan["teamBillingManagement"]) ?? "none"
|
||||
let manualOverride = billingBool(plan["hasManualVmPlanOverride"]) ?? false
|
||||
let billingAvailable = billingBool(plan["billingAvailable"]) ?? true
|
||||
|
||||
print("plan: \(planName)")
|
||||
print("pro active: \(isPro ? "yes" : "no")")
|
||||
print("team plan: \(billingDisplayPlanName(planId: teamPlanId, isPro: nil))")
|
||||
print("billing available: \(billingAvailable ? "yes" : "no")")
|
||||
print("billing management: \(billingManagement)")
|
||||
print("team billing management: \(teamBillingManagement)")
|
||||
print("manual VM override: \(manualOverride ? "yes" : "no")")
|
||||
if let source, !source.isEmpty {
|
||||
print("source: \(source)")
|
||||
}
|
||||
}
|
||||
|
||||
private func handleBillingURLResponse(_ response: [String: Any], mode: BillingURLMode, noun: String) throws {
|
||||
try handleBillingStructuredError(response)
|
||||
guard let url = response["url"] as? String, !url.isEmpty else {
|
||||
let state = billingString(response["billing"])
|
||||
?? billingString(response["welcome"])
|
||||
?? billingString(response["error"])
|
||||
?? "unavailable"
|
||||
let source = (response["source"] as? String).map { " (source: \($0))" } ?? ""
|
||||
throw CLIError(message: "Billing \(noun) is not available: \(state)\(source)")
|
||||
}
|
||||
switch mode {
|
||||
case .url:
|
||||
print(url)
|
||||
case .open:
|
||||
try openBillingURL(url)
|
||||
print("Opened billing \(noun): \(url)")
|
||||
if let source = response["source"] as? String, !source.isEmpty {
|
||||
print("source: \(source)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleBillingStructuredError(_ response: [String: Any]) throws {
|
||||
if let ok = response["ok"] as? Bool, ok == false {
|
||||
let error = billingString(response["error"]) ?? "billing_unavailable"
|
||||
let source = (response["source"] as? String).map { "source: \($0)" }
|
||||
let detail = billingString(response["detail"])
|
||||
let suffix = [detail, source].compactMap { $0 }.joined(separator: "\n")
|
||||
let extra = suffix.isEmpty ? "" : "\n\(suffix)"
|
||||
switch error {
|
||||
case "not_signed_in":
|
||||
throw CLIError(message: "You are not signed in to cmux. Run `cmux auth login`, then retry.\(extra)")
|
||||
case "session_refresh_failed":
|
||||
throw CLIError(message: "cmux could not refresh your session. Retry in a moment.\(extra)")
|
||||
case "active_already_subscribed":
|
||||
throw CLIError(message: "You already have an active subscription.\(extra)")
|
||||
case "error", "unavailable", "invalid_plan":
|
||||
let billing = billingString(response["billing"]) ?? error
|
||||
throw CLIError(message: "Billing is not available: \(billing).\(extra)")
|
||||
default:
|
||||
throw CLIError(message: "Billing request failed: \(error).\(extra)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func openBillingURL(_ rawURL: String) throws {
|
||||
guard URL(string: rawURL) != nil else {
|
||||
throw CLIError(message: "Billing returned an invalid URL.")
|
||||
}
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/open")
|
||||
process.arguments = [rawURL]
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
if process.terminationStatus != 0 {
|
||||
throw CLIError(message: "Failed to open billing URL. Run with --url and open it manually.")
|
||||
}
|
||||
}
|
||||
|
||||
private func billingDisplayPlanName(planId: String, isPro: Bool?) -> String {
|
||||
if isPro == true { return "Pro" }
|
||||
switch planId.lowercased() {
|
||||
case "pro":
|
||||
return "Pro"
|
||||
case "team":
|
||||
return "Team"
|
||||
case "free":
|
||||
return "Free"
|
||||
default:
|
||||
return Self.sanitizeForTerminal(planId)
|
||||
}
|
||||
}
|
||||
|
||||
private func billingString(_ value: Any?) -> String? {
|
||||
switch value {
|
||||
case let string as String:
|
||||
let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : Self.sanitizeForTerminal(trimmed)
|
||||
case let number as NSNumber:
|
||||
return number.stringValue
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func billingBool(_ value: Any?) -> Bool? {
|
||||
switch value {
|
||||
case let bool as Bool:
|
||||
return bool
|
||||
case let number as NSNumber:
|
||||
return number.boolValue
|
||||
case let string as String:
|
||||
switch string.lowercased() {
|
||||
case "true", "yes", "1":
|
||||
return true
|
||||
case "false", "no", "0":
|
||||
return false
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,7 @@ extension CMUXCLI {
|
||||
"ai-accounts",
|
||||
"auth",
|
||||
"bind-key",
|
||||
"billing",
|
||||
"break-pane",
|
||||
"browser",
|
||||
"browser-back",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import Foundation
|
||||
|
||||
extension CMUXCLI {
|
||||
static let pingUsage = """
|
||||
Usage: cmux ping
|
||||
|
||||
Check connectivity to the cmux socket server.
|
||||
"""
|
||||
|
||||
static let capabilitiesUsage = """
|
||||
Usage: cmux capabilities
|
||||
|
||||
Print server capabilities as JSON.
|
||||
"""
|
||||
}
|
||||
+8
-10
@@ -4174,6 +4174,9 @@ struct CMUXCLI {
|
||||
case "ai-accounts":
|
||||
try runAIAccountsCommand(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput)
|
||||
|
||||
case "billing":
|
||||
try runBillingCommand(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput)
|
||||
|
||||
case "mobile":
|
||||
let sub = commandArgs.first?.lowercased()
|
||||
let rest = Array(commandArgs.dropFirst())
|
||||
@@ -15120,18 +15123,12 @@ struct CMUXCLI {
|
||||
return Self.remotesUsage
|
||||
case "ai-accounts":
|
||||
return Self.aiAccountsUsage
|
||||
case "billing":
|
||||
return Self.billingUsage
|
||||
case "ping":
|
||||
return """
|
||||
Usage: cmux ping
|
||||
|
||||
Check connectivity to the cmux socket server.
|
||||
"""
|
||||
return Self.pingUsage
|
||||
case "capabilities":
|
||||
return """
|
||||
Usage: cmux capabilities
|
||||
|
||||
Print server capabilities as JSON.
|
||||
"""
|
||||
return Self.capabilitiesUsage
|
||||
case "canvas":
|
||||
return """
|
||||
Usage: cmux canvas <subcommand> [args] [--workspace <id|ref>]
|
||||
@@ -35415,6 +35412,7 @@ export default CMUXSessionRestore;
|
||||
auth <status|login|logout>
|
||||
login | logout (aliases for auth login/logout)
|
||||
vm <base|new|ls|status|snapshot|fork|restore|rm|exec|shell|ssh> [args...] (alias: cloud)
|
||||
billing <status|checkout|portal> [--json] [--url|--open]
|
||||
remotes <list|add|remove> [--route <host:port>] [--tag <tag>] [--json] (alias: remote)
|
||||
ai-accounts <list|upload|remove> [--team <id>] [--json]
|
||||
rpc <method> [json-params]
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
/// The billing control seam (part of the ``ControlCommandContext`` umbrella).
|
||||
///
|
||||
/// Billing calls perform authenticated web API requests in the app process so
|
||||
/// Stack tokens never cross the socket boundary. The methods are `nonisolated`
|
||||
/// because `billing.*` runs on the socket worker; each app-side witness owns
|
||||
/// any main-actor/auth hops it needs.
|
||||
public protocol ControlBillingContext: AnyObject {
|
||||
/// Fetches the current signed-in user's live billing plan state.
|
||||
nonisolated func controlBillingStatus() -> ControlCallResult
|
||||
|
||||
/// Starts checkout for the requested plan and returns either a URL or a structured billing state.
|
||||
nonisolated func controlBillingCheckout(plan: String) -> ControlCallResult
|
||||
|
||||
/// Starts the customer portal flow and returns either a URL or a structured billing state.
|
||||
nonisolated func controlBillingPortal() -> ControlCallResult
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
extension ControlCommandCoordinator {
|
||||
nonisolated func billingStatus(context: (any ControlCommandContext)?) -> ControlCallResult {
|
||||
guard let context = context as? any ControlBillingContext else {
|
||||
return .ok(.object(["ok": .bool(false), "error": .string("unavailable")]))
|
||||
}
|
||||
return context.controlBillingStatus()
|
||||
}
|
||||
|
||||
nonisolated func billingCheckout(
|
||||
_ params: [String: JSONValue],
|
||||
context: (any ControlCommandContext)?
|
||||
) -> ControlCallResult {
|
||||
let plan = string(params, "plan") ?? "pro"
|
||||
guard plan == "pro" || plan == "team" else {
|
||||
return .ok(.object([
|
||||
"ok": .bool(false),
|
||||
"error": .string("invalid_plan"),
|
||||
"billing": .string("invalid_plan"),
|
||||
]))
|
||||
}
|
||||
guard let context = context as? any ControlBillingContext else {
|
||||
return .ok(.object(["ok": .bool(false), "error": .string("unavailable")]))
|
||||
}
|
||||
return context.controlBillingCheckout(plan: plan)
|
||||
}
|
||||
|
||||
nonisolated func billingPortal(context: (any ControlCommandContext)?) -> ControlCallResult {
|
||||
guard let context = context as? any ControlBillingContext else {
|
||||
return .ok(.object(["ok": .bool(false), "error": .string("unavailable")]))
|
||||
}
|
||||
return context.controlBillingPortal()
|
||||
}
|
||||
}
|
||||
+1
@@ -26,6 +26,7 @@ public protocol ControlCommandContext:
|
||||
ControlWorkspaceContext,
|
||||
ControlSurfaceContext,
|
||||
ControlSystemContext,
|
||||
ControlBillingContext,
|
||||
ControlProjectContext,
|
||||
ControlDebugContext,
|
||||
ControlSidebarContext,
|
||||
|
||||
+6
@@ -138,6 +138,12 @@ public final class ControlCommandCoordinator {
|
||||
return systemIdentify(request.params, context: context)
|
||||
case "system.tree":
|
||||
return systemTree(request.params, context: context)
|
||||
case "billing.status":
|
||||
return billingStatus(context: context)
|
||||
case "billing.checkout":
|
||||
return billingCheckout(request.params, context: context)
|
||||
case "billing.portal":
|
||||
return billingPortal(context: context)
|
||||
case "surface.send_text":
|
||||
return surfaceSendText(request.params, context: context)
|
||||
case "surface.send_key":
|
||||
|
||||
+5
-3
@@ -16,12 +16,13 @@ public enum ControlCommandExecutionPolicy: Sendable, Equatable {
|
||||
/// from the main thread.
|
||||
case socketWorker(mainThreadCallable: Bool)
|
||||
|
||||
/// Classifies a method: every `vm.`-, `remotes.`-, and
|
||||
/// `aiAccounts.`-prefixed method and the fixed socket-worker set run on the
|
||||
/// Classifies a method: every `vm.`-, `remotes.`-, `aiAccounts.`-, and
|
||||
/// `billing.`-prefixed method and the fixed socket-worker set run on the
|
||||
/// worker; everything else runs on the main actor.
|
||||
///
|
||||
/// `remotes.*` (the `cmux remotes` device-registry verbs) and
|
||||
/// `aiAccounts.*` (the team's subrouter AI-account verbs) make blocking,
|
||||
/// `aiAccounts.*` (the team's subrouter AI-account verbs) and `billing.*`
|
||||
/// (live plan/Stripe redirects from the app web origin) make blocking,
|
||||
/// authenticated web API calls just like `vm.*`, so they must stay off the
|
||||
/// main actor; prefix matches keep each verb family in lockstep without
|
||||
/// listing each method.
|
||||
@@ -29,6 +30,7 @@ public enum ControlCommandExecutionPolicy: Sendable, Equatable {
|
||||
/// - Parameter method: The trimmed method name.
|
||||
public init(forMethod method: String) {
|
||||
if method.hasPrefix("vm.") || method.hasPrefix("remotes.") || method.hasPrefix("aiAccounts.")
|
||||
|| method.hasPrefix("billing.")
|
||||
|| Self.socketWorkerMethods.contains(method) {
|
||||
self = .socketWorker(
|
||||
mainThreadCallable: Self.mainThreadCallableSocketWorkerMethods.contains(method)
|
||||
|
||||
+14
@@ -42,6 +42,20 @@ extension ControlFeedContext {
|
||||
func controlFeedSnapshotItems(pendingOnly: Bool) -> [JSONValue] { [] }
|
||||
}
|
||||
|
||||
extension ControlBillingContext {
|
||||
func controlBillingStatus() -> ControlCallResult {
|
||||
.ok(.object(["ok": .bool(false), "error": .string("unavailable")]))
|
||||
}
|
||||
|
||||
func controlBillingCheckout(plan: String) -> ControlCallResult {
|
||||
.ok(.object(["ok": .bool(false), "error": .string("unavailable")]))
|
||||
}
|
||||
|
||||
func controlBillingPortal() -> ControlCallResult {
|
||||
.ok(.object(["ok": .bool(false), "error": .string("unavailable")]))
|
||||
}
|
||||
}
|
||||
|
||||
extension ControlPaneContext {
|
||||
func controlPaneList(routing: ControlRoutingSelectors) -> ControlPaneListSnapshot? { nil }
|
||||
func controlPaneRoutingResolvesTabManager(routing: ControlRoutingSelectors) -> Bool { false }
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
import Testing
|
||||
@testable import CmuxControlSocket
|
||||
|
||||
private final class FakeBillingControlCommandContext: ControlCommandContext {
|
||||
nonisolated(unsafe) var checkoutPlan: String?
|
||||
|
||||
nonisolated func controlBillingStatus() -> ControlCallResult {
|
||||
.ok(.object([
|
||||
"source": .string("https://cmux.com"),
|
||||
"plan": .object(["planId": .string("free")]),
|
||||
]))
|
||||
}
|
||||
|
||||
nonisolated func controlBillingCheckout(plan: String) -> ControlCallResult {
|
||||
checkoutPlan = plan
|
||||
return .ok(.object(["ok": .bool(true), "url": .string("https://checkout.stripe.com/c/test")]))
|
||||
}
|
||||
|
||||
nonisolated func controlBillingPortal() -> ControlCallResult {
|
||||
.ok(.object(["ok": .bool(true), "url": .string("https://billing.stripe.com/p/session")]))
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("ControlCommandCoordinator billing domain")
|
||||
struct ControlCommandCoordinatorBillingTests {
|
||||
@Test @MainActor func statusDispatchesToBillingContext() {
|
||||
let context = FakeBillingControlCommandContext()
|
||||
let coordinator = ControlCommandCoordinator(context: context)
|
||||
|
||||
let result = coordinator.handleSocketWorkerV2(
|
||||
ControlRequest(id: nil, method: "billing.status", params: [:]),
|
||||
context: context
|
||||
)
|
||||
|
||||
#expect(result == .ok(.object([
|
||||
"source": .string("https://cmux.com"),
|
||||
"plan": .object(["planId": .string("free")]),
|
||||
])))
|
||||
}
|
||||
|
||||
@Test @MainActor func checkoutDefaultsToPro() {
|
||||
let context = FakeBillingControlCommandContext()
|
||||
let coordinator = ControlCommandCoordinator(context: context)
|
||||
|
||||
_ = coordinator.handleSocketWorkerV2(
|
||||
ControlRequest(id: nil, method: "billing.checkout", params: [:]),
|
||||
context: context
|
||||
)
|
||||
|
||||
#expect(context.checkoutPlan == "pro")
|
||||
}
|
||||
|
||||
@Test @MainActor func checkoutRejectsUnknownPlanAsStructuredBillingState() {
|
||||
let context = FakeBillingControlCommandContext()
|
||||
let coordinator = ControlCommandCoordinator(context: context)
|
||||
|
||||
let result = coordinator.handleSocketWorkerV2(
|
||||
ControlRequest(
|
||||
id: nil,
|
||||
method: "billing.checkout",
|
||||
params: ["plan": .string("enterprise")]
|
||||
),
|
||||
context: context
|
||||
)
|
||||
|
||||
#expect(result == .ok(.object([
|
||||
"ok": .bool(false),
|
||||
"error": .string("invalid_plan"),
|
||||
"billing": .string("invalid_plan"),
|
||||
])))
|
||||
}
|
||||
}
|
||||
+8
@@ -26,6 +26,14 @@ struct ControlCommandExecutionPolicyTests {
|
||||
#expect(ControlCommandExecutionPolicy(forMethod: "aiAccounts.remove") == .socketWorker(mainThreadCallable: false))
|
||||
}
|
||||
|
||||
@Test func billingPrefixedMethodsRunOnTheSocketWorker() {
|
||||
// `cmux billing` verbs make blocking authenticated web API calls, so
|
||||
// they must run on the worker while the app keeps Stack tokens local.
|
||||
#expect(ControlCommandExecutionPolicy(forMethod: "billing.status") == .socketWorker(mainThreadCallable: false))
|
||||
#expect(ControlCommandExecutionPolicy(forMethod: "billing.checkout") == .socketWorker(mainThreadCallable: false))
|
||||
#expect(ControlCommandExecutionPolicy(forMethod: "billing.portal") == .socketWorker(mainThreadCallable: false))
|
||||
}
|
||||
|
||||
@Test func fixedWorkerSetRunsOnTheSocketWorker() {
|
||||
for method in [
|
||||
"system.ping", "system.capabilities", "auth.status", "auth.sign_in_url",
|
||||
|
||||
@@ -2061,7 +2061,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
|
||||
self.auth = auth
|
||||
VMClient.bootstrap(auth: auth.coordinator)
|
||||
RemotesClient.bootstrap(auth: auth.coordinator)
|
||||
AIAccountsClient.bootstrap(auth: auth.coordinator)
|
||||
AIAccountsClient.bootstrap(auth: auth.coordinator); BillingClient.bootstrap(auth: auth.coordinator)
|
||||
PhonePushClient.shared.configure(auth: auth.coordinator)
|
||||
MobileHostService.shared.configure(auth: auth.coordinator)
|
||||
DeviceRegistryClient.shared.configure(auth: auth.coordinator)
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import CmuxAuthRuntime
|
||||
import CmuxControlSocket
|
||||
import Foundation
|
||||
|
||||
enum BillingClientError: Error {
|
||||
case notSignedIn
|
||||
case sessionRefreshFailed
|
||||
case malformedResponse(String)
|
||||
case backendUnreachable(url: String, detail: String)
|
||||
}
|
||||
|
||||
actor BillingClient {
|
||||
@MainActor private(set) static var shared: BillingClient!
|
||||
|
||||
@MainActor
|
||||
static func bootstrap(auth: AuthCoordinator, session: URLSession = .shared) {
|
||||
let redirectSession = URLSession(
|
||||
configuration: .default,
|
||||
delegate: BillingNoRedirectDelegate(),
|
||||
delegateQueue: nil
|
||||
)
|
||||
shared = BillingClient(session: session, redirectSession: redirectSession, auth: auth)
|
||||
}
|
||||
|
||||
private let session: URLSession
|
||||
private let redirectSession: URLSession
|
||||
private let auth: AuthCoordinator
|
||||
|
||||
init(session: URLSession = .shared, redirectSession: URLSession, auth: AuthCoordinator) {
|
||||
self.session = session
|
||||
self.redirectSession = redirectSession
|
||||
self.auth = auth
|
||||
}
|
||||
|
||||
func status() async -> JSONValue {
|
||||
do {
|
||||
let (data, http) = try await request("GET", path: "/api/billing/plan", followsRedirects: true)
|
||||
guard (200...299).contains(http.statusCode) else {
|
||||
return failure("http_status", source: sourceOrigin, status: http.statusCode, body: data)
|
||||
}
|
||||
let object = try decodeJSONValue(data)
|
||||
return .object([
|
||||
"source": .string(sourceOrigin),
|
||||
"plan": object,
|
||||
])
|
||||
} catch let error as BillingClientError {
|
||||
return failure(error)
|
||||
} catch {
|
||||
return failure("request_failed", source: sourceOrigin, detail: String(describing: error))
|
||||
}
|
||||
}
|
||||
|
||||
func checkout(plan: String) async -> JSONValue {
|
||||
await redirect(path: "/api/billing/checkout", queryItems: [
|
||||
URLQueryItem(name: "plan", value: plan),
|
||||
])
|
||||
}
|
||||
|
||||
func portal() async -> JSONValue {
|
||||
await redirect(path: "/api/billing/portal", queryItems: [])
|
||||
}
|
||||
|
||||
private func redirect(path: String, queryItems: [URLQueryItem]) async -> JSONValue {
|
||||
do {
|
||||
let (data, http) = try await request("GET", path: path, queryItems: queryItems, followsRedirects: false)
|
||||
if (300...399).contains(http.statusCode),
|
||||
let location = http.value(forHTTPHeaderField: "Location"),
|
||||
let url = URL(string: location, relativeTo: AuthEnvironment.apiBaseURL)?.absoluteURL {
|
||||
return redirectPayload(url)
|
||||
}
|
||||
if (200...299).contains(http.statusCode),
|
||||
let object = try? decodeJSONObject(data),
|
||||
let url = object["url"] as? String,
|
||||
!url.isEmpty {
|
||||
return .object(["ok": .bool(true), "source": .string(sourceOrigin), "url": .string(url)])
|
||||
}
|
||||
return failure("billing_unavailable", source: sourceOrigin, status: http.statusCode, body: data)
|
||||
} catch let error as BillingClientError {
|
||||
return failure(error)
|
||||
} catch {
|
||||
return failure("request_failed", source: sourceOrigin, detail: String(describing: error))
|
||||
}
|
||||
}
|
||||
|
||||
private func request(
|
||||
_ method: String,
|
||||
path: String,
|
||||
queryItems: [URLQueryItem] = [],
|
||||
followsRedirects: Bool
|
||||
) async throws -> (Data, HTTPURLResponse) {
|
||||
let tokens = try await currentTokens()
|
||||
guard var components = URLComponents(url: AuthEnvironment.apiBaseURL, resolvingAgainstBaseURL: false) else {
|
||||
throw BillingClientError.malformedResponse("bad api base URL")
|
||||
}
|
||||
components.path = (components.path.hasSuffix("/") ? String(components.path.dropLast()) : components.path) + path
|
||||
if !queryItems.isEmpty {
|
||||
components.queryItems = queryItems
|
||||
}
|
||||
guard let url = components.url else {
|
||||
throw BillingClientError.malformedResponse("could not build URL for \(path)")
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = method
|
||||
request.timeoutInterval = 30
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
request.setValue("Bearer \(tokens.accessToken)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue(tokens.refreshToken, forHTTPHeaderField: "X-Stack-Refresh-Token")
|
||||
|
||||
let activeSession = followsRedirects ? session : redirectSession
|
||||
let data: Data
|
||||
let response: URLResponse
|
||||
do {
|
||||
(data, response) = try await activeSession.data(for: request)
|
||||
} catch let error as URLError {
|
||||
switch error.code {
|
||||
case .cannotConnectToHost, .cannotFindHost, .timedOut, .networkConnectionLost, .notConnectedToInternet:
|
||||
throw BillingClientError.backendUnreachable(url: sourceOrigin, detail: error.localizedDescription)
|
||||
default:
|
||||
throw error
|
||||
}
|
||||
}
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw BillingClientError.malformedResponse("non-HTTP response")
|
||||
}
|
||||
return (data, http)
|
||||
}
|
||||
|
||||
private func currentTokens() async throws -> (accessToken: String, refreshToken: String) {
|
||||
do {
|
||||
return try await auth.currentTokens()
|
||||
} catch AuthError.networkError {
|
||||
throw BillingClientError.sessionRefreshFailed
|
||||
} catch {
|
||||
throw BillingClientError.notSignedIn
|
||||
}
|
||||
}
|
||||
|
||||
private func redirectPayload(_ url: URL) -> JSONValue {
|
||||
if let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
|
||||
components.path.hasSuffix("/pricing") || components.path.hasSuffix("/app-pricing") {
|
||||
let billing = components.queryItems?.first(where: { $0.name == "billing" })?.value
|
||||
let welcome = components.queryItems?.first(where: { $0.name == "welcome" })?.value
|
||||
if let billing, !billing.isEmpty {
|
||||
return .object([
|
||||
"ok": .bool(false),
|
||||
"source": .string(sourceOrigin),
|
||||
"error": .string(billing),
|
||||
"billing": .string(billing),
|
||||
])
|
||||
}
|
||||
if welcome == "active" || welcome == "active-already-subscribed" {
|
||||
return .object([
|
||||
"ok": .bool(false),
|
||||
"source": .string(sourceOrigin),
|
||||
"error": .string("active_already_subscribed"),
|
||||
"welcome": .string(welcome ?? ""),
|
||||
])
|
||||
}
|
||||
if let welcome, !welcome.isEmpty {
|
||||
return .object([
|
||||
"ok": .bool(false),
|
||||
"source": .string(sourceOrigin),
|
||||
"error": .string(welcome),
|
||||
"welcome": .string(welcome),
|
||||
])
|
||||
}
|
||||
return .object([
|
||||
"ok": .bool(false),
|
||||
"source": .string(sourceOrigin),
|
||||
"error": .string("unavailable"),
|
||||
])
|
||||
}
|
||||
return .object([
|
||||
"ok": .bool(true),
|
||||
"source": .string(sourceOrigin),
|
||||
"url": .string(url.absoluteString),
|
||||
])
|
||||
}
|
||||
|
||||
private func failure(_ error: BillingClientError) -> JSONValue {
|
||||
switch error {
|
||||
case .notSignedIn:
|
||||
return .object(["ok": .bool(false), "source": .string(sourceOrigin), "error": .string("not_signed_in")])
|
||||
case .sessionRefreshFailed:
|
||||
return .object(["ok": .bool(false), "source": .string(sourceOrigin), "error": .string("session_refresh_failed")])
|
||||
case let .malformedResponse(message):
|
||||
return .object([
|
||||
"ok": .bool(false),
|
||||
"source": .string(sourceOrigin),
|
||||
"error": .string("malformed_response"),
|
||||
"detail": .string(message),
|
||||
])
|
||||
case let .backendUnreachable(_, detail):
|
||||
return .object([
|
||||
"ok": .bool(false),
|
||||
"source": .string(sourceOrigin),
|
||||
"error": .string("billing_unreachable"),
|
||||
"detail": .string(detail),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
private func failure(
|
||||
_ error: String,
|
||||
source: String,
|
||||
status: Int? = nil,
|
||||
body: Data? = nil,
|
||||
detail: String? = nil
|
||||
) -> JSONValue {
|
||||
var payload: [String: JSONValue] = ["ok": .bool(false), "source": .string(source), "error": .string(error)]
|
||||
if let status {
|
||||
payload["status"] = .int(Int64(status))
|
||||
}
|
||||
if let body, let serverError = serverErrorString(body), !serverError.isEmpty {
|
||||
payload["detail"] = .string(serverError)
|
||||
} else if let detail, !detail.isEmpty {
|
||||
payload["detail"] = .string(detail)
|
||||
}
|
||||
return .object(payload)
|
||||
}
|
||||
|
||||
private func decodeJSONValue(_ data: Data) throws -> JSONValue {
|
||||
let parsed = try JSONSerialization.jsonObject(with: data, options: [])
|
||||
guard let value = JSONValue(foundationObject: parsed) else {
|
||||
throw BillingClientError.malformedResponse("response is not valid JSON")
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
private func decodeJSONObject(_ data: Data) throws -> [String: Any] {
|
||||
let parsed = try JSONSerialization.jsonObject(with: data, options: [])
|
||||
guard let object = parsed as? [String: Any] else {
|
||||
throw BillingClientError.malformedResponse("expected a JSON object")
|
||||
}
|
||||
return object
|
||||
}
|
||||
|
||||
private func serverErrorString(_ data: Data) -> String? {
|
||||
guard let parsed = try? JSONSerialization.jsonObject(with: data, options: []),
|
||||
let object = parsed as? [String: Any] else {
|
||||
return String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
return (object["error"] as? String) ?? (object["message"] as? String)
|
||||
}
|
||||
|
||||
private var sourceOrigin: String {
|
||||
var components = URLComponents()
|
||||
components.scheme = AuthEnvironment.apiBaseURL.scheme
|
||||
components.host = AuthEnvironment.apiBaseURL.host
|
||||
components.port = AuthEnvironment.apiBaseURL.port
|
||||
return components.url?.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
?? AuthEnvironment.apiBaseURL.absoluteString.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import Foundation
|
||||
|
||||
// URLSession owns delegate callbacks on its internal queues; this delegate has
|
||||
// no mutable state, so sharing it with the session is safe.
|
||||
final class BillingNoRedirectDelegate: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
task: URLSessionTask,
|
||||
willPerformHTTPRedirection response: HTTPURLResponse,
|
||||
newRequest request: URLRequest
|
||||
) async -> URLRequest? {
|
||||
nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import CmuxControlSocket
|
||||
import Foundation
|
||||
|
||||
extension TerminalController: ControlBillingContext {
|
||||
nonisolated func controlBillingStatus() -> ControlCallResult {
|
||||
billingCall {
|
||||
await BillingClient.shared.status()
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func controlBillingCheckout(plan: String) -> ControlCallResult {
|
||||
billingCall {
|
||||
await BillingClient.shared.checkout(plan: plan)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func controlBillingPortal() -> ControlCallResult {
|
||||
billingCall {
|
||||
await BillingClient.shared.portal()
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated func billingCall(_ work: @escaping () async -> JSONValue) -> ControlCallResult {
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
nonisolated(unsafe) var payload: JSONValue?
|
||||
let task = Task {
|
||||
payload = await work()
|
||||
semaphore.signal()
|
||||
}
|
||||
if semaphore.wait(timeout: .now() + 60) == .timedOut {
|
||||
task.cancel()
|
||||
return .ok(.object([
|
||||
"ok": .bool(false),
|
||||
"error": .string("timeout"),
|
||||
]))
|
||||
}
|
||||
guard let payload else {
|
||||
return .ok(.object([
|
||||
"ok": .bool(false),
|
||||
"error": .string("malformed_response"),
|
||||
]))
|
||||
}
|
||||
return .ok(payload)
|
||||
}
|
||||
}
|
||||
@@ -2379,7 +2379,7 @@ class TerminalController {
|
||||
"vm.ssh_info",
|
||||
"aiAccounts.list",
|
||||
"aiAccounts.upload",
|
||||
"aiAccounts.remove",
|
||||
"aiAccounts.remove", "billing.status", "billing.checkout", "billing.portal",
|
||||
"window.list",
|
||||
"window.current",
|
||||
"window.focus",
|
||||
|
||||
@@ -132,6 +132,8 @@
|
||||
74AB3F34FE3B4974A3A5D264 /* AutoNamingHookPayloadAdapterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46792DAA478444ED87B17B31 /* AutoNamingHookPayloadAdapterTests.swift */; };
|
||||
C0DE35860000000000000001 /* BackgroundWorkspacePrimeCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE35860000000000000002 /* BackgroundWorkspacePrimeCoordinator.swift */; };
|
||||
A50012F1 /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50012F0 /* Backport.swift */; };
|
||||
B11100000000000000000001 /* BillingClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = B11100000000000000000002 /* BillingClient.swift */; };
|
||||
B11100000000000000000003 /* BillingNoRedirectDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B11100000000000000000004 /* BillingNoRedirectDelegate.swift */; };
|
||||
B057B0017E57B0017E57B001 /* Bonsplit in Frameworks */ = {isa = PBXBuildFile; productRef = A5001262 /* Bonsplit */; };
|
||||
D0B10010A1B2C3D4E5F60001 /* BonsplitTabBarDebug.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B10011A1B2C3D4E5F60001 /* BonsplitTabBarDebug.swift */; };
|
||||
D0B10002A1B2C3D4E5F60001 /* BonsplitTabBarPassThrough.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B10003A1B2C3D4E5F60001 /* BonsplitTabBarPassThrough.swift */; };
|
||||
@@ -340,6 +342,7 @@
|
||||
6D9C51AB30A64B1ABC819143 /* CMUXCLI+AutoNamingGenericHooks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5257257034CA4729B1211167 /* CMUXCLI+AutoNamingGenericHooks.swift */; };
|
||||
6D9C51AB30A64B1ABC819144 /* CMUXCLI+AutoNamingHooks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5257257034CA4729B1211168 /* CMUXCLI+AutoNamingHooks.swift */; };
|
||||
6D9C51AB30A64B1ABC819145 /* CMUXCLI+AutoNamingSummarizers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5257257034CA4729B1211169 /* CMUXCLI+AutoNamingSummarizers.swift */; };
|
||||
B11100000000000000000007 /* CMUXCLI+Billing.swift in Sources */ = {isa = PBXBuildFile; fileRef = B11100000000000000000008 /* CMUXCLI+Billing.swift */; };
|
||||
C72280000000000000000002 /* CMUXCLI+ClaudeHookWorkspaceRouting.swift in Sources */ = {isa = PBXBuildFile; fileRef = C72280000000000000000001 /* CMUXCLI+ClaudeHookWorkspaceRouting.swift */; };
|
||||
2DA8D48D99FB520EB179B015 /* CMUXCLI+ClaudePushNotificationHook.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CF59318F1D5B2195AC77C28 /* CMUXCLI+ClaudePushNotificationHook.swift */; };
|
||||
C0D3F1F00000000000000101 /* CMUXCLI+CodexFireAndForgetHooks.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0D3F1F00000000000000102 /* CMUXCLI+CodexFireAndForgetHooks.swift */; };
|
||||
@@ -352,6 +355,7 @@
|
||||
B9000052A1B2C3D4E5F60719 /* CMUXCLI+InstallPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000053A1B2C3D4E5F60719 /* CMUXCLI+InstallPreview.swift */; };
|
||||
489F4CF9B768C42D87B5EB2F /* CMUXCLI+KimiHooks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A3D532FF0A00E20DA31667F /* CMUXCLI+KimiHooks.swift */; };
|
||||
B9000071A1B2C3D4E5F60719 /* CMUXCLI+Memory.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000070A1B2C3D4E5F60719 /* CMUXCLI+Memory.swift */; };
|
||||
B11100000000000000000107 /* CMUXCLI+MiscUsage.swift in Sources */ = {isa = PBXBuildFile; fileRef = B11100000000000000000108 /* CMUXCLI+MiscUsage.swift */; };
|
||||
D7AB0000000000000000000D /* CMUXCLI+MoveTabToNewWorkspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB0000000000000000000E /* CMUXCLI+MoveTabToNewWorkspace.swift */; };
|
||||
B9000072A1B2C3D4E5F60719 /* CMUXCLI+OmpExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000073A1B2C3D4E5F60719 /* CMUXCLI+OmpExtension.swift */; };
|
||||
C05555010000000000000001 /* CMUXCLI+PiExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C05555010000000000000002 /* CMUXCLI+PiExtension.swift */; };
|
||||
@@ -1229,6 +1233,7 @@
|
||||
E4D1768B7041CDE4F9A084A4 /* TerminalClearScreenKeepScrollbackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2B7555E04A04849992547A2 /* TerminalClearScreenKeepScrollbackTests.swift */; };
|
||||
C2577000A1B2C3D4E5F60718 /* TerminalCmdClickUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2577001A1B2C3D4E5F60718 /* TerminalCmdClickUITests.swift */; };
|
||||
C0DE00000000000000000C42 /* TerminalController+ControlAppFocusContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000C41 /* TerminalController+ControlAppFocusContext.swift */; };
|
||||
B11100000000000000000005 /* TerminalController+ControlBillingContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = B11100000000000000000006 /* TerminalController+ControlBillingContext.swift */; };
|
||||
C0DE00000000000000000C82 /* TerminalController+ControlBrowserPanelContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000C81 /* TerminalController+ControlBrowserPanelContext.swift */; };
|
||||
CA52E0020000000000000000 /* TerminalController+ControlCanvasContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA52F0020000000000000000 /* TerminalController+ControlCanvasContext.swift */; };
|
||||
C0DE00000000000000000C6E /* TerminalController+ControlDebugContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE00000000000000000C6D /* TerminalController+ControlDebugContext.swift */; };
|
||||
@@ -1677,6 +1682,8 @@
|
||||
46792DAA478444ED87B17B31 /* AutoNamingHookPayloadAdapterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutoNamingHookPayloadAdapterTests.swift; sourceTree = "<group>"; };
|
||||
C0DE35860000000000000002 /* BackgroundWorkspacePrimeCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorkspacePrimeCoordinator.swift; sourceTree = "<group>"; };
|
||||
A50012F0 /* Backport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Backport.swift; sourceTree = "<group>"; };
|
||||
B11100000000000000000002 /* BillingClient.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BillingClient.swift; sourceTree = "<group>"; };
|
||||
B11100000000000000000004 /* BillingNoRedirectDelegate.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BillingNoRedirectDelegate.swift; sourceTree = "<group>"; };
|
||||
D0B10011A1B2C3D4E5F60001 /* BonsplitTabBarDebug.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BonsplitTabBarDebug.swift; sourceTree = "<group>"; };
|
||||
D0B10003A1B2C3D4E5F60001 /* BonsplitTabBarPassThrough.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BonsplitTabBarPassThrough.swift; sourceTree = "<group>"; };
|
||||
AA1B2C3D4E5F60719 /* BonsplitTabDragUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BonsplitTabDragUITests.swift; sourceTree = "<group>"; };
|
||||
@@ -1872,6 +1879,7 @@
|
||||
5257257034CA4729B1211167 /* CMUXCLI+AutoNamingGenericHooks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+AutoNamingGenericHooks.swift"; sourceTree = "<group>"; };
|
||||
5257257034CA4729B1211168 /* CMUXCLI+AutoNamingHooks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+AutoNamingHooks.swift"; sourceTree = "<group>"; };
|
||||
5257257034CA4729B1211169 /* CMUXCLI+AutoNamingSummarizers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+AutoNamingSummarizers.swift"; sourceTree = "<group>"; };
|
||||
B11100000000000000000008 /* CMUXCLI+Billing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+Billing.swift"; sourceTree = "<group>"; };
|
||||
C72280000000000000000001 /* CMUXCLI+ClaudeHookWorkspaceRouting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+ClaudeHookWorkspaceRouting.swift"; sourceTree = "<group>"; };
|
||||
4CF59318F1D5B2195AC77C28 /* CMUXCLI+ClaudePushNotificationHook.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+ClaudePushNotificationHook.swift"; sourceTree = "<group>"; };
|
||||
C0D3F1F00000000000000102 /* CMUXCLI+CodexFireAndForgetHooks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+CodexFireAndForgetHooks.swift"; sourceTree = "<group>"; };
|
||||
@@ -1884,6 +1892,7 @@
|
||||
B9000053A1B2C3D4E5F60719 /* CMUXCLI+InstallPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+InstallPreview.swift"; sourceTree = "<group>"; };
|
||||
7A3D532FF0A00E20DA31667F /* CMUXCLI+KimiHooks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+KimiHooks.swift"; sourceTree = "<group>"; };
|
||||
B9000070A1B2C3D4E5F60719 /* CMUXCLI+Memory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+Memory.swift"; sourceTree = "<group>"; };
|
||||
B11100000000000000000108 /* CMUXCLI+MiscUsage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+MiscUsage.swift"; sourceTree = "<group>"; };
|
||||
D7AB0000000000000000000E /* CMUXCLI+MoveTabToNewWorkspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+MoveTabToNewWorkspace.swift"; sourceTree = "<group>"; };
|
||||
B9000073A1B2C3D4E5F60719 /* CMUXCLI+OmpExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+OmpExtension.swift"; sourceTree = "<group>"; };
|
||||
C05555010000000000000002 /* CMUXCLI+PiExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+PiExtension.swift"; sourceTree = "<group>"; };
|
||||
@@ -2704,6 +2713,7 @@
|
||||
F2B7555E04A04849992547A2 /* TerminalClearScreenKeepScrollbackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalClearScreenKeepScrollbackTests.swift; sourceTree = "<group>"; };
|
||||
C2577001A1B2C3D4E5F60718 /* TerminalCmdClickUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalCmdClickUITests.swift; sourceTree = "<group>"; };
|
||||
C0DE00000000000000000C41 /* TerminalController+ControlAppFocusContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+ControlAppFocusContext.swift"; sourceTree = "<group>"; };
|
||||
B11100000000000000000006 /* TerminalController+ControlBillingContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+ControlBillingContext.swift"; sourceTree = "<group>"; };
|
||||
C0DE00000000000000000C81 /* TerminalController+ControlBrowserPanelContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+ControlBrowserPanelContext.swift"; sourceTree = "<group>"; };
|
||||
CA52F0020000000000000000 /* TerminalController+ControlCanvasContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+ControlCanvasContext.swift"; sourceTree = "<group>"; };
|
||||
C0DE00000000000000000C6D /* TerminalController+ControlDebugContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+ControlDebugContext.swift"; sourceTree = "<group>"; };
|
||||
@@ -3195,6 +3205,8 @@
|
||||
9CEC6EF35B71AE59FA45BA69 /* VMClient.swift */,
|
||||
REE0CA0000000000000000E1 /* AIAccountsClient.swift */,
|
||||
REE0CA0000000000000000F1 /* AIAccountCredentialSources.swift */,
|
||||
B11100000000000000000002 /* BillingClient.swift */,
|
||||
B11100000000000000000004 /* BillingNoRedirectDelegate.swift */,
|
||||
REE0CA0000000000000000A1 /* RemotesClient.swift */,
|
||||
REE0CA0000000000000000D1 /* RemoteRouteSpec.swift */,
|
||||
D1F0A00100000000000000A2 /* PhonePushClient.swift */,
|
||||
@@ -3591,6 +3603,7 @@
|
||||
C0DE00000000000000000C45 /* TerminalController+ControlNotificationContext.swift */,
|
||||
C0DE1A040000000000000002 /* TerminalController+ControlLayoutContext.swift */,
|
||||
C0DE00000000000000000C61 /* TerminalController+ControlWorkspaceContext.swift */,
|
||||
B11100000000000000000006 /* TerminalController+ControlBillingContext.swift */,
|
||||
D7AB00000000000000B000 /* TerminalController+MobileNotificationSync.swift */,
|
||||
D7AB00000000000000B010 /* TerminalController+MobileScrollPrefetch.swift */,
|
||||
C0DE00000000000000000C81 /* TerminalController+ControlBrowserPanelContext.swift */,
|
||||
@@ -4109,6 +4122,8 @@
|
||||
children = (
|
||||
B9000001A1B2C3D4E5F60719 /* cmux.swift */,
|
||||
REE0CA0000000000000000C1 /* CMUXCLI+Remotes.swift */,
|
||||
B11100000000000000000008 /* CMUXCLI+Billing.swift */,
|
||||
B11100000000000000000108 /* CMUXCLI+MiscUsage.swift */,
|
||||
1E4DB33FA55F1B13C60EFFC8 /* SSHPTYAttachReconnectInputFilter.swift */,
|
||||
E60610200000000000000001 /* SSHPTYAttachReconnectInputFilterControl.swift */,
|
||||
E60610100000000000000001 /* SSHPTYAttachReconnectInputFilterSequenceMatch.swift */,
|
||||
@@ -5066,6 +5081,8 @@
|
||||
D9FEC58D5BACCF76459F1BBE /* AuthEnvironment.swift in Sources */,
|
||||
C0DE35860000000000000001 /* BackgroundWorkspacePrimeCoordinator.swift in Sources */,
|
||||
A50012F1 /* Backport.swift in Sources */,
|
||||
B11100000000000000000001 /* BillingClient.swift in Sources */,
|
||||
B11100000000000000000003 /* BillingNoRedirectDelegate.swift in Sources */,
|
||||
D0B10010A1B2C3D4E5F60001 /* BonsplitTabBarDebug.swift in Sources */,
|
||||
D0B10002A1B2C3D4E5F60001 /* BonsplitTabBarPassThrough.swift in Sources */,
|
||||
D7032A070000000000000001 /* BrowserAuthPromptTextFormatter.swift in Sources */,
|
||||
@@ -5699,6 +5716,7 @@
|
||||
C7A502000000000000000002 /* TaskManagerWindowController.swift in Sources */,
|
||||
7490C00F7490C00F7490C00F /* TaskVMInfoMemoryPressureFootprintSampler.swift in Sources */,
|
||||
C0DE00000000000000000C42 /* TerminalController+ControlAppFocusContext.swift in Sources */,
|
||||
B11100000000000000000005 /* TerminalController+ControlBillingContext.swift in Sources */,
|
||||
C0DE00000000000000000C82 /* TerminalController+ControlBrowserPanelContext.swift in Sources */,
|
||||
CA52E0020000000000000000 /* TerminalController+ControlCanvasContext.swift in Sources */,
|
||||
C0DE00000000000000000C6E /* TerminalController+ControlDebugContext.swift in Sources */,
|
||||
@@ -5907,6 +5925,7 @@
|
||||
6D9C51AB30A64B1ABC819143 /* CMUXCLI+AutoNamingGenericHooks.swift in Sources */,
|
||||
6D9C51AB30A64B1ABC819144 /* CMUXCLI+AutoNamingHooks.swift in Sources */,
|
||||
6D9C51AB30A64B1ABC819145 /* CMUXCLI+AutoNamingSummarizers.swift in Sources */,
|
||||
B11100000000000000000007 /* CMUXCLI+Billing.swift in Sources */,
|
||||
C72280000000000000000002 /* CMUXCLI+ClaudeHookWorkspaceRouting.swift in Sources */,
|
||||
2DA8D48D99FB520EB179B015 /* CMUXCLI+ClaudePushNotificationHook.swift in Sources */,
|
||||
C0D3F1F00000000000000101 /* CMUXCLI+CodexFireAndForgetHooks.swift in Sources */,
|
||||
@@ -5919,6 +5938,7 @@
|
||||
B9000052A1B2C3D4E5F60719 /* CMUXCLI+InstallPreview.swift in Sources */,
|
||||
489F4CF9B768C42D87B5EB2F /* CMUXCLI+KimiHooks.swift in Sources */,
|
||||
B9000071A1B2C3D4E5F60719 /* CMUXCLI+Memory.swift in Sources */,
|
||||
B11100000000000000000107 /* CMUXCLI+MiscUsage.swift in Sources */,
|
||||
D7AB0000000000000000000D /* CMUXCLI+MoveTabToNewWorkspace.swift in Sources */,
|
||||
B9000072A1B2C3D4E5F60719 /* CMUXCLI+OmpExtension.swift in Sources */,
|
||||
C05555010000000000000001 /* CMUXCLI+PiExtension.swift in Sources */,
|
||||
|
||||
Reference in New Issue
Block a user