fix(login): INT L 登录走对应域名 + 可清除 WebView 登录态

- Trae INTL 登录改打开 trae.ai 授权页,不再误开 trae.cn
- WorkBuddy auth state/token 按区域切换 copilot.tencent.com / workbuddy.ai
- 登录页新增「清除登录」按钮:清 Cookie/WebStorage 后重开,避免沿用上一个号码的登录态
- 新建账号表单保持留空(确认逻辑已存在),登录结果回填新账号
- 版本升至 1.7.9
This commit is contained in:
Liuxinyu176 2026-10-09 15:28:00 +08:00
parent e914633797
commit d6e4d34a42
10 changed files with 97 additions and 28 deletions

View File

@ -16,8 +16,8 @@ android {
applicationId = "com.rainy.token"
minSdk = 31
targetSdk = 35
versionCode = 28
versionName = "1.7.8"
versionCode = 29
versionName = "1.7.9"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {

View File

@ -90,13 +90,16 @@ object Routes {
}
const val ACCOUNTS = "accounts/{type}"
fun accounts(type: ServiceType) = "accounts/${type.name}"
const val WEBVIEW_LOGIN = "webview_login/{type}?account={account}"
fun webviewLogin(type: ServiceType, accountId: String? = null): String =
if (accountId.isNullOrBlank()) {
"webview_login/${type.name}"
} else {
"webview_login/${type.name}?account=${Uri.encode(accountId)}"
const val WEBVIEW_LOGIN = "webview_login/{type}?account={account}&region={region}"
fun webviewLogin(type: ServiceType, accountId: String? = null, region: String? = null): String = buildString {
append("webview_login/").append(type.name)
if (!accountId.isNullOrBlank()) {
append("?account=").append(Uri.encode(accountId))
}
if (!region.isNullOrBlank()) {
append(if (accountId.isNullOrBlank()) "?" else "&").append("region=").append(Uri.encode(region))
}
}
const val WEBVIEW_PAGE = "webview_page?url={url}&title={title}"
fun webviewPage(url: String, title: String) =
"webview_page?url=${Uri.encode(url)}&title=${Uri.encode(title)}"
@ -356,7 +359,7 @@ private fun CompactNavHost() {
service = type,
accountId = accountId,
onBack = guardedPop,
onStartWebViewLogin = { svc, acc -> navController.navigate(Routes.webviewLogin(svc, acc)) },
onStartWebViewLogin = { svc, acc, region -> navController.navigate(Routes.webviewLogin(svc, acc, region)) },
onWebViewLoginSuccess = { },
onStartCodexOAuth = { navController.navigate(Routes.CODEX_OAUTH) }
)
@ -369,14 +372,21 @@ private fun CompactNavHost() {
type = NavType.StringType
nullable = true
defaultValue = null
},
navArgument("region") {
type = NavType.StringType
nullable = true
defaultValue = null
}
)
) { backStackEntry ->
val type = parseServiceType(backStackEntry.arguments?.getString("type"))
val accountId = backStackEntry.arguments?.getString("account")
val region = backStackEntry.arguments?.getString("region")
WebViewLoginScreen(
service = type,
accountId = accountId,
region = region,
onBack = guardedPop,
onLoginSucceeded = { _, effectiveId ->
// 把实际写入的账号 ID 回传给上一层(凭据编辑页),新建账号模式用于回填
@ -417,7 +427,7 @@ private fun CompactNavHost() {
}
},
onConfigureCredential = { svc -> navController.navigate(credentialEntry(svc)) },
onStartWebViewLogin = { svc, acc -> navController.navigate(Routes.webviewLogin(svc, acc)) },
onStartWebViewLogin = { svc, acc, region -> navController.navigate(Routes.webviewLogin(svc, acc, region)) },
onOpenRecharge = { svc ->
ServiceConfigProvider.get(svc).externalUrl?.let { url ->
navController.navigate(Routes.webviewPage(url, rechargeLabel))
@ -543,7 +553,7 @@ private fun ExpandedDetailPane(
onBack = onClose,
onSwitchAccount = onSwitchAccount,
onConfigureCredential = { onClose() },
onStartWebViewLogin = { _, _ -> },
onStartWebViewLogin = { _, _, _ -> },
onOpenRecharge = { svc ->
ServiceConfigProvider.get(svc).externalUrl?.let { url ->
navController.navigate(Routes.webviewPage(url, rechargeLabel))
@ -692,8 +702,8 @@ private fun ExpandedDetailPane(
service = type,
accountId = accountId,
onBack = { apiNavController.popBackStack() },
onStartWebViewLogin = { svc, acc ->
apiNavController.navigate(Routes.webviewLogin(svc, acc))
onStartWebViewLogin = { svc, acc, region ->
apiNavController.navigate(Routes.webviewLogin(svc, acc, region))
},
onWebViewLoginSuccess = { },
onStartCodexOAuth = { apiNavController.navigate(Routes.CODEX_OAUTH) }
@ -707,14 +717,21 @@ private fun ExpandedDetailPane(
type = NavType.StringType
nullable = true
defaultValue = null
},
navArgument("region") {
type = NavType.StringType
nullable = true
defaultValue = null
}
)
) { entry ->
val type = parseServiceType(entry.arguments?.getString("type"))
val accountId = entry.arguments?.getString("account")
val region = entry.arguments?.getString("region")
WebViewLoginScreen(
service = type,
accountId = accountId,
region = region,
onBack = { apiNavController.popBackStack() },
onLoginSucceeded = { _, effectiveId ->
if (!effectiveId.isNullOrBlank()) {
@ -817,8 +834,8 @@ private fun ExpandedDetailPane(
service = type,
accountId = accountId,
onBack = { settingsNavController.popBackStack() },
onStartWebViewLogin = { svc, acc ->
settingsNavController.navigate(Routes.webviewLogin(svc, acc))
onStartWebViewLogin = { svc, acc, region ->
settingsNavController.navigate(Routes.webviewLogin(svc, acc, region))
},
onWebViewLoginSuccess = { },
onStartCodexOAuth = { settingsNavController.navigate(Routes.CODEX_OAUTH) }
@ -832,14 +849,21 @@ private fun ExpandedDetailPane(
type = NavType.StringType
nullable = true
defaultValue = null
},
navArgument("region") {
type = NavType.StringType
nullable = true
defaultValue = null
}
)
) { entry ->
val type = parseServiceType(entry.arguments?.getString("type"))
val accountId = entry.arguments?.getString("account")
val region = entry.arguments?.getString("region")
WebViewLoginScreen(
service = type,
accountId = accountId,
region = region,
onBack = { settingsNavController.popBackStack() },
onLoginSucceeded = { _, effectiveId ->
if (!effectiveId.isNullOrBlank()) {

View File

@ -114,7 +114,7 @@ fun ServiceDetailScreen(
onBack: () -> Unit,
onSwitchAccount: (ServiceType, String?) -> Unit = { _, _ -> },
onConfigureCredential: (ServiceType) -> Unit,
onStartWebViewLogin: (ServiceType, String?) -> Unit,
onStartWebViewLogin: (ServiceType, String?, String?) -> Unit,
onOpenRecharge: (ServiceType) -> Unit = { },
viewModel: ServiceDetailViewModel = hiltViewModel()
) {
@ -315,7 +315,7 @@ fun ServiceDetailScreen(
modelsLoading = modelsLoading,
onRefresh = { viewModel.refresh() },
onConfigureCredential = { onConfigureCredential(service) },
onStartWebViewLogin = { onStartWebViewLogin(service, accountId) },
onStartWebViewLogin = { onStartWebViewLogin(service, accountId, uiState.region) },
onOpenRecharge = config.externalUrl?.let { { onOpenRecharge(service) } },
onTriggerUsage = { viewModel.triggerUsage() },
onSelectModel = { viewModel.selectModel(it) },

View File

@ -454,6 +454,13 @@ class ServiceDetailViewModel @Inject constructor(
if (serviceGen != serviceGeneration || _serviceType.value != type) return@launch
currentCredentialFingerprint = newFingerprint
val currentCredential = credentialRepository.get(type, accountId)
val currentRegion = when (currentCredential) {
is Credential.TraeCredential -> currentCredential.region.uppercase()
is Credential.WorkBuddyCredential -> currentCredential.region.uppercase()
else -> null
}
_uiState.update { it.copy(region = currentRegion) }
when (change) {
CredentialChange.NONE_TO_NONE, CredentialChange.UNCHANGED -> {
@ -1008,7 +1015,9 @@ sealed class State {
data class ServiceDetailUiState(
val state: State = State.Loading,
val hasCredential: Boolean = false,
val cached: CachedBalance? = null
val cached: CachedBalance? = null,
/** 当前账号区域(Trae/WorkBuddy):CN / INTL;其他服务为 null。 */
val region: String? = null
)
sealed class TriggerState {

View File

@ -82,7 +82,7 @@ fun CredentialEditScreen(
service: ServiceType,
accountId: String? = null,
onBack: () -> Unit,
onStartWebViewLogin: (ServiceType, String?) -> Unit,
onStartWebViewLogin: (ServiceType, String?, String?) -> Unit,
onWebViewLoginSuccess: (ServiceType) -> Unit,
onStartCodexOAuth: () -> Unit = {},
viewModel: CredentialEditViewModel = hiltViewModel()
@ -159,7 +159,7 @@ fun CredentialEditScreen(
onJwtChange = viewModel::updateTraeJwt,
onRegionChange = viewModel::updateTraeRegion,
onCheckinDeviceIdChange = viewModel::updateTraeCheckinDeviceId,
onStartInAppLogin = { onStartWebViewLogin(ServiceType.TRAE, accountId) },
onStartInAppLogin = { onStartWebViewLogin(ServiceType.TRAE, accountId, uiState.traeRegion) },
onSave = viewModel::saveTraeCredential
)
} else if (service == ServiceType.WORKBUDDY) {
@ -171,7 +171,7 @@ fun CredentialEditScreen(
onRefreshChange = viewModel::updateWorkBuddyRefresh,
region = uiState.workBuddyRegion,
onRegionChange = viewModel::updateWorkBuddyRegion,
onStartInAppLogin = { onStartWebViewLogin(ServiceType.WORKBUDDY, accountId) },
onStartInAppLogin = { onStartWebViewLogin(ServiceType.WORKBUDDY, accountId, uiState.workBuddyRegion) },
onSave = viewModel::saveWorkBuddyCredential
)
} else if (service == ServiceType.SUB2API) {
@ -269,7 +269,7 @@ fun CredentialEditScreen(
onImportFromClipboard = { viewModel.importFromClipboard(context) },
onCopyLoginUrl = { copyToClipboard(context, uiState.loginUrl) },
onOpenLoginUrl = { openInBrowser(context, uiState.loginUrl) },
onStartInAppLogin = { onStartWebViewLogin(ServiceType.OPENCODE_GO, accountId) },
onStartInAppLogin = { onStartWebViewLogin(ServiceType.OPENCODE_GO, accountId, null) },
onShowHelp = { showGoHelp = true },
onApiKeyChange = viewModel::updateTriggerApiKey,
onSaveApiKey = { viewModel.saveTriggerApiKey() }

View File

@ -29,7 +29,10 @@ 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.runtime.mutableStateOf
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
@ -54,6 +57,7 @@ import com.rainy.token.ui.components.asString
fun WebViewLoginScreen(
service: ServiceType,
accountId: String? = null,
region: String? = null,
onBack: () -> Unit,
onLoginSucceeded: (ServiceType, String?) -> Unit,
/** 用户已保存的面板基址(Sub2API 的 loginUrl 为空,靠它提供登录入口与可信 host)。 */
@ -62,10 +66,11 @@ fun WebViewLoginScreen(
) {
// configuredBaseUrl 可选:不传时 ViewModel 自行从凭据仓库读取(Sub2API 自托管实例)
LaunchedEffect(service, configuredBaseUrl, accountId) {
viewModel.bind(service, configuredBaseUrl, accountId)
viewModel.bind(service, configuredBaseUrl, accountId, region)
}
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
var webViewGeneration by remember { mutableStateOf(0) }
// 登录成功自动回调(携带实际写入的账号 ID,供新建账号模式回填)
LaunchedEffect(uiState.loginSucceeded) {
@ -97,6 +102,14 @@ fun WebViewLoginScreen(
IconButton(onClick = onBack) {
Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
}
},
actions = {
TextButton(onClick = {
viewModel.clearWebViewSession()
webViewGeneration++
}) {
Text(stringResource(R.string.webview_clear_login))
}
}
)
}
@ -105,6 +118,7 @@ fun WebViewLoginScreen(
.fillMaxSize()
.padding(innerPadding)) {
if (uiState.loginUrl.isNotEmpty()) {
key(webViewGeneration) {
AndroidView(
factory = { context ->
WebView(context).apply {
@ -180,6 +194,7 @@ fun WebViewLoginScreen(
},
modifier = Modifier.fillMaxSize()
)
}
} else {
// loginUrl 为空 = ViewModel 正在生成授权地址(Trae/WorkBuddy 需要片刻)
Box(

View File

@ -57,14 +57,19 @@ class WebViewLoginViewModel @Inject constructor(
/** WorkBuddy 轮询协程,bind 新服务时取消。 */
private var workBuddyPollingJob: Job? = null
/** WorkBuddy 当前基址(决定 auth state/token 用 CN 还是 INTL)。 */
private var workBuddyBase = "https://copilot.tencent.com"
/** 目标账号(多账号服务);null = 新建账号。 */
private var accountId: String? = null
/** 当前登录目标区域(Trae/WorkBuddy);null = 其他服务。 */
private var region: String? = null
fun bind(service: ServiceType, configuredBaseUrl: String? = null, accountId: String? = null) {
fun bind(service: ServiceType, configuredBaseUrl: String? = null, accountId: String? = null, region: String? = null) {
workBuddyPollingJob?.cancel()
workBuddyPollingJob = null
this.accountId = accountId?.takeIf { it.isNotBlank() }
this.region = region?.takeIf { it.isNotBlank() }?.uppercase()
val config = ServiceConfigProvider.get(service)
_uiState.update {
@ -134,8 +139,9 @@ class WebViewLoginViewModel @Inject constructor(
// 也不可用 userJwt.ClientID(固定客户端标识,所有用户相同,必被按设备限流)。
val deviceId = traeDeviceId()
val callbackUrl = "http://127.0.0.1:18080/authorize"
val authRoot = if (region == "INTL") "https://www.trae.ai/authorization" else "https://www.trae.cn/authorization"
val authUrl = buildString {
append("https://www.trae.cn/authorization?")
append(authRoot).append("?")
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&")
@ -221,7 +227,7 @@ class WebViewLoginViewModel @Inject constructor(
val credential = Credential.TraeCredential(
service = ServiceType.TRAE,
jwt = token,
region = "CN",
region = if (region == "INTL") "INTL" else "CN",
deviceId = deviceId,
userId = userId?.takeIf { it.isNotBlank() },
lastVerifiedAt = System.currentTimeMillis()
@ -239,8 +245,9 @@ class WebViewLoginViewModel @Inject constructor(
private fun bindWorkBuddy() {
viewModelScope.launch {
try {
workBuddyBase = if (region == "INTL") "https://www.workbuddy.ai" else "https://copilot.tencent.com"
val request = Request.Builder()
.url("https://copilot.tencent.com/v2/plugin/auth/state?platform=CLI")
.url(workBuddyBase + "/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()))
@ -285,7 +292,7 @@ class WebViewLoginViewModel @Inject constructor(
if (_uiState.value.loginSucceeded) return
try {
val request = Request.Builder()
.url("https://copilot.tencent.com/v2/plugin/auth/token?state=$state")
.url(workBuddyBase + "/v2/plugin/auth/token?state=$state")
.addHeader("User-Agent", "CLI/2.63.2 CodeBuddy/2.63.2")
.get()
.build()
@ -312,6 +319,7 @@ class WebViewLoginViewModel @Inject constructor(
service = ServiceType.WORKBUDDY,
accessToken = accessToken,
refreshToken = refreshToken ?: "",
region = if (region == "INTL") "INTL" else "CN",
expiresAt = expiresAt,
lastVerifiedAt = System.currentTimeMillis()
)
@ -361,6 +369,16 @@ class WebViewLoginViewModel @Inject constructor(
}
}
/** 清除 WebView 登录缓存(Cookie/Storage),用于更换账号登录。 */
fun clearWebViewSession() {
runCatching {
val cm = android.webkit.CookieManager.getInstance()
cm.removeAllCookies(null)
cm.removeSessionCookies(null)
android.webkit.WebStorage.getInstance().deleteAllData()
}
}
// ─── Page lifecycle ────────────────────────────────────────────────────
fun onPageFinished(url: String) {

View File

@ -447,6 +447,7 @@
<string name="error_token_refresh_generic">token 刷新失败,请重新登录或重新导入 auth.json</string>
<!-- ══════════ WebView 登录 ══════════ -->
<string name="webview_clear_login">清除登录</string>
<string name="title_webview_login">登录 %1$s</string>
<string name="dialog_login_done_title">完成登录了吗?</string>
<string name="dialog_login_done_body">如果你已经在页面上完成登录但页面没有跳转,可点击下方按钮让我抓取 Cookie 并保存登录态。</string>

View File

@ -447,6 +447,7 @@
<string name="error_token_refresh_generic">token 重新整理失敗,請重新登入或重新匯入 auth.json</string>
<!-- ══════════ WebView 登录 ══════════ -->
<string name="webview_clear_login">清除登入</string>
<string name="title_webview_login">登入 %1$s</string>
<string name="dialog_login_done_title">完成登入了嗎?</string>
<string name="dialog_login_done_body">如果你已經在頁面上完成登入但頁面沒有跳轉,可點選下方按鈕讓我抓取 Cookie 並儲存登入態。</string>

View File

@ -450,6 +450,7 @@
<string name="error_token_refresh_generic">Token refresh failed. Please log in again or import auth.json.</string>
<!-- ══════════ WebView 登录 ══════════ -->
<string name="webview_clear_login">Clear login</string>
<string name="title_webview_login">Sign in to %1$s</string>
<string name="dialog_login_done_title">Done logging in?</string>
<string name="dialog_login_done_body">If you finished signing in but the page didn\'t redirect, tap the button below to grab the cookie and save your session.</string>