From deea17bf6e4ce2f4a894fcb90fcb6eb1eea93117 Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Fri, 9 Oct 2026 18:38:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(trae):=20=E7=B2=98=E8=B4=B4=E5=9B=9E?= =?UTF-8?q?=E8=B0=83=E5=AF=BC=E5=85=A5=20JWT=20+=20=E6=B5=8F=E8=A7=88?= =?UTF-8?q?=E5=99=A8=E5=9B=9E=E8=B0=83=E6=9C=8D=E5=8A=A1=E5=99=A8=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=9B=9E=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Trae 表单新增「登录回调链接」粘贴框,支持整条 http://127.0.0.1:18080/authorize?... 解析并填入 JWT - 「在浏览器中打开登录入口」改为启动本地 18081 回调服务器 + 浏览器登录,登录完自动保存(Sub2API 同款) --- app/build.gradle.kts | 4 +- .../token/data/proxy/LoginCallbackServer.kt | 70 +++++++++++++++ .../token/ui/settings/CredentialEditScreen.kt | 22 +++++ .../ui/settings/CredentialEditViewModel.kt | 73 +++++++++++++++- .../token/ui/webview/WebViewLoginScreen.kt | 23 ++--- .../token/ui/webview/WebViewLoginViewModel.kt | 86 ++++++++++++++++++- app/src/main/res/values-b+zh+Hans/strings.xml | 6 ++ app/src/main/res/values-b+zh+Hant/strings.xml | 6 ++ app/src/main/res/values/strings.xml | 6 ++ 9 files changed, 279 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/com/rainy/token/data/proxy/LoginCallbackServer.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 104391d..7e97029 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -16,8 +16,8 @@ android { applicationId = "com.rainy.token" minSdk = 31 targetSdk = 35 - versionCode = 35 - versionName = "1.7.15" + versionCode = 36 + versionName = "1.7.16" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/app/src/main/java/com/rainy/token/data/proxy/LoginCallbackServer.kt b/app/src/main/java/com/rainy/token/data/proxy/LoginCallbackServer.kt new file mode 100644 index 0000000..74886a7 --- /dev/null +++ b/app/src/main/java/com/rainy/token/data/proxy/LoginCallbackServer.kt @@ -0,0 +1,70 @@ +package com.rainy.token.data.proxy + +import io.ktor.http.ContentType +import io.ktor.server.cio.CIO +import io.ktor.server.engine.EmbeddedServer +import io.ktor.server.engine.embeddedServer +import io.ktor.server.response.respondText +import io.ktor.server.routing.get +import io.ktor.server.routing.routing +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.withTimeoutOrNull + +/** + * 本地登录回调服务器:在 App 内起一个 127.0.0.1 端口, + * 手机浏览器完成 Trae OAuth 后跳回 `http://127.0.0.1:/authorize?...`, + * 这里把整个回调 URL 交给 ViewModel 解析并保存凭据(Sub2API 同款体验)。 + * + * 只绑 loopback;授权完成/超时/退出登录页时调用 [stop] 释放端口。 + */ +object LoginCallbackServer { + + private const val PATH = "/authorize" + + private var server: EmbeddedServer<*, *>? = null + private var pending: CompletableDeferred? = null + + /** 启动(已启动则只重置等待任务)。返回是否可用。 */ + @Synchronized + fun start(port: Int): Boolean { + if (server != null) { + pending = CompletableDeferred() + return true + } + pending = CompletableDeferred() + return try { + val engine = embeddedServer(CIO, host = "127.0.0.1", port = port) { + routing { + get(PATH) { + val callbackUrl = "http://127.0.0.1:$port" + call.request.local.uri + pending?.complete(callbackUrl) + call.respondText( + "登录成功,可以返回雨晴Token了。", + ContentType.Text.Html + ) + } + } + } + engine.start(wait = false) + server = engine + true + } catch (t: Throwable) { + server = null + pending = null + false + } + } + + /** 等待登录回调;[timeoutMs] 内没等到返回 null。 */ + suspend fun await(timeoutMs: Long): String? { + val deferred = pending ?: return null + return withTimeoutOrNull(timeoutMs) { deferred.await() } + } + + @Synchronized + fun stop() { + runCatching { server?.stop(gracePeriodMillis = 200, timeoutMillis = 1000) } + server = null + pending = null + } +} \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditScreen.kt b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditScreen.kt index 11f03be..da8c0cf 100644 --- a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditScreen.kt @@ -153,10 +153,13 @@ fun CredentialEditScreen( if (service == ServiceType.TRAE) { TraeCredentialForm( jwt = uiState.traeJwt, + callbackUrl = uiState.traeCallbackUrl, region = uiState.traeRegion, checkinDeviceId = uiState.traeCheckinDeviceId, hasExisting = uiState.hasExisting, onJwtChange = viewModel::updateTraeJwt, + onCallbackUrlChange = viewModel::updateTraeCallbackUrl, + onImportCallback = { viewModel.importTraeCallbackFromUrl(it) }, onRegionChange = viewModel::updateTraeRegion, onCheckinDeviceIdChange = viewModel::updateTraeCheckinDeviceId, onStartInAppLogin = { onStartWebViewLogin(ServiceType.TRAE, accountId, uiState.traeRegion) }, @@ -779,10 +782,13 @@ private fun ManualCookieForm( @Composable private fun TraeCredentialForm( jwt: String, + callbackUrl: String, region: String, checkinDeviceId: String, hasExisting: Boolean, onJwtChange: (String) -> Unit, + onCallbackUrlChange: (String) -> Unit, + onImportCallback: (String) -> Unit, onRegionChange: (String) -> Unit, onCheckinDeviceIdChange: (String) -> Unit, onStartInAppLogin: () -> Unit, @@ -800,6 +806,22 @@ private fun TraeCredentialForm( ) { Text(stringResource(R.string.action_login_in_app)) } + OutlinedTextField( + value = callbackUrl, + onValueChange = onCallbackUrlChange, + label = { Text(stringResource(R.string.field_trae_callback_url)) }, + supportingText = { Text(stringResource(R.string.field_trae_callback_hint)) }, + minLines = 1, + maxLines = 3, + modifier = Modifier.fillMaxWidth() + ) + OutlinedButton( + onClick = { onImportCallback(callbackUrl) }, + enabled = callbackUrl.isNotBlank(), + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.action_parse_callback)) + } OutlinedTextField( value = jwt, onValueChange = onJwtChange, diff --git a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt index 5c727d3..17e5645 100644 --- a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt @@ -26,6 +26,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.serialization.json.Json +import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive @@ -299,6 +300,70 @@ class CredentialEditViewModel @Inject constructor( fun updateTraeJwt(value: String) { _uiState.update { it.copy(traeJwt = value) } } + fun updateTraeCallbackUrl(value: String) { + _uiState.update { it.copy(traeCallbackUrl = value) } + } + + /** 解析浏览器登录回调链接,填入 JWT;解析失败给提示。 */ + fun importTraeCallbackFromUrl(raw: String) { + val input = raw.trim() + if (input.isEmpty()) { + _uiState.update { it.copy(message = UiText.Resource(R.string.error_api_key_empty)) } + return + } + val parsed = parseTraeCallback(input) + if (parsed == null) { + _uiState.update { it.copy(message = UiText.Resource(R.string.error_trae_callback_invalid)) } + return + } + _uiState.update { + it.copy( + traeJwt = parsed.first, + traeRegion = parsed.second, + traeCallbackUrl = input + ) + } + } + + private fun parseTraeCallback(input: String): Pair? { + val raw = input.trim() + val uri = runCatching { android.net.Uri.parse(raw) }.getOrNull() ?: return null + fun query(name: String): String? { + uri.getQueryParameter(name)?.let { return it } + val frag = uri.fragment + if (!frag.isNullOrBlank()) { + frag.split("&").forEach { pair -> + val kv = pair.split("=", limit = 2) + if (kv.size == 2 && kv[0] == name) return android.net.Uri.decode(kv[1]) + if (kv.size == 1 && kv[0] == name) return "" + } + } + return null + } + val userJwtRaw = query("userJwt") ?: query("user_jwt") ?: "" + val userJwt = runCatching { + kotlinx.serialization.json.Json.parseToJsonElement(userJwtRaw).jsonObject + }.getOrNull() ?: kotlinx.serialization.json.JsonObject(emptyMap()) + val token = userJwt["Token"]?.jsonPrimitive?.contentOrNull + ?: userJwt["token"]?.jsonPrimitive?.contentOrNull + ?: userJwt["AccessToken"]?.jsonPrimitive?.contentOrNull + ?: userJwt["accessToken"]?.jsonPrimitive?.contentOrNull + ?: if (raw.startsWith("{")) userJwt["jwt"]?.jsonPrimitive?.contentOrNull else null + if (token.isNullOrBlank()) return null + // 区域推断:回调显式 userRegion/us/INTL,或 userInfo.Region 非 CN 域 + val userRegion = query("userRegion")?.lowercase() + val userInfoRegion = query("userInfo")?.let { infoRaw -> + runCatching { + val info = kotlinx.serialization.json.Json.parseToJsonElement(infoRaw).jsonObject + info["Region"]?.jsonPrimitive?.contentOrNull + ?: info["region"]?.jsonPrimitive?.contentOrNull + }.getOrNull() + } + val intl = userRegion?.let { it != "cn" } == true || + userInfoRegion?.let { !it.equals("cn", true) && !it.equals("china", true) } == true || + query("userRegion")?.equals("us", true) == true + return token to (if (intl) "INTL" else "CN") + } fun updateTraeRegion(value: String) { _uiState.update { it.copy(traeRegion = value) } @@ -311,7 +376,11 @@ class CredentialEditViewModel @Inject constructor( fun saveTraeCredential() { val type = serviceType ?: return val current = _uiState.value - val jwt = current.traeJwt.trim() + var jwt = current.traeJwt.trim() + if (jwt.isBlank() && current.traeCallbackUrl.isNotBlank()) { + val parsed = parseTraeCallback(current.traeCallbackUrl) + if (parsed != null) jwt = parsed.first + } if (jwt.isBlank()) { _uiState.update { it.copy(message = UiText.Resource(R.string.error_api_key_empty)) } return @@ -978,6 +1047,8 @@ data class CredentialEditUiState( val triggerApiKey: String = "", /** Trae:Cloud-IDE-JWT + 区域 */ val traeJwt: String = "", + /** Trae:浏览器回调地址粘贴导入(http://127.0.0.1:18080/authorize?...) */ + val traeCallbackUrl: String = "", val traeRegion: String = "CN", /** Trae 签到设备 ID(官方客户端绑定 did;留空用内置默认) */ val traeCheckinDeviceId: String = "", diff --git a/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginScreen.kt b/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginScreen.kt index 73055e7..19102cc 100644 --- a/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginScreen.kt @@ -113,16 +113,7 @@ fun WebViewLoginScreen( } }, actions = { - TextButton(onClick = { - val url = uiState.loginUrl - if (url.isNotBlank()) { - runCatching { - context.startActivity( - Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - ) - } - } - }) { + TextButton(onClick = { viewModel.openExternalBrowser() }) { Text(stringResource(R.string.action_open_login_entry)) } TextButton(onClick = { @@ -142,6 +133,18 @@ fun WebViewLoginScreen( if (pageLoading) { LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) } + if (uiState.externalBrowserWaiting) { + androidx.compose.material3.Surface( + color = androidx.compose.material3.MaterialTheme.colorScheme.secondaryContainer, + modifier = Modifier.fillMaxWidth() + ) { + Text( + text = stringResource(R.string.webview_browser_waiting), + style = androidx.compose.material3.MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(12.dp) + ) + } + } key(webViewGeneration) { AndroidView( factory = { context -> diff --git a/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginViewModel.kt b/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginViewModel.kt index f4e1679..3570806 100644 --- a/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginViewModel.kt @@ -21,6 +21,7 @@ import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.longOrNull import com.rainy.token.R import com.rainy.token.data.debug.DebugLog +import com.rainy.token.data.proxy.LoginCallbackServer import com.rainy.token.data.repository.WebViewSessionSaver import com.rainy.token.data.repository.CredentialRepository import com.rainy.token.domain.model.Credential @@ -149,7 +150,17 @@ class WebViewLoginViewModel @Inject constructor( // 登录 URL 与签到 claim 用同一个值。不能每次随机(会话绑定不一致会被风控拒), // 也不可用 userJwt.ClientID(固定客户端标识,所有用户相同,必被按设备限流)。 val deviceId = traeDeviceId() - val callbackUrl = "http://127.0.0.1:18080/authorize" + val callbackUrl = buildTraeAuthUrl(LOGIN_WEBVIEW_CALLBACK_PORT) + _uiState.update { it.copy(loginUrl = callbackUrl) } + return + } + + /** 构建 Trae 授权 URL;WebView 走 18080,外部浏览器走 18081(本地回调服务器)。 */ + private fun buildTraeAuthUrl(callbackPort: Int): String { + val traceId = randomHex(16) + val machineId = randomHex(32) + val deviceId = traeDeviceId() + val callbackUrl = "http://127.0.0.1:$callbackPort/authorize" val authRoot = if (region == "INTL") "https://www.trae.ai/authorization" else "https://www.trae.cn/authorization" val authUrl = buildString { append(authRoot).append("?") @@ -165,8 +176,8 @@ class WebViewLoginViewModel @Inject constructor( append("x_os_version=1.0&x_app_version=0.1.43&") append("x_app_type=stable&plugin_version=2.3.62834") } - DebugLog.i("TraeLogin", "bindTrae: 授权URL已生成 len=${authUrl.length} deviceId持久化=${deviceId.take(6)}…") - _uiState.update { it.copy(loginUrl = authUrl) } + DebugLog.i("TraeLogin", "buildTraeAuthUrl: port=$callbackPort len=${authUrl.length} deviceId持久化=${deviceId.take(6)}…") + return authUrl } /** Trae 签到用的持久化设备 ID:首次生成 19 位数字并保存,此后跨登录复用。 */ @@ -504,6 +515,67 @@ class WebViewLoginViewModel @Inject constructor( } } + /** + * 「在浏览器中打开登录入口」:Trae 起本地回调服务器,登录完自动回传凭证; + * WorkBuddy 直接打开浏览器并继续原有轮询(官方登录本身就在浏览器完成)。 + */ + fun openExternalBrowser() { + val service = _uiState.value.service ?: return + val currentUrl = _uiState.value.loginUrl + when (service) { + ServiceType.TRAE -> { + if (!LoginCallbackServer.start(LOGIN_EXTERNAL_CALLBACK_PORT)) { + _uiState.update { + it.copy(error = UiText.Resource(R.string.error_login_callback_server)) + } + return + } + val authUrl = buildTraeAuthUrl(LOGIN_EXTERNAL_CALLBACK_PORT) + _uiState.update { + it.copy(loginUrl = authUrl, externalBrowserWaiting = true, error = null) + } + launchExternalBrowser(authUrl) + viewModelScope.launch { + val callback = LoginCallbackServer.await(LOGIN_EXTERNAL_TIMEOUT_MS) + if (callback != null) { + onTraeCallback(callback) + } else if (!_uiState.value.loginSucceeded) { + _uiState.update { + it.copy( + error = UiText.Resource(R.string.error_login_timeout), + externalBrowserWaiting = false + ) + } + } + } + } + ServiceType.WORKBUDDY -> { + if (currentUrl.isNotBlank()) { + _uiState.update { + it.copy(loginUrl = currentUrl, externalBrowserWaiting = true, error = null) + } + launchExternalBrowser(currentUrl) + } + } + else -> { + if (currentUrl.isNotBlank()) { + launchExternalBrowser(currentUrl) + } + } + } + } + + private fun launchExternalBrowser(url: String) { + runCatching { + val ctx = com.rainy.token.RainyTokenApplication.appContext + val intent = android.content.Intent( + android.content.Intent.ACTION_VIEW, + android.net.Uri.parse(url) + ).addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) + ctx.startActivity(intent) + } + } + /** 清除 WebView 登录缓存(Cookie/Storage),用于更换账号登录。 */ fun clearWebViewSession() { runCatching { @@ -668,6 +740,10 @@ private const val SUB2_NONCE_KEY = "nonce" /** OpenCode dashboard URL 中的 workspaceId:`/workspace/{id}/go`。 */ private val OPENCODE_WORKSPACE_REGEX = Regex("""/workspace/([^/?#]+)/go""") +private const val LOGIN_WEBVIEW_CALLBACK_PORT = 18080 +private const val LOGIN_EXTERNAL_CALLBACK_PORT = 18081 +private const val LOGIN_EXTERNAL_TIMEOUT_MS = 20 * 60 * 1000L + internal fun buildProbeScript(nonce: String): String = """ (function () { try { @@ -698,5 +774,7 @@ data class WebViewLoginUiState( val pendingManualConfirm: Boolean = false, val error: UiText? = null, /** Trae/WorkBuddy 登录实际写入的账号 ID(新建账号模式时用于回填信用编辑页)。 */ - val loginAccountId: String? = null + val loginAccountId: String? = null, + /** 已在手机浏览器打开登录页,等待回调/轮询自动带回凭证。 */ + val externalBrowserWaiting: Boolean = false ) diff --git a/app/src/main/res/values-b+zh+Hans/strings.xml b/app/src/main/res/values-b+zh+Hans/strings.xml index ee87539..0c9ce51 100644 --- a/app/src/main/res/values-b+zh+Hans/strings.xml +++ b/app/src/main/res/values-b+zh+Hans/strings.xml @@ -424,6 +424,12 @@ 未抓到 Cookie,请确认已登录 未配置登录 URL 登录成功但未获取到令牌,请复制回调地址里的 userJwt 后手动粘贴 + Trae 登录回调链接 + 在浏览器登录完成后,把地址栏整条链接粘到这里(http://127.0.0.1:18080/authorize?...) + 解析并填入 JWT + 无法解析这条回调链接,请完整复制地址栏内容 + 本地回调服务启动失败,请改用应用内登录 + 已在浏览器打开,登录完成后会自动回到雨晴Token 登录超时,请重试 URL 应以 %1$s 开头 授权失败: %1$s diff --git a/app/src/main/res/values-b+zh+Hant/strings.xml b/app/src/main/res/values-b+zh+Hant/strings.xml index 2c447a4..67b3637 100644 --- a/app/src/main/res/values-b+zh+Hant/strings.xml +++ b/app/src/main/res/values-b+zh+Hant/strings.xml @@ -424,6 +424,12 @@ 未抓到 Cookie,請確認已登入 未配置登入 URL 登入成功但未取得令牌,請複製回呼位址中的 userJwt 後手動貼上 + Trae 登入回呼連結 + 在瀏覽器登入完成後,把網址列整條連結貼到這裡(http://127.0.0.1:18080/authorize?...) + 解析並填入 JWT + 無法解析這條回呼連結,請完整複製網址列內容 + 本機回呼服務啟動失敗,請改用應用內登入 + 已在瀏覽器開啟,登入完成後會自動回到雨晴Token 登入逾時,請重試 URL 應以 %1$s 開頭 授權失敗: %1$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7b14865..f5b151f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -427,6 +427,12 @@ No cookie captured, please make sure you\'re logged in No login URL configured Login succeeded but no token was received. Copy userJwt from the callback URL and paste it manually. + Trae login callback URL + Paste the whole callback URL after logging in the browser (http://127.0.0.1:18080/authorize?...). + Parse and fill JWT + Could not parse this callback URL. Please copy the address bar URL completely. + Unable to start local callback server. Please use in-app login instead. + Opened in browser. After login it will return to RainyToken automatically. Login timed out, please retry URL should start with %1$s Authorization failed: %1$s