216 lines
8.8 KiB
Kotlin
216 lines
8.8 KiB
Kotlin
package com.example.aiapp
|
|
|
|
import androidx.compose.foundation.layout.Column
|
|
import androidx.compose.foundation.layout.Row
|
|
import androidx.compose.foundation.layout.Spacer
|
|
import androidx.compose.foundation.layout.fillMaxWidth
|
|
import androidx.compose.foundation.layout.height
|
|
import androidx.compose.material3.AlertDialog
|
|
import androidx.compose.material3.CircularProgressIndicator
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.OutlinedTextField
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.material3.TextButton
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.LaunchedEffect
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.mutableIntStateOf
|
|
import androidx.compose.runtime.mutableStateOf
|
|
import androidx.compose.runtime.remember
|
|
import androidx.compose.runtime.rememberCoroutineScope
|
|
import androidx.compose.runtime.setValue
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.platform.LocalUriHandler
|
|
import androidx.compose.ui.unit.dp
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.delay
|
|
import kotlinx.coroutines.launch
|
|
import kotlinx.coroutines.withContext
|
|
|
|
/**
|
|
* Relays a provider CLI's headless browser login without ever owning its credentials.
|
|
*
|
|
* The URL and code live only in this composition. The CLI process on [machineId] remains the one
|
|
* OAuth client and the only writer of its credential file.
|
|
*/
|
|
@Composable
|
|
fun ProviderLoginDialog(
|
|
settings: ServerSettings,
|
|
machineId: String,
|
|
machineName: String,
|
|
provider: String,
|
|
onDismiss: () -> Unit,
|
|
onSignedIn: () -> Unit,
|
|
) {
|
|
val scope = rememberCoroutineScope()
|
|
val uriHandler = LocalUriHandler.current
|
|
var login by remember(machineId, provider) { mutableStateOf<ProviderLogin?>(null) }
|
|
var code by remember(machineId, provider) { mutableStateOf("") }
|
|
var error by remember(machineId, provider) { mutableStateOf<String?>(null) }
|
|
var retry by remember(machineId, provider) { mutableIntStateOf(0) }
|
|
|
|
suspend fun follow(initial: ProviderLogin): ProviderLogin {
|
|
var current = initial
|
|
val wasSubmitting = initial.state == "submitting"
|
|
while (current.state == "starting" || current.state == "submitting") {
|
|
delay(400)
|
|
current =
|
|
withContext(Dispatchers.IO) {
|
|
fetchProviderLogin(
|
|
settings,
|
|
machineId,
|
|
provider,
|
|
current.attempt,
|
|
)
|
|
}
|
|
login = current
|
|
}
|
|
if (wasSubmitting && current.state == "waitingForCode" && current.detail == null) {
|
|
current =
|
|
current.copy(
|
|
detail = "That code was not accepted. Copy the complete code and try again."
|
|
)
|
|
login = current
|
|
}
|
|
return current
|
|
}
|
|
|
|
LaunchedEffect(machineId, provider, retry) {
|
|
error = null
|
|
code = ""
|
|
login = null
|
|
try {
|
|
val started =
|
|
withContext(Dispatchers.IO) { startProviderLogin(settings, machineId, provider) }
|
|
login = started
|
|
if (follow(started).state == "succeeded") {
|
|
onSignedIn()
|
|
}
|
|
} catch (e: ApiException) {
|
|
error = e.message
|
|
}
|
|
}
|
|
|
|
fun dismiss() {
|
|
login
|
|
?.takeUnless { it.state in setOf("succeeded", "failed", "cancelled") }
|
|
?.let {
|
|
scope.launch(Dispatchers.IO) {
|
|
runCatching { cancelProviderLogin(settings, machineId, provider, it.attempt) }
|
|
}
|
|
}
|
|
onDismiss()
|
|
}
|
|
|
|
AlertDialog(
|
|
onDismissRequest = ::dismiss,
|
|
title = { Text("Sign in to Claude") },
|
|
text = {
|
|
Column {
|
|
Text(
|
|
"Claude will sign in on $machineName. Open the authorization page, then " +
|
|
"paste the code it gives you here."
|
|
)
|
|
Spacer(Modifier.height(12.dp))
|
|
when (val current = login) {
|
|
null ->
|
|
if (error == null) {
|
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
CircularProgressIndicator()
|
|
Text("Starting sign-in…")
|
|
}
|
|
}
|
|
else ->
|
|
when (current.state) {
|
|
"starting",
|
|
"submitting" ->
|
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
CircularProgressIndicator()
|
|
Text(
|
|
if (current.state == "submitting") "Checking code…"
|
|
else "Starting sign-in…"
|
|
)
|
|
}
|
|
"waitingForCode" -> {
|
|
TextButton(
|
|
onClick = {
|
|
runCatching {
|
|
current.authorizationUrl?.let(uriHandler::openUri)
|
|
}
|
|
.onFailure {
|
|
error = "Couldn't open the authorization page."
|
|
}
|
|
},
|
|
enabled = current.authorizationUrl != null,
|
|
) {
|
|
Text("Open authorization page")
|
|
}
|
|
OutlinedTextField(
|
|
value = code,
|
|
onValueChange = { code = it },
|
|
label = { Text("Authorization code") },
|
|
singleLine = true,
|
|
modifier = Modifier.fillMaxWidth(),
|
|
)
|
|
current.detail?.let {
|
|
Text(it, color = MaterialTheme.colorScheme.error)
|
|
}
|
|
}
|
|
"succeeded" -> Text("Signed in on $machineName.")
|
|
"cancelled" -> Text("Sign-in was cancelled.")
|
|
else ->
|
|
Text(
|
|
current.detail ?: "Sign-in failed.",
|
|
color = MaterialTheme.colorScheme.error,
|
|
)
|
|
}
|
|
}
|
|
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
|
}
|
|
},
|
|
confirmButton = {
|
|
val current = login
|
|
when {
|
|
current?.state == "waitingForCode" ->
|
|
TextButton(
|
|
onClick = {
|
|
scope.launch {
|
|
error = null
|
|
try {
|
|
val submitted =
|
|
withContext(Dispatchers.IO) {
|
|
submitProviderLoginCode(
|
|
settings,
|
|
machineId,
|
|
provider,
|
|
current.attempt,
|
|
code,
|
|
)
|
|
}
|
|
login = submitted
|
|
if (follow(submitted).state == "succeeded") {
|
|
onSignedIn()
|
|
}
|
|
} catch (e: ApiException) {
|
|
error = e.message
|
|
}
|
|
}
|
|
},
|
|
enabled = code.isNotBlank(),
|
|
) {
|
|
Text("Continue")
|
|
}
|
|
error != null || current?.state == "failed" || current?.state == "cancelled" ->
|
|
TextButton(onClick = { retry++ }) { Text("Try again") }
|
|
current?.state == "succeeded" -> TextButton(onClick = onDismiss) { Text("Done") }
|
|
}
|
|
},
|
|
dismissButton = {
|
|
if (login?.state != "succeeded") {
|
|
TextButton(onClick = ::dismiss) { Text("Cancel") }
|
|
}
|
|
},
|
|
)
|
|
}
|