feat(login): Trae+WorkBuddy 软件内登录

- 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
This commit is contained in:
Liuxinyu176 2026-09-13 14:57:33 +08:00
parent 085a4d722f
commit 0bed6d626a
6 changed files with 272 additions and 58 deletions

View File

@ -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,

View File

@ -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()
}
}
}

View File

@ -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<WebViewLoginUiState> = _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=<JSON>&userInfo=<JSON>&...`
*
* `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<String, String>) {
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<String> = emptySet(),
val loginSucceeded: Boolean = false,
val savedSession: Credential.SessionCredential? = null,
val pendingManualConfirm: Boolean = false,
val error: UiText? = null
)
)

View File

@ -18,6 +18,7 @@
<string name="action_delete">删除</string>
<string name="action_clear">清除</string>
<string name="action_save">保存</string>
<string name="action_login_in_app">软件内登录</string>
<string name="action_jump">跳转</string>
<string name="action_prev_page">◀ 上一页</string>
<string name="action_next_page">下一页 ▶</string>
@ -351,6 +352,7 @@
<string name="error_select_model_first">请先选择模型</string>
<string name="error_cookie_not_found">未抓到 Cookie,请确认已登录</string>
<string name="error_login_url_missing">未配置登录 URL</string>
<string name="error_login_timeout">登录超时,请重试</string>
<string name="error_oauth_url_prefix">URL 应以 %1$s 开头</string>
<string name="error_oauth_auth_failed">授权失败: %1$s</string>
<string name="error_oauth_state_mismatch">State 不匹配,请重试</string>

View File

@ -18,6 +18,7 @@
<string name="action_delete">刪除</string>
<string name="action_clear">清除</string>
<string name="action_save">儲存</string>
<string name="action_login_in_app">軟體內登入</string>
<string name="action_jump">跳轉</string>
<string name="action_prev_page">◀ 上一頁</string>
<string name="action_next_page">下一頁 ▶</string>
@ -351,6 +352,7 @@
<string name="error_select_model_first">請先選擇模型</string>
<string name="error_cookie_not_found">未抓到 Cookie,請確認已登入</string>
<string name="error_login_url_missing">未配置登入 URL</string>
<string name="error_login_timeout">登入逾時,請重試</string>
<string name="error_oauth_url_prefix">URL 應以 %1$s 開頭</string>
<string name="error_oauth_auth_failed">授權失敗: %1$s</string>
<string name="error_oauth_state_mismatch">State 不匹配,請重試</string>

View File

@ -22,6 +22,7 @@
<string name="action_delete">Delete</string>
<string name="action_clear">Clear</string>
<string name="action_save">Save</string>
<string name="action_login_in_app">Sign in within app</string>
<string name="action_jump">Go</string>
<string name="action_prev_page">◀ Prev</string>
<string name="action_next_page">Next ▶</string>
@ -354,6 +355,7 @@
<string name="error_select_model_first">Please select a model first</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_timeout">Login timed out, please retry</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_state_mismatch">State mismatch, please retry</string>