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 4277911..baea919 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 @@ -1,6 +1,7 @@ package com.rainy.token.data.repository import android.webkit.CookieManager +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import com.rainy.token.domain.model.CookieEntry import com.rainy.token.domain.model.Credential import com.rainy.token.domain.service.ServiceType @@ -48,6 +49,61 @@ class WebViewSessionSaver @Inject constructor( return session } + /** + * 面板登录成功后自动写入 Sub2API 凭据。 + * + * 面板把登录态放在 localStorage.auth_token(JWT),所以 WebView 登录完成后 + * 注入 JS 读取该键,连同站点基址一起落库,用户无需再手工粘贴。 + * + * @param baseUrl 面板基址(登录页所在 origin,自动补 https 前缀) + * @param authToken localStorage.auth_token 的值;为空表示没抓到,返回 null 不覆盖旧凭据 + * @param siteIcon 站点 Logo(可选,登录页 favicon/og:image) + * @param email 登录邮箱(可选,仅用于展示与回退登录) + */ + suspend fun saveSub2ApiPanelSession( + baseUrl: String, + authToken: String, + siteIcon: String? = null, + email: String? = null + ): Credential.Sub2ApiCredential? { + val token = authToken.trim() + if (token.isEmpty()) return null + val base = normalizeBaseUrl(baseUrl) ?: return null + val existing = credentialRepository.get(ServiceType.SUB2API) as? Credential.Sub2ApiCredential + val credential = Credential.Sub2ApiCredential( + service = ServiceType.SUB2API, + baseUrl = base, + email = email?.takeIf { it.isNotBlank() } ?: existing?.email.orEmpty(), + password = existing?.password.orEmpty(), + apiKey = existing?.apiKey, + authToken = token, + siteIcon = siteIcon?.takeIf { it.isNotBlank() } ?: existing?.siteIcon, + lastVerifiedAt = System.currentTimeMillis() + ) + credentialRepository.save(credential) + return credential + } + + /** + * 归一化面板基址:补 scheme、去尾斜杠,只保留 scheme://host[:port]。 + * + * 用 [okhttp3.HttpUrl] 真正解析而不是字符串长度判断——否则 "not a url" 这类垃圾 + * 会被拼成 "https://not a url" 通过校验并落库,后续拼请求 URL 时 OkHttp 直接抛异常。 + */ + private fun normalizeBaseUrl(raw: String): String? { + val trimmed = raw.trim() + if (trimmed.isEmpty()) return null + val withScheme = if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) { + trimmed + } else { + "https://" + trimmed + } + val parsed = withScheme.toHttpUrlOrNull() ?: return null + // 只保留 origin,丢掉 path/query(面板基址就是 origin) + return parsed.newBuilder().encodedPath("/").query(null).fragment(null).build() + .toString().trimEnd('/') + } + /** * 把 `name1=value1; name2=value2` 解析为 [CookieEntry] 列表。 * 完整属性(domain/path/expires/secure/httpOnly)暂不可用,因为 Android CookieManager 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 0cbe42e..04cc3d5 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 @@ -464,15 +464,27 @@ private fun rememberDashboardItems( EmptyServicesHintCard(onOpenSettings = onOpenSettings) }) } - add(DashboardHomeItem(USAGE_OCGO_CARD_ID) { - UsageStatsCard(onOpenDetail = onOpenUsageDetail, onOpenHeatmap = onOpenHeatmap, refreshTrigger = refreshTrigger) - }) - add(DashboardHomeItem(USAGE_CCGO_CARD_ID) { - CommandCodeUsageStatsCard(onOpenDetail = onOpenCcgoUsageDetail, refreshTrigger = refreshTrigger) - }) - add(DashboardHomeItem(HEATMAP_CARD_ID) { - HeatmapEntryCard(onOpenHeatmap = onOpenHeatmap) - }) + // 用量卡与热力图卡只在对应服务已配置时出现: + // 未配置 OCGO/CCGO 时不应展示它们的用量卡(此前无条件添加,导致未配置也占位)。 + val configuredServices = cards.map { it.service }.toSet() + val ocgoConfigured = ServiceType.OPENCODE_GO in configuredServices + val ccgoConfigured = ServiceType.COMMANDCODE_GO in configuredServices + if (ocgoConfigured) { + add(DashboardHomeItem(USAGE_OCGO_CARD_ID) { + UsageStatsCard(onOpenDetail = onOpenUsageDetail, onOpenHeatmap = onOpenHeatmap, refreshTrigger = refreshTrigger) + }) + } + if (ccgoConfigured) { + add(DashboardHomeItem(USAGE_CCGO_CARD_ID) { + CommandCodeUsageStatsCard(onOpenDetail = onOpenCcgoUsageDetail, refreshTrigger = refreshTrigger) + }) + } + // 热力图统计的是 OCGO 数据,未配置 OCGO 时同样不展示 + if (ocgoConfigured) { + add(DashboardHomeItem(HEATMAP_CARD_ID) { + HeatmapEntryCard(onOpenHeatmap = onOpenHeatmap) + }) + } cards.forEach { card -> add(DashboardHomeItem("service:${card.service.storageKey}") { DashboardCard( @@ -783,31 +795,7 @@ private fun DashboardCard( Spacer(modifier = Modifier.height(16.dp)) // ─── 主体:服务特定的主信息 ─── - // Sub2API 把「面板管理」入口传入余额行,使其与余额数字齐平(见 Sub2ApiBalanceWithUsage) - val showPanelEntry = card.service == ServiceType.SUB2API && - card.credentialState != CredentialStatus.State.NOT_CONFIGURED && - onOpenPanelManagement != null - BalanceMainArea( - card = card, - balanceTrailing = if (showPanelEntry) { - { - TextButton(onClick = { onOpenPanelManagement!!.invoke() }) { - Text( - stringResource(R.string.sub2_panel_manage_title), - color = StrawberryPink, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Spacer(modifier = Modifier.width(2.dp)) - Icon( - imageVector = Icons.Filled.KeyboardArrowRight, - contentDescription = null, - tint = StrawberryPink - ) - } - } - } else null - ) + BalanceMainArea(card) // 用量服务卡片底部:用量详情入口 + Token 活动快捷入口(仅当有凭证时显示) if (onOpenUsageDetail != null && card.credentialState != com.rainy.token.domain.model.CredentialStatus.State.NOT_CONFIGURED) { @@ -851,16 +839,47 @@ private fun DashboardCard( } } - // ─── 底部:更新时间 / 错误信息 ─── + // ─── 底部:左侧更新时间/错误信息,右侧「面板管理」入口(同一行、右对齐)─── Spacer(modifier = Modifier.height(12.dp)) - Text( - text = footerText(card).asString(), - style = MaterialTheme.typography.bodySmall, - color = if (card.lastFetchError != null) - MaterialTheme.colorScheme.error - else - inkMuted() - ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = footerText(card).asString(), + style = MaterialTheme.typography.bodySmall, + color = if (card.lastFetchError != null) + MaterialTheme.colorScheme.error + else + inkMuted(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false) + ) + // 弹性间距:窄屏/英文长文案时优先压缩左侧文案,按钮保持右对齐不被折行 + Spacer(modifier = Modifier.weight(1f)) + if (card.service == ServiceType.SUB2API && + card.credentialState != CredentialStatus.State.NOT_CONFIGURED && + onOpenPanelManagement != null + ) { + TextButton( + onClick = onOpenPanelManagement, + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp) + ) { + Text( + stringResource(R.string.sub2_panel_manage_title), + color = StrawberryPink, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Spacer(modifier = Modifier.width(2.dp)) + Icon( + imageVector = Icons.Filled.KeyboardArrowRight, + contentDescription = null, + tint = StrawberryPink, + modifier = Modifier.size(16.dp) + ) + } + } + } } } } diff --git a/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginScreen.kt b/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginScreen.kt index c2ad9fb..7046b88 100644 --- a/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginScreen.kt @@ -49,9 +49,14 @@ fun WebViewLoginScreen( service: ServiceType, onBack: () -> Unit, onLoginSucceeded: (ServiceType) -> Unit, + /** 用户已保存的面板基址(Sub2API 的 loginUrl 为空,靠它提供登录入口与可信 host)。 */ + configuredBaseUrl: String? = null, viewModel: WebViewLoginViewModel = hiltViewModel() ) { - LaunchedEffect(service) { viewModel.bind(service) } + // configuredBaseUrl 可选:不传时 ViewModel 自行从凭据仓库读取(Sub2API 自托管实例) + LaunchedEffect(service, configuredBaseUrl) { + viewModel.bind(service, configuredBaseUrl) + } val uiState by viewModel.uiState.collectAsStateWithLifecycle() // 登录成功自动回调 @@ -100,9 +105,21 @@ fun WebViewLoginScreen( // 且 TLS fingerprinting 也会识别 WebView。此入口主要作为 fallback, // 主流程走"手动粘贴 Cookie"路线。 + // 注入 JS 桥:面板登录后把 localStorage 里的登录态回传原生层, + // 实现「网页登录一次即自动配置凭据」,无需手工粘贴 token + addJavascriptInterface( + LoginProbeBridge { json -> + viewModel.onLocalStorageProbed(parseProbeJson(json)) + }, + "AndroidBridge" + ) + webViewClient = object : WebViewClient() { override fun onPageFinished(view: WebView?, url: String?) { - url?.let { viewModel.onPageFinished(it) } + url ?: return + // 登录成功后面板会写入 localStorage.auth_token,抓到即自动保存 + view?.evaluateJavascript(viewModel.probeScript, null) + viewModel.onPageFinished(url) } } webChromeClient = WebChromeClient() @@ -146,4 +163,27 @@ fun WebViewLoginScreen( } ) } -} \ No newline at end of file +} +/** + * JS → 原生桥:页面脚本通过 window.AndroidBridge.onProbe(json) 回传 localStorage 登录态。 + * + * 用 @JavascriptInterface 注解的方法只允许传基本类型,这里统一走 JSON 字符串, + * 解析失败按空处理(页面脚本也可能被站点 CSP 拦掉)。 + */ +private class LoginProbeBridge(private val onProbe: (String) -> Unit) { + @android.webkit.JavascriptInterface + fun onProbe(json: String) { + runCatching { onProbe(json) } + } +} + +/** 解析探测脚本回传的 JSON(扁平 string→string 结构)。 */ +private fun parseProbeJson(json: String): Map = runCatching { + val obj = org.json.JSONObject(json) + buildMap { + obj.keys().forEach { k -> + val v = obj.optString(k, "") + if (v.isNotEmpty()) put(k, v) + } + } +}.getOrDefault(emptyMap()) \ No newline at end of file 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 780f21d..31a2493 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 @@ -1,6 +1,9 @@ package com.rainy.token.ui.webview import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.launch +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import com.rainy.token.R import com.rainy.token.data.repository.WebViewSessionSaver import com.rainy.token.domain.model.Credential @@ -22,19 +25,38 @@ import javax.inject.Inject */ @HiltViewModel class WebViewLoginViewModel @Inject constructor( - private val sessionSaver: WebViewSessionSaver + private val sessionSaver: WebViewSessionSaver, + private val credentialRepository: com.rainy.token.data.repository.CredentialRepository ) : ViewModel() { private val _uiState = MutableStateFlow(WebViewLoginUiState()) val uiState: StateFlow = _uiState.asStateFlow() - fun bind(service: ServiceType) { + /** + * @param configuredBaseUrl 用户已保存的自托管实例基址(Sub2API 用)。 + * 用于把「可信 host」限定为用户自己配置的域名,登录过程中跳转到的第三方页面 + * 无法借此写入凭据。 + */ + fun bind(service: ServiceType, configuredBaseUrl: String? = null) { val config = ServiceConfigProvider.get(service) _uiState.update { - it.copy( - service = service, - loginUrl = config.loginUrl - ) + 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 + val hosts = buildSet { + base?.toHttpUrlOrNull()?.host?.let { add(it) } + config.loginUrl.toHttpUrlOrNull()?.host?.let { add(it) } + } + _uiState.update { + it.copy( + loginUrl = config.loginUrl.ifBlank { base.orEmpty() }, + trustedHosts = hosts + ) + } } } @@ -63,6 +85,66 @@ class WebViewLoginViewModel @Inject constructor( _uiState.update { it.copy(pendingManualConfirm = false) } } + /** + * 面板登录成功后由 WebView 注入 JS 回调,把 localStorage 里的凭据回传。 + * + * Sub2API 面板把登录态放在 localStorage.auth_token(JWT);抓到即代表登录成功, + * 无需用户在设置页手工粘贴 token。 + */ + fun onLocalStorageProbed(values: Map) { + 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( + baseUrl = origin.ifBlank { _uiState.value.loginUrl }, + authToken = token, + siteIcon = values[SUB2_ICON_KEY], + email = values[SUB2_EMAIL_KEY] + ) + if (saved != null) { + _uiState.update { + it.copy(loginSucceeded = true, pendingManualConfirm = false, error = null) + } + } + } + } + + /** + * origin 是否可信:必须与用户填写/已知的面板基址同 host。 + * 登录页会跳转到第三方域(OAuth、CDN),那些页面注入的脚本不应改写本地凭据。 + */ + private fun isTrustedOrigin(origin: String): Boolean { + val probeHost = origin.toHttpUrlOrNull()?.host ?: return false + val allowed = _uiState.value.trustedHosts + if (allowed.isEmpty()) return false + 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 + private fun saveSession(url: String) { val service = _uiState.value.service ?: return val saved = sessionSaver.saveFromCookieManager(service = service, url = url) @@ -92,9 +174,44 @@ class WebViewLoginViewModel @Inject constructor( } } +/** 面板 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 { + var out = {}; + var t = window.localStorage.getItem('auth_token'); + if (t) out.auth_token = t; + var u = window.localStorage.getItem('auth_user'); + if (u) { + try { out.email = (JSON.parse(u) || {}).email || ''; } catch (e) {} + } + var icon = document.querySelector('link[rel~="icon"]'); + if (icon && icon.href) out.site_icon = icon.href; + out.origin = window.location.origin; + out.nonce = '$nonce'; + if (window.AndroidBridge && window.AndroidBridge.onProbe) { + window.AndroidBridge.onProbe(JSON.stringify(out)); + } + } catch (e) {} +})(); +""" + data class WebViewLoginUiState( val service: ServiceType? = null, val loginUrl: String = "", + /** 允许通过 JS 桥写入凭据的 host 白名单(来自用户配置的面板基址 / 登录 URL)。 */ + val trustedHosts: Set = emptySet(), val loginSucceeded: Boolean = false, val savedSession: Credential.SessionCredential? = null, val pendingManualConfirm: Boolean = false,