Files
openclaw/apps/macos/Sources/OpenClaw/OnboardingAISetupView.swift
Peter Steinberger 847460600f feat(macos): redesign device-code sign-in (#104766)
* feat(macos): redesign device-code sign-in

* chore(i18n): refresh native string inventory

* fix(macos): localize device-code completion action

* fix(auth): preserve device-code fallback copy

* fix(xai): report browser launch accurately
2026-07-11 16:43:21 -07:00

849 lines
31 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppKit
import Foundation
import OpenClawChatUI
import OpenClawProtocol
import SwiftUI
enum OnboardingProviderIcon {
private static let resourceBundle: Bundle? = locateResourceBundle()
static func resourceURL(for kind: String) -> URL? {
guard let name = resourceName(for: kind) else { return nil }
return self.resourceBundle?.url(
forResource: name,
withExtension: "svg",
subdirectory: "ProviderIcons")
}
static func image(for kind: String) -> NSImage? {
guard let url = resourceURL(for: kind), let image = NSImage(contentsOf: url) else {
return nil
}
image.isTemplate = true
return image
}
private static func resourceName(for kind: String) -> String? {
switch kind {
case "claude-cli": "ProviderIcon-claude"
case "codex-cli": "ProviderIcon-codex"
default: nil
}
}
private static func locateResourceBundle() -> Bundle? {
if self.bundleContainsProviderIcons(Bundle.main) {
return Bundle.main
}
// Packaged apps copy these vectors into Bundle.main. SwiftPM's generated
// Bundle.module accessor can fatalError when that sidecar is absent, so
// consult it only for development/test executables, never an .app.
if Bundle.main.bundleURL.pathExtension != "app",
self.bundleContainsProviderIcons(Bundle.module)
{
return Bundle.module
}
return nil
}
private static func bundleContainsProviderIcons(_ bundle: Bundle) -> Bool {
bundle.url(
forResource: "ProviderIcon-claude",
withExtension: "svg",
subdirectory: "ProviderIcons") != nil
}
}
enum OnboardingProviderAuthLink {
static func safeURL(_ rawValue: String?) -> URL? {
guard let rawValue,
let url = URL(string: rawValue),
url.scheme?.lowercased() == "https",
url.host() != nil,
url.user() == nil,
url.password() == nil
else { return nil }
return url
}
}
struct OnboardingAISetupView: View {
@Bindable var model: OnboardingAISetupModel
var crestodianChat: CrestodianOnboardingChatModel
@Binding var showCrestodianChat: Bool
var retryConfiguredGatewayProbe: () -> Void
@State private var openedProviderAuthURL: URL?
var body: some View {
VStack(alignment: .leading, spacing: 12) {
switch self.model.phase {
case .idle, .detecting:
self.detectingView
default:
self.resultsView
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.sheet(isPresented: self.$showCrestodianChat) {
self.crestodianSheet
}
.sheet(isPresented: Binding(
get: { self.model.activeAuthOption != nil },
set: {
if !$0 {
self.model.cancelProviderAuth()
}
})) {
self.providerAuthSheet
}
}
private var detectingView: some View {
HStack(spacing: 10) {
ProgressView()
.controlSize(.small)
VStack(alignment: .leading, spacing: 2) {
Text(self.model.waitingForPendingActivationDeadline
? "Waiting for the previous AI test to finish…"
: "Looking for AI you already use…")
.font(.callout.weight(.semibold))
Text(self.model.waitingForPendingActivationDeadline
? "OpenClaw will check again before changing any inference settings."
: "Checking for Claude Code, Codex, Gemini, and saved API keys.")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
}
.padding(.vertical, 18)
.frame(maxWidth: .infinity)
}
@ViewBuilder
private var resultsView: some View {
if self.model.connected {
self.connectedBanner
}
if !self.model.candidates.isEmpty {
VStack(spacing: 8) {
ForEach(self.model.candidates) { candidate in
self.candidateRow(candidate)
}
}
} else if self.model.phase != .connected, self.model.detectError == nil {
// A failed detect must not claim "nothing found" the error card
// below owns that state and the claim would be unproven.
self.noCandidatesIntro
}
if let detectError = model.detectError {
OnboardingErrorCard(
title: self.model.configuredGatewayProbeUnavailable
? "Couldnt check this Gateway for AI accounts"
: "Couldnt check this Mac for AI accounts",
message: detectError.summary,
details: detectError.detail,
docsSlug: "start/onboarding",
retryTitle: "Try again")
{
if self.model.configuredGatewayProbeUnavailable {
self.retryConfiguredGatewayProbe()
} else {
self.model.retryFromScratch()
}
}
}
if let providerCatalogError = model.providerCatalogError {
OnboardingErrorCard(
title: "Couldnt load the full provider list",
message: providerCatalogError,
docsSlug: "start/onboarding",
retryTitle: "Try again")
{
self.model.retryFromScratch()
}
}
if self.model.exhaustedAutoCandidates, !self.model.connected {
OnboardingErrorCard(
title: "None of the found options worked",
message: """
The details are listed on each option above. \
You can fix the login and retry, or connect with an API key or token below.
""",
docsSlug: "concepts/model-providers",
retryTitle: "Check again")
{
self.model.retryFromScratch()
}
}
if !self.model.connected, self.model.providerCatalogLoaded {
self.providerAuthSection
self.manualSection
}
if CrestodianAvailability.shouldShow(configuredModel: self.model.connectedModelRef) {
HStack {
Spacer(minLength: 0)
Button {
self.showCrestodianChat = true
} label: {
Label("Need help? Chat with Crestodian", systemImage: "questionmark.bubble")
.font(.caption)
}
.buttonStyle(.link)
}
}
}
private var connectedBanner: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .center, spacing: 10) {
Image(systemName: "checkmark.circle.fill")
.font(.title2)
.foregroundStyle(.green)
VStack(alignment: .leading, spacing: 2) {
Text("Your AI is ready")
.font(.headline)
Text(self.model.connectedSummary)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
}
if !self.model.connectedSetupLines.isEmpty {
Divider()
Text("Setup details")
.font(.caption.weight(.semibold))
ScrollView(.vertical) {
Text(self.model.connectedSetupCopyText)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxHeight: 150)
Button {
OnboardingErrorDetails.copy(self.model.connectedSetupCopyText)
} label: {
Label("Copy setup details", systemImage: "doc.on.doc")
}
.buttonStyle(.link)
.font(.caption)
}
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color.green.opacity(0.12)))
}
private var noCandidatesIntro: some View {
VStack(alignment: .leading, spacing: 6) {
Text("No AI accounts found on this Mac")
.font(.headline)
Text(
"Thats fine — you can connect one with an API key or token. " +
"If you use Claude Code, Codex, or the Gemini CLI on this Mac, " +
"sign in there first and hit “Check again”.")
.font(.subheadline)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Button("Check again") {
self.model.retryFromScratch()
}
.buttonStyle(.bordered)
.controlSize(.small)
}
.padding(.vertical, 4)
}
private func candidateRow(_ candidate: OnboardingAISetupModel.Candidate) -> some View {
let status = self.model.statuses[candidate.kind] ?? .untried
let selected = self.model.selectedKind == candidate.kind
return VStack(alignment: .leading, spacing: 0) {
Button {
self.model.userSelect(kind: candidate.kind)
} label: {
HStack(alignment: .center, spacing: 12) {
self.providerIcon(for: candidate.kind)
VStack(alignment: .leading, spacing: 2) {
Text(candidate.label)
.font(.callout.weight(.semibold))
Text(self.subtitle(for: candidate, status: status))
.font(.caption)
.foregroundStyle(self.subtitleStyle(for: status))
.lineLimit(2)
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 0)
self.trailingIndicator(status: status, selected: selected)
}
}
.buttonStyle(.plain)
.disabled(self.model.isBusy || self.model.connected)
if case let .failed(failure) = status {
OnboardingErrorDetails(text: failure.copyText)
.padding(.leading, 38)
.padding(.top, 6)
}
}
.openClawSelectableRowChrome(selected: selected && !Self.isFailed(status))
}
@ViewBuilder
private func providerIcon(for kind: String) -> some View {
if let image = OnboardingProviderIcon.image(for: kind) {
Image(nsImage: image)
.renderingMode(.template)
.resizable()
.scaledToFit()
.frame(width: 21, height: 21)
.foregroundStyle(Color.accentColor)
.frame(width: 26)
} else {
Image(systemName: Self.symbol(for: kind))
.font(.title3.weight(.semibold))
.foregroundStyle(Color.accentColor)
.frame(width: 26)
}
}
private func subtitle(
for candidate: OnboardingAISetupModel.Candidate,
status: OnboardingAISetupModel.CandidateStatus) -> String
{
switch status {
case .testing:
"Testing — asking \(candidate.modelRef) for a quick reply…"
case let .failed(failure):
failure.summary
case .connected:
self.model.connectedSummary
case .untried:
"\(candidate.modelRef) · \(candidate.detail)"
}
}
private func subtitleStyle(
for status: OnboardingAISetupModel.CandidateStatus) -> Color
{
if case .failed = status {
return .orange
}
return .secondary
}
@ViewBuilder
private func trailingIndicator(
status: OnboardingAISetupModel.CandidateStatus,
selected: Bool) -> some View
{
switch status {
case .testing:
ProgressView()
.controlSize(.small)
case .connected:
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
case .failed:
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
case .untried:
SelectionStateIndicator(selected: selected)
}
}
private static func symbol(for kind: String) -> String {
switch kind {
case "claude-cli": "sparkle"
case "codex-cli": "chevron.left.forwardslash.chevron.right"
case "gemini-cli": "diamond"
case "existing-model": "checkmark.seal"
default: "key.fill"
}
}
private static func isFailed(_ status: OnboardingAISetupModel.CandidateStatus) -> Bool {
if case .failed = status {
return true
}
return false
}
@ViewBuilder
private var manualSection: some View {
if self.model.manualProviders.isEmpty {
if self.model.authOptions.isEmpty {
OnboardingErrorCard(
title: "No key-based providers are available",
message: "Enable or install a text-inference provider plugin on this Gateway, then check again.",
docsSlug: "concepts/model-providers",
retryTitle: "Check again")
{
self.model.retryFromScratch()
}
}
} else {
VStack(alignment: .leading, spacing: 10) {
if self.model.candidates.isEmpty || self.model.showManualEntry {
self.manualForm
} else {
Button {
withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) {
self.model.showManualEntry = true
}
} label: {
Label("Connect with an API key or token instead…", systemImage: "key")
.font(.callout)
}
.buttonStyle(.link)
.disabled(self.model.isBusy)
}
}
}
}
@ViewBuilder
private var providerAuthSection: some View {
if !self.model.authOptions.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text("Sign in with a provider")
.font(.headline)
Text(
"Use an existing subscription or provider account. " +
"OpenClaw opens the providers own sign-in flow, then verifies it with a real reply.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
let featured = self.model.authOptions.filter(\.featured)
let more = self.model.authOptions.filter { !$0.featured }
ForEach(featured) { option in
self.providerAuthRow(option)
}
if !more.isEmpty {
DisclosureGroup("More sign-in options") {
VStack(spacing: 8) {
ForEach(more) { option in
self.providerAuthRow(option)
}
}
.padding(.top, 6)
}
.font(.callout)
}
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color(NSColor.controlBackgroundColor)))
}
}
private func providerAuthRow(_ option: OnboardingAISetupModel.AuthOption) -> some View {
Button {
self.openedProviderAuthURL = nil
self.model.startProviderAuth(option)
} label: {
HStack(spacing: 10) {
Image(systemName: option
.kind == "device-code" ? "link.badge.plus" : "person.crop.circle.badge.checkmark")
.font(.title3)
.frame(width: 24)
VStack(alignment: .leading, spacing: 2) {
Text(option.label)
.font(.callout.weight(.semibold))
if let hint = option.hint, !hint.isEmpty {
Text(hint)
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.leading)
}
}
Spacer(minLength: 0)
Text(option.kind == "device-code" ? "Pair" : "Sign in")
.font(.caption.weight(.semibold))
.foregroundStyle(Color.accentColor)
}
}
.buttonStyle(.plain)
.disabled(self.model.isBusy)
.openClawSelectableRowChrome(selected: false)
}
private var providerAuthSheet: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(self.model.activeAuthOption?.label ?? "Provider sign-in")
.font(.title3.weight(.semibold))
Text("Credentials stay on this Gateway and are saved only after the live test succeeds.")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
}
if let step = self.model.authStep {
let deviceCode = parseWizardDeviceCode(step.devicecode)
if deviceCode == nil,
let title = step.title,
!title.isEmpty,
title != self.model.activeAuthOption?.label
{
Text(title).font(.headline)
}
if let deviceCode {
self.deviceCodeStep(deviceCode)
} else if let message = step.message, !message.isEmpty {
ScrollView {
Text(message)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxHeight: 190)
}
if deviceCode == nil,
let url = OnboardingProviderAuthLink.safeURL(step.externalurl)
{
Link("Open sign-in page…", destination: url)
.font(.caption.weight(.semibold))
}
self.authStepInput(step)
} else if self.model.authBusy {
HStack(spacing: 10) {
ProgressView().controlSize(.small)
Text("Starting secure sign-in…")
}
}
if let error = self.model.authError {
OnboardingErrorCard(
title: "Sign-in didnt complete",
message: error.summary,
details: error.detail,
docsSlug: "concepts/model-providers",
retryTitle: nil,
retry: nil)
}
Spacer(minLength: 0)
HStack {
Button("Cancel") { self.model.cancelProviderAuth() }
Spacer(minLength: 0)
if self.model.authStep != nil {
Button(self.authContinueTitle) { self.model.continueProviderAuth() }
.buttonStyle(.borderedProminent)
.disabled(self.model.authBusy)
}
}
}
.padding(22)
.frame(width: 560)
.frame(minHeight: 330)
.onAppear {
self.openProviderAuthURLIfNeeded(self.model.authStep?.externalurl)
}
.onChange(of: self.model.authStep?.externalurl) { _, rawURL in
self.openProviderAuthURLIfNeeded(rawURL)
}
}
private func deviceCodeStep(_ deviceCode: WizardDeviceCodePresentation) -> some View {
VStack(alignment: .leading, spacing: 14) {
VStack(alignment: .leading, spacing: 4) {
Text("Finish in your browser")
.font(.headline)
Text(deviceCode.message ?? "Enter this one-time code on the provider's sign-in page.")
.font(.callout)
.foregroundStyle(.secondary)
}
HStack(spacing: 12) {
Text(deviceCode.code)
.font(.system(.title2, design: .monospaced).weight(.semibold))
.textSelection(.enabled)
Spacer(minLength: 8)
Button {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(deviceCode.code, forType: .string)
} label: {
Label("Copy", systemImage: "doc.on.doc")
}
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.background(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Color(NSColor.controlBackgroundColor)))
HStack(spacing: 12) {
if let minutes = deviceCode.expiresInMinutes {
Label("Expires in \(minutes) minutes", systemImage: "clock")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
if let url = OnboardingProviderAuthLink.safeURL(self.model.authStep?.externalurl) {
Link(destination: url) {
Label("Open sign-in page", systemImage: "arrow.up.right.square")
}
.font(.caption.weight(.semibold))
}
}
}
}
private func openProviderAuthURLIfNeeded(_ rawURL: String?) {
guard let url = OnboardingProviderAuthLink.safeURL(rawURL),
url != openedProviderAuthURL
else { return }
self.openedProviderAuthURL = url
NSWorkspace.shared.open(url)
}
@ViewBuilder
private func authStepInput(_ step: WizardStep) -> some View {
switch wizardStepType(step) {
case "text":
if step.sensitive == true {
SecureField(step.placeholder ?? "Value", text: self.$model.authText)
.textFieldStyle(.roundedBorder)
} else {
TextField(step.placeholder ?? "Value", text: self.$model.authText)
.textFieldStyle(.roundedBorder)
}
case "select":
Picker("Option", selection: self.$model.authSelection) {
ForEach(Array(self.model.authWizardOptions.enumerated()), id: \.offset) { index, option in
Text(option.label).tag(index)
}
}
case "confirm":
Toggle("Confirm", isOn: self.$model.authConfirmation)
default:
EmptyView()
}
}
private var authContinueTitle: String {
guard let step = model.authStep else { return "Continue" }
if parseWizardDeviceCode(step.devicecode) != nil {
return String(localized: "I've signed in")
}
return wizardStepType(step) == "note" ? "Continue" : "Submit"
}
private var manualForm: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Connect with an API key or token")
.font(.headline)
HStack(spacing: 8) {
Picker("Provider", selection: self.$model.manualProviderID) {
ForEach(self.model.manualProviders) { provider in
Text(provider.label).tag(provider.id)
}
}
.labelsHidden()
.frame(width: 230)
SecureField("API key or token", text: self.$model.manualKey)
.textFieldStyle(.roundedBorder)
.onSubmit { self.model.submitManualKey() }
Button {
self.model.submitManualKey()
} label: {
if self.model.manualTesting {
ProgressView()
.controlSize(.small)
.frame(minWidth: 74)
} else {
Text("Connect")
.frame(minWidth: 74)
}
}
.buttonStyle(.borderedProminent)
.disabled(self.model.manualTesting ||
self.model.manualKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
Text(self.manualProviderHelp)
.font(.caption)
.foregroundStyle(.secondary)
if let manualError = model.manualError {
OnboardingErrorCard(
title: "That key didnt work",
message: manualError.summary,
details: manualError.detail,
docsSlug: "concepts/model-providers",
retryTitle: nil,
retry: nil)
}
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color(NSColor.controlBackgroundColor)))
}
private var manualProviderHelp: String {
let hint = self.model.selectedManualProvider?.hint?.trimmingCharacters(in: .whitespacesAndNewlines)
guard let hint, !hint.isEmpty else {
return "Paste the key or token here, and OpenClaw checks it with a real test question."
}
return "\(hint). Paste it here, and OpenClaw checks it with a real test question."
}
private var crestodianSheet: some View {
VStack(spacing: 8) {
HStack {
Label("Crestodian — setup helper", systemImage: "lifepreserver")
.font(.headline)
Spacer(minLength: 0)
Button("Done") {
self.showCrestodianChat = false
}
}
.padding([.top, .horizontal], 14)
CrestodianOnboardingChatView(model: self.crestodianChat)
.task { await self.crestodianChat.startIfNeeded() }
}
.frame(width: 520, height: 480)
}
}
/// Friendly error presentation with a consistent docs escape hatch.
/// Every onboarding failure points at a docs.openclaw.ai page so people are
/// never stuck staring at a raw error string.
struct OnboardingErrorCard: View {
let title: String
let message: String
var details: String?
let docsSlug: String
var retryTitle: String?
var retry: (() -> Void)?
init(
title: String,
message: String,
details: String? = nil,
docsSlug: String,
retryTitle: String? = nil,
retry: (() -> Void)? = nil)
{
self.title = title
self.message = message
self.details = details
self.docsSlug = docsSlug
self.retryTitle = retryTitle
self.retry = retry
}
var body: some View {
HStack(alignment: .top, spacing: 10) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
.padding(.top, 1)
VStack(alignment: .leading, spacing: 4) {
Text(self.title)
.font(.callout.weight(.semibold))
Text(self.message)
.font(.caption)
.foregroundStyle(.secondary)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
if let details {
OnboardingErrorDetails(text: details)
}
HStack(spacing: 14) {
if let retryTitle, let retry {
Button(retryTitle, action: retry)
.buttonStyle(.borderedProminent)
.controlSize(.small)
}
Button("Open help…") {
if let url = URL(string: "https://docs.openclaw.ai/\(docsSlug)") {
NSWorkspace.shared.open(url)
}
}
.buttonStyle(.link)
.font(.caption)
if details == nil {
Button("Copy error") {
OnboardingErrorDetails.copy(self.message)
}
.buttonStyle(.link)
.font(.caption)
}
}
.padding(.top, 2)
}
Spacer(minLength: 0)
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(Color.orange.opacity(0.10)))
}
}
private struct OnboardingErrorDetails: View {
let text: String
@State private var expanded = false
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Button {
withAnimation(.easeInOut(duration: 0.15)) {
self.expanded.toggle()
}
} label: {
Label(
self.expanded ? "Hide details" : "Show details",
systemImage: self.expanded ? "chevron.down" : "chevron.right")
}
.buttonStyle(.link)
.font(.caption)
if self.expanded {
ScrollView(.vertical) {
Text(self.text)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxHeight: 180)
.background(
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(Color.primary.opacity(0.05)))
Button {
Self.copy(self.text)
} label: {
Label("Copy error", systemImage: "doc.on.doc")
}
.buttonStyle(.link)
.font(.caption)
}
}
}
static func copy(_ text: String) {
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(text, forType: .string)
}
}