From 9db3e01d498d63f5222d8846ba7b091404f73d57 Mon Sep 17 00:00:00 2001
From: Liuxinyu176 <1041316040@qq.com>
Date: Fri, 9 Oct 2026 17:21:48 +0800
Subject: [PATCH] =?UTF-8?q?fix(trae):=20=E6=94=AF=E6=8C=81=E5=9B=9E?=
=?UTF-8?q?=E8=B0=83=E4=BB=85=E5=B8=A6=20refreshToken=20=E6=97=B6=E8=87=AA?=
=?UTF-8?q?=E5=8A=A8=E5=85=91=E6=8D=A2=20JWT?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 登录回调不再只认 userJwt;老流程只回传 refreshToken 时调用 ExchangeToken 兑换 Cloud-IDE-JWT
- 兑换失败给出明确提示,不再永远停在 Authenticating
---
app/build.gradle.kts | 4 +-
.../token/ui/webview/WebViewLoginViewModel.kt | 141 +++++++++++++-----
app/src/main/res/values-b+zh+Hans/strings.xml | 1 +
app/src/main/res/values-b+zh+Hant/strings.xml | 1 +
app/src/main/res/values/strings.xml | 1 +
5 files changed, 109 insertions(+), 39 deletions(-)
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 1156d6f..3596af6 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 = 33
- versionName = "1.7.13"
+ versionCode = 34
+ versionName = "1.7.14"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
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 c7d13bf..eb6ce4a 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
@@ -195,52 +195,76 @@ class WebViewLoginViewModel @Inject constructor(
DebugLog.w("TraeLogin", "onTraeCallback: URL 解析失败")
return
}
- // 只记录参数名(不含 token 值,避免把 userJwt 明文落调试日志)
+ // 只记录参数名(不含 token 值,避免把 token 明文落调试日志)
DebugLog.i("TraeLogin", "onTraeCallback: 收到回调,参数=${parsed.queryParameterNames}")
- // Trae 回调中参数名可能为 userJwt 或 user_jwt,值是 JSON 字符串
+ // Trae 回调可能走两套:
+ // 1) userJwt/user_jwt 直接带 Token+RefreshToken(正常流程)
+ // 2) 只带 refreshToken(老流程),需要调用 ExchangeToken 兑换 Cloud-IDE-JWT
val userJwtRaw = parsed.queryParameter("userJwt")
?: parsed.queryParameter("user_jwt")
- ?: run {
- DebugLog.w("TraeLogin", "onTraeCallback: 无 userJwt/user_jwt 参数")
- return
- }
- val userJwt = try {
- Json.parseToJsonElement(userJwtRaw).jsonObject
- } catch (e: Exception) {
- DebugLog.w("TraeLogin", "onTraeCallback: userJwt JSON 解析失败: ${e.message}")
- return
- }
- val token = userJwt["Token"]?.jsonPrimitive?.contentOrNull
- if (token.isNullOrBlank()) {
- DebugLog.w("TraeLogin", "onTraeCallback: 收到回调但 userJwt 无 Token(userJwtKeys=${userJwt.keys})")
- return
- }
- DebugLog.i("TraeLogin", "onTraeCallback: userJwt 解析成功 token非空=${token.isNotBlank()}")
- // 设备绑定 ID:登录会话绑定的 App 持久化设备 ID(与授权 URL device_id 一致)。
- // 注意不能用 userJwt.ClientID——那是固定客户端标识(en1oxy7wnw8j9n),所有
- // 用户相同,作 x-device-id 会被服务端按设备风控拒绝(「参与用户太多」)。
- val deviceId = traeDeviceId()
- // 用户 ID:从回调 userInfo 参数提取,签到按 trae-check 口径拼 ${deviceId}-${userId}。
- val userId = parsed.queryParameter("userInfo")?.let { raw ->
- runCatching {
- val u = Json.parseToJsonElement(raw).jsonObject
- u["userId"]?.jsonPrimitive?.contentOrNull
- ?: u["user_id"]?.jsonPrimitive?.contentOrNull
- ?: u["id"]?.jsonPrimitive?.contentOrNull
- ?: u["accountId"]?.jsonPrimitive?.contentOrNull
- }.getOrNull()
- }
- DebugLog.i("TraeLogin", "onTraeCallback: userId=${if (userId.isNullOrBlank()) "null" else "ok"}")
-
+ ?: ""
viewModelScope.launch {
- // 刚成功登录的凭据视为已验证:lastVerifiedAt 记为当前时间,
- // 否则首页/列表会显示"未验证/未登录"直到进详情页拉余额后才正常
+ val userJwt = runCatching {
+ Json.parseToJsonElement(userJwtRaw).jsonObject
+ }.getOrNull() ?: JsonObject(emptyMap())
+
+ var token = userJwt["Token"]?.jsonPrimitive?.contentOrNull
+ ?: userJwt["token"]?.jsonPrimitive?.contentOrNull
+ ?: userJwt["AccessToken"]?.jsonPrimitive?.contentOrNull
+ ?: userJwt["accessToken"]?.jsonPrimitive?.contentOrNull
+ var refresh = userJwt["RefreshToken"]?.jsonPrimitive?.contentOrNull
+ ?: userJwt["refreshToken"]?.jsonPrimitive?.contentOrNull
+ ?: parsed.queryParameter("refreshToken")
+ ?: parsed.queryParameter("refresh_token")
+ ?: parsed.queryParameter("data")
+ val clientId = userJwt["ClientID"]?.jsonPrimitive?.contentOrNull
+ ?: userJwt["clientId"]?.jsonPrimitive?.contentOrNull
+ ?: parsed.queryParameter("clientId")
+ ?: parsed.queryParameter("client_id")
+ ?: "en1oxy7wnw8j9n"
+
+ // 老流程:回调只有 refreshToken,先兑换 Cloud-IDE-JWT
+ if (token.isNullOrBlank() && !refresh.isNullOrBlank()) {
+ val exchangeHost = parsed.queryParameter("host")?.takeIf { it.startsWith("http") }
+ ?: if (region == "INTL") "https://a0ai-api-sg.byteintlapi.com" else "https://api.trae.cn"
+ val exchanged = exchangeTraeRefreshToken(refresh, clientId, exchangeHost)
+ if (exchanged != null) {
+ token = exchanged.token
+ refresh = exchanged.refreshToken
+ DebugLog.i("TraeLogin", "onTraeCallback: refreshToken 兑换成功(host=${exchangeHost})")
+ } else {
+ DebugLog.w("TraeLogin", "onTraeCallback: refreshToken 兑换失败(host=${exchangeHost})")
+ }
+ }
+
+ if (token.isNullOrBlank()) {
+ DebugLog.w("TraeLogin", "onTraeCallback: 收到回调但无 Token(userJwtKeys=${userJwt.keys})")
+ _uiState.update {
+ it.copy(error = UiText.Resource(R.string.error_login_token_missing))
+ }
+ return@launch
+ }
+
+ // 设备绑定 ID:登录会话绑定的 App 持久化设备 ID(与授权 URL device_id 一致)。
+ val deviceId = traeDeviceId()
+ // 用户 ID:从回调 userInfo 参数提取,签到按 trae-check 口径拼 ${deviceId}-${userId}。
+ val parsedUserId = parsed.queryParameter("userInfo")?.let { raw ->
+ runCatching {
+ val u = Json.parseToJsonElement(raw).jsonObject
+ u["userId"]?.jsonPrimitive?.contentOrNull
+ ?: u["user_id"]?.jsonPrimitive?.contentOrNull
+ ?: u["id"]?.jsonPrimitive?.contentOrNull
+ ?: u["accountId"]?.jsonPrimitive?.contentOrNull
+ }.getOrNull()
+ }
+ DebugLog.i("TraeLogin", "onTraeCallback: userId=${if (parsedUserId.isNullOrBlank()) "null" else "ok"}")
+
val credential = Credential.TraeCredential(
service = ServiceType.TRAE,
jwt = token,
region = if (region == "INTL") "INTL" else "CN",
deviceId = deviceId,
- userId = userId?.takeIf { it.isNotBlank() },
+ userId = parsedUserId?.takeIf { it.isNotBlank() },
lastVerifiedAt = System.currentTimeMillis()
)
val effectiveId = credentialRepository.save(credential, accountId)
@@ -251,6 +275,49 @@ class WebViewLoginViewModel @Inject constructor(
}
}
+ /** 使用 refreshToken 向 Trae OAuth 兑换 Cloud-IDE-JWT(老流程回调没有 userJwt)。 */
+ private data class TraeExchangeResult(val token: String, val refreshToken: String)
+
+ private suspend fun exchangeTraeRefreshToken(
+ refreshToken: String,
+ clientId: String,
+ host: String
+ ): TraeExchangeResult? = withContext(Dispatchers.IO) {
+ try {
+ val payload = buildString {
+ append("{\"ClientID\":\"").append(clientId)
+ .append("\",\"RefreshToken\":\"").append(refreshToken)
+ .append("\",\"ClientSecret\":\"-\",\"UserID\":\"\"}")
+ }
+ val request = Request.Builder()
+ .url(host.trimEnd('/') + "/cloudide/api/v3/trae/oauth/ExchangeToken")
+ .addHeader("Content-Type", "application/json")
+ .post(payload.toRequestBody("application/json".toMediaTypeOrNull()))
+ .build()
+ okHttpClient.newCall(request).execute().use { resp ->
+ val body = resp.body?.string().orEmpty()
+ DebugLog.i("TraeLogin", "ExchangeToken: http=${resp.code} bodyLen=${body.length}")
+ val root = runCatching { Json.parseToJsonElement(body).jsonObject }.getOrNull() ?: return@use null
+ val data = root["Result"]?.jsonObject ?: root["result"]?.jsonObject ?: root
+ val token = data["Token"]?.jsonPrimitive?.contentOrNull
+ ?: data["token"]?.jsonPrimitive?.contentOrNull
+ ?: data["AccessToken"]?.jsonPrimitive?.contentOrNull
+ ?: data["accessToken"]?.jsonPrimitive?.contentOrNull
+ if (token.isNullOrBlank()) {
+ DebugLog.w("TraeLogin", "ExchangeToken: 返回无 Token(keys=${data.keys})")
+ return@use null
+ }
+ val newRefresh = data["RefreshToken"]?.jsonPrimitive?.contentOrNull
+ ?: data["refreshToken"]?.jsonPrimitive?.contentOrNull
+ ?: refreshToken
+ TraeExchangeResult(token, newRefresh)
+ }
+ } catch (e: Exception) {
+ DebugLog.w("TraeLogin", "ExchangeToken: 异常=" + (e.message ?: "unknown"))
+ null
+ }
+ }
+
// ─── WorkBuddy ─────────────────────────────────────────────────────────
private fun bindWorkBuddy() {
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 d2d2811..ee87539 100644
--- a/app/src/main/res/values-b+zh+Hans/strings.xml
+++ b/app/src/main/res/values-b+zh+Hans/strings.xml
@@ -423,6 +423,7 @@
请先选择模型
未抓到 Cookie,请确认已登录
未配置登录 URL
+ 登录成功但未获取到令牌,请复制回调地址里的 userJwt 后手动粘贴
登录超时,请重试
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 cd8f83e..2c447a4 100644
--- a/app/src/main/res/values-b+zh+Hant/strings.xml
+++ b/app/src/main/res/values-b+zh+Hant/strings.xml
@@ -423,6 +423,7 @@
請先選擇模型
未抓到 Cookie,請確認已登入
未配置登入 URL
+ 登入成功但未取得令牌,請複製回呼位址中的 userJwt 後手動貼上
登入逾時,請重試
URL 應以 %1$s 開頭
授權失敗: %1$s
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index bfed392..7b14865 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -426,6 +426,7 @@
Please select a model first
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.
Login timed out, please retry
URL should start with %1$s
Authorization failed: %1$s