feat(sub2api): 未配置服务不再占位;面板管理入口改到卡片右下角;网页登录自动抓取凭据
1) 未配置的用量卡不再显示
OpenCode/CommandCode 用量卡与 Token 热力图卡此前是无条件加入首页列表的,
即使对应服务未配置凭据也会占位显示。现改为按 cards 里是否包含该服务来判断,
热力图卡跟随 OCGO。
2) 面板管理入口位置:卡片底部右下角,与「刚刚更新」同一行右对齐
(此前放在余额右侧,用户反馈位置不对)
底部 Row:左 footerText(weight(1f, fill=false)) + 中间弹性 Spacer + 右按钮。
3) 网页登录后自动获取凭据
- WebViewLoginScreen 注入 JS 桥(@JavascriptInterface LoginProbeBridge),
页面加载完成后读取 localStorage.auth_token / auth_user / favicon / origin 回传。
- WebViewSessionSaver.saveSub2ApiPanelSession 落库面板凭据,保留已有 email/password/apiKey。
- 安全:每次会话随机 nonce(回传必须匹配)+ origin host 白名单(trustedHosts
来自用户配置的面板基址),防止登录过程中跳转的第三方页面伪造 payload 投毒凭据。
- 自托管实例的 baseUrl 由 ViewModel 自行从凭据仓库读取,避免每个导航调用点漏传。
4) 修复(独立审查发现)
- saveSub2ApiPanelSession 去 runBlocking 改 suspend,在 viewModelScope 中调用,
避免 JS 桥线程同步等待 Keystore 加解密 + DataStore 写盘。
- 删除「用 origin 查 Cookie」的旁路:origin 无 path,CookieManager 查不到会误报
「登录完成了吗」,Cookie 抓取统一交回 onPageFinished 的真实 URL。
- normalizeBaseUrl 改用 okhttp3 HttpUrl 解析,垃圾串不再通过校验后落库。
- 撤销给 DeepSeek 加 loginUrl 的改动:其凭据类型是 ApiKeyCredential,
走 Cookie 路径会写成 SessionCredential 并导致 API Key 静默丢失。
真机验证(V2172A / Android 15):
首页只剩 Sub2API 一张卡;面板管理与「刚刚更新」同一行(均 y1492-1555,按钮右对齐 x783-980);
点按钮正常打开弹窗;网页登录容器正常加载面板首页(证明 baseUrl 兜底生效)。
compileDebugKotlin 与 lintDebug 均通过。
This commit is contained in:
parent
edc2a40dc1
commit
8592f15663
@ -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
|
||||
|
||||
@ -464,15 +464,27 @@ private fun rememberDashboardItems(
|
||||
EmptyServicesHintCard(onOpenSettings = onOpenSettings)
|
||||
})
|
||||
}
|
||||
// 用量卡与热力图卡只在对应服务已配置时出现:
|
||||
// 未配置 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))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = footerText(card).asString(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (card.lastFetchError != null)
|
||||
MaterialTheme.colorScheme.error
|
||||
else
|
||||
inkMuted()
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
@ -147,3 +164,26 @@ fun WebViewLoginScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 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<String, String> = 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())
|
||||
@ -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,21 +25,40 @@ 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<WebViewLoginUiState> = _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, 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(
|
||||
service = service,
|
||||
loginUrl = config.loginUrl
|
||||
loginUrl = config.loginUrl.ifBlank { base.orEmpty() },
|
||||
trustedHosts = hosts
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 成功 URL 模式匹配(计划 4.1:精确匹配 + fallback)。
|
||||
@ -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<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(
|
||||
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<String> = emptySet(),
|
||||
val loginSucceeded: Boolean = false,
|
||||
val savedSession: Credential.SessionCredential? = null,
|
||||
val pendingManualConfirm: Boolean = false,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user