Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 168 additions & 0 deletions ios/App/ConnectedAppsView.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
20 changes: 20 additions & 0 deletions ios/App/Session.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
11 changes: 8 additions & 3 deletions ios/App/SettingsView.swift
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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 {
Expand Down
44 changes: 44 additions & 0 deletions ios/Sources/CompanionCore/Client.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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(
Expand Down
47 changes: 46 additions & 1 deletion ios/Sources/CompanionCore/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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?
Expand All @@ -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
}
Loading
Loading