diff --git a/app/src/main/java/com/rainy/token/data/repository/WebViewSessionSaver.kt b/app/src/main/java/com/rainy/token/data/repository/WebViewSessionSaver.kt index baea919..efcdc90 100644 --- a/app/src/main/java/com/rainy/token/data/repository/WebViewSessionSaver.kt +++ b/app/src/main/java/com/rainy/token/data/repository/WebViewSessionSaver.kt @@ -25,7 +25,7 @@ class WebViewSessionSaver @Inject constructor( /** * 提取并保存 Cookie 列表。返回保存后的凭据。 */ - fun saveFromCookieManager( + suspend fun saveFromCookieManager( service: ServiceType, url: String, token: String? = null, @@ -42,10 +42,8 @@ class WebViewSessionSaver @Inject constructor( expiresAt = expiresAt, lastVerifiedAt = System.currentTimeMillis() ) - // 用 runBlocking 写凭据 —— 调用方在 Composable 中,可以接受 - kotlinx.coroutines.runBlocking { - credentialRepository.save(session) - } + // 挂起写凭据:调用方在 viewModelScope 中,避免在主线程做 Keystore 加解密 + DataStore 写盘 + credentialRepository.save(session) return session } diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt b/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt index 04cc3d5..8f3d82b 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt @@ -851,17 +851,20 @@ private fun DashboardCard( inkMuted(), maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f, fill = false) + // 占据全部剩余宽度,把右侧按钮顶到卡片最右边。 + // 注意不能再用一个 Spacer(weight) 平分剩余空间——那样按钮只会到中间。 + modifier = Modifier.weight(1f) ) - // 弹性间距:窄屏/英文长文案时优先压缩左侧文案,按钮保持右对齐不被折行 - Spacer(modifier = Modifier.weight(1f)) if (card.service == ServiceType.SUB2API && card.credentialState != CredentialStatus.State.NOT_CONFIGURED && onOpenPanelManagement != null ) { + // 清零内边距让文字贴到卡片右内缘。 + // 注意:Material3 的最小尺寸挂在 TextButton 内层节点上,外层 modifier 传 + // defaultMinSize(0) 压不下去(实测为 no-op),这里不再写无用约束。 TextButton( onClick = onOpenPanelManagement, - contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp) + contentPadding = PaddingValues(0.dp) ) { Text( stringResource(R.string.sub2_panel_manage_title), @@ -875,7 +878,9 @@ private fun DashboardCard( imageVector = Icons.Filled.KeyboardArrowRight, contentDescription = null, tint = StrawberryPink, - modifier = Modifier.size(16.dp) + // 图标盒即箭头可视范围;不用 offset 微调(offset 只改放置不改测量, + // 会让右移的那几 dp 落在按钮命中区之外造成误触)。 + modifier = Modifier.size(14.dp) ) } } diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/ServiceBalanceCards.kt b/app/src/main/java/com/rainy/token/ui/dashboard/ServiceBalanceCards.kt index 887036c..4f87081 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/ServiceBalanceCards.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/ServiceBalanceCards.kt @@ -51,7 +51,7 @@ import java.util.Locale // ── Service-specific balance Composables ── @Composable -internal fun BalanceMainArea(card: DashboardCardUi, balanceTrailing: (@Composable () -> Unit)? = null) { +internal fun BalanceMainArea(card: DashboardCardUi) { val balance = card.displayBalance when { card.credentialState == CredentialStatus.State.NOT_CONFIGURED -> { @@ -99,7 +99,7 @@ internal fun BalanceMainArea(card: DashboardCardUi, balanceTrailing: (@Composabl OllamaUsageWindows(balance) } card.service == ServiceType.SUB2API -> { - Sub2ApiBalanceWithUsage(balance, balanceTrailing = balanceTrailing) + Sub2ApiBalanceWithUsage(balance) } else -> { Row(verticalAlignment = Alignment.Bottom) { @@ -647,16 +647,9 @@ internal fun usageUpdatedAtText(updatedAt: Long): String { * 磁贴等宽 + 两行固定结构,保证 360dp 窄屏不换行、不同周期数值长度不一会错位。 */ @Composable -internal fun Sub2ApiBalanceWithUsage( - balance: ServiceBalance, - balanceTrailing: (@Composable () -> Unit)? = null -) { +internal fun Sub2ApiBalanceWithUsage(balance: ServiceBalance) { Column(modifier = Modifier.fillMaxWidth()) { - // 余额与右侧入口(面板管理)同处一行、垂直居中,使按钮与余额数字高度齐平 - Row(verticalAlignment = Alignment.CenterVertically) { - Sub2ApiBalanceHero(balance, modifier = Modifier.weight(1f)) - balanceTrailing?.invoke() - } + Sub2ApiBalanceHero(balance) Spacer(modifier = Modifier.height(10.dp)) Sub2ApiUsageTiles(balance) } 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 6a0b9ab..825d429 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 @@ -1019,16 +1019,39 @@ private fun PanelLoginDialog( } } + // 只接受来自面板本域名的 auth_token: + // 登录容器会跳转到第三方页面(OAuth 回调 / CDN),任何被加载的页面都能改写自己的 + // localStorage,不做来源校验就等于允许它们伪造 token 覆盖用户凭据。 + val expectedHost = remember(baseUrl) { + val withScheme = if (baseUrl.contains("://")) baseUrl else "https://" + baseUrl + okhttp3.HttpUrl.Companion.run { withScheme.toHttpUrlOrNull()?.host } + } + // 轮询 localStorage.auth_token(登录成功后自动捕获并回调关闭) - LaunchedEffect(detected) { + LaunchedEffect(detected, expectedHost) { while (!detected) { kotlinx.coroutines.delay(1500) val web = webViewRef ?: continue + // 回传 "hosttoken",避免 JSON 在 Kotlin 字符串里的多层转义 web.evaluateJavascript( - "(function(){try{var t=window.localStorage.getItem('auth_token');return t?t:''}catch(e){return ''}})()" + "(function(){try{" + + "var t=window.localStorage.getItem('auth_token')||'';" + + "var h=window.location.hostname||'';" + + "return h+'\u0001'+t;" + + "}catch(e){return ''}})()" ) { value -> - val token = value?.trim('"') ?: "" - if (token.length > 20 && !detected) { + if (detected) return@evaluateJavascript + // evaluateJavascript 回传的是 JS 字符串字面量,带引号与转义 + val unquoted = value?.let { v -> + runCatching { org.json.JSONTokener(v).nextValue() as? String }.getOrNull() + } ?: return@evaluateJavascript + val sep = unquoted.indexOf('') + if (sep < 0) return@evaluateJavascript + val host = unquoted.substring(0, sep) + val token = unquoted.substring(sep + 1) + if (token.length > 20 && expectedHost != null && + host.equals(expectedHost, ignoreCase = true) + ) { detected = true onToken(token) } 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 31a2493..2af52fe 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 @@ -147,21 +147,23 @@ class WebViewLoginViewModel @Inject constructor( private fun saveSession(url: String) { val service = _uiState.value.service ?: return - val saved = sessionSaver.saveFromCookieManager(service = service, url = url) - if (saved != null) { - _uiState.update { - it.copy( - loginSucceeded = true, - savedSession = saved, - pendingManualConfirm = false - ) - } - } else { - _uiState.update { - it.copy( - error = UiText.Resource(R.string.error_cookie_not_found), - pendingManualConfirm = true - ) + viewModelScope.launch { + val saved = sessionSaver.saveFromCookieManager(service = service, url = url) + if (saved != null) { + _uiState.update { + it.copy( + loginSucceeded = true, + savedSession = saved, + pendingManualConfirm = false + ) + } + } else { + _uiState.update { + it.copy( + error = UiText.Resource(R.string.error_cookie_not_found), + pendingManualConfirm = true + ) + } } } }