feat(trae): 粘贴回调导入 JWT + 浏览器回调服务器自动回传

- Trae 表单新增「登录回调链接」粘贴框,支持整条 http://127.0.0.1:18080/authorize?... 解析并填入 JWT
- 「在浏览器中打开登录入口」改为启动本地 18081 回调服务器 + 浏览器登录,登录完自动保存(Sub2API 同款)
This commit is contained in:
Liuxinyu176 2026-10-09 18:38:01 +08:00
parent df84aa94ab
commit deea17bf6e
9 changed files with 279 additions and 17 deletions

View File

@ -16,8 +16,8 @@ android {
applicationId = "com.rainy.token" applicationId = "com.rainy.token"
minSdk = 31 minSdk = 31
targetSdk = 35 targetSdk = 35
versionCode = 35 versionCode = 36
versionName = "1.7.15" versionName = "1.7.16"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {

View File

@ -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:<port>/authorize?...`,
* 这里把整个回调 URL 交给 ViewModel 解析并保存凭据(Sub2API 同款体验)。
*
* 只绑 loopback;授权完成/超时/退出登录页时调用 [stop] 释放端口。
*/
object LoginCallbackServer {
private const val PATH = "/authorize"
private var server: EmbeddedServer<*, *>? = null
private var pending: CompletableDeferred<String>? = 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
}
}

View File

@ -153,10 +153,13 @@ fun CredentialEditScreen(
if (service == ServiceType.TRAE) { if (service == ServiceType.TRAE) {
TraeCredentialForm( TraeCredentialForm(
jwt = uiState.traeJwt, jwt = uiState.traeJwt,
callbackUrl = uiState.traeCallbackUrl,
region = uiState.traeRegion, region = uiState.traeRegion,
checkinDeviceId = uiState.traeCheckinDeviceId, checkinDeviceId = uiState.traeCheckinDeviceId,
hasExisting = uiState.hasExisting, hasExisting = uiState.hasExisting,
onJwtChange = viewModel::updateTraeJwt, onJwtChange = viewModel::updateTraeJwt,
onCallbackUrlChange = viewModel::updateTraeCallbackUrl,
onImportCallback = { viewModel.importTraeCallbackFromUrl(it) },
onRegionChange = viewModel::updateTraeRegion, onRegionChange = viewModel::updateTraeRegion,
onCheckinDeviceIdChange = viewModel::updateTraeCheckinDeviceId, onCheckinDeviceIdChange = viewModel::updateTraeCheckinDeviceId,
onStartInAppLogin = { onStartWebViewLogin(ServiceType.TRAE, accountId, uiState.traeRegion) }, onStartInAppLogin = { onStartWebViewLogin(ServiceType.TRAE, accountId, uiState.traeRegion) },
@ -779,10 +782,13 @@ private fun ManualCookieForm(
@Composable @Composable
private fun TraeCredentialForm( private fun TraeCredentialForm(
jwt: String, jwt: String,
callbackUrl: String,
region: String, region: String,
checkinDeviceId: String, checkinDeviceId: String,
hasExisting: Boolean, hasExisting: Boolean,
onJwtChange: (String) -> Unit, onJwtChange: (String) -> Unit,
onCallbackUrlChange: (String) -> Unit,
onImportCallback: (String) -> Unit,
onRegionChange: (String) -> Unit, onRegionChange: (String) -> Unit,
onCheckinDeviceIdChange: (String) -> Unit, onCheckinDeviceIdChange: (String) -> Unit,
onStartInAppLogin: () -> Unit, onStartInAppLogin: () -> Unit,
@ -800,6 +806,22 @@ private fun TraeCredentialForm(
) { ) {
Text(stringResource(R.string.action_login_in_app)) 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( OutlinedTextField(
value = jwt, value = jwt,
onValueChange = onJwtChange, onValueChange = onJwtChange,

View File

@ -26,6 +26,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
@ -299,6 +300,70 @@ class CredentialEditViewModel @Inject constructor(
fun updateTraeJwt(value: String) { fun updateTraeJwt(value: String) {
_uiState.update { it.copy(traeJwt = value) } _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<String, String>? {
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) { fun updateTraeRegion(value: String) {
_uiState.update { it.copy(traeRegion = value) } _uiState.update { it.copy(traeRegion = value) }
@ -311,7 +376,11 @@ class CredentialEditViewModel @Inject constructor(
fun saveTraeCredential() { fun saveTraeCredential() {
val type = serviceType ?: return val type = serviceType ?: return
val current = _uiState.value 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()) { if (jwt.isBlank()) {
_uiState.update { it.copy(message = UiText.Resource(R.string.error_api_key_empty)) } _uiState.update { it.copy(message = UiText.Resource(R.string.error_api_key_empty)) }
return return
@ -978,6 +1047,8 @@ data class CredentialEditUiState(
val triggerApiKey: String = "", val triggerApiKey: String = "",
/** Trae:Cloud-IDE-JWT + 区域 */ /** Trae:Cloud-IDE-JWT + 区域 */
val traeJwt: String = "", val traeJwt: String = "",
/** Trae:浏览器回调地址粘贴导入(http://127.0.0.1:18080/authorize?...) */
val traeCallbackUrl: String = "",
val traeRegion: String = "CN", val traeRegion: String = "CN",
/** Trae 签到设备 ID(官方客户端绑定 did;留空用内置默认) */ /** Trae 签到设备 ID(官方客户端绑定 did;留空用内置默认) */
val traeCheckinDeviceId: String = "", val traeCheckinDeviceId: String = "",

View File

@ -113,16 +113,7 @@ fun WebViewLoginScreen(
} }
}, },
actions = { actions = {
TextButton(onClick = { TextButton(onClick = { viewModel.openExternalBrowser() }) {
val url = uiState.loginUrl
if (url.isNotBlank()) {
runCatching {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
}
}
}) {
Text(stringResource(R.string.action_open_login_entry)) Text(stringResource(R.string.action_open_login_entry))
} }
TextButton(onClick = { TextButton(onClick = {
@ -142,6 +133,18 @@ fun WebViewLoginScreen(
if (pageLoading) { if (pageLoading) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) 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) { key(webViewGeneration) {
AndroidView( AndroidView(
factory = { context -> factory = { context ->

View File

@ -21,6 +21,7 @@ import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.longOrNull import kotlinx.serialization.json.longOrNull
import com.rainy.token.R import com.rainy.token.R
import com.rainy.token.data.debug.DebugLog 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.WebViewSessionSaver
import com.rainy.token.data.repository.CredentialRepository import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.domain.model.Credential import com.rainy.token.domain.model.Credential
@ -149,7 +150,17 @@ class WebViewLoginViewModel @Inject constructor(
// 登录 URL 与签到 claim 用同一个值。不能每次随机(会话绑定不一致会被风控拒), // 登录 URL 与签到 claim 用同一个值。不能每次随机(会话绑定不一致会被风控拒),
// 也不可用 userJwt.ClientID(固定客户端标识,所有用户相同,必被按设备限流)。 // 也不可用 userJwt.ClientID(固定客户端标识,所有用户相同,必被按设备限流)。
val deviceId = traeDeviceId() 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 authRoot = if (region == "INTL") "https://www.trae.ai/authorization" else "https://www.trae.cn/authorization"
val authUrl = buildString { val authUrl = buildString {
append(authRoot).append("?") 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_os_version=1.0&x_app_version=0.1.43&")
append("x_app_type=stable&plugin_version=2.3.62834") append("x_app_type=stable&plugin_version=2.3.62834")
} }
DebugLog.i("TraeLogin", "bindTrae: 授权URL已生成 len=${authUrl.length} deviceId持久化=${deviceId.take(6)}…") DebugLog.i("TraeLogin", "buildTraeAuthUrl: port=$callbackPort len=${authUrl.length} deviceId持久化=${deviceId.take(6)}…")
_uiState.update { it.copy(loginUrl = authUrl) } return authUrl
} }
/** Trae 签到用的持久化设备 ID:首次生成 19 位数字并保存,此后跨登录复用。 */ /** 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),用于更换账号登录。 */ /** 清除 WebView 登录缓存(Cookie/Storage),用于更换账号登录。 */
fun clearWebViewSession() { fun clearWebViewSession() {
runCatching { runCatching {
@ -668,6 +740,10 @@ private const val SUB2_NONCE_KEY = "nonce"
/** OpenCode dashboard URL 中的 workspaceId:`/workspace/{id}/go`。 */ /** OpenCode dashboard URL 中的 workspaceId:`/workspace/{id}/go`。 */
private val OPENCODE_WORKSPACE_REGEX = Regex("""/workspace/([^/?#]+)/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 = """ internal fun buildProbeScript(nonce: String): String = """
(function () { (function () {
try { try {
@ -698,5 +774,7 @@ data class WebViewLoginUiState(
val pendingManualConfirm: Boolean = false, val pendingManualConfirm: Boolean = false,
val error: UiText? = null, val error: UiText? = null,
/** Trae/WorkBuddy 登录实际写入的账号 ID(新建账号模式时用于回填信用编辑页)。 */ /** Trae/WorkBuddy 登录实际写入的账号 ID(新建账号模式时用于回填信用编辑页)。 */
val loginAccountId: String? = null val loginAccountId: String? = null,
/** 已在手机浏览器打开登录页,等待回调/轮询自动带回凭证。 */
val externalBrowserWaiting: Boolean = false
) )

View File

@ -424,6 +424,12 @@
<string name="error_cookie_not_found">未抓到 Cookie,请确认已登录</string> <string name="error_cookie_not_found">未抓到 Cookie,请确认已登录</string>
<string name="error_login_url_missing">未配置登录 URL</string> <string name="error_login_url_missing">未配置登录 URL</string>
<string name="error_login_token_missing">登录成功但未获取到令牌,请复制回调地址里的 userJwt 后手动粘贴</string> <string name="error_login_token_missing">登录成功但未获取到令牌,请复制回调地址里的 userJwt 后手动粘贴</string>
<string name="field_trae_callback_url">Trae 登录回调链接</string>
<string name="field_trae_callback_hint">在浏览器登录完成后,把地址栏整条链接粘到这里(http://127.0.0.1:18080/authorize?...)</string>
<string name="action_parse_callback">解析并填入 JWT</string>
<string name="error_trae_callback_invalid">无法解析这条回调链接,请完整复制地址栏内容</string>
<string name="error_login_callback_server">本地回调服务启动失败,请改用应用内登录</string>
<string name="webview_browser_waiting">已在浏览器打开,登录完成后会自动回到雨晴Token</string>
<string name="error_login_timeout">登录超时,请重试</string> <string name="error_login_timeout">登录超时,请重试</string>
<string name="error_oauth_url_prefix">URL 应以 %1$s 开头</string> <string name="error_oauth_url_prefix">URL 应以 %1$s 开头</string>
<string name="error_oauth_auth_failed">授权失败: %1$s</string> <string name="error_oauth_auth_failed">授权失败: %1$s</string>

View File

@ -424,6 +424,12 @@
<string name="error_cookie_not_found">未抓到 Cookie,請確認已登入</string> <string name="error_cookie_not_found">未抓到 Cookie,請確認已登入</string>
<string name="error_login_url_missing">未配置登入 URL</string> <string name="error_login_url_missing">未配置登入 URL</string>
<string name="error_login_token_missing">登入成功但未取得令牌,請複製回呼位址中的 userJwt 後手動貼上</string> <string name="error_login_token_missing">登入成功但未取得令牌,請複製回呼位址中的 userJwt 後手動貼上</string>
<string name="field_trae_callback_url">Trae 登入回呼連結</string>
<string name="field_trae_callback_hint">在瀏覽器登入完成後,把網址列整條連結貼到這裡(http://127.0.0.1:18080/authorize?...)</string>
<string name="action_parse_callback">解析並填入 JWT</string>
<string name="error_trae_callback_invalid">無法解析這條回呼連結,請完整複製網址列內容</string>
<string name="error_login_callback_server">本機回呼服務啟動失敗,請改用應用內登入</string>
<string name="webview_browser_waiting">已在瀏覽器開啟,登入完成後會自動回到雨晴Token</string>
<string name="error_login_timeout">登入逾時,請重試</string> <string name="error_login_timeout">登入逾時,請重試</string>
<string name="error_oauth_url_prefix">URL 應以 %1$s 開頭</string> <string name="error_oauth_url_prefix">URL 應以 %1$s 開頭</string>
<string name="error_oauth_auth_failed">授權失敗: %1$s</string> <string name="error_oauth_auth_failed">授權失敗: %1$s</string>

View File

@ -427,6 +427,12 @@
<string name="error_cookie_not_found">No cookie captured, please make sure you\'re logged in</string> <string name="error_cookie_not_found">No cookie captured, please make sure you\'re logged in</string>
<string name="error_login_url_missing">No login URL configured</string> <string name="error_login_url_missing">No login URL configured</string>
<string name="error_login_token_missing">Login succeeded but no token was received. Copy userJwt from the callback URL and paste it manually.</string> <string name="error_login_token_missing">Login succeeded but no token was received. Copy userJwt from the callback URL and paste it manually.</string>
<string name="field_trae_callback_url">Trae login callback URL</string>
<string name="field_trae_callback_hint">Paste the whole callback URL after logging in the browser (http://127.0.0.1:18080/authorize?...).</string>
<string name="action_parse_callback">Parse and fill JWT</string>
<string name="error_trae_callback_invalid">Could not parse this callback URL. Please copy the address bar URL completely.</string>
<string name="error_login_callback_server">Unable to start local callback server. Please use in-app login instead.</string>
<string name="webview_browser_waiting">Opened in browser. After login it will return to RainyToken automatically.</string>
<string name="error_login_timeout">Login timed out, please retry</string> <string name="error_login_timeout">Login timed out, please retry</string>
<string name="error_oauth_url_prefix">URL should start with %1$s</string> <string name="error_oauth_url_prefix">URL should start with %1$s</string>
<string name="error_oauth_auth_failed">Authorization failed: %1$s</string> <string name="error_oauth_auth_failed">Authorization failed: %1$s</string>