From 0bed6d626a375e7c3f0fdcc53b7f109432189ac8 Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Sun, 13 Sep 2026 14:57:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(login):=20Trae+WorkBuddy=20=E8=BD=AF?= =?UTF-8?q?=E4=BB=B6=E5=86=85=E7=99=BB=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WebViewLoginViewModel: 新增 Trae OAuth 回调解析(onTraeCallback) 和 WorkBuddy 设备授权流(bindWorkBuddy+pollWorkBuddyToken) - WebViewLoginScreen: 添加 shouldOverrideUrlLoading 拦截 Trae 回调 (http://127.0.0.1:18080/authorize), loginUrl 为空时显示加载指示器 - CredentialEditScreen: TraeCredentialForm 和 WorkBuddyCredentialForm 顶部添加'软件内登录'按钮(OutlinedButton) - strings.xml(三语): 新增 action_login_in_app 和 error_login_timeout - 修复: WorkBuddy 网络请求包裹 withContext(Dispatchers.IO) 防止 NetworkOnMainThreadException --- .../token/ui/settings/CredentialEditScreen.kt | 16 + .../token/ui/webview/WebViewLoginScreen.kt | 28 +- .../token/ui/webview/WebViewLoginViewModel.kt | 280 ++++++++++++++---- app/src/main/res/values-b+zh+Hans/strings.xml | 2 + app/src/main/res/values-b+zh+Hant/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + 6 files changed, 272 insertions(+), 58 deletions(-) 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 825d429..07f4267 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 @@ -137,6 +137,7 @@ fun CredentialEditScreen( hasExisting = uiState.hasExisting, onJwtChange = viewModel::updateTraeJwt, onRegionChange = viewModel::updateTraeRegion, + onStartInAppLogin = { onStartWebViewLogin(ServiceType.TRAE) }, onSave = viewModel::saveTraeCredential ) } else if (service == ServiceType.WORKBUDDY) { @@ -146,6 +147,7 @@ fun CredentialEditScreen( hasExisting = uiState.hasExisting, onAccessChange = viewModel::updateWorkBuddyAccess, onRefreshChange = viewModel::updateWorkBuddyRefresh, + onStartInAppLogin = { onStartWebViewLogin(ServiceType.WORKBUDDY) }, onSave = viewModel::saveWorkBuddyCredential ) } else if (service == ServiceType.SUB2API) { @@ -751,6 +753,7 @@ private fun TraeCredentialForm( hasExisting: Boolean, onJwtChange: (String) -> Unit, onRegionChange: (String) -> Unit, + onStartInAppLogin: () -> Unit, onSave: () -> Unit ) { Text(text = stringResource(R.string.credential_title_trae), style = MaterialTheme.typography.titleMedium) @@ -759,6 +762,12 @@ private fun TraeCredentialForm( style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.outline ) + OutlinedButton( + onClick = onStartInAppLogin, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.action_login_in_app)) + } OutlinedTextField( value = jwt, onValueChange = onJwtChange, @@ -806,6 +815,7 @@ private fun WorkBuddyCredentialForm( hasExisting: Boolean, onAccessChange: (String) -> Unit, onRefreshChange: (String) -> Unit, + onStartInAppLogin: () -> Unit, onSave: () -> Unit ) { Text(text = stringResource(R.string.credential_title_workbuddy), style = MaterialTheme.typography.titleMedium) @@ -814,6 +824,12 @@ private fun WorkBuddyCredentialForm( style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.outline ) + OutlinedButton( + onClick = onStartInAppLogin, + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.action_login_in_app)) + } OutlinedTextField( value = access, onValueChange = onAccessChange, 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 7046b88..42da0ae 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 @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -26,6 +27,7 @@ import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.viewinterop.AndroidView @@ -115,6 +117,19 @@ fun WebViewLoginScreen( ) webViewClient = object : WebViewClient() { + override fun shouldOverrideUrlLoading( + view: WebView?, + url: String? + ): Boolean { + url ?: return false + // Trae OAuth 回调:拦截 127.0.0.1:18080/authorize,提取 userJwt + if (url.startsWith("http://127.0.0.1:18080/authorize")) { + viewModel.onTraeCallback(url) + return true + } + return false + } + override fun onPageFinished(view: WebView?, url: String?) { url ?: return // 登录成功后面板会写入 localStorage.auth_token,抓到即自动保存 @@ -129,13 +144,12 @@ fun WebViewLoginScreen( modifier = Modifier.fillMaxSize() ) } else { - Column(modifier = Modifier - .fillMaxSize() - .padding(16.dp)) { - Text( - text = stringResource(R.string.error_login_url_missing), - style = MaterialTheme.typography.bodyLarge - ) + // loginUrl 为空 = ViewModel 正在生成授权地址(Trae/WorkBuddy 需要片刻) + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() } } } 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 2af52fe..ad8f003 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 @@ -2,10 +2,26 @@ package com.rainy.token.ui.webview import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull import com.rainy.token.R import com.rainy.token.data.repository.WebViewSessionSaver +import com.rainy.token.data.repository.CredentialRepository import com.rainy.token.domain.model.Credential import com.rainy.token.domain.service.ServiceConfigProvider import com.rainy.token.domain.service.ServiceType @@ -18,32 +34,210 @@ import kotlinx.coroutines.flow.update import javax.inject.Inject /** - * WebView 登录容器 ViewModel。计划 4.1: - * - 持有目标 URL(来自 ServiceConfig) - * - 提供 [onLoginSuccess] 回调:抓 Cookie → 加密存 SecureStorage - * - 跟踪"用户手动确认登录"状态(N 秒超时未识别成功时显示按钮) + * WebView 登录容器 ViewModel。 + * + * 支持三种服务的软件内登录: + * - **Trae**:打开 trae.cn 授权页,用户登录后拦截 `http://127.0.0.1:18080/authorize` + * 回调,解析 `userJwt` 中的 JWT 并保存为 [Credential.TraeCredential]。 + * - **WorkBuddy**:调用 copilot.tencent.com 设备授权 API 获取 QR 码 URL, + * 在 WebView 中展示二维码,后台轮询直到用户扫码授权,保存为 + * [Credential.WorkBuddyCredential]。 + * - **Sub2API**:打开自托管面板登录页,注入 JS 读取 localStorage.auth_token。 */ @HiltViewModel class WebViewLoginViewModel @Inject constructor( private val sessionSaver: WebViewSessionSaver, - private val credentialRepository: com.rainy.token.data.repository.CredentialRepository + private val credentialRepository: CredentialRepository, + private val okHttpClient: OkHttpClient ) : ViewModel() { private val _uiState = MutableStateFlow(WebViewLoginUiState()) val uiState: StateFlow = _uiState.asStateFlow() - /** - * @param configuredBaseUrl 用户已保存的自托管实例基址(Sub2API 用)。 - * 用于把「可信 host」限定为用户自己配置的域名,登录过程中跳转到的第三方页面 - * 无法借此写入凭据。 - */ + /** WorkBuddy 轮询协程,bind 新服务时取消。 */ + private var workBuddyPollingJob: Job? = null + fun bind(service: ServiceType, configuredBaseUrl: String? = null) { + workBuddyPollingJob?.cancel() + workBuddyPollingJob = null + val config = ServiceConfigProvider.get(service) + _uiState.update { + it.copy( + service = service, + loginUrl = "", + loginSucceeded = false, + error = null, + pendingManualConfirm = false + ) + } + + when (service) { + ServiceType.TRAE -> bindTrae() + ServiceType.WORKBUDDY -> bindWorkBuddy() + else -> bindSub2Api(service, config, configuredBaseUrl) + } + } + + // ─── Trae ────────────────────────────────────────────────────────────── + + private fun bindTrae() { + val traceId = randomHex(16) + val machineId = randomHex(32) + val deviceId = randomDigits(19) + val callbackUrl = "http://127.0.0.1:18080/authorize" + val authUrl = buildString { + append("https://www.trae.cn/authorization?") + append("login_version=1&auth_from=solo&login_channel=native_ide&") + append("auth_type=local&client_id=en1oxy7wnw8j9n&redirect=0&") + append("login_trace_id=$traceId&") + append("auth_callback_url=").append(java.net.URLEncoder.encode(callbackUrl, "UTF-8")).append("&") + append("machine_id=$machineId&") + append("device_id=$deviceId&") + append("x_device_id=$deviceId&") + append("x_machine_id=$machineId&") + append("x_device_brand=PC&x_device_type=PC&") + append("x_os_version=1.0&x_app_version=0.1.43&") + append("x_app_type=stable&plugin_version=2.3.62834") + } + _uiState.update { it.copy(loginUrl = authUrl) } + } + + /** + * Trae 授权回调拦截。URL 形如: + * `http://127.0.0.1:18080/authorize?userJwt=&userInfo=&...` + * + * `userJwt` 是 JSON 字符串,含 `{ Token, RefreshToken, ClientID, TokenExpireAt, ... }`。 + * 提取 `Token`(即 Cloud-IDE-JWT)保存为 [Credential.TraeCredential]。 + */ + fun onTraeCallback(url: String) { + val service = _uiState.value.service ?: return + if (service != ServiceType.TRAE) return + if (_uiState.value.loginSucceeded) return + + val parsed = url.toHttpUrlOrNull() ?: return + val userJwtRaw = parsed.queryParameter("userJwt") ?: return + val userJwt = try { + Json.parseToJsonElement(userJwtRaw).jsonObject + } catch (_: Exception) { + return + } + val token = userJwt["Token"]?.jsonPrimitive?.contentOrNull + if (token.isNullOrBlank()) return + + viewModelScope.launch { + val credential = Credential.TraeCredential( + service = ServiceType.TRAE, + jwt = token, + region = "CN" + ) + credentialRepository.save(credential) + _uiState.update { + it.copy(loginSucceeded = true, pendingManualConfirm = false, error = null) + } + } + } + + // ─── WorkBuddy ───────────────────────────────────────────────────────── + + private fun bindWorkBuddy() { + viewModelScope.launch { + try { + val request = Request.Builder() + .url("https://copilot.tencent.com/v2/plugin/auth/state?platform=CLI") + .addHeader("User-Agent", "CLI/2.63.2 CodeBuddy/2.63.2") + .addHeader("Content-Type", "application/json") + .post("{}".toRequestBody("application/json".toMediaTypeOrNull())) + .build() + val (body, ok) = withContext(Dispatchers.IO) { + val resp = okHttpClient.newCall(request).execute() + resp.body?.string().orEmpty() to resp.isSuccessful + } + if (!ok || body.isBlank()) { + _uiState.update { + it.copy(error = UiText.Resource(R.string.error_network_generic)) + } + return@launch + } + val json = Json.parseToJsonElement(body).jsonObject + val data = json["data"]?.jsonObject + val state = data?.get("state")?.jsonPrimitive?.contentOrNull + val authURL = data?.get("authURL")?.jsonPrimitive?.contentOrNull + if (state.isNullOrBlank() || authURL.isNullOrBlank()) { + _uiState.update { + it.copy(error = UiText.Resource(R.string.error_network_generic)) + } + return@launch + } + _uiState.update { it.copy(loginUrl = authURL) } + workBuddyPollingJob = viewModelScope.launch { pollWorkBuddyToken(state) } + } catch (_: Exception) { + _uiState.update { + it.copy(error = UiText.Resource(R.string.error_network_generic)) + } + } + } + } + + /** 每 2 秒轮询一次 token 接口,120 秒超时。 */ + private suspend fun pollWorkBuddyToken(state: String) { + val timeoutMs = 120_000L + val startTime = System.currentTimeMillis() + while (System.currentTimeMillis() - startTime < timeoutMs) { + if (_uiState.value.loginSucceeded) return + try { + val request = Request.Builder() + .url("https://copilot.tencent.com/v2/plugin/auth/token?state=$state") + .addHeader("User-Agent", "CLI/2.63.2 CodeBuddy/2.63.2") + .addHeader("Content-Type", "application/json") + .post("{\"state\":\"$state\"}".toRequestBody("application/json".toMediaTypeOrNull())) + .build() + val body = withContext(Dispatchers.IO) { + val resp = okHttpClient.newCall(request).execute() + val s = resp.body?.string().orEmpty() + resp.close() + s + } + val json = Json.parseToJsonElement(body).jsonObject + val code = json["code"]?.jsonPrimitive?.intOrNull ?: -1 + if (code == 0) { + val data = json["data"]?.jsonObject + val accessToken = data?.get("access_token")?.jsonPrimitive?.contentOrNull + val refreshToken = data?.get("refresh_token")?.jsonPrimitive?.contentOrNull + val expiresIn = data?.get("expires_in")?.jsonPrimitive?.longOrNull ?: 7200L + if (!accessToken.isNullOrBlank()) { + val expiresAt = System.currentTimeMillis() + expiresIn * 1000 + val credential = Credential.WorkBuddyCredential( + service = ServiceType.WORKBUDDY, + accessToken = accessToken, + refreshToken = refreshToken ?: "", + expiresAt = expiresAt + ) + credentialRepository.save(credential) + _uiState.update { + it.copy(loginSucceeded = true, pendingManualConfirm = false, error = null) + } + return + } + } + } catch (_: Exception) { + // 轮询网络错误忽略,下次重试 + } + delay(2000) + } + _uiState.update { it.copy(error = UiText.Resource(R.string.error_login_timeout)) } + } + + // ─── Sub2API (existing) ──────────────────────────────────────────────── + + private fun bindSub2Api( + service: ServiceType, + config: com.rainy.token.domain.service.ServiceConfig, + configuredBaseUrl: String? + ) { _uiState.update { it.copy(service = service, loginUrl = config.loginUrl, trustedHosts = emptySet()) } - // Sub2API 是自托管实例:loginUrl 为空,登录入口与可信 host 都来自用户已保存的 baseUrl。 - // 这里自己从凭据仓库取,避免每个导航调用点都要传一遍(漏传会直接导致登录页打不开)。 viewModelScope.launch { val base = configuredBaseUrl ?: (credentialRepository.get(service) as? Credential.Sub2ApiCredential)?.baseUrl @@ -60,12 +254,8 @@ class WebViewLoginViewModel @Inject constructor( } } - /** - * 成功 URL 模式匹配(计划 4.1:精确匹配 + fallback)。 - * - * 默认启发:URL 跳转到非登录域 + 路径不包含 /auth/login/signin 等关键词, - * 即认为登录完成。具体规则各服务可在 [ServiceConfigProvider] 扩展。 - */ + // ─── Page lifecycle ──────────────────────────────────────────────────── + fun onPageFinished(url: String) { val current = _uiState.value if (current.loginSucceeded) return @@ -85,25 +275,14 @@ class WebViewLoginViewModel @Inject constructor( _uiState.update { it.copy(pendingManualConfirm = false) } } - /** - * 面板登录成功后由 WebView 注入 JS 回调,把 localStorage 里的凭据回传。 - * - * Sub2API 面板把登录态放在 localStorage.auth_token(JWT);抓到即代表登录成功, - * 无需用户在设置页手工粘贴 token。 - */ fun onLocalStorageProbed(values: Map) { val service = _uiState.value.service ?: return if (_uiState.value.loginSucceeded) return - // nonce 校验:拒绝非本次注入脚本发出的调用(防第三方页面伪造) if (!isValidProbe(values[SUB2_NONCE_KEY])) return - // 只有自托管面板走 localStorage 抓取;其余服务的登录态在 Cookie 里, - // 由 onPageFinished(真实带 path 的 URL) 负责,避免用 origin 查 Cookie 查不到而误报。 if (service != ServiceType.SUB2API) return val token = values[SUB2_AUTH_TOKEN_KEY].orEmpty() if (token.isBlank()) return val origin = values[SUB2_ORIGIN_KEY].orEmpty() - // 防凭据投毒:登录容器会加载第三方页面(OAuth / CDN),它们同样能调到 JS 桥。 - // 只有 origin 与用户配置的面板基址同源时才接受,避免被伪造的 auth_token 覆盖凭据。 if (!isTrustedOrigin(origin)) return viewModelScope.launch { val saved = sessionSaver.saveSub2ApiPanelSession( @@ -120,10 +299,6 @@ class WebViewLoginViewModel @Inject constructor( } } - /** - * origin 是否可信:必须与用户填写/已知的面板基址同 host。 - * 登录页会跳转到第三方域(OAuth、CDN),那些页面注入的脚本不应改写本地凭据。 - */ private fun isTrustedOrigin(origin: String): Boolean { val probeHost = origin.toHttpUrlOrNull()?.host ?: return false val allowed = _uiState.value.trustedHosts @@ -131,17 +306,8 @@ class WebViewLoginViewModel @Inject constructor( return allowed.any { it.equals(probeHost, ignoreCase = true) } } - /** - * 每次会话的随机串,随探测脚本一起注入;回传时必须带上它才被接受。 - * 登录容器会加载第三方页面(OAuth / CDN),仅靠「origin 同源校验」不足以防止 - * 它们直接调用 JS 桥伪造 payload,nonce 让伪造者无法构造合法调用。 - */ private val probeNonce: String = java.util.UUID.randomUUID().toString().replace("-", "") - - /** 探测脚本(注入 nonce,回传时会带回来校验)。 */ val probeScript: String get() = buildProbeScript(probeNonce) - - /** 校验回传是否来自本次注入的脚本。 */ fun isValidProbe(nonce: String?): Boolean = nonce != null && nonce.isNotEmpty() && nonce == probeNonce @@ -170,24 +336,37 @@ class WebViewLoginViewModel @Inject constructor( private fun looksLikeLoggedInPage(url: String): Boolean { val u = url.lowercase() - // 简单启发:URL 不再包含登录路径关键字 val loginKeywords = listOf("/auth", "/login", "/signin", "/oauth") return loginKeywords.none { u.contains(it) } } + + // ─── Helpers ─────────────────────────────────────────────────────────── + + private fun randomHex(length: Int): String { + val bytes = ByteArray(length / 2) + java.security.SecureRandom().nextBytes(bytes) + return bytes.joinToString("") { "%02x".format(it) } + } + + private fun randomDigits(length: Int): String { + val sb = StringBuilder(length) + val rnd = java.security.SecureRandom() + repeat(length) { sb.append(rnd.nextInt(10)) } + return sb.toString() + } + + override fun onCleared() { + workBuddyPollingJob?.cancel() + super.onCleared() + } } -/** 面板 localStorage 中承载登录态的键(与网页端一致)。 */ private const val SUB2_AUTH_TOKEN_KEY = "auth_token" private const val SUB2_ORIGIN_KEY = "origin" private const val SUB2_ICON_KEY = "site_icon" private const val SUB2_EMAIL_KEY = "email" private const val SUB2_NONCE_KEY = "nonce" -/** - * 在页面里读取面板登录态并回传。 - * 通过 window.AndroidBridge(由 WebView 注入)回调,避免 evaluateJavascript 的返回值在 - * onPageFinished 时机不可靠的问题。 - */ internal fun buildProbeScript(nonce: String): String = """ (function () { try { @@ -212,10 +391,9 @@ internal fun buildProbeScript(nonce: String): String = """ data class WebViewLoginUiState( val service: ServiceType? = null, val loginUrl: String = "", - /** 允许通过 JS 桥写入凭据的 host 白名单(来自用户配置的面板基址 / 登录 URL)。 */ val trustedHosts: Set = emptySet(), val loginSucceeded: Boolean = false, val savedSession: Credential.SessionCredential? = null, val pendingManualConfirm: Boolean = false, val error: UiText? = null -) \ No newline at end of file +) 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 cc1353e..ff60201 100644 --- a/app/src/main/res/values-b+zh+Hans/strings.xml +++ b/app/src/main/res/values-b+zh+Hans/strings.xml @@ -18,6 +18,7 @@ 删除 清除 保存 + 软件内登录 跳转 ◀ 上一页 下一页 ▶ @@ -351,6 +352,7 @@ 请先选择模型 未抓到 Cookie,请确认已登录 未配置登录 URL + 登录超时,请重试 URL 应以 %1$s 开头 授权失败: %1$s State 不匹配,请重试 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 c36fa5b..b73d40e 100644 --- a/app/src/main/res/values-b+zh+Hant/strings.xml +++ b/app/src/main/res/values-b+zh+Hant/strings.xml @@ -18,6 +18,7 @@ 刪除 清除 儲存 + 軟體內登入 跳轉 ◀ 上一頁 下一頁 ▶ @@ -351,6 +352,7 @@ 請先選擇模型 未抓到 Cookie,請確認已登入 未配置登入 URL + 登入逾時,請重試 URL 應以 %1$s 開頭 授權失敗: %1$s State 不匹配,請重試 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5966bb2..7a7d006 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -22,6 +22,7 @@ Delete Clear Save + Sign in within app Go ◀ Prev Next ▶ @@ -354,6 +355,7 @@ Please select a model first No cookie captured, please make sure you\'re logged in No login URL configured + Login timed out, please retry URL should start with %1$s Authorization failed: %1$s State mismatch, please retry