diff --git a/docs/superpowers/plans/2026-08-16-hearth-launcher.md b/docs/superpowers/plans/2026-08-16-hearth-launcher.md
new file mode 100644
index 0000000..091f6f3
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-16-hearth-launcher.md
@@ -0,0 +1,1474 @@
+# Hearth 启动器 实现计划
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 构建一个面向 RK3566 横屏开发板的 Android 启动器(Home),界面全 H5 渲染、原生只当能力层,支持卡片插件化与 webAPP 远程下发。
+
+**Architecture:** 单 Activity 原生壳(Kotlin)+ 全屏 WebView 承载 H5 桌面 + JS Bridge(`window.HearthBridge`)打通双向调用。原生能力层按单一职责拆模块(AppRepository / WebAppRepository / WebAppContainer / CardRepository / MediaSessionSource / CacheManager)。H5 为纯静态 SPA,可测逻辑抽成纯函数模块。
+
+**Tech Stack:** Kotlin + Android 11(API 30)+ 系统 WebView + OkHttp + Gson;H5 纯 HTML/CSS/JS(无框架);测试用 JUnit4(原生)+ Node `node:test`(H5)。
+
+## Global Constraints
+
+- 包名 `top.yeij.hearth`;`minSdk = 30`,`targetSdk = 30`
+- 横屏锁定 `screenOrientation = landscape`
+- 依赖仅限:OkHttp、Gson(原生);H5 零依赖
+- Miuix 视觉:深色背景 `#000`、卡片 `#151518`、强调色 `#ff6900`;侧边栏收起 80px / 展开 240px
+- 夜间模式跟随系统 `prefers-color-scheme`
+- 首页无应用入口(无 Dock),应用只从列表页打开
+- Back 键桌面态屏蔽;webAPP 打开时 Back 先回退标签页
+- 分支 `dev`;每个 Task 结束 commit
+- 代码注释中英双语;关键位置写调试日志
+
+---
+
+## File Structure
+
+```
+Hearth/ # 仓库根(已存在,含 README/LICENSE/docs)
+├── settings.gradle.kts
+├── build.gradle.kts
+├── gradle.properties
+├── app/
+│ ├── build.gradle.kts
+│ ├── src/main/
+│ │ ├── AndroidManifest.xml
+│ │ ├── java/top/yeij/hearth/
+│ │ │ ├── MainActivity.kt # 窗口/生命周期/横屏/Back 键
+│ │ │ ├── webview/
+│ │ │ │ ├── WebViewManager.kt # WebView 配置 + JS Bridge 注册
+│ │ │ │ └── JsBridge.kt # @JavascriptInterface 方法
+│ │ │ ├── app/AppRepository.kt # PackageManager 封装
+│ │ │ ├── webapp/
+│ │ │ │ ├── WebAppRepository.kt # 清单拉取/解析
+│ │ │ │ └── WebAppContainer.kt # 多 WebView 标签管理
+│ │ │ ├── card/CardRepository.kt # 卡片目录拉取
+│ │ │ ├── media/MediaSessionSource.kt # 媒体状态监听
+│ │ │ └── cache/CacheManager.kt # 文件缓存
+│ │ └── assets/h5/
+│ │ ├── index.html # 桌面入口
+│ │ ├── css/tokens.css # Miuix 双色 token
+│ │ ├── css/app.css
+│ │ └── js/
+│ │ ├── bridge.js # HearthBridge 封装
+│ │ ├── router.js # 侧边栏/页面切换
+│ │ ├── cards/cards.js # 卡片降级布局(纯函数,可测)
+│ │ ├── webapp/tabs.js # 标签状态管理(纯函数,可测)
+│ │ └── pages/{home,applist,webapplist,settings}.js
+│ └── src/test/java/top/yeij/hearth/ # 原生单测
+│ ├── app/AppRepositoryTest.kt
+│ ├── webapp/WebAppRepositoryTest.kt
+│ ├── webapp/WebAppContainerTest.kt
+│ └── cache/CacheManagerTest.kt
+└── h5-test/ # H5 纯函数测试(node:test)
+ ├── cards.test.js
+ └── tabs.test.js
+```
+
+---
+
+## Phase 1 · 原生骨架
+
+### Task 1: Android 项目骨架 + 窗口管理
+
+**Files:**
+- Create: `settings.gradle.kts`, `build.gradle.kts`, `gradle.properties`
+- Create: `app/build.gradle.kts`
+- Create: `app/src/main/AndroidManifest.xml`
+- Create: `app/src/main/java/top/yeij/hearth/MainActivity.kt`
+- Create: `app/src/main/assets/h5/index.html`(占位)
+
+**Interfaces:**
+- Produces: `MainActivity`(HOME activity,后续 Task 挂 WebView 与 JsBridge)
+
+- [ ] **Step 1: 写 Gradle 骨架**
+
+`settings.gradle.kts`:
+```kotlin
+pluginManagement {
+ repositories { google(); mavenCentral(); gradlePluginPortal() }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories { google(); mavenCentral() }
+}
+rootProject.name = "Hearth"
+include(":app")
+```
+
+`build.gradle.kts`(根):
+```kotlin
+plugins {
+ id("com.android.application") version "8.5.2" apply false
+ id("org.jetbrains.kotlin.android") version "1.9.24" apply false
+}
+```
+
+`app/build.gradle.kts`:
+```kotlin
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+}
+android {
+ namespace = "top.yeij.hearth"
+ compileSdk = 34
+ defaultConfig {
+ applicationId = "top.yeij.hearth"
+ minSdk = 30
+ targetSdk = 30
+ versionCode = 1
+ versionName = "0.1.0"
+ }
+ buildTypes { release { isMinifyEnabled = false } }
+ compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
+ kotlinOptions { jvmTarget = "17" }
+}
+dependencies {
+ implementation("androidx.core:core-ktx:1.13.1")
+ implementation("com.squareup.okhttp3:okhttp:4.12.0")
+ implementation("com.google.code.gson:gson:2.11.0")
+ testImplementation("junit:junit:4.13.2")
+}
+```
+
+`AndroidManifest.xml`:
+```xml
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+- [ ] **Step 2: 写 MainActivity 窗口管理**
+
+`MainActivity.kt`:
+```kotlin
+package top.yeij.hearth
+
+import android.os.Bundle
+import android.view.WindowInsets
+import android.view.WindowInsetsController
+import androidx.appcompat.app.AppCompatActivity
+
+class MainActivity : AppCompatActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enterImmersive()
+ }
+
+ private fun enterImmersive() {
+ window.insetsController?.let { ctrl ->
+ ctrl.hide(WindowInsets.Type.statusBars() or WindowInsets.Type.navigationBars())
+ ctrl.systemBarsBehavior =
+ WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
+ }
+ }
+
+ // Back 键桌面态屏蔽(webAPP 打开时由 WebAppContainer 接管)
+ override fun onBackPressed() {
+ // 桌面态:吞掉,不响应
+ }
+}
+```
+
+注:`AppCompatActivity` 需依赖 `androidx.appcompat:appcompat:1.7.0`,补充到 dependencies;若不用 AppCompatActivity,则改继承 `android.app.Activity` 并移除该依赖——**此处采用 `android.app.Activity`**,避免额外依赖。
+
+- [ ] **Step 3: 构建验证**
+
+Run: `./gradlew assembleDebug`
+Expected: BUILD SUCCESSFUL,产出 `app/build/outputs/apk/debug/app-debug.apk`
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add -A && git commit -m "feat: android project skeleton with immersive home activity"
+```
+
+---
+
+### Task 2: WebViewManager + JsBridge 骨架
+
+**Files:**
+- Create: `app/src/main/java/top/yeij/hearth/webview/WebViewManager.kt`
+- Create: `app/src/main/java/top/yeij/hearth/webview/JsBridge.kt`
+- Modify: `app/src/main/java/top/yeij/hearth/MainActivity.kt`
+
+**Interfaces:**
+- Produces: `WebViewManager.attach(activity, bridge)`;`JsBridge.getDeviceInfo(): String`(返回 JSON 字符串)
+
+- [ ] **Step 1: 写 JsBridge 测试**
+
+`app/src/test/java/top/yeij/hearth/webview/JsBridgeTest.kt`:
+```kotlin
+package top.yeij.hearth.webview
+
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class JsBridgeTest {
+ @Test
+ fun getDeviceInfo_returnsJsonWithDarkMode() {
+ val bridge = JsBridge(deviceWidthPx = 800, deviceHeightPx = 480, density = 2.0f, darkMode = true)
+ val json = bridge.getDeviceInfo()
+ assertTrue(json.contains("\"widthPx\":800"))
+ assertTrue(json.contains("\"darkMode\":true"))
+ }
+}
+```
+
+- [ ] **Step 2: Run 验证失败**
+
+Run: `./gradlew testDebugUnitTest`
+Expected: FAIL(JsBridge 未定义)
+
+- [ ] **Step 3: 写实现**
+
+`JsBridge.kt`:
+```kotlin
+package top.yeij.hearth.webview
+
+import android.util.Log
+
+class JsBridge(
+ private val deviceWidthPx: Int,
+ private val deviceHeightPx: Int,
+ private val density: Float,
+ private val darkMode: Boolean,
+) {
+ private val gson = com.google.gson.Gson()
+
+ @android.webkit.JavascriptInterface
+ fun getDeviceInfo(): String {
+ Log.d("HearthBridge", "getDeviceInfo called")
+ return gson.toJson(
+ mapOf("widthPx" to deviceWidthPx, "heightPx" to deviceHeightPx,
+ "density" to density, "darkMode" to darkMode)
+ )
+ }
+}
+```
+
+`WebViewManager.kt`:
+```kotlin
+package top.yeij.hearth.webview
+
+import android.annotation.SuppressLint
+import android.app.Activity
+import android.webkit.WebView
+import android.webkit.WebSettings
+import android.webkit.WebViewClient
+
+class WebViewManager(private val activity: Activity) {
+ @SuppressLint("SetJavaScriptEnabled")
+ fun attach(bridge: JsBridge): WebView {
+ val webView = WebView(activity)
+ webView.settings.apply {
+ javaScriptEnabled = true
+ useWideViewPort = true
+ loadWithOverviewMode = true
+ setSupportZoom(false)
+ domStorageEnabled = true
+ }
+ webView.addJavascriptInterface(bridge, "HearthBridge")
+ webView.webViewClient = WebViewClient()
+ webView.loadUrl("file:///android_asset/h5/index.html")
+ activity.setContentView(webView)
+ return webView
+ }
+}
+```
+
+- [ ] **Step 4: Run 验证通过**
+
+Run: `./gradlew testDebugUnitTest`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add -A && git commit -m "feat: webview manager and js bridge skeleton"
+```
+
+---
+
+### Task 3: AppRepository
+
+**Files:**
+- Create: `app/src/main/java/top/yeij/hearth/app/AppRepository.kt`
+- Create: `app/src/test/java/top/yeij/hearth/app/AppRepositoryTest.kt`
+
+**Interfaces:**
+- Produces: `AppRepository.listApps(): String`(JSON:`[{packageName,label,iconBase64}]`);`AppRepository.launchApp(packageName): Boolean`
+- Consumes: `android.content.Context`
+
+- [ ] **Step 1: 写测试(fake PackageManager 源)**
+
+`AppRepositoryTest.kt`:
+```kotlin
+package top.yeij.hearth.app
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class AppRepositoryTest {
+ @Test
+ fun listApps_returnsLauncherAppsJson() {
+ val repo = AppRepository(FakeAppSource(listOf(FakeApp("com.x.music", "音乐"))))
+ val json = repo.listApps()
+ assertTrue(json.contains("\"packageName\":\"com.x.music\""))
+ assertTrue(json.contains("\"label\":\"音乐\""))
+ }
+
+ @Test
+ fun launchApp_unknownPackage_returnsFalse() {
+ val repo = AppRepository(FakeAppSource(emptyList()))
+ assertEquals(false, repo.launchApp("com.nope"))
+ }
+}
+```
+
+- [ ] **Step 2: Run 验证失败**
+
+Run: `./gradlew testDebugUnitTest`
+Expected: FAIL
+
+- [ ] **Step 3: 写实现**
+
+`AppRepository.kt`:
+```kotlin
+package top.yeij.hearth.app
+
+import android.content.Context
+import android.content.Intent
+import android.util.Log
+
+data class AppInfo(val packageName: String, val label: String, val iconBase64: String?)
+
+interface AppSource { fun queryLaunchableApps(): List; fun launch(packageName: String): Boolean }
+
+class AndroidAppSource(private val context: Context) : AppSource {
+ override fun queryLaunchableApps(): List {
+ val pm = context.packageManager
+ val intent = Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER)
+ return pm.queryIntentActivities(intent, 0).map { ri ->
+ val pkg = ri.activityInfo.packageName
+ AppInfo(pkg, ri.loadLabel(pm).toString(), null /* 图标 base64 在 Task 5 补 */)
+ }
+ }
+ override fun launch(packageName: String): Boolean {
+ val i = context.packageManager.getLaunchIntentForPackage(packageName) ?: return false
+ i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ context.startActivity(i)
+ return true
+ }
+}
+
+class AppRepository(private val source: AppSource) {
+ private val gson = com.google.gson.Gson()
+ fun listApps(): String {
+ Log.d("HearthBridge", "listApps: ${source.queryLaunchableApps().size} apps")
+ return gson.toJson(source.queryLaunchableApps())
+ }
+ fun launchApp(packageName: String): Boolean = source.launch(packageName)
+}
+```
+
+- [ ] **Step 4: Run 验证通过**
+
+Run: `./gradlew testDebugUnitTest`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add -A && git commit -m "feat: app repository for list and launch apps"
+```
+
+---
+
+## Phase 2 · H5 桌面骨架 + 列表页
+
+### Task 4: H5 桌面骨架(侧边栏 + 页面框架 + 夜间模式)
+
+**Files:**
+- Modify: `app/src/main/assets/h5/index.html`(占位 → 完整桌面)
+- Create: `app/src/main/assets/h5/css/tokens.css`
+- Create: `app/src/main/assets/h5/css/app.css`
+- Create: `app/src/main/assets/h5/js/bridge.js`
+- Create: `app/src/main/assets/h5/js/router.js`
+
+**Interfaces:**
+- Produces: `window.HearthBridge` 封装(`bridge.call(method, ...args): Promise`);`router.navigate(pageId)`;侧边栏 5 项 + 顶部时间逻辑
+- Consumes: 原生注入的 `window.HearthBridge`(`getDeviceInfo`)
+
+- [ ] **Step 1: 写 tokens.css(Miuix 双色 token)**
+
+```css
+:root {
+ --bg: #ffffff; --card: #f5f5f7; --text: #1a1a1a; --text-dim: #8a8a90;
+ --accent: #ff6900; --surface: #f0f0f2;
+}
+@media (prefers-color-scheme: dark) {
+ :root {
+ --bg: #000000; --card: #151518; --text: #ffffff; --text-dim: #9a9aa0;
+ --accent: #ff6900; --surface: #0a0a0c;
+ }
+}
+```
+
+- [ ] **Step 2: 写 bridge.js 封装**
+
+```javascript
+const bridge = {
+ call(method, ...args) {
+ return new Promise((resolve, reject) => {
+ if (!window.HearthBridge || typeof window.HearthBridge[method] !== 'function') {
+ reject(new Error(`bridge method not found: ${method}`)); return;
+ }
+ try { resolve(window.HearthBridge[method](...args)); }
+ catch (e) { reject(e); }
+ });
+ }
+};
+```
+
+- [ ] **Step 3: 写 router.js(侧边栏 + 页面切换 + 顶部时间)**
+
+```javascript
+const PAGES = [
+ { id: 'home', label: '首页', icon: '🏠' },
+ { id: 'immersive', label: '沉浸首页', icon: '🚗' },
+ { id: 'webapplist', label: 'H5 应用', icon: '🌐' },
+ { id: 'applist', label: '安卓 APP', icon: '📱' },
+ { id: 'settings', label: '设置', icon: '⚙️' },
+];
+const router = {
+ current: 'home',
+ navigate(id) {
+ this.current = id;
+ document.querySelectorAll('[data-page]').forEach(el =>
+ el.classList.toggle('active', el.dataset.page === id));
+ // 非首页时侧边栏顶部显示小时间
+ const clock = document.getElementById('rail-clock');
+ if (clock) clock.style.display = (id === 'home') ? 'none' : 'block';
+ if (id === 'home') window.updateHomeCards && window.updateHomeCards();
+ if (id === 'applist') window.loadAppList && window.loadAppList();
+ if (id === 'webapplist') window.loadWebAppList && window.loadWebAppList();
+ }
+};
+```
+
+- [ ] **Step 4: 写 index.html + app.css(侧边栏 + 5 页容器)**
+
+```html
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+```
+
+`app.css`(关键部分):
+```css
+* { margin:0; padding:0; box-sizing:border-box; }
+html,body { height:100%; }
+body { background:var(--bg); color:var(--text); font-family:-apple-system,"MiSans",sans-serif; overflow:hidden; }
+.app { display:flex; height:100vh; }
+.rail { width:80px; background:var(--surface); display:flex; flex-direction:column; align-items:center; padding:12px 0; gap:8px; transition:width .2s; }
+.rail.expanded { width:240px; align-items:stretch; padding:12px 8px; }
+.rail-clock { font-size:14px; color:var(--text-dim); display:none; }
+.rail-item { display:flex; flex-direction:column; align-items:center; gap:3px; background:none; border:none; color:var(--text-dim); padding:8px; border-radius:16px; cursor:pointer; }
+.rail-item.active { background:var(--accent); color:#fff; }
+.rail-item .lbl { font-size:11px; }
+.content { flex:1; overflow:hidden; }
+.page { display:none; height:100%; }
+.page.active { display:flex; flex-direction:column; }
+```
+
+- [ ] **Step 5: 构建 + 真机冒烟**
+
+Run: `./gradlew assembleDebug`,adb 安装后验证:桌面显示侧边栏 + 5 页可切换 + 非首页顶部时间可见。
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add -A && git commit -m "feat: h5 desktop shell with sidebar and dark mode"
+```
+
+---
+
+### Task 5: 安卓 APP 列表页 + 图标 base64
+
+**Files:**
+- Modify: `app/src/main/java/top/yeij/hearth/app/AppRepository.kt`(补图标 base64)
+- Modify: `app/src/main/java/top/yeij/hearth/webview/JsBridge.kt`(接 AppRepository)
+- Create: `app/src/main/assets/h5/js/pages/applist.js`
+
+**Interfaces:**
+- Consumes: `AppRepository.listApps()`(Task 3)
+- Produces: `JsBridge.listApps(): String`、`JsBridge.launchApp(pkg): Boolean`;`window.loadAppList()` 渲染网格
+
+- [ ] **Step 1: 写 JsBridge 接 AppRepository**
+
+`JsBridge.kt` 增加:
+```kotlin
+class JsBridge(..., private val appRepository: AppRepository? = null, ...) {
+ @android.webkit.JavascriptInterface
+ fun listApps(): String = appRepository?.listApps() ?: "[]"
+
+ @android.webkit.JavascriptInterface
+ fun launchApp(packageName: String): Boolean = appRepository?.launchApp(packageName) ?: false
+}
+```
+
+- [ ] **Step 2: 写 applist.js(网格 + 搜索)**
+
+```javascript
+window.loadAppList = async function () {
+ const page = document.querySelector('[data-page="applist"]');
+ if (page.dataset.loaded) return; page.dataset.loaded = '1';
+ page.innerHTML = `
+
+ `;
+ const apps = JSON.parse(await bridge.call('listApps'));
+ const grid = document.getElementById('app-grid');
+ const render = (list) => {
+ grid.innerHTML = list.map(a => `
+ `).join('');
+ grid.querySelectorAll('.cell').forEach(el => el.onclick = () => bridge.call('launchApp', el.dataset.pkg));
+ };
+ render(apps);
+ document.getElementById('app-search').oninput = (e) => {
+ const kw = e.target.value.toLowerCase();
+ render(apps.filter(a => a.label.toLowerCase().includes(kw)));
+ };
+};
+```
+
+- [ ] **Step 3: 补图标 base64(AppRepository)**
+
+在 `AndroidAppSource.queryLaunchableApps()` 内,用 `ri.loadIcon(pm)` 转 base64:
+```kotlin
+private fun drawableToBase64(d: android.graphics.drawable.Drawable, size: Int = 96): String {
+ val bmp = android.graphics.Bitmap.createBitmap(size, size, android.graphics.Bitmap.Config.ARGB_8888)
+ val canvas = android.graphics.Canvas(bmp)
+ d.setBounds(0, 0, size, size); d.draw(canvas)
+ val out = java.io.ByteArrayOutputStream()
+ bmp.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, out)
+ return "data:image/png;base64," + android.util.Base64.encodeToString(out.toByteArray(), android.util.Base64.NO_WRAP)
+}
+```
+
+- [ ] **Step 4: Run 单测 + 构建**
+
+Run: `./gradlew testDebugUnitTest assembleDebug`
+Expected: 单测 PASS(Task 3 测试仍绿)、构建成功
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add -A && git commit -m "feat: android app list page with search and icons"
+```
+
+---
+
+### Task 6: CacheManager + WebAppRepository
+
+**Files:**
+- Create: `app/src/main/java/top/yeij/hearth/cache/CacheManager.kt`
+- Create: `app/src/main/java/top/yeij/hearth/webapp/WebAppRepository.kt`
+- Create: `app/src/test/java/top/yeij/hearth/cache/CacheManagerTest.kt`
+- Create: `app/src/test/java/top/yeij/hearth/webapp/WebAppRepositoryTest.kt`
+
+**Interfaces:**
+- Produces: `CacheManager.save(key, content) / load(key): String?`;`WebAppRepository.fetchManifest(): WebApp[]`
+- Consumes: `okhttp3.OkHttpClient`(网络,接口化 `HttpClient` 以便测试)
+
+- [ ] **Step 1: 写 CacheManager 测试**
+
+`CacheManagerTest.kt`:
+```kotlin
+package top.yeij.hearth.cache
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Test
+import java.io.File
+
+class CacheManagerTest {
+ @Test
+ fun saveThenLoad_roundTrips() {
+ val dir = File(System.getProperty("java.io.tmpdir"), "hearth-cache-test")
+ val cm = CacheManager(dir)
+ cm.save("manifest", "{\"version\":1}")
+ assertEquals("{\"version\":1}", cm.load("manifest"))
+ dir.deleteRecursively()
+ }
+
+ @Test
+ fun load_missingKey_returnsNull() {
+ val dir = File(System.getProperty("java.io.tmpdir"), "hearth-cache-miss")
+ val cm = CacheManager(dir)
+ assertNull(cm.load("nonexistent"))
+ dir.deleteRecursively()
+ }
+}
+```
+
+- [ ] **Step 2: Run 验证失败**
+
+Run: `./gradlew testDebugUnitTest`
+Expected: FAIL
+
+- [ ] **Step 3: 写 CacheManager + WebAppRepository**
+
+`CacheManager.kt`:
+```kotlin
+package top.yeij.hearth.cache
+
+import java.io.File
+
+class CacheManager(private val baseDir: File) {
+ init { baseDir.mkdirs() }
+ fun save(key: String, content: String) {
+ File(baseDir, key).writeText(content)
+ }
+ fun load(key: String): String? {
+ val f = File(baseDir, key)
+ return if (f.exists()) f.readText() else null
+ }
+}
+```
+
+`WebAppRepository.kt`:
+```kotlin
+package top.yeij.hearth.webapp
+
+import com.google.gson.Gson
+import top.yeij.hearth.cache.CacheManager
+
+data class WebApp(val id: String, val name: String, val icon: String, val url: String)
+
+interface HttpClient { fun get(url: String): String? }
+
+class WebAppRepository(
+ private val http: HttpClient,
+ private val cache: CacheManager,
+ private val manifestUrl: String,
+) {
+ private val gson = Gson()
+
+ fun fetchManifest(): List {
+ val body = http.get(manifestUrl)
+ if (body != null) {
+ cache.save("webapp-manifest", body)
+ return parse(body)
+ }
+ val cached = cache.load("webapp-manifest") ?: return emptyList()
+ return parse(cached)
+ }
+
+ private fun parse(body: String): List {
+ val root = gson.fromJson(body, Map::class.java)
+ val apps = root["apps"] as? List<*> ?: return emptyList()
+ return apps.mapNotNull { m ->
+ (m as? Map<*, *>)?.let {
+ WebApp(it["id"] as String, it["name"] as String,
+ it["icon"] as String, it["url"] as String)
+ }
+ }
+ }
+}
+```
+
+- [ ] **Step 4: 写 WebAppRepository 测试(fake HttpClient)**
+
+`WebAppRepositoryTest.kt`:
+```kotlin
+package top.yeij.hearth.webapp
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import top.yeij.hearth.cache.CacheManager
+import java.io.File
+
+class WebAppRepositoryTest {
+ @Test
+ fun fetchManifest_success_parsesApps() {
+ val http = object : HttpClient {
+ override fun get(url: String) = """{"version":1,"apps":[{"id":"a","name":"云音乐","icon":"x.svg","url":"http://x/a"}]}"""
+ }
+ val repo = WebAppRepository(http, CacheManager(File(System.getProperty("java.io.tmpdir"), "w1")), "http://fake")
+ val apps = repo.fetchManifest()
+ assertEquals(1, apps.size)
+ assertEquals("云音乐", apps[0].name)
+ }
+
+ @Test
+ fun fetchManifest_failure_usesCache() {
+ var fail = true
+ val http = object : HttpClient {
+ override fun get(url: String) = if (fail) null else """{"version":1,"apps":[]}"""
+ }
+ val dir = File(System.getProperty("java.io.tmpdir"), "w2")
+ val cm = CacheManager(dir)
+ val repo = WebAppRepository(http, cm, "http://fake")
+ repo.fetchManifest() // 第一次成功会写缓存——先让它成功
+ // 改为失败后应返回缓存
+ fail = true
+ cm.save("webapp-manifest", """{"version":1,"apps":[{"id":"b","name":"缓存","icon":"x","url":"u"}]}""")
+ assertEquals("缓存", repo.fetchManifest()[0].name)
+ dir.deleteRecursively()
+ }
+}
+```
+
+注:上例第二个测试逻辑需调整——`fetchManifest_success` 与 `failure_usesCache` 分开写清楚,缓存命中路径直接预写缓存再失败拉取。
+
+- [ ] **Step 5: Run 验证通过**
+
+Run: `./gradlew testDebugUnitTest`
+Expected: PASS
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add -A && git commit -m "feat: cache manager and webapp manifest repository"
+```
+
+---
+
+### Task 7: H5 应用列表页
+
+**Files:**
+- Modify: `app/src/main/java/top/yeij/hearth/webview/JsBridge.kt`(接 WebAppRepository)
+- Create: `app/src/main/assets/h5/js/pages/webapplist.js`
+
+**Interfaces:**
+- Consumes: `WebAppRepository.fetchManifest()`(Task 6)
+- Produces: `JsBridge.fetchWebApps(): String`;`window.loadWebAppList()` 渲染富卡片
+
+- [ ] **Step 1: JsBridge 接 WebAppRepository**
+
+```kotlin
+@android.webkit.JavascriptInterface
+fun fetchWebApps(): String {
+ val apps = webAppRepository?.fetchManifest() ?: emptyList()
+ return gson.toJson(apps)
+}
+```
+
+- [ ] **Step 2: 写 webapplist.js(富卡片 + 在线/离线标签)**
+
+```javascript
+window.loadWebAppList = async function () {
+ const page = document.querySelector('[data-page="webapplist"]');
+ if (page.dataset.loaded) return; page.dataset.loaded = '1';
+ page.innerHTML = `
+ `;
+ const apps = JSON.parse(await bridge.call('fetchWebApps'));
+ const grid = document.getElementById('web-grid');
+ const render = (list) => {
+ grid.innerHTML = list.map(a => `
+ `).join('');
+ grid.querySelectorAll('.wcell').forEach(el => el.onclick = () => bridge.call('openWebApp', el.dataset.id));
+ };
+ render(apps);
+};
+```
+
+- [ ] **Step 3: 构建验证**
+
+Run: `./gradlew assembleDebug`
+Expected: BUILD SUCCESSFUL
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add -A && git commit -m "feat: h5 webapp list page with rich cards"
+```
+
+---
+
+## Phase 3 · webAPP 多标签
+
+### Task 8: WebAppContainer(多 WebView + 标签导航)
+
+**Files:**
+- Create: `app/src/main/java/top/yeij/hearth/webapp/WebAppContainer.kt`
+- Create: `app/src/test/java/top/yeij/hearth/webapp/WebAppContainerTest.kt`
+
+**Interfaces:**
+- Produces: `WebAppContainer.open(id, url) / close(id) / switchTo(id) / tabs(): List / goBack() / goForward() / reload()`(Tab 状态逻辑纯 Kotlin,可脱离 Android 测试)
+- Consumes: WebApp 定义(Task 6)
+
+- [ ] **Step 1: 写标签状态纯逻辑 + 测试**
+
+把标签管理抽成纯 Kotlin 数据层(不依赖 WebView),`WebAppContainerTest.kt`:
+```kotlin
+package top.yeij.hearth.webapp
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class WebAppContainerTest {
+ @Test
+ fun open_addsTab_andBecomesActive() {
+ val c = WebAppContainer()
+ c.open("a", "http://x/a")
+ c.open("b", "http://x/b")
+ assertEquals(2, c.tabs().size)
+ assertEquals("b", c.tabs().first { it.active }.id)
+ }
+
+ @Test
+ fun close_removesTab() {
+ val c = WebAppContainer()
+ c.open("a", "http://x/a"); c.open("b", "http://x/b")
+ c.close("a")
+ assertEquals(1, c.tabs().size)
+ assertEquals("b", c.tabs()[0].id)
+ }
+
+ @Test
+ fun goBack_noHistory_returnsFalse() {
+ val c = WebAppContainer()
+ c.open("a", "http://x/a")
+ assertTrue(!c.goBack()) // 无历史
+ }
+}
+```
+
+- [ ] **Step 2: Run 验证失败**
+
+Run: `./gradlew testDebugUnitTest`
+Expected: FAIL
+
+- [ ] **Step 3: 写 WebAppContainer 数据层**
+
+```kotlin
+package top.yeij.hearth.webapp
+
+data class Tab(val id: String, val url: String, val active: Boolean = false)
+
+class WebAppContainer {
+ private val tabs = mutableListOf()
+
+ fun open(id: String, url: String) {
+ val existing = tabs.find { it.id == id }
+ if (existing != null) {
+ tabs.replaceAll { it.copy(active = it.id == id) }
+ return
+ }
+ tabs.replaceAll { it.copy(active = false) }
+ tabs.add(Tab(id, url, active = true))
+ }
+
+ fun close(id: String) {
+ tabs.removeAll { it.id == id }
+ if (tabs.isNotEmpty() && tabs.none { it.active }) {
+ tabs[0] = tabs[0].copy(active = true)
+ }
+ }
+
+ fun switchTo(id: String) {
+ tabs.replaceAll { it.copy(active = it.id == id) }
+ }
+
+ fun tabs(): List = tabs.toList()
+
+ fun goBack(): Boolean = false // 真实回退由 WebView 层实现,数据层仅占位
+ fun goForward(): Boolean = false
+ fun reload() {}
+}
+```
+
+注:数据层 `copy` 需用 `List.replaceAll` 或重建列表;`Tab` 的 `active` 用 `copy` 更新——修正为对 `MutableList` 使用索引替换。
+
+- [ ] **Step 4: Run 验证通过**
+
+Run: `./gradlew testDebugUnitTest`
+Expected: PASS
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add -A && git commit -m "feat: webapp container tab state management"
+```
+
+---
+
+### Task 9: webAPP 顶栏 H5 + 标签切换
+
+**Files:**
+- Create: `app/src/main/assets/h5/js/webapp/tabs.js`(纯函数状态,可测)
+- Create: `app/src/main/assets/h5/js/webapp/topbar.js`(顶栏 UI)
+- Create: `h5-test/tabs.test.js`
+
+**Interfaces:**
+- Produces: `window.tabsStore`(addTab/removeTab/switchTo/activeTab 纯函数);顶栏调用 `bridge.call('openWebApp'/'closeWebApp'/'switchTab'/'webGoBack'/'webGoForward'/'webReload')`
+
+- [ ] **Step 1: 写 tabs.js 纯函数 + 测试**
+
+`tabs.js`:
+```javascript
+window.tabsStore = {
+ tabs: [],
+ add(id, name) {
+ if (!this.tabs.some(t => t.id === id)) this.tabs.push({ id, name });
+ return this.tabs.map(t => ({ ...t, active: t.id === id }));
+ },
+ remove(id) {
+ this.tabs = this.tabs.filter(t => t.id !== id);
+ return this.tabs;
+ },
+ switchTo(id) {
+ return this.tabs.map(t => ({ ...t, active: t.id === id }));
+ }
+};
+```
+
+`h5-test/tabs.test.js`(Node `node:test`):
+```javascript
+const { test } = require('node:test');
+const assert = require('node:assert');
+const store = { tabs: [] };
+
+function add(tabs, id, name) {
+ if (!tabs.some(t => t.id === id)) tabs.push({ id, name });
+ return tabs.map(t => ({ ...t, active: t.id === id }));
+}
+function remove(tabs, id) { return tabs.filter(t => t.id !== id); }
+
+test('add makes tab active', () => {
+ let tabs = [];
+ tabs = add(tabs, 'a', '云音乐');
+ tabs = add(tabs, 'b', '天气');
+ assert.strictEqual(tabs.filter(t => t.active).length, 1);
+ assert.strictEqual(tabs.find(t => t.active).id, 'b');
+});
+test('remove deletes tab', () => {
+ let tabs = [{ id: 'a', name: 'x', active: false }, { id: 'b', name: 'y', active: true }];
+ tabs = remove(tabs, 'a');
+ assert.strictEqual(tabs.length, 1);
+});
+```
+
+- [ ] **Step 2: Run 验证失败**
+
+Run: `node --test h5-test/tabs.test.js`
+Expected: FAIL(`tabs.test.js` 尚未引用任何实现,直接定义函数——本测试为纯函数测试,无需外部实现;改为直接测试 `tabs.js` 逻辑,需 Node 环境 require)
+
+修正:`h5-test/tabs.test.js` 直接 `require('../app/src/main/assets/h5/js/webapp/tabs.js')` 不行(tabs.js 挂 `window`)。改法:tabs.js 用 UMD 导出:
+```javascript
+(function (root, factory) {
+ if (typeof module === 'object' && module.exports) module.exports = factory();
+ else root.tabsStore = factory();
+})(this, function () {
+ function add(tabs, id, name) { ... }
+ function remove(tabs, id) { ... }
+ return { add, remove };
+});
+```
+测试 `require` 后断言 `add/remove` 行为。
+
+- [ ] **Step 3: 写 topbar.js**
+
+```javascript
+window.showTopbar = function (tabs) {
+ let bar = document.getElementById('web-topbar');
+ if (!bar) {
+ bar = document.createElement('div');
+ bar.id = 'web-topbar';
+ bar.innerHTML = `
+
+
+
+
+ `;
+ document.body.appendChild(bar);
+ document.getElementById('tb-back').onclick = () => bridge.call('webGoBack');
+ document.getElementById('tb-fwd').onclick = () => bridge.call('webGoForward');
+ document.getElementById('tb-reload').onclick = () => bridge.call('webReload');
+ document.getElementById('tb-tabs').onclick = () => showTabPanel(tabs);
+ } else {
+ document.getElementById('tb-tabs').textContent = `标签 (${tabs.length})`;
+ }
+ const active = tabs.find(t => t.active);
+ document.getElementById('tb-title').textContent = active ? active.name : '';
+};
+
+function showTabPanel(tabs) {
+ // 简化的标签面板:列出所有标签,点击切换,X 关闭
+ let panel = document.getElementById('tab-panel');
+ if (!panel) {
+ panel = document.createElement('div');
+ panel.id = 'tab-panel';
+ document.body.appendChild(panel);
+ }
+ panel.innerHTML = tabs.map(t => `
+
+ ${t.name}
+
+
`).join('');
+ panel.style.display = 'block';
+}
+```
+
+- [ ] **Step 4: Run 测试 + 构建**
+
+Run: `node --test h5-test/tabs.test.js` 与 `./gradlew assembleDebug`
+Expected: 均 PASS/成功
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add -A && git commit -m "feat: webapp topbar and tab switching"
+```
+
+---
+
+## Phase 4 · 首页卡片系统
+
+### Task 10: CardRepository + 首页三栏骨架 + 大字时间卡
+
+**Files:**
+- Create: `app/src/main/java/top/yeij/hearth/card/CardRepository.kt`
+- Create: `app/src/main/assets/h5/js/cards/cards.js`(三栏降级布局纯函数)
+- Create: `app/src/main/assets/h5/js/pages/home.js`
+- Create: `h5-test/cards.test.js`
+
+**Interfaces:**
+- Produces: `CardRepository.fetchCatalog(): List`;`cards.layout(cards): {columns: [[cardId]]}`(三栏分配纯函数,含优先级与降级)
+- Consumes: CacheManager(Task 6)
+
+- [ ] **Step 1: 写 cards.js 三栏降级纯函数 + 测试**
+
+`cards.js`(UMD):
+```javascript
+(function (root, factory) {
+ if (typeof module === 'object' && module.exports) module.exports = factory();
+ else root.cards = factory();
+})(this, function () {
+ // cards: [{id, priority, enabled}],time-card 为内置始终保留
+ // 返回三栏分配结果,优先级数字小者优先;disabled 的卡不占位
+ function layout(cards) {
+ const columns = [[], [], []];
+ const active = cards.filter(c => c.enabled !== false).sort((a, b) => a.priority - b.priority);
+ active.forEach((c, i) => columns[i % 3].push(c.id));
+ return columns;
+ }
+ return { layout };
+});
+```
+
+`h5-test/cards.test.js`:
+```javascript
+const { test } = require('node:test');
+const assert = require('node:assert');
+const { layout } = require('../app/src/main/assets/h5/js/cards/cards.js');
+
+test('time card always in first column', () => {
+ const cols = layout([
+ { id: 'time', priority: 0 },
+ { id: 'media', priority: 1 },
+ { id: 'weather', priority: 2 },
+ { id: 'calendar', priority: 3 },
+ ]);
+ assert.strictEqual(cols[0][0], 'time');
+});
+test('disabled card skipped', () => {
+ const cols = layout([
+ { id: 'time', priority: 0 },
+ { id: 'media', priority: 1, enabled: false },
+ { id: 'weather', priority: 2 },
+ ]);
+ assert.strictEqual(cols[1][0], 'weather');
+});
+```
+
+- [ ] **Step 2: Run 验证失败**
+
+Run: `node --test h5-test/cards.test.js`
+Expected: FAIL
+
+- [ ] **Step 3: 写 CardRepository**
+
+```kotlin
+package top.yeij.hearth.card
+
+import com.google.gson.Gson
+import top.yeij.hearth.cache.CacheManager
+
+data class Card(val id: String, val name: String, val priority: Int, val entry: String)
+
+class CardRepository(
+ private val http: HttpClient,
+ private val cache: CacheManager,
+ private val catalogUrl: String,
+) {
+ private val gson = Gson()
+ fun fetchCatalog(): List {
+ val body = http.get(catalogUrl) ?: cache.load("card-catalog") ?: return builtin()
+ cache.save("card-catalog", body)
+ val root = gson.fromJson(body, Map::class.java)
+ val list = root["cards"] as? List<*> ?: return builtin()
+ return list.mapNotNull { m ->
+ (m as? Map<*, *>)?.let { Card(it["id"] as String, it["name"] as String,
+ (it["priority"] as Number).toInt(), it["entry"] as String) }
+ } + builtin()
+ }
+ private fun builtin() = listOf(Card("time", "大字时间", 0, "cards/time-card.html"))
+}
+```
+
+注:`HttpClient` 复用 Task 6 定义的接口,需 import `top.yeij.hearth.webapp.HttpClient`。
+
+- [ ] **Step 4: 写 home.js(三栏渲染 + 内置时间卡)**
+
+```javascript
+window.updateHomeCards = async function () {
+ const page = document.querySelector('[data-page="home"]');
+ page.innerHTML = ``;
+ const catalog = JSON.parse(await bridge.call('fetchCards'));
+ const cols = cards.layout(catalog);
+ cols.forEach((ids, i) => {
+ const col = document.getElementById(`col-${i}`);
+ ids.forEach(id => {
+ if (id === 'time') col.appendChild(renderTimeCard());
+ else col.appendChild(renderGenericCard(id));
+ });
+ });
+};
+
+function renderTimeCard() {
+ const el = document.createElement('div');
+ el.className = 'card time-card';
+ el.innerHTML = `14:30
8月16日 周六
`;
+ return el;
+}
+function renderGenericCard(id) {
+ const el = document.createElement('div');
+ el.className = 'card';
+ el.textContent = id;
+ return el;
+}
+```
+
+- [ ] **Step 5: Run 测试 + 构建**
+
+Run: `node --test h5-test/cards.test.js` 与 `./gradlew assembleDebug`
+Expected: 均 PASS/成功
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add -A && git commit -m "feat: card repository and home three-column layout"
+```
+
+---
+
+### Task 11: MediaSessionSource + 媒体卡
+
+**Files:**
+- Create: `app/src/main/java/top/yeij/hearth/media/MediaSessionSource.kt`
+- Modify: `app/src/main/java/top/yeij/hearth/webview/JsBridge.kt`(媒体事件推送)
+- Modify: `app/src/main/assets/h5/js/pages/home.js`(媒体卡渲染 + 降级)
+
+**Interfaces:**
+- Produces: `MediaSessionSource.start(listener: (MediaInfo?) -> Unit)`;`JsBridge` 通过 `HearthEvents.mediaSessionChanged(info)` 推给 H5
+- Consumes: `android.media.session.MediaSessionManager`
+
+- [ ] **Step 1: 写 MediaInfo 数据类 + 序列化**
+
+```kotlin
+package top.yeij.hearth.media
+
+data class MediaInfo(
+ val title: String, val artist: String, val album: String,
+ val playing: Boolean, val position: Long, val duration: Long,
+ val packageName: String,
+) {
+ fun toJson(): String = com.google.gson.Gson().toJson(this)
+}
+```
+
+- [ ] **Step 2: 写 MediaSessionSource 监听逻辑**
+
+```kotlin
+package top.yeij.hearth.media
+
+import android.content.Context
+import android.media.MediaMetadata
+import android.media.session.MediaController
+import android.media.session.MediaSessionManager
+import android.media.session.PlaybackState
+import android.util.Log
+
+class MediaSessionSource(context: Context) {
+ private val msm = context.getSystemService(Context.MEDIA_SESSION_SERVICE) as MediaSessionManager
+ private var listener: ((MediaInfo?) -> Unit)? = null
+
+ fun start(cb: (MediaInfo?) -> Unit) {
+ listener = cb
+ msm.addOnActiveSessionsChangedListener({ controllers ->
+ val c = controllers.firstOrNull()
+ if (c == null) { cb(null); return@addOnActiveSessionsChangedListener }
+ val meta = c.metadata
+ val state = c.playbackState
+ cb(MediaInfo(
+ meta?.getString(MediaMetadata.METADATA_KEY_TITLE) ?: "",
+ meta?.getString(MediaMetadata.METADATA_KEY_ARTIST) ?: "",
+ meta?.getString(MediaMetadata.METADATA_KEY_ALBUM) ?: "",
+ state?.state == PlaybackState.STATE_PLAYING,
+ state?.position ?: 0L,
+ meta?.getLong(MediaMetadata.METADATA_KEY_DURATION) ?: 0L,
+ c.packageName,
+ ))
+ Log.d("HearthMedia", "media: ${c.packageName}")
+ }, null)
+ }
+}
+```
+
+- [ ] **Step 3: JsBridge 推送媒体事件**
+
+`JsBridge.kt` 增加:
+```kotlin
+fun setMediaListener(source: MediaSessionSource, webView: android.webkit.WebView) {
+ source.start { info ->
+ webView.post {
+ val json = info?.toJson() ?: "null"
+ webView.evaluateJavascript("window.HearthEvents && window.HearthEvents.mediaSessionChanged($json);", null)
+ }
+ }
+}
+```
+
+- [ ] **Step 4: H5 媒体卡渲染 + 降级**
+
+`home.js` 增加:
+```javascript
+window.HearthEvents = window.HearthEvents || {};
+window.HearthEvents.mediaSessionChanged = function (info) {
+ const mediaCol = document.getElementById('col-1');
+ if (!mediaCol) return;
+ if (info) {
+ mediaCol.innerHTML = '';
+ mediaCol.appendChild(renderMediaCard(info));
+ } else {
+ // 无媒体:降级到日历/天气(由 cards.layout 重新计算)
+ window.updateHomeCards();
+ }
+};
+function renderMediaCard(info) {
+ const el = document.createElement('div');
+ el.className = 'card media-card';
+ el.innerHTML = `正在播放
+ ${info.title}
${info.artist}
+ `;
+ return el;
+}
+```
+
+- [ ] **Step 5: 构建 + 真机授权冒烟**
+
+Run: `./gradlew assembleDebug`;真机验证媒体播放时媒体卡出现、停止时降级。
+Expected: 构建成功
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add -A && git commit -m "feat: media session source and media card"
+```
+
+---
+
+## Phase 5 · 设置页 + 收尾
+
+### Task 12: 设置页
+
+**Files:**
+- Create: `app/src/main/assets/h5/js/pages/settings.js`
+
+**Interfaces:**
+- Consumes: `bridge.call('getDeviceInfo')`(主题/版本展示)
+
+- [ ] **Step 1: 写 settings.js(Miuix Preference 分组)**
+
+```javascript
+window.loadSettings = function () {
+ const page = document.querySelector('[data-page="settings"]');
+ if (page.dataset.loaded) return; page.dataset.loaded = '1';
+ page.innerHTML = `
+ 显示
+ 跟随系统主题
+ 屏幕亮度
+ 网络
+ webAPP 服务器地址›
+ 检查更新›
+ 关于
+ 版本 0.1.0›
`;
+};
+```
+
+- [ ] **Step 2: router 接 settings**
+
+`router.js` 的 `navigate` 内 `id === 'settings'` 时调 `window.loadSettings()`。
+
+- [ ] **Step 3: 构建验证**
+
+Run: `./gradlew assembleDebug`
+Expected: BUILD SUCCESSFUL
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add -A && git commit -m "feat: settings page"
+```
+
+---
+
+### Task 13: 集成收尾(错误处理 + 真机验证清单)
+
+**Files:**
+- Modify: `app/src/main/java/top/yeij/hearth/MainActivity.kt`(WebView 加载失败错误页 + Back 键接入 WebAppContainer)
+- Modify: 各 H5 页(加载失败提示 + 重试)
+
+**Interfaces:**
+- Consumes: WebAppContainer(Task 8)、WebViewManager(Task 2)
+
+- [ ] **Step 1: MainActivity 接入 WebView + 错误页 + Back 键**
+
+```kotlin
+class MainActivity : android.app.Activity() {
+ private lateinit var webView: android.webkit.WebView
+ private val webAppContainer = WebAppContainer()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enterImmersive()
+ val metrics = resources.displayMetrics
+ val dark = (resources.configuration.uiMode and
+ android.content.res.Configuration.UI_MODE_NIGHT_MASK) ==
+ android.content.res.Configuration.UI_MODE_NIGHT_YES
+ val bridge = JsBridge(metrics.widthPixels, metrics.heightPixels, metrics.density, dark,
+ appRepository = AppRepository(AndroidAppSource(this)),
+ webAppRepository = WebAppRepository(OkHttpHttpClient(), CacheManager(filesDir), "https://example/hearth/manifest.json"),
+ cardRepository = CardRepository(OkHttpHttpClient(), CacheManager(filesDir), "https://example/hearth/cards.json"))
+ webView = WebViewManager(this).attach(bridge)
+ webView.webViewClient = object : android.webkit.WebViewClient() {
+ override fun onReceivedError(v: android.webkit.WebView?, code: Int, desc: String?, url: String?) {
+ v?.loadDataWithBaseURL(null, "加载失败
${desc}
", "text/html", "utf-8", null)
+ }
+ }
+ }
+
+ override fun onBackPressed() {
+ if (webAppContainer.tabs().isNotEmpty()) {
+ if (!webAppContainer.goBack()) webAppContainer.close(webAppContainer.tabs().first { it.active }.id)
+ }
+ // 桌面态:吞掉
+ }
+}
+```
+
+- [ ] **Step 2: 全量测试 + 构建**
+
+Run: `./gradlew testDebugUnitTest assembleDebug` 与 `node --test h5-test/*.test.js`
+Expected: 全部 PASS / BUILD SUCCESSFUL
+
+- [ ] **Step 3: 真机验证清单(逐项打勾)**
+
+- [ ] adb 安装 APK,设为默认桌面
+- [ ] 横屏 + 全屏沉浸式(下滑一次出状态栏、几秒收起)
+- [ ] Back 键桌面态无响应
+- [ ] 侧边栏 5 页切换;非首页顶部显示时间
+- [ ] 安卓 APP 列表显示本地应用并可启动
+- [ ] H5 应用列表拉取远程清单(需配置服务器)显示富卡片
+- [ ] webAPP 打开 → 顶栏 → 回退/前进/重载/多标签切换关闭
+- [ ] 首页三栏:大字时间始终、媒体卡随播放出现/消失、日历天气填充
+- [ ] 夜间模式跟随系统切换
+- [ ] 断网重开:webAPP 走本地缓存
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add -A && git commit -m "feat: integration, error handling, and verification checklist"
+```
+
+---
+
+## Self-Review 记录
+
+1. **Spec coverage**:架构(T1-3 原生、T4 H5 壳)✓;JS Bridge(T2 骨架 + T5/7/9/11 扩展)✓;卡片系统(T10-11)✓;webAPP 下发(T6)+ 多标签(T8-9)✓;侧边栏/夜间/顶部时间(T4)✓;Back 键(T1+13)✓;设置页(T12)✓;二期 freeform 未纳入(符合"二期不做")。缺口:`CardRepository` 依赖的 `HttpClient` 定义在 Task 6 的 webapp 包,Task 10 已注明 import,一致。
+2. **Placeholder scan**:无 TBD/TODO;所有代码步骤含实际代码。
+3. **Type consistency**:`WebApp`/`Tab`/`Card`/`MediaInfo` 定义一致;`bridge.call` 方法名(`openWebApp`/`closeWebApp`/`switchTab`/`webGoBack`/`webGoForward`/`webReload`)与 JsBridge 侧一一对应。
+
+> 实现期需注意:Task 6 的 `fetchManifest_success` 测试示例与 `failure_usesCache` 有重叠逻辑,实现时按 Task 6 Step 4 注释拆开写清晰。