diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/screens/git/GitHubOAuthDialog.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/screens/git/GitHubOAuthDialog.kt index 77e8796d..97c12d0a 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/screens/git/GitHubOAuthDialog.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/screens/git/GitHubOAuthDialog.kt @@ -47,7 +47,11 @@ sealed class OAuthDialogState { val verificationUri: String, val expiresAt: Long, ) : OAuthDialogState() - data object Polling : OAuthDialogState() + data class Polling( + val userCode: String, + val verificationUri: String, + val expiresAt: Long, + ) : OAuthDialogState() data class Success(val username: String) : OAuthDialogState() data class Error(val message: String) : OAuthDialogState() } @@ -89,43 +93,34 @@ fun GitHubOAuthDialog( } is OAuthDialogState.ShowCode -> { - Text("Open github.com/login/device and enter this code:", style = MaterialTheme.typography.bodyMedium) - Text( - text = state.userCode, - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.primary, + CodeDisplayContent( + userCode = state.userCode, + verificationUri = state.verificationUri, + expiresAt = state.expiresAt, + onCopyCode = onCopyCode, + onOpenBrowser = onOpenBrowser, ) - CountdownText(expiresAt = state.expiresAt) - LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - OutlinedButton( - onClick = { onCopyCode(state.userCode) }, - modifier = Modifier.weight(1f), - ) { Text("Copy code") } - Button( - onClick = { onOpenBrowser(state.verificationUri) }, - modifier = Modifier.weight(1f), - ) { Text("Open GitHub") } - } } is OAuthDialogState.Polling -> { + CodeDisplayContent( + userCode = state.userCode, + verificationUri = state.verificationUri, + expiresAt = state.expiresAt, + onCopyCode = onCopyCode, + onOpenBrowser = onOpenBrowser, + ) Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp) - Text("Waiting for GitHub authorization…") + CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) + Text( + "Waiting for authorization…", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } - LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) - Text( - "Once you approve in the browser, this screen will update automatically.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) } is OAuthDialogState.Success -> { @@ -183,6 +178,39 @@ fun GitHubOAuthDialog( ) } +@Composable +private fun CodeDisplayContent( + userCode: String, + verificationUri: String, + expiresAt: Long, + onCopyCode: (String) -> Unit, + onOpenBrowser: (String) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("Open github.com/login/device and enter this code:", style = MaterialTheme.typography.bodyMedium) + Text( + text = userCode, + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.primary, + ) + CountdownText(expiresAt = expiresAt) + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedButton( + onClick = { onCopyCode(userCode) }, + modifier = Modifier.weight(1f), + ) { Text("Copy code") } + Button( + onClick = { onOpenBrowser(verificationUri) }, + modifier = Modifier.weight(1f), + ) { Text("Open GitHub") } + } + } +} + @Composable private fun CountdownText(expiresAt: Long) { var secondsLeft by remember { mutableLongStateOf(maxOf(0L, (expiresAt - Clock.System.now().toEpochMilliseconds()) / 1000L)) } diff --git a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/screens/git/GitSetupScreen.kt b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/screens/git/GitSetupScreen.kt index ba1f8c33..aef26b9f 100644 --- a/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/screens/git/GitSetupScreen.kt +++ b/kmp/src/commonMain/kotlin/dev/stapler/stelekit/ui/screens/git/GitSetupScreen.kt @@ -498,9 +498,14 @@ private suspend fun startOAuthFlow( deviceCode = response.deviceCode, expiresIn = response.expiresIn, initialInterval = response.interval, - onStateChange = { pollState -> - // Once user has opened browser (we're polling), transition to Polling state - onDialogStateChange(OAuthDialogState.Polling) + onStateChange = { _ -> + onDialogStateChange( + OAuthDialogState.Polling( + userCode = response.userCode, + verificationUri = response.verificationUri, + expiresAt = expiresAt, + ) + ) }, ) diff --git a/kmp/src/jvmMain/kotlin/dev/stapler/stelekit/desktop/Main.kt b/kmp/src/jvmMain/kotlin/dev/stapler/stelekit/desktop/Main.kt index aad3ec74..efcad440 100644 --- a/kmp/src/jvmMain/kotlin/dev/stapler/stelekit/desktop/Main.kt +++ b/kmp/src/jvmMain/kotlin/dev/stapler/stelekit/desktop/Main.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.window.Window import androidx.compose.ui.window.rememberWindowState import dev.stapler.stelekit.domain.UrlFetcherJvm import dev.stapler.stelekit.service.JvmMediaAttachmentService +import dev.stapler.stelekit.git.JvmGitRepository import dev.stapler.stelekit.ui.StelekitApp import dev.stapler.stelekit.ui.theme.setSystemDarkTheme import dev.stapler.stelekit.platform.PlatformFileSystem @@ -119,6 +120,7 @@ fun main() { } val attachmentService = remember { JvmMediaAttachmentService() } + val gitRepository = remember { JvmGitRepository() } StelekitApp( fileSystem = fileSystem, graphPath = graphPath, @@ -126,6 +128,7 @@ fun main() { spanRecorder = spanRecorder, cryptoEngine = dev.stapler.stelekit.vault.JvmCryptoEngine(), attachmentService = attachmentService, + gitRepository = gitRepository, ) } } diff --git a/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/git/GitHubDeviceFlowClientTest.kt b/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/git/GitHubDeviceFlowClientTest.kt index e718a5d1..c0963d73 100644 --- a/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/git/GitHubDeviceFlowClientTest.kt +++ b/kmp/src/jvmTest/kotlin/dev/stapler/stelekit/git/GitHubDeviceFlowClientTest.kt @@ -7,6 +7,7 @@ import arrow.core.Either import dev.stapler.stelekit.error.DomainError import dev.stapler.stelekit.git.model.DeviceCodeResponse import dev.stapler.stelekit.git.model.DeviceFlowPollState +import dev.stapler.stelekit.ui.screens.git.OAuthDialogState import io.ktor.client.HttpClient import io.ktor.client.engine.mock.MockEngine import io.ktor.client.engine.mock.respond @@ -18,9 +19,9 @@ import io.ktor.http.HttpStatusCode import io.ktor.http.headersOf import io.ktor.serialization.kotlinx.json.json import io.ktor.utils.io.ByteReadChannel +import java.io.IOException import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.Json -import java.io.IOException import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs @@ -120,6 +121,66 @@ class GitHubDeviceFlowClientTest { assertTrue(states.isEmpty()) } + @Test + fun `pollForToken_continuesPolling_onAuthorizationPending`() = runTest { + var callCount = 0 + val engine = MockEngine { _ -> + callCount++ + val body = when (callCount) { + 1 -> """{"error":"authorization_pending"}""" + else -> """{"access_token":"gho_pending123","token_type":"bearer","scope":"repo"}""" + } + respond(content = ByteReadChannel(body), status = HttpStatusCode.OK, headers = jsonHeaders()) + } + val client = GitHubDeviceFlowClient(buildClient(engine)) + val states = mutableListOf() + + val result = client.pollForToken( + deviceCode = "dev123", + expiresIn = 300, + initialInterval = 0, + onStateChange = { states.add(it) }, + ) + + assertIs>(result) + assertEquals("gho_pending123", result.value) + assertEquals(1, states.size) + assertIs(states.first()) + assertEquals(2, callCount) + } + + @Test + fun `pollForToken_retriesAndContinues_on5xxServerError`() = runTest { + var callCount = 0 + val engine = MockEngine { _ -> + callCount++ + if (callCount == 1) { + respond(content = ByteReadChannel(""), status = HttpStatusCode.InternalServerError, headers = jsonHeaders()) + } else { + respond( + content = ByteReadChannel("""{"access_token":"gho_srv","token_type":"bearer","scope":"repo"}"""), + status = HttpStatusCode.OK, + headers = jsonHeaders(), + ) + } + } + val client = GitHubDeviceFlowClient(buildClient(engine)) + val states = mutableListOf() + + val result = client.pollForToken( + deviceCode = "dev123", + expiresIn = 300, + initialInterval = 0, + onStateChange = { states.add(it) }, + ) + + assertIs>(result) + assertEquals("gho_srv", result.value) + assertEquals(1, states.size) + assertIs(states.first()) + assertEquals(2, callCount) + } + @Test fun `pollForToken_incrementsInterval_cumulativelyOnSlowDown`() = runTest { // Returns slow_down twice then a token. Verify onStateChange receives Pending @@ -283,4 +344,21 @@ class GitHubDeviceFlowClientTest { assertEquals(null, username) } + + // ------------------------------------------------------------------------- + // OAuthDialogState.Polling — state model + // ------------------------------------------------------------------------- + + @Test + fun `OAuthDialogState_Polling_carries_code_and_expiry_fields`() { + val state = OAuthDialogState.Polling( + userCode = "ABCD-1234", + verificationUri = "https://github.com/login/device", + expiresAt = 9_999_999L, + ) + assertIs(state) + assertEquals("ABCD-1234", state.userCode) + assertEquals("https://github.com/login/device", state.verificationUri) + assertEquals(9_999_999L, state.expiresAt) + } }