From 62ddf50638c7d213aa56856c2091fd2e82558f5b Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Sat, 12 Sep 2026 11:31:15 +0800 Subject: [PATCH] =?UTF-8?q?fix(sub2api):=20=E9=9D=A2=E6=9D=BF=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E6=8C=89=E9=92=AE=E8=B4=B4=E5=8F=B3=E8=BE=B9=E7=BC=98?= =?UTF-8?q?=EF=BC=9B=E8=A1=A5=E9=BD=90=E5=AE=A1=E6=9F=A5=E5=8F=91=E7=8E=B0?= =?UTF-8?q?=E7=9A=84=E7=99=BB=E5=BD=95=E5=AE=89=E5=85=A8=E4=B8=8E=E6=AD=BB?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 面板管理按钮(用户反馈「没有放到最右边」): - 根因:底部 Row 里同时给左侧文案 weight(1f, fill=false) 和中间 Spacer weight(1f), 两个 weight 平分剩余空间,按钮只被推到一半。改为只让文案 weight(1f) 吃掉全部剩余宽度。 - 同时清零 TextButton 的 contentPadding,并去掉无效的 defaultMinSize(0) (Material3 的最小尺寸挂在按钮内层节点,外层 modifier 压不下去,实测为 no-op)。 - 去掉箭头 Icon 的 offset 微调:offset 只改放置不改测量,右移的像素会落在按钮命中区之外造成误触。 独立复审发现并修复: - 【安全】线上真正使用的面板登录弹窗 PanelLoginDialog 轮询 localStorage.auth_token 时 完全没有来源校验——登录容器会跳转到第三方页(OAuth/CDN),它们可伪造 token 覆盖用户凭据。 现要求 token 必须来自配置的面板域名(比较 hostname),并回传 host 一并校验。 - 【阻塞】saveFromCookieManager 仍在主线程 runBlocking 做 Keystore 加解密 + DataStore 写盘, 改 suspend 并在 viewModelScope 中调用。 - 【死代码】移除已无调用点的 balanceTrailing 插槽(余额行入口已改到底部)。 说明:WebViewLoginScreen 那套 JS 桥(LoginProbeBridge/nonce/trustedHosts)经查其路由 Routes.WEBVIEW_LOGIN 没有任何 UI 入口(onStartWebViewLogin 只声明未调用),实际不可达, 故本次把安全校验补到了真正在跑的 PanelLoginDialog 上,避免两套并行实现语义分叉。 验证:compileDebugKotlin 与 lintDebug 通过;真机实测按钮与「刚刚更新」同行(y1492-1555) 且可点击区域右边缘到达卡片内容区右缘,点按正常打开弹窗。 --- .../data/repository/WebViewSessionSaver.kt | 8 ++--- .../token/ui/dashboard/DashboardScreen.kt | 15 ++++++--- .../token/ui/dashboard/ServiceBalanceCards.kt | 15 +++------ .../token/ui/settings/CredentialEditScreen.kt | 31 +++++++++++++++--- .../token/ui/webview/WebViewLoginViewModel.kt | 32 ++++++++++--------- 5 files changed, 61 insertions(+), 40 deletions(-) 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 + ) + } } } }