fix: final review findings - thread safety, xss, cache

This commit is contained in:
2026-08-16 17:35:13 +08:00
parent a0f8873b7a
commit f7cffe27d9
8 changed files with 157 additions and 39 deletions
@@ -22,24 +22,38 @@ class CardRepository(
fun fetchCatalog(): List<Card> {
val body = http.get(catalogUrl)
if (body != null) {
cache.save(KEY, body)
Log.d(TAG, "fetchCatalog: fetched ${body.length} bytes from network")
return parse(body)
// 先解析、成功才缓存,避免畸形 body 被永久缓存
// Parse first, cache only on success (never cache malformed bodies)
val cards = parse(body)
if (cards != null) {
cache.save(KEY, body)
Log.d(TAG, "fetchCatalog: fetched ${body.length} bytes from network")
return cards
}
Log.w(TAG, "fetchCatalog: invalid catalog body, not cached")
return builtin()
}
val cached = cache.load(KEY)
if (cached != null) {
Log.d(TAG, "fetchCatalog: network failed, using cache")
return parse(cached)
return parse(cached) ?: builtin()
}
Log.d(TAG, "fetchCatalog: no network and no cache, return builtin time card")
return builtin()
}
// 解析目录 JSON{ "cards": [ {id,name,priority,entry} ] },字段缺失/非法时跳过该项
// Parse catalog JSON; skip malformed or missing-field entries
private fun parse(body: String): List<Card> {
val root = gson.fromJson(body, Map::class.java)
val list = root["cards"] as? List<*> ?: return builtin()
// 解析目录 JSON{ "cards": [ {id,name,priority,entry} ] },字段缺失/非法时跳过该项
// 畸形 JSON 或缺少 cards 字段返回 null(调用方回退内置 time 卡)
// Parse catalog JSON; skip malformed or missing-field entries. Return null on
// malformed JSON or a missing cards field (caller falls back to builtin time card)
private fun parse(body: String): List<Card>? {
val root = try {
gson.fromJson(body, Map::class.java)
} catch (e: Exception) {
Log.w(TAG, "parse: invalid catalog JSON: ${e.message}")
return null
}
val list = root["cards"] as? List<*> ?: return null
val parsed = list.mapNotNull { m ->
val map = m as? Map<*, *> ?: return@mapNotNull null
val id = map["id"] as? String ?: return@mapNotNull null
@@ -1,5 +1,6 @@
package top.yeij.hearth.webapp
import android.util.Log
import com.google.gson.Gson
import top.yeij.hearth.cache.CacheManager
@@ -33,16 +34,30 @@ class WebAppRepository(
fun fetchManifest(): List<WebApp> {
val body = http.get(manifestUrl)
if (body != null) {
cache.save(KEY, body)
return parse(body)
// 先解析、成功才缓存,避免畸形 body 或空结果被永久缓存
// Parse first, cache only on success (never cache malformed/empty bodies)
val apps = parse(body)
if (apps != null) {
cache.save(KEY, body)
return apps
}
Log.w(TAG, "fetchManifest: invalid body, not cached")
return emptyList()
}
val cached = cache.load(KEY) ?: return emptyList()
return parse(cached)
return parse(cached) ?: emptyList()
}
private fun parse(body: String): List<WebApp> {
val root = gson.fromJson(body, Map::class.java)
val apps = root["apps"] as? List<*> ?: return emptyList()
// 解析清单 JSON;畸形 JSON 或缺少 apps 字段返回 null(调用方决定回退)
// Parse the manifest JSON; return null on malformed JSON / missing apps field
private fun parse(body: String): List<WebApp>? {
val root = try {
gson.fromJson(body, Map::class.java)
} catch (e: Exception) {
Log.w(TAG, "parse: invalid manifest JSON: ${e.message}")
return null
}
val apps = root["apps"] as? List<*> ?: return null
return apps.mapNotNull { m ->
val map = m as? Map<*, *> ?: return@mapNotNull null
val id = map["id"] as? String ?: return@mapNotNull null
@@ -57,6 +72,7 @@ class WebAppRepository(
}
companion object {
private const val TAG = "HearthWebApp"
private const val KEY = "webapp-manifest"
}
}
@@ -75,8 +75,10 @@ class JsBridge(
Log.d("HearthBridge", "openWebApp: unknown id=$id")
return
}
container.open(id, app.url, app.name)
// 状态变更与 View 变更统一在主线程串行,避免与 onBackPressed/listTabs 并发读写
// Mutate tab state and view on the main thread together, serializing access
onMain {
container.open(id, app.url, app.name)
host.openWebView(id, app.url)
host.syncTabs(container.tabs())
}
@@ -90,14 +92,14 @@ class JsBridge(
fun closeWebApp(id: String) {
val container = webAppContainer ?: return
val host = webAppHost ?: return
container.close(id)
val nextActiveId = container.activeTabId()
onMain {
container.close(id)
val nextActiveId = container.activeTabId()
host.closeWebView(id)
nextActiveId?.let { host.switchWebView(it) }
host.syncTabs(container.tabs())
Log.d("HearthBridge", "closeWebApp: id=$id remaining=${container.tabs().size}")
}
Log.d("HearthBridge", "closeWebApp: id=$id remaining=${container.tabs().size}")
}
// 切换激活标签:校验 id 存在后更新状态 → 主线程切换 WebView 可见性 → 同步顶栏
@@ -107,10 +109,11 @@ class JsBridge(
fun switchTab(id: String) {
val container = webAppContainer ?: return
val host = webAppHost ?: return
if (!container.switchTo(id)) return
onMain {
host.switchWebView(id)
host.syncTabs(container.tabs())
if (container.switchTo(id)) {
host.switchWebView(id)
host.syncTabs(container.tabs())
}
}
Log.d("HearthBridge", "switchTab: id=$id")
}
@@ -153,12 +156,15 @@ class JsBridge(
Log.d("HearthBridge", "webReload called")
}
// 清单内存缓存:首次拉取后缓存,后续复用(避免 openWebApp 重复 I/O
// In-memory manifest cache: fetch once, reuse afterwards (avoid repeated I/O)
// 清单内存缓存:首次拉取成功后缓存,后续复用(避免 openWebApp 重复 I/O
// 空结果(离线失败)不缓存,下次调用重试拉取,避免空清单被永久缓存
// In-memory manifest cache: cache only on a successful non-empty fetch (avoid
// repeated I/O); empty results (offline failure) are never cached so the next
// call retries instead of being permanently stuck with an empty manifest
private fun manifest(): List<WebApp> {
manifestCache?.let { return it }
val apps = webAppRepository?.fetchManifest() ?: emptyList<WebApp>()
manifestCache = apps
if (apps.isNotEmpty()) manifestCache = apps
return apps
}