diff --git a/CLI/CMUXCLI+AuthTeam.swift b/CLI/CMUXCLI+AuthTeam.swift new file mode 100644 index 000000000000..cdc74b9cfa1f --- /dev/null +++ b/CLI/CMUXCLI+AuthTeam.swift @@ -0,0 +1,75 @@ +import Foundation + +extension CMUXCLI { + /// Implements `cmux auth team` through the same authenticated socket + /// actions used by the sidebar picker. + func runAuthTeamCommand( + commandArgs: [String], + client: SocketClient, + jsonOutput: Bool + ) throws { + let subcommand = commandArgs.first?.lowercased() ?? "list" + switch subcommand { + case "list": + let response = try client.sendV2(method: "auth.team.list") + if jsonOutput { + print(jsonString(response)) + } else { + let teams = response["teams"] as? [[String: Any]] ?? [] + let selected = response["selected_team_id"] as? String + for team in teams { + guard let id = team["id"] as? String else { continue } + let name = team["display_name"] as? String ?? id + print("\(id == selected ? "*" : " ") \(name) (\(id))") + } + } + case "use": + guard commandArgs.count == 2, !commandArgs[1].isEmpty else { + throw CLIError(message: String( + localized: "cli.auth.team.useUsage", + defaultValue: "Usage: cmux auth team use " + )) + } + let response = try client.sendV2( + method: "auth.team.use", + params: ["team_id": commandArgs[1]] + ) + if jsonOutput { + print(jsonString(response)) + } else { + let selected = response["selected_team_id"] as? String ?? commandArgs[1] + print(String(format: String( + localized: "cli.auth.team.selected", + defaultValue: "Selected team: %@" + ), selected)) + } + case "create": + let displayName = commandArgs.dropFirst().joined(separator: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !displayName.isEmpty else { + throw CLIError(message: String( + localized: "cli.auth.team.createUsage", + defaultValue: "Usage: cmux auth team create " + )) + } + let response = try client.sendV2( + method: "auth.team.create", + params: ["display_name": displayName] + ) + if jsonOutput { + print(jsonString(response)) + } else { + let selected = response["selected_team_id"] as? String ?? displayName + print(String(format: String( + localized: "cli.auth.team.created", + defaultValue: "Created and selected team: %@" + ), selected)) + } + default: + throw CLIError(message: String( + localized: "cli.auth.team.usage", + defaultValue: "Usage: cmux auth team |create >" + )) + } + } +} diff --git a/CLI/cmux.swift b/CLI/cmux.swift index bf17f8ca14dd..c58c8b0c73b1 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -5311,7 +5311,6 @@ struct CMUXCLI { } } defer { client.close() } - try authenticateClientIfNeeded( client, explicitPassword: socketPasswordArg, @@ -5319,7 +5318,6 @@ struct CMUXCLI { responseTimeout: cursorHookSocketTimeout, deadline: cursorHookDeadline ) - let idFormat = try resolvedIDFormat(jsonOutput: jsonOutput, raw: idFormatArg) // Workspace inspection JSON is a scripting boundary: keep stable UUIDs // beside renumberable refs unless the caller explicitly chooses a format. @@ -5335,33 +5333,26 @@ struct CMUXCLI { throw error } } - let capturesSocketErrorsInsideCommand = ["claude-hook", "codex-hook", "feed-hook", "hooks"].contains(command) // Backwards compatibility aliases stay hidden from help. do { switch command { case "automation": try runAutomationCommand(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput) - case "__sidebar_footer_icon_balance": let response = try sendV1Command("__sidebar_footer_icon_balance", client: client) print(response) - case "__internal_flags": let response = try sendV1Command("__internal_flags", client: client) print(response) - case "ping": let response = try sendV1Command("ping", client: client) print(response) - case "iroh-diag": let response = try sendV1Command("iroh_diag", client: client) print(response) - case "capabilities": let response = try client.sendV2(method: "system.capabilities") print(jsonString(formatIDs(response, mode: idFormat))) - case "agent-hibernation": try runAgentHibernation(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput) @@ -5459,9 +5450,17 @@ struct CMUXCLI { } else { print("Sign-out requested but state hasn't cleared yet. Run `cmux auth status` to confirm.") } - + case "team": + try runAuthTeamCommand( + commandArgs: Array(authArgs.dropFirst()), + client: client, + jsonOutput: jsonOutput + ) default: - throw CLIError(message: "Usage: cmux auth ") + throw CLIError(message: String( + localized: "cli.auth.usage", + defaultValue: "Usage: cmux auth " + )) } case "agent": @@ -18194,13 +18193,14 @@ struct CMUXCLI { case "billing": return "Usage: cmux billing checkout --plan [--no-open]\n\nCreate checkout for the signed-in cmux account. Max is $200/month. Payment requires browser confirmation. --no-open or --json returns the URL without opening a browser." case "auth": - return """ - Usage: cmux auth + return String(localized: "cli.auth.help", defaultValue: """ + Usage: cmux auth status Print whether the user is signed in (add `cmux --json` for JSON). login Open the sign-in popup on the cmux web app and wait for it to finish. logout Clear the current session. - """ + team List teams or select one (`team list|use |create `). + """) case "login": return """ Usage: cmux login @@ -41155,7 +41155,7 @@ export default CMUXSessionRestore; capabilities events [--after ] [--cursor-file ] [--name ] [--category ] [--reconnect] [--limit ] [--no-ack] [--no-heartbeat] automation [args] - auth + auth login | logout (aliases for auth login/logout) \(localizedCoderouterAliases()) \(localizedCoderouterCommands()) diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Client/AuthClient.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Client/AuthClient.swift index 11955c88c170..fb20b0a5a4ea 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Client/AuthClient.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Client/AuthClient.swift @@ -39,6 +39,23 @@ public protocol AuthClient: Sendable { /// - Returns: The user's teams; empty when no user is signed in. func listTeams() async throws -> [CMUXAuthTeam] + /// Read Stack Auth's cross-device selected team, when the backend exposes + /// one for the current session. + func selectedTeamID() async throws -> String? + + /// Persist the user's selected team in Stack Auth. + /// + /// The local coordinator keeps a durable fallback for offline launch, but + /// this server value is the cross-device source of truth used by the web + /// dashboard and other cmux clients. + /// - Parameter id: The team id to select, or `nil` to clear the selection. + func setSelectedTeam(id: String?) async throws + + /// Create a Stack Auth team and add the current user as its creator. + /// - Parameter displayName: The team's display name. + /// - Returns: The newly-created team summary. + func createTeam(displayName: String) async throws -> CMUXAuthTeam + /// Send a magic-link email and return the opaque nonce to combine with the /// user-entered code. /// - Parameters: @@ -102,3 +119,25 @@ public protocol AuthClient: Sendable { /// could be resolved (offline, dead server). func freshAccessToken(accessToken: String?, refreshToken: String) async -> String? } + +/// Errors returned when a team operation cannot be performed by an auth client. +public enum AuthClientError: Error, Equatable, Sendable { + /// The client does not support team mutation (used by test-only clients). + case unsupported + /// The requested team is not in the authenticated user's membership list. + case teamNotAvailable + /// The requested team name is empty after trimming. + case invalidTeamName +} + +public extension AuthClient { + func selectedTeamID() async throws -> String? { nil } + + func setSelectedTeam(id: String?) async throws { + throw AuthClientError.unsupported + } + + func createTeam(displayName: String) async throws -> CMUXAuthTeam { + throw AuthClientError.unsupported + } +} diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Client/StackAuthClient.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Client/StackAuthClient.swift index 7dcc60104cc5..f3423f58e2ee 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Client/StackAuthClient.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Client/StackAuthClient.swift @@ -10,11 +10,18 @@ public import StackAuth /// safe to inject as `any AuthClient`. public struct StackAuthClient: AuthClient { private let stack: StackClientApp + private let apiBaseURL: URL? /// Wrap a Stack client app. /// - Parameter stack: The configured Stack client to delegate to. public init(stack: StackClientApp) { self.stack = stack + self.apiBaseURL = nil + } + + private init(stack: StackClientApp, apiBaseURL: URL?) { + self.stack = stack + self.apiBaseURL = apiBaseURL } /// Build a Stack client from resolved config and a token-store choice. @@ -42,7 +49,8 @@ public struct StackAuthClient: AuthClient { tokenStore: tokenStore, noAutomaticPrefetch: noAutomaticPrefetch, oauthBrowserSessionPrivacy: oauthBrowserSessionPrivacy - ) + ), + apiBaseURL: URL(string: config.apiBaseURL) ) } @@ -78,6 +86,57 @@ public struct StackAuthClient: AuthClient { return summaries } + public func selectedTeamID() async throws -> String? { + guard let user = try await stack.getUser(or: .returnNull) else { return nil } + return await user.selectedTeam?.id + } + + public func setSelectedTeam(id: String?) async throws { + guard let user = try await stack.getUser(or: .returnNull) else { + throw AuthClientError.unsupported + } + try await user.setSelectedTeam(id: id) + } + + public func createTeam(displayName: String) async throws -> CMUXAuthTeam { + if let apiBaseURL { + return try await createTeamThroughCmuxBackend(displayName: displayName, apiBaseURL: apiBaseURL) + } + guard let user = try await stack.getUser(or: .returnNull) else { + throw AuthClientError.unsupported + } + let team = try await user.createTeam(displayName: displayName) + return CMUXAuthTeam( + id: team.id, + displayName: await team.displayName + ) + } + + private func createTeamThroughCmuxBackend( + displayName: String, + apiBaseURL: URL + ) async throws -> CMUXAuthTeam { + guard let accessToken = await stack.getAccessToken(), + let refreshToken = await stack.getRefreshToken() else { + throw AuthClientError.unsupported + } + let endpoint = apiBaseURL.appendingPathComponent("api/subrouter/teams") + var request = URLRequest(url: endpoint) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + request.setValue(refreshToken, forHTTPHeaderField: "X-Stack-Refresh-Token") + request.httpBody = try JSONEncoder().encode(CreateTeamRequest(displayName: displayName)) + let (data, response) = try await URLSession.shared.data(for: request) + guard let http = response as? HTTPURLResponse, + (200..<300).contains(http.statusCode) else { + throw AuthClientError.unsupported + } + let created = try JSONDecoder().decode(CreateTeamResponse.self, from: data) + return CMUXAuthTeam(id: created.team.id, displayName: created.team.name) + } + public func sendMagicLinkEmail(email: String, callbackURL: String) async throws -> String { try await stack.sendMagicLinkEmail(email: email, callbackUrl: callbackURL) } @@ -156,3 +215,16 @@ extension CurrentUser { ) } } + +private struct CreateTeamRequest: Encodable { + let displayName: String +} + +private struct CreateTeamResponse: Decodable { + let team: TeamSummary + + struct TeamSummary: Decodable { + let id: String + let name: String + } +} diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator+TeamSelection.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator+TeamSelection.swift new file mode 100644 index 000000000000..395fd05487c2 --- /dev/null +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator+TeamSelection.swift @@ -0,0 +1,53 @@ +public import CMUXAuthCore + +public extension AuthCoordinator { + /// Persist a team selection on Stack Auth before changing the local + /// projection. Every UI surface uses this action so a rejected request + /// leaves the current cloud scope and open work untouched. + /// - Parameter id: A team id from ``availableTeams``. + func selectTeam(id: String?) async throws { + if let id, !availableTeams.contains(where: { $0.id == id }) { + throw AuthClientError.teamNotAvailable + } + teamMutationGeneration &+= 1 + let mutationGeneration = teamMutationGeneration + let sessionGeneration = self.sessionGeneration + try await client.setSelectedTeam(id: id) + guard sessionGeneration == self.sessionGeneration, + mutationGeneration == teamMutationGeneration, + isAuthenticated else { + throw AuthError.unauthorized + } + selectedTeamID = id + } + + /// Creates a team, refreshes membership, and selects the new team. + /// - Parameter displayName: The display name entered by the user. + /// - Returns: The authoritative newly-created team. + func createTeam(displayName: String) async throws -> CMUXAuthTeam { + let trimmed = displayName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { throw AuthClientError.invalidTeamName } + guard isAuthenticated else { throw AuthError.unauthorized } + teamMutationGeneration &+= 1 + let mutationGeneration = teamMutationGeneration + let generation = sessionGeneration + let created = try await client.createTeam(displayName: trimmed) + guard generation == sessionGeneration, + mutationGeneration == teamMutationGeneration, + isAuthenticated else { + throw AuthError.unauthorized + } + var refreshed = try await client.listTeams() + guard generation == sessionGeneration, + mutationGeneration == teamMutationGeneration, + isAuthenticated else { + throw AuthError.unauthorized + } + if !refreshed.contains(where: { $0.id == created.id }) { + refreshed.append(created) + } + availableTeams = refreshed + try await selectTeam(id: created.id) + return created + } +} diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator.swift index a7098a0d4ed1..7af1611009f6 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator.swift @@ -40,7 +40,7 @@ public final class AuthCoordinator { /// Whether a cached session is being restored/validated at launch. public private(set) var isRestoringSession = false /// The teams the signed-in user belongs to (refreshed on sign-in/restore). - public private(set) var availableTeams: [CMUXAuthTeam] = [] { + public internal(set) var availableTeams: [CMUXAuthTeam] = [] { didSet { publishAuthenticatedTeamScope() } } /// The user's selected team id. Writes persist through the injected @@ -144,9 +144,9 @@ public final class AuthCoordinator { @ObservationIgnored var activeTokenTouchingPhases: [UUID: AuthTrackedTokenWork] = [:] @ObservationIgnored var timedOutTokenTouchingPhaseStates: [AuthPhase: AuthPhaseTimedOutState] = [:] @ObservationIgnored var tokenTouchingTimedOutResetNanoseconds: UInt64 = 30_000_000_000 + @ObservationIgnored var teamMutationGeneration: UInt64 = 0 @ObservationIgnored var isCapturingSignOutCredentials = false @ObservationIgnored var signOutCredentialCaptureWaiters: [CheckedContinuation] = [] - /// Begin a sign-in flow: register it as the newest attempt and capture /// the staleness context. Call before the flow's first await. private func beginSignInFlow() async throws -> SignInFlowContext { @@ -617,7 +617,6 @@ public final class AuthCoordinator { } // MARK: - State helpers - /// Why a signed-in user is being published, deciding whether the session /// generation advances. enum SessionPublication { @@ -636,7 +635,6 @@ public final class AuthCoordinator { /// signed-out state) is still a transition and advances. case revalidation } - func applySignedInUser( _ user: CMUXAuthUser, publication: SessionPublication @@ -685,7 +683,6 @@ public final class AuthCoordinator { await onSignedIn() } } - /// Refresh ``availableTeams`` from the client, tolerating failure so a /// flaky team fetch never blocks or unwinds a successful sign-in. Drops /// the writes when a sign-out raced the fetch, so a signed-out shell does @@ -693,18 +690,22 @@ public final class AuthCoordinator { private func refreshTeams(generation: UInt64) async { do { let client = self.client - let teams = try await runPhase(.listTeams, timeout: timeouts.network) { - try await client.listTeams() + let (teams, serverSelectedTeamID) = try await runPhase(.listTeams, timeout: timeouts.network) { + async let teams = client.listTeams() + async let selectedTeamID: String? = try? await client.selectedTeamID() + return try await (teams, selectedTeamID) } guard generation == sessionGeneration else { return } authenticatedTeamsSessionGeneration = generation availableTeams = teams - selectedTeamID = Self.resolveTeamID(selectedTeamID: selectedTeamID, teams: teams) + selectedTeamID = Self.resolveTeamID( + selectedTeamID: serverSelectedTeamID ?? selectedTeamID, + teams: teams + ) } catch { authLog.error("Failed to list teams: \(error.localizedDescription, privacy: .private)") } } - private static func resolveTeamID( selectedTeamID: String?, teams: [CMUXAuthTeam] @@ -718,7 +719,6 @@ public final class AuthCoordinator { } return teams.first?.id } - func clearAuthState( preservePendingCode: Bool = false, sessionTransitionAlreadyAnnounced: Bool = false diff --git a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/AuthCoordinatorTeamActionsTests.swift b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/AuthCoordinatorTeamActionsTests.swift new file mode 100644 index 000000000000..bf1488ce6633 --- /dev/null +++ b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/AuthCoordinatorTeamActionsTests.swift @@ -0,0 +1,65 @@ +import CMUXAuthCore +import Foundation +import Testing +@testable import CmuxAuthRuntime + +@MainActor +@Suite("Auth coordinator team actions") +struct AuthCoordinatorTeamActionsTests { + private func makeCoordinator(client: FakeAuthClient) -> AuthCoordinator { + let store = FakeKeyValueStore() + return AuthCoordinator( + client: client, + sessionCache: CMUXAuthSessionCache(keyValueStore: store, key: "has_tokens"), + userCache: CMUXAuthIdentityStore(keyValueStore: store, key: "cached_user"), + teamSelection: CMUXAuthTeamSelectionStore(keyValueStore: store, key: "selected_team"), + anchor: FakeAnchor(), + config: .test, + launch: .plain() + ) + } + + @Test func selectingTeamPersistsServerAndPublishesScope() async throws { + let user = CMUXAuthUser(id: "user", primaryEmail: "user@example.com", displayName: "User") + let client = FakeAuthClient(user: user) + await client.setTeams([ + CMUXAuthTeam(id: "team-a", displayName: "Alpha"), + CMUXAuthTeam(id: "team-b", displayName: "Beta") + ]) + let coordinator = makeCoordinator(client: client) + try await coordinator.signInWithPassword(email: "user@example.com", password: "password") + + try await coordinator.selectTeam(id: "team-b") + + #expect(coordinator.resolvedTeamID == "team-b") + #expect(await client.lastSelectedTeamID == "team-b") + } + + @Test func creatingTeamAddsAndSelectsAuthoritativeTeam() async throws { + let user = CMUXAuthUser(id: "user", primaryEmail: "user@example.com", displayName: "User") + let client = FakeAuthClient(user: user) + await client.setTeams([CMUXAuthTeam(id: "team-a", displayName: "Alpha")]) + let coordinator = makeCoordinator(client: client) + try await coordinator.signInWithPassword(email: "user@example.com", password: "password") + + let created = try await coordinator.createTeam(displayName: "Shared Cloud") + + #expect(created.displayName == "Shared Cloud") + #expect(coordinator.resolvedTeamID == created.id) + #expect(coordinator.availableTeams.contains(created)) + #expect(await client.lastSelectedTeamID == created.id) + } + + @Test func selectingUnknownTeamDoesNotChangeScope() async throws { + let user = CMUXAuthUser(id: "user", primaryEmail: "user@example.com", displayName: "User") + let client = FakeAuthClient(user: user) + await client.setTeams([CMUXAuthTeam(id: "team-a", displayName: "Alpha")]) + let coordinator = makeCoordinator(client: client) + try await coordinator.signInWithPassword(email: "user@example.com", password: "password") + + await #expect(throws: AuthClientError.teamNotAvailable) { + try await coordinator.selectTeam(id: "not-a-member") + } + #expect(coordinator.resolvedTeamID == "team-a") + } +} diff --git a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/Fakes.swift b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/Fakes.swift index 0fe9a115f3bd..a4ab23842258 100644 --- a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/Fakes.swift +++ b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/Fakes.swift @@ -26,6 +26,9 @@ actor FakeAuthClient: AuthClient { var forceRefreshResult: String?? var user: CMUXAuthUser? var teams: [CMUXAuthTeam] = [] + private(set) var lastSelectedTeamID: String? + var serverSelectedTeamID: String? + var nextCreatedTeamID = "team-created" var throwOnCurrentUser: (any Error)? var throwOnListTeams: (any Error)? var nonce = "nonce-123" @@ -98,6 +101,20 @@ actor FakeAuthClient: AuthClient { return teams } + func selectedTeamID() async throws -> String? { serverSelectedTeamID } + + func setSelectedTeam(id: String?) async throws { + lastSelectedTeamID = id + serverSelectedTeamID = id + } + + func createTeam(displayName: String) async throws -> CMUXAuthTeam { + let team = CMUXAuthTeam(id: nextCreatedTeamID, displayName: displayName) + teams.append(team) + lastSelectedTeamID = team.id + return team + } + func sendMagicLinkEmail(email: String, callbackURL: String) async throws -> String { nonce } func signInWithMagicLink(code: String) async throws { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift index 5788bf1af6ef..5cfdb08b2017 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift @@ -871,13 +871,13 @@ struct MobileSettingsView: View { /// Drives the team Picker. Reads the EFFECTIVE current team (`resolvedTeamID`, /// which falls back to the first team when nothing is explicitly selected) so /// the picker always shows a concrete selection, and writes the user's choice - /// to `selectedTeamID` (persisted; observed by the root for the lazy re-scope). + /// through the shared coordinator action (persisted; observed by the root for the lazy re-scope). private var teamSelection: Binding { Binding( get: { authManager.resolvedTeamID }, set: { newValue in if let newValue, newValue != authManager.selectedTeamID { - authManager.selectedTeamID = newValue + Task { try? await authManager.selectTeam(id: newValue) } } } ) diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Popover/ArrowlessPopoverAnchor.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Popover/ArrowlessPopoverAnchor.swift index c51bae693a40..053a659ba62d 100644 --- a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Popover/ArrowlessPopoverAnchor.swift +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Popover/ArrowlessPopoverAnchor.swift @@ -10,6 +10,7 @@ public struct ArrowlessPopoverAnchor: NSViewRepresentable @Binding public var isPresented: Bool public let preferredEdge: NSRectEdge public let detachedGap: CGFloat + private let group: CmuxPopoverGroup? @ViewBuilder public let content: () -> PopoverContent /// Creates an arrowless popover anchor. @@ -17,16 +18,19 @@ public struct ArrowlessPopoverAnchor: NSViewRepresentable /// - isPresented: Binding driving popover presentation. /// - preferredEdge: The edge of the anchor the popover prefers to appear from. /// - detachedGap: The gap, in points, between the anchor edge and the popover. + /// - group: Shared dismissal owner when this popover belongs to a nested menu. /// - content: The SwiftUI content rendered inside the popover. public init( isPresented: Binding, preferredEdge: NSRectEdge, detachedGap: CGFloat, + group: CmuxPopoverGroup? = nil, @ViewBuilder content: @escaping () -> PopoverContent ) { self._isPresented = isPresented self.preferredEdge = preferredEdge self.detachedGap = detachedGap + self.group = group self.content = content } @@ -39,6 +43,7 @@ public struct ArrowlessPopoverAnchor: NSViewRepresentable public func updateNSView(_ nsView: NSView, context: Context) { let coordinator = context.coordinator coordinator.anchorView = nsView + coordinator.updatePresentationBinding($isPresented) switch ArrowlessPopoverRootViewUpdatePolicy.rootViewUpdateStrategy( isPresented: isPresented, popoverIsShown: coordinator.isPopoverShown @@ -62,7 +67,11 @@ public struct ArrowlessPopoverAnchor: NSViewRepresentable } public func makeCoordinator() -> Coordinator { - Coordinator(isPresented: $isPresented) + Coordinator(isPresented: $isPresented, group: group) + } + + public static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) { + coordinator.dismiss() } /// Bridges popover lifecycle between AppKit's `NSPopover` and the SwiftUI binding. @@ -75,10 +84,17 @@ public struct ArrowlessPopoverAnchor: NSViewRepresentable private let visibleUpdateScheduler = CmuxPopoverVisibleUpdateScheduler() private var popover: NSPopover? private var pendingVisibleRootView: AnyView? + private let group: CmuxPopoverGroup? + private var groupMemberID: UUID? var isPopoverShown: Bool { popover?.isShown == true } - init(isPresented: Binding) { + init(isPresented: Binding, group: CmuxPopoverGroup?) { _isPresented = isPresented + self.group = group + } + + func updatePresentationBinding(_ binding: Binding) { + _isPresented = binding } func updateRootView(_ rootView: AnyView) { @@ -141,14 +157,28 @@ public struct ArrowlessPopoverAnchor: NSViewRepresentable of: anchorView, preferredEdge: preferredEdge ) + if popover.isShown { + groupMemberID = group?.register(popover: popover, anchor: anchorView) + } } func dismiss() { cancelDeferredRootViewUpdate() + unregisterFromGroup() popover?.performClose(nil) popover = nil } + public func popoverWillClose(_ notification: Notification) { + unregisterFromGroup() + } + + private func unregisterFromGroup() { + guard let id = groupMemberID else { return } + groupMemberID = nil + group?.unregister(id) + } + public func popoverDidClose(_ notification: Notification) { cancelDeferredRootViewUpdate() popover = nil @@ -159,8 +189,8 @@ public struct ArrowlessPopoverAnchor: NSViewRepresentable private func makePopover() -> NSPopover { let popover = NSPopover() - popover.behavior = .semitransient - popover.animates = true + popover.behavior = group == nil ? .semitransient : .applicationDefined + popover.animates = group == nil popover.setValue(true, forKeyPath: "shouldHideAnchor") popover.contentViewController = hostingController popover.delegate = self diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Popover/CmuxPopoverGroup.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Popover/CmuxPopoverGroup.swift new file mode 100644 index 000000000000..ba5ea6595039 --- /dev/null +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Popover/CmuxPopoverGroup.swift @@ -0,0 +1,198 @@ +import AppKit + +/// Owns click-away dismissal for a menu and its nested popovers. +/// +/// Pass the same instance to each ``ArrowlessPopoverAnchor`` in the menu. +/// Members use application-defined behavior because AppKit does not support +/// anchoring a semitransient popover inside another popover. +@MainActor +public final class CmuxPopoverGroup { + private struct Member { + let id: UUID + let parent: UUID? + let contains: (Int?, CGPoint) -> Bool + let containsPointer: (Int?, CGPoint) -> Bool + let close: () -> Void + } + + private struct MouseTracking { + weak var window: NSWindow? + let previousValue: Bool + var members: Set + } + + private var members: [Member] = [] + private var windows: [UUID: () -> NSWindow?] = [:] + private var mouseTracking: [ObjectIdentifier: MouseTracking] = [:] + private var localMonitor: Any? + private var globalMonitor: Any? + private var activationObserver: (any NSObjectProtocol)? + + /// Creates an independent dismissal group for one menu presentation. + public init() {} + + func register(popover: NSPopover, anchor: NSView) -> UUID { + let id = UUID() + let parent = members.last { windows[$0.id]?() === anchor.window }?.id + let contains: (Int?, CGPoint) -> Bool = { [weak popover, weak anchor] windowNumber, point in + if let window = popover?.contentViewController?.view.window, + popover?.isShown == true, + windowNumber == nil || windowNumber == window.windowNumber, + window.frame.contains(point) { + return true + } + // Let the footer button toggle its own menu on a second click. + guard parent == nil, let anchor, let window = anchor.window, + windowNumber == nil || windowNumber == window.windowNumber else { return false } + return window.convertToScreen(anchor.convert(anchor.bounds, to: nil)).contains(point) + } + let containsPointer: (Int?, CGPoint) -> Bool = { [weak popover, weak anchor] _, point in + guard let popover, popover.isShown, + let submenu = popover.contentViewController?.view.window, + let anchor, let sourceWindow = anchor.window else { return false } + let source = sourceWindow.convertToScreen(anchor.convert(anchor.bounds, to: nil)) + return CmuxSubmenuHoverRegion(source: source, submenu: submenu.frame).contains(point) + } + register( + id: id, + parent: parent, + contains: contains, + containsPointer: containsPointer, + close: { [weak popover] in popover?.close() } + ) + windows[id] = { [weak popover] in popover?.contentViewController?.view.window } + enableMouseTracking(in: anchor.window, for: id) + enableMouseTracking(in: popover.contentViewController?.view.window, for: id) + startMonitoring() + return id + } + + // The lifecycle and hit-testing seam is independent of AppKit windows so + // tests can drive the same click/close path with synthetic menu rectangles. + func register( + id: UUID, + parent: UUID?, + contains: @escaping (Int?, CGPoint) -> Bool, + containsPointer: ((Int?, CGPoint) -> Bool)? = nil, + close: @escaping () -> Void + ) { + members.append(Member( + id: id, + parent: parent, + contains: contains, + containsPointer: containsPointer ?? contains, + close: close + )) + } + + func handleClick(windowNumber: Int?, point: CGPoint) { + guard !members.contains(where: { $0.contains(windowNumber, point) }) else { return } + dismissAll() + } + + func handleMove(windowNumber: Int?, point: CGPoint) { + for member in members.reversed() where member.parent != nil { + guard members.contains(where: { $0.id == member.id }) else { continue } + if member.containsPointer(windowNumber, point) { return } + unregister(member.id) + // Unregistering retires tracking; it does not close this window. + member.close() + } + } + + /// Closes children before their parent so no detached child can retain an + /// empty parent popover window. Safe when a close callback re-enters here. + func unregister(_ id: UUID) { + var removed: Set = [id] + for member in members where member.parent.map(removed.contains) == true { + removed.insert(member.id) + } + let children = members.filter { $0.id != id && removed.contains($0.id) } + members.removeAll { removed.contains($0.id) } + for removedID in removed { windows[removedID] = nil } + restoreMouseTracking(removing: removed) + if members.isEmpty { stopMonitoring() } + for member in children.reversed() { member.close() } + } + + /// Dismisses the entire menu, preserving the underlying click event. + public func dismissAll() { + let closing = members.reversed() + members = [] + windows = [:] + restoreMouseTracking(removing: Set(closing.map(\.id))) + stopMonitoring() + for member in closing { member.close() } + } + + private func startMonitoring() { + guard localMonitor == nil else { return } + let clicks: NSEvent.EventTypeMask = [.leftMouseDown, .rightMouseDown, .otherMouseDown] + localMonitor = NSEvent.addLocalMonitorForEvents(matching: clicks.union([.keyDown, .mouseMoved])) { [weak self] event in + // AppKit delivers local event monitors on the main thread. + let consumed = MainActor.assumeIsolated { + if event.type == .keyDown { + guard event.keyCode == 53 else { return false } + self?.dismissAll() + return true + } + let point = event.window?.convertPoint(toScreen: event.locationInWindow) ?? NSEvent.mouseLocation + if event.type == .mouseMoved { + self?.handleMove(windowNumber: event.window?.windowNumber, point: point) + } else { + self?.handleClick(windowNumber: event.window?.windowNumber, point: point) + } + return false + } + return consumed ? nil : event + } + globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: clicks.union(.mouseMoved)) { [weak self] event in + MainActor.assumeIsolated { + if event.type == .mouseMoved { + self?.handleMove(windowNumber: nil, point: NSEvent.mouseLocation) + } else { + self?.dismissAll() + } + } + } + activationObserver = NotificationCenter.default.addObserver( + forName: NSApplication.didResignActiveNotification, object: nil, queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { self?.dismissAll() } + } + } + + private func stopMonitoring() { + if let localMonitor { NSEvent.removeMonitor(localMonitor) } + if let globalMonitor { NSEvent.removeMonitor(globalMonitor) } + if let activationObserver { NotificationCenter.default.removeObserver(activationObserver) } + localMonitor = nil + globalMonitor = nil + activationObserver = nil + } + + private func enableMouseTracking(in window: NSWindow?, for member: UUID) { + guard let window else { return } + let id = ObjectIdentifier(window) + if mouseTracking[id] == nil { + mouseTracking[id] = MouseTracking( + window: window, previousValue: window.acceptsMouseMovedEvents, members: [] + ) + } + mouseTracking[id]?.members.insert(member) + window.acceptsMouseMovedEvents = true + } + + private func restoreMouseTracking(removing members: Set) { + for id in Array(mouseTracking.keys) { + guard var tracking = mouseTracking[id] else { continue } + tracking.members.subtract(members) + if tracking.members.isEmpty { + tracking.window?.acceptsMouseMovedEvents = tracking.previousValue + mouseTracking[id] = nil + } else { + mouseTracking[id] = tracking + } + } + } +} diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Popover/CmuxSubmenuHoverRegion.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Popover/CmuxSubmenuHoverRegion.swift new file mode 100644 index 000000000000..28df846dc483 --- /dev/null +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/Popover/CmuxSubmenuHoverRegion.swift @@ -0,0 +1,28 @@ +import Foundation + +/// The source row, submenu, and narrow diagonal path connecting their edges. +struct CmuxSubmenuHoverRegion { + let source: CGRect + let submenu: CGRect + + func contains(_ point: CGPoint) -> Bool { + if source.contains(point) || submenu.contains(point) { return true } + let sourceX: CGFloat + let submenuX: CGFloat + if submenu.minX >= source.maxX { + sourceX = source.maxX + submenuX = submenu.minX + } else if submenu.maxX <= source.minX { + sourceX = source.minX + submenuX = submenu.maxX + } else { + return false + } + guard sourceX != submenuX else { return false } + let progress = (point.x - sourceX) / (submenuX - sourceX) + guard (0...1).contains(progress) else { return false } + let lowerY = source.minY + (submenu.minY - source.minY) * progress + let upperY = source.maxY + (submenu.maxY - source.maxY) * progress + return point.y >= lowerY && point.y <= upperY + } +} diff --git a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift index 7a376bb6d3ab..88a660549a66 100644 --- a/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift +++ b/Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Wire/ControlCommandExecutionPolicy.swift @@ -68,13 +68,11 @@ public enum ControlCommandExecutionPolicy: Sendable, Equatable { self = .mainActor } } - /// True when the command runs on the socket-worker thread. public var runsOnSocketWorker: Bool { if case .socketWorker = self { return true } return false } - /// Socket-worker methods; internal so package tests can pin the exact set. static let socketWorkerMethods: Set = Set([ "system.ping", @@ -83,6 +81,9 @@ public enum ControlCommandExecutionPolicy: Sendable, Equatable { "auth.sign_in_url", "auth.begin_sign_in", "auth.sign_out", + "auth.team.list", + "auth.team.use", + "auth.team.create", "feedback.submit", // `feed.jump` awaits its actor-owned hook-session lookup while the // socket worker waits for the response. @@ -91,8 +92,7 @@ public enum ControlCommandExecutionPolicy: Sendable, Equatable { "feed.permission.reply", "feed.question.reply", "feed.exit_plan.reply", - // Admission only appends an immutable event to the actor-owned queue; - // all downstream process/socket work happens after the reply. + // Admission appends an immutable event to the actor-owned queue. "agent.hook.enqueue", "agent.hook.barrier", // Performs a fresh off-main process scan before one agent exec. Only diff --git a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift index f0ef280603e6..6918ca4a6c07 100644 --- a/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift +++ b/Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandExecutionPolicyTests.swift @@ -42,6 +42,7 @@ struct ControlCommandExecutionPolicyTests { @Test func fixedWorkerSetRunsOnTheSocketWorker() { for method in [ "system.ping", "system.capabilities", "auth.status", "auth.sign_in_url", + "auth.team.list", "auth.team.use", "auth.team.create", "feed.jump", "feed.push", "agent.hook.enqueue", "agent.hook.barrier", "agent.restore.admit", "agent.restore.release", "browser.download.list", "browser.download.wait", "system.top", "system.memory", diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift index ba846d083d56..ff1cc4644188 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Defaults.swift @@ -68,6 +68,7 @@ extension ShortcutAction { private var builtInDefaultStroke: ShortcutStroke? { switch self { case .openSettings: return ShortcutStroke(key: ",", command: true) + case .openTeamPicker: return ShortcutStroke(key: "t", command: true, shift: true, option: true) case .reloadConfiguration: return ShortcutStroke(key: ",", command: true, shift: true) case .showHideAllWindows: return ShortcutStroke(key: ".", command: true, option: true, control: true) case .globalSearch: return ShortcutStroke(key: "f", command: true, option: true) diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+DisplayName.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+DisplayName.swift index 5d0fccc5ebd7..74188e004397 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+DisplayName.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+DisplayName.swift @@ -5,6 +5,8 @@ extension ShortcutAction { public var displayName: String { switch self { case .openSettings: return "Settings…" + case .openTeamPicker: + return String(localized: "shortcut.openTeamPicker.label", defaultValue: "Open Team Picker") case .reloadConfiguration: return "Reload Configuration" case .showHideAllWindows: return "Show/Hide All Windows" case .globalSearch: return "Global Search" diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Group.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Group.swift index 3e1d1a6d77c4..686af5bf1223 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Group.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction+Group.swift @@ -2,7 +2,7 @@ extension ShortcutAction { /// Which group this action belongs to in the settings pane. public var group: Group { switch self { - case .openSettings, .reloadConfiguration, .showHideAllWindows, .globalSearch, + case .openSettings, .openTeamPicker, .reloadConfiguration, .showHideAllWindows, .globalSearch, .newWindow, .closeWindow, .toggleFullScreen, .quit: return .app case .toggleSidebar, .newTab, .newBrowserWorkspace, .newCloudWorkspace, .newCloudMachine, .saveLayoutTemplate, .openFolder, .reopenPreviousSession, .goToWorkspace, diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift index 6c46944639fa..c25015e777a6 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutAction.swift @@ -9,6 +9,7 @@ import Foundation public enum ShortcutAction: String, CaseIterable, Sendable, Hashable, SettingCodable { // MARK: App case openSettings + case openTeamPicker case reloadConfiguration case showHideAllWindows case globalSearch diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Account/AccountFlow.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Account/AccountFlow.swift index e7e00c765e8f..21d0ddb50959 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Account/AccountFlow.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Account/AccountFlow.swift @@ -21,7 +21,11 @@ public protocol AccountFlow: AnyObject { var availableTeams: [AccountTeamSummary] { get } /// Identifier of the currently selected team, or `nil` if none. - var selectedTeamID: String? { get set } + var selectedTeamID: String? { get } + + /// Selects a team through the host's shared auth mutation path. + /// - Parameter id: A member team id, or `nil` to clear the explicit choice. + func selectTeam(id: String?) async throws /// Whether the host is currently in the middle of a sign-in or /// sign-out network round trip. The UI disables interaction while diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Account/AccountTeamPicker.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Account/AccountTeamPicker.swift index 3c9317cd83a3..bdfce78d38a3 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Account/AccountTeamPicker.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Account/AccountTeamPicker.swift @@ -7,19 +7,29 @@ struct AccountTeamPicker: View { let flow: AccountFlow var body: some View { - Picker( + SettingsCardRow( String(localized: "settings.account.activeTeam", defaultValue: "Active Team"), - selection: Binding( - get: { flow.selectedTeamID ?? "" }, - set: { newValue in - flow.selectedTeamID = newValue.isEmpty ? nil : newValue - } - ) + controlWidth: 196 ) { - Text(String(localized: "settings.account.activeTeam.none", defaultValue: "None")).tag("") - ForEach(flow.availableTeams) { team in - Text(team.displayName).tag(team.id) + Picker( + "", + selection: Binding( + get: { flow.selectedTeamID ?? "" }, + set: { newValue in + Task { + try? await flow.selectTeam(id: newValue.isEmpty ? nil : newValue) + } + } + ) + ) { + Text(String(localized: "settings.account.activeTeam.none", defaultValue: "None")).tag("") + ForEach(flow.availableTeams) { team in + Text(team.displayName).tag(team.id) + } } + .labelsHidden() + .pickerStyle(.menu) + .controlSize(.small) } } } diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AccountSection.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AccountSection.swift index 0d0f6f4c8883..56c3d430c2a6 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AccountSection.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AccountSection.swift @@ -29,7 +29,14 @@ public struct AccountSection: View { Group { SettingsSectionHeader(String(localized: "settings.section.account", defaultValue: "Account"), section: .account) SettingsCard { - AccountIdentityCard(flow: accountFlow) + VStack(alignment: .leading, spacing: 0) { + AccountIdentityCard(flow: accountFlow) + if accountFlow?.currentIdentity != nil, + !(accountFlow?.availableTeams.isEmpty ?? true) { + Divider() + AccountTeamPicker(flow: accountFlow!) + } + } } .settingsSearchAnchors(["setting:account:account"]) if accountFlow?.isProUpgradeAvailable ?? false { diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 0819f8e55eb1..b6a222905e6e 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -529821,6 +529821,1777 @@ } } } + }, + + "sidebar.account.noTeam": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No team" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kein Team" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucune équipe" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا فريق" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Sin equipo" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "沒有團隊" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "没有团队" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "팀 없음" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "チームなし" + } + } + } + }, + "sidebar.account.teamWithPlan": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ · Pro" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%1$@ · Pro" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%1$@ · Pro" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "%1$@ · Pro" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%1$@ · Pro" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%1$@ · Pro" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%1$@ · Pro" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%1$@ · Pro" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%1$@ · Pro" + } + } + } + }, + "sidebar.account.loadingTeams": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading teams…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Teams werden geladen…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Chargement des équipes…" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ تحميل الفرق…" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cargando equipos…" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在載入團隊…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在加载团队…" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "팀을 불러오는 중…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "チームを読み込み中…" + } + } + } + }, + "sidebar.account.createTeam": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Create team…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Team erstellen…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Créer une équipe…" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إنشاء فريق…" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Crear equipo…" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "建立團隊…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "创建团队…" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "팀 생성…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "チームを作成…" + } + } + } + }, + "sidebar.account.headerLabel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@, %2$@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%1$@, %2$@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%1$@, %2$@" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "%1$@، %2$@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%1$@, %2$@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%1$@,%2$@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%1$@,%2$@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%1$@, %2$@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%1$@、%2$@" + } + } + } + }, + "sidebar.account.switchTeamFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not switch teams. Try again." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Teams konnten nicht gewechselt werden. Erneut versuchen." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible de changer d’équipe. Réessayez." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذر تبديل الفرق. حاول مرة أخرى." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudieron cambiar los equipos. Inténtalo de nuevo." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法切換團隊,請再試一次。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法切换团队,请重试。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "팀을 전환할 수 없습니다. 다시 시도하세요." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "チームを切り替えられませんでした。もう一度お試しください。" + } + } + } + }, + "sidebar.account.teamRowLabel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@%2$@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%1$@%2$@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%1$@%2$@" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "%1$@%2$@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%1$@%2$@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%1$@%2$@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%1$@%2$@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%1$@%2$@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%1$@%2$@" + } + } + } + }, + "sidebar.account.activeSuffix": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": ", active" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": ", aktiv" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": ", active" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "، نشط" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": ", activo" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": ",目前使用中" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": ",当前使用中" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": ", 활성" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "、アクティブ" + } + } + } + }, + "sidebar.account.createTeamPlaceholder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Team name" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Teamname" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nom de l’équipe" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "اسم الفريق" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Nombre del equipo" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "團隊名稱" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "团队名称" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "팀 이름" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "チーム名" + } + } + } + }, + "sidebar.account.createTeamSubmit": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Create team" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Team erstellen" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Créer une équipe" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إنشاء فريق" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Crear equipo" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "建立團隊" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "创建团队" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "팀 생성" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "チームを作成" + } + } + } + }, + "sidebar.account.createTeamCancel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cancel" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Abbrechen" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Annuler" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إلغاء" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cancelar" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "取消" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "取消" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "취소" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "キャンセル" + } + } + } + }, + "sidebar.account.createTeamFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not create that team. Try again." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Team konnte nicht erstellt werden. Erneut versuchen." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible de créer cette équipe. Réessayez." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذر إنشاء الفريق. حاول مرة أخرى." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo crear el equipo. Inténtalo de nuevo." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法建立團隊,請再試一次。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法创建团队,请重试。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "팀을 만들 수 없습니다. 다시 시도하세요." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "チームを作成できませんでした。もう一度お試しください。" + } + } + } + }, + "shortcut.openTeamPicker.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Team Picker" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Team-Auswahl öffnen" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvrir le sélecteur d’équipe" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فتح مُحدِّد الفرق" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Abrir selector de equipos" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "開啟團隊選擇器" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开团队选择器" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "팀 선택기 열기" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "チームピッカーを開く" + } + } + } + }, + "machines.teamSwitch.disconnectedDetail": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Cloud VM access moved to another team." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Cloud-VM-Zugriff wurde in ein anderes Team verschoben." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "L’accès aux VM Cloud est passé à une autre équipe." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم نقل الوصول إلى أجهزة Cloud الافتراضية إلى فريق آخر." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El acceso a las VM de Cloud se trasladó a otro equipo." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Cloud VM 存取權已移至另一個團隊。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Cloud VM 访问权限已移至另一个团队。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Cloud VM 액세스가 다른 팀으로 이동했습니다." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Cloud VM のアクセス先が別のチームに移動しました。" + } + } + } + }, + "socket.authTeam.missingTeam": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "A team id is required." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Eine Team-ID ist erforderlich." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Un identifiant d’équipe est requis." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "معرّف الفريق مطلوب." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Se necesita un ID de equipo." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "需要團隊 ID。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "需要团队 ID。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "팀 ID가 필요합니다." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "チーム ID が必要です。" + } + } + } + }, + "socket.authTeam.missingName": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "A team name is required." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ein Teamname ist erforderlich." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Un nom d’équipe est requis." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "اسم الفريق مطلوب." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Se necesita un nombre de equipo." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "需要團隊名稱。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "需要团队名称。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "팀 이름이 필요합니다." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "チーム名が必要です。" + } + } + } + }, + "socket.authTeam.unknownMethod": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unknown team action." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Unbekannte Teamaktion." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Action d’équipe inconnue." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إجراء فريق غير معروف." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Acción de equipo desconocida." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未知的團隊操作。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未知的团队操作。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "알 수 없는 팀 작업입니다." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "不明なチーム操作です。" + } + } + } + }, + "cli.auth.team.useUsage": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Usage: cmux auth team use " + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verwendung: cmux auth team use " + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Utilisation : cmux auth team use " + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الاستخدام: cmux auth team use " + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Uso: cmux auth team use " + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "用法:cmux auth team use " + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "用法:cmux auth team use " + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용법: cmux auth team use " + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "使い方: cmux auth team use " + } + } + } + }, + "cli.auth.team.createUsage": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Usage: cmux auth team create " + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verwendung: cmux auth team create " + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Utilisation : cmux auth team create " + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الاستخدام: cmux auth team create " + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Uso: cmux auth team create " + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "用法:cmux auth team create " + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "用法:cmux auth team create " + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용법: cmux auth team create " + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "使い方: cmux auth team create " + } + } + } + }, + "cli.auth.team.selected": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Selected team: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ausgewähltes Team: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Équipe sélectionnée : %@" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الفريق المحدد: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Equipo seleccionado: %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已選取團隊:%@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已选择团队:%@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "선택한 팀: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "選択したチーム: %@" + } + } + } + }, + "cli.auth.team.created": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Created and selected team: %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Team erstellt und ausgewählt: %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Équipe créée et sélectionnée : %@" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم إنشاء الفريق وتحديده: %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Equipo creado y seleccionado: %@" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已建立並選取團隊:%@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已创建并选择团队:%@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "생성 및 선택한 팀: %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "作成して選択したチーム: %@" + } + } + } + }, + "cli.auth.team.usage": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Usage: cmux auth team |create >" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verwendung: cmux auth team |create >" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Utilisation : cmux auth team |create >" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الاستخدام: cmux auth team |create >" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Uso: cmux auth team |create >" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "用法:cmux auth team |create >" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "用法:cmux auth team |create >" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용법: cmux auth team |create >" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "使い方: cmux auth team |create >" + } + } + } + }, + "command.auth.teamPicker.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Team Picker" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Team-Auswahl öffnen" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ouvrir le sélecteur d’équipe" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "فتح مُحدِّد الفرق" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Abrir selector de equipos" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "開啟團隊選擇器" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "打开团队选择器" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "팀 선택기 열기" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "チームピッカーを開く" + } + } + } + }, + "cli.auth.usage": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Usage: cmux auth " + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verwendung: cmux auth " + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Utilisation : cmux auth " + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الاستخدام: cmux auth " + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Uso: cmux auth " + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "用法:cmux auth " + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "用法:cmux auth " + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용법: cmux auth " + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "使い方: cmux auth " + } + } + } + }, + "cli.auth.help": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Usage: cmux auth \n\nstatus Print whether the user is signed in (add `cmux --json` for JSON).\nlogin Open the sign-in popup on the cmux web app and wait for it to finish.\nlogout Clear the current session.\nteam List teams or select one (`team list|use |create `)." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "使い方: cmux auth \n\nstatus サインイン状態を表示します(JSON には `cmux --json` を追加)。\nlogin cmux Web アプリのサインインポップアップを開き、完了を待ちます。\nlogout 現在のセッションを消去します。\nteam チームを一覧表示または選択します(`team list|use |create `)。" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verwendung: cmux auth \n\nstatus Zeigt den Anmeldestatus an.\nlogin Öffnet das Anmeldefenster.\nlogout Löscht die aktuelle Sitzung.\nteam Listet Teams auf oder wählt ein Team aus." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Utilisation : cmux auth \n\nstatus Affiche l’état de connexion.\nlogin Ouvre la fenêtre de connexion.\nlogout Efface la session actuelle.\nteam Liste ou sélectionne une équipe." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "الاستخدام: cmux auth \n\nstatus يعرض حالة تسجيل الدخول.\nlogin يفتح نافذة تسجيل الدخول.\nlogout يمسح الجلسة الحالية.\nteam يعرض الفرق أو يحدد فريقًا." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Uso: cmux auth \n\nstatus Muestra el estado de inicio de sesión.\nlogin Abre la ventana de inicio de sesión.\nlogout Borra la sesión actual.\nteam Lista o selecciona un equipo." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "用法:cmux auth \n\nstatus 顯示登入狀態。\nlogin 開啟登入視窗。\nlogout 清除目前工作階段。\nteam 列出或選取團隊。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "用法:cmux auth \n\nstatus 显示登录状态。\nlogin 打开登录窗口。\nlogout 清除当前会话。\nteam 列出或选择团队。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사용법: cmux auth \n\nstatus 로그인 상태를 표시합니다.\nlogin 로그인 창을 엽니다.\nlogout 현재 세션을 지웁니다.\nteam 팀을 나열하거나 선택합니다." + } + } + } + }, + "socket.authTeam.asyncRequired": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Team actions require asynchronous socket dispatch." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Teamaktionen benötigen asynchrone Socket-Verarbeitung." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Les actions d’équipe nécessitent un traitement de socket asynchrone." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تتطلب إجراءات الفريق معالجة غير متزامنة للمقبس." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Las acciones de equipo requieren un despacho de socket asíncrono." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "團隊操作需要非同步 Socket 分派。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "团队操作需要异步 Socket 分派。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "팀 작업에는 비동기 소켓 디스패치가 필요합니다." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "チーム操作には非同期ソケットディスパッチが必要です。" + } + } + } + }, + "socket.authTeam.signedOut": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sign in to manage teams." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Melden Sie sich an, um Teams zu verwalten." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Connectez-vous pour gérer les équipes." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "سجّل الدخول لإدارة الفرق." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Inicia sesión para administrar equipos." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "請登入以管理團隊。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请登录以管理团队。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "팀을 관리하려면 로그인하세요." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "チームを管理するにはサインインしてください。" + } + } + } + }, + "socket.authTeam.notMember": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "You are not a member of that team." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sie sind kein Mitglied dieses Teams." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vous n’êtes pas membre de cette équipe." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "لست عضوًا في هذا الفريق." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No eres miembro de ese equipo." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "您不是該團隊的成員。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "您不是该团队的成员。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "해당 팀의 멤버가 아닙니다." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "そのチームのメンバーではありません。" + } + } + } + }, + "socket.authTeam.invalidName": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Enter a team name." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Geben Sie einen Teamnamen ein." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Saisissez un nom d’équipe." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "أدخل اسم فريق." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Introduce un nombre de equipo." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "請輸入團隊名稱。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "请输入团队名称。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "팀 이름을 입력하세요." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "チーム名を入力してください。" + } + } + } + }, + "socket.authTeam.failed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Could not update the team. Try again." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Das Team konnte nicht aktualisiert werden. Erneut versuchen." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Impossible de mettre à jour l’équipe. Réessayez." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذر تحديث الفريق. حاول مرة أخرى." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo actualizar el equipo. Inténtalo de nuevo." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "無法更新團隊,請再試一次。" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "无法更新团队,请重试。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "팀을 업데이트할 수 없습니다. 다시 시도하세요." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "チームを更新できませんでした。もう一度お試しください。" + } + } + } } }, "version": "1.0" diff --git a/Sources/AppDelegate+DockShortcutRouting.swift b/Sources/AppDelegate+DockShortcutRouting.swift index 126b2d7cd1a5..ed78becb6d62 100644 --- a/Sources/AppDelegate+DockShortcutRouting.swift +++ b/Sources/AppDelegate+DockShortcutRouting.swift @@ -69,7 +69,7 @@ extension KeyboardShortcutSettings.Action { .diffViewerNextFile, .diffViewerPreviousFile: .focusResolved - case .openSettings, .reloadConfiguration, + case .openSettings, .openTeamPicker, .reloadConfiguration, .showHideAllWindows, .globalSearch, .newWindow, .closeWindow, .toggleFullScreen, .quit, .toggleSidebar, .newTab, .newBrowserWorkspace, .newCloudWorkspace, .newCloudMachine, diff --git a/Sources/AppDelegate+TeamScope.swift b/Sources/AppDelegate+TeamScope.swift new file mode 100644 index 000000000000..066239e60f7a --- /dev/null +++ b/Sources/AppDelegate+TeamScope.swift @@ -0,0 +1,42 @@ +import AppKit + +extension AppDelegate { + /// Closes local Cloud projections before a team switch so an old team's + /// terminals, browser URLs, and reconnect configuration cannot leak into + /// the newly-selected team. + @MainActor + func prepareCloudVMAccessForTeamSwitch() { + CloudVMActionLauncher.shared.cancelAllForAuthTransition() + let detail = String( + localized: "machines.teamSwitch.disconnectedDetail", + defaultValue: "Cloud VM access moved to another team." + ) + for manager in liveWorkspaceIdentityTabManagers() { + let cloudWorkspaces = manager.tabs.filter { workspace in + workspace.isManagedCloudVMWorkspace || + workspace.panels.values.contains { $0.panelType == .cloudVMLoading } + } + for workspace in cloudWorkspaces { + workspace.disconnectRemoteConnection( + clearConfiguration: true, + disconnectedDetail: detail + ) + if manager.tabs.count > 1 { + manager.closeWorkspace(workspace, recordHistory: false) + } else { + workspace.withClosedPanelHistorySuppressed { + workspace.teardownAllPanels() + } + } + } + } + ClosedItemHistoryStore.shared.removeManagedCloudVMRecords() + cloudWorkspaceOperationController?.cancelAll() + cloudTunnelAccessDidEnd() + NotificationCenter.default.post( + name: .cmuxCloudVMAccessDidEnd, + object: self, + userInfo: ["cmux.teamSwitch": true] + ) + } +} diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 144766c7ffa8..d3642b6a8e86 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -15064,25 +15064,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent ) { return false } - if activeConfiguredShortcutChordPrefixForCurrentEvent == nil, globalSearchShortcut.hasChord, globalSearchShortcutWhenClauseAllows(event: event), armConfiguredShortcutChordIfNeeded(event: event, actions: [], shortcuts: [globalSearchShortcut]) { return true } - if matchesGlobalSearchShortcut { toggleGlobalSearchPalette() return true } - if matchConfiguredShortcut(event: event, action: .commandPalette) { let targetWindow = commandPaletteTargetWindow ?? event.window ?? shortcutRoutingActiveWindow requestCommandPaletteCommands(preferredWindow: targetWindow, source: "shortcut.commandPalette") return true } - if handleSavedLayoutShortcut(event) { return true } if !hasFocusedAddressBarInShortcutContext, @@ -15099,6 +15095,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent openPreferencesWindow(debugSource: "shortcut.openSettings") return true } + if matchConfiguredShortcut(event: event, action: .openTeamPicker) { + NotificationCenter.default.post(name: .cmuxTeamPickerShortcutRequested, object: self) + return true + } if matchConfiguredShortcut(event: event, action: .reloadConfiguration) { reloadConfiguration(source: "shortcut.reloadConfiguration") return true diff --git a/Sources/Auth/HostAccountFlow+TeamSelection.swift b/Sources/Auth/HostAccountFlow+TeamSelection.swift new file mode 100644 index 000000000000..446f631dadb0 --- /dev/null +++ b/Sources/Auth/HostAccountFlow+TeamSelection.swift @@ -0,0 +1,69 @@ +import CMUXAuthCore +import CmuxAuthRuntime +import CmuxSettingsUI +import Foundation +import Observation + +extension HostAccountFlow { + /// Re-emits coordinator changes through this app-facing projection so + /// SwiftUI consumers observe team membership without holding the runtime + /// coordinator directly. + func startCoordinatorObservation() { + observeCoordinator() + } + + private func observeCoordinator() { + withObservationTracking { + _ = coordinator.currentUser + _ = coordinator.availableTeams + _ = coordinator.selectedTeamID + _ = coordinator.isAuthenticated + _ = coordinator.isLoading + _ = coordinator.isRestoringSession + } onChange: { [weak self] in + Task { @MainActor [weak self] in + guard let self else { return } + self.teamObservationRevision &+= 1 + self.observeCoordinator() + } + } + } + + /// Projects pending selection immediately; a rejected request restores the + /// confirmed selection by clearing only this request's pending projection. + func selectTeam(id: String?) async throws { + let requestID = UUID() + pendingTeamSelection = (requestID, id) + defer { + if pendingTeamSelection?.requestID == requestID { pendingTeamSelection = nil } + } + try await coordinator.selectTeam(id: id) + } + + /// Creates a team through Stack Auth and makes it the active team. + func createTeam(displayName: String) async throws -> AccountTeamSummary { + let team = try await coordinator.createTeam(displayName: displayName) + return AccountTeamSummary(id: team.id, displayName: team.displayName, slug: team.slug) + } + + /// The active team label used by the sidebar account surface. + var activeTeamDisplayName: String? { + _ = teamObservationRevision + guard let id = coordinator.resolvedTeamID else { return nil } + return coordinator.availableTeams.first(where: { $0.id == id })?.displayName + } + + /// The compact account subtitle shown in the sidebar footer and popover. + var sidebarTeamSubtitle: String { + _ = teamObservationRevision + let team = activeTeamDisplayName + ?? String(localized: "sidebar.account.noTeam", defaultValue: "No team") + if isProActive { + return String( + format: String(localized: "sidebar.account.teamWithPlan", defaultValue: "%1$@ · Pro"), + team + ) + } + return team + } +} diff --git a/Sources/Auth/HostAccountFlow.swift b/Sources/Auth/HostAccountFlow.swift index 060fe1556139..28453cf5f820 100644 --- a/Sources/Auth/HostAccountFlow.swift +++ b/Sources/Auth/HostAccountFlow.swift @@ -17,13 +17,18 @@ import Observation @MainActor @Observable final class HostAccountFlow: AccountFlow, AccountSignInFlow { - private let coordinator: AuthCoordinator + let coordinator: AuthCoordinator private let browserSignIn: HostBrowserSignInFlow private let featureFlags = CmuxFeatureFlags.shared @ObservationIgnored private var featureFlagsObserver: (any NSObjectProtocol)? private(set) var isProUpgradeAvailable: Bool private(set) var isProActive = false private(set) var canManageBilling = false + var teamObservationRevision: UInt64 = 0 + /// Pending selection is shared by Settings, the menu and socket actions. + /// Cloud requests keep using the confirmed coordinator scope until success. + var pendingTeamSelection: (requestID: UUID, teamID: String?)? + var isSelectingTeam: Bool { pendingTeamSelection != nil } init(coordinator: AuthCoordinator, browserSignIn: HostBrowserSignInFlow) { self.coordinator = coordinator @@ -38,6 +43,7 @@ final class HostAccountFlow: AccountFlow, AccountSignInFlow { self?.isProUpgradeAvailable = CmuxFeatureFlags.shared.isProUpgradeUIEnabled } } + startCoordinatorObservation() } deinit { @@ -47,26 +53,38 @@ final class HostAccountFlow: AccountFlow, AccountSignInFlow { } var currentIdentity: AccountIdentity? { - Self.identity(from: coordinator.currentUser) + _ = teamObservationRevision + return Self.identity(from: coordinator.currentUser) } var availableTeams: [AccountTeamSummary] { - coordinator.availableTeams.map { team in + _ = teamObservationRevision + return coordinator.availableTeams.map { team in AccountTeamSummary(id: team.id, displayName: team.displayName, slug: team.slug) } } var selectedTeamID: String? { - get { coordinator.selectedTeamID } - set { coordinator.selectedTeamID = newValue } + get { + if let pendingTeamSelection { return pendingTeamSelection.teamID } + return confirmedTeamID + } + } + + /// Cloud scope and persisted machine preferences follow confirmed authority. + var confirmedTeamID: String? { + _ = teamObservationRevision + return coordinator.resolvedTeamID } var isWorkingOnAuth: Bool { - coordinator.isLoading || coordinator.isRestoringSession || browserSignIn.isPresentingSignIn + _ = teamObservationRevision + return coordinator.isLoading || coordinator.isRestoringSession || browserSignIn.isPresentingSignIn } var isAuthenticated: Bool { - coordinator.isAuthenticated + _ = teamObservationRevision + return coordinator.isAuthenticated } var isPresentingSignIn: Bool { @@ -78,7 +96,8 @@ final class HostAccountFlow: AccountFlow, AccountSignInFlow { } var isCompletingSignIn: Bool { - coordinator.isLoading || coordinator.isRestoringSession + _ = teamObservationRevision + return coordinator.isLoading || coordinator.isRestoringSession } var lastSignInFailure: AccountSignInModel.Failure? { diff --git a/Sources/Auth/MacAuthComposition.swift b/Sources/Auth/MacAuthComposition.swift index bf20d3d2e48a..d454e3a7862b 100644 --- a/Sources/Auth/MacAuthComposition.swift +++ b/Sources/Auth/MacAuthComposition.swift @@ -26,6 +26,8 @@ struct MacAuthComposition { let browserAppSession: BrowserAppSessionController /// Shared observable account projection used by Settings and sidebar UI. let accountFlow: HostAccountFlow + /// Reconciles Cloud transports with the coordinator's selected team. + let cloudTeamScopeObserver: CloudTeamScopeObserver /// Build the auth graph. /// - Parameters: @@ -210,11 +212,15 @@ struct MacAuthComposition { coordinator: coordinator, browserSignIn: browserSignIn ) + self.cloudTeamScopeObserver = CloudTeamScopeObserver(auth: coordinator) { + AppDelegate.shared?.prepareCloudVMAccessForTeamSwitch() + } } /// Begin asynchronous session restore. Call once after construction, at /// the composition root. func start() { + cloudTeamScopeObserver.start() coordinator.start() } diff --git a/Sources/Cloud/CloudTeamScopeObserver.swift b/Sources/Cloud/CloudTeamScopeObserver.swift new file mode 100644 index 000000000000..2565229f2b6e --- /dev/null +++ b/Sources/Cloud/CloudTeamScopeObserver.swift @@ -0,0 +1,113 @@ +import CmuxAuthRuntime +import Foundation + +extension Notification.Name { + static let cmuxCloudTeamScopeDidChange = Notification.Name("cmux.cloudTeamScopeDidChange") +} + +/// Reconciles local Cloud transports whenever the authenticated team changes. +/// The auth coordinator is the source of truth; this observer only coordinates +/// teardown and rediscovery at the app boundary. +@MainActor +final class CloudTeamScopeObserver { + private let auth: AuthCoordinator + private let registry: CmuxTuiSurfaceProviderRegistry + private let onTeamWillChange: @MainActor () -> Void + private var observationTask: Task? + /// Registry teardown can wait for remote transports. Keep it out of the + /// auth scope stream so a slow provider never prevents the next team + /// selection from being observed. Requests are coalesced and reconciled in + /// order against the latest authenticated scope. + private var reconciliationTask: Task? + private var desiredScope: AuthenticatedTeamScope? + private var desiredGeneration: UInt64 = 0 + private var needsTeardown = false + private var needsResume = false + + init( + auth: AuthCoordinator, + registry: CmuxTuiSurfaceProviderRegistry? = nil, + onTeamWillChange: @escaping @MainActor () -> Void + ) { + self.auth = auth + self.registry = registry ?? .shared + self.onTeamWillChange = onTeamWillChange + } + + func start() { + observationTask?.cancel() + reconciliationTask?.cancel() + reconciliationTask = nil + observationTask = Task { @MainActor [weak self] in + guard let self else { return } + await self.auth.awaitBootstrapped() + var previousScope: AuthenticatedTeamScope? + for await scope in self.auth.authenticatedTeamScopes() { + guard !Task.isCancelled else { return } + guard scope != previousScope else { continue } + let changedTeams = previousScope != nil + previousScope = scope + + if changedTeams { + self.onTeamWillChange() + NotificationCenter.default.post(name: .cmuxCloudTeamScopeDidChange, object: self) + } + self.requestReconciliation( + scope: scope, + requiresTeardown: scope == nil || changedTeams, + requiresResume: scope != nil + ) + } + } + } + + private func requestReconciliation( + scope: AuthenticatedTeamScope?, + requiresTeardown: Bool, + requiresResume: Bool + ) { + desiredScope = scope + desiredGeneration &+= 1 + if requiresTeardown { + needsTeardown = true + } + needsResume = requiresResume + guard reconciliationTask == nil else { return } + reconciliationTask = Task { @MainActor [weak self] in + await self?.reconcileCloudScope() + } + } + + private func reconcileCloudScope() async { + defer { reconciliationTask = nil } + while !Task.isCancelled { + let generation = desiredGeneration + let scope = desiredScope + if needsTeardown { + await registry.accessDidEnd() + // A newer scope may have arrived while teardown was waiting on + // transports. The same teardown is sufficient for that scope; + // the generation check below will move directly to resume. + needsTeardown = false + } + guard generation == desiredGeneration else { continue } + guard let scope else { + needsResume = false + return + } + guard !Task.isCancelled, auth.isAuthenticatedTeamScopeCurrent(scope) else { return } + if needsResume { + needsResume = false + await registry.resumeAfterSignIn() + } + guard generation == desiredGeneration else { continue } + guard auth.isAuthenticatedTeamScopeCurrent(scope) else { return } + return + } + } + + deinit { + observationTask?.cancel() + reconciliationTask?.cancel() + } +} diff --git a/Sources/Cloud/DeviceRegistryClient.swift b/Sources/Cloud/DeviceRegistryClient.swift index 7d9e359f113f..1c63920a9530 100644 --- a/Sources/Cloud/DeviceRegistryClient.swift +++ b/Sources/Cloud/DeviceRegistryClient.swift @@ -25,6 +25,8 @@ final class DeviceRegistryClient { private var auth: AuthCoordinator? private var observeTask: Task? private var defaultsObserver: NSObjectProtocol? + private var teamScopeObserver: NSObjectProtocol? + private var latestRoutes: [CmxAttachRoute] = [] /// The scope (team + tag + routes) most recently registered, used to skip /// redundant POSTs. Keyed on the full scope rather than routes alone so an /// account/team switch with unchanged routes still re-registers in the newly @@ -55,6 +57,20 @@ final class DeviceRegistryClient { } } } + if teamScopeObserver == nil { + teamScopeObserver = NotificationCenter.default.addObserver( + forName: .cmuxCloudTeamScopeDidChange, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + guard let self else { return } + self.lastRegistration = nil + let routes = self.latestRoutes + Task { await self.registerIfRoutesChanged(routes: routes) } + } + } + } evaluate() } @@ -84,14 +100,10 @@ final class DeviceRegistryClient { private func startObserving() { guard observeTask == nil else { return } - // Registration is currently driven only by host-route changes. The dedup - // key includes the team, so a team switch *does* re-register once the - // next status tick arrives, but a mid-session team switch with otherwise - // unchanged routes is not registered in the new team until then. Known - // limitation; an explicit auth/team-change trigger is a follow-up. observeTask = Task { @MainActor [weak self] in for await status in MobileHostService.shared.statusUpdates() { if Task.isCancelled { break } + self?.latestRoutes = status.routes await self?.registerIfRoutesChanged(routes: status.routes) } } diff --git a/Sources/Cloud/MacPairedMacBackupPublisher.swift b/Sources/Cloud/MacPairedMacBackupPublisher.swift index 6907fd97135f..af8083d4982c 100644 --- a/Sources/Cloud/MacPairedMacBackupPublisher.swift +++ b/Sources/Cloud/MacPairedMacBackupPublisher.swift @@ -32,6 +32,8 @@ final class MacPairedMacBackupPublisher { private var auth: AuthCoordinator? private var observeTask: Task? private var defaultsObserver: NSObjectProtocol? + private var teamScopeObserver: NSObjectProtocol? + private var latestRoutes: [CmxAttachRoute] = [] /// The routes most recently published, so an unchanged status update (the /// common case) does not re-POST. private var lastPublishedRoutes: [CmxAttachRoute] = [] @@ -80,6 +82,21 @@ final class MacPairedMacBackupPublisher { } } } + if teamScopeObserver == nil { + teamScopeObserver = NotificationCenter.default.addObserver( + forName: .cmuxCloudTeamScopeDidChange, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + guard let self else { return } + self.lastPublishedRoutes = [] + let routes = self.latestRoutes + guard !routes.isEmpty else { return } + Task { await self.publish(routes: routes) } + } + } + } evaluate() } @@ -98,6 +115,7 @@ final class MacPairedMacBackupPublisher { observeTask = Task { @MainActor [weak self] in for await status in MobileHostService.shared.statusUpdates() { guard let self, !Task.isCancelled else { break } + self.latestRoutes = status.routes guard MobileHostService.isListeningEnabled else { self.lastPublishedRoutes = [] continue diff --git a/Sources/Cloud/MachinesPanelView.swift b/Sources/Cloud/MachinesPanelView.swift index f0a51acbcd41..ca45b23140c1 100644 --- a/Sources/Cloud/MachinesPanelView.swift +++ b/Sources/Cloud/MachinesPanelView.swift @@ -82,7 +82,7 @@ struct MachinesPanelView: View { } // Pins are scoped per account and team; a switch re-reads the scope and // the fleet so the tree never shows another scope's pins. - .onChange(of: accountFlow?.selectedTeamID) { _, _ in + .onChange(of: accountFlow?.confirmedTeamID) { _, _ in viewModel.refreshAccountScope() } .onChange(of: accountFlow?.currentIdentity?.id) { _, _ in diff --git a/Sources/Cloud/MachinesPanelViewModel.swift b/Sources/Cloud/MachinesPanelViewModel.swift index aa042930732b..61bec26600f1 100644 --- a/Sources/Cloud/MachinesPanelViewModel.swift +++ b/Sources/Cloud/MachinesPanelViewModel.swift @@ -124,7 +124,7 @@ final class MachinesPanelViewModel: ObservableObject { var lockedMemoryOptionsMb: [Int]? { lastLimits?.lockedMemoryOptionsMb } var memoryUpgradePlanId: String? { lastLimits?.memoryUpgradePlanId } var memoryUpgradePlansByMb: [String: String]? { lastLimits?.memoryUpgradePlansByMb } - private var authSignOutObserver: NSObjectProtocol? + private var authScopeObservers: [NSObjectProtocol] = [] private var featureFlagObserver: CloudFeatureAvailabilityObserver? private var wantsPolling = false private var treeChangeObserver: NSObjectProtocol? @@ -162,12 +162,14 @@ final class MachinesPanelViewModel: ObservableObject { let finished = notification.userInfo?[finishedUserInfoKey] as? MachineCreateCoordinator.Finished MainActor.assumeIsolated { self?.createsDidChange(finished: finished) } } - authSignOutObserver = NotificationCenter.default.addObserver( - forName: .cmuxCloudVMAccessDidEnd, - object: nil, - queue: .main - ) { [weak self] _ in - MainActor.assumeIsolated { self?.resetForAuthTransition() } + authScopeObservers = [Notification.Name.cmuxCloudVMAccessDidEnd, .cmuxCloudTeamScopeDidChange].map { name in + NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { + guard let self else { return } + if name == .cmuxCloudVMAccessDidEnd { self.resetForAuthTransition() } + else if self.wantsPolling { self.startPolling() } + } + } } featureFlagObserver = CloudFeatureAvailabilityObserver( isEnabled: { CloudMachinesFeature.isEnabled }, @@ -234,8 +236,8 @@ final class MachinesPanelViewModel: ObservableObject { } deinit { resourceUpdatesTask?.cancel() - if let authSignOutObserver { - NotificationCenter.default.removeObserver(authSignOutObserver) + for observer in authScopeObservers { + NotificationCenter.default.removeObserver(observer) } if let treeChangeObserver { NotificationCenter.default.removeObserver(treeChangeObserver) @@ -339,8 +341,9 @@ final class MachinesPanelViewModel: ObservableObject { guard CloudMachinesFeature.isEnabled, usageTask == nil else { return } if let retryNotBefore = usageRetryNotBefore, retryNotBefore > Date() { return } guard let client = MachineUsageClient.shared else { return } + let generation = refreshGeneration usageTask = Task { [weak self] in - defer { self?.usageTask = nil } + defer { if generation == self?.refreshGeneration { self?.usageTask = nil } } do { let usage = (try await client.teamUsage()).byMachineID guard !Task.isCancelled, CloudMachinesFeature.isEnabled, let self else { return } @@ -464,23 +467,7 @@ final class MachinesPanelViewModel: ObservableObject { /// fleet while SwiftUI is catching up with the auth projection. func resetForAuthTransition() { resourceStats?.reset() - pollTask?.cancel() - pollTask = nil - refreshTask?.cancel() - refreshTask = nil - refreshRequestedWhileLoading = false - refreshGeneration &+= 1 - statsTask?.cancel() - statsTask = nil - usageTask?.cancel() - usageTask = nil - usageFailureCount = 0 - usageRetryNotBefore = nil - freeAccessTransitionTask?.cancel() - freeAccessTransitionTask = nil - treeTask?.cancel() - treeTask = nil - machineRefreshes.cancelAll() + pausePolling() freeAccessWindowDays = 0 lastLimits = nil machines = [] @@ -584,7 +571,8 @@ final class MachinesPanelViewModel: ObservableObject { } catch is CancellationError { return } catch let error as VMClientError { - guard generation == refreshGeneration, scope == machinePinStore?.scopeIdentifier else { return } + guard !Task.isCancelled, generation == refreshGeneration, + scope == machinePinStore?.scopeIdentifier else { return } if case .notSignedIn = error { machines = [] machineIndexByID.removeAll() @@ -599,7 +587,8 @@ final class MachinesPanelViewModel: ObservableObject { lastErrorDescription = String(describing: error) listProblem = Self.classifyListFailure(error) } catch { - guard generation == refreshGeneration, scope == machinePinStore?.scopeIdentifier else { return } + guard !Task.isCancelled, generation == refreshGeneration, + scope == machinePinStore?.scopeIdentifier else { return } lastErrorDescription = String(describing: error) listProblem = .unreachable } diff --git a/Sources/Cloud/PresenceHeartbeatClient.swift b/Sources/Cloud/PresenceHeartbeatClient.swift index a229e2ee6d59..72bdd6d33d23 100644 --- a/Sources/Cloud/PresenceHeartbeatClient.swift +++ b/Sources/Cloud/PresenceHeartbeatClient.swift @@ -33,6 +33,7 @@ final class PresenceHeartbeatClient { private var loopTask: Task? private var routesObserveTask: Task? private var defaultsObserver: NSObjectProtocol? + private var teamScopeObserver: NSObjectProtocol? /// Cadence between heartbeats; server-owned, seeded with the service default. private var intervalMs: Int = 15_000 /// The attach routes most recently advertised by ``MobileHostService``, @@ -60,6 +61,18 @@ final class PresenceHeartbeatClient { } } } + if teamScopeObserver == nil { + teamScopeObserver = NotificationCenter.default.addObserver( + forName: .cmuxCloudTeamScopeDidChange, + object: nil, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + guard let self, self.loopTask != nil else { return } + Task { await self.sendHeartbeat(stopping: false) } + } + } + } evaluate() } diff --git a/Sources/Cloud/VMClient.swift b/Sources/Cloud/VMClient.swift index 00090e5d2ffa..457cf3e0049f 100644 --- a/Sources/Cloud/VMClient.swift +++ b/Sources/Cloud/VMClient.swift @@ -33,8 +33,6 @@ extension URLError.Code { } } - - func formattedCloudVMHTTPError(status: Int, body: String) -> String { let trimmedBody = body.trimmingCharacters(in: .whitespacesAndNewlines) guard let data = trimmedBody.data(using: .utf8), @@ -2012,7 +2010,6 @@ actor VMClient { // safety net when this tail cannot reach the API. } } - private func revokeCloudAccess( deviceID: String, accessToken: String?, @@ -2039,7 +2036,6 @@ actor VMClient { } } - func withOperation( _ kind: CloudOperationKind, foreground: Bool, _ work: () async throws -> T @@ -2182,7 +2178,6 @@ actor VMClient { } return traceId } - private func performRequest( _ method: String, path: String, @@ -2199,6 +2194,7 @@ actor VMClient { let sessionIdentity = await auth.authenticatedSessionIdentity let isAuthenticated = await auth.isAuthenticated let isRestoringSession = await auth.isRestoringSession + let requestedTeamID = await auth.resolvedTeamID guard isAuthenticated || isRestoringSession else { throw VMClientError.notSignedIn } @@ -2210,8 +2206,7 @@ actor VMClient { } catch { throw VMClientError.notSignedIn } - let teamID = await auth.resolvedTeamID - + let teamID = requestedTeamID guard var url = URLComponents(url: AuthEnvironment.vmAPIBaseURL, resolvingAgainstBaseURL: false) else { throw VMClientError.malformedResponse("bad vmAPIBaseURL") } @@ -2219,7 +2214,6 @@ actor VMClient { guard let resolved = url.url else { throw VMClientError.malformedResponse("could not build URL for \(path)") } - var req = URLRequest(url: resolved) req.httpMethod = method if let timeoutSeconds { @@ -2237,7 +2231,6 @@ actor VMClient { for (key, value) in extraHeaders { req.setValue(value, forHTTPHeaderField: key) } - // HTTP 429 from the VM API is an upstream auth throttle rejected before any work // happened (rate_limited in services/vms/authErrors.ts), so every verb is safe to // retry. Waiting out Retry-After here turns a transient throttle into a short pause @@ -2245,6 +2238,9 @@ actor VMClient { var retriesLeft = 2 while true { try Task.checkCancellation() + guard await auth.resolvedTeamID == requestedTeamID else { + throw VMClientError.notSignedIn + } if !allowedWhenCloudDisabled, !isCloudEnabled() { throw VMClientError.cloudMachinesDisabled } let data: Data let response: URLResponse @@ -2321,10 +2317,14 @@ actor VMClient { throw VMClientError.notSignedIn } } + if let requestedTeamID { + guard await auth.resolvedTeamID == requestedTeamID else { + throw VMClientError.notSignedIn + } + } return (data, http) } } - private func pollOperationProgress(context: CloudOperationContext, request: URLRequest) async { struct ProgressResponse: Decodable { let steps: [CloudRemoteOperationStep] } var progress = request diff --git a/Sources/ContentView+AuthCommandPalette.swift b/Sources/ContentView+AuthCommandPalette.swift index e8079ff9ec8a..d2569a476695 100644 --- a/Sources/ContentView+AuthCommandPalette.swift +++ b/Sources/ContentView+AuthCommandPalette.swift @@ -5,6 +5,7 @@ import Foundation extension ContentView { static let commandPaletteAuthSignInCommandId = "palette.auth.signIn" static let commandPaletteAuthSignOutCommandId = "palette.auth.signOut" + static let commandPaletteAuthTeamPickerCommandId = "palette.auth.teamPicker" static func commandPaletteAuthCommandContributions() -> [CommandPaletteCommandContribution] { func constant(_ value: String) -> (CommandPaletteContextSnapshot) -> String { @@ -32,6 +33,16 @@ extension ContentView { && !context.bool(CommandPaletteContextKeys.authWorking) } ), + CommandPaletteCommandContribution( + commandId: commandPaletteAuthTeamPickerCommandId, + title: constant(String(localized: "command.auth.teamPicker.title", defaultValue: "Open Team Picker")), + subtitle: constant(String(localized: "command.auth.subtitle", defaultValue: "Account")), + keywords: ["account", "auth", "team", "teams", "switch", "create"], + when: { context in + context.bool(CommandPaletteContextKeys.authSignedIn) + && !context.bool(CommandPaletteContextKeys.authWorking) + } + ), ] } @@ -58,6 +69,9 @@ extension ContentView { await auth.accountFlow.signOut() } } + registry.register(commandId: Self.commandPaletteAuthTeamPickerCommandId) { + NotificationCenter.default.post(name: .cmuxTeamPickerShortcutRequested, object: self) + } } } diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index 3b0e3f4a9a5a..d61aa1a7491a 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -79,10 +79,10 @@ enum KeyboardShortcutSettings { case accepted(StoredShortcut) case rejected(ShortcutRecordingRejection) } - enum Action: String, CaseIterable, Identifiable { // App / window case openSettings + case openTeamPicker case reloadConfiguration case showHideAllWindows case globalSearch @@ -90,7 +90,6 @@ enum KeyboardShortcutSettings { case closeWindow case toggleFullScreen case quit - // Titlebar / primary UI case toggleSidebar case newTab @@ -119,7 +118,6 @@ enum KeyboardShortcutSettings { case switchRightSidebarToDock case switchRightSidebarToMachines case triggerFlash - // Navigation case nextSurface case prevSurface @@ -155,7 +153,6 @@ enum KeyboardShortcutSettings { case focusTextBoxInput, cycleTextBoxSubmitAction, attachTextBoxFile case sendCtrlFToTerminal case clearScreenKeepScrollback - // Panes / splits case focusLeft case focusRight @@ -240,6 +237,7 @@ enum KeyboardShortcutSettings { var label: String { switch self { case .openSettings: return String(localized: "menu.app.settings", defaultValue: "Settings…") + case .openTeamPicker: return String(localized: "shortcut.openTeamPicker.label", defaultValue: "Open Team Picker") case .reloadConfiguration: return String(localized: "menu.app.reloadConfiguration", defaultValue: "Reload Configuration") case .showHideAllWindows: return String(localized: "settings.globalHotkey.shortcut", defaultValue: "Show/Hide All Windows") case .globalSearch: return String(localized: "shortcut.globalSearch.label", defaultValue: "Global Search") @@ -415,6 +413,8 @@ enum KeyboardShortcutSettings { switch self { case .openSettings: return StoredShortcut(key: ",", command: true, shift: false, option: false, control: false) + case .openTeamPicker: + return StoredShortcut(key: "t", command: true, shift: true, option: true, control: false) case .reloadConfiguration: return StoredShortcut(key: ",", command: true, shift: true, option: false, control: false) case .showHideAllWindows: diff --git a/Sources/SidebarAccountMenuButtonStyle.swift b/Sources/SidebarAccountMenuButtonStyle.swift new file mode 100644 index 000000000000..9385950ce5d0 --- /dev/null +++ b/Sources/SidebarAccountMenuButtonStyle.swift @@ -0,0 +1,12 @@ +import SwiftUI + +/// Gives both levels of the account menu the same compact row rhythm. +struct SidebarAccountMenuButtonStyle: ButtonStyle { + static let rowHeight: CGFloat = 26 + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .frame(minHeight: Self.rowHeight, alignment: .leading) + .contentShape(Rectangle()) + } +} diff --git a/Sources/SidebarAccountTeamPicker.swift b/Sources/SidebarAccountTeamPicker.swift new file mode 100644 index 000000000000..4b92d2af0cb5 --- /dev/null +++ b/Sources/SidebarAccountTeamPicker.swift @@ -0,0 +1,176 @@ +import CmuxSettingsUI +import SwiftUI + +/// The nested team menu. Selection and creation keep both menu levels open. +struct SidebarAccountTeamPicker: View { + let accountFlow: HostAccountFlow + @State private var isCreatingTeam = false + @State private var newTeamName = "" + @State private var isSubmitting = false + @State private var errorMessage: String? + @FocusState private var isCreateFieldFocused: Bool + + var body: some View { + teamPickerContent + .buttonStyle(SidebarAccountMenuButtonStyle()) + .disabled(isSubmitting || accountFlow.isWorkingOnAuth) + .padding(12) + .frame(width: 220, alignment: .leading) + } + + @ViewBuilder + private var teamPickerContent: some View { + VStack(alignment: .leading, spacing: 0) { + if accountFlow.availableTeams.isEmpty { + Text(String(localized: "sidebar.account.loadingTeams", defaultValue: "Loading teams…")) + .cmuxFont(size: 12) + .foregroundStyle(.secondary) + .frame(minHeight: SidebarAccountMenuButtonStyle.rowHeight, alignment: .leading) + } else { + ForEach(accountFlow.availableTeams) { team in + teamRow(team) + } + } + if isCreatingTeam { + createTeamEditor + } else { + accountMenuRow( + title: String(localized: "sidebar.account.createTeam", defaultValue: "Create team…"), + systemImage: "plus" + ) { + errorMessage = nil + newTeamName = "" + isCreatingTeam = true + } + .accessibilityIdentifier("SidebarAccountCreateTeamButton") + } + if let errorMessage { + Text(errorMessage) + .cmuxFont(size: 11) + .foregroundStyle(.red) + .fixedSize(horizontal: false, vertical: true) + .padding(.leading, 24) + .accessibilityLabel(errorMessage) + } + } + } + + private func teamRow(_ team: AccountTeamSummary) -> some View { + let activeTeamID = accountFlow.selectedTeamID + let isSelected = team.id == activeTeamID + let isPending = team.id == accountFlow.pendingTeamSelection?.teamID + return Button { + guard !isSelected, !accountFlow.isSelectingTeam else { return } + errorMessage = nil + Task { @MainActor in + do { + try await accountFlow.selectTeam(id: team.id) + } catch { + errorMessage = String( + localized: "sidebar.account.switchTeamFailed", + defaultValue: "Could not switch teams. Try again." + ) + } + } + } label: { + HStack(spacing: 8) { + Label(team.displayName, systemImage: "person.2") + .lineLimit(1) + Spacer(minLength: 8) + if isPending { + ProgressView().controlSize(.mini) + } else if isSelected { + Image(systemName: "checkmark") + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .accessibilityLabel(String( + format: String(localized: "sidebar.account.teamRowLabel", defaultValue: "%1$@%2$@"), + team.displayName, + isSelected ? String(localized: "sidebar.account.activeSuffix", defaultValue: ", active") : "" + )) + .accessibilityIdentifier("SidebarAccountTeam_\(team.id)") + } + + private var createTeamEditor: some View { + HStack(spacing: 7) { + Image(systemName: "plus") + .frame(width: 16) + .foregroundStyle(.secondary) + TextField( + String(localized: "sidebar.account.createTeamPlaceholder", defaultValue: "Team name"), + text: $newTeamName + ) + .textFieldStyle(.plain) + .focused($isCreateFieldFocused) + .padding(.horizontal, 7) + .frame(height: 22) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color.primary.opacity(0.06)) + ) + .onSubmit { submitCreateTeam() } + if isSubmitting { + ProgressView().controlSize(.mini).frame(width: 22, height: 22) + } else { + Button { + submitCreateTeam() + } label: { + Image(systemName: "checkmark").frame(width: 22, height: 22) + } + .buttonStyle(.borderless) + .disabled(newTeamName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .foregroundStyle(.secondary) + .accessibilityLabel(String(localized: "sidebar.account.createTeamSubmit", defaultValue: "Create team")) + Button { + isCreateFieldFocused = false + isCreatingTeam = false + } label: { + Image(systemName: "xmark").frame(width: 22, height: 22) + } + .buttonStyle(.borderless) + .foregroundStyle(.secondary) + .accessibilityLabel(String(localized: "sidebar.account.createTeamCancel", defaultValue: "Cancel")) + } + } + .padding(.leading, 1) + .padding(.vertical, 2) + .frame(minHeight: SidebarAccountMenuButtonStyle.rowHeight, alignment: .leading) + .onAppear { isCreateFieldFocused = true } + .accessibilityIdentifier("SidebarAccountCreateTeamEditor") + } + + private func submitCreateTeam() { + let name = newTeamName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty, !isSubmitting else { return } + isSubmitting = true + errorMessage = nil + Task { @MainActor in + defer { isSubmitting = false } + do { + _ = try await accountFlow.createTeam(displayName: name) + isCreateFieldFocused = false + isCreatingTeam = false + newTeamName = "" + } catch { + errorMessage = String( + localized: "sidebar.account.createTeamFailed", + defaultValue: "Could not create that team. Try again." + ) + } + } + } + + private func accountMenuRow( + title: String, + systemImage: String, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + Label(title, systemImage: systemImage) + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} diff --git a/Sources/SidebarAccountTeamPickerRow.swift b/Sources/SidebarAccountTeamPickerRow.swift new file mode 100644 index 000000000000..034bebfbaab5 --- /dev/null +++ b/Sources/SidebarAccountTeamPickerRow.swift @@ -0,0 +1,68 @@ +import CmuxAppKitSupportUI +import CmuxSettingsUI +import SwiftUI + +/// Opens the team submenu on hover, click, or keyboard activation. +struct SidebarAccountTeamPickerRow: View { + let accountFlow: HostAccountFlow + @Binding var isPresented: Bool + let popoverGroup: CmuxPopoverGroup + + private var currentTeam: AccountTeamSummary? { + accountFlow.availableTeams.first { $0.id == accountFlow.selectedTeamID } + } + + var body: some View { + Button { + isPresented = true + } label: { + HStack(spacing: 8) { + Label(currentTeam?.displayName ?? String( + localized: "sidebar.account.noTeam", + defaultValue: "No team" + ), systemImage: "person.2") + .lineLimit(1) + Spacer(minLength: 8) + Image(systemName: "chevron.right") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(SidebarAccountMenuButtonStyle()) + .contentShape(Rectangle()) + .onHover { hovering in + if hovering { isPresented = true } + } + .overlay(alignment: .trailing) { + ArrowlessPopoverAnchor( + isPresented: $isPresented, + preferredEdge: .maxX, + detachedGap: 4, + group: popoverGroup + ) { + SidebarAccountTeamPicker(accountFlow: accountFlow) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .allowsHitTesting(false) + } + .accessibilityLabel(teamPickerAccessibilityLabel) + .accessibilityHint(String( + localized: "settings.account.activeTeam", + defaultValue: "Active Team" + )) + .accessibilityIdentifier("SidebarAccountTeamPickerButton") + } + + private var teamPickerAccessibilityLabel: String { + let name = currentTeam?.displayName ?? String( + localized: "sidebar.account.noTeam", + defaultValue: "No team" + ) + return String( + format: String(localized: "sidebar.account.teamRowLabel", defaultValue: "%1$@%2$@"), + name, + String(localized: "sidebar.account.activeSuffix", defaultValue: ", active") + ) + } +} diff --git a/Sources/SidebarAccountTeamPopover.swift b/Sources/SidebarAccountTeamPopover.swift new file mode 100644 index 000000000000..28b19596c4c4 --- /dev/null +++ b/Sources/SidebarAccountTeamPopover.swift @@ -0,0 +1,258 @@ +import AppKit +import CmuxAppKitSupportUI +import CmuxSettingsUI +import SwiftUI + +extension Notification.Name { + static let cmuxTeamPickerShortcutRequested = Notification.Name("cmux.teamPicker.shortcutRequested") +} + +/// The existing compact sidebar account button with team management added to +/// its account popover. +struct SidebarAccountMenuButton: View { + @EnvironmentObject private var tabManager: TabManager + private var accountFlow: HostAccountFlow? { AppDelegate.shared?.auth?.accountFlow } + private let title = String(localized: "settings.section.account", defaultValue: "Account") + private let signInTitle = String(localized: "settings.account.signIn", defaultValue: "Sign In…") + private let buttonSize = SidebarFooterButtonMetrics.buttonSize + @State private var isPopoverPresented = false + @State private var isShowingTeamPicker = false + @State private var popoverGroup = CmuxPopoverGroup() +#if DEBUG + @AppStorage(SidebarFooterProfileIconDebugSettings.sizeKey) + private var debugIconSize = SidebarFooterProfileIconDebugSettings.defaultSize + @AppStorage(SidebarFooterProfileDisplayDebugSettings.displayKey) + private var debugProfileDisplay = SidebarFooterProfileDisplayDebugSettings.defaultDisplay.rawValue +#endif + + private var profileIconSize: CGFloat { +#if DEBUG + CGFloat(debugIconSize) +#else + SidebarFooterButtonMetrics.profileIconSize +#endif + } + + private var prefersProfileIcon: Bool { +#if DEBUG + SidebarFooterProfileDisplayDebugChoice(rawValue: debugProfileDisplay) == .icon +#else + false +#endif + } + + private func presentation( + isSignedIn: Bool, + hasProfilePicture: Bool + ) -> SidebarAccountButtonPresentation { + let presentation = SidebarAccountButtonPresentation.resolve( + isSignedIn: isSignedIn, + prefersProfileIcon: prefersProfileIcon, + hasProfilePicture: hasProfilePicture + ) +#if DEBUG + if !presentation.showsProfilePicture { + return SidebarAccountButtonPresentation( + visual: presentation.visual, + size: profileIconSize + ) + } +#endif + return presentation + } + + var body: some View { + let identity = accountFlow?.currentIdentity + let isSignedIn = identity != nil + let buttonTitle = isSignedIn ? title : signInTitle + let profile = presentation( + isSignedIn: isSignedIn, + hasProfilePicture: identity?.avatarURL != nil + ) + Button { + if isSignedIn { + isPopoverPresented.toggle() + } else { + _ = AppDelegate.shared?.performAccountSignInWorkspaceAction( + tabManager: tabManager, + debugSource: "sidebar.account" + ) + } + } label: { + SidebarAccountAvatar( + avatarURL: identity?.avatarURL, + displayName: identity?.displayName ?? "", + email: identity?.email ?? "", + isSignedIn: profile.showsProfilePicture, + size: profile.size + ) + .frame(width: buttonSize, height: buttonSize) + } + .buttonStyle(SidebarFooterIconButtonStyle()) + .disabled(accountFlow?.isWorkingOnAuth == true) + .frame(width: buttonSize, height: buttonSize) + .background(ArrowlessPopoverAnchor( + isPresented: $isPopoverPresented, + preferredEdge: .maxY, + detachedGap: 4, + group: popoverGroup + ) { + SidebarAccountPopover( + accountFlow: accountFlow, + dismiss: { popoverGroup.dismissAll() }, + isShowingTeamPicker: $isShowingTeamPicker, + popoverGroup: popoverGroup + ) + }) + .safeHelp(buttonTitle) + .accessibilityLabel(buttonTitle) + .accessibilityIdentifier("SidebarAccountMenuButton") + .task { + for await _ in NotificationCenter.default.notifications(named: .cmuxTeamPickerShortcutRequested) { + guard !Task.isCancelled else { return } + isPopoverPresented = true + } + } + .onChange(of: isPopoverPresented) { _, presented in + if !presented { + isShowingTeamPicker = false + } + } + } +} + +private struct SidebarAccountPopover: View { + let accountFlow: HostAccountFlow? + let dismiss: () -> Void + @Binding var isShowingTeamPicker: Bool + let popoverGroup: CmuxPopoverGroup + @State private var shortcutObserver = KeyboardShortcutSettingsObserver.shared + + private var settingsShortcutHint: String { + let _ = shortcutObserver.revision + return KeyboardShortcutSettings.shortcut(for: .openSettings).displayString + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + if let identity = accountFlow?.currentIdentity { + HStack(spacing: 10) { + SidebarAccountAvatar( + avatarURL: identity.avatarURL, + displayName: identity.displayName, + email: identity.email, + isSignedIn: true, + size: 34 + ) + VStack(alignment: .leading, spacing: 2) { + Text(identity.displayName.isEmpty ? identity.email : identity.displayName) + .cmuxFont(size: 13, weight: .semibold) + .lineLimit(1) + if !identity.email.isEmpty && identity.email != identity.displayName { + Text(identity.email) + .cmuxFont(size: 11) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + } + Divider() + .padding(.vertical, 4) + if let accountFlow { + SidebarAccountTeamPickerRow( + accountFlow: accountFlow, + isPresented: $isShowingTeamPicker, + popoverGroup: popoverGroup + ) + } + settingsRow + } else { + Text(String(localized: "settings.account.signedOut.title", defaultValue: "Not signed in")) + .cmuxFont(size: 13, weight: .semibold) + Button { + dismiss() + accountFlow?.startSignIn() + } label: { + Label( + String(localized: "settings.account.signIn", defaultValue: "Sign In…"), + systemImage: "person.crop.circle.badge.plus" + ) + .frame(maxWidth: .infinity, alignment: .leading) + } + .accessibilityIdentifier("SidebarAccountSignInButton") + } + if accountFlow?.isProUpgradeAvailable == true { + if accountFlow?.currentIdentity == nil { + Divider() + .padding(.vertical, 4) + } + accountMenuRow( + title: String(localized: "menu.help.upgradeToPro", defaultValue: "Upgrade to cmux Pro…"), + systemImage: "sparkles" + ) { + dismiss() + accountFlow?.openProUpgrade(source: .sidebarAccountMenu) + } + .accessibilityIdentifier("SidebarAccountUpgradeButton") + } + if accountFlow?.currentIdentity != nil { + accountMenuRow( + title: String(localized: "settings.account.signOut", defaultValue: "Sign Out"), + systemImage: "rectangle.portrait.and.arrow.right" + ) { + dismiss() + Task { await accountFlow?.signOut() } + } + .accessibilityIdentifier("SidebarAccountSignOutButton") + } + } + .buttonStyle(SidebarAccountMenuButtonStyle()) + .disabled(accountFlow?.isWorkingOnAuth == true) + .padding(12) + .frame(width: 220, alignment: .leading) + } + + private var settingsRow: some View { + VStack(alignment: .leading, spacing: 0) { + Divider() + .padding(.vertical, 4) + Button { + dismiss() + AppDelegate.shared?.openPreferencesWindow( + debugSource: "sidebar.account.settings", + navigationTarget: .account + ) + } label: { + HStack(spacing: 8) { + Label( + String(localized: "menu.app.settings", defaultValue: "Settings…"), + systemImage: "gearshape" + ) + Spacer(minLength: 8) + Text(settingsShortcutHint) + .cmuxFont(size: 11) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .accessibilityLabel(String( + format: String(localized: "sidebar.account.settingsLabel", defaultValue: "%1$@, %2$@"), + String(localized: "menu.app.settings", defaultValue: "Settings…"), + settingsShortcutHint + )) + .accessibilityIdentifier("SidebarAccountSettingsButton") + } + } + + + private func accountMenuRow( + title: String, + systemImage: String, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + Label(title, systemImage: systemImage) + .frame(maxWidth: .infinity, alignment: .leading) + } + } +} diff --git a/Sources/Surfaces/CmuxTuiSurfaceProviderRegistry.swift b/Sources/Surfaces/CmuxTuiSurfaceProviderRegistry.swift index a5554b013256..0a19bd4b36d6 100644 --- a/Sources/Surfaces/CmuxTuiSurfaceProviderRegistry.swift +++ b/Sources/Surfaces/CmuxTuiSurfaceProviderRegistry.swift @@ -98,7 +98,6 @@ final class CmuxTuiSurfaceProviderRegistry { MainActor.assumeIsolated { self?.syncPollingToActivationPolicy() } } } - /// True while the periodic fleet read is scheduled. var isPolling: Bool { pollTask != nil } @@ -132,14 +131,14 @@ final class CmuxTuiSurfaceProviderRegistry { accessEpoch &+= 1 refreshGeneration &+= 1 let epoch = accessEpoch - // Block observers are retained by NotificationCenter: drop the previous - // tokens so a re-start never leaves stale callbacks registered. + // Replacing block observers prevents stale callbacks after a restart. if let accessObserver { notificationCenter.removeObserver(accessObserver) } accessObserver = notificationCenter.addObserver( forName: .cmuxCloudVMAccessDidEnd, object: nil, queue: .main - ) { [weak self] _ in + ) { [weak self] notification in + guard notification.userInfo?["cmux.teamSwitch"] as? Bool != true else { return } Task { @MainActor in await self?.accessDidEnd(epoch: epoch) } } // A Ghostty config reload can change the resolved theme; re-push it so remote @@ -486,9 +485,10 @@ final class CmuxTuiSurfaceProviderRegistry { let suspension = featureSuspensionTask featureSuspensionTask = nil isFeatureSuspended = false - for provider in providers.values { await provider.stop() } - for id in providers.keys { catalog?.unregister(machine: .cloud(id)) } + let retiringProviders = providers + for id in retiringProviders.keys { catalog?.unregister(machine: .cloud(id)) } providers.removeAll() + for provider in retiringProviders.values { await provider.stop() } let teardowns = Array(machineTeardowns.values) machineTeardowns.removeAll() let previous = teardownInFlight diff --git a/Sources/TerminalController+AuthTeam.swift b/Sources/TerminalController+AuthTeam.swift new file mode 100644 index 000000000000..972b0201aaaf --- /dev/null +++ b/Sources/TerminalController+AuthTeam.swift @@ -0,0 +1,145 @@ +import CmuxAuthRuntime +import CmuxControlSocket +import Foundation +import OSLog + +private let authTeamLog = Logger(subsystem: "ai.manaflow.cmux", category: "auth-team") + +extension TerminalController { + /// Handles the shared team-selection socket actions used by the CLI. + /// Keeping the mutation here means CLI and SwiftUI both call the same + /// coordinator operation and receive the same rollback semantics. + nonisolated func v2AuthTeamResponse(_ request: V2SocketRequest) -> String { + switch request.method { + case "auth.team.list": + return v2Ok(id: request.id, result: v2AuthTeamStatusPayload()) + case "auth.team.use": + return v2Error( + id: request.id, + code: "invalid_dispatch", + message: String(localized: "socket.authTeam.asyncRequired", defaultValue: "Team actions require asynchronous socket dispatch.") + ) + case "auth.team.create": + return v2Error( + id: request.id, + code: "invalid_dispatch", + message: String(localized: "socket.authTeam.asyncRequired", defaultValue: "Team actions require asynchronous socket dispatch.") + ) + default: + return v2Error( + id: request.id, + code: "method_not_found", + message: String(localized: "socket.authTeam.unknownMethod", defaultValue: "Unknown team action.") + ) + } + } + + private nonisolated func v2AuthTeamStatusPayload() -> [String: Any] { + v2MainSync { self.v2AuthTeamStatusPayloadOnMain() } + } + + /// Async socket path for team mutations. Socket connections must suspend + /// while the MainActor-owned auth coordinator performs network work; they + /// must not park a worker thread behind a semaphore. + nonisolated func v2AuthTeamResponseAsync(_ request: ControlRequest) async -> String { + let params = request.params.mapValues(\.foundationObject) + let id = request.id?.foundationObject + switch request.method { + case "auth.team.list": + return v2Ok(id: id, result: await v2AuthTeamStatusPayloadAsync()) + case "auth.team.use": + guard let teamID = params["team_id"] as? String, + !teamID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return v2Error( + id: id, + code: "invalid_params", + message: String(localized: "socket.authTeam.missingTeam", defaultValue: "A team id is required.") + ) + } + return await v2AuthTeamMutationAsync(id: id) { flow in + try await flow.selectTeam(id: teamID) + } + case "auth.team.create": + guard let displayName = params["display_name"] as? String, + !displayName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return v2Error( + id: id, + code: "invalid_params", + message: String(localized: "socket.authTeam.missingName", defaultValue: "A team name is required.") + ) + } + return await v2AuthTeamMutationAsync(id: id) { flow in + _ = try await flow.createTeam(displayName: displayName) + } + default: + return v2Error( + id: id, + code: "method_not_found", + message: String(localized: "socket.authTeam.unknownMethod", defaultValue: "Unknown team action.") + ) + } + } + + private nonisolated func v2AuthTeamMutationAsync( + id: Any?, + action: @escaping @MainActor (HostAccountFlow) async throws -> Void + ) async -> String { + guard let flow = await v2MainAsync({ self.accountFlow }) else { + return v2Error( + id: id, + code: "auth_required", + message: String(localized: "socket.authTeam.signedOut", defaultValue: "Sign in to manage teams.") + ) + } + do { + try await action(flow) + return v2Ok(id: id, result: await v2AuthTeamStatusPayloadAsync()) + } catch { + authTeamLog.error("team mutation failed: \(String(describing: error), privacy: .private)") + return v2Error( + id: id, + code: "team_selection_failed", + message: v2AuthTeamUserMessage(error) + ) + } + } + + private nonisolated func v2AuthTeamUserMessage(_ error: Error) -> String { + switch error { + case AuthError.unauthorized: + return String(localized: "socket.authTeam.signedOut", defaultValue: "Sign in to manage teams.") + case AuthClientError.teamNotAvailable: + return String(localized: "socket.authTeam.notMember", defaultValue: "You are not a member of that team.") + case AuthClientError.invalidTeamName: + return String(localized: "socket.authTeam.invalidName", defaultValue: "Enter a team name.") + default: + return String(localized: "socket.authTeam.failed", defaultValue: "Could not update the team. Try again.") + } + } + + private nonisolated func v2AuthTeamStatusPayloadAsync() async -> [String: Any] { + await v2MainAsync { + self.v2AuthTeamStatusPayloadOnMain() + } + } + + @MainActor + private func v2AuthTeamStatusPayloadOnMain() -> [String: Any] { + guard let coordinator = authCoordinator else { + return ["signed_in": false, "teams": []] + } + var status: [String: Any] = ["signed_in": coordinator.isAuthenticated] + if let teamID = coordinator.resolvedTeamID { + status["selected_team_id"] = teamID + } + status["teams"] = coordinator.availableTeams.map { team in + var value: [String: Any] = [ + "id": team.id, + "display_name": team.displayName + ] + if let slug = team.slug { value["slug"] = slug } + return value + } + return status + } +} diff --git a/Sources/TerminalController+ControlSocketAsync.swift b/Sources/TerminalController+ControlSocketAsync.swift index 80d28644946a..e75cddd868eb 100644 --- a/Sources/TerminalController+ControlSocketAsync.swift +++ b/Sources/TerminalController+ControlSocketAsync.swift @@ -132,7 +132,6 @@ extension TerminalController { } } } - let parts = trimmed.split(separator: " ", maxSplits: 1).map(String.init) guard let commandToken = parts.first else { return await v2MainAsync { @@ -164,7 +163,6 @@ extension TerminalController { } } } - /// Handles a v2 worker request. Snapshot hits are entirely off-main; /// topology misses use the coordinator's typed result seam once and cache /// that result for subsequent polls. Legacy worker methods remain on their @@ -172,10 +170,14 @@ extension TerminalController { private nonisolated func socketWorkerV2ResponseAsync( _ request: ControlRequest ) async -> String? { + if request.method == "auth.team.list" + || request.method == "auth.team.use" + || request.method == "auth.team.create" { + return await v2AuthTeamResponseAsync(request) + } if request.method == "surface.read_selection" { return await socketSurfaceSelectionResponseAsync(request) } - if request.method == "feed.jump" { guard let result = await controlCommandCoordinator .handleSocketWorkerFeedAsync(request, context: self) else { @@ -191,7 +193,6 @@ extension TerminalController { } return Self.v2Encoder.response(id: request.id, result) } - if request.method == "agent.restore.admit" { return await agentRestoreAdmissionResponse(request) } @@ -209,7 +210,6 @@ extension TerminalController { ) { return Self.v2Encoder.response(id: request.id, snapshotResult) } - if ControlCommandExecutionPolicy.servesFromPublishedReadSnapshot(method: request.method), let coordinatorResult = await v2MainAsync({ self.controlCommandCoordinator.handleSocketWorkerV2( diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 712b6df7f31b..2e158162a5de 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -1158,7 +1158,7 @@ class TerminalController { /// (`Any`) field shapes, so the existing command bodies keep their /// `[String: Any]` params until they migrate onto the typed DTOs in the /// ControlCommandCoordinator stage. - private struct V2SocketRequest { + struct V2SocketRequest { let id: Any? let method: String let params: [String: Any] @@ -1501,7 +1501,6 @@ class TerminalController { return "ERROR: reload_config busy" } } - private nonisolated static func feedPushWaitTimeoutSeconds(params: [String: Any]) -> TimeInterval? { guard let rawTimeout = params["wait_timeout_seconds"] else { return 0 @@ -1521,7 +1520,6 @@ class TerminalController { } return seconds } - private nonisolated func socketWorkerV2Response(_ request: V2SocketRequest) -> String { switch request.method { case "auth.status": @@ -1564,6 +1562,8 @@ class TerminalController { } semaphore.wait() return v2Ok(id: request.id, result: v2AuthStatusPayload(timedOut: false)) + case "auth.team.list", "auth.team.use", "auth.team.create": + return v2AuthTeamResponse(request) case "feedback.submit": return v2Result(id: request.id, v2FeedbackSubmit(params: request.params)) case "feed.push": @@ -3174,10 +3174,10 @@ class TerminalController { "auth.status", "auth.sign_in_url", "auth.begin_sign_in", - "auth.sign_out", + "auth.sign_out", "auth.team.list", "auth.team.use", + "auth.team.create", "vm.billing_checkout", - "vm.list", - "vm.diagnostics", "vm.file_transfer_failure", + "vm.list", "vm.diagnostics", "vm.file_transfer_failure", "vm.publication_list", "vm.publication_create", "vm.publication_verify", diff --git a/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift b/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift index e3d2e052e9e4..bba9072c4d23 100644 --- a/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift +++ b/Sources/VerticalTabsSidebar+EmptyAreasAndFooter.swift @@ -228,182 +228,6 @@ struct SidebarFooterHelpIcon: View { } } -struct SidebarAccountMenuButton: View { - @EnvironmentObject private var tabManager: TabManager - private var accountFlow: HostAccountFlow? { AppDelegate.shared?.auth?.accountFlow } - private let title = String(localized: "settings.section.account", defaultValue: "Account") - private let signInTitle = String(localized: "settings.account.signIn", defaultValue: "Sign In…") - private let buttonSize = SidebarFooterButtonMetrics.buttonSize - @State private var isPopoverPresented = false -#if DEBUG - @AppStorage(SidebarFooterProfileIconDebugSettings.sizeKey) - private var debugIconSize = SidebarFooterProfileIconDebugSettings.defaultSize - @AppStorage(SidebarFooterProfileDisplayDebugSettings.displayKey) - private var debugProfileDisplay = SidebarFooterProfileDisplayDebugSettings.defaultDisplay.rawValue -#endif - - private var profileIconSize: CGFloat { -#if DEBUG - CGFloat(debugIconSize) -#else - SidebarFooterButtonMetrics.profileIconSize -#endif - } - - private var prefersProfileIcon: Bool { -#if DEBUG - SidebarFooterProfileDisplayDebugChoice(rawValue: debugProfileDisplay) == .icon -#else - false -#endif - } - - private func presentation( - isSignedIn: Bool, - hasProfilePicture: Bool - ) -> SidebarAccountButtonPresentation { - let presentation = SidebarAccountButtonPresentation.resolve( - isSignedIn: isSignedIn, - prefersProfileIcon: prefersProfileIcon, - hasProfilePicture: hasProfilePicture - ) -#if DEBUG - if !presentation.showsProfilePicture { - return SidebarAccountButtonPresentation( - visual: presentation.visual, - size: profileIconSize - ) - } -#endif - return presentation - } - - var body: some View { - let identity = accountFlow?.currentIdentity - let isSignedIn = identity != nil - let buttonTitle = isSignedIn ? title : signInTitle - let presentation = presentation( - isSignedIn: isSignedIn, - hasProfilePicture: identity?.avatarURL != nil - ) - Button { - if isSignedIn { - isPopoverPresented.toggle() - } else { - _ = AppDelegate.shared?.performAccountSignInWorkspaceAction( - tabManager: tabManager, - debugSource: "sidebar.account" - ) - } - } label: { - SidebarAccountAvatar( - avatarURL: identity?.avatarURL, - displayName: identity?.displayName ?? "", - email: identity?.email ?? "", - isSignedIn: presentation.showsProfilePicture, - size: presentation.size - ) - .frame(width: buttonSize, height: buttonSize) - } - .buttonStyle(SidebarFooterIconButtonStyle()) - .disabled(accountFlow?.isWorkingOnAuth == true) - .frame(width: buttonSize, height: buttonSize) - .background(ArrowlessPopoverAnchor( - isPresented: $isPopoverPresented, - preferredEdge: .maxY, - detachedGap: 4 - ) { - SidebarAccountPopover( - accountFlow: accountFlow, - dismiss: { isPopoverPresented = false } - ) - }) - .safeHelp(buttonTitle) - .accessibilityLabel(buttonTitle) - .accessibilityIdentifier("SidebarAccountMenuButton") - } -} - -private struct SidebarAccountPopover: View { - let accountFlow: HostAccountFlow? - let dismiss: () -> Void - - var body: some View { - VStack(alignment: .leading, spacing: 10) { - if let identity = accountFlow?.currentIdentity { - HStack(spacing: 10) { - SidebarAccountAvatar( - avatarURL: identity.avatarURL, - displayName: identity.displayName, - email: identity.email, - isSignedIn: true, - size: 34 - ) - VStack(alignment: .leading, spacing: 2) { - Text(identity.displayName.isEmpty ? identity.email : identity.displayName) - .cmuxFont(size: 13, weight: .semibold) - .lineLimit(1) - if !identity.email.isEmpty && identity.email != identity.displayName { - Text(identity.email) - .cmuxFont(size: 11) - .foregroundStyle(.secondary) - .lineLimit(1) - } - } - } - Divider() - } else { - Text(String(localized: "settings.account.signedOut.title", defaultValue: "Not signed in")) - .cmuxFont(size: 13, weight: .semibold) - Button { - dismiss() - accountFlow?.startSignIn() - } label: { - Label( - String(localized: "settings.account.signIn", defaultValue: "Sign In…"), - systemImage: "person.crop.circle.badge.plus" - ) - .frame(maxWidth: .infinity, alignment: .leading) - } - .accessibilityIdentifier("SidebarAccountSignInButton") - } - if accountFlow?.isProUpgradeAvailable == true { - if accountFlow?.currentIdentity == nil { - Divider() - } - Button { - dismiss() - accountFlow?.openProUpgrade(source: .sidebarAccountMenu) - } label: { - Label( - String(localized: "menu.help.upgradeToPro", defaultValue: "Upgrade to cmux Pro…"), - systemImage: "sparkles" - ) - .frame(maxWidth: .infinity, alignment: .leading) - } - .accessibilityIdentifier("SidebarAccountUpgradeButton") - } - if accountFlow?.currentIdentity != nil { - Button { - dismiss() - Task { await accountFlow?.signOut() } - } label: { - Label( - String(localized: "settings.account.signOut", defaultValue: "Sign Out"), - systemImage: "rectangle.portrait.and.arrow.right" - ) - .frame(maxWidth: .infinity, alignment: .leading) - } - .accessibilityIdentifier("SidebarAccountSignOutButton") - } - } - .buttonStyle(.plain) - .disabled(accountFlow?.isWorkingOnAuth == true) - .padding(12) - .frame(width: 220, alignment: .leading) - } -} - struct SidebarAccountAvatar: View { let avatarURL: URL? let displayName: String diff --git a/Sources/cmuxApp+CloudWorkspace.swift b/Sources/cmuxApp+CloudWorkspace.swift index 752d4b142797..bf55f43b09e9 100644 --- a/Sources/cmuxApp+CloudWorkspace.swift +++ b/Sources/cmuxApp+CloudWorkspace.swift @@ -7,7 +7,7 @@ extension cmuxApp { static func makeCloudMachinePinStore(auth: MacAuthComposition) -> CloudMachinePinStore { CloudMachinePinStore(defaults: .standard, scopeProvider: { [auth] in guard let userID = auth.accountFlow.currentIdentity?.id, !userID.isEmpty else { return nil } - return "user:\(userID)|team:\(auth.accountFlow.selectedTeamID ?? "personal")" + return "user:\(userID)|team:\(auth.accountFlow.confirmedTeamID ?? "personal")" }) } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 2a40681864e5..7fafcd60358f 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -252,6 +252,7 @@ C51A740000000000000000A1 /* AppDelegate+SimulatorShortcutRouting.swift in Sources */ = {isa = PBXBuildFile; fileRef = C51A740000000000000000A2 /* AppDelegate+SimulatorShortcutRouting.swift */; }; A79840020000000000000002 /* AppDelegate+SocketConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = A79840020000000000000001 /* AppDelegate+SocketConfiguration.swift */; }; 4F33E467181C4EA1BFEBF6AF /* AppDelegate+SurfaceOwnership.swift in Sources */ = {isa = PBXBuildFile; fileRef = 113DE273128A45B791B46B00 /* AppDelegate+SurfaceOwnership.swift */; }; + 13019D000000000000000002 /* AppDelegate+TeamScope.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13019D000000000000000001 /* AppDelegate+TeamScope.swift */; }; A10557010000000000000001 /* AppDelegate+TerminalConfigurationApply.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10557010000000000000002 /* AppDelegate+TerminalConfigurationApply.swift */; }; D5AAB5856FF095F610EB565C /* AppDelegate+VisibleGlobalSearchShortcutRouting.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5AAB5856FF095F610EB565D /* AppDelegate+VisibleGlobalSearchShortcutRouting.swift */; }; DCDC1000000000000000C030 /* AppDelegate+WindowDock.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCDC1000000000000000C031 /* AppDelegate+WindowDock.swift */; }; @@ -863,6 +864,7 @@ 6D2669CD5E4944C2B8C0F49C /* CloudSurfaceOwnershipTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4DAE66C6AF74046B5545B47 /* CloudSurfaceOwnershipTests.swift */; }; E2B852A520D64882866D700C /* CloudTabNameAuthority.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD1C27A86AE4440C848A1B61 /* CloudTabNameAuthority.swift */; }; 1B425E2EA8D5475B94ED1BC1 /* CloudTabNameSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF9BB61CBFC4479997B23A3A /* CloudTabNameSource.swift */; }; + 13019C000000000000000002 /* CloudTeamScopeObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13019C000000000000000001 /* CloudTeamScopeObserver.swift */; }; CCBC23BCB453E9A38F16DB2D /* CloudTelemetryClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF35B9235AD817EE95281459 /* CloudTelemetryClient.swift */; }; A38052AB06B7E0B46A215689 /* CloudTelemetrySending.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EBCFB7E8CD8163F74525C53 /* CloudTelemetrySending.swift */; }; 3C939B66FD8358A0E08156BD /* CloudTelemetrySpan.swift in Sources */ = {isa = PBXBuildFile; fileRef = D995157010FFC40EB0D7460B /* CloudTelemetrySpan.swift */; }; @@ -1114,6 +1116,7 @@ 9758B0119758B0119758B011 /* CMUXCLI+AmpExtensionSourcePart1.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9758B0019758B0019758B001 /* CMUXCLI+AmpExtensionSourcePart1.swift */; }; 9758B0129758B0129758B012 /* CMUXCLI+AmpExtensionSourcePart2.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9758B0029758B0029758B002 /* CMUXCLI+AmpExtensionSourcePart2.swift */; }; 9758B0139758B0139758B013 /* CMUXCLI+AmpExtensionSourcePart3.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9758B0039758B0039758B003 /* CMUXCLI+AmpExtensionSourcePart3.swift */; }; + 13019F000000000000000002 /* CMUXCLI+AuthTeam.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13019F000000000000000001 /* CMUXCLI+AuthTeam.swift */; }; A106490E0000000000000001 /* CMUXCLI+Automation.swift in Sources */ = {isa = PBXBuildFile; fileRef = A106490D0000000000000001 /* CMUXCLI+Automation.swift */; }; 0CB4E9797AD54D3BB9CF06F9 /* CMUXCLI+AutoNaming.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5257257034CA4729B1211166 /* CMUXCLI+AutoNaming.swift */; }; 6D9C51AB30A64B1ABC819142 /* CMUXCLI+AutoNaming.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5257257034CA4729B1211166 /* CMUXCLI+AutoNaming.swift */; }; @@ -1341,6 +1344,7 @@ E3B7A30000000000000000F3 /* CmuxPanes in Frameworks */ = {isa = PBXBuildFile; productRef = E3B7A30000000000000000F2 /* CmuxPanes */; }; F0C0A1000000000000000003 /* CmuxPhonePush in Frameworks */ = {isa = PBXBuildFile; productRef = F0C0A1000000000000000002 /* CmuxPhonePush */; }; F0C0A1000000000000000004 /* CmuxPhonePush in Frameworks */ = {isa = PBXBuildFile; productRef = F0C0A1000000000000000002 /* CmuxPhonePush */; }; + CF6237863B42421E82388C27 /* CmuxPopoverGroupTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96063440572D43F6AB2B499D /* CmuxPopoverGroupTests.swift */; }; A5C0DE0000000000000000A3 /* CMUXProjectModel in Frameworks */ = {isa = PBXBuildFile; productRef = A5C0DE0000000000000000A2 /* CMUXProjectModel */; }; CC0DE10000000000000000A3 /* CmuxRemoteDaemon in Frameworks */ = {isa = PBXBuildFile; productRef = CC0DE10000000000000000A2 /* CmuxRemoteDaemon */; }; CC0DE30000000000000000A3 /* CmuxRemoteSession in Frameworks */ = {isa = PBXBuildFile; productRef = CC0DE30000000000000000A2 /* CmuxRemoteSession */; }; @@ -1968,6 +1972,7 @@ C4160A020000000000000001 /* HistoryMenuCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4160A020000000000000002 /* HistoryMenuCoordinator.swift */; }; A10348030000000000000001 /* HistoryMenuCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10348030000000000000002 /* HistoryMenuCoordinatorTests.swift */; }; A10348020000000000000001 /* HistoryMenuState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10348020000000000000002 /* HistoryMenuState.swift */; }; + 13019B000000000000000002 /* HostAccountFlow+TeamSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13019B000000000000000001 /* HostAccountFlow+TeamSelection.swift */; }; CD0CFE6000000000CD0CFE60 /* HostAccountFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = CD0CFE6100000000CD0CFE61 /* HostAccountFlow.swift */; }; B1F0C0030000000000000001 /* HostedInspectorDockControlScript.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1F0C0030000000000000002 /* HostedInspectorDockControlScript.swift */; }; B1F0C0040000000000000001 /* HostedInspectorDockControlScriptTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1F0C0040000000000000002 /* HostedInspectorDockControlScriptTests.swift */; }; @@ -2838,6 +2843,10 @@ 5C3E0454B6C24B02A2F091A8 /* ShortcutRoutingSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = B42A82C6AA614E74873D9A5F /* ShortcutRoutingSupport.swift */; }; F6001000A1B2C3D4E5F60718 /* ShortcutUnbindingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6001001A1B2C3D4E5F60718 /* ShortcutUnbindingTests.swift */; }; C3467AB10000000000000001 /* ShortcutWhenClauseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3467AB10000000000000002 /* ShortcutWhenClauseTests.swift */; }; + 173E29C3FB4A4A0F867894B0 /* SidebarAccountMenuButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC1E153B939944FC893F1EC2 /* SidebarAccountMenuButtonStyle.swift */; }; + 13019A000000000000000004 /* SidebarAccountTeamPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13019A000000000000000003 /* SidebarAccountTeamPicker.swift */; }; + 13019A000000000000000006 /* SidebarAccountTeamPickerRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13019A000000000000000005 /* SidebarAccountTeamPickerRow.swift */; }; + 13019A000000000000000002 /* SidebarAccountTeamPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13019A000000000000000001 /* SidebarAccountTeamPopover.swift */; }; A6AC71020000000000000001 /* SidebarAgentActivityIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6AC71030000000000000001 /* SidebarAgentActivityIndicator.swift */; }; A6AC71040000000000000001 /* SidebarAgentActivitySummary.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6AC71050000000000000001 /* SidebarAgentActivitySummary.swift */; }; 211A75777F433ED8BA5AE208 /* SidebarAppearanceSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 243632BA1DBBA36FD46E0610 /* SidebarAppearanceSupport.swift */; }; @@ -3270,6 +3279,7 @@ D35A00000000000000000014 /* TerminalController+AgentPromptDelivery.swift in Sources */ = {isa = PBXBuildFile; fileRef = D35B00000000000000000014 /* TerminalController+AgentPromptDelivery.swift */; }; 110434021104340211043402 /* TerminalController+AgentRestoreAdmission.swift in Sources */ = {isa = PBXBuildFile; fileRef = 110434011104340111043401 /* TerminalController+AgentRestoreAdmission.swift */; }; 2AEE214AD8A145C2BEA7AEE3 /* TerminalController+AgentTitle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CCD6F86F7D943F79609DF83 /* TerminalController+AgentTitle.swift */; }; + 13019E000000000000000002 /* TerminalController+AuthTeam.swift in Sources */ = {isa = PBXBuildFile; fileRef = 13019E000000000000000001 /* TerminalController+AuthTeam.swift */; }; A106490C0000000000000001 /* TerminalController+Automation.swift in Sources */ = {isa = PBXBuildFile; fileRef = A106490B0000000000000001 /* TerminalController+Automation.swift */; }; 8054A0030000000000000003 /* TerminalController+BrowserAutomationRecovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8054A0040000000000000004 /* TerminalController+BrowserAutomationRecovery.swift */; }; D35A00000000000000000011 /* TerminalController+BrowserDesignMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = D35B00000000000000000011 /* TerminalController+BrowserDesignMode.swift */; }; @@ -4277,6 +4287,7 @@ C51A740000000000000000A2 /* AppDelegate+SimulatorShortcutRouting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+SimulatorShortcutRouting.swift"; sourceTree = ""; }; A79840020000000000000001 /* AppDelegate+SocketConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+SocketConfiguration.swift"; sourceTree = ""; }; 113DE273128A45B791B46B00 /* AppDelegate+SurfaceOwnership.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+SurfaceOwnership.swift"; sourceTree = ""; }; + 13019D000000000000000001 /* AppDelegate+TeamScope.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+TeamScope.swift"; sourceTree = ""; }; A10557010000000000000002 /* AppDelegate+TerminalConfigurationApply.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+TerminalConfigurationApply.swift"; sourceTree = ""; }; D5AAB5856FF095F610EB565D /* AppDelegate+VisibleGlobalSearchShortcutRouting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "App/AppDelegate+VisibleGlobalSearchShortcutRouting.swift"; sourceTree = ""; }; DCDC1000000000000000C031 /* AppDelegate+WindowDock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppDelegate+WindowDock.swift"; sourceTree = ""; }; @@ -4879,6 +4890,7 @@ D4DAE66C6AF74046B5545B47 /* CloudSurfaceOwnershipTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CloudSurfaceOwnershipTests.swift; sourceTree = ""; }; BD1C27A86AE4440C848A1B61 /* CloudTabNameAuthority.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudTabNameAuthority.swift"; sourceTree = ""; }; AF9BB61CBFC4479997B23A3A /* CloudTabNameSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudTabNameSource.swift"; sourceTree = ""; }; + 13019C000000000000000001 /* CloudTeamScopeObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudTeamScopeObserver.swift"; sourceTree = ""; }; FF35B9235AD817EE95281459 /* CloudTelemetryClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudTelemetryClient.swift"; sourceTree = ""; }; 3EBCFB7E8CD8163F74525C53 /* CloudTelemetrySending.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudTelemetrySending.swift"; sourceTree = ""; }; D995157010FFC40EB0D7460B /* CloudTelemetrySpan.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CloudTelemetrySpan.swift"; sourceTree = ""; }; @@ -5111,6 +5123,7 @@ 9758B0019758B0019758B001 /* CMUXCLI+AmpExtensionSourcePart1.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+AmpExtensionSourcePart1.swift"; sourceTree = ""; }; 9758B0029758B0029758B002 /* CMUXCLI+AmpExtensionSourcePart2.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+AmpExtensionSourcePart2.swift"; sourceTree = ""; }; 9758B0039758B0039758B003 /* CMUXCLI+AmpExtensionSourcePart3.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+AmpExtensionSourcePart3.swift"; sourceTree = ""; }; + 13019F000000000000000001 /* CMUXCLI+AuthTeam.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+AuthTeam.swift"; sourceTree = ""; }; A106490D0000000000000001 /* CMUXCLI+Automation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+Automation.swift"; sourceTree = ""; }; 5257257034CA4729B1211166 /* CMUXCLI+AutoNaming.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+AutoNaming.swift"; sourceTree = ""; }; 5257257034CA4729B121116A /* CMUXCLI+AutoNamingDispatch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+AutoNamingDispatch.swift"; sourceTree = ""; }; @@ -5295,6 +5308,7 @@ C85390050000000000000002 /* CMUXOpenCommandLocationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CMUXOpenCommandLocationTests.swift; sourceTree = ""; }; C0DE31390000000000000102 /* CMUXOpenCommandTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CMUXOpenCommandTests.swift; sourceTree = ""; }; C0DE31390000000000000112 /* CMUXOpenHTMLFocusTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CMUXOpenHTMLFocusTests.swift; sourceTree = ""; }; + 96063440572D43F6AB2B499D /* CmuxPopoverGroupTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CmuxPopoverGroupTests.swift"; sourceTree = ""; }; A9F100000000000000000005 /* CmuxRuntimeDebugCapture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxRuntimeDebugCapture.swift; sourceTree = ""; }; A9F100000000000000000006 /* CmuxRuntimeDebugCaptureConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxRuntimeDebugCaptureConfiguration.swift; sourceTree = ""; }; A9F10000000000000000001A /* CmuxRuntimeDebugCaptureSender.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxRuntimeDebugCaptureSender.swift; sourceTree = ""; }; @@ -5880,6 +5894,7 @@ C4160A020000000000000002 /* HistoryMenuCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryMenuCoordinator.swift; sourceTree = ""; }; A10348030000000000000002 /* HistoryMenuCoordinatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryMenuCoordinatorTests.swift; sourceTree = ""; }; A10348020000000000000002 /* HistoryMenuState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryMenuState.swift; sourceTree = ""; }; + 13019B000000000000000001 /* HostAccountFlow+TeamSelection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HostAccountFlow+TeamSelection.swift"; sourceTree = ""; }; CD0CFE6100000000CD0CFE61 /* HostAccountFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HostAccountFlow.swift; sourceTree = ""; }; B1F0C0030000000000000002 /* HostedInspectorDockControlScript.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/HostedInspectorDockControlScript.swift; sourceTree = ""; }; B1F0C0040000000000000002 /* HostedInspectorDockControlScriptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostedInspectorDockControlScriptTests.swift; sourceTree = ""; }; @@ -6735,6 +6750,10 @@ B42A82C6AA614E74873D9A5F /* ShortcutRoutingSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/ShortcutRoutingSupport.swift; sourceTree = ""; }; F6001001A1B2C3D4E5F60718 /* ShortcutUnbindingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutUnbindingTests.swift; sourceTree = ""; }; C3467AB10000000000000002 /* ShortcutWhenClauseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutWhenClauseTests.swift; sourceTree = ""; }; + EC1E153B939944FC893F1EC2 /* SidebarAccountMenuButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SidebarAccountMenuButtonStyle.swift"; sourceTree = ""; }; + 13019A000000000000000003 /* SidebarAccountTeamPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SidebarAccountTeamPicker.swift"; sourceTree = ""; }; + 13019A000000000000000005 /* SidebarAccountTeamPickerRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SidebarAccountTeamPickerRow.swift"; sourceTree = ""; }; + 13019A000000000000000001 /* SidebarAccountTeamPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SidebarAccountTeamPopover.swift"; sourceTree = ""; }; A6AC71030000000000000001 /* SidebarAgentActivityIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarAgentActivityIndicator.swift; sourceTree = ""; }; A6AC71050000000000000001 /* SidebarAgentActivitySummary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarAgentActivitySummary.swift; sourceTree = ""; }; 243632BA1DBBA36FD46E0610 /* SidebarAppearanceSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarAppearanceSupport.swift; sourceTree = ""; }; @@ -7159,6 +7178,7 @@ D35B00000000000000000014 /* TerminalController+AgentPromptDelivery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+AgentPromptDelivery.swift"; sourceTree = ""; }; 110434011104340111043401 /* TerminalController+AgentRestoreAdmission.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+AgentRestoreAdmission.swift"; sourceTree = ""; }; 0CCD6F86F7D943F79609DF83 /* TerminalController+AgentTitle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+AgentTitle.swift"; sourceTree = ""; }; + 13019E000000000000000001 /* TerminalController+AuthTeam.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+AuthTeam.swift"; sourceTree = ""; }; A106490B0000000000000001 /* TerminalController+Automation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+Automation.swift"; sourceTree = ""; }; 8054A0040000000000000004 /* TerminalController+BrowserAutomationRecovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+BrowserAutomationRecovery.swift"; sourceTree = ""; }; D35B00000000000000000011 /* TerminalController+BrowserDesignMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TerminalController+BrowserDesignMode.swift"; sourceTree = ""; }; @@ -8049,6 +8069,7 @@ DEBDADADADADADADAD000002 /* DebugDogfoodCredentialResolver.swift */, 53750023A0B1C2D3E4F50023 /* MacAuthComposition.swift */, CD0CFE6100000000CD0CFE61 /* HostAccountFlow.swift */, + 13019B000000000000000001 /* HostAccountFlow+TeamSelection.swift */, A5C017000000000000000006 /* StackAccountAvatarView.swift */, A11C00020000000000000002 /* StackAccountAvatarImageLoader.swift */, ); @@ -8584,6 +8605,7 @@ 1652962DDCD1CB279278F8D4 /* CloudMachineCache.swift */, 352B2228561CD8C4B042302A /* CloudActivationPolicy.swift */, 7A0CE1000000000000000725 /* CloudTreeNodeActions+Prompts.swift */, + 13019C000000000000000001 /* CloudTeamScopeObserver.swift */, ); name = Cloud; path = Cloud; @@ -8681,6 +8703,10 @@ C0DE86060000000000000002 /* CommandPaletteFocusRestoreCoordinator.swift */, C0DE86060000000000000004 /* CommandPaletteRestoreFocusTarget.swift */, F7216001000000000000002 /* VerticalTabsSidebar+EmptyAreasAndFooter.swift */, + 13019A000000000000000001 /* SidebarAccountTeamPopover.swift */, + 13019A000000000000000003 /* SidebarAccountTeamPicker.swift */, + 13019A000000000000000005 /* SidebarAccountTeamPickerRow.swift */, + EC1E153B939944FC893F1EC2 /* SidebarAccountMenuButtonStyle.swift */, 8430B0030000000000000003 /* SidebarTabItemSettingsSnapshot.swift */, @@ -9355,6 +9381,7 @@ F90070010000000000000002 /* WorkspaceSurfaceOwnershipTarget.swift */, A5001417 /* WorkspaceContentView.swift */, 5C0728FF0C3B4FF2925CA071 /* AppDelegate+ManagedPolicy.swift */, + 13019D000000000000000001 /* AppDelegate+TeamScope.swift */, CFDB146929E340BE89407AC1 /* ManagedPolicyEnforcementObserver.swift */, D15AB1ED000000000000A001 /* ManagedCapabilityPolicy.swift */, 111A8E6117014894B1DB6998 /* BrowserAvailabilityManagedPolicy.swift */, @@ -9693,6 +9720,7 @@ 110438011104380111043801 /* AgentJournalLifecycleCenter+Database.swift */, A77A000FA1B2C3D4E5F60001 /* AgentJournalLazyStore.swift */, A77A0008A1B2C3D4E5F60001 /* TerminalController+AgentJournal.swift */, + 13019E000000000000000001 /* TerminalController+AuthTeam.swift */, 6313B2A1 /* PaneMemoryGuardrailEngine.swift */, 6313B3A1 /* PaneMemoryGuardrailEngineOutput.swift */, 6313C6A1 /* PaneMemoryGuardrailSampleBatch.swift */, @@ -10625,6 +10653,7 @@ D96971000000000000000001 /* CMUXCLI+Sudo.swift */, REE0CA0000000000000000C1 /* CMUXCLI+Remotes.swift */, C0DE60C0000000000000A021 /* CMUXCLI+Coderouter.swift */, + 13019F000000000000000001 /* CMUXCLI+AuthTeam.swift */, C0DE60C0000000000000A033 /* CMUXCLI+CoderouterPassthrough.swift */, 1E4DB33FA55F1B13C60EFFC8 /* SSHPTYAttachReconnectInputFilter.swift */, E60610200000000000000001 /* SSHPTYAttachReconnectInputFilterControl.swift */, @@ -11455,6 +11484,7 @@ C06552020000000000000002 /* TabManagerTitleUpdateTests.swift */, A10507000000000000000002 /* TitleUpdateAmplificationRegressionTests.swift */, 42092CDB2109E250F7F2A76E /* TabManagerUnitTests.swift */, + 96063440572D43F6AB2B499D /* CmuxPopoverGroupTests.swift */, C0A710120000000000000001 /* ComputerUseUXTests.swift */, C0A717100000000000000001 /* ExternalWindowSamplingServiceTests.swift */, C0A716950000000000000001 /* ComputerUseOnboardingWindowTests.swift */, @@ -12693,6 +12723,7 @@ C51A740000000000000000A1 /* AppDelegate+SimulatorShortcutRouting.swift in Sources */, A79840020000000000000002 /* AppDelegate+SocketConfiguration.swift in Sources */, 4F33E467181C4EA1BFEBF6AF /* AppDelegate+SurfaceOwnership.swift in Sources */, + 13019D000000000000000002 /* AppDelegate+TeamScope.swift in Sources */, A10557010000000000000001 /* AppDelegate+TerminalConfigurationApply.swift in Sources */, D5AAB5856FF095F610EB565C /* AppDelegate+VisibleGlobalSearchShortcutRouting.swift in Sources */, DCDC1000000000000000C030 /* AppDelegate+WindowDock.swift in Sources */, @@ -13034,6 +13065,7 @@ 09FBC463786149D49E73B297 /* CloudSurfaceDropGateView.swift in Sources */, E2B852A520D64882866D700C /* CloudTabNameAuthority.swift in Sources */, 1B425E2EA8D5475B94ED1BC1 /* CloudTabNameSource.swift in Sources */, + 13019C000000000000000002 /* CloudTeamScopeObserver.swift in Sources */, CCBC23BCB453E9A38F16DB2D /* CloudTelemetryClient.swift in Sources */, A38052AB06B7E0B46A215689 /* CloudTelemetrySending.swift in Sources */, 3C939B66FD8358A0E08156BD /* CloudTelemetrySpan.swift in Sources */, @@ -13627,6 +13659,7 @@ A10348010000000000000001 /* HistoryMenuActions.swift in Sources */, C4160A020000000000000001 /* HistoryMenuCoordinator.swift in Sources */, A10348020000000000000001 /* HistoryMenuState.swift in Sources */, + 13019B000000000000000002 /* HostAccountFlow+TeamSelection.swift in Sources */, CD0CFE6000000000CD0CFE60 /* HostAccountFlow.swift in Sources */, B1F0C0030000000000000001 /* HostedInspectorDockControlScript.swift in Sources */, F17A00000000000000000002 /* HostLatencyTrace.swift in Sources */, @@ -14197,6 +14230,10 @@ 6042C0046042C0046042C004 /* ShortcutHintTitlebarPolicy.swift in Sources */, D35A00000000000000000010 /* ShortcutRecorderRejectedAttempt.swift in Sources */, 5C3E0454B6C24B02A2F091A8 /* ShortcutRoutingSupport.swift in Sources */, + 173E29C3FB4A4A0F867894B0 /* SidebarAccountMenuButtonStyle.swift in Sources */, + 13019A000000000000000004 /* SidebarAccountTeamPicker.swift in Sources */, + 13019A000000000000000006 /* SidebarAccountTeamPickerRow.swift in Sources */, + 13019A000000000000000002 /* SidebarAccountTeamPopover.swift in Sources */, A6AC71020000000000000001 /* SidebarAgentActivityIndicator.swift in Sources */, A6AC71040000000000000001 /* SidebarAgentActivitySummary.swift in Sources */, 211A75777F433ED8BA5AE208 /* SidebarAppearanceSupport.swift in Sources */, @@ -14501,6 +14538,7 @@ D35A00000000000000000014 /* TerminalController+AgentPromptDelivery.swift in Sources */, 110434021104340211043402 /* TerminalController+AgentRestoreAdmission.swift in Sources */, 2AEE214AD8A145C2BEA7AEE3 /* TerminalController+AgentTitle.swift in Sources */, + 13019E000000000000000002 /* TerminalController+AuthTeam.swift in Sources */, A106490C0000000000000001 /* TerminalController+Automation.swift in Sources */, 8054A0030000000000000003 /* TerminalController+BrowserAutomationRecovery.swift in Sources */, D35A00000000000000000011 /* TerminalController+BrowserDesignMode.swift in Sources */, @@ -15058,6 +15096,7 @@ 9758B0119758B0119758B011 /* CMUXCLI+AmpExtensionSourcePart1.swift in Sources */, 9758B0129758B0129758B012 /* CMUXCLI+AmpExtensionSourcePart2.swift in Sources */, 9758B0139758B0139758B013 /* CMUXCLI+AmpExtensionSourcePart3.swift in Sources */, + 13019F000000000000000002 /* CMUXCLI+AuthTeam.swift in Sources */, A106490E0000000000000001 /* CMUXCLI+Automation.swift in Sources */, 6D9C51AB30A64B1ABC819142 /* CMUXCLI+AutoNaming.swift in Sources */, 6D9C51AB30A64B1ABC81914A /* CMUXCLI+AutoNamingDispatch.swift in Sources */, @@ -15678,6 +15717,7 @@ C85390050000000000000001 /* CMUXOpenCommandLocationTests.swift in Sources */, C0DE31390000000000000101 /* CMUXOpenCommandTests.swift in Sources */, C0DE31390000000000000111 /* CMUXOpenHTMLFocusTests.swift in Sources */, + CF6237863B42421E82388C27 /* CmuxPopoverGroupTests.swift in Sources */, 7837E0017837E0017837E001 /* CmuxSocketEventMapperTests.swift in Sources */, C3677001000000000000001 /* CmuxSSHURLRequestTests.swift in Sources */, A2977C204AD69CF268D38EE3 /* CmuxTestWindowReleaseGuard.m in Sources */, diff --git a/cmuxTests/CmuxPopoverGroupTests.swift b/cmuxTests/CmuxPopoverGroupTests.swift new file mode 100644 index 000000000000..1ddccdbc0e5f --- /dev/null +++ b/cmuxTests/CmuxPopoverGroupTests.swift @@ -0,0 +1,148 @@ +import AppKit +import Testing +@testable import CmuxAppKitSupportUI + +@MainActor +@Suite +struct CmuxPopoverGroupTests { + @Test func clicksInsideEitherMenuKeepBothOpen() { + let group = CmuxPopoverGroup() + let parent = UUID() + let child = UUID() + var closed: [UUID] = [] + group.register(id: parent, parent: nil, contains: { _, point in + CGRect(x: 0, y: 0, width: 220, height: 240).contains(point) + }, close: { closed.append(parent) }) + group.register(id: child, parent: parent, contains: { _, point in + CGRect(x: 224, y: 50, width: 220, height: 180).contains(point) + }, close: { closed.append(child) }) + + group.handleClick(windowNumber: nil, point: CGPoint(x: 40, y: 80)) + group.handleClick(windowNumber: nil, point: CGPoint(x: 250, y: 80)) + #expect(closed.isEmpty) + + group.handleClick(windowNumber: nil, point: CGPoint(x: 600, y: 80)) + #expect(closed == [child, parent]) + group.dismissAll() + #expect(closed == [child, parent]) + } + + @Test func closingParentAlsoClosesItsSubmenu() { + let group = CmuxPopoverGroup() + let parent = UUID() + let child = UUID() + let grandchild = UUID() + var closed: [UUID] = [] + for (id, owner) in [(parent, nil), (child, parent), (grandchild, child)] as [(UUID, UUID?)] { + group.register(id: id, parent: owner, contains: { _, _ in true }, close: { + closed.append(id) + group.unregister(id) + }) + } + group.unregister(parent) + #expect(closed == [grandchild, child]) + group.dismissAll() + #expect(closed == [grandchild, child]) + } + + @Test func closingOnlySubmenuLeavesParentUntilOutsideClick() { + let group = CmuxPopoverGroup() + let parent = UUID() + let child = UUID() + var closed: [UUID] = [] + group.register(id: parent, parent: nil, contains: { _, point in point.x < 220 }, close: { + closed.append(parent) + }) + group.register(id: child, parent: parent, contains: { _, _ in true }, close: { + closed.append(child) + }) + group.unregister(child) + group.handleClick(windowNumber: nil, point: CGPoint(x: 40, y: 80)) + #expect(closed.isEmpty) + group.handleClick(windowNumber: nil, point: CGPoint(x: 250, y: 80)) + #expect(closed == [parent]) + } + + @Test func hoverKeepsSourceAndSubmenuOpenThenClosesOnlySubmenu() { + let group = CmuxPopoverGroup() + let parent = UUID() + let child = UUID() + var closed: [UUID] = [] + let region = CmuxSubmenuHoverRegion( + source: CGRect(x: 12, y: 100, width: 196, height: 26), + submenu: CGRect(x: 224, y: 50, width: 220, height: 180) + ) + group.register( + id: parent, + parent: nil, + contains: { _, point in CGRect(x: 0, y: 0, width: 220, height: 240).contains(point) }, + close: { closed.append(parent) } + ) + group.register( + id: child, + parent: parent, + contains: { _, point in CGRect(x: 224, y: 50, width: 220, height: 180).contains(point) }, + containsPointer: { _, point in region.contains(point) }, + close: { + closed.append(child) + group.unregister(child) + } + ) + group.handleMove(windowNumber: 1, point: CGPoint(x: 80, y: 113)) + group.handleMove(windowNumber: 2, point: CGPoint(x: 250, y: 80)) + group.handleMove(windowNumber: 1, point: CGPoint(x: 216, y: 90)) + #expect(closed.isEmpty) + // Settings is inside the account popover, outside the team row. + group.handleMove(windowNumber: 1, point: CGPoint(x: 80, y: 160)) + #expect(closed == [child]) + group.handleMove(windowNumber: nil, point: CGPoint(x: 700, y: 500)) + #expect(closed == [child]) + group.handleClick(windowNumber: nil, point: CGPoint(x: 700, y: 500)) + #expect(closed == [child, parent]) + } + + @Test func hoverRegionSupportsLeftAndRightSubmenusWithoutExtraPadding() { + let row = CGRect(x: 230, y: 100, width: 196, height: 26) + let right = CmuxSubmenuHoverRegion(source: row, submenu: CGRect(x: 442, y: 50, width: 220, height: 180)) + let left = CmuxSubmenuHoverRegion(source: row, submenu: CGRect(x: -6, y: 50, width: 220, height: 180)) + #expect(right.contains(CGPoint(x: 434, y: 90))) + #expect(left.contains(CGPoint(x: 222, y: 90))) + #expect(!right.contains(CGPoint(x: 300, y: 160))) + #expect(!left.contains(CGPoint(x: 300, y: 160))) + #expect(!right.contains(CGPoint(x: 670, y: 160))) + #expect(!left.contains(CGPoint(x: -10, y: 160))) + #expect(!right.contains(CGPoint(x: 434, y: 240))) + } + + @Test func mouseMovementSubscriptionRestoresTheWindowSetting() { + let window = NSWindow(contentRect: CGRect(x: 0, y: 0, width: 300, height: 300), + styleMask: [.borderless], backing: .buffered, defer: false) + let anchor = NSView(frame: CGRect(x: 10, y: 10, width: 100, height: 26)) + window.contentView?.addSubview(anchor) + window.acceptsMouseMovedEvents = false + let group = CmuxPopoverGroup() + let popover = NSPopover() + let member = group.register(popover: popover, anchor: anchor) + #expect(window.acceptsMouseMovedEvents) + group.unregister(member) + #expect(!window.acceptsMouseMovedEvents) + } + + @Test func reopenedMenuDoesNotKeepStaleMemberWindows() { + let group = CmuxPopoverGroup() + let first = UUID() + var closed: [UUID] = [] + group.register(id: first, parent: nil, contains: { _, _ in true }, close: { + closed.append(first) + group.unregister(first) + }) + group.dismissAll() + + let second = UUID() + group.register(id: second, parent: nil, contains: { _, _ in false }, close: { + closed.append(second) + }) + group.handleClick(windowNumber: nil, point: .zero) + #expect(closed == [first, second]) + } +} diff --git a/scripts/lib/tagged-reload-lock.py b/scripts/lib/tagged-reload-lock.py new file mode 100644 index 000000000000..f61f3cd77622 --- /dev/null +++ b/scripts/lib/tagged-reload-lock.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +"""Keep each tagged reload's cleanup, build, and launch in one exclusive lease.""" + +import fcntl +import os +from pathlib import Path +import signal +import subprocess +import sys +import tempfile + + +def main() -> int: + tag, script, *arguments = sys.argv[1:] + lock_directory = Path(tempfile.gettempdir()) / f"cmux-reload-tags-{os.getuid()}" + lock_directory.mkdir(mode=0o700, parents=True, exist_ok=True) + with (lock_directory / f"{tag}.lock").open("a") as lease: + try: + fcntl.flock(lease.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + print(f"==> Waiting for the active reload of tag {tag}...", flush=True) + fcntl.flock(lease.fileno(), fcntl.LOCK_EX) + + # Only this supervisor owns the lease. Build services and the launched + # app must not inherit it and keep later reloads locked indefinitely. + environment = os.environ.copy() + environment["CMUX_RELOAD_TAG_LOCK_OWNER"] = str(os.getpid()) + child = None + pending_signals = [] + + def forward_signal(number, _frame): + if child is None: + pending_signals.append(number) + return + try: + os.killpg(child.pid, number) + except ProcessLookupError: + pass + + for number in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP): + signal.signal(number, forward_signal) + child = subprocess.Popen( + ["bash", script, *arguments], + env=environment, + start_new_session=True, + close_fds=True, + ) + for number in pending_signals: + forward_signal(number, None) + result = child.wait() + return result if result >= 0 else 128 - result + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/localization-allowed-omissions.json b/scripts/localization-allowed-omissions.json index 8c9a0935754a..b0af0abf32d9 100644 --- a/scripts/localization-allowed-omissions.json +++ b/scripts/localization-allowed-omissions.json @@ -3138,5 +3138,151 @@ "es": "The standard pixel unit abbreviation px is invariant; the localized entry preserves the numeric placeholder.", "ja": "The standard pixel unit abbreviation px is invariant; the localized entry preserves the numeric placeholder." } + }, + "sidebar.account.teamWithPlan": { + "source": "%1$@ · Pro", + "identityLocales": { + "de": { + "reason": "Accessible label preserves user-provided names and punctuation.", + "values": [ + "%1$@ · Pro" + ] + }, + "fr": { + "reason": "Accessible label preserves user-provided names and punctuation.", + "values": [ + "%1$@ · Pro" + ] + }, + "ar": { + "reason": "Accessible label preserves user-provided names and punctuation.", + "values": [ + "%1$@ · Pro" + ] + }, + "es": { + "reason": "Accessible label preserves user-provided names and punctuation.", + "values": [ + "%1$@ · Pro" + ] + }, + "zh-Hant": { + "reason": "Accessible label preserves user-provided names and punctuation.", + "values": [ + "%1$@ · Pro" + ] + }, + "zh-Hans": { + "reason": "Accessible label preserves user-provided names and punctuation.", + "values": [ + "%1$@ · Pro" + ] + }, + "ko": { + "reason": "Accessible label preserves user-provided names and punctuation.", + "values": [ + "%1$@ · Pro" + ] + }, + "ja": { + "reason": "Accessible label preserves user-provided names and punctuation.", + "values": [ + "%1$@ · Pro" + ] + } + } + }, + "sidebar.account.headerLabel": { + "source": "%1$@, %2$@", + "identityLocales": { + "de": { + "reason": "Accessible label preserves user-provided names and punctuation.", + "values": [ + "%1$@, %2$@" + ] + }, + "fr": { + "reason": "Accessible label preserves user-provided names and punctuation.", + "values": [ + "%1$@, %2$@" + ] + }, + "es": { + "reason": "Accessible label preserves user-provided names and punctuation.", + "values": [ + "%1$@, %2$@" + ] + }, + "ko": { + "reason": "Accessible label preserves user-provided names and punctuation.", + "values": [ + "%1$@, %2$@" + ] + } + } + }, + "sidebar.account.teamRowLabel": { + "source": "%1$@%2$@", + "identityLocales": { + "de": { + "reason": "Accessible label preserves user-provided names and punctuation.", + "values": [ + "%1$@%2$@" + ] + }, + "fr": { + "reason": "Accessible label preserves user-provided names and punctuation.", + "values": [ + "%1$@%2$@" + ] + }, + "ar": { + "reason": "Accessible label preserves user-provided names and punctuation.", + "values": [ + "%1$@%2$@" + ] + }, + "es": { + "reason": "Accessible label preserves user-provided names and punctuation.", + "values": [ + "%1$@%2$@" + ] + }, + "zh-Hant": { + "reason": "Accessible label preserves user-provided names and punctuation.", + "values": [ + "%1$@%2$@" + ] + }, + "zh-Hans": { + "reason": "Accessible label preserves user-provided names and punctuation.", + "values": [ + "%1$@%2$@" + ] + }, + "ko": { + "reason": "Accessible label preserves user-provided names and punctuation.", + "values": [ + "%1$@%2$@" + ] + }, + "ja": { + "reason": "Accessible label preserves user-provided names and punctuation.", + "values": [ + "%1$@%2$@" + ] + } + } + }, + "sidebar.account.activeSuffix": { + "source": ", active", + "identityLocales": { + "fr": { + "reason": "Accessible label preserves user-provided names and punctuation.", + "values": [ + ", active" + ] + } + } } } diff --git a/scripts/reload.sh b/scripts/reload.sh index 83ef067020b1..f14d0d78793b 100755 --- a/scripts/reload.sh +++ b/scripts/reload.sh @@ -2,6 +2,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +RELOAD_ORIGINAL_ARGS=("$@") export SWIFTPM_MIRROR_CONFIG="${SWIFTPM_MIRROR_CONFIG:-$SCRIPT_DIR/../config/swiftpm/mirrors.json}" # shellcheck source=scripts/lib/mobile-attach.sh source "$SCRIPT_DIR/lib/mobile-attach.sh" @@ -1262,6 +1263,13 @@ if [[ -n "$TAG" ]]; then fi TAG_ID="$(sanitize_bundle "$TAG")" TAG_SLUG="$(sanitize_path "$TAG")" + # Serialize the complete reload, including cleanup and log publication. + # Xcode's database lock alone is too late: a losing reload's cleanup can + # delete the active build's generated app before it finishes signing. + if [[ "${CMUX_RELOAD_TAG_LOCK_OWNER:-}" != "$PPID" ]]; then + exec python3 "$SCRIPT_DIR/lib/tagged-reload-lock.py" \ + "$TAG_SLUG" "$0" "${RELOAD_ORIGINAL_ARGS[@]}" + fi if [[ "$NAME_SET" -eq 0 ]]; then APP_NAME="cmux DEV ${TAG_SLUG}" fi diff --git a/tests/test_reload_tag_lock.py b/tests/test_reload_tag_lock.py new file mode 100644 index 000000000000..cbee18d1cd38 --- /dev/null +++ b/tests/test_reload_tag_lock.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Behavior checks for concurrent tagged reloads without invoking Xcode.""" + +from pathlib import Path +import selectors +import subprocess +import sys +import tempfile +import unittest +import uuid + + +HELPER = Path(__file__).resolve().parents[1] / "scripts/lib/tagged-reload-lock.py" + + +class ReloadTagLockTests(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory(prefix="cmux-reload-lock-test-") + self.addCleanup(self.directory.cleanup) + self.script = Path(self.directory.name) / "build.sh" + self.script.write_text( + 'test "$CMUX_RELOAD_TAG_LOCK_OWNER" = "$PPID" || exit 90\n' + 'printf "ready\\n"\n' + 'read -r release\n' + 'exit "${release:-0}"\n' + ) + self.tag = "test-" + uuid.uuid4().hex + + def start_reload(self, tag): + process = subprocess.Popen( + [sys.executable, str(HELPER), tag, str(self.script)], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + self.addCleanup(self.stop_reload, process) + return process + + def stop_reload(self, process): + if process.poll() is None: + process.terminate() + process.wait(timeout=10) + for pipe in (process.stdin, process.stdout, process.stderr): + pipe.close() + + def read_signal(self, process): + with selectors.DefaultSelector() as selector: + selector.register(process.stdout, selectors.EVENT_READ) + self.assertTrue(selector.select(10), "Reload did not signal progress") + return process.stdout.readline().strip() + + def finish_reload(self, process, result=0): + process.stdin.write(f"{result}\n") + process.stdin.flush() + return process.wait(timeout=10) + + def test_same_tag_waits_and_other_tags_run_independently(self): + first = self.start_reload(self.tag) + self.assertEqual(self.read_signal(first), "ready") + second = self.start_reload(self.tag) + self.assertNotEqual(self.read_signal(second), "ready") + + other = self.start_reload(self.tag + "-other") + self.assertEqual(self.read_signal(other), "ready") + self.assertEqual(self.finish_reload(other), 0) + + # A failed build must release the lease and preserve its exit status. + self.assertEqual(self.finish_reload(first, result=7), 7) + self.assertEqual(self.read_signal(second), "ready") + self.assertEqual(self.finish_reload(second), 0) + + def test_termination_reaches_build_and_releases_lease(self): + interrupted = self.start_reload(self.tag) + self.assertEqual(self.read_signal(interrupted), "ready") + interrupted.terminate() + self.assertEqual(interrupted.wait(timeout=10), 143) + + following = self.start_reload(self.tag) + self.assertEqual(self.read_signal(following), "ready") + self.assertEqual(self.finish_reload(following), 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/web/app/[locale]/dashboard/coderouter/page.tsx b/web/app/[locale]/dashboard/coderouter/page.tsx index e990e378970f..ffd10f3e56d0 100644 --- a/web/app/[locale]/dashboard/coderouter/page.tsx +++ b/web/app/[locale]/dashboard/coderouter/page.tsx @@ -22,9 +22,6 @@ import { type CoderouterTeamMetrics, } from "@/services/coderouter/teamMetrics"; import { loadMachineUsage, MachineUsageSection } from "./machine-usage"; -import { - coderouterOrganizationFromCookieHeader, -} from "@/services/coderouter/organizationScope"; import { listClaudeAccounts } from "@/services/coderouter/claudeUpstream"; import { CoderouterAccountsSection, @@ -261,10 +258,6 @@ async function resolveCoderouterAuthorization( const selectedTeam = selectTeam( teams, requestedTeamId, - coderouterOrganizationFromCookieHeader( - requestHeaders.get("cookie"), - authenticated.user.id, - ), authenticated.user.selectedTeamId, ); return { @@ -459,7 +452,6 @@ function StatusPanel({ title, body }: { title: string; body: string }) { function selectTeam( teams: readonly DashboardTeam[], requestedTeamId: string | undefined, - scopedTeamId: string | null, selectedTeamId: string | null, ): DashboardTeam { const requested = requestedTeamId?.trim(); @@ -467,10 +459,6 @@ function selectTeam( const selected = teams.find((team) => team.id === requested); if (selected) return selected; } - if (scopedTeamId) { - const scoped = teams.find((team) => team.id === scopedTeamId); - if (scoped) return scoped; - } if (selectedTeamId) { const selected = teams.find((team) => team.id === selectedTeamId); if (selected) return selected; diff --git a/web/app/[locale]/dashboard/dashboard-team-scope.ts b/web/app/[locale]/dashboard/dashboard-team-scope.ts index 6c5fba3a46e5..57d647b06fff 100644 --- a/web/app/[locale]/dashboard/dashboard-team-scope.ts +++ b/web/app/[locale]/dashboard/dashboard-team-scope.ts @@ -27,15 +27,15 @@ export type DashboardTeamScope = readonly status: "ready"; readonly teams: readonly DashboardCatalogTeam[]; readonly selected: DashboardCatalogTeam; - readonly switchTeam: (team: DashboardCatalogTeam) => void; + readonly switchTeam: (team: DashboardCatalogTeam) => Promise; }; const CATALOG_TIMEOUT_MS = 10_000; /** - * The dashboard-wide team scope. Every team-scoped page reads the same - * persisted cookie on the server, so switching here changes what the whole - * dashboard shows without a page-level picker. + * The dashboard-wide team scope. Stack Auth owns the selected team on the + * server, so switching here changes what every dashboard surface shows + * without a page-level picker. The legacy cookie is mirrored for older pages. */ export function useDashboardTeamScope(userId: string | null): DashboardTeamScope { const router = useRouter(); @@ -59,10 +59,16 @@ export function useDashboardTeamScope(userId: string | null): DashboardTeamScope if (teams.length === 0) return { status: "unavailable" }; const selected = selectedTeam(teams, data.selectedTeamId, searchParams.get("team")); - const switchTeam = (team: DashboardCatalogTeam) => { + const switchTeam = async (team: DashboardCatalogTeam) => { if (team.id === selected.id) return; - // The scope cookie is what the server reads. Persisting it before the - // refresh means the very next render already shows the chosen team. + const response = await fetch("/api/subrouter/teams", { + method: "PATCH", + headers: { "content-type": "application/json", accept: "application/json" }, + body: JSON.stringify({ teamId: team.id }), + }); + if (!response.ok) throw new Error("Could not switch dashboard team"); + // Keep the legacy cookie in sync for older dashboard pages while the + // Stack Auth selected team remains the authority. persistCoderouterOrganizationScope(userId, team.id); queryClient.setQueryData( queryKey, diff --git a/web/app/[locale]/dashboard/iroh/iroh-dashboard.tsx b/web/app/[locale]/dashboard/iroh/iroh-dashboard.tsx index 151fa499725c..25f0b1a0e07d 100644 --- a/web/app/[locale]/dashboard/iroh/iroh-dashboard.tsx +++ b/web/app/[locale]/dashboard/iroh/iroh-dashboard.tsx @@ -6,7 +6,6 @@ import { useTranslations } from "next-intl"; import { useRouter } from "@/i18n/navigation"; import { persistCoderouterOrganizationScope, - coderouterOrganizationFromCookieHeader, } from "@/services/coderouter/organizationScope"; import { V2DashboardController, type DashboardDirectory } from "./v2-dashboard-controller"; @@ -39,11 +38,7 @@ function AuthenticatedIrohDashboard({ user, userId, userEmail, stack }: Props & const teams = user.useTeams(); useEffect(() => { - const cookieTeam = coderouterOrganizationFromCookieHeader( - typeof document === "undefined" ? null : document.cookie, - userId, - ); - setTeamId(cookieTeam ?? user.selectedTeam?.id ?? teams[0]?.id ?? null); + setTeamId(user.selectedTeam?.id ?? teams[0]?.id ?? null); }, [userId, user.selectedTeam?.id, teams]); useEffect(() => { @@ -70,8 +65,14 @@ function AuthenticatedIrohDashboard({ user, userId, userEmail, stack }: Props & }; }, [stack, teamId, userId]); - const chooseTeam = (next: string) => { + const chooseTeam = async (next: string) => { if (!next || next === teamId) return; + const response = await fetch("/api/subrouter/teams", { + method: "PATCH", + headers: { "content-type": "application/json", accept: "application/json" }, + body: JSON.stringify({ teamId: next }), + }); + if (!response.ok) return; persistCoderouterOrganizationScope(userId, next); setTeamId(next); router.refresh(); diff --git a/web/app/api/subrouter/teams/route.ts b/web/app/api/subrouter/teams/route.ts index 64f050b29a39..ec572baf90fc 100644 --- a/web/app/api/subrouter/teams/route.ts +++ b/web/app/api/subrouter/teams/route.ts @@ -1,5 +1,10 @@ import { authenticateRequestRouteToken, ROUTE_TOKEN_HEADER, VM_ID_HEADER } from "../../../../services/coderouter/routeTokenAuth"; -import { jsonResponse } from "../../../../services/vms/routeHelpers"; +import { + browserMutationOriginAllowed, + jsonResponse, + parseBearer, + requiresBrowserMutationProtection, +} from "../../../../services/vms/routeHelpers"; import { isSubrouterAuthorizationError, unauthorized, @@ -11,16 +16,97 @@ import { authorizedSubrouterTeams, serviceUnavailableResponse, } from "../../../../services/subrouter/routeHelpers"; -import { - coderouterOrganizationFromCookieHeader, -} from "../../../../services/coderouter/organizationScope"; import { captureCoderouterEvent } from "../../../../services/coderouter/analytics"; +import { getStackServerApp } from "../../../lib/stack"; export async function GET(request: Request): Promise { return organizationsGet(request, authorizedSubrouterTeams); } +/** Create a Stack Auth team for the authenticated user and return its summary. */ +export async function POST(request: Request): Promise { + if ( + requiresBrowserMutationProtection(request.method, parseBearer(request)) && + !browserMutationOriginAllowed(request) + ) return jsonResponse({ error: "forbidden" }, 403); + try { + return await withSubrouterAuthorizationDeadline(async (signal) => { + const user = await verifySubrouterRequest(request, signal, { + allowCookie: true, + listAllTeams: true, + }); + if (!user) return unauthorized(); + + const payload = await request.json().catch(() => null) as { displayName?: unknown } | null; + const displayName = typeof payload?.displayName === "string" + ? payload.displayName.trim() + : ""; + if (!displayName || displayName.length > 120) { + return jsonResponse({ error: "invalid_team_name" }, 400); + } + + const team = await getStackServerApp().createTeam({ + displayName, + creatorUserId: user.id, + }); + const stackUser = await getStackServerApp().getUser(user.id); + if (!stackUser) return unauthorized(); + await stackUser.update({ selectedTeamId: team.id }); + return jsonResponse({ + team: { id: team.id, name: team.displayName }, + selectedTeamId: team.id, + }, 201); + }); + } catch (error) { + if (isSubrouterAuthorizationError(error)) { + console.error("Subrouter team creation authorization unavailable", { + errorType: error.name, + }); + return serviceUnavailableResponse(); + } + throw error; + } +} + +/** Persist the selected member team in Stack Auth for browser clients. */ +export async function PATCH(request: Request): Promise { + if ( + requiresBrowserMutationProtection(request.method, parseBearer(request)) && + !browserMutationOriginAllowed(request) + ) return jsonResponse({ error: "forbidden" }, 403); + try { + return await withSubrouterAuthorizationDeadline(async (signal) => { + const user = await verifySubrouterRequest(request, signal, { + allowCookie: true, + listAllTeams: true, + }); + if (!user) return unauthorized(); + + const payload = await request.json().catch(() => null) as { teamId?: unknown } | null; + const teamId = typeof payload?.teamId === "string" + ? payload.teamId.trim() + : ""; + if (!teamId || (!user.teamIds.includes(teamId) && teamId !== user.id)) { + return jsonResponse({ error: "team_not_found" }, 403); + } + + const stackUser = await getStackServerApp().getUser(user.id); + if (!stackUser) return unauthorized(); + await stackUser.update({ selectedTeamId: teamId }); + return jsonResponse({ selectedTeamId: teamId }); + }); + } catch (error) { + if (isSubrouterAuthorizationError(error)) { + console.error("Subrouter team selection authorization unavailable", { + errorType: error.name, + }); + return serviceUnavailableResponse(); + } + throw error; + } +} + export async function organizationsGet(request: Request, listTeams: (user: AuthedUser) => ReturnType | Promise>, ): Promise { @@ -41,15 +127,10 @@ export async function organizationsGet(request: Request, if (!user) return unauthorized(); const authorized = await listTeams(user); - const scopedTeamId = coderouterOrganizationFromCookieHeader( - request.headers.get("cookie"), - user.id, - ); let selectedTeamId: string | null = null; let stackSelectedTeamId: string | null = null; const teams = []; for (const team of authorized) { - if (team.teamId === scopedTeamId) selectedTeamId = scopedTeamId; if (team.teamId === user.selectedTeamId) { stackSelectedTeamId = user.selectedTeamId; } diff --git a/web/data/cmux-shortcuts.ts b/web/data/cmux-shortcuts.ts index 5619b8c64d81..3aab91279ee7 100644 --- a/web/data/cmux-shortcuts.ts +++ b/web/data/cmux-shortcuts.ts @@ -31,6 +31,32 @@ export const shortcutCategories: ShortcutCategory[] = [ blurbKey: "appBlurb", shortcuts: [ { id: "openSettings", combos: [["⌘", ","]], description: { en: "Settings", ja: "設定" } }, + { + id: "openTeamPicker", + combos: [["⌥", "⇧", "⌘", "T"]], + description: { + en: "Open team picker", + ja: "チームピッカーを開く", + "zh-CN": "打开团队选择器", + "zh-TW": "開啟團隊選擇器", + ko: "팀 선택기 열기", + de: "Team-Auswahl öffnen", + es: "Abrir selector de equipos", + fr: "Ouvrir le sélecteur d’équipes", + it: "Apri il selettore dei team", + da: "Åbn teamvælger", + pl: "Otwórz wybór zespołu", + ru: "Открыть выбор команды", + bs: "Otvori birač tima", + ar: "فتح محدد الفريق", + no: "Åpne teamvelger", + "pt-BR": "Abrir seletor de equipes", + th: "เปิดตัวเลือกทีม", + tr: "Ekip seçiciyi aç", + km: "បើកកម្មវិធីជ្រើសរើសក្រុម", + uk: "Відкрити вибір команди", + }, + }, { id: "reloadConfiguration", combos: [["⌘", "⇧", ","]], description: { en: "Reload configuration", ja: "構成を再読み込み" } }, { id: "showHideAllWindows", diff --git a/web/data/cmux.schema.json b/web/data/cmux.schema.json index b122fc5917a3..bee48bd43b3e 100644 --- a/web/data/cmux.schema.json +++ b/web/data/cmux.schema.json @@ -1710,6 +1710,7 @@ "propertyNames": { "enum": [ "openSettings", + "openTeamPicker", "reloadConfiguration", "showHideAllWindows", "globalSearch", diff --git a/web/tests/dashboard-coderouter-page.test.tsx b/web/tests/dashboard-coderouter-page.test.tsx index 3ce279af3cab..fcb73c5d1ca4 100644 --- a/web/tests/dashboard-coderouter-page.test.tsx +++ b/web/tests/dashboard-coderouter-page.test.tsx @@ -465,7 +465,7 @@ describe("coderouter dashboard", () => { expect(metricsTeamIds).toEqual(["team-2"]); }); - test("uses the persisted CodeRouter scope before the Stack default", async () => { + test("uses the Stack selected team before the legacy cookie", async () => { authorizationAvailable = true; selectedTeamId = "team-1"; scopedTeamId = "team-2"; @@ -488,7 +488,7 @@ describe("coderouter dashboard", () => { locale: "en", }); - expect(metricsTeamIds).toEqual(["team-2"]); + expect(metricsTeamIds).toEqual(["team-1"]); }); test("normalizes a null Stack selection to the personal organization", async () => { diff --git a/web/tests/hosted-subrouter-routes.test.ts b/web/tests/hosted-subrouter-routes.test.ts index f9f62c5e4049..bded25eef299 100644 --- a/web/tests/hosted-subrouter-routes.test.ts +++ b/web/tests/hosted-subrouter-routes.test.ts @@ -21,6 +21,12 @@ let authJson = { }; let authJsonError: Error | null = null; const getUser = mock(async () => currentUser); +const createTeam = mock(async ({ displayName, creatorUserId }: { displayName: string; creatorUserId?: string }) => ({ + id: "team-created", + displayName, + creatorUserId, +})); +const updateUser = mock(async (_data: unknown) => {}); const getAuthJson = mock(async () => { if (authJsonError) throw authJsonError; return authJson; @@ -30,7 +36,7 @@ let hostedCutoverReady = true; const hostedSubrouterCutoverReadyForTeam = mock(async () => hostedCutoverReady); mock.module("../app/lib/stack", () => ({ - getStackServerApp: () => ({ getUser, getAuthJson, getTeam: async (id: string) => ({ id }) }), + getStackServerApp: () => ({ getUser, getAuthJson, getTeam: async (id: string) => ({ id }), createTeam }), getNonRedirectingStackServerApp: () => ({ getUser, signOut }), isStackConfigured: () => true, stackServerApp: { getUser }, @@ -98,6 +104,8 @@ beforeEach(() => { exchangeStatus = 200; accountListStatus = 200; getUser.mockClear(); + createTeam.mockClear(); + updateUser.mockClear(); getAuthJson.mockClear(); signOut.mockClear(); hostedSubrouterCutoverReadyForTeam.mockClear(); @@ -632,7 +640,7 @@ describe("hosted Subrouter account routes", () => { ); expect(organizationsResponse.status).toBe(200); expect(await organizationsResponse.json()).toEqual({ - selectedTeamId: "team-b", + selectedTeamId: "team-a", teams: [ { id: "team-a", @@ -680,6 +688,54 @@ describe("hosted Subrouter account routes", () => { }, }); }); + + test("creates a team only for the authenticated member", async () => { + const response = await teamsRoute.POST( + request("/api/subrouter/teams", { + method: "POST", + body: JSON.stringify({ displayName: "Shared Cloud" }), + }), + ); + expect(response.status).toBe(201); + expect(await response.json()).toEqual({ + team: { id: "team-created", name: "Shared Cloud" }, + selectedTeamId: "team-created", + }); + expect(createTeam).toHaveBeenCalledWith({ + displayName: "Shared Cloud", + creatorUserId: "user-1", + }); + expect(updateUser).toHaveBeenCalledWith({ selectedTeamId: "team-created" }); + + const invalid = await teamsRoute.POST( + request("/api/subrouter/teams", { + method: "POST", + body: JSON.stringify({ displayName: " " }), + }), + ); + expect(invalid.status).toBe(400); + }); + + test("persists a selected member team in Stack Auth", async () => { + currentUser = { ...stackUser(), update: updateUser }; + const response = await teamsRoute.PATCH( + request("/api/subrouter/teams", { + method: "PATCH", + body: JSON.stringify({ teamId: "team-b" }), + }), + ); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ selectedTeamId: "team-b" }); + expect(updateUser).toHaveBeenCalledWith({ selectedTeamId: "team-b" }); + + const unauthorized = await teamsRoute.PATCH( + request("/api/subrouter/teams", { + method: "PATCH", + body: JSON.stringify({ teamId: "team-other" }), + }), + ); + expect(unauthorized.status).toBe(403); + }); }); type TestRequestInit = RequestInit & { @@ -713,6 +769,7 @@ function stackUser() { { id: "team-a", displayName: "Team A" }, { id: "team-b", displayName: "Team B" }, ], + update: updateUser, }; }