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:
parent
085a4d722f
commit
0bed6d626a
@ -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,
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,7 +391,6 @@ 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,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user