diff --git a/ios/App/ConnectedAppsView.swift b/ios/App/ConnectedAppsView.swift new file mode 100644 index 000000000..6c9ce0f05 --- /dev/null +++ b/ios/App/ConnectedAppsView.swift @@ -0,0 +1,168 @@ +import CompanionCore +import SwiftUI +import UIKit + +/// Account-aware Composio inventory for a paired phone. +/// +/// The companion may list accounts and start authorization, but revocation +/// deliberately remains on the Mac. That keeps a lost phone from removing a +/// workspace integration while still giving mobile users explicit Work, +/// Personal, and client-account choices. +struct ConnectedAppsView: View { + @EnvironmentObject private var session: Session + @Environment(\.scenePhase) private var scenePhase + @State private var catalog: ConnectorCatalog? + @State private var statuses: [String: ConnectorStatus] = [:] + @State private var query = "" + @State private var aliasCard: ConnectorCard? + @State private var alias = "" + @State private var loading = true + @State private var refreshing = false + + private var cards: [ConnectorCard] { + let values = catalog?.cards ?? [] + guard !query.isEmpty else { return values } + return values.filter { + $0.label.localizedCaseInsensitiveContains(query) || + $0.slug.localizedCaseInsensitiveContains(query) + } + } + + var body: some View { + List { + if catalog?.configured == false { + Section { + ContentUnavailableView( + "Connected apps need setup", + systemImage: "link.badge.plus", + description: Text("Configure Composio on your computer first. Provider credentials are never returned to this phone.") + ) + } + } + + ForEach(cards) { card in + connectorSection(card) + } + } + .navigationTitle("Connected Apps") + .searchable(text: $query, prompt: "Search apps") + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button("Refresh", systemImage: "arrow.clockwise") { + Task { await refreshStatuses(showProgress: true) } + } + .disabled(refreshing) + } + } + .overlay { if loading { ProgressView() } } + .task { await load() } + .refreshable { await refreshStatuses() } + .onChange(of: scenePhase) { _, phase in + if phase == .active { Task { await refreshStatuses() } } + } + .alert("Account alias", isPresented: Binding( + get: { aliasCard != nil }, + set: { if !$0 { aliasCard = nil } } + )) { + TextField("Work, Personal, Client…", text: $alias) + .textInputAutocapitalization(.words) + Button("Cancel", role: .cancel) { aliasCard = nil } + Button("Continue") { + guard let card = aliasCard else { return } + let value = String(alias.trimmingCharacters(in: .whitespacesAndNewlines).prefix(64)) + aliasCard = nil + Task { await authorize(card, alias: value) } + } + .disabled(alias.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } message: { + Text("An alias makes the account explicit when an agent uses more than one \(aliasCard?.label ?? "app") account.") + } + } + + @ViewBuilder + private func connectorSection(_ card: ConnectorCard) -> some View { + let status = statuses[card.slug] + let accounts = status?.accounts ?? [] + let isConnected = status?.connected == true + let isPending = status?.pending == true + + Section { + if accounts.isEmpty, !isConnected, !isPending { + Button("Connect \(card.label)", systemImage: "plus.circle") { + Task { await authorize(card, alias: nil) } + } + .disabled(catalog?.configured != true) + } else if accounts.isEmpty { + let statusLabel = isPending ? "Connecting…" : "Connected" + let statusSymbol = isPending ? "clock" : "checkmark.circle.fill" + Label(statusLabel, systemImage: statusSymbol) + .foregroundStyle(isPending ? Color.secondary : Color.green) + Text("Account details are unavailable from this provider. Refresh after authorization finishes.") + .font(.caption) + .foregroundStyle(.secondary) + } else { + ForEach(accounts) { account in + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(account.nonemptyAlias ?? "Primary account") + Text(account.status.replacingOccurrences(of: "_", with: " ").capitalized) + .font(.caption) + .foregroundStyle(.secondary) + Text(account.id) + .font(.caption2.monospaced()) + .foregroundStyle(.tertiary) + .lineLimit(1) + .textSelection(.enabled) + } + Spacer() + Image(systemName: account.isActive ? "checkmark.circle.fill" : "clock") + .foregroundStyle(account.isActive ? .green : .secondary) + } + } + + Button("Add another account", systemImage: "person.crop.circle.badge.plus") { + alias = "" + aliasCard = card + } + .disabled(accounts.count >= 5) + } + } header: { + HStack { + Text(card.label) + Spacer() + if isPending { Text("Connecting…") } + } + } footer: { + Text(card.blurb) + } + } + + private func load() async { + loading = true + defer { loading = false } + catalog = await session.loadConnectorCatalog() + await refreshStatuses() + } + + private func refreshStatuses(showProgress: Bool = false) async { + if showProgress { refreshing = true } + defer { if showProgress { refreshing = false } } + guard let response = await session.loadAllConnectorStatuses() else { return } + statuses = response.services + } + + private func authorize(_ card: ConnectorCard, alias: String?) async { + guard let url = await session.authorizeConnector(card.slug, alias: alias) else { return } + guard await UIApplication.shared.open(url) else { + session.actionError = "The authorization page could not be opened. Try again after checking your browser restrictions." + return + } + } +} + +private extension ConnectorAccount { + var nonemptyAlias: String? { + let value = alias?.trimmingCharacters(in: .whitespacesAndNewlines) + return value?.isEmpty == false ? value : nil + } +} diff --git a/ios/App/Session.swift b/ios/App/Session.swift index 8c64d6714..d154deb92 100644 --- a/ios/App/Session.swift +++ b/ios/App/Session.swift @@ -872,6 +872,26 @@ final class Session: ObservableObject { } } + // MARK: - Connected apps + + func loadConnectorCatalog() async -> ConnectorCatalog? { + guard let client else { return nil } + do { return try await client.connectorCatalog() } + catch { actionError = error.localizedDescription; return nil } + } + + func loadAllConnectorStatuses() async -> ConnectorStatuses? { + guard let client else { return nil } + do { return try await client.allConnectorStatuses() } + catch { actionError = error.localizedDescription; return nil } + } + + func authorizeConnector(_ slug: String, alias: String?) async -> URL? { + guard let client else { return nil } + do { return try await client.authorizeConnector(slug: slug, alias: alias) } + catch { actionError = error.localizedDescription; return nil } + } + func refreshNotificationAuthorization() async { notificationAuthorization = await NotificationCoordinator.shared.authorizationStatus() } diff --git a/ios/App/SettingsView.swift b/ios/App/SettingsView.swift index b39708022..8e20a9076 100644 --- a/ios/App/SettingsView.swift +++ b/ios/App/SettingsView.swift @@ -1,8 +1,8 @@ // Paired-device settings and safe workspace feature entry points. // // Credentials, revocation, Local VM and execution policy still live only on -// the computer. The phone can manage renderer-neutral routines without -// widening that boundary. +// the computer. The phone can manage renderer-neutral routines and connected- +// account inventory/authorization without widening that boundary. import SwiftUI import CompanionCore @@ -49,10 +49,15 @@ struct SettingsView: View { } label: { Label("Tasks & Routines", systemImage: "calendar.badge.clock") } + NavigationLink { + ConnectedAppsView() + } label: { + Label("Connected Apps", systemImage: "link") + } } header: { Text("Workspace") } footer: { - Text("Routine schedules are safe to manage here. Provider keys, webhook secrets, pairing, revocation, Local VM, and agent execution policy stay on your computer.") + Text("Manage routine schedules, view connected accounts, and add Work, Personal, or client aliases here. Provider keys, webhook secrets, account revocation, pairing, Local VM, and agent execution policy stay on your computer.") } Section { diff --git a/ios/Sources/CompanionCore/Client.swift b/ios/Sources/CompanionCore/Client.swift index d9f391468..74e5c8457 100644 --- a/ios/Sources/CompanionCore/Client.swift +++ b/ios/Sources/CompanionCore/Client.swift @@ -409,6 +409,19 @@ public struct CompanionClient: Sendable { try await send(try makeRequest("GET", "/api/config"), as: ConfigStatus.self) } + public func connectorCatalog() async throws -> ConnectorCatalog { + try await send(try makeRequest("GET", "/api/connectors/catalog"), as: ConnectorCatalog.self) + } + + /// Complete account-aware status in one request. This is the inventory + /// source for the phone; a catalog page is not an account list. + public func allConnectorStatuses() async throws -> ConnectorStatuses { + try await send( + try makeRequest("GET", "/api/connectors/connected"), + as: ConnectorStatuses.self + ) + } + /// The pixels of one screen message. public func image(threadId: String, messageId: String) async throws -> Data { let imageRequest = try makeRequest("GET", "/api/threads/\(threadId)/messages/\(messageId)/image") @@ -593,6 +606,37 @@ public struct CompanionClient: Sendable { try await send(try makeRequest("POST", "/api/bots/\(botId)/always-allow", body: ["allowKey": key])) } + /// Starts one more account authorization for a toolkit. Revocation is + /// intentionally absent: the paired-device boundary keeps that on the Mac. + public func authorizeConnector(slug: String, alias: String?) async throws -> URL { + guard Self.validConnectorSlug(slug) else { throw APIError.badURL } + let trimmed = alias?.trimmingCharacters(in: .whitespacesAndNewlines) + let body: [String: String]? + if let trimmed, !trimmed.isEmpty { + body = ["alias": trimmed] + } else { + body = nil + } + let response = try await send( + try makeRequest("POST", "/api/connectors/\(slug)/authorize", body: body), + as: ConnectorAuthorizationResponse.self + ) + guard let url = URL(string: response.url), + url.scheme == "https", + url.host != nil + else { throw APIError.badURL } + return url + } + + /// Matches the companion's `[\w-]+` toolkit route component. JavaScript + /// `\w` is ASCII here; Unicode letters must not become a confusing 404. + private static func validConnectorSlug(_ value: String) -> Bool { + !value.isEmpty && value.utf8.allSatisfy { + (48...57).contains($0) || (65...90).contains($0) || + (97...122).contains($0) || $0 == 95 || $0 == 45 + } + } + public func toggleReaction(threadId: String, messageId: String, emoji: String) async throws -> Message { try await send( try makeRequest( diff --git a/ios/Sources/CompanionCore/Models.swift b/ios/Sources/CompanionCore/Models.swift index 955c1b759..0252797a2 100644 --- a/ios/Sources/CompanionCore/Models.swift +++ b/ios/Sources/CompanionCore/Models.swift @@ -643,6 +643,48 @@ public struct NotificationTarget: Equatable, Sendable { } } +// MARK: - Connected apps + +public struct ConnectorCard: Codable, Hashable, Identifiable, Sendable { + public var slug: String + public var label: String + public var blurb: String + public var logo: String? + public var domain: String? + public var id: String { slug } +} + +public struct ConnectorAccount: Codable, Hashable, Identifiable, Sendable { + public var id: String + public var alias: String? + public var status: String + + /// Composio lifecycle values include both `ACTIVE` and `INACTIVE`; an + /// exact normalized comparison avoids rendering the latter as connected. + public var isActive: Bool { + status.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() == "ACTIVE" + } +} + +public struct ConnectorStatus: Codable, Hashable, Sendable { + public var connected: Bool + public var pending: Bool? + public var status: String? + public var accounts: [ConnectorAccount]? +} + +public struct ConnectorCatalog: Codable, Sendable { + public var configured: Bool + public var mode: String? + public var source: String? + public var cards: [ConnectorCard] +} + +public struct ConnectorStatuses: Codable, Sendable { + public var configured: Bool + public var services: [String: ConnectorStatus] +} + /// The harness's error body. Every non-2xx response carries one. public struct APIErrorBody: Codable, Sendable { public var error: String @@ -688,7 +730,6 @@ struct ActiveBranchResponse: Codable, Sendable { struct BotResponse: Codable, Sendable { var bot: Bot } - struct VoiceListResponse: Codable, Sendable { var voices: [Voice] var error: String? @@ -712,3 +753,7 @@ struct RoutinesResponse: Codable, Sendable { struct RoutineResponse: Codable, Sendable { var routine: Routine } struct RoutineRunResponse: Codable, Sendable { var run: RoutineRun } + +struct ConnectorAuthorizationResponse: Codable, Sendable { + var url: String +} diff --git a/ios/Tests/CompanionCoreTests/ConnectedAppsClientTests.swift b/ios/Tests/CompanionCoreTests/ConnectedAppsClientTests.swift new file mode 100644 index 000000000..e0de268a9 --- /dev/null +++ b/ios/Tests/CompanionCoreTests/ConnectedAppsClientTests.swift @@ -0,0 +1,131 @@ +import Foundation +import XCTest +@testable import CompanionCore + +private final class ConnectorRequestStub: URLProtocol { + static var responseBody = Data() + static var statusCode = 200 + static var capturedRequest: URLRequest? + static var capturedBody: Data? + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + Self.capturedRequest = request + Self.capturedBody = Self.readBody(from: request) + let response = HTTPURLResponse( + url: request.url!, + statusCode: Self.statusCode, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/json"] + )! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Self.responseBody) + client?.urlProtocolDidFinishLoading(self) + } + + override func stopLoading() {} + + private static func readBody(from request: URLRequest) -> Data? { + if let body = request.httpBody { return body } + guard let stream = request.httpBodyStream else { return nil } + stream.open() + defer { stream.close() } + var data = Data() + var buffer = [UInt8](repeating: 0, count: 1_024) + while stream.hasBytesAvailable { + let count = stream.read(&buffer, maxLength: buffer.count) + guard count >= 0 else { return nil } + if count == 0 { break } + data.append(buffer, count: count) + } + return data + } +} + +final class ConnectedAppsClientTests: XCTestCase { + private var session: URLSession! + private var client: CompanionClient! + + override func setUp() { + super.setUp() + ConnectorRequestStub.responseBody = Data() + ConnectorRequestStub.statusCode = 200 + ConnectorRequestStub.capturedRequest = nil + ConnectorRequestStub.capturedBody = nil + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [ConnectorRequestStub.self] + session = URLSession(configuration: configuration) + client = CompanionClient( + connection: Connection(name: "Test", host: "127.0.0.1", port: 8810), + token: "paired-token", + session: session + ) + } + + override func tearDown() { + session?.invalidateAndCancel() + session = nil + client = nil + super.tearDown() + } + + func testLoadsCompleteAccountInventoryRatherThanInferringItFromTheCatalog() async throws { + ConnectorRequestStub.responseBody = Data(#"{"configured":true,"services":{"slack":{"connected":true,"accounts":[{"id":"ca_work","alias":"Work","status":"ACTIVE"},{"id":"ca_client","alias":"Client","status":"ACTIVE"}]}}}"#.utf8) + + let statuses = try await client.allConnectorStatuses() + + XCTAssertEqual(ConnectorRequestStub.capturedRequest?.url?.path, "/api/connectors/connected") + XCTAssertEqual(statuses.services["slack"]?.accounts?.map(\.alias), ["Work", "Client"]) + XCTAssertEqual( + ConnectorRequestStub.capturedRequest?.value(forHTTPHeaderField: "Authorization"), + "Bearer paired-token" + ) + } + + func testAuthorizesAnotherAccountWithAnExplicitAlias() async throws { + ConnectorRequestStub.responseBody = Data(#"{"url":"https://auth.example/connect"}"#.utf8) + + let url = try await client.authorizeConnector(slug: "google-calendar", alias: " Personal ") + + XCTAssertEqual(url.absoluteString, "https://auth.example/connect") + XCTAssertEqual(ConnectorRequestStub.capturedRequest?.httpMethod, "POST") + XCTAssertEqual(ConnectorRequestStub.capturedRequest?.url?.path, "/api/connectors/google-calendar/authorize") + let body = try XCTUnwrap(ConnectorRequestStub.capturedBody) + let object = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: String]) + XCTAssertEqual(object, ["alias": "Personal"]) + } + + func testOmitsAWhitespaceOnlyAlias() async throws { + ConnectorRequestStub.responseBody = Data(#"{"url":"https://auth.example/connect"}"#.utf8) + + _ = try await client.authorizeConnector(slug: "gmail", alias: " \n ") + + XCTAssertNil(ConnectorRequestStub.capturedBody) + } + + func testRejectsUnsafeToolkitComponentsAndAuthorizationURLsLocally() async { + await assertBadURL { _ = try await self.client.authorizeConnector(slug: "café", alias: nil) } + await assertBadURL { _ = try await self.client.authorizeConnector(slug: "bad/slash", alias: nil) } + XCTAssertNil(ConnectorRequestStub.capturedRequest) + + ConnectorRequestStub.responseBody = Data(#"{"url":"http://auth.example/connect"}"#.utf8) + await assertBadURL { _ = try await self.client.authorizeConnector(slug: "gmail", alias: nil) } + } + + private func assertBadURL( + _ operation: () async throws -> Void, + file: StaticString = #filePath, + line: UInt = #line + ) async { + do { + try await operation() + XCTFail("expected badURL", file: file, line: line) + } catch APIError.badURL { + // Expected: reject before sending paired credentials or opening it. + } catch { + XCTFail("unexpected error: \(error)", file: file, line: line) + } + } +} diff --git a/ios/Tests/CompanionCoreTests/DecodingTests.swift b/ios/Tests/CompanionCoreTests/DecodingTests.swift index 95aa15199..230e13560 100644 --- a/ios/Tests/CompanionCoreTests/DecodingTests.swift +++ b/ios/Tests/CompanionCoreTests/DecodingTests.swift @@ -131,6 +131,50 @@ final class DecodingTests: XCTestCase { XCTAssertNil(fleet.bots.last?.cloudBackend) } + func testDecodesMultipleConnectedAccountsAndNoAuthToolkit() throws { + let payload = #""" + { + "configured": true, + "services": { + "gmail": { + "connected": true, + "pending": false, + "status": "ACTIVE", + "accounts": [ + {"id": "ca_work", "alias": "Work", "status": "ACTIVE"}, + {"id": "ca_personal", "status": "INACTIVE"} + ] + }, + "weather": { + "connected": true, + "pending": false, + "status": "ACTIVE", + "accounts": [] + }, + "slack": { + "connected": false, + "pending": true + } + } + } + """# + let statuses = try JSONDecoder().decode(ConnectorStatuses.self, from: Data(payload.utf8)) + let gmail = try XCTUnwrap(statuses.services["gmail"]) + XCTAssertEqual(gmail.accounts?.map(\.id), ["ca_work", "ca_personal"]) + XCTAssertEqual(gmail.accounts?.first?.alias, "Work") + XCTAssertNil(gmail.accounts?.last?.alias) + XCTAssertTrue(try XCTUnwrap(gmail.accounts?.first).isActive) + XCTAssertFalse(try XCTUnwrap(gmail.accounts?.last).isActive) + + let noAuth = try XCTUnwrap(statuses.services["weather"]) + XCTAssertTrue(noAuth.connected) + XCTAssertEqual(noAuth.accounts?.isEmpty, true) + + let pending = try XCTUnwrap(statuses.services["slack"]) + XCTAssertEqual(pending.pending, true) + XCTAssertNil(pending.accounts) + } + func testOneMalformedBotDoesNotHideTheRestOfTheFleet() throws { let json = """ {