diff --git a/app/macos/Sources/OpenClawApp/OpenClawApp.swift b/app/macos/Sources/OpenClawApp/OpenClawApp.swift index f1c3685..fe43242 100644 --- a/app/macos/Sources/OpenClawApp/OpenClawApp.swift +++ b/app/macos/Sources/OpenClawApp/OpenClawApp.swift @@ -11,6 +11,9 @@ struct OpenClawApp: App { .frame(width: 520, height: launcher.state == .running ? 580 : 520) .animation(.easeInOut(duration: 0.25), value: launcher.state) .onAppear { launcher.start() } + .onOpenURL { url in + handleOAuthCallback(url) + } } .windowStyle(.hiddenTitleBar) .windowResizability(.contentSize) @@ -22,4 +25,25 @@ struct OpenClawApp: App { Text("🐙") } } + + /// Handle OAuth callback from custom URL scheme + /// Expected format: openclaw://oauth/callback?code=XXX&state=YYY + private func handleOAuthCallback(_ url: URL) { + guard url.scheme == "openclaw", + url.host == "oauth", + url.path == "/callback" || url.path.isEmpty, + let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + let code = components.queryItems?.first(where: { $0.name == "code" })?.value + else { + return + } + + // The state parameter contains the PKCE verifier in our implementation + let state = components.queryItems?.first(where: { $0.name == "state" })?.value + + // Pass the code to the launcher to complete the exchange + Task { @MainActor in + launcher.handleOAuthCallback(code: code, state: state) + } + } } diff --git a/app/macos/Sources/OpenClawLib/AnthropicOAuth.swift b/app/macos/Sources/OpenClawLib/AnthropicOAuth.swift index d65a721..6a888c2 100644 --- a/app/macos/Sources/OpenClawLib/AnthropicOAuth.swift +++ b/app/macos/Sources/OpenClawLib/AnthropicOAuth.swift @@ -18,7 +18,16 @@ public enum AnthropicOAuth { public static let clientId = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" private static let authorizeURL = URL(string: "https://claude.ai/oauth/authorize")! private static let tokenURL = URL(string: "https://console.anthropic.com/v1/oauth/token")! - private static let redirectURI = "https://console.anthropic.com/oauth/code/callback" + + // Standard redirect URI (requires manual code copy) + private static let webRedirectURI = "https://console.anthropic.com/oauth/code/callback" + + // Custom URL scheme for automatic callback (requires Anthropic to accept it) + public static let customRedirectURI = "openclaw://oauth/callback" + + // Use web redirect by default until we verify Anthropic accepts custom schemes + private static var redirectURI: String { webRedirectURI } + private static let scopes = "org:create_api_key user:profile user:inference" public struct PKCE { diff --git a/app/macos/Sources/OpenClawLib/LauncherViews.swift b/app/macos/Sources/OpenClawLib/LauncherViews.swift index f72add4..0664a74 100644 --- a/app/macos/Sources/OpenClawLib/LauncherViews.swift +++ b/app/macos/Sources/OpenClawLib/LauncherViews.swift @@ -339,13 +339,13 @@ struct SetupView: View { // Bottom actions VStack(spacing: 12) { if launcher.state == .needsAuth { - AuthChoiceView(launcher: launcher) + ProviderSelectionView(launcher: launcher) + } else if launcher.state == .selectingProvider { + AuthMethodView(launcher: launcher) + } else if launcher.state == .waitingForApiKey { + ApiKeyInputView(launcher: launcher) } else if launcher.state == .waitingForOAuthCode { - if launcher.showApiKeyField { - ApiKeyInputView(launcher: launcher) - } else { - OAuthCodeInputView(launcher: launcher) - } + OAuthCodeInputView(launcher: launcher) } else if launcher.state == .stopped { Button("Start OpenClaw") { launcher.start() @@ -380,65 +380,139 @@ struct SetupView: View { } } -struct AuthChoiceView: View { +// MARK: - Provider Selection View + +struct ProviderSelectionView: View { @ObservedObject var launcher: OpenClawLauncher var body: some View { - VStack(spacing: 12) { - Text("Authentication") + VStack(spacing: 16) { + Text("Connect AI Model") .font(.system(size: 14, weight: .semibold)) - Text("Choose how to connect to Anthropic.") + Text("Choose your AI provider to get started.") .font(.system(size: 11)) .foregroundStyle(.secondary) VStack(spacing: 8) { - Button("Sign in with Claude") { - launcher.startOAuth() - } - .buttonStyle(.borderedProminent) - .controlSize(.large) - - Button("Use API Key") { - launcher.showApiKeyInput() + ForEach(AuthProvider.allCases) { provider in + Button(action: { launcher.selectProvider(provider) }) { + HStack { + Text(provider.displayName) + .fontWeight(.medium) + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + .padding(.horizontal, 16) + .frame(height: 44) + } + .buttonStyle(.bordered) + .controlSize(.large) } - .buttonStyle(.bordered) - .controlSize(.large) - Button("Skip") { + Button("Skip for now") { launcher.skipAuth() } .buttonStyle(.borderless) .foregroundStyle(.secondary) .font(.system(size: 12)) + .padding(.top, 4) + } + .frame(maxWidth: 300) + + Text("You can always change this in the Control UI later.") + .font(.system(size: 10)) + .foregroundStyle(.tertiary) + } + } +} + +// MARK: - Auth Method View (OAuth vs API Key for providers that support both) + +struct AuthMethodView: View { + @ObservedObject var launcher: OpenClawLauncher + + var body: some View { + VStack(spacing: 12) { + if let provider = launcher.selectedProvider { + Text(provider.displayName) + .font(.system(size: 14, weight: .semibold)) + Text(provider.description) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + + VStack(spacing: 8) { + if provider.supportsOAuth { + Button("Sign in with Claude") { + launcher.startOAuthForProvider() + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + + Button("Use API Key") { + launcher.showApiKeyInputForProvider() + } + .buttonStyle(.bordered) + .controlSize(.large) + } else { + Button("Use API Key") { + launcher.showApiKeyInputForProvider() + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + } + + Button("Back") { + launcher.backToProviderSelection() + } + .buttonStyle(.borderless) + .foregroundStyle(.secondary) + .font(.system(size: 12)) + } } } } } +// MARK: - API Key Input View + struct ApiKeyInputView: View { @ObservedObject var launcher: OpenClawLauncher var body: some View { VStack(spacing: 12) { - Text("API Key Setup") - .font(.system(size: 14, weight: .semibold)) - Text("Enter your Anthropic API key.") - .font(.system(size: 11)) - .foregroundStyle(.secondary) - SecureField("sk-ant-...", text: $launcher.apiKeyInput) - .textFieldStyle(.roundedBorder) - .font(.system(size: 12, design: .monospaced)) - .frame(maxWidth: 360) - HStack(spacing: 12) { - Button("Continue") { launcher.submitApiKey() } - .buttonStyle(.borderedProminent).controlSize(.large) - Button("Back") { launcher.state = .needsAuth } - .buttonStyle(.bordered).controlSize(.large) + if let provider = launcher.selectedProvider { + Text("\(provider.displayName) API Key") + .font(.system(size: 14, weight: .semibold)) + Text("Enter your \(provider.rawValue) API key.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + SecureField(provider.apiKeyPlaceholder, text: $launcher.apiKeyInput) + .textFieldStyle(.roundedBorder) + .font(.system(size: 12, design: .monospaced)) + .frame(maxWidth: 360) + HStack(spacing: 12) { + Button("Continue") { launcher.submitApiKey() } + .buttonStyle(.borderedProminent).controlSize(.large) + Button("Back") { + if launcher.selectedProvider?.supportsOAuth == true { + launcher.state = .selectingProvider + } else { + launcher.backToProviderSelection() + } + } + .buttonStyle(.bordered).controlSize(.large) + } } } } } +// MARK: - OAuth Code Input View + struct OAuthCodeInputView: View { @ObservedObject var launcher: OpenClawLauncher @@ -458,7 +532,7 @@ struct OAuthCodeInputView: View { Button("Exchange") { launcher.exchangeOAuthCode() } .buttonStyle(.borderedProminent).controlSize(.large) .disabled(launcher.oauthCodeInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - Button("Back") { launcher.state = .needsAuth } + Button("Back") { launcher.state = .selectingProvider } .buttonStyle(.bordered).controlSize(.large) } } diff --git a/app/macos/Sources/OpenClawLib/Models.swift b/app/macos/Sources/OpenClawLib/Models.swift index ea2e4e3..550f4b7 100644 --- a/app/macos/Sources/OpenClawLib/Models.swift +++ b/app/macos/Sources/OpenClawLib/Models.swift @@ -2,10 +2,59 @@ import Foundation public enum StepStatus { case pending, running, done, error, warning } -public enum LauncherState { case idle, working, needsAuth, waitingForOAuthCode, running, stopped, error } +public enum LauncherState { case idle, working, needsAuth, selectingProvider, waitingForApiKey, waitingForOAuthCode, running, stopped, error } public enum MenuBarStatus { case starting, running, stopped } +// MARK: - Auth Providers + +public enum AuthProvider: String, CaseIterable, Identifiable { + case anthropic = "Anthropic" + case openai = "OpenAI" + case google = "Google AI" + + public var id: String { rawValue } + + public var displayName: String { + switch self { + case .anthropic: return "Claude (Anthropic)" + case .openai: return "GPT (OpenAI)" + case .google: return "Gemini (Google AI)" + } + } + + public var description: String { + switch self { + case .anthropic: return "Sign in with your Claude account or use an API key" + case .openai: return "Enter your OpenAI API key" + case .google: return "Enter your Google AI API key" + } + } + + public var supportsOAuth: Bool { + switch self { + case .anthropic: return true + case .openai, .google: return false + } + } + + public var apiKeyPrefix: String { + switch self { + case .anthropic: return "sk-ant-" + case .openai: return "sk-" + case .google: return "" // Google AI keys don't have a standard prefix + } + } + + public var apiKeyPlaceholder: String { + switch self { + case .anthropic: return "sk-ant-api..." + case .openai: return "sk-proj-..." + case .google: return "AIza..." + } + } +} + public struct GatewayStatus: Codable { public let uptime: Int? } diff --git a/app/macos/Sources/OpenClawLib/OpenClawLauncher.swift b/app/macos/Sources/OpenClawLib/OpenClawLauncher.swift index 6f32762..abfe866 100644 --- a/app/macos/Sources/OpenClawLib/OpenClawLauncher.swift +++ b/app/macos/Sources/OpenClawLib/OpenClawLauncher.swift @@ -67,6 +67,7 @@ public class OpenClawLauncher: ObservableObject { @Published public var showResetConfirm: Bool = false @Published public var needsDockerInstall: Bool = false @Published public var authExpiredBanner: String? + @Published public var selectedProvider: AuthProvider? private var isFirstRun = false private var currentPKCE: AnthropicOAuth.PKCE? @@ -173,10 +174,9 @@ public class OpenClawLauncher: ObservableObject { // Check for expired OAuth tokens and attempt refresh await refreshOAuthIfNeeded() - // Pause for auth on first run + // Note if no auth configured (non-blocking - user can configure in Control UI) if isFirstRun && !authProfileExists() && !oauthCredentialsExist() { - state = .needsAuth - return + addStep(.warning, "No model auth configured — set up in Control UI") } try await continueAfterSetup() @@ -270,17 +270,74 @@ public class OpenClawLauncher: ObservableObject { } } + // MARK: - Provider Selection + + public func selectProvider(_ provider: AuthProvider) { + selectedProvider = provider + apiKeyInput = "" + + if provider.supportsOAuth { + // Show choice between OAuth and API key for Anthropic + state = .selectingProvider + } else { + // Go directly to API key input for OpenAI/Google + state = .waitingForApiKey + } + } + + public func startOAuthForProvider() { + guard selectedProvider == .anthropic else { return } + do { + let pkce = try AnthropicOAuth.generatePKCE() + currentPKCE = pkce + let url = AnthropicOAuth.buildAuthorizeURL(pkce: pkce) + NSWorkspace.shared.open(url) + oauthCodeInput = "" + state = .waitingForOAuthCode + addStep(.running, "Opened browser for Anthropic sign-in") + } catch { + addStep(.error, "Failed to start OAuth: \(error.localizedDescription)") + } + } + + public func showApiKeyInputForProvider() { + apiKeyInput = "" + state = .waitingForApiKey + } + public func submitApiKey() { + guard let provider = selectedProvider else { + addStep(.error, "No provider selected") + state = .needsAuth + return + } + let key = apiKeyInput.trimmingCharacters(in: .whitespacesAndNewlines) if !key.isEmpty { let authDir = configDir.appendingPathComponent("agents/default/agent") let authFile = authDir.appendingPathComponent("auth-profiles.json") + + // Build profile key based on provider + let profileKey: String + let providerName: String + switch provider { + case .anthropic: + profileKey = "anthropic:default" + providerName = "anthropic" + case .openai: + profileKey = "openai:default" + providerName = "openai" + case .google: + profileKey = "google:default" + providerName = "google" + } + let payload: [String: Any] = [ "version": 1, "profiles": [ - "anthropic:default": [ + profileKey: [ "type": "api_key", - "provider": "anthropic", + "provider": providerName, "key": key ] ] @@ -288,7 +345,7 @@ public class OpenClawLauncher: ObservableObject { if let data = try? JSONSerialization.data(withJSONObject: payload, options: .prettyPrinted), let _ = try? data.write(to: authFile) { try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: authFile.path) - addStep(.done, "API key saved") + addStep(.done, "\(provider.displayName) API key saved") } else { addStep(.error, "Failed to save API key") } @@ -296,6 +353,7 @@ public class OpenClawLauncher: ObservableObject { addStep(.warning, "Skipped API key — set up later in Control UI") } + selectedProvider = nil state = .working Task { do { @@ -307,29 +365,14 @@ public class OpenClawLauncher: ObservableObject { } } - public func startOAuth() { - do { - let pkce = try AnthropicOAuth.generatePKCE() - currentPKCE = pkce - let url = AnthropicOAuth.buildAuthorizeURL(pkce: pkce) - NSWorkspace.shared.open(url) - showApiKeyField = false - oauthCodeInput = "" - state = .waitingForOAuthCode - addStep(.running, "Opened browser for Anthropic sign-in") - } catch { - addStep(.error, "Failed to start OAuth: \(error.localizedDescription)") - } - } - - public func showApiKeyInput() { - showApiKeyField = true - apiKeyInput = "" - state = .waitingForOAuthCode + public func backToProviderSelection() { + state = .needsAuth + selectedProvider = nil } public func skipAuth() { addStep(.warning, "Skipped auth — set up later in Control UI") + selectedProvider = nil state = .working Task { do { try await continueAfterSetup() } @@ -337,6 +380,48 @@ public class OpenClawLauncher: ObservableObject { } } + // Legacy method for backward compatibility + public func startOAuth() { + selectedProvider = .anthropic + startOAuthForProvider() + } + + public func showApiKeyInput() { + selectedProvider = .anthropic + showApiKeyInputForProvider() + } + + /// Handle OAuth callback from custom URL scheme (openclaw://oauth/callback) + public func handleOAuthCallback(code: String, state: String?) { + guard let pkce = currentPKCE else { + addStep(.error, "No PKCE session. Try signing in again.") + state.map { _ in } // Silence unused warning + self.state = .needsAuth + return + } + + // Optionally verify state matches our PKCE verifier + if let receivedState = state, receivedState != pkce.verifier { + addStep(.warning, "OAuth state mismatch — proceeding anyway") + } + + print("[OpenClaw] Received OAuth callback with code: \(code.prefix(8))...") + addStep(.running, "Processing OAuth callback...") + self.state = .working + + Task { + do { + let creds = try await AnthropicOAuth.exchangeCode(code: code, verifier: pkce.verifier) + try saveOAuthCredentials(creds) + addStep(.done, "Signed in with Claude") + try await continueAfterSetup() + } catch { + addStep(.error, "OAuth exchange failed: \(error.localizedDescription)") + self.state = .needsAuth + } + } + } + public func exchangeOAuthCode() { guard let pkce = currentPKCE else { addStep(.error, "No PKCE session. Try signing in again.") diff --git a/app/macos/build.sh b/app/macos/build.sh index ca81da5..59283d8 100644 --- a/app/macos/build.sh +++ b/app/macos/build.sh @@ -83,6 +83,17 @@ cat > "$CONTENTS/Info.plist" <AppIcon NSHighResolutionCapable + CFBundleURLTypes + + + CFBundleURLName + ai.openclaw.launcher + CFBundleURLSchemes + + openclaw + + + PLIST