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
117 changes: 8 additions & 109 deletions packages/happy-ios/Sources/HappyUI/Agents/AddProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -289,122 +289,21 @@ final class DeviceSignInViewController: ProviderSignInViewController {
}
}

/// Claude's sign-in (§5): its page, opened in Safari, shows a code that is
/// pasted back here; or a setup token from `claude setup-token` (§3) where
/// the server takes one. A server that doesn't run the sign-in yet offers
/// only the token.
/// Claude's setup token from `claude setup-token` (§3), its only way in:
/// no browser sign-in, whatever the server offers.
final class ClaudeSignInViewController: ProviderSignInViewController {
private let field = UITextField()
private let offered: Bool
private let setupToken: Bool
private var signIn: CloudSignIn?
private var open: UIButton?
private var connect: UIButton?

init(flows: ProviderFlows, offered: Bool, setupToken: Bool) {
self.offered = offered
self.setupToken = setupToken
super.init(flows: flows, title: Strings.Providers.signInClaude)
init(flows: ProviderFlows) {
super.init(flows: flows, title: Strings.Providers.claudeSetupToken)
}

override func viewDidLoad() {
super.viewDidLoad()
view.accessibilityIdentifier = "provider-claude"
let token = button(Strings.Providers.useSetupToken, id: "provider-claude-token") { [weak self] in
self?.flows.addSetupToken { self?.follow?.cancel() }
}
token.isHidden = !setupToken
guard offered else {
stack.addArrangedSubview(label(Strings.Providers.setupTokenHint))
stack.addArrangedSubview(token)
return
}
field.placeholder = Strings.Providers.code
field.borderStyle = .roundedRect
field.autocapitalizationType = .none
field.autocorrectionType = .no
field.spellCheckingType = .no
field.accessibilityIdentifier = "provider-claude-code"
field.heightAnchor.constraint(greaterThanOrEqualToConstant: 44).isActive = true
let open = button(Strings.Providers.openClaude, prominent: true, id: "provider-claude-open") { [weak self] in self?.openPage(self?.signIn?.url) }
let connect = button(Strings.Providers.connect, id: "provider-claude-connect") { [weak self] in self?.send() }
self.open = open; self.connect = connect
open.isEnabled = false; connect.isEnabled = false
let waiting = UIStackView(arrangedSubviews: [spinner, status])
waiting.axis = .vertical
waiting.spacing = 8
for view in [label(Strings.Providers.claudeInstructions), open, field, connect, waiting, token] as [UIView] { stack.addArrangedSubview(view) }
start()
}

private func start() {
spinner.startAnimating()
status.text = nil
field.text = nil
follow?.cancel()
let client = flows.client, container = flows.destination.container(vendor: "claude-code")
follow = Task { @MainActor [weak self] in
do {
let started = try await client.startCloudSignIn(.claude, container: container)
self?.show(started)
self?.watch(started)
} catch is CancellationError {
} catch {
guard !Task.isCancelled else { return }
self?.failedToStart(error, cli: .claude)
}
let token = button(Strings.Providers.addSetupToken, prominent: true, id: "provider-claude-token") { [weak self] in
self?.flows.addSetupToken()
}
}

/// Its page and code field, once the server's CLI has them.
private func show(_ signIn: CloudSignIn) {
self.signIn = signIn
open?.isEnabled = signIn.url != nil
connect?.isEnabled = signIn.acceptsCode
if signIn.url != nil { spinner.stopAnimating() }
}

/// Follows the sign-in to its end, whoever ends it, showing its page
/// when it comes.
private func watch(_ signIn: CloudSignIn) {
let client = flows.client
follow = Task { @MainActor [weak self] in
let end = try? await client.waitForCloudSignIn(signIn, tick: { [weak self] next in
Task { @MainActor [weak self] in if next.id == signIn.id, !next.ended { self?.show(next) } }
})
guard let end, !Task.isCancelled else { return }
self?.finished(end, success: Strings.Providers.claudeConnected) { self?.start() }
}
}

private func send() {
let code = (field.text ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
guard !code.isEmpty, signIn != nil else { return }
field.resignFirstResponder()
connect?.isEnabled = false
spinner.startAnimating()
status.text = Strings.Providers.waiting
let client = flows.client
Task { @MainActor [weak self] in
do {
let next = try await client.finishCloudSignIn(.claude, code: code)
self?.field.text = nil
if next.ended { self?.follow?.cancel(); self?.finished(next, success: Strings.Providers.claudeConnected) { self?.start() } }
} catch {
self?.spinner.stopAnimating()
self?.connect?.isEnabled = true
self?.status.text = ProviderFlows.words(error)
}
}
}

override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
// Leaving with a sign-in still waiting for its code ends it.
guard isMovingFromParent, let signIn, !signIn.ended else { return }
follow?.cancel()
let client = flows.client
Task { await client.cancelCloudSignIn(.claude) }
stack.addArrangedSubview(label(Strings.Providers.setupTokenHint))
stack.addArrangedSubview(token)
}
}
#endif
Original file line number Diff line number Diff line change
Expand Up @@ -131,8 +131,8 @@ final class ProvidersViewController: SettingsPageViewController {
if offer.signIns.contains(.codex) {
sheet.addAction(UIAlertAction(title: Text.signInChatGPT, style: .default) { [weak self] _ in self?.open(.providersAddOpenAI, destination: destination) })
}
if offer.signIns.contains(.claude) || offer.setupToken {
sheet.addAction(UIAlertAction(title: Text.signInClaude, style: .default) { [weak self] _ in self?.open(.providersAddAnthropic, destination: destination) })
if offer.setupToken {
sheet.addAction(UIAlertAction(title: Text.claudeSetupToken, style: .default) { [weak self] _ in self?.open(.providersAddAnthropic, destination: destination) })
}
if offer.apiKeys {
sheet.addAction(UIAlertAction(title: "\(Text.addAPIKey) · \(Text.openAI)", style: .default) { [weak self] _ in
Expand Down Expand Up @@ -166,7 +166,7 @@ final class ProvidersViewController: SettingsPageViewController {
// A way this server doesn't offer (a link from elsewhere): the chooser.
let offered = switch page {
case .providersAddOpenAI: offer.signIns.contains(.codex)
case .providersAddAnthropic: offer.signIns.contains(.claude) || offer.setupToken
case .providersAddAnthropic: offer.setupToken
case .providersAddAPIKey: offer.apiKeys
default: false
}
Expand All @@ -181,7 +181,7 @@ final class ProvidersViewController: SettingsPageViewController {
case .providersAddOpenAI:
navigationController?.pushViewController(DeviceSignInViewController(flows: flows), animated: true)
case .providersAddAnthropic:
navigationController?.pushViewController(ClaudeSignInViewController(flows: flows, offered: offer.signIns.contains(.claude), setupToken: offer.setupToken), animated: true)
navigationController?.pushViewController(ClaudeSignInViewController(flows: flows), animated: true)
case .providersAddAPIKey:
flows.addKey(vendor: vendor ?? "openai")
default:
Expand Down
7 changes: 2 additions & 5 deletions packages/happy-ios/Sources/HappyUI/Strings/Providers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ extension Strings {
static let loadFailed = "Couldn’t load the providers."

static let signInChatGPT = "Sign in with ChatGPT"
static let signInClaude = "Sign in with Claude"
static let claudeSetupToken = "Use a Claude setup token"
static let addAPIKey = "Add API key"
static let openAI = "OpenAI"
static let anthropic = "Anthropic"
Expand All @@ -40,11 +40,8 @@ extension Strings {
static let openOpenAI = "Open OpenAI"
static let waiting = "Waiting for you to finish…"

static let claudeInstructions = "Sign in to Claude in the browser, then paste the code it shows."
static let openClaude = "Open Claude"
static let code = "Code"
static let connect = "Connect"
static let useSetupToken = "Use a setup token instead"
static let addSetupToken = "Add setup token"
static let setupToken = "Setup token"
static let setupTokenHint = "From `claude setup-token` on your computer."

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -281,17 +281,16 @@ final class HappyCloudUITests: XCTestCase {
app.buttons["provider-device-cancel"].tap()
require(add.waitForExistence(timeout: 10), "cancel didn't come back to the list")

// Claude's: its page to open, and a field for the code it shows.
// Claude's: a setup token only, with no page to open and no code to paste.
add.tap()
let claude = app.buttons["Sign in with Claude"]
require(claude.waitForExistence(timeout: 10), "the server's Claude sign-in isn't offered")
let claude = app.buttons["Use a Claude setup token"]
require(claude.waitForExistence(timeout: 10), "the server's Claude setup token isn't offered")
claude.tap()
if mine.waitForExistence(timeout: 5) { mine.tap() }
let open = app.buttons["provider-claude-open"]
require(open.waitForExistence(timeout: 20), "no Claude screen")
require(XCTWaiter.wait(for: [expectation(for: NSPredicate(format: "isEnabled == true"), evaluatedWith: open)], timeout: 120) == .completed, "Claude's page never came")
XCTAssertTrue(app.textFields["provider-claude-code"].exists, "no code field")
XCTAssertTrue(app.buttons["provider-claude-token"].exists, "the live server offers setup tokens")
let token = app.buttons["provider-claude-token"]
require(token.waitForExistence(timeout: 20), "no Claude screen")
XCTAssertFalse(app.buttons["provider-claude-open"].exists, "no Claude page to open")
XCTAssertFalse(app.textFields["provider-claude-code"].exists, "no code field")
capture("47-claude")
app.navigationBars.buttons.element(boundBy: 0).tap()
require(add.waitForExistence(timeout: 10), "back didn't come to the list")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ private actor CloudProvidersServer: AgentTransport {
{"id":"operator:openai","name":"Happy (free)","scope":"team","source":"operator","vendors":["openai"],"models":["openai/gpt-6.1-terra"],"credentialCount":0},
{"id":"anthropic","name":"Anthropic","scope":"team","source":"tenant","vendors":["anthropic"],"models":[],"credentialCount":1,
"credentials":[{"id":"k1","vendor":"anthropic","kind":"api_key","label":"Team key","enabled":true,"state":"invalid"}]}
],"capabilities":{"apiKeys":true,"claudeSetupToken":false,"codexSignIn":true,"claudeSignIn":true}}
],"capabilities":{"apiKeys":true,"claudeSetupToken":true,"codexSignIn":true,"claudeSignIn":false}}
"""#)
case ("GET", "/v0/provider-clis"):
return ok(#"{"providerClis":[{"id":"codex"},{"id":"claude"}]}"#)
Expand Down Expand Up @@ -186,10 +186,17 @@ final class HostedProvidersTests: XCTestCase {
let server = CloudProvidersServer()
let (list, navigation, window, _) = try await host(server)
let flows = ProviderFlows(client: AgentClient(transport: server, token: "t"), destination: .mine, host: list, added: {})
let claude = ClaudeSignInViewController(flows: flows, offered: false, setupToken: true)
let claude = ClaudeSignInViewController(flows: flows)
navigation.pushViewController(claude, animated: false)
window.layoutIfNeeded()
let token = try XCTUnwrap(Self.find(UIButton.self, in: claude.view) { $0.accessibilityIdentifier == "provider-claude-token" })
// A setup token only: no Claude page to open, no code to paste.
XCTAssertEqual(claude.title, "Use a Claude setup token")
XCTAssertEqual(token.configuration?.title, "Add setup token")
XCTAssertNil(Self.find(UIButton.self, in: claude.view) { $0.accessibilityIdentifier == "provider-claude-open" })
XCTAssertNil(Self.find(UITextField.self, in: claude.view) { $0.accessibilityIdentifier == "provider-claude-code" })
let shot = XCTAttachment(image: UIGraphicsImageRenderer(bounds: window.bounds).image { _ in window.drawHierarchy(in: window.bounds, afterScreenUpdates: true) })
shot.name = "claude-setup-token"; shot.lifetime = .keepAlways; add(shot)
token.sendActions(for: .touchUpInside)
let form = try await until { claude.presentedViewController as? UIAlertController }
XCTAssertEqual(form.title, "Setup token")
Expand All @@ -206,7 +213,7 @@ final class HostedProvidersTests: XCTestCase {
let device = try await until { navigation.topViewController as? DeviceSignInViewController }
_ = try await until { (device.presentedViewController as? UIAlertController)?.title == "Sign-in failed" ? true : nil }
XCTAssertFalse(list.chooser().actions.contains { $0.title == "Sign in with ChatGPT" })
XCTAssertTrue(list.chooser().actions.contains { $0.title == "Sign in with Claude" })
XCTAssertTrue(list.chooser().actions.contains { $0.title == "Use a Claude setup token" })
list.load()
_ = try await until { list.chooser().actions.contains { $0.title == "Sign in with ChatGPT" } ? true : nil }
}
Expand Down
7 changes: 4 additions & 3 deletions packages/happy-ios/docs/SERVER-SIGN-IN.md
Original file line number Diff line number Diff line change
Expand Up @@ -439,9 +439,10 @@ ported from happy-agent-client's `happyLinks.ts`.
`SettingsLinkPlace` says: `general` Settings, `account` Profile, `voice`
the computer's Voice row, `servers` Happy Cloud's servers (Settings off
Happy Cloud). `providers` opens the native AI providers list on Cloud.
Its `add` chooser offers the server's ChatGPT and Claude sign-ins,
Claude setup tokens when enabled, and OpenAI, Anthropic and Grok API
keys. `providers/add/api-key?vendor=openai|anthropic|grok` opens the
Its `add` chooser offers the server's ChatGPT sign-in, a Claude setup
token when enabled (Claude's only way in: no browser sign-in, whatever
the server says), and OpenAI, Anthropic and Grok API keys.
`providers/add/api-key?vendor=openai|anthropic|grok` opens the
secure key form. Every add link accepts `to=private|team`, subject to
the member's permissions. Bedrock setup remains on desktop.
Every other page opens
Expand Down
Loading