Kototoro iOS / Windows / KMP 方案(2026-10)
文档信息
- 创建日期:2026-10-02
- 状态:规划草案;P0 中的 S1(KMP 工具链)与 S2(Room KMP)已完成并通过(§9、§10)。P1 已完成四个模块的抽取:
:reader-core、:parser-api(叶子类型,§11)、:core-db(§12)和:core-backup(§13)。:core-domain已抽取同步/状态规则、四类列表快照与派生、书架、换源写入计划、进度与标题匹配规则(§14–§18); 完整业务服务仍待迁移。:core-net已抽取 Google Drive、WebDAV、MALSync 和 MAL 公共查询 HTTP 协议, 并完成 Android 引擎装配与契约回归(§19–§22); XML/JSON 平台投影、文件 IO 与 iOS 引擎由平台负责,其余 tracker/scrobbler 待迁移。:core-source已实现共享 DTO、版本化 JSON 客户端/入口与 Android 适配器(§24); P2 的:source-host已实现 Mihon JAR 检查/注册、内容执行、动态过滤与章节图片执行/物化(§25–28); Windows 同步纳入目标,已补上常驻 JSONL 调试入口(§29)、持久化数据库(§30)、偏好存储(§31) 和原生标准 PreferenceScreen 控制树/变更协议(§32);可选:mihon-desktop-compat已提供基础 Windows 平台初始化及真实 API 离线测试(§33),WebView2 离线 HTML/JS/Cookie 桥(§34), 可选 Compose Desktop 的浏览/搜索/详情/收藏/章节图片/历史/标准设置入口(§35)。 完整桌面能力/安装包/其余平台服务及 iOS 真机源验收仍未完成;iOS 模拟器 C/JNI/Zero 见 §91, 真实 APK 详情与基本图片阅读见 §92。 S0(真机评估)、S3(许可证)尚未开始 - iOS 原生开发已启动(2026-10-10,§90):新增
ios-shared与 SwiftUIiosApp,共享模块的模拟器目标 原生编译/framework 链接、Room v84 持久化和 SwiftUI 分类创建/重启恢复已验证。 模拟器嵌入式 JVM、共享源协议与真实再漫画 APK 列表已打通(§91),详情/封面/章节与基本图片阅读 已验证(§92);完整阅读场景与 G0/G2 真机门槛仍待。 - 目标:共享一个 KMP 内核,同时支持 iOS 与 Windows;iOS 用 SwiftUI 外壳和嵌入式 JVM,Windows 用 JVM 桌面外壳并优先承载调试。源(Mihon JAR、Kotatsu 解析器、Legado 规则)复用同一个 JVM host。
- 前提假设:az4521 的 TachiyomiAZ iOS 路线(OpenJDK Mobile Zero + Suwayomi AndroidCompat)在真机上 可用。该前提尚未由我们验证,由 §5 的 G0 门槛负责证实或证伪
- 关联文档:
- iOS 开发阶段交接记录(§90–§92 的接手入口与本地复验指南)
- Scene Reader 改进计划(§8.2 曾约定"无真实多平台消费者之前不引入 KMP")
- Browser Transport 实施计划(Cloudflare 策略选择器)
- Legado Runtime 抽取方案
- 外部扩展集成指南
1. 决策摘要
- 共享 KMP 内核 + iOS 原生外壳 + Windows JVM 桌面外壳 + JVM 源运行时。 全量 KMP/Compose Multiplatform 不现实:代码库 35.7 万行里,现在能直接进 commonMain 的只有约 6~9%(§2.1)。
- iOS 版就是 §8.2 所说的"真实多平台消费者",KMP 在此立项。 首个模块是已经纯 Kotlin 的
reader-core(58 个文件、2638 行)。 - 源不用 Kotlin 重写,在 iOS 上由一个嵌入式 JVM 统一承载(§2.2):Mihon JAR、Kotatsu 解析器、 Legado 运行时都是 JVM 代码。上游 Keiyoushi 已为每个 APK 发布同名 JAR,Kotlin/JS 或源码级编译 路线因此失去前提。
- Android 现有的进程内源加载保持不变。 JVM host 与它共用同一份协议,用于 iOS、Windows 与契约测试。
- 共享模块不依赖 DI 框架(只用构造注入);Android 继续用 Hilt 装配,iOS 手动装配。
- v1 只做漫画;iOS 只做侧载分发(AltStore / SideStore)。Windows 单独做桌面调试/应用分发。 小说、动漫、视频、翻译后置;Windows 优先顺序与验收见 §29。
2. 依据
2.1 代码库可移植性画像
对 app、parser-api、reader-core 共 2319 个 Kotlin 文件、357,508 行做静态扫描:按 import 与 同包标识符引用建依赖图,并对"依赖了不可移植文件的文件"做传递闭包。
| 口径 | 文件 | 占比 | 代码行 | 占比 |
|---|---|---|---|---|
| L0 严格(仅 kotlin/kotlinx/okio/ktor/coil3/Room/Paging 等 KMP 可用库) | 518 | 22.3% | 22,236 | 6.2% |
| L1 + 忽略 DI 注解 | 539 | 23.2% | 22,974 | 6.4% |
L2 + java.* 可用 expect/actual 或 stdlib 替换 | 624 | 26.9% | 27,042 | 7.6% |
| L3 + Compose 若采用 CMP | 694 | 29.9% | 32,993 | 9.2% |
按包(L1 / L2):core/db 92%;core/prefs 66% / 68%;parser-api 34% / 46%(阻塞项:okhttp 33 个 文件、jsoup 16 个);backups 29% / 35%;core/model 23% / 27%;core/network 11% / 20% (okhttp 33 个文件);reader 18% / 19%(主体是 Android 与 Compose)。
局限:这是上界,未考虑反射、资源文件、测试代码;同包引用基于标识符匹配,存在少量误判。
更正: 上表中 core/db 的 92% 只统计该目录内的文件,会低估数据库抽取的范围。实际的 Room schema 是 MangaDatabase + 36 张表对应的实体 + 约 35 个 DAO 文件,其中大部分实体和 DAO 分散在约 15 个功能包 (history/data、favourites/data、tracker/data、space/data 等),不在 core/db 里。S2 实测这个 schema 层的闭包是 96 个文件、约 5000 行非空行(§9),所以数据库抽取是跨包搬迁,而不是移动一个目录。 同样,直接用 import 与同包引用做传递闭包会得到 1822 个文件的误导性结果,真正的依赖由编译器给出才可靠。
2.2 源生态与嵌入式 JVM
Keiyoushi 的 index.json 为 1396 个扩展各提供一个 jarUrl(与 APK 同名,带 sha256),其中 extension-lib 1.6 有 1323 个、1.4 有 73 个。我们下载了 1395 个(tr.trmanga 的下载返回 HTTP 500)。 环境:Suwayomi-Server eb2dc0b(固定 commit)+ az 的 iOS 补丁 + az 脚本固定的 36 个运行时 JAR + az 的 MobileCompatShims(TachiyomiAzIOS @ b86cead),JDK 镜像用 jlink 裁成与 iOS 相同的 4 个模块(java.base、java.xml、jdk.crypto.ec、jdk.unsupported)。
| 测试 | 结果 |
|---|---|
| 静态:解析 class 常量池,对照 iOS JDK 镜像 / AndroidCompat / az shim | 1386 / 1395(99.4%)可满足;干净 322,加小型 shim 1051,加 UI stub 1163,加 QuickJs 1189,加 WebView 1223,加图形 1386;静态失败 9 个 |
动态:加载、构造、listSources、getSearchFilters、getSettings | 1389 / 1395(99.6%)。完整 JDK 与 4 模块镜像结果一致,没有只在 4 模块镜像上出现的失败 |
| 联网:每个 JAR 第一个 source 请求第 1 页 popular | 通过 750(53.8%);站点有响应且能判定的 793 个中通过率 94.6% |
动态 6 个失败:3 个构造时创建 WebView(缺 WKWebView 原生桥,桌面无法验证),3 个调用了 Android stub(SpannableString、ColorMatrix、Preference.setOnPreferenceClickListener)。
联网未通过的构成:Cloudflare 挑战 220(15.8%)、连不上或 HTTP 错误 329(23.6%)、需要原生桥 53 (3.8%:52 个 WebView + 1 个 JS 引擎)、空结果 33(2.4%)、其它异常 9(0.6%)、兼容层缺陷 1(0.1%)。
这些数字不能证明什么: 只覆盖构造阶段与第 1 页列表;没有核对内容是否正确;只测了每个 JAR 的第一个 source;延迟是桌面 JIT 加单一网络视角,不代表 iOS 的 Zero 解释器;没有 Android 基线对照;静态名字比对 有盲区(方法名存在但函数体是 stub)。
2.3 参考实现:az4521 / TachiyomiAZ
- 两个仓库:
TachiyomiAzIOS(Swift,Aidoku 的 fork,2026-08-06 创建、2026-08-14 归档)与TachiyomiAZmonorepo(Android + KMP 共享模块 + SwiftUI 的iosApp,持续更新)。 - 路线:OpenJDK Mobile 26 Zero(纯解释器)+ Suwayomi AndroidCompat + 自有 iOS shim(主 Looper、 WebView→WKWebView、CookieManager→WKHTTPCookieStore、图形/文本、QuickJs);Swift 经 JNI 以 JSON 调用 Java 侧
ExtensionHost;每个扩展一个URLClassLoader(同进程,非安全边界)。 - 分发:仅 AltStore / SideStore,README 明确不上 App Store / TestFlight。
- CI(GitHub Actions):Linux job 构建兼容 JAR;macos-14 + Xcode 15.4 + JDK 24 构建 OpenJDK 镜像并缓存; macos-26
xcodebuild … CODE_SIGNING_ALLOWED=NO出未签名 IPA(83.2 MB)。IPA 工作流近 12 次成功 10 次, 缓存命中 8~12 分钟、冷启动 32 分钟;monorepoios.yml近 10 次成功 9 次。 - 许可:应用 GPLv3;Suwayomi MPL-2.0;OpenJDK 补丁源自 GPLv2 + Classpath 例外。
- 作者自述"可以正常使用"(Discord,未核实,范围不明)。monorepo 的
IOS_PORT.md(2026-08-17)自述 "源"仍是未完成的大头,并记录标准运行时无法在模拟器启动,需给 OpenJDK 打 3 处源码补丁。
2.4 对 §8.2 的修订
reader-scene-improvement-plan §8.2 的约束是"无真实多平台消费者之前不为假想未来付 KMP 工具链复杂度"。 iOS 版是真实消费者,该条件满足。§8.2 其余约束保留:不创建空壳接口或一对一转发层;:reader-core 的 I1 边界(零 Android/Compose/渲染器依赖)不变,只是实现方式从"纯 JVM 模块"变为"KMP 模块"。
3. 目标架构
commonMain(KMP 共享内核)
reader-core 纯 Kotlin,已存在,最先转 KMP
core-model Content / Chapter / Page 模型(parser-api 去掉 okhttp、jsoup 依赖)
core-source SourceRuntime 接口 + 协议 DTO(kotlinx.serialization)
core-db Room KMP,v84 schema
core-domain 历史、收藏、更新规则、迁移规则
core-backup 备份格式、Mihon / Aniyomi 备份解码(protobuf)
core-net Ktor:tracker、WebDAV
androidMain Hilt 装配、现有进程内 loader、OkHttp、WebViewClearanceSolver
iosMain Ktor Darwin、BrowserTransport(WKWebView)、JvmSourceRuntime(C 桥 → 嵌入式 JVM)
desktop/JVM 同一共享内核、Room JVM、文件/网络/偏好平台服务、同一 JVM host
JVM host(平台无关) Mihon JAR + AndroidCompat / Kotatsu parsers / Legado runtime,统一协议
iosApp SwiftUI + 共享 XCFramework
desktopApp Windows Compose Desktop 外壳(待实现);JSONL host 调试入口已可运行原则:
- 共享模块只用构造注入;DI 框架只出现在各平台的装配层。
- 源协议只有一份。Android 继续用现有进程内适配器,JVM host 的契约测试在桌面 JVM 上运行 (Linux CI,不需要 Mac)。
- 平台差异只放在
expect/actual或平台模块,不在共享模块里写if (android)。
4. 待决策项
| # | 决策 | 建议 |
|---|---|---|
| D1 | iOS UI 方案 | 原生 SwiftUI。Android UI、Media3 与原生服务需平台适配;Backdrop 上游已有 KMP/Skiko 实现,可用于 Windows CMP,但不能直接接入 SwiftUI(§55) |
| D2 | 分发方式 | 仅侧载。含下载并执行字节码的嵌入式 JVM 难以通过 App Store 审核(请核对审核指南原文) |
| D3 | 许可证 | iOS 外壳单独成 GPLv3 模块,可借用 az 的桥与 CI;共享内核保持现有许可。注意 Kotatsu 是 GPL-3.0,而根目录 LICENSE 是 Apache-2.0,请先确认现状 |
| D4 | v1 范围 | 仅漫画(Mihon JAR + Kotatsu 解析器);小说、动漫、翻译、视频后置 |
| D6 | Content / ContentChapter / ContentPage 的二进制兼容垫片 | 这三个类带着给旧版外部解析器 jar 用的 JVM 垫片(手写的 (…, mask, DefaultConstructorMarker) 合成构造器、copy$default,注释写明"parsers compiled before sourceData"),无法进入 commonMain。要么保留垫片、三个类留在 androidMain(iOS 侧用协议 DTO),要么在外部解析器 jar 全部重编后放弃垫片。这是影响外部生态的决定,建议先保留,等你确认旧 jar 的存量再定 |
| D7 | java.util.Locale 出现在公开 API | ContentListFilter、SearchableField 的公开类型是 Locale。建议 expect class Locale + Android 上 actual typealias(Android 二进制不变),iOS 侧另写实现;需要你确认该 API 形状可以改成 expect |
| D5 | 共享数据库 | Room KMP(DAO 无需重写)。S2 已验证 Android 与 JVM 目标:v84 schema 与现有 84.json 完全一致,旧迁移链不需要改;iOS 目标待 macOS 验证 |
5. 阶段与门槛
相对工作量:S 小、M 中、L 大、XL 很大。
| 阶段 | 内容 | 工作量 | 退出条件 |
|---|---|---|---|
| P0 验证(不动主线) | S0:fork az 的 CI,AltStore 装机做真机评估,并向作者核实 S1:KMP 工具链(见 §9) S2:Room KMP——新建 v84 schema,且 Android 侧 85 个迁移(约 298 处 execSQL)仍通过 MigrationTestHelper(已通过,见 §9)S3:许可证决定(D3) | S~M | G0:真机上的冷启动、内存、五类场景(纯 HTML、JSON API、Cloudflare、图像解扰、带 JS)数字;S1、S2 通过。任一不过则方案回炉 |
| P1 抽共享内核 | 顺序:reader-core → core-model → core-db → core-domain/core-backup → core-net。每步加元数据编译守卫,Android 测试全绿 | L | 各模块 compileCommonMainKotlinMetadata 通过,Android 构建与测试不回退 |
| P2 源运行时 | 定义 SourceRuntime 协议;JVM host 模块;iOS 侧 C 桥;macOS CI 构建 OpenJDK 镜像 | L | G2:iOS 真机跑通 Keiyoushi 抽样(覆盖 G0 的五类场景) |
| P3 iOS 最小应用 | 库、浏览、搜索、详情、阅读、历史;Kototoro 与 Mihon .tachibk 备份恢复;WebDAV | XL | 能读完一部漫画,并恢复 Android 备份 |
| P4 对齐 | 下载、更新检查、tracker、WKWebView 的 Cloudflare 求解、图像解扰 shim、偏好投影 | XL | 功能对齐清单 |
| P5 可选 | 小说(Legado 走 JVM)、翻译(Vision OCR + 共享 MT)、视频(AVPlayer) | 按需 | — |
6. 模块抽取顺序与守卫
reader-core:已纯 Kotlin,仅依赖kotlin.math,25 个测试文件,隔离守卫测试已禁止java.导入。core-model:不新建模块,直接把:parser-api原地转为 KMP(步骤与结果见 §11)。其中org.koitharu.kotatsu.parsers.*是外部解析器 jar 的二进制 ABI,原样留在 androidMain。ContentRepository里的LocalMangaSource/TestContentSource需要上提为抽象。core-db:已完成(§12)。schema 层闭包约 96 个文件、5000 行,分散在约 15 个功能包,已按原包名跨包搬进:core-db(实体、DAO、约 14 个投影类*Row/*WithContent)。S2 实测的切断清单(14 个文件的 1~9 行机械 改动 + 4 个类型拆分)已全部落地;唯一的设计层面改动是ListFilterOption:DAO 用它拼 SQL,但它带资源 id 与图标,现在拆成数据类型ListFilterCriteria,应用在 Repository 边界转换。85 个迁移留在androidMain, iOS 直接从 v84 schema 建库。core-domain、core-backup:备份格式已完成抽取(§13):Kototoro JSON 模型、Mihon/Aniyomi protobuf 解码与 Usagi 格式模型进入:core-backup;Android 文件读取、ZIP、Venera SQLite 解码与恢复编排留在:app。core-domain已抽取内容身份、同步快照、历史/收藏冲突合并、内容导入、更新状态比较(§14), 四类列表的快照/派生与书架(§15–§16),换源写入计划与章节映射(§17),以及进度、历史恢复与标题匹配规则(§18)。 UI 资源、词库、历史语言分支选择、Unicode/Locale 适配、事务与完整仓库仍由应用负责。core-net:Google Drive 同步、WebDAV、MALSync 映射与 MAL 公共查询 HTTP 协议进入 Ktor commonMain(§19–§22)。 Android 保留原网络装配、授权/刷新链、平台解析、文件 IO、备份清理与缓存;其余 tracker/scrobbler 待迁移。
守卫:共享逻辑放在 commonMain,平台实现留在各自 source set;compileCommonMainKotlinMetadata 作为可移植性检查(JVM 编译 会放过 JVM 专有 API,元数据编译不会)。该检查在 Windows 与 Linux 上即可运行;链接 iOS 二进制只能在 macOS 上。
7. 风险与未验证项
- 性能与内存:Zero 解释器无 JIT,我们没有真机数据;G0 是硬门槛。
- 进程内无隔离:扩展崩溃即整个 App 崩溃;HotSpot 不能原地重建 VM,只能靠看门狗加整体重启。
- OpenJDK Mobile 构建脆弱:依赖 macos-14、Xcode 15.4、JDK 24 与源码补丁,按脚本哈希缓存。
- 图形与 WebView:图像解扰约 12% 的源,WebView 约 3.8%,Cloudflare 约 15.8%,只能真机验证。
- iOS 上的 Room 未验证:S1/S2 已在 Windows 上验证 KMP 插件、AGP 9.3.1 的 KMP 库插件、Room KMP 的 Android 与 JVM 目标。iOS 目标的 KSP 代码生成、链接与运行需要 macOS(Windows 上 Apple 原生任务被禁用)。 JVM 目标与 iOS 走同一套非 Android 的代码生成路径,可作代理,但不等于验证。
- Android 不能换 SQLite 驱动:旧迁移依赖框架驱动;若改用
BundledSQLiteDriver,85 个迁移必须重写为migrate(SQLiteConnection),否则会抛NotImplementedError。 - 长期维护两套 UI。
- 许可证:见 D3。
8. 不做什么
- 不做全量 Compose Multiplatform,不把现有 Compose UI 搬到 iOS。
- 不走 App Store 分发。
- v1 不含小说、动漫、视频、翻译、超分。
- 不替换 Android 现有的进程内源加载器。
- 不复制 az 的 GPLv3 代码进共享内核。
9. S1 执行记录(KMP 工具链)
日期:2026-10-02。分支:feat/kmp-s1-reader-core(基于 devel 的 fa6500c41,未提交)。 环境:Windows 11、JDK 17、Gradle 9.7.0、AGP 9.3.1、Kotlin 2.4.0,KONAN_DATA_DIR 指向 E 盘。
改动
reader-core转为 KMP 模块:src/main/kotlin→src/commonMain/kotlin,src/test→src/jvmTest;build.gradle改用org.jetbrains.kotlin.multiplatform,目标为jvm()、iosArm64()、iosSimulatorArm64()。-Xjspecify-annotations=strict只留在jvm目标上。ReaderCoreIsolationGuardTest里硬编码的源码与 fixture 路径同步更新。- 元数据编译抓到并修复了 3 处 JVM 专有用法:
PageId.kt的@JvmInline缺import kotlin.jvm.JvmInline;PagedReaderScene.kt两处Map.putIfAbsent换为语义等价的getOrPut。
结果
| 检查 | 结果 |
|---|---|
:reader-core:compileCommonMainKotlinMetadata | 首次失败 3 处(见上),修复后通过 |
:reader-core:jvmTest | 20 个测试类、150 个测试、0 失败,与转换前基线一致(含隔离守卫测试) |
:app:compileDebugKotlin | 通过,0 个编译错误;冷启动 4 分 11 秒,应用消费 jvm 变体 |
实验:再加 com.android.kotlin.multiplatform.library(androidLibrary {}) | 元数据编译、jvmTest、assemble 通过,产出 reader-core.aar 与 iosArm64/iosSimulatorArm64 元数据 jar;:app:compileDebugKotlin 通过(消费 Android 变体) |
Windows 上的行为:Apple 原生任务(如 iosSimulatorArm64Test)被禁用并给出警告,可用 kotlin.native.ignoreDisabledTargets=true 消除;但 iOS / Apple / Native 的元数据与 commonizer 任务 可以运行。因此可移植性检查在 Windows 与 Linux 上即可执行,只有链接与运行 iOS 二进制需要 macOS。
结论
S1 通过:KMP 插件、AGP 9.3.1 与 Kotlin 2.4.0 在本仓库中共存,commonMain 元数据编译能有效拒绝 JVM 专有 API,Android 应用可以继续消费该模块。
决定:reader-core 保持 jvm() 目标,不引入 androidLibrary(该模块没有 androidMain 需求)。 待首个需要 androidMain 的模块(如 core-db)再采用 AGP 的 KMP 库插件,上述实验证明其可用。
副作用
KMP 之后 :reader-core:test 不再存在,对应任务是 :reader-core:jvmTest(聚合任务为 allTests)。reader-scene-improvement-plan §10 中两处"当前该运行的命令"已更新;其余为历史验收记录, 保持原样。
未覆盖
- 未在 macOS 上编译、链接或运行 iOS 目标(commonMain 中没有 iosMain 代码,目前只验证了元数据层)。
- 未运行
:app:testDebugUnitTest全量与 instrumented 测试,只验证了应用的 Kotlin 编译与reader-core的测试。 - 未在 Linux CI 上运行;仓库现有 CI 工作流未改动,也未验证它们是否引用旧的
:reader-core:test(本次在.github、脚本与文档中未搜到其它引用)。 - S0(真机评估)、S3(许可证)尚未开始。
10. S2 执行记录(Room KMP 与 85 个迁移)
日期:2026-10-02。模块:spikes/room-kmp,由 -PwithKmpSpikes 门控(settings.gradle 新增 4 行,默认关闭, 普通构建、Android Studio 同步与 CI 都不会配置它)。
后续: 该 spike 已被真正的
:core-db取代并从仓库中删除(§12),它的 JVM 冒烟测试和迁移链测试分别迁到了core-db/src/jvmTest与app/src/androidTest。下面的记录保留作为依据。
方法
把 schema 层复制(不移动)到一个 KMP 模块:96 个 commonMain 文件(约 5000 行非空行),包括 MangaDatabase、36 张表对应的实体、约 35 个 DAO 文件、DAO 的投影类和少量辅助;85 个迁移原样复制到 androidMain。然后让 Kotlin 编译器与 Room KSP 指出需要切断的依赖,一轮一轮收敛: 首轮 215 个编译错误 → 26 → 11 → 0,再到 Room KSP 通过。
结果
| 检查 | 结果 |
|---|---|
| commonMain 元数据编译 | 0 个错误 |
| Room KSP(JVM、Android 目标) | 通过;Room 在编译期校验了全部 DAO 的 SQL |
导出的 v84 schema 对比 app/schemas/…/84.json | 完全一致:identityHash 相同(08b58827…)、36 张表逐表相同、setupQueries 相同 |
JVM DAO 冒烟测试(BundledSQLiteDriver,内存库) | 2/2 通过:RoomRawQuery 的 suspend 与 Flow 路径、@Transaction 的 open 方法、@Upsert、@Insert(IGNORE) |
| Android 设备测试(模拟器 API 35):旧迁移链 | 3/3 通过:按现有 23 个 schema 快照分 22 段(32→33→…→84)全部通过 runMigrationsAndValidate;负控制(空的 83→84 迁移)被校验拒绝,说明测试不是空转 |
默认构建(不带开关):reader-core 元数据编译与 jvmTest | 通过,spike 未被配置 |
| iOS 目标的 KSP、链接、运行 | 未验证(Windows 上 Apple 原生任务被禁用) |
切断清单(把 schema 放进 commonMain 所需的全部改动)
96 个文件中 18 个与原文件不同:
| 类别 | 内容 |
|---|---|
| 14 个文件的小改动(每个 1~9 行) | System.currentTimeMillis() → kotlin.time.Clock(6 个文件);javaClass → ::class;SupportSQLiteQuery / SimpleSQLiteQuery → RoomRawQuery(5 个文件,所有原调用都不带绑定参数);DatabaseUtils.sqlEscapeString → 本地函数;BuildConfig.VERSION_CODE → 注入的常量;java.util.LinkedList → ArrayList;@SuppressWarnings → @Suppress;option.category.id → option.categoryId(3 处) |
| 4 个类型拆分 | ListFilterOption:带 @StringRes/@DrawableRes、图标和各类 source 展示逻辑的 UI 向类型,却被 3 个 DAO 与 MangaQueryBuilder 用来拼 SQL,需要拆成纯数据的过滤条件(本次 210 行 → 22 行);SourcesSortOrder:去掉 @StringRes;ScrobblingStatus:去掉 UI 的 ListModel 接口;CloudFlareHelper:只保留 3 个状态常量,其余依赖 OkHttp 与 Jsoup |
MangaDatabase | 声明进 commonMain(加 @ConstructedBy 与 expect 构造器);迁移列表与 Room.databaseBuilder 进 androidMain;依赖 Android 生命周期的 removeObserverAsync 留在 app |
| 需随 schema 一起搬的投影类 | 约 14 个 *Row、*WithContent 等,与 DAO 同包、没有 import,静态依赖图没有发现,是编译器报出来的 |
发现
- schema 层很小但分散。 36 张表对应的实体与约 35 个 DAO 分布在约 15 个功能包;
core-db抽取是跨包搬迁。 - 只有一处设计层面的耦合: DAO 层依赖 UI 向的
ListFilterOption。其余全是机械替换。 - 旧迁移不需要改。 Room 2.8.4 的
Migration.migrate(SQLiteConnection)对SupportSQLiteConnection委托给旧的migrate(SupportSQLiteDatabase);Android 继续用框架驱动即可。若 Android 改用BundledSQLiteDriver,旧迁移会抛NotImplementedError,必须重写。 - 既有问题(与 KMP 无关):
app/androidTest里的MangaDatabaseTest.migrateAll要从1.json开始, 而仓库只保留了 23 个 schema 快照(32–37、39、40、42、68–73、76–80、82–84),所以该测试本来就无法通过。 上面的分段测试是在现有快照上做的等价验证。 - 单靠 import 和同包标识符做依赖闭包会严重高估(1822 个文件,实际 96 个),要以编译器为准。
结论
S2 通过(Android 与 JVM 目标)。D5 采用 Room KMP。iOS 目标需要在 macOS 上补验 KSP 代码生成与链接。
未覆盖
- 未在 macOS 上运行 iOS 目标;iOS 的数据库构建器(
BundledSQLiteDriver、数据库文件路径)尚未编写。 - spike 是副本,
:app仍使用自己的MangaDatabase;DatabasePrePopulateCallback(依赖应用资源)未纳入。 - DAO 行为测试只覆盖
MangaSourcesDao的 6 条路径,没有覆盖其余 DAO 与复杂的关系查询。 - 没有测性能(框架驱动与
BundledSQLiteDriver的差异)。 - schema 一致性是对 v84 做的;v84 之前的版本只验证了迁移链能走通并通过 Room 的校验。
11. P1 执行记录::parser-api 转为 KMP
日期:2026-10-02。分支:feat/kmp-p1-parser-api-model(基于 S1/S2 的三个提交)。
做了什么
- 原地转换(提交
build(parser-api): convert to a Kotlin Multiplatform module):模块改用org.jetbrains.kotlin.multiplatform+com.android.kotlin.multiplatform.library,目标为 androidLibrary、 iosArm64、iosSimulatorArm64;所有源码原样从src/main移到src/androidMain,对使用方没有变化。kotlin-parcelize因没有任何@Parcelize用法而去掉;okhttp、okio、jsoup、rhino、androidx.collection继续作为 androidMain 的api依赖暴露,因为:app在传递使用它们。 - 叶子类型进入 commonMain:
ContentSource、ContentState、ContentType、ContentRating、SortOrder、ContentTag、ContentTagGroup、Demographic、EbookFormat、NovelChapterContent、WordSet、ContentListFilterCapabilities、Constants和InternalParsersApi,共 14 个文件。改动只有: 为@JvmField/@JvmStatic/@JvmOverloads补import kotlin.jvm.*(common 不会默认导入)、Constants.kt的文件级@file:JvmName写成全限定名、ContentTag去掉一个只用于 KDoc 的 import。
验证
| 检查 | 结果 |
|---|---|
:parser-api:compileCommonMainKotlinMetadata(common + iOS 元数据) | 通过 |
:parser-api:assemble | 通过 |
:app:compileDebugKotlin | 通过,0 个编译错误 |
| 字节码 ABI 对比 | 在基线提交上另行构建一份 AAR,对全部 307 个类用 javap -protected -s 比较公开/受保护签名(含 JVM 描述符):类清单与签名完全一致,0 个差异 |
没有搬的,以及为什么
| 文件 | 原因 | 对应决策 |
|---|---|---|
Content、ContentChapter、ContentPage | 带旧版外部 jar 的二进制兼容垫片(合成构造器、copy$default,纯 JVM) | D6 |
ContentListFilter、ContentListFilterOptions、search/* | 公开 API 使用 java.util.Locale,search/* 还用了 Class 反射 | D7 |
Favicon、Favicons | 依赖 OkHttp 的 HttpUrl | 需把 URL 解析换成纯 Kotlin,或留在 Android |
util/* 里 nullIfEmpty、formatSimple 等 | 这些纯函数和 java.net、DecimalFormat 等 JVM 函数混在同一个文件里。拆分需要 @file:JvmMultifileClass 保持门面类名;我试过,可行,但在 D6 之前没有用到,所以回退了 | — |
结论
:parser-api 已经是 KMP 模块,且已有 14 个叶子类型可被 iOS 与后续共享模块使用,其中包括 core-db 切断清单里需要的 ContentSource、ContentState、ContentType、SortOrder。再往前走需要先做 D6、D7 两个决定。
未覆盖
- 没有运行应用的单元测试与 instrumented 测试,只验证了编译与字节码 ABI。
- iOS 目标仍只验证到元数据层。
- 14 个文件里没有加入新的测试;它们大多是枚举与数据类,现有行为由使用方的测试间接覆盖。
12. P1 执行记录:数据库抽成 :core-db
日期:2026-10-02。分支:feat/kmp-p1-parser-api-model。按 §11 末尾的建议,采用 ListFilterCriteria 方案, schema 快照目录 app/schemas 留在 app(已提交,app 的 androidTest 以 assets 读取;:core-db 的 Room 插件 指向它)。
做了什么
- 新建 KMP 模块
:core-db(androidLibrary + jvm + iosArm64 + iosSimulatorArm64)。用git mv(保留历史)把 88 个 schema 文件从:app搬进commonMain、85 个迁移搬进androidMain,包名全部不变,所以:app里 绝大部分import不需要改。 - 应用 S2 验证过的切断清单:
System.currentTimeMillis()→kotlin.time.Clock(6 个文件)、javaClass→::class、SupportSQLiteQuery/SimpleSQLiteQuery→RoomRawQuery(5 个文件)、sqlEscapeString→ 本地 函数、LinkedList→ArrayList、@SuppressWarnings→@Suppress。 MangaDatabase一分为三:声明与@ConstructedBy进commonMain;getDatabaseMigrations(context)进androidMain;依赖应用资源与进程生命周期的MangaDatabase(context)构建函数和removeObserverAsync留在:app(MangaDatabaseFactory.kt,同包,调用方不用改)。- 新增
ListFilterCriteria(:core-db,纯数据,保留原来的层级和groupKey,不支持的选项仍然抛异常)。:app的ListFilterOption保持不变(71 个文件在用),在 Repository 边界用toCriteria()转换。 CloudFlareHelper的三个持久化状态常量放进:parser-apicommonMain 的CloudFlareProtection,原类引用它们, 编译出的常量值不变。:parser-api另加了jvm()目标,供:core-db的 JVM 测试使用。:app的构建配置去掉 Room 插件、ksp room-compiler和ksp { arg('room.generateKotlin') },加上implementation project(':core-db')。- 删除已被取代的 S2 spike(
spikes/room-kmp与-PwithKmpSpikes开关)。
对调用方的改动(行为语义不变)
| 改动 | 原因 |
|---|---|
MangaSourcesDao.setPinned、setEnabled 新增参数 appVersionCode: Int(5 个调用点传 BuildConfig.VERSION_CODE) | BuildConfig 属于 :app,模块里拿不到 |
TracksDao.observeUpdatedContent、SuggestionDao.observeAll 改收 ListFilterCriteria(2 个 Repository 加 .toCriteria()) | DAO 不再依赖带资源 id 的 UI 类型 |
SourcesSortOrder.titleResId 变成 :app 里的扩展属性(1 个调用点补 import) | 枚举搬走后不能带 @StringRes |
ComposeNovelMarkingsSheet 里 2 处 marking.note 的智能转换改成 .orEmpty() | Kotlin 不允许对跨模块的公开属性做智能转换 |
验证
| 检查 | 结果 |
|---|---|
:core-db 的 commonMain 元数据编译、JVM 与 Android 的 Room KSP | 通过 |
导出的 v84 schema 对比已提交的 app/schemas/…/84.json | 字节级一致(用空的临时目录强制导出后 cmp)。普通构建不会重写它,因为 Room 发现内容相同 |
:app:compileDebugKotlin、compileDebugUnitTestKotlin、compileDebugAndroidTestKotlin | 通过,0 个错误 |
:core-db:jvmTest(新增:查询构建器 6 个、DAO 冒烟 3 个) | 9/9 通过 |
:reader-core:jvmTest | 150/150 通过 |
:app:testDebugUnitTest(全量) | 2823 个测试,1 个失败(见下,与本次无关) |
模拟器(API 35)上 core.db 包的设备测试 | 24 个:19 通过、4 失败、1 跳过。4 个失败在抽取之前的基线提交上以完全相同的原因失败(见下) |
| 其中的迁移相关测试 | 原有的 Migration83To84Test(2 个)、SourceOriginsMigration77To78Test(3 个,含 DAO 的增删改查)、新增的 MangaDatabaseSnapshotChainTest(按 23 个快照走 22 段 + 空迁移的负控制)全部通过 |
顺带发现的既有问题(不是本次引入的)
MangaDatabaseTest有 4 个失败:migrateAll、migrate65To66…、migrate74To75…因缺1.json/65.json/74.json快照而抛FileNotFoundException;versions因迁移列表里的降级迁移Migration24To23破坏了 "起止版本连续"的断言(expected:<25> but was:<23>)。已在基线提交上重跑确认完全一致。ListSortOptionTest.favorites exposes seven criteria…失败(expected:<7> but was:<8>):测试与被测代码是 同一个提交(f9953753d,2026-09-14)加入的,当时已在devel;本分支没有改动排序相关的代码和测试。这是由 "输入未变"推出的结论,没有在基线上实际运行。RealDataMigrationTest因缺少真实数据库副本而被跳过。
未覆盖
- iOS 目标: KSP 代码生成、链接与运行仍需 macOS;iOS 的数据库构建器(
BundledSQLiteDriver、文件路径)尚未编写。 - R8 / release 构建: 没有跑
assembleRelease/assembleNightly,也没有在设备上运行混淆后的包。Room 的生成类现在 位于库模块里,应在发布前用 nightly 构建验证一次,确认MangaDatabase_Impl与新增的MangaDatabaseConstructor没有被混淆掉。 - 只跑了
core.db包的设备测试,没有跑完整的 instrumented 套件。 - 没有测启动耗时与 APK 体积的变化。
DatabasePrePopulateCallback仍在:app,依赖应用资源。
结论
P1 的数据库部分完成:schema、DAO 与迁移已是 KMP 共享模块,Android 行为在已覆盖的测试范围内没有回归。 P1 剩余:core-domain / core-backup、core-net;D6、D7 仍待决定。
13. P1 执行记录:备份格式抽成 :core-backup
日期:2026-10-02。从工作区中尚未提交的备份抽取继续完成验证与收尾;本节记录整个抽取阶段。
共享边界
- 新建
:core-backup,目标为 androidLibrary、jvm、iosArm64、iosSimulatorArm64。Android 应用依赖该模块。 - Kototoro 的 18 个备份模型、
BackupSection、Mihon/Aniyomi protobuf 模型、Usagi 模型、外部备份 DTO 与ExternalBackupLibrary进入 commonMain,包名和序列化字段名不变。保留WORK_*与实体图谱 section 名, 用于读取旧版备份;不恢复已移除的实体体系。 readExternalBackupBytes以 Okio 读取普通或 gzip 数据,拒绝已有的三种 JSON 文件头;decodeMihonOrAniyomiBackup负责 protobuf 解码、来源信息、分类、收藏时间和阅读进度映射。:app保留Uri/Context读取、ZIP 文件处理、Venera SQLite 解码、导出服务、BackupRepository与BackupPayloadGuard。共享模块依赖已抽取的core-db和parser-api叶子类型,沿用现有 serialization/Okio 版本, 不引入新的依赖库或 DI 框架。
切断平台依赖
| 原依赖 | 迁移后 |
|---|---|
BackupIndex 的 BuildConfig 构造器与 Kotatsu 兼容工厂 | 同包的应用层工厂 BackupIndexFactory.kt,输出的应用 id、版本及 generation/schema 默认值不变 |
BackupSection.of(ZipEntry) 与 Locale.ROOT | 共享 of(String) 使用语言无关的 lowercase();应用同包扩展保留 ZIP 调用入口 |
| category 默认排序、history 无进度标记依赖应用 UI 类型 | 共享持久化常量 NEWEST、-1f;应用测试核对其与原值一致 |
mapToSet 所在文件带 JVM 依赖 | mapTo(LinkedHashSet(...)),保留标签去重与插入顺序 |
Usagi 模型的 System.currentTimeMillis() | kotlin.time.Clock.System |
| Aniyomi 解码里的 Android 日志 | 移除日志,映射逻辑保持原样 |
| 跨模块公开属性的智能转换 | 应用恢复逻辑先读取局部变量,随后验证并写入历史记录 |
额外修复:共享读取函数现在在成功、JSON 拒绝、过短文件和 gzip 解码失败时都关闭输入流,函数 KDoc 明确所有权。 这是原 Android 调用方由外层 InputStream.use 保证、但新的独立共享入口需要自行保证的资源边界。
验证
| 检查 | 结果 |
|---|---|
:core-backup:compileCommonMainKotlinMetadata | 通过,拒绝 JVM/Android 专有 API 的可移植性检查有效 |
:core-backup:jvmTest | 2 个测试类,13/13 通过 |
:app:compileDebugKotlin、compileDebugUnitTestKotlin、compileDebugAndroidTestKotlin | 通过,0 个编译错误 |
:app:testDebugUnitTest --tests "org.skepsun.kototoro.backups.*" --tests "org.skepsun.kototoro.sync.*" | 23 个测试类,89/89 通过,0 跳过;覆盖旧备份兼容、来源 id、导出映射、恢复守卫、WebDAV 编排及同步协议 |
git diff --check | 通过 |
新增共享测试包括固定 protobuf 字节夹具(独立于模型 serializer,验证 Aniyomi field 501 与 source registry 103)、gzip/原始输入、动漫与漫画来源映射、仅历史记录的恢复、最新阅读点、时间戳 归一化、进度边界和失败时关闭输入流;应用侧验证共享默认值与 BuildConfig 工厂。
Windows 验证遇到另一个 Gradle 测试 worker 占用原 core-backup 运行时 JAR,随后默认 app/build 的 ASM 中间产物也出现不可读取的错误。为保留现有进程,使用被忽略的本地 build/kmp-backup-validation.init.gradle 将两个模块的验证输出分别放到 build/kmp-backup-validation/core-backup 与 build/kmp-backup-validation/app; 项目的 build.gradle、默认输出目录和全局环境配置不受影响。
正常环境的复验命令(本次在命令中另加上述本地 -I 参数绕开输出目录占用):
./gradlew.bat :core-backup:compileCommonMainKotlinMetadata :core-backup:jvmTest `
:app:compileDebugKotlin :app:compileDebugAndroidTestKotlin :app:testDebugUnitTest `
--tests "org.skepsun.kototoro.backups.*" --tests "org.skepsun.kototoro.sync.*" --console=plain未覆盖与后续
- iOS 目标仍只验证 commonMain 元数据;原生编译、链接、gzip 运行与 Room 依赖的 KSP 代码生成需要 macOS。
- 尚未验证完整 Android 导出/恢复设备流程及 R8/nightly 包;已有数据库迁移设备测试结果仍见 §12。
- 应用单元测试只运行备份与同步相关包,本轮没有重跑全量测试;已知的排序测试基线失败仍见 §12。
core-backup共享的是格式与外部 protobuf 解码,完整 Kototoro ZIP 恢复编排尚未共享。- P1 下一步为
core-domain,随后core-net。D6/D7 继续保留为待决策项,不改旧外部解析器的 JVM ABI。
结论
P1 的备份格式阶段完成:Android 已消费共享模型与 protobuf 解码,共享元数据和本轮回归全部通过。 按单一职责将平台文件访问与共享格式分离,复用既有模块和依赖;完整恢复服务与 iOS 平台装配仍在后续范围内。
14. P1 执行记录::core-domain 第一批业务规则
日期:2026-10-02。基于 §13 的工作区继续抽取,不改变数据库 schema 或源加载 ABI。
范围选择
梳理历史、收藏、更新列表后发现:HistoryLibraryDeriver、FavouriteLibraryDeriver、UpdatesDeriver 与 FeedDeriver 虽然在内存中工作,输入仍引用 ListFilterOption、带资源 id 的 ListSortOrder、浏览 UI 的 BrowseGroupTab/SourceTag 和依赖 Content 的 GlobalTagBlacklist。把整个目录直接移入 commonMain 会把 UI 或 D6 尚未决策的 JVM 模型一起拉进共享模块。
本轮先共享已有明确存储边界、覆盖实际历史/收藏/更新状态的规则,作为 core-domain 的第一批:
| 共享代码(包名不变) | 行为 |
|---|---|
core/model/ContentIdentityKeys | 基于 source + URL/public URL 的内容身份,不用标题/封面猜测身份 |
sync/google/data/model/GoogleDriveSyncModels | 现行同步 DTO、序列化字段、协议门槛、旧 work 快照到 content v3 的转换、与共享数据库实体互转 |
sync/google/domain/GoogleDriveSyncMerger | 内容去重与所有者重映射;历史/收藏的最后更新获胜与 tombstone;统计去重;更新状态、设置版本、源配置和扩展描述合并 |
sync/google/domain/GoogleDriveSyncContentImport | 复用本地身份;ID 冲突时分配负数 ID;跳过无 URL 内容;输出远端到本地的 ID 映射 |
tracker/data/TrackFeedRules | 更新状态的新旧比较、恢复时未读计数合并、日志能否清除更新的时间边界 |
模块目标为 androidLibrary、jvm、iosArm64、iosSimulatorArm64,应用新增对 :core-domain 的依赖。 产品依赖沿用 core-db(传递使用 parser-api 叶子类型)和现有 serialization;没有新网络库、DI 框架或转发层。
平台边界与改动
- 4 个原文件移入 commonMain;包名、类名、序列化字段名与协议常量保持不变。
TrackFeedCompatibility.kt拆出 3 个纯函数到共享模块;normalizeTrackFeedState的 Android Room 事务仍在应用。SyncContentStore、importSyncContent与结果类型改为公开,供应用和后续 iOS 适配器调用;结果构造器仍为 internal。 接口原本就存在,Android 的 DAO 适配器继续原样实现它,未新增一对一仓库抽象。SyncJsonSource.toEntity两处 JVM 时钟改为kotlin.time.Clock.System,保留缺少时间戳时使用当前时间的语义。- Google Drive 授权与网络 API、数据库事务、扩展文件访问与安装、设置读写仍在
:app。 - 4 个已有测试文件随规则移入
core-domain/src/jvmTest,避免应用与共享模块重复维护同一份测试。
验证
| 检查 | 结果 |
|---|---|
:core-domain:compileCommonMainKotlinMetadata | 通过 |
:core-domain:jvmTest | 6 个测试类,35/35 通过,0 跳过 |
:app:compileDebugKotlin、compileDebugUnitTestKotlin、compileDebugAndroidTestKotlin | 通过,0 个编译错误 |
应用相关包的 :app:testDebugUnitTest | 63 个测试类,345/345 通过,0 跳过;范围见下方命令 |
ContentIdentityKeys、GoogleDriveSyncMerger 对比抽取前 HEAD 源码 | 忽略换行编码后逐行一致;纯业务逻辑未重写 |
git diff --check | 通过 |
新增覆盖固定旧版 JSON 夹具、协议字段名、重复压缩的幂等性、双向合并的删除标记、更新清零、设置版本与日志 清除边界。Android 应用继续使用抽取后的实现,原扩展描述合并和 UI 派生等使用方的测试也通过。
复验命令(本次沿用 §13 的本地 -I build/kmp-backup-validation.init.gradle,复用独立 Android 验证目录):
./gradlew.bat :core-domain:compileCommonMainKotlinMetadata :core-domain:jvmTest `
:app:compileDebugKotlin :app:compileDebugAndroidTestKotlin :app:testDebugUnitTest `
--tests "org.skepsun.kototoro.backups.*" --tests "org.skepsun.kototoro.sync.*" `
--tests "org.skepsun.kototoro.tracker.*" --tests "org.skepsun.kototoro.history.*" `
--tests "org.skepsun.kototoro.favourites.*" --tests "org.skepsun.kototoro.migration.*" `
--tests "org.skepsun.kototoro.core.parser.StoredContentIdentityResolverTest" --console=plain未覆盖与后续
- 这是
core-domain第一批,完整历史/收藏 Repository、列表派生、换源迁移计划与进度计算仍在应用。 - 下一步将列表派生输入转换为纯数据筛选条件,拆开排序/浏览分组的资源依赖,再迁移已有派生器及其测试;
Content/ContentChapter的迁移继续受 D6 约束。 - 本轮不迁移 Google Drive 网络层;
core-net与 iOS 平台适配器仍在后续阶段。 - iOS 原生编译、链接与运行仍需要 macOS;commonMain 元数据检查不能替代原生验收。
- 本轮没有重跑全量应用测试、设备流程或 R8/nightly 构建;既有全量测试基线问题仍见 §12。
结论
core-domain 第一批完成:共享规则已被 Android 消费,元数据、共享测试与相关应用回归全部通过。 以现有存储接口隔离平台实现、迁移而不复制业务算法与测试,保持单一职责和单份实现;下一批继续拆分列表派生输入。
15. P1 执行记录:共享历史、更新与动态列表派生
日期:2026-10-02。继续 §14 的 :core-domain,完成第二批;数据库 schema、同步格式及外部解析器 ABI 未改变。
共享范围与 Android 接入
| 移入 core-domain/commonMain 的代码(原包名保留) | 职责 |
|---|---|
HistoryLibrarySnapshot、HistoryLibraryDeriver | 历史快照、十类排序、快速筛选、空间绑定、来源与内容分组 |
UpdatesSnapshot、UpdatesDeriver | 更新实体组快照、标签/分类筛选、稳定排序 |
FeedSnapshot、FeedDeriver | 动态快照、待更新合成条目与 owner 去重、分类作用域、置顶排序和数量窗口 |
ContentGroup、OriginGroup、SourceGroup | 来源分类数据,保留枚举顺序及对应位掩码 |
BrowseGroupTab、SourceTag | 分组匹配、来源筛选与持久化 ID/旧 ID 兼容规则 |
ListSortOrder、isReadingCompleted | 排序枚举及其旧顺序/持久化名称、历史完成进度阈值 |
TagBlacklist | 由平台实现标签标题匹配的最小接口,默认 Empty 不屏蔽标签 |
- 三类派生器复用
core-db/ListFilterCriteria。Android ViewModel 在调用边界通过已有toCriteria()将ListFilterOption转为共享条件,未复制算法或另建筛选枚举。 ListFilterCriteria.Tag新增可选title/key:SQL、更新、动态仍按tagId匹配; 历史保留原本的“标题 + 键”匹配。Android 映射同时填充三者,其他平台调用历史筛选时也需提供标题和键。- Android 资源标题和图标改为应用侧扩展属性;使用方补充导入,包括设备测试源码。 排序枚举名称、顺序及 HISTORY/FAVORITES 的迭代顺序与旧 EnumSet 一致。
GlobalTagBlacklist实现共享TagBlacklist;Android 词库、繁简转换及Content便利函数继续留在应用。 iOS 需装配自己的匹配实现,当前尚未共享完整词库。ReadingProgress.isCompleted委托共享函数,保留历史原来的0.99999f阈值。 收藏派生器的0.999f阈值继续保留,未趁迁移修改产品语义。TrackRowSeed仍是 Android 快照存储内部类型,独立放在应用文件中; DAO、SnapshotStore、卡片映射、日期分组、ViewModel、事务及 Hilt 继续由应用负责。- 收藏快照含 Compose
Immutable与应用ScrobblingStatus,本轮只让其复用已共享的排序/分组模型,FavouriteLibraryDeriver与收藏快照本身留到后续批次。
验证
| 检查 | 结果 |
|---|---|
:core-domain:compileCommonMainKotlinMetadata | 通过 |
:core-domain:jvmTest | 9 类,49/49 通过,0 跳过 |
:core-db:jvmTest | 2 类,9/9 通过,0 跳过 |
:app:compileDebugKotlin、compileDebugUnitTestKotlin、compileDebugAndroidTestKotlin | 通过 |
应用相关包 :app:testDebugUnitTest | 65 类,360/360 通过,0 跳过 |
git diff --check | 通过 |
6 个既有浏览分组测试随模型移入共享 JVM 测试;新增 8 个共享边界测试覆盖三类列表的标签身份差异、 完成阈值与并列排序、平台标签匹配、分类查找键、先排序后截取、待更新合成去重和来源位掩码。 应用侧保留 47 个既有派生器测试,继续从 UI 筛选映射与实际黑名单进入共享实现; 另验证来源分组及词库别名、中文标签、raw 选择的匹配适配。
复验沿用 §13 的本地独立构建目录,未清理或终止其他构建进程:
./gradlew.bat -I "build/kmp-backup-validation.init.gradle" `
:core-domain:compileCommonMainKotlinMetadata :core-domain:jvmTest :core-db:jvmTest `
:app:compileDebugKotlin :app:compileDebugAndroidTestKotlin :app:testDebugUnitTest `
--tests "org.skepsun.kototoro.backups.*" --tests "org.skepsun.kototoro.sync.*" `
--tests "org.skepsun.kototoro.tracker.*" --tests "org.skepsun.kototoro.history.*" `
--tests "org.skepsun.kototoro.favourites.*" --tests "org.skepsun.kototoro.migration.*" `
--tests "org.skepsun.kototoro.core.parser.StoredContentIdentityResolverTest" `
--tests "org.skepsun.kototoro.core.model.GlobalTagBlacklistTest" `
--tests "org.skepsun.kototoro.core.jsonsource.SourceGroupManagerTest" --console=plain后续边界
- 下一批处理收藏快照/筛选输入的 Compose 与追踪状态依赖,随后继续换源迁移、进度规则与
core-net。 - 本批完成的是列表数据与派生规则共享;完整 Repository 和平台存储编排尚未共享。
- Windows 只验证 commonMain 元数据及 JVM/Android 编译测试,iOS 原生编译、链接和运行需 macOS。
- 尚未执行本批设备 UI 流程、全量应用测试或 R8/nightly;已知基线问题仍见 §12。
- D6/D7 继续待决策,不修改
Content/ContentChapter的 JVM ABI 或解析器 Locale 公共接口。
16. P1 执行记录:共享收藏快照、派生与继续阅读书架
日期:2026-10-02。在 §15 之后继续抽取收藏列表;包名、字段名、类别 ID 与可见列表身份保持原约定。
边界与实现
FavouriteLibrarySnapshot、卡片行/标签、facet、成员关系与筛选元数据共 6 个类型迁入core-domain/commonMain。FavouriteCardRow.readingStatus改为数据库持久化状态名称字符串,复用ListFilterCriteria.ReadingStatus.statusName。 Android SnapshotStore 仍按原规则解析有效状态或根据进度回退,再输出枚举name;ScrobblingStatus和它的 UIListModel接口留在应用,没有再造共享枚举。FavouriteLibraryDeriver使用共享筛选条件与TagBlacklist。Android 状态组装转换 UI 筛选、 解析词库并传入匹配器,空选择用TagBlacklist.Empty。类别成员置顶、手动顺序、OR/AND 筛选、 同序时 entity ID 决胜、来源位掩码与0.999f完成阈值保持原行为。- 收藏数据上的 Compose 注解移出共享代码;应用以
app/compose-stability.conf精确列出原来 6 个@Immutable类型,由composeCompiler.stabilityConfigurationFiles读取,继续沿用 “快照发布后不修改集合”的原契约,没有将整个包或 Kotlin 集合全局标成稳定。 方式依据 Android 官方稳定性文档 与 Kotlin 2.4 Gradle 插件 API。 FavouriteFacetTag.toContentTag留为应用侧扩展函数,继续使用原来源工厂;已有 chip 身份测试核对 生成标签 ID 与共享 facet ID 一致。selectFavouritesShelfRows与 12 项上限移入FavouritesShelfRules;包含网格卡片的FavouritesShelfState留在应用。更新章节优先、阅读中内容随后、排序与截取语义未改。- 修复迁出模块后失效的可空字段智能转换:卡片副标题/作者显式过滤空值,选择作品 ID 显式回退到 entity ID。 继续使用原卡片映射、SnapshotStore、ViewModel、DAO、搜索与平台事务,不复制业务规则,不新增产品依赖。
验证
- 19 个既有收藏派生测试与 4 个书架测试随源码移入共享 JVM 测试; 另增 2 个共享测试,固定收藏与历史不同的完成阈值,以及标签 OR / 状态 AND 的组合边界。
- Android 新增 2 个状态组装测试,验证追踪状态名称映射及中文词库别名/raw 黑名单适配; 原卡片字段、facet 身份、类别计数、筛选后保留完整 row map 的测试继续通过。
- 本批共享 JVM 测试 11 类,74/74 通过;应用收藏、历史、追踪与黑名单相关回归 25 类,167/167 通过, 均无失败或跳过。commonMain 元数据、Android 主代码、单元测试与设备测试源码编译通过。
- 合并后续换源规则后的最终复验见 §17;
git diff --check通过。
本批完成收藏数据与规则共享;iOS 仍需平台词库匹配器、存储装配与原生构建验证,完整收藏仓库仍在应用。
17. P1 执行记录:共享换源写入计划与章节映射
日期:2026-10-02。用户继续推进后,在 §16 验证通过的基础上抽取换源规则,不修改外部解析器模型 ABI。
实现与行为边界
| 共享类型/规则 | 内容 |
|---|---|
MigrationContentInput、MigrationChapter | 窄输入:作品 ID、来源名、章节 ID/卷号/编号/分支/上传时间及平台选定的历史分支 |
MigrationChapterMapper | 同分支优先;缺失分支取最大章节组;卷号与正编号优先,否则按位置并钳制到末章 |
MigrationSnapshot、MigrationPlan、MigrationPlanRules | 输出收藏、历史、偏好、追踪绑定、更新状态、笔记、阅读会话与跳转记录的写入计划 |
MigrationDataFlag、MigrationMode、MatchMode | 原迁移开关位值、模式和持久化名称,原样移动 |
- Android
MigrationPlanner.plan(Content, Content, ...)和ChapterIdMapper保留原调用入口, 将 parser 模型投影为共享窄输入并消费共享结果。没有迁移或修改Content/ContentChapter的 JVM 签名。 - 历史分支仍由原 Android
getPreferredBranch根据当前章节或语言策略选择,仅在进度迁移需要时调用; commonMain 消费结果。iOS 适配器也需提供正确的回退分支(null可以是实际分支)。 - 原行为保持:未知旧章节按进度比例定位并保留 percent;已知章节按卷号/编号或位置重映射并将 percent 置为 -1;没有新章节不删除旧历史;统计仅在 REPLACE 模式迁移;笔记 COPY 分配新 ID、REPLACE 保留 ID。
- 历史回退分支采用平台选择,笔记/会话章节映射采用最大分支,继续保留这两个原本不同的规则。
- 共享模块只生成计划;Android
MigrateUseCase的数据库事务、实际写入、远端追踪与源访问保留。 标题相似度/Unicode NFKC、源健康检查、智能搜索及迁移设置仍在应用,未引入新依赖。
最终验证
| 检查 | 结果 |
|---|---|
:core-domain:compileCommonMainKotlinMetadata | 通过,收藏与迁移共享源码不含 Android/JVM 专有导入 |
:core-domain:jvmTest | 13 类,90/90 通过,0 跳过 |
:app:compileDebugKotlin、compileDebugUnitTestKotlin、compileDebugAndroidTestKotlin | 通过 |
:app:testDebugUnitTest(下列范围) | 41 类,245/245 通过,0 跳过 |
git diff --check | 通过 |
9 个既有规划测试移入共享模块;新增 4 个共享规划边界测试和 3 个章节映射测试,覆盖无目标章节、 平台分支与最大分支的差异、比例进度、章节缺失、卷号身份、同大小分支顺序及原位置钳制行为。 应用保留 5 个章节适配测试,并新增 3 个规划适配测试,验证历史分支传递、未知旧章节以及禁用进度时跳过平台分支选择。
复验命令(沿用 §13 的 ignored 本地 init script 与独立 Android 输出目录):
./gradlew.bat -I "build/kmp-backup-validation.init.gradle" `
:core-domain:compileCommonMainKotlinMetadata :core-domain:jvmTest `
:app:compileDebugKotlin :app:compileDebugAndroidTestKotlin :app:testDebugUnitTest `
--tests "org.skepsun.kototoro.favourites.*" --tests "org.skepsun.kototoro.history.*" `
--tests "org.skepsun.kototoro.tracker.*" --tests "org.skepsun.kototoro.migration.*" `
--tests "org.skepsun.kototoro.core.model.GlobalTagBlacklistTest" --console=plain后续
- 继续处理标题匹配中的 Unicode/Locale 平台边界、尚未共享的历史更新/进度模型及
core-net。 - P1 的核心业务规则已覆盖四类列表和迁移计划,完整仓库/网络服务与 iOS 装配尚未完成。
- 本批没有执行设备迁移事务/UI 流程、全量应用测试或 R8/nightly;已知基线问题仍见 §12。
- iOS 原生编译、链接、运行与真机验收仍需 macOS;Windows 元数据与 JVM 验证不替代该门槛。
18. P1 执行记录:共享进度、历史章节恢复与标题匹配规则
日期:2026-10-02。继续 §17,优先共享现有纯规则并由 Android 消费,未增加依赖或修改数据库 schema。
实现与行为边界
| commonMain 类型/规则 | Android 保留的边界 |
|---|---|
ReadingProgress、ProgressIndicatorMode | 偏好读取、资源/进度标签与 Compose 渲染 |
ReadingProgress.fromHistory | 历史 DAO 与收藏行投影,复用完成归一化/有效性规则 |
recoverHistoryChapterId | parser 章节 ID 投影、历史实体更新与数据库写入 |
TitleMatchingRules | NFKC、Locale 小写转换和 Unicode 字母正则,通过窄回调传入 |
ReadingProgress和模式枚举保留原包名、字段、枚举顺序及名称;章节数继续向上取整,完成阈值仍为 0.99999,与收藏快捷筛选的 0.999 阈值保持区别。fromHistory提取历史单条/批量查询和收藏卡片中 重复的逻辑,先将已完成进度归一到 1,再验证模式、进度范围与章节数。- 移除共享类的 Android 注解后,
app/compose-stability.conf精确增加ReadingProgress,保留原@Immutable合约;app/proguard-rules.pro为模式枚举增加原@Keep等效规则,保护偏好反射读取。 历史卡片原本直接使用 raw percent,继续保持该路径,未统一为收藏/Repository 的归一化行为。 - 历史章节恢复只消费 ID 列表:本地作品、章节仍存在、章节列表缺失或存在不同 EPUB 父章节均不恢复。 缺失远端章节按
size * percent的原 Float→Int 规则定位;越界不钳制到末章。这与换源章节映射的 钳制行为不同。原 NaN/微小负进度的转换结果也保留,未在迁移中加入额外纠错。 - 标题规则共享字母/数字过滤、UTF-16 Levenshtein 评分、备选标题匹配、括号清理、俄文章节后缀清理、 短标题回退及查询排序/去重。评分函数用平台提供的规范化回调,仍在满分时提前结束匹配。 私有编辑距离使用
IntArray,通过应用契约测试与现有 parser 工具逐对比较;parser 旧公共工具与 ABI 保留。 - Android
TitleNormalizer仍按 NFKC →Locale.ROOT小写 → 共享过滤处理;TitleSimilarity保留原入口, 深搜继续使用Locale.getDefault(),Unicode 过滤继续用原\p{L}正则。共享俄文后缀规则以[0-9]明确原 JVM 默认\d的 ASCII 数字含义,不引入默认语言变化。 - 回归记录了既有 Unicode 限制:短标题倒序分支按 Char 插入,会破坏补充平面字符的代理对,随后被过滤; 较长 Unicode 标题可保留这类字符。迁移保留原行为,后续修复需作为独立搜索行为变更验证。
以上通过窄输入与必要的函数回调隔离平台能力,复用已有规则,避免新的 DI、Unicode 或网络依赖。 ContentHistory 的 Parcelable/Instant、历史更新协程作用域、数据库事务与 scrobbling 仍留在应用。
验证
| 检查 | 结果 |
|---|---|
:core-domain:compileCommonMainKotlinMetadata | 通过 |
:core-domain:jvmTest | 16 类,116/116 通过,0 跳过 |
:app:compileDebugKotlin、compileDebugUnitTestKotlin、compileDebugAndroidTestKotlin | 通过 |
:app:testDebugUnitTest(下列范围) | 32 类,176/176 通过,0 跳过 |
git diff --check | 通过 |
新增 26 个共享测试,覆盖模式持久化合约、完成阈值、舍入/有效性、EPUB 父章节、历史越界恢复、编辑距离、 备选匹配短路、括号与查询规则;应用新增 4 个测试,覆盖 parser 距离合约、土耳其 Locale、补充平面字符 及兼容/组合 Unicode 规范化。原智能换源、重复匹配、收藏/历史映射、卡片进度标签与备份常量回归继续通过。
沿用 §13 的 ignored 本地 init script 与独立 Android 输出目录:
./gradlew.bat -I "build/kmp-backup-validation.init.gradle" `
:core-domain:compileCommonMainKotlinMetadata :core-domain:jvmTest `
:app:compileDebugKotlin :app:compileDebugAndroidTestKotlin :app:testDebugUnitTest `
--tests "org.skepsun.kototoro.favourites.*" --tests "org.skepsun.kototoro.history.*" `
--tests "org.skepsun.kototoro.migration.*" `
--tests "org.skepsun.kototoro.list.ui.compose.ContentCardStatusTest" `
--tests "org.skepsun.kototoro.backups.data.model.BackupSharedConstantsTest" --console=plain下一批网络层入口与剩余门槛
GoogleDriveSyncApi目前自行创建 OkHttp 客户端(连接 15 秒、读写 30 秒、整次调用 60 秒), 执行同步文件查询/版本/下载/上传/删除与网络异常重试。授权在GoogleDriveSyncAuth,可保留 Android Google Identity 装配,将 HTTP 请求/JSON 响应另行抽到core-net并以 mock transport 验证。WebDavBackupUploader依赖应用BaseHttpClient的代理等配置,PROPFIND 调用超时 30 秒、传输 120 秒; 另有 Basic auth、XML DOM/日期解析、文件流、重试与备份保留策略。迁移需明确传输、元数据解析和文件 I/O 边界,不能直接以默认 Ktor 客户端替换。当前仅完成代码审计,尚未创建core-net。- iOS 仍需提供 Unicode/Locale 回调及存储/服务装配。Windows 元数据与 JVM 测试不验证原生 Unicode 分类、 Kotlin/Native 编译或链接;macOS 与真机门槛不变。本批未执行全量应用测试、设备事务/UI 流程或 R8/nightly。
19. P1 执行记录:Google Drive 同步 HTTP 协议进入 core-net
日期:2026-10-02。继续 §18 的网络审计。此批只迁移 Google Drive HTTP 协议,不改授权方式、同步文件格式、 文件合并/选取策略或数据库事务;WebDAV 与 tracker 客户端仍在应用。
实现与依赖边界
- 新增
:core-net,采用已有 Kotlin/AGP 工具链与 Android/JVM/iOS Arm64/模拟器 Arm64 目标。GoogleDriveSyncApi、原嵌套DriveFile与GoogleDriveSyncApiException保留包名与方法/字段进入 commonMain。 API 通过构造参数接收 KtorHttpClient;客户端生命周期、引擎和网络配置由平台管理,不依赖 Hilt、Room 或 parser。 - Android
GoogleDriveSyncNetworkModule用 Hilt 提供 singleton API,并装配 Ktor OkHttp 引擎。GoogleDriveSyncAuth的 Google Identity/PendingIntent、账号设置与完整 Repository 仍在应用。 原 Repository 注入的 API 类型不变;构造改由 provider 完成。 - 新增迁移必要的
ktor-client-core、Androidktor-client-okhttp和仅测试使用的ktor-client-mock, 全部复用版本目录既有 Ktor 3.5.0,没有升级 Kotlin、OkHttp、serialization 或 Ktor 版本。 直接使用已有 serialization JSON 解码,不引入 ContentNegotiation、自动 HTTP 重试或新 DI 依赖。 原app的ktor-http仍有其他消费者,继续保留。 - 共享模块未创建 Darwin 引擎或 iOS 授权实现;iOS 将注入自身管理的
HttpClient,并须验证超时、重定向、 证书与网络异常语义。commonMain 元数据检查只证明共享源码可移植,不证明原生运行已完成。
保留的 HTTP 合约与取消修正
| 合约 | 实现 |
|---|---|
| 三代文件查询 | current content v3、work v2、legacy 文件名;appDataFolder、字段、创建时间排序及 pageSize 100 不变 |
| 新文件上传 | 先 POST 创建 appDataFolder 元数据,再 PATCH JSON;空 PATCH 响应回退目标 ID |
| 下载/删除 | alt=media 原始字节下载;DELETE 2xx 与 404 成功,其他 HTTP 状态仍返回应用异常 |
| JSON/错误 | 忽略未知字段、可空元数据、空响应处理、原 HTTP code/正文/原因短语与操作名上下文 |
| Android 超时 | OkHttp 连接 15 秒、读写 30 秒、整次调用 60 秒;连接恢复设置不变 |
| 重定向 | 由原 OkHttp 行为处理,Ktor 重定向插件关闭;保留 POST→GET 转换和跨 origin 移除 Authorization |
| 显式网络重试 | 最多 2 次,仅在进入响应处理前的 IOException;HTTP/JSON/响应体读取错误不重试 |
- 响应在
HttpStatement.execute { ... }范围内处理,成功、解码/读取异常和取消均释放响应;API 不关闭注入客户端。 每个请求显式expectSuccess=false,即使注入客户端打开默认成功检查,仍保留应用的 HTTP 异常类型与 404 合约。 - 取消不进入重试或错误正文回退。Android Repository 的同步/旧版导入/远端删除入口重新抛出
CancellationException;同步锁仍在 finally 释放,不将取消写为网络错误或清空同步状态。 三处远端文件清理复用 best-effort helper,普通删除失败继续忽略,取消继续向上传递。 - 本批沿用原重试策略,包括创建元数据时的网络重试;未引入新的 HTTP 状态重试或幂等策略。 文件列表分页、POST 幂等改进及其他协议行为调整不混入本次迁移。
设计上通过一个窄 HTTP 客户端构造边界分离协议与平台,复用错误/请求处理及清理逻辑,减少重复并保持现有行为。
验证记录
| 检查 | 结果 |
|---|---|
:core-net:compileCommonMainKotlinMetadata、compileKotlinJvm、compileAndroidMain | 通过 |
:core-net:jvmTest | 1 类,18/18 通过,0 跳过 |
:core-domain:jvmTest、:core-backup:jvmTest | 既有规则/格式回归,116/116 与 13/13 通过 |
:app:compileDebugKotlin、compileDebugUnitTestKotlin、compileDebugAndroidTestKotlin | 通过,含 Hilt 装配 |
:app:checkDebugDuplicateClasses | 通过,新增 Ktor 客户端依赖没有打包重复类 |
:app:testDebugUnitTest(同步/备份范围) | 22 类,82/82 通过,0 跳过 |
git diff --check | 通过 |
新增 18 个共享协议测试验证请求字段/授权、三代文件名、nullable/未知 JSON 字段、创建后 PATCH、字节上传/下载、 空响应、HTTP 错误和 404、两次网络失败、正文失败、解析失败、取消及响应生命周期。 应用新增 6 个真实引擎测试验证超时、二进制请求、跨 origin 授权移除、POST 重定向、响应体断流不重试、 等待响应时取消真实网络请求;另有 5 个 Repository 测试验证取消传递、锁释放和 best-effort 删除原行为。 全部网络测试使用 MockEngine 或本机 MockWebServer,没有调用生产 Google Drive API。
复验命令(沿用 §13 的 ignored 本地 init script 与独立 Android 输出目录):
./gradlew.bat -I "build/kmp-backup-validation.init.gradle" `
:core-net:compileCommonMainKotlinMetadata :core-net:jvmTest `
:core-domain:jvmTest :core-backup:jvmTest `
:app:compileDebugKotlin :app:compileDebugAndroidTestKotlin `
:app:checkDebugDuplicateClasses :app:testDebugUnitTest `
--tests "org.skepsun.kototoro.sync.*" --tests "org.skepsun.kototoro.backups.*" --console=plain后续与未验证范围
- 下一批按 §18 审计抽取 WebDAV 协议,保留代理配置、XML/日期解析、文件流和保留策略的现有行为。
- iOS Darwin 引擎、Google 授权、网络能力装配及原生测试仍待实现/验证;macOS 与真机门槛不变。
- 本批未执行真实账号同步、设备/UI 验收、全量应用测试、APK 体积测量或 R8/nightly;已知基线问题仍见 §12。
官方参考
实现核对使用项目固定的 3.5.0 源码,未跟随文档当前版本升级:
20. P1 执行记录:WebDAV 协议与备份目录规则进入 core-net
本批继续 §18–§19 的网络边界抽取。共享 HTTP 请求与远端目录规则,Android 保留设置、Basic 授权编码、 DOM/日期解析、文件 IO、日志和上传后的 best-effort 清理编排。不新增依赖,不改变备份格式或数据库 schema。
共享与平台边界
WebDavBackupClient负责 PROPFIND/PUT/GET/DELETE,接收平台管理的元数据与传输两个 Ktor 客户端。 上传通过WriteChannelContent的 channel 回调,下载在响应作用域内将ByteReadChannel交给平台; ZIP 不整体读入内存。平台在每次上传尝试重新打开文件流,所有文件操作在 IO dispatcher 执行并关闭流。WebDavBackupCatalog负责 ZIP 过滤、三代文件名/版本分类、时间降序、最新备份选择与保留删除清单;WebDavResource/WebDavBackupFile使用毫秒时间戳。RemoteNamespace保持原包名与 writer generation。 应用的BackupFileInfo.lastModified: Date、公开上传/下载/列表方法及调用者继续保留。WebDavResponseParser留在应用,以窄回调向共享层投影资源;使用原 namespace-aware DOM 和SimpleDateFormat。新文件名仍使用平台本地时区生成时间戳。WebDavNetworkModule以 Hilt singleton 管理两个客户端,保留元数据 30 秒和文件传输 120 秒整次调用超时。 它从原@BaseHttpClient克隆,继承代理、Cookie、拦截器、连接/读写超时和连接恢复设置。 Google Drive 与 WebDAV 复用createKtorOkHttpClient,保持 OkHttp 重定向处理与跨 origin 授权移除。 singleton 客户端随应用进程存活;共享协议不关闭注入客户端,iOS 应自行管理其引擎生命周期。
保留的行为与取消修正
| 合约 | 实现 |
|---|---|
| 目录与命名 | V1/V2/V3 仍共用一个目录,以原文件名前缀区分;保留远端路径首尾斜杠处理和 href 百分号编码 |
| 连接与列表 | PROPFIND Depth 0 测连接、Depth 1 查属性;所有 2xx(含 207)成功,列表 404 返回空列表 |
| 解析兼容 | 忽略目录/缺失属性结构;取首个 propstat,不新增 status 过滤;无效日期/大小回退零,保留 lenient 日期解析 |
| 分类与排序 | ZIP 后缀大小写不敏感、generation 前缀大小写敏感;V1 可读取旧文件名,严格版本溢出不再 fallback |
| 最新备份 | 无参入口只列表一次,优先 V3→V2→V1;带 namespace 入口取该代最新,时间相同保留响应顺序 |
| 上传 | PUT ZIP,最多三次;普通网络/HTTP 失败仍按 1 秒、2 秒重试;读取重试时的授权设置并重开文件流 |
| 错误正文 | 上传失败正文 trim 后最多展示 1024 字符及省略号;读正文失败回退原 HTTP 状态/原因短语 |
| 下载/删除 | 先验证下载 HTTP 状态再打开输出文件;下载不重试;DELETE 2xx/404 成功,其他状态抛原操作异常 |
| 保留策略 | maxCount≤0 不请求目录;只删除所选代超出数量的旧文件,普通删除失败继续后续清理 |
HttpStatement.execute { ... }释放成功、错误、解析失败、文件写入失败和取消时的响应。 每次请求显式expectSuccess=false,保留自定义 HTTP 错误与 404 分支。CancellationException不进入上传重试、错误正文回退或 best-effort 清理。 上传、文件流循环和重试检查 coroutine 状态,取消时不继续后续删除。- 未改变 WebDAV 原有的三次上传重试策略;它与 Google Drive 的两次、仅响应前 IOException 重试策略保持独立。 XML 解析库替换、Native 日期解析和下载部分文件回滚不混入本批。
设计通过注入传输、XML 投影和文件 channel 回调隔离平台依赖,目录规则只有一个实现; 应用适配器承担设置与文件编排,避免重复网络代码与新增解析依赖,符合 SRP、DIP、DRY、KISS 与 YAGNI。
验证记录
| 检查 | 结果 |
|---|---|
:core-net:compileCommonMainKotlinMetadata、compileKotlinJvm、compileAndroidMain | 通过 |
:core-net:jvmTest | 3 类,41/41 通过,0 跳过(新增 WebDAV 23 项,Google Drive 18 项回归) |
:core-domain:jvmTest、:core-backup:jvmTest | 116/116 与 13/13 通过 |
:app:compileDebugKotlin、compileDebugUnitTestKotlin、compileDebugAndroidTestKotlin | 通过,含 Hilt 装配 |
:app:checkDebugDuplicateClasses | 通过 |
:app:testDebugUnitTest(同步/备份范围) | 25 类,99/99 通过,0 跳过(新增 WebDAV 17 项) |
git diff --check | 通过 |
共享测试覆盖协议字段、授权、三代分类/排序/保留、两级重试间隔、HTTP/网络/解析失败、取消与未读响应释放。 Android 新增 5 项 XML、7 项真实 OkHttp 引擎和 5 项清理编排测试,验证代理配置继承、原 Basic 授权编码合约、 200KB 文件上传重试的完整字节、下载流、HTTP 失败保留目标文件、单次 XML 列表映射、跨 origin 授权移除、 响应断流不重试、真实网络取消,以及清理期间普通错误/取消的不同处理。 网络测试只使用 MockEngine 和本机 MockWebServer,没有调用真实 WebDAV 服务。
复验命令(沿用 §13 的 ignored 本地 init script 与独立 Android 输出目录):
./gradlew.bat -I "build/kmp-backup-validation.init.gradle" `
:core-net:compileCommonMainKotlinMetadata :core-net:jvmTest `
:core-domain:jvmTest :core-backup:jvmTest `
:app:compileDebugKotlin :app:compileDebugAndroidTestKotlin `
:app:checkDebugDuplicateClasses :app:testDebugUnitTest `
--tests "org.skepsun.kototoro.sync.*" --tests "org.skepsun.kototoro.backups.*" --console=plain后续与未验证范围
- 下一批审计 tracker/scrobbler 的 HTTP、认证、响应解码与平台模型边界,再抽取可共享协议。
- iOS 需实现 XML/日期解析回调、文件流与 Darwin 客户端装配;Google 授权和网络能力仍待实现。 Windows 元数据/JVM/Android 验证不能替代 macOS 的 Native 编译、链接和设备运行。
- 本批未执行真实 WebDAV 服务器验收、设备/UI 验收、全量应用测试、APK 体积或 R8/nightly。 S0/S3、D6/D7 和 §12 的既有基线限制不变。
官方参考
21. P1 执行记录:MALSync 跨站映射协议进入 core-net
本批开始处理 tracking/scrobbler 的网络边界,先抽取无账号授权的 MALSync 映射。 Google Drive 与 WebDAV 的既有共享协议继续回归;各 scrobbler 的账号、列表写入与数据库编排仍由应用负责。
网络装配审计与本批选择
| 客户端 | 当前平台边界 |
|---|---|
| MAL / AniList / Shikimori | 从 BaseHttpClient 克隆;各自 Authenticator、token 拦截器与 ScrobblerStorage,MAL 还依赖资源 client ID |
| Kitsu / Bangumi | 独立 OkHttp Builder 与 Authenticator/拦截器,不能直接假设它们继承 BaseHttpClient 的代理/Cookie 配置 |
| MangaUpdates | BaseHttpClient、MutableCookieJar、token/cookie 双路径授权、Room 与 Jsoup 网页解析 |
| Simkl | BaseHttpClient、平台 client ID/app version/token 拦截器、Context 资源、Room 与时间格式化 |
| MangaBaka | 远端 HEAD/下载与本地 SQLite、tar/gzip 安装、索引和文件元数据耦合 |
| MALSync | 无账号授权;原 BaseHttpClient GET、JSONObject 投影、服务分类/ID 回退/去重、LRU 与同键 Mutex |
本批选择 MALSync,将协议与映射规则上提,不在迁移中重构上述有授权客户端的装配或账号存储。 审计表只记录已确认的边界,不表示其他服务已完成协议抽取或原生适配。
共享与 Android 适配
MALSyncMappingApi使用注入的 Ktor HttpClient 请求原公开端点。 保留四个可查询服务路径 mal/anilist/kitsu/shikimori 与 manga/anime 两种媒体路径; Bangumi/MangaUpdates 只能作为返回的映射站点,SIMKL 仍不支持。没有新授权头或协议级重试。MALSyncMappingRules负责大小写不敏感的六站点识别、排除来源站、identifier 优先与 entry key 回退、 空元数据归一及按 service/remoteId 去重。零、负数、Long 边界和未 trim 的非空文本保持旧行为。 重复映射保留平台投影顺序中的首项。MALSyncEntry是窄平台投影,MALSyncMapping/MALSyncService/MALSyncKind不依赖 Android。 原ScrobblerService的资源 id 继续留在应用;Repository 显式转换服务与媒体枚举, 保留其公开Kind、Mapping和resolve(...)合约及 UI 调用。parseMALSyncEntries保留平台 JSONObject 的 optJSONObject/optString 与键迭代行为。 Android 与 JVM 测试使用的 org.json 在 null/对象/数字转字符串和迭代细节上可能不同; 不用共享 JSON 解码器静默替换这些行为。iOS 后续实现同样的窄投影回调。MALSyncMappingNetworkModule用 Hilt singleton 管理协议客户端,复用createKtorOkHttpClient。 原 BaseHttpClient 的代理、Cookie、拦截器、超时、连接恢复和 OkHttp 重定向设置继续继承; 不新增网络依赖或独立超时策略。
缓存、错误与取消合约
| 行为 | 本批结果 |
|---|---|
| HTTP 结果 | 所有 2xx 仍解析;非 2xx 返回空结果且不解析正文,每个请求关闭默认 expectSuccess 检查 |
| 响应生命周期 | 在 HttpStatement.execute 作用域内读取/投影,HTTP 错误的未读正文也释放 |
| 普通错误 | 网络与 JSON 解析异常交给 Repository,继续回退并缓存空结果,不新增重试 |
| 缓存键 | service path / media kind / remote ID,64 项 LRU 容量及命中更新最近使用顺序不变 |
| 并发 | 同键调用共用一次取数,不同键仍可并行;既有 per-key Mutex 表未做额外生命周期重构 |
| 取消 | CancellationException 向上传递,不写空缓存;锁自动释放,后续同键调用可重新请求 |
Repository 在入口、普通异常回退和写缓存前检查 coroutine 状态, 避免 socket 在已取消的协程中抛普通 IOException,或在取消后返回结果时污染缓存。 这一取消修正覆盖真实网络取消、等待同键锁、普通错误以及成功结果写入前的路径。
协议、映射规则、平台解析与缓存各自承担单一职责;窄回调隔离平台行为, 共享规则只有一份实现,网络装配复用现有工厂,符合 SRP/DIP、DRY、KISS 与 YAGNI。 没有改动账号数据、数据库 schema、资源枚举或已有外部解析器 JVM ABI。
验证记录
| 检查 | 结果 |
|---|---|
:core-net:compileCommonMainKotlinMetadata、compileKotlinJvm、compileAndroidMain | 通过 |
:core-net:jvmTest | 5 类,58/58 通过,0 跳过;新增 MALSync 17 项,既有网络 41 项回归 |
:core-domain:jvmTest、:core-backup:jvmTest | 116/116 与 13/13 通过 |
:app:compileDebugKotlin、compileDebugUnitTestKotlin、compileDebugAndroidTestKotlin | 通过,含 Hilt 装配 |
:app:checkDebugDuplicateClasses | 通过 |
:app:testDebugUnitTest(tracking/scrobbling/tracker/sync/backups 范围) | 41 类,207/207 通过,0 跳过 |
git diff --check | 通过 |
新增共享 8 项映射规则与 9 项 HTTP 协议测试;Android 新增 12 项 Repository 与 7 项投影/真实引擎测试。 覆盖查询路径、六站点映射、ID 溢出/回退、去重、HTTP/网络/正文/解析错误、响应清理、模型适配、 空结果回退、64 项 LRU、同键去重、不同键并发、取消不缓存、锁释放、真实 GET/UTF-8 JSON 和真实请求取消。 网络测试仅使用 MockEngine 与本机 MockWebServer,不访问生产映射 API 或用户账号。
复验命令(沿用 §13 的 ignored 本地 init script 与独立 Android 输出目录):
./gradlew.bat -I "build/kmp-backup-validation.init.gradle" `
:core-net:compileCommonMainKotlinMetadata :core-net:jvmTest `
:core-domain:jvmTest :core-backup:jvmTest `
:app:compileDebugKotlin :app:compileDebugAndroidTestKotlin `
:app:checkDebugDuplicateClasses :app:testDebugUnitTest `
--tests "org.skepsun.kototoro.tracking.*" --tests "org.skepsun.kototoro.scrobbling.*" `
--tests "org.skepsun.kototoro.tracker.*" --tests "org.skepsun.kototoro.sync.*" `
--tests "org.skepsun.kototoro.backups.*" --console=plain后续与未验证范围
- 后续抽取有授权服务的公共搜索/榜单等只读协议,再处理账号刷新、列表写入和 Room 编排边界; 不能将 Kitsu/Bangumi 的独立客户端无条件换成 BaseHttpClient。
- MALSync 的 iOS JSON 投影、其他平台网络装配和 Native 测试仍待实现。 Windows 元数据/JVM/Android 检查不替代 macOS 原生编译、链接或设备运行。
- 本批未做真实站点验收、账号操作、设备/UI 验收、全量应用测试或 R8/nightly; S0/S3、D6/D7 和 §12 已知基线限制不变。
官方参考
22. P1 执行记录:MAL 搜索、榜单和季度查询进入 core-net
本批从 §21 的有授权服务审计继续,抽取 MAL 的只读公共查询。 应用的账号刷新、用户资料、详情/网页解析、列表同步/写入、媒体提示缓存和 Room 编排继续保持平台实现。
共享与平台边界
MALDiscoveryApi共享三类 GET 请求:动漫/漫画搜索、动漫/漫画榜单与动漫季度查询;MALMediaType集中管理 endpoint 和不同媒体的 discovery fields,消除五个应用入口中的重复请求构造。MALDiscoveryNetworkModule注入原@ScrobblerType(MAL) OkHttpClient与资源 client ID, 复用createKtorOkHttpClient。原 MALInterceptor、MALAuthenticator、Storage 和 Provider 依赖链保留, 不替换成无鉴权的 BaseHttpClient。Hilt 主应用和 Android 测试源码装配均通过编译。MALRepository的 findContent/searchAnime/getAnimeRanking/getMangaRanking/getSeasonalAnime 保留公开签名、 默认参数、JSONObject 解析和原 ScrobblerContent 映射;返回内容仍记录原媒体提示。 原始 JSON 文本是窄平台边界,iOS 需实现相应解析/UI 映射和认证装配。- 没有新增依赖或更新版本,没有改动 OAuth 请求、PKCE、token 存储、列表写入、数据库 schema 或外部 parser ABI。
保留的协议与错误行为
| 合约 | 本批结果 |
|---|---|
| 搜索 | 原 anime/manga 路径,offset、nsfw=true、媒体 fields 和 q;不增加 limit,不 trim 查询 |
| 查询截断 | 继续 query.take(64) 的 UTF-16 单元限制;补充平面字符和截断代理对的解码值与原 OkHttp 对比通过 |
| 榜单 | ranking_type 默认 all、limit 默认 20、offset 默认 0;自定义值不新增校验或 clamp |
| 季度查询 | anime/season/year/season,season 作为一个编码路径段;原 sort/default/paging/fields 保留 |
| 授权头 | 公共 client ID 保留;无 token 仍可请求,有 token 继续由原拦截器加 bearer |
| token 刷新 | 401 继续由原 Authenticator 调用 authorize(null),成功后用新 token 重发 |
| 剩余错误 | 拦截器留下的非 2xx 正文仍交给原 JSON 解析,不新增全局 HTTP 成功检查;HTML 继续转标题 IOException |
| JSON/UI | 搜索缺 data 仍报 Invalid response,榜单缺 data 仍为空;标题、别名、最佳匹配、评分、进度与日期格式保留 |
| 重定向/取消 | 原 OkHttp 跨 origin 移除 bearer;查询不增加协议级重试,取消继续传递 |
HttpStatement.execute 在响应作用域内读取正文并释放响应。 MALInterceptor 在未解决的 401/403 分支原先直接抛认证异常,未关闭已收到的响应; 本批补上 response.close(),保留原 ScrobblerAuthRequiredException 类型和服务值。 真实引擎测试抓取响应体,验证异常抛出后底层 source 已关闭。
设计集中请求/字段规则、复用网络工厂,并以文本边界隔离平台 JSON 和 UI, 保持 SRP/DIP、DRY、KISS 与 YAGNI;不提前构造新的跨平台账号或全量追踪模型。
验证记录
| 检查 | 结果 |
|---|---|
:core-net:compileCommonMainKotlinMetadata、compileKotlinJvm、compileAndroidMain | 通过 |
:core-net:jvmTest | 6 类,70/70 通过,0 跳过;新增 MAL 12 项,既有网络 58 项回归 |
:core-domain:jvmTest、:core-backup:jvmTest | 116/116 与 13/13 通过 |
:app:compileDebugKotlin、compileDebugUnitTestKotlin、compileDebugAndroidTestKotlin | 通过,含 Hilt 装配 |
:app:checkDebugDuplicateClasses | 通过 |
:app:testDebugUnitTest(tracking/scrobbling/tracker/sync/backups) | 43 类,222/222 通过,0 跳过 |
git diff --check | 通过 |
新增共享 12 项请求/错误/取消测试,应用 7 项 Repository 和 8 项真实引擎测试。 真实引擎验证 client ID/JSON 头、旧 bearer、原 Authenticator 刷新、认证异常与响应关闭、 HTML 标题错误、保留字符与截断代理对的查询编码、跨 origin 重定向和真实请求取消。 Repository 验证五个入口、默认/自定义参数、动漫/漫画 UI 映射、两种缺 data 语义、JSON 失败与取消。 全部网络测试使用 MockEngine 或本机 MockWebServer,token/账号依赖为测试替身,没有访问生产服务或真实账号。
复验命令(沿用 §13 的 ignored 本地 init script 与独立 Android 输出目录):
./gradlew.bat -I "build/kmp-backup-validation.init.gradle" `
:core-net:compileCommonMainKotlinMetadata :core-net:jvmTest `
:core-domain:jvmTest :core-backup:jvmTest `
:app:compileDebugKotlin :app:compileDebugAndroidTestKotlin `
:app:checkDebugDuplicateClasses :app:testDebugUnitTest `
--tests "org.skepsun.kototoro.scrobbling.*" --tests "org.skepsun.kototoro.tracking.*" `
--tests "org.skepsun.kototoro.tracker.*" --tests "org.skepsun.kototoro.sync.*" `
--tests "org.skepsun.kototoro.backups.*" --console=plain后续与未验证范围
- 下一批继续其他 scrobbler 的只读协议或 MAL 详情请求,保留其认证/解析差异; 账号刷新、列表写入和 Room 编排的共享仍需独立抽取。
- iOS 认证、MAL JSON/UI 映射、Darwin 装配与 Native 验证未完成。 Windows 的元数据/JVM/Android 结果不替代 macOS 原生编译、链接和设备运行。
- 本批未执行真实账号/站点验收、设备/UI 测试、全量应用测试或 R8/nightly; S0/S3、D6/D7 与 §12 的基线限制不变。
官方参考
23. Claude 方案交接核对:继承 JAR + 嵌入式 JVM 路线
日期:2026-10-02。按用户要求读取 Claude 最近的原始对话,而非只依赖摘要。 对应会话为 f4687870-7e3a-44aa-b5d5-e65749db5353,文件位于 C:/Users/chuxi/.claude/projects/E--kototoro-demo-Kototoro/。 研究从源码跨编译方案的评估,转向上游同名 JAR + 真 JDK 的方案;用户随后明确要求 “先假定他的这套可行,规划我们的 kmp 方案”。这与 §1–§5 的目标一致。
已复核的本机证据
Claude 的原始存档目录: C:/Users/chuxi/AppData/Local/Temp/claude/E--kototoro-demo-Kototoro/f4687870-7e3a-44aa-b5d5-e65749db5353/scratchpad/az/。 本次直接逐行统计 TSV;错误文本含引号,应按制表符分列,不用 CSV 引号规则解析。
| 产物 | 本次复核 |
|---|---|
harness_fulljdk.tsv | 1395 行:1389 OK、6 FAIL |
harness_jre4.tsv | 1395 行:1389 OK、6 FAIL,与完整 JDK 一致 |
harness_network.tsv | 1395 行:750 OK、33 EMPTY、612 FAIL;没有重发联网请求 |
Harness.java | inspect、load、listSources、getSearchFilters、getSettings,各步骤限时;不等于完整阅读流程 |
D:/tmp/kt-harness | 驱动源码与编译产物仍在,可作为独立实验入口 |
D:/tmp/kt-compat36、kt-shims、kt-jre4、kt-extra | 原兼容层、shim 与裁剪镜像仍在;没有复制进项目或重新下载 |
TSV 核实的是当时的结果,未重新执行所有 JAR;§2.2 的细分失败归因沿用 Claude 的研究记录。 99.6% 表示桌面加载/构造/筛选/设置调用通过,不表示 iOS 真机兼容率。 94.6% 的分母是当时有响应且能判定的 793 个源,不是全部 1395 个 JAR。 桌面 HotSpot/JIT 与四模块镜像均不能替代 iOS Zero 的性能和语义验证。
后续实现必须继承的边界
- 直接加载仓库提供的 JAR。 Android 继续安装 APK;iOS 不执行 DEX,也不做 dex2jar 或逐源 Kotlin/JS 改写。JVM host 承载原 extension-lib 1.4–1.6,旧 Rx 由兼容 API 的 suspend 默认实现回落。
- JNI 只传协议数据。 iOS 的 SwiftUI/KMP 不持有 Mihon、OkHttp 或 Jsoup 对象。 每个扩展的
URLClassLoader负责命名空间隔离;一个进程内 VM,不提供安全隔离或原地重建 VM。 - 源身份不能改名。 上游
SourceFactory展开后的 sourceId 必须独立寻址;Kototoro 继续使用MIHON_{id}的稳定键,不能照搬参考实现的mihon.<id>,否则 Android 备份/收藏/历史无法对应。 - 保留不透明元数据与原页面上下文。
memo/sourceData不重新解释;章节原始 page URL、 图片 fragment、headers 都可能参与解扰。封面请求与章节HttpSource.imageRequest(Page)语义不同。 - 图片仍经过扩展链。 实际保留的
ExtensionHost.java包含materializeImage,通过扩展自己的 OkHttp client 执行请求及拦截器。只导出图片 URL/headers 再交 Darwin 下载,无法覆盖图片解扰类源。 当前core-source.getPageUrl只解析地址,没有完成图片执行/物化能力。 - 浏览器和偏好是必要能力。 WKWebView、主 Looper、JavaScript 回调、CookieManager/OkHttp cookie jar 和同一 user agent 必须相互衔接;偏好设置不是可忽略的 UI stub,
getSettings实际执行设置构建。 - v1 漫画侧载范围不扩张。 继续 SwiftUI 外壳与嵌入式 JVM;P5 的小说/视频/翻译后置。 读取参考源码用于理解,不把 GPL 实现直接搬进现有共享模块;D3/S3 的许可决策仍独立待办。
后续应在 core-source 上接实际 JVM host,并复用桌面驱动做本地契约验证。 扩展安装/校验、完整 Mihon FilterList/偏好投影、图片物化、WKWebView/Cookie/JS 原生桥和 OpenJDK 镜像构建均仍需实现;不能把协议与 Android 编译通过写成 P2/G2 完成。
24. P2 前置实现:共享源协议与 Android 适配器
本批建立 :core-source,目标是让源调用能穿过 JSON 边界,并验证与现有 Android 模型的映射。 它独立于 parser-api 的 JVM 类型与二进制垫片,不修改 D6/D7;模块使用既有版本的 kotlinx.serialization 与 coroutines,不引入新的第三方运行时或版本更新。
可执行入口与协议
SourceRuntime覆盖启用源目录、能力描述、筛选选项、列表/搜索、详情、章节页面、页面地址与相关推荐。SourceEndpoint.exchange解码版本化请求并执行 runtime;SourceProtocolClient实现同一接口, 用注入的 transport 发 JSON,检查版本、请求 ID 与结果类型。请求 ID 生成及 transport 生命周期由平台管理。- v1 JSON 包含
version、requestId、call.operation;响应只有 result/error 中的一项。 未知版本、坏请求、源不可用、无效参数与普通运行时失败分别返回明确代码。 错误正文不直接携带 parser 堆栈、token 或私有请求 URL。 - 64 位 ID 与上传时间戳以十进制字符串编码,避免 JSON 中间消费者丢精度; 不透明
sourceData、null/已加载空章节、null/空 headers、章节分支、电子书格式及附加页面字段均保留。 - 枚举使用名称、Locale 使用 BCP 47;分页明确 OFFSET/PAGE_INDEX,详情保留 ALLOW_CACHE/FORCE_REFRESH。 保留 query、author、排除标签、年份范围、原始语言、分组顺序/互斥属性及显式 effectiveTagGroups。
- 不增加 offset clamp、排序默认值、查询 trim 或协议重试。取消直接传递; runtime 即使吞掉取消并返回数据,入口也检查 coroutine 状态,不把它转成成功。
AndroidSourceRuntime 使用原 ContentSourcesRepository.getEnabledSources() 的用户启用/可见策略, 其余调用复用 ContentRepository.Factory.createWithDiagnostics 的解析、选择与缓存。 DTO 里的源元数据不能替代实际源对象,按稳定名称解析回现有 repository 的 canonical source。 无法选中 provider/返回 EmptyContentRepository 时报告不可用,不把它编码成成功空列表。 Hilt 仅在 app 装配 SourceRuntime 和 JSON 入口;共享模块没有 DI 依赖,Android UI 原调用链不变。
设计按 SRP 分离 DTO、协议、transport 和平台适配;DIP 通过 SourceRuntime/SourceTransport 接缝, DRY 复用现有源工厂与字段映射,KISS/YAGNI 保持单次请求协议,不预建另一套源加载器或 UI。
验证与剩余能力
| 检查 | 结果 |
|---|---|
:core-source:compileCommonMainKotlinMetadata、compileKotlinJvm、compileAndroidMain | 通过 |
:core-source:jvmTest | 18/18 通过,0 跳过 |
| 应用源协议/映射测试 | 2 类,16/16 通过,0 跳过 |
既有 ContentRepositoryFactoryTest | 18/18 回归通过 |
| 应用 main/unit/androidTest Kotlin 编译与 Hilt 装配 | 通过 |
:app:checkDebugDuplicateClasses | 通过 |
git diff --check | 通过 |
共享测试验证八类调用端到端往返、64 位极值/JSON 编码、null 与空集合、增量字段兼容、 版本/请求 ID/结果类型校验、坏请求/坏枚举/ID 溢出、错误分类与正文脱敏、取消和并发逆序完成。 Android 测试通过真实适配器/客户端/入口验证完整字段往返、显式 effectiveTagGroups、 BCP 47、canonical source、启用目录策略、factory 不可用回退、详情刷新模式、原页面上下文与取消。 源目录、factory 和底层 repository 使用替身;未加载真实 Mihon JAR,未验证 JNI。
复验命令(沿用 §13 的 ignored 本地 init script):
./gradlew.bat -I "build/kmp-backup-validation.init.gradle" `
:core-source:compileCommonMainKotlinMetadata :core-source:jvmTest `
:app:compileDebugAndroidTestKotlin :app:checkDebugDuplicateClasses `
:app:testDebugUnitTest --tests "org.skepsun.kototoro.core.source.*" `
--tests "org.skepsun.kototoro.core.parser.ContentRepositoryFactoryTest" --console=plain本批没有实现 JAR 安装/签名或哈希校验、Mihon host、动态 FilterList/偏好设置、图片请求执行、 渐进页面 Flow、Cloudflare/JS 原生桥或 iOS 应用。过滤 DTO 是现有 Android ContentListFilter 的投影, 不能称为完整 Mihon FilterList 协议。Coroutine 测试也不能证明未来 JNI 阻塞调用可以被中断。 所有契约测试使用本地 runtime/repository 替身,没有访问站点;本次未重跑 Claude 的联网样本。 Windows 元数据/JVM/Android 编译结果不替代 macOS 原生编译、链接与真机 G0/G2。
25. P2 执行记录:JVM JAR 检查器与真实扩展注册
日期:2026-10-02。承接 §23 的 Claude 研究与 §24 的共享协议,建立 :source-host JVM 模块, 不修改 Android APK 加载器,不复制参考项目的 GPL Java/Swift/JNI 实现。
代码与边界
core-source/MihonExtensionModels定义检查/加载结果 DTO,源 ID 使用十进制字符串; 保留包名、版本、extension-lib、entry classes、NSFW、hash、字节码版本和每个展开源的元数据。MihonJarInspector读取带 Android XML namespace 的文本 manifest,接受 1.4–1.6; 规范化相对/简单/完整、多 entry class;检查重复字段、manifest 大小、XML 外部实体与根 class header。 检查 VM class 版本上限、拒绝 preview 字节码,不把未选中的 multi-release 版本误计入上限。 inspection 阶段只读取数据,不加载或执行扩展。- registry 必须先校验调用方的 package/version/SHA-256,再构造 entry。 parent 是嵌入平台已经初始化的兼容 ClassLoader,每个扩展使用独立 URLClassLoader。 接口来自 parent 的真实 CatalogueSource/SourceFactory,不创建另一份生产 Mihon ABI。
- SourceFactory 的所有语言源独立注册;稳定键继续
MIHON_{id},显示名与语言仅是元数据。 同包重复加载、同包/跨包 sourceId 冲突、空/失败 factory 拒绝发布部分注册结果。 构造/访问 getter 时切换 context ClassLoader,退出时恢复原值。 unload/close只移除内存注册并关闭 JAR 句柄,不删除原文件或尝试重建 VM。 失败构造关闭新 loader,关闭异常不覆盖原异常;VM 级 fatal error 不伪装成普通成功结果。- CLI 的
inspect直接输出共享 JSON;默认installDist仅包含自有 host、core-source 与既有 Kotlin/serialization/coroutines 依赖,不打包外部兼容层、GPL bootstrap 或 Keiyoushi JAR。
SRP 分离数据检查与执行/注册,DIP 由平台注入兼容 ClassLoader,DRY 复用共享 DTO; 保持 KISS/YAGNI,不新造 Mihon ABI、另一套 Android loader 或完整虚拟机生命周期框架。 仓库信任、下载/安装记录/更新和产物不可变性仍由安装层负责。 hash 校验不等于签名验证,也不能把 URLClassLoader 称为安全沙箱。
真实语料检查与加载
本次重新用自有检查器读取了 Claude 留下的全部 1395 个 JAR,而非只转述旧报告。 期望身份取自其存档 index.json(包/版本/源 ID)与 ra.json(产物 SHA-256)。 逐个调用 inspect/verify,并另以本机 Get-FileHash 复核全部产物;没有下载或请求站点。
| 检查 | 结果 |
|---|---|
| 文本 manifest、支持版本、class header/ceiling | 1395/1395 通过 |
| package/version/hash 与存档目录比对 | 1395/1395 通过,0 mismatch |
| 本机语料分布 | 1.4:73 个;1.6:1322 个;最高 class major 为 55(Java 11) |
原目录共有 1396 个条目;缺失的一个不纳入本次 1395 个检查分母。 不把“manifest/class header 可读”写成整个字节码或运行行为通过。
接着仅在本机研究进程中使用已有 Suwayomi 兼容层、shim 与 Claude 的已编译 bootstrap 做初始化, 实际加载由本批独立编写的 registry 完成。运行 JDK 25,home/tmp 指向 ignored build 下的隔离目录。 外部 bootstrap 和兼容 JAR 没有复制到源码或发行包;没有执行 popular/search/details/image 网络调用。
| 真实扩展 | extension-lib | 本次展开源数 |
|---|---|---|
all.comicfury | 1.6 | 14 |
all.mangadex | 1.6 | 61 |
all.buondua | 1.6 | 1 |
en.akaicomic | 1.4 | 1 |
四个 JAR 全部加载,总计 77 个源;每个展开 sourceId 都与存档目录的完整集合比对一致, 所有 Kototoro 键均为 MIHON_{id}。这里只证明这些源的加载/展开/元数据读取, 不证明旧版 Rx 请求、联网解析、图片解扰、偏好持久化或 iOS 上的运行行为。 local smoke 驱动/目录 pins/日志在 ignored build 内,复验依赖 §23 的本机研究产物。
编译与单元测试
| 检查 | 结果 |
|---|---|
:source-host:compileKotlin、:source-host:installDist | 通过;Java/Kotlin target 均为 11 |
:source-host:test | 2 类,18/18 通过,0 跳过 |
:core-source:compileCommonMainKotlinMetadata、JVM/Android 编译 | 通过 |
:core-source:jvmTest | 18/18 回归通过 |
:app:compileDebugKotlin 与 unitTest Kotlin 编译 | 通过 |
| 应用 source 适配/映射与既有工厂测试 | 3 类,34/34 回归通过 |
git diff --check | 通过 |
host 的测试 JAR 使用 JavaCompiler 以 --release 11 实际编译,并由独立 ClassLoader 加载。 覆盖元数据、XML/大小/重复 entry、hash/package/version、字节码上限、multi-release、构造前校验、 单源/factory 全展开、Long 极值、namespace/class ownership、冲突、失败注册原子性、thread loader 恢复、关闭与重新加载。fixture API 是独立编写的最小测试接口,不在生产发行包内。
复验命令:
./gradlew.bat -I "build/kmp-backup-validation.init.gradle" `
:source-host:test :source-host:installDist `
:core-source:compileCommonMainKotlinMetadata :core-source:jvmTest `
:app:compileDebugKotlin :app:testDebugUnitTest `
--tests "org.skepsun.kototoro.core.source.*" `
--tests "org.skepsun.kototoro.core.parser.ContentRepositoryFactoryTest" --console=plain本批没有运行全量 app 测试、设备测试或 R8/nightly;此前基线限制保持不变。
后续与未完成范围
下一步把 Mihon 源对象接到 §24 的 SourceRuntime/SourceEndpoint:抽取共用 ID/URL/内容映射, 提供真实 suspend/Rx 调用、分页和状态隔离,再补 FilterList/偏好、图片请求与物化。 本批 registry 不以空列表或虚假成功替代这些执行能力;它也不提供完整 host RPC、JNI 或兼容层 bootstrap。 sources CLI 需要调用进程已准备的兼容运行环境;默认发行目录中的 inspect 独立可用。
iOS 原生桥、OpenJDK Mobile 镜像、WKWebView/Cookie/JS、SwiftUI 应用和 G0/G2 真机证据仍未完成。 D3/S3、D6/D7 及 P1 其余业务服务仍待;本批不宣称 P2 或 KMP 总目标完成。
26. P2 执行记录:Mihon 内容执行器与实际 Rx ABI 闭环
日期:2026-10-02。承接 §25 的源注册器,新增自有 MihonSourceRuntime,通过 §24 的 SourceEndpoint / SourceProtocolClient 调用已初始化兼容层中的源。Android 的原调用链保留。
实现与兼容性
core-source/MihonModelRules抽取现有 Android 的 manga/chapter/page ID、相对 URL、重复协议 清理、genre 清洗和内容分级规则;Android 的 MihonDataConverters 改为复用同一实现。 manga ID 继续使用既有 64 位 seed/31 哈希,chapter/page 继续使用原 String hash;没有改变备份键。- host 反射真实 API 的 getPopularManga/getLatestUpdates/getSearchManga/getMangaUpdate/getPageList/ getImageUrl;Continuation 与 COROUTINE_SUSPENDED 正常处理,异步回调支持取消。 不另行实现 RxJava 或替换 Mihon API,旧 Rx 回落交给平台兼容 API 的默认方法。
- 实际 ABI 验证发现 Suwayomi HttpSource 的若干挂起转发方法只有 ACC_BRIDGE;选择器现在接受 这些方法,并优先普通 override。测试 fixture 模拟同样的字节码 flag,防止再次跳过 bridge。
- 页索引无调用顺序状态:协议 PAGE_INDEX offset 0 对应 Mihon page 1,offset 1 对应 page 2。 负值/溢出/不支持的 order 明确拒绝;不同搜索不会污染下一页。Android 原 OFFSET 行为不改。
- 详情始终请求 details+chapters。保留原 ID/URL,补全 partial SManga 的 title/thumbnail/memo。 章节按 Android 规则反转、对未提供编号的章节按位置编号再升序;保留 parent-scoped ID、 毫秒时间戳、scanlator/branch 与 memo。两个 fetchMode 都刷新,不把 snapshot 当作详情响应缓存。
- 每个源/运行时保存有界 manga/chapter LRU(100/500),由 registry 的扩展 generation 持有; 卸载后旧状态不进入重载实例。不能仅靠弱 source key 加强 value,因为 native snapshot 可能引用其源。 实际 Suwayomi 的默认 copy()/copyFrom() 均遗漏 memo;host 显式补复制及其余可用的扩展字段。
- 页面保留真实 index、origin URL、image URL fragment 与 protected imageRequest 的 headers。 延迟图片地址编码到 mihon://resolve;有独立 origin 的直接图片编码到 mihon://image;解析时保留 index/UTF-8/加号上下文。可选 public URL/header 投影失败按 Android 规则回退,不丢弃整个列表。
- 同一源 mutex 串行,扩展间可并行;挂起期间不持有 JVM synchronized monitor。 ThreadContextElement 在 coroutine 调度/恢复时切换并还原 extension ClassLoader。 unload/close 立即注销、拒绝新调用,但 active lease 保持旧 loader,直到已有调用/回调完成才关闭。 取消不返回成功;若扩展忽略取消,回调 lease 保留至其完成,不强行关闭仍使用的 JAR。
- 尚未投影动态 FilterList,因此能力只声明文本搜索。非文本过滤、filters 与相关推荐返回新增
UNSUPPORTED_OPERATION结构化错误;没有以忽略条件/空列表伪装完成。普通错误仍经 endpoint 脱敏。
SRP 分开反射 ABI、模型投影与运行调度;DRY 复用 common 的身份/URL/分级规则和已有协议; KISS 保留平台现成的 suspend/Rx 默认实现,不增加依赖版本、另一份生产 API 或独立虚拟机框架。
验证
| 检查 | 结果 |
|---|---|
| source-host 编译/发行目录 | 通过;仍为 Java 11 target,不打包兼容层/外部扩展 |
| source-host 单元测试 | 3 类,26/26(原 18 + 执行器 8),0 跳过 |
| core-source common metadata / JVM 测试 | 通过;2 类,24/24(原 18 + 规则 6) |
| app Kotlin / Hilt / unitTest 编译 | 通过 |
| app source 适配/映射、原工厂与 Mihon 差分测试 | 4 类,37/37(原 34 + 差分 3) |
| 本批合计 | 87/87;未运行全量应用/设备测试 |
执行器测试通过真正编译并装入 child URLClassLoader 的独立 Java fixture:JSON 请求/响应、 源 canonical 元数据、页索引/搜索、native memo 拷贝/DTO 恢复、partial details、章节编号、 非连续页面 index、Unicode/加号/fragment、protected headers、异步 resume 与错误脱敏。 取消/卸载测试令扩展完成回调前从旧 JAR 延迟加载 class,完成后确认 loader 不再加载新 class; 另测 registry close 后已有异步调用仍完成、新调用被拒绝。测试代码/最小 ABI 不进入发行目录。
随后用 §23 的本机真实 Suwayomi server-1.0.jar/依赖/shim 和已有外部 bootstrap,仅在研究进程 初始化兼容环境。独立编写的 OfflineRxSource 以 --release 11 针对该真实 API 编译,所有 fetch 方法 只返回 Observable.just 的本地结果。加载/执行/映射由自己的 host 实现,JSON 调用由共享 client 发起。
验证热门/最新/搜索、Rx 默认到 suspend、partial details、manga/chapter memo、章节页面、延迟图片 URL、原始 index、image fragment 与 protected imageRequest headers。调用结果:
ACTUAL_SUWAYOMI_RX_ABI=PASS JSON_REQUESTS=7 NETWORK_REQUESTS=0这验证真实兼容 API 的本机离线调用闭环,不是七个联网源成功、1395 个源请求通过或 iOS 成功。 fixture、驱动及日志在 ignored build;外部 GPL/MPL bootstrap/兼容产物不在源码/默认发行包。 最后构建与测试使用 §25 的 scoped 命令;host 生命周期改动之后重跑 host 测试/发行及真实 API smoke。
仍待完成
动态 FilterList 全部控件/偏好持久化、相关推荐搜索回落、图片通过扩展自己的 client 执行/解扰与物化 仍待。不能将 getPageUrl 的普通 URL 直接交给 Darwin 下载器并称图片路径完成。 JNI 原生入口、OpenJDK Mobile、WKWebView/Cookie/JS、iOS SwiftUI 壳、S0/S3 和 G0/G2 真机证据仍待; Windows 的本机/JVM/Android 结果不替代 macOS 原生编译或设备表现,KMP 总目标保持进行中。
27. P2 执行记录:动态 FilterList、共享 Android 映射与回调状态隔离
日期:2026-10-02。§26 的标准筛选空缺已推进为可执行协议;偏好设置和 iOS 控件渲染未因此完成。
协议与映射
- commonMain 新增
SourceDynamicFilters/SourceFilterNode/SourceFilterValue/SourceFilterChange。 标准类型包括 HEADER、SEPARATOR、CHECKBOX、TRISTATE、SELECT、SORT、TEXT、GROUP; 其他 custom Filter 显式标为 UNSUPPORTED。嵌套组、顺序、默认 state 和原生 choice 索引保留。 getDynamicFilters经 SourceRuntime/Endpoint/Client 提供独立 JSON 操作;descriptor 新增isDynamicFilteringSupported,默认 false。SourceFilter.dynamicFilters提供 typed 变更。 state union 使用既有 serializer 的 operation discriminator:toggle/triState/choice/sort/text; sort 同时携带 index/ascending,null 清空 nullable selection。- 节点 ID 使用 path/kind/name,区别同名控件;值仍由原生 index 选取,不将 label/清洗后的 genre 写回扩展。类型、范围、未知/重复 ID 与跨源标签在写入前全部验证;树/选项改变需重新读取定义。
MihonFilterRules共享原 Android 的 top:/sort:/text: 与组路径键、genre 清洗、组标题合并、 CheckBox/TriState/Select/Sort/Text 写入规则。既有标签选项和偏好中保存的键不改名。 原来 text 标签 startsWith 会将 AuthorExtra 写入 Author;共享规则改为完整 key 或 key= 边界匹配。- Android
MihonFilterMapper只做 typed ABI 投影/写回,复用 common 规则;旧 UI 的 permissive stale tag 策略、checkbox/tri 重置和 select/sort/text 默认值策略保留。外部二进制未知 Filter 子类 不能仅依赖本地 sealed when 穷尽性,adapter 保留 UNSUPPORTED 分支。 - JVM
getFilterOptions现在返回通用标签选项,getDynamicFilters返回完整标准树;热门/最新分页不改。 query/tag/exclusion/typed 控件走源的 getSearchManga。query-only/typed 请求只修改指定状态, 未指定控件保留源默认值;legacy tag 请求复用旧 Android 的 checkbox/tri 策略。 未映射的通用 year/locale/author 字段仍报 UNSUPPORTED_OPERATION;不忽略它们后返回列表。 - Android SourceRuntime 的动态树默认不支持,descriptor 保持 false;toParser 对非空 typed 变更明确 报 UNSUPPORTED_OPERATION,防止把 JVM 控件参数悄悄丢弃。原 UI 调用路径继续正常运行。
原生对象与挂起生命周期
MihonNativeFilters 保留 source 返回的真实 FilterList、subclass、choice 对象及原 state,只修改 state。 全部请求校验完成后再写,写失败恢复;suspend/Rx 真正完成(成功或失败)后恢复,继而交付调用结果。 这也支持多次返回同一 cached FilterList 的扩展,搜索结束后不改变其默认控件。
取消不能使仍在执行的扩展过早丢失过滤上下文。反射 bridge 的 completion hook 同时负责恢复、 释放回调 lease 和完成 source gate;调用方取消后立即获得 cancellation,但回调完成前控件 state 仍保留。同一源后续操作等待 gate,且 gate 在 registry generation 内由所有 runtime 实例共享。 每个 runtime 的 manga/chapter snapshot 仍独立;不同源不共用 gate。 unload/reload 使用新 generation,旧回调只恢复旧 source 对象,不修改新安装源的 state。
SRP 分离共用规则、Android ABI、JVM 反射/native state 与调度;DRY 复用协议与原标签规则; KISS/YAGNI 只投影标准控件并保留现有原生对象,不创建平行 Filter API 或升级依赖。
验证与范围
| 检查 | 结果 |
|---|---|
| source-host 编译/发行、测试 | 3 类,32/32(执行器含 6 个新增动态筛选用例) |
| core-source common metadata / JVM | 3 类,33/33(含 9 个共用筛选规则用例);协议闭环覆盖新增 operation |
| app Kotlin / Hilt / unitTest 编译、选定回归 | 5 类,43/43;含 Android mapper 5 项与 dynamic 参数拒绝 |
| 合计 | 108/108,0 跳过;不代表全量 app 或设备测试通过 |
测试编译并加载 Java 11 ABI/JAR fixture,读取所有标准控件/未知节点,验证真实 native 对象状态 被写成包含/排除、原索引、排序方向、Unicode/=+/& 文本与嵌套 boolean;结束后原状态完全恢复。 涵盖错误恢复、非法参数写入前失败、取消后仍保留旧状态、另一个 runtime 等待回调、重载后隔离。 Android 差分检查原 tags/groups/source 对象、旧默认行为、文本边界及二进制未知子类的兼容路径。 未知子类用例生成独立 JVM 11 subtype 并跳过构造,只验证 sealed-dispatch fallback, 不把它称为外部未知 Filter 的构造/API 兼容性验证;MockK 的 sealed 替身会选用已知 child,不适用该用例。
本机研究进程另针对真实 Suwayomi API 编译独立离线扩展。Java 提供 Rx/local data 与实际 native Filter subclasses,Kotlin 2.4(项目已有编译器)override getFilterList;这遵循真实 Kotlin default bridge ABI,未修改外部 runtime jar 的 flags 或内容。fixture target 为 Java 11。 经自有 registry/runtime/shared JSON client,验证 source 的原始 cached FilterList:真实 typed 控件写回、原 choice index、sort ascending、nested group、JSON tree/legacy options 与默认状态恢复。 同时复验 §26 的列表/详情/章节/图片 URL/memo 路径,全程 Observable.just,无站点网络调用。
ACTUAL_SUWAYOMI_RX_ABI=PASS JSON_REQUESTS=12 NETWORK_REQUESTS=0驱动、fixture、研究 bootstrap/compat 层与日志仍在 ignored 本机路径,不在生产源码/默认发行包。 复验使用 §25 的 scoped Gradle 命令;host/API smoke 不等同 1395 个扩展的联网筛选可用性或 iOS 可用性。
后续仍须偏好设置/持久化、相关推荐、图片 client 执行/解扰/物化、原生控件 UI 与 JNI。 OpenJDK Mobile、WKWebView/Cookie/JS、SwiftUI、S0/S3、G0/G2 真机证据及 P1 剩余业务迁移仍待; KMP 总目标保持进行中。
28. P2 执行记录:章节图片经扩展 client 执行与文件物化
日期:2026-10-02。继续 §23–27 的 JAR/JVM host 路线,补上章节图片执行。封面、原生图片消费与缓存 保留策略仍待;本节不是 iOS 图片桥或完整 Mihon 兼容性验收。
协议与调用链
- commonMain 新增
SourcePageRequestContext(index,url,imageUrl,uri),显式保留原始 Page 字段。 页面 ID 是哈希,不能还原 index;非连续页索引、Unicode/+ 的页面 URL 和解扰 fragment 原样保留。 - 新增
SourceRuntime.fetchImage、JSON image operation 与SourceImageArtifact;artifact 携带来源、 pageId、SHA-256、hash.img 相对路径、字节数和 contentType,64 位值继续用十进制字符串传输。 DTO 校验只允许与 hash 对应的相对文件名;不跨 JSON 传图片字节、原生 Response 或绝对路径。 MihonSourceRuntime(registry,imageStore)的 store 默认 null,descriptor 的isImageFetchingSupported默认 false,仅显式配置后的 HTTP 源启用。embedding 平台提供私有图片根目录。- fetchImage 使用原始 context;无 imageUrl 时先调用源的 getImageUrl,再创建原索引/URL 的 Page, 调用真实 HttpSource.getImage。该 API 内部调用扩展自己的 getClient 和 protected imageRequest, 因此 source interceptor 的解密/解扰结果作为 response body 进入 store,不绕过为通用 Darwin 下载。
- 旧 mihon://image/resolve 包装仍可解析 index/origin/image;普通 URL 若没有 context 明确拒绝, 不从哈希 id 猜索引。非 null native uri 显式 UNSUPPORTED,等待平台 file/content URI 桥。
- Android ContentPage 的 parser ABI 不增字段;Android adapter 对 host context 明确拒绝,防止跨执行器 投影悄悄丢失图像上下文。原 Android 页面/reader 链不因此切换到 JVM host。
文件与资源生命周期
SourceImageStore 是接收流与取消检查的窄接口;FileSourceImageStore 只负责存储,不持有 HTTP 客户端 或图片解码器。64 KiB 块写入自建 staging 文件,默认每张上限 64 MiB,校验响应长度与文件头类型。 文件以 SHA-256 命名,在同目录原子发布;复用相同内容,已有 blob 损坏时校验后替换。多个 store 在本 JVM 用有界锁串行化同一文件的校验/发布,修复 Windows 打开校验流时并发替换产生 AccessDenied。 不支持原子 move 的文件系统直接失败;一个 JVM host 独占发布目录,不声称跨进程写入协调。
类型覆盖 PNG/JPEG/GIF/WebP/BMP/TIFF/JXL/AVIF/HEIC/HEIF 文件头,只做格式识别,不代表完整解码或 iOS 可显示。没有新依赖,不把 java.desktop 或另一个 OkHttp/graphics 实现加入发行包。
MihonImageResponse 保有独立 lease;消费完成、错误、取消和迟到回调都关闭 native response,使用其 扩展 ClassLoader 上下文。取消先关闭 body,再随流退出清理 staging;回调/body 未结束时卸载只能退休 注册,不能提前关闭 JAR。反射 resume 的取消清理 hook 负责已转换却未交付的响应;completion/映射 异常仍完成同源 gate,并释放已获得的资源,清理异常附加到原错误。平台 API 必须提供可由 close 解阻的流。
取消若发生在原子发布之后可留下完整 blob,调用仍取消,不将其报告为成功。文件累计容量/LRU、清理、 崩溃遗留 staging 和原生消费者生命周期属于平台缓存管理,未在这一层伪称已完成。 SRP 分离协议、源调用、响应 ownership 与文件存储;DIP 通过 store 接口连接平台目录;DRY 复用 registry lease/JSON/原生 Page;KISS/YAGNI 保留源自身客户端,不创建平行 HTTP 或解码实现。
验证与剩余
Scoped Gradle 检查包含 host 测试/发行、core-source common metadata/JVM、app Kotlin/Hilt/unitTest 编译, 以及 SourceRuntime/映射/Mihon 兼容/RepositoryFactory 选定回归。最终 XML 汇总如下;不代表全量 app、macOS 编译或真机检查。
| 检查 | 结果 |
|---|---|
| source-host 编译/发行、测试 | 4 类,46/46(文件存储 8、执行器 20、检查器/注册表各 9) |
| core-source common metadata / JVM | 3 类,34/34;image/context/64 位 artifact 协议闭环通过 |
| app Kotlin/Hilt/unitTest 编译、选定回归 | 5 类,44/44;原生 context 投影拒绝及 RepositoryFactory 回归通过 |
| 合计 | 124/124,0 失败、0 跳过 |
新增测试覆盖 JSON 与 64 位 artifact、原始字段/legacy context、unsupported 能力/URI、响应错误、取消 blocked body、迟到回调/退休 JAR、completion 失败清理、逐块 bytes/hash、 限额/截断/错误格式、去重/损坏修复、并发 store 和已存在非普通文件拒绝。
真实 Suwayomi API 的本机研究 fixture 增加自有 OkHttpClient:第一层拦截器 XOR 解密,终端本地拦截器 直接返回加密 PNG,绝不调用网络 proceed。protected imageRequest 检查原始 index/origin/image fragment。 自有 registry/runtime/JSON client/store 保存的字节与预期 PNG 完全一致,两页相同内容复用同一 blob。 同时复验列表、memo、章节、页面解析、FilterList state 写回/恢复。
ACTUAL_SUWAYOMI_RX_ABI=PASS JSON_REQUESTS=14 IMAGE_CLIENT_REQUESTS=2 NETWORK_REQUESTS=0这是实际 getImage/client/interceptor API 的本机离线闭环;不代表真实站点图片解扰、1395 个扩展请求、 Android Bitmap shim 或 iOS OpenJDK Mobile 成功。fixture/bootstrap/外部 compat 与日志仍在 ignored 路径,不进入源码或默认发行包。日志为 build/kmp-host-image-final.log 与 build/kmp-host-image-actual-api.log。
偏好持久化、相关推荐、封面执行、图片 native 消费/缓存/格式支持、动态控件 UI、JNI、Mobile JVM、 WKWebView/Cookie/JS、SwiftUI、S0/S3 和 G0/G2 真机证据仍待;KMP 总目标保持进行中。
29. Windows 纳入迁移目标与桌面常驻调试入口
日期:2026-10-02。用户明确要求同时考虑 Windows,并优先利用它更容易调试的条件。此决定补充既有 iOS 路线;Windows 是正式消费者与首选 JVM 调试平台,Windows 通过不能代替 iOS Zero/真机门槛。
平台分工与推进顺序
| 层 | Windows | iOS |
|---|---|---|
| 业务内核 | 复用 shared JVM 产物、Room JVM、reader-core/domain/net/backup/source | 复用同一 commonMain 的 Native 产物 |
| 源运行时 | 桌面 JVM,直接调用同一 registry/runtime/协议;可以断点调试 | OpenJDK Mobile Zero,经 C/JNI 协议桥 |
| UI | 独立 Compose Desktop 外壳,先做调试/最小漫画应用 | SwiftUI 外壳 |
| 图片 | 同一 getImage/client/store;桌面从平台私有目录消费产物 | 同一 host 执行,原生侧从共享私有目录消费 |
| 浏览器/JS/Cookie | Windows 平台桥,优先评估 WebView2;待实现 | WKWebView 平台桥,待实现 |
| 分发与数据目录 | 独立 JVM 应用/Windows 安装包、用户私有数据目录;待实现 | 侧载、iOS sandbox;待实现 |
Windows 首先覆盖当前本机的 x64;ARM64 需单独验证兼容层、图像/native DLL 与安装包。桌面界面由 新外壳消费共享状态/reader 语义,不搬运 Android Activity/Hilt/资源/Backdrop/Media3,也不改变 iOS UI 决策。 当前官方 Compose 平台/版本说明 列出 Windows 桌面支持与打包 JDK 条件;引入桌面插件前须用项目现有 Kotlin/Gradle 实测版本组合。 Windows 浏览器可参考微软 WebView2,它支持 原生应用嵌入 HTML/CSS/JS;JVM 互操作、线程/Cookie 生命周期与 Cloudflare 场景仍需自己的桥和验证。
| 顺序 | 工作与验收 | 当前状态 |
|---|---|---|
| W0 | 可复用的常驻 JVM host,协议调试、UTF-8/空格路径、身份校验、图片 client/文件、错误恢复/EOF | 本节已实现并在 Windows fixture 验证;真实 API 离线研究通过 |
| W1 | 桌面平台初始化、数据/偏好/网络装配、备份恢复、Room 实例、封面与图像消费;独立应用数据目录 | 数据目录/Room/图片 store(§30)、偏好与控制协议(§31–32)、基础兼容初始化(§33)、封面执行/缓存/消费(§41)、图书馆备份导出/合并恢复(含书签/统计,§53–54)已实现;完整平台能力/HTTP cache 路径/其余备份节/外部格式/完整图片格式与保留管理待完成 |
| W2 | Windows BrowserTransport/JS/Cookie/graphics shim 与代表源请求、调试面板 | WebView2 桥/Provider(§34)、调试 UI(§35、§38)、POST/headers/base URL 与 SDK Cookie(§37)、可见来源挑战闭环(§39)已接入;真实挑战、完整 API、代表源与高级图形待完成 |
| W3 | Compose Desktop 的库/浏览/搜索/详情/阅读/历史最小闭环、Windows 打包 | 初步操作闭环与窗口/重启验证已完成(§35),共享核心分页阅读见 §40,真实封面见 §41,标准动态筛选见 §42,Fit/相机交互见 §43,连续阅读与像素进度恢复见 §44,页面离线索引与临时图像资源见 §45,PNG/JPEG 长图分块见 §46,可选自动跨章与尺寸提示见 §47,整章下载/暂停继续与离线入口见 §48,下载分块清单/原子发布与旧索引迁移见 §49,元数据占用预览/确认回收见 §50,文件头尺寸与完整页面输出像素预算见 §51,Windows 本地 EXE/ZIP/MSI 与独立运行验证见 §52,图书馆备份页及跨进程恢复(含书签/统计)见 §53–54;完整阅读器、下载批量/图片磁盘容量管理、筛选预设与自定义控件/其余备份节/外部格式/同步、全量源生态、实际安装/升级/卸载与 S3/签名待完成 |
推进时优先在 Windows 验证共享逻辑与 host,再把同一 DTO/行为接到 iOS。iOS 的 G0/G2、S0/S3 继续独立记录;桌面 HotSpot 的速度、网络栈与图形实现不作为 iOS Zero 性能和兼容率的替代证据。
本轮可运行入口
source-host 新增 SourceHostPlatform 初始化/关闭 SPI、SourceHostConfig 与 SourceHostJsonSession。 CLI 的 serve <config.json> 在一个 JVM/registry 中接收 UTF-8 JSONL,每行一个既有 SourceRequest, 输出对应 SourceResponse 后立即 flush;请求顺序执行,状态/loader 不因换行重建。空行跳过,普通协议 错误返回结构化 error 后继续,EOF 关闭 registry 与平台。调用方仍拥有 Reader/Writer。
config 指定零参数的 SourceHostPlatform 类、扩展 path + 预期 package/versionCode/SHA-256,以及可选 imageDirectory。相对路径基于 config 所在目录,不受进程当前目录影响;先验证所有扩展,再创建图片目录。 provider 在显式初始化后提供 compatibility ClassLoader,负责其线程/资源关闭;默认发行包不附第三方兼容层。 CLI 将 bootstrap/扩展的 stdout 日志转到 stderr,stdout 只用于协议,不启动 TCP 服务或占用网络端口。 调试启动时可以向 JVM 传标准 JDWP 参数,断点落在与 iOS 共用的 source-host 代码上。
示例及 SPI 装配说明见 source-host/README.md。production provider/Windows graphics/browser 不是 CLI 自动提供的能力;不能仅写一个 config 就宣称完整 Windows Mihon 兼容层已完成。
SRP 将平台初始化、配置/会话与 source 执行分离;DRY 复用既有 JSON/registry/图片实现;KISS/YAGNI 先用简单 stdio 常驻会话,不新增 HTTP 服务、依赖、Windows Native 主目标或 Android 模型副本。 serialization 使用项目已有插件/版本,未升级 Kotlin 或核心依赖。
验证与证据边界
- 本机 Windows 的 host 51/51、core-source 34/34 通过。新增 5 项覆盖 Unicode/空格目录、实际 JAR 加载/跨请求图片复用、相关 ID、坏请求/未知源后的继续处理、初始化/身份失败清理与真实子进程 CLI。 子进程 fixture 没有修改 os.name,验证 stdout 完整 JSON、日志走 stderr、EOF 退出与 provider 关闭。
- 与本轮前段已通过的 Android 44 项合计 129/129,0 跳过;common metadata、host installDist 通过。 日志 build/kmp-windows-host-tests.log;默认发行目录仍只包含自有 host/core-source 与既有 Kotlin 依赖。
- 独立研究驱动通过真实 Suwayomi API/provider 调用生产 CLI serve,验证 6 个关联响应、2 次经源客户端 解密的图片物化、64 位 pageId、错误后恢复、EOF platform close 和 stdout 隔离,站点网络请求 0。
WINDOWS_JSONL_ACTUAL_ABI=PASS JSON_RESPONSES=6 IMAGE_CLIENT_REQUESTS=2 NETWORK_REQUESTS=0 STDOUT_PROTOCOL_ONLY=true真实 API 驱动仍沿用之前本机研究 bootstrap 的 Mac OS X 属性/shim 配置,并在验证结束后退出研究 JVM。 它证明 stdio/真实 API 的连接,不证明 Windows 原生 compat provider、graphics、浏览器或研究运行时线程 完整关闭。后者是 W1/W2 工作;临时驱动/第三方产物均不进入生产源码/发行目录。 证据为 build/kmp-windows-stdio-actual-api.log 与 build/kmp-windows-stdio-responses.jsonl。
Windows 桌面 UI/安装包与 iOS 原生/真机工作均未标记完成;KMP 总目标保持进行中。
30. W1 执行记录:Windows 持久化 Room 装配与失败/取消资源回收
日期:2026-10-02。上一轮 W0 的 JSONL host 已可调试,本节新增实际桌面平台存储消费者。
代码与依赖
- 新增 JVM/application 模块
desktop-runtime,与未来 desktopApp UI 分开。复用 core-db/core-source/ source-host,没有新 schema、DAO、Android 模型副本或 DI 框架。 DesktopDataPaths的 Windows 默认目录为 LOCALAPPDATA/Kototoro,缺省用 user.home/AppData/Local/Kototoro;其他 JVM 提供 .kototoro 通用回退。默认选择函数只计算路径, 不创建文件、不改环境;open 可指定独立 root,支持 Unicode/空格。实际验证仅使用临时/ignored build 目录。DesktopDatabase用既有 Room 2.8.4 的 JVM databaseBuilder、bundled SQLite 2.6.2 与 IO dispatcher。 SQLite bundled 原先已由 core-db JVM 测试使用,现在供桌面运行时使用;没有升级依赖。当前 官方 Room KMP 配置 已引用 Room 3 alpha, 本项目只参考 JVM builder/driver/协程上下文原则,以本地固定 2.8.4 API 编译和实际运行验证为准。DesktopRuntime.open执行实际 DAO 查询,使 Room 的 lazy 数据库完成 schema/migration 校验后再 创建图片目录并交付 runtime。对 unknown legacy version/corrupt 文件保留数据并报错;没有 destructive fallback,也没有把 Android 的 SupportSQLite 迁移链挪到 Windows。- 同一 runtime 暴露共享 MangaDatabase 与 FileSourceImageStore,后续 repositories/host/UI 用构造装配。 数据库和 post-interceptor 图片处于同一持久化根目录;暂不实现偏好、备份恢复或浏览器/兼容 provider。
Windows 验证发现与修复
实际 JVM 子进程的 System.out 默认编码会把中文 JSON 路径输出成乱码。desktop storage CLI 改用明确的 UTF-8 字节输出,独立子进程按 UTF-8 解码并比对真实中文目录,不能仅以 JSON parse 成功作为验证。
在固定 Room 2.8.4 上,损坏/v83 schema 的 startup 失败后,即使常规 Room.close 被调用,本机 Windows 仍发现数据库/WAL 句柄占用,JUnit 无法清理其测试目录。失败发生在 driver 已 open 但 Room 配置尚未成功 入池的阶段。DesktopSQLiteDriver 以 SQLiteDriver/SQLiteConnection 接口包装、追踪自己创建的连接, 由 desktop database owner 先关闭 Room,再兜底关闭剩余 native 连接;不反射/修改 Room 或 JNI 库。 正常 pool 关闭与兜底关闭不会重复调用 native close;清理会尝试全部连接并保留 primary/suppressed 错误。
DesktopRuntime.close 幂等。另有 acquisition ownership:withContext(IO) 可能完成资源创建却在返回 dispatcher 时因取消丢弃结果,open 的外层捕获该路径,关闭已创建 runtime。用可控制的 caller dispatcher 测试在 IO 返回后、交付前取消,确保调用方没有获得 runtime,数据库句柄已释放,仍可重开既有完整数据库。 关闭不会删除数据;成功建成的数据库/目录可以在取消后保留并于后续启动复用。
SRP 分离路径、database builder/owner、driver 句柄与应用 runtime;DIP 通过原有 SQLiteDriver 与源 store 接口装配;DRY 使用共同 schema/DAO/图片实现;KISS/YAGNI 不重写 Room 池或先引入 UI/服务框架。
检查与证据
| 检查 | 结果 |
|---|---|
| desktop-runtime 测试/编译/发行 | 13/13,0 跳过;真实 bundled Windows native SQLite + 磁盘数据库 |
| core-db JVM 回归 | 9/9,0 跳过 |
| source-host 回归 | 51/51,0 跳过 |
| 合计 | 73/73,0 失败 |
| core-db common metadata / app compileDebugKotlin | 通过;Android schema 文件没有变动 |
测试关闭后重新打开磁盘 DB,逐项比较来源、漫画、opaque sourceData、收藏分类/成员与阅读进度(含大 64 位 ID/章节 ID),并读取原 DAO 的关联查询和 Flow。独立 SQL 查询实际 PRAGMA user_version=84, room_master_table identity 与已提交 Android 84.json 一致。未知版本保留 sentinel/版本;损坏文件字节 不变;图片 cache 被占用时 startup 失败也释放数据库;Windows 对关闭/失败/取消后的文件移动均通过。 driver 测试覆盖正常/未入池连接、幂等 close、全部清理尝试与原错误保留。
installDist 中实际包含 Room 2.8.4、SQLite bundled 2.6.2 与自有 JVM 模块,不包含 Android app 或研究 compat/bootstrap。独立 Java 进程运行生成发行目录,输出中文路径的 UTF-8 JSON,正常退出并释放数据库。 另直接运行生成的 Windows .bat:
& "desktop-runtime/build/install/desktop-runtime/bin/desktop-runtime.bat" storage "E:/kototoro_demo/kt-kmp/build/kmp-windows-storage-smoke"结果退出码 0,实际 schemaVersion=84、sourceCount=0,database/images 位于指定 root。运行 JVM 产物不 引用 Android 应用组件;Gradle 构建仍在同一 Android/KMP 仓库工具链中。测试命令为:
./gradlew.bat -I "build/kmp-backup-validation.init.gradle" :desktop-runtime:test :desktop-runtime:installDist :core-db:jvmTest :core-db:compileCommonMainKotlinMetadata :source-host:test :app:compileDebugKotlin日志:build/kmp-desktop-storage-final.log、build/kmp-windows-storage-cli.log。 Windows storage 不是 Windows 整体完成:偏好/网络/备份、封面/图像消费、compat provider、browser shim、 Compose Desktop UI 与安装包仍待。iOS builder、C/JNI/Mobile JVM/SwiftUI、G0/G2/S0/S3 仍需独立推进, KMP 总目标保持进行中。
31. Windows / JVM 偏好持久化与构造前绑定(2026-10-02)
Windows W1 继续接入偏好存储,供桌面应用和后续嵌入 JVM 的 iOS 平台使用。本批实现共享类型/存储契约、 JVM 文件 backend、真实 Android 接口 bridge 和初始化 SPI;偏好控制树/变更协议与原生设置 UI 仍待。
构造顺序与类型
真实 pinned Suwayomi ConfigurableSource.getSourcePreferences 默认通过 Injekt Application 获取 source_<id> SharedPreferences,扩展构造或 lazy baseUrl/client 可以立即读它。因此启动顺序必须是 store → 初始化兼容层并绑定 Application/Context → registry 加载并构造扩展。不能加载后才恢复设置。 AndroidCompat CustomContext 默认另建 JavaSharedPreferences,没有现成的外部 store setter;本批没有改写 第三方类/字节码。具体平台 Application override 委托自己的 MihonPreferenceBridge,完整 Windows compat provider 仍待。
core-source 新增 SourcePreferenceStore/SourcePreferences、edit/change/listener 与六种序列化值: String、Boolean、Int、Long、Float、StringSet。Int/Long 不隐式互转;Long 保持十进制字符串,Float 原始 IEEE bits 保留 -0、Infinity、NaN,避免非标准 JSON 数字。读写集合隔离,保留 namespace/key,无 DI。
FileSourcePreferenceStore 使用 namespace SHA-256 文件名,文档校验 namespace/version/UTF-8,单份默认 4 MiB,损坏/未知版本保留原文件并拒绝读取。一个目录一个进程 owner,Windows 文件锁在 close 释放。 写入同目录临时文件,force 后 atomic move;无非原子覆盖 fallback。clear 先于最终 key 变更,并发 editor 按 delta 合并,commit 写盘失败返回 false,已发布内存仍可读,之后 edit 重试完整最新快照。 监听回调执行时不持有 store monitor,可重入编辑。
bridge 只引用父兼容 ClassLoader 的 Android 接口,用 Proxy 实现 SharedPreferences/Editor;Application 仍是具体平台类。保留类型错配 ClassCastException、默认值、空值删除、最后一次 key 写入、editor 复用、 弱监听器/平台 dispatcher、Android R+ clear null key 通知(可配置旧目标行为)。apply 当前也同步完成磁盘 尝试,异步写盘调度未实现,不将此行为描述成完整 Android UI/Looper 兼容。
serve config 的可选 preferenceDirectory 要求 SourceHostPreferencePlatform.initialize(store),不支持的 provider 在构造前明确失败,不默默落到临时偏好。session 先关闭 registry/platform,再关闭 store;失败 路径也回收 owner,平台 shutdown 仍能保存设置。DesktopRuntime 将 preferences 放在同一持久化根目录, 拥有 store、close/取消清理、初始化失败 DB 回收;storage CLI 同时输出偏好路径。
检查与证据
| 检查 | 结果 |
|---|---|
| core-source JVM | 35/35,0 跳过;新增类型 JSON roundtrip |
| source-host JVM | 63/63,0 跳过;新增文件存储 6、bridge 4、session 生命周期 2 项 |
| desktop-runtime JVM | 15/15,0 跳过;新增持久化/owner 启动失败 2 项 |
| 合计 | 113/113,0 失败 |
| core-source common metadata / app compileDebugKotlin | 通过;未改变 parser ABI/Android 偏好入口/DB schema |
新增测试覆盖重开、64 位/float bits、namespace 隔离、集合隔离、clear/remove/最终写入、监听重入与排队 注销、并发 delta、限额、损坏/未知版本保留、写盘失败保留内存/恢复重试、Windows 关闭后目录移动、 初始化失败释放、扩展构造前读取与平台关闭回调保存。所有目录是临时/ignored build,没有打开真实用户 LocalAppData 数据。
另以本地实际 AndroidCompat-1.0/server-1.0 API 编译独立离线 fixture,在两个普通 Windows JVM 进程依次 write/read:自己的 Application override + Injekt 绑定 → 真实 ConfigurableSource 默认偏好方法 → 构造时 读 domain → 真实 PreferenceScreen/ListPreference listener 拒绝/接受 → saveNewValue → 六种类型 commit。 第二进程构造立即读到保存 domain,控制对象 getCurrentValue 与其他类型均恢复。实际 PreferenceScreen addPreference 会把 screen 的 SharedPreferences 传给控件,必须正确绑定 screen;这属于后续控制树桥 的真实 API 约束,不能把 UI 控件当作无行为 DTO。
结果:ACTUAL_ANDROIDCOMPAT_PREFERENCES=PASS,PHASE=write/read,NATIVE_TYPES=6, CONSTRUCTOR_READ=true,OS=Windows 11,NETWORK_REQUESTS=0。该实验没有 os.name override、boot shim、 第三方 bootstrap 或站点请求;研究 fixture/API 仍只在 ignored 本地目录,默认发行包不含第三方 compat。 这只证明偏好链路可在原生 Windows JVM 运行,不扩展成整个兼容层/真实扩展全量通过。
日志:build/kmp-preferences-final.log、build/kmp-preferences-ownership.log、 build/kmp-preferences-actual-write.log、build/kmp-preferences-actual-read.log。 构建命令:
./gradlew.bat -I "build/kmp-backup-validation.init.gradle" :core-source:jvmTest :core-source:compileCommonMainKotlinMetadata :source-host:test :source-host:installDist :desktop-runtime:test :app:compileDebugKotlin按 SRP 分离共同类型、磁盘 backend、Android ABI bridge 与平台构造;DIP 通过 store/SPI;DRY 共用桌面/ stdio backend;KISS/YAGNI 不先引入设置框架或复制 Android 应用 prefs。下一步接入可执行 PreferenceScreen 控制树/变更协议,再推进 Windows compat/browser 与桌面界面。iOS G0/G2/S0/S3、C/JNI/Mobile JVM/SwiftUI 仍独立待验,KMP 总目标继续进行中。
32. P2 / W1 执行记录:可执行偏好控制树与设置变更协议
日期:2026-10-02。继续 §31 的构造前偏好存储,补齐 host 标准设置树与源校验/保存调用链。 高级输入绑定/自定义动作、原生设置界面、完整 Windows compat/browser provider 仍待;KMP 总目标保持进行中。
契约与执行
core-source 新增 SourcePreferenceScreen/Node/Choice、Kind、UpdateStatus/Update 与 preferences/ updatePreference JSON 操作。树带 SourceRef、随机 revision、路径节点 ID、原 key、标题/summary、值/ 默认值、entryValues 和 enabled/visible;子树共享平台无关类型。describe 的 isPreferencesSupported 默认为 false,Android adapter 保持原设置入口并明确报 UNSUPPORTED_OPERATION;parser ABI/DB schema 不变。
provider 可实现 SourceHostPreferenceUiPlatform 返回已初始化的真实 AndroidCompat Context;库装配由 MihonSourceRuntime 的第三个参数传入,省略时明确不支持。MihonNativePreferences 运行真实 ConfigurableSource.setupPreferenceScreen,setup 前绑定 screen 的 SharedPreferences,setup 后绑定 整个树;保留原生控件/回调对象与 loader generation,值不会只投影为未经验证的 key 写入。
List/MultiSelect/EditText/Switch/CheckBox 的基本修改已接入;choices 保留原 entryValue,重复标题允许。 重复 key、选择定义缺失/错位/重复值、自定义动作、OnBindEditTextListener 输入绑定明确 UNSUPPORTED。 group 子树继承 enabled/visible 限制;树限制 16 层/1024 个控件。按父兼容运行时 ABI 读取,不注入另一份 Android/Preference 类型,也没有复制第三方设置框架。
update 先验证 source session/revision/节点、enabled/visible、可编辑类型与原选择值;验证失败不执行回调。 随后 invalidate revision → callChangeListener → 接受时 saveNewValue → 空 editor.commit → 投影更新后树。 空 commit 使用 native 保存后的快照,既覆盖 Android apply 的异步 flush,也让自有 store 重试完整快照; 成功、源拒绝、持久化失败分别返回 ACCEPTED/REJECTED/PERSISTENCE_FAILED。
拒绝/抛异常也不能回滚源的其他设置或控制对象副作用;throw 使用原协议脱敏错误,旧 revision 已失效。 读 preferences 会重建树并使旧 token 失效;不同 runtime session/卸载重载不复用旧 token。 同步回调共用现有同源 execution mutex/loader lease;caller 取消不假装中断已进入的同步回调,其他 runtime 实例等其真正结束。设置写入可在取消后发生,调用方不能无条件自动重试含副作用的 listener。 改变设置不会自动重构构造时缓存的 domain/client;需要重建时由平台生命周期显式处理。
检查与证据
新增 JVM executor 测试覆盖 JSON 投影、原始选择值/重复标题、默认/当前值、嵌套可见性、未知动作/ 输入绑定/重复 key、拒绝/接受、其他控件变更、非法值零回调、过期 revision、异常效果保留/错误脱敏、 磁盘失败/内存值/重试、typed save、取消阻塞回调对另一 runtime 的源锁、generation 重载与 stdio Context SPI。 core-source 既有全操作 roundtrip 扩展覆盖新协议;Android 新增显式 unsupported 回归。
| 检查 | 结果 |
|---|---|
| core-source JVM | 35/35,0 跳过;全操作 roundtrip 包含新设置协议 |
| source-host JVM | 72/72,0 跳过;新增偏好执行器 9 项 |
| desktop-runtime JVM | 15/15,0 跳过 |
| app core/source 选定回归 | 27/27,0 跳过;新增 Android 设置协议显式 unsupported 1 项 |
| 合计 | 149/149,0 失败 |
| common metadata / app compileDebugKotlin | 通过 |
./gradlew.bat -I "build/kmp-backup-validation.init.gradle" :core-source:jvmTest :core-source:compileCommonMainKotlinMetadata :source-host:test :desktop-runtime:test :app:compileDebugKotlin :app:testDebugUnitTest --tests "org.skepsun.kototoro.core.source.*"另以真实本地 AndroidCompat-1.0/server-1.0 API 编译独立 NativePreferenceSource JAR,自己的 inspector 核对 package/version/hash 后经 registry 构造;Application override/Injekt 在构造前绑定自有持久化 store。 source 使用真实 ConfigurableSource/CatalogueSource、PreferenceScreen/List/EditText/Switch/MultiSelect; 所有内容入口禁止网络。
由自己的 runtime → endpoint → JSON transport → protocol client 完成 describe/preferences/拒绝/文本/ 开关/多选/接受/过期请求。写阶段 8 个响应,读阶段新 JVM 2 个响应;源构造时恢复 domain,四种控件值 重开一致,listener 对另一控件 enabled 的修改正确投影。结果为:
NATIVE_PREFERENCE_PROTOCOL=PASS PHASE=write JSON_RESPONSES=8 OS=Windows 11 NETWORK_REQUESTS=0
NATIVE_PREFERENCE_PROTOCOL=PASS PHASE=read JSON_RESPONSES=2 OS=Windows 11 NETWORK_REQUESTS=0没有 os.name override、boot shim、第三方 bootstrap 或站点请求。研究 JAR/driver/兼容 API 均在 ignored 本地目录,默认发行包只含自己的代码与已有 Kotlin 依赖;此证据不扩大成全量真实扩展设置或完整 Windows 兼容层成功。
日志:build/kmp-preference-screen-final.log、build/kmp-native-preference-actual-write.log、 build/kmp-native-preference-actual-read.log;JSON 响应在 build/kmp-native-preferences-write/read.jsonl。 按 SRP 分离共享 DTO 与原生执行,DIP 用 Context SPI,DRY 使用源执行锁和既有持久化桥;KISS/YAGNI 先接真实标准控件,不引入 UI 框架、并行设置体系或自动源重建。
33. P2 / W1 执行记录:Windows 兼容平台初始化
日期:2026-10-02。基于 §29–32 的正式 Windows 消费者,新增可选 :mihon-desktop-compat,用实际 AndroidCompat/Suwayomi API 初始化平台后接到原 registry/runtime/JSONL。完整 W1、浏览器/UI、分发与 iOS 门槛仍未完成,KMP 总目标继续进行中。
初始化与生命周期
直接调用 stock AndroidCompatInitializer/startApp 的本机探针失败:CustomContext 创建 ActivityManager 时反射调用非公开构造器,Windows/JDK 25 抛 IllegalAccessException。没有修改外部 JAR 或复制参考 GPL Context/bootstrap 实现;独立 Application 覆盖公开存储/偏好入口,使用实际 createAppModule 和 NetworkHelper,形成另一条已验证的 API 装配链。
MihonDesktopPlatform 实现 SourceHostPreferenceUiPlatform,公开零参数构造可供 CLI 反射;嵌入时可 显式传 dataDirectory。未指定时从 FileSourcePreferenceStore.directory 的父目录选择同级 compat。 store 必须在扩展构造前绑定;Application.getSharedPreferences 委托既有 MihonPreferenceBridge, files/cache/no-backup 进入私有目录。不创建 ConfigManager/stock CustomContext 或 server.conf。
兼容主线程使用实际 Looper.prepare + setMainLooperForTest + loop;这些为 pinned desktop API 的公开 方法,使 Looper 可退出。SharedPreferences listener 派发到此线程,真实 NetworkHelper/PersistentCookieStore 使用同一 cookie_store backend。Koin 注册实际 AppModule/Context,Injekt 切换到实际 KoinRegistrar; 不创建生产 Mihon/Android ABI 副本,也没有 boot shim、os.name override 或研究 bootstrap。
SDK 含全局状态,因此一个 JVM 只允许一个平台生命周期,初始化失败后需新进程重试;扩展重载沿用 已有 registry。拒绝已有全局 Koin/main Looper,初始化中途失败执行同一 close 路径。已创建的客户端单独 记录,清理不会再次触发 lazy client 初始化;CookieHandler 在 helper 初始化的 finally 中记录,以覆盖 部分初始化失败。close 取消已知调用、关闭 cache/pool/dispatcher,恢复自己替换的 CookieHandler/Injekt/ Koin,退出 Looper;多次 close 幂等。CLI 在平台之后关闭 store,Windows 句柄释放由测试实际检查。
构建与运行入口
默认 settings 不包含模块,通过 -PwithMihonDesktopCompat 显式启用。API 目录由 mihonCompatibilityDirectory 指定,AndroidCompat → server → android-jar 的顺序固定,避免原始 stub 覆盖 compat 类;五个关键 API JAR 核对 SHA-256。项目提供现有 Kotlin/coroutines/serialization 版本, 外部目录对应重复核心 JAR 排除;完整传递产物锁定与许可证清单仍待。
第三方 API 实测 class major 65,需要 Java 21+;adapter 自有字节码仍为 Java 11。仅 fixture compiler、 测试/子进程与 JavaExec 选择独立 toolchain(默认 21),本机已有 17/25 时指定 -PmihonCompatibilityJavaVersion=25,不改变 Android/Gradle JVM 或核心依赖版本。
外部 API 为 compileOnly/testRuntimeOnly,模块 JAR 只含自己的 adapter。默认 source-host installDist 仍只有自有 host/core-source 与既有 Kotlin 依赖,不附第三方 SDK、fixture 或扩展。仓库根 LICENSE 为 Apache-2.0;没有复制参考 GPL 实现,不代表 D3/S3 的运行时集成和分发许可已经完成。
runSourceHost 接收 sourceHostConfig,启动原 SourceHostCliKt serve 并转交 stdin;配置的 platformClass 为 org.skepsun.kototoro.desktop.compat.MihonDesktopPlatform,必须提供 preferenceDirectory 和经核对的 JAR identity。相对路径依旧基于配置目录。Gradle 可能打印自身构建信息;程序化纯 JSONL 直接运行 CLI 进程,扩展日志进 stderr、UTF-8 响应进 stdout。配置/启动示例见 mihon-desktop-compat/README.md。
./gradlew.bat -PwithMihonDesktopCompat "-PmihonCompatibilityDirectory=D:/tmp/kt-compat36" -PmihonCompatibilityJavaVersion=25 :mihon-desktop-compat:test :core-source:jvmTest :core-source:compileCommonMainKotlinMetadata :source-host:test :source-host:installDist :desktop-runtime:test
./gradlew.bat -I "build/kmp-backup-validation.init.gradle" :app:compileDebugKotlin
./gradlew.bat -q -PwithMihonDesktopCompat "-PmihonCompatibilityDirectory=D:/tmp/kt-compat36" -PmihonCompatibilityJavaVersion=25 "-PsourceHostConfig=E:/path/to/config.json" :mihon-desktop-compat:runSourceHost验证与能力边界
fixture 使用实际 HttpSource/ConfigurableSource、Rx 默认 suspend bridge、ListPreference、OkHttp client/imageRequest/interceptor;编译为独立 Java 11 JAR,经自己的 inspector/registry 加载,不放入父 classpath 或发行 JAR。源构造读取 domain,真实 listener 拒绝/接受,第二个普通 Windows JVM 构造时 恢复保存值。终端 interceptor 返回本地加密 PNG,不调用网络 proceed;实际 SDK/client 解密后的字节 经 runtime/endpoint/shared JSON client/image store 与期望 PNG 比对一致,原 Page index=12 保留。
五项测试分别覆盖:Cookie/主线程 listener/关闭;跨进程源设置与离线图片;错误数据根目录原数据保留; 已建立 Koin/Looper 后偏好 backend 失败的清理;真实 CLI 零参数 provider/中文控制标题/JSON 请求 ID/ EOF 正常退出。关闭后检查全局状态、自己的线程/dispatcher、重开偏好目录与 Windows 目录移动。
| 检查 | 结果 |
|---|---|
| mihon-desktop-compat 实际 API | 5/5,0 失败/跳过;子进程 OS=Windows 11,离线 fixture NETWORK_REQUESTS=0 |
| core-source JVM / common metadata | 35/35;metadata 通过 |
| source-host JVM / 默认发行 | 72/72;installDist 通过,未增加第三方 compat JAR |
| desktop-runtime JVM | 15/15 |
| 合计 | 127/127,0 失败/跳过 |
| 默认配置 app compileDebugKotlin | 通过;没有启用可选模块 |
| adapter JAR / JVM target | 只含自有类;class major 55 |
另通过同一生产 provider 的 runSourceHost 加载四个本地真实扩展:comicfury 14、mangadex 61、buondua 1、 akaicomic 1,共 77 个源。source ID 完整集合与 §25 存档目录逐项一致,MIHON 键保持不变;EOF 正常退出。 这次不使用旧研究 bootstrap/shim,只调用 sources 元数据,没有调用站点内容入口;不扩大成全量扩展 请求/设置/图片或 iOS 验收。
日志:build/kmp-desktop-compat-final.log、build/kmp-desktop-compat-ownership-final.log、 build/kmp-desktop-compat-default-app.log、build/kmp-desktop-production-real-load.log。 初始化研究探针日志:build/kmp-windows-compat-init-probe.log、build/kmp-windows-context-probe.log。 外部 API/真实扩展与临时研究驱动仍在本机 ignored 目录,测试 fixture 自有源码在可选模块的 src/fixture。
剩余约束必须继续推进:SDK UAFlow observer 没有公开 Job,依赖进程退出终结;WebView factory 为 进程全局,目前明确 unsupported,避免默认 KCEF 下载。Android 系统服务/Resources/PackageManager 也明确 unsupported。NetworkHelper 的 HTTP cache 使用 SDK 创建的系统临时目录,关闭只释放句柄, 目录迁移、容量与遗留清理尚待;不能把 Application.cacheDir 私有化写成 HTTP cache 全部完成。
按 SRP 分离 Application/Looper/装配 owner,DIP 接既有 store/Context SPI,DRY 共用 JSON/session/源锁与 SDK 网络实现;KISS/YAGNI 只接当前实际链路,不造另一个 Android runtime 或引入浏览器框架。 下一步为 W1 剩余网络/数据生命周期、W2 BrowserTransport/JS/Cookie/graphics 与 W3 桌面界面; JNI/OpenJDK Mobile/WKWebView/SwiftUI/G0/G2/S0/S3 仍需独立完成。
34. W2 执行记录:Windows WebView2 跨进程桥、离线 HTML/JS/Cookie 验证与 WebViewProvider 装配
日期:2026-10-02。基于 §29–33 的 Windows 兼容层与运行时基础,接入真实 Windows 浏览器/JS 执行能力。
架构决策与选型
- 拒绝重型 CEF/KCEF 依赖:避免运行时从外部网络动态拉取 200MB+ Chromium 二进制文件,不向发行包注入闭源大型二进制,符合轻量桌面分发与离线确定性原则。
- 利用 Windows 11 预装 Evergreen Edge WebView2:Windows 11 系统路径(
C:\Program Files (x86)\Microsoft\EdgeWebView\Application)已预装常青版 Edge WebView2 运行时(当前本机为154.0.4258.48)。 - 跨进程架构与 STA 消息泵解耦: WebView2 原生 CoreWebView2 与异步回调对调用线程有严格的 Windows STA 消息循环要求(无消息循环直接同步等待会死锁)。 为此设计独立轻量 C# 桥进程
KototoroWebViewBridge.cs,使用 Windows 自带的.NET Framework 4.0C# 编译器(csc.exe)编译。 桥进程内部运行专有 STA 消息循环(Application.Run(new ApplicationContext())),对外通过标准输入输出(stdin/stdout)提供 JSON-RPC 协议通道。 完全隔离了浏览器崩溃或文件锁对主 JVM 进程的影响。 - 协议契约: 提供
init,navigate,loadHtml,evaluateJs,getCookies,setCookie,close七项操作。 支持 origin Cookie 注入与读取,以及无界面(off-screen)JavaScript 表达式求值;尚未验证真实挑战和 SDK Cookie 互通。
客户端与 AndroidCompat 集成
DesktopWebViewBridge.kt:基于 Kotlin 协程与 ProcessBuilder 的 JSON-RPC 客户端,支持结构化请求 ID 关联、UTF-8 字符流与安全超时,实现AutoCloseable。DesktopWebViewProvider.kt:实现android.webkit.WebViewProvider,通过动态代理实现ViewDelegate与ScrollDelegate。接驳loadUrl,loadDataWithBaseURL,evaluateJavaScript(ValueCallback)以及页面加载完成通知。- Looper 与线程安全: AndroidCompat 的
android.webkit.WebView.<init>强制检查Looper.myLooper() != null。通过在主线程/兼容线程绑定DesktopCompatibilityLooper或调用Looper.prepare()满足要求;所有客户端回调(WebViewClient.onPageFinished,ValueCallback.onReceiveValue)均派发至兼容主 Looper。 MihonDesktopPlatform.kt:在平台初始化时读取显式kototoro.compat.bridge.exe,注册WebView.setProviderFactory,在未提供桥可执行文件时回退至不支持状态。生产代码不自动搜索默认桥。
检查与验证证据
mihon-desktop-compat新增DesktopWebViewBridgeTest,共 5 项测试:bridge launches and reports Evergreen Edge WebView2 version:成功启动桥进程并识别 Evergreen Edge WebView2 运行时版本。bridge loads offline HTML and evaluates JavaScript expressions:加载离线 HTML 并通过 JS 表达式求值准确返回 DOM/标题。bridge sets and retrieves cookies for an origin:针对特定 origin 写入 Cookie 并验证能够准确提取(验证 cf_clearance 场景所需能力)。DesktopWebViewProvider integrates with android webkit WebView to evaluate JavaScript:通过真实android.webkit.WebView实例接驳 Provider,异步执行 JS 并通过ValueCallback获取返回值。bridge closes cleanly and terminates process:验证桥正常退出、子进程销毁以及关闭后拒绝对话的幂等保护。
- 规避测试临时目录锁冲突:Edge 在异步退出时可能短时间持有 Crashpad 锁文件,测试配置使用专用子目录路径隔离,保证 JUnit 进程清理平稳。
| 检查 | 结果 |
|---|---|
| mihon-desktop-compat 单元测试 | 10/10,0 失败/跳过(5 项平台探针 + 5 项 WebView2 桥/Provider 集成测试) |
| core-source JVM / common metadata | 35/35;通过 |
| source-host JVM / 默认发行 | 72/72;installDist 通过 |
| desktop-runtime JVM | 15/15;通过 |
| 合计 | 132/132,0 失败/跳过 |
| app compileDebugKotlin | 通过;无任何编译退化 |
本节证明 Windows WebView2 离线 HTML/JS/Cookie 与基础 WebViewProvider 装配,不能据此标记完整 W2 完成。 2026-10-03 复核当前代码:loadUrl 丢弃 additional headers,postUrl 退化为 GET, loadDataWithBaseURL 未保留真实 origin/base URL;SDK CookieManager 与 WebView2 尚未互通。 Settings 应用、并发导航次序、失败回传/请求取消和 Chromium profile 关闭仍需补齐。 Cookie 测试只写入固定名称和值,不证明 Cloudflare 挑战成功;跨进程也不能保证浏览器文件锁永不影响关闭。 W3 接口与验证见 §35,iOS 原生和许可门槛继续独立推进。
35. W3 执行记录:Windows Compose 桌面入口与实际操作/重启/原生窗口验证
日期:2026-10-03。按用户最新优先级先推进 Windows,接续另一个模型的 §34 桥代码。 新增可选 desktop-app,默认配置保持 Android 入口;完整 KMP/iOS 和 Windows 打包仍未完成。
代码与操作入口
- Compose Multiplatform 1.12.0 与项目既有 Kotlin/Compose compiler 配合,采用该版本稳定 Material 控件; 没有升级 Kotlin、Room、SQLite、coroutines 或 serialization。JVM 目标为 Java 17,外部 API 仍需 Java 21+。
DesktopSession装配真实MihonDesktopPlatform、registry、共享 JSON endpoint/client 和现行存储。 JAR 导入保存原文件 path/package/version/hash,启动时核对恢复;不复制安装、不转换 APK、不触及 Android 包管理。DesktopController使用不可变 StateFlow 和串行操作;窗口接来源筛选、热门/搜索分页、详情/章节、 收藏、继续阅读/单页图片、历史、标准源控件与拒绝/持久化结果。DesktopLibrary复用共享 v84 schema/DAO,写入漫画/标签/章节/收藏/历史,状态直接归属 manga_id。 Entity Graph / Work 没有恢复;Room Entity 仅为现有表映射。写入标签实体,保留标签 pin、收藏 pin/order/ createdAt、历史 createdAt,按分支计算进度,拒绝 ID 冲突和无效页面。- Session 持有浏览器,profile 位于私有数据目录;界面离开和协程取消不会丢失 native process owner。 启动取消会回收已获得未交付的 Session;窗口退出先取消/等待操作,再关闭 registry/平台/存储。
application(exitProcessOnExit=false)允许正常返回后验证资源释放,不依赖强制 JVM 退出掩盖关闭问题。 - 开发 classpath 顺序从 compat 的
suppliedCompatibilityRuntime配置复用,仅加入 run/test; 普通 runtimeClasspath/默认 Android/source-host 发行不附外部 SDK,Windows EXE/MSI 尚未交付。
启动文档见仓库 desktop-app/README.md。本机可运行:
./desktop-app/run-windows.ps1 -CompatibilityDirectory "D:/tmp/kt-compat36" -JavaVersion 25可选参数 BridgeExecutable、DataDirectory、ImportJar 分别指定 WebView2 exe、独立数据目录和启动导入。 默认数据目录为 LOCALAPPDATA/Kototoro。自动 UI/数据库/窗口测试使用自己的临时目录,不访问站点。 首次正式脚本 smoke 发现 Compose 的晚配置覆盖了 run classpath/args,遗漏 Koin 且忽略了指定数据目录; 启动在 LOCALAPPDATA/Kototoro 新建了 v84 存储。只读 SQL 核对 manga/favourites/history 均为 0 行, 该目录保留未删除。现于 run 的 doFirst 应用开发 classpath/参数,实际脚本已使用指定的 build/desktop-preview, 并持久化离线 fixture 的身份记录;日志为 build/kmp-desktop-ui-preview.log。LinkageError 在窗口边界显示错误。
验证与边界
DesktopLibraryTest 五项覆盖:大整数/opaque sourceData/标签/章节在重开后完整恢复;按分支的页进度与 历史创建时间;收藏 pin/order/createdAt 与墓碑恢复;删除分类过滤;身份冲突/无效页/变更章节拒绝后数据保留。 没有数据库 schema 改动。
DesktopAppTest 两项使用真实 Windows 子进程、实际兼容 SDK 与独立自有 fixture JAR:
- 使用 Compose v2 UI 测试 API, 点击来源/中文搜索/详情/收藏/阅读/历史/标准选择设置,验证 listener 拒绝和保存;第二个进程恢复源设置/ 收藏/阅读进度。章节 PNG 仍由源 client/interceptor 解密,网络请求为 0;本机已配置桥时另执行真实 HTML/JS。 对 browse/search/details/reader/preferences 渲染截图并目视检查中文布局。
- 通过生产 main 创建实际原生 Window,分别在启动中与导入后触发关闭;退出后重开数据库/偏好、 移动测试数据目录,验证自己的句柄释放。WebView2 profile 的 Edge 子进程可能晚于桥退出释放, 包含浏览器的测试采用最长 10 秒有界等待,不能将其写成所有原生子进程已同步退出。
测试任务将 fixture JAR 与桥二进制作为输入,避免文件内容变化后误用 up-to-date 的旧探针结果。
| 检查 | 结果 |
|---|---|
| core-source JVM / common metadata | 35/35;metadata 通过 |
| source-host JVM / 默认发行 | 72/72;installDist 通过 |
| desktop-runtime JVM | 20/20,含 5 项新库/历史测试 |
| mihon-desktop-compat 实际 API / WebView2 | 10/10 |
| desktop-app UI / 生产 Window | 2/2,四个 Windows JVM 验证 write/read/window/window-early |
| 合计 | 139/139,0 失败/跳过 |
| 默认 Android compileDebugKotlin | 通过,没有启用可选桌面模块 |
| 正式脚本启动 | 已启动实际窗口,指定数据目录的 fixture 注册记录存在 |
./gradlew.bat -PwithDesktopApp "-PmihonCompatibilityDirectory=D:/tmp/kt-compat36" -PmihonCompatibilityJavaVersion=25 :desktop-app:test :desktop-runtime:test :mihon-desktop-compat:test :source-host:test :source-host:installDist :core-source:jvmTest :core-source:compileCommonMainKotlinMetadata日志:build/kmp-desktop-ui-final.log、build/kmp-desktop-ui-interactions.log、build/kmp-desktop-window-tests.log、 build/kmp-desktop-ui-boundary-final.log、build/kmp-desktop-ui-default-app.log; 渲染图与 write/read/window/window-early 进程结果在 desktop-app/build/reports/desktop-smoke。 这些是本机 ignored 构建产物,不进入提交。
剩余 Windows 工作:真实封面、reader-core 场景/连续滚动/键盘/预取/下载、高级过滤、备份/同步、 APK 转换/扩展复制安装与更新、Kotatsu/Legado 桌面装配、完整 WebView/请求/Cookie/图形与代表源验收、 HTTP cache 生命周期、Windows 打包和 S3。iOS 的 JNI/OpenJDK Mobile/WKWebView/SwiftUI/G0/G2/S0 继续独立完成。已有通过项不替代这些验收,KMP 总目标保持进行中。
SRP 分离窗口、Controller、Session、浏览器与存储投影;DIP/DRY 共用既有协议、registry、native 设置和 Room schema;KISS 保持单窗口与单页阅读,未引入另一套 DI 或平行状态库。
36. Entity/Work 移除后的同步路径修复
核对剩余 entity 文件发现:Room 的 MangaEntity、FavouriteEntity、HistoryEntity 等仍是 v84 有效表的行映射,不能按目录名删除。旧表常量供历史迁移使用,旧备份及同步 DTO 的可空 entity/anchor 字段保留读取兼容;收藏、历史和追踪读模型部分 entity_id 是 manga_id 的查询别名。 详情页的 EntityRelation UI 名称目前用于远端追踪站点的人员、角色和关联作品展示。
运行时同步存在实际遗漏:SyncProvider 的白名单及冲突更新仍指向已删除的 work_favourites、 work_history 和 entity_id,SyncHelper 成功同步后的 GC 也访问旧表。已改为 favourites(manga_id, category_id)、history(manga_id),继续使用既有事务和四天墓碑保留期。 没有修改 schema 或恢复实体图谱。收藏读模型的旧 work/entity preferences 注释也已纠正。
SyncProviderProjectionTest 使用 AndroidX ProviderTestRule、独立内存 Room 数据库和真实 ContentResolver/ContentProviderClient;通过可覆写的受保护 database 属性提供测试数据库,生产仍由 原 Hilt EntryPoint 装配。测试 APK 声明系统可选 android.test.mock 库供该规则使用,生产 APK 不添加 依赖。HTTP 测试在 transport/auth 拦截器前返回自有 204 响应,不打开 socket、不改账户或生产数据。 参考 Android 官方 Provider 测试说明 与 Room 测试说明。
本机 API 35 模拟器四项全部通过、零跳过:
- 同一漫画的不同收藏分类保留独立行;重复插入只更新匹配分类,历史只更新匹配 manga_id。
- 批量操作中无效外键导致整批回滚,之前更新的排序键也恢复。
- 旧 Work URI 不再暴露,SQLite 实际没有对应表。
- 调用生产 SyncHelper 的收藏及历史同步:先导出有效行和全部墓碑,再清除十天前墓碑及过期分类; 有效行和一小时前墓碑完整保留。
./gradlew.bat -I "build/kmp-backup-validation.init.gradle" :app:compileDebugKotlin :app:compileDebugAndroidTestKotlin
./gradlew.bat -I "build/kmp-backup-validation.init.gradle" "-Pandroid.testInstrumentationRunnerArguments.class=org.skepsun.kototoro.sync.ui.SyncProviderProjectionTest" :app:connectedDebugAndroidTest本机 ignored init 脚本只将 app/core-backup 构建输出隔离到 build 目录;没有打开桌面可选模块。 应用编译通过;测试编译日志 build/kmp-sync-projection-test-compile.log,设备最终结果日志 build/kmp-sync-projection-device-tests-final.log。最初 AndroidX 规则缺少系统 mock 库的运行时声明导致 测试失败,补齐测试 manifest 后同一四项全部通过。已有 app/schemas 没有变化。
本次复用 v84 表、现有 DTO、Provider 事务和同步实现,避免新增状态体系与依赖;只保留用于隔离 测试数据库的单一装配入口。Windows 的浏览器请求/Cookie 互通工作仍在推进,其中跨进程 Cookie 恢复尚未通过,不能将本次 Android 同步结果视为 Windows 同步完成。KMP 总目标保持进行中。
37. W2 续接:Windows 请求语义、SDK Cookie 互通与跨进程恢复
日期:2026-10-03。接续 §34 的桥与 §35 的实际桌面入口,修复原先被忽略的请求参数,并解决 §36 尚未通过的 Cookie 恢复。使用固定外部 API、真实 WebView2 和自有 fixture;测试 HTTP 请求仅到 127.0.0.1,.invalid/其他 origin 只用于 Cookie 元数据查询,没有代表站点或挑战成功的结论。
实现与证据
- 原生 GET/POST 使用 CreateWebResourceRequest/NavigateWithWebResourceRequest,保留自定义请求头、 UA 和原始二进制 POST 内容;拒绝请求头的 CR/LF。采用 Microsoft 请求 API。
- HTTP(S) base URL 的 HTML 通过一次主文档响应替换保留真实 origin、path/query/fragment 和相对资源; 不请求原主文档。空 base URL 仍使用 NavigateToString。依据 本地内容说明 选择该方式,避免 about:blank 的 origin 影响 Cookie 和相对请求。
- 原生命令按 STA 顺序执行,stop/close 可打断导航;Kotlin 导航 mutex 和 Provider actor 保证调用顺序。 actor 在启动子任务前登记 activeNavigation,防止 onPageStarted 立即 stop 时漏掉当前任务。 取消/超时发送 Stop 并清理 pending request,错误后仍可继续操作。关闭时验证桥进程退出码 0, 包括导航中关闭;原生 EOF 不再向已销毁 Form 排队,Kotlin 关闭不再吞掉等待进程终止的失败。
- Provider 将设置、headers、postData、base URL 接到真实桥,reload 保留原 POST 字节;原生 document 结果更新 URL/title,page/title/JS 和旧式错误回调在兼容主 Looper 执行。导航及 JS 前同步 SDK Cookie,完成后读取浏览器 Cookie,使扩展 CookieManager 和真实 OkHttp client 使用同一 SDK store。
DesktopBrowserCookies通过公开 Cookie API 同步 Secure/HttpOnly、host-only/domain、path、会话和 过期属性,并处理删除。首次 push 从已有 native profile 清掉 SDK 中不存在的 Cookie,防止在 SDK 离线清除后重启时恢复旧 token;后续 push 只写 SDK 变更的值,保留浏览器尚未 pull 的修改。 使用 Microsoft Cookie API。- 固定 SDK 的 cookie_store 已写入域名键的 Set-Cookie 字符串集合,但其初始化错误截取最后一个点 之前的部分,漏读 dotted host。负向探针确认磁盘仍有 token、SDK 内存却为空。平台在 helper 创建 前保存同一个偏好快照,通过公开 Cookie.parse/addAll 补回缺失的有效持久化 Cookie,保留已经由 SDK 恢复的记录;不改第三方字节码、不复制 SDK 实现、不新建 Cookie jar 或第二份存储。 该适配依赖当前固定 SDK 存储格式,升级须重验。过期、会话、畸形及不匹配 domain 的记录不恢复。
- 桌面调试 Browser 复用平台 Cookie coordinator,Session 持有其生命周期。WebView2 子进程可能在 桥退出后继续释放 profile 句柄,测试在自有临时目录内检查移动,最长等 10 秒;不宣称 Chromium 全部子进程已同步退出。相关生命周期参考 Microsoft 进程模型。
验证
新增九项测试:五项真实请求/HTML/设置/取消/导航中关闭;一项跨进程 Cookie 探针;两项持久化恢复 边界;一项实际平台 WebView 探针。Cookie 探针顺序为 write/read/sdk-clear/empty/write/clear/empty 七个独立 JVM,覆盖原生与 SDK 删除、浏览器自行变更、SDK 更新、domain/host-only/security 属性、 会话不恢复以及清除后重启不复活。只记录自有域名/成功标志,不输出通用 Cookie 值。
实际 WebView 探针通过平台 factory 构造 SDK WebView,验证 GET headers/UA/Cookie、连续 load/JS 顺序、POST/reload 字节、base URL/title、CookieManager 互通、错误后恢复、onPageStarted 内 stop 再加载和回调主线程;关闭后 main Looper 消失且自有 profile 句柄最终释放。
./gradlew.bat -PwithDesktopApp "-PmihonCompatibilityDirectory=D:/tmp/kt-compat36" -PmihonCompatibilityJavaVersion=25 :mihon-desktop-compat:test :desktop-app:test :desktop-runtime:test :source-host:test :source-host:installDist :core-source:jvmTest :core-source:compileCommonMainKotlinMetadata| 检查 | 当前结果 |
|---|---|
| core-source JVM / metadata | 35/35;metadata 通过 |
| source-host JVM / 默认发行 | 72/72;installDist 通过 |
| desktop-runtime JVM | 20/20 |
| mihon-desktop-compat 实际 API / WebView2 | 19/19,含新增九项 |
| desktop-app UI / 生产 Window | 2/2,仍覆盖 write/read/window/window-early |
| 汇总 | 148/148,0 失败、0 跳过 |
最终日志 build/kmp-windows-browser-complete-batch.log;未变更模块由 Gradle 复用匹配输入的 up-to-date 结果。Cookie/Provider 子进程结果在 compat 的 build/reports/browser-cookies 和 build/reports/browser-provider,UI 截图及进程结果仍在 desktop-app/build/reports/desktop-smoke。 复用 app 两项测试证明新 coordinator 接入后窗口操作/关闭仍有效,没有重启用户已有 preview 窗口。
W2 仍需现代错误回调、完整 WebView API/可见挑战、SameSite/分区/同名多 path、多 WebView 并发、 代表源与高级图形验收。HTTP cache、完整阅读器、封面/下载/扩展管理/APK 转换、其他源运行时、 桌面备份/同步、Windows EXE/MSI 和 S3 继续推进;iOS Mobile JVM/JNI/WKWebView/SwiftUI 与真机门槛 保持原范围。基础请求和 Cookie 验证不替代这些验收,KMP 总目标未完成。
SRP 将 Cookie 启动恢复与运行中同步分开;DRY/DIP 共享原 SDK store、偏好 backend 和现有平台 factory;KISS 保持公开 API 的一次恢复与串行 browser 操作,没有引入新的依赖或状态持久化体系。
38. W2 续接:Windows 可见浏览器与桌面交互调试
日期:2026-10-03。将 §37 的隐藏执行器接入可见 Windows 窗口和生产桌面调试页。测试仍使用 自有 HTML / 127.0.0.1 服务;没有访问生产站点、执行真实 Cloudflare 挑战或得出代表源兼容结论。
窗口、页面与生命周期
- 原生桥启动时创建隐藏 Form 和固定 HWND,支持显示/隐藏、调整大小、只读地址栏及页面标题。 浏览器嵌在填充窗口的 Panel 内,尺寸变化更新 controller.Bounds,移动通知父窗口位置变化。 原先运行中修改 FormBorderStyle / ShowInTaskbar 会重建父 HWND、使 WebView2 controller 失效, 真实显示测试捕获该问题;现在这些属性在创建前固定,整个生命周期不重建窗口。
- 显示/隐藏和窗口状态命令绕过导航队列,可在慢请求期间操作。原生关闭按钮通过 FormClosing 取消销毁并隐藏窗口;Session/Provider 的 close 仍最终释放 controller、Form 与桥进程。
dismissWindow测试触发同一 FormClosing 路径,不宣称进行了物理鼠标关闭按钮验收。 - 桥增加窗口状态和 PNG 预览,使用 Microsoft Bounds API、 IsVisible API 和 CapturePreviewAsync。 JSON 消息上限为 16 MiB,以容纳预览 PNG 的 Base64;预览用于验证,不新增截图持久化业务。
- 桌面浏览器调试页支持 HTTP(S) 地址、打开可见网页、显示/隐藏、加载 HTML、当前页面 JS 与 手动 Cookie 同步。Session 继续持有同一个 Browser 和原 SDK store。操作复用串行入口;输入 错误和导航取消保留已初始化的 live bridge,初始化失败或进程死亡才回收并允许创建新桥。 调试页可滚动,Composable 的操作取消后原生窗口仍有 owner,退出时统一释放。
- 完整回归发现 Cookie 清除的时序问题:SDK store 已为空,原生删除返回后仍能查询到旧记录。 删除 RPC 现在通过 GetCookiesAsync 确认指定 name/domain/path 已消失后才返回成功,最长等 2 秒,超时明确失败;不采用测试内固定 sleep 隐藏问题。仍使用 DeleteCookiesWithDomainAndPath 的精确 domain/path 语义,不扩大删除范围。保留 Cookie 测试的失败身份信息,只有自有 fixture name/domain/path,避免打印 Cookie 内容。
验证与产物
新增四项测试,并复跑原平台与 Cookie 用例:
- 真实原生窗口渲染中文页面;PNG 尺寸与 viewport 一致,背景像素匹配自有 HTML;调整窗口 后 DOM innerWidth 减小。隐藏后仍能执行 JS,FormClosing 后可重开,owner close 退出码为 0。
- 慢导航期间可显示/关闭窗口;停止导航后继续加载,正常退出。
- 原生 Cookie 删除确认只删匹配 domain/path,同名的另一路径与 domain Cookie 保留,最终 全部目标删除后查询为空。该项只覆盖桥的精确删除,不等于 SDK 同名多 path 已全部验证。
- 新的独立 JVM 桌面 UI 探针点击调试页按钮,打开 loopback 页面、用 JS 触发自有确认按钮, Cookie 写入 SDK cookie_store;隐藏/重开、无效 URL 后保留页面、离线 HTML 均通过。离开 面板取消慢导航后仍可使用原浏览器;Session 在可见窗口、未完成导航期间关闭,存储可重开, profile 最长 10 秒内释放句柄。测试等待 Compose 取消完成后再查询 browser,不阻塞测试调度器。
./gradlew.bat -PwithDesktopApp "-PmihonCompatibilityDirectory=D:/tmp/kt-compat36" -PmihonCompatibilityJavaVersion=25 :mihon-desktop-compat:test :desktop-app:test :desktop-runtime:test :source-host:test :source-host:installDist :core-source:jvmTest :core-source:compileCommonMainKotlinMetadata| 检查 | 当前结果 |
|---|---|
| core-source JVM / metadata | 35/35;metadata 通过 |
| source-host JVM / 默认发行 | 72/72;installDist 通过 |
| desktop-runtime JVM | 20/20 |
| mihon-desktop-compat 实际 API / WebView2 | 22/22 |
| desktop-app UI / 生产 Window | 3/3 |
| 汇总 | 152/152,0 失败、0 跳过 |
最终日志 build/kmp-windows-visible-browser-complete.log。未变更模块由 Gradle 复用匹配输入的 up-to-date 结果;compat 与 desktop-app 实际重跑。原生 viewport PNG 在 mihon-desktop-compat/build/reports/browser-window/visible-page.png,UI 截图在 desktop-app/build/reports/desktop-smoke/browser-panel.png,两张均实际查看,中文文本和控件正常。 没有重启用户已有 preview 窗口;需要重启开发应用才能使用此次编译的代码。
当前可见窗口用于手动调试,并与 SDK Cookie store 互通;源调用中的挑战检测、自动提示/窗口 交付及请求恢复仍需完成。现代错误回调、完整 WebView API、多 WebView 并发、SameSite/分区 Cookie 和代表源验收继续推进。Windows 完整阅读器/封面/下载/扩展安装/APK 转换/其他源运行时、 桌面备份与同步、EXE/MSI、S3,以及 iOS Mobile JVM/JNI/WKWebView/SwiftUI 和真机门槛保持原范围。 本轮没有修改 schema、Room Entity、旧备份 wire 字段或恢复已移除的 Entity Graph / Work;KMP 总目标未完成。
SRP 保持原生窗口归桥 owner、UI 状态归 Composable;DRY 复用同一初始化/串行操作入口;DIP 沿用平台 Cookie factory 和共享存储;KISS 不增加依赖、平行浏览器状态库或新的数据模型。
39. W2 续接:Windows 来源请求的人工网页验证
日期:2026-10-03。接续 §38 的可见窗口,将来源 HTTP 请求、桌面提示、浏览器 Cookie 与请求恢复 接成同一个流程。验证使用固定外部 SDK、独立加载的自有扩展 JAR、真实 Compose UI 和 WebView2; HTTP 服务仅为 127.0.0.1。模拟页面自行写入测试 Cookie,没有执行真实验证码或 Cloudflare 挑战。
SDK 接入与交互行为
- 通过 javap 核对当前 SDK:NetworkHelper 的 CloudflareInterceptor 走 ServerConfig / FlareSolverr, 并不使用 WebView factory。因此仅提供 WebView2 factory 不会让来源请求进入可见验证窗口。 平台在扩展取得客户端前,通过原 client.newBuilder 替换这一 application interceptor,保留原 CookieJar、UA、cache、dispatcher、connection pool 和其他拦截器。
- NetworkHelper 是 final 且没有公开 client setter。版本适配集中在 DesktopChallengeInterceptor: 检查恰好一个 CloudflareInterceptor,并核对 client$delegate / cloudflareClient$delegate 为 Lazy, 反射绑定到同一个新客户端;不匹配则初始化失败并清理。没有修改 SDK JAR、字节码或新建 helper。 这是固定 SDK 私有 ABI 的显式依赖,升级必须重验;现行 Gradle 验证五个关键 JAR 哈希,不能据此 宣称兼容任意 SDK 版本或已经完成发行锁定。
- 按 Cloudflare 官方响应标记 检测 cf-mitigated: challenge;普通 403 和 Server: cloudflare 不打开窗口。仅 GET/HEAD 进入交互, 先关闭原响应,确认后只重试一次;再次收到挑战由来源处理,不循环。POST 等方法明确失败,不自动重发。
- DesktopSession 显式启用 enableBrowserChallenges;零参数平台及 JSONL host 默认关闭,避免缺少 确认 UI 时等待人工操作。Session 持有一个 DesktopBrowserChallenges,通过 pending StateFlow 发布带 ID 的提示;UI 支持“已完成,继续请求”“取消验证”和“显示验证窗口”,来源 busy 时仍可操作。 旧 ID 不能接受后续请求。浏览器继承原请求 UA,导航前 push SDK Cookie,确认后 pull 再重试。
- 同一平台串行处理人工交互,默认超时两分钟。排队 Call 取消不会取消前一个提示;UI 取消、原 Call 取消、桌面动作取消、超时和 owner close 均结束等待、清除提示并释放桥。关闭应用先结束交互,再 cancelAndJoin 来源任务,避免退出等到人工超时。
- 实际 UI 取消测试暴露 SDK/Rx 的同步 subscribe/execute 时序:Call.cancel handler 尚未登记时, 只取消桌面 Job 不会打断等待。withRequestCancellation 通过 ThreadLocal.asContextElement 传递 当前操作的原始 Job,协调器同时观察此 owner 和 Call,并在首次请求及重试前检查取消。 该归属按请求保存,避免全局“取消当前提示”误伤下一请求;扩展自建线程的 owner 传播尚未验收。
- 实际 HTTP 403 页面已渲染,但原生 NavigationCompleted 报 IsSuccess=false / WebErrorStatus.Unknown。 为挑战导航增加显式 allowHttpErrorResponse,仅在有 HTTP 4xx/5xx 状态时允许页面交互;连接失败 仍报错。普通 WebView 和浏览器调试导航保持原行为。
验证与产物
新增六项父测试,包含独立 JVM 内的多个真实交互场景:
- 普通 403 不触发交互;挑战 POST 只发一次且不创建浏览器,两项 interceptor 边界测试。
- 原生 HTTP 403 默认失败,显式允许后页面可读;关闭本地服务后,允许 HTTP 错误也不掩盖连接失败。
- 原生交互超时,以及排队 Call 取消,两项独立 JVM 测试;取消排队请求后当前提示仍有效。 关闭平台后在自有临时目录检查 profile 句柄最终释放,不宣称所有 Chromium 子进程同步终止。
- 实际桌面加载独立 InteractiveSource 扩展:来源初始请求 → 浏览器导航 → 来源重试共三次, UA 一致,重试携带页面写入的测试 Cookie 并返回作品。随后验证 UI 取消无重试、动作 Job 取消、 旧提示 ID 无效、未解除挑战只重试一次、重新显示窗口,以及等待人工操作时退出并重开 v84 存储。
./gradlew.bat -PwithDesktopApp "-PmihonCompatibilityDirectory=D:/tmp/kt-compat36" -PmihonCompatibilityJavaVersion=25 :mihon-desktop-compat:test :desktop-app:test :desktop-runtime:test :source-host:test :source-host:installDist :core-source:jvmTest :core-source:compileCommonMainKotlinMetadata| 检查 | 当前结果 |
|---|---|
| core-source JVM / metadata | 35/35;metadata 通过 |
| source-host JVM / 默认发行 | 72/72;installDist 通过 |
| desktop-runtime JVM | 20/20 |
| mihon-desktop-compat 实际 API / WebView2 | 27/27 |
| desktop-app UI / 生产 Window | 4/4 |
| 汇总 | 158/158,0 失败、0 跳过 |
最终日志 build/kmp-windows-source-challenge-complete.log:BUILD SUCCESSFUL,46 秒,12 项执行、 34 项 up-to-date。compat 与 desktop-app 实际重跑;未变更模块复用匹配输入的测试结果。 生命周期日志在 mihon-desktop-compat/build/reports/browser-challenges,实际 UI 截图为 desktop-app/build/reports/desktop-smoke/source-challenge.png,已查看三个中文操作按钮与显示反馈。 测试结束未发现遗留的桥进程,没有重启用户已有 preview;开发应用需重启才会载入新编译代码。
来源挑战的本地闭环已建立,真实 Cloudflare、代表来源、其他请求方法、现代回调、完整 WebView API、 多 WebView 并发及 SameSite/分区 Cookie 仍待验收。Windows 完整阅读器、封面、下载、扩展安装/APK 转换、其他源运行时、桌面备份/同步、EXE/MSI 与 S3,以及 iOS Mobile JVM/JNI/WKWebView/SwiftUI 和真机门槛继续沿用原计划。没有修改 schema、Room Entity 或重建已移除的 Entity Graph / Work; KMP 总目标仍未完成。
SRP 将固定 SDK 客户端适配、交互协调和 UI 分开;DRY/DIP 复用现有客户端资源、Cookie factory 与 Session 生命周期;KISS 使用单一提示、按请求取消和一次重试,没有增加依赖或另一套 Cookie 存储。
40. W3 续接:共享核心的 Windows 分页阅读器
日期:2026-10-03。接续 §35 的单张 Fit 阅读,将 reader-core 作为真实桌面消费者接入;保持 Android 阅读器与共享场景算法不变,不新增第三方依赖、数据库表或 Entity Graph / Work。Windows 优先范围 继续按 §29 执行,本节不代表完整阅读器、Windows 或 KMP 迁移完成。
场景、图片与操作
- DesktopReaderLayout 将原 SourcePage ID、章节 ID 和图片头尺寸转换为 PagedPageSpec,使用共享 PagedReaderScene / PagedSpreadConfig 决定单页/双页、LTR/RTL、宽图单独显示和 Fit 几何。 可见图片、前后页组、历史锚点均来自同一场景;UI 根据实际 viewport 像素尺寸绘制 ReaderFrame 的页面位置,不另写配对或 RTL 算法。框架焦点和图片加载属于桌面层,reader-core 保持纯 Kotlin。
- 未取得图片尺寸时使用 Estimated hint;请求后读取实际宽高并重新解析当前页组,直到其可见页面 已加载。宽图可能将预估双页拆成单页,保留请求目标的 PageId;未读取的更早页面仍只有预估尺寸, 不宣称未加载整章也已知所有宽图。章节切换重建页面集合,不将不同章节拼成同一个双页。
- 图片继续经原 SourceProtocolClient → MihonSourceRuntime → 扩展 getImage/imageRequest/client, 保留 Page requestContext 与 headers,再由既有 image store 物化。桌面只保存当前章节的路径和 头尺寸,显示中的页组由 Compose/Skia 解码;回看已加载页面不重复请求,“重新加载”重新获取当前 页组。加载/尺寸读取失败不发布新页组,也不推进历史;解码 UI 提供重新加载提示。
- DesktopReader 提供模式/方向、前后页组、同分支上一章/下一章和首尾键盘跳转。模式与方向写入 原 store 的 desktop_reader namespace,不建立另一份持久化系统。image 状态从 readerImages 和当前 anchor 派生,避免单页路径与页组路径互相失配。
- 依据 Compose Desktop 官方键盘事件 API, 焦点范围内处理左右键(RTL 左键向前)、PageUp/PageDown、Space/Shift+Space、Home/End 和 Esc。 进入章节及点击页面取得焦点,Ctrl/Alt/Meta 组合不翻页;busy/关闭期间消费快捷键但不排队新操作。 页面区域使用指针手势取得焦点,避免 clickable 合并语义节点后隐藏独立页面;图片保留可读页码。
- DesktopLibrary.recordPage 新增默认等于 page 的 lastVisiblePage:page 保存页组起始锚点以恢复, percent 使用页组末页计算分支进度。双页到章末不会少算一页,既有单页调用保持原行为。 参数越界先拒绝,继续写 v84 历史字段,没有 schema/DAO/Android 迁移变化。
验证与产物
新增四项父测试:两项桌面场景适配、一项持久化页组进度,以及一个包含两个独立 JVM 的完整阅读 UI 测试。独立 ReaderSource JAR 使用真实 SDK、host 和共享协议,提供五张自有彩色 PNG(第三张 900×300,其余 400×600)及两章;终端 interceptor 验证原始 index/Referer,返回自有数据,不访问 DNS、网络或真实来源。没有将 fixture 类放入测试父 classpath 或发行 JAR。
- 场景测试覆盖完整的 [1,2] → [3 宽图] → [4,5] 翻页顺序、首尾边界、大整数 PageId、双页的起始 锚点、RTL 物理位置交换和不同 viewport 下页面仍可见。
- 实际 UI 通过触摸焦点与键盘输入事件验证左右键、PageUp/PageDown、Space/Shift+Space、Home/End、 Esc 和 Ctrl 组合;断言真实图片节点的位置及页码。图片 503 时仍显示原页,历史行保持不变; 回看不增加请求计数,重新加载当前双页恰好新增两次请求。
- 同分支下一章/上一章按钮、末章禁用、末页组完成百分比与历史锚点通过。第二个 JVM 在 920×620 像素画布恢复双页/RTL 和第一章页组锚点 3(显示第 4–5 页),断言图片均在实际 viewport 内。 第一进程使用 1260×850;这是两种渲染尺寸与 Compose 键盘注入验证,不等于物理键盘或高 DPI 全验收。
- v84 数据重开,以及已有来源交互、生产 Window 启动/导入/退出、设置、Cookie、Provider 与原生 浏览器测试仍通过。全量回归曾暴露已有超时测试把原生冷启动算入 4 秒交互预算的问题:测试现在 给同一完整请求 15 秒,明确等到提示或请求终结,仍断言窗口出现、超时 cause、提示清理和句柄 最终释放;父进程 45 秒有界等待。生产协调器的两分钟超时未变,不用固定 sleep 掩盖失败。
./gradlew.bat -PwithDesktopApp "-PmihonCompatibilityDirectory=D:/tmp/kt-compat36" -PmihonCompatibilityJavaVersion=25 :mihon-desktop-compat:test :desktop-app:test :desktop-runtime:test :source-host:test :source-host:installDist :core-source:jvmTest :core-source:compileCommonMainKotlinMetadata :reader-core:jvmTest :reader-core:compileCommonMainKotlinMetadata| 检查 | 当前结果 |
|---|---|
| core-source JVM / metadata | 35/35;metadata 通过 |
| source-host JVM / 默认发行 | 72/72;installDist 通过 |
| desktop-runtime JVM | 21/21 |
| mihon-desktop-compat 实际 API / WebView2 | 27/27 |
| desktop-app UI / 场景适配 / 生产 Window | 7/7 |
| reader-core JVM / metadata | 150/150;metadata 通过 |
| 汇总 | 312/312,0 失败、0 跳过 |
最终日志 build/kmp-windows-reader-complete.log;未变更模块复用匹配输入的 up-to-date 结果。 UI 子进程记录在 desktop-app/build/reports/desktop-smoke/reader-write.log 和 reader-read.log; 同目录的 reader-write-ltr.png、reader-write-rtl.png、reader-write-wide.png、reader-read-restored.png 记录双页、宽图和较小窗口恢复。RTL、宽图及恢复截图已实际查看,彩色页面与中文操作正常。 没有重启用户已有 preview;开发应用需重启才能载入新编译代码。
后续继续完成连续滚动、缩放/拖动、预取、自动跨章与下载,真实封面、动态筛选、备份/同步、扩展 管理/APK 路线、其他源生态、HTTP cache 生命周期、Windows 安装包及 S3。真实 Cloudflare 和代表源 仍需独立验收;iOS 的 JNI/Mobile JVM/WKWebView/SwiftUI 与原生编译、硬件门槛保持原范围。
SRP 分离场景适配、操作与渲染;DRY 使用原 reader-core 配对/进度规则、图片 store 和偏好 backend; DIP 继续经共享 SourceRuntime 执行图片,不直接绕过扩展 client;KISS 保持当前页组按需加载,避免 复制 Android renderer 或增加平行缓存/数据库。
41. W1 / W3 续接:真实来源封面、取消归属与离线缓存
日期:2026-10-03。将 Windows 浏览、详情、收藏与历史的占位封面接到既有源执行和图片 store。 使用当前固定 SDK、独立编译/加载的自有扩展、真实 Skia 解码和桌面 UI;终端 interceptor 返回自有 图片,测试不访问 DNS、生产站点或实际来源。KMP 总目标和 §29 的 Windows 优先范围保持不变。
共享协议与来源执行
- 增加独立 fetchCover(content, large) / cover 操作及 isCoverFetchingSupported 能力;默认关闭, 未实现的 SourceRuntime 返回 unsupported。协议版本仍为 1 的增量操作,保留既有 image 字段。 SourceCoverArtifact 使用 contentId,章节 SourceImageArtifact 仍使用 pageId;ID/byteSize 继续 为十进制字符串,响应只有 source/hash/relativePath/size/type,不传图片字节或绝对文件路径。 两种产物共享路径/哈希/大小校验,封面复用现有 SourceImageStore 的流式物化及原子发布。
- 核对 Android ContentCoverFetcher / MihonMangaRepository:封面尝试扩展 imageRequest,章节 上下文假设失败时回退通用请求,仍使用扩展 client。host 采用相同顺序,回退使用 source.headers 的 GET;不经 getImage 的章节处理,不绕过 client 的 Cookie/限流/解扰拦截器。既有特殊封面 Referer 补充规则提取到 commonMain MihonModelRules,Android 与 host 共用,来源声明的值不覆盖。
- MihonHttpCall 使用平台提供的公开 OkHttp enqueue/Callback ABI,动态加载其类型;不增加 OkHttp 依赖、不创建第二个 client、不修改第三方 JAR。取消执行 actual Call.cancel,回调期间 设置扩展 context ClassLoader,迟到结果归响应 owner 关闭;回调结束前保留 loader lease 与 pendingCall,后续同来源操作等待原调用结束。body 与章节图片共享同一关闭/限额/暂存清理逻辑。
Windows 缓存、界面与生命周期
- DesktopSession 持有 DesktopCovers;相同 source name + content ID + 实际封面 URL 合并为一个 正在执行的请求,等待者分别取消。最后一个等待者离开才取消 producer,后续调用可以重试; 最多并行三个 producer,同一来源仍遵循 host 执行等待。Session 关闭先 cancelAndJoin 封面 owner, 再关闭 registry/platform/storage,防止 UI 任务在存储关闭后继续写缓存。
- 内存仅保存最多 256 个文件路径;原 preferences backend 的 desktop_covers namespace 保存 最多 256 条带写入时间的 metadata,按写入时间淘汰旧记录,不宣称磁盘 LRU。文件仍用原 image store 的 SHA-256 blob;记录 key 对来源/作品/URL 求哈希,不额外保存原 URL。恢复检查内容/来源 身份、普通文件(不跟随文件符号链接)、大小和 SHA-256;损坏或缺失再获取,locale 缺失不会 改变身份。成功可供收藏/历史和离线重启复用;失败没有负缓存,磁盘写入失败允许内存继续使用。
- UI 按 Compose 生命周期请求/解码封面,支持大图 URL、标题占位及单独“重试封面”。封面任务 不将整个 Controller 标成 busy,也不把原始网络错误/URL 打进错误提示。列表与阅读操作仍经过 原来源执行队列,不能因此宣称同一来源绕过其限流或正在等待的验证。新增状态不改写 Room schema 或用户收藏/历史归属,没有恢复 Entity Graph / Work。
- 本节只限制封面 metadata/内存路径和并发,不回收已有图片 blob,不代表 HTTP cache 的私有目录、 TTL/容量、全图片磁盘保留策略或全部解码格式已完成。生产的真实来源、挑战封面、缓存更新策略与 iOS 图片消费仍需按原计划验收。
验证与证据
新增九项父测试:三项 commonMain 规则/协议边界、五项缓存生命周期/持久化、一个包括两个独立 JVM 的实际封面 UI/host 探针。确认五个缓存方法均为 JUnit 可发现的 Unit 返回类型,XML 中五项 全部实际执行;其中一个初版曾因推断返回异常对象而未被发现,已修正并重跑,不能只以编译/总绿作证。
- cover JSON 保留超过 2^53 的 contentId/byteSize,结果没有 pageId/绝对 URL;拒绝越界路径/空大小。 旧 descriptor 缺少字段默认能力 false,未实现执行返回 unsupported;既有 Referer 行为保留。
- 两个缓存等待者共享一次请求,取消其中一个不停止另一个;最后一个取消终止 producer,可再次 请求;owner close 在 producer 清理完成后返回。失败可重试、不发布失败记录。
- 原 preferences 与文件重开后无网络恢复;来源缺少 locale 仍复用。自有 blob 被改坏后重新请求并 修复;260 个记录限制为 256 条,相同图片内容仍只有一个 blob。
- 独立 CoverSource 扩展的自定义请求、缺章节上下文后的 source.headers 回退、client XOR 解扰 和坏图片重试通过。列表 → 详情 → 收藏 → 阅读 → 历史的同一封面只有一次成功请求;独立 large URL 的请求/产物身份通过。SDK 当前没有 banner setter,实际详情沿用 thumbnail fallback; large URL 的独立调用测试不冒充该 SDK 原生 banner 元数据支持。
- 第二个 JVM 将终端请求设置为离线失败,浏览/收藏/历史仍渲染已验证的持久化封面,没有触发 封面网络执行。取消故意延迟的实际 OkHttp 请求后,下一来源操作等待其回调;释放后原 body 关闭,下一操作成功,图片目录没有 .part 残留。
./gradlew.bat -PwithDesktopApp "-PmihonCompatibilityDirectory=D:/tmp/kt-compat36" -PmihonCompatibilityJavaVersion=25 :mihon-desktop-compat:test :desktop-app:test :desktop-runtime:test :source-host:test :source-host:installDist :core-source:jvmTest :core-source:compileCommonMainKotlinMetadata :reader-core:jvmTest :reader-core:compileCommonMainKotlinMetadata
./gradlew.bat -I "build/kmp-backup-validation.init.gradle" :app:compileDebugKotlin| 检查 | 当前结果 |
|---|---|
| core-source JVM / metadata | 38/38;metadata 通过 |
| source-host JVM / 默认发行 | 72/72;installDist 通过,未增加第三方 HTTP/SDK 依赖 |
| desktop-runtime JVM | 21/21 |
| mihon-desktop-compat 实际 API / WebView2 | 27/27 |
| desktop-app UI / 缓存 / 场景 / 生产 Window | 13/13 |
| reader-core JVM / metadata | 150/150;metadata 通过 |
| 汇总 | 321/321,0 失败、0 跳过 |
| Android 应用编译 | compileDebugKotlin 通过 |
最终日志 build/kmp-windows-covers-complete.log(1 分 3 秒;19 项执行、37 项 up-to-date)与 build/kmp-cover-android-compile.log(1 分 40 秒)。未变更模块复用匹配输入的结果;Android 本机 ignored init 只隔离 app/core-backup 的构建输出,不启用桌面模块或改变生产配置。没有运行设备/ 真实账号/站点测试或 R8,Android 编译与共用规则测试不替代这些验收。 UI 日志/截图在 desktop-app/build/reports/desktop-smoke/cover-write-* 和 cover-read-*;已查看 cover-write-browse.png 与 cover-read-history.png,真实彩色封面、中文界面和离线历史显示正常。 测试结束未发现遗留的 WebView2 桥进程,没有重启用户已有 preview。
继续完成完整阅读器、下载/动态筛选/备份/同步、扩展管理/APK 路线与其他源生态、完整浏览器/真实 挑战及代表源、HTTP/图片 cache 管理、Windows 安装包与 S3。iOS 的 Mobile JVM/JNI/WKWebView/ SwiftUI、原生构建与硬件门槛仍按原范围推进;本节完成封面执行闭环,KMP 总目标仍未完成。
SRP 分开 cover 协议、HTTP callback、缓存 owner 和 UI;DRY 共用原 image store/response 生命周期、 偏好 backend 与 Android 的 Referer 规则;DIP 通过共享 SourceRuntime 执行,KISS 使用有界 metadata 和按等待者取消,没有另一套用户数据库、HTTP 下载器或第三方依赖。
42. W3 续接:Windows 原生动态筛选与网页验证互通
日期:2026-10-03。将既有 SourceDynamicFilters / SourceFilterChange 与 Mihon 原生筛选执行器 接入 Windows 浏览界面,继续复用共享 SourceRuntime JSON 契约。没有新增依赖、修改第三方 JAR、 schema 或 Android 代码,没有重启用户已有 preview;KMP 总目标仍未完成。
行为与边界
- DesktopFilters 直接消费共享控件树,显示 header/separator/group 与嵌套条件,支持 checkbox、 tristate 的忽略/包含/排除、select、text、sort 的索引/升降序及 nullable 清除。提交的是类型化 ID/index/value,不用展示标签替代原生值;同名选项可以选择不同的 opaque 原生对象。 unsupported 控件明确显示提示并保留来源默认状态,不伪造自定义控件交互。
- 草稿属于当前面板;取消不执行来源请求,恢复默认取当前来源声明的实际值,包括 true、 INCLUDE、非空文本及 null sort,而不是将全部条件置零。应用前使用共享 MihonFilterRules 校验;只有来源成功返回时才发布新列表、查询和 appliedFilters,失败保留此前结果与草稿并允许重试。
- 翻批次和再次搜索沿用已应用条件;应用新条件回到第一个批次。热门/最新清除查询与筛选, 最新仅对声明 UPDATED 能力的来源显示,并在翻批次时保留 UPDATED。切换来源清空旧筛选定义、 查询、排序和应用状态,不把另一个来源的 ID 或值送到新来源。
- 筛选请求开始后,面板暂时收起,原草稿仍保存在 Compose composition;主窗口的网页验证继续/ 取消/重显按钮因此可操作。请求失败或验证取消后面板重新出现并保留草稿,成功后关闭。 退出仍由 Controller/Session 原 owner 取消和清理,不引入第二套请求队列或浏览器。
- 搜索行与来源操作行分开排布,避免新增按钮压缩搜索框。面板列表可滚动,嵌套分组保留层级。 当前筛选条件只在 Session 中保留,不包含筛选预设/跨重启存储、自定义控件与全部来源生态验收; 库内收藏筛选也不等同于来源筛选,本节不宣称这些剩余项已完成。
实际验证
独立编译的自有 FilterSource JAR 使用当前固定 SDK 的缓存 FilterList、具体原生子类、嵌套 Group 和两个展示名称相同的 opaque Select 对象。通过共享协议进入真实 ClassLoader/host,再从实际 fetchSearchManga 接收的原生状态核对参数;不访问 DNS、HTTP 或生产站点。
新增一个 JUnit 父测试,在两个独立 Windows JVM 中分别使用 1260×900 与 920×620 的实际 Compose 桌面 UI,点击标准控件,覆盖取消、应用、分页、再次搜索、排序清除、热门/最新、恢复默认、 来源失败后重试和来源切换。核对查询/页号、checkbox/tristate、opaque 原生对象索引、文本及排序 方向;成功和失败后再执行 popular,确认缓存原生 FilterList 的默认状态恢复。首次未应用的查询 也可被热门按钮清空。不支持的控件实际显示提示,源码没有临时模拟“支持”标志。
既有 InteractiveSource 自有 loopback 扩展增加一个原生 Text,WebView2 UI 探针从筛选面板发起 真实 SDK/Rx HTTP 请求。请求等待自有 cf-mitigated 页面时,面板消失而验证按钮可操作;点击取消后 面板和原文本草稿恢复。只访问自有 127.0.0.1 fixture,不代表真实 Cloudflare 或代表来源兼容率。 UI 测试继续使用 Compose 官方测试 API, 没有升级项目固定的 Compose/Kotlin 版本。空查询检查 EditableText,避免将 placeholder 当成输入内容。
./gradlew.bat -PwithDesktopApp "-PmihonCompatibilityDirectory=D:/tmp/kt-compat36" -PmihonCompatibilityJavaVersion=25 :mihon-desktop-compat:test :desktop-app:test :desktop-runtime:test :source-host:test :source-host:installDist :core-source:jvmTest :core-source:compileCommonMainKotlinMetadata :reader-core:jvmTest :reader-core:compileCommonMainKotlinMetadata| 检查 | 最终结果 |
|---|---|
| core-source JVM / metadata | 38/38;metadata 通过 |
| source-host JVM / 默认发行 | 72/72;installDist 通过 |
| desktop-runtime JVM | 21/21 |
| mihon-desktop-compat API / WebView2 | 27/27 |
| desktop-app UI / 缓存 / 场景 / 生产 Window | 14/14 |
| reader-core JVM / metadata | 150/150;metadata 通过 |
| 汇总 | 322/322;0 失败、0 错误、0 跳过 |
最终日志 build/kmp-windows-filters-complete.log:59 秒,57 项任务(7 项执行、50 项 up-to-date); 未变更模块复用匹配输入的既有结果。两种窗口的子进程日志 filters.log / filters-narrow.log 均有 DESKTOP_UI_OK 与 NETWORK_REQUESTS=0。已查看 filters-edited.png / filters-narrow-edited.png: 中文嵌套条件、排除、同名选项、文本、升序与恢复默认/取消/应用均正常显示。 本轮没有 Android 输入变更,未重跑 Android 编译/设备/R8;§41 的 Android 结果仍为对应输入的证据。
SRP 将面板草稿与来源执行分开;DRY 复用既有共享校验/协议和 host 原生状态恢复;DIP 依赖 SourceRuntime,UI 不引入 Mihon SDK 类型。KISS 使用 Session 内类型化条件,不建立平行用户状态 数据库或重复的筛选解释器。继续完成完整阅读器、下载、备份/同步、图片/HTTP cache 管理、扩展/APK 路线、安装包与 S3;iOS Mobile JVM/JNI/WKWebView/SwiftUI 及原生/真机门槛仍独立推进。
43. W3 续接:共享相机数学、Windows Fit 与缩放/平移
日期:2026-10-03。继续完成 Windows 阅读器交互,复用 reader-core 的 PagedSpreadResolver、 PagedPanBoundsResolver 与 PagedSlot 反算视口。没有新增依赖、修改数据库 schema/第三方 SDK 或 重启已有 preview;Android 渲染器行为未改写,KMP 总目标仍未完成。
共享语义与桌面接入
- commonMain 增加不可变 PagedCameraTransform,提供 1–5 倍指针锚点缩放、平移限界、基准阅读 起点及 ReaderViewport 反算。限界/起点复用原 PagedPanBoundsResolver,不重新实现另一套范围 数学;支持非零 scene slot、native/Fit 溢出和 RTL 起点。NaN/Infinity/无效倍率不进入 renderer, 极大但有限的手势值限界后再构造状态。
- DesktopReaderSettings 使用原 ZoomMode 的 FIT_CENTER / FIT_WIDTH / FIT_HEIGHT / KEEP_START, Fit 模式写入原 desktop_reader namespace,旧记录缺少字段时仍默认 FIT_CENTER。KEEP_START 继续表示共享核心的原始像素几何,不把原始尺寸伪装成 user zoom;四种模式均通过同一场景排版。
- DesktopReaderLayout 的加载集合/进度锚点来自完整 canonical slot,而不是相机当前可见节点。 native 溢出或放大可能只显示双页中的一张,但整个页组仍可平移到达,记录的 page/percent 不因此 改变。当前页组均已加载后,zoom/pan 不触发来源请求、重新解码或历史写入;翻页仍按原页组规则执行。
- DesktopReaderCanvas 使用原生 pixel placement 与单个 graphicsLayer 相机,外层视口裁剪。 自定义 Layout 按共享页面的实际尺寸测量/摆放,允许子页面超出视口,不让普通 size 约束把 FIT_WIDTH 高页或原始尺寸强制压小。页组切换/跨章/模式变化重置相机;视口 resize 后,用户已经 操作的倍率保留、位移重新限界,未操作的视图按新几何重新对齐起点。这也处理 busy 加载条引起 的短暂视口高度变化,避免 RTL/Fit 起点保留旧尺寸偏移。
- 增加缩放按钮、Ctrl+加/减号、Ctrl+0、Ctrl+滚轮、双击基准/200%、拖动、普通滚轮平移及触摸 pinch。Fit 或原始尺寸在 100% 时已溢出的内容也允许平移;“重置视图”回到当前 Fit 的阅读起点。 使用稳定 pointerInput 与 detectTransformGestures,参考 桌面鼠标事件 API 与 Compose 多点手势 API。 原翻页快捷键仍在 reader 焦点范围消费,缩放焦点不影响同分支跨章与错误后重试。
Fit 模式随偏好跨重启保存,临时 zoom/pan 不持久化。当前仍按完整图片解码/当前页组加载,没有 旋转、惯性、分块解码、连续滚动、预测预取、自动跨章或下载,不代表完整阅读器验收。这一纯 Kotlin 相机可由 iOS 消费,但 common metadata 通过不替代 iOS 编译/原生 UI/硬件手势与性能验收。
验证与证据
新增七项父测试:五项 shared camera 数学/边界、一项 native 溢出时完整页组装载、一项含两个独立 Windows JVM 的实际相机 UI 探针。共用现有独立 ReaderSource,自有彩色页码和 terminal interceptor, 不访问 DNS、网络站点或真实来源;WebView2/浏览器旧回归仍只访问自有 loopback。
- 指针锚点在非零 slot 的缩放前后指向同一 scene 点,反算视口尺寸正确;极大有限倍率/平移 限界、缩小到基准、FIT_WIDTH 从顶部到下缘、native 双页 RTL 起点、resize 限界和无效数值均通过。
- 原始尺寸双页中离开基准视口的页面仍属于加载集合,完整 canonical group、翻页目标和起始 进度锚点保持正确;既有单/双页、RTL 与宽图独页场景继续通过。
- 实际 UI 点击按钮、键盘、鼠标双击/拖动、Ctrl+滚轮及两指触摸。核对 100%/125%/200%/500% 和倍率上限;使用实际 transformed bounds 与截图中白色页码质心移动验证页面缩放/拖动, 不只检查百分比文本,也不把 unscaled layout size 当作 graphicsLayer 缩放后的尺寸。
- FIT_WIDTH 的页面宽度等于视口、长页从顶部开始且普通滚轮可向下平移;KEEP_START 的自有页面 保持 400×600 原始像素;FIT_HEIGHT 宽页保持视口高度、超出宽度可平移,RTL 起点通过实际页码 像素位置核对。缩放/平移过程中历史与图片请求数不变,双页放大不改变 canonical pageIndex。
- 第二个 920×620 JVM 恢复 DOUBLE / RTL / FIT_WIDTH、页组 4–5 和 page=3 / percent=.5,倍率 回到 100%。重开共享 v84 存储通过;原阅读器错误/回看/重载/键盘/跨章与生产 Window 退出仍通过。
./gradlew.bat -PwithDesktopApp "-PmihonCompatibilityDirectory=D:/tmp/kt-compat36" -PmihonCompatibilityJavaVersion=25 :mihon-desktop-compat:test :desktop-app:test :desktop-runtime:test :source-host:test :source-host:installDist :core-source:jvmTest :core-source:compileCommonMainKotlinMetadata :reader-core:jvmTest :reader-core:compileCommonMainKotlinMetadata
./gradlew.bat -I "build/kmp-backup-validation.init.gradle" :app:compileDebugKotlin| 检查 | 最终结果 |
|---|---|
| core-source JVM / metadata | 38/38;metadata 通过 |
| source-host JVM / 默认发行 | 72/72;installDist 通过 |
| desktop-runtime JVM | 21/21 |
| mihon-desktop-compat API / WebView2 | 27/27 |
| desktop-app UI / 缓存 / 场景 / 生产 Window | 16/16 |
| reader-core JVM / metadata | 155/155;metadata 通过 |
| 汇总 | 329/329,0 失败、0 错误、0 跳过 |
| Android 应用编译 | compileDebugKotlin 通过 |
最终日志 build/kmp-windows-camera-complete.log:1 分 10 秒,57 项任务(7 项执行、50 项 up-to-date); 未变更模块复用匹配输入的结果。Android 日志 build/kmp-reader-camera-android-compile.log:24 秒, 50 项任务(9 项执行、41 项 up-to-date),ignored init 只隔离 app/core-backup 输出,不改变生产 配置/开启桌面模块。未运行 Android 设备、R8、真实来源或 iOS Native/硬件测试。 已查看 reader-camera-write-zoom-drag.png / fit-width-pan.png 与 reader-camera-read-restored-fit.png; 真实页面、缩放、平移和较小窗口的 RTL/Fit 恢复正常。子 JVM 均输出 DESKTOP_UI_OK 和 NETWORK_REQUESTS=0。
SRP 由 shared camera 负责数学、canvas 负责输入/渲染、原 Controller 负责页组/历史;DRY 复用原 场景/边界/偏好 backend。DIP 让桌面依赖 commonMain 的相机/ZoomMode,KISS 保持单个视口 layer 与当前页组,不引入平行阅读进度或图片下载器。完整 Windows 阅读器、下载/备份/同步、缓存管理、 扩展/APK 路线、安装包与 S3,以及 iOS 全部原生/真机门槛继续推进。
44. W3 续接:Windows 连续阅读与像素位置恢复
日期:2026-10-03。增加 CONTINUOUS 模式,沿用原来源执行器、图片 store、偏好和 Room v84 历史表,没有新增依赖、schema 或第三方 SDK 改动,没有重启已有 preview。
实现
- DesktopScrollLayout 复用 VerticalReaderScene,已加载图片头和未知页面估计尺寸决定页面高度 与视口进度。LazyColumn 使用稳定 page ID;上方迟到尺寸不改变 item/页内像素锚点,宽度变化 重新限界并恢复锚点。支持滚轮、拖动、滚动条、上下键、PageUp/PageDown、Space/Shift+Space 和 Home/End;连续模式按宽度排版,暂不使用分页相机,保存原分页方向/Fit 偏好。
- DesktopScrollOperations 由原 Controller scope/gate 持有,按可见页集合加载,同需求不重复 建立任务。需求变化取消旧任务,generation 阻止迟到状态提交;前台操作先取消并等待任务。 图片继续走扩展自己的 client/interceptor。成功页面逐页缓存,失败页等待显式重试,后台加载 不设全局 busy。成功定位后恢复阅读焦点,重试按钮不阻断后续键盘操作。
- 全部可见页成功且实际 item 高度匹配共享几何后才提交进度。首页、页内像素偏移与末个可见页 使用原 history;scroll 沿用 Android 的 page-relative pixel 语义,percent 沿用分支章节规则。 跳转目标与最后验证位置分别保存,失败目标不推进 page/scroll/percent。
- 历史写入 debounce 150ms,导航与退出先 flush 最后验证的视口。负数/非有限 scroll 写库前 拒绝;原分页调用默认 scroll=0,数据库版本仍为 84。
验证
新增四项父测试:两项垂直场景投影、一项实际 SQLite 像素进度与无效值拒绝、一项含两个独立 Windows JVM 的真实 Compose UI 探针。自有 ReaderSource 使用实际 SDK/JAR 和 terminal interceptor 返回彩色 PNG,不触发 DNS、socket 或生产站点。UI 验证可见范围加载、可见页失败 不写历史、显式重试、缓存回看请求不增长、滚轮/键盘、首末页、跨章、失败跳转后的重试及键盘 继续阅读。立即返回详情后,第二个较小窗口 JVM 恢复 page=3 和相同页内像素偏移。 新增失败跳转测试发现并修正重试后焦点问题,专项复验通过。
已查看 failure/failed-navigation/pixel-progress/restored 真实渲染截图,两个子 JVM 输出 DESKTOP_UI_OK 与 NETWORK_REQUESTS=0。既有 WebView2 回归仍只访问自有 loopback,不代表 真实 Cloudflare 或代表来源兼容率。
| 检查 | 最终结果 |
|---|---|
| core-source JVM / metadata | 38/38;metadata 通过 |
| source-host JVM / 默认发行 | 72/72;installDist 通过 |
| desktop-runtime JVM | 22/22 |
| mihon-desktop-compat API / WebView2 | 27/27 |
| desktop-app UI / 缓存 / 场景 / 生产 Window | 19/19 |
| reader-core JVM / metadata | 155/155;metadata 通过 |
| 汇总 | 333/333,0 失败、0 错误、0 跳过 |
最终日志 build/kmp-windows-scroll-complete.log:1 分 20 秒,57 项任务(7 项执行、50 项 up-to-date);未变更模块复用匹配输入的结果。 Android compileDebugKotlin 通过,日志 build/kmp-reader-scroll-android-compile.log:2 秒, 50 项任务(7 项执行、43 项 up-to-date),编译输入未变更而复用结果。ignored init 只隔离 app/core-backup 输出,默认 Android 构建未启用桌面模块;未运行 Android 设备或 R8。
SRP 分开场景投影、输入/渲染与加载/进度任务;DRY 复用共享垂直场景、图片请求与历史表; DIP 通过来源协议执行扩展;KISS 使用现有 scope/gate 和稳定 item,不建立平行下载器或数据库。 章节已加载图片保留到离开/跨章,尚无长章节容量淘汰、预测预取、自动跨章、分块解码或下载。 Windows 备份/同步、缓存管理、扩展/APK、安装包与 S3,以及 iOS Mobile JVM/JNI/WKWebView/ SwiftUI、原生编译和真机门槛仍未完成;本轮没有运行 iOS Native/硬件验收。
45. W1 / W3 续接:页面离线索引、请求身份与临时 Skia 资源
日期:2026-10-03。检查长章节生命周期后确认 readerImages 保存的是文件路径/尺寸,像素由 Compose 页面节点持有;不能将路径数量当成已解码图片数量。当前明确缺口是跨章/重启丢失 artifact 索引而再次请求图片,以及转换 ImageBitmap 后临时 Skia Image 未及时关闭。
实现与归属
- Session 增加 DesktopReaderImages,分页/连续模式共用原图片 store 与 desktop_reader_images namespace。最多保存 256 条 artifact 索引,按写入时间淘汰;已有过量/无效记录也在新写入时 限界。淘汰只移除索引,原 immutable blob 保留供现有阅读节点使用,没有删用户图片文件。
- cache key 使用扩展原 JAR SHA-256 与完整 SourcePage JSON,包括 source/page ID、URL、headers 和原始 requestContext。扩展更新或请求上下文改变不命中旧记录,原生 image fragment 不丢失。 索引只保存 artifact/写入时间,原始请求字段不写入索引值。
- 读取复用前检查 regular file、大小及 SHA-256;损坏/缺失走原来源 fetchImage,返回的 page ID/source 和文件内容再次校验。共享 verifiedImagePath 也用于封面跨进程缓存恢复, 读取时检查协程取消,I/O 缺失按 cache miss 处理,不建立另一套 HTTP 或图片下载器。
- 强制刷新绕过索引,只有新图片校验/头读取成功后替换记录,失败不覆盖此前成功缓存。 请求仍使用原扩展 client/interceptor/挑战 owner;函数在调用者协程内执行,Mutex 保护索引 更新,没有新增 detached scope/后台任务。偏好写入仍为既有 best-effort 缓存元数据语义, 磁盘失败不会阻止当前图片阅读,也不承诺该记录能在重启后保留。
- 检查本机固定 Compose 1.12.0 的 SkiaImageAsset 与 Actuals 字节码,toComposeImageBitmap 创建独立 Bitmap 并绘制源 Image。ReaderImage/DesktopCover 因此用 use 及时释放源 Image, 转换后的 Bitmap 继续由 Compose 持有;不是在显示期间关闭 Compose 的 Bitmap。
验证
新增六项实际图片 store/偏好磁盘测试:关闭后离线重开、同尺寸内容损坏后恢复、强制刷新与 失败保留、扩展/来源/原生上下文/headers 隔离、数量淘汰且文件保留、无效记录/过量索引恢复、 错误来源身份拒绝及调用者取消后不提交记录。多个检查合在对应语义测试中,不只复刻实现。
既有 ReaderSource terminal interceptor 增加自有 offline 开关。分页和连续模式的第二个 独立 JVM 在读取前启用 offline,任何图片请求都计数并抛错;实际 UI 仍恢复 DOUBLE/RTL 页组 4–5 和连续模式 page=3/页内像素位置,图片请求数为零。测试没有要求源浏览/详情/章节元数据 离线可用,也不把页面缓存宣称为完整离线下载。查看 reader-read-restored.png 与 reader-scroll-read-restored.png,实际像素正确;既有缩放/触摸/Fit、失败重载、封面及原生窗口 退出回归继续通过。两个子进程输出 DESKTOP_UI_OK 和 NETWORK_REQUESTS=0。
./gradlew.bat -PwithDesktopApp "-PmihonCompatibilityDirectory=D:/tmp/kt-compat36" -PmihonCompatibilityJavaVersion=25 :mihon-desktop-compat:test :desktop-app:test :desktop-runtime:test :source-host:test :source-host:installDist :core-source:jvmTest :core-source:compileCommonMainKotlinMetadata :reader-core:jvmTest :reader-core:compileCommonMainKotlinMetadata| 检查 | 最终结果 |
|---|---|
| core-source JVM / metadata | 38/38;metadata 通过 |
| source-host JVM / 默认发行 | 72/72;installDist 通过 |
| desktop-runtime JVM | 22/22 |
| mihon-desktop-compat API / WebView2 | 27/27 |
| desktop-app UI / 缓存 / 场景 / 生产 Window | 25/25 |
| reader-core JVM / metadata | 155/155;metadata 通过 |
| 汇总 | 339/339,0 失败、0 错误、0 跳过 |
最终日志 build/kmp-windows-reader-cache-complete.log:1 分 33 秒,57 项任务(11 项执行、46 项 up-to-date);未变更模块复用匹配输入的结果。本轮未修改 Android 输入,§44 的 Android 编译 仍对应当前输入,未重复设备/R8/iOS Native 或硬件验收。未增加依赖、修改 schema/第三方 SDK、 提交 Git 或重启已有 preview,WebView2 旧测试仍只访问自有 loopback。
SRP 将持久化索引与视口/历史分开;DRY 共用 artifact 校验和原图片 store;DIP 通过共享来源 协议执行图片请求;KISS 沿用调用者取消和现有偏好,不创建平行下载数据库。256 条索引不是 像素内存/图片目录总容量上限;全图解码、长图分块、预测预取、自动跨章、下载与磁盘清理仍待。 完整 Windows 打包/备份/同步、扩展/APK 与 S3,以及 iOS 全部原生/真机门槛继续推进。
46. W3 续接:共享分块几何与 Windows 长图区域读取
日期:2026-10-03。将 Android 已有纯 Kotlin ImageSourceGeometry / TileGrid 迁入 reader-core commonMain,连同十九项几何/裁剪/旋转/split/gutter 测试迁到 jvmTest。共享类型使用 reader.core 包,Android 原 reader.image 名称保留 typealias,消费同一实现。隔离守卫扫描扩大到整个 commonMain reader 目录,仍禁止 Android/Compose/应用层引用,没有放宽允许依赖名单。
场景、区域解码与像素生命周期
- VisibleImageTiles 使用共享 VisibleNode 的 sceneBounds/visibleRegion 反算源像素区域,按 当前显示尺寸/相机倍率选择 power-of-two sample,再复用原 TileGrid 选择可见单元。 512×512 解码基准格与原 seam padding 给出 gutter,目标 logicalRect 不重叠;当前不做预测预取。
- PNG/JPEG 高度超过 4096 或超过 16M 像素时,分页与连续模式使用 DesktopTiledImage。 分页节点来自 PagedSlot.visibleContentNodes,相机缩放/平移进入同一反算;连续模式节点来自 VerticalReaderScene。进度与图片下载仍由既有 Controller/来源协议负责,视口移动只读取原文件。
- DesktopTileDecoder 使用 JDK ImageReader 的 sourceRegion/sourceSubsampling,创建区域目标 BufferedImage,不先建立一个全页目的 bitmap 再裁剪。实际输出尺寸与 TileSpec.decodedSize 核对,超过 2 MiB 的 ARGB 估计 payload 在开文件前拒绝;最多两个区域读取同时执行。 参考 ImageReadParam 官方契约。
- 当前 intersecting tile 才进入 composition,tile key 包含 page/sample/col/row;离开区域的块 退出 composition,转换后的 Bitmap 沿用 Compose/Skia 管理语义。BufferedImage 转换后 flush, reader/文件流在 finally/use 释放;读取进度回调发现调用者取消时 abort,结果交付前再检查取消。 区域解码失败有局部重试入口,不循环重新请求来源。
- Canvas 将 decodeRegion 的 gutter 映射到页面位置,并裁剪到 logicalRect;源尺寸与场景尺寸 保持分开,不用 sampled 输出尺寸改写页面高度。没有新依赖、图片下载器、schema 或平台像素 类型进入 commonMain,原 Android 解码/renderer 不切换到 JDK。
验证
新增五项父测试:两项共享可见区域/采样/限界、两项 PNG/JPEG 实际区域输出和超预算拒绝、 一项独立 Windows JVM 的真实长图 UI。十九项原 Android 几何测试迁入此次共享回归,不将它们 宣称为十九项新行为。自有 ReaderSource 提供 400×24000 PNG,三个彩色区段;terminal interceptor 不访问 DNS/socket/生产站点。
真实 UI 从分页 FIT_WIDTH 顶部平移到中部,再切换连续模式滚到 source y=12180,使分块边界 落在视口中。逐行检查视口中间列的实际蓝色像素,覆盖接缝并等待新块显示,避免只以旧块消失 作为渲染成功证据。核对页索引不变、分页历史不变、视口移动没有新增来源图片请求。查看 reader-tiles-paged-top.png / paged-middle.png / continuous-middle.png;子 JVM 输出 DESKTOP_UI_OK=reader-tiles 和 NETWORK_REQUESTS=0。PNG/JPEG 单元测试读取 1200×12000 图片深处的真实颜色,输出尺寸最多 528×528(包含 gutter),而非全图输出后裁剪的结果尺寸。
./gradlew.bat -PwithDesktopApp "-PmihonCompatibilityDirectory=D:/tmp/kt-compat36" -PmihonCompatibilityJavaVersion=25 :mihon-desktop-compat:test :desktop-app:test :desktop-runtime:test :source-host:test :source-host:installDist :core-source:jvmTest :core-source:compileCommonMainKotlinMetadata :reader-core:jvmTest :reader-core:compileCommonMainKotlinMetadata
./gradlew.bat -I "build/kmp-backup-validation.init.gradle" :app:compileDebugKotlin| 检查 | 最终结果 |
|---|---|
| core-source JVM / metadata | 38/38;metadata 通过 |
| source-host JVM / 默认发行 | 72/72;installDist 通过 |
| desktop-runtime JVM | 22/22 |
| mihon-desktop-compat API / WebView2 | 27/27 |
| desktop-app UI / 缓存 / 场景 / 生产 Window | 28/28 |
| reader-core JVM / metadata | 176/176;metadata 通过 |
| 汇总 | 363/363,0 失败、0 错误、0 跳过 |
| Android 编译 | compileDebugKotlin 通过 |
最终日志 build/kmp-windows-tiles-complete.log:1 分 35 秒,57 项任务(9 项执行、48 项 up-to-date);未变更模块复用匹配输入结果。Android 日志 build/kmp-reader-tiles-android-compile.log: 31 秒,50 项任务(9 项执行、41 项 up-to-date)。ignored init 只隔离 app/core-backup 输出; 未运行 Android 设备/R8/iOS Native 或硬件验收,没有重启用户 preview 或提交 Git。
SRP 分离共享区域选择、JDK 解码与 Compose 绘制;DRY 迁移原网格/坐标数学而非复制;DIP 使 renderer 消费 shared TileSpec,来源仍执行原 client;KISS 只组合可见块与有界解码并行度。 2 MiB 是单块 ARGB 输出预算,不是 provider 内部内存、GC 后 resident 或应用峰值内存的证明。 PNG 深处读取可能扫描此前行,性能仍需代表图片与硬件验证;其他格式继续原 Skia 全图路径。 尚无全量格式区域解码、overview/预测预取、跨视口持久 tile cache、全局像素预算、超大 Compose 布局尺寸虚拟化、自动跨章、下载或磁盘容量清理。完整 Windows 功能/打包、S3 及 iOS 原生/真机 门槛继续推进,KMP 总目标没有缩减或标记完成。
47. W3 续接:Windows 可选自动跨章与持久化尺寸提示
日期:2026-10-03。阅读设置新增默认关闭的 automaticChapter,沿用现有偏好 store 保存。 共享 SourceChapterNavigation 按来源给出的章节顺序选择同 source.name、同 branch 的邻章, 不按章节号重排,不穿过其他分支;桌面手动章节按钮与自动边界导航消费同一规则。
导航、失败与图片身份
- 单页/双页模式在 canonical slot 边界继续翻页时跨章;RTL 仅改变物理按键方向,章节顺序 不反转。向前从目标章首页进入,向后从末页组进入;正常手动章节按钮仍从首页进入。
- 连续模式在已恢复、已验证的实际列表边界接受新的键盘/滚轮输入;触摸残余滚动走 NestedScrollConnection。显式处理 Desktop Scroll pointer 事件,避免实际滚轮边界遗漏。 当前章/navigation 标识和 pending/busy 守卫阻止迟到事件切换新章节。恢复、窗口调整、 Home/End 或单次滚动刚好抵达边界不会自行连续跨章。向后进入上章末页底部。
- 先获取目标页列表并加载分页目标页组,或连续模式的目标锚点图片,再发布新章节状态。 这两步失败保留原章节/图片/已验证进度;连续模式其余可见图片仍按既有按需加载与进度 验证规则处理。错误存在时滚轮不自动重试来源,用户可通过页面/章节按钮明确重试。
- 仅改变自动跨章开关不重新定位当前页组。切换章前仍 flush 原章已验证的连续视口; Float.MAX_VALUE 仅为向后跳转的临时滚动目标,按实际页面/列表限界,绝不写入 history。
- 实际 UI 验证发现清空图片状态也丢失已知宽图尺寸,会令返回上章按估计尺寸分组。 既有 256 条页面索引追加带默认值的 width/height,不新增 schema/命名空间。重开章节时 按原完整请求上下文和扩展哈希读取尺寸提示;实际加载图片的尺寸优先,旧记录在完整 校验命中后补齐尺寸,并保留原 storedAt。尺寸提示不等于像素/ready 图片,不绕过原 文件 SHA/大小校验;文件缺失、大小变化或读取尺寸时 I/O 失败只忽略提示。
验证与范围
新增 SourceChapterNavigation 两项边界/来源/分支/顺序测试、两项桌面场景/持久化提示测试, 以及一个父测试启动两个独立 Windows JVM 的实际 Compose UI。ReaderSource 自有扩展可选 第三个其他分支章节,并可注入 getPages 或目标图片错误;图片 terminal interceptor 不访问 DNS/socket。分页目标失败后核对旧章节、图片和数据库历史;RTL 回上章核对宽图后末页组 锚点;连续 PageUp/PageDown 与滚轮核对上章底部/下章顶部、错误后的重复滚轮请求数不变。 第二个较小窗口 JVM 将图片来源置为离线,核对开关/模式/章节与实际页面恢复、零图片请求。
持久化提示测试重开真实偏好 store,核对 revision/headers 隔离;同尺寸损坏文件仍可提供 尺寸提示,但 visible load 必须通过 SHA 发现损坏并重新请求。场景测试以空图片 map 复现 末页组,随后核对实际已加载尺寸覆盖过期提示。已查看 failed-image、continuous-final、 read-restored 三张截图;专项子进程输出 DESKTOP_UI_OK 和 NETWORK_REQUESTS=0。
./gradlew.bat -PwithDesktopApp "-PmihonCompatibilityDirectory=D:/tmp/kt-compat36" -PmihonCompatibilityJavaVersion=25 :mihon-desktop-compat:test :desktop-app:test :desktop-runtime:test :source-host:test :source-host:installDist :core-source:jvmTest :core-source:compileCommonMainKotlinMetadata :reader-core:jvmTest :reader-core:compileCommonMainKotlinMetadata
./gradlew.bat -I "build/kmp-backup-validation.init.gradle" :app:compileDebugKotlin| 检查 | 最终结果 |
|---|---|
| core-source JVM / metadata | 40/40;metadata 通过 |
| source-host JVM / 默认发行 | 72/72;installDist 通过 |
| desktop-runtime JVM | 22/22 |
| mihon-desktop-compat API / WebView2 | 27/27 |
| desktop-app UI / 缓存 / 场景 / 生产 Window | 31/31 |
| reader-core JVM / metadata | 176/176;metadata 通过 |
| 汇总 | 368/368,0 失败、0 错误、0 跳过 |
| Android 编译 | compileDebugKotlin 通过 |
完整日志 build/kmp-windows-chapters-complete.log:1 分 51 秒,57 项任务(20 项执行、37 项 up-to-date)。Android 日志 build/kmp-reader-chapters-android-compile.log:26 秒,50 项任务 (11 项执行、39 项 up-to-date)。未修改隔离 init,未重启用户 preview、运行 Android 设备/ R8/iOS Native 或提交 Git;最终无遗留 WebView2 桥进程,git diff --check 通过。
SRP 分开章节选择、事务式目标准备与视口事件;DRY 复用共享章节规则与原图片索引;DIP 保留 SourceRuntime 请求边界;KISS 仅在用户继续边界输入时导航,没有轮询或后台跨章任务。 尚未取得尺寸的未读页仍使用估计几何,不宣称全章节任意宽图分组预知;连续触摸边界的真实 硬件验收、全量格式区域读取、预测预取、下载、全局像素/磁盘预算、Windows 安装包与 S3、 iOS 原生/真机门槛仍待。KMP 总目标继续保持 active。
48. W1 / W3 续接:Windows 整章下载、暂停恢复与离线入口
日期:2026-10-03。详情章节增加下载按钮,侧栏增加下载列表与暂停、继续、校验和离线阅读入口。 整章下载不再依赖 256 条 evictable 阅读索引;共享 SourceChapterDownload / SourceDownloadPage 在 commonMain 保存原章节、十进制 64-bit 作品/页身份、完整 SourcePage 请求、扩展哈希、 SourceImageArtifact 与尺寸,不含 JVM 路径/Compose bitmap。重复页、其他来源、错误 artifact 身份和无效尺寸拒绝构造;没有新增 Room schema、用户数据库或恢复 Entity Graph / Work。
请求、生命周期与离线数据
- DesktopDownloadStore 使用既有 SourcePreferences 保存 manifest,key 由 source/content/chapter 身份取 SHA-256。排入队列先保存记录,获取页列表后保存请求快照,再逐页保存成功 artifact; 暂停/失败保留先前进度。容量超限与磁盘失败显式报错,不发布成功下载状态。图片仍写入原 内容寻址 store,下载引用不会因阅读索引淘汰而移除;当前不删除任何已发布图片。
- DesktopDownloads 使用 Controller 原 scope 的 child Job 与独立 Mutex 串行执行章节,同一 章节重复请求复用活动任务。暂停取消当前任务或等待任务;退出随 Controller 取消/等待资源。 重开应用,未完成记录展示暂停,完成记录展示已下载;不在启动时自动执行来源请求。 “校验 / 继续”重新核对已完成文件,扩展 revision 改变则获取新页列表;下载没有 history 写入。
- prepareDownload 在下载 manifest 或原阅读索引中寻找匹配的已验证文件;否则执行原 SourceRuntime.fetchImage。来源的 native Page index、Referer、opaque imageUrl 和 client/ interceptor 保持原路径,网页挑战沿用现有 owner。后台传输不持有前台阅读缓存 gate, 以免慢下载阻塞普通 reader cache.load;图片文件发布继续使用原有校验/原子发布。
- 下载列表的离线入口通过 DesktopLibrary.find 读取现行本地作品/章节 DTO,完成 manifest 提供页列表;读图按下载引用校验大小/SHA 并解码,不调用来源详情或页列表。正常阅读也复用 匹配的完成章节快照与尺寸,缺失/损坏下载文件保持错误,需用户联网后校验/继续修复。 只有实际进入阅读器才按原 history 规则记录进度。下载不是“所有来源元数据都已离线”。
- 新增导航后,920×620 窗口的原侧栏固定间隔把 LazyColumn 可用高度挤空,完整筛选回归据此 失败。将导航分组并减小组间距离,恢复真实滚动区域;窄窗口探针先滚到来源再执行原筛选 交互,不删除窄窗口验收或减少控件覆盖。
验证与范围
新增两项 shared manifest 协议测试,核对完整请求/极限 Long round-trip 与无效数据拒绝。 五项桌面下载测试使用真实偏好 store / 图片文件,覆盖部分失败后的跨进程式重开/继续、活动及 等待 Job 取消、重复排队、revision 变化、容量失败、后台传输与前台缓存隔离。257 页测试使用 自有同一 1×1 PNG blob、257 个不同页身份与请求,先填满并淘汰阅读索引,再保存/重开独立下载 引用;完整校验没有来源 fetch,同尺寸损坏后必须重新 fetch 修复。它不代表 257 张代表图性能验收。
新增一个父测试启动两个独立 Windows JVM,通过实际 SDK 扩展和 Compose UI 操作。五页下载 在第三页失败,核对前两页已保存、history 为空;继续时延迟第三页,再从 UI 暂停,核对原 Rx/HTTP 调用结束;再次继续只请求剩余三页。整个下载过程总计 7 次图片调用、1 次页列表调用, 仍无 history。下载完成后把页列表/图片来源都置为离线,从下载列表进入并翻到第五页,核对 两种来源调用数不变、此时才产生页索引 4 的 history。第二个 920×620 JVM 直接从本地下载 入口显示末页,页列表及图片来源调用均为零。terminal interceptor 不访问 DNS/socket。
已查看 downloads-write-paused.png、complete.png 和 downloads-read-offline-last-page.png; 独立 JVM 输出 DESKTOP_UI_OK=downloads-write/read 与 NETWORK_REQUESTS=0。下载 UI 仅经过 自有来源验证,不代表真实站点、所有文件格式、Cloudflare 或全量扩展验收。
./gradlew.bat -PwithDesktopApp "-PmihonCompatibilityDirectory=D:/tmp/kt-compat36" -PmihonCompatibilityJavaVersion=25 :mihon-desktop-compat:test :desktop-app:test :desktop-runtime:test :source-host:test :source-host:installDist :core-source:jvmTest :core-source:compileCommonMainKotlinMetadata :reader-core:jvmTest :reader-core:compileCommonMainKotlinMetadata
./gradlew.bat -I "build/kmp-backup-validation.init.gradle" :app:compileDebugKotlin| 检查 | 最终结果 |
|---|---|
| core-source JVM / metadata | 42/42;metadata 通过 |
| source-host JVM / 默认发行 | 72/72;installDist 通过 |
| desktop-runtime JVM | 22/22 |
| mihon-desktop-compat API / WebView2 | 27/27 |
| desktop-app UI / 缓存 / 下载 / 场景 / 生产 Window | 37/37 |
| reader-core JVM / metadata | 176/176;metadata 通过 |
| 汇总 | 376/376,0 失败、0 错误、0 跳过 |
| Android 编译 | compileDebugKotlin 通过 |
最终完整日志 build/kmp-windows-downloads-complete.log:1 分 41 秒,57 项任务(7 项执行、 50 项 up-to-date);未变更模块复用匹配输入结果。Android 日志 build/kmp-reader-downloads-android-compile.log: 31 秒,50 项任务(11 项执行、39 项 up-to-date)。ignored init 沿用原 app/core-backup 输出 隔离,没有改变生产配置。未运行 Android 设备/R8/iOS Native 或硬件验收,未重启用户 preview 或提交 Git;最终无 WebView2 桥进程残留,git diff --check 通过。
SRP 分开共享下载数据、持久化、任务与 UI;DRY 复用图片完整性校验、原 SDK client/store 与 本地作品投影;DIP 保留 SourceRuntime 边界;KISS 沿用 Controller 任务所有权、逐页 manifest 与现有偏好原子快照,不引入另一套 HTTP 下载器或服务数据库。
当前整份 manifest 索引仍受偏好 store 的 4 MiB snapshot 上限,超限明确失败;不等于无限容量 下载系统。批量选择/优先级、导出/下载删除、磁盘容量/文件保留管理、预测预取与完整图片格式、 备份/同步、其他源运行时、扩展/APK 转换、Windows 安装包与 S3,以及 iOS Mobile JVM/JNI/ WKWebView/SwiftUI 和原生/真机门槛继续推进,KMP 总目标保持 active。
49. W1 / W3 续接:下载分块清单、原子发布与旧索引迁移
日期:2026-10-03。§48 的整份下载索引受 4 MiB 偏好 snapshot 上限,无法随章节/请求数据总量 扩展。本节不提高底层限制,改为共享 SourceDownloadManifest / SourceDownloadChunk 格式: 清单保存作品/章节/revision/标题/页数与块 hash;64 页一块,保留原 SourceDownloadPage 数据。 格式版本、hash 形状、页数/块数和块大小由 commonMain 构造约束;Long 仍用十进制字符串。
持久化与恢复顺序
- DesktopDownloadStore 改为消费 SourcePreferenceStore,图片字节仍在原 image store,不新增 DB/schema。数据块按完整 JSON 内容的 SHA-256 寻址,写入独立 namespace;成功块可复用。 每章 namespace 保存一个清单,发现索引按章节身份 hash 的首字符拆成 16 份,只保存 key。 页面请求集合不再全部塞进一个偏好文件,实际底层每份 snapshot 仍保留 4 MiB 约束。
- 先提交所有引用块,再原子保存章节清单;首次记录最后提交发现索引。清单仍沿用偏好 store 的临时文件、force 与同目录 ATOMIC_MOVE 发布规则。UI records 只在全部必要步骤成功后更新。 改动只生成新块并改发布点,不修改清单仍引用的旧块;块写入或清单失败不会改写上次成功 的章节文件,发现步骤失败的首次记录不进入成功状态。
- committedChunks / registered 仅在对应 edit 返回成功或启动读取验证通过后登记。既有偏好 契约允许磁盘失败仍更新内存,因此不能仅凭 snapshot 含有同值跳过重试;章节清单每次 save 都重新写入,失败阶段重试仍必须再次调用 edit,防止指向未提交的数据。
- 启动先读取新版发现索引/清单/块,核对块 JSON 的 hash、每块预期页数与完整章节的身份/ source/唯一页约束。单章坏块报错并隔离,健康章节仍可读;已发现新版身份不会悄悄回退到 原旧进度。发现分片读取失败与退休的旧 namespace 读取失败也保留错误和原数据。
- 再迁移未被新版发现的 desktop_downloads 平面记录,沿用相同发布顺序。旧记录与文件不 删除或覆盖;迁移失败保留原内存记录/错误,下一次真正重开 store 可以继续迁移。 当前下载/暂停/继续/离线入口和 history 语义不变,像素读取仍逐文件校验 SHA/大小。
验证与范围
新增两项共享块/清单测试,核对 64 页边界、65 页两块、空清单、版本/hash/数量拒绝与完整 请求/Long round-trip;五项 DesktopDownloadStore 测试使用真实 FileSourcePreferenceStore, 而非提高 maximumBytes 绕过限制。实际写入五章请求数据,总量与最后一章均超过 4 MiB; 最后一章含 448 页,来源请求使用自有长 opaque URL,各章 payload 不同。逐文件核对仍低于 原上限,重开后章节/完整页数据一致;同数据尝试旧平面 snapshot 明确被原容量检查拒绝。 测试数据仅为 manifest/合成 artifact 身份,不把长 URL 视作真实来源或下载图片性能验收。
旧版部分下载在迁移后保留原快照,更新新版完整记录后重开不复活旧进度。迁移块写入失败 保留旧两页并可在真实 store 重开后重试。块、章节清单和发现索引三阶段故障测试按偏好 契约模拟“内存已更新、磁盘未提交”,核对记录不错误发布、既有清单的实际文件字节不变、 首次发现文件未产生,且同一实例重试确实再次 edit。重开真实偏好目录验证最后提交可读。 坏块测试仅增加合法 JSON 的尾部空白,解析仍可成功但 SHA 不符,必须拒绝该章;保留旧平面 记录也不能回退,另一章仍可读。随后破坏退休的旧偏好文档,健康新版章节继续可恢复。
原下载 UI 父测试继续执行两个独立 Windows JVM,核对暂停/取消/仅补剩余页面、零下载进度 写入、页列表与图片来源同时离线后的真实末页显示、重启恢复及零额外来源调用;没有新增 生产请求。既有 reader/source/API/WebView2 回归继续保留。
./gradlew.bat -PwithDesktopApp "-PmihonCompatibilityDirectory=D:/tmp/kt-compat36" -PmihonCompatibilityJavaVersion=25 :mihon-desktop-compat:test :desktop-app:test :desktop-runtime:test :source-host:test :source-host:installDist :core-source:jvmTest :core-source:compileCommonMainKotlinMetadata :reader-core:jvmTest :reader-core:compileCommonMainKotlinMetadata
./gradlew.bat -I "build/kmp-backup-validation.init.gradle" :app:compileDebugKotlin| 检查 | 最终结果 |
|---|---|
| core-source JVM / metadata | 44/44;metadata 通过 |
| source-host JVM / 默认发行 | 72/72;installDist 通过 |
| desktop-runtime JVM | 22/22 |
| mihon-desktop-compat API / WebView2 | 27/27 |
| desktop-app UI / 缓存 / 下载 / 场景 / 生产 Window | 42/42 |
| reader-core JVM / metadata | 176/176;metadata 通过 |
| 汇总 | 383/383,0 失败、0 错误、0 跳过 |
| Android 编译 | compileDebugKotlin 通过 |
最终完整日志 build/kmp-windows-download-manifests-complete.log:1 分 56 秒,57 项任务 (18 项执行、39 项 up-to-date)。Android 日志 build/kmp-reader-download-manifests-android-compile.log: 25 秒,50 项任务(11 项执行、39 项 up-to-date);原 ignored init 的 app/core-backup 输出 隔离配置未变更。下载子 JVM 再次输出 DESKTOP_UI_OK 与 NETWORK_REQUESTS=0,最终无 WebView2 桥进程残留,git diff --check 通过;未重启用户 preview、提交 Git 或运行 Android 设备/R8/iOS Native/真机验收。
SRP 分开共享格式与平台原子存储;DRY 复用原偏好/文件锁/发布实现、SourceDownloadPage 与 SHA helper;DIP 通过 SourcePreferenceStore 发布;KISS 固定 64 页块和每章单一清单,没有 引入新事务数据库或平台像素类型。旧清单引用/迁移记录和未发布的新块暂不清理,单文件仍有 容量限制,全部元数据启动时驻留;没有宣称无限容量、内存预算、断电 fsync 目录耐久性或 下载/磁盘管理全面完成。批量/导出/删除、旧块回收与容量策略、Windows 打包/S3,以及 iOS 原生/真机和全量 KMP 门槛继续推进。
50. W1 / W3 续接:下载元数据占用预览与确认回收
日期:2026-10-03。§49 每次修改生成新的内容寻址块,旧块已无引用但仍占磁盘/偏好 cache。 下载页新增占用预览与确认回收,列出记录占用、可回收旧块数量/字节;取消不执行回收。 用户明确确认后仅清理已批准且当时仍未引用的元数据块,图片/章节/旧平面记录和其他偏好保留。 本节没有自动清理、图片删除或全盘容量预算,测试动作仅针对自有临时目录。
文件身份、引用与任务
- FileSourcePreferenceStore 增加实际磁盘 snapshot inventory/read/remove API,仍由原目录 owner 和 monitor 管理。只枚举 canonical SHA 文件名的 JSON,检查普通 NOFOLLOW 文件、 原大小限制、版本/namespace 与文件名对应关系;token 包含 namespace、实际文件字节数和 SHA。移除前核对同一文件身份,变化拒绝移除;路径从 namespace hash 构造在原目录内。 未知/损坏文档停止扫描,不把不可识别数据当垃圾。
- 成功删除 snapshot 后移除相应缓存实例并退休旧视图,避免借用者用旧值重建已移除文件; 新 open 可创建空的新视图。下载 store 也清除对应 committedChunks hash,若用户继续/ 重用某旧版本,save 必须重新写出所需块,不能因旧成功登记跳过。
- DesktopDownloadCleanup 遍历磁盘上所有章节根,包括首次清单已写但发现索引未成功的根。 清单身份、块 hash/数量、页 source/唯一性/geometry 继续验证;根缺块、数据损坏或图不完整 阻止回收。只有不被任何根引用的 v2 页面块可进入预览;清单/索引/其他设置不作为候选。
- DesktopDownloadStore 预览及确认与 save 共用 monitor。确认重新扫描引用,只取新合格 token 与批准 token 的交集;预览后被引用/改变的旧块跳过,新产生未引用块也不加入本次 批准集合。每次 I/O 扫描/删除前检查 Controller owner 取消,文件 token 删除前再验证。 当前保证基于同一 Session/目录 owner 的写入纪律,不宣称恶意外部文件替换的无竞态证明。
- Controller 使用原 action/scope,窗口关闭取消;保存独立预览对象,确认必须匹配当前对象。 UI 模态对话框展示具体范围,确认后报告实际移除/跳过数量,取消仅撤销预览。 对话框有自己的 Compose root,截图探针改为捕获对话框节点,保留实际 modal UI 验证。
验证与范围
新增两项 source-host snapshot 测试:真实磁盘 inventory/read/remove、新旧视图生命周期及 重开;同尺寸文件内容变化拒绝删除,损坏实际偏好文档停止 inventory 且保留字节。 新增三项下载回收测试:旧块清理后保存旧版本必须重建且重开可读、确认时保护重新被引用 块且不删除未批准的新旧块、未索引的真实清单保护其块,损坏图阻止删除任何批准候选。
既有两个 Windows 子 JVM 的下载 UI 探针增加预览/取消/确认:取消前后候选 token 一致, 确认后旧候选为零、已下载状态/history 不变。随后同时将页列表/图片来源置为离线,仍显示 末页,第二个较小窗口 JVM 重开同数据仍离线显示,来源调用数不增加。自有 fixture 的确认 对话框显示 26 KB 元数据,5 条旧记录约 20 KB 可回收;已查看 downloads-write-cleanup-preview.png。 这些是实际文件字节数而非 NTFS allocated size,不是代表库规模或内存/硬件性能验收。
./gradlew.bat -PwithDesktopApp "-PmihonCompatibilityDirectory=D:/tmp/kt-compat36" -PmihonCompatibilityJavaVersion=25 :mihon-desktop-compat:test :desktop-app:test :desktop-runtime:test :source-host:test :source-host:installDist :core-source:jvmTest :core-source:compileCommonMainKotlinMetadata :reader-core:jvmTest :reader-core:compileCommonMainKotlinMetadata
./gradlew.bat -I "build/kmp-backup-validation.init.gradle" :app:compileDebugKotlin| 检查 | 最终结果 |
|---|---|
| core-source JVM / metadata | 44/44;metadata 通过 |
| source-host JVM / 默认发行 | 74/74;installDist 通过 |
| desktop-runtime JVM | 22/22 |
| mihon-desktop-compat API / WebView2 | 27/27 |
| desktop-app UI / 缓存 / 下载 / 场景 / 生产 Window | 45/45 |
| reader-core JVM / metadata | 176/176;metadata 通过 |
| 汇总 | 388/388,0 失败、0 错误、0 跳过 |
| Android 编译 | compileDebugKotlin 通过(up-to-date) |
首次完整回归中 Cookie 测试的原 10 秒目录释放检查遇到 AccessDeniedException; 未修改等待期限或移除断言。保持代码不变的单项复测通过,日志 build/kmp-windows-download-cleanup-cookie-recheck.log:17 秒,13 项任务 (3 项执行、10 项 up-to-date)。随后完整回归通过,最终日志 build/kmp-windows-download-cleanup-complete.log:1 分 1 秒,57 项任务 (7 项执行、50 项 up-to-date);XML 核对上述 388 项全部通过。 初次目录释放失败原因尚未确认,不能据此宣布原生浏览器生命周期在所有硬件上稳定。
Android 日志 build/kmp-reader-download-cleanup-android-compile.log:2 秒,50 项任务 (7 项执行、43 项 up-to-date),复用原 ignored 输出隔离 init。最终 WebView2 桥进程 残留为 0,git diff --check 通过。未重启用户 preview、操作真实用户下载目录或提交 Git; 没有运行 Android 设备/R8/iOS Native/真机验收。
SRP 分开文件身份、引用分析和确认 UI;DRY 复用原偏好解码/大小/UTF-8/hash 与共享下载 格式;DIP 仍通过 owner 存储/Controller 任务,不新增服务数据库;KISS 仅一次预览后确认, 没有后台自动删除。全部元数据启动驻留、图片预算与已下载内容管理、批量/导出/删除、 Windows 打包/S3,以及 iOS 原生/真机和全量 KMP 门槛仍待。
51. W3 续接:文件头尺寸与完整页面输出像素预算
日期:2026-10-03。§46 已让 PNG/JPEG 长页按可见区域解码,但准备尺寸仍读取整份编码文件, 其他格式及普通页面仍在 Compose 转换时分配原始尺寸的 Bitmap。本轮分开原始场景几何与 显示输出尺寸,复用既有 Skia/JDK provider,没有新增解码依赖或删除图片文件。
共享几何与桌面解码
- reader-core commonMain 新增 ImageDecodeSize,仅消费共享 IntSize 和像素/边长限制。 普通小页原样返回;超预算按比例缩小,并处理极细页面的一像素下限,使用 Long 计算面积。 单边和像素上限分别约束输出,不改变 source 坐标、页组、历史位置或持久化原始尺寸。
- DesktopImageDecoder 的 PNG/JPEG header 使用 FileImageInputStream 与 ImageReader 的 getWidth/getHeight,dispose reader 并关闭文件。其他既有格式继续通过 Skia 获取尺寸。 文件头可提供尺寸,不能证明像素 payload 完整/可解码;原 artifact 大小/SHA 校验仍保留, 可见解码失败仍显示失败提示。没有把尺寸提示升级成像素成功证明。
- 完整页面输出使用 ImageDecodeSize,限制 4194304 个 N32 像素(16 MiB)及单边 16384, allocPixels 保留原 color space、使用 premultiplied alpha;写入完成后 setImmutable,避免 后续消费者按可变像素处理。Skia scalePixels 的 cache=false 避免请求本地像素缓存,编码 Image/Pixmap 在输出完成后释放。
- Compose 直接 asComposeImageBitmap 接管成功输出,不再经过 toComposeImageBitmap 的 另一份像素复制。已核对本机 Compose 1.12.0 的实际 bytecode:前者包装同一 Bitmap, 后者调用 toBitmap。调用者取消/失败时关闭未交付 Bitmap;交付后的可见 Bitmap 沿用 Compose/Skia 生命周期,尚未宣称全部 native/GPU 资源即时释放或全局 residency 受限。
- 区域解码和完整页面共用两个并发名额;PNG/JPEG 的分块触发阈值由原 16M 降至 4194304 像素,高度超过 4096 的规则保留。这些格式超预算后仍可按局部采样保留细节。 大 WebP 等暂未支持区域读取的格式使用降采样输出,放大细节受输出分辨率限制,原文件保留。
平台调用参考 JetBrains Image API 和 Bitmap API,实际使用 版本由本机 Skiko 0.150.1 bytecode/编译验证;没有仅按最新文档猜测库签名。
验证与范围
三项共享测试核对普通页保持像素、比例与像素限制、Int.MAX_VALUE/极细页/一像素预算的 面积与单边边界、非法参数。五项桌面测试使用实际 PNG/JPEG/Skia WebP 与 N32 Bitmap, 核对尺寸/实际 computeByteSize/颜色、透明度、坏图失败和等待解码取消不丢并发名额。 只有 33 字节 PNG 文件头仍能返回 128×24000 尺寸,证明尺寸路径不要求读取像素 payload; 这不代表该截断文件可显示。文件流关闭后能移动测试文件。
新增父 UI 测试启动两个独立 Windows JVM。自有 fixture 经原 SDK imageRequest/client 返回 3000×2000 红蓝 WebP,保留原 headers/原生 index/opaque URL;第一个 JVM 验证真实像素、 放大不推进历史、连续阅读和宽图单页。第二个较小窗口 JVM 无可用图片来源,仍恢复原始 几何/双页模式中的宽图单页与红蓝像素,图片来源调用为零。已查看 reader-bounded-write-wide-solo.png。既有 PNG/JPEG 长图/接缝、相机、分页/连续、下载离线与 WebView2 检查继续保留;测试图片是自有样本,不是全格式/代表站点/硬件峰值内存验收。
./gradlew.bat -PwithDesktopApp "-PmihonCompatibilityDirectory=D:/tmp/kt-compat36" -PmihonCompatibilityJavaVersion=25 :mihon-desktop-compat:test :desktop-app:test :desktop-runtime:test :source-host:test :source-host:installDist :core-source:jvmTest :core-source:compileCommonMainKotlinMetadata :reader-core:jvmTest :reader-core:compileCommonMainKotlinMetadata
./gradlew.bat -I "build/kmp-backup-validation.init.gradle" :app:compileDebugKotlin| 检查 | 最终结果 |
|---|---|
| core-source JVM / metadata | 44/44;metadata 通过 |
| source-host JVM / 默认发行 | 74/74;installDist 通过 |
| desktop-runtime JVM | 22/22 |
| mihon-desktop-compat API / WebView2 | 27/27 |
| desktop-app UI / 缓存 / 下载 / 场景 / 生产 Window | 51/51 |
| reader-core JVM / metadata | 179/179;metadata 通过 |
| 汇总 | 397/397,0 失败、0 错误、0 跳过 |
| Android 编译 | compileDebugKotlin 通过,实际重新编译 |
完整日志 build/kmp-windows-image-decode-complete.log:2 分 40 秒,57 项任务 (13 项执行、44 项 up-to-date)。最后补上输出 setImmutable 后,再完整执行 desktop-app 51 项测试,日志 build/kmp-windows-image-decode-immutable-check.log:2 分 25 秒,37 项任务 (10 项执行、27 项 up-to-date);全部通过,其他模块输入未变。最终 XML 核对上表 397 项。 两个新增子 JVM 均输出 DESKTOP_UI_OK 与 NETWORK_REQUESTS=0。
Android 日志 build/kmp-reader-image-decode-android-compile.log:35 秒,50 项任务 (9 项执行、41 项 up-to-date),复用原 ignored 输出隔离 init。最终 WebView2 桥进程残留 为 0,git diff --check 通过;没有重启用户 preview、操作真实用户下载目录、提交 Git 或 执行 Android 设备/R8/iOS Native/真机验收。
SRP 分开输出几何、平台解码与 Compose 所有权;DRY 将分块和整页并发限制统一,下载/阅读 复用同一 header;DIP 的共享几何不依赖 Compose/Skia/JDK,KISS 保留原文件/错误/请求路径。 预算只约束单次 destination Bitmap;Skia codec 中间缓冲、编码输入驻留、所有可见节点/封面/ GPU 的总预算、预测预取和其他格式区域读取仍待。磁盘容量策略、长章元数据驻留、下载批量/ 导出/删除、Windows 打包/S3 与 iOS 原生/真机和全量 KMP 门槛仍待。
52. W3 续接:Windows 独立应用、本地 ZIP/MSI 与随包运行时验证
日期:2026-10-03。此前 desktop-app 的开发启动依赖本机 API 目录、JDK 和显式浏览器桥路径。 本轮将当前已验证的 Windows 实现生成本地预览应用,保留 Android/source-host 的默认发行范围; 没有新增依赖、升级工具链版本或改变 Room v84。此轮产物不等于公开发行或全部 KMP 完成。
单一运行目录与输入校验
- 可选
-PwithWindowsDistribution打开分发配置;未提供标志的 native package 任务明确失败, 避免生成缺少外部运行时的应用。新增desktop-app/package-windows.ps1串联验证与打包。 windows-runtime-pins.properties锁定实际选入的完整 29 个外部 JAR。新校验任务检查成员集合、 重复文件名及 SHA-256,沿用原五个关键产物的身份检查。三份独立自有目录分别修改非关键 JAR、 缺少 dec.jar、添加 unexpected.jar,实际执行 Gradle 均被拒绝;原 API 目录保持不变。- 复用原 AndroidCompat → server → android-jar → 其余文件顺序,将外部 JAR 按三位数字编号, 复制时不改变字节。Compose/jpackage 会重新按文件名排序,编号确保 Android 桩类不会遮蔽兼容实现。 staging 目录按 pin 文件 SHA 分隔;其他既有 Kotlin/Compose/Room 依赖沿原 runtimeClasspath 进入应用。
- 同一应用目录携带 JVM、Skiko/SQLite、桥 EXE 与三个 WebView2 DLL。资源中保留输入文件名/大小/SHA、 29 项 pin、本地预览范围说明及根 LICENSE 副本;JVM legal 文件保留。系统仍需 Edge WebView2 Runtime。 资源定位优先使用显式桥路径,否则读取 Compose 的应用资源路径;错误显式覆盖不会悄悄回退。
- 保留随包
runtime/bin/java.exe,用于独立支持检查和生产窗口探针。Compose 1.12.0 的getStripNativeCommands$compose适配已核对实际 bytecode;将来升级插件需重新核对这个内部属性。 包装过程关闭 configuration cache;普通回归仍使用缓存。
基于 Compose Multiplatform 官方原生发行说明 配置应用/JVM/资源和 MSI;具体 1.12.0 的任务属性以本机插件字节码和实际构建为依据。
安装目录与同一份应用产物
MSI 使用 limited/per-user 权限及固定 upgrade UUID,默认程序目录为 %LOCALAPPDATA%/Kototoro-App,数据继续位于 %LOCALAPPDATA%/Kototoro,目录选择关闭。 最初默认同名目录会让卸载规则覆盖数据根,检查后修正。曾尝试 Programs/Kototoro: 单反斜杠被参数文件解析吞掉;修正转义后又因 jpackage 的中间 Programs 目录缺少 RemoveFile 规则触发 WiX ICE64。最终采用相邻的专用程序目录,没有禁用 ICE 校验或定制卸载删除逻辑。
MSI 的 appImage 直接引用经过集成测试的应用目录,ZIP 也使用同一目录;两者依赖 windowsDistributionTest。若分别按输入 JAR 重建,Compose 的 Skiko 解包/重打包会产生不同的 JAR 字节/名称,逐文件检查曾正确拒绝该路径。集成测试将整个应用目录声明为输入,避免只根据 路径字符串复用旧测试结果。inspect-windows-msi.ps1 仅使用项目内 WiX dark 反编译/提取,不执行 MSI。
MSI 内容检查拒绝程序/数据重叠、非 HKCU 注册表项、升级标识变化、越界文件、重复/额外/遗漏文件, 逐文件比较应用 payload 的 SHA-256。JDK25 将构建用 app/.jpackage.xml 替换为安装用 app/.package; 仅接受这一个具体元数据转换,并验证包名标记为 Kototoro,445 个应用文件仍逐一完全一致。 ZIP 保留完整 446 个目录文件,包括构建元数据,另用实际 ZIP 流逐文件比对 SHA 与成员数量。
验证与范围
新增三项桥定位测试、一项隐式用户数据目录拒绝测试,以及一项可选原生分发集成测试。 --check-runtime <report> 必须同时提供 --data-dir:使用独立目录启动并关闭真实 Session, 检查 Room v84、AndroidCompat 类所有者、Skia 实际 PNG 像素及 WebView2 本地 HTML/JS,成功后写报告。 可以显式导入自有 fixture,但检查不会请求生产来源网站。
分发集成测试将完整应用复制到含中文和空格的临时路径,从独立工作目录启动原生 Kototoro.exe; 子进程移除 JAVA_HOME/CLASSPATH/JVM 注入选项,PATH 仅含 System32。报告确认 Java.home 和浏览器 均来自移动后的应用,schema=84、image/browser=ok、来源数=1,Android 类来自编号后的 AndroidCompat。 随后用随包 java.exe 和应用自身 classpath 加载自有探针,创建生产 Window,验证启动中关闭和 导入后关闭;保留原十秒有界文件句柄释放检查。未启动/重启用户现有 preview。
./desktop-app/package-windows.ps1 -CompatibilityDirectory "D:/tmp/kt-compat36" -JavaVersion 25
./gradlew.bat -PwithDesktopApp "-PmihonCompatibilityDirectory=D:/tmp/kt-compat36" -PmihonCompatibilityJavaVersion=25 :mihon-desktop-compat:test :desktop-app:test :desktop-runtime:test :source-host:test :source-host:installDist :core-source:jvmTest :core-source:compileCommonMainKotlinMetadata :reader-core:jvmTest :reader-core:compileCommonMainKotlinMetadata
./gradlew.bat -I "build/kmp-backup-validation.init.gradle" :app:compileDebugKotlin| 检查 | 最终结果 |
|---|---|
| core-source JVM / metadata | 44/44;metadata 通过 |
| source-host JVM / 默认发行 | 74/74;installDist 通过 |
| desktop-runtime JVM | 22/22 |
| mihon-desktop-compat API / WebView2 | 30/30 |
| desktop-app UI / 缓存 / 下载 / 场景 / 生产 Window | 52/52 |
| reader-core JVM / metadata | 179/179;metadata 通过 |
| Windows 移动目录 / EXE / 随包 JVM / 生产 Window | 1/1 |
| 汇总 | 402/402,0 失败、0 错误、0 跳过 |
| MSI 内容 / ZIP 内容 | 445 个应用文件 + 一个校验的包名标记 / 446 个文件,SHA 与成员检查通过 |
| Android 编译 | compileDebugKotlin 通过,up-to-date |
普通完整回归日志 build/kmp-windows-distribution-complete.log:2 分 3 秒,57 项任务 (9 项执行、48 项 up-to-date),configuration cache 存储成功。Android 日志 build/kmp-reader-windows-distribution-android-compile.log:2 秒,50 项任务 (7 项执行、43 项 up-to-date),沿用 ignored 的输出隔离 init。 打包最终记录在 build/kmp-windows-distribution-final-package.log:1 分 1 秒,50 项任务 (13 项执行、37 项 up-to-date),分发集成测试本轮实际重新执行;运行报告和 MSI 清单位于 desktop-app/build/reports/windows-distribution、desktop-app/build/reports/windows-msi。 最终产物为 desktop-app/build/compose/binaries/main 下的 Kototoro.exe、portable ZIP 和 MSI; 均是 ignored 的本地构建输出,没有提交 Git。最终 ZIP 152128114 字节,MSI 153198764 字节; ZIP SHA/成员复核日志为 build/kmp-windows-zip-contents-check.log。桥进程残留 0,git diff --check 通过。
输入 pin 和 LICENSE 副本只证明输入身份及保留项目声明,不替代组合产物的 S3 来源/许可/notice 审计。实际 MSI 安装、快捷方式、注册表、升级与卸载尚未执行,需按用户提供的 AGENTS 高风险 操作要求取得明确授权;dark 内容验证不证明安装 UI 或升级行为。没有进行签名、公开发布、 Android 设备/R8、iOS Native 或真机验收。
SRP 分开运行时支持检查、打包与内容检查;DRY 的 ZIP/MSI 共用已测试应用目录,桥和 API 复用现有实现;KISS 采用独立程序目录避免 WiX 自定义卸载;DIP 的共享模块不依赖桌面包装。 下载批量/导出/删除、磁盘总量与图片驻留预算、预取/更多格式、备份/同步、完整扩展/APK 与源生态、 Windows 实际安装/升级/卸载和 S3/签名,以及 iOS Mobile JVM/JNI/WKWebView/SwiftUI/原生真机门槛仍待。
53. W1 / W3 续接:Windows 收藏与历史备份、私有预览及事务合并恢复
日期:2026-10-03。§13 已共享备份模型,但 Windows 没有备份入口。本轮 desktop-runtime 直接 依赖既有 core-backup,接入当前库数据的导出与恢复。没有新依赖版本、schema 变更、平行数据库或 实体图谱;完整备份/外部格式/同步和 iOS 原生仍保留原目标。
格式与文件所有权
- DesktopLibraryBackup 导出 index/categories/favourites/history/sources/projections 六个原生 ZIP 节, 共用 Android 的 BackupIndex、CategoryBackup、FavouriteBackup、HistoryBackup、SourceBackup、 ContentBackup。索引使用现有 generation 3/schema 4;字段名和 Long 编号保持原样。 各 DTO 逐条写 JSON 数组,使用同一数据库事务获取一致视图;写完 ZIP 后才发布最终文件。 已有文件拒绝覆盖,失败/取消关闭流并清理自己创建的临时文件。
- 导出压缩文件上限 512 MiB、全部 payload 上限 1 GiB;读取采用相同上限,避免生成本端不能打开 的备份。大小限制包含压缩 ZIP 元数据,解压扫描按实际读取字节计数,不信任 entry 声明大小。 这些上限不证明全局内存受控:单个 JSON 记录、分类映射与作品锚点集合仍可能占用较多内存。
- DesktopBackupArchive 在预览时复制到唯一的私有临时文件。完整 ZipInputStream 扫描验证全部 payload/CRC(含未支持节),拒绝路径/重复/无效节名、过多 entry,并核对 central directory 的 节名集合。索引须恰好一条、版本受支持;支持的数据节逐条解码计数,其他节列入预览说明。 原选中文件在预览后变化不会影响确认数据;取消/替换/恢复结束/退出关闭 ZIP 并回收自有副本。 dispatcher 返回期间取消也关闭已创建未交付的预览。
合并与旧版兼容
恢复只做 MERGE,在一个 Room immediateTransaction 内执行,保留外键约束;格式、关联或身份 错误及取消会结束事务,避免部分分类/收藏/内容已经落盘。分类按现有同名记录映射;新分类使用 本地分配的编号,收藏统一使用映射后的编号,不改写既有分类。避免极大的外部分类编号把本地 SQLite 自增序列推到 Int 范围之外,测试覆盖 INT_MAX 分类恢复后新增收藏并再次导出/恢复。
内容按现行 source + URL/public URL 检查 Long 身份冲突;标签检查已有身份及重复编号,并保留 已有 pin。Android wire 没有 description/source_data/chapters,恢复已有内容时保留这些本地详情, 新作品需安装对应扩展并刷新详情。收藏以 updatedAt 合并;历史新增 findIncludingDeleted 查询, 比较时包含 tombstone,较旧备份不能复活更新的本地删除记录;写入复用 upsertSync。 共享 DAO 仅新增查询,没有新增表、改变 schema/version/identity 或 Android 现有 find 的语义。
旧 work_favourites/work_history 用共享 DTO 投影到 projections 中的 anchor_manga_id,允许 0、负数和完整 Long。活动记录缺少锚点拒绝恢复;无锚点的已删除旧记录可跳过。 entity graph 节不会恢复到已移除的实体体系。其他节在预览中明确标示未恢复,当前也不做 REPLACE、 自动导入插件/授权或后台同步;原 ZIP 文件保持不变。
界面与验收
侧栏新增“备份与恢复”,提供导出、选择文件、文件名/中文时间/记录数量预览、取消及“确认合并恢复”。 Controller 复用原有 gate、scope 和退出路径;确认须是当前预览实例,旧预览不能提交。 未安装扩展时收藏/历史仍能通过现有本地库显示;这不证明缺少扩展时可以请求详情或在线阅读。 完整用户备份迁移、Mihon/Aniyomi .tachibk 导入和 WebDAV 继续推进。
十一项数据库/ZIP 测试覆盖原生导出后读取/重开、手写 Android 字段格式且乱序的 ZIP、分类碰撞 重映射、本地 opaque/章节保留、较新的历史删除记录、后期内容冲突与进度/内嵌编号/重复标签错误 整次回滚、重复节/缺索引/未来版本/坏 JSON/路径拒绝、CRC 损坏、预览后文件变化、关闭预览拒绝 再次恢复、已有导出文件保留、流式写入预算,以及 0/负数旧锚点映射。只使用自有临时数据。
新增父 UI 测试启动两个独立 Windows JVM:先从自有数据库导出,真实界面验证预览/取消不写库、 确认合并、导出后再预览;第二个 JVM 在没有扩展的情况下显示同一收藏/历史,核对完整 Long chapter、页码及像素 scroll。退出前留一个未确认预览,关闭后重开数据库并验证该预览已不可使用。 两个 JVM 输出 DESKTOP_UI_OK 和 NETWORK_REQUESTS=0;已查看 backups-write-preview.png。
完整回归初次发现 filters-narrow 的来源切换测试直接点击已被 LazyColumn 回收的不可见项; 新增侧栏入口降低了 620 像素高窗口的列表空间。保留现有窗口尺寸/断言和实际点击,改为在切换前 滚动到对应来源。普通与窄窗筛选回归均通过,没有禁用测试或移除 source isolation 检查。
原生 EXE 的 --check-runtime 新增本地备份导出/解析检查及 backup=ok 报告;检查不执行恢复, 只回收自己成功创建的诊断 ZIP。移动目录的分发测试断言该字段,验证随包 core-backup 与 serializer 可以实际工作,仍保留随包 JVM/Skia/WebView2/生产 Window 和 MSI 逐文件校验。
./gradlew.bat -PwithDesktopApp "-PmihonCompatibilityDirectory=D:/tmp/kt-compat36" -PmihonCompatibilityJavaVersion=25 :mihon-desktop-compat:test :desktop-app:test :desktop-runtime:test :source-host:test :source-host:installDist :core-source:jvmTest :core-source:compileCommonMainKotlinMetadata :reader-core:jvmTest :reader-core:compileCommonMainKotlinMetadata :core-backup:jvmTest :core-backup:compileCommonMainKotlinMetadata :core-db:compileCommonMainKotlinMetadata
./gradlew.bat -I "build/kmp-backup-validation.init.gradle" :app:compileDebugKotlin :app:testDebugUnitTest --tests "org.skepsun.kototoro.backups.data.BackupIndexCompatTest" --tests "org.skepsun.kototoro.backups.data.KotatsuBackupPayloadCompatTest" --tests "org.skepsun.kototoro.backups.domain.BackupPayloadGuardTest" --tests "org.skepsun.kototoro.backups.domain.BackupRestoreFormatTest"
./desktop-app/package-windows.ps1 -CompatibilityDirectory "D:/tmp/kt-compat36" -JavaVersion 25| 检查 | 最终结果 |
|---|---|
| core-source JVM / metadata | 44/44;metadata 通过 |
| source-host JVM / 默认发行 | 74/74;installDist 通过 |
| desktop-runtime JVM | 33/33 |
| mihon-desktop-compat API / WebView2 | 30/30 |
| desktop-app UI / 缓存 / 下载 / 场景 / 生产 Window | 53/53 |
| reader-core JVM / metadata | 179/179;metadata 通过 |
| core-backup JVM / metadata、core-db metadata | 13/13;两模块 metadata 通过 |
| 普通 Windows 回归汇总 | 426/426,0 失败、0 错误、0 跳过 |
| Android 编译 / 指定兼容单元测试 | compileDebugKotlin 实际重新编译;4 类 14/14,0 跳过 |
完整日志 build/kmp-windows-library-backup-complete.log:2 分 8 秒,88 项任务 (14 项执行、74 项 up-to-date),configuration cache 复用。Android 日志 build/kmp-reader-windows-library-backup-android.log:2 分 27 秒,111 项任务 (27 项执行、84 项 up-to-date),沿用 ignored 的输出隔离 init;不是 Android 设备实际恢复测试。 分发日志 build/kmp-windows-library-backup-package.log:1 分 1 秒,53 项任务 (15 项执行、38 项 up-to-date),移动目录的原生分发测试 1/1,合计 Windows/共享测试 427/427。 MSI 验证 448 个应用文件及一个包名标记;ZIP 449 个文件逐一通过 SHA/成员检查,记录在 build/kmp-windows-library-backup-zip-check.log。ZIP 153070696 字节,MSI 154038516 字节; 桥进程残留 0。本轮只更新本地预览包,不执行 MSI 安装。
SRP 将 ZIP 所有权、数据库合并和界面确认分开;DRY 复用共享 wire 模型及原 DAO/store/Controller; KISS 只提供可审查的显式合并、不做清空模式;DIP 运行时依赖共享备份契约,不依赖 Android UI。 书签/统计/追踪/设置与授权/扩展和图片备份、外部格式导入及 WebDAV、数据库大规模恢复驻留预算、 下载批量/删除/导出与磁盘/图像容量、完整平台和源生态、Windows 实际安装/升级/卸载与 S3/签名、 以及 iOS Mobile JVM/JNI/WKWebView/SwiftUI/原生真机门槛仍待,全量 KMP 未完成。
54. W1 / W3 续接:Windows 书签与阅读统计备份、完整主键及幂等合并
日期:2026-10-03。延续 §53,图书馆备份新增 bookmarks/statistics 两个原生节,直接复用 BookmarkBackup、StatisticBackup 与现行 BookmarksDao/StatsDao;索引仍是 generation 3/schema 4, 没有依赖版本、数据库 schema、表或状态所有权变更。Room 的 core/db/entity 目录继续承担现行表 映射;移除的是 Entity Graph / Work 业务体系,共享备份模型中误称旧图谱仍为状态所有者的注释已修正。
导出、预览及恢复规则
原生 ZIP 共八节:index/categories/favourites/history/sources/bookmarks/statistics/projections。 导出书签时保留 Android 的外置 tags;只含书签或统计、没有活动收藏或历史的作品也加入 projections, 避免恢复到空库时遗失其外键目标。继续复用同一数据库快照事务、流式 ZIP、文件预算与私有预览。 预览统计实际书签条数,不把分页产生的作品包装条目误报为书签数;支持节须在预览时完成解码。
- 书签按复合主键 manga_id/page_id 匹配,使用新增的精确 DAO 查询,不借用原 pageId-only 查询。 保留 createdAt 较新的本地书签;已有同编号书签的 chapter/page 归属不同则拒绝恢复。 检查内嵌作品编号、非负页码/scroll、有限且处于 [-1,1] 的 percent;完整 Long 编号仍保留。
- 统计按 manga_id/started_at 匹配,同一阅读会话的 duration/pages 各取较大值。 格式没有 updatedAt,因此不把备份计数直接覆盖较大本地计数,也不相加导致重复导入膨胀。 检查非负计数与作品关联;完整 Long 时长/时间与 Int 页数无需累加运算。
- 旧 work_stats 按 projections 的 anchor_manga_id 写入 stats;没有备份作品锚点的旧统计按 既有兼容方向跳过。当前 statistics 缺少对应作品则拒绝恢复;两者都不重建 entity/work 表。 所有支持节仍在同一个 immediateTransaction 合并,后期统计错误会回滚此前的分类、作品及书签。
界面改称“图书馆备份”,补充书签/统计范围、实际预览数量与统计合并说明。仅提供备份迁移能力; 桌面阅读器的书签编辑界面与实时统计记录仍待接入,不能把恢复测试当作这些功能的完成证明。 其余备份节、外部格式与 WebDAV 继续保留原目标。
验收与产物
新增五项数据库/ZIP 测试:9 条书签跨导出分页、仅有书签/统计的作品和标签、两轮导出/恢复后重开、 0/负数/Long.MAX/MIN 编号、同页面编号属于不同作品、较新书签保留、外置标签恢复、统计计数最大值及 重复恢复、旧统计锚点、错误位置/编号/计数/缺作品导致整次回滚、坏 JSON 在预览前拒绝。 统计错误测试先写入有效书签再触发失败,实际核对书签和此前作品/分类均回滚。 首轮手写样例误用 stats 节名,被真实 statistics 输出断言及合并测试发现;修正为既有格式,未新增别名。
两进程 UI 探针加入书签/统计 fixture,真实界面核对数量、取消后无数据、确认合并、导出后预览, 第二进程从同一数据库读取完整书签位置和统计。已查看 backups-write-preview.png,界面可滚动到确认按钮。 未使用用户库、用户真实备份、生产源请求或 Android 设备恢复。
| 检查 | 最终结果 |
|---|---|
| core-source JVM / metadata | 44/44;metadata 通过 |
| source-host JVM / 默认发行 | 74/74;installDist 通过 |
| desktop-runtime JVM | 38/38,含新增 5 项;最终回滚断言修改后再执行全部 38 项通过 |
| mihon-desktop-compat API / WebView2 | 30/30 |
| desktop-app UI / 存储 / 原生窗口 | 53/53 |
| reader-core JVM / metadata | 179/179;metadata 通过 |
| core-backup JVM / metadata、core-db metadata | 13/13;两模块 metadata 通过 |
| 普通 Windows/共享回归 | 431/431,0 失败、0 错误、0 跳过 |
| 移动目录原生分发 | 1/1;随包 JVM、Room、备份、Skia、WebView2 与生产 Window 通过 |
| Windows/共享含分发汇总 | 432/432,0 失败、0 错误、0 跳过 |
| Android 编译 / 指定兼容测试 | compileDebugKotlin 实际重新编译;4 类 14/14,0 失败、0 跳过 |
| MSI / ZIP 内容 | 448 个应用文件 + 一个包名标记 / 449 个文件,成员与 SHA 检查通过 |
普通回归日志 build/kmp-windows-bookmark-stats-complete.log:2 分 8 秒,88 项任务 (17 项执行、71 项 up-to-date);最终 runtime 日志 build/kmp-windows-bookmark-stats-runtime-final.log: 15 秒,21 项任务(4 项执行、17 项 up-to-date)。Android 日志 build/kmp-windows-bookmark-stats-android.log:2 分 8 秒,111 项任务(24 项执行、87 项 up-to-date), 沿用 ignored 的输出隔离 init。没有 Android 真机、R8 或 iOS 原生验证。
包装脚本日志 build/kmp-windows-bookmark-stats-package.log:7 秒,53 项任务 (9 项执行、44 项 up-to-date),复用了内容未变化的应用与分发测试。随后对 windowsDistributionTest 使用单任务 --rerun 再实际执行移动目录检查;日志 build/kmp-windows-bookmark-stats-native-final.log:20 秒,53 项任务(10 项执行、43 项 up-to-date)。 原生 EXE 实际导出/解析的新节集合包含 BOOKMARKS/STATS,报告 backup=ok、status=ok, Java.home 指向含中文和空格的移动目录内 runtime,schema=84;MSI 内容检查再次通过。
本地输出位于 desktop-app/build/compose/binaries/main;ZIP 153091981 字节,MSI 154054900 字节, ZIP SHA/成员复核日志为 build/kmp-windows-bookmark-stats-zip-check.log。输出均被 Git 忽略; 桥进程残留 0,git diff --check 通过。未执行 MSI 安装/升级/卸载、注册表/快捷方式修改、签名、发布或提交。
DRY 复用共享 wire/DAO 与既有事务;KISS 用完整主键和会话计数最大值维持显式合并;SRP 保持 ZIP 所有权、数据库恢复和确认 UI 的边界。完整备份/外部格式/同步、桌面书签与统计交互、全局图像/ 磁盘预算、下载批量操作、完整源生态、Windows 安装/升级/卸载与 S3/签名,以及 iOS Mobile JVM/ JNI/WKWebView/SwiftUI/原生真机门槛仍待,全量 KMP 未完成。
复验命令
./gradlew.bat -PwithDesktopApp "-PmihonCompatibilityDirectory=D:/tmp/kt-compat36" -PmihonCompatibilityJavaVersion=25 :mihon-desktop-compat:test :desktop-app:test :desktop-runtime:test :source-host:test :source-host:installDist :core-source:jvmTest :core-source:compileCommonMainKotlinMetadata :reader-core:jvmTest :reader-core:compileCommonMainKotlinMetadata :core-backup:jvmTest :core-backup:compileCommonMainKotlinMetadata :core-db:compileCommonMainKotlinMetadata
./gradlew.bat -I "build/kmp-backup-validation.init.gradle" :app:compileDebugKotlin :app:testDebugUnitTest --tests "org.skepsun.kototoro.backups.data.BackupIndexCompatTest" --tests "org.skepsun.kototoro.backups.data.KotatsuBackupPayloadCompatTest" --tests "org.skepsun.kototoro.backups.domain.BackupPayloadGuardTest" --tests "org.skepsun.kototoro.backups.domain.BackupRestoreFormatTest"
./desktop-app/package-windows.ps1 -CompatibilityDirectory "D:/tmp/kt-compat36" -JavaVersion 25早期普通回归与包装分别为 2 分 42 秒、2 分 1 秒;同名日志现已由上述复验覆盖,最终证据以本节 验收表和当前日志为准。单独强制原生分发复验时,在 withWindowsDistribution 模式下使用 :desktop-app:windowsDistributionTest --rerun,其余包装输入与任务保持原命令。
55. Windows 产品入口核对:仓库、内置解析器与 Android 视觉复用
日期:2026-10-03。用户反馈 Windows 只有 Mihon/JAR 入口、无法添加 .pb 仓库、内置解析器缺失, 以及界面与 Android 不一致。此前回归证明执行/存储闭环,不证明源生态、仓库安装或最终产品 UI 已迁移。 本节调整后续 Windows 优先顺序,不宣称以下待办已经实现。
| 项目 | 当前证据 | 后续实现边界 |
|---|---|---|
| 源执行 | source-host 仅装配 MihonSourceRuntime;桌面选择器仅接受 JAR | 继续保留 Mihon ABI;为 kototoro-parsers 增加同一 SourceRuntime 契约的适配,不把它伪装成 Mihon 扩展 |
| 仓库 | 桌面无仓库入口;Android ExtensionStoreIndex 已含 protobuf 索引与 resources.jarUrl(field 501) | 抽取中立索引模型/解码与仓库解析规则,提供添加/列表/下载/安装/更新和受应用管理的产物目录 |
| APK 与 JAR | 当前加载器检查文本 AndroidManifest/JVM class,未实现 DEX 转换或 APK 安装 | .pb 是索引,格式不决定产物类型;有 jarUrl 时使用发布的 JAR,只有 APK 时明确不可安装,不能替换后缀冒充 JAR |
| 内置解析器 | 同级 kototoro-parsers 是 kotlin.jvm/java-library;桌面尚未依赖或装配 | 补 ContentLoaderContext 的 HTTP/Cookie/配置/JS/Bitmap/浏览器操作,保持 parser 二进制合约及既有源 ID,不要求重写站点解析器 |
| UI | desktop-app 使用 compose.material/lightColors 与独立固定色,Android 使用 Material3/GlassSurface | 提取主题/字形/形状与纯 Compose 组件,再迁移主导航、卡片、详情和源管理;宽窗口布局可适配,视觉系统应共用 |
| Backdrop | 本地模块为 com.android.library,UPSTREAM.md 明确未取 skikoMain;四处 expect/actual 已压平为 Android 实现 | 恢复与现行 vendored 2.0.0 对应的 commonMain/androidMain/skikoMain/desktopMain,保留本地布局去重与采样补丁;不为此自动升级核心依赖 |
核对上游 Backdrop 构建 及 Skiko shader 实现: 桌面目标实际依赖 skikoMain,RuntimeShader 使用 Skia RuntimeEffect。原“Backdrop 不可移植”表述过宽, 已修正;恢复多平台模块和 GlassSurface 的 Android Context/Hilt/偏好边界后才能在 Windows 使用, 本次没有进行玻璃渲染编译/截图/性能验收,也不改变 iOS SwiftUI 的现行决策。
核对 Keiyoushi 当前索引, resources 同时含 apkUrl/jarUrl。这仅证明该仓库发布 JAR,不证明任意 .pb 仓库都有 JAR,也不证明 全量扩展已通过当前桌面兼容层。具体用户仓库地址待提供后核对;当前可手动下载其 jarUrl 并导入, 由于加载记录仍引用原文件路径,导入后不能随意移动或删除该文件。
下一阶段优先补仓库获取/安装和 kototoro-parsers,使用户能从入口获得来源;随后落实共用视觉系统 与 Backdrop 桌面适配。其余备份/同步与原平台门槛继续保留;不把临时验证外壳当作最终 Windows UI。
56. Windows 仓库添加、JAR 安装更新与应用拥有的产物
日期:2026-10-03,执行 §55 的第一项产品入口修补。ExtensionStoreIndex 的唯一模型进入 core-source/commonMain,保留全部 protobuf 字段编号,Android 留原路径说明并引用共享模型。 共用解码支持 .pb 和新版 JSON 的前缀枚举/数值枚举、字符串 64 位整数及未知字段;不把长编号转为 Double。 沿用统一 serialization 1.11.0,没有更新核心依赖或引入独立索引模型。
桌面侧栏“扩展与仓库”新增地址输入、保存的仓库刷新、扩展/来源/语言搜索、安装与更新。 支持完整 index.pb/index.json、repo.json 或根地址;根地址只在 .pb 返回 HTTP 404 时回退 JSON。 相对外部列表/JAR 地址以实际响应地址解析,支持 gzip,索引上限 16 MiB、JAR 上限 64 MiB, HTTP 整体 30 秒超时,取消关闭 header future/响应体,移除仅由此次请求创建的临时文件。 只有 APK 的条目禁用并显示“仅有 APK”,不执行 APK/DEX 转换或替换 URL 后缀。
下载与新手动导入先复制到应用 extensions 目录,再检查清单、包名、版本、扩展 API、JVM class 与 SHA,发布为不可变 SHA-256.jar。构造来源、重复源检查及偏好记录提交成功后才发布 registry 替换;失败保留原来源,旧调用的 lease 保持旧加载器到调用结束。禁止安装较旧版本。 重启用同一 managed JAR 与持久化 identity 恢复,不需要原导入文件。此前的外部路径记录未迁移, 仍需要保留该文件;旧版本 owned JAR 暂不清理。索引 signingKey 变化被拒绝,但此字段不证明 JAR 签名,SHA 校验仅证明应用下载副本及随后恢复的一致身份。
新增覆盖:共享 wire 三项、registry 替换/旧调用保活两项、仓库运行时六项,以及实际 Compose 仓库添加/安装/更新/另一离线进程恢复一项。UI fixture 使用自建 localhost 服务,下载两次, 重启下载零次,生产站点请求零次;安装后可点击实际来源浏览。已检查 catalog/installed/offline 截图与中文路径关闭后移动,未以静态 UI 或 fake registry 代替执行验证。
首次完整回归暴露取消测试的调度竞态:服务端发送首块不代表客户端已创建 staging 文件, 过早取消允许目录尚未创建。测试改为有界等待 owned partial 文件,再取消阻塞读取, 从而明确验证“下载过程中取消”的清理行为;生产关闭流导致的 IOException 转回 CancellationException。
SRP 分离共享 wire、平台 HTTP/产物所有权与 Session 的安装发布;DRY 复用现行偏好、inspector、 registry 与 SourceRuntime,KISS 保持显式失败/版本规则。内置 kototoro-parsers、共用 Material3 视觉/Backdrop、卸载/仓库删除/自动更新、APK-only 支持、其他生态与全量 KMP/iOS 门槛仍待, 此次不宣称上述工作完成。§55 的“无仓库入口/新导入仍依赖原路径”描述是本次改动前的证据。
最终普通回归 build/kmp-windows-repository-complete.log:2 分 52 秒,88 项任务 (16 项执行、72 项 up-to-date),Windows/shared 443/443、0 失败、0 跳过:core-source 47、 source-host 76、desktop-runtime 44、compat 30、desktop-app 54、reader-core 179、core-backup 13。 Android build/kmp-windows-repository-android.log:4 分 28 秒,111 项任务(22 项执行、89 项 up-to-date), compileDebugKotlin 与 ExtensionRepoServiceTest 16/16、NovelSourceryIndexFixtureTest 4/4 通过。 复验使用 §54 的普通回归命令;Android 测试过滤改为上述两类,保持 ignored 输出隔离 init。
Windows 包装 build/kmp-windows-repository-package.log:1 分 21 秒,53 项任务(15 项执行、38 项 up-to-date)。移动目录的独立 JVM/原生 EXE/实际窗口验证 1/1 通过,status/backup/image/browser=ok, schema=84,Java.home 位于移动后的中文目录;加上普通回归共 444 项。MSI 核对 448 个应用文件 及包名标记,ZIP 449 个成员逐文件 SHA 与应用目录一致。ZIP 153240922 字节,MSI 154202356 字节, ZIP SHA-256 为 3705D9F1ACED98BFEF1117153A2205EF9A648A15523EA06CE506DE8691DB33B9。 没有执行 MSI 安装/升级/卸载、注册表/快捷方式写入、发布、签名或 Git 提交。
首次包装失败为 C 盘临时目录空间不足(剩余约 180 MiB),发生在复制运行时 image 期间。 将 windowsDistributionTest 的 java.io.tmpdir 放到本模块 ignored build/tmp,子进程 TEMP/TMP 也仅指向测试自有目录;不修改系统环境或清理用户数据。复验已通过,最后桥进程残留 0、 git diff --check 通过,catalog/installed/offline 截图位于 desktop-app/build/reports/desktop-smoke。
57. 再漫画浏览失败:补齐桌面 streaming JSON 运行时
日期:2026-10-03。用户导入 Zaimanhua 后点来源,显示 Source operation failed。 使用本机已安装的同一 JAR(只读)和独立 ignored build 数据目录复现:describe 成功,getList 抛出 Mihon runtime ABI failure,根因为 NoClassDefFoundError: kotlinx/serialization/json/okio/OkioStreamsKt。SourceEndpoint 按既有规则将异常折叠为通用错误, 因此此前 UI 提示没有显示缺失的依赖名称。没有读取用户 Cookie/源设置、修改用户数据库或重新导入用户扩展。
在 mihon-desktop-compat 的 Gradle runtime 添加现行统一版本的 serialization-json-okio 1.11.0。 Android 已使用此版本;保持全项目版本与 29 个外部 runtime pin 不变,排除该依赖的传递 Okio, 沿用原 classpath 优先级。职责仍归兼容模块;没有新增 JSON shim、修改扩展 JAR 或改写协议错误为原始异常。 DRY 复用 Android 已有库与平台版本,KISS 用缺失的真实 ABI 依赖修复响应解析。
自建 OfflineSource 在 fetchPopularManga 中通过真实 OkioStreamsKt.decodeFromBufferedSource 解析 Buffer JSON,保持既有返回内容。已有独立进程/UI 测试会经过该路径;移动目录的随包 JVM 新增 source-browse probe,通过生产 Session/SourceProtocol 浏览同一离线来源并验证返回标题。 仓库 UI probe 改为等待浏览内容或显式错误,以免 action 调度前 busy=false 导致过早断言。
针对回归 7/7:MihonDesktopPlatformTest 五项、真实窗口浏览/状态恢复及仓库安装更新/恢复两项。 日志 build/kmp-windows-zaimanhua-focused.log(最终 21 秒;兼容测试复用本次首轮已通过的五项)。 最终包装与移动目录门槛 1/1:build/kmp-windows-zaimanhua-package-final.log(20 秒,53 项任务, 11 项执行、42 项 up-to-date),source-browse.log 报告 DESKTOP_SOURCE_BROWSE_OK=streaming-json。 首次新包包装日志 build/kmp-windows-zaimanhua-package.log 为 59 秒,随后新增随包浏览门槛再验证。
真实来源另用新 image 的 runtime/bin/java 和仅取自 Kototoro.cfg 的生产依赖重放; build/zaimanhua-image-probe.log 报告再漫画 MIHON_524579092615598717、DESCRIBE=OK、BROWSE=OK COUNT=10。 这是一次实际来源的浏览验证,不推断全量来源、详情或阅读兼容率;离线 fixture 无生产请求。 真实来源请求只使用独立默认设置,探针代码/数据/日志均在 ignored build,用户根目录保持不变。
MSI 比对 449 个应用文件及包名标记;ZIP 450 个成员逐文件 SHA 与 image 一致, 新增且只含一份 serialization-json-okio 1.11.0。ZIP 153247038 字节,MSI 154210572 字节, ZIP SHA-256 为 B9B938517C04F0336A276BC19FFE51B17E6483C0D8A6501C26E8073D04BC20F9。 原生运行/窗口关闭与 streaming JSON 浏览通过,桥进程残留 0、git diff --check 通过。 没有 MSI 实际安装、发布、签名、提交或 Android/iOS 重验;此修复仅增加可选桌面运行时依赖。
58. 再漫画仍报错:保留 SDK 客户端拦截器并验证完整浏览路径
日期:2026-10-03。用户反馈 §57 新包仍显示同一错误。只读检查正在运行的 E:/Kototoro, 确认已经包含 serialization-json-okio 1.11.0;沿 DesktopSession/Controller 的生产路径,使用 同一已导入 JAR 和独立 build 数据目录复现 UI_ERROR=Source operation failed、UI_ITEMS=0。 根因为扩展 keiyoushi.source.a.getClient 检查默认客户端必须包含 CloudflareInterceptor, 桌面开启网页验证时替换了此拦截器,触发 IllegalStateException。 §57 的真实来源探针关闭网页验证并直接调用 runtime,只证明 streaming JSON 依赖修复; 没有覆盖生产桌面客户端装配,验收范围不足。复现日志为 build/zaimanhua-session-probe.log。
适配改为保留原 SDK CloudflareInterceptor 实例,在其后加入 DesktopChallengeInterceptor。 后置处理仍按 cf-mitigated 标记进行 GET/HEAD 人工验证并最多重试一次;未解决的挑战,以及 SDK 识别的 403/503 + Server: cloudflare/cloudflare-nginx,关闭响应并抛出 IOException, 避免回到服务器专用的 FlareSolverr/ServerConfig 路径。普通拒绝响应不打开浏览器,POST 不重发。 不改第三方 JAR、原 CookieJar、UA、缓存或线程池,也不创建另一套 NetworkHelper。 新增测试检查原 SDK 拦截器身份及 403/503 保护;离线 fixture 也要求默认客户端保留真实 SDK 实例。
SourceEndpoint 增加可选本地失败观察,SourceRemoteException 携带现有响应 requestId; 保留两个类原单参数 JVM 构造函数,wire 格式和通用错误文本保持不变。Session 记录当前会话 最近 16 项诊断,单项最多 4096 字符、最多六层原因与每层六个栈帧。Controller 根据精确 requestId 显示错误类别、诊断编号及 logs/source-errors.log 路径。日志只包含时间、操作类型、 错误类型和栈帧,不记录请求参数、异常消息、原始网址、Cookie、请求头或响应内容。 写入失败不会掩盖来源错误;测试覆盖有界保留、敏感文本排除及日志目录不可写时的数据保护。
回归还发现操作入队后、IO 协程开始前 busy=false 的短暂空隙,导致仓库 UI 等待过早结束。 Controller 在返回 Job 前发布 busy,用串行操作的待完成计数在 completion 回调中清除状态, 包括尚未开始即被取消的操作。沿用原 scope/gate,不增加请求队列。SRP 将诊断归本地独立组件, DRY 复用协议请求编号和原客户端,KISS 保留固定 SDK 对象身份。
普通回归 build/kmp-windows-zaimanhua-client-complete.log:3 分 16 秒,88 项任务 (20 项执行、68 项 up-to-date);common metadata 与 source-host installDist 一并通过。
| 模块 | 通过测试 |
|---|---|
| core-source | 48 |
| source-host | 76 |
| desktop-runtime | 44 |
| mihon-desktop-compat | 31 |
| desktop-app | 56 |
| reader-core | 179 |
| core-backup | 13 |
| 合计 | 447,0 失败、0 跳过 |
Android 隔离输出编译 build/kmp-windows-zaimanhua-diagnostics-android.log:1 分 29 秒, 50 项任务(11 项执行、39 项 up-to-date)。没有 Android 设备或 iOS 真机验证。
Windows 包装 build/kmp-windows-zaimanhua-client-package.log:1 分 22 秒,53 项任务 (15 项执行、38 项 up-to-date)。移动目录/随包 JVM/原生窗口门槛 1/1,加普通回归共 448 项。 source-browse probe 改为经过生产 Controller/Session/SourceProtocol,验证状态无错误和返回标题, 成功标记为 DESKTOP_SOURCE_BROWSE_OK=controller-streaming-json。运行时报告 status/backup/image/ browser=ok、schema=84,Java.home 与浏览器桥位于移动后的中文测试目录。
再用新 image 的随包 JVM、仅来自 Kototoro.cfg 的生产依赖和同一再漫画 JAR,沿生产 Controller.selectSource 验证,build/zaimanhua-session-fixed.log 报告 UI_ERROR=null、UI_ITEMS=10。 探针仅增加异常类型/栈帧观察,不替换来源逻辑;扩展读取只限已导入 JAR,所有新数据写入独立 ignored build 目录,没有读取用户 Cookie/源设置或修改用户数据库。这是再漫画本次浏览验证, 不能推断详情、阅读、真实网页挑战或全量扩展兼容率。
MSI 比对 449 个应用文件及包名标记;ZIP 450 个成员逐文件 SHA 与 image 一致,只含一份 serialization-json-okio 1.11.0。ZIP 153257258 字节,MSI 154218764 字节,ZIP SHA-256 为 1A6DF9DFABB83D7F41F345AB5C388BCA696D45BC9D67E65F296A1F1735AA475F。 另复制为 Kototoro-Windows-zaimanhua-client-fix-20261003.zip 并验证哈希一致,以区分 §57 旧包。 桥进程残留 0、git diff --check 通过;没有覆盖用户安装目录、执行 MSI 实际安装、发布、签名或 Git 提交。
59. Windows 对齐 Android 平板结构与阅读器交互
日期:2026-10-03。用户明确目标是 Android 版本的平板视图,并指出 Windows 阅读器简陋。 对照 KototoroBottomNav 的横屏导航、AppSettings 的默认 80dp 栏宽、TabletLayout 的 1000dp expanded 阈值、ComposeReaderActivityScaffold 的独立章节/选项面板,完成第一轮布局改造。 同时查阅 Android 官方 Reply 自适应示例 和 Compose Desktop 窗口状态文档。 采用按窗口宽度切换单/双栏和声明式 WindowPlacement,不照搬 Android Activity、Hilt 或平台资源调用。
主界面调整:
- 80dp 主导航仅保留收藏、浏览、历史、更多;扩展与仓库、下载、备份及高级网页工具归更多。 来源列表只属于浏览页,移除常驻 244dp 调试侧栏和每个页面底部的数据路径。
- 本地导入回到浏览页;仓库安装/更新保留仓库页。浏览入口重新调用原 browseOnIo,避免把 收藏/历史条目误当作当前来源列表。既有 UI 回归改为走新的真实导航入口。
- 封面采用 2:3 竖版、156dp 自适应网格;沿用 DesktopCovers 的原请求、缓存与错误重试。
- 1000dp 及以上窗口,详情使用信息栏和独立 LazyColumn 章节栏,章节可搜索/正倒序; 更窄窗口采用横向封面/操作区与页面级章节列表,避免大封面把阅读按钮挤出可见区域。
- 构建直接读取 Android 原有八个 vector 图标,生成随主 JAR 分发的 SVG;只支持本次实际使用的 path 和 group translation,不认识的节点/变换明确失败。没有额外图标库或第二份手写图形。
阅读器调整:
- 独占内容窗口,隐藏主导航、来源栏和主页面标题;深色 ReaderTheme 显式提供内容色, 修复从浅色父主题继承黑色文字导致的对比度问题。
- 顶部作品/章节标题、章节/设置/全屏入口,底部翻页、跨章、页码滑条和常用模式操作。 滑条拖动只改本地目标,完成后通过原 controller.page 跳转,不创建另一套进度写入路径。
- H/收起隐藏工具栏并扩大画布,底部按钮/H 恢复;收起不重取图片或改历史。 连续模式移除常驻缓存统计文字,沿用原可见范围、恢复和滚动条逻辑。
- 同窗口章节侧面板支持搜索、排序、当前章节标记及选择章节;选项侧面板提供明确的 单页/双页/连续和适配选项。开关面板不替换阅读场景,不重建 Session/Controller。
- 章节输入拥有编辑键,面板存在时主阅读翻页快捷键让出;Esc 先关闭面板,再返回详情。 F11/按钮通过生产 WindowState 切换 Fullscreen,退出恢复此前 Floating/Maximized;返回详情也退出。
- 保留原双页/RTL、宽图独页、相机输入、连续滚动、长图分块、自动跨章、图片缓存及历史恢复。 SRP 将导航/详情与阅读侧面板拆为独立组件;DRY 复用 Android 图标和现有 Controller/reader-core, 不新增用户数据库、schema、依赖、并行源执行器或阅读设置体系。
新增 DesktopTabletProbe 使用自有离线阅读扩展、真实 SDK、UI 和数据库,在 1260×850 与 920×620 窗口检查导航栏宽度、详情分栏、阅读器无主导航、收起后画布增长、图片请求数/历史不变、 页码跳转到第 4 页、面板及输入键归属、章节切换和全屏回调/返回恢复。全屏测试验证 UI 回调状态; 生产入口绑定真实 WindowPlacement,但没有单独录制操作系统全屏过渡。 截图位于 desktop-app/build/reports/desktop-smoke/tablet-.png 和 tablet-narrow-.png,已实际查看 宽屏阅读、选项/章节与窄屏详情;截图仅含自有彩色测试页,不代表生产漫画图像质量验收。
首次完整回归发现浏览器探针仍点击旧导航入口,以及长图探针固定 wheel=500 依赖旧侧栏宽度。 改为走更多入口、按真实视口宽度定位自有长图的蓝色中部,再核对像素、请求数和历史。 保留原分块接缝与连续场景检查,不放宽像素断言或跳过失败。
最终普通回归 build/kmp-windows-tablet-complete.log:2 分 32 秒,57 项任务 (11 项执行、46 项 up-to-date)。core-source 48、source-host 76、desktop-runtime 44、 mihon-desktop-compat 31、desktop-app 57、reader-core 179、core-backup 13,共 448 项, 0 失败、0 跳过;共享模块未改动的任务复用当前已通过的结果。最终编译后的分发门槛另 1/1,合计 449。
本地包装 build/kmp-windows-tablet-package-final.log:1 分 1 秒,54 项任务 (18 项执行、36 项 up-to-date)。移动中文目录的随包 JVM、原生窗口关闭、资源和 Controller streaming JSON 浏览通过;MSI 比对 449 个应用文件及包名标记,ZIP 450 个成员逐文件 SHA 与 image 一致。新 image 另使用用户已导入的再漫画 JAR(只读)和独立 build 数据目录,经生产 Controller 验证,build/zaimanhua-tablet-session.log 报告 UI_ERROR=null、UI_ITEMS=10。 该结果只验证本次浏览,没有读取用户 Cookie/源设置或写用户数据库。
交付 Kototoro-Windows-tablet-reader-20261003.zip,153348590 字节;MSI 154321164 字节。 ZIP SHA-256:A9517A9EAC44ABA9B714E0136FBEEEB0C14292B6502D13DB18BE8CDF2DA8B0B5。 桥进程残留 0、git diff --check 通过;没有覆盖用户安装目录、实际 MSI 安装、发布、签名或 Git 提交。
本轮是信息结构与阅读操作的第一轮对齐,尚未完成 Android UI 的完全复用。桌面仍用现有 Material 宿主;Android Material3/主题偏好、Backdrop、书库筛选抽屉、详情浮动预览、阅读书签与完整手势/ 高级选项仍待。仓库 vendored Backdrop 是 Android-only 模块,不能直接接到桌面;§55 所列 upstream KMP 路线仍有效,应保留本地 geometry 修复并单独完成桌面渲染验证。本轮未修改 Android 源码或 Backdrop,也未执行 Android 设备/平板视觉对照、iOS 真机或真实网页挑战验收。
60. Windows 书库筛选抽屉与阅读书签
日期:2026-10-03。继续 §59 的 Android 平板信息结构对齐,补齐收藏/历史的查找与筛选, 以及阅读器的书签保存和跨章节恢复。当前 vendored Backdrop 和 Android Material3 主题未改动。
书库:
- DesktopLibrary.snapshot 在共享 Room 的读事务中取得作品、活跃收藏分类/成员关系和历史进度, 投影为桌面 DTO;Compose 不接触持久化映射,也不重建已移除的 Entity Graph / Work。
- 标题/别名/作者搜索;右侧抽屉提供单分类、多来源、未读/阅读中/已读完组合筛选,以及默认顺序、 标题、最近阅读、进度排序。多个来源取并集,与分类/进度/查询交集;排序以完整 Long id 稳定收尾。
- 搜索和条件仅派生现有快照,不重新请求来源列表或逐字查询数据库;可见封面仍沿用原请求/缓存。 收藏与历史条件在当前 Controller 生命周期内分别保留,空结果可重置;显式刷新重新读取快照。
- 抽屉有可拖动滚动条、关闭按钮、点击遮罩/Esc 关闭,窄窗口可访问分类与来源。已读完按当前 分支的共享历史 percent 判断,不代表所有分支已阅读;默认顺序沿用收藏/历史记录的更新时间。
阅读书签:
- 底部添加/移除书签、B 快捷键及顶部书签侧面板,显示章节、页码、作品进度和当前位置。 面板内编辑仍拥有按键,原 H/F11/Esc、章节、单/双页/连续、相机和长图逻辑保留。
- DesktopLibrary 直接写现有 bookmarks 表,完整保留作品/页面/章节的有符号 64 位编号, 按 Android 的章节+页码位置切换书签,即使来源重建页面 id,也能移除同位置旧记录。 连续滚动保留整数像素偏移;写入前验证页码、偏移、内容身份和存储章节,使用原 writer 事务。
- 重启后重新读取书签;跳转优先匹配 pageId,旧 id 不存在时采用仍有效的保存页码; 缺失章节/越界页明确失败。通过原 readOnIo/loadPagedOnIo 或连续导航恢复,不新增进度写入通道。
- 复用现有 core-backup 书签格式,没有 schema、依赖或 Android 源码变化。SRP 将书库界面、 纯筛选派生和持久化 DTO 分开;DRY 复用原 Controller gate、数据库、阅读布局与缓存。
验证:
- DesktopLibraryTest 新增书签重开、极端 Long 编号、像素偏移、非法位置拒绝、按位置移除、 书签不写历史,以及库快照/删除分类/历史维度检查。
- DesktopLibrarySelectionTest 覆盖来源并集与多维交集、别名/作者/忽略大小写查询、阅读状态和稳定排序。
- DesktopTabletProbe 在 1260×850、920×620 真实 UI 中保存/打开/移除第 4 页书签,并验证跨章返回、 搜索无结果/重置、未读筛选、滚动到分类/来源选择,以及收藏/历史条件隔离。
- DesktopBookmarksProbe 分两个 JVM,使用自有扩展和独立数据根目录。写入连续模式第 4 页非零偏移, 换到下一章后退出;第二个进程禁用图片网络,仍可跨章恢复书签页和偏移、移除书签,图片请求为 0。 此像素恢复测试使用相同窗口尺寸,不保证改变窗口尺寸后的像素偏移等同于相同内容位置。
- 已查看宽、窄窗口截图;截图位于 desktop-app/build/reports/desktop-smoke/tablet*-library*.png 和 tablet*-bookmarks.png,仅使用自有测试作品,未作 Android 平板设备视觉对照。
普通回归 build/kmp-windows-library-complete.log:2 分 47 秒,57 项任务(14 执行、43 up-to-date)。 core-source 48、source-host 76、desktop-runtime 46、mihon-desktop-compat 31、desktop-app 60、 reader-core 179、core-backup 13,共 453 项,0 失败、0 跳过;未改共享模块复用已通过的测试结果。 Windows 分发门槛另 1/1,合计 454。打包 build/kmp-windows-library-package.log:1 分 7 秒, 54 项任务(17 执行、37 up-to-date),移动中文路径随包 JVM、原生窗口关闭、资源和生产 Controller streaming JSON 浏览通过;MSI 比对 449 个应用文件及包名标记,ZIP 450 个成员逐文件 SHA 与 image 一致。
新 image 使用只读的用户已导入再漫画 JAR,沿生产 Session/Controller 在独立 ignored build 数据目录 验证浏览,build/zaimanhua-library-session.log:UI_ERROR=null、UI_ITEMS=10。未读取用户 Cookie/设置 或修改用户数据库;此结果仅覆盖浏览,不推断真实漫画详情、阅读、网页挑战或全部扩展兼容。
交付 Kototoro-Windows-library-bookmarks-20261003.zip,153440654 字节;MSI 154403084 字节。 ZIP SHA-256:57DD7959A4F9FF476D968ACFBC9EFBCF0B2051ABD4B322D9E9559D8F4CBE3CA5。 桥进程残留 0、git diff --check 通过;未覆盖用户安装目录、执行 MSI 实际安装、发布或 Git 提交。
尚待:Android 主题/Material3 与 Backdrop 的桌面适配、详情浮动预览、更多阅读手势/选项。 本轮基础书库抽屉尚未覆盖 Android 标签、追踪状态、下载条件与分类管理;书签尚无缩略图、全局 书签页及批量管理。筛选条件只在当前运行保留。没有声称完成全量 Android UI 复用或整个 KMP 迁移。
61. Tsundoku / Aniyomi 接入、APK 转换与 R8 修复(Windows)
- Tsundoku:与 Mihon 共用 Tachiyomi ABI。
MihonJarInspector按清单键区分tachiyomi.extension/tachiyomi.novelextension/tachiyomi.animeextension(同时出现则AMBIGUOUS_ECOSYSTEM);来源名TSUNDOKU_<id>、类型NOVEL/HENTAI_NOVEL;MihonSourceRuntime.getChapterContent汇总Page.text/fetchPageText(HTML 原样放行,纯文本转义成段落,图片页进images)。 - APK 摄取:
dex-convert的ApkExtensionConverter把 APK 转成带文本清单的 JVM jar;DesktopRepositories对只发布 APK 的条目下载后本机转换(jar 优先,否则 apk);索引里的apkUrl同样按仓库基址解析。 - dex2jar 缺陷修复(
DexRepair.kt):R8 内联平凡构造器后留下new-instance LT; invoke-direct {v}, LS;-><init>(S 为祖先),JVM 校验失败。转换前在 DexFileNode 上把调用改回 T 的构造器并补合成构造器(含跨 dex、整条继承链、寄存器搬运、循环);另开dontSanitizeNames(true)保留 Kotlin 内联类的-impl名。DexJarConverter.VERSION=3,报告含repairedInstantiations。 - Aniyomi:
mihon-desktop-compat内移植 animesource ABI(去掉 Kototoro 请求上下文;client 用平台network.client);AniyomiSourceRuntime(列表/搜索/筛选/详情/剧集/视频流:先getVideoList再 hoster,resolveVideo,preferred优先;SourcePage新增externalAudioTracks);路由mihonRuntime::owns/aniyomiRuntime::owns;来源名ANIYOMI_<id>、类型VIDEO。无浏览器组件时仍用DesktopChallengeInterceptor(challenges=null)拦住 SDK 的 Cloudflare 处理器,避免ServerConfigKt链接错误。 - 真实验证:novelfull/allnovelfull(Tsundoku,AllNovelFull 联网列表→详情→正文通过)、animegg(Aniyomi,联网列表 125→1165 集→5 路流通过);animekhor 列表/详情通过但无流(第三方播放器站点),animetake 需网页验证,animeparadise 站点 API 已变。测试开关:
-PrealApkDirectory、-PrealNovelApk、-PrealAnimeApk、-PrealDirect。 - 回归:desktop-app 63(除已修正默认 APK 的联网用例)、desktop-runtime 57、parser-host 23、dex-convert 14+5、compat 31、source-host 85、core-* 全绿。
- 未完成:Windows 视频播放器(方案:JNA + libmpv,
--wid嵌入 SwingPanel,运行时发现libmpv-2.dll,缺失时回退外部 mpv);Aniyomi 旧版index.min.json仓库格式;DesktopExtensionsPanel的 Aniyomi 提示与 UI 探针;Tsundoku 仓库 jar 是否含同样 R8 缺陷(已下载 allnovelfull-repo.jar 待检);Aniyomi 偏好设置界面;文档 CLAUDE.md/README 补parser-host、dex-convert。
62. Windows 视频播放(libmpv)与旧版仓库索引
desktop-player模块:JNA 绑定 libmpv 客户端 API。MpvPlayer有窗口句柄时wid嵌入(vo=gpu),否则vo/ao=null供测试;事件线程处理 START/FILE_LOADED/END_FILE(错误转为中文提示),采样线程每 200ms 读取位置/时长/暂停/缓冲/ EOF/音量/倍速/轨道。请求头通过http-header-fields(UA 用user-agent)施加于主流与sub-add/audio-add的旁轨,loadfile ... start=起播。MpvLocator运行时查找,commandLine生成外部 mpv 回退参数。- 桌面接入:
DesktopScreen.VIDEO+DesktopVideo(剧集、线路列表、选中线路、起播秒、generation);watchOnIo用getPages取线路,selectVideoStream续播切换,changeVideoEpisode,DesktopVideoOperations每 5 秒去抖写历史(page=秒、pageCount=时长)。UI:SwingPanel内重量级 Canvas 取 HWND,创建/销毁播放器均在后台 线程(避免与 EDT 的窗口消息互锁);窗口重建时从最后位置续播;缺少 libmpv 时给出放置路径、重新检测、复制地址、外部 mpv。 详情页对视频显示“播放”,视频章节不提供图片下载。 - 验证:
MpvPlayerTest(lavfi 测试源:加载/暂停/跳转/倍速/音量/标题;libmpv 自编码 MKV 经本地 HTTP:Referer/UA 到达服务器、起播位置生效、缺头 403 报错);DesktopVideoProbe(真实窗口:Aniyomi 夹具 → 详情 → 播放 → 线路切换续播 → 历史 ≥3 秒 → 重开恢复 → 下一集)。本会话屏幕捕获整窗为黑(锁屏/远程会话),像素断言改为核对 mpv 日志中 VO 窗口尺寸 = 画布 × DPI;可截屏时仍要求测试图案的彩色像素。 - 旧版仓库:
decodeLegacyExtensionIndex支持index.min.json数组(APK-only,extensionLib取版本名前缀); 根地址依次尝试 index.pb → index.json → index.min.json。仓库探针改为滚动到目标卡片后断言。 - 其他:
SourcePage.externalAudioTracks放在参数末尾以保持位置参数兼容;Tsundoku 仓库自带 JAR 抽检无 R8 缺陷,仍优先下载 JAR。 - 未完成:Anime4K/NCNN 超分、DLNA、弹幕;Aniyomi 偏好设置界面;libmpv 随包分发的授权决定;扩展卸载/自动更新。
63. Aniyomi 来源设置
- animesource ABI 的
ConfigurableAnimeSource补上getSourcePreferences()(默认取宿主 Application 的source_<id>), 以及 Aniyomi 的顶层preferenceKey()/sourcePreferences()辅助函数,旧版扩展自行读取同一存储也一致。 MihonNativePreferences.supports(lease, abi)识别ConfigurableAnimeSource;AniyomiSourceRuntime接收 preferenceContext, 实现getPreferences/updatePreference(与 Mihon 同一原生控件桥:选项、多选、开关、文本,监听器拒绝/异常语义不变); 桌面 Session 传入平台 Context,现有“来源设置”界面直接可用。- 验证:
AniyomiPreferencesTest(夹具 ABI:读取控件、修改选项与开关并落盘source_4243,无 Context 时不声明支持); 桌面窗口探针在真实兼容运行时上打开 Aniyomi 夹具扩展的设置、把“首选线路”改为备用线路,重新播放时扩展按设置把备用线路排第一。
64. 超分:视频 Anime4K/FSR 与阅读器 NCNN/Anime4K
- 视频(与 Android 视频增强对应):
MpvEnhancementMode= 关闭 / Anime4K 快速(Android FAST = Mode B)/ Anime4K 质量(QUALITY = Mode A)/ Anime4K 仅修复(Mode C)/ FSR 1.0(锐度按 Android 换算 stops = 2×(1−值),生成带#define SHARPNESS的变体文件)。Anime4K GLSL 由 Gradle 从app/src/main/assets/shaders复制进desktop-player资源(与 Android 同一份,MIT 许可随附),FSR 为 AMD FidelityFX FSR 1.0.2 的 mpv 移植(agyild,MIT,文件头含许可)。MpvPlayer.setShaders写glsl-shaders;播放器控制条“画质增强”菜单,设置存desktop_video偏好、跨视频与重启保持。验证:单测解包/锐度/属性往返;窗口探针在真实 d3d11 管线确认 Anime4K(VL 上采样)与 FSR(RCAS) 着色器编译运行且无[e][vo/gpu]错误。 - 阅读器(与 Android 阅读器超分对应):
desktop-runtime/DesktopSuperResolution调用官方realcugan-ncnn-vulkan(nihui 20220728)与realesrgan-ncnn-vulkan(xinntao v0.2.5.0)——Android 模型目录本就引用后者的 Windows 包。不随应用分发:设置里“下载安装”(GitHub 资产 API 优先,固定 SHA-256 校验后解压到tools/)或“选择压缩包”。模型:RealCUGAN 2x(保守/无/1x/2x/3x 降噪)、Real-ESRGAN 4x 动漫、AnimeVideo v3 2x, 以及 Android 的 Anime4K A/B/C/A+A/B+B/C+A——后者经MpvImageEnhancer用 libmpv 编码模式 +vf=gpu离屏(Vulkan)复用同一套着色器。 输入像素上限与 Android 相同,结果按内容+设置缓存为 PNG(默认 1 GiB LRU),同一时刻只处理一张;失败保留原图并在设置面板显示原因; 切换设置会重新取当前页(命中缓存不重复下载)。本机耗时(120×180):RealCUGAN ≈4.6s、ESRGAN 4x ≈2.5s、AnimeVideo ≈1.0s、Anime4K ≈1.5–2.6s。 - 验证:
DesktopSuperResolutionTest(非固定包拒绝、未安装报错、超限跳过;-PncnnDirectory时三模型真实放大且缓存命中);MpvPlayerTest(六种 Anime4K 图片模式尺寸正确且与无着色器缩放不同);DesktopUpscaleProbe(阅读器 UI:未安装保留原图→安装后 2 倍→ RealCUGAN 降噪→关闭恢复→4 倍→Anime4K B 2 倍 / C 原尺寸→偏好持久化)。
65. 扩展卸载与自动更新、DLNA 投屏、随包分发 libmpv / ncnn
- 扩展管理:“扩展与仓库”新增已安装列表(Mihon/Tsundoku/Aniyomi 扩展与 kototoro/kotatsu/UMA 插件,显示生态、版本、来源数)。 卸载先删安装记录再卸载来源,运行中的调用在旧加载器上完成;
cleanupArtifacts删除不再被记录引用的产物(卸载、更新后与每次启动时, Windows 下被占用的文件留待下次)。“检查更新”读取已保存仓库,取各包最高版本;“全部更新/单个更新”逐个安装,失败不影响其他。 “启动时自动更新”存desktop_extension_updates;启动后后台检查(仓库不可达时静默),开启则自动安装,否则提示有更新。 验证:仓库探针(第三版经检查更新安装、旧产物清理、自动更新开关跨进程、离线启动无错误、卸载后来源/记录/文件移除);解析器探针卸载 UMA 插件。 - DLNA 投屏(对应 Android
video/dlna+ LAN 代理):DlnaDiscovery(各 IPv4 网卡分别发 SSDP M-SEARCH,解析描述含嵌入式设备、URLBase、 相对 controlURL;kototoro.dlna.targets可追加单播目标)、DlnaRenderer(SetAVTransportURI 带 DIDL-Lite、Play/Pause/Stop/Seek、 GetPositionInfo/GetTransportInfo、RenderingControl SetVolume)、DlnaStreamProxy(0.0.0.0 中继:补站点请求头、透传 Range、HLS 主/子播放列表与URI="…"(密钥、map)改写经中继,附 DLNA 传输头)。控制器持有DesktopCast(切集/返回即结束),投屏时本机暂停,结束后从渲染器位置续播; 播放器顶栏“投屏”对话框搜索设备,投屏中控制条换成远端进度/暂停/±10s/结束。验证:DlnaTest(假渲染器与要求 Referer 的假站点); 窗口探针投屏到假渲染器(中继 200、约 4.9MB、动作序列、本机暂停、结束后从 12s 继续)。本次窗口探针截屏可用,视频区实测彩色像素。 - 随包分发:
-PwithWindowsDistribution时prepareBundledThirdParty按固定版本与 SHA-256 取得 libmpv(zhongfly/mpv-winbuild LGPL 版 2026-10-03,bsdtar 解 7z)与 realcugan/realesrgan ncnn-vulkan 官方包(可用-PlibmpvDirectory/-PncnnDirectory提供本地副本,否则 GitHub 资产 API 下载并缓存于根build/third-party-downloads),放入resources/mpv、resources/upscale/<tool>(去掉示例媒体),写THIRD_PARTY_PLAYBACK.md许可与来源说明。运行时优先使用随包程序。--check-runtime新增播放器(lavfi 测试流)与两种 ncnn 超分自检;windowsDistributionTest断言均来自 resources。安装包体积约增加 200 MB(libmpv 101 MB、ncnn 程序与模型 102 MB)。
66. Windows 对齐 Android 截图风格与详情浮动预览
日期:2026-10-04。用户要求继续推进 Windows,并明确优先让整体视觉向仓库中的 Android 手机版 截图靠齐。实际查看 metadata/en-US/images/phoneScreenshots/1-3.png,同时对照 Android InterfaceStyleTokens、TabletLayout、设计令牌及主题实现;参考官方 Reply 的约束驱动自适应布局。
DesktopTheme集中映射桌面颜色、排版和形状:柔和浅色画布、蓝色强调、主题派生顶部色调、 28/36sp 一级标题、胶囊搜索框及按钮。所有原 Accent/Canvas/Ink/Muted 使用当前主题角色, 深色不再继承硬编码浅色前景。没有增加依赖或改写 Android 主题。- 80dp 导航槽保留,以圆角浮动侧栏表达 Android 浮动导航;收藏图标直接使用 Android 的心形 vector,搜索图标仍走原构建转换。收藏/历史标题与搜索/筛选同排,书架保留 2:3 封面网格。
- “更多 → 外观”提供跟随系统、浅色、深色。使用现有偏好 store 的
desktop_appearance/theme; 写入成功后才提交 UI 状态,未知值回退系统模式,没有新增设置数据库或 schema。 - 阅读器保留独立深色画布,控制组使用圆角浮动表面,强调色改为蓝色;不更换 reader-core、 图片请求、阅读进度或相机控制路径。
- 从浏览/收藏/历史打开详情时记录原列表页面。1000dp 及以上窗口显示 380dp 右侧预览,窄窗口 使用完整详情;完整详情按钮只改变呈现,不再抓取详情。两种呈现直接复用原详情/章节组件。 原列表保持 composition,关闭、Esc 或宽屏遮罩返回原页,保留搜索草稿、筛选及网格位置; 收藏/历史关闭时重新读取库快照以反映阅读与收藏变化。阅读后返回仍知道详情来自哪个列表。
- 详情柔化背景复用封面缓存,封面解码由同一 Compose helper 提供;仅模糊图片,不模糊或复制 控件背景。失败与无封面时回退稳定主题画布,保留原封面重试行为。
验证增加真实 Windows JVM/UI 的浅色→深色→离线重启→浅色探针,核对画布像素、封面实际加载、 背景渲染、偏好持久化与零重复封面请求。宽/窄平板探针覆盖预览、完整详情、Esc/遮罩/关闭、 收藏与历史来源、搜索草稿及 37 项书库滚动后的原位置恢复,并继续原阅读器/书签/进度检查。 图片探针调整为明确验证预览与原列表同时存在的两个封面节点;未放宽封面请求计数或离线断言。 截图位于 desktop-app/build/reports/desktop-smoke/appearance-*.png 与 tablet*.png,使用自有夹具。
完整 :desktop-app:test :desktop-runtime:test 回归通过:127 项中 121 项执行成功、6 项可选外部 环境测试跳过,0 失败,日志 build/windows-android-style-regression.log。宽/窄截图及浅/深色截图 已实际查看;没有 Android 真机视觉对照。本轮复用原 Controller、详情组件、图标、偏好和封面缓存, 没有新增依赖、schema 或并行业务路径,遵循 KISS/DRY/SRP。
原默认 app image 被正在运行的旧窗口占用,常规打包失败;保留该窗口,使用 ignored build 中的 init script 将 desktop-app 输出隔离至 desktop-app/build/windows-android-style。独立输出打包成功, 分发门槛 1/1:移动中文目录后从随包 JVM 启动,schema/图片/备份/浏览器/播放器/超分自检及原生 窗口关闭全部通过。日志 build/windows-android-style-package-isolated.log,耗时 1 分 29 秒。 ZIP 520 个文件逐项核对大小与 SHA-256,与通过分发检查的 image 一致;便携包为 Kototoro-Windows-Android-style-20261004.zip,292852084 字节,SHA-256: A8AFD9DFF4C0B54E38A1E8B6A89AF8AFF893226A6AD73CAB5B05E4D40C7A0776。 包含既有扩展仓库修复与随包播放器/超分工具;未执行 MSI 安装、发布、签名或 Git 提交。
仍待:Android Material3 Expressive 组件与主题偏好的直接共享、Backdrop 桌面渲染、完整动态背景 与玻璃折射、更多书架信息/标签/分组及高级阅读选项。本轮不声称完成 Android 全量 UI 或 KMP 迁移。
67. Android 平板 UI 直接共享(2026-10-04)
按用户确认的方向,新增 core-ui Android/JVM Compose Multiplatform 模块,app 与 desktop-app 共同消费从 Android 提取的 UI。共享代码不依赖应用、Context、Hilt、文件系统、平台 parser 或仓库; 平台适配器提供显示值、图片 Painter/封面渲染与操作回调。没有增加 iOS Compose UI 目标。
| 共享组件 | Android 保留的适配 | Windows 接入 |
|---|---|---|
| TabletNavigationRail | 原图标动画、徽标、重选、标题/继续阅读和系统 inset | 收藏/浏览/历史/更多动作及原 Android 图标 |
| TabletPreviewContent | Coil、源请求上下文、本地化/HTML/日期、玻璃或 Material 表面 | SourceContent、现有封面缓存及 Controller 回调 |
| TabletDetailsPanes | 原详情内容、章节面板、dock 与手机模式 | 原详情内层与章节列表,使用同一等宽双栏/16dp 间距 |
| TabletLayoutRules | 平板模式偏好与设备配置 | 窗口根约束提供实际完整宽度,共用 600/1000dp 分类及预览宽度 |
| 字体/形状/尺寸令牌 | 字体加载、风格偏好、颜色解析 | Material3 宿主,未迁移的 Material 2 屏幕映射同一语义角色 |
共享预览保留原模糊封面头部、标签/评分/状态、阅读/详情/收藏、展开简介及主分支最新/最初章节。 短窗口让头部与正文一起滚动,避免操作区不可达。原章节分支和升降序规则抽成泛型回调,避免两套策略; 阅读操作仍走 Controller,继续进度语义不变。Windows 忙碌时禁用操作,已收藏时禁止重复收藏。 共享 Kotlin 顶层文件采用独立 facade 名称,避免与保留 Android 适配器的同包文件产生重复 JVM 类。 新增桌面 Material3 依赖是共享既有组件所需;未升级 Kotlin/Compose 插件或改动数据库 schema。 组件负责呈现,平台负责数据/图片与执行操作,遵循 DRY/SRP,未复制仓库或新建业务状态机。
验证结果:
:core-ui:jvmTest :desktop-app:test成功:共 72 项,68 项通过、4 项可选环境测试跳过,0 失败。 日志build/shared-tablet-desktop-regression.log,完整 XML 保存在desktop-app/build/reports/shared-tablet-full-regression/xml。- 共享章节测试覆盖空列表、短主分支、多翻译、升降序、无编号日期和未知分支。 真实 Compose 预览在 380×640/380×300 验证封面头部高度、全部操作可滚动访问、章节回调及禁用状态; 既有来源、收藏、历史、下载、阅读与离线重启回归继续通过。
- 完整窗口宽度修正后,平板/共享 UI 定向验证再次成功:920、999、1000、1260dp 窗口,预览/完整详情、 搜索草稿、筛选、37 项网格原位置及阅读器/书签恢复;日志
build/shared-tablet-window-boundaries.log。 - Android
:app:compileDebugKotlin :app:checkDebugDuplicateClasses成功,5 分 45 秒; 日志build/shared-tablet-android-compile.log。已查看 Windows 浅/深色预览及详情双栏截图。 初次探针按新预览更新封面数量检查,并限制测试子进程堆大小;请求数、离线和位置断言未放宽。 - 深色截图发现 Material 2 表面内的 Material3 标题/图标默认前景对比不足。原 Android 的 Material 预览表面进一步提取为
TabletPreviewSurface,两端共用颜色/圆角/阴影与内容色。浅/深色与高/矮窗口 再次验证全部操作,并新增深色章节标题前景的像素断言;外观离线重启、四种窗口宽度、共享章节测试和 Android 编译/重复类一并再次通过。日志build/shared-tablet-final-surface-validation.log,1 分 22 秒。 - 隔离输出
desktop-app/build/windows-shared-tablet打包成功,分发检查 1/1:移至中文目录,从随包 JVM 启动,schema/图片/备份/浏览器/播放器/超分自检及原生窗口关闭通过。 最终日志build/windows-shared-tablet-package-final.log,耗时 1 分 8 秒。 - ZIP 524 个文件逐项核对大小及 SHA-256,与通过检查的 image 一致。便携包
Kototoro-Windows-Shared-Tablet-20261004.zip,297814347 字节,SHA-256:07FE8457FD8A8C7FDC3E045D9111D249465BE36E8723BB07A4FFF88FB82F470B。
仍待提取书库卡片、源管理屏幕、详情内层及阅读器控制栏。颜色/主题偏好、Backdrop 和动态背景仍由 平台适配;本轮不声称全部 Android UI 已共享。未执行 Git 提交、发布或安装。
68. 最新平板参考与进一步共享 UI(2026-10-04)
用户提供的订阅、浏览、历史、主页和收藏五张截图替代仓库的过时截图。主要视觉依据是窄侧栏、 顶部通栏搜索、紧凑封面网格、封面内渐变标题和徽标,以及主页推荐/历史/更新与快捷入口分区。 原图保存在 ignored 的 build/latest-tablet-reference/,不会进入 Git。颜色继续由主题解析。
本轮继续从现有 Android 组件提取实现,Android 与 Windows 都消费 core-ui,而不是另写桌面外观:
| 共享实现 | Android 适配 | Windows 适配 |
|---|---|---|
| TabletPosterCover、标题遮罩、书脊、封面边框顺序和尺寸规则 | Coil/快照缓存、共享转场坐标、选择态、四角徽标与进度插槽 | 既有封面缓存/详情动作、语言标签、紧凑网格 |
| TabletSourceTile 与 SourceQuickAccessMetrics | 实际源图标、本地化、置顶/空源徽标、TV 焦点和长按 | 来源选择/搜索、首字图标回退、生态/语言/内容类型说明 |
| SharedLiquidGlassSurface、调参模型、镜片限界和反馈规则 | Hilt/AppSettings、AMOLED/风格/窗口可用性及 Surface 回退 | 自有风格偏好、独立画布 Backdrop、搜索表面和 Material 回退 |
| Backdrop 2.0.0 commonMain/androidMain/skikoMain | 原平台支持检查及 Android actual | 发布版本原有 Skiko actual、RuntimeEffect/RuntimeShader |
Backdrop 的四处 expect/actual 已从相同 2.0.0 发布源码恢复,未升级上游版本;两处本地性能修补 原位保留。恢复的源码逐字节核对,哈希与位置记录在 backdrop/UPSTREAM.md。桌面默认 Material 3, 可在更多页切换 iOS 玻璃并持久化;只捕获控件绘制前的独立画布,避免玻璃采样自身。 共享模块不读取 Context/Hilt/文件/仓库,不接管平台业务状态;遵循 DRY/SRP/KISS。
仍待共享的是整页主页/订阅、主栏操作与完整筛选、详情内层、完整书库徽标/进度和阅读器控制; Windows 的实际源 favicon 和动态作品背景也仍由后续平台适配推进。没有声称全量 Android UI 已迁移, 没有复制业务 ViewModel、修改 schema、提交或发布。
验证已通过(build/shared-components-fixed-regression.log,6 分 18 秒):
:core-ui:jvmTest5/5;完整:desktop-app:test70 项中 66 项通过、4 项可选外部环境测试跳过。- Android
:app:compileDebugKotlin :app:checkDebugDuplicateClasses通过。 - Android 既有 GlassTuningTest、GlassSurfacePolicyTest、SourceQuickAccessMetricsTest 共 34 项通过。
- 共 109 项,105 项通过、4 项跳过,0 失败。桌面完整 XML 已保存在
desktop-app/build/reports/shared-components-full-regression/xml。 - 新增测试验证共用源卡片点击/长按/禁用、两种主题标题在封面范围内,以及 Skiko 模糊对条纹背景 的像素变化。既有 UI 探针继续覆盖真实扩展、收藏、预览、阅读、离线封面与重启;风格切换及保存 也进入真实重启检查。首轮发现的来源类型说明缺失和 import 清理漏掉 getValue 已修复,未降低断言。
- 实际查看了
shared-components-light.png、parsers-write-sources.png和appearance-write-light-library.png;均为自有夹具。没有宣称 Android 真机视觉对照或全源兼容验证。
69. 共享卡片徽标与阅读进度(2026-10-04)
继续 §68 的“完整书库徽标/进度”。Android KototoroContentCard 中与平台无关的绘制提取到 core-ui 的 SharedContentCardBadges.kt(同包 list.ui.compose,独立 facade 名称):
| 共享实现 | Android 保留的适配 | Windows 接入 |
|---|---|---|
ContentCardBadgeMetrics/contentCardBadgeMetricsFor | 原调用方不变(同 FQN 迁移) | 按网格单元宽度缩放 |
ContentCardBadgePill + ContentCardBadgeTone(中性/仅计数/仅 NSFW)+ ContentCardBadgeText | 徽标集合、tracker/来源图标、收藏/本地/置顶图标、NSFW 文案与内联颜色 | 语言徽标改用共享胶囊,与 Android 一样抬高到标题遮罩之上 |
ContentCardBottomProgressBar(percent, completed) | 原 ReadingProgress 重载委托,保留 isValid 语义 | 书库/历史使用 DesktopLibraryEntry.progressPercent,完成判定复用 ReadingProgress.isCompleted |
ContentCardReadingProgressRing | 原环形指示器委托,标签格式与 ic_check 由 Android 提供 | 暂未接入(桌面尚无进度样式偏好) |
rememberCoverRimBorderBrush(isIosStyle, isDark) | 默认 isSystemInDarkTheme(),行为不变 | 使用桌面自有明暗偏好,替换原手写渐变 |
core-ui 仍不依赖 core-domain:共享组件只接收 percent/completed/label 等基础值。 独立 NSFW 徽标保留 iOS 描边,角标中“仅 NSFW”胶囊无描边,与原实现一致。 桌面未读/新章节计数尚无数据源(Android 来自追踪表),本轮未伪造计数,留待追踪接入。
验证(日志 build/card-badges-*.log):
:core-ui:jvmTest :desktop-app:test76 项:72 项通过、4 项可选环境测试跳过、0 失败(4 分 10 秒)。- 新增
Android card badges and progress bar ...像素测试:Material/iOS 两种风格下,半进度用主题主色、 未读部分为暗轨道、完成为 #34C759、0 进度不绘制,计数胶囊分别为主色 / #FF3B30。 - Android
:app:compileDebugKotlin :app:checkDebugDuplicateClasses通过(4 分 29 秒)。 - 实际查看了
cover-read-history.png与appearance-write-dark-library.png:历史卡片底部出现完成态 绿色进度条,语言徽标为共享胶囊。未重新打包,未提交。
仍待共享:主栏顶栏/快速筛选 chip 行、主页与订阅整页、详情内层、阅读器控制栏。
70. 详情章节分组:分支与卷(2026-10-04)
用户反馈 Windows 详情页章节无法识别分组、直接平铺。原因:桌面 ChapterList 只把 content.chapters 平铺并搜索,未使用 SourceChapter 已有的 branch/volume/scanlator;Android 的分组规则散落在 Content.getPreferredBranch、ChaptersPagesViewModel.quickFilter、ChaptersMapper.withVolumeHeaders 与 ChaptersScreen 的私有标题中,未共享。
本轮提取到 core-ui 的 core.ui.chapters(泛型,不依赖平台章节模型):
| 共享实现 | Android 委托 | Windows 接入 |
|---|---|---|
resolvePreferredChapterBranch:历史章节分支 → 唯一分支 → 语言匹配(最大)→ 最大分支 | getPreferredBranch 仅提供 LocaleListCompat 语言列表与日志 | 打开详情时按历史进度与 JVM 显示语言选择,存入 DesktopAppState.chapterBranch |
chapterBranchOptions(>1 分支才出现,比较器异常时保持原序)、chaptersOfBranch(失效分支回落最大分支) | 分支 chip 用 LocaleStringComparator | Collator 排序;无历史时“开始阅读”从选中分支第一章开始 |
withVolumeSections/shouldShowVolumeHeaders:卷号变化或新组名插入标题 | 非 EPUB 路径委托,文案仍用 volume_/volume_unknown | “第 N 卷”/“未知卷”与 Android 中文资源一致 |
ChapterSectionHeader、ChapterBranchChips、chapterBranchChipLabel(“分支 · 数量”) | TV 焦点作为外部 modifier;折叠图标经 rememberVectorPainter | 双栏与单栏详情都显示分支 chip 与卷标题 |
桌面顺序与 Android 一致:选分支 → 正/倒序 → 搜索 → 在剩余结果上插入卷标题。阅读器内上一/下一章 原本已按分支导航(SourceChapterNavigation),未改。EPUB 卷分组、合并重复章节、隐藏已读等 Android 专属选项未迁移。
验证(日志 build/chapter-groups-*.log):
- 新增
ChapterGroupingTest4 项(首选分支优先级、选项/回落、比较器异常、卷与组名标题),以及桌面details chapters are grouped by branch and volume like Android(真实desktopChapterList、倒序、搜索保留 卷标题、失效分支回落、单分支不显示 chip、点击 chip 切换分支与标题)。 - Android
:app:compileDebugKotlin通过(checkDebugDuplicateClasses为 UP-TO-DATE),既有ChaptersMapperVolumeHeaderTest2/2。 :core-ui:jvmTest :desktop-app:test81 项:77 通过、4 项可选环境测试跳过、0 失败(4 分 2 秒)。- 未用真实多分支来源做端到端检查,未打包、未提交。
71. 共享顶栏标题、搜索胶囊与分类/筛选栏(2026-10-04)
KototoroTopBar 与玻璃表面、菜单、偏好深度耦合,未整体迁移;平台无关部分提取到 core-ui 的 core.ui.topbar/TabletTopBar.kt,胶囊背景经 RailSurface 插槽由宿主提供:
| 共享实现 | Android 委托 | Windows 接入 |
|---|---|---|
TopBarTitleBlock(标题/副标题) | 展开与紧凑两处标题 | 收藏/历史页标题 |
TopBarSearchPillContent | 展开搜索胶囊内容,TV 焦点作为外部 modifier | 未用(桌面保留可直接输入的搜索框) |
TopBarTabsRail + TopBarTabItem(居中滚动、下划线指示、pager 位置插值) | CompactTopBarTabsRail 委托;CompactTopBarTabItem 改为 typealias | 收藏分类从筛选抽屉单选改为顶部分类栏(全部 + 分类),与最新平板截图一致 |
TopBarFilterRail<T>(选中项可见、远处图标延迟加载标志) | CompactTopBarFilterRail 委托,来源图标仍由 Android 绘制 | 暂未接入(桌面快速筛选待迁移) |
compactRailEdgeFade、EnsureRailItemFullyVisible | 内联分类栏继续使用 | 随共享栏使用 |
发现并修复一个自持重组循环:筛选栏以 remember(listState.layoutInfo) 计算可见区间,每次测量都会写入 新的 layoutInfo(neverEqual),导致重组→测量→重组永不停止;桌面 UI 测试因此等不到空闲而挂起。 改为 derivedStateOf,只在区间变化时重组。Android 筛选栏委托共享实现,同样获得修复(Android 侧未做 gfxinfo 帧数对比)。新测试加 60 秒超时,避免再次挂起整个构建。
验证(日志 build/topbar-*.log):
- 新增
Android top bar rails select tabs and filters on desktop:标题/副标题、两条栏的胶囊插槽、点击标签、 远处标签被滚动到可见、筛选项切换与图标插槽。 DesktopTabletProbe改为关闭抽屉后点选顶部分类标签,再在抽屉中选来源,筛选计数仍为 2。- Android
:app:compileDebugKotlin通过;:core-ui:jvmTest :desktop-app:test82 项:78 通过、4 项跳过、 0 失败(4 分 5 秒)。已查看tablet-library.png:标题、搜索胶囊与带下划线的分类栏。未打包、未提交。
72. 内容源类型 / 内容类型过滤与 Space 现状(2026-10-04)
用户要求:所有页面都应有 Android 的内容源类型过滤;内容类型过滤取决于 Space 是否开启。核查结果:
- Android 规则(
KototoroApp):源类型过滤 = 页面支持 && 设置开启;内容类型过滤 = 页面支持 && 设置开启 &&!spaceUiState.switcherEnabled(开启 Space 切换器后由 Space 决定内容范围,类型筛选隐藏)。 SourceTag枚举与匹配已在core-domain,但 Windows 未使用;7 个 Android 页面各自重复同一段多选切换。- Space:Android
space/37 个文件 + 其余引用,全部在app;仅实体/DAO(core-db7 个文件)共享。 domain 接口/模型基本无平台依赖,data 实现依赖 Hilt/AppSettings,SpaceSwitcher等 UI 依赖 Android。 Windows 未实现、未共享 Space。
本轮实现:
| 位置 | 内容 |
|---|---|
core-domain SourceTag | toggle(空→清空、已选→移除、否则加入)、menuOrder(已选在前)、accepts(OR,未知来源不匹配)、matchesOrigin(matches 委托,行为不变) |
| Android | 6 处页面切换与 SourceTagDropdown 排序改为委托(收藏页的 clear/set 变体保留) |
| Windows | DesktopSourceFilter:Kototoro/Kotatsu/UMA→内置(与 Android 插件归 NATIVE 一致)、Mihon、Aniyomi、Tsundoku;内容类型复用 BrowseGroupTab.matchesContentType;无对应已装来源的选项禁用 |
| Windows 页面 | 浏览来源网格与侧栏、收藏、历史各自持有过滤(与 Android 按页一致);右侧胶囊放内容类型菜单与源类型菜单,单选时显示该源类型图标 |
| 图标 | 桌面图标转换支持 pivot/scale 与 evenOdd,复用 Android ic_source_*、ic_filter_menu、ic_check |
Windows 只列出本机生态可能满足的 4 种源类型(Legado/TVBox/IReader/Cloudstream/LNReader 在 Windows 永远为空)。 因 Windows 尚无 Space,内容类型过滤始终显示;接入 Space 后按 Android 规则在切换器开启时隐藏。 收藏/历史中来自未安装来源的作品无法判定源类型,启用源类型过滤时不显示。
验证(日志 build/source-filter-*.log):SourceTagTest 新增 3 项;新增 DesktopSourceFilterTest 3 项(生态映射、 可用选项、OR 组合、书库按已装生态过滤、菜单交互与禁用项);Android :app:compileDebugKotlin 通过; :core-domain:jvmTest 119、:core-ui:jvmTest 9、:desktop-app:test 76(4 跳过)全部通过(4 分 6 秒)。 已查看 parsers-write-sources.png。未打包、未提交。
Space 后续建议:先把 domain 模型/接口与 SpaceContentPolicy 移到 core-domain,基于 core-db 实现平台无关仓库, 偏好经平台适配;再抽 SpaceSwitcher 去除 Android 依赖后共享。
73. 主页与订阅页(2026-10-04)
Windows 原先没有主页、订阅页,也没有新章节追踪。本轮按“数据规则下沉 + 展示组件共享”实现:
数据层(core-domain,Android 委托)
tracker/domain/TrackRules.kt:compareTrackedChapters(空追踪/无新章/最后章节消失/有新章四种情况)、afterSuccessfulCheck/afterFailedCheck(追踪行更新)、trackedLastChapterDate、trackLogChapters。CheckNewChaptersUseCase.compare与TrackingRepository.mergeWith/日志拼接改为委托。tracker/domain/feed/FeedSnapshotAssembler:AndroidFeedSnapshotStore.buildSnapshot/observe整体下沉, 来源分组/来源归属由平台注入(Android 用SourceGroupManager,Windows 用已装生态与内容类型)。- Windows
desktop-runtime/DesktopTracker:在共享tracks/track_logs上实现 Android 默认范围(开启追踪的收藏分类), 分支选择同 Android(历史章节 → 上次最后章节 → 首选分支),拉取详情以追踪作品 id 保存(copy(id = ownerId)), 任何异常记为失败而不中断整批;markRead清计数与未读日志。Windows 新建默认收藏分类改为track = true(与 Android 新建分类一致);已有库在订阅页提示“全部开启”。
共享展示(core-ui,Android 委托)
| 组件 | Android 保留 |
|---|---|
core.ui.feed.FeedTimelineCard(时间线、节点、日期标签、继续阅读按钮) | Coil 封面、共享元素、NSFW 徽标、快速滚动避让 |
core.ui.feed.UpdatedContentCarousel<T> + 轮播几何(焦点宽度、距离衰减、倾斜形状) | 入场动画(itemWrapper)、Coil、角标、共享元素 |
core.ui.home.HomeSectionHeader、HomeQuickActionsGrid(含配色规则)、HomeListRailRow | TV 焦点、iOS 玻璃/作品背景配色、封面角标/进度 |
HomeHeroText、HomeBadge、HomeHeroArtworkScrim、HeroPagerIndicator(移入 core-ui 同包) | 多种 hero 版式、全景背景、自动轮播 |
buildHomeHeroEntries<C>、homeBalancedGridColumns、homeHeroCardWidth、homeCountLabel | 原内部函数委托(测试不变) |
Windows 页面:导航按 Android 顺序加入“主页”“订阅”,订阅图标显示有更新作品数。主页:hero(继续阅读/历史/更新)、 历史栏、更新列表栏、右侧快捷入口(收藏/历史/订阅/下载/随机/拓展/备份/设置);订阅页:检查更新、更新内容轮播、 FeedDeriver 派生的时间线。两页都有内容源类型/内容类型过滤;从两页打开作品时以浮动预览显示并在关闭后刷新。
未迁移:推荐(依赖 Android 推荐生成)、主页其他 hero 版式与全景背景、订阅页分类 chip 与“全部更新”页、阅读时自动调整 计数(Android CheckNewChaptersUseCase(manga, chapterId))、后台定时检查与通知。日期分组标签为 Windows 本地实现 (Android 的 DateTimeAgo 依赖资源)。Windows 启动页仍为“浏览”。
验证(日志 build/home-feed-*.log):
- 新增
TrackRulesTest3 项、HomeRulesTest3 项、DesktopTrackerTest2 项(真实 SQLite:建立基线、发现新章节、 写日志、订阅快照、失败保留计数、标记已读、关闭/开启追踪、按阅读分支计数)。 - 平板探针(四种窗口宽度)新增:订阅页检查更新、模拟上次只见第一章后发现新章节、轮播/时间线/导航徽标、 主页 hero/历史/更新/快捷入口、从更新打开作品后计数清零、快捷入口跳转。
- Android
:app:compileDebugKotlin通过;相关 Android 单元测试 13 项通过。 :core-domain:jvmTest122、:core-ui:jvmTest12、:desktop-runtime:test63(2 跳过)、:desktop-app:test76(4 跳过), 0 失败(5 分 12 秒)。已查看tablet-home.png、tablet-feed.png(夹具无封面图,显示首字母占位)。未打包、未提交。
74. 推荐(2026-10-04)
Android 推荐由 SuggestionsWorker 每 6 小时生成:最近 20 条历史 + 20 部收藏为种子,取白名单 + 最常见 10 个标签; 每个来源按优先排序(UPDATED/NEWEST/POPULARITY/RATING)并用首个匹配标签筛选取列表,空则回退无筛选;清洗后每源最多 20 条;按标签相关度排序、去重、优先来源与每源 12 条均衡,最多 160 条,名次编码进 relevance 存入共享 suggestions 表。
core-domain(suggestions/domain,Android 委托):SourceBalancedSelector、SuggestionSourceCollector (原样迁移,去掉 Android 扩展依赖)、SuggestionRules(suggestionSeedTags/mostFrequent、suggestionRelevance、 pickSuggestionSortOrder、pickSuggestionTag、cleanSuggestionList、rankSuggestions、SuggestionTagBlacklist、 SuggestionLimits)。core-domain 带 iOS 目标而 almostEquals 只在 JVM/Android,故模糊匹配以 SuggestionTagMatcher 注入(两端都传 almostEquals(…, 0.4))。Android SuggestionsWorker 的种子、选序、选标签、清洗、排名改为委托, TagsBlacklist 内部用共享实现;通知、调度、设置读取保留在 Android。
行为差异:无标签作品的相关度由 Android 的 NaN(会被排到最前)改为 0;种子标签并列时按首次出现排序 (Android 原 ArrayMap 按哈希序)。两者都在共享实现中,Android 一并采用。
Windows:desktop-runtime/DesktopSuggestions 用已安装来源与共享规则生成;DesktopLibrary.replaceSuggestions 单事务整体替换(同 Android SuggestionRepository.replace),已在库的作品只建立关联、不用列表数据覆盖详情, 身份冲突跳过。主页新增“推荐”栏(空时“生成推荐”,标题旁刷新)与 hero 推荐页,并受页面过滤器约束。 未做:定时后台生成与通知、推荐设置页(排除来源/标签黑白名单/排除 NSFW 目前用默认值)、推荐列表页。
验证(日志 build/suggest-*.log):新增 SuggestionRulesTest 5 项、DesktopSuggestionsTest 2 项(假来源运行时 + 真实 SQLite:种子标签发给来源、无标签来源不筛选、失败来源跳过、空标题剔除、相关度排序、收藏详情不被覆盖、排除来源/NSFW、 每源均衡);平板探针四种宽度新增主页“生成推荐”。Android 编译与原有 SourceBalancedSelectorTest/ SuggestionSourceCollectorTest 9 项通过;:core-domain:compileCommonMainKotlinMetadata 通过。 :core-domain:jvmTest 127、:core-ui:jvmTest 12、:desktop-runtime:test 65(2 跳过)、:desktop-app:test 76(4 跳过),0 失败。 未打包、未提交。
75. 推荐/更新检查设置与后台定时运行(2026-10-04)
共享规则(core-domain,Android 委托)
trackerCheckIntervalHours(trackCount, batchSize, frequency):AndroidTrackWorker.Scheduler原公式 (18 / 批次数整除后除以频率、四舍五入、至少 2 小时;频率 ≤0 为手动),Android 调度改为调用。SUGGESTIONS_INTERVAL_HOURS = 6(Android 推荐周期任务改用)、parseSuggestionTags(AndroidAppSettings的排除/偏好标签解析改用)。
Windows
DesktopSuggestionSettings/DesktopTrackerSettings:沿用 Android 键名与默认值(suggestions默认关、suggestions_exclude_nsfw、suggestions_exclude_tags/_preferred_tags逗号文本、_preferred_sources/_excluded_sources集合、tracker_enabled默认开、tracker_freq默认 1),存于desktop_background偏好。- 更多页新增“推荐”(启用、排除成人内容、排除/偏好标签、按来源优先/排除)与“更新检查”(启用、Android 的 手动/低频/默认/高频 = -1/0.4/1/2)。
- 后台:
dueBackgroundTasks判断到期(Windows 每次检查全部追踪,故完整检查周期 = 18 h / 频率;推荐每 6 h),startBackgroundWork在应用打开期间每 15 分钟评估一次(启动后 1 分钟首查),不进入 UI 动作队列;手动与后台 同一任务经同一互斥锁串行。上次运行时间持久化。只有正式入口启动后台,探针显式调用runDueBackgroundWork。 - 主页:推荐默认关闭(同 Android),空状态按钮为“开启推荐”(开启并立即生成)或“生成推荐”。
未做:Wi-Fi 限定、通知(推荐/新章节)、应用未打开时的系统级计划任务、追踪范围“历史”选项与按分类开关追踪的界面。
验证:TrackRulesTest 新增间隔公式(含 Android 整除细节)、SuggestionRulesTest 新增标签解析、 DesktopBackgroundSettingsTest(键名/默认值往返)、DesktopBackgroundWorkTest(到期判断);平板探针新增 “开启推荐”持久化、频率选择持久化、手动运行后无任务到期、10 小时后两项任务到期并执行。DesktopBrowserProbe 点击“浏览器调试”前补滚动(更多页变长)。Android 编译与推荐单元测试 9 项通过;compileCommonMainKotlinMetadata 通过; :core-domain:jvmTest 129、:core-ui:jvmTest 12、:desktop-runtime:test 66(2 跳过)、:desktop-app:test 77(4 跳过),0 失败。 未打包、未提交。
76. 共享 Mihon 默认 Cloudflare 求解器(2026-10-04)
新增纯 JVM 模块 :core-cloudflare(OkHttp、parser-api 由使用方提供,compileOnly),Android 与 Windows 共用:
- 从 Android 迁入(包名不变,Android 导入无改动):
CloudflareHostCooldown(单个 host 失败冷却 30 s,测试时钟nowMillis改为 public)、CloudflareSolveCoordinator(同一 host 只跑一次求解;最后一个等待方离开时取消求解)、CloudFlareDetection.kt(CF_STATE_JS、parseCloudFlarePageState等,internal改为 public)。 cloudflare/ClearanceSolving.kt:Mihon 默认方案的规则——触发条件isMihonCloudflareChallenge(Cloudflare 服务器返回 403/503,或带cf-mitigated: challenge)、超时CLEARANCE_SOLVE_TIMEOUT_MS = 30 s、cf_clearance作为成功信号、 浏览器可接受请求头的过滤(isBrowserRequestHeaderSafe、Headers.safeForBrowser)、ClearanceSolveTracker(根据事件判定:主框架 403/503 则等待 JS,其他错误或页面无挑战加载完成则失败;Windows 用页面状态轮询判定: 勾选式挑战持续超过 8 s 才判失败,因为 Turnstile 常常只是短暂出现,硬拦截立即失败)、ClearanceSolver接口, 以及Interceptor.Chain.solveClearanceAndRetry(经协调器求解后只重试一次;调用方取消时停止等待)。
Android(委托):WebViewClearanceSolver 实现 ClearanceSolver,改用共享的请求头过滤、超时、cookie 名和 ClearanceSolveTracker 判定(删去私有副本);CloudFlareInterceptor 与 KotoNetworkHelper 的 MIHON 分支中重复的 “求解后重试”代码改用 solveClearanceAndRetry。Cloudstream 拦截器重试的是改写过的请求,保持原样(协调器仍是共享的)。
Windows:DesktopBrowserChallenges 实现 ClearanceSolver,用 WebView2 隐藏窗口求解:把 SDK cookie 推入浏览器, 删除旧的 cf_clearance,以安全请求头导航(允许 HTTP 错误页),每 500 ms 轮询新 cf_clearance 和 CF_STATE_JS, 成功后把 cookie 拉回 SDK 存储。DesktopChallengeInterceptor 改用 Mihon 触发规则:先自动求解并重试一次;只有带 cf-mitigated: challenge 的挑战在自动求解失败后才弹出原有的人工验证窗口;普通 Cloudflare 403/503 求解不了时原样 返回响应(保留原响应体,不会误弹窗口)。
验证:
- 新增
ClearanceSolvingTest(触发条件、请求头、事件和轮询判定、MockWebServer 重试、失败冷却、取消),CloudflareSolveCoordinatorTest和CloudFlareDetectionTest随代码迁入,:core-cloudflare:test17 项全部通过。 - 桌面拦截器测试新增“明确挑战离屏解决后重试、不打扰用户”。
- 新增真实 WebView2 探针
DesktopClearanceSolveTest:本地模拟托管挑战(403 +Server: cloudflare+#challenge-running,页面脚本写入cf_clearance后重载),约 3 s 内在隐藏窗口自动通过,SDK 请求返回内容, 没有人工弹窗;持续的勾选式挑战约 8 s 后转为人工验证。 DesktopChallengeProbe的请求计数改为包含一次隐藏求解。- 结果:Android
compileDebugKotlin通过,Android Cloudflare 相关单元测试 16 项通过;:mihon-desktop-compat:test34、:core-domain:jvmTest129、:core-ui:jvmTest12、:desktop-runtime:test66(2 跳过)、:desktop-app:test77(4 跳过),全部 0 失败;compileCommonMainKotlinMetadata通过。
kototoro/kotatsu/UMA parser 插件复用平台的共享 HTTP 客户端,因此同样经过这条自动求解链路。
未做:Windows 的 TRANSPORT/MANUAL 策略选项(目前固定为 MIHON 加人工兜底),以及用真实 Cloudflare 站点做实测。未打包、未提交。
77. 浏览页返回来源列表;所有来源的 User-Agent 设置(2026-10-04)
浏览页返回:进入某个来源后原先无法回到来源网格(导航栏“浏览”只会重新加载当前来源)。现在:
- 来源结果页标题前新增返回按钮(
source-back,复用 Android 的ic_arrow_forward并水平镜像); - 停留在来源内时再次点击导航栏“浏览”,同样回到来源网格(与 Android 重复点击标签的行为一致);
- 两者都调用
DesktopController.exitSource(),它会清空所选来源、列表、查询、排序和筛选。
User-Agent:parser 来源(kototoro/kotatsu/UMA)通过 ConfigKey.UserAgent 本来就有这一项。Mihon/Tsundoku/Aniyomi 来源的扩展不提供 UA 设置,因此在 source-host 新增 HostUserAgent:
- 存储与 Android 来源设置一致,写在该来源的
source_<id>偏好中,键为user_agent; - 生效方式:用来源自己的
headersBuilder()重建默认请求头并替换 User-Agent,再替换HttpSource.headers的 lazy 委托字段(lazyOf由扩展类加载器中的 Kotlin 运行时创建)。之后来源基于默认请求头构造的所有请求(包括图片)都 带上新 UA;留空则恢复来源默认值。 - 每次调用来源前检查一次,值有变化才重新安装。若来源自己覆盖了
headers,该行置灰并提示无法覆盖。 - 请求头值不合法(含控制字符)时直接拒绝,不写入存储。
isPreferencesSupported改为“可配置的扩展 或 任意 HTTP 来源”,因此没有自有设置的 HTTP 来源也会显示“源设置”。- 协议层把
host:user_agent这一行追加在扩展自有设置之后(保持扩展节点的位置不变),桌面界面渲染时把host:行排到最前。
验证:
- 真实 SDK fixture(
OfflineSource的imageRequest改为以getHeaders()为基础,与 Mihon 默认实现一致):DesktopPlatformProbe覆盖 UA 行存在、非法值被拒绝、保存后页面请求头的 User-Agent 生效,第二个进程中设置仍保留 且仍生效。 MihonDesktopPlatformTest的 CLI 断言更新为[域名设置, User-Agent];DesktopAppProbe改为按host:前缀区分。DesktopTabletProbe新增返回按钮和重复点击“浏览”的检查。- 结果:
:source-host:test86、:mihon-desktop-compat:test34、:desktop-app:test77(4 跳过),全部 0 失败。
未做:Android 侧 Mihon/Aniyomi 来源同样没有 UA 设置,可改用同一套键名实现;UA 预设下拉(Android 的 userAgentPresets)也尚未共享。未打包、未提交。
78. Windows 支持 Cloudstream(2026-10-04)
官方 Cloudstream 的 library 模块有 JVM 目标(Android 端本来就用其 library:jvmJar),插件 .cs3 是 d8 产物 (classes.dex + manifest.json)。Windows 端复用三样已有能力:dex 转换(:dex-convert)、Mihon 用的 Android 兼容运行时(AndroidCompat 提供 Context/SharedPreferences/Log 等)、平台共享 HTTP 客户端(Cookie 与自动 CF 求解器)。
共享(新增纯 JVM 模块 :cloudstream-shared,Android 与 Windows 共用)
- 从 Android 迁入,包名不变:
CloudstreamSource、元数据编解码CloudstreamMetadata、CloudstreamLinkSessionCache、CloudstreamApiGateway、插件兼容性检查,以及 YouTube 提取器存根。 - 新增
CloudstreamCatalog:原CloudstreamContentRepository的完整逻辑,包括首页分区聚合与按分区筛选、搜索分页 终止、详情与剧集映射(电影/直播/种子/剧集/动漫按配音分支)、loadLinks链接与字幕事件、链接会话缓存、 插件 fallback 后的挑战重试、推荐。 - 新增
CloudstreamPlatform接口(日志、按源请求上下文、loadLinks 挑战透传、挑战识别与解决、诊断),以及CloudstreamRequestScope(源上下文 ThreadLocal,缺省 UA / Referer / POST Origin 头)。 - Android 委托:
CloudstreamContentRepository只剩缓存、Android 日志、CF 异常与 WebView 解析(TRANSPORT)、相关内容 搜索兜底;CloudstreamRequestContext的源上下文与缺省头改用共享作用域,只保留 CF 策略标签和调试日志。
Windows(新增 :cloudstream-desktop,随兼容层开启时编译)
- 宿主垫片(插件链接的
com.lagradost.*宿主类):- 与 Android 同一份源码,构建时从 app 拷入,直接对 AndroidCompat 编译:
Plugin、AcraApplication、ContextHelper、VideoClickAction/Holder、TextUtil(UiText)、DataStore。DataStore在拷贝时去掉 androidx 的两处调用,edit {}由桌面同包函数提供。 CommonActivity是 Java(Kotlin 无法声明showToast$default),从app/src/main/java单独拷入,并用兼容层的 Java 21 toolchain 编译(options.release = 11,本模块自己的字节码仍为 11)。漏掉它时引用showToast("…")的插件在 Windows 被兼容性检查判定缺宿主类、Android 却照常加载,夹具插件已固定链接该桥方法。- 只有
CloudStreamApp是桌面版:成员与 Android 相同,去掉基于 Fragment 的浏览器方法,改用系统浏览器。
- 与 Android 同一份源码,构建时从 app 拷入,直接对 AndroidCompat 编译:
- 运行库:官方 jvmJar 去掉宿主替换的
Youtube*和ContextHelper_jvmKt(与 Android 的处理一致),依赖版本沿用 app 锁定的 NiceHttp、jackson、jsoup、rhino、ktor-http、datetime、io、cryptography、fuzzywuzzy、gson。 CloudstreamPluginRegistry:- 读取 manifest,做兼容性检查;把 dex 转成 jar(按哈希缓存),每个插件用独立 URLClassLoader,父加载器包含官方库、 垫片和兼容运行时。
- 调用
Plugin.load(Context),Context 用兼容运行时的。从APIHolder收集该插件注册的 provider 并init;卸载时beforeUnload,移除 provider 映射和提取器。 - 全局环境:
CloudStreamApp.context,app.baseClient使用平台共享客户端并在最前面加上源请求头拦截器。
CloudstreamSourceRuntime:按页序号翻页,排序只有 RELEVANCE,首页分区作为互斥标签组,封面带 provider 的海报请求头; 通过公开后的 parser-host 模型映射接入:core-source协议。- 会话与界面:
- 新增
SourceEcosystem.CLOUDSTREAM(来源过滤:Cloudstream 标签 / 视频分组)。 - 导入
.cs3(文件选择器接受 jar/apk/cs3),按哈希受管存放,安装记录前缀cloudstream:,启动时恢复;已安装列表、 卸载、产物清理都覆盖.cs3。 - Cloudstream 仓库:
repo.json(pluginLists)或plugins.json映射为扩展目录(下架状态的插件被过滤,按插件版本 检查更新);raw.githubusercontent 链接自动改走 jsDelivr。 - 内容类型为 VIDEO,剧集直接进入现有 libmpv 播放器,线路带请求头和外挂字幕。
- 新增
验证:
:cloudstream-desktop:test:- 自建插件经 Gradle 任务用 SDK d8 打成
.cs3,在真实兼容运行时与共享客户端里覆盖:首页两个分区聚合、按分区第 2 页 与终止、搜索、电影/剧集详情、剧集链接(m3u8、quality、自定义头、referer、UA)与字幕、卸载。 - 官方仓库的 3 个真实插件(InternetArchive、Dailymotion、Invidious,经 jsDelivr 下载)全部加载并注册 provider。
- 联网往返(
-PcloudstreamOnline)因本机到这些站点都不通(archive.org、dailymotion 超时,代理未开)未能执行。
- 自建插件经 Gradle 任务用 SDK d8 打成
DesktopRepositoriesTest新增 Cloudstream 仓库读取与安装、jsDelivr 改写两项。DesktopAppTest新增 Cloudstream UI 端到端(写入轮:导入→浏览页源网格→首页分区→搜索→详情→进入播放状态并带 请求头与字幕;读取轮:重启后恢复,再卸载)。- Android:
compileDebugKotlin通过,Cloudstream 相关单元测试 124 项通过。 - 全量回归:
compileCommonMainKotlinMetadata通过;- core-source 52、core-domain 129、core-ui 12、core-cloudflare 17、cloudstream-desktop 2、source-host 86、 parser-host 23(2 跳过)、dex-convert 14(2 跳过)、mihon-desktop-compat 34、desktop-runtime 68(2 跳过)、 desktop-app 78(4 跳过),全部 0 失败;
DesktopSourceFilterTest的期望改为包含 Cloudstream 来源标签。
未做:
- 插件设置页(
openSettings,需要 Android UI)和requiresResources插件(没有 Resources)。 - Windows 上依赖 WebView 的
WebViewResolver:仍是官方的 JVM 空实现,后续可接 WebView2。 - 同步追踪(AccountManager/AniList 垫片)。
- 真实站点联网验证。 未打包、未提交。
79. 阅读器:预加载、拖拽翻页、翻页动画(第一阶段,2026-10-05)
原状:Windows 分页阅读器只渲染当前页组,翻页时同步加载下一页(期间界面忙碌),没有预加载、拖拽翻页和动画;鼠标滚轮 只能平移。Android 的分页阅读器建立在 reader-core 之上:所有页组排成一条,用同一个滚动偏移驱动;PagedDragState 负责平移与翻页的衔接,PagedSnapResolver 负责吸附,过渡效果由 PagedTransitionResolver 和场景过渡渲染器给出, 预加载窗口由 PagedSceneResourceWindowStrategy 规划。Windows 本来就依赖 reader-core,这一轮改为走同一条路径。
共享(迁入 core-ui,包名不变,Android 导入不改):ReaderAnimation(无 / 滑动 / 覆盖 / 仿真)、TapGridArea、 ComposeReaderPageAnimation(翻页变换、卷页几何、composeReaderPageCurl、卷页阴影)、ComposeReaderTapGrid (九宫格点击识别)、ScenePageTransitionRenderer(滑动 / 覆盖 / 卷页的单页组变换和覆盖模式固定页)。 core-ui 的 commonMain 新增依赖 reader-core(Android 目标能正常消费)。
Windows 分页阅读器(DesktopReaderCanvas 重写)
- 当前页组和前后相邻页组排在一条上,由
travel(以页组为单位)平移。每个页组按 Android 的方式应用共享过渡: 卷页沿用composeReaderPageCurl,覆盖模式用固定页偏移,加上 zIndex、透明度和翻开页阴影。每个页组单独裁剪, 这样宽页或原始尺寸溢出时不会画到当前页上(与 Android 的裁剪一致)。 - 鼠标拖拽:用
PagedDragState处理缩放平移与翻页的衔接(放大时先平移到边缘再翻页),松手时按 reader-core 的阈值 和速度吸附;未过阈值回弹;在章节首尾继续拖动会进入相邻章节(自动跨章开启时);双指捏合仍是缩放。 - 键盘、按钮、点击区和滚轮翻页都有动画:键盘和按钮在页组切换后从原位置滑入;点击区和滚轮先滑出再提交。 动画时长与 Android 相同(280 ms)。
- 点击区按 Android 默认九宫格:左列和上中为上一页,右列和下中为下一页,中间切换工具栏;双击缩放保留。
- 滚轮:页面溢出时平移,到边缘或页面完全可见时翻页;Ctrl+滚轮缩放。
- 设置:新增“翻页动画”(无 / 滑动 / 覆盖 / 仿真翻页),默认与 Android 一样是滑动,持久化;快捷键说明同步更新。
预加载
- 分页模式:每次页组稳定后,按
PagedSceneResourceWindowStrategy(lookahead 2, prepareAdjacentSlots)在后台加载前后 两个页组,近的优先。后台加载不经过动作锁,不会让翻页等待;新的窗口会取消旧的。相邻页组一直参与组合,所以离屏时 已经完成解码,翻页时不会闪占位图。 - 连续滚动模式:使用 Android 连续模式的
ReaderPrediction规划可见范围以外的前瞻页。 - 后台加载失败的页面在本章内不再重复请求,真正显示该页时照常加载并报错。
验证:
- 新增
DesktopReaderGestureProbe:后台预取、拖拽翻页、短拖回弹、反向拖拽、预取页不重复请求、左右点击区、 中间切换工具栏、滚轮翻页,以及动画设置持久化(重启后仍是“覆盖”)。 - 原有阅读器探针按新行为调整:
- 只统计视口内的页面节点(相邻页组离屏、裁剪后边界为空);
- 用
requestFocus代替点击视口中心(中心点击现在用来切换工具栏); - 失败注入改在打开章节前设置(夹具支持多个页序号);
- 请求计数改为“增量等于新加载的页数”,保留“已缓存页面不重复请求”的原意;
- 等待
isPrefetching结束后再计数。
- 结果:
- Android
compileDebugKotlin通过,阅读器单元测试 694 项通过; - 全量回归:reader-core 179、core-source 52、core-domain 129、core-ui 12、core-cloudflare 17、cloudstream-desktop 2(1 跳过, 本次没有提供真实插件目录)、source-host 86、parser-host 23(2 跳过)、dex-convert 14(2 跳过)、mihon-desktop-compat 34、 desktop-runtime 68(2 跳过)、desktop-app 79(4 跳过),全部 0 失败;
compileCommonMainKotlinMetadata通过。
- Android
未做(下一阶段候选)
- 竖向翻页、长条(webtoon)模式的拖拽惯性。
- 自定义九宫格动作。
- 音量键 / 自动翻页、阅读器背景色与亮度、页面裁边、图片滤镜。
- 预加载下一章。
- 翻译叠加层。
- 小说阅读器的翻页动画。
- 把 Android 的选项面板 UI 搬到 core-ui 共享。
80. 阅读器第二阶段:色彩校正、背景、页码、竖向翻页、下一章预载、连续模式拖拽(2026-10-05)
共享:新增 core-ui ReaderColorMatrix,是 Android ReaderColorFilter 的 4×5 颜色矩阵运算,与平台无关。按 Android ColorMatrix 的语义逐步重放:灰度用 setSaturation(0) 替换单位矩阵,之后的每一步都是 postConcat;反色沿用 Android 的 R' = A − R + 1;护眼系数 0.92。Android 的 ReaderColorFilter.toColorMatrix() 和 isEmpty 改为委托它。 ReaderColorMatrixTest 用像素结果验证亮度、以中灰为轴的对比度、反色先于亮度的顺序、灰度权重和护眼效果。
Windows
- 色彩校正:反色、灰度、亮度和对比度(−1..1,显示为 0–200%,与 Android 相同)、护眼,可一键重置。通过
ColorFilter.colorMatrix(ReaderColorMatrix.of(...))作用于整页图像和分块图像,连续模式同样生效。 - 阅读背景:沿用 Android 的六种(默认 / 浅色 / 深色 / 白色 / 黑色 / 跟随系统);浅色背景开启护眼时同样被染成书页色。 页面样式(背景、滤镜、文字色)由一个 CompositionLocal 提供,分页、连续、分块图像统一读取。
- 显示页码:分页视口底部居中显示“3 / 5”(双页显示“4–5 / 5”)。
- 竖向翻页(Android 的竖向模式):方向按钮在“从左向右 / 从右向左 / 从上到下翻页”之间切换;竖向时不组双页。 页带、拖拽(
PagedDragState按纵轴衔接平移与翻页)、吸附速度、过渡和卷页都切换到纵轴,↑/↓ 也可以翻页。 - 下一章预载:分页模式到达最后两个页组、连续模式最后可见页进入末尾两页时,在后台取得下一章页面列表和前两页; 打开该章时直接使用缓存的页面列表(不再请求来源),图片命中缓存。预载失败的章节在本章阅读期间不会在后台重试 (回归测试发现,进度每次上报都会重试一次,会持续请求失败的来源),打开其他章节后才会重新尝试。
- 连续模式:
- 鼠标拖拽滚动,松手按 Compose 默认惯性甩动,到章节首尾时触发自动跨章;
- Android 九宫格:中间切换工具栏,左右两侧各滚动 0.9 屏,带动画。
- 设置全部持久化:
cf_brightness、cf_contrast、cf_invert、cf_grayscale、cf_book、background、page_numbers、vertical。只影响显示的设置不会重新加载页面。
验证:
DesktopReaderGestureProbe新增:- 反色后黄色页面在屏幕上变成蓝色为主,白色背景的上下留白为纯白;
- 页码显示“3 / 5”;
- 竖向时向上拖拽和按 ↓ 都翻到下一页;
- 进入章节末尾后下一章被预载,自动跨章后缓存被消费;
- 连续模式下鼠标拖拽使滚动位置前进,点击中间切换工具栏。
- 原有探针按预载行为调整:
- 跨章失败注入改在打开章节前设置;连续模式里第二次注入前用测试钩子丢弃已预载的章节;
- 离线阅读已下载章节时,允许预载下一章(未下载)发出一次页面列表请求和前两页请求;
- 不再点击视口中心来获得焦点。
- Android:
compileDebugKotlin通过,阅读器单元测试 694 项通过。 - 全量回归:reader-core 179、core-source 52、core-domain 129、core-ui 16(新增色彩矩阵 4 项)、core-cloudflare 17、 cloudstream-desktop 2(1 跳过)、source-host 86、parser-host 23(2 跳过)、dex-convert 14(2 跳过)、mihon-desktop-compat 34、 desktop-runtime 68(2 跳过)、desktop-app 79(4 跳过),全部 0 失败;
compileCommonMainKotlinMetadata通过。
仍未做
- 页面裁边(Android 按位图分析白边)。
- 自定义九宫格动作和长按菜单。
- 自动翻页。
- 翻译叠加层。
- 小说阅读器动画。
- 共享 Android 的阅读器选项面板 UI(目前桌面面板是 Material2,Android 是带资源字符串的 Material3 组件)。
81. 阅读器第三阶段:裁剪白边、自动翻页 / 自动滚动(2026-10-05)
共享(reader-core,Android 委托)
ReaderEdgeDetection:原 AndroidEdgeDetector的白边检测算法,与平台无关。保留原有判定:以 100 px 块从四边扫描到第一个 非白像素(容差 16);任一边的空白超过页面三分之一就视为没有可裁的白边;大图按 1 / 0.75 / 0.5 / 0.25 缩小后扫描。 像素通过BlockReader读取:Android 用Bitmap.getPixels,Windows 用 Skia 位图,各自的解码器不变。AndroidEdgeDetector只保留解码和缓存,isColorTheSame也委托共享实现(TrimTransformation不受影响)。ReaderAutoScroll:原ScrollTimer的速度曲线(0.1 + 速度 × 10)、滚动间隔(32 ms / 倍率)、换页间隔 (10 s / 倍率)、交互后暂停 2 s、默认速度 0.24。Android 的ScrollTimer和AppSettings默认值改为引用它。ReaderEdgeDetectionTest:用合成页面验证四边白边、2× 降采样结果一致、无白边或白边超过三分之一时不裁、 降采样档位,以及自动滚动的时间曲线。
Windows
- 裁剪白边:沿用 Android 分开设置翻页和连续滚动的做法。
DesktopImageDecoder.contentBounds降采样解码后调用共享检测,并按文件缓存结果。- 会话的
cropPages(与超分同样的接法)决定是否给页面附上crop;分页和连续场景都按裁剪后的尺寸排版, 页面图像用BitmapPainter只绘制内容区域,按解码缩放比例换算。 - 分块显示的超长页暂不裁剪。
- 切换开关会清空已加载页面并按新设置重新排版(本地缓存命中,不重新请求)。
- 自动翻页 / 自动滚动:
- 底部新增“自动翻页 / 自动滚动”按钮。分页模式下,页面比视口大时每次滚动 1 dp,滚不动或页面完全可见时按共享 换页间隔翻页;连续模式每次滚动 1 dp,到章末交给自动跨章。
- 点击、滚轮和按键都会暂停 2 s(与 Android 一致)。
- 速度滑块显示倍率,例如 ×2.5。
- 新设置持久化:
crop_paged、crop_continuous、as_speed。
验证:
DesktopReaderGestureProbe新增:- 每章第一页四周带白边(内容区为 40,60–360,540);开启裁剪后检测结果正确,页面显示比例为 320:480,中心像素是内容色;
- 速度设为 1 时,自动翻页在约 1 s 内翻到下一页,停止后 2.5 s 内页码不再变化。
- Android:
compileDebugKotlin通过,阅读器单元测试 694 项通过。 - 全量回归:reader-core 183、core-source 52、core-domain 129、core-ui 16、core-cloudflare 17、cloudstream-desktop 2(1 跳过)、source-host 86、parser-host 23(2 跳过)、dex-convert 14(2 跳过)、mihon-desktop-compat 34、desktop-runtime 68(2 跳过)、desktop-app 79(4 跳过),全部 0 失败;
compileCommonMainKotlinMetadata通过。
仍未做
- 自定义九宫格动作和长按菜单。
- 翻译叠加层。
- 小说阅读器动画。
- 共享阅读器选项面板 UI。
- 超长分块页的裁剪。
82. 阅读器第四阶段:自定义九宫格动作(阅读操作)与长按 / 右键(2026-10-05)
共享(core-ui,包名 org.skepsun.kototoro.reader.ui.tapgrid 不变,Android 导入不改)
TapAction(下一页 / 上一页 / 下一章 / 上一章 / 显示隐藏 UI / 显示菜单,含配置网格的着色)迁入 core-ui,去掉 Android 字符串资源; Android 用扩展属性TapAction.nameStringResId(TapActionNames.kt)保留原写法。TapGridConfig:Androidtap_grid的键名(CENTER、CENTER_long、_init)、默认布局(左列和上中为上一页,右列和下中为下一页, 中间点按切换工具栏、长按显示菜单)、“全部禁用”,以及按区域读写动作。TapActions从 ViewModel 的嵌套类移到这里。 AndroidTapGridSettings的键名和默认值改为引用它。ReaderTapGridConfigGrid:Android “阅读操作”页的九宫格(按点按动作着色、标出点按 / 长按动作、分隔线),文案和文字样式由调用方传入; AndroidReaderTapGridConfigScreen只保留顶栏、菜单和选择对话框。readerTapGestures新增可选的onSecondaryTap:右键单击直接报告所在区域,不进入点按 / 长按判定。Android 不传,行为不变。
Windows
DesktopReaderSettings.tapGrid持久化为tap_grid.<Android 键名>:值为动作名,空字符串表示“无”,缺省时用共享默认值。- 分页和连续阅读器都按配置执行动作(
performTapAction,对应 AndroidReaderControlDelegate.processAction):翻页动作在分页模式下带动画翻页, 在连续模式下滚动 0.9 屏;“显示菜单”打开阅读设置面板。长按和右键执行长按动作,默认中间区域打开阅读设置。 点击回调由rememberTapGridHandlers固定下来,重组时不会重启手势检测,按住过程中的长按不会被打断。 - 阅读设置新增“阅读操作”:使用共享网格,点击单元格设置点按动作,长按或右键设置长按动作,下方列出可选动作;支持“重置”和“全部禁用”。 快捷键说明同步更新。
验证:
- core-ui
TapGridConfigTest:默认布局与 Android 一致、键名、单项修改不影响其他项、全部禁用。 DesktopReaderGestureProbe新增:在面板里把左侧中间改为“下一页”,右键右下单元格把长按设为“下一章”;点击左侧中间后向前翻页; 在阅读区右键中间打开阅读设置且不翻页;重启后配置仍在,未修改的区域保持默认值。- Android:
compileDebugKotlin通过,阅读器单元测试 694 项通过。 - 全量回归:reader-core 183、core-source 52、core-domain 129、core-ui 19(新增 3 项)、core-cloudflare 17、cloudstream-desktop 2(1 跳过)、 source-host 86、parser-host 23(2 跳过)、dex-convert 14(2 跳过)、mihon-desktop-compat 34、desktop-runtime 68(2 跳过)、 desktop-app 79(4 跳过),全部 0 失败;
compileCommonMainKotlinMetadata通过。
仍未做
- 翻译叠加层。
- 小说阅读器动画。
- 共享阅读器选项面板 UI(桌面面板仍是 Material2)。
- 超长分块页的裁剪。
- Android 双页模式中“长按左右页中央打开菜单”的特殊判定,以及按长按位置选中目标页(
setTargetPageBySide),桌面暂未实现。
83. 阅读器选项面板共享:Android 面板直接用于 Windows(2026-10-05)
目标:Windows 阅读器设置不再单独实现一套 Material2 侧栏,而是直接使用 Android 的阅读器选项面板。
迁入 core-ui(包名不变,Android 导入基本不改)
- 面板组件(
reader.ui.compose.design):ReaderPanelColors(含mangaReaderPanelColors、forEInk、ProvideReaderPanelColors)、ReaderOptionControls(选项卡片、分节、分隔线、开关行、取值行)、ReaderPanelComponents(胶囊标签栏、选择芯片、图标选择条、 快捷操作宫格、开关芯片、步进行、滑块行)、ReaderControlTokens。为了不依赖平台资源,ReaderQuickAction只保留id和toggled, 图标和文字由宿主传入宫格;ReaderSliderRow.leadingIcon改为Painter。 - 图标:新增 Gradle 任务
:core-ui:generateReaderPanelIcons,在构建时把 Android 的矢量图(ic_arrow_forward、ic_settings、阅读模式、 缩放、快捷操作等 20 个)生成为共享的ReaderPanelIconsImageVector,两端绘制同一套图标。 - 面板宿主(
reader.ui.compose.panel):ReaderPanelHost/ReaderOptionsPanelHost。Android 的液态玻璃通过LocalReaderPanelGlass注入(AndroidReaderPanelGlass由KototoroTheme提供),没有玻璃的宿主使用不透明面板。rememberReaderPanelSurfaceMode留在 Android。 - 可拖拽底部面板
StableAnchoredSheetLayout(core.ui.compose)。返回键改用 expect/actual 的PlatformBackHandler:Android 端为 activity BackHandler(core-ui androidMain 新增 activity-compose 依赖),桌面端为空实现,由桌面自己处理 Esc。窗口版StableAnchoredBottomSheet留在 Android。新增openExpanded:以四分之三高度打开且不设预览停靠点,与横屏平板“预览过高时没有中间档” 的行为一致,快捷层保持可见。 - 漫画选项面板(
reader.ui.compose.ComposeReaderOptions.kt):ComposeReaderOptionsState/Callbacks,快捷层ReaderMangaQuickLayer, 标签页ReaderOptionsDetailTabs,“排版”页ReaderLayoutOptionsPage和“显示”页ReaderDisplayOptionsPage,背景色块, 动画图标和背景图标。文案通过ReaderOptionsStrings传入;ReaderOptionsFeatures声明宿主支持哪些项,不支持的行会隐藏。 Android 的ComposeReaderOptionsPanel只负责组装:传入字符串资源、翻译标签页和效果预览。 - 色彩校正控件
ReaderColorCorrectionControls(reader.ui.colorfilter,文案由ReaderColorCorrectionLabels传入)。Android 的同名 4 参数版本改为委托共享实现;前后对比预览依赖 coil,仍留在 Android。 - 模型类型:
ReaderMode、ReaderBackground(仅枚举;Android 的resolve(context)/isLight(context)改为扩展函数)、ReaderOcrMode、UiPresentationMode、UiPresentationConfig/LocalUiPresentationConfig、tvFocusable、ReaderColorFilter(Android 的toColorMatrix/toColorFilter/getBackgroundTint改为扩展函数)、ImageServerOptions。 - 注意:Android 与 core-ui 中同包同名的 Kotlin 文件会编译成同一个
XxxKt类,导致其中一个被遮蔽。因此 Android 的面板文件 改名为AndroidReaderPanelGlass.kt。
Windows
- 构建时从 Android 的
values和values-zh-rCN(strings*.xml、arrays.xml)生成android-strings.properties(任务prepareDesktopStrings,字符串数组会展开为对应的@string),由AndroidStrings读取,面板文案与 Android 中文版逐字一致。 - “阅读设置”按钮改为打开共享面板
DesktopReaderOptionsPanel。桌面设置与 Android 状态之间的映射:- 阅读模式:默认 / 从右到左 / 从上到下 / 条漫,分别对应 SINGLE·DOUBLE + 方向,以及 CONTINUOUS;不提供横向连续模式。
- 横屏双页对应 DOUBLE;新增“首图作为封面”(
double_cover,即 reader-core 的isCoverOffset)。 - 动画、缩放、页码、裁切(按当前模式写入翻页或连续滚动的开关)、背景、色彩校正、超分开关,以及全屏(对应 F11)。
- 色彩校正的滑块每一步都会立即显示,停止 250 ms 后才保存,避免每一步都排队写盘。
- 快捷操作:章节与页面、添加书签、自动翻页。
- 面板的齿轮按钮打开“更多阅读设置”侧栏,对应 Android 的阅读器设置页。侧栏只保留 Android 面板中没有的项:阅读操作(九宫格)、 自动翻页速度、超分模型与降噪、快捷键说明。旧侧栏中与共享面板重复的项已删除。阅读操作的文案也改用 Android 字符串。
验证:
DesktopReaderGestureProbe:在共享面板中切换动画(使用 Android 文案“高级”);从齿轮按钮进入阅读操作;右键中间打开面板; 在图标条中依次切换从右到左、双页、封面、条漫、默认;在“显示”页选择白色背景,反色后重置(验证保存防抖); 快捷操作“添加书签”会关闭面板并切换书签。DesktopTabletProbe和DesktopUpscaleProbe按新流程调整(Esc 关闭面板、齿轮进入超分设置)。- Android:阅读器及面板相关单元测试 738 项通过(包括已迁入 core-ui 的面板配色、组件、宿主和底部面板测试)。
- 全量回归:reader-core 183、core-source 52、core-domain 129、core-ui 19、core-cloudflare 17、cloudstream-desktop 2(1 跳过)、source-host 86、parser-host 23(2 跳过)、dex-convert 14(2 跳过)、mihon-desktop-compat 34、desktop-runtime 68(2 跳过)、desktop-app 79(4 跳过),全部 0 失败;
compileCommonMainKotlinMetadata通过。
仍未做
- 漫画阅读器的顶部和底部控制栏(Android
ReaderControlShell)尚未共享,桌面仍是自己的工具栏。 - 小说阅读器的选项面板(
ComposeNovelReaderOptionsSheet)尚未接入桌面。 - 翻译标签页;“横向连续”模式;分割双页、折叠屏、渲染器、性能等桌面没有对应引擎的项。
- “Layout”“Two pages”等少数字符串在 Android 中文资源里没有译文,两端都显示英文(应通过 Weblate 补译)。
84. 阅读器控制栏共享:Android 与 Windows 共用浮动控件(2026-10-05)
共享(core-ui,原包名保持)
ReaderChrome.kt提取 Android 顶部返回/章节标题/选项、书签等浮动按钮、进度滑条与上一章/下一章、 进度底座和缩放按钮。图标复用原 Android vector;文案、状态和动作由宿主提供。ReaderChromeSurfaces/LocalReaderChromeSurfaces隔离平台表面:Android 的AndroidReaderChromeSurfaces通过主题注入原玻璃药丸与底座,共享组件提供 Material3 表面回退。ImmersiveEdgeGradient与透明色处理迁入共享模块,Android 原顶部/底部渐变与布局参数保持。- Android
ComposeReaderActivityScaffold委托共享组件,保留 TV 焦点、章节标题底置、资源字符串及浮动动作装配。
Windows
DesktopReaderChrome使用共享顶部栏、进度底座、书签/自动翻页按钮及边缘渐变;分页画布使用共享缩放控件, 保留倍率显示和重置视图。缩放控件文字色取自阅读背景,避免浅色应用主题在深色阅读画布上显示黑色按钮。 控制栏悬浮在全窗口视口上,显示/隐藏不再改变页面排版或阅读进度。- 中央点击或 H 切换控制栏,隐藏时显示章节与可见页码;标题打开章节,选项打开共享 Android 阅读设置。 点按书签按钮保存/移除当前书签,长按打开书签面板。全屏沿用 F11 和“排版”页的全屏开关。
- 自动跨章与重新加载归入“更多阅读设置”;阅读操作、超分模型、自动翻页速度等桌面适配仍由原 Controller 处理。
- 现有交互探针改为操作共享选项与浮动控件。
ReaderChromeTestKit统一焦点、快捷键、模式、适配方式、 刷新及隐藏状态页码检查;像素/分块接缝断言先隐藏覆盖层,继续验证原有颜色与完整采样列。 - 全屏开关通过选项列表滚动操作;短窗口首次滚动会先展开面板,探针使用有次数上限的分步滚动, 并断言开关确实显示,避免对尚未进入组合的控件调用
performScrollTo。
验证:
:core-ui:jvmTest19 项通过;:core-ui:compileCommonMainKotlinMetadata通过。- Android
:app:compileDebugKotlin通过;阅读器与阅读设置单元测试 694 项通过,ImmersiveEdgeGradientTest/StableAnchoredBottomSheetTest另 15 项通过。 - Windows
:desktop-app:test首轮覆盖 79 项,72 项通过、4 项按运行条件跳过;3 项控制栏适配失败 (背景像素、分块接缝、窗口面板)修复后分别定向重跑通过。 - 窗口探针覆盖 1260×850、920×620、1000×850、999×620:控制栏显隐不改变视口、全屏开关可滚动到达、 滑条保存进度、书签长按及跨章恢复、章节搜索不触发快捷键、返回详情退出全屏。
- 缩放对比度修复后额外重跑相机探针,鼠标/触摸/适配/倍率限界及重启恢复均通过; 截图已检查共享控制栏、浅色主题的深色阅读画布和短窗口全屏选项。
仍未做
- 小说阅读器选项面板共享、翻译标签页、横向连续模式、超长分块页裁剪。
- 双页长按按位置选择目标页与 Android 特殊中央菜单判定。
85. 小说阅读选项 UI 共享(2026-10-05)
共享(core-ui)
NovelReaderOptionsUi提取 Android 小说字号步进控件、主题预览色卡、快捷层和排版卡片。 宿主提供文字、数值范围、主题颜色和修改回调,不把 Android Settings、Context 或 Windows 存储带进共享层。- 小说标签页复用已有
ReaderOptionsDetailTabs,不显示齿轮时也不预留按钮宽度; 排版、阅读与翻译工具页复用已有ReaderOptionsPageList,保持原有间距和滚动行为。 - Android 原字体选择、段距、页边距、首行缩进、亮度、翻译、替换规则和 TTS 继续使用原宿主逻辑。 字号范围与归一化保持原值;纸张主题、电子墨水颜色、TV 焦点和玻璃表面由原适配层提供。
- 状态和事件边界参考 Compose 官方状态提升指南, 只共享呈现组件,业务状态和持久化留在宿主;没有新增依赖或偏好格式。
Windows
DesktopNovelOptionsPanel替代旧 Material 2 侧栏,使用共享锚点面板、主题色卡、字号、排版卡片及标签布局。 宽窗口直接展开;短窗口保留可滚动内容,Esc 或遮罩关闭后把焦点交还正文。- 字号、行距、衬线字体和版心宽度沿用
DesktopNovelSettings范围及原偏好键,主题仍兼容已有LIGHT/SEPIA/GREEN/DARK记录;阅读页全屏开关接入实际窗口回调。 - 原按段落连续滚动、章节切换、进度报告、插图请求和第二进程恢复流程保持。 桌面没有显示尚未接入的亮度、分页动画、双页、翻译或 TTS 控件。
验证:
:core-ui:jvmTest19 项通过;共享模块 Android/JVM 编译和:app:compileDebugKotlin通过。- Android 小说相关 JUnit 测试 168 项通过,0 失败、0 跳过。首次默认引擎运行中,Kotest 未遵守 Gradle 的小说筛选范围而执行无关 EPUB 属性测试;停止该进程后,使用仅限本地的
build/kmp-novel-validation.init.gradle限定 JUnit Jupiter,并重验最终代码。 正式测试配置未改动,本轮结果不包含 Kotest 属性测试或 Android 真机 UI 验证。 - 桌面解析器探针从真实 fixture 插件打开小说,经共享 UI 修改主题、字号、行距、版心和字体, 检查全屏、Esc、F11 及跨章,再启动第二个 JVM 检查设置与章节恢复。
- 探针第一进程为 1260×850,第二进程为 920×620;两种尺寸的排版面板截图已检查。 短窗口的后续扩展卸载检查先滚动已安装列表,避免依赖控件已进入组合。
- 漫画平板回归通过,覆盖 1260×850、920×620、1000×850 和 999×620,验证共用标签栏、 全屏、书签及章节操作未回归。
仍未做
- 小说正文分页/双页引擎、章节面板和翻译/TTS 的桌面接入;本节完成的是阅读选项组件共享。
- 漫画横向连续模式、超长分块页裁剪及双页长按按位置选择目标页。
86. 小说上下栏共享与浮动布局对齐(2026-10-05)
共享边界
SharedNovelReaderChrome提取小说顶部返回、章节胶囊、选项按钮、底部进度底座、跨章按钮、 渐变、显隐动画、底置章节标题、隐藏阅读状态与浮动动作布局;基础按钮和滑条沿用共享ReaderChrome。NovelReaderChromeState、颜色、文案和事件由宿主传入;进度单位归属阅读引擎,Android 按页, Windows 按正文块。共享呈现不依赖 Android Settings、Context、小说模型或桌面存储。- 控件颜色跟随小说阅读主题,底座和浮动按钮支持显式内容色;玻璃表面仍由 Android 主题注入。 底部控件可见性和电子墨水动画开关迁入共同策略,Android 原入口委托该策略。
- Android 保留原书签、翻译、替换规则、标记、TTS 与 BackHandler 的装配;
navigationBarsIgnoringVisibility由 Android 宿主传入,保留沉浸模式的原导航栏间距。
Windows 行为
DesktopNovelReaderChrome委托同一套上下栏,正文使用完整窗口视口,控件作为覆盖层呈现。 固定正文首尾留白不随显隐变化;点击正文空白或 H 切换控件,隐藏阅读状态可点击恢复。- 标题打开章节面板、选项打开共享小说阅读设置、滑条跳转段落、跨章按钮遵守章节边界。 全屏沿用 F11 和阅读设置中的真实窗口开关。
- 进度报告使用 LazyList 的实际阅读锚点;顶部留白后方仍组合的正文块不会把目标段落的保存位置向前偏移。 沿用原 Controller、历史表、偏好键和章节加载,没有新增依赖或数据格式。
- fixture 小说扩展增加长章节,检查非零段落的定位、保存及跨进程恢复;原插图仍经过来源客户端。
验证范围
:core-ui:jvmTest19 项通过,共享 UI 的 JVM/Android 编译通过。:app:compileDebugKotlin通过;小说相关 JUnit 168 项和底部可见性 2 项,共 170 项通过, 0 失败、0 跳过。沿用 §85 的本地 init script 限定 JUnit Jupiter,本轮不包含 Kotest 属性测试。- 桌面解析器与漫画平板两个定向测试通过。小说第一进程 1260×850 写入正文块索引 12, 通过跨章按钮切到第二章,再用滑条定位索引 16;第二进程 920×620 验证章节、正文块和深色排版偏好恢复。
- 探针检查 H 与鼠标点击显隐、阅读状态点击恢复、正文视口/块坐标/插图请求稳定、标题打开章节、 Esc 返回、进度持久化、跨章边界及实际全屏回调;长章节插图仍从扩展客户端加载。
- 漫画回归覆盖 1260×850、920×620、1000×850、999×620,检查共享底座与控件调整未回归。 浅色/深色上下栏、隐藏状态、段落跳转与窄窗口设置截图已检查,保存在忽略的
desktop-app/build/reports/desktop-smoke;相关差异的git diff --check通过。
仍未做
- Windows 小说分页/双页引擎、共享章节面板、小说书签与翻译/TTS 接入;本节对齐的是上下栏呈现和现有滚动引擎交互。
- Android 真机控制栏视觉与系统栏回归、iOS 原生构建与真机验证。
87. 小说章节目录共享(2026-10-05)
共享呈现与目录规则
NovelChapterDirectoryEntry是面向呈现的轻量输入;宿主解析标题、分卷、组名及搜索别名, 共享目录保留原章节索引,倒序和筛选不会改变选择事件的索引含义。NovelChapterDirectoryContent提取 Android 搜索框、清除与倒序按钮、分组标题、章节行、 已读淡化、当前章背景及强调条;定位当前章会清除筛选并滚动到对应行。 非空筛选从首项展示,空筛选定位当前章;目录数据或当前章变化后重新解析位置。- 列表继续使用 LazyColumn 和唯一 key,重复组名/卷名以及重复章节 ID 保留原索引以区分节点。 参考 Compose 官方列表指南 的 key 和滚动状态说明。
ReaderChapterPanelHeader、副标题格式与定位按钮进入共同层。搜索框图标复用已有 Android vector 生成流程;没有新增依赖。Android 原目录入口和测试辅助函数委托共享实现,保留本地小说组名规则。- Android 正文搜索、笔记/标记、书签与 Pager 标签仍由原宿主装配;本节不迁移这些业务。
Windows 接入
DesktopNovelChaptersPanel替代小说原 Material 2 侧栏,复用共享 ReaderPanelHost、标题和章节目录。 面板颜色跟随阅读主题;目录内容保持 Android 的最大宽度、搜索、正倒序和当前章定位布局。- 章节选择回到原 Controller;选择当前章只关闭面板,保留滚动位置和已加载正文。 选择其他章沿用原加载/历史流程。忙碌时章节行禁用;Esc 或遮罩关闭后恢复正文焦点。
- 目录打开期间正文仍挂载;搜索输入 H 不触发阅读区的显隐快捷键。
验证范围
:core-ui:jvmTest24 项通过,包括新增目录规则 5 项;共享代码 JVM/Android 编译通过。:app:compileDebugKotlin通过,小说相关 JUnit 168 项及章节标题测试 2 项,共 170 项通过, 0 失败、0 跳过。沿用 §85 的本地 init script 限定 JUnit Jupiter,本轮不包含 Kotest 属性测试。- 共享组件长目录探针使用 120 个章节,覆盖 1260×850 浅色和 520×620 深色布局,验证离开当前章后 定位、筛选清除、倒序后原索引选择、组名/译者搜索、禁用行不触发事件及空目录。
- 真实 fixture 插件的小说探针在 1260×850 写入非零正文块,第二进程 920×620 恢复章节与设置, 检查目录打开期间输入 H、搜索/倒序/定位、当前章返回、Esc 关闭、正文视口和插图请求保持。
- 漫画平板回归通过,覆盖 1260×850、920×620、1000×850、999×620;共享标题提取未回归。 桌面目录首轮断言误把占位文案与 EditableText 一起比较,改为检查 EditableText 后两项定向测试均通过。
- 浅色/深色目录、当前章和窄窗口长列表截图已检查,保存在忽略的
desktop-app/build/reports/desktop-smoke; 相关差异的git diff --check通过,没有新增依赖、偏好键或存储格式。
仍未做
- Windows 小说书签/笔记、正文搜索与翻译/TTS 接入;小说分页/双页引擎。
- 漫画章节目录呈现共享,以及 Android 真机/iOS 原生视觉和交互验收。
88. 小说书签卡片共享与 Windows 保存/恢复(2026-10-05)
共同呈现
- 将 Android 小说书签卡片提取为
NovelBookmarkCardContent:正文摘要、强调条、章节、日期和删除按钮 使用同一 Material3 布局。Android 保留原摘要解析、日期格式和笔记/标记操作,由原宿主传入数据及回调。 - 删除图标沿用 Android vector 的共享生成流程;Windows 面板复用 ReaderPanelHost、共享标题与搜索框。 宽屏内容居中,颜色跟随小说阅读主题,没有新增依赖或另一套图标。
- Windows 小说浮动书签按钮复用
ReaderFloatingControlButton与NovelReaderFloatingControls, 点击或 B 添加/移除当前正文位置,长按打开书签列表;面板输入 B 不触发正文快捷键。
存储与定位
DesktopLibrary共用漫画/小说书签的位置切换事务及身份检查。小说摘要截取纯文本至 200 字符, 写入既有书签表的 image 字段;page 保存正文块索引,scroll 为 0,percent 按章节所属分支计算。 同一作品内生成记录 ID 时检查已有主键,保存书签本身不写阅读历史。- 单条删除先比对完整持久化记录,拒绝过期卡片、作品身份冲突和错误作品 ID;无效正文位置或变更章节 不写入书签。桌面 DTO 补充已有 image 字段的投影,Room schema 和备份格式保持兼容。
- 小说打开时重新载入本作品书签。列表支持摘要/章节标题搜索、最近添加排序、单条删除和同章/跨章跳转。 同章跳转保留正文对象,通过 navigation 请求重新滚动,连续跳回同一目标也生效且不新增插图请求。 跨章先加载正文并校验位置,失效位置不强行夹到其他段落;阅读进度仍走原历史流程。
验证结果
:core-ui:jvmTest24 项通过;:desktop-runtime:test --tests "*DesktopLibraryTest*"11 项通过, 包含新增小说书签重启/摘要/分支进度和无效位置/过期删除保护测试。- 桌面 fixture 小说使用 1260×850 写入两章书签,再在第二进程 920×620 恢复;验证空列表、搜索、编辑 B、 跨章和重复同章跳转、正文视口不变、单条删除、快捷键切换及持久化读取。浅色/深色书签截图已检查。
- 原漫画书签的跨章节、页码/像素偏移和第二进程恢复探针通过。居中修正后定向小说探针再次通过。
:app:compileDebugKotlin与 Android 小说/章节标题 JUnit 170 项通过,0 失败、0 跳过; 沿用 §85 的本地 init script 限定 Jupiter,不包含其他 Kotest 属性测试。- 验证日志及截图位于忽略的 build 目录;本节相关文件的
git diff --check通过。
边界与后续
- Windows 的正文块索引与 Android 的分页索引不能保证逐项对应,共享 schema 不代表跨阅读引擎精确定位。 当前验证针对桌面创建的纯文本书签;Android 历史 HTML/data URL 摘要的桌面解析仍待统一。
- Windows 小说笔记/标记、正文搜索、翻译/TTS 和分页/双页引擎继续推进;Android 真机及 iOS 验收仍待。
89. 小说书签摘要、正文定位与当前位置状态统一(2026-10-05)
共享规则
core-domain/commonMain提供摘要清理、分支进度和NovelBookmarkTextIndex。纯文本、HTML 和 HTML/plain base64 data URL 采用同一预览策略,去除无关标签、规范空白并保留最多 200 字符。 网络/文件图片地址、无效 data URL 返回空摘要;不会读取外部文件或发起网络请求。- Android 与 JVM 从同一个
jvmSharedMain目录编译已有 Jsoup 的适配器,Android 原入口保留 LruCache。 不新增依赖、Room schema、偏好键或备份字段;common metadata 编译验证了定位规则的可移植性。 Base64 使用 Kotlin 标准库共同 API。 - 章节索引一次拼接渲染后的文本,忽略换行、缩进和空白,书签返回摘要起点所属的页或正文块。 摘要跨页、跨段落仍可匹配;有文本而未命中时返回失败,不使用另一端保存的页码强行跳转。 重复摘要按分支进度选择较近位置;空摘要保留范围内的原数字位置作为旧格式兼容。
两端接入
- Windows 的展示和搜索解析历史 HTML/data URL 摘要,保留原 DTO 用于身份校验和删除。 同章/跨章跳转、当前书签按钮和 B 移除操作均使用解析后的正文位置,保存页码即使超出当前正文块数也可恢复。 同章继续保留正文对象;跨章匹配失败时保留原章节和导航位置。
- Android 分页与连续滚动分别投影当前渲染页/块到共同索引;跳转等待目标章与精确分页结果就绪。 请求编号防止旧请求被误消费,等待期间阻止旧滚动位置回报覆盖目标,完成或失败后恢复正常回报。 相关副作用按请求与布局结果设置 key,参考 Compose 官方副作用说明。
- Android 当前位置按钮、选区切换和普通切换按正文映射识别已有记录,删除原持久化记录。 单条 Repository 删除在事务内比对完整记录,拒绝过期对象误删新记录。 连续滚动发布当前正文块的原文,修复摘要取到旧页面或章节开头的问题;图片块保留空摘要。
- 新建 Android 小说书签采用与 Windows 一致的分支进度计算,修正此前只记录章内进度的行为。
验证与边界
:core-domain:compileCommonMainKotlinMetadata通过;共享定位 JUnit 9 项、桌面存储 11 项通过。 覆盖中文 HTML、base64 换行、跨页摘要、空白、失效文本、图片旧位置、重复文本和分支进度。:app:compileDebugKotlin通过,Android 小说/摘要/章节标题 JUnit 180 项通过,0 失败、0 跳过。 包含新增分页/滚动窗口映射、当前按钮状态以及请求跨章保留和旧请求消费保护测试; 沿用 §85 本地 init script 限定 Jupiter,不包含其他 Kotest 属性测试。- 桌面 fixture 增加旧 HTML/base64 书签:保存索引 700,经搜索跨章跳至当前正文块 12, 验证移除按钮与 B 删除原记录;修改摘要后拒绝跳转且保留原正文、章节、首块和导航编号。 失效跳转断言检查导航状态,不比较会随可见区测量更新的末块索引。
- 小说解析器与漫画书签两个端到端测试通过,均覆盖第二进程恢复;桌面写入进程 1260×850、 读取进程 920×620。小说浅色/深色书签截图仍使用共享卡片,日志与截图保存在忽略的 build 目录。 本轮相关文件的
git diff --check通过。 - 摘要定位解决 §88 的文本书签兼容缺口,但不是原文字符偏移或像素锚点协议。 重复文本及旧 Android 章内 percent 可能产生近似选择;译文、替换规则或源正文改变后可能无法匹配。 图片/空摘要的旧数字位置仍无法保证跨引擎对应,没有升级或重写旧数据。
- iOS 尚需原生 HTML 适配器与原生构建验证;Android 真机分页/滚动跳转和视觉验收仍待。 Windows 小说笔记/标记、正文搜索、翻译/TTS 及分页/双页引擎继续推进。
90. iOS 原生启动、共享 framework 与 Room 持久化(2026-10-10)
实现范围
- 新增
:ios-shared平台装配模块,声明 iosArm64、iosSimulatorArm64 和KototoroShared静态 framework / XCFramework。复用core-db、core-domain、core-backup、core-source、core-net、reader-core; Swift 只消费分类应用接口,Room 与 parser 类型不向 Swift 整体导出。模块有实际源码,避免空模块NO-SOURCE但没有 framework 的假成功。 IosLibrary直接装配既有MangaDatabase与已固定的 sqlite-bundled 2.6.2,查询运行在 Native IO dispatcher。 Swift 创建 Application Support/Kototoro 目录并传入绝对数据库文件路径;使用同一 v84 schema, 没有新增表、迁移或破坏性回退。- 分类读取复用既有 DAO;新建分类在 writer immediate transaction 内分配排序键并写入。空白标题拒绝、 有效标题去除两端空白,采用共享
ListSortOrder.NEWEST。构造与 suspend 方法声明@Throws, 让 Swift 接收错误并显示重试,而不是因未声明的 Kotlin 异常直接终止进程。 iosApp/Kototoro.xcodeproj是可直接打开的共享 scheme 工程,无 CocoaPods/XcodeGen 依赖。 Xcode 构建阶段调用embedAndSignAppleFrameworkForXcode;模拟器排除 x86_64,匹配现有 Apple Silicon KMP targets。使用 SwiftUI/NavigationStack/TabView,提供漫画库分类、新建分类表单、浏览未接入提示与设置。 Swift 的 MainActor model 管理应用生命周期内的数据库实例,读写异步进入 Kotlin;源码没有复制 Android UI。
原生验证
- 环境:Apple Silicon Mac、Xcode 27.0(27A266a)、iOS 27.0 runtime(24A434)。创建专用
Kototoro iPhone(iPhone 18 Pro)模拟器;此前只有 runtime,没有可用 device。 core-db的 iosSimulatorArm64 KSP 生成/编译及其 parser-api 依赖通过;core-domain、core-backup、core-net、 core-source、reader-core 的模拟器原生编译通过,ios-shared:linkDebugFrameworkIosSimulatorArm64通过。- iosArm64 的同组模块编译、Room KSP 生成与 framework 链接也通过;
ios-shared:assembleKototoroSharedDebugXCFramework成功产出真机 Arm64 + 模拟器 Arm64 两个切片, 路径为ios-shared/build/XCFrameworks/debug/KototoroShared.xcframework。这不代表真机运行验收。 ios-shared:iosSimulatorArm64Test:2 项,0 失败/跳过。真实 Room 分类写入、关闭并重开数据库, 校验同一 ID/中文标题与顺序;空白标题不写入;相对数据库路径被拒绝。xcodebuild ... test:Swift/静态 framework 集成构建与 XCTest UI 测试通过,1 项、0 失败。 新建表单默认禁用空标题保存;通过 UI 写入唯一分类,终止并重新启动应用,确认同一分类仍显示。- 实际应用沙盒数据库经 sqlite3 只读检查:
user_version=84,36 张应用表,分类记录与 UI 一致。 空漫画库与重启后分类截图已实际检查,产物在忽略的iosApp/build/reports。 :app:compileDebugKotlin最终通过。首次检查遇到工作区另一处正在修改的 LNReaderRepoViewModelobserveAsFlow参数错误,该文件更新后复查成功;本轮没有修改这部分 Android 代码。- 首次工具链自动下载 Kotlin/Native LLVM/libffi。Apple 依赖下载超时后使用本地 127.0.0.1:7890 代理。 Native 的
Dispatchers.IO需要显式导入kotlinx.coroutines.IO;Groovy XCFramework 使用XCFrameworkConfig(project, name),不能直接使用 Kotlin DSL 的工厂函数名称当构造器。
原则与接续门槛
- 共享业务继续保留在既有模块,iOS 只承担平台装配和 Swift 友好投影;复用既有 DAO/schema/排序规则, 没有平行数据库、额外 DI 框架或新版本依赖。参照 Room KMP 官方指南 与 Kotlin direct integration, 保持本项目 Room 2.8.4 与工具链版本,没有按最新版示例迁移 Room 3。
- 这是 iOS 首个存储/外壳切片;还不能正常添加来源漫画或阅读。framework 包含共享模块依赖不等于 备份恢复、HTTP 引擎、同步服务或阅读器已经装配。
- 下一批仍采用原方案:固定并核对 TachiyomiAzIOS/TachiyomiAZ 的 OpenJDK Mobile Zero、AndroidCompat、 C/JNI/WKWebView 桥与许可,再将现有 JVM source-host 接入 core-source。没有复制或分发上游代码/运行时。 上游 IOS_PORT.md 记录模拟器 VM 启动 需要
MAP_JIT、W^X、pthread_jit_write_protect_np三处专用补丁,应独立验证。 - 本轮不将 S0/G0/G2/S3 标为完成;真机安装、Zero 启动/内存/网络/JS/图形/Cloudflare 场景仍需验收。 本机运行与测试命令见仓库中的
iosApp/README.md。
91. iOS 模拟器 Zero / C-JNI 与真实 Mihon APK 列表(2026-10-10)
已实现的调用链
SwiftUI → IosSources → core-source/SourceProtocolClient → NativeSourceTransport → C/JNI → EmbeddedSourceHost → SourceHostSession → 同一 SourceEndpoint/MihonSourceRuntime。 Swift 不重复定义协议版本、请求 ID 校验、错误码或 64 位 ID 规则。Kotlin/Native 负责 IO dispatcher 与 Swift DTO 投影;C/JNI 只负责 VM 启动、Java global reference、线程附着、UTF-16 和资源释放。
SourceHostSession抽出原 JSONL 内部的常驻平台/registry/endpoint,JSONL 与 JNI 共用生命周期。exchange/关闭串行化,关闭幂等,初始化失败按 registry → platform → preferences 清理;错误保留 suppressed。EmbeddedSourceHost.open提供静态 JNI ABI,配置目录必须绝对路径。没有启动子进程或重写源执行器。- 原生桥创建一个进程级 VM,拒绝同一进程内的不同配置;VM 留到进程退出,close 只释放 Java 会话。 每个调用独立获取 JNIEnv/local frame,并按需附着/分离线程,JNIEnv 不跨线程保存。 文本按显式长度的 UTF-16 传输,避免 modified UTF-8 对中文/emoji/NUL 的影响。
MihonIosPlatform是可选 JVM 适配器,用本地固定的 Mobile API、独立 Application、持久化偏好桥、 兼容主 Looper、Koin/Injekt 与源客户端初始化;不是服务端启动器,也没有调用上游 ExtensionHost。 Mobile API 的 AndroidCompat/server 与 Windows 的 pin 不同,保持两个适配器的构建与摘要独立。- Swift 浏览页支持源枚举与第一页列表,区分独立测试源和真实 APK 验证预览。普通构建默认关闭嵌入式 JVM, 不打包第三方运行时。各预览使用独立 DerivedData,默认构建会拒绝复用带 JVM 资源的产物目录。
运行时来源与可复验构建
- 固定 TachiyomiAzIOS
b86cead7dd1387b09c71e7f4b15c1e020463e422、OpenJDK Mobilead8bb1b065bf230d193a1dfd0ebd39a8b1fedf53、TachiyomiAZ 模拟器补丁来源820a5cf20bdd7eba189437be6fa5b21daa504cec。下载摘要集中在iosApp/Scripts/jvm-preview-pins.json。 - 自有脚本在 ignored build 中下载完整原始源码/许可证,执行固定的 iOS 路径/静态符号补丁与模拟器 MAP_JIT/W^X/dlsym 补丁;没有执行上游脚本中的 Git reset/clean 或原有目录清理。 GNU m4/autoconf 安装到 build/tools,不修改系统工具。JDK 24 启动编译,目标为 iOS 17 arm64 Simulator Zero。 参考 上游模拟器构建脚本。
- Java 26 模块镜像、配置、cacerts 与 legal 取自已校验的 v8.9.1 IPA;只复用匹配固定源版本的模块数据。 模拟器的 native VM 重新编译,没有将真机 IPA 的原生二进制当作模拟器库。
- 已用另一棵干净源码树执行
build-jvm-preview.py成功。stage-mihon-preview.py生成真实 APK 配置和 编号 classpath;运行时、上游 JAR、APK、源码、截图与日志都在 ignorediosApp/build,不进入默认发行包。 完整命令见iosApp/README.md。
真实 APK 与修复
- 样本为再漫画 1.6.0 / versionCode 106000,公开下载 APK SHA-256 为
f6605e36a7d1453b60814bb6f7340a8f724183b11ee3f95e02d9542753ba81fe。 使用现有ApkExtensionConverter/ dex2jar 2.4.38 转换,不安装 APK,也不使用下载到的官方预转换 JAR替代闭环。 同一 JVM host 加载生成产物,iOS 模拟器枚举出MIHON_524579092615598717,在线第一页返回 10 项。 - 首次转换输出含不存在的
java/util/Object。原 DEX 没有该类型;固定 dex2jar 源码的getCommonSuperClass异常回退分支写错了 Object 包名。最小 smali 用例在转换 classpath 缺少外部父类型 时复现 JVM NoClassDefFoundError;仅纠正输出栈帧的该回退类型,两条分支验证并执行通过。 未改指令/字段/字符串;转换缓存版本升为 4,Windows 同一转换入口也消费这个修复。 - 首次在线列表失败为预览依赖筛选误排除了
serialization-json-okio,导致缺失OkioStreamsKt。 保留独立适配 JAR,基础 serialization/coroutines/Kotlin 仍使用共享版本;无依赖升级。 本机 JVM 对同一转换产物返回 10 项,模拟器 XCTest 也通过;临时诊断代码已移除。
验证与边界
- source-host:89 项,0 失败/跳过;包括原 JSONL、初始化失败清理和新增 JNI ABI/常驻会话。
- dex-convert:15 项,0 失败;2 项未配置真实小说/动漫样本而跳过,13 项执行通过。 新栈帧用例先红后绿,并重新加载原始再漫画 APK 的转换产物通过。
- C/JNI 本机烟测:中文、emoji、NUL、64 位源 ID、20 次多线程列表调用、错误恢复及关闭通过。
- ios-shared Native:2 项数据库回归通过;普通 SwiftUI 分类持久化测试通过。 独立 fixture 的 Zero/JNI 源列表与进程重启通过;真实 APK 的模拟器源枚举与在线列表测试通过。
- 证据:
iosApp/build/jvm-preview-repro.log、jni-smoke.log、ios-host-final-tests.log、dex-frame-red.log/dex-frame-green.log、ios-default-final-test.log、ios-jvm-final-test.log、ios-real-apk-final-test.log和ios-android-final-compile.log。XCTest 的真实列表截图已导出并实际检查。 最新 CLI 转换结果与模拟器已验证的 JAR SHA-256 一致;Android Kotlin 编译通过。 - 本轮是 arm64 模拟器的真实源列表切片,不将 S0/G0/G2/S3 标为完成。真机 Zero、内存/性能与分发许可仍待。 用户扩展导入/更新界面、搜索/详情/阅读、WKWebView/WebKit Cookie、JavaScript 与图形 native 方法尚未接入。 单个真实公开列表成功不能推出全量扩展兼容性;没有使用用户 Cookie、扩展设置或库数据。
92. iOS 真实 APK 详情 / 封面 / 章节与基本图片阅读(2026-10-10)
已实现的用户流程
浏览结果点击漫画 → 详情/作者/标签/封面/章节 → 章节图片 → 前后翻页与同分支换章 → 返回详情/浏览。 默认构建保持原有分类库入口;阅读流程使用 §91 的可选嵌入式 JVM 预览与同一源会话。
IosSourceContent/IosSourceChapter/IosSourcePage保留内部完整共享对象,只导出 Swift 所需的 字段。调用details/pages/image/cover时传回原对象,不从标题/URL 重建协议请求, 因而保留 extension memo、chapter sourceData、页 headers、原生 index 与 image URL fragment。 Swift 的 ID 仍为十进制字符串;共享协议与源执行器未复制。- 新增 SwiftUI 详情与基本图片阅读页,借用父浏览页的
IosSources;子页面退出不关闭常驻 VM。 前后章节继续消费SourceChapterNavigation的来源/分支规则,保留共享章节顺序。 - 封面/正文均通过 JVM
fetchCover/fetchImage下载和FileSourceImageStore物化; iOS 只消费已处理的文件,不用独立 HTTP 请求绕过扩展 headers、Cookie 或 image interceptors。 native consumer 与 host 固定共享应用沙盒Kototoro/sources/images。 - 新增
SourceImageFile:只接受摘要对应的单文件名,拒绝符号链接,校验大小/64 MiB 上限和 SHA-256, 再在后台使用 ImageIO 解码。长边限制 4096 像素,阅读器只保留当前页的解码图片; 没有把章节所有页一次载入内存。文件校验/解码由同一 loader 供封面和正文复用。 参照 Apple ImageIO 缩略图像素上限 与 SwiftUI NavigationLink。 - UI 区分请求失败、空章节/页列表与图片解码失败;可重试;切页/换章重置图片与页码。 Swift Task 取消后不应用过期结果,请求标识避免重入任务清除新任务的 loading 状态。 已进入同步 JNI 的请求尚不能被 Swift Task 中断,仍由源 HTTP 客户端超时结束。
真实源与回归证据
- 同一再漫画 1.6.0 原始 APK 的共享转换产物:本机 JVM 返回《大理寺日志》详情、392 个章节、 首章 2 页,第一张 JPEG 345381 字节、封面 JPEG 33852 字节;未更换 APK 或使用预转换 JAR。
- iOS 模拟器 XCTest 通过真实 APK 的列表 → 详情/章节 → 第 1 页 → 第 2 页。 实际截图已导出并检查:显示封面、章节数,以及可辨认的漫画内容,而不只是图片 URL。 图片文件来自 JNI 执行的源请求。
- Native 回归 4 项通过:原数据库 2 项,加新增 DTO/完整隐藏字段往返与图片/封面身份校验 2 项; 新用例包含 Long.MIN_VALUE、Long.MAX_VALUE 及超过 JavaScript 精确范围的页 ID。
- 独立 JVM fixture 的 XCTest 通过源枚举、进程重启、详情/章节、PNG 解码、前后翻页、首末页按钮、 下一章及返回详情/浏览;
SourceImageFileTests在模拟器执行生产校验/解码实现,覆盖 PNG 解码、 等长篡改、大小错误、越界路径、符号链接、超限和不能解码的 HTML。 - 既有 host 图片/详情回归 29 项通过:
MihonSourceRuntimeTest21 项、FileSourceImageStoreTest8 项。 修正自有测试 PNG 的 IDAT CRC(尺寸/字节长度不变),消除 PNG 解码时的 CRC 报错;未修改真实源图片。 - 默认 SwiftUI 分类创建/重启与图片文件测试通过。日志位于 ignored
iosApp/build:ios-reader-native-test.log、ios-reader-host-test.log、ios-reader-jvm-probe.log、ios-reader-default-test.log、ios-reader-fixture-final-test.log、ios-reader-real-apk-test.log与ios-reader-final-build.log; 真实详情/阅读截图位于iosApp/build/reports/real-reader。
原则与边界
- KISS/YAGNI:交付首个基本图片阅读流程,没有先引入新的图像库、网络栈、导航框架或 DI; 未改动共享协议、Android 生产代码或依赖版本。
- DRY/SOLID:共享对象/章节导航/协议/源下载继续由已有模块负责;iOS 分开负责 DTO 投影、 文件校验/解码和页面状态,封面与正文共用解码实现。
- 当前是基本按页图片阅读,未接入完整
reader-core场景、缩放/分块、预读、离线下载、 收藏/历史与阅读进度持久化;搜索/列表分页/用户 APK 导入仍待实现。 WKWebView、完整 WebKit Cookie、JS/图形 native、真机与 S0/G0/G2/S3 仍按原计划验收。 单个公开源的详情/图片成功不代表全量扩展兼容。
93. iOS 原生苹果 CMS 影视源与系统播放器(2026-10-10)
调用链:SwiftUI → IosSources → RoutedSourceRuntime → MacCmsSourceRuntime(Kotlin/Native)→ Ktor Darwin。 JSON_TVBOX_* 源原生执行,其余源仍路由到 §91 的可选 JNI/JVM 会话;默认构建(不含 JVM)即可使用。
- 新增 KMP 模块
:source-tvbox(commonMain,jvm + iosArm64 + iosSimulatorArm64,无 Android/JVM 专有 API):TvBoxImport:TVBox 配置(注释/尾逗号/xxxxxxxx**Base64 包装)、urls多仓(最多 3 层、去重)、 MacCMS API 地址与"看起来是 CMS 响应"的地址;只导入 type 0/1 站点,type 3/4 与csp_计入跳过数。 AES 加密配置明确报不支持。- 存储与 Android 完全一致:写入共享
json_sources(type=TVBOX),配置为schemaVersion/importType/site/root/meta,ID 为JSON_TVBOX_+ 指纹 Java 字符串哈希的无符号十六进制。 MacCmsParser移植 AndroidTVBoxRepository的 CMS 分支:JSON 载荷遍历、XML(MiniXml,不展开 DOCTYPE/实体)、vod_play_from/vod_play_url线路解析、可播放地址判定、列表/详情 URL 变体、header 规则与 Referer。tvbox://item|chapterURL、item/chapter/page 63 位 ID 与 Android 同一公式,便于后续历史/同步互通。- 与 Android 唯一的有意差异:章节
sourceData附带{"line": 线路名},供 iOS 按线路分组;ID/标题/branch 不变。 MacCmsSourceRuntime : SourceRuntime:PAGE_INDEX 分页、分类标签cms_type:<id>、检测结果与条目缓存、NEWEST保留站点原始顺序(默认仍为 Android 的 ALPHABETICAL)。爬虫站点返回 SourceUnavailable。
ios-shared:IosHttp(Ktor 3.5.0 Darwin 引擎,版本复用目录既有 Ktor)、IosVideoSources(导入/列出/删除, 保留已有 enabled/created/pinned)、IosSources(transport?, videoSources?)路由;catalog()中扩展运行时失败 不会隐藏原生源;page(...)按源分页模式换算 offset;投影新增 coverUrl/summary/line/页地址与请求头。- SwiftUI(部署目标提升到 iOS 26,直接使用系统液态玻璃:
.glass/.glassProminent按钮、glassEffect、GlassEffectContainer、TabViewtabBarMinimizeBehavior):浏览页影视/扩展分区与导入表单、源内 2:3 海报网格- 分类胶囊 +
.searchable+ 无限滚动;详情页按线路分段选择、剧集按钮网格;AVPlayerViewController播放(原生控件/AirPlay/画中画),AVURLAssetHTTPHeaderFieldsKey传站点 headers,播完同线路自动下一集。Info.plist允许任意 HTTP(用户 CMS 与媒体普遍为 http)并开启后台音频(画中画)。 漫画详情/阅读沿用原流程;扩展封面仍由源客户端下载,原生源用 AsyncImage。
- 分类胶囊 +
验证
:source-tvbox:jvmTest11 项通过(JSON/XML/搜索/分类/线路/ID 公式/导入/多仓/Base64/URL 解析与编码);:source-tvbox:compileCommonMainKotlinMetadata通过。:ios-shared:iosSimulatorArm64Test6 项通过:新增真实 Room 导入 → 关闭重开 → 路由运行时列表/详情/线路/播放地址 → 删除;Android 导入的爬虫站点留在表中但不提供;仅含爬虫站点的配置给出原因。- XCTest(默认构建,无 JVM):
VideoSourceTests对本机iosApp/Scripts/serve-cms-fixture.py(ffmpeg 生成的 HLS) 完成 UI 导入 → 网格/服务端分类筛选 → 详情/线路选择器 → 播放进度推进 → 下一集且不跨线路 → 重启后源仍在; 分类持久化与图片文件回归同时通过,JVM 预览用例按设计跳过。截图已实际检查(iosApp/build/reports/video)。 - 未运行
:app:compileDebugKotlin:本轮未修改 Android 源码,只在版本目录新增ktor-client-darwin条目。
真实仓库验收(同日补充):使用 Kototoro 仓库中心 的 TVBOX 条目 (28 个 CMS 接口 + 1 个多仓配置)。可选在线探测 MacCmsOnlineProbeTest (-PtvboxOnlineList=<文件>,可选 -PtvboxProxy=host:port,报告写入 source-tvbox/build/reports/tvbox-online.txt) 逐站执行导入 → 分类 → 第 1/2 页 → 分类筛选 → 搜索 → 详情 → 播放地址 → 实际 GET 校验 #EXTM3U。
- 三轮(直连 ×2、代理 ×1)中 27/28 个 CMS 接口至少一轮全链路通过(JSON 与 XML/
seaxml都有);失败均为网络层: 直连超时/RST、代理出口 403(地区限制),sdzyapi 直连与代理均不可达。站点可用性随网络环境变化。 - 真实问题与修复:多仓中的中文域名(
饭太硬.net、肥猫.net)需要 IDN → 新增通用 Punycode(RFC 3492, 与java.net.IDN对照测试);JPEG 伪装 +xxxxxxxx**Base64 配置在 KotlinBase64.Mime下解码失败 → 改为过滤到字母表后宽松解码。修复后该多仓可完整读取:293 个站点全部为 type 3/4 爬虫、0 个 CMS, iOS 现提示"N 个站点都需要爬虫运行时"而不是首个子仓错误。另:JDK HttpClient 对 http:// 自动发Upgrade: h2c会让部分 TVBox 主机返回 HTML,探测客户端已固定 HTTP/1.1(URLSession/OkHttp 不发此头,不影响应用)。 - iOS 模拟器
VideoSourceTests.testRealAppleCmsSitePlaysOnSimulator(TEST_RUNNER_KOTOTORO_REAL_CMS=<接口>): 光速(XML)、暴风、量子(JSON)三站 UI 导入 → 真实海报网格 → 详情 24 集 → AVPlayer 实际播放均通过, 截图已检查(iosApp/build/reports/real-cms)。播放器标题与详情一致去掉线路前缀。
边界:未接入解析(parses/嗅探)、直播(lives)、弹幕、播放进度/历史持久化、 type 3/4 爬虫与加密配置。Android TVBoxRepository 尚未改为消费 :source-tvbox(两份实现规则一致,后续可收敛)。
94. iOS:Aniyomi / Tsundoku / JAR 解析器插件接入嵌入式 JVM(2026-10-10)
调用链:与 §91 相同的 SwiftUI → IosSources → JNI → SourceHostSession,会话内改为与桌面一致的 RoutedSourceRuntime:MihonSourceRuntime(Mihon 漫画 + Tsundoku 小说)→ AniyomiSourceRuntime(视频)→ 平台提供的额外路由(parser-host 的 kototoro / kotatsu / UMA JAR)。苹果 CMS 仍在 Kotlin/Native 原生路由(§93)。
- source-host:
SourceHostConfig.plugins(path/id/sha256,可选字段,旧配置兼容);新 SPISourceHostRuntimePlatform.openRuntimes(plugins, images, preferences)返回SourceHostRuntimes(路由 + 关闭), source-host 不依赖 parser-host。配置了插件但平台不支持时明确拒绝。会话改为并发 exchange:关闭先拒绝新请求、 等待进行中的请求,再按 插件运行时 → registry → 平台 → 偏好 顺序释放;Mihon/Aniyomi 运行时内部按源串行的语义不变。 开发诊断-Dkototoro.source.diagnostics=stderr输出宿主侧异常栈(协议仍不携带原因,默认关闭)。 - mihon-ios-compat:直接编译
mihon-desktop-compat的 Aniyomi animesource ABI 源码(只依赖两套 API 都有的 OkHttp/Rx/Injekt/jsoup,不复制第二份);MihonIosPlatform实现 SPI,IosParserPlatform与 Mihon 扩展共用 Mobile API 的NetworkHelper.client(Cookie/清除共享);位图重绘与 JS 尚未提供,需要的解析器按基础错误失败。 - dex-convert CLI:一次接受多组
<输入> <输出>(-PconvertPairs=a|b|c|d),自动区分 APK 扩展与纯 DEX 插件 JAR, 每组输出CONVERTED {json}(包名/versionCode/sha256/类数)。 - 装配脚本
stage-mihon-preview.py:从仓库中心列出的仓库下载并按 SHA-256 固定 5 个样本——再漫画(Mihon)、 AllNovelFull 1.6.19(Tsundoku,NovelSourcery release)、AnimeGG 14.2 / Anime1.me 14.4(Aniyomi,yuzono)、 kototoro-parsers 插件 137(纯 DEX,repo/apk/plugin.jar);一次 Gradle 调用完成转换,生成 jars + plugins 配置。 classpath 去重:Kotlin/coroutines/serialization 用本项目版本,其余重叠构件(OkHttp/okio/jsoup…)只保留 Mobile API 的一份;parser-api、parser-host、rhino、org.json、tsuki-runtime 由适配器补齐。probe-jvm-preview.py在 Mac JDK 24 上用同一 classpath/配置通过 JSONL serve 做真实网络探测(可经KOTOTORO_PROBE_JVM_OPTIONS传代理)。 - iOS 投影/界面:
IosSource.ecosystem/mediaKind/isAdult;IosSources.chapterText(HTML + 插图及其请求头)。 浏览页按生态分区(Mihon / Tsundoku / Aniyomi / 解析器插件),支持搜索,成人内容源默认隐藏(显示菜单开关)。 新增原生NovelReaderView:HTML → 段落/插图块(无 WebView),字号/行距可调,插图带站点请求头加载并降采样, 同源前后章。播放器保留一集的全部流(Aniyomi 多清晰度/hoster),菜单切换并续播,加载失败自动尝试下一路。 长集名每集一行,短集名用每行 4 个的分块网格(千集级剧集保持懒加载)。JVM 封面最多 2 个并发,视图消失后的排队请求 不进入 Kotlin,避免同一源的详情排在整页封面之后。Swift 传输层只在打开/关闭时加锁,关闭等待进行中的 JNI 调用。
验证
- JVM:source-host 92 项(新增同一会话路由漫画/动漫/插件、插件先于平台关闭、无 SPI 拒绝插件、并发请求与关闭等待); dex-convert 15(2 跳过)、parser-host 23(2 跳过)通过;ios-shared Native 7 项通过。
- Mac JVM(Mobile API classpath)真实网络:再漫画 列表10→392章→图片;AllNovelFull 列表20→2166章→正文;AnimeGG 列表125→1165集→5路流;JAR:MANGADEX(36页图片)、BILINOVEL/ROYALROAD(正文)、COPYMANGA(需代理,WebP 图片)、 FANTUAN/NIUNIUZY/DMBUS/SENZHIWU(m3u8)。失败均定位到站点/网络:Anime1 CDN DNS 失败、BAOZIMH 与 WENKU8 需要浏览器验证 (待 WKWebView 桥)、AGEDM 默认线路需 VIP。
- iOS 模拟器(arm64 Zero,真实网络):
ExtensionEcosystemTests5 项——Tsundoku 正文、Aniyomi One Piece 实际播放、 JAR MANGADEX 图片、JAR BILINOVEL 正文、JAR FANTUAN 实际播放——全部通过,再漫画回归通过,截图已检查 (iosApp/build/reports/eco2)。修复过程中定位:插件相对路径未被 Swift 改写为包内绝对路径(INVALID_ARCHIVE); 请求在 Swift 锁 + 会话锁下完全串行,导致详情排在整页封面之后超时。
边界:APK/DEX 转换仍在 Mac 进行;没有用户安装/更新界面;WKWebView/Cloudflare、位图重绘、JS 引擎未接入; 外挂字幕/音轨尚未接入 AVPlayer;Zero 解释执行下千集级详情耗时较长(AnimeGG 约 30–60 秒),需要真机基线。
95. iOS 漫画下载队列、暂停与按页恢复(2026-10-10)
已有产品闭环的当前状态见 主要功能实施记录。本次继续推进下载生命周期:
- 单章/多选下载通过同一个应用级
ReaderDownloadQueue,退出阅读器后前台任务继续。 设置和阅读器面板均提供下载管理入口,可暂停/继续、重试与移除任务;移除任务保留离线图片。 - 队列在
sources/downloads/queue.json持久化(fixture 使用独立sources-fixture); 任务按来源/作品/章节身份去重,最多 500 条/32 MiB,写入成功后才发布状态。IosDownloadArchive保留源 memo、所选章节与精确 64 位身份,整部作品章节目录不复制进每个任务。 - 已进入 JNI 的网络调用无法被 Swift 直接打断;暂停会保存状态并发出取消,worker 等旧调用结束再执行后续任务。 进入后台暂停正在执行与等待中的任务;中断的执行任务重启后恢复为暂停,用户手动继续。 按 Swift 协作取消 与 ScenePhase 生命周期 接入。
- 下载逐页保存完整页面上下文与图片 artifact 检查点。继续时重新校验已存图片的长度、SHA-256 和解码; 完整保存后原子发布离线清单,损坏图片重新获取,损坏清单重新请求页面。 复用源客户端下载和原生 ImageIO,无新增网络栈、数据库、依赖升级或共享源协议分叉。
- Native 15 项通过;Swift 队列 5 项、图片 2 项和分类重启 1 项通过; 嵌入式 JVM fixture 阅读流程通过,下载管理及重启保留记录截图已检查。
compileKotlinIosArm64通过;日志/截图位置见产品记录。
边界:前台漫画队列支持页面级恢复,没有 HTTP 字节级断点续传或系统后台调度。 真实慢源中断的完整恢复、长期大队列和真机存储/内存压力尚未验收;小说/视频下载、 WKWebView、真机 Zero 与分发门槛继续按原计划推进。
96. iOS 原生扩展管理(2026-10-10)
- 对照 Android
UnifiedSourcesScreen的仓库、扩展状态、生态与语言管理,采用 SwiftUI 原生List、分段Picker、searchable、工具栏Menu、添加Form与详情 sheet。 添加表单从常驻列表移入工具栏,扩展按更新/已安装/语言分区,仓库单独管理。 - 扩展详情展示名称、版本、仓库、所含来源、成人标记与可展开包信息;沿用设备转换、安装与确认卸载。 成人发现与浏览页共用偏好,已安装成人扩展仍保留管理入口。默认无 JVM 构建可浏览目录。
- Swift 列表投影按生态+包名合并多个仓库,优先最高版本,同版本按仓库身份稳定选择。 仓库详情保留各自目录,安装绑定明确的候选仓库;旧版本禁止覆盖较新安装。 仓库独立刷新/报错,失败保留已有目录;移除仓库不删除已安装扩展。
SourceHostManagement的列表回复补充可选名称/版本/来源/语言/成人元数据,Swift 兼容旧回复。IosExtension只投影共享索引的来源名称和图标,继续复用同一解码器、仓库表与运行时。ExtensionPresentation负责派生与筛选,RepositoriesModel负责加载和安装编排;无新增依赖。- Native 15 项、host 会话 8 项、默认 Swift/UI 10 项定向回归通过,真机 Native 切片编译通过。 添加、列表、仓库与扩展详情截图已实际检查,位于
iosApp/build/extensions-final-attachments/。 日志与真实安装验证见 产品实施记录。
边界:本次改善既有四类扩展的管理体验,不扩大生态;真机 JVM、签名分发、iPad/横屏与 辅助功能运行验收仍按原计划单独推进。
97. iOS 图书馆备份、浏览筛选、详情页与阅读手势(2026-10-11)
备份与恢复(设置 → 备份与恢复):与 Android/Kotatsu 同一 ZIP 格式(每节一个 JSON 数组,index 必须唯一)。
- 桌面
DesktopLibraryBackup的导出与合并规则抽到core-library的LibraryBackup(commonMain), 经LibraryBackupInput/LibraryBackupOutput与平台归档解耦;桌面保留原公开接口,只负责 JVM ZIP 与临时文件。 合并规则不变:分类按标题合并、较新的updatedAt胜出、统计取较大值、旧 work 节按锚点投影、本地表从不清空。 - iOS
IosBackupZip用 Kotlin/Native 的系统 zlib 读写(STORED/DEFLATE、数据描述符;拒绝 ZIP64、加密、 重复/含路径的节名,压缩包 ≤256 MiB、单节 ≤256 MiB、总解压 ≤1 GiB)。预览前校验每节 CRC 与长度并统计行数, 预览持有内存快照,选中文件之后的变化不影响确认后的恢复;导出先写.partial再原子改名,目标已存在时拒绝。 - SwiftUI:系统文件导出器保存、文件选择器读取(安全作用域),预览列出各节数量与 iOS 不恢复的节 (设置、追踪账号等)。同一视图同时挂
fileImporter与fileExporter只会生效一个,二者分别挂在各自按钮上。
浏览筛选:IosSources.filterPanel 投影源描述的排序方式、多标签/排除能力与筛选选项(标签组、连载状态、 内容分级、类型、受众、语言、年份);filteredPage 以稳定 key 解析回源自己的 SourceTag, 不支持“搜索 + 筛选”的源在搜索时只发送关键字。SwiftUI 筛选表单:标签点按包含、再次点按排除,单选组/单标签源 自动替换;快捷分类胶囊与筛选面板互相同步。
源名称与图标:SourceHostRuntimes.titles 把 parser-host 插件的可读标题并入 host 管理回复的 sourceTitles (此前仅 Mihon 扩展有标题,JAR 源显示枚举名)。SourceDescriptor.homepage(可选,旧 host 兼容)由 parser 的 domain、Mihon/Aniyomi 的 baseUrl 提供;iOS 按需描述可见行,取站点 apple-touch-icon.png/favicon.ico, 磁盘缓存,失败一周后重试,期间显示按名称着色的首字图标。
封面网格:封面固定为 2:3 占位框并以 overlay 填充裁切,图片自身比例不再撑开网格;解码后的封面进入 CoverCache(NSCache,64 MiB 成本上限),懒加载格子重建时直接取缓存,不再回滚后重新转圈。原生 CMS 封面改为 ImageIO 降采样(最长边 600 px)。
详情页:参照 Android——模糊封面背景、封面阴影、标题/别名/作者/来源、状态/评分/章节数标签、 主按钮(开始/继续阅读或播放,显示目标章节)+ 收藏按钮、标签流式布局、可展开简介;章节列表支持搜索、 正倒序(持久化)、多分支选择(默认进入正在阅读的分支)、当前章高亮与进度、已读章节淡化、扫描组与日期;下拉刷新强制重取详情。
阅读手势与分页手感:分页模式直接使用系统 .paging(对应原生分页滚动:一次一页、系统松手动画);此前包装成 自定义 ScrollTargetBehavior 后失去原生分页,松手动画慢且快速轻扫会按速度投射到后面多页。新增 Android 式点击区域: 左右 30% 与上下 20% 翻页(向左阅读模式镜像,可整体反转或关闭),中间显示/隐藏控件;连续模式点击滚动 85% 视口; 越过首末页切换相邻章节,向前切章从上一章末页开始。双击缩放与双指缩放保留。
验证(arm64 模拟器 iPhone 18 Pro / iOS 27,日志在 iosApp/build/)
:desktop-runtime:test备份 16 项(抽取后)、:source-host:test94、:parser-host:test23(2 跳过)、:core-source:jvmTest53 通过;新增会话回归验证插件标题进入管理回复。:ios-shared:iosSimulatorArm64Test18 项通过:新增导出→另一数据库预览/合并(同名分类合并、二次合并幂等、 书签/进度保留)、Pythonzipfile流式生成的 Android 风格 ZIP(数据描述符 + 不支持的settings节)、 损坏/非 ZIP/缺失文件在写入前拒绝。iOS 导出文件经unzip -t与 Pythonzipfile交叉读取通过。- 默认构建 UI:
BackupFlowTests经系统文件导出器保存到“我的 iPhone”→ 文件选择器选回 → 预览 → 合并; CMS 用例新增筛选面板与分类胶囊同步、重置恢复全部列表;其余默认用例(分类重启、CMS 收藏/续播、仓库管理、 下载队列、链接解析等)通过。LibraryPersistenceTests因设备上累积分类超出首屏而改为滚动查找。 - JVM fixture 预览:阅读器端到端新增点击区域翻页(左缘前进、右缘后退、中间切换控件),换章/下载/重启续读与 6 种方向×模式的居中用例通过(居中用例末尾复位步骤偶发一次失败,单独重跑通过)。
- 真实扩展预览(重新
stage-mihon-preview.py):MangaDex 列表显示“MangaDex”与站点图标,阅读图片通过; 详情/浏览/筛选/阅读截图已实际检查。:app:compileDebugKotlin、:core-library:compileCommonMainKotlinMetadata、:ios-shared:compileKotlinIosArm64与文档构建通过。
边界:备份不含设置、追踪账号、保存的筛选与扩展仓库(预览中列出为“iOS 暂不恢复”);Mihon 动态 FilterList 控件已在 §98 接入;源图标依赖站点公开 favicon;真机手感与辅助功能待验收。
98. iOS 扩展原生筛选控件与解析器扩展筛选项(2026-10-11)
范围:Mihon、Tsundoku(经 MihonSourceRuntime)与 Aniyomi(AniyomiSourceRuntime)声明 isDynamicFilteringSupported 时,IosSources.filterPanel 额外调用 getDynamicFilters,失败时回退到原标签投影。 IosFilterPanel.controls 以 IosFilterControl 树暴露扩展自己的 FilterList(header/separator/checkbox/tristate/ select/sort/text/group/unsupported),初始值来自扩展当前状态;IosFilterValue 为 Swift 友好的类型化值, IosFilterSelection.dynamic 只携带与默认不同的控件,由 Kotlin 按控件类型转换为 SourceFilterChange (越界索引/未知 ID 丢弃,排序索引 < 0 表示清除),host 端仍按 MihonFilterRules.changes(strict) 校验。 JAR 解析器(kototoro/kotatsu parsers)按能力新增年份区间、原作语言(沿用 Android:选项同 availableLocales) 与作者输入;不修改 parser-host 映射与协议。
界面:SourceFilterSheet 对动态面板按扩展 header 分节;开关、三态/下拉/排序为菜单 Picker,排序可切换升序, 文本为行内输入框;只含勾选/三态的分组(如类型)折叠为可展开的胶囊(点击:包含 → 排除 → 不限),混合分组 递归嵌套;未知控件显示“暂不支持”。标签搜索同时过滤分组胶囊。快捷分类栏对动态源只列真实勾选/三态, 不再出现排序值或“📝 文本”伪标签,并与面板编辑同一原生控件。搜索激活时系统隐藏工具栏按钮,分类栏前新增 “筛选”胶囊(带已选数量),搜索中也可打开面板。
验证:
:ios-shared:iosSimulatorArm64Test20 项通过,新增IosSourceFiltersTest:类型化转换、快捷标签、 清除排序/越界丢弃,以及解析器年份区间/原作语言/作者。- JVM fixture 预览:
testExtensionFilterControlsReachNativeFilterList修改开关、三态、下拉、排序+升序、文本、 二级嵌套分组,fixture 搜索返回的原生状态与预期一致;重开面板保持状态,重置恢复扩展默认。SourceRuntimeTests全部 3 项通过。 - 真实扩展预览:
testFilterPanelsAcrossEcosystems对再漫画(Mihon)、AllNovelFull(Tsundoku)、 AnimeGG(Aniyomi)、MangaDex(JAR)打开面板、修改一项并成功加载筛选结果;截图已检查各自原生控件。 - 默认构建
VideoSourceTests(苹果 CMS 标签筛选)通过。
边界:Mihon 的排序方式选择(人气/最新)在应用筛选后与 Android 相同地由扩展搜索决定;保存的筛选预设、 扩展自定义控件类型仍未支持。
99. iOS 仓库深链闪退与无库版本标记扩展(2026-10-11)
问题与修复:
- 从仓库网页点“导入”跳转应用即闪退:
onOpenURL打开的仓库 sheet 挂在environmentObject之外的修饰链上,RepositoriesView读取LibraryModel时触发EnvironmentObject.error()(模拟器simctl openurl复现, 崩溃报告定位到 RepositoriesView.body)。sheet 内容现显式注入 library/sources/downloads。 - 与 Android 清单一致,iOS 额外登记
tachiyomi://、aniyomi://、anikku://的add-repo链接:kind参数优先, 缺省时按 scheme 推断(tachiyomi/kototoro → MIHON,aniyomi/anikku → ANIYOMI)。 - 安装第三方仓库(如 LittleSurvival/copymanga-copy20)的 Mihon 扩展报
UNSUPPORTED_LIBRARY:这些 APK 清单没有tachiyomix.extensionLib,Mihon/Android 加载器本就按 versionName 推断(1.4.85 → 1.4)。MihonJarInspector对 Mihon 与 Tsundoku 统一从 versionName 推断,Aniyomi 规则不变;受支持范围仍为 1.4–1.6。
验证::source-host:test 95 项通过(新增无标记 1.4.85 接受、1.3.2 拒绝);RepositoryLinkTests 3 项通过; 真实扩展预览重新构建后,testExternalRepositoryLinkImportsAndInstallsMarkerlessApk 通过外部深链打开应用、 导入该仓库并安装 VomicMH(CopyManga 带 NSFW 标记默认隐藏);ProductFlowTests 其余 2 项(本地仓库 APK 转换安装、CMS 收藏/历史/续播)通过。
边界:真机上的深链与安装需在设备上复测;安装失败的 host 错误码已映射为中文说明并保留原码。
100. iOS 详情页全景封面(2026-10-11)
实现:DetailsPanorama 对照 Android AnimatedPanoramaBackdrop:封面全宽铺在详情页顶部并延伸到状态栏/导航栏 下方,按预设模糊(blur% × 18pt)、顶部不透明度与渐隐范围遮罩(同 Android 的 0.18–0.82 起点、0.96 结束、 0.35/0.70 中间点)融入分组背景。三种预设(清晰/均衡/柔和)与两种布局沿用 Android 数值:顶部横幅(半屏,默认) 与全屏背景。顶部横幅随列表滚动移出、下拉时拉伸;可选缓慢平移缩放动画(系统“减弱动态效果”时关闭)。 滚动偏移来自 onScrollGeometryChange;导航栏标题在头部滚出后才显示。头部为横幅预留 48pt,标题信息叠在渐隐区。 2026-10-12 调整顶部横幅:通过封面卡片的布局 anchor 取得底部位置,结合滚动偏移还原静止高度, 使 0.96 的渐隐结束点对齐卡片下边缘,替代按屏宽估算高度;列表回收头部时保留最近的测量值。 设置页新增“详情页”分组(开关/布局/效果/动画/跟随滚动,@AppStorage);关闭时回退到原模糊背景。 CoverImage 新增无占位的装饰模式;同一封面的并发请求(详情缩略图与全景)合并为一次,所有等待者离开后才取消, 保留网格快速滚动时退出封面队列的行为。
验证:真实扩展预览 testDetailsPanoramaHeader(MangaDex 详情静止/滚动/回顶截图已检查)与 testJarMangaPagesRender 通过;JVM fixture SourceRuntimeTests 3 项通过。章节行因头部变高而落在首屏之下, UI 测试统一改用 revealChapter(详情加载后滚动直到首个章节行出现)。 此次下边缘调整的同一 UI 回归再次通过,静止、滚动和回顶截图已检查。
边界:封面沿用 600px 解码结果,清晰预设下大屏放大会略软;滚动联动在真机上的帧率待验收。
101. iOS 作品库与统一作品列表显示模式(2026-10-11)
作品库:第一个标签页由“漫画库”改为“作品库”(Library),独立为 LibraryView。收藏作品默认以 2:3 封面网格 展示(阅读进度显示为封面底部进度条),支持标题/作者搜索与“最近更新/标题/阅读进度”排序。收藏分类改为顶部 单行可横滑的玻璃胶囊(全部 + 各分类,附作品数),点选筛选、长按重命名或删除;新建分类保留在工具栏。 IosLibrary 新增 renameCategory(保留排序、追踪与书架可见性)与 removeCategory(同 Android:同一事务删除该 分类的收藏行并软删分类、清理章节;作品的其他分类保留,仅在该分类中的作品取消收藏)。
统一显示模式:ContentCollection 对照 Android ListMode 的列表、详细列表、网格、紧凑网格(标题叠加在封面 渐变上)四种模式,以 LazyVGrid/LazyVStack、Menu+Picker 实现;作品库、来源浏览、历史与更新共用, 各页面以 @AppStorage(listMode.library/browse/history/updates)分别记住选择。详细列表显示作者、来源、 标签与纯文本简介;更新记录以角标显示新增章节数。上下文菜单提供整理分类/取消收藏、删除记录或分享。 浏览页的分页、source.content. 标识与筛选不变;原 PosterCard/LibraryContentRow 移除。
验证::ios-shared:iosSimulatorArm64Test 21 项通过(新增分类重命名/删除保留其他分类成员关系); 真实扩展预览 testDisplayModesInBrowseAndLibrary 在 MangaDex 浏览页与作品库逐一切换四种模式并截图检查, 结束时取消测试收藏以免污染共享模拟器数据。默认构建全量 UI 中分类持久化改为按分类胶囊存在性断言、CMS 闭环按 作品标题定位后通过;JVM fixture SourceRuntimeTests 3 项通过,居中用例末尾复位等待旋转完成后不再偶发失败。
边界:Android 的电影网格(16:9)与网格尺寸滑杆、分类拖拽排序与“在书架显示”开关尚未移植。
102. iOS 网格尺寸/电影网格、TVBox 并入扩展管理、JAR 插件安装与来源开关(2026-10-11)
显示模式:ContentListMode 增加电影网格与紧凑电影网格(16:9,标题叠加于紧凑版),共六种,与 Android ListMode 一致;网格尺寸 50–150%(list.gridSize,全局)按 Android 共享卡片几何计算目标宽度(海报 96pt、电影 176pt 夹在 160–264),LazyVGrid(.adaptive) 的列数公式与 Android 相同。显示选项改为原生 popover:六个模式磁贴、网格尺寸 滑杆(列表模式禁用)及页面附加项(作品库排序)。
TVBox 并入扩展管理:苹果 CMS/TVBox 最早作为独立原生能力(§93)在浏览页单独入口,扩展管理(§96)后来已支持 kind=TVBOX。现移除浏览页“添加源”按钮与独立表单,添加仓库表单在 TVBox 类型下提供可选站点名称;扩展管理“仓库”页 在扩展仓库之后列出“影视站点”,可滑动/长按删除。浏览页不再单独分区:TVBox 与 Mihon/Tsundoku/Aniyomi/JAR 作为同级 生态分组,行样式、图标与搜索一致;没有任何来源时引导至扩展管理(默认构建预选 TVBox)。
JAR 插件:
- 旧格式 JSON 索引仓库此前以索引所在目录命名(GitHub 仓库普遍显示为“repo”),现同 Android
deriveRepoName:GitHub 托管 为“owner/repo”,已保存的“repo”在读取时修正。 - 语言筛选会隐藏
all/multi插件,导致扩展页看不到 JAR 安装入口;语言无关的包现在在任意语言筛选下都显示。 成人插件(k-parsers-r 的nsfw: 1)在仓库详情中提示“另有 N 个成人内容扩展已隐藏”并可一键显示。 - 实测 kototoro-parsers、k-parsers-r 与 UMA 的 JAR 只含
classes.dex,须在设备上由仅解释器的 Zero JVM 转换: 本机 HotSpot-Xint转换 kototoro-parsers 需 26 s(JIT 2.2 s),设备约一分钟,k-parsers-r(5.6 MB DEX)数分钟; 转换结果确定(两次 SHA 相同)。安装时显示大小与预计耗时,并关闭自动锁屏、申请后台任务时间。 - 嵌入 JVM 堆原为
-Xmx128m,两个大型插件同时加载时OutOfMemoryError;现由 Swift 按物理内存 1/8 设定(256–768 MB)。 - 每次插件安装原会
openRuntimes重新加载全部插件并保留旧副本到会话结束(内存随安装次数增长)。SourceHostRuntimes新增update:单个插件安装/卸载在现有 registry 中原地replace/unload(先构建校验再持久化),其余插件不动, 仅复杂变更回退完整重建。 - 安装/卸载后删除
extensions/中未被安装记录引用的转换产物,会话启动时清理中断转换遗留的临时文件。 - 预览随包插件 id 改为仓库包名
org.skepsun.kototoro.parsers,从仓库安装时替换随包副本而非重复加载;DEFAULT_PRIORITY补充仓库包名别名,使同名来源按既定插件优先级解析。 - 启动阶段计时输出到 stderr(
[source-host] …);当前真实预览启动:平台 1.1 s、7 个扩展 1.0 s、插件 3.1 s。
来源开关:状态写入共享 sources 表(MangaSourcesDao.setEnabled,随备份同步,与 Android 一致)。管理回复为每个扩展/ 插件附带 sourceIds,IosSource 得到所属 group 与该组语言数;未显式设置的来源按默认规则:多语言扩展/插件只开启 系统首选语言(前三个)与多语言来源,单语言扩展与影视站点全部开启。扩展管理新增“来源”页:按扩展分组(组标题显示 已启用/总数,菜单提供全部开启/关闭/仅开启我的语言)、已启用/未启用筛选,并复用搜索、语言、生态与成人设置;扩展详情 同样列出其来源开关。浏览页只显示已启用来源,列表行可滑动停用,底部“N 个来源未启用 · 管理来源”。
浏览页布局与筛选:列表/网格(紧凑图标磁贴,按生态分组)两种布局;“显示”菜单(保持展开)包含布局、内容类型 (漫画/小说/视频)、来源类型(存在的生态)与成人内容开关,筛选以“隐藏集合”存储,新生态默认显示。
分组、排序与置顶:浏览页可按来源类型、内容类型分组或不分组(sources.grouping);排序为手动顺序或名称。置顶与 手动位置写入同一 sources 表的 pinned/sort_key(与 Android 一致,IosLibrary.sourceSettings/setSourcePinned/ setSourceOrder;保存顺序时为尚无记录的来源写入其当前生效的开关,不改变启用状态)。置顶来源在任何分组下都位于顶部 “置顶”分区并带图钉标记,列表行右滑置顶/取消置顶,网格长按菜单同样提供;“调整顺序…”打开原生拖动排序表(置顶与其余 分区分别排序)。
验证::source-host:test 96 项、:parser-host:test、:ios-shared:iosSimulatorArm64Test 23 项通过(新增仓库命名、 来源开关持久化、插件原地更新与产物清理、sourceIds);真实扩展预览 testDisplayModesInBrowseAndLibrary(六种模式与 网格尺寸)、testSourceSwitchesLayoutAndFiltersInBrowse(停用后从浏览消失、默认语言规则、网格与内容类型筛选)通过; 默认构建 testBrowseGroupingPinningAndOrder(三种分组、置顶分区、排序表)与 CMS 导入/播放用例通过, 截图已检查;JAR 仓库测试确认名称与安装入口,kototoro-parsers 安装成功。
边界:大型 DEX 插件首次安装仍需数分钟;根本解法是仓库同时发布 JVM 字节码版本(或预转换产物),由 iOS 直接安装。 真机报告的 JAR 安装 “Failed requirement” 未在模拟器复现,需真机日志确认具体校验点。 “新来源”提示与按使用频率排序未移植。
103. iOS 播放器与全局搜索验收(2026-10-11)
播放器:使用 AVPlayerLayer 展示视频,自定义一套控制栏,避免系统播放器与应用关闭按钮重叠。 单击显示/隐藏控制栏,播放时 3.5 秒后自动隐藏,暂停时保持显示;支持上一集/下一集、前后跳转 10 秒、 进度拖动、播放速度、扩展返回的多线路、音轨/字幕菜单,以及双击切换适应/填充。 AirPlay 与画中画使用系统控制器。UI 测试通过 playerControl 在操作前显示控制栏。
搜索:底部新增独立搜索标签,搜索收藏/历史与已启用来源,每次最多并发请求四个来源。 来源按置顶和手动顺序排列,分别展示加载、结果或失败;支持重试失败来源、内容类型筛选、仅搜索置顶来源、 最近搜索,以及“更多”携带查询进入来源列表。来源列表使用顶部搜索按钮。 首次进入搜索页曾只显示最近搜索而没有输入框:按 Apple 的搜索标签示例, 将 .searchable 放到 TabView,查询通过绑定交给搜索页;来源标题的测试标识只挂在标题文本, 避免覆盖“更多”按钮自身的标识。 底栏搜索要求与四个导航标签分离,使用系统 Tab(role: .search) 及 .tabViewSearchActivation(.searchTabSelection): 选择搜索即激活输入,iOS 27 的系统搜索标签按自动激活配置获得独立显示。 模拟器实图已确认四个导航标签共用左侧胶囊,右侧搜索为独立胶囊。 保留系统布局、玻璃效果与搜索状态切换,不另绘底栏。 TabView 处理回车提交,并将提交事件交给搜索页;来源“更多”页使用独立提交作用域, 避免来源内搜索触发全局重新搜索。
验收修正:跨生态筛选固定使用再漫画 ID,避免模拟器安装其他 Mihon 扩展后按前缀误选来源; 仓库和影视源测试先按名称搜索,再定位原生懒加载列表中的目标行。 网格尺寸的无障碍值表示实际比例(50–150%),XCTest 的归一化调整误把它当成 0–100%, 改为按实际比例定位拇指并慢速拖动到端点/中心,最多三次修正实际手势位置, 仍严格断言 50%、150%、100%,不修改产品比例。
安装排查:新增 CopyManga 与 MH1234 的仓库安装/重启恢复回归。 扩展失败提示增加可展开、可复制的“错误详情”,保留 JNI 已传回的完整异常链。 CopyManga 1.4.85 与 MH1234 1.6.5 在 arm64 模拟器均完成仓库安装、初始化及重启恢复; MH1234 的 APK 和仓库 JAR 在 Mac JVM 中分别通过安装。
初始化失败根因:最后一次独立 fixture UI 验证将 JVMPreview 应用安装到了用户的模拟器。 该配置的 platformClass 为 fixture.EmbeddedFixturePlatform,只包含自有测试 ABI, 不包含真实 Mihon 的 ConfigurableSource 等接口。真实 APK 转换成功后构造来源失败 (CONSTRUCTION_FAILED → NoClassDefFoundError: eu/kanade/tachiyomi/source/ConfigurableSource), 安装记录尚未持久化,因此列表仍显示需要安装。 模拟器 sources-fixture/extensions 中用户失败的 CopyManga 1.4.85 与 MH1234 1.6.5 转换产物, 不做任何修改即可在完整运行时安装成功;并已使用真实 CopyManga APK 重现 fixture 的上述异常。
扩展管理复用原有配置识别逻辑,在 fixture 构建显示原因,禁用仓库和文件安装入口; 安装模型同时拦截直接调用。运行 fixture 回归后必须恢复 RealApiPreview 应用, 避免把测试 ABI 留给用户继续安装真实扩展。fixture 与真实扩展的数据目录继续隔离。
验证记录:codex-final-real 中 CopyManga、MH1234 安装与重启两项通过; codex-regressions 中 Aniyomi 实际播放与跨生态筛选两项通过; codex-fixture-final 中扩展原生筛选与测试构建安装提示/禁用文件入口两项通过。 codex-product-final 中 CMS 分页失败重试、分类筛选、播放/换集、单套控制栏与重启恢复通过; codex-acceptance 两项通过:浏览及作品库六种显示模式、网格 50%/150%/100%; 单源顶部搜索、全局作品库与来源结果,以及“更多”保留查询。相关定向 UI 验收合计 9 项通过。 这些日志及 .xcresult 位于 ignored iosApp/build/,安装与播放器截图已实际检查。
104. 作品库分类排序与书架可见性
作品库右上角增加“管理分类”:使用原生 List 拖拽手柄调整顺序,并为每个分类提供 “在书架显示”开关。编辑仅修改本地快照,取消不写入;保存时在同一个共享 Room 事务中 更新已有 sort_key 与 show_in_lib 字段,不增加数据库表或 Swift 持久化副本。 如果编辑期间分类被添加或删除,拒绝过期快照,保留已有数据并提示重新打开管理页。
书架分类胶囊只显示可见分类;隐藏当前分类后回到“全部”。隐藏不取消收藏, 作品仍能在“全部”和整理分类中找到,管理页可重新开启显示。 重命名、收藏成员关系与备份继续使用原有共享规则。
共享分类与备份 8 项测试通过(codex-category-native-regression),覆盖排序与可见性重开、 隐藏后收藏成员关系保持、无效/过期快照拒绝,以及导出后恢复至另一个数据库。 codex-category-acceptance 的模拟器 UI 用例通过: 创建两个分类、取消隐藏编辑不落库、拖拽交换顺序、保存隐藏、重启保持顺序与开关、重新显示。 codex-category-ui 中原有分类创建/重启用例也通过。已检查管理页实际截图, npm run docs:build 通过;日志、.xcresult 与截图位于 ignored iosApp/build/。
105. 来源使用排序与搜索清空恢复
浏览显示菜单增加“最近使用”和“使用频率”。前者读取共享 sources.used_at,进入来源页或作品详情时更新; 后者沿用 Android POPULARITY 的本地已保存作品数,不把重复进入同一作品累加成访问次数。 使用计数通过一个聚合查询投影,时间与计数读取同一数据库快照;不增加 schema 或 Swift 数据副本。 同值保留手动顺序,置顶分组继续优先,搜索/生态/内容类型筛选结果使用所选排序。 首次使用没有保存设置的来源时,先保存当前有效顺序与开关,再记录使用时间,避免访问本身改变手动顺序。 来源使用记录保留开关、置顶和手动位置,既有 Android 备份字段继续保存使用时间。
全局搜索清空回归在旧实现稳定失败:取消了请求,但 submitted 和结果行没有重置,页面仍进入旧结果分支。 现清空时同时重置已提交查询、结果、完成数与加载状态,恢复最近搜索;已取消任务不会继续启动排队请求, 也不能用迟到结果覆盖后续搜索。最近搜索按钮仍可重新提交,并由“更多”携带查询进入单源搜索。
共享分类/备份/来源 9 项原生测试通过(codex-source-usage-native);模型探针覆盖清空、 忽略取消的旧请求迟到、执行前取消三个场景,修复前失败、修复后通过。 两个定向模拟器 UI 用例分别通过:codex-source-sort-final 验证首次访问不改变手动顺序、 最近使用与作品数排序、重启保留排序偏好与使用时间、置顶优先和取消置顶后的顺序; codex-source-usage-acceptance 中的全局/单源搜索用例验证清空恢复最近搜索、重新提交及“更多”查询传递。 后一个批次的来源排序用例曾因搜索激活时顶栏菜单隐藏而失败,修正测试操作后由前一个独立用例通过, 不把旧批次整体计为通过。已检查三张来源排序与一张清空搜索截图; 探针、日志、.xcresult 与截图位于 ignored iosApp/build/。 本轮完成 §102 的来源使用排序;新来源版本提示尚未迁移。
106. 浏览页新来源版本提示
沿用共享 sources.added_in 字段,IosLibrary.registerSourceCatalog 在一次事务中只插入目录中缺失的来源, 保留已存在来源的开关、置顶、手动位置、使用时间与加入版本。首次启用该功能时以现有目录建立基线(加入版本 0), 随后新增的来源记录当前 iOS CFBundleVersion;旧来源不随升级重新标记。 目录注册按浏览页原有生态顺序追加新位置,不增加表、schema、依赖或来源名单的 Swift 持久化副本。 sources.catalogVersion 只记录基线已成功建立;空目录或扩展初始化失败不会完成基线。
浏览列表与网格提供当前构建新增来源的数量提示和“仅显示本版本新来源”开关;行与瓦片不再画“新”标记(它在整个版本内不会消失,且在列表里多占一行)。 新来源筛选与搜索、成人开关、内容/生态筛选、来源启用状态组合;置顶、分组和排序继续作用于结果。 开关随重启保留;没有符合条件的新来源时可通过空状态“显示全部”恢复。 既有备份格式继续保存加入版本;应用版本与来源扩展自身版本分别沿用各自现有字段。
共享分类/备份/来源 10 项原生测试通过(codex-new-sources-native),新增测试覆盖基线、新增/升级、 重复刷新、非法输入无部分写入、保持原设置,以及数据库重开和备份恢复。 codex-new-sources-acceptance 的定向 UI 用例通过,覆盖基线不标新、后续导入标记、数量与筛选、 重启恢复、真实网格(以卡片宽度断言)及搜索空状态恢复全部来源;三张实际截图已检查。 codex-new-sources-ui 中既有使用排序用例也单独通过;该旧批次的新来源用例因未搜索定位屏外站点而失败, 后续验收补齐定位,并解除固定列表的启动参数,避免网格步骤被参数掩盖;不把旧批次整体计为通过。 本轮完成 §102 尚未迁移的新来源提示;日志、.xcresult 与截图位于 ignored iosApp/build/。
107. 设备端 DEX 转换提速、图片加载对齐 Android、内容类型切换与阅读器翻页
DEX 转换:k-parsers-r(5.6 MB DEX,4266 类)在设备端转换需数分钟。剖析发现与解释器无关的算法热点: dex2jar 2.4.38 RemoveLocalFromSSA.removeLoopFromPhi 以工作队列反复把整个祖先集合并入子节点求传递闭包, 巨型方法(数千 phi)呈立方级,JIT 下也占 88% 时间。它只需判断每个 phi 是否恰有一个非 phi 来源, LinearRemoveLocalFromSSA 用强连通分量 + 拓扑序 + 二元上限集合等价计算(迭代 Tarjan,避免解释器线程栈溢出)。 DexJarConverter 改为直接驱动 ExDex2Asm(设置与原 Dex2jar 相同,仅替换该 pass),单 DEX 只解析一次 (修复计划与翻译共用节点,原先解析两遍并复制一遍),类文件在内存交给 ZIP,帧回退直接返回 java/lang/Object (去掉逐类二次读写),按类分片最多 4 线程翻译(dex2jar transformer 无实例状态)。缓存版本升为 6。 原版 dex2jar 输出本就不确定(两次转换 SHA 不同),故等价性在 IR 层验证:真实插件 17151 个方法(6561 含 phi) 在三个调用点与原版结果逐一相同,变异实验可被夹具捕获;新旧产物在 parser-host 中 1370 / 95 个来源构造与描述摘要一致。
| 指标 | 原版 | 现在 |
|---|---|---|
k-parsers-r,Mac HotSpot -Xint(单线程,模拟设备解释器) | 800 s | 89 s |
| k-parsers-r,模拟器 Zero 仓库安装(下载+转换+加载) | 超过 300 s 超时 | 106–108 s |
| kototoro-parsers,模拟器安装 | — | 37–41 s |
| UMA,模拟器安装 | — | 79–89 s |
图片加载对齐 Android:iOS 原有三层 Android 没有的限制——JVM 侧封面/章节图片走按源互斥(同源并发 1,且与列表/详情互相排队)、 封面全局并发 2、阅读页无并发上限也无预读。现在 Mihon/Aniyomi 的 fetchImage/fetchCover 在按源调用顺序之外执行 (与 Android 直接并发调用扩展 client 一致;各自持有 lease,迟到回调语义不变,不等待被取消的控制调用), 新增“图片挂起时列表/描述不排队”回归。Swift 侧 RequestGate(对应 PriorityPermitPool):封面抓取 8、解码 3 (Coil 设置);阅读页按“阅读器线程数”(默认 3,1–10)并发,可见页优先于预读并可提升排队中的预读; 预读沿用 Android 规则(当前可见页之后 10 页中最近的“预读页数”页,默认 6,1–20;离开窗口的排队预读取消), 策略“页面预加载”(总是/仅 Wi-Fi(默认)/从不),省电与低数据模式下不预读。三项设置用 Android 相同键名,位于设置页“阅读器加载”。 可见页下载在没有页面等待且非预读时取消,已完成下载保留供回到该页重新解码。
内容类型切换:浏览、作品库与记录(历史/更新)顶部加入“全部/漫画/小说/视频”分段切换,仅当页面含两种以上内容类型时显示, 已保存类型不存在时不生效。浏览页原“显示”菜单中的内容类型开关移除(仅剩一种可见类型的旧设置迁移为对应选择); “按内容类型分组”只在“全部”时可选与生效,选中单一类型时按来源类型分组,回到“全部”恢复原选择。
阅读器:
- 更多面板:章节胶囊与“更多”按钮间距小于玻璃容器融合距离时两者被并成一个形状,面板看起来从章节按钮形变; 拉开间距、按来源控件设置缩放锚点并显式匹配几何过渡。
- 左缘返回:隐藏导航栏后系统边缘返回手势不再触发。阅读器显示期间接管其委托(离开时还原),SwiftUI 再次关闭时用 KVO 重新打开,并让分页滚动与阅读手势等待边缘手势失败(非边缘起点会立即失败,不影响翻页);不启用全宽内容返回。 去掉了多余的
navigationBarBackButtonHidden(导航栏已整体隐藏)。 - 翻页动画:Android 的 cover / curl 规则原在
core-ui(仅 Android/JVM 目标),iOS 无法使用。现下沉到reader-core/PageTurnTransition(样式、逐页变换、cover 固定页、卷页几何),core-ui的ScenePageTransition/ScenePageTransform改为类型别名并委托,Android 相关单测 17+24 项与 reader-core 187 项通过。 iOS 选择“高级/仿真”时改用自驱动分页器ReaderTurnPager(与 scene host 一样自己持有位移,拖动/点击驱动阅读顺序位置,Animatable逐帧调用IosReader.pageTurn):卷页按折线裁出正面、镜像背面并加白层与阴影,被揭开页加暗; 每页有不透明阅读背景(下一页固定在下方,信箱留白不能透出)。拖动用simultaneousGesture:作为普通手势时要等 页面自身的双击/缩放手势在抬手时失败,拖动事件会积压到松手才一次性交付;页面放大时拖动交给页面平移。 不依赖 Metal 工具链(本机 Xcode 未装该组件,也避免给所有构建引入依赖)。原有“淡入淡出/缩放”保留,取值不变 (高级=4、仿真=5)。模拟器上按住拖动期间由宿主机截图,确认从左到右/从右到左卷页与叠加效果。 - 阅读模式改用 Android 名称:默认(从左到右)、从右到左、从上到下、条漫、横向条漫(连续);新设置默认从左到右分页。
放大采样(SSIV)现状:基础图按 ImageDecodeSize 4MP/16384 采样解码;超过阈值的长图在分页相机与连续模式按可见区域 分块叠加,块采样率随缩放变化。实测 ImageIO 对 JPEG 为真正区域解码(单块 24 ms,对比整图 349 ms), PNG 每块仍解整张(约 80 ms,与位置无关)。连续(条漫)模式没有双指缩放;PNG 分块缓存与条漫缩放待做(条漫缩放已于 §111 完成)。
仓库页:页面导航取消的目录刷新/导入请求(-999)曾显示为错误“已取消”,使 UMA 安装看似失败;取消类错误不再展示。
边界:数据为 Apple Silicon 模拟器与 Mac 解释器;真机 Zero 的转换时间、内存与多线程收益需真机验证。 卷页的手指高度只影响折线倾角,点击翻页从中部翻起。
108. iOS 内容空间、就地搜索、继续阅读与作品卡片
内容空间:对应 Android 的 Space(先内置漫画/小说/视频)。浏览、作品库与记录的右上角为同一个空间菜单 (space.kind,跨页共享、重启保留),页面标题下用 navigationSubtitle 标出当前空间;空间里没有内容时给出 “显示全部”。设置页“内容空间”可关闭(space.enabled),关闭后各页恢复 §107 的顶部分段切换(各自保存)。 “按内容类型分组”只在未限定类型时可用。
搜索:三个页面都在顶栏提供就地搜索按钮(searchToolbarBehavior(.minimize)):作品库搜收藏、记录搜历史/更新、 浏览搜来源。全局搜索保留为系统搜索标签(Apple 推荐的应用级搜索位置),浏览页另有带地球的“搜索全部来源”按钮跳转。 浏览页工具栏项目较多,“仓库与扩展”移到左侧,避免页内搜索按钮被挤出。
继续阅读:Android 底栏的继续阅读按钮放在系统的标签栏附件位(tabViewBottomAccessory,iOS 26.1 起无记录时隐藏), 显示最近作品的封面、标题与进度,点按以全屏打开详情并直接续读当前章节;返回后可关闭。
作品库分类:分类胶囊改为 safeAreaBar 悬浮在内容上方,滚动时保持可用。
作品卡片(SwiftUI 原生绘制,参考 Android ContentCardRenderModel / TabletPosterCover): 发丝描边、柔和阴影、书本封面的书脊阴影;角标选用新章节数(右上红色计数)、已读完(右上对勾)、已收藏与已下载 (左上圆形标记,作品库内不重复标收藏)、成人内容(右下 18+);列表行改为 Android 式状态行(新章节胶囊+已读/已收藏/已下载文字)。 标记由 ContentCollection 从作品库(收藏、更新)与下载队列统一推导,所有列表一致。
验证:真实扩展预览上空间开启/关闭两种模式、继续阅读附件续读与关闭、JAR 漫画阅读与真实 APK 阅读用例通过; UI 测试统一用 pageSearchField() 展开页内搜索按钮。
109. iOS 原生 Space 切换面板与空间续读
§108 最初只有右上角 Picker 菜单,与 Android 的 SpaceSidekickPanel 不等价。 现同一入口打开 SwiftUI 原生 sheet,提供“全部”与漫画/小说/视频卡片,显示当前选择、 已启用来源数和收藏数。点击卡片切换后关闭面板;完成按钮与系统下滑手势可直接关闭,保持原选择。 继续沿用 space.kind/space.enabled:浏览、作品库和记录共用选择,重启保留;关闭空间仍恢复各页分段切换。
每个媒体空间从共享历史中选取该类型的最近作品,显示封面、标题与进度;无历史显示“暂无阅读记录”。 续读先切到该空间,等 sheet 完成关闭后再打开阅读器或播放器,避免两层模态展示冲突。 面板与底栏附件共用 ContinueReadingPresentation 和既有 SourceDetailsView(autoResume: true), 复用章节/进度恢复与下载环境,不建立独立历史存储、导航协议或数据库表。
此次对齐空间卡片、选择与各空间续读;Android 的自定义空间、侧边拖拽入口及独立空间导航栈未迁移。
同时修复连续切换显示模式后的卡片长按菜单回归:卡片交互按作品 ID+显示模式设置唯一身份, 切换布局时重建菜单交互;不能只用显示模式作身份,否则同一网格内的卡片会重复标识。 测试辅助方法识别系统折叠的标签栏,先展开再选择标签,并只用真正的 BackButton 返回,避免点击顶栏业务按钮。 全局搜索激活期间系统隐藏顶栏,入口用例改为断言应用级搜索框与键盘。
验证:真实扩展模拟器预览编译通过,四个定向 UI 用例分别通过: testContentSpacesAndPerPageSwitch(codex-space-panel)、 testContinueReadingAccessoryResumes(codex-space-regression,包含空间卡片续读)、 testBrowseToolbarOpensGlobalSearch(codex-space-context-probe)、 testDisplayModesInBrowseAndLibrary(codex-space-display-verified,含六种布局、网格尺寸和长按操作)。 前两个批次包含已修正测试脚本的失败记录,不把整个旧批次计为通过。 Space 面板及长按操作截图已检查;临时诊断用例已移除,保留原完整回归。 日志、.xcresult 和截图在 ignored iosApp/build/,此次未进行真机验收。
110. iOS 视频外挂字幕
Aniyomi 的 Video.subtitleTracks 已经由 source-host 写入 SourcePage.externalSubtitleTracks,但 iOS 播放器只使用流内的 HLS/内嵌字幕。AVPlayer 不能把独立字幕文件加入 HLS 或渐进式资源,因此改为文本叠加层:
ios-shared/IosSubtitles解析 WebVTT、SubRip 和 ASS/SSA(去除样式/定位标签,跳过 VTT 的 NOTE/STYLE/REGION 块, 按 ASS[Events]的 Format 字段顺序解析,正文中的逗号保留;重叠 cue 同时显示)。按开始时间二分查找当前文本;IosSourcePage.subtitles取代原来未使用的subtitleUrls,并携带语言标签和请求头。- 播放器用流的请求头加轨道自身的请求头下载字幕文件,依次尝试 UTF-16(带 BOM)、UTF-8、GB 18030 解码。 字幕叠加层位于控件渐变之上,不拦截触摸;控件显示时上移,避免遮挡进度条。cue 时间观察器每 0.1 秒运行一次, 只有文本变化时才发布。
- 字幕菜单把关闭、内嵌和外挂字幕合并成一个选择:选择外挂字幕时取消内嵌字幕,反之亦然。如果流已经默认开启内嵌字幕, 就保留内嵌字幕;否则依次选择本次会话上一次选择的语言、用户偏好语言和第一条外挂字幕。主动关闭后,切换线路或剧集 也不会重新开启。下载或解析失败时,该项标为“加载失败”,不影响播放。
未做:外挂音轨(externalAudioTracks)需要与视频同步的第二路音频,HLS 无法用 AVMutableComposition 合成, 仍未接入;画中画中不显示叠加字幕。
验证:IosSubtitlesTest 4 项(模拟器 iosSimulatorArm64Test)通过。默认构建下运行 CMS 夹具的 testAppleCmsSourceImportsBrowsesAndPlaysNatively,并临时注入一条夹具 VTT 字幕:在真实 HLS 播放中断言叠加文本, 截图已检查(暗色渐变问题已修正后复验);注入代码和断言已移除。真实 Aniyomi 源的外挂字幕尚未做端到端验收。
111. iOS 条漫双指缩放
§107 记录了连续(条漫)模式不支持双指缩放。Android 的 webtoon 缩放框会放大整条长图,iOS 现已对齐, 但没有采用对整个 ScrollView 做 scaleEffect 的方案:那样章节开头会被推出可视区,图片清晰度也不会随缩放提高。 现在的做法是按缩放后的宽度重新排版:
IosReader.setZoom只作用于连续模式:VerticalReaderScene以“视口宽度 × 缩放倍数”的列宽排版 (横向条漫则放大高度),范围是 1–4 倍。progress返回的页内偏移和scrollOrigin接收的偏移都换算成未缩放像素, 因此历史进度和书签与缩放无关。分页模式忽略此值。- 条漫的 ScrollView 改为纵横双向滚动(横向按内容尺寸回弹)。缩放后,页面瓦片按实际显示尺寸取视口和采样率,
visibleTiles仍使用屏幕视口,超长页的分块会随缩放提高清晰度。 - 捏合时先用
scaleEffect绕起点预览,松手后提交新的排版,并把捏合起点下的内容滚回原处;双击在 1× 和 2× 之间切换, 也提供 VoiceOver 动作。滚回原处走共享场景的锚点:progress给出起点下的页与未缩放偏移,scrollOrigin给出新排版 下的滚动位置;不能按缩放比例换算场景偏移,因为跳转或恢复后上方页面仍是一屏估值,比例换算会把阅读页带偏 (旧实现偏移一页以上,上方未测量页越多越远)。横向平移轴仍按比例缩放。 排版本身无法插值(页面尺寸与其间偏移不是旧排版的等比缩放),而且页面要按新尺寸重切瓦片解码,提交后的前几帧是空白, 所以移动用快照呈现:提交前对条漫取snapshotView,提交后把快照绕触点放大到ratio(双击)或原地(捏合松手时 预览已是目标尺寸),0.32s 缓动、最后 0.2s 淡出,新页面在快照下面解码完成。UIAccessibility.isReduceMotionEnabled时直接切换;捏合若在原地结束(ratio == 1)不提交任何排版,预览用的 transform 和快照一并丢弃。 翻页点击区、自动滚动和跨章节导航沿用原逻辑,跨章节时保留横向平移位置。 缩放只在本次会话有效,切换阅读模式后恢复适应宽度(与 Android 一致,不持久化)。
未做:横向条漫(模式 4)的交互缩放(共享层已支持,Swift 尚未启用,需要处理从右到左的偏移镜像); 非分块图片在 4× 时仍受 4 MP 解码上限限制,会有一定的放大模糊。
验证:IosProductTest.readerCoreOwnsSpreadGeometryAndContinuousProgress 新增断言,验证缩放后的排版、进度归一化与上限, 共享测试 8 项通过。真实扩展预览新增 testWebtoonStripPinchZoomPansAndResets(再漫画《大理寺日志》): 页面宽度从 1× 放大到 4×、可横向平移、双击还原。首次运行在捏合前失败,用临时日志确认手势和提交链路正常, 随后连续两次通过;测试已在切换模式后等待布局稳定。放大、平移和还原的截图已检查,未做真机验收。 LocalImportTests/testContinuousDoubleTapKeepsThePageAfterAJump 用本地条漫夹具在跳转到第 5 页后连续双击放大、还原, 断言 reader.canvas 的阅读页不变:旧的比例换算在跳转后的排版上落到第 6 页(上方 4 页仍是一屏估值), 修正后两次都停在第 5 页;testContinuousDoubleTapZoomsAtTheTap、 testHorizontalContinuousDirectionZoomAndBookmarkRestore、testPagedToContinuousSwitchRestoresTheReadingPage 与 testPagedDoubleTapZoomsAtTheTapAndPansBeforeTurning 同批次通过。 动画用模拟器录屏逐帧测量(xcrun simctl io recordVideo 加 30fps 抽帧,按页面色带边界和背景占比判断): 快照方案下整个缩放窗口内背景占比全程 0.000,色带边界连续外移(1×→2× 时页左缘 388→342→264→156→38→0 像素); 而直接把新排版按 1 / ratio 缩放、以及完全不加动画两种写法,放大后背景占比都会升到 0.62–0.75 并持续约 0.2s, 说明空白来自页面按新尺寸重切瓦片,慢的不是动画本身。 同时修正了上一轮 testContinueReadingAccessoryResumes 新增的断言:模拟器上已有小说阅读记录,所以小说空间的底栏附件可以合法地显示小说。现在改为断言附件不再显示刚读的漫画,并加 defer,确保中途失败时空间也恢复为“全部”(此前一次中途失败把 space.kind=NOVEL 留在设备上,导致后续用例找不到漫画源)。两个用例随后在同一批次中通过。
112. iOS 小说章节下载与离线阅读
IosDownloadArchive 现在接受漫画和小说章节(视频仍拒绝),并新增 IosChapterTextArchive: IosChapterText 直接包装共享的 SourceChapterContent,HTML、图片列表和每张图片的请求头一起序列化。 下载复用原有的串行队列、暂停和重试:小说章节先保存 text.json,再按来源要求的请求头逐张下载插图 (ImageIO 校验,单张 32 MB 上限),每完成一项就是一个检查点,全部完成后写入 novel.json (原 URL 到本地文件的映射)。队列状态对小说不显示“页”。阅读时优先读离线副本,插图走本地文件, 标题下显示“已下载 · 离线阅读”;离线副本损坏时回退到在线。阅读器工具栏提供“下载本章/后续 10 章/全部未下载章节” 和下载队列入口,目录中已下载的章节有标记,左滑可单章下载。
修正:底栏继续阅读附件在阅读器内仍然可见,会遮挡小说正文,测试中也会误触。现在由 ReaderPresence 在阅读器显示期间关闭附件(iOS 26.1+);附件打开的全屏展示也上移到 TabView,避免附件隐藏时连带关闭阅读器。
验证:共享测试新增 2 项(小说可下载、视频拒绝;离线章节存档往返);真实扩展预览新增 testTsundokuNovelChapterDownloadsAndReadsOffline、testJarNovelChapterDownloadsAndReadsOffline(BILINOVEL), 两项均通过,并验证附件在阅读器中不可点击;testContinueReadingAccessoryResumes 回归通过。 首轮失败原因是工具栏 Menu 不带 accessibilityIdentifier,以及测试点到了附件,已改为按标题查找并在点击前避开附件。
113. iOS 主界面搜索、作品库顶栏与继续阅读附件修正
- 页内搜索无法关闭:在这个 TabView 中,系统的
searchToolbarBehavior(.minimize)搜索的关闭按钮只会收起键盘, 随后输入框重新获得焦点,isPresented也不会变为 false(日志确认;移走 TabView 的 searchable 或tabViewSearchActivation均无效)。作品库、浏览和记录改用自有的pageSearch:工具栏按钮打开顶部紧凑输入框, “取消”关闭并清空;提交后收成“🔍 关键词 ✕”胶囊,点击可再次编辑,✕ 一键清除,只占一行。 - 作品库顶部模糊、不可点:分类条是
safeAreaBar里的横向 ScrollView,导航栏把它当成页面的滚动视图, 于是大标题一直模糊、分类胶囊被边缘效果淡出。对照实验(去掉 ScrollView 后标题清晰)确认了原因, 分类条已移到页面内容顶部。 - 附件封面不显示:根因是
IosSources.cover用完整SourceRef校验来源,而作品库恢复的作品带的是数据库里的 locale 或 contentType,与运行时不一致,校验因Cover identity mismatch失败。现改为按来源名校验,并新增共享测试。 另外,系统托管的附件不会执行视图 task,也不跟随环境值更新,因此封面由 TabView 加载后通过@Observable传入。 - 附件显示已读章节:
ChaptersDao.findByIds批量查出历史记录对应的章节,SourceLibraryEntry.lastChapter提供章节名;附件显示“继续阅读 · 第 12 话 · 35%”。
验证:新增 testRootPageSearchClosesAndShowsKeyword(三个页面),以及作品库、附件截图检查; 空间、显示模式、全局搜索入口、继续阅读、作品库持久化等回归通过。
114. 来源设置、User-Agent 预设与 Cloudflare 验证
来源设置:浏览页工具栏的筛选按钮与分类条中的筛选胶囊重复,已改为“来源设置”。设置页按运行时声明渲染:
- JAR 解析器:
ConfigKey,包括域名(含预设)、User-Agent、拦截 Cloudflare 等; - Mihon/Tsundoku/Aniyomi:扩展自身的设置,加上宿主的 User-Agent 行;
- 苹果 CMS:API 地址和 User-Agent,写入站点
site.api和site.headers,保存后清空运行时缓存 (IosVideoSources.settings/updateSettings)。
所有 User-Agent 行都提供 7 个常用预设(iPhone/iPad Safari、Android Chrome、Windows Chrome/Edge/Firefox、 macOS Safari)以及“恢复来源默认值”。Mihon 扩展的域名与 Android 一致,依赖扩展自己的镜像设置; 宿主层通用的域名改写尚未实现。
Cloudflare:此前协议对所有来源失败一律返回 Source operation failed,界面无法区分。本次协议新增以下内容 (均向后兼容):
- 错误码
CHALLENGE_REQUIRED,SourceError.url只携带站点 origin;SourceChallengeException沿 cause 链识别;SourceEndpoint可以注入宿主识别器; browserSession:返回站点地址和运行时请求实际使用的 User-Agent(Cloudflare 的 clearance 与 UA 绑定);importCookies:把浏览器 Cookie 写回来源请求所用的 cookie jar。
宿主端由 HostChallenges 识别解析器的 CloudFlareProtectedException,以及 Suwayomi 拦截器在未配置 FlareSolverr 时抛出的 “Cloudflare bypass currently disabled”;HostBrowser 通过反射写入扩展 OkHttp 客户端的 cookieJar, 解析器使用平台 cookieJar(iOS 上与 Mihon 共用同一个)。
iOS 端,IosSourceErrors 把协议错误码转成中文说明;浏览页、详情页、漫画和小说阅读器遇到验证时显示 “在浏览器中验证”,打开内置 WKWebView(使用运行时的 UA)。页面检测到 cf_clearance 后提示已通过, 点“完成”把该站点的 Cookie 写回运行时并自动重试。设置页也可以直接用内置浏览器打开站点。
未做:CMS 原生请求的 Cloudflare 识别;没有走 Cloudflare 拦截器的 Mihon 普通 client 返回的 403 (无法与一般 403 区分);自动(无界面)求解。iOS 上没有 Android 的 WebView 拦截器,后续可以用离屏 WKWebView 加载挑战页并等待 cf_clearance,相当于 Mihon 的自动求解,但需要宿主在请求失败时回调 Swift。
验证:core-source 协议测试新增 2 项(challenge 只带 origin、cause 链与识别器;browserSession/importCookies 往返); source-host HostBrowserTest 3 项(真实 OkHttp cookieJar 写入、两类异常识别、普通 403 不误判); ios-shared 新增 3 项(CMS 设置改地址和 UA 并真正生效、challenge 错误投影与 Cookie 回写、恢复作品的封面校验)。 真实扩展预览新增 testSourceSettingsUserAgentPresetsAndBrowser(再漫画 Mihon、MangaDex JAR),流程为: 从浏览页工具栏进入设置,User-Agent 选择 Windows Chrome 预设并保存,恢复默认后显示“默认”,用内置浏览器打开站点;该用例通过。 MangaDex 的设置页显示域名预设和首选图片服务器。真实 Cloudflare 站点的端到端验证尚未进行。 预览的 JVM 资源目录只替换了 core-source/source-host/parser-host 三个 JAR,没有重新转换 APK。
同批回归:作品库持久化 2 项,空间、显示模式、全局搜索入口和继续阅读 4 项,仓库管理 2 项, 产品流程 5 项(其中 3 项在首轮因仓库夹具未启动失败,启动夹具后通过),全部通过。 ProductFlowTests.testCmsFavouritesHistoryAndPlaybackResume 仍然失败:导入站点后,importVideoSite 没有回到浏览页就查找来源行。该用例与辅助函数在本次之前已被改写(未提交),这次没有改动导入流程,原因尚未确认。
115. 全来源筛选入口、外部备份导入、备份补节、本地导入、扩展域名与自动 Cloudflare 验证
筛选入口:浏览页的“筛选”按钮对所有来源都显示。Tsundoku 等没有 FilterList、也不支持“最新”的来源, 筛选面板会显示唯一的排序方式,并说明来源没有提供筛选条件;面板读取失败时显示原因,并可重试(FilterPanelPlaceholder)。
外部备份:Android ExternalBackupRepository 的写库逻辑迁入 core-backup commonMain,作为 ExternalLibraryImport。 平台通过三个注入点接入:ExternalSourceCatalog 提供已安装来源,ExternalImportTransaction 提供事务, NSFW 判定由回调提供。BulkImportEntry、分类映射、ExHentai fork 映射、Venera 来源表和 ID 公式都保持原样; AdultTagKeywords 随之迁入 core-backup,包名不变。Venera 的解析规则迁为 VeneraBackupDecoding, 平台只负责实现 VeneraDatabase 的 SQLite 查询:Android 用 Cursor,iOS 用 bundled SQLite。 Android 的 ExternalBackupRepository 和 ExternalBackupDecoder 改为委托共享实现。
iOS 的“从文件恢复”自动识别备份类型:带 index 的 ZIP 走 Kototoro 恢复,其他 ZIP 按 Venera 处理,其余文件按 .tachibk 处理(含 anime 字段的视为 Aniyomi,否则视为 Mihon)。导入完成后报告:未安装的扩展(作品已导入, 保留原有标识)、Venera 中找不到对应来源而未导入的作品。备份 ZIP 读取器新增宽松模式,可以读取 Venera 的嵌套目录。
Kototoro 备份补节:LibraryBackup 新增 source_origins、extension_repos、tracks 和 track_logs 四节的导出、 恢复与计数。更新检测的合并规则(mergeWithRestored、mergeRestoredTrack/TrackLog)从 Android BackupRepository 迁入 core-domain,两端共用。iOS 预览中“跳过”的节只剩 settings、reader_grid、scrobbling、 saved_filters、auth。
本地导入:新增 KMP 模块 core-local,内容如下:
LocalBookReader:用 okioopenZip读取以下格式。- CBZ/ZIP:可读 Kototoro 写出的
index.json,按 entries 规则%08d_%04d\d{4}分配各章页面; 没有索引时,按目录分章并按自然序排列。 - EPUB:解析 container、OPF、spine,目录取自 nav 或 NCX。
- TXT:UTF-8 优先,其他编码交给平台解码器(iOS 用 Foundation 依次尝试 GB18030、Big5); 按“第N章”等标题分章,没有标题时按约 8000 字切分。
- CBZ/ZIP:可读 Kototoro 写出的
LocalSourceRuntime:以 Android 的来源名LOCAL和LOCAL_NOVEL实现SourceRuntime。- 图片以
<sha256>.img写入阅读器的图片目录;EPUB 插图写入作品目录,并以 file URL 引用。 - 同名、同大小的文件重复导入时,替换旧的副本。
- 图片以
iOS 端经 RoutedSourceRuntime 路由本地源,并在浏览页显示“本地作品”分组(本地漫画、本地小说)。 本地源的浏览页,工具栏是“+ 导入文件”(可多选),作品卡片上有“删除文件”菜单,阅读器中隐藏下载入口。 Info.plist 声明了 cbz/zip/epub/txt 文档类型,“用 Kototoro 打开”会直接导入。 收藏、历史和继续阅读都沿用通用流程。
扩展域名:Mihon/Tsundoku/Aniyomi 的 HTTP 来源设置里,在 User-Agent 行之外新增宿主的“域名”行(HostDomain)。
- 设置值存于
source_<id>的domain_override,只替换主机和端口,保留原地址的路径。 - 扩展的
baseUrl字段如果存在(含 R8 改名的字段,以值等于getBaseUrl()定位,并写入探测值确认 getter 生效), 直接替换该字段。 - 发布版扩展常被 R8 把
getBaseUrl()内联为常量,例如再漫画 keiyoushi 版。对这类扩展,iOS 平台在 NetworkHelper 的client、cloudflareClient最前面安装DomainRewriteInterceptor,按HostDomainRewrites的映射把原主机的 请求、Referer 和 Origin 改到新域名。平台没有安装拦截器、扩展也没有可替换字段时,该行显示为不可用。 - 文本设置(域名等)的编辑页也提供“恢复来源默认值”。
自动 Cloudflare 验证:ChallengeSolver 对应 Mihon 的自动求解。遇到验证时,先用窗口内不可见、不可交互的 WKWebView(使用来源的 UA)加载站点,最多等待 20 秒;拿到 cf_clearance 后把 Cookie 写回运行时并自动重试。 同一站点 2 分钟内只尝试一次,自动验证失败时才显示“在浏览器中验证”。浏览页、详情页和两个阅读器共用 ChallengeVerifyButton。需要点击的交互式挑战无法自动通过。
测试修复:testFilterPanelsAcrossEcosystems 在第二轮误用了状态恢复后保留的全局搜索框,pageSearchField 改为优先使用页面自己的入口。testCmsFavouritesHistoryAndPlaybackResume 有两处问题:来源列表过长,导入的站点 没有渲染出来,改为先搜索;另外其他用例收藏了同名的“测试动画”,改为匹配带进度的卡片。这两处都是测试问题,不是产品缺陷。
验证:
- core-local jvmTest 7 项(Kototoro 索引 CBZ、目录 ZIP、EPUB 目录与插图、GB18030 TXT、无标题切分、列表/搜索/重导入/删除、 错误文件不残留);
- ios-shared 共 42 项全部通过,其中新增:
IosExternalBackupTest5 项:Android ID 公式在 Kotlin/Native 上一致,Mihon gzip 备份含分类、历史、未安装扩展, Aniyomi 识别,Venera SQLite 来源匹配,未知文件被拒绝;IosBackupTest中四个新节的往返测试;IosLocalImportTest;
- source-host 102 项;mihon-ios-compat 新增
DomainRewriteInterceptorTest2 项; - core-backup、core-domain、core-library 的 jvmTest 和 Android
:app:compileDebugKotlin及备份相关单测通过; - UI 测试(真实扩展预览)通过:
testFilterPanelsAcrossEcosystems、testCmsFavouritesHistoryAndPlaybackResume;BackupFlowTests2 项,含通过系统文件选择器导入 Mihon 备份;LocalImportTests2 项:CBZ 在阅读器中显示,GB18030 TXT 分章阅读;testSourceSettingsUserAgentPresetsAndBrowser:Mihon 来源设置域名后显示新值,恢复默认;
- 默认(无 JVM)构建通过。
选择器夹具由 iosApp/Scripts/stage-picker-fixtures.py <simulator id> 放入模拟器的“我的 iPhone”。
仍未覆盖:
- 真实 Cloudflare 站点的端到端测试(自动和手动两条路径);
- CMS 原生请求的 Cloudflare 识别;
- Mihon 域名改写对真实镜像站的联网验证(单测只验证了请求改写);
- 本地导入暂不支持视频文件和 PDF/CBR/7z;
- 同步(按要求暂缓)。
116. 独立 iOS JVM shim 与源码运行时构建(2026-10-12)
沿用用户选择的自行实现方向,新增可选 mihon-ios-shims,替换 TachiyomiAzIOS 的 Mobile shim JAR。 WebView/CookieManager 使用 Kototoro 的 IosWebKit 协议;其他操作经项目自写的 KototoroNative.mm 注册到 IosNative,由 ImageIO、CoreText、CoreGraphics 和 JavaScriptCore 执行。
Java/Kotlin 侧提供 Bitmap/Canvas/Paint/文字折行、PDF 与文件描述符、QuickJs 接口、常用 ICU、流式 JsonReader、 zstd、系统资源/内存/时钟及网络配置适配。实现和支持边界见 mihon-ios-shims/README.md。不宣称完整 Android framework、ICU 排序一致性 或 QuickJS 字节码兼容;未实现的高级操作明确失败。
构建使用固定 OpenJDK Mobile 的独立源码树和项目的 openjdk-ios.py,不读取 TachiyomiAZ 的补丁、导出表或 符号保留代码。760 个原生入口(含 JNI/JNU helper)从本次静态库生成;Java 模块、时区与证书数据来自同源构建。 主机 JDK 26 仅作工具,使用 Mobile 修订的 JlinkTask 并禁用 JLI 预生成。运行时包含 java.base、java.xml、 java.logging、jdk.unsupported 和 jdk.localedata,不加载 boot shim。
实际模拟器验证发现并修复以下运行时问题:
- Mobile 原始 iOS 分支使用
Documents/lib/modules;适配现在明确作用于 Apple 分支,并统一使用 应用传入的JAVA_HOME/lib/modules。 - 普通命令行二进制的可见符号掩盖了 JNU helper 遗漏;App 链接后报
JNU_NewStringPlatform missing。 符号生成器已补齐 JNI/JNU 入口,test-jvm-simulator.py使用-no_exported_symbols保留真实失败条件。 - Mobile 构建生成的
PlatformProps把 iOS 传给没有 IOS 成员的OperatingSystem枚举,导致 JAR 资源读取初始化失败。自写适配将 iOS 映射到 Core JDK 的 MACOS 分支;回归实际打开 shim JAR 内的 class 资源并检查 CAFEBABE 文件头,已确认修复前失败、修复后通过。
预览把 00-kototoro-ios-shims.jar 排在第一位,拒绝旧 boot profile 和 app/tachiaz/ 类;产物审计确认 没有这些类或 TachiaZ 原生符号。AndroidCompat/Suwayomi 与基础依赖仍从固定摘要的发行容器提取,保留各自 来源及许可,自行实现 shim 不重新许可这些依赖。runtime-origin.json 记录构建来源,inventory.json 记录摘要。
已验证:shim 测试 15 项全部运行并通过;iOS 适配 2 项、source-host 102 项通过;dex-convert 18 项中 15 项通过、3 项条件跳过。macOS 与隐藏符号配置下的模拟器 Zero transport 回归通过,覆盖 Unicode、 64 位 ID、20 次跨线程调用、异常恢复与关闭。模拟器原生 shim 回归通过 JavaScriptCore、UTF-16、ImageIO 像素/透明度、CoreText 和 JAR 资源流。默认 iOS 构建与真实 API 预览 build-for-testing 通过。 独立 fixture 的完整 App UI 回归通过,覆盖源枚举、进程重启、详情/章节、图片、阅读模式、进度与下载。 测试明确设置阅读方向,避免持久化阅读偏好影响页码断言。
新 shim 的 Mac JVM 在线探测通过:再漫画列表/详情/392 章/首章图片,AllNovelFull 列表/2166 章/正文, AnimeGG 列表/1165 集/视频地址。再漫画真实 APK 的模拟器 App UI 回归通过列表、详情、章节和图片阅读; Aniyomi AnimeGG 的实际播放进度、JAR MangaDex 的图片和 Tsundoku AllNovelFull 的小说正文均通过 App UI 回归。 Comix 样本属于 Mihon APK 生态,不是 JAR 插件里的 Comix;现已对齐用户 Android 环境的 1.6.46, 固定官方发行 b5d2cd5 与 SHA-256。此前 1.6.45 的列表通过、章节解码捕获超时,不能用该结果推断 1.6.46。 1.6.46 在正常 WebView 桥流程下通过完整 App UI 回归:列表、详情、章节和真实图片;临时响应字段与 安全上下文探针均已移除。与 1.6.45 相比,该样本的章节脚本由并发翻页改为带延迟的逐页读取, 版本差异已在转换产物中直接核对,不把旧版失败归为 JAR 插件 Comix 或新 shim 的通用缺陷。
117. iOS 启动只加载被使用的来源(2026-10-12)
模拟器冷启动时宿主侧约 12.8 s:平台 1.4 s、13 个扩展 JAR 1.9 s、解析器插件 9.3 s(k-parsers-r 5.4 s、UMA 2.4 s、 kototoro-parsers 1.4 s)。插件耗时来自仅解释执行的 JVM 对整个 JAR 做 SHA-256、逐个读取类头,并初始化上千常量的来源枚举; 启动阶段其实只需要来源清单。
ParserPluginRegistry(catalogDirectory)与MihonJarRegistry(catalogDirectory):首次完整加载并校验后,按产物 SHA-256 缓存来源清单(插件为制表符分隔文本,扩展为LoadedMihonExtensionJSON)。之后启动命中缓存只注册来源,不打开 JAR、 不执行扩展构造;某个来源第一次被调用时才校验哈希、加载类并构造,且必须与缓存的架构/来源一致,否则这次调用失败、下次重试。 安装与更新始终完整构造;缓存损坏或身份不符时回退完整加载并重写。- 按需加载在注册表锁外按插件/扩展各自加锁,大插件加载不阻塞其他来源;持久化回调失败仍报告为安装失败。
- 关闭的来源不在浏览页出现、不会被调用,所在插件/扩展因此不会加载。iOS 打开两处缓存:扩展用会话配置
catalogDirectory = catalogs/extensions,插件在compat/parser-catalogs;桌面默认不开启。 - 实测(模拟器,第二次冷启动):扩展 JAR 2252 → 21 ms,解析器插件 9531 → 144 ms;浏览页显示的已启用来源随后在后台加载 (kototoro-parsers 约 2.9 s、再漫画 0.25 s),k-parsers-r 与 UMA 未被加载。
- 验证:
:source-host:test104 项、:parser-host:test25 项(2 项条件跳过)通过,新增缓存注册/首次调用构造/产物被替换后拒绝/ 缓存损坏回退用例;真实预览testConvertedRealMihonApkRunsOnSimulator、testJarMangaPagesRender、testAniyomiEpisodePlays通过。
118. iOS 扩展网络改由 URLSession 传输(2026-10-14)
测量:模拟器 Zero 上用本机 HTTPS 服务(网络耗时约为 0)测纯 CPU 成本。原 OkHttp + JSSE 首次连接 2606 ms, 1 MiB 响应体 1159–1190 ms,4 MiB 4284 ms;同进程 URLSession 分别为 18–77、6–8、20 ms。明文 HTTP 下 OkHttp 读 1 MiB 只要 6–12 ms,说明开销几乎全在解释执行的 TLS 记录解密(AES-GCM 1 MiB 668 ms、SHA-256 1 MiB 865 ms)。漫画页图片、列表与 详情请求都经扩展的 OkHttp client,因此每 MiB 约 1 秒的解密是图片加载的主要瓶颈。
实现:mihon-ios-shims 在 classpath 首位提供 OkHttp 5.4.0 的 ConnectInterceptor 替代类。可由 URLSession 承载的调用 改走 NativeTransport,其余照原实现建立 OkHttp 连接(调用同一内部 ABI initExchange/copy,固定版本,升级须重验)。
- 位置在应用拦截器、
RetryAndFollowUpInterceptor、BridgeInterceptor、CacheInterceptor之后:扩展自己追加的拦截器、 域名改写、Cloudflare 处理、Cookie jar、重定向与缓存语义不变;客户端的网络拦截器在新链上照常执行,最后由 URLSession 收发。 - URLSession 不存 Cookie、不跟随重定向、不用 URL 缓存;已解码的 gzip/deflate/br 去掉
Content-Encoding/Content-Length, 避免 OkHttp 二次解压;URLSession 合并的多个Set-Cookie经NSHTTPCookie拆回逐条(Domain/Path/Expires/Secure/ HttpOnly/SameSite)。响应体按需流式拉取,读完或 Cleaner 回收时释放原生缓冲;call.cancel()、call timeout 与线程中断 在 200 ms 内生效。超时映射为SocketTimeoutException,解析失败为UnknownHostException;没有 Exchange 时retryAfterFailure()返回 false,不会重复发送。 - 保留 OkHttp 连接:WebSocket、显式代理、自定义信任管理器或主机名校验、证书固定、duplex/one-shot 请求体。
-Dkototoro.native.net=false可整体关闭。原生实现在KototoroNative.mm,与 ImageIO/JavaScriptCore 同一注册表。 - 附带效果:TLS 指纹与 WKWebView 同源(Cloudflare clearance 绑定指纹时更容易复用);遵循系统代理/VPN。
验证:test-ios-shims.py 在 macOS 上用真实 URLSession 运行 NativeTransportTest 11 项(原生路径计数、非 ASCII 头、 3 MiB 分小块读取、EOF 后重复读、多个 Set-Cookie 进入 jar 并回传、重定向由 OkHttp 跟随且保留每跳 Cookie、gzip 不二次 解压、POST 与 Content-Type、网络拦截器、取消、call timeout、自定义信任回退),原有 shims 15 项与 compat 2 项通过; iphoneos 目标 -Wall -Wextra -Werror 编译通过。模拟器 Zero 上默认 OkHttpClient 走新路径:首次 HTTPS 44 ms、1 MiB 7 ms、 4 MiB 21 ms,10 个请求全部经 URLSession。 -Dkototoro.native.net.trace=true 每个请求向 stderr 输出一行(URLSession 或保留 OkHttp 的原因、主机、状态、耗时)。
真实源:模拟器 Zero 上以 JSONL serve 驱动真实扩展,trace 显示再漫画与 MangaDex 的 10 个请求全部经 URLSession, 没有回退。Tsundoku(AllNovelFull,2166 章)、再漫画、Aniyomi(AnimeGG)、MangaDex、BILINOVEL 均完成列表 → 详情 → 章节 → 图片/正文/视频地址;Mac JVM 上开启与关闭原生传输结果一致。MangaDex 1.4 MB 图片仍约 3 s:响应头 0.64 s,其余为 CDN 下载与宿主在解释器中计算图片 SHA-256(约 0.87 s/MiB),后者是下一个可原生化的热点。 UI 回归(真实扩展预览)testConvertedRealMihonApkRunsOnSimulator、testJarMangaPagesRender、testAniyomiEpisodePlays、 testJarNovelChapterText 通过。testTsundokuNovelChapterText 失败与本改动无关:同一代码关闭原生传输时同样失败, 录屏显示正文已显示(分页小说阅读器),用例等待的 novel.firstParagraph 不再出现。
119. iOS 消息摘要改由 CommonCrypto 计算(2026-10-14)
§118 之后的下一个解释器热点是 SHA-256:模拟器 Zero 上 JDK 的 SUN 提供者 1 MiB 需 817 ms(MD5 323 ms)。图片与封面物化、 扩展/插件安装与首次加载校验、偏好文件摘要、扩展自身的 MD5/SHA 签名以及仍走 JSSE 的握手都经 MessageDigest。
mihon-ios-shims新增 JCA 提供者IosDigestProvider(名称KototoroIOS):MD5、SHA-1、SHA-256、SHA-384、SHA-512 及常用 别名/OID 由 CommonCrypto 计算。KototoroNative.mm在注册 native 方法后立即调用install()插到提供者首位,早于任何宿主代码; 旧 shim JAR 没有该类或安装失败时保留 JDK 实现,不影响 JVM 启动。共享 source-host/parser-host 代码不变。- 单字节与小块更新在 Java 侧批量,较大输入经
GetPrimitiveArrayCritical直接交给 CommonCrypto;clone()复制原生上下文 (JSSE 握手哈希依赖);上下文由 Cleaner 释放。-Dkototoro.native.digest=false关闭。 - 验证:
IosDigestProviderTest6 项在 macOS 真实 JNI 上与 SUN 逐字节一致(5 种算法、0–1 MiB+3 的边界长度、单字节/小块/大块/ ByteBuffer 混合更新、digest/reset 复用、未完成状态下 clone 分叉、写入调用方缓冲、2 万个未完成实例回收);shims 其他 26 项与 compat 2 项通过,iphoneos-Wall -Wextra -Werror编译通过。模拟器 Zero:SHA-256 1 MiB 0.58 ms、MD5 2.2 ms,默认MessageDigest.getInstance即取到该提供者;真实扩展 serve 的 sources 步骤(扩展/插件哈希校验)4.0 s → 2.0 s。图片步骤受在线 下载波动主导,单次对比不能分离出摘要耗时,按微基准 2.5 MB 图片约省 2 s CPU。 - UI 回归(真实扩展预览,含 §118/§119):
testConvertedRealMihonApkRunsOnSimulator、testJarMangaPagesRender、testAniyomiEpisodePlays通过。testJarNovelChapterText在同一时间的工作区构建中失败:录屏显示章节与插图已加载(分页小说 阅读器加入浮动操作按钮的未提交改动),用例等待的novel.firstParagraph不再出现,与 Tsundoku 用例同因,需随阅读器改动更新用例。 注意:多个会话共用同一模拟器会互相重装应用、终止进程并改写space.kind等偏好,UI 回归应独占模拟器或使用克隆设备。
120. iOS 长章节列表:先显示已保存的列表,反射查找缓存(2026-10-14)
测量:Tsundoku AllNovelFull(2166 章)在模拟器 Zero 上详情约 55 s。trace 显示扩展按每页 50 章串行请求 47 页, 响应头约 0.5 s/页(约 26 s);其余是解释执行的 CPU。Mac -Xint + JFR:工作线程中 jsoup 约 60% 采样, 宿主 MihonReflection 的方法查找约 5.6%(每章多次反射调用,每次复制并扫描整个类层级的 declaredMethods)。 翻页与 jsoup 都是扩展自身的行为,宿主不能改变;因此优化等待体验并去掉宿主自身的开销。
MihonReflection按目标类(ClassValue,卸载扩展不受缓存牵连)缓存"方法名 + 参数运行时类型"的解析结果, 包括找不到的结果(optional依赖它)。解析只取决于这些输入,行为与原来逐次查找一致。JFR 中查找采样 14 → 0。- 详情页先显示已有列表再向源请求:本会话已取过的详情(
DetailsCache,24 部 LRU)、打开时带着章节的记录(作品库、 历史)、或上次打开时保存的详情(详情页每次都会saveDetails;新增IosLibrary.storedDetails按作品 ID 读回, 来源不同或无章节时为 null)。请求期间列表可用,顶部显示"正在更新章节…";完成后替换并保留所选线路/分支; 失败时保留列表并显示可重试的错误。"继续阅读"进入时用已保存的列表立即续读,不再等源返回。 - 详情模型只在 details 变化时桥接一次章节数组;
IosSourceContent.chapters每次读取都会映射并桥接全部章节, 章节数、分支、线路和续读定位原先反复读取它。新增chapterCount供不构造列表的判断。 - 首次打开仍需完整等待;把简介与章节拆成两个协议调用(Mihon 本有 getMangaDetails / getChapterList)可让首屏先出现, 涉及 core-source 协议与各端适配器,暂未实施。
验证::source-host:test 107 项(新增 MihonReflectionTest 3 项:重载按参数类型、null 不选原始类型、 子类重写与父类 protected、缺失方法稳定为 null);:ios-shared:iosSimulatorArm64Test 60 项(新增 storedDetails: 未保存与无章节行为 null,保存后按列表项身份读回章节)。 UI(真实扩展预览)新增 SourceRuntimeTests.testReopenedLongChapterListShowsAtOnce 通过:Tsundoku 作品重新打开时章节立即出现 并显示"正在更新章节…"(后台刷新约 44 s),重启应用后由保存的详情约 2 s 出现;testConvertedRealMihonApkRunsOnSimulator、 testAniyomiEpisodePlays 回归通过。另见:宿主按 number 排序章节,AllNovelFull 用 (\d+)\D*$ 取标题末尾数字作章节号 ("Wedding Night 1" 记为 1),使该源章节顺序错乱;与本节缓存无关,待定是否改为保留源顺序。
121. 章节默认保留源顺序,按章节号排序改为可选(2026-10-14)
- source-host 的 Mihon/Tsundoku 与 Aniyomi 运行时不再按扩展声明的章节号/集数重排:保留源列表(反转为最早在前), 缺失的编号仍按位置补齐。与 Mihon/Aniyomi 的列表顺序一致;扩展从标题误解析编号时不会再打乱列表, 前后章导航(
SourceChapterNavigation)随之按源顺序。桌面端消费同一宿主,同样生效。 - 夹具让声明编号与列表顺序矛盾(Mihon 最新章 0.5、Aniyomi Hosters 1.5),断言保持源顺序;恢复排序时用例失败 (期望
[old, new],实际[new, old])。:source-host:test107 项通过。 - iOS 详情页"排序"改为菜单:排序依据"源顺序"(默认)/"章节号"(
details.chapterOrder),方向"最早在前"/"最新在前" (沿用details.newestFirst)。按章节号为稳定排序,同号保持源顺序;视频剧集同样适用。阅读器的前后章仍按源顺序。 - 已知:Tsundoku AllNovelFull 1.6.19 自身先用
(\d+)\D*$从标题末尾取编号("Chapter 2157 - Wedding Night 1" 记为 1), 再按该编号sortedWith排序后返回;其"源顺序"即为错乱顺序,与 Mihon 中所见一致,宿主保留源顺序不能修正该源。 - UI:新增
SourceRuntimeTests.testChapterOrderMenuDefaultsToSourceOrder(默认源顺序、菜单四个选项、切换章节号与恢复)通过;testConvertedRealMihonApkRunsOnSimulator、testAniyomiEpisodePlays回归通过。
122. Android / Windows 主页布局与 Hero 直接共用(2026-10-14)
目标与当前边界
- Windows 已经使用 Compose Desktop,但旧页面仍混有独立 Material 2 呈现;相同技术和相似外观 不等于同一份 UI 源码。后续以 Android 现有呈现为基础提取到
core-ui/commonMain,两端直接调用。 - 本轮统一主页页面布局与 Hero。历史、更新、推荐的业务投影和分区装配仍在宿主;订阅页容器、 详情内部和其余 Material 2 页面仍需逐项提取。没有宣称整应用已经完成共享 UI 迁移。
同一份 Compose 实现
HomeScreenLayout接管原 Android 的 LazyColumn、最大内容宽度、双栏比例、快捷入口位置、 加载占位、Hero 测量与随滚动的布局位移。断点根据主页内容面板自身宽度决定,不包含导航栏。HomeHeroPager接管卡片宽度、内容边距、稳定 key、预组合、滑动缩放/透明度和选中卡片的 转场静止策略。HomeHeroCardSurface与HomeHeroStandardContent共用卡片和海报/文字排列。- Android 原入口直接委托共同实现;系统 inset、滚动 interop、TV 焦点、Coil/全景背景和 shared-element 回调留在宿主。原背景和内容布局偏好继续有效,TV Hero 仍置于垂直懒列表内。
- Windows 原入口同样委托共同实现,沿用来源模型、扩展图片客户端、Controller 和存储。 移除独立主页的滚动容器、宽度判断、双栏与 Hero 编排;主页不再消费 Material 2 的主题状态。
- 未新增依赖、偏好键、数据库迁移或备份字段。呈现放在共同层、数据和平台回调保留适配边界, 避免复制页面,也避免把 Android/JVM 服务带入共同源码。
验证设计
- 新共享主页 UI 测试覆盖两种主题、520 / 999 / 1000 / 1720dp 内容面板、宽屏居中、 窄屏快捷入口可滚动到达、Hero 高度变化与滚动后实际布局坐标、视口裁剪及顶部工具栏像素、 TV 懒列表及加载状态。
- Hero 测试覆盖实际卡片几何、点击身份、数据缩减和清空。既有 fixture 平板流程继续覆盖真实 来源、详情、漫画阅读、订阅、推荐和快捷入口;窄屏探针按 LazyColumn 行为滚动到入口后操作。
- 列表与 key 策略参考 Compose 官方列表文档。 Android 真机转场、TV D-pad 和 iOS 原生构建仍待设备验证。
验证结果
- 最终
:core-ui:jvmTest24 项、共享主页 UI 4 项、原有平板全流程 1 项通过,共 29 项, 0 失败、0 跳过;该全流程包含 1260×850、920×620、1000×850 和 999×620 四个独立窗口探针。 :app:compileDebugKotlin通过;JVM / Android 共享 UI 与 Windows 应用、测试源码编译通过。 最终回归日志为本轮工作目录的shared-home-final-validation.log。- 实际检查了宽/窄主页和深色共享布局截图;修复窄窗口滚动时 Hero 越过视口覆盖标题的问题, 并加入裁剪与工具栏像素断言。加载状态测试沿用懒列表的稳定 key,先滚回插入的 Hero 再验证显示。
git diff --check通过;没有执行用户数据操作或 Android 真机 / TV 设备视觉验收。- 隔离构建目录生成 Windows 便携包;
windowsDistributionTest1 项通过,验证随包 JVM、 原生 EXE、移动后的运行时、真实来源浏览及窗口关闭。连同上述测试共 30 项通过。 已检查 ZIP 包含本轮三组共享主页组件的编译类;打包日志为shared-home-package.log。
123. 共享鼠标拖动滚动与主页手势(2026-10-14)
复现与原因
- 检查项目实际依赖的 Compose Foundation Desktop 1.12.0 源码,
CanDragCalculation明确排除PointerType.Mouse。列表/分页已有触摸和滚轮支持,但鼠标按住封面拖动不会自动触发滚动。 来源为 该版本的官方源码包。 - 用实际
DesktopHomePanel和 30 条自有历史/推荐数据注入鼠标按下、移动、松开事件,修复前 历史横栏的实际语义滚动位置保持为 0,定向测试失败。此前触摸/语义滚动检查没有覆盖这类输入。
共用实现与接入
core-ui/commonMain的mouseDragScroll接入既有ScrollableState,仅处理鼠标主键。 超过拖动阈值后按主方向接管,纵向手势交给父级;轻点、右键、触摸和滚轮沿用原处理。- 整次拖动持有同一个 UserInput 滚动事务,取代旧惯性;松手速度交给 Compose 自身的 fling。 Hero 复用 PagerDefaults 的吸附行为,方向通过 ScrollableDefaults 统一处理 RTL 与反向列表。 手势取消、禁用或组件离开时取消所属任务并释放滚动事务。
- 接入主页历史/推荐横栏、共享主页纵向列表、Hero、订阅更新横栏,以及共享分类/来源筛选横栏。 Android 与 Windows 使用同一手势源码,呈现和 Controller/数据适配沿用既有边界。
验证
:core-ui:jvmTest24 项、桌面定向测试 12 项,共 36 项通过,0 失败、0 跳过;:app:compileDebugKotlin与共享 JVM / Android、Windows 编译通过。- 新鼠标 UI 测试覆盖双向拖动、惯性、新拖动停止旧惯性、禁用后释放事务、阈值内轻点、 边界拖动不误开作品、右键、父子方向衔接、RTL/反向布局、触摸与原生滚轮,以及 Hero 吸附。 订阅横栏和顶部筛选/分类也注入实际鼠标事件,确认拖动不触发选择回调。
- 主页模型/Controller 探针验证 30 条历史/推荐的封面位置变化和无详情误触;原平板流程覆盖 四种窗口尺寸,原漫画阅读手势另覆盖拖动、点击、滚轮、预取与跨进程设置恢复。
- 测试数据来自自有夹具。最终回归日志为
mouse-scroll-final-validation.log;截图保存到desktop-app/build/reports/desktop-smoke/*-mouse-drag.png。git diff --check通过。 - 新 Windows 便携包的
windowsDistributionTest1 项通过,含随包原生窗口/运行时检查; 连同前述测试共 37 项通过。隔离目录再次通过实际主页鼠标测试,已检查 ZIP 内含共享鼠标手势类。 打包日志为mouse-scroll-package.log。
124. iOS JAR 来源账号与设置链路修复(2026-10-16)
根因与行为
- JAR 工厂返回的
ContentParserWrapper不实现登录接口,但通过authorizationProvider转发它。ParserPreferences原先直接转换 parser,导致账号状态、用户名、密码和登录按钮整体缺失。 读取账号能力与执行登录现在都使用同一authorizationProvider。 - Kotatsu 适配器补充授权提供者转发;Tsuki 改用其声明的
authorizationProvider,支持独立提供者与包装器。 Kotatsu 的DisableUpdateChecking配置不再被适配器丢弃,映射为现有共享 Toggle 控件。 - 登录错误与实际会话状态分别展示;状态查询失败显示“无法确认”,取消继续传播。 密码提交保留原字符串;凭据更新验证类型和来源能力,并如实返回磁盘保存失败。 来源异常的原始消息不放入账号设置,避免错误消息携带凭据或响应令牌。
- 原“退出登录”只删除保存的密码,无法撤销 Cookie 或来源内存中的令牌。 该操作改为“清除已保存的密码”,明确保留站点会话,不伪造退出成功。
- 账号分组增加网页登录入口,使用提供者的完整 HTTP(S) 登录地址。
SourcePreferenceNode.actionUrl是有默认值的可选字段,Swift 直接打开网页;Windows 共用消费方也接入该字段。 浏览器读取来源运行时实际 User-Agent,完成 Cookie 回写并关闭后重新加载账号状态。
同时修复的设置缺陷
- Swift 设置列表、编辑定位和 User-Agent 查找支持完整的可见嵌套分组。 文本编辑页保留通用键盘,并在面板内部显示保存失败;提交期间禁止重复保存和关闭编辑页。
- 设置提交在 iOS 界面和 parser host 内串行,避免同一 revision 被并发消费。 host 将设置绑定到 parser 实例,插件更新后的旧屏幕不能继续执行已退役插件的登录动作。 列表、正文和图片读取不加入该设置锁。
- 多选控件保存成功后更新勾选;拒绝或失败时不显示虚假的成功状态。
- JAR 域名预设增加“自定义域名”入口,保存后可显示不在预设内的当前值。 域名验证拒绝 URL 用户信息、查询、片段与反斜杠,继续接受主机和端口。
- 设置变更或导入 Cookie 后失效该来源的描述和筛选缓存;域名编辑后刷新设置页的浏览器地址。 CMS 的浏览器地址直接使用已更新的站点设置。设置加载失败可重试或下拉刷新。
编译中发现的共享代码问题
core-local/commonMain原先直接使用FileSystem.SYSTEM,公共元数据编译失败。 通过同一expect/actual属性提供 JVM/iOS 系统文件接口,保留构造参数与调用方式。- EPUB 正文、目录和 XML 文本匹配使用
[\s\S]保留跨行匹配,移除公共层不可用的RegexOption.DOT_MATCHES_ALL;平台差异见 Kotlin RegexOption 官方文档。
验证边界
- 本轮遵照要求只执行 Gradle 编译和代码阅读,没有运行测试、来源登录请求、模拟器或设备。
- 新增
ParserAuthPreferencesTest八项用例,覆盖包装器和 JSON 边界、三类 parser 授权、 特殊配置控件、参数类型、保存失败、密码空白、重新登录失败后的会话状态、取消与错误脱敏、 域名校验,以及插件更新后拒绝旧屏幕;只确认测试源码编译,不宣称行为测试通过。 :parser-host:compileKotlin、:parser-host:compileTestKotlin、三类compileFixture*Kotlin、:source-host:compileTestKotlin、:core-source:compileCommonMainKotlinMetadata/compileTestKotlinJvm、:core-local:compileCommonMainKotlinMetadata/compileTestKotlinJvm、:ios-shared:compileIosMainKotlinMetadata、:app:compileDebugKotlin通过。- Windows 共享消费方
:desktop-app:compileKotlin通过,使用既有固定兼容 API 与 Java 25 配置。 最终聚合编译记录位于忽略的build/reports/ios-source-settings-compile.log。 - SwiftUI 仅经代码阅读;Windows 上的 iOS Kotlin 元数据编译不替代 Xcode 编译、framework 链接或 UI 验证。 没有新增依赖、修改 parser 外部 ABI、提交代码或推送。
125. iOS 浏览、筛选缓存、相关推荐与下载身份续查(2026-10-16)
继续沿来源请求、界面状态和下载持久化链路进行代码审查,修复以下问题:
| 触发场景 | 原问题与修复 |
|---|---|
| OFFSET 来源的分页结果存在重复项 | 下一页曾使用去重后卡片数作为偏移;现在累计来源实际返回的条目数,同一页内也去重,失败重试不推进偏移。 |
| 多标签或默认开启的动态标签 | “全部”曾因 selectedCategory == nil 提前返回;现在按控件初值生成明确的忽略/关闭值,并按实际包含、排除状态同步高亮。 |
| 修改来源设置后回到浏览页 | 原列表与筛选定义一直保留;设置变更或浏览器回写 Cookie 后通知浏览页,关闭设置面板时重载,并保留仍有效的条件。 |
| 设置改变可用标签或动态选项 | 草稿曾继续持有失效的键与索引;现在根据新面板核对标签、枚举、语言、能力和原生控件值。 |
| 缓存失效后继续按标签/动态控件浏览 | 缺少面板时 toFilter 会丢弃所有关联条件;现在先重载所需面板。不能组合搜索与筛选的来源仍直接执行搜索,不额外依赖面板。 |
| 已声明动态筛选但读取失败 | 原异常被吞掉,界面退回不完整的标签投影;现在显示可重试错误,避免把故障伪装成来源没有这些控件。 |
| 描述或筛选请求跨越缓存失效 | 旧请求曾能重新填入旧缓存;现在发布时核对版本,失效后重新读取。CMS 设置更新也走同一缓存失效入口。 |
| 详情刷新、换源与旧请求交错 | 刷新与首次加载共用请求身份;换源清空旧推荐并失效请求,旧结果和旧错误不会覆盖新页面。 |
| 相关推荐回退搜索 | 从 page: 1 改为协议的首页 page: 0;加入取消/请求检查、去重,并按来源区分相同 ID 或相对 URL。 |
| 来源描述变化后恢复阅读页或加入下载 | 图片产物及作品/章节来源曾比较整个 SourceRef,语言等描述差异会被误判为身份不符;改为校验稳定来源名称,图片继续验证页面 ID,保留原始请求字段。 |
| 不同来源具有相同作品 ID | 作品集合的网格、列表及搜索卡片改用现有 ContentMarks.key 作为渲染身份;展示、回调和辅助功能标识仍沿用原值。 |
| 搜索结果重复或本地删除失败 | 搜索结果去重并使用统一来源错误文案;删除失败不再被 try? 吞掉,浏览页的操作提示集中到一个弹窗入口。 |
实现继续复用现有来源协议、原生控件转换、ContentMarks.key 和存储,不增加运行依赖、数据库迁移或平行状态仓库。
编译与回归源码检查
:ios-shared:compileIosMainKotlinMetadata通过,日志为忽略的build/reports/ios-source-followup-compile.log(147 个任务,17 执行、130 已是最新)。- 新增
IosSourceCacheTest五项用例:Cookie 失效后重载标签面板、不支持组合筛选时独立搜索、 动态控件加载失败向上传播,以及描述/面板请求跨越失效时拒绝旧缓存写回。IosSourcesTest与IosProductTest扩展图片与下载的来源描述差异检查,并保留跨来源拒绝检查。 - 为在 Windows 检查 iOS 测试源码,临时 init script 等待
projectsEvaluated,将src/iosTest/kotlin加入本次iosMain元数据编译,并加入同版本的kotlin-test:2.4.0编译依赖。 全部 iOS 主代码与测试源码的元数据编译通过;正式构建文件未改,随后再次执行正常主代码编译以恢复输出。
./gradlew.bat --init-script "build/reports/ios-test-metadata.init.gradle" `
:ios-shared:compileIosMainKotlinMetadata --no-configuration-cache --console=plain
./gradlew.bat :ios-shared:compileIosMainKotlinMetadata --console=plaininit script 是本地忽略的编译辅助文件,不随应用发布。以上检查没有执行测试、网络来源请求或本地文件删除。 SwiftUI 仅经代码阅读,尚未经过 Swift/Xcode 编译、framework 链接或设备 UI 验证;没有提交或推送。
126. iOS 详情页拖拽面板修复与仓库资源地址共享(2026-10-16)
详情页 dock/面板(DetailsDockPane):
- 阅读按钮与 dock 的拖拽设为优先手势,向下拖拽后松手不再触发阅读;最大展开时 dock 仍保留在面板上方,可直接下拖回半屏或收起。
- 底部安全区在
ignoresSafeArea之前读取(之后读到的恒为 0,dock 曾只距底边 6pt)。 - 面板使用固定布局高度,仅随手指位移;列表内容不再随拖动重排抖动,底部留白由锚点决定。
- 章节行(
.plain按钮)补contentShape(Rectangle()):此前行中间空白处点击不触发选择/打开。 - UI 测试:
DetailsDockPaneTests(几何)与LocalImportTests的拖拽面板、章节多选用例。收起判定以手柄移出屏幕为准。
仓库资源地址:带 repo.json 元数据的仓库(如 LittleSurvival/copymanga-copy20)经第二段列表读取后, 资源基础地址曾被设为列表文件本身,APK 变成 …/index.min.json/apk/… 而 404(表现为 NSURLErrorDomain -1011)。
core-source新增ExtensionRepoUrls(repositoryBase/resourceBase/resolveResource),对齐 Android:资源相对仓库目录, 与列表文件位置无关。iOS 的 Kotlin 目录与 Swift 解析改用它(IosRepositoryCatalog.resolve),已保存的旧目录也被纠正; Android 仅替换行为一致的回退拼接,baseUrlFromIndexUrl/normalizeIndexUrl的 HttpUrl、Cloudstream、protobuf 分支未动。- 下载失败现在显示 HTTP 状态码与完整地址(
InstallDownloadError)。
验证::core-source:jvmTest、:ios-shared:iosSimulatorArm64Test、:app:compileDebugKotlin 通过;真实 copy20 仓库的 testCopyMangaInitializesAndPersistsInstallation、testExternalRepositoryLinkImportsAndInstallsMarkerlessApk 通过。
未做:真机验证 dock 高度与拖动手感;曾尝试把章节/缩略图/书签平铺进详情页并只保留右侧阅读按钮,因首屏章节被挤到底部而回退,未保留。
127. Windows 书签多选删除与收藏分类管理(2026-10-23)
日期:2026-10-23。继续 §59–60 的平板/详情对齐,把 Android 的书签长按多选与分类界面搬到桌面。
书签多选(提交 3839fbff9):
- 详情书签标签新增“选择书签”态:卡片出现勾选框,批量栏提供全选/清除选择/删除所选; 多选时点击卡片切换选中,单条删除与“全部删除”隐藏或禁用,避免重叠的破坏性操作。
DesktopController.removeBookmarks(pageIds)在单个 action 内按页 id 过滤本作品书签并逐条删除 (每条独立事务,别处已删的只跳过),随后刷新书签与原小说正文锚点,提示“已删除 N 个书签”。- 书签列表变化后自动清理失效选中项,删空后退出多选;选择状态以
remember(bookmarks)派生的 稳定集合为 key,避免每次重组重启LaunchedEffect形成写入死循环。
收藏分类管理:
- 增删改查进入
core-library的SourceLibrary(categories/createCategory/renameCategory/removeCategories/reorderCategories,另含 tracking/可见性),语义对齐 AndroidFavouritesRepository: 新建取getNextSortKey()并以NEWEST起排、重命名只改标题(保留排序与开关)、删除在同一事务里 先清该分类的收藏成员再软删分类并回收章节、调序按给定顺序写sort_key。 - 收藏页顶栏“分类管理”对话框:新建、重命名、上移/下移、删除;改动写库后重载快照, 标签栏、卡片归属与“全部”切片同步更新,删除正被筛选的分类会退回“全部”。
- 分类增删改查不再局限于备份合并,Android 后续可直接复用同一入口。
验证::desktop-runtime:test 新增两个用例(重命名保留 order/track、调序与快照一致、 删除连带成员并让后续收藏落到新建默认分类)通过;:desktop-app:test 164 个用例、 verifyDesktopStrings 718 键无未使用项;真实窗口探针 tablet 在对话框里新建/重命名/上移/删除分类 并截图(tablet-library-categories.png),parsers-write 验证书签多选删除的勾选、全选、删除与空态。
未做:分类的追踪开关与备份节内的分类排序仍走原备份流程;标签检索跳转、分类封面与拖拽排序未接。
128. 共享文案层:UiStrings 按 Android 资源名取词(2026-10-23)
日期:2026-10-23。按“先做文案抽象 LocalStrings,再逐屏迁移”的顺序,先让 core-ui 里的共享界面能取词。
core-ui新增text/UiStrings(string/format/array)、LocalUiStrings(staticCompositionLocalOf, 默认NameOnlyUiStrings原样返回键名)与uiString/uiStringArray。键就是 Android 资源名 (content_type_manga、reader_page_label等),Weblate 维护的app/src/main/res/values…/strings.xml仍是唯一来源,不复制一份翻译。- Android 侧
AndroidUiStrings用带缓存的getIdentifier(含命名空间回退)把名字解析成字符串/数组,String.format留在各平台适配器(commonMain 没有),解析不到时返回键名,预览与测试不会崩;KototoroTheme在最外层 provide,Android 现有stringResource调用不受影响。 - Windows 侧
DesktopUiStrings转接到AndroidStrings,也就是prepareDesktopStrings从同一份app/src/main/res导出的目录(en / zh-CN),DesktopTheme负责 provide;切换语言仍即时生效。 - 内容类型标题改为派生:
contentTypeTitle(name)取content_type_<小写类型名>,取不到回落类型名, 与 AndroidContentType.titleResId同构;桌面重复的 15 组label.contentType.*键删除,DesktopSourceLabels.contentType与浏览分组标签改为调用共享实现(中文显示与 Android 一致, 旧的桌面译法如“画师 CG”统一成 Android 的“艺术家CG”)。 :core-ui:verifyUiStrings扫描core-ui/app/desktop-app源码里的uiString("…")字面量,解析app/src/main/res的 strings 与 arrays,再读parser-api的ContentType枚举,要求每个类型都有content_type_<lowercase>;扫不到 1000 个键或不到 10 个类型直接失败,避免空跑通过。故意写入一个 不存在的键可以复现失败,已手工验证后删除。
验证::core-ui:compileCommonMainKotlinMetadata、:core-ui:jvmTest(新增 ContentTypeTitlesTest)、 :core-ui:verifyUiStrings、:desktop-app:compileKotlin/compileTestKotlin/verifyDesktopStrings、 :app:compileDebugKotlin;桌面全量 :desktop-runtime:test(90 例)与 :desktop-app:test(168 例, 含新增 DesktopUiStringsTest 6 例)通过,真实 tablet 探针的“漫画”“全部”标签来自 Android 目录。
未做:Android 界面仍用 stringResource,共享界面逐步迁移时再统一切到 uiString;pluralStringResource 与带格式占位符的资源尚无共享封装。
129. Windows 来源栏补上内容类型与来源类型筛选(2026-10-23)
日期:2026-10-23。对应“打开某个源时左侧快速切换列表缺少过滤器”的反馈。
DesktopSourcePane的 240dp 来源栏复用DesktopSourceFilterControls(rail前缀):内容类型下拉与 来源类型菜单,与DesktopSourceGrid顶部是同一个state.exploreFilter,所以侧栏收窄后,选中源的 作品列表沿用同一范围,对齐 Android 平板“一个范围同时管源列表与内容”的行为。- 栏头计数改为当前可见源数量,一个都不匹配时显示“导入 JAR 后,来源会显示在这里”。
- tablet 探针的四个窗口模式都会断言两个控件存在,并在
exploreFilter(CLOUDSTREAM + 视频)后 断言该源从侧栏消失且出现空态,恢复筛选后重新出现(截图tablet-browse-rail-filtered.png)。
验证::desktop-app:test --tests "*DesktopAppTest*tablet*" 通过(tablet / narrow / boundary / boundary-compact)。
130. Windows 桌面外壳统一到 Material 3(2026-10-23)
日期:2026-10-23。共享组件是 Material 3,桌面外壳之前靠 DesktopTheme 里的 Material 2 桥接运行: M2 的 Colors 与 Typography.h4–caption 角色被手工映射到 M3 角色,控件本身仍是 M2 的。
- 删除桥接的
MaterialTheme(colors = …)与Colors/Shapes/Typography映射;DesktopTheme只保留 一个 M3 主题(darkColorScheme/lightColorScheme+kototoroTypography+kototoroShapes), 配色逐项搬运,不再有两套语义。 - 23 个桌面文件的
androidx.compose.material.*导入改为androidx.compose.material3.*;MaterialTheme.colors→colorScheme,typography.h4/h5/h6/subtitle1/body1/body2/caption→headlineLarge/headlineMedium/titleLarge/titleMedium/bodyLarge/bodyMedium/bodySmall(与原桥接映射一致, 视觉等价),Divider()→HorizontalDivider(),ButtonDefaults.outlinedButtonColors(backgroundColor=)→containerColor,Surface(elevation=)→shadowElevation,Card(elevation=)→CardDefaults.cardElevation,DropdownMenuItem(action, modifier) { text }→DropdownMenuItem(onClick = …, text = { … })。 - 筛选对话框的 M2
buttons槽改成 M3 的confirmButton(应用)与dismissButton(恢复默认、取消), 三个 testTag 保留,探针仍点得到。 - M3 没有
Colors.isLight,暗色分支改读新的LocalDesktopDark(由外观设置决定,不再按亮度猜)。 - 阅读器内部的局部主题从
darkColors(…)换成darkColorScheme(…)并显式给onSurface = White, 正文与面板文字颜色保持不变。
验证::desktop-app:compileKotlin、:desktop-app:compileTestKotlin、:desktop-app:verifyDesktopStrings 与 :desktop-app:test(含四种窗口模式的 UI 探针与截图)通过。
未做:Slider/Switch/Checkbox 的 M3 默认度量与 M2 略有差别,若需像素级对齐再做逐控件微调。
131. Windows 收藏/历史补上标签筛选组(2026-10-23)
日期:2026-10-23。Android 的作品列表筛选里 ListFilterCriteria.Tag 是一个独立的筛选组: 同组内多选按 SQL 的“或”展开,不同组之间“且”。桌面的收藏/历史抽屉只有排序、阅读进度与来源, 共享库里带标签的作品无法按标签浏览。
DesktopLibrarySelection新增tags: Set<SourceTag>,过滤条件为tags.isEmpty() || content.tags.any { it in tags },与 Android 的同组语义一致;已选标签计入filterCount,顶栏“筛选 · N”随之变化。标签身份用SourceTag(title/key/source)的值相等, 不引入桌面专有的标签 id。- 抽屉新增“标签”区(
library-tags-open),未选中时显示“选择标签”,选中后显示“标签 · N”; 库内没有标签时按钮禁用,避免打开空对话框。 - 新增
DesktopTagPicker.kt:M3AlertDialog+DesktopSearchField+ 复选列表,按“作品数降序、 同数按标题”排序并显示作品数,行、搜索框、关闭、清除各有 testTag(library-tag:<key>、library-tag-search、library-tag-done、library-tag-clear)。勾选立即生效并重算网格, 关闭只收起对话框,清除只清标签组。 - 标签来自收藏保存时写入的标签行(
SourceLibrary.content()从tags表投影),列表项本身没有 标签的作品不参与标签筛选——这与 Android 的持久化行为一致,不是桌面端缺失。
验证::desktop-app:test --tests "*DesktopLibrarySelectionTest*" 覆盖同组“或”、跨组“且”、 标签与搜索/进度的组合以及 filterCount;:desktop-app:test --tests "*DesktopAppTest*tablet*" 在探针里收藏一部带 Drama 标签的作品,打开对话框截图(library-tag-picker.png)、勾选后 网格只剩该作品、清除后恢复;:desktop-app:verifyDesktopStrings 校验新增中英文案键一致。
未做:Android 的追踪状态(_reading_status/_publication_state)与“已下载”筛选组, 桌面侧还缺对应的持久化条件。
132. Windows 收藏/历史补上出版状态与下载筛选组(2026-10-23)
日期:2026-10-23。承接 §131:Android 的 ListFilterCriteria.PublicationState(_publication_state) 与“已下载”仍是缺口;同时确认 _reading_status 在桌面已由“阅读进度”按共享历史进度近似 (进度为空→计划中、有进度→阅读中、≥0.99→已读完,对应 Android resolveReadingStatus 的回退分支), 只有追踪器写入的“重读中/搁置/弃坑”需要桌面尚未持久化的追踪状态,因此本轮先补两组。
DesktopLibrarySelection新增states: Set<String>(content.state组内“或”)与download: DesktopLibraryDownload(ALL/DOWNLOADED/MISSING互斥单选,谓词accepts(onDevice));filterCount计入状态个数与下载条件。select(...)新增downloadedContentIds: Set<Long>参数,由面板从controller.downloads.state投影(每条章下载记录的record.contentId),与下载页共用同一份队列,不另建索引。- 抽屉新增两个区:出版状态只列出此库实际存在的状态(
DesktopContentStates按 AndroidContentState顺序过滤,复选框,testTaglibrary-state:<NAME>);下载为三个FilterRadio(library-download:ALL|DOWNLOADED|MISSING)。 DesktopDetailsInfo.kt:desktopContentStatusLabel补齐UPCOMING/RESTRICTED(详情页状态行同步受益),并新增DesktopContentStates顺序常量。- 文案:
library.state、library.download{,.all,.only,.missing}、details.status.{upcoming,restricted}中英文各一份。
验证::desktop-app:test --tests "*DesktopLibrarySelectionTest*" 新增两例(状态组内“或”且与进度/标签 “且”、下载三态与 filterCount);:desktop-app:test --tests "*DesktopAppTest*tablet*" 四个窗口尺寸 全绿,探针收藏一部状态为 FINISHED 的作品后勾选“已完结”只剩它、取消后恢复,“已下载”在无本地下载时 清空网格(用例读顶栏 library-count 的计数,避免惰性网格的虚拟化误判),“未下载”恢复全部并计入筛选数, 截图 library-filter-groups.png、tablet-narrow-library-filter-groups.png 等; :desktop-app:verifyDesktopStrings 通过。
未做:追踪器专属阅读状态(RE_READING/ON_HOLD/DROPPED)需要桌面侧先有 scrobbling 状态持久化; 状态/下载组的“取反”独立选项(Android 的 Inverted)由三态单选覆盖,不另设。
133. Windows 收藏/历史补上宏筛选组(2026-10-23)
日期:2026-10-23。承接 §132:Android 的 ListFilterCriteria.Macro 是抽屉里最后一组还没有落地的筛选, 其中“有新章节/NSFW/收藏(及取反)”在桌面缺失。
SourceLibraryEntry新增两个来自现有数据库行的字段(都有默认值,其余构造点不变):isNsfw(manga.nsfw,与 Android 收藏快照FavouriteCardRow.isNsfw同源)与newChapters(tracks.chapters_new,即更新检查器发现的未读新章数)。SourceLibrary.snapshot()用两条批量查询填充:MangaDao.findEntitiesByIds取 nsfw 位、TracksDao.findNewChapters(ids)取新章数,不做逐卡查询。DesktopLibrarySelection新增macros: Set<DesktopLibraryMacro>(NEW_CHAPTERS/FAVORITE/NOT_FAVORITE/NSFW/SFW)。宏在 Android 是各自独立的 group(groupKey = name), 因此桌面按“全部满足”求交;NOT_FAVORITE/SFW对应 Android 的Inverted选项。filterCount计入所选宏个数。DesktopLibraryMacro.choices(history):收藏页只给“新章节/18+/全年龄”(收藏页全是收藏, 收藏与非收藏无意义,与 Android 收藏快捷筛选一致);历史页给全部五个。- 抽屉新增“快速筛选”区(复选框,testTag
library-macro:<NAME>),排在下载区之后。 - 文案:
library.quick、library.quick.{newChapters,favorite,notFavorite,nsfw,sfw}中英文各一份; 中文沿用 Android 资源(新章节/收藏/非收藏/18+/全年龄)。 - 对齐修复:
DesktopLibraryReading.FINISHED的完成阈值从>= 1f改为共享常量CompletedProgress(0.999f),与 AndroidresolveReadingStatus/FavouriteLibraryDeriver一致。 - 未做:Android 的
COMPLETED宏不再单独提供——桌面的“阅读进度=已读完”读的正是同一进度 (Android 那边两枚芯片之所以不同,是因为它的阅读状态筛选读的是追踪器状态);值 0.999 阈值已对齐。
验证::desktop-app:test 新增两例(宏分别读 nsfw 位/新章计数/分类成员,独立宏求交, NSFW 与 SFW 同选为空;收藏页的宏列表裁剪);:desktop-app:test --tests "*DesktopAppTest*tablet*" 四个窗口尺寸全绿,探针写入一条 chapters_new = 2 的 tracks 行后勾选“新章节”只剩该作品、 取消后删除该行,收藏页不存在“收藏/非收藏”行,NSFW 清空网格、与 SFW 同选仍为空、 只留 SFW 时恢复全部,截图 tablet-library-filter-groups.png 等;:desktop-app:verifyDesktopStrings 与 :core-library:compileCommonMainKotlinMetadata 通过。相关后续:收藏卡片上的新章节角标 (Android 有)可在本次字段基础上直接接。
顺手修掉的两个既有隐患:snapshot() 里所有 IN (:ids) 批量查询(history 进度、nsfw、新章计数) 在空书架时会拼成 IN () 并抛 SQL 语法错误,现在空列表直接跳过(Room 不会为集合参数生成 NULL)。
本机环境注记:本轮跑 :desktop-app:test 时 parsers-* 探针(kototoro/kotatsu/UMA 插件)与 continuous Windows reader … 探针各自报 UncompletedCoroutinesError: After waiting for 1m, 子进程 60 秒内没跑完即被判失败;把本节改动 git stash 后连跑三次 parsers-* 仍 3/3 失败, 说明与本轮无关,是这两个探针在本机较慢环境下的既有超时(其余 170+ 用例正常)。
134. Windows 收藏卡片补新章节角标(2026-10-23)
日期:2026-10-23。承接 §133 的数据字段:Android 在收藏网格的封面上画一个右上角的新章节数字角标, 桌面此前只在详情/订阅里看到新章数。
- 卡片:
ContentGrid新增可选参数newChaptersOf: (SourceContent) -> Int(默认 0,浏览网格不受影响), 在封面右上角画ContentCardBadgePill(ContentCardBadgeTone.COUNTER)+ 纯数字,位置/内边距(5dp)与 AndroidKototoroContentCard的 top-right 角标一致;语言角标仍在左下角。 角标同时带contentDescription(library.newChapters→ “%d 个新章节”/“%d new chapters”), testTag 为content-new-chapters:<id>。 - 显示规则集中在
newChaptersBadge(entry, history):AndroidFavouritesCardMapper用的是counter = if (progress?.isCompleted() == true) 0 else row.newChapters,因此 读完的作品不画角标(阈值沿用共享ReadingProgress.isCompleted= 99.999%, 与 §133 宏/阅读状态的 99.9% 阈值不同,两者各按 Android 原实现); 历史页永不画(HistoryCardMapper把counter固定为 0)。 “新章节”宏读的仍是tracks.chapters_new原值,所以读完的作品在宏里仍算“有新章节”。 - 文案:新增
library.newChapters(中英各一份),仅用于角标语义,不改变 Android 数字角标的视觉。
验证::desktop-app:test 新增一例(未读/未开始显示、progress = 1f 与 0.99999f 归零、 0.999f 仍显示、历史页恒为 0);:desktop-app:test --tests "*DesktopAppTest*tablet*" 四尺寸全绿, 探针给两本收藏各写一条 tracks 行(一本读完 + 新章 3、一本未读 + 新章 2), 未读卡片存在 content-new-chapters 且语义为“2 个新章节”,读完卡片不存在该角标, “新章节”宏此时命中 2 本(宏读计数、角标看完成度),结束后删除两条 tracks 行; 截图 tablet-library-filter-groups.png 可见 “Tagged shelf” 右上角蓝色 “2”, “Finished shelf” 无角标。:desktop-app:verifyDesktopStrings 通过。
135. Windows 源设置页分组化与共享设置行(2026-10-23)
日期:2026-10-23。用户指出设置页(尤其每个源的配置)在 Windows 上是平铺的一列卡片,与 Android 不一致。 Android 的来源设置由 ComposePreferenceAdapter 把 androidx PreferenceCategory 折成带标题的 section: 每节一张卡片、行间一条细分割线,开关行整行可点,选项/多选/文本/动作行只显示当前值和箭头、编辑放进对话框。 本轮先补共享行组件与源设置页,推荐设置和拓展管理随后按同一套组件迁移。
共享设置行(core-ui)
- 新增
core/ui/settings/SharedSettingsRows.kt:SettingsSectionHeader(节标签,primary/labelLarge)、SettingsGroup(一张 20dp 圆角卡片)、SettingsRowDivider(左右 16dp、10% 细线)、SettingsSwitchRow(整行toggleable(role = Role.Switch),尾部Switch(onCheckedChange = null), 与 AndroidSettingsSwitchPreference一样整行可点且只暴露一个无障碍开关节点)、SettingsValueRow(左侧标题/摘要,右侧当前值 +ReaderPanelIcons.ArrowForward,值最宽 260dp 后省略)、SettingsInfoRow。行内边距 16dp/12dp,标题bodyLarge+Medium、摘要bodySmall/onSurfaceVariant。 只使用MaterialTheme.colorScheme角色,不引用桌面私有的Ink/Muted,Android 与 iOS 可直接复用。 - 文件在
core-uicommonMain,:core-ui:compileCommonMainKotlinMetadata通过,非 JVM 目标同样可编译。
桌面源设置页
- 新文件
DesktopSourcePreferences.kt取代DesktopApp.kt里的Preferences/PreferenceNode:sourcePreferenceSections(nodes, fallbackTitle)把协议树折成节——Kototoro 自己的host:行 (User-Agent、域名)排在前面并放进以 Android “设置”串命名的默认节,带标题的 GROUP 各自成节, 无标题 GROUP 和嵌套 GROUP 拍平成所在节的行(与 Android 拍平PreferenceGroup一致); 不可见节点丢弃,没有可见行的节不出现;被 GROUP 隔开的两段散行会产生两个同名默认节(Android 同样如此), 因此 section id 带序号,避免 Compose key 冲突。 - 行渲染:TOGGLE 即时提交;CHOICE/MULTI_CHOICE/TEXT 显示当前值(多选取标签逗号连接、密文按长度打点、 空值不显示)并打开对话框,选中即提交并关闭(多选保持打开、逐项提交);ACTION 整行可点提交; INFO 只读;UNSUPPORTED 只读并在无摘要时显示“本平台不支持此控件”。testTag 保持
preference:<id>, 对话框内选项沿用preference:<id>:<value>,新增preference-dialog:<id>、preference-field:<id>、preference-save:<id>。 - 删掉不再使用的
desktopText("preference.execute")(动作行不再画按钮),中英两份 properties 同步删除。
验证::desktop-app:test 新增 DesktopSourcePreferencesTest 四例(host 行置顶且落默认节、无标题 GROUP 拍平且默认节 id 唯一、嵌套 GROUP 拍平、不可见行与空节丢弃); --tests "*DesktopAppTest.Windows rendered source details reader settings and shared state survive a second process" 两个独立 JVM 全绿:探针改为先点 preference:<id> 打开对话框、断言 preference-dialog:<id> 存在, 再点 rejected(来源拒绝提示)→ 对话框关闭 → 重新打开点 saved,值跨进程保持、域名设置 行仍在; 截图 write-preferences.png 可见“设置”节标签、单卡片三行(User-Agent、域名、域名设置=Saved)与尾部箭头。 :desktop-app:verifyDesktopStrings、:core-ui:verifyUiStrings、:core-ui:compileCommonMainKotlinMetadata 通过。 本轮没有做推荐设置页与拓展管理页,也没有跑全量回归。
136. Windows 推荐设置独立成页与共享设置对话框(2026-10-23)
日期:2026-10-23。§135 之后用户继续指出“推荐设置(尤其是各个源的配置,怎么能平铺呢)”, 桌面原来把推荐规则压缩在“更多”页的一张卡片里:两个开关、两个内联标签输入框, 外加每个已装来源一行 FilterChip(优先/排除),正是 Android 刻意避免的平铺布局。 Android 侧对应 settings/compose/SuggestionsSettingsScreen.kt:开关行、标签行(对话框编辑)、 来源规则(多选对话框),彼此分组。
独立页面
- 新增
DesktopRecommendations与DesktopScreen.RECOMMENDATIONS,入口在“更多”页第一张卡片 (ic_suggestion,testTagsettings-recommendations-open),页面内“返回”按钮回到“更多”, 标题用新增的screens.recommendations。 - 三节,全部使用 §135 的共享行:推荐(启用推荐整行开关 + 摘要、排除成人内容、说明 InfoRow)、 标签(排除标签 / 偏好标签:值行显示逗号连接值,开单行文本对话框,保存仍走共享
parseSuggestionTags)、来源(偏好来源 / 排除来源:值行显示来源显示名,开多选对话框)。 - 已装来源不再逐行出现:多选对话框一次列出全部来源,勾选即时提交且保持打开; 两张列表互斥(勾选偏好来源会从排除列表移除,反之亦然),与旧 chips 的语义一致。
- 关闭推荐时除“启用推荐”外全部禁用,对应 Android 的 preference 依赖关系。
共享设置对话框
- 新增
DesktopSettingsDialogs.kt:DesktopChoiceSettingsDialog(单选,选中即提交并关闭)、DesktopMultiChoiceSettingsDialog(多选,逐项提交、保持打开)、DesktopTextSettingsDialog(单行文本,onSave返回 false 可让对话框保持打开,secret用密码遮罩)。 tag 形状统一为bodyTag、<optionTagPrefix>:<value>、<bodyTag>-close,方便探针关闭对话框。 - 源设置页改用同一组对话框,行为与既有 tag(
preference-dialog:<id>、preference:<id>:<value>、preference-field:<id>、preference-save:<id>)不变,去掉了一份私有实现。
更新检查
- “更多”页的 tracker 卡片改为
DesktopTrackerSettings(原文件DesktopBackgroundSettings.kt重命名为DesktopTrackerSettings.kt):开关行 + 频率值行,频率从一排 chips 改成单选对话框 (行 tagsettings-tracker-frequency,选项settings-tracker-frequency:<value>), 与 Android 的列表偏好一致。
文案
- 新增
screens.recommendations、common.back、more.recommendationsSummary、settings.suggestionsTags、settings.suggestionsSources、settings.suggestionsPreferredSources、settings.suggestionsExcludedSources、settings.suggestionsInfo、settings.suggestionsSourcesDetail(中英各一份)。 - 删除随 chips 一起消失的
settings.prefer、settings.exclude、settings.sources。
验证::desktop-app:compileKotlin 通过;DesktopAppTest 两例(tablet 四窗口 + 源设置两进程) tests=2 failures=0、DesktopSourcePreferencesTest tests=4 failures=0(读 build/test-results/test/*.xml); :desktop-app:verifyDesktopStrings 通过(删掉三个未用键后才通过,剩余键全部被页面引用)。 tablet 探针现在走“更多”→“频率对话框”选中 2.0(断言写回 tracker_freq 且对话框关闭)→ “推荐设置”→ 勾选偏好来源(断言写回 suggestionSettings.preferredSources)→ 关闭对话框 → 回到“更多”; 截图 tablet-recommendations.png 可见三节卡片、开关行、带箭头与摘要的值行。 本轮没有做拓展管理页,也没有跑全量回归。
137. Windows 扩展管理页改用共享设置节与行(2026-10-23)
日期:2026-10-23。§135/§136 之后“更多 → 扩展与仓库”仍是平铺的一列:裸 Text 标题、全宽输入框、 每行仓库两个 TextButton、目录里每个扩展一张 Card、已安装用 Checkbox + 文字。Android 的 “扩展管理”(extension_management → UnifiedSourcesScreen)是带标题的分节 + 行内控件的设置式列表, 因此桌面这一页也换成同一套词汇。
core-ui的共享行新增SettingsActionRow(title, summary, verticalPadding, content):左侧标题/摘要与 其他行同排同字号,右侧由宿主放任意控件(两个按钮、一个按钮)。仓库行(刷新 / 忘记)、目录行(安装)、 已安装行(更新 / 卸载)都用它,行间距与内部 padding 和SettingsSwitchRow一致。- 页面结构:扩展仓库(说明 + 地址输入 + 添加按钮 + 每个仓库一行)→ 目录(仓库名与数量作节标题, 搜索框、列表在同一张卡片里,行间细分割线)→ 已安装(自动更新改整行开关,检查更新/全部更新留在 卡片内的动作行,已安装列表每行带更新与卸载)。空目录/空列表各显示一条
SettingsInfoRow。 - 交互与 tag 不变:
repository-url、repository-add、repository:<url>、repository-remove:<url>、extension-search、extension-list、extension-install:<pkg>、extension-auto-update(改成开关行, 整行可点)、extension-check-updates、extension-update-all、extension-update:<id>、installed-list、extension-uninstall:<id>、extension-uninstall-confirm,探针无需改流程。 - 文案:仓库刷新按钮改用既有的
common.refresh(原来带仓库名会挤占行宽,且行标题已显示仓库名), 随之中英两份 properties 删掉不再使用的extensions.refreshRepository;新增extensions.catalogEmpty、extensions.installedEmpty。
验证::desktop-app:compileKotlin、:core-ui:compileCommonMainKotlinMetadata、 :desktop-app:verifyDesktopStrings 通过;DesktopAppTest 的 “Windows repository add install update and app-owned extensions persist in another offline process” (两个独立 JVM)tests=1 failures=0,覆盖添加仓库、APK-only 条目可装/无包条目不可装、安装、刷新仓库出“更新”、 再次安装升级、检查更新 + 全部更新、自动更新开关(现在点整行)、卸载,并核对请求数与旧产物清理; 截图 repositories-write-catalog.png / repositories-write-installed.png 可见分节卡片、仓库行尾的 “刷新 / 忘记该仓库”、目录行的安装按钮、已安装行的“卸载”与整行开关。
范围:Android 的统一扩展管理还有 JSON 源与 APK 扩展的独立页签、解析器(JAR)管理、安装策略与 推荐仓库弹窗,Windows 侧仍只有仓库 + 目录 + 已安装三块,后续按需再补。
138. Windows“更多”页改为设置式分节(2026-10-23)
日期:2026-10-23。§137 之后唯一还是旧样式的外壳页面是“更多”:四张图标大卡 + 一张卡里三排 OutlinedButton 内联选项(语言 4 个、主题 3 个、界面风格 2 个)。Android 的“更多”(辅助功能)是 设置列表,每项一行、当前值在箭头前,改值开对话框,因此这一页也换成同一套词汇。
SettingsValueRow增加可选leadingIcon: Painter?:共享行只接收平台给的画笔,Android 传canvasPainter(R.drawable.…)、Windows 传随包的 SVG,共享模块不需要知道资源系统。 §135–137 的行样式不变,leadingIcon默认 null。- 页面结构:功能(推荐设置、扩展与仓库、下载、备份与恢复,都带图标,
nav:<标题>与settings-recommendations-open的 tag 保持不变)→ 外观(主题、界面风格、语言,右侧显示当前值, 点击开单选对话框)→ 更新检查(§136 的 tracker 卡)→ 关于与更新 → 高级工具(浏览器调试)。 语言/主题/界面风格三排内联按钮改成DesktopChoiceSettingsDialog,选项 tag 仍是appearance:<NAME>、interface-style:<NAME>、language:<tag>,只是先点行再选值。 - 删除
LanguageChoice与MoreDestination(体积更小的SettingsValueRow已覆盖)。 - 文案:新增
more.features(功能 / Features)、more.theme(主题 / Theme);顺手清掉中英两份 properties 里重复的more.language与more.languageSystem(后者两份取值还不一致,保留短的那条)。
验证::desktop-app:compileKotlin、:desktop-app:verifyDesktopStrings、:core-ui:verifyUiStrings、 :core-ui:compileCommonMainKotlinMetadata 通过;DesktopAppTest 的 “Android styled light and dark themes render covers and persist across an offline restart”(两个 JVM, 先写后读)tests=1 failures=0,语言/主题/界面风格对话框均被点击并落到偏好; “tablet layout and immersive reader panels …” 与“Windows backup preview cancel merge …” tests=2 failures=0,说明 nav:更多、nav:扩展与仓库、nav:下载、nav:备份与恢复、settings-recommendations-open 等入口未受结构改动影响;新截图 tablet-more.png 与 tablet-boundary-compact-more.png 可见分节卡片、 带图标的功能行、右侧当前值与箭头。
139. Windows 阅读设置页(2026-10-23)
日期:2026-10-23。Android 的阅读设置是独立设置屏(ReaderSettingsScreen);Windows 侧这些偏好只存在于 打开章节后的阅读器侧面板里,与超分下载、点击区域混在一起,不打开一本书就改不了。§138 把“更多”整成 设置页之后,这一页成了唯一还缺的 Android 设置屏,本轮补上。
- 新页面
DesktopScreen.READER_SETTINGS(“更多 → 功能 → 阅读设置”,行 tagreader-settings-open, 图标ic_read.svg),复用 §135–138 的节/行/对话框词汇:- 页面:阅读模式(单页/双页/连续)、阅读方向(从左到右/从右到左/从上到下)、缩放方式(适应整页/ 适应宽度/适应高度/原始大小)、背景(默认/浅色/深色/白色/黑色/跟随系统)、显示页码、裁剪边距、 封面单独成页(连续模式隐藏)。
- 翻页:到章节末尾自动跨章、翻页动画(连续模式置灰)、自动翻页/滚动速度滑块。
- 方向只有一枚选项:共享场景里
vertical优先于rightToLeft,所以选“从上到下”会清掉 RTL,反之亦然, 与DesktopReaderSettings.readingDirection的判定一致。 - 裁剪是 Android 的两份偏好(分页
cropPaged/ 连续cropContinuous)合成一行:页面按当前模式显示并写对应 的那一份,避免用户看到两个语义重复的开关。 - 速度滑块用本地草稿、松手才提交(
onValueChangeFinished),与阅读器面板一致,避免一次拖动写几十次偏好。 - 页面编辑的是同一份
DesktopReaderSettings(同一批偏好键),所以这里改完下次打开章节即生效;阅读器面板 一行未动,两条入口并存。 desktopText("reader.direction." + name)这种拼键会被verifyDesktopStrings当成字面量键而报缺失, 方向标题因此改成when显式列举。
验证::desktop-app:compileKotlin、:desktop-app:verifyDesktopStrings 通过;DesktopAppTest “tablet layout and immersive reader panels preserve progress in wide and narrow windows” tests=1 failures=0, 该探针在“更多”里进入阅读设置页、逐个打开模式/方向/缩放/背景/动画五个对话框并断言 readerSettings 的 mode、rightToLeft、fitMode、background、pageNumbers、automaticChapter、animation 全部落值,再返回“更多”;截图 tablet-reader-settings.png 可见两节的卡片、右侧当前值与滑块。
140. Windows 收藏/历史批量选择(2026-10-23)
日期:2026-10-23。Android 的书架长按任意卡片即进入选择模式,顶栏换成 KototoroSelectionTopBar (全选/置顶/移除/下载/分类/分享/标为已读/迁移)。Windows 侧这两个书架此前只有逐卡操作:把一部作品挪出某个分类、 或清掉一页历史,都要一个个点。本轮补上选择模式与其中三条动作。
- 入口与 Android 一致:卡片长按进入(
ContentGrid新增selectedIds/onToggleSelection,为null时是 原有浏览语义),进入后单击即勾选/取消,卡片左上角画勾选徽标(tagcontent-selected:<id>,需useUnmergedTree,因为整卡是 merged 节点)。 - 顶栏换成
DesktopLibrarySelectionBar(taglibrary-selection-bar):已选数量、全选、分类、移出收藏、 取消。全选按当前可见行(搜索/筛选后的结果)计数,不是整个书架。 - 收藏页的“分类”是 Android 批量分类对话框:勾选集合就是所选作品的目标分类集合(勾上加入、取消勾选移出), 因此对话框内只改本地草稿,
确定时才调SourceLibrary.updateCategories(contents, included, excluded)——included为勾选、excluded为其余分类,未改动分类里的成员身份与已存详情保持原样。 初始勾选取所选作品分类的交集,避免把各自独有的分类显示成全选。 - “移出收藏”与历史的“从历史移除”都走一次确认对话框(favourites 保留阅读进度,history 连进度一起删); 分别接
SourceLibrary.removeFavourite与removeHistory,改完重载同一份快照,书架与首页“继续阅读”一致。 - 仍未移植的 Android 动作:置顶(
PIN)、下载(SAVE)、分享(SHARE)、标为已读(MARK_AS_COMPLETED)、 迁移/编辑覆盖(MIGRATE/EDIT_OVERRIDE在 Android 属溢出菜单)。它们要么缺少桌面后端(分享、迁移), 要么与详情页已有的“标为已读”重复,故不在本轮硬凑。 - 断言只走
useUnmergedTree的徽标与顶栏 tag;对话框正文另挂library-category-pick-dialog/library-selection-dialog供snapshotTag截图(对话框是独立 root,onRoot()会二义)。
141. Windows 来源设置页(2026-10-23)
日期:2026-10-23。Android 的 SourcesSettingsScreen 是“概览 / 远程来源 / 成人内容过滤 / 更多”四节的设置页: 来源排序、网格模式、按语言分组、置顶顺序、显示损坏来源、成人过滤目标、18+ 隐身模式、全局标签黑名单、 标签警告、镜像切换、链接处理、扩展安装策略。Windows 侧这些值此前要么写死(来源总览固定网格、固定安装顺序、 永远显示全部来源),要么根本不存在,来源页只有每-source 的偏好页可编辑。本轮补上能真实生效的三项与成人过滤。
- 新页面“更多 → 来源设置”(
DesktopSourceSettingsPage,screenSOURCE_SETTINGS,入口 tagsource-settings-open,返回source-settings-back),两节:概览(排序方式、以网格显示、按语言分组)、 成人内容(隐藏 18+ 内容的目标集合)。节/行/对话框沿用 §135–§140 的SettingsGroup/SettingsValueRow/SettingsSwitchRow/SettingsInfoRow与DesktopChoiceSettingsDialog/DesktopMultiChoiceSettingsDialog, 单选/多选都开对话框,没有一行平铺的 chips。 - 设置模型
DesktopSourceSettings(desktop-runtime,与DesktopSuggestionSettings同式)落在desktop_background的sources_sort/sources_grid/sources_group_language/sources_nsfw_targets四键上,from(snapshot)恢复、toPreferences()落盘,保存失败复用settings.sourcesSaveFailed。 - 排序:安装顺序(默认)/名称/语言。Android 的“手动置顶顺序”需要拖拽编辑器,Windows 没有,故第三项用语言排序; 排序同时作用于来源总览与浏览栏(
DesktopSourceSettings.sorted)。 - 网格/列表:Android 的
isSourcesGridMode切来源总览的布局。Windows 总览此前恒为LazyVerticalGrid; 现在grid=false时改渲染LazyColumn单行(tagsource-list-rows,网格仍是source-list,既有探针的performScrollToNode不受影响),行内仍带source:<name>点击 tag。默认true,与 Android 默认一致。 - 按语言分组:
DesktopSourceSettings.groups按SourceListing.locale分组,空 locale 归一组;两种布局都在 组前插source-group:<locale>标题(网格用GridItemSpan(maxLineSpan)跨整行),未启用分组时不插标题。 - 成人过滤:Android 的
AdultContentFilterTarget在 Windows 只有三个能兑现的目标——来源与浏览(SOURCES)、 收藏(FAVOURITES)、历史(HISTORY)。前者在来源总览与浏览栏里滤掉contentType.startsWith("HENTAI_")的来源;后两者作为展示规则进入DesktopLibrarySelection.select(..., hideNsfw),与该页其它筛选一起作用于 可见行(全选/计数因此也看不到被隐藏的作品)。订阅与推荐各用已有的 18+ 开关,页面底部用SettingsInfoRow明说这一点。 - 仍未移植:远程来源节的 JAR 优先顺序(需要拖拽编辑器)、显示损坏来源(桌面没有“可用性检查”这一概念)、 18+ 隐身模式(
TriStateOption,桌面无隐身语义)、全局标签黑名单与标签警告(Android 在详情页弹警告, 桌面标签只做展示)、镜像切换与链接处理(core-net没有 mirror/handle_links后端)、扩展安装策略 (Windows 的 JAR 安装是显式选择文件,没有“安装时询问”这条路径)。 - 探针:
DesktopTabletProbe在“更多 → 阅读设置”之后进入来源设置页,打开排序对话框(截图tablet-source-settings-sort.png)、选“名称”并断言sourceSettings.sort == TITLE且sources_sort落盘为TITLE,关掉网格与打开分组、在成人对话框里勾“收藏”并断言只有FAVOURITES命中,返回“更多”; 随后点两次“浏览”回到来源总览,断言source-list-rows存在而source-list不存在,截图tablet-source-overview-list.png可见语言标题zh与单行来源。
142. Windows 书架显示布局(列表/网格,2026-10-23)
日期:2026-10-23。Android 的每条内容列表都有显示选项(ListMode 六档:列表、详细列表、网格、紧凑网格、 影视网格、紧凑影视网格),列表模式写进 AppSettings.KEY_LIST_MODE。Windows 的收藏/历史书架此前只有网格, ContentGrid 里写死 LazyVerticalGrid。本轮补上其中能由现有封面数据兑现的两档,并把选择持久化。
desktop-runtime新增DesktopListMode { GRID, LIST }与DesktopLibraryDisplay(listMode),落盘键library_list_mode,用独立的desktop_library偏好 store(与desktop_background/desktop_appearance并列,避免把显示设置混进源设置)。Android 详细列表/影视网格需要简介与横版海报,桌面书架还没有这些数据,故不移植。- 入口放在书架既有的“筛选与排序”抽屉里新加“布局”一节(两个单选行
library-mode:GRID/library-mode:LIST), 与其它组一样是组内单选、整行可点,不是平铺 chips。 ContentGrid增加listMode与metaOf两个可选参数:LIST时改渲染ContentRowList(LazyColumn, 容器 tagcontent-list,网格仍是content-grid),每行是 64×88 封面 + 标题 + 一行副标题 + 进度条; 行容器仍带content:<id>并沿用combinedClickable(长按选择、单击导航),所以 §140 的批量选择在列表模式下 完全一致。副标题由书架传入(作者,缺失时用来源显示名),进度/新章节徽标沿用content-progress:<id>与content-new-chapters:<id>两个 tag。- 保存失败复用
library.displaySaveFailed,与其它设置页一样只报错、不改内存值之外的副作用。 - 探针:在收藏页打开抽屉截图
tablet-library-display-options.png(可见“布局”两个单选),选“每行一个作品”后断言libraryDisplay.listMode == LIST、library_list_mode落盘为LIST、content-list存在而content-grid不存在,截图tablet-library-list.png(三部作品的封面/标题/阅读测试来源副标题/进度条),再切回网格断言两者互换。
143. Windows 导入 TVBox/JSON CMS 源(2026-10-23)
日期:2026-10-23。Android 的扩展页可以导入 TVBox/苹果 CMS 配置,导入后的站点作为普通来源出现在来源栏。 Windows 侧的 source-tvbox(MacCmsSourceRuntime + MacCmsParser)早就存在,但桌面 Session 没有把它接进 路由,也没有导入入口,所以 JSON 源在桌面上完全不可用。本轮把这条链路补齐。
desktop-runtime的DesktopJsonSources把 Androidjson_sources表保存的同一份配置文档写进desktop_json_sourcesstore 的单值json_sources(SourcePreferenceValue.Text),按 id 去重、后者覆盖, 便于重复导入刷新;errors暴露解析失败的条目,reloadJsonSources()把它们并入startupErrors。DesktopSession新增第 4 条路由MacCmsSourceRuntime::owns(JSON_TVBOX_前缀),SourceRef回退与sourceListings()都会把导入的站点展示成SourceEcosystem.TVBOX来源;导入走共享TvBoxImport.importFromUrl, HTTP 由 Session 注入的TvBoxHttp(OkHttp)执行。- TVBox 源只给出封面 URL,没有 cover 操作协议(
fetchCover默认抛SourceOperationUnsupportedException), 所以 Session 的 cover provider 捕获SourceRemoteException(UNSUPPORTED_OPERATION)后改为直接 GET 该 URL, 按同一 SHA-256 命名写进图片目录并返回SourceCoverArtifact——与 Android 让图片库直接加载 URL 的行为一致。 - 扩展与仓库页新增“JSON 源”一节:已导入站点各占一行(标题 + API 地址,行尾“移除”),下面是“添加 JSON 源”行 (行尾“导入”)打开对话框,填链接与可选显示名;导入结果用页面顶部提示报告“已添加 N 个 JSON 源”和被跳过的爬虫站点。
- 只支持 type 0/1 的 MacCMS 站点:type 3/4(
csp_)需要 QuickJS/Android 运行时,Windows 侧拒绝并计入跳过数, 该限制写在卡片底部的说明行里。 - 探针(
json-sources模式):本地HttpServer提供 CMS 列表与一张 PNG 封面;断言空态 → 通过对话框导入 (state.error == null、偏好里存有该 id、来源列表出现TVBOX条目)→ 浏览两次进入来源栏点该源 → 列表非空且请求数 > 0 → 卡片封面(cover:<id>,在未合并树中)出现 → 返回扩展页移除后来源与列表同时消失。
144. Windows 书架批量“标为已完成”(2026-10-24)
日期:2026-10-24。Android 的收藏页与历史页在选择模式下提供 MARK_AS_COMPLETED:确认后把所选作品 标记为读完。桌面侧此前只有详情页的按章“标为已读”与批量章节标记,书架层面没有对应动作,读完一部作品 要逐章点或进详情。本轮补这条选择动作。
- 语义对齐 Android 的
MarkAsReadUseCase:每个作品取章节列表的最后一章,写入历史时用一章一页的 合成页数(page = 0, pageCount = 1),共享的recordPage会把它换算成作品末尾的percent = 1f, 因而原来的阅读位置被覆盖——这也是 Android 弹窗警告“当前进度会丢失”的原因,桌面弹窗照抄该提示。 SourceLibrary.findMany增加withChapters参数(默认false,保持既有调用方的窄投影); 批量标记传true,一次拿到每部作品的章节,不再逐个find发查询。- 选择栏新增“标为已完成”按钮(
library-selection-complete),点开确认对话框 (library-complete-dialog/library-complete-confirm);取消不改任何记录,确认后清空选择、 重载书架快照并提示“已将 N 部作品标为已完成”。没有章节的作品跳过;全都不可标记时报错文案。 - 历史页同样使用这条动作(与 Android 历史页的 supportedActions 一致)。
- 探针(
tablet模式,在既有的批量选择段落里):先确认读者留下了未完成进度 → 长按进入选择 → 取消对话框后进度不变 → 再次确认后percent == 1f、chapterId等于库里最后一章、 书架条目的progressPercent同步为 1,并截图tablet-library-selection-complete.png。
145. Windows 收藏批量置顶(2026-10-24)
日期:2026-10-24。Android 收藏页的选择模式首条动作是 PIN(FavoritesListScreen 的 preferredSelectionInlineActions,处理器 listHost.togglePinned):置顶后作品排到书架最前, 卡片在角标行画一枚 ic_pin,再点一次取消。桌面侧此前完全没有置顶入口:favourites.pinned 这一列只有订阅 feed 在读(DesktopFeedPanel 按 isPinned 排序),书架既不写也不用它。
- 共享存储照 Android 写同一列:
SourceLibrary.setPinned(ids, isPinned)就是FavouritesDao.setPinned,作品在多个分类里时按 DAO 的MAX(pinned)语义任一行置顶即算置顶; 非收藏作品没有行可写,自然不受影响。 SourceLibraryEntry增加isPinned,快照从已经查出的收藏行(memberships)里取,不额外发查询。- 排序:Android 收藏读模型的排序是“置顶优先,再按所选顺序”,桌面把这条做成
DesktopLibrarySelection.select(..., pinnedFirst = true)——先按isPinned倒序,再用用户选的 比较器,最后用 id 稳定兜底。pinnedFirst默认false,所以历史书架顺序不变(Android 历史 DAO 的ORDER BY也不看置顶),与“历史的选择栏没有 PIN”一致。 - 选择栏新增置顶按钮(
library-selection-pin,仅收藏页出现),标签随所选集合切换“置顶/取消置顶” (对应 Android 的allPinned判定);动作后重载快照并提示“已置顶 N 部作品”/“已取消置顶 N 部作品”。 - 卡片角标:网格模式在封面右下角画
ic_pin(content-pinned:<id>,primary 着色,右下是唯一 没被勾选/新章节/语言角标占用的位置),列表模式画在标题行;图标走既有的prepareDesktopIcons,把 Android 的ic_pin.xml加进随包生成列表,仍是同一份矢量源。 - 单元测试
DesktopLibrarySelectionTest覆盖“置顶在每种排序里都领先、但只有pinnedFirst为真时才领先”(历史路径保持原序)。探针在批量选择段落续接:长按 → 置顶 → 断言快照isPinned、FavouritesDao.isPinned为真、提示文案、卡片角标存在、按钮标签变成“取消置顶”, 截图tablet-library-selection-pin.png;再点一次取消置顶,断言角标消失、DAO 归零、提示切换。 - 验证:
:desktop-app:compileKotlin、:desktop-app:compileTestKotlin、:desktop-app:verifyDesktopStrings、:core-library:compileCommonMainKotlinMetadata通过;DesktopLibrarySelectionTest9 项与DesktopAppTest的 tablet 探针(四个子 JVM)全部通过,tablet.log以DESKTOP_UI_OK=tablet结束。
146. Windows 订阅页成人过滤目标(2026-10-24)
日期:2026-10-24。承 §141:Android 的 AdultContentFilterTarget 有七个目标(来源与浏览、历史、收藏、订阅、更新、 推荐、书签),Windows 的来源设置当时只映射了三个能兑现的(SOURCES/FAVOURITES/HISTORY)。订阅页其实两种成人内容 界面都在:FeedCardRow.isNsfw 的时间线与 FeedUpdateRow.isNsfw 的新章节轮播,所以补上 FEED 与 UPDATES 两个目标, 并顺手修掉“筛空了还提示点检查更新”的空态文案。
DesktopNsfwTarget增加FEED、UPDATES(声明在HISTORY之后,对话框顺序与 Android 的枚举接近),DesktopSourceSettings.hidesNsfw与sources_nsfw_targets存储照旧——集合里多两个名字即可。 Android 的SUGGESTIONS/BOOKMARKS在 Windows 没有对应页面,继续不映射。- 时间线走共享派生器:
FeedDeriver.Input.excludedNsfw = hidesNsfw(FEED),FeedDeriver自己按FeedCardRow.isNsfw过滤,因此筛选计数(hasActiveFilters)也会把这个目标算进去,语义与 Android 的_excluded_nsfw一致,不新增桌面私有过滤。 - 轮播是桌面自己的投影(
updateRowsByOwnerId),先按hidesNsfw(UPDATES)过滤FeedUpdateRow.isNsfw再排序; 两个目标各自独立,只关一个时另一半照常显示。 - 空态:
FeedDeriver.Derived.hasActiveFilters为真时改显示feed.filteredTitle/feed.filteredDetail(“没有符合当前筛选条件的更新 / 清除订阅筛选,或关闭 More → Source settings 里的 18+ 内容隐藏”), 否则仍是“暂无更新记录 / 点击检查更新”。此前任何筛选(分类、来源、标签)筛空时都在误导用户再点一次检查。 - 文案:
sources.nsfw.feed(订阅 / Subscriptions)、sources.nsfw.updates(更新 / Updates)、feed.filteredTitle、feed.filteredDetail,中英各一份。 DesktopBackgroundSettingsTest新增一例:五个目标的名字顺序、sources_nsfw_targets往返, 以及hidesNsfw只命中被勾选的两个(FAVOURITES/HISTORY 为 false)。- 探针(
tablet):来源设置对话框里勾上订阅与更新(截图tablet-source-nsfw-targets.png可见五个目标); 回到订阅页把被追踪作品在库里标成ADULT(MangaDao.updateContentRating),重新进页只重载快照、不再取源—— 时间线与轮播同时消失、空态是筛选文案(截图tablet-feed-nsfw.png);单独关掉订阅目标后时间线回来而轮播仍隐藏, 再关掉更新目标轮播也回来;最后把作品改回安全并确认行重新显示且不再标记成人。 细节:checkUpdates会用源返回的详情library.save覆盖评级,所以成人标记必须写在检查之后, 之后只走loadFeedOnIo(重新进入订阅页)重载快照。 - 验证:
:desktop-runtime:test --tests "*DesktopBackgroundSettingsTest*"tests=2 failures=0;:desktop-app:test --tests "*DesktopAppTest*tablet*"tests=1 failures=0(tablet.log以DESKTOP_UI_OK=tablet结束);:desktop-app:compileKotlin、:desktop-app:compileTestKotlin、:desktop-app:verifyDesktopStrings、:core-ui:verifyUiStrings、:core-library:compileCommonMainKotlinMetadata通过。
147. Windows 收藏选择栏的“分享”(2026-10-24)
日期:2026-10-24。承 §140、§145:Android 收藏列表的选择栏还有 SHARE(ShareHelper),Windows 侧的选择栏此前只有全选/分类/标为已完成/置顶/移出。Windows 没有分享面板,落到同一处的就是剪贴板。
- 文本沿用 Android 的形状:单部作品是
标题\n \npublicUrl(Android 单链接分享的排版),多选是每部一行标题 - publicUrl(Android 多选拼接的形状)。Windows 没有注册kototoro://深链,Android 文本里那段 appUrl 不再出现。 - 链接取
publicUrl,为空时退回url(有的源只填内链);两者都为空的本地导入作品不产生空行,整批都没有链接时提示library.shareEmpty而不是复制一段空文本。 DesktopClipboard(DesktopShareLinks.kt)是唯一的剪贴板入口:AWT 在 Windows 上会与占用剪贴板的进程抢开,setContents报错时内容往往已经写入,所以写完回读同一段文本才算成功;回读也拿不到才算失败。- 失败时不假装成功:提示
library.shareFailed,同页的DesktopShareLinksDialog把同一段文本以可选中文本展示(SelectionContainer),链接不会因为剪贴板被拒而丢失。 - 只有收藏栏有“分享”(历史栏传
onShare = null就不画),与 Android 的集合一致:历史栏的批量动作是全选/移出/保存/收藏/标为已完成。 - 文案:
library.selectionShare(分享 / Share)、library.shared(已复制 %d 个链接到剪贴板 / Copied %d links to the clipboard)、library.shareFailed、library.shareEmpty,中英各一份;DesktopShareLinksDialog复用common.close。 - 单测
DesktopShareLinksTest三例:单部作品的排版、多选跳过无链接作品、只填内链时退回url且全空为 empty。 - 探针(
tablet):取消置顶之后选择仍只有一部作品,点“分享”直接读系统剪贴板,断言内容等于“标题 + 空行 + publicUrl”、message是library.shared(1)、shareLinks为空(说明没有走回退对话框);tablet-library-selection-pin.png快照里能看到新增的“分享”按钮排在“移出收藏”之前。 - 验证:
DesktopShareLinksTest3 项、DesktopLibrarySelectionTest9 项全绿;:desktop-app:test --tests "*DesktopAppTest*tablet*"tests=1 failures=0,tablet.log以DESKTOP_UI_OK=tablet结束;:desktop-app:compileKotlin、:desktop-app:compileTestKotlin、:desktop-app:verifyDesktopStrings、:core-ui:verifyUiStrings、:core-library:compileCommonMainKotlinMetadata全部通过。 - 环境备注:
DesktopAppTest的 parsers 探针(parsers-read)在本机超时失败,已用git stash在未改动的 HEAD 上复现,属既有环境问题(需要真实插件与联网),与本改动无关。
148. Windows 历史选择栏的“收藏”(2026-10-24)
日期:2026-10-24。承接 §140、§147:Android 历史列表的选择栏动作是 SELECT_ALL / REMOVE / SAVE / FAVOURITE / MARK_AS_COMPLETED,其中 FAVOURITE 打开的就是收藏分类对话框 (AppRouter.showFavoriteDialog → FavoriteCategoryDialog),历史页与收藏页共用同一个对话框。 Windows 侧此前把“分类”只挂在收藏栏(历史传 onAssignCategories = null),历史页因而少了这个动作。
- 现在两个书架都渲染
library-selection-categories:历史页点开的是同一个DesktopCategoryPickerDialog, 勾选集合即目标分类集合,确认走setFavouriteCategories→SourceLibrary.updateCategories。 - 语义与 Android 相同:作品的“收藏”身份由分类成员关系决定,所以历史行勾上任意分类就变成收藏, 把最后一个分类取消就退回只有历史记录的状态;行本身始终留在历史页(历史页读的是 history 快照)。
- 对话框初始勾选沿用“所选作品的分类交集”,历史页读到的是这些作品当前的收藏分类, 因此从历史页也能直接把作品移出收藏(与 Android 的 FAVOURITE 双向开关一致)。
- 历史选择栏的动作集由此与 Android 只差 SAVE:全选 / 分类 / 标为已完成 / 移出 / 取消。
- 无新增文案(复用
library.selectionCategories与library.categoriesUpdated)。 - 探针(
tablet):历史页先把该作品移出收藏并重载快照(必须controller.library(true), 它的默认参数是收藏页,传错会把历史行整体换掉),勾选分类确认后断言isFavourite为真、categoryIds == setOf(category.id)、提示是library.categoriesUpdated(1); 再进一次对话框取消该分类,断言又变回非收藏且categoryIds为空;截图tablet-history-category-pick.png。 - 验证:
:desktop-app:test --tests "*DesktopAppTest*tablet*"tests=1 failures=0 (tablet.log以DESKTOP_UI_OK=tablet结束);:desktop-app:compileKotlin、:desktop-app:compileTestKotlin通过。
149. Windows 书架选择栏的“下载”(2026-10-24)
日期:2026-10-24。承接 §140、§147、§148:Android 两条书架栏的 SAVE 打开的是下载对话框 (showDownloadDialog(models):章节范围 + 章节勾选),选定后为每个选中的作品入队。Windows 侧此前只有 详情页的批量下载菜单(details-download-menu,单部作品),书架选择栏没有 SAVE,多选作品要逐个进详情页。
- 两个书架的选择栏都渲染
library-selection-download(“下载”),点开DesktopLibraryDownloadDialog(library-download-dialog):标题复用details.downloadChapters,正文是所选作品数 + 作用说明, 按钮是详情页那套章节范围DesktopShelfDownloadOptions= 全部章节 / 未读章节 / 前 10 / 25 / 50 章 (标签library-download:<SCOPE>:<count>,确认后清空选择)。 - 范围计算沿用详情页的共享规则
desktopDownloadTargets,但按作品逐个计算:每部作品用自己的当前章节 (session.library.progress的 chapterId)和自己的已保存章节(DesktopDownloadStore里isComplete的记录), 所以“未读”对每部作品都是“它自己没读到的那部分”,而不是一个共享下标。容器DesktopShelfDownloadWork/DesktopShelfDownload与纯函数desktopShelfDownloadTargets都不依赖数据库,便于单测。 - 章节来源是本地已存章节列表(
SourceContent.chapters,快照带withChapters),不再为批量下载逐部刷新详情: 离线也能算,也不会因为某个来源在批量途中失败而中断整批。代价是章节列表与最后一次详情同步一致。 - 与详情菜单的差别:详情菜单按当前筛选分支算范围,书架批量作用于全部分支(选择的是作品,不是某个翻译), 顺序沿用存储顺序;因此不像详情菜单那样提供“当前分支”一项(分支因作品而异,共享文案无法命名)。
- 只纳入
DesktopReaderKind.PAGES的作品;没有章节、或该范围下没剩下待下章节的作品直接跳过, 整批为空时提示downloads.nothingToQueue,否则提示downloads.batchQueued(总章数)。 下载仍走同一个串行队列DesktopDownloads,不写阅读进度,与详情页批量下载一致。 - 文案:新增
library.selectionDownload(下载 / Download)、library.downloadHint(每部作品按各自进度挑选章节,已保存的章节会跳过。)、library.downloadWhole、library.downloadUnread、library.downloadFirst,中英各一份;提示复用downloads.batchQueued/downloads.nothingToQueue。 - 单测
DesktopDownloadOptionsTest增加 4 例(共 9 例):每部作品按各自进度与已保存章节取范围、全保存/ 无章节/读完的作品从批次里消失且整批为空、前 N 按作品分别计数、书架的 5 个范围标签与 tag 唯一且不含分支项。 - 探针(
tablet):收藏页长按该作品 → “下载” → 断言对话框存在并截图;点“前 10 章”后断言对话框与选择栏都消失、 新增的队列项全部属于该作品且不超过 10 章、提示等于downloads.batchQueued(新增章数); 随后pauseAll+ 逐项settle/删除记录/forget,让 fixture 回到无下载状态。 历史页再长按一次,断言“下载”同样在(历史栏对应 Android 的 SAVE),打开对话框截图后取消。 截图tablet-library-selection-download.png(书架栏的“下载”排在“分享”与“移出收藏”之间)与tablet-history-selection-download.png。 - 验证:
DesktopDownloadOptionsTest9 项全绿;:desktop-app:test --tests "*DesktopAppTest*tablet*"tests=1 failures=0(tablet.log以DESKTOP_UI_OK=tablet结束,129.7s);:desktop-app:compileKotlin、:desktop-app:compileTestKotlin、:desktop-app:verifyDesktopStrings通过。
150. Windows 收藏选择栏的“迁移”(2026-10-24)
日期:2026-10-24。承接 §147–§149:Android 收藏列表的选择栏动作是 SELECT_ALL / SHARE / PIN / MIGRATE / categories / SAVE,其中 MIGRATE 走 AppRouter.openMigration(ids) → MigrationListActivity (选目标来源、搜索、候选列表、MigrationConfigSheet 的 REPLACE/COPY 与数据位开关),提交后由 MigrateUseCase 逐部在单个事务里换源。Windows 侧此前完全没有入口,换源只能在 Android 上做。
- 收藏选择栏新增
library-selection-migrate(“迁移”),排在“下载”与“移出收藏”之间。 只挂在收藏栏:Android 的历史栏没有 MIGRATE(defaultSelectionActions不含它)。 按钮在选中数量不是 1 时禁用——候选作品要人眼确认“是不是同一部作品”,一次一部才不会张冠李戴; Android 那种一次多部(每部各自挑候选)留给后续切片。 - 对话框
DesktopMigrationDialog(library-migrate-dialog):作品标题、一段说明、 “目标来源:<名称>”行(library-migrate-source,点开复用DesktopChoiceSettingsDialog, 体library-migrate-source-pick、选项library-migrate-source:<sourceName>)、 搜索框(library-migrate-query,预填作品标题)与“搜索”(library-migrate-search)、 候选列表(library-migrate-candidate:<id>,点选高亮)、“保留原作品”复选框(library-migrate-keep)、 确认/取消(library-migrate-confirm/library-migrate-dismiss)。对话框本身不碰数据库。 - 目标来源与候选的判定做成纯函数(
DesktopLibraryMigration.kt):desktopMigrationSources只留同内容类型的 其他来源(去掉HENTAI_前缀再比,与 AndroidMigrateUseCase的判断一致)、按来源名去重;desktopMigrationCandidates只留属于目标来源、id 不同、同内容类型的结果。 - 流程由 Controller 承担:
beginMigration(读SourceLibrary.find+state.sources,没有可用目标来源时 直接报library.migrateNoSources)、migrationSource(换来源会清空候选)、migrationQuery、migrationKeepOriginal、searchMigration(SourceRuntime.getList(target, 0, null, SourceFilter(query = 标题)), 搜索期间searching置位、按钮禁用并显示“正在搜索…”)、migrateContent(对候选getDetails(FORCE_REFRESH)拿到目标章节,再调SourceLibrary.migrate,成功提示library.migrated(目标来源名) 并重载书架)。 - 迁移语义来自共享
SourceLibrary.migrate(§141 之前就存在):REPLACE = 作品从原来源挪到新来源 (原收藏/历史随之消失),COPY = 保留原作品、新作品另立一条收藏;“保留原作品”勾上就是 COPY, 与 Android 的MigrationMode同名同义。分类、进度、统计一起迁移(MigrationDataFlag.ALL), 下载与书签按 Android 的约定留在原作品上,整个换源在一个事务里完成(失败不留半成品)。 - 与 Android 的差距:不提供
MigrationConfigSheet的数据位开关(Windows 固定 ALL),不提供一次多部作品的 批量候选列表,也不提供“目标来源已有同名作品时合并”的进度比较预览——这些是MigrationListActivity的界面部分。 - 文案:新增
library.migrate(迁移 / Migrate)、library.migrateHint、library.migrateSource、library.migrateKeep、library.migrateEmpty、library.migrateSearching、library.migrateNoSources、library.migrated,中英各一份;搜索按钮复用browse.search,取消复用common.cancel。 - 单测
DesktopMigrationRulesTest5 例:同内容类型筛掉自身来源、成人作品与普通作品可互相迁移(HENTAI_前缀)、 同名来源去重、候选只留目标来源且排除自身、对话框的sourceLabel/canMigrate。 - 探针(
tablet):fixture 只装了阅读来源,所以先用args[2](离线 fixture JAR)导入第二个来源并等sources.size == 2;回到收藏页长按该作品 → “迁移” → 断言对话框存在并截图 → 打开来源选择对话框截图并选目标来源 → 勾“保留原作品” → 搜索 → 断言恰好一个候选 → 点选候选截图 → 确认;随后断言对话框与选择栏都消失、state.migration == null、提示等于library.migrated(目标来源名)、原作品仍是收藏、 新作品以候选 id 出现在收藏快照里。收尾:removeFavourite新作品、uninstall第二个来源 (依据导入前后installedEntries的差集取条目)、断言来源回到 1 个并重载书架。 截图tablet-library-selection-migrate.png(对话框与书架栏的“迁移”)、tablet-library-migrate-source.png、tablet-library-migrate-candidate.png。 - 验证:
DesktopMigrationRulesTest5 项全绿;:desktop-app:test --tests "*DesktopAppTest*tablet*"tests=1 failures=0(tablet.log以DESKTOP_UI_OK=tablet结束,142.4s);:desktop-app:compileKotlin、:desktop-app:compileTestKotlin、:desktop-app:verifyDesktopStrings、:core-ui:verifyUiStrings通过。
151. 标签目录下沉 core-domain(2026-10-24)
日期:2026-10-24。承接 §135–§150 的“先提取共享代码、再迁移界面”的顺序:Android 的内容过滤建立在一份 334 条的 标签目录上(KototoroTaxonomy,848 行,含 CURATED_ALIASES 与 FUTON_SOURCE_TAGS),它此前只在 app 里, Windows 若要复现“全局标签黑名单”这类功能只能抄一份数据。这一节把目录与索引搬到 core-domain,平台只保留 语言相关的薄壳。
- 新文件
core-domain/src/commonMain/kotlin/org/skepsun/kototoro/core/model/TagTaxonomy.kt:TaxonomyCategory(12 个类别,RAW仍表示“源标签”伪类别)、TaxonomyTag(id/类别/中英标签/繁中标签/ 别名/adult/sensitive/deprecated)与TagTaxonomy(tags、find、knownSourceTags、resolve、search、normalize)。数据行、别名表、Futon 词表逐字搬移,索引(tagsById、idsByAlias)仍是 lazy 一次构建: id、短名、英文标签、简中标签、繁中标签与别名全部进同一张idsByAlias,仍然是精确匹配(act不会命中action),一个别名可以指向多个永久 ID(reincarnated in another world→narrative.reincarnation+setting.other-world)。 - 唯一的平台输入是简→繁转换:
TagTaxonomy.traditionalLabelConverter默认恒等(不做繁简转换的平台直接得到 简体标签),Android 的KototoroTaxonomy对象在init里装上ChineseConverter::s2t——它仍是 Android 访问 目录的唯一入口,所以转换器一定在tags首次求值之前就位,resolve("輕度情色")与search(tag, "復仇")的行为与搬移前完全一致。 - 留在
app的只有java.util.Locale相关部分:KototoroTaxonomy.kt从 848 行缩到 47 行的门面 (tags/knownSourceTags/find/resolve/search/normalize全部转调共享对象)加上两个顶层扩展TaxonomyCategory.displayName(locale)与TaxonomyTag.displayName(locale),繁中判定仍在usesTraditionalChinese()(script == Hant或 country ∈ HK/MO/TW)。 - 共享对象的本地化入口叫
localizedLabel(language, traditional)而不是displayName:成员函数会遮蔽同名的 顶层扩展,若共享类型上留一个displayName,tag.displayName(locale)与category.displayName()都会被解析 到成员并报“缺少 language 参数”;改名后 Android 侧调用点只需各补一行import org.skepsun.kototoro.core.model.displayName。 - 与 Android 的差距:没有。目录内容、别名、繁简标签、精确匹配规则与敏感/adult 标记都是同一份数据。 iOS 目标编译通过但还没有界面消费它,Windows 的消费在下一节。
- 验证:
:app:compileDebugKotlin通过;AndroidKototoroTaxonomyTest(8 项,含 “标准标签同时有简中与繁中写法”)与GlobalTagBlacklistTest(6 项)在搬移后全绿; 新增:core-domain:jvmTest的TagTaxonomyTest6 项(334 条唯一 ID、别名精确匹配与多目标、Futon 词表与 安全标记、localizedLabel的中英分支、默认恒等转换器下的繁中标签、search的 id/标签/别名覆盖);:core-domain:compileCommonMainKotlinMetadata与:desktop-app:compileKotlin通过。
152. 全局标签黑名单:共享匹配器 + Windows 页面(2026-10-24)
日期:2026-10-24。§151 把目录搬进 core-domain 之后,Android 的“全局标签黑名单”仍是一层平台实现 (GlobalTagBlacklist 自己解析 ID、自己拼 raw: 键、自己做精确匹配)。这一节把它下沉成共享匹配器, Windows 复制的是“选择界面”,不是“匹配规则”。
- 新文件
core-domain/src/commonMain/kotlin/org/skepsun/kototoro/core/model/TagTitleBlacklist.kt:class TagTitleBlacklist(tags: Collection<String>) : TagBlacklist。构造时把已存的选择分成两类—— 能解析到词库的存成词库 ID(TagTaxonomy.find直接命中,或TagTaxonomy.resolve的全部命中), 解析不到的按rawTagKey(title)("raw:" + trim().lowercase())存成原始键。containsTagTitle因此只需要 “原始键精确命中”或“resolve(title)的 ID 落在选择里”,与 Android 原实现逐条对齐;isEmpty让调用方 跳过整条过滤,containsAnyTitle给一次看多个标题的场景(订阅行、更新轮播)用。 app/core/model/GlobalTagBlacklist.kt从 80 行缩成转调:contains(Content)、filter(contents)、RAW_TAG_PREFIX、rawTagKey()、normalizeTag()的公开形状不变,规则全在共享类里,Android 的行为没有变化。- 顺手修掉 §151 留下的初始化顺序缺陷:
TaxonomyTag.traditionalChineseLabel现在是每次读取都走TagTaxonomy.traditionalLabel(chineseLabel),tags/find/resolve共用一个@Volatile目录快照, 安装转换器时作废该快照。此前繁中标签在tags首次求值时被烘进对象,谁先读目录谁就决定了它——GlobalTagBlacklist改成直接走共享对象后,Android 上“先构造黑名单、后打开设置页”会让繁中标签永久停在简体。BaseApp.onCreate现在也显式installTraditionalLabels(),共享规则不再依赖“某个界面先跑过”。 - Windows 侧存储:
DesktopSourceSettings.tagBlacklist: Set<String>,键名沿用 Android 的global_tag_blacklist(TextSet,仍存desktop_background),TextSet里的字符串就是 Android 存的那套 (词库 ID 或raw:键),因此同一份导出/备份在两端读到的选择一致。空的tagBlacklist返回TagBlacklist.Empty。 - 消费点(都走共享规则,因此 Windows 与 Android 对同一份选择隐藏同一批作品):
DesktopLibrarySelection.select(...)的过滤链(收藏/历史两个书架,DesktopLibraryPanel传入);FeedDeriver.Input.tagBlacklist(订阅时间线);更新轮播按feedContents[id].tags过滤; 浏览/搜索结果网格在渲染前过滤(分页仍按源返回的原始页,与 Android 的ExploreRepository一致); 主页的“最近更新”与“为你推荐”两条 rail(对应 Android 的UpdatesViewModel/SuggestionsViewModel)。 - 新页面
DesktopScreen.TAG_BLACKLIST(DesktopTagBlacklistPage.kt,入口在来源设置的新“标签”节, 行标签source-setting-tag-blacklist):顶部是已选数量与“清空”(对应 Android 的 clear action), 搜索框按TagTaxonomy.search过滤目录,目录按类别分组(RAW组放knownSourceTags里词库不认识的名字), 已选区先列当前选择;“添加‘xxx’”只在查询非空、词库解析不到该文本且该raw:键尚未选中时出现。 芯片点击切换,长按弹出详情(键、英文标签、类别、别名)——对应 Android 长按芯片看别名。 - 与 Android 的差距:Android 的黑名单页会把内容库里实际出现过的标签标题也列进目录 (
observeAllTagTitles()),Windows 没有等价的“全库标签标题”查询,目录固定为词库 334 条 +knownSourceTags;缺的名字仍可通过“添加‘xxx’”变成raw:模式,所以能力不缺,只是候选列表更静态。 - 验证:
:core-domain:jvmTest的TagTitleBlacklistTest6 项与TagTaxonomyTest7 项(新增 “转换器晚装也会重建缓存目录”)全绿;desktop-runtime新增DesktopSourceSettingsTest4 项(往返、 非法/空白值回退、规则命中、键名复用)绿;DesktopLibrarySelectionTest新增黑名单用例(词库 ID、raw:精确匹配、空选择不过滤)共 10 项绿;:desktop-app:compileKotlin、compileTestKotlin、verifyDesktopStrings通过;平板探针新增“来源设置 → 标签黑名单 → 搜索 → 选中 → 落盘 → 长按详情 → 清空 → 返回”段落,tablet.log以DESKTOP_UI_OK=tablet结束。
153. 敏感类别高亮:共享警示词表 + Windows 开关(2026-10-24)
Android 的“高亮敏感类别”(tags_warnings) 不是黑名单,而是词表着色:res/raw/tags_warnlist 里 110 条标题 (含 гуро 等俄语别名)命中时,ContentListMapper.tagTint 给标签芯片换成 R.color.warning,开关只决定要不要着色。
- 词表迁到
core-domain的TagWarnings(TITLE_DATA常量 + 懒解析成Set,isWarned精确匹配、 大小写不敏感),Android 的ContentListMapper改为消费它,删掉res/raw/tags_warnlist与ContentListMapper里的Context依赖(构造函数也少一个参数),行为与原来逐字一致。 - Windows 用同一个键
tags_warnings存进desktop_background(DesktopSourceSettings.tagsWarnings,默认开), 来源设置的“标签”节多一行整行开关source-setting-tags-warnings,文案与 Android 相同。 - 着色点:详情页信息卡的标签芯片(
DesktopDetailsInfo新增warnings参数),命中时用Warn(Android#E65100,暗色提到#FFB74D)而非Accent;芯片同时打DesktopWarnedTagKey语义位, 探针/UI 测试能直接读到判定结果而不必采样像素。其余列表沿用 Windows 既有的无芯片卡片,不另加着色。 - 验证:
TagWarningsTest2 项(110 条、大小写/空白/俄语、非模糊)、DesktopSourceSettingsTest5 项 (新增“默认开 + 走共享词表 + 关掉只停着色 + 读回 false”)、DesktopTagWarningsUiTest2 项 (开/关两种设置下Rape/Гуро有标记、Drama无)、DesktopLibrarySelectionTest10 项; Android:app:compileDebugKotlin通过,GlobalTagBlacklistTest6 +KototoroTaxonomyTest8 绿 (ContentListMapperTrackingBatchTest在本机因 mockk 无法拦截AppSettings这个 final 类的真实方法而 红,与本次改动无关,见下);平板探针在标签黑名单段后追加开关往返并核对tags_warnings落盘,tablet.log以DESKTOP_UI_OK=tablet结束。
已知遗留:ContentListMapperTrackingBatchTest 在本机一直红——mockk<AppSettings> 对 final 类没有生效, every { getContentListBadges() } 会执行真实 getter 并在 prefs(objenesis 未初始化)上 NPE; 本切片删掉 ContentListMapper 的 context 参数时顺手更新了它的构造调用,没有改动该 mock 本身。
154. Windows 18+ 无痕阅读:共享 NSFW 判定 + Android 同键三态(2026-10-24)
Android 的无痕阅读有三层:全局 incognito 开关(不在设置界面里)、incognito_nsfw 三态 (TriStateOption { ENABLED, ASK, DISABLED },默认 ASK,存枚举名字符串),以及阅读器里按作品类型 自动判定 AppSettings.isIncognitoModeEnabled(isNsfw)。真正拦写入的是 HistoryRepository.shouldSkip、 ReadingRecordRepository.shouldSkip 与 ReaderViewModel 的 isIncognitoMode(初始 null,非 false 前每处写入都跳过); ReaderActivity 的 IncognitoModeDialog(禁用/取消/无痕 + “不再询问”)只在 ASK 时弹出, “不再询问”把答案写回同一个 incognito_nsfw。详情页的宿主栏只在 !historyInfo.isIncognitoMode 时给“无痕模式”。
- 共享判定落在
core-source:SourceContent.isNsfw=contentRating == "ADULT"或source.contentType以HENTAI_开头(与 AndroidContent.isNsfw()同款),core-library的SourceLibrary.saveInTransaction改为调用它,不再各页自己拼字符串。 DesktopSourceSettings新增incognitoForNsfw: DesktopTriState(默认ASK),键与 Android 同为incognito_nsfw,值存枚举名(DesktopTriState.ENABLED.name),共享备份因此能互通; 读回时名字不认识(或类型不对)退回ASK。同处给出requiresIncognito(content)(ENABLED + 成人)与asksForIncognito(content)(ASK + 成人),页面与控制器共用同一套判定。- 控制器:
DesktopAppState.readerIncognito是“本次会话不记录”,savesProgress是它唯一的读法; 四处自动保存(分页锚点、连续滚动flush、小说flush、视频flush)都先过它,显式命令 (标已读/未读、批量下载)不受影响。所有顶层入口(详情“阅读”、详情书签、下载页打开、订阅“继续阅读”) 统一走startReading(DesktopReadingRequest, incognito: Boolean?):null表示按设置判定, ASK 成人作品时把请求存进incognitoPrompt并停在原页面(Android 是在阅读器里弹,Windows 改成打开前问, 所以“取消”就留在详情),resolveIncognitoPrompt按答案打开并在勾选“不再询问”时改写设置。 会话内的换章/换集不改这个标志,所以一次无痕读到底仍然无痕。 - 界面:共享平板预览卡
TabletPreviewContent与TabletPreviewLabels多了可选的incognito/onReadIncognito(默认 null,Android 的搜索预览卡不传就还是原样),Windows 传上它,于是 1260dp 浮动预览卡和展开后的详情页 信息卡都有preview-incognito(对应 Android 宿主栏的“无痕模式”,任何作品都有,会话已是无痕时不显示, 因为此时这行没有意义);这是本切片唯一动到core-ui的地方,仍是宿主提供文案、共享组件只画控件。 窗口根节点挂DesktopIncognitoDialog(incognito-dialog, 标题/提示/“不再询问”/禁用·取消·无痕全部复用 Android 已翻译的incognito_mode、incognito_mode_hint_nsfw、dont_ask_again、disable、cancel、incognito); 来源设置“成人”节多一行source-setting-incognito-nsfw(incognito_for_nsfw标题 +incognito_mode_hint摘要,选项用 Android 的enable/ask_every_time/disable)。 - 验证:
DesktopSourceSettingsTest8 项(新增“同键 + 默认 ASK + 存名字读回 + 成人规则只碰成人 + 未知/错类型退回 ASK”)、SharedTabletUiTest2 项(共享预览卡“宿主不传就没有这行、传了就点得到”)、DesktopIncognitoTest6 项(对话框三答 + 不再询问勾选 + 详情按钮 +savesProgress只被无痕会话关闭 + 三态名与 Android 一致);新探针incognito(DesktopAppTest的“Windows incognito reader records nothing…”)走真实界面: 浮动预览卡点preview-incognito无痕读 →readerIncognito真、历史表没有行,返回后普通读 → 历史行写下(证明拦住的是门而不是 fixture), 再进来源设置把三态改到ENABLED/DISABLED并核对desktop_background的incognito_nsfw落盘为枚举名。
155. Windows 阅读统计:会话采集 + 统计仪表盘(Android stats 对齐)(2026-10-24)
Android 的阅读统计落在两张表:stats(每个作品一行,duration/pages)与 reading_sessions (每次停读一行,start_at/end_at/起止章节与页码/percent)。写入靠 ViewModel 作用域的 StatsCollector 与 ReadingRecordRepository.recordSession(MIN_SESSION_DURATION_MS = 5_000:够长才落 session 行, 否则只写 stats);读模型早已在 core-domain 共享(deriveReadingStatistics:period 窗口、legacy 行只填 未被 session 覆盖的区间、MANGA 用 pages 当 units、其余类型按 distinct 章节计)。Windows 此前没有任何写入者, 备份与 WebDAV 只是搬运这两张表。
- 采集落在
core-library:ReadingStatistics.kt新增ReadingSessionTracker(begin/onPage/finish/abandon),SourceLibrary注入clock后挂在recordPage上——阅读器三处自动保存(分页锚点、连续滚动、各flush) 本来就都走这个入口,所以没有改阅读器的任何文件。onPage的第一次 tick 只把作品锚上(pages不变), 同章节同页再 tick 不计数,换页/换章+1,别的作品返回 null 而不延长他人会话;finish在duration < 5_000时只写 stats 行。 - session 行必须由宿主显式打开:
beginReadingStop()在readOnIo/readNovelOnIo/watchOnIo设置阅读器屏幕 之前调用(savesProgress为假的无痕会话不打开),settleReadingSession在离开阅读器的 action 的finally里关闭。于是标已读/批量下载这类写历史而不读书的路径不会污染统计,无痕会话一行也不写。 Windows 一次阅读器打开算一个 session(Android 按章节切分),这是记录在案的差异。 - 读模型:
readingStatistics(period, zone)在 reader 连接里取findOverlapping的 session 行与 legacy stats 行, 交给共享的deriveReadingStatistics;clearReadingStatistics()只清stats与reading_sessions, 历史与收藏不动。 - 界面
DesktopReadingStatsPage:更多 → 阅读统计(reading-stats-open)。对应 AndroidStatsScreen的卡片: 摘要(总时长/活跃天数/会话/作品 + 连续天数)、每日活动条形(附活跃天数说明)、时段分布(有小时数据才画)、 类型占比(多于一种类型才画)、作品榜(名次 + 封面 + units/会话数);周期行stats-period:<NAME>用 Android 的day/week/month/three_months/all_time标题,清空统计先弹确认。 页面拆成DesktopReadingStatsPage(读 state 与会话)与DesktopReadingStatsContent(只吃参数), 所以 UI 测试不需要数据库和控制器。 - 文案:只新增一个桌面键
more.readingStatsSummary(入口摘要);统计标签全部复用 Android 已翻译的reading_stats、stats_*、empty_stats_text、clear_stats*,没有重复词条。 - 顺带修复(验证时暴露的旧问题):扩展与仓库页在 620px 高的窗口里,仓库节、JSON 来源节把目录与已安装两张列表 压成 0 高度,已安装行的卸载按钮既不可见也不可点,UI 探针的
performScrollToNode因此在找不到节点时空转 (waitForIdle永远等不到空闲)。页面改为整页verticalScroll,目录列表按窗口高度取高度 (maxHeight - 340.dp,下限 260dp),已安装行改成页内 inline 行(不再嵌套LazyColumn),installed-list标签移到已安装分组上。 - 验证:
desktop-runtime的DesktopReadingStatsTest6 项(停读写两表 + 跨作品不串 + 未 begin 不写 + 不足 5 秒只写 stats + abandon 不留 session + 清空只清统计 + 三种 period 的每日长度);desktop-app的DesktopReadingStatsPageTest5 项(空态、摘要与榜单点击、周期行、清空确认、时长格式阈值)。 新探针stats(DesktopAppTest的“a read stop shows up in the statistics screen…”)走真实界面: 真读一章 → 更多 → 阅读统计 → 作品在榜且时长 > 0 → 切成 DAY(每日长度 1)→ 清空先弹框、取消保留、 确认后空态且历史仍在 → 返回更多。回归::desktop-runtime:test16 套 105 项全绿,:desktop-app:test全绿,:desktop-app:verifyDesktopStrings、:core-library:compileCommonMainKotlinMetadata、:desktop-app:compileKotlin/:compileTestKotlin通过。
156. Windows 阅读统计的类型筛选(Android StatsScreen 对齐)(2026-10-24)
Android 的统计页除了周期下拉还有一个 StatsContentKind 分段选择器(全部/漫画/小说/视频): StatsRepository.getDashboard 先按 kind 过滤 contents,再把过滤后的 id 集合交给 deriveReadingStatistics。§155 只做了周期。
- 读模型:
SourceLibrary.readingStatistics增加可选参数kind,实现就是把喂给派生函数的kinds映射按类型过滤 ——共享派生只遍历这张映射的键,所以每日/时段序列、总时长、会话数、作品数一起跟着筛,与 Android 先过滤再派生等价。 参数排在now之后,既有按位置传now的调用点不用改。 - 界面:周期行下方是 Android 的
ContentKindSelector(胶囊底 + 四个 chip,选中填 primary,标签复用stats_kind_all/stats_kind_manga/stats_kind_novel/stats_kind_video),标签stats-kind-filter与stats-kind-option:<NAME>。Android 把周期菜单和类型芯片放在同一个横向滚动行里; 桌面窗口够宽,这里分成两行,同时避免在整页纵向滚动中再嵌一个横向滚动视口 (嵌套后 UI 测试的performScrollTo只滚最近的滚动容器,够不着另一方向的节点,点击会静默打偏)。 筛到空时沿用 Android 的空态;类型占比本来只在多于一种类型时出现,筛成单一类型时自动消失。 - 状态:
DesktopAppState.readingStatsKind(默认ALL)与DesktopController.readingStatsKind(kind),loadReadingStats同时带周期与类型,重新进入页面时保留上次的选择。 - 验证:
DesktopReadingStatsTest7 项(新增:漫画与小说各读一段,ALL 两部都在、合计 60s;只筛漫画 20s/1 session; 只筛小说 40s;筛视频为空);DesktopReadingStatsPageTest6 项(新增:四个 chip 用 Android 标签,点“小说”回报 NOVEL);stats探针增加真界面步骤:读一章 → 更多 → 阅读统计 → 周期切 DAY → 类型切“小说”得到空态 → 切回“漫画”作品仍在 → 继续清空确认与返回。
157. Windows 单作品统计弹窗(Android ContentStatsDialog 对齐)(2026-10-24)
Android 的统计页点作品不是直接进详情,而是 AppRouter.showStatisticSheet(manga) 打开 ContentStatsDialog: 作品标题、90 天每日时长柱状图、首次阅读时间、三块指标(总专注时长/会话数/页数或章节数或集数), 底部「取消 / 详情」,只有「详情」才导航。Windows 之前点作品直接跳详情,本节点补齐这个中间层。
- 读模型:
SourceLibrary.contentReadingStats(contentId, zone, now = clock())返回LibraryContentStats(kind、总时长、会话数、units、firstActivityAt、90 天dailyActivity)。实现与 AndroidStatsRepository.getContentSnapshot同构:ReadingStatsPeriod.ALL窗口 + 只含这一部作品的kinds映射, 交给共享deriveReadingStatistics,所以日序列只含这部作品的时长、units与首次阅读直接取派生出的作品记录。 作品没有统计时仍返回全零快照(kind 取 manga 行的 content type,缺行回落 MANGA),不像详情那样报错。 - 界面:
DesktopContentStatsDialog(标题 + Canvas 柱状图 + 首次阅读 + 三块指标 + 取消/详情), 柱状图照 AndroidContentStatsBarChart:窗口装不下时按平均压缩柱子、虚线数值网格、圆角柱、粗基线, 颜色按内容类型(漫画 primary / 小说 tertiary / 视频 secondary),全零时显示empty_stats_text。 指标标签复用 Android 的stats_total_time/stats_sessions/stats_unit_pages|chapters|episodes, 首次阅读那行照 Android 只印一个相对时间(feedDateLabel的桶与 AndroidDateTimeAgo同构),不加前缀。 - 状态与导航:
DesktopAppState.contentStats(DesktopContentStats(content, stats),null 表示关闭),showContentStats先立即可见再异步填数(Android 的 sheet 也是这样),closeContentStats关闭; 进入/离开统计页(showReadingStats/more)都清空,弹窗永不属于别的页面。统计页作品行改为调用controller::showContentStats,弹窗的「详情」先关弹窗再controller.details(content)。 - 标签:
content-stats、content-stats-chart、content-stats-first、content-stats-total/-sessions/-units、content-stats-close、content-stats-details。 - 验证:
:desktop-runtime:test全绿(DesktopReadingStatsTest8 项,新增:同一部作品两段阅读 + 另一部作品一段, 单作品快照 45s/2 session/1 页/首次阅读 = 第一段起点/90 天序列只含它的时长;无统计的作品返回全零快照);:desktop-app:test212 项 1 失败 4 跳过(唯一失败仍是外来 TV 提交的SharedChapterDirectoryTest, 已知非本工作流);新增DesktopContentStatsDialogTest2 项(数值/标签/首次阅读行 + 取消与详情回调; 无统计时保留弹窗、空图表、无首次阅读行、单位按小说换章节);stats探针增加真界面步骤:点作品行 → 弹窗出现且仍在统计页 → 图表/首次阅读/三块指标存在 → 关闭后回到统计页 → 继续清空确认与返回,输出DESKTOP_UI_OK=stats。
158. Windows 代理设置页(Android ProxySettingsScreen 对齐)(2026-10-24)
Android 在“存储与网络 → 代理”里配置 DIRECT/HTTP/SOCKS 与可选认证,ProxyProvider 把它装到所有 OkHttp 客户端上。 Windows 之前完全没有代理:每个来源、封面、JSON 导入都走 compat 客户端建立时的直连。本节点补上这个页面, 并让设置真正接管共享 HTTP 客户端。
- 值模型:
desktop-runtime的DesktopProxySettings(type/address/port/login/password)读写 Android 同一组键proxy_type_2、proxy_address、proxy_port、proxy_login、proxy_password,类型存枚举名DIRECT/HTTP/SOCKS(与 Android 的Proxy.Type.name一致);portNumber只接受 1..65535,isConfigured对应 Android 的“地址非空且端口合法”(DIRECT 恒为已配置)。 - 运行时:
DesktopProxy(preferences)持有settingsStateFlow,save写入 store 后发布新值;selector是ProxySelector,每次select才读当前值——换代理不需要重建客户端;authenticator只在 HTTP 且填了用户名密码时回 407 的Proxy-Authorization(请求或前一个响应已有该头则不动)。 地址用InetSocketAddress.createUnresolved,DNS 交给代理。 - 客户端接管:
DesktopChallengeInterceptor.kt新增installDesktopProxyClient(helper, selector, authenticator), 在 SDK 客户端newBuilder()上加proxySelector/proxyAuthenticator,复用浏览器挑战那套client$delegate/cloudflareClient$delegate私有委托替换,所以 SDK 自己持有的client/cloudflareClient以及所有已捕获过它们的来源、封面与图片请求都改走新路由;MihonDesktopPlatform增加可选proxySelector/proxyAuthenticator并在挑战安装之前装上,DesktopSession.open打开desktop_network命名空间后传给它。 - 连接池:
ProxySelector.select只影响新连接,OkHttp 仍会复用旧路由建立的池化连接(实测:切回直连后同一客户端 仍命中代理)。所以updateProxySettings保存后调DesktopSession.resetHttpConnections()(evictAll()), 下一次请求才真的按新路由走。 - 界面:
DesktopProxySettingsPage,两节与 Android 相同——“网络”(类型/地址/端口/测试连接)与 “认证(可选)”(用户名/密码)。类型是单选对话框(DIRECT 显示 Android 的disabled,另两项是协议名), 地址/端口/用户名/密码是文本对话框,密码行以圆点回显、对话框密文输入;端口对话框拒绝空以外非 1..65535 的输入 (不关对话框),与“扫描一切输入都存下来”的 Android 行为相比只是不落库明显非法的值。 测试连接行照 Android:运行中显示loading_、未配置时摘要为invalid_proxy_configuration, 目标同为http://neverssl.com,成功弹connection_ok、失败弹异常消息,结果对话框proxy-test-result。 标签:proxy-settings-back、proxy-type、proxy-address、proxy-port、proxy-test、proxy-username、proxy-password、proxy-type-dialog/proxy-type-option:<NAME>、proxy-address-field/-save等、proxy-test-close。 - 导航:
DesktopScreen.PROXY_SETTINGS、DesktopAppState.proxySettings/proxyTest(DesktopProxyTestState(running, message, success))、showProxySettings/updateProxySettings/testProxyConnection/closeProxyTest;窗口标题与行文案复用 Android 的proxy/network, 入口在“更多 → 功能”节的proxy-settings-open行(Android 从存储与网络进,Windows 没有那个枢纽)。 - 验证:
:desktop-runtime:test全绿,新增DesktopProxyTest5 项(新 store 是直连;值经 Android 键往返、重开 store 仍读到; 空地址/空端口/0/70000/非数字都保持直连;selector 把真实 OkHttp 请求送到 loopback HTTP 代理且只命中一次, 切回直连并evictAll后同一客户端失败且代理未再命中;凭据回答 407 挑战,页面清空后不再带)。:mihon-desktop-compat:test全绿,新增探针proxy模式:MihonDesktopPlatform(root, proxySelector = …)之后sharedHttpClient().proxySelector === selector,经 loopback 代理取回proxied且只命中一次(NETWORK_REQUESTS=0); 同时把两条偏好断言从“最后一行是 User-Agent”改成按 id 断言——HostDomain的host:domain行已排在其后, 旧断言在提交205cddd4b之后一直是失败的。:desktop-app:test213 项 1 失败 4 跳过(唯一失败仍是外来 TV 提交的SharedChapterDirectoryTest, 已知非本工作流;新增的代理探针全绿),探针proxy走真界面:更多 → 代理 → 类型 HTTP → 地址/端口(70000 被拒)→ 测试连接得到connection_ok且代理确实收到请求 → 用不可解析的http://tvbox-import.invalid/config.json导入 JSON 来源成功(只能经代理)→ 切回直连后同一导入失败、 代理未再收到请求 → 返回更多,输出DESKTOP_UI_OK=proxy。
159. Windows 存储与网络页(存储用量 + 代理入口,对齐 Android StorageAndNetworkSettings)(2026-10-24)
- 问题:Android 的“存储与网络”把存储用量和代理放在同一个枢纽里;Windows 只有下载页里的图片缓存数字, 看不到数据库、偏好/下载记录、日志各占多少,也不知道数据所在磁盘还剩多少;代理页则孤零零挂在“更多 → 功能”。 上一节(§158)为此在注释里写了“Windows 没有那个枢纽”,这一节把这个枢纽补上。
desktop-runtime:新增DesktopStorageUsage.kt。DesktopStorageCategory是DATABASE/IMAGES/PREFERENCES/LOGS/OTHER/AVAILABLE;desktopStorageUsage(paths)只读元数据, 按数据根目录的顶层子项归属切片(kototoro.db及其-wal/-shm归数据库、images/preferences/logs各自一类、其余一律OTHER),这样将来新增的目录会出现在“其他”而不是被漏掉;符号链接不跟随(指向数据根之外 的链接不算本卷占用);AVAILABLE取File.usableSpace,volumeBytes取File.totalSpace, 百分比按整卷计算(与 Android 的行一致,所以各项之和不等于 100%)。DesktopDataPaths补logs属性, 与既有root.resolve("logs")同义,避免再写一份字面量。desktop-app:DesktopSession.storageUsage()在Dispatchers.IO上执行遍历;DesktopController新增DesktopScreen.STORAGE、DesktopAppState.storageUsage、showStorage()(进页面时同时刷新图片缓存报告, 因为图片行要写“其中已保存章节保留多少”)、refreshStorageUsage()、loadStorageUsageOnIo()、backToStorage()(代理页返回枢纽时不重新遍历)。DesktopStorageSettingsPage: “存储用量”节是 Canvas 画的比例条 + 一行storage.summary(Kototoro 占用 / 磁盘可用 / 卷容量)+ 每个切片一行(色点、大小、百分比,storage-usage:<CATEGORY>标签),图片行在有已保存章节时改用storage.imagesProtected文案;“网络”节是 Android 的代理行(proxy-settings-open,值显示disabled/HTTP/SOCKS,已配置时摘要写地址:端口)。配色沿用 Android 的调色板。- 导航:更多 → 功能里原来的代理行换成“存储与网络”(
storage-settings-open,图标ic_storage, 摘要storage.moreSummary);代理页的返回按钮改回枢纽而不是“更多”(proxy-settings-back)。 因此探针的路径从“更多 → 代理”变成“更多 → 存储与网络 → 代理”。 - 文案:
storage.moreSummary、storage.database、storage.images、storage.imagesProtected、storage.preferences、storage.logs、storage.other、storage.summary为新增桌面键(中英两本目录都加); 其余复用 Android 键storage_usage、storage_and_network、available、computing_、network、proxy、disabled。:desktop-app:verifyDesktopStrings全绿(键集、占位符、未使用键与中文硬编码四项检查)。 - 验证:
:desktop-runtime:test全绿,新增DesktopStorageUsageTest3 项(按顶层子项归属且usedBytes不重复计数、百分比落在 0..100 且可用空间比 2 KB 图片大、空根目录只报可用空间不编造空切片、 符号链接不跟随);:desktop-app:test见下(新增storage探针 + 改路径后的proxy探针都全绿)。 探针storage先在images/probe/page.img写 3000 字节、logs/source-errors.log写 100 字节,再走 更多 → 存储与网络:proxy-settings-open在“更多”页已不存在,storage-usage-chart/storage-usage-summary/storage-usage:IMAGES/storage-usage:LOGS/storage-usage:AVAILABLE都在,读模型里IMAGES ≥ 3000、LOGS ≥ 100、usedBytes ≥ 3100、available > 0、volume ≥ available;再进代理页、 返回枢纽、返回“更多”,输出DESKTOP_UI_OK=storage。proxy探针改走同一枢纽,并在最后校验 代理页返回枢纽、枢纽返回“更多”。
160. Windows 播放设置页(背景/解码器/控件与渐隐不透明度,对齐 Android PlaybackSettingsScreen)(2026-10-24)
- 问题:Windows 播放器的背景固定深灰、控件条不透明、解码器只有“给 mpv 传
hwdec=auto-safe”一个默认, Android 的“播放设置”里这几项都能改。这一节把该页补齐,并让播放器真正消费这份偏好。 core-ui:SharedSettingsRows.kt新增共享SettingsSliderRow(标题+摘要+右侧数值一行、滑条在下, 与 Android 的SettingsSliderPreference同构;整型值/范围/步长,valueText交给宿主格式化文案)。 拖动只改本地 draft,松手才回调onValueChange——Windows 的偏好 store 每次写入都落盘, 按像素写会每秒几十次 IO;Android 那边SeekBarPreference也是松手提交。无障碍/测试的SetProgress语义同样走onValueChangeFinished,所以探针用它驱动。desktop-app:DesktopVideoSettings.kt新增DesktopVideoDecoderMode(对齐 AndroidVideoDecoderMode: HARDWARE_ONLY / HARDWARE_PREFERRED / SOFTWARE_PREFERRED)与DesktopVideoSettings(背景 = 阅读背景枚举、 解码器、controlsAlpha默认 90、gradientAlpha默认 70,两个 alpha 都按 Android 存百分比整数, 派生controlsOpacity/gradientOpacity并夹在30..100/0..100)。解码器到 mpv 的映射: 优选 =hwdec=auto-safe;仅硬件 =auto-safe+hwdec-fallback=no(解不了就失败,不悄悄退回 CPU); 仅软件 =hwdec=no。DesktopController:新增DesktopScreen.PLAYBACK_SETTINGS、DesktopAppState.videoSettings、showPlaybackSettings()、videoSettings(settings)(写video_background/video_decoder_mode/video_controls_alpha/video_gradient_alpha四键——与 Android 同名,值分别存枚举名与整数)、restoredVideoSettings()(读回并夹紧范围)。写盘失败只提示内存生效(error.videoSettingsInMemory)。DesktopPlaybackSettingsPage:返回“更多”(playback-settings-back);“播放”节四行:背景、解码器(单选对话框playback-background/playback-decoder,选项标签带枚举名)、控件不透明度、渐隐不透明度 (两个SettingsSliderRow,值显示NN%)。背景只给 Androidvideo_backgrounds的五个值(无 AUTO)。 Android 这一页的第五行是 AI 设置(OCR/翻译),本端尚未移植,不挂通向不存在页面的入口。- 播放器:
DesktopVideoPlayer的外框与视频面都改用rememberVideoBackdrop(state)(DEFAULT 保持原来的 深色播放器底色,其余取阅读背景色,并随应用明暗解析 AUTO);Controls拆成“渐隐 + 控件条”, 渐隐是从透明到gradientAlpha黑的自定义渐变,控件条背景用controlsAlpha的 panel 色, 于是视频能从半透明控件后透出来;建播放器时把解码器选项并入 mpv 参数(-Dkototoro.mpv.options仍可覆盖), 运行中改解码器再runCatching { setProperty("hwdec", …) }一次(libmpv 不允许时静默,下次开窗即生效)。 desktop-player:MpvPlayer原本对每个选项都check,现在只对自带默认值严格;调用方传入的额外选项 (-Dkototoro.mpv.options、hwdec-fallback)在旧 libmpv 上不存在时不再让播放器创建失败。- 文案:新增桌面键
video.decoder.hardwareOnly/hardwarePreferred/softwarePreferred、playback.moreSummary、error.videoSettingsInMemory(中英两本目录都加);标题与选项标签复用 Android 键playback_settings、video_background、video_decoder_mode、video_controls_alpha、video_gradient_alpha、video_decoder_hardware_only/hardware_preferred/software_preferred、system_default、color_light/dark/white/black。:desktop-app:verifyDesktopStrings全绿。 - 导航:更多 → 功能里在“阅读设置”之后新增“播放设置”(
playback-settings-open,图标ic_content_video, 摘要playback.moreSummary;desktop-app/build.gradle的desktopIcons白名单补ic_content_video)。 - 验证:
:desktop-app:test新增DesktopVideoSettingsTest3 项(默认值=Android 的 90/70 与优选解码、 两个不透明度的夹紧、三种解码器的hwdec映射);探针playback走 更多 → 播放设置,校验四行与默认值, 解码器对话框选“仅软件”后状态为 SOFTWARE_PREFERRED、背景对话框没有AUTO且选“白色”后状态为 WHITE、 两个滑条的SetProgress分别落到 50/20(即松手提交),最后返回“更多”,输出DESKTOP_UI_OK=playback。
161. GitHub 镜像下沉 core-net + Windows 存储与网络页的镜像选择(2026-10-24)
- 问题:Android 的“存储与网络”里有一个 GitHub 镜像选择(28 个公共反代 + 直连),扩展仓库索引、 扩展 JAR、Cloudstream 列表都走它;Windows 既没有这个选择,几个下载路径也只会直连 GitHub——
raw.githubusercontent.com与被墙的github.com发布件在受限网络里等于不可用。 core-net:GitHubMirrorCatalog.kt从app迁到core-net/src/commonMain(包名org.skepsun.kototoro.core.prefs不变,Android 侧 import 全不动),GitHubMirrorStrategy/GitHubMirrorEntry/GitHubMirrorManifest/builtin/legacyAliases/apply/rawFileUrl/normalizeMirrors/syncCandidateUrls全部共享。只有两个平台的入口留各自宿主: Android 的GitHubMirrorEntry.displayName(context)(要R.string.github_mirror_native)留在app的GitHubMirrorDisplay.kt。可移植化只需把Map.putIfAbsent换成手写containsKey判断 (前者是 JVM 成员,metadata 编译会拒绝)。:core-net:compileCommonMainKotlinMetadata通过, Android 侧GitHubMirrorCatalogTest(4) 与GitHubMirrorManifestTest(10) 原样全绿。desktop-runtime:新增DesktopGitHubMirrorSettings(只有一个id,读不到的 id 不改写 store, 解析时退回直连;Android 的kkgithub/ghproxy/ghproxy_net/jsdmirror旧别名经builtinById仍解析到gh_proxy_com)与DesktopGitHubMirror(读写desktop_network的github_mirror——与 Android 同键同值;settings是StateFlow,rewrite(url)每次读当前值, 换镜像不用重启)。desktop-runtime因此api project(':core-net')。- 接线(都是把 URL 交给
rewrite,直连时是恒等函数):DesktopAppUpdateClient(mirror=)改写发布件与.sha256(不改api.github.com的版本查询—— 没有镜像代理 API,而且检查必须先知道文件名);DesktopSuperResolution(mirror=)改写模型releases/download候选(API 那个候选同样直连);DesktopRepositories(mirror=)改写扩展 JAR、 解析器插件、Cloudstream 插件与 Cloudstream 索引/列表——不改仓库索引地址本身 (它是用户输入的、要落盘的、相对jarUrl的解析基准),Cloudstream 的 raw 链接先过镜像、 直连时仍退回原来的 jsDelivr 改写。三个 HTTP 客户端都已是Redirect.NORMAL,反代的 302 能跟。DesktopSession新增githubMirror(与DesktopProxy共用同一个desktop_networkstore 实例)。 desktop-app:存储与网络页“网络”节在代理行后加 GitHub 镜像行(github-mirror-open,图标ic_code,摘要用 Android 的pref_github_mirror_summary,右侧显示当前镜像名——直连显示github_mirror_native,其余就是域名,不翻译)。点开是共享DesktopChoiceSettingsDialog(github-mirror-dialog,选项github-mirror-option:<id>,列表 =GitHubMirrorCatalog.builtin, 直连在首位),选中即DesktopGitHubMirror.save并更新 state。没有新增桌面文案键,:desktop-app:verifyDesktopStrings不受影响;desktop-app/build.gradle的desktopIcons白名单补ic_code。- 验证:
:desktop-runtime:test新增DesktopGitHubMirrorTest6 项(默认直连恒等、按 Android 键 往返与重启读回、gh_proxy_com改写发布件与 raw 文件而 API 不动、切回直连即时生效、 未知 id 保留值但直连 + 旧别名解析、对话框列表直连在首位且 id 唯一); 探针storage扩展为在同一页打开镜像对话框:默认native、列表里没有 jsDelivr、 选gh_proxy_com后 state、session.githubMirror.rewrite与desktop_network里的github_mirror三处一致,输出DESKTOP_UI_OK=storage。:app:compileDebugKotlin通过。 - 仍待:Android 的镜像清单远程同步(
github_mirror_sync_url+ 延迟探测)、HuggingFace/Bangumi 镜像、 DNS-over-HTTPS、图片代理等其余网络项。
162. 镜像测速:探测模型共享 + Windows 存储与网络页的测速行(2026-10-24)
- 问题:§161 让 Windows 能选镜像,但选哪一个只能靠猜——Android 早就有“测速”(
mirror_probe_*一组文案, 每个镜像拉一次 GitHub 上的小文件,把毫秒标在选项后面,并给最快的那条打标、支持“选用最快的”)。 Windows 少了这一步,用户面对 28 个反代只能逐个试。 core-net:探测的结果模型随目录一起共享——GitHubMirrorProbeResult(latencyMillis, isAvailable)、GitHubMirrorProbeState{Idle,Running(completed,total),Finished(available,total,fastestId,fastestMillis)}搬进GitHubMirrorCatalog.kt(纯 Kotlin,无 JVM API),探测目标GitHubMirrorCatalog.PROBE_URL(raw.githubusercontent.com/skepsun/kototoro-parsers/repo/index.min.json)也成为共享常量。 Android 的GitHubMirrorCatalogRepository.kt删掉本地声明、改从core.prefs导入这两个类型 (AppUpdateViewModel/AppUpdateScreen/WelcomeViewModel/StorageAndNetworkSettingsViewModel/StorageAndNetworkSettingsRoute/AppUpdateMirrorOptionsTest六处只动 import,逻辑一行不改): Kotlin 不允许通过typealias访问嵌套分类器(Alias.Finished解析不到),别名保旧名这条路不成立。 它自己的PROBE_URL常量也删掉,改用共享的那个。 真正跑探测的仍是平台代码(Android 走 OkHttp + 6 s 超时,Windows 走 JDKHttpClient), 因为客户端、Dispatcher 与取消语义都在宿主。desktop-runtime:DesktopGitHubMirror新增probeState/probeResults两个StateFlow与suspend fun probe(entries = options):整批async并行、每个请求 6 s 超时、响应头即答案 (跟到死主机的重定向到不了这一步),逐条写回结果并推进Running(completed,total); 6 s 超时与连接失败都记成isAvailable = false的一行,不会拖垮同批其他镜像(Android 的实现同理)。 结束时算available与fastestId(可用里延迟最小的那条)。取消走runInterruptible包住阻塞的HttpClient.send,所以页面按“取消”会真的断开 socket,而不是等到超时;finally把仍是Running的状态收敛回Idle,已答完的行保留。desktop-app:存储与网络页“网络”节在镜像行后加 Android 的测速行(SettingsActionRow,github-mirror-probe,按钮github-mirror-probe-start/github-mirror-probe-cancel, 最快且未选中时多一个github-mirror-probe-fastest= “选用最快的”)。摘要文案直接用 Android 的mirror_probe_action/mirror_probe_summary/mirror_probe_running/mirror_probe_finished/mirror_probe_none_available/mirror_probe_cancel/mirror_probe_short_action/mirror_probe_select_fastest(AndroidStrings,带format占位符),没有新增桌面文案键。对话框的选项标签按 Android 的latencyLabel追加" · 213 ms"/" · 超时"(mirror_probe_timeout),最快的那条再追加" · 最快"(mirror_probe_fastest_badge)。页面用collectAsState()直接读controller.session.githubMirror的两个 flow(与下载队列同一条既有先例),DesktopAppState不加字段; 控制器只负责用mirrorProbeJob起停这次扫描。- 验证:
DesktopGitHubMirrorTest由 6 项增至 10 项——环回 HTTP 服务下两个镜像都可用、Finished(2,2,最快,≥0ms); 连127.0.0.1:1(必然被拒)得到Finished(0,2,null,null)且每条都是不可用;持住响应的服务上probeState停在Running、cancelAndJoin后回到Idle;空列表不进Running。:desktop-runtime:test全绿。探针storage追加“点开始→进入 Running→点取消→回 Idle→按钮复位”, 输出DESKTOP_UI_OK=storage。 - 已知(与本切片无关,HEAD 上就失败):
DesktopAppTest > Android styled light and dark themes render covers and persist across an offline restart在DesktopAppearanceProbe.kt:86报 “找不到interface-style:IOS”——把本切片改动 stash 掉后同样失败,属界面风格/玻璃外框那条工作流。 - 仍待:镜像清单的远程同步(
github_mirror_sync_url+docs/github-mirrors.json), Windows 目前只用内置目录;HuggingFace/Bangumi 镜像、DoH、图片代理等其余网络项。
163. 共享 Markdown 子集 + Windows 更新说明与更新日志页(对齐 Android AppUpdateScreen/Changelog)(2026-10-24)
- 问题:Windows 的“关于与更新”卡只会说“发现新版本 2.3.1”,既没有 Android 那个“新版本 / 安装包大小 / 发布说明”的
UpdateDescription,也没有更新日志页(Android 的ChangelogScreen,把appUpdateRepository.getAvailableVersions()拼成"# 版本\n\n说明"交给 Markwon 渲染)。 Windows 从来没有 Markdown 渲染器,直接搬 Markwon 又会给桌面加一个 Android 依赖。 core-ui(新包core/ui/markdown/,纯共享代码,Android/iOS 也能用):MarkdownBlocks.kt:parseMarkdownBlocks(markdown)→Heading/Paragraph/Quote/ListItem(marker,depth)/Code(language)/Rule,parseMarkdownInline(markdown)→Text(bold/italic/strikeThrough/code)/Link(text,url)。 覆盖 GitHub release note 真正会用的子集:ATX 标题(#后必须有空格,#hashtag仍是正文)、 ``` 与 ~~~ 围栏(未闭合时吃到文末)、---/***/___分隔线、>引用(相邻行合并)、 嵌套无序列表与- [x]任务框(渲染成 ☑/☐)、有序列表保留原编号、空行分段、 行尾两空格或\是硬换行否则软换行、<!-- -->注释丢弃。 行内:**/__加粗、*/_斜体(下划线要求两侧词边界,snake_case保持字面)、~~删除线、 反引号代码、[文字](url "title")与、\<char>转义、<br>当换行、其他标签丢弃、 未闭合标记保持字面。没有引入任何依赖(AGENTS.md/CONTRIBUTING.md 不接受为 APK 体积新增依赖)。MarkdownText.kt:MarkdownText(markdown, modifier, style, onLinkClick)的 Compose 渲染 (标题四级用 titleLarge/titleMedium/titleSmall/bodyLarge,引用带左侧色条,列表项 marker 固定 18/26 dp 并按 depth 缩进 14 dp,代码块 monospace + 横向滚动),以及markdownPlainText(markdown)供测试/摘要用。 链接默认走 Compose 的LinkAnnotation.Url(LocalUriHandler开系统浏览器),传onLinkClick时改LinkAnnotation.Clickable。
desktop-runtime:DesktopAppUpdateClient.releases()返回本频道所有可安装版本(新→旧)。 过滤规则抽成List<DesktopUpdateRelease>.offerableFor(channel)(有包且有下载地址;stable 装不了预发布),newestRelease与新增的changelogReleases(releases, channel)都读它——所以页面列出的说明 与它真会装的版本永远同一份,不会出现“说明了却不给升”。desktop-app:- 更新卡(
DesktopAppUpdateSettings.kt)在“发现新版本”那一行右侧加app-update-changelog(文案复用 Android 的changelog),下面按 Android 的UpdateDescription补app-update-size(update.size+desktopCacheSize)、app-update-published(update.published+desktopReleaseDate,GitHub 的published_at转本地日期,读不出来就退回日期部分或干脆不显示),以及app-update-notes——发布说明本身 用MarkdownText渲染(最高 220 dp 内滚动,免得长说明把开关挤下去)。 - 新页
DesktopChangelogPage.kt+DesktopScreen.CHANGELOG(controller.showChangelog();返回键回“更多”): 标题与摘要直接用 Android 的changelog/changelog_summary,右上“重新加载”(update.changelogReload), 每版一张卡(版本号、当前版本徽标update.changelogCurrent、发布日期与包大小、说明 Markdown), 列表用LazyColumn(key = tag)。三态:错误changelog-error、未到达/加载中changelog-loading、 真空changelog-empty——“null 就是还没到”,不会把“还没问”画成“没有说明”。 DesktopUpdateOperations:DesktopUpdateUi加changelog/changelogLoading/changelogError,changelog(refresh)每次会话只拉一次(重进页面不重复请求,点“重新加载”才重拉),失败只落页面文案, 不污染更新卡自己的error。
- 更新卡(
- 验证:
:core-ui:jvmTest --tests MarkdownBlocksTest18 项全绿(标题/围栏/列表/任务框/引用/分隔线/软硬换行/ 行内强调与链接/snake_case/未闭合标记/HTML 注释与标签/真实 GitHub release note 一段)。:desktop-runtime:test --tests DesktopAppUpdateTest15 项全绿,新增 “changelog lists offerable releases newest first and skips pre-releases for a stable copy”: stable 只列正式版且新→旧,nightly 频道保留自己的预发布并仍按版本排序,没有安装包的版本一条都不出现。- 探针
changelog(:desktop-app:test里DesktopAppTest同名用例):先从频道直接取一次releases(),再进“更多→关于卡→更新日志”,等页面加载完成后逐项对账——页面列出的版本顺序与频道一致、 最新一版在第一张卡、说明的首行文本真的出现在界面上(证明MarkdownText渲染的是发布说明本身)、 返回后回到“更多”、再次进入不重复请求。本机实测DESKTOP_CHANGELOG_STATE=releases:[v2.3.1]、 输出DESKTOP_UI_OK=changelog(离线时该探针断言的是页面的错误态)。 - 邻居回归:
storage、playback两个探针仍绿(“更多”页新增按钮后卡片布局不挤坏其他行)。
- 已知(与本切片无关,HEAD 上就失败):
DesktopAppTest > Android styled light and dark themes render covers and persist across an offline restart在DesktopAppearanceProbe.kt:86报找不到interface-style:IOS。 - 仍待:更新渠道(
KEY_UPDATES_UNSTABLE/nightly 切换)与崩溃日志行(AndroidAboutSettingsScreen的另外两项)。
164. Windows 不稳定更新渠道 + “支持”链接组(对齐 Android AboutSettingsScreen 余下两项)(2026-10-24)
- 问题:§163 的“仍待”前半——Android 的关于页里,
allow_unstable_updates决定稳定版要不要跟 nightly 仓库 (nightly 包本身不可回退,开关固定为开且禁用),下面还有一组about_group_support链接(使用手册/源码/ Ko-fi/爱发电/Discord)。Windows 的频道只来自构建时的版本戳,用户改不了,也没有任何链接入口。 desktop-runtime:DesktopUpdateSettings加unstable(默认 false),键app_update_unstable, 与自动检查/自动下载/退出安装三键同存desktop_app_updates偏好库,toPreferences()/from()往返。 放在应用更新的设置对象里而不是另开存储,是因为它和那三项一起决定“这个更新器要不要动手”。desktop-app:- 新文件
DesktopAboutLinks.kt(纯函数 + 一条数据,方便单测):desktopUpdateChannel(stamped, unstable)——版本戳是 nightly 就永远是 nightly(给 nightly 装正式包是降级), 正式版只有用户打开开关才走 nightly;desktopUnstableEditable(stamped)——只有正式版能改;DesktopAboutLink(key, title, url)与desktopAboutLinks()——五行按 Android 的顺序,标题取user_manual/source_code/about_donate/about_donate_afdian/about_discord,地址取url_*;openDesktopUrl(url)用java.awt.Desktop开系统浏览器,没有桌面/没有浏览器时返回 false 而不是抛。 DesktopUpdateUi加派生updateChannel(版本戳+开关合成)与unstableEditable;DesktopUpdateOperations.client()改用updateChannel建客户端,所以自动检查、手动检查、下载、更新日志全部跟着同一个频道走。settings(value)先比较“新旧开关是否换了频道”,换了就丢掉旧频道的答案(available/progress/changelog/changelogError/error)再落盘——否则卡片会把别的频道的“发现新版本”和刚切过去的检查结果并排显示。- 更新卡加
app-update-unstable开关(Android 的allow_unstable_updates与 summary,整行可点,沿用卡内UpdateSettingSwitch):勾选状态按有效频道算,nightly 包与下载/安装中禁用。位置在版本行下方,与 Android 把开关排在“检查更新”之后一致。 - “更多”页在更新卡之后加
about_group_support分节(新SettingsSectionHeader+SettingsGroup), 五行about-link:<key>,摘要就是地址本身,点一下开浏览器;地址不是 http(s) 时该行禁用。DesktopController.openExternalUrl(title, url)打不开时写一条about.linkFailed(含地址), 不让“点了没反应”变成静默失败。
- 新文件
- 字符串导出:
prepareDesktopStrings原来只读strings*.xml/arrays.xml,而 Android 把url_github/url_user_manual/url_discord这类地址放在values/constants.xml里(translatable="false"), 所以桌面侧拿不到——现在额外读constants.xml,但只收url_*:同一个文件里还有构建键与 OAuth client secret,不能复制进明文可读的 catalog。于是五个地址仍是 Android 那一份,没有第二份抄写。 - 验证:
DesktopAboutLinksTest4 项:正式版默认 RELEASE、开开关变 NIGHTLY;nightly 包无论开关都说 NIGHTLY; 只有正式版editable;五行链接的 key 唯一、标题非空、地址是绝对 https 且openable。- 探针
about(DesktopAppTest同名用例,DesktopAboutProbe):进“更多”,断言卡片与更新器算出的频道一致; 正式版上点app-update-unstable→settings.unstable为真、频道变 NIGHTLY、changelog/available被清空、 再从desktop_app_updates读回同一开关(证明落盘),点回去 → RELEASE;然后五行链接逐个存在且显示各自地址。 本机实测DESKTOP_ABOUT_STATE=channel:RELEASE editable:true unstable:false、输出DESKTOP_UI_OK=about。 - 邻居回归:
changelog、storage、playback三个探针仍绿(“更多”页多了整整一节,滚动与既有行不受影响)。 :desktop-runtime:test --tests DesktopAppUpdateTest15 项全绿;verifyDesktopStrings通过(新键about.linkFailed两个 catalog 都有)。
- 已知(与本切片无关,HEAD 上就失败):
DesktopAppTest > Android styled light and dark themes render covers and persist across an offline restart在DesktopAppearanceProbe.kt:86报找不到interface-style:IOS。 - 仍待:崩溃日志(Android
CrashLogScreen/CrashLogDetailScreen,Windows 目前既没有崩溃处理器也没有logs/下的崩溃文件,需要先写一个 writer);之后是其余设置页对齐(Spaces、替换规则、下载、备份周期、 同步、追踪、服务、导航配置、通知、全景、内容目录、TTS 等)。
165. Windows 崩溃报告:writer + 关于页入口 + 崩溃日志页(对齐 Android CrashLogScreen/CrashLogDetailScreen)(2026-10-24)
- 问题:§164 的“仍待”前半。Android 在
CrashLogWriter里挂Thread.setDefaultUncaughtExceptionHandler, 把未捕获异常写成crash_logs/crash_yyyyMMdd_HHmmss.log,再从关于页的“崩溃日志”进列表、详情复制/分享。 Windows 侧进程崩了就是没了:没有处理器、没有文件、没有入口,用户拿到“闪退”只能猜。 desktop-runtime新文件DesktopCrashLogs:- 目录/文件名/上限沿用 Android:
<数据根>/logs/crash_logs、crash_<时间戳>.log、MAX_FILES = 20(isLogName只认这个前缀+后缀,别的文件(如notes.txt)列不出来也删不掉)。这样一份日志在两端可以互看, 存储页的logs子项也仍然是那一格。 - 报告格式沿用 Android:
=== Crash Report ===头(Time:/App Version:/Thread:),平台行换成Windows: <os.name> <os.version> (<os.arch>)、Java: <java.version> (<java.vendor>)、User:; 然后=== Stack Trace ===+printStackTrace,每个 cause 前加=== Caused by ===。 - 与 Android 刻意不同的一处:Android 按秒命名,同一秒内第二次崩溃会覆盖第一份;这里撞名时追加
_1、_2, 因为读不到的报告比长一点的文件名更糟(MAX_FILES仍然兜底)。 write()在未捕获异常处理器里跑,绝不抛:目录建不出、磁盘满都只是一份缺失的日志(返回 null), 不能变成第二次崩溃。写<name>.part再Files.move,避免“崩溃中崩溃”留下半个文件。files()新→旧、只读每份前 8 KiB 找首行含Exception/Error的行作摘要;目录不存在或读不了是空列表而不是错误。install()是实例方法(companion 里this是 Companion),用 AtomicBoolean 幂等,并串上 JVM 原本的 默认处理器(Android 也串)——测试框架/JUnit 自己的处理器不会被吞掉。
- 目录/文件名/上限沿用 Android:
- 接入点:
DesktopSession.openOnIo在DesktopRuntime.open(root)之后立刻DesktopCrashLogs.of(storage.paths, 版本).install()。 数据根要等存储打开才知道,所以只能挂在这里(Android 在 Application 里挂)。此时起,图源/渲染器的未捕获异常 都会落盘;重启不需要重复挂(install幂等)。 desktop-app:- 新文件
DesktopCrashLogOperations.kt:DesktopCrashLogsUi(files, loading, detail)是独立 flow (controller.crashLogs,页面collectAsState()读),不进DesktopAppState——和probeState同一个理由: 只是一件事的临时进度。files == null表示“还没读过”,空列表才是真答案。进页面每次都重读(窗口还开着时 也可能新写一份报告),删除/清空后直接重读,不等下一次进页面。 - 新文件
DesktopCrashLogsPage.kt:标题 + Android 的crash_logs_summary,行是yyyy-MM-dd HH:mm:ss+ 摘要 + 大小, 空态crashLogs.empty,非空才给“清空”(Android 的clear,同在标题行右侧)。点行开详情对话框: 等宽 12sp、可选中、限高 420dp 内部滚动,底部copy/delete/关闭。Android 的列表页+详情页在这里收成 一页 + 对话框,理由同 §163 的更新日志:桌面宽够,少一条路由。 - 复制走
DesktopClipboard.write(回读校验);Android 的“分享”没有桌面等价物,复制即分享,成功/失败各一条crashLogs.copied/crashLogs.copyFailed。 - “更多”页更新卡下方、
about_group_support之前加一张单行SettingsGroup(app-crash-logs,标题/摘要取 Android 的crash_logs/crash_logs_summary),位置与 Android 关于页一致;新DesktopScreen.CRASH_LOGS(DesktopApp的页面when、标题when各加一支,导航栏继续落到else -> 3的“更多”),返回回“更多”。
- 新文件
- 验证:
DesktopCrashLogsTest5 项(@TempDir+ 假时钟):报告含 Android 的头部/堆栈/=== Caused by ===且不留.part; 列表新→旧且摘要就是异常那一行;24 次崩溃后只剩 20 份(最旧的三份被剪掉、第 4 份还在);目录还不存在时files()/clear()不报错,删一份后read返回 null,clear返回删除数;非crash_*.log的文件既不列出也不删。- 探针
crashlogs(DesktopAppTest同名用例,DesktopCrashLogsProbe):起一个线程error("probe crash from a thread")让它真的未捕获地死掉 → 断言files()出现 1 份且摘要就是这条消息(证明install()挂上了、格式对); 再直接写第二份;进“更多”点app-crash-logs→ 两行都在、最新的是第二份;点行开详情(crash-log-text, 控制器侧断言文本含 “second report”);crash-log-delete→ 对话框关闭、列表剩 1 行、磁盘上也只剩 1 份;crash-logs-clear→ 空态出现、目录清空;返回回“更多”。本机实测DESKTOP_CRASH_STATE=files:1 name:crash_20261024_130907.log summary:java.lang.IllegalStateException: probe crash from a thread、 输出DESKTOP_UI_OK=crashlogs。 :desktop-runtime:test --tests *DesktopCrashLogsTest*5/5;verifyDesktopStrings通过(4 个新键两个 catalog 都有)。
- 仍待:Android 详情页的“分享”(桌面没有分享面板,用剪贴板代替,已记录);崩溃时的“下次启动提示” (Android 的
CrashLogActivity进入即看日志,Windows 目前只从关于页进);其余设置页对齐(Spaces、替换规则、 下载、备份周期、同步、追踪、服务、导航配置、通知、全景、内容目录、TTS 等)。
166. 共享替换规则引擎 + Windows 替换规则页与小说应用规则(对齐 Android ReplaceRulesSettingsScreen/NovelTextProcessor)(2026-10-24)
- 问题:§165 的“仍待”里第一条设置页。Android 的替换规则是“阅读设置 → 小说 → 文本替换规则”进的一页 (动作组 + 按分组排列的规则行 + 带实时预览的编辑对话框),规则由
NovelTextProcessor应用到小说正文与章节标题; Windows 侧既没有这一页,读完同一本书也不会替换任何文本,尽管replace_rules表本来就在共享core-db里。 - 共享层(
core-domain,包名不变所以 Android 侧没有 import 变更):ReplaceRuleJsonCodec从app移入core-domain/src/commonMain(Legado 形状的导入/导出编解码), 对应测试移入core-domain/src/jvmTest;core-domain的 commonMain 因此显式加kotlinx-serialization-json。- 新文件
core/replace/ReplaceRuleRules.kt:normalizeReplaceRuleIds(缺失/重复 id 重发号,从已用最大值往上)、mergeImportedReplaceRules(同 id 覆盖、空规则跳过、返回导入条数)、filterReplaceRulesForBook(只留isValid()且作用于正文或标题、按order排序)、applyReplaceRules(逐条withContext(Dispatchers.Default)+withTimeoutOrNull(rule.timeoutMillisecond), 超时/抛异常的那条按name.ifBlank { pattern }记名并保留上一条的结果;编译不了的 pattern 与 Android 一样是静默 no-op)。 - Android 侧
ReplaceRuleRepository的filterRulesForBook/saveAll/importFromJson/旧 SharedPreferences 迁移 全部改调共享函数(normalizeIds私有实现删除,行为不变);NovelTextProcessor.processWithRules的循环也改为applyReplaceRules,只留 Android 独有的revisionOf(正文 + rule.id + rule.order 的 SHA-256,供缓存失效用)。
desktop-runtime新文件DesktopReplaceRules:直接读写共享replace_rules表(getReplaceRuleDao()), 提供observeAll/all/saveAll/save/setEnabled/setAllEnabled/delete/clear/importFromJson/exportToJson, 另有forBook(scopeName, origin)走共享过滤。Windows 没有 Android 那份“旧版 SharedPreferences 规则”可迁移 (新的 Windows 数据根从未有过那些键),所以不做迁移分支。desktop-app:- 新文件
DesktopReplaceRuleOperations.kt:DesktopReplaceRulesUi(rules, editorOpen, editing)是独立 flow (controller.replaceRules,理由同 §165:一件事情的临时状态不进DesktopAppState),rules == null表示“还没读”。导入/导出走Path(读整串解码、写调用方选好的文件),导入失败返回 0。 - 新文件
DesktopReplaceRulesPage.kt:标题行是返回 / 标题 / 导入 / 导出 / 添加,下面是 Android 的replace_rules_summary、全开/全关(和 Android 一样,没有可开/可关的目标时灰掉)、分隔线; 规则按group分组(多组或组名非空时加组标题,空组用replace_rule_group_default), 每行是SettingsActionRow(标题name.ifBlank { pattern },摘要是 Android 的模式 · 作用范围 · pattern → replacement),行尾开关只改启用状态、点行进编辑器;空列表用replace_rule_empty/replace_rule_empty_summary。编辑器对话框字段与 Android 一一对应 (名称/分组/pattern/replacement/范围/排除范围/顺序/超时/正则/启用/标题/正文),isValid() && (scopeTitle || scopeContent)才允许保存,非法时显示replace_rule_invalid; 预览区(replace_rule_preview*)在对话框里就对样例文本跑一次candidate.apply。 SAF 换成JFileChooser(FileNameExtensionFilter一条json),默认文件名仍是kototoro-replace-rules.json。 - 入口与导航:阅读设置页新增“小说”节(Android 的
novel),一行replace_rules(摘要replace_rules_summary) 进新DesktopScreen.REPLACE_RULES(DesktopApp页面when+ 标题when各加一支,导航继续落到“更多”), 返回回阅读设置。 - 应用规则:
DesktopNovelTextRules(新文件)把共享的超时/跳过语义搬到已解析的 block 上——一条规则一次遍历 整章文本 block(改写的段落换成单 run、未改动的保留原样式),标题规则一次遍历整章标题列表;readNovelOnIo用 Android 同款 scope 值(content.title+content.source.name)取forBook再按Scope.CONTENT/TITLE拆分,DesktopNovel.chapterTitles只存被规则改动过的章节标题 (titleOf(chapter)供章节面板与阅读器顶栏取值)。
- 新文件
- 验证:
:core-domain:jvmTest --tests *ReplaceRule*9/9(新ReplaceRuleRulesTest7 项 + 原ReplaceRuleJsonCodecTest2 项): id 重发号越过已用最大值、导入同 id 覆盖且跳过空白规则、书过滤只剩命中该书且有效的规则、 按顺序应用并跳过编译不了的 pattern、isEnabled=false与disabledIds都不参与、纯文本规则按字面替换。:desktop-runtime:test --tests *DesktopReplaceRulesTest*5/5(真实DesktopRuntime.open+@TempDir): 重启后规则仍在同一张表且按order返回、observeAll跟着每次写更新、导出是 Legado 形状并原样导回、 坏文档导入返回 0 且不清空已有规则、forBook只给出命中该书的两条。- 探针
replacerules(DesktopAppTest同名用例,DesktopReplaceRulesProbe):空态 → 添加一条正则规则并在 预览框看到替换结果 → 保存后行出现、isRegex/scopeContent与落库一致 → 开关写进表(重启后同值)→ 全开 → 导出含规则、导入后仍是 1 条 → 返回阅读设置。本机实测输出DESKTOP_UI_OK=replacerules。 :desktop-app:verifyDesktopStrings通过(11 个新键两个 catalog 都有);:app:compileDebugKotlin通过 (共享提取 +NovelTextProcessor改调共享函数后 Android 仍能编译)。
- 仍待:Windows 小说阅读器的“按书关闭规则/逐条禁用”(Android 的
NovelBookPreferences两个键, Windows 目前按全局启用状态应用);词典规则(Androiddictionary_rules,同一“小说”节里的另一行); 其余设置页对齐(Spaces、下载、备份周期、同步、追踪、服务、导航配置、通知、全景、内容目录、TTS 等)。
167. 共享词典规则(排序/重编号/导入合并)+ Windows 词典规则页(对齐 Android DictionaryRulesActivity)(2026-10-24)
- 问题:§166 的“仍待”里第二条。“阅读设置 → 小说”节的第二行
dictionary_rules(摘要dictionary_rules_summary) 对应 Android 的DictionaryRulesActivity:一页表格、纸质相同的五行编辑对话框,加导入/导出/添加。 Windows 之前完全没有入口,dictionary_rules表也就没人写,词典查询(AndroidDictionaryActivity)无从谈起。 - 共享提取(
core-domain,包名仍是org.skepsun.kototoro.core.dictionary,Android 侧 import 无需改动):git mv把DictionaryRuleJsonCodec(和它的测试)从app搬到core-domain,与 §166 的ReplaceRuleJsonCodec同一位置;JSON 形状、字段名与容错语义不变。- 新
DictionaryRuleRules.kt:sortDictionaryRules(先sortNumber后name,即 Android 比较器的顺序)、normalizeDictionaryRuleOrder(按当前顺序重编号 0..n-1)、DictionaryRulesImport、mergeImportedDictionaryRules(existing, imported)(按name覆盖同名、追加新名, 再把合并结果按导入的sortNumber排序后重编号,importedCount是导入文档里的条数;空文档返回原列表 + 0)。 - Android
DictionaryRuleRepository改调这些函数:observeAll/getAll/getEnabled过sortDictionaryRules,saveAll先重编号再落库,importFromJson走mergeImportedDictionaryRules后整表替换。 这样“导入时被同名规则顶掉的位置由导入的sortNumber决定”这条 Android 行为只有一份实现。
- 记住的两个 Android 语义(不修,照抄):
- 主键是
name,save也按name查找,所以在编辑器里改名字等于新增一条,旧行留在表里; - 表为空时任何读到它的路径都会重新铺随包默认规则,因此“删光所有规则”实际等于“回到随包的两条”。
- 主键是
desktop-runtime:新DesktopDictionaryRules.kt,与DesktopReplaceRules同形——observeAll(IO 上先ensureDefaults()再转发 DAO 的 flow)、all/enabled、saveAll(重编号 +deleteAll+upsert,返回落库列表)、save(同名替换否则追加)、setEnabled、delete、importFromJson(解码失败或空文档返回 0)、exportToJson、ensureDefaults(Mutex幂等,读 classpathdefaultData/dictRules.json铺底)。 Windows 不复制一份默认规则 JSON,而是让构建把 Android 的app/src/main/assets/defaultData/dictRules.json拷进本模块的生成资源目录(prepareDesktopDictionaryRules,sourceSets.main.resources.srcDir+processResources依赖),两端随包规则永远同一份文件;desktop-app通过desktop-runtime依赖拿到它。desktop-app:- 新
DesktopDictionaryRuleOperations.kt:DesktopDictionaryRulesUi(rules, editorOpen, editing)是独立 flow (controller.dictionaryRules,理由同 §165/§166),rules == null表示“还没读”,空列表才是真答案。 - 新
DesktopDictionaryRulesPage.kt:标题行是返回 / 标题 / 导入 / 导出 / 添加(Android 的 FAB 换成头部按钮, 同 §166 的替换规则页),下面是dictionary_rules_summary与分隔线;每行是SettingsActionRow(标题name、摘要urlRule,行内滚动省略),行尾开关只改启用状态、点行进编辑器、再右侧是删除; 空列表用dictionary_rules_empty。编辑器对话框字段与 Android 一一对应(名称 / URL 规则 / 展示规则 / 排序 / 启用), 展示规则用等宽字体,name与urlRule都非空才允许保存(Android 的confirmButton条件), 编辑现有规则时对话框里还有删除按钮(Android 的 dismiss 分支)。 SAF 换成JFileChooser(一条json过滤器),默认文件名仍是kototoro-dictionary-rules.json。 - 入口与导航:阅读设置页“小说”节在替换规则下方新增
dictionary_rules行,进新DesktopScreen.DICTIONARY_RULES(DesktopApp页面when+ 标题when各加一支,导航继续落到“更多”), 返回回阅读设置。 - 顺手修掉 §166 的一个文案缺陷:桌面的 catalog 用
java.util.String.format占位符(%s/%d),replaceRules.exported当时写成了{0},导出成功会原样显示大括号;改成%s, 并在两个规则探针里断言消息含文件名/条数且不含{,以后写错就会红。
- 新
- 验证:
:core-domain:jvmTest --tests *DictionaryRule*7/7(新DictionaryRuleRulesTest5 项: 先号后名排序;重编号覆盖位置;导入同名覆盖、新名追加、位置由导入的sortNumber决定且重编号 (断言[百度汉语, 本地词典, 海词英文],因为被顶掉的规则号是 5);导入保留原enabled(顺序[新, 关, 开]→[true, false, true]);空文档不改表、条数 0。加上搬过来的 codec 2 项)。:desktop-runtime:test --tests *DesktopDictionaryRulesTest*6/6(真实DesktopRuntime.open+@TempDir: 新数据根就有随包的两条(sortNumber0/1、urlRule非空);重启后顺序/启用状态不变、enabled()只给开的;observeAll跟着写更新且开关不动其它规则;同名保存原地替换、新名追加;导出含规则且导回幂等、坏文档返回 0 且不清表; 删光后随包规则重新回来(与 Android 一致)。- 探针
dictionaryrules(DesktopAppTest同名用例,DesktopDictionaryRulesProbe):阅读设置 → 词典规则页 → 断言两条随包规则都在、空态不显示 → 添加(保存按钮先灰)一条带展示规则的本地词典 → 开关写库且enabled()不再给它 → 改名字(照 Android 变成 4 行)→ 导出含新名字、导入报 4 条且不重复 → 删除恢复 3 行 → 返回阅读设置。本机实测输出DESKTOP_DICTIONARY_STATE=seeded:百度汉语|海词英文、DESKTOP_DICTIONARY_IMPORT=message:已导入 4 条词典规则 error:null、DESKTOP_UI_OK=dictionaryrules。 :desktop-app:verifyDesktopStrings通过(8 个新键两个 catalog 都有);:desktop-app:compileKotlin通过;:app:compileDebugKotlin与:core-domain:jvmTest全绿(共享提取后 Android 仍能编译、原有测试不受影响)。
- 仍待:词典查询本身(Android
DictionaryActivity+DictionaryLookupService,urlRule的展开与showRule的 jsoup 选择器执行)还没移植,Windows 目前只能维护规则表;小说阅读器的按书规则/逐条禁用 (AndroidNovelBookPreferences);其余设置页对齐(下载、备份周期、同步、追踪、服务、导航配置、通知、全景、 内容目录、TTS 等)。
168. 共享下载限速/重试规则 + Windows 下载设置页(对齐 Android DownloadsSettingsScreen)(2026-10-24)
- 问题:§167 的“仍待”里第一条的“下载”页。Windows 下载器当初按“串行后台执行”实现:一章一页一页抓, 没有页级重试,也没有并发与间隔的概念,Android 那四组行(线程 / 同时下载作品数 / 请求间隔 / 重试次数与间隔) 在桌面既没有页面也没有消费者。线程与任务这块要“优化”,先把规则提成共享的一份,再让 Windows 引擎真的读它。
- 共享提取(
core-domain,包名org.skepsun.kototoro.core.download):- 新
DownloadPolicy:线程 1..10、同时作品 1..11(11 = 不限)、请求间隔 0..5000 ms、重试 1..10 次、 重试间隔 0.5..10 s、Retry-After上限 2 h 等默认/边界常量,加十个只做 clamp 与判断的小函数 (threads、maxActiveSeries、requestDelayMs、retryCount、两个retryDelayMs重载、sourceDelayMs、activeSeriesLimit、attempts、isRetryDelayExcessive)。 - Android 的
AppSettings把这五组 getter/setter 与 companion 默认值改为调用共享 policy;download/ui/worker/DownloadPolicy.kt变成薄适配器(保留HLS_SEGMENT_CONCURRENCY = 3、MAX_WORK_RETRIES、shouldRetry = error is IOException)。runFailsafe的语义原样保留:attempts(retryCount) = retryCount.coerceAtLeast(1),即“总尝试次数等于用户填的数”,不是“重试次数 + 1”。
- 新
desktop-runtime:新DesktopDownloadSettings(五个字段 +activeSeriesLimit/isSeriesUnlimited/toPreferences())与DesktopDownloadPreferences(settings: StateFlow、current()、save()、reload())。 键与 Android 同名(downloads_threads/downloads_max_active_series/downloads_request_delay/downloads_retry_count/downloads_retry_delay),from()全部过共享 clamp,所以手改 store 也写不出 11 线程或 20 分钟重试间隔。store 必须是desktop_download_settings:desktop_downloads已经被FileSourcePreferenceStore用去存章节清单,同名会让清单加载器把设置项当 manifest 解码。desktop-app引擎:- 新
DesktopDownloadConcurrency.kt:DesktopDownloadSlots(每章占一个“作品位”,maxActiveSeries是上限, 11 表示不限;被取消的等待者不吞名额)与DesktopRequestPacer(按作品 key 记nextAllowed,间隔 0 直接放行)。 DesktopDownloads增加settings: () -> DesktopDownloadSettings供应商(Controller 传session.downloadPreferences.current()),开章与每页请求前都重读一次,改设置不用重启;页池是minOf(threads, pages)个 worker 从一个AtomicInteger取下标,成功一页就在 mutex 里写进清单并落盘, 失败或暂停仍保存已落地的页,续传只补验证不过的那些。- 顺带修掉两个既有缺陷:失败章节的状态发布的是
fetchPages之前的本地副本(页面全空),界面上的 “已完成页数”永远是 0,现在读 store 里已经落盘的那份清单;重试谓词本来只有IOException, 而源层把扩展抛的一切异常都包成SourceRemoteException(RUNTIME_FAILURE),页级重试在 Windows 上从不触发, 现在 RUNTIME_FAILURE 也算可重试,CHALLENGE_REQUIRED/INVALID_ARGUMENT/UNSUPPORTED_OPERATION/SOURCE_UNAVAILABLE不重试(要人等的不该空转)。
- 新
- Windows 的默认值取“与 Android 相同的线程/并发/重试(4 / 4 / 两次 × 2 s),请求间隔默认 0”: Android 的 1600 ms 只对声明
isSlowdownEnabled()的源生效(DownloadSlowdownDispatcher), 共享源契约没有这个标志,默认开就会把每个 Windows 下载都拖慢,这条写进了页面说明与 KDoc。 - 页面:更多 → 下载设置(
download-settings-open,SettingsValueRow+ic_download.svg)进DesktopScreen.DOWNLOAD_SETTINGS,标题取 Androiddownloads。一页一个SettingsSectionHeader(download_speed_and_retries)+ 一个SettingsGroup的五个SettingsSliderRow(1..10 线程;1..11 同时作品数, 11 显示 Android 的download_max_active_series_uncapped;0..5000 请求间隔;1..10 重试次数; 500..10000 重试间隔,单位 ms),与播放设置页同规矩:拖动只改本地 draft,松手才saveDownloadSettings, 写盘失败在状态栏报error.downloadSettingsInMemory。下方SettingsInfoRow(downloadsSettings.parity) 说明哪些 Android 行没搬。行文案全部取 Android 资源(download_threads/download_max_active_series/download_request_delay/download_retry_count/download_retry_delay与它们的_summary),桌面只新增 5 个键。 - 验证:
:core-domain:jvmTest --tests *DownloadPolicyTest*5/5(clamp 边界、11 = 不限、attempts至少 1、Retry-After上限、间隔 0)。:desktop-runtime:test --tests *DesktopDownloadSettingsTest*4/4(空 store 默认值;五个键在desktop_download_settings下往返;手改越界值被夹回;类型不对退回默认)。:desktop-app:test的八个下载相关套件全绿,新增 8 项:三页threads = 3时并发峰值恰好 3 且全部落地;IOException页重试一次后完成;SourceRemoteException(RUNTIME_FAILURE)同样重试;CHALLENGE_REQUIRED只试一次就报错;失败章节的状态带着已落地的 2 页;超过作品上限的章停在 QUEUED;DesktopDownloadSlots/DesktopRequestPacer的四项(名额、取消不吞名额、同作品间隔 ≥ 80 ms 而另一作品立即、 间隔 0 关闭)。原有队列测试统一钉上serialQueueSettings()(串行 + 无间隔 + 单次尝试),因为它们要数请求与页数。- 探针:新
downloadsettings(独立用例)从更多进下载设置,断言默认 4/4/0/2/2000、五个滑杆与说明行都在, 拖到 7 线程 / 10 次重试 / 11(不限)作品 / 800 ms 间隔 / 1500 ms 重试间隔后断言 store 里五个键都写了、controller.downloadSettings.value与读回一致、不限作品数显示 Android 的“无限制”文案,再返回更多。downloads-write/downloads-read把设置钉成threads = 1, maxActiveSeries = 1, requestDelayMs = 0, retryCount = 2, retryDelayMs = 500:作者故障页被试两次,图片请求数从 7 变 8(7 是没有页级重试时的数)。 :desktop-app:verifyDesktopStrings通过(5 个新键两个 catalog 都有);:desktop-app:compileKotlin通过;:app:compileDebugKotlin全绿(AppSettings与 AndroidDownloadPolicy改写后仍能编译,0 error)。
- 仍待:下载设置页还没搬的 Android 行(内容目录 / 下载格式 / 视频画质 / 小说插图 / 仅蜂窝网络 / 电池优化 与
downloads_settings_info);isSlowdownEnabled的等价物(源级限速开关,现在只有全局间隔); HLS 段并发仍固定 3(云端视频下载没进桌面);元数据驻留控制与预测预取也仍未做。
169. 主导航配置规则下沉与 Windows 导航配置页(2026-10-24)
Android 的 NavConfigSettings(界面标题取 main_screen_sections)允许用户改底部导航的入口顺序、增删入口, 并守着“最多五个、不重复、删空回首页”这几条规矩。这套规矩原本只写在 app 的 AppSettings 与 NavConfigViewModel 里,Windows 侧栏是一份写死的六项列表。本轮把规则提到 core-domain 由两端共用, 并给 Windows 补上同一页,让侧栏真正可配置。
core-domain新增core/prefs/MainNavigationRules.kt(object,与 Android 同包名,AppSettings直接消费, 与ProgressIndicatorMode的做法一致):MAX_ITEMS = 5;limit(去重 + 截断);move(from, to)(等价 remove-then-insert,越界或原地返回原实例);step(item, ±1);add(到上限或已存在时不改);remove(item, fallback)(删空退回 fallback);available/canAdd;parse(raw, resolve, fallback, excluded)(空白、未知、被排除的键一律回落 fallback,否则按存储顺序去重截断);format(默认取name,逗号连接)。 在无操作时返回原实例是刻意的:调用方用===判“什么都没变”,跳过写盘,也跳过 Android 那 500 ms 的防抖重建。app:NavItem.MAX_MAIN_NAV_ITEM_COUNT改为引用MainNavigationRules.MAX_ITEMS,limitMainNavigationItems()变成一行委托(底部栏、电视侧栏与既有MainNavigationLimitTest仍走它,语义不变);AppSettings.mainNavItems的读写改用parse/format/limit,DOWNLOADS → LOCAL与三项集的一次性迁移 原样保留,DISCOVER仍在排除集里;NavConfigViewModel的reorder/moveItem/addItem/removeItem与availableItems/canAddAction/添加行都改调共享规则,排除集收敛成一处excludedNavItems = listOf(NavItem.DISCOVER)。desktop-runtime新增DesktopNavigationSettings.kt:DesktopNavItem(HOME/FAVOURITES/BROWSE/HISTORY/SUBSCRIPTIONS)是 Windows 自己的词表——Android 枚举带R.id/R.drawable句柄,不能直接复用;defaultDesktopNavItems就是侧栏原本的顺序。DesktopNavigationSettings持有items,canAdd/available/step/move/add/remove全部委托共享规则, 删空仍回 HOME。DesktopNavigationPreferences写自己的 storedesktop_nav,键仍是 Android 的nav_main, 值是逗号连接的枚举名,所以手改文档也要过parse的去重/截断/未知词过滤。- “更多”故意不在这份文档里:它是通往其他所有页面的唯一出口,侧栏渲染时永远追加,用户删不掉也排不到别处。
desktop-app:DesktopSession.navigationPreferences;DesktopController增加navSettings、showNavigationConfig()、saveNavigationSettings()与DesktopScreen.NAV_CONFIG(写盘失败在状态栏报error.navSettingsInMemory);DesktopApp补页面与标题两臂,标题取 Androidmain_screen_sections。DesktopNavigationRail改为按配置生成目的地,并固定追加 id 3 的“更多”。id 仍是 4/0/1/2/5,desktopNavItemId/desktopNavItemLabel一处定义,选中态、图标、订阅角标与nav:<标签>用例标签都跟着 id 走, 所以既有探针一行没改;配置里没有当前页面的目的地时,选中态落到“更多”。- 新页
DesktopNavigationConfigPage:返回按钮 + 标题,每个入口一行(SettingsActionRow+ ↑/↓/✕ 三个TextButton,摘要写“第 n 个,共 m 个”,首/末行的越界按钮禁用),未满上限时多一行“添加” (DesktopChoiceSettingsDialog单选未显示的目的地,选中即追加),下方两条说明分别是“更多永远最后” 与 Android 的五个上限。入口是更多页功能节的nav-config-open(复用ic_settings.svg)。
- 验证:
:core-domain:jvmTest --tests *MainNavigationRulesTest*7/7(去重截断、双向 move 与越界、两端 step、 上限/重复的 add、删空回落、带排除的 available、parse 的保序/未知丢弃/排除回落/空串回落/format 往返)。:desktop-runtime:test --tests *DesktopNavigationSettingsTest*6/6(空 store 默认顺序;nav_main单键往返; 手改串去重截断并忽略大小写与未知项;空白或类型不对回落默认;增删移各规则;删空回首页)。- 新探针
navconfig(独立用例):默认五个入口且没有“添加”行 → 删 HOME(行、存储、旁边侧栏的标签同时消失, “更多”仍在)→ 添加 HOME 落到末尾 → 上移一次、再把 FAVOURITES 下移 → 断言desktop_nav的nav_main就是配置顺序 → 返回更多后侧栏仍是这一份顺序。 - 回归:
:desktop-app:test --tests *tablet* --tests *storage* --tests *playback*全绿(侧栏改数据驱动后nav:标签仍稳定);:app:compileDebugKotlin与:desktop-app:verifyDesktopStrings通过 (新增 8 个桌面键,两份 catalog 都有)。
- 仍待:Windows 侧栏没有 Android 的拖拽排序(用按钮排序即可,不做 drag handle);Android 的“标签常显/浮动导航栏” 这类外观开关与
pref_nav_*没搬;平板/电视变体的导航还没共用这份配置;NavItem.DISCOVER仍被排除, Windows 也没有对应入口。
170. 合并 origin/devel(追踪章节元数据与更新生命周期)与冲突收口(2026-10-24)
远端 11 个提交(a9a88126d)把“发现更新”和“读掉更新”拆开:track_logs 增加 chapter_metadata (Room v86,Migration85To86),UpdateLifecycle.kt 新增 recordUpdateBatch/ refreshPendingUpdateCount/completeUpdateChapter/completePendingUpdates——订阅计数只在新章节读到末页时清, 打开作品只算“已提醒”;时间线改按发现时间排序(FeedDeriver.FEED_ORDER 去掉置顶优先)。 本轮把本地 devel(Windows 侧 100+ 提交)与它合并,冲突按“两边意图都保留”收口:
core-domain/build.gradle:保留 HEAD 那行 replace-rule 说明注释,远端只加了同名依赖。core-library/SourceLibrary.recordPage:同一个立即事务里既跑我们的阅读统计 tick (readingSessions.onPage),也跑远端的章节完成(lastVisiblePage == pageCount - 1且是分支末章时completeUpdateChapter),事务外仍是stats?.let { writeReadingSession(it) };第一版把 lambda 写成Unit,报actual type is 'Unit', but 'ReadingSessionWrite' was expected,改成先取val write再返回。desktop-app/DesktopHomePanel:更新轮播保持lastChapterDate ?: lastCheckTime排序(与共享UpdatesDeriver.UPDATES_ORDER一致),行内按远端新字段newChapters投影。- 两份
desktop-strings*.properties:并集——我们的设置/导航键 + 远端feed.readCompleted、feed.pendingChapters、feed.updatesAvailable、feed.sourceTime*、feed.chapterDetails, 单空行分隔、无重复、无 CR(977/973 行)。 SharedListDerivationTest:远端取消“置顶优先”,分类 join 后再截断的期望改成[1L]并写明原因。DesktopTabletProbe:打开更新不再消计数,改为断言feed.updateRowsByOwnerId仍有该作品、home-update:*与nav-feed-badge仍在(读到末页才算完成);快捷入口改用performScrollToNode经懒列表滚入,不再依赖惰性组合的偶然位置。MainNavigationRules.step:Android 拖拽句柄传的是原始偏移(NavConfigViewModelTest用 -10 断言落到表头), 越界一步改为钳到端点而不是不动;move的越界语义不变,另加一条钳位用例。
顺带修掉两条与本合并无关、却让整套测试长期变红的既有失败:
ChapterItemCard(共享):列表行的点击区在内层(尾部操作按钮要留在焦点树外),enabled = false时 卡片连“禁用”状态一起消失;现在列表行与网格卡各自声明disabled(),TalkBack 与用例都能看到。SyncedExtensionFileTest:两条用例故意在旧 JAR/APK 仍被打开时替换文件,这正是 Android 的 rename 语义; Windows 的 JVM 无法 move 到被其他句柄打开的文件上(AccessDeniedException),按平台跳过,而不是削弱断言。
验证::core-domain:compileCommonMainKotlinMetadata、:core-domain:jvmTest(223)、:desktop-runtime:test、 :core-db:jvmTest(12)、:app:compileDebugKotlin、:desktop-app:verifyDesktopStrings、 :app:testDebugUnitTest(3149)、:desktop-app:test(*tablet*、*reorders*、*SharedChapterDirectoryTest* 全绿;DesktopAppTest > Android styled light and dark themes … 仍是既有失败)。
仍待:Windows 没有 Android TrackingRepository 那套显式“全部标为已读/清除更新”入口, completePendingUpdates 目前只被 Android 调用,所以 Windows 只能靠读掉新章节清计数——下一轮补这个入口; DesktopFeedPanel 的待读行仍按置顶 + lastCheckTime 排,与共享 FeedDeriver 的发现时间排序不一致,需对齐。
171. Windows 订阅页菜单(显示更新区 + 清空订阅)对齐 Android opt_feed(2026-10-24)
Android 的订阅页把三项动作放在 R.menu.opt_feed 里:update(检查更新)、show_updated (隐藏/显示顶部“已追踪作品有新章节”区块,键 feed_header)、clear_feed(清空更新记录,弹窗再问要不要 连同新章节计数一起清)。Windows 之前只有第一项——检查更新是个可见按钮,另外两项没有入口,所以计数只能靠 把新章节读到末页才清掉(这正是 §170 补的 completePendingUpdates 在 Windows 无人调用的原因)。
- 新
desktop-runtime/.../DesktopFeedDisplay.kt:DesktopFeedDisplay(showUpdated)读写 Android 同名的feed_header(Toggle,默认开),存在desktop_library(与列表布局同一个 store,都是订阅/书架的显示项);DesktopFeedDisplayTest覆盖默认值、false/true 往返与类型不符时回退默认。 DesktopController:DesktopAppState.feedDisplay在初始化时从同一个 store 读出;setShowFeedUpdated(show)只改状态与feed_header(写盘失败用新的feed.displaySaveFailed提示,不假装保存成功);clearFeed(clearPendingCounters)复用 Android 的语义——getTrackLogsDao().clear()清时间线, 勾选时再对每个tracks行跑共享的completePendingUpdates(mangaId, now)(clearCounter+ 待读标记), 最后重载订阅快照并提示feed.cleared。DesktopFeedPanel:顶栏在“检查更新”右侧加溢出菜单(feed-menu,ic_more_vert), 两项分别是feed-menu-show-updated(开启时前置ic_check,文案取 Android 的show_updated)与feed-menu-clear(clear_feed);清空走确认弹窗feed-clear-dialog——标题/说明/复选框文案全部是 Android 的clear_updates_feed、text_clear_updates_feed_prompt、clear_new_chapters_counters(默认勾选,与 Android 一致), 取消按钮feed-clear-cancel、确认feed-clear-confirm用桌面 catalog 的common.cancel/common.clear。 新章节轮播额外受feedDisplay.showUpdated约束,时间线行与侧栏nav-feed-badge不受影响(与 Android 隐藏的只是 顶部区块一致)。两份desktop-strings*.properties各加feed.cleared、feed.displaySaveFailed。
验证::desktop-runtime:test(158,含新增 3 条)、:desktop-app:verifyDesktopStrings、 :desktop-app:compileKotlin、:desktop-app:test --tests *tablet* 全绿。tablet 探针在同一个已追踪作品上走完整条链路: 菜单开着看到两项 → 关掉 show_updated 后 feed-updated:* 消失而 feed-row:* 与 nav-feed-badge 仍在、store 里 feed_header 变 Toggle(false) → 再打开恢复 → 清空弹窗取消后日志还在 → 确认后 track_logs 归零、订阅行/轮播/角标 全消失、显示空态与 feed.cleared,且该作品的 chapters_new 被 completePendingUpdates 归零 (截图 tablet-feed-clear-dialog.png、tablet-feed-cleared.png)。
仍待:Android 菜单的第一项(update)在 Windows 已经是常驻按钮,不再重复放进溢出菜单; DesktopFeedPanel 的待读行排序仍与共享 FeedDeriver 的发现时间不一致(§170 遗留)。
172. Windows 数据清理页对齐 Android 存储与网络 → 数据清理(2026-10-24)
Android 的 StorageAndNetworkSettingsScreen 在“存储用量”和“网络”之间还有两组:缓存限制与数据清理 (SettingsDestination.DataCleanupSettings、标题 data_removal)。Windows 的存储与网络枢纽只有前者的用量 与后者的代理/镜像,数据清理完全没有入口,而它列的正是这台机器上确实存在的几处可回收数据: 图片缓存(封面与阅读页面共用同一个 images 目录)、阅读页面索引(256 条记录)、订阅更新记录。 Android 的 DataCleanupSettingsScreen 有本地存储、缓存、隐私、章节四组二十来行,其中大部分(本地漫画/小说/视频、 HTTP 缓存、搜索历史、Cookie/浏览器数据、整个数据库)在 Windows 没有对应实现,所以这一页只列真能执行的项, 而不是摆一排点了什么都不会发生的按钮;差异写进 DesktopDataCleanupPage 的文件注释里。
DesktopReaderImages.clearIndex()/size():Android 的“清除页面缓存”落在阅读器的索引上, 所以这里只删desktop_reader_images的记录、返回删掉的条数,blob 一个不碰——孤立 blob 只占磁盘, 图片预算本来就按“未被已保存章节引用”回收,直接删目录会连离线阅读一起毁掉。DesktopSession暴露clearReaderIndex()/readerIndexCount(),DesktopReaderIndexTest覆盖“删两条、返回 2、再删返回 0”。DesktopController:新DesktopScreen.DATA_CLEANUP与showDataCleanup()(进页重读图片报告、索引条数与订阅快照, 因为三个数字都可能已经变了)、backToStorage()复用为返回、clearReaderPageCache()走action(失败cleanup.pagesClearFailed,成功cleanup.pagesCleared并刷新计数);DesktopAppState.readerIndexCount。- 新
DesktopDataCleanupPage:返回按钮data-cleanup-back;“缓存”节两行对齐 Android 的clear_thumbs_cache(摘要desktopCacheSize即图片目录占用,动作复用下载页的clearImageCache(),保留已保存章节引用的 blob) 与clear_pages_cache(摘要cleanup.pageIndexRecords,动作clearReaderPageCache()); “隐私”节一行是clear_updates_feed(摘要cleanup.items即订阅时间线条数),点击弹出订阅页同一份FeedClearDialog(§171,改为internal复用),标题/说明/复选框/默认勾选与 Android 一致。 - 存储与网络枢纽在“网络”之前加 Android 同名的
data_removal节与cleanup-open行(ic_delete_all, 已加进desktop-app/build.gradle的desktopIcons);DesktopApp的分发与标题映射各加一项。 - 两份
desktop-strings*.properties各加cleanup.pageIndexRecords、cleanup.items、cleanup.pagesCleared、cleanup.pagesClearFailed;其余文案直接用 Android 的data_removal/cache/privacy/clear_thumbs_cache/clear_pages_cache/clear_updates_feed。
验证::desktop-app:verifyDesktopStrings、:desktop-app:compileKotlin、:desktop-app:compileTestKotlin、 :desktop-app:test --tests *DesktopReaderIndexTest*(1 条)、:desktop-app:test --tests *storage*hub* 全绿; storage 探针在真实数据根上写一个 2 KiB 的 probe-cleaned.img 与一条索引记录,进枢纽 → 数据清理: “清除缩略图缓存”删掉那个 blob 且 imageCache.usedBytes 归零,“清除页面缓存”把索引清零并给出 cleanup.pagesCleared,“清除订阅更新记录”弹出订阅页那个确认框、取消后关闭,最后返回枢纽 (截图 storage-data-cleanup.png、storage-data-cleanup-feed-dialog.png)。
仍待:Android 的数据清理页其余分组仍无 Windows 实现——“删除已读章节的本地下载”需要下载 store 侧的 “按进度判已读再删章节”规则(批量移除现在在下载页),“清除数据库”(重置整个库)与 Cookie/浏览器数据 (compat 的 WebView2 profile)都需要各自的确认与代价说明,搜索历史在 Windows 尚无存储; 缓存限制页(Android 的 CacheLimitsSettings)也还只有下载页里的图片预算一处。
173. Windows 推荐设置的来源选择器对齐 Android 的 SuggestionSourcesDialog(2026-10-24)
§136 把推荐设置从“更多”页拆成独立页时,两个来源偏好(优先来源/排除来源)用的是共享的多选对话框:一个把所有 已装来源平铺成复选列表、点一下立刻写盘的弹窗。Android 的同一个偏好不是这样——SuggestionsSettingsScreen 里 的 SuggestionSourcesPreference 摘要只显示前三个名字加 +N(空时显示解释文案),点开的是 SuggestionSourcesDialog:搜索框、内容类型与来源类型两组 FilterChip(首片“全部内容/全部源”= 清空该组过滤)、 复选列表,选择是 pending,底部“清空/取消/确定”三键,只有确定才落盘。来源一多,平铺列表既找不着也改不动, 所以按 Android 重做。
- 纯逻辑放在新文件
DesktopSuggestionSources.kt,不依赖 Compose:suggestionSourceOptions()(把SourceListing投影成 id/标题/内容类型/生态,保持宿主顺序)、filterSuggestionSourceOptions()(内容类型与 生态两组“空集=全部”的与运算 + 标题/id 的大小写不敏感查询,等同 Android 的filterSuggestionSourceOptions)、suggestionSourceSummary()(前三个名字 ++N或空提示)、availableContentTypes()(按 AndroidContentType的序号排,未知类型落到末尾)与availableEcosystems()。DesktopSuggestionSourcesTest六条覆盖空查询/两facet/交叉过滤/芯片顺序/摘要规则/身份投影。 DesktopSuggestionSourcesDialog:OutlinedTextField查询(-query)、两条FilterChip行 (-content:/-content-all、-ecosystem:/-ecosystem-all,标题与“全部”文案用 Android 的content_types/source_types/all_content/all_sources,search作占位),LazyColumn高 380dp 的复选行 (沿用settings-suggestions-prefer:/-exclude:标签),底部-clear/-cancel/-confirm(新增common.ok,中文“确定”)。选择在确定前只在本地,所以“清空”与“取消”都能表达,这是写盘式弹窗做不到的。DesktopRecommendations:两行改成summary = suggestionSourceSummary(..., AndroidStrings[...])、value = null(Android 把名字放摘要而不是右侧值),确定时仍保持 Android 的“两个集合互斥”规则 (选进优先就从排除里去掉,反之亦然);settings.suggestionsSourcesDetail因此无人使用,从两份 catalog 删除。- tablet 探针:打开选择器截图,输入不存在的查询确认行被过滤、清空查询后点击该行确认“仍未写盘”, 取消后集合仍空且弹窗关闭;再开一次点中并确定,断言状态与
desktop_background的suggestions_preferred_sources都只含该来源;最后打开点“清空”再确定,断言集合清空。
验证::desktop-app:verifyDesktopStrings(918 键,中文全覆盖)、:desktop-app:compileKotlin、 :desktop-app:test --tests *DesktopSuggestionSourcesTest*(6 条)、:desktop-app:test --tests *tablet* 全绿; 截图 tablet-recommendations-source-picker.png(搜索框、内容类型“全部内容/漫画”、内容源类型“全部源/Mihon”、 复选行)与 tablet-recommendations.png(两行摘要显示 Android 的空提示文案)已逐张看过。
仍待:Android 推荐设置还有三个 Windows 没有的开关——仅 WiFi(桌面没有移动网络)、包含禁用来源(Windows 来源栏 尚无“禁用来源”概念)、通知(Windows 未接通知),它们不摆空按钮,等对应能力落地;SuggestionsSettingsScreen 里“排除题材/优先题材”Android 是内联 SettingsTextInputPreference,Windows 仍是文本框对话框。
174. Windows 扩展与仓库的来源开关对齐 Android 的来源启用/禁用(2026-10-24)
Android 的“源”页(UnifiedSourcesScreen)里每个来源带一个开关(SourceEntity.enabled):关掉的来源仍然装着、它的收藏进度与 下载都还在,只是不再出现在浏览/搜索/推荐/换源候选里。Windows 之前只有“装/卸”关系,想暂时隐藏一个来源只能卸载整个扩展 (一个扩展常常带好几个来源,代价太大)。这一节把开关补上,并把“已装来源”与“可用来源”在状态层分开。
- 存储:开关放进扩展记录同一个 store(
desktop_extensions)的disabled_sources(TextSet,存来源名,与 Android 用名字 而不是数字 id 的口径一致,重装后仍然对得上)。同一 store 里还躺着 JAR 记录(Text),所以restoreJars()改成as? SourcePreferenceValue.Text ?: continue——离线重启探针原本就撞上这个坑:启动恢复对着TextSet强转,抛异常并记一条 “扩展恢复失败”。uninstall()之后pruneDisabledSources()把已不存在的名字清掉;其余残留名无害(过滤时只是不匹配)。 - Session/状态:
DesktopSession.disabledSources()/setSourceEnabled(name, enabled);DesktopAppState新增disabledSources与派生的enabledSources(对应 Android 的getEnabledSources())。来源栏、宽屏来源网格、来源筛选器、 搜索建议、换源候选与迁移都用enabledSources;而收藏/历史/订阅/更新的筛选器、主页的生态映射与标题查找继续用state.sources——禁用来源里存着的作品仍要能被筛到、被认出来,这与 Android 只是“不给新内容”一致。 - Controller:
setSourceEnabled走串行 action,写盘成功后刷新sources/disabledSources;如果当时正在浏览被关掉的那个来源, 就清空selectedSource/descriptor/items并回到扩展页,避免停在一个已不可用的来源上。卸载后同样刷新。 - UI:
更多 → 扩展与仓库在“仓库”与“JSON 源”之间多一行“来源”(右侧值“已启用 m/n”,source-switches-open),点开是SourceSwitchDialog:标题用 Android 的计数(来源:已启用 m/n)、搜索框(source-list-query)、高 420dp 的开关列表 (source-enable-list,行source-enabled:<名字>,摘要“内容类型 · 生态 · 语言”)、空态“没有匹配的来源”、底部“确定”。 之所以做成对话框而不是页内平铺:真实安装有几十个来源,页内一行行铺开会把下面的目录/已安装两块顶出窗口气泡 (第一次实现就是这样,探针的安装点击直接被挤出可视区而失败);对话框用自己的滚动,页面高度预算不变。 - 探针与测试:
DesktopSourceSwitchTest四条(空集返回原列表同一实例、关掉后离开enabledSources但留在sources且恢复原位置、 全关仍保留已装列表、未知名字被忽略);repositories-write增加:搜索不到时的空态、关掉开关后状态与 store 的disabled_sources、列表里的Off、再打开后清空、浏览页不再出现该来源(source:MIHON_…缺失 + 宽屏空态文案);repositories-read断言离线重启后开关仍是关的、来源栏仍不给它、卸载后集合清空。 - 截图:
repositories-write-sources.png(对话框:标题计数、搜索框、“initial / 漫画 · Mihon · zh”开关行、确定)、repositories-write-source-off.png(0/1 且开关关闭)、json-sources-json-sources.png(页面里新的一行“来源 / 关掉来源不必卸载… / 已启用 1/1 →”夹在仓库与 JSON 源之间)已逐张看过。
验证::desktop-app:verifyDesktopStrings(924 键,中文全覆盖、无未使用键)、:desktop-app:compileTestKotlin、 :desktop-app:test --tests *SourceSwitch*(4 条)、--tests *SuggestionSources*(6 条)、 --tests *repository*(write+read 双进程全绿)、--tests *CMS*(JSON 源页面探针,页面新行后的布局不受影响)全绿。
仍待:Android 的 KEY_SOURCES_ENABLED_ALL 总开关与扩展自带的 lock 来源;来源列表还没有启用/禁用筛选与批量开关; 推荐设置里“包含禁用来源”仍无处可指(现在是硬性排除);Android 同一页的“远程来源 JAR 优先级/显示损坏来源/扩展安装行为” 与“镜像切换/处理链接”分组尚未在 Windows 出现。
175. 来源开关对话框的启用筛选与批量启停(对齐 Android UnifiedEnabledFilter / 全选批处理)(2026-10-24)
§174 只做了逐个开关。Android 的源页还有两件配套:UnifiedEnabledFilter 的“全部/已启用/已禁用”筛选芯片,以及 “把当前筛选结果一次性打开或关掉”(confirmSetFilteredSourcesEnabled,先确认、再对筛选结果批量写)。装上几十个扩展 之后,逐个点是点不完的,所以这一节把两者补上。
- 纯逻辑在新文件
DesktopSourceSwitch.kt:DesktopSourceEnabledFilter(ALL/ENABLED/DISABLED)与filterSourceSwitchEntries(sources, query, filter, disabled)——查询(标题或来源名、大小写不敏感、先去空白)与 筛选按“与”组合,顺序保持宿主自己的顺序;两者都是默认值时直接返回原列表实例,省掉一次重建。DesktopSourceSwitchTest增三条:默认直返原实例、筛选只列对应的那一侧、查询与筛选相乘(含大小写与前后空白)。 DesktopSession.setSourcesEnabled(names, enabled)与DesktopController.setSourcesEnabled(names, enabled): Android 的批量原语。整批一次写盘(不会中途只剩一半),一次状态刷新;单条开关改成委托给它,所以 “正在浏览的来源被关掉就退出浏览”那条规则对批量同样成立(selectedSource落在disabledSources里就清空)。- 对话框:搜索框下面是三个
FilterChip(source-enabled-filter:ALL/ENABLED/DISABLED,文案取 Android 的all/enabled/disabled),列表下面是 Android 工具栏那一对“全部启用/全部禁用” (source-switches-enable-all/-disable-all,文案取unified_sources_enable_all/_disable_all,与芯片的 启用/禁用区分开);任一侧没有可改的行时按钮置灰。点按钮先弹 Android 的确认框 (source-switch-batch-confirm,标题unified_sources_disable_all、正文禁用当前筛选结果中的 %1$d 个源?、确认键disable/enable、取消键common.cancel),确认后对当前可见行 (entries,即搜索+筛选的结果)批量写。 - 一处有意偏离:批量写完后把筛选切到结果所在的一侧(禁用→“已禁用”、启用→“已启用”)。Android 是让行直接从当前筛选 里消失,Windows 的对话框那样看起来像“点了确认之后自己清空了”,跟着结果走更像操作生效。
- 探针(
repositories-write):清空查询后点“已禁用”看到空态、点“已启用”看到那一行;全部禁用→ 确认框正文 “禁用当前筛选结果中的 1 个源?” → 确认后disabledSources与开关同步(筛选已切到“已禁用”,行仍在且是关的); 再全部启用→ 确认 →disabledSources清空、行回到开;最后切回“全部”继续原来那条单开关路径。 截图repositories-write-filter-enabled.png(芯片+列表+全部启用/全部禁用)与repositories-write-batch-confirm.png(“全部禁用 / 禁用当前筛选结果中的 1 个源?/ 取消 / 禁用”)已看过。
验证::desktop-app:verifyDesktopStrings(924 键不变,因为新文案全部来自 Android 的 android-strings 目录)、 :desktop-app:test --tests *SourceSwitch*(7 条)、--tests *SuggestionSources*(6 条)、 --tests *repository*(write+read 双进程全绿)、--tests *CMS*(JSON 源页面)全绿。
仍待:Android 选择栏的其余动作(选中若干来源后删除/切换 NSFW)在 Windows 还没有对应能力——选中态本身、批量 启用/禁用与“测试选中来源”已由 §192 补上;来源总览的 “全部启用/禁用”是筛选+批量,Windows 目前只在开关对话框里提供同一能力。
176. 远程来源 · 插件去重优先级(对齐 Android jar_priority_order,规则下沉 core-domain)(2026-10-24)
§174–§175 把“来源开关”补齐之后,Android 来源设置里 remote_sources 分组还剩三项:JAR 优先级、显示损坏来源、 扩展安装行为。这一节做第一项,同时把它背后的纯规则从 app 提到共享模块。
- 共享提取:
app的core/extensions/JarSourcePriority.kt整文件git mv到core-domain/src/commonMain/.../core/extensions/(包名不变、去掉internal),内容是 Android 的单文件 JAR 去重规则——DEFAULT_JAR_PRIORITY_ORDER_VALUE、resolveJarPriorityOrder、jarPriorityComparator、jarCandidateComparator、selectPreferredJarSources、findShadowedJarSources、deduplicateJarPackages、JarDeduplicationResult与jarPackageKey/jarVersionCode/jarBaseName。测试同样移到core-domain/src/jvmTest(9 条,:core-domain:jvmTest --tests *JarSourcePriority*)。Android 的AppSettings/SourcesSettingsViewModel/GlobalExtensionManager/SourceGroupRepository只是少了一个源文件, 导入路径不变,:app:compileDebugKotlin全绿。 - Windows 的语义映射:Android 存的是 JAR 文件名,Windows 装的是 parser 插件,所以同一把键里放插件 id, 排名规则本来就一致——
ParserPluginRegistry的构造参数priority与recompute()正是按plugin.info.id排名、第一个插件独占同名来源。DesktopSession.open在构造 registry 之前读一次jar_priority_order, 新的parserPluginPriority(savedOrder)把它拼在ParserPluginRegistry.DEFAULT_PRIORITY前面(去重、大小写不敏感), 所以没存过值的全新安装与 Android 默认完全一样。 - 存储:
jar_priority_order写desktop_extensions(与disabled_sources同一把 store),值仍是 Android 的 逗号分隔文本,备份/复制设置含义相同。DesktopSession.jarPriorityOrder()/jarPriorityNames()(resolveJarPriorityOrder(已装插件 id, 存值),解析不到的旧名字会被丢掉)/setJarPriorityOrder(names)一次写盘;DesktopAppState.jarPriorityOrder只是它的镜像。 - UI:来源设置页在“概览”下方新增“远程来源”一节(Android
remote_sources分组的位置),行是source-setting-jar-priority:标题/摘要取 Android 的jar_priority_order_title/jar_priority_order_summary, 值显示解析后的插件 id(还没存过就是 Android 的not_specified),下面一行说明sources.jarPriorityNote。 点行开jar-priority-dialog:Android 的说明、每个已装 parser 插件一行(jar-priority:<id>)、行尾“上移/下移” (jar-priority-up/down:<id>,文案复用library.categoryUp/Down),每次移动立刻写盘(同分类管理,没有确认键), 关闭按钮jar-priority-close;没有已装插件时显示jar-priority-empty。 - 为什么注明“下次启动生效”:
recompute()只在 registry 打开时排一次,对话框不假装能热排,行下直接写明时机。 - 顺带修的一处冷启动缺口:
installedEntries原本只在打开“扩展与仓库”后才填,所以直接进来源设置会看不到插件 行。DesktopController.refreshSources()(启动时必跑)现在也带installedEntries;页面在还没有插件列表时退回 只显示存下来的名字,不走resolve丢掉它们。 - 探针走两个 JVM(
jarpriority-write/jarpriority-read,同一数据根,用例名含deduplication):两个 parser 夹具都声明FIXTURE_SHARED,默认排序(两者都不在DEFAULT_PRIORITY里,退化成按 id 字母序)由fixture-kotatsu提供服务;write 阶段在设置页把fixture-kototoro上移到最前,断言desktop_extensions里存成"fixture-kototoro,fixture-kotatsu"且页面跟着变;read 阶段重开数据根,断言pluginFor("FIXTURE_SHARED")换成fixture-kototoro,而且这正是收起来的顺序里第一个、默认排序不会选的那个。截图jarpriority-write-dialog.png(说明 + 两行上移/下移 + 生效说明)与jarpriority-write-row.png(远程来源节与解析后的值)已看过。
验证::core-domain:jvmTest --tests *JarSourcePriority*(9 条)、:core-domain:compileCommonMainKotlinMetadata (可移植性)、:desktop-app:test --tests *JarPriority*(4 条)、:desktop-app:test --tests *deduplication* (write+read 双进程,DESKTOP_JAR_PRIORITY 两行标记)、:desktop-app:verifyDesktopStrings、:app:compileDebugKotlin 全绿。
仍待:Android 同组的“显示损坏来源”(show_broken_sources)与“扩展安装行为”(extension_install_behavior: 询问/仅安装/安装并启用)在 Windows 还没有行;deduplicateJarPackages(同包多版本只留最新)在 Windows 侧还没有 调用点——桌面的安装记录按 sha 目录天然分开,接之前要先确定“同包不同版本”在桌面上的表示。
177. 远程来源 · 显示已损坏来源(对齐 Android show_broken_sources,parser host 读 @Broken)(2026-10-24)
Android 的“显示已损坏来源”不是“加载失败的扩展”,而是扩展作者在解析器类上用 @Broken 主动标记的源: JarExtensionLoader.scanBrokenSourceNames 扫 DEX 里带 @Broken 的类、再用 @ContentSourceParser(name) / @MangaSourceParser(name) 得到源名,GlobalExtensionManager 把它塞进 PluginContentSource(broken=…), ContentSource.title 变成 "$baseTitle (${getString(source_broken)})",ContentSourcesRepository 只在 show_broken_sources 打开时才把 isBroken 的源放进列表。插件本身照常安装、照常可调用,开关只管“要不要在UI里提供它”。 Windows 这一轮把这个语义补齐,因为此前的 Windows 连“已损坏”这个概念都没有:ParserPluginRegistry.build() 把所有枚举常量都构造成 ContentSource,任何一个抛异常就整个插件 CONSTRUCTION_FAILED,而作者主动标记的坏源 反而完全看不出来。
- parser host 侧(
ParserPluginRegistry.kt):ParserSourceInfo新增broken: Boolean = false。brokenSourceNames(path, loader)读 JAR 条目字节——只有含Lorg/skepsun/kototoro/parsers/Broken;或Lorg/koitharu/kotatsu/parsers/Broken;描述符的 class 才会被loadClass并读注解(Android 是全量扫 DEX, 这里先用字节预筛,几千个类的插件也只加载那几个),注解类型由宿主共享 ABI 提供(ParserPluginClassLoader对这些包父优先),所以按 class 身份比较是稳的;单个类加载失败只跳过它,不带垮整次扫描。 - 目录文件跟着升版:
CATALOG_VERSION = "2",每个源一行从 4 列变 5 列(末列1/0表示broken)。 旧 v1 目录会被判为无效 → 走一次完整加载并重写,正好把标记算出来;readCatalog之后注册的插件也带着这个标记, 不需要提前 open JAR。 - 桌面侧:键
show_broken_sources与 Android 同名,存desktop_extensions(与disabled_sources、jar_priority_order同一把 store),值是Toggle,默认关。DesktopSession.sourceListings(includeBroken = showBrokenSources())是唯一入口,所以来源栏、来源网格、筛选器、搜索建议、换源候选、来源开关对话框全都自动跟着走;pruneDisabledSources()显式传includeBroken = true,否则关掉开关时会把坏源的“已禁用”状态当成过期名字删掉。 - 标题:
parserListing(info)对坏源拼上 Android 的source_broken(Fixture Broken (已损坏)), 与 AndroidContentSource.title同一套写法,取词走AndroidStrings。 - UI:来源设置页“远程来源”一节在插件去重顺序行与其“下次启动生效”说明之后新增整行开关
source-setting-show-broken(排在说明后面,因为它立即生效、不需要重启,紧贴说明会被读成也要重启), 标题/摘要直接用 Android 的show_broken_sources/show_broken_sources_summary(“允许查看与使用被标记为已损坏的源插件”)。 切换后DesktopController.setShowBrokenSources重发sources;如果当前浏览的正好是被藏起来的坏源, 清掉浏览选择(同禁用来源的处理),作品的进度、下载、设置都不动。 - 夹具:
parser-host/src/fixtureBroken(新 sourceSet,fixture-broken.jar)只有一个源FIXTURE_BROKEN, 解析器类同时带@Broken("fixture: the site is offline")与@ContentSourceParser(name = "FIXTURE_BROKEN", …),newParser返回真实可调的BrokenFixtureParser——这样“标记坏”与“能不能调”是两件事,能被分别断言。:desktop-app的测试任务补了dependsOn ':parser-host:fixtureBrokenJar'。 - 验证:
:parser-host:test --tests *ParserPluginRegistry*新增一条a parser the plugin marks broken is flagged, kept callable and remembered by the catalog(10 条全绿):装fixture-broken.jar后sources()里FIXTURE_BROKEN.broken == true、标题是Fixture Broken、withParser仍能正常取列表(0 条);把工件换成非 JAR 的 PNG 后用目录重新注册,标记照样回来——目录 v2 的broken列确实生效。桌面探针brokensource-write/brokensource-read走两个 JVM:write 阶段导入夹具,断言 host 已标记、sourceListings()默认不含它、includeBroken = true才含, 进来源设置点source-setting-show-broken,断言desktop_extensions里存成Toggle(true)、state.sources里出现Fixture Broken (已损坏)、再点一次又消失,截图brokensource-write-off.png/-on.png; read 阶段重开同一数据根,断言开关持久化、标题仍带“(已损坏)”、pluginFor("FIXTURE_BROKEN")仍解析得到。 - 仍待:Tsuki 自己模型里的
isBroken(Android 在PluginContentSource里与注解标记并列判断)在 Windows 侧 还没有对应实现;extension_install_behavior(询问/仅安装/安装并启用)已在 §178 补上。 - 顺带修的一处共享模块编译缺口:
core-ui的TvFocus.kt用WindowInsets.isImeVisible判断软键盘(TV 输入框 左右键的放行条件),这个扩展只在foundation-layout的 Android 工件里,commonMain编译 JVM/iOS 目标时是 未解析引用——Android 侧:app:compileDebugKotlin一直是绿的,桌面侧:core-ui:compileKotlinJvm却红了, 连带:desktop-app:*全部编译不了。改成共享写法WindowInsets.ime.getBottom(LocalDensity.current) > 0(Android 上就是同一个 ime 高度判定),:core-ui:compileKotlinJvm与:app:compileDebugKotlin都重新编译通过。
178. Windows 扩展安装行为对齐 Android 的 extension_install_behavior(2026-10-24)
§174–§177 补完来源侧之后,“装上一个包以后,它带来的来源算不算启用”这条规则在 Windows 上还没有位置。 Android 的 UnifiedSourcesScreen 在装/导入第三方包时按 UnifiedSourceKind 问一句,并把答案存在 AppSettings.KEY_EXTENSION_INSTALL_POLICIES(extension_install_policies,"<type>=<POLICY>" 的 Set<String>, ASK_EVERY_TIME 不落盘)。
- 共享:
ExtensionInstallPolicy与其编解码从app原样搬到core-domain的commonMain(同包org.skepsun.kototoro.extensions.install),decode/encodeExtensionInstallPolicies由internal改成 public; Android 侧只删实现、import 不变,两条测试跟着搬到core-domain/src/jvmTest。契约里更厚的ExtensionInstallService与SystemPackageInstaller依赖 APK/系统安装,仍留在app。 - Windows 的“包类型”用 Android 自己的
UnifiedSourceKind名:MIHON/TSUNDOKU/ANIYOMI/JAR/CLOUDSTREAM(DesktopInstallKind)。仓库条目按包名标记分类(.animeextension.→ Aniyomi、.novelextension.→ Tsundoku、 解析器插件 → JAR、.cs3→ Cloudstream),与目录行自己的生态标签同一份判据;文件导入只有 jar/APK 流水线可看, 所以小说 jar 走文件导入时记 MIHON(KDoc 里写明)。LEGADO/LNREADER/TVBOX/JS/IREADER 不在列表内: Windows 的 JSON 来源是 URL 对话框,没有“装完是否启用”这一步。 - 存储:
desktop_extensions的extension_install_policies(与 Android 同键),值仍是那套文本集合,ASK_EVERY_TIME写回时删除条目;入口是DesktopSession.extensionInstallPolicies()/setExtensionInstallPolicy(kind, policy)/setAllExtensionInstallPolicies(policy)。 - 生效点:
DesktopSession的installExtension/importJar/installManaged/installParser/installCloudstream多一个enableSources: Boolean?(null = 不动开关),装完用既有的setSourcesEnabled(names, enable)落到disabled_sources,与来源开关对话框是同一份数据。 - 询问:
DesktopController.installExtension(目录行按钮)照 Android 的installPackage先看策略,ASK_EVERY_TIME就把待装请求放进DesktopAppState.installPrompt(与无痕提示同一种“状态里存问题、回调里续做”的写法, 不占用action {}的 busy),对话框是 Android 的UnifiedInstallChoiceDialog(标题“安装 %s”、来源条数、extension_install_remember_choice勾选、安装并启用/仅安装/取消);勾选即写该类型的策略。 更新不询问(Android 的enableAfterInstall = null),开关也保持原样。文件导入不询问:用户已经挑了这个文件,ASK_EVERY_TIME对它就等于“装上并显示来源”;真正值得提问的是目录行——一个仓库动辄几十个来源。 - UI:来源设置“远程来源”节在“显示已损坏来源”之后新增整行
source-setting-install-behavior(摘要 = 各类型答案去重后按 Android 的类型顺序拼起来,如“仅安装 · 每次询问”),打开install-behavior-dialog: “批量统一设置”行(对应 Android 的分段按钮)加“按源类型设置”分组,每个类型一行,点开是同一个三选一对话框 (install-behavior-option:<POLICY>)。 - 验证:
:core-domain:jvmTest(策略编解码两条测试随模块走)、:desktop-app:compileTestKotlin、:app:compileDebugKotlin(共享枚举搬家后 Android 仍绿)。探针installpolicy-write/installpolicy-read两段 JVM: write 断言默认集为空、JAR=INSTALL_ONLY的文本集合、批量写 5 条、批量回“每次询问”清空;导入fixture-kototoro.jar后FIXTURE_KOTOTORO_ONLY进disabled_sources,换INSTALL_AND_ENABLE再导入fixture-kotatsu.jar则FIXTURE_SHARED不进;仓库装包走真实目录行——取消后什么都没发生,勾选“记住”选 “仅安装”后包装上、该类型存成INSTALL_ONLY、它带来的来源进disabled_sources;设置页读回混合摘要, 弹窗里逐类型与批量行都改得对(批量回 ASK 时集合清空);把同一包升一个版本号再装一次不弹问题、开关不动。 截图installpolicy-write-prompt.png/-sheet.png/-choice.png;read 段重开数据根,断言JAR=INSTALL_ONLY、 两个来源的开关状态、设置页摘要都还在。 - 既有探针的连带更新:
repositories-write第一次点目录行现在会弹问题,探针点“安装并启用”并断言不勾选就不落盘; 其余经controller.importJar的探针默认答案等价于旧行为。 - 仍待:
setup_wizard、handle_links、SourceSwitchDialog的选择模式、服务设置的浏览记录轮播、 外部追踪、定期本地备份等(推荐设置的三个 worker 开关已在 §179 补上,related_manga已在 §191 补上)。
179. Windows 推荐设置补齐 Android 推荐 worker 的三个开关(2026-10-24)
§173 把“更多 → 推荐设置”按 Android 的 SuggestionsSettingsScreen 搬过来时只搬了“允许推荐 / 排除 NSFW / 标签 / 来源” 几节,页面上还有三个直接接 SuggestionsWorker 的开关没有位置:only_using_wifi(WorkManager 的 UNMETERED 约束)、 include_disabled_sources(候选是否包含已禁用的来源)与 notifications_enable(生成后是否提醒)。 本轮把这三条补上,语义按 Android 的 worker 来定,而不是按“看上去合理”来定。
- 存储:
desktop-runtime的DesktopSuggestionSettings新增wifiOnly/includeDisabledSources/notifications, 三个都默认false,键与 Android 的AppSettings一致——suggestions_wifi、suggestions_disabled_sources、suggestions_notifications,仍写在desktop_background这个命名 store 里。 - Wi-Fi 约束:Android 读的是
NetworkType.UNMETERED,而流量计费是ConnectivityManager才有的概念,JVM 上只有网卡。 新文件DesktopConnectivity.kt把网卡列表折成hasUnmeteredNetwork(interfaces):isUp、非回环、且网卡名或 显示名里没有wwan/cellular/mobile/rmnet/modem/lte/4g/5g才算“不计流量”——有线与 Wi-Fi 都过, 移动宽带不过。desktopNetworkInterfaces()用NetworkInterface.getNetworkInterfaces()读一次,读失败就当空列表 (宁可跳过也不要假装不计流量)。DesktopController把这个判定做成构造参数networkAvailable: () -> Boolean(默认读真网卡),探针因此能逐条驱动“这台机器有没有不计流量的链路”。 跳过时runSuggestionsOnIo在recordRun(LAST_SUGGESTIONS_RUN)之前返回 0:不记本轮时间,下一次到期检查会 重试,而不是等满一个周期——这与 WorkManager 把“约束未满足”当作“本次不跑”而不是“跑完了”是同一件事。 跳过也不清空已有推荐(refresh本身在缺种子/候选时就是“返回 0、不动表”)。 - 含禁用来源:开关为真时候选换成
session.sourceListings()全集,否则沿用state.value.disabledSources过滤后的 “已启用来源”。禁用来源本来就只是“不再出现在浏览/搜索/推荐里”,它仍安装、仍可调用(§174/§175 的口径), 所以这里只是换一个候选集合,不需要重装或唤醒什么。 - 通知:Windows 没有 Android 的托盘与通知渠道,于是它落在应用内——
DesktopAppState.suggestionNotice存一行AndroidStrings.format("suggestion_manga", 作品名)(Android 的原词条),只有“真的存进推荐(stored > 0)” 且开关打开时才设;主页推荐轨道上方用secondaryContainer圆角条显示,右侧一个“关闭”按钮调dismissSuggestionNotice()清掉。开关关着时什么都不发生,与 Android 不发通知一致。 - UI(
DesktopRecommendations.kt):在“排除 NSFW”行与其后的分隔线之后,按 Android 的顺序插三条整行开关settings-suggestions-wifi/-disabled-sources/-notifications,标题与摘要直接用 Android 的only_using_wifi+suggestions_wifi_only_summary、include_disabled_sources+suggestions_disabled_sources_summary、notifications_enable+suggestions_notifications_summary(都由prepareDesktopStrings从app/src/main/res导出), 并且与 Android 的enabled = isEnabled一样,只有“允许推荐”开着时这三行才可点。页尾说明换成 Android 的suggestions_info(自造的settings.suggestionsInfo两条译文随之删除)。另外把“允许推荐”的摘要改写了一句: 候选默认只来自已启用的来源、下面那个开关可以放宽——Android 原文没讲这一层,但 Windows 上“来源”是一等配置, 说明里不提会让人以为那个开关没有生效。 - 验证:新探针
suggestionpolicy-write/suggestionpolicy-read(DesktopSuggestionPolicyProbe.kt),与 §178 一样分两个 JVM。write 段导入 reader 夹具、把列表里第一部作品收藏起来当 worker 的种子,然后在真实 UI 里走推荐设置页—— 三行都在、标题与摘要都是 Android 词条、推荐关着时三行都assertIsNotEnabled;打开推荐后逐个点开,suggestionSettings三个字段翻真、desktop_background里三个键都落成Toggle(true),截图-worker-rows.png。 接着脱离页面直接驱动 worker:networkAvailable = { unmetered }置假跑一次,断言既没有标题、也没有last_suggestions_run、推荐表仍为空;置真再跑一次,标题出现且等于某个已存作品按suggestion_manga拼出来的样子; 回主页滚动到home-suggestion-notice截图后点关闭,条子与状态一起消失。最后禁用唯一来源:含禁用来源开着时 再跑仍会出标题,把它关掉再跑就没有了,之后恢复来源与开关。read 段重开数据根,断言三个键还在、DesktopSuggestionSettings.from读回三个true、推荐表非空。 - 顺带修掉“宽屏探针偶发
UncompletedCoroutinesError”:子 JVM 里runDesktopComposeUiTest的testTimeout参数 并不改runTest的默认 60 秒,而这几个探针在忙起来的机器上本来就要跑一分钟上下,于是报“test body did not run to completion”而不是真失败。DesktopAppTest.probe()现在给子 JVM 加-Dkotlinx.coroutines.test.default_timeout=10m, 四个 tablet 探针的父进程等待也统一成 150 秒(宽屏本来就是 150)——真正的上限仍是父进程的waitFor(seconds)。 改完后tablet/tablet-narrow/tablet-boundary/tablet-boundary-compact四个探针全绿(本轮 6m36s, 机器同时有别的构建在跑)。 - 探针跑法的一个坑(顺手记下):
cmd /c "gradlew.bat :desktop-app:test --tests "*xxx*" ..."那对内层引号会被 cmd 吃掉,Gradle 收到的是“带前导空格的模式”,于是报No tests found for given includes——哪怕测试明明在类里。 改成 PowerShell 直接传'--tests=*xxx*'即可;这一轮先被这个坑绕了一圈。 - 仍待:
setup_wizard、handle_links、SourceSwitchDialog的选择模式、服务设置的浏览记录轮播、 外部追踪、定期本地备份等(§178 列过的其余项不变,related_manga已在 §191 补上)。
180. Windows 服务设置页对齐 Android 的 ServicesSettingsScreen(2026-10-24)
§155 起 Windows 的阅读统计一直挂在“更多”列表里,推荐设置(§173/§179)也是同一层的另一行;Android 把这两项和 “它们到底记不记”的几个开关一起收在一个“服务”页(ServicesSettingsScreen)里,“更多”只列页面。本轮把这一层 结构补上,并按 Android 的开关语义让它们真的起作用——不摆只有存储没有行为的开关。
- 页面与动词:新增
DesktopScreen.SERVICES,DesktopMorePanel里原来那两行(settings-recommendations-open、reading-stats-open)换成一个services-open行(标题services、摘要more.servicesSummary,图标沿用推荐那只), 新页DesktopServicesPage.kt按 Android 的顺序画:推荐(值行,摘要按 Android 写成“启用/禁用”,即state.suggestionSettings.enabled)、阅读统计(Android 的分裂行)、阅读时间估算(整行开关)。 Android 的suggestionsSummary与isStatsEnabled的摘要都是enabled/disabled两个词条,这里照抄。 统计页与推荐页的返回按钮同时指向showServices(),不再回“更多”。 - 共享行组件:
core-ui的SharedSettingsRows.kt新增SettingsSplitSwitchRow——行尾是开关、行首标题区可点, 对应 Android 的SettingsSplitSwitchPreference(点标题进统计页、点开关只切记录;Android 上这是两个不同目标, 所以不能用整行开关代替)。它和其余设置行共用SettingsRowText与 120dp 行高约定。 - 存储:
desktop-runtime新增DesktopServicesSettings(statsEnabled/readingTimeEstimation,都默认true) 与DesktopServicesPreferences(独立的desktop_servicesstore,读写形状同 §139/§160 的导航与下载设置), 键用 Android 的stats_on与reading_time。 stats_on的真实语义:Android 关掉后阅读仍写历史与进度,只是不再产生会话/统计行。Windows 的统计写入只有一个 收口点——SourceLibrary.recordPage里的ReadingSessionTracker与writeReadingSession——所以在core-library加了一个var statsRecording: Boolean = true,recordPage用它决定要不要 tick、writeReadingSession第一行直接 return。这样六个recordPage调用点(详情预览、分页/连续阅读、小说、视频、滚动操作)都不必各自判断, 也不会出现“某个入口偷偷记账”。DesktopController.init把存下来的值同时写进state.services与session.library.statsRecording;servicesSettings(...)保存后即时同步,写盘失败才报services.saveFailed。reading_time的真实语义:Android 的ReadingTimeUseCase在HistoryInfo.estimatedTime里算这份估计, 但Android 侧没有任何界面渲染它(详情页、历史页、预览卡都没有引用estimatedTime)。Windows 把同一份公式 摆到详情页的事实行里(details-reading-time,标签details.readingTime,值%1$d h %2$d min/%d min), 否则这个开关就没有可观察的效果;公式本身放在desktop-runtime的desktopReadingTime(...):每章 20 页、 每页用该作品自己的totalDuration / units(没有统计或统计关着时退回 Android 的 10 秒默认),有未完进度就按1 - percent折算,不足 1 分钟返回null(行不画)。DesktopController.estimatedReadingTime在detailsOnIo里算一次,DesktopAppState.detailsReadingTime随详情一起下发。- 未搬的行与理由:Android 这一页还有“浏览记录推荐”(
browse_tracking_recommendations与其子项browse_more_tracking_recommendations)、“相关作品”(related_manga)和 Discord RPC。前两个依赖 Windows 还不存在的 功能(MAL/Bangumi 发现轮播、相关作品接口),Discord RPC 需要 IPC 客户端;按本项目的口径没有行为就不出行, 三个都留到后续轮次(related_manga是下一步的候选),而不是先存一个什么都不管的开关。 - 验证:新探针
services-write/services-read(DesktopServicesProbe.kt),两个 JVM。write 段导入夹具后走真实 UI: 详情页先经details-expand打开整页事实(宽窗默认是预览卡,事实在“详情”后面)并核对details-reading-time与公式一致;回“更多”断言reading-stats-open已不存在、services-open打开服务页,三行的标题/摘要都是 Android 词条, 截图-services-page.png;点services-stats-enabled关掉统计——state.services、desktop_services的stats_on、session.library.statsRecording三处同时为假,截图-services-page-off.png;此时读一章:历史有了、会话数仍是 0; 打开开关再读一章:会话数变 1;关掉reading_time后回详情页,detailsReadingTime为null且行消失; 最后回服务页截图-services-page-reading-time-off.png。read 段重开数据根,断言两个键(stats_on=true、reading_time=false)、DesktopServicesSettings.from的结果、state.services与statsRecording都一致。 - 回归:
stats探针(“更多 → 服务 → 阅读统计”的新路径与统计页返回目标)、suggestionpolicy探针(推荐入口的新路径)、tablet探针(宽窗详情与“更多”列表)全部重跑通过,合计约 7 分钟。 - 仍待:浏览记录推荐的两个轮播(
related_manga已在 §191 补上)。其余不变(setup_wizard、handle_links、手动重排、BOOKMARKS的 NSFW 过滤目标、SourceSwitchDialog选择模式、外部追踪、定期本地备份、CacheLimitsSettings)。
181. Windows 已安装扩展按包展开来源,对齐 Android 的 UnifiedPackageList(2026-10-24)
驱动原话里的“拓展管理…尤其是各个源的配置,怎么能平铺呢”,落到 Windows 上是这几页里唯一还和 Android 结构不同的一处: Android 的统一来源页把来源挂在声明的扩展包下面(UnifiedSourcePackageHeader + 包内 UnifiedSourceRow, 点标题展开/收起,包行写版本与来源数,来源行写内容类型/语言并带自己的启用开关),而 Windows 的“更多 → 扩展与仓库” 里“已安装”只是一行一个包、行尾“更新/卸载”,包里的来源完全看不到,只能去“来源”对话框里全局平铺找。
- 模型:
DesktopInstalledEntry新增sourceNames: List<String>(默认空),DesktopSession.installedEntries()按包 现算:Mihon/Tsundoku/Aniyomi 扩展取extension.sources.map { it.source.name },解析器插件取plugin.sources.map { it.source.name },Cloudstream 取cloudstreamListing(it).source.name。所以这一列永远和 registry 里的实际清单一致,不需要另存一份表。 - 界面:
InstalledExtensions里包行末尾多一个TextButton(extension-sources:<包名或插件 id>),文案是extensions.showSources=来源 · %d/extensions.hideSources=收起来源 · %d(zh-CN 同键),只在sourceNames非空时出现; 展开集合是这一页自己的remember { mutableStateOf(emptySet<String>()) }(Android 也不持久化展开状态)。 展开后每个来源一行共享的SettingsSplitSwitchRow(§180 为“阅读统计”加的那个分裂行):标题点进该来源自己的设置页 (controller.sourceSettings(name),等价于原来“先选中来源再进设置”,不改选中来源之外的任何状态), 行尾开关只切启用(controller.setSourceEnabled,落desktop_extensions的disabled_sources),摘要照旧是内容类型 · 生态 · 语言,行左侧缩进 24dp 表示从属关系。测试标签extension-source-open:<来源名>/extension-source:<来源名>,和“来源”对话框的source-enabled:<来源名>分开。 - 为什么不做 Android 的包内多选/批量:Android 那页还有长按选择、批量启用/禁用、安装/更新/删除的整条数据流; Windows 的“来源”对话框已经提供搜索 + 全部/启用/禁用筛选 + 按筛选结果批量开关(§174)。这一轮只补“包 → 来源” 的层级和单个来源的入口,避免在同一个页面上叠两套批量语义。
- 验证:扩了
repositories探针的 write 段(真实 UI,服务器走本地 HTTP):装包后installedEntries.single().sourceNames等于该 JAR 声明的来源名;extension-sources:<包名>点开前extension-source:<来源名>不存在、点开后存在并截图-installed-sources.png(另有-installed-rows.png只截已安装这一组,避免整窗截图被窗口下沿裁掉); 点extension-source:<来源名>关掉该来源——state.disabledSources立刻等于{来源名},再点回打开——集合清空; 点extension-source-open:<来源名>进设置页:screen == PREFERENCES、selectedSource与preferences.source都是它(截图-installed-sources-settings.png),再从主导航回到扩展页继续后面的更新/卸载流程。read 段在离线重启后 断言installedEntries.single().sourceNames仍是同一来源,证明这列不是内存里的临时值。 行在窗口下沿之外时点击不会命中,探针先用performScrollToNode把它滚进视口——这一点已经写进探针注释,后续加行时同样处理。 - 回归:
repositories、kototoro kotatsu and UMA plugins through the UI(用installed-list滚到extension-uninstall:<id>)、a Cloudstream plugin imports browses...三个探针重跑通过。 - 仍待:包内多选/批量(见上)、Android 统一页的仓库卡片分组与语言/组合筛选、
SourceSwitchDialog的选择模式。
182. Windows 来源手动重排,对齐 Android 的 ReorderDialog 与 SourcesSortOrder.MANUAL(2026-10-24)
Android 的“来源设置 → 概览”只在排序方式为手动(SourcesSortOrder.MANUAL)时多出一行“重新排序”,点开是 ReorderDialog:拖动手柄或长按条目移动、跨分组禁止、显式保存才写盘(saveSourceOrder 把每个名字写进该来源行的 sort_key,并把排序方式改成 MANUAL)。Windows 之前的三种顺序里没有手动这一项,DesktopSourceSort 的注释还明说 “没有手动重排编辑器”,所以用户排不出自己想要的顺序。
- 模型(
desktop-runtime的DesktopSourceSettings):DesktopSourceSort追加CUSTOM——不叫MANUAL,因为DEFAULT已经是“安装顺序”这个基线;落盘仍是枚举名,键还是原来的sources_sort。新增order: List<String>, 键sources_order(TextSet,值就是来源名):Android 没有这个键(它把顺序写在各来源行的sort_key上), Windows 的库没有来源表,只能把名字序列存进desktop_background。sorted()在CUSTOM下按order的下标排名, 未列出的来源(刚装的、名字还没进序列的)按安装顺序跟在后面;order为空时就是安装顺序。已卸载但在序列里的名字 保留(重装后能回到原位),排序时匹配不到来源而已。 - 控制器:
DesktopController.saveSourceOrder(names)复刻saveSourceOrder的“保存即切顺序”语义——先按当前可用 来源过滤掉禁用/已卸载的名字并去重,再一次走已有的internal fun sourceSettings(settings)落盘 (desktop_background,失败仍报settings.sourcesSaveFailed)。 - 界面(
DesktopSourceSettingsPage):“概览”组的source-setting-sort下面,仅当settings.sort == CUSTOM多一行source-setting-reorder(Android 同样只在手动排序时显示),标题取 Android 的reorder(重新排序),摘要用 桌面自己的sources.reorderSummary=逐行上移或下移调整顺序,完成后保存:Android 的reorder_hint讲的是“拖动手柄 或长按条目”,桌面既没有手柄也没有长按,照抄会写错交互。 对话框DesktopSourceOrderDialog(source-order-dialog、行source-order:<名字>、source-order-up:、source-order-down:、source-order-save、source-order-cancel,空列表source-order-empty)照DesktopJarPriorityDialog的形状:行是本地 draft,移动不写盘,点“保存”才调controller.saveSourceOrder。 Android 的分组内限制这里不需要——桌面列表是平的、不画置顶/语言标题,而“按语言分组”开着时来源栏会自己重新分组, 存下的序列决定的是每组内部的先后。 - 刻意不映射的:Android 的
POPULARITY与LAST_USED。桌面库既不统计来源热度也没有“上次使用”时间戳,加进去就是 点了没反应的选项(§177、§180 对同类差异的处理一致);反过来LANGUAGE是 Windows 独有(Android 用分组而非排序)。 - 验证:
DesktopSourceOrderProbe(sourceorder-write/sourceorder-read两个 JVM,本地 HTTP 服务器提供 MacCMS 接口) 导入两个 JSON 来源“重排甲/重排乙”→ 断言默认排序下没有重排行 → 排序方式选“自定义顺序”→ 重排行出现 → 打开对话框、 点source-order-up:<第二个>后断言此刻order仍为空(移动不写盘)→ 保存 → 断言sort == CUSTOM、order == [乙, 甲]、desktop_background快照里的sources_order就是这个顺序 → 回“浏览”断言来源总览的两块瓷砖 按行优先顺序乙在前(截图-settings.png/-dialog.png/-overview.png)。read 段离线重启后断言这三项都还在, 且重排行仍在。单元测试补在DesktopSourceSettingsTest:序列经偏好往返保序、CUSTOM排名(序列含已卸载名字、 空序列回退安装顺序、其它排序忽略序列)。 - 回归:
tablet layout and immersive reader panels…(来源栏/来源网格都走同一份settings.sorted)与a JSON CMS source imports from the extensions page…重跑通过。 - 仍待:把上移/下移换成真正的拖拽、Android 统一来源页的批量选择与仓库筛选分组。
183. Windows 设置向导(对齐 Android 的 setup_wizard / WelcomeSheet)(2026-10-24)
Android 的“来源设置 → 概览”最后一行是“设置向导”(setup_wizard),首次启动时还会自己弹出一次 (router.showWelcomeSheet()),内容是一个四页 pager:简介(WelcomeIntroStep)、权限(WelcomePermissionsStep)、 外观(WelcomeAppearanceStep,含 WelcomeSpacesStep)、完成(WelcomeDoneStep)。Windows 之前完全没有这一行, 侧栏也没有对应的 screen,所以这是概览组里最后一个缺口(§182 收尾时列的第一条“仍待”)。
- 三页而不是四页:
- 简介页、完成页照搬 Android 的字符串(
welcome_intro_*、welcome_done_*),所以“仓库/拓展/源”三个概念的说明、 免责声明、“设置完成”的正文与“如果内容源出问题”的排查清单都是原句; - 外观页保留 Android 的
interface_style思路,但把开关换成 Windows 真有的三项——主题、界面风格、语言, 每行打开的就是“更多”页那套DesktopChoiceSettingsDialog(同一个desktop_appearancestore:theme/interface_style/language),只挑风格的向导会漏掉最显眼的两个设置; - Android 的权限页整页丢弃:桌面没有运行时授权,那一页的每一行(通知、安装未知应用、电池优化)都是 Android 独有能力;
- Android 的空间页也丢弃:Windows 只有一个库、没有 space 概念;
- 不自动弹出:Android 首次启动会直接盖住界面,Windows 若照做,任何新建数据根的测试(以及只想先看看的用户) 都会先撞上一个流程页,所以向导只能从“来源设置 → 设置向导”手动进入。
- 简介页、完成页照搬 Android 的字符串(
- 界面(
DesktopSetupWizard.kt,DesktopScreen.SETUP_WIZARD,controller.showSetupWizard(),标题取 Android 的setup_wizard):页面顶部照桌面惯例留一个“返回”(离开向导,回来源设置),下面一行wizard-progress写“第 1 / 3 步”(sources.wizardStep),然后是按步渲染的三块内容,最底下一对动作按钮wizard-back(文案是新增的common.previous=上一步,第一步禁用——Android 的动作条只画一个返回箭头,桌面用文字才和顶部“返回”分得开) 与wizard-next(最后一页换成 Android 的done=完成,点了回来源设置)。 - 入口行
source-setting-wizard放在概览组的最后(Android 同位置),标题用 Android 的setup_wizard, 摘要用桌面自己的sources.wizardSummary:Android 的setup_wizard_summary写的是“选择内容语言和类型, 这重置已启用的图源列表”,而当前 Android 的四页里并没有这一步,桌面的向导也不重置来源,照抄会写错功能。 同理外观页的说明用sources.wizardAppearanceSummary而不是welcome_appearance_summary——后者承诺的 “导航转场与详情页全景动画”只有手机画得出来(和 §182 拒绝reorder_hint的拖动文案同一条规矩)。 - 完成页的两行就是 Android 那两个跳转:
wizard-open-extensions进“扩展与仓库”(EXTENSIONS),wizard-restore-backup进“图书馆备份”(BACKUPS,摘要用 Android 的welcome_restore_summary); Android 的页头摘要与卡片正文在桌面折成一行SettingsInfoRow(摘要, 正文)。 - 不持久化当前页:Android 把
currentPage存在偏好里以便中途退出后回到原页,Windows 的向导每次打开都从简介开始 ——它只有三页、也没有自动弹出,存一个“上次停在哪”反而要让“重看一遍”多一步。 - 验证:
DesktopWizardProbe(wizard单段,空数据根,无 fixture)走完整条路径—— 更多 → 来源设置 → 断言概览组末尾有source-setting-wizard→ 点进向导 → 断言wizard-progress是第 1/3 步、wizard-back禁用、简介页的 Android 原句(标题与免责声明)都在 → 下一步 → 断言主题/界面风格/语言三行都在、 点主题选LIGHT→ 断言state.appearance与desktop_appearance快照里的theme都变成LIGHT(向导写的是同一把键) → 下一步 → 断言完成页的标题、两个跳转行与“完成”文案 → 点wizard-open-extensions落到EXTENSIONS、 重新进向导点wizard-restore-backup落到BACKUPS→ 再进向导,wizard-back从第 3 步退回第 2 步、 “完成”回来源设置。截图wizard-{settings,intro,appearance,done,returned}.png都已逐张看过: 入口行在概览组最后、三页排版一致、主题改成浅色后完成页确实是浅色(说明那条设置真生效了)。 - 回归:
the source reorder editor stores Android's manual order…与a parser marked broken stays installed and the show broken sources switch hides and offers it重跑通过 ——两者都要在同一张来源设置页上滚动找行,新行不能把已有的行挤出可达范围。 - 仍待:
handle_links(Android 概览组里的“处理链接”,Windows 没有注册http/https的关联入口)、 把重排对话框换成真正的拖拽、Android 统一来源页的批量选择与仓库筛选分组。
184. Windows 来源测试页(对齐 Android 的 SourceTester / SourceTestDialog)(2026-10-24)
Android 的“来源设置”与统一来源页各有一个“测试全部”(source_test_all),点开是 SourceTestDialog: SourceTester(settings/sources/test/SourceTester.kt)对每个已启用来源依次问七件事——主列表、封面、详情、 章节、页面、随机、搜索——每步给出 PASS/FAIL/SKIP/CLOUDFLARE 与耗时,跑完汇总“可用/不可用/Cloudflare”, 还能导出 JSON、禁用失败的来源。Windows 之前没有任何对应物:来源出问题只能靠逐个点进去看错误提示, 诊断信息还带 requestId,用户没法一眼比较“到底是哪几家挂了”。
- 六个步骤而不是七个(
DesktopSourceTester.kt):- RANDOM 丢弃:
core-source的SourceRuntime契约里没有随机操作(浏览页的“换一批”是页内重排), 没有可问的东西就不假装问了;source_test_step_random这一行不会出现。 - LIST/COVERS/DETAILS/CHAPTERS/PAGES/SEARCH 与 Android 同序同名,字符串直接用 Android 的
source_test_step_*、source_test_msg_*、source_test_status_*(中文 catalog 里这批键没有译文, 所以中文界面下步骤名与判定仍是英文——与手机上的表现一致,不手工翻译,见 Weblate 规矩)。 - 超时与并发照搬 Android:单步 20 s(
source_test_msg_timeout)、单个来源 90 s (source_test_msg_total_timeout)、同时三个来源;某步失败后已排到的后续步骤不再执行, 并按“前一步失败”填 SKIP(source_test_msg_prev_failed),不是中断整轮。 - 无操作支持的步骤(
UNSUPPORTED_OPERATION)读 SKIP(source_test_msg_unsupported),与 Android 同规则; Cloudflare/挑战读独立的 CHALLENGE(source_test_status_cloudflare),不在跑测中途弹浏览器窗口—— 需要人工过验证的来源正是这一页要列出来的东西,让用户在无人的跑测里点掉它反而是错的行为。
- RANDOM 丢弃:
- 三种内容类型走三条 PAGES 判定(Android 的 PAGES 只有图片一种):
DesktopReaderKind.PAGES:getPages后只下载第一张图,下载成功才算 PASS(source_test_msg_pages);NOVEL:getChapterContent的 html 非空(source_test_msg_text_chapter);VIDEO:getPages给出流地址后DesktopSession.probeUrlGET 一次,状态码成功才算source_test_msg_video_stream(“stream reachable”这句话必须真的验过),失败写source_test_msg_stream_failed。- COVERS 复用
session.covers.load(content, refresh = true),与网格同一条管线,所以 TVBox/CMS 那种 “协议不返回封面、只有 URL”的来源也走 Session 的直连兜底,PASS 意味着图片真的落盘了。
- 页面(
DesktopSourceTestPage.kt,DesktopScreen.SOURCE_TEST,标题source_test_title):Windows 的设置页 都是平铺页而不是对话框(§158–183 一路如此),六个步骤 × N 个来源也塞不进一个弹窗,所以做成独立页; 入口在“扩展与仓库”的已安装区末尾(extension-test-sources,标题source_test_title、摘要extensions.testSourcesSummary), 因为“来源”本来就住在那一页。返回回“扩展与仓库”。- 进度块:
LinearProgressIndicator(source-test-bar)、“N / M checked”(source_test_progress)、 正在跑的是谁(source_test_running,固定两行避免按钮跳动)、跑完的汇总(source_test_summary)、 取消说明(source_test_cancelled_note);按钮在不跑时是“测试全部”(source-test-run,禁用条件是没有已启用来源), 跑着时换成“取消”(source-test-cancel),旁边是“导出结果”(source_test_export,写剪贴板, 失败时用 DesktopClipboard 的兜底提示)与“禁用失败的来源”(source_test_disable_failed,跑完且有 FAIL 才出现)。 - 结果区按来源一行(
source-test-result:<name>):标题、判定(source-test-verdict:<name>)、“N 步”, 点开逐行列出步骤名、判定(source-test-step:<name>:<STEP>)、耗时与消息——顺序与 Android 对话框一致。 - 进度放在
DesktopSourceTester.progress这个独立 StateFlow 里(不进DesktopAppState,同 §162 的镜像测速), 页面collectAsState()直接读;跑测是通过scope.launch起的,不走action{}的 gate, 所以一轮几分钟的跑测不会把窗口其余部分锁成 busy。 - 进入页面即
sourceTester.reset():第二次进来看到的是一张空表,而不是上一轮里某个已经卸载来源的判定。
- 进度块:
- “禁用失败的来源”写的就是来源开关对话框那份
disabled_sources(session.setSourcesEnabled), 失败的来源随即离开来源栏/网格/筛选器,收藏进度与下载都保留;Android 的disableFailed()是同一语义。 - 导出与 Android 的
exportJson()同形:{"generated":ISO,"sources":[{"name","status","steps":{STEP:pass|fail|skip|challenge},"messages":{…}}]}, 状态小写、消息只给 FAIL/CHALLENGE,一份 JSON 可以逐行对照两个平台。 - 一个真实现象:CMS 站点整体坏掉(目录返回 500 或读不懂的文档)在 Windows 侧读成 UNSUPPORTED——
MacCmsSourceRuntime的provider()找不到能解析出分类/条目的候选地址就抛SourceOperationUnsupportedException——所以那种来源的 LIST 是 SKIP 而不是 FAIL。这不是本页的取舍,而是该 runtime 自己的判定语义;测试夹具因此让“坏站点”的某一集返回 500,FAIL 出现在真正失败的那一步上。 - 验证:
DesktopSourceTestProbe(sourcetest单段,空数据根,内建com.sun.net.httpserver假站点)—— 导入两个 CMS 来源(一个正常、一个剧集地址 500)→ 扩展与仓库 →extension-test-sources→ 断言进了SOURCE_TEST→ 点“测试全部” → 断言 2/2 完成、正常来源六步全 PASS(含真的下了封面、真的是 200 的流),坏来源只有 PAGES FAIL 且消息是Stream failed: HTTP 500、其后 SEARCH 仍然执行(证明单步失败不中断)→ 断言页面上的判定、展开的步骤行、 汇总行都在 → 导出 JSON 含"status":"pass"、"status":"fail"、"messages":{"PAGES":"Stream failed: HTTP 500"}→ 点“禁用失败的来源”后坏来源进disabled_sources、正常来源仍在enabledSources→ 返回再进页面,上一轮结果已清空。 截图sourcetest-{source-test-idle,source-test-results,source-test-disabled}.png都已逐张看过。 - 回归:整套
:desktop-app:test重跑(48 个用例)通过——新行与新页都不影响既有的来源设置、安装策略、JSON 来源与 仓库路径。 - 仍待(本切片之外):Android 统一来源页的批量选择、仓库筛选与分组、真正的拖拽重排、
handle_links。
185. Android 导出的备份在 Windows 恢复失败:特殊浮点与后加字段(2026-10-25)
用户反馈“安卓「备份与恢复」页导出的 ZIP 拷到 Windows 后在图书馆备份页恢复会报错”,没拿到错误原文, 所以从两端的读写契约反推,并用安卓形状的归档夹具复现——两条都在预览阶段就让整份档案读不进来, 与恢复模式、数据内容无关:
- Android 侧
BackupRepository的Json开着allowSpecialFloatingPointValues,所以percent/scroll这类进度 浮点在“没算出来/除零/小说偏移”时会写成NaN、Infinity;core-library的LibraryBackupJson没开这个键, 于是第一行坏浮点就让preview()抛JsonDecodingException(JsonExceptions.kt:48)。脏进度的容忍本来就在——normalizeBackupPercent早就在处理 1.0000001 的溢出与 18844.0 这种小说偏移——只是解码器先一步拒绝了它。 现在LibraryBackupJson与 Android 同为allowSpecialFloatingPointValues = true,脏值改为建行时归一化:history()的scroll非有限或负数落 0(不再整段回滚),percent仍走normalizeBackupPercent(→-1未知);ReadingStatisticsBackup.restoreReadingStatistic不再要求会话start_percent/end_percent有限, 非有限按 0 处理后再coerceIn(0f, 1f)。 BookmarkBackup.Bookmark的image_url、percent与BookmarkBackup.tags原本是必填字段,早于“书签快照图” 的写入器(以及 Kotatsu 时代的档案)没有这几个键,整份档案就停在MissingFieldException: Field 'image_url' is required ... at path: $.bookmarks[0]。现在三者都有默认值 (""、BACKUP_PROGRESS_NONE、emptySet()),书签本身照常恢复。- 复现即验证:
DesktopLibraryBackupTest新增an Android export with non-finite progress restores instead of failing the whole preview——一份安卓形状的档案 (history的scroll:Infinity+percent:NaN、书签缺image_url且percent:NaN、统计会话两个 percent 是NaN/Infinity)在旧代码上失败于JsonDecodingException,修好后restore通过,落库值是scroll=0、history.percent=-1、bookmark.percent=-1、会话 percent0。 - 回归:
:desktop-runtime:test161 个用例 0 失败、:core-backup:jvmTest15 个 0 失败、:core-library:jvmTest通过。LibraryBackupJson是共享单例,所以 Windows 导出遇到本地已有NaN也不再抛,iOS 的恢复同步受益。 - 仍未确认:拿不到用户那份原文件,也没有报错原文;若还有第三个原因,最可能在
DesktopBackupArchive.open的 ZIP 结构校验或saveContent的作品编号冲突上——这两处都会把异常原文带到界面上,等反馈补充。
186. 源设置里嵌套的分组不再被压平:Android 的分组标签行(2026-10-25)
“各个源的配置怎么能平铺”这条反馈落到源设置页(DesktopSourcePreferences)时,还有最后一段没对齐: 协议树的 GROUP 节点带 children,可以任意嵌套,而 Windows 的折页只把顶层有标题的 GROUP 变成 section, 顶层的无标题 GROUP 和所有嵌套 GROUP 一律被摊平成行——分组标题直接丢掉,于是像 Account → Advanced → 代理 这种三层的扩展在界面上就是一张看不出结构的长卡片。
Android 的做法是 ComposePreferenceAdapter.flattenGroupRows:嵌套的 PreferenceGroup 先补一行 SourceSettingsGroupLabelRowUiState(SourceSettingsScreen 用 SettingsGroupLabel 画:labelLarge、 onSurfaceVariant、左右 16 dp / 上下 10 dp,仍在同一张卡里),再递归它自己的行;无标题的嵌套组只贡献行。 Windows 现在照同一规则折:
SourcePreferenceSection的rows换成entries: List<SourcePreferenceEntry>,Row(node)与Label(id, title)两种;rows留作只取叶子的派生属性(对话框宿主按下标查行、测试断言叶序都用它)。groupEntries递归时,嵌套且有标题且有可见后代的GROUP先产出Label("label:<id>")再产出子树, 无标题的组直接展开,叶子顺序与源的声明顺序一致。- 共享行组件新增
SettingsGroupLabelRow(core-ui的SharedSettingsRows.kt),与 Android 的SettingsGroupLabel同字号同色同内边距,所以 iOS 拿到同一棵树也只差一个调用。 - 分隔线只在两个相邻的
Row之间画:标签本身就是它下面那组行的分隔,画了反而多一道线。 DesktopSourcePreferencesTest三处旧断言里的“嵌套组摊平”改成“嵌套组建标签行” (nested groups keep their title as a label row inside the owning section,断言source:a → label:group:inner → source:b且rows仍是source:a, source:b),另有an untitled nested group only contributes its rows覆盖“无标题组不产标签、两层嵌套各留标题”。- 回归:
desktop-app的*DesktopSourcePreferencesTest*5 个用例 0 失败;write/read探针 (Windows rendered source details reader settings and shared state survive a second process)走的是同一份 源设置页,仍能按preference:<id>找到行并成功写入,read-preferences.png与旧截图一致。 - 仍未做:Android 顶层的
PreferenceCategory与PreferenceGroup在协议里都只是GROUP,所以 Windows 无法 区分“该成为 section 的分类”和“该成为标签行的嵌套组”——现在按“顶层有标题即 section、嵌套有标题即标签” 处理,多层嵌套比 Android 多留一层标题(Android 对嵌套的PreferenceCategory会丢标题);要精确对齐得给 协议节点加一个来源类型字段,属于协议变更,本轮不做。
187. 定期备份的本地副本:上传前先落盘,没有 WebDAV 也能按间隔备份(2026-10-25)
视频/备份页的云同步此前只有“上传”一条路:没有服务器就完全没有自动备份,服务器 500 时这一轮备份直接丢掉。 Android 的 PeriodicalBackupService 不是这样——它把导出写进临时文件后先 externalBackupStorage.put(output) 再 trim(periodicalBackupMaxCount),之后才做 WebDAV 上传, 所以本地永远有一份可恢复的副本,服务器故障只影响远端。Windows 现在照这个顺序补齐:
desktop-runtime新增DesktopLocalBackups(DesktopDataPaths.backups,即数据根下的backups): 文件名复用 AndroidBackupUtils.generateFileName的<app>_yyyyMMdd-HHmm.bk.zip(小写kototoro_),list()只认这一个形状(desktopBackupTimestamp解析失败的名字——手工导出的kototoro-library-*.zip、暂存文件、目录——一律不算),按时间新→旧排;keep()复制时先写<name>.part再ATOMIC_MOVE到最终名,崩溃不会留下看起来能恢复的半截档案, 同一分钟内第二份用-2/-3后缀而不是覆盖;trim(count)只删这类文件,count <= 0全留 (对应 Android “删除旧备份”关掉时的Int.MAX_VALUE)。- 设置:
DesktopWebDavSettings加keepLocalCopy/trimLocal/localCount(默认true/true/10), 键是桌面自己的webdav_keep_local_copy/webdav_trim_local_copy/webdav_local_max_count(Android 的backup_periodic_trim/backup_periodic_count存在手机的 SharedPreferences 里,桌面不共享这份存储, 所以沿用桌面webdav_*命名空间;语义与默认值照 Android)。 - 上传路径:
uploadLocked()在backups.export(file)之后、client.upload之前调keepLocal(), 落盘失败只吞掉本地那一步(check只留给纯本地模式),上传照常;WebDavUploadResult.local回传文件名,webDavUpload()把它接在“已上传 …”后面(webdav.localKept)。 写盘同时记webdav_last_local_backup_millis并refreshLocal(),列表不需要退出页面就能看到新副本。 - 纯本地定时备份:
autoBackupEnabled从“必须有服务器”放宽成intervalHours > 0 && (configured || keepLocalCopy),autoBackup()在没有服务器时走keepLocalOnly()(导出→keepLocal,不建 client、不发任何请求)并回新值DesktopAutoBackup.Saved。计时基准刻意与 Android 不同:配了服务器时仍只看lastUploadMillis, 上传失败继续按调用方的十分钟退避重试(Android 取max(local, webdav),一次本地成功就会压掉整轮的重试, 用户会以为已经同步到云端);纯本地模式才用lastLocalMillis。 - 界面(备份与恢复页的云同步卡):
上传后保留本地副本(Android 的webdav_keep_local_copy原句)、 开时多出删除旧备份文件(delete_old_backups)与最大备份数滑块(max_backups_count,1..32, 共享SettingsSliderRow)、本地副本目录行带 Android 的路径与“打开”按钮、最后一行last_successful_backup原句加副本数与最新文件名;关掉保留开关时这几行整体消失,与 Android 的分组一致。 标签webdav-keep-local/webdav-trim-local/webdav-local-count/webdav-local-open/webdav-local-status。 新增桌面文案webdav.localKept/localNever/localSummary/localFailed/localOpen/open/localOpenFailed(base 与_zh-CN同时写,:desktop-app:verifyDesktopStrings954 键全通过)。 - 回归:
DesktopLocalBackupsTest7 个用例(命名/往返解析/keep覆盖同分钟与.part发布/列表排序/ 保留只删自己的文件/trim(0)/进行中的副本不进列表);DesktopWebDavAutoBackupTest8 个用例,其中 “服务器 500 仍留本地副本、重启后仍列出”“纯本地 tick 写副本且requests == 0、第二次要等满间隔”、 “保留开关关掉后整页只剩上传”三条是新加的;探针backups-write/backups-read在真实窗口里断言五行存在、 关掉开关后webdav-trim-local消失、把间隔设成 1 小时打开纯本地计时器后waitUntil到kototoro_*.bk.zip真的写进backups(截图backups-write-local-copy-filled.png:最近备份成功: 2026年10月25日 09:55、共 1 个副本 · 最新 kototoro_20261025-0955.bk.zip), 读阶段重开同一数据根仍能看到这份副本。 - 仍未做:Android 的 SAF 自选输出目录(Windows 固定用数据根
backups,页面给了“打开”按钮)、 WebDAV 自动恢复(backup_periodic_webdav_auto_restore)、把本地副本直接列进恢复区的卡片 (现在走既有“选择恢复文件”,文件选择器本来就认.bk.zip)。
188. WebDAV 启动时自动恢复(对齐 Android 的 webdav_auto_restore / WebDavAutoRestoreRunner)(2026-10-25)
Android 在备份页和定期备份页都摆着“启动时自动恢复”(backup_periodic_webdav_auto_restore,默认关): 每天首次启动时由 WebDavAutoRestoreService + WebDavAutoRestoreRunner 列出远端、 只取 V3 命名空间里最新的那份、下载后按合并方式恢复一次,恢复完立刻把合并后的快照传回服务器, 再把“今天已经查过”的时间(backup_periodic_webdav_last_auto_restore_check)写盘。 判定细节:开关关或 WebDAV 配置不全直接跳过;yyyy-MM-dd 相同就跳过;列表为空/没有可用备份算“查过了”; 抛异常不算(下次启动还会再试)。
Windows 侧没有开机广播和前台服务,所以“启动时”是应用打开会话的那一刻:
DesktopWebDavSettings增autoRestore(键webdav_auto_restore,默认 false,与 Android 同语义), 页面在云同步卡的本地副本开关之上加启动时自动恢复一行(webdav_auto_restore与webdav_auto_restore_summary直接用 Android 原句),只有服务器地址填好才可点,标签webdav-auto-restore。DesktopWebDavOperations.autoRestore()返回DesktopAutoRestore:Disabled/NotDue/Nothing/Restored(name, merged, uploaded)/Failed。查过的时间存webdav_last_auto_restore_check_millis, 日期比较与 Android 一样按本地日历日(23:59 与次日 00:01 算两天),失败不落盘。- 恢复复用
DesktopLibraryBackup.preview()/restore()(和手动恢复、远端预览同一条路),下载走既有 staging 目录并在用完后删除;随后uploadLocked()把合并后的快照传回,所以另一台设备能合并到这次的并集。 - 控制器
startAutoRestore()是 one-shot:init里webDav.restore()之后跑一次,webDavSettings()保存后 再跑一次(用户刚打开开关就生效,不必等下次启动),成功时把items/content清空让界面重读, 并提示webdav.autoRestoredAndUploaded(新增桌面文案,base 与_zh-CN同时写);失败只留在webDavUi.error里,由页面自己显示。
验证:
DesktopWebDavAutoRestoreTest5 个用例:最新备份被合并并把合并快照传回(puts == 1)、当天再调返回NotDue且零请求、次日再合并再上传;“空服务器也算查过”(Nothing+ 当天不再请求); “失败的检查不算今天”(Failed且时间仍是 0,把服务器修好后同一天能重试成功); “开关需要完整目的地与显式同意”(关着或服务器为空时Disabled且requests == 0); “查过的时间能跨进程”(重开同一数据根仍是NotDue且不再请求)。- 真实窗口探针
backups-write/backups-read:断言webdav-auto-restore行存在、没填服务器时是禁用的、 填上服务器后变为可用且默认仍是关(截图backups-write-auto-restore.png:启动时自动恢复 / 应用每天首次启动时合并恢复 WebDAV 上最新备份一次,恢复完成后立即把合并后的快照上传回 WebDAV。)。 - 回归:
DesktopWebDavTest10、DesktopWebDavAutoBackupTest8、DesktopWebDavRoundTripTest1 全绿;:desktop-app:verifyDesktopStrings通过。
与 Android 不同的地方:没有系统级启动触发(只在打开应用时检查),也没有 TeleCloud/Telegram 目的地; 上传是“每次成功恢复后都传一次”(Android 只在写入器世代为权威快照时上传),对本机是幂等的。
189. 备份输出目录可自选(对齐 Android 的 backups_output_directory / periodicalBackupDirectory)(2026-10-25)
Android 备份页第一组第一行就是“备份文件保存目录”:AppSettings.periodicalBackupDirectory 存一个 SAF tree URI,BackupUtils 在 tree 解析不出来或 canWrite() 为 false 时回落到应用自己的备份目录, 页面把回落前的状态显示成 invalid_value_message 而不是让每次备份都失败。
Windows 之前这一行只读(显示数据根 backups,带一个“打开”按钮),用户在桌面端没有等价操作: 想把备份放到 OneDrive/网盘目录、或放到另一块磁盘上,只能手动搬文件。 本切片把它变成 Android 那样的可选目录:
- 设置里新增
DesktopWebDavSettings.outputDirectory(键backup_output_directory,空 = 数据根backups), 和已有的本地副本开关/保留份数写在同一个desktop_webdavstore 里。 desktop-runtime新增顶层函数desktopBackupDirectory(chosen, fallback):chosen非空且仍是可写目录时用它, 否则(空、路径名非法、不存在、不是目录、不可写)返回fallback——就是 Android 的回落规则, 所以用户把目录删掉/拔掉移动盘之后备份不会静默消失,而是回到数据根继续写。DesktopWebDavOperations不再在构造时固定DesktopLocalBackups,改为每次按当前设置解析 (localBackups(settings)/localDirectory(settings)):本地副本、纯本地定时备份的报错文案、 页面显示的目录都走同一个解析,settings()保存后重读列表,切换到新目录时旧目录的条目立刻不再列出。- 页面把原来的只读行换成 Android 的
backups_output_directory(标题直接用 Android 原句 “备份文件保存目录”), 行尾三个动作:选择…(JFileChooser只能选目录,与仓库导出/恢复用同一套 Swing 选择器)、恢复默认(只在自定义目录时出现)、打开(仍然打开当前这个目录)。 - 控制器
webDavOutputDirectory(Path?)像 Android 那样拒绝不是一个目录的路径(提示webdav.outputInvalid,设置保持不变),webDavOpenLocalDirectory()改为打开当前目录而不是数据根。 - 文案:新增
webdav.choose、webdav.outputReset、webdav.outputInvalid(base 与_zh-CN同时写), 删掉被替换的webdav.localOpen;:desktop-app:verifyDesktopStrings仍然要求无缺失、无未使用键。
验证:
DesktopLocalBackupsTest新增 2 个用例(共 9 个):可用的自选目录胜出;空串、纯空格、已删除目录、 普通文件、JVM 拒绝命名的路径都回落到数据根。DesktopWebDavAutoBackupTest新增 1 个用例(共 9 个):outputDirectory指向自选目录时纯本地定时备份 真的写进那个目录(数据根里没有同名文件,localDirectory与列表一致);把设置改到另一个目录后 列表换成新目录(旧的条目不再出现)且下一次备份写进新目录;把那个目录删掉后localDirectory回到数据根。- 真实窗口探针
backups-write/backups-read:断言webdav-local-open行存在、选择后摘要与webdavUi.localDirectory都变成所选目录、webdav-local-choose/webdav-local-reset都在、 传一个文件路径会被拒绝(设置不变且有提示)、恢复默认之后回到数据根(截图backups-write-output-directory.png:备份文件保存目录 / …\chosen-backups 选择… 恢复默认 打开)。 - 回归:
DesktopWebDavTest10、DesktopWebDavAutoBackupTest9、DesktopWebDavAutoRestoreTest5、DesktopWebDavRoundTripTest1、DesktopLocalBackupsTest9 全绿;:desktop-app:verifyDesktopStrings通过。
与 Android 不同的地方:Android 用 SAF,Windows 用系统文件夹选择器(只能是本机路径,没有 URI 授权); Android 的“备份频率”下拉(6 小时/1 天/…/1 个月)在 Windows 仍是“自动备份间隔(小时)”数字输入, 两者量纲不同但语义相同(0 = 关),这次没有改动。
190. 数据清理页的“章节”分组:删除已读章节 + 自动删除(对齐 Android 的 delete_read_chapters / chapters_clear_auto)(2026-10-25)
Android 的数据清理页(DataCleanupSettingsScreen)最后还有一个“章节”分组,两行:一行动作 delete_read_chapters(摘要 delete_read_chapters_summary,确认框 delete_read_chapters_prompt), 一行开关 delete_read_chapters_auto(摘要 runs_on_app_start,键 AppSettings.KEY_CHAPTERS_CLEAR_AUTO, 默认关)。Android 的“已读”不是逐章标记,而是相对历史记录:DeleteReadChaptersUseCase 取该作品的历史 章节,按话号排序、限定同一分支(branch),把“当前章节之前”的所有章节算作已读,再与真正存下来的章节取交集, 删掉交集——历史不在列表里、没有历史行、或者交集为空时什么都不做。Windows 之前这两行都没有, 清理页注释里写的是“章节那行就是下载页的批量移除”,但用户没法一键清掉已读的本地下载。
本切片把 Android 的这两行补上:
desktop-runtime新增DesktopChapterCleanup.kt,三个纯函数就是 Android 那套规则的可测内核:desktopReadChapterIds(chapters, currentChapterId)(当前章节之前的同分支章节;当前章节不在列表里 → 空集)、desktopChapterCandidates(saved, downloaded)(库里恢复的章节表 ∪ 已下载章节,按 id 去重,对应 Android 的(localChapters + dbChapters).distinctBy { it.id })、desktopReadSavedChapters(saved, downloaded, currentChapterId)(把交集窄到“真的存了”的章节, 来源列表里提到但没下载的章节永远不会被当成删除目标)。DesktopController.cleanupReadChapters()按downloadStore.records的contentId分组,逐部作品读历史 (session.library.progress)与章节表(session.library.find(...)?.chapters),算出已读的已保存章节, 然后走下载页批量移除同一条序列(downloads.pause→settle→downloadStore.delete→forget), 最后把选择集裁到还存在的行为,并报cleanup.readChaptersDeleted。图片文件不在这里删: 这些清单引用过的页面块变成“无引用的元数据”,由图片预算在下一轮清理里回收(和批量移除完全一样的手递手)。- 自动开关:
DesktopSession.chaptersClearAuto()/setChaptersClearAuto()读写新的desktop_cleanupstore 的chapters_clear_auto(与 Android 同键、同Toggle语义、默认关);DesktopAppState.chaptersClearAuto供页面渲染,控制器setChaptersClearAuto只是记住,不会立刻扫一遍——Android 的开关也是等下次启动。 会话打开时(init里startAutoRestore()之后)如果开关是开的,就跑一次runCleanupReadChapters(announceEmpty = false): 没有可删的章节时不弹任何提示,有的话留下cleanup.readChaptersDeleted消息。 - 页面
DesktopDataCleanupPage加 Android 的“章节”分组:动作行(cleanup-read-chapters, 没有已保存章节时禁用——Android 也是这个规则)、开关行(cleanup-read-chapters-auto), 确认框用 Android 的delete_read_chapters/delete_read_chapters_prompt(cleanup-read-chapters-dialog、-confirm、-cancel),零删除时用 Android 的no_chapters_deleted。 - 文案:新增
cleanup.readChaptersDeleted(base 与_zh-CN);其余标题/摘要/提示/开关摘要/空结果都取 Android 原句 (AndroidStrings)。
验证:
desktop-runtime的DesktopChapterCleanupTest5 个用例:话号乱序也能按顺序判定、当前章节与其后的章节保留、 另一个分支独立、当前章节不在列表里(或列表为空)算零已读、下载章节补进候选且不重复。desktop-app的DesktopReadChaptersCleanupTest2 个用例:写 4 章真记录进DesktopDownloadStore, 历史停在第 3 章时只删第 1、2 章(重开 store 证明清单真的没了,第 3、4 章还在); 历史 id 对不上时一章不删。- 真实窗口探针
DesktopStorageProbe:在数据清理页断言cleanup-read-chapters在没有保存章节时是禁用的、 自动开关默认关、点击后state.chaptersClearAuto变真且desktop_cleanup里存的是chapters_clear_auto=true(截图storage-data-cleanup-chapters.png:章节 / 删除已读章节 / 自动删除已读章节 [开])。
与 Android 不同的地方:Android 在删除后用数据库体积差报“释放了多少字节”,Windows 拿不到可靠的数字 (页面块要等图片预算回收才真正落盘),所以只报删掉的章数;Android 的自动删除挂在 WorkManager/启动服务上, Windows 是会话打开时同步跑一次;章节表只取库里恢复的那份(离线优先),没有 Android 那种“表里没有就现拉远端章节列表” 的回退,所以一部从没在桌面端打开过详情的作品不会被这一步判定。
191. 详情页的相关作品与「相关漫画」开关(对齐 Android 的 related_manga / RelatedContentUseCase)(2026-10-25)
Android 的详情页在简介/标签下面还有一条“相关作品”(details_related_works)横向列表,点一张就换到那部作品; 它由服务页的 related_manga 开关(services 分组,摘要 related_manga_summary)决定要不要问来源。 数据来自 RelatedContentUseCase:先让来源自己给相关列表(Mihon 的 fetchRelatedMangaList,supportsRelatedMangas 且没被扩展禁用时),否则退回 RelatedContentSearchFallback——拿作品的主标题与别名(再加标题里的词,最多 6 个查询) 逐个搜索,只留“标题或别名里出现过这个查询”的结果,最后取结果最少的那一组。取回来之后还要把作品自己滤掉: 同 id、同 URL/公开 URL 身份、或同标题配同一张封面(按 URL 路径比较,换域名也算同一张图)。Windows 之前这整块 都没有,服务页注释里也把 related_manga 写成“没有 Windows 行为”。
本切片把这条链路补上,并把可以共享的规则真的搬进 core-domain:
core-domain新增details/domain/RelatedContentRules.kt:searchQueries(title, altTitles)(主标题 → 别名 → 标题里的词,去重去空、最多MAX_QUERIES = 6)、isSearchCandidate(query, RelatedCandidate)(标题或别名包含该查询, 忽略大小写)、smallestResult(sets)(结果最少的一组,空输入给空表)。Android 的core/parser/RelatedContentSearchFallback.kt改成调用这三个函数,只留自己的id/url/publicUrl去重, 所以两端的位置与取舍规则是同一份代码,不再各写一遍。core-domain还新增tracking/domain/TitleKeys.kt:normalize(去首尾空白、小写、标点/符号变空格、折叠空白) 与normalize(value, sourceNames)/stripTrailingSourceSuffix(去掉与来源名重复的(来源名)尾巴)。 Android 的tracking/discovery/domain/TitleSimilarity.kt里normalizeStrictTitleKey与stripTrailingSourceTitleSuffix现在只是它的薄封装(模糊评分要用java.text.Normalizer,仍留在 Android)。core-domain再新增details/domain/RelatedWorkRules.kt:filterCurrentWork(seed, candidates, key)就是 Android 的filterCurrentWorkFromRelated——别的来源的候选一律保留;同来源时丢掉同 id、ContentIdentityKeys.hasSameIdentity(...)命中的、以及TitleKeys严格标题相同且封面路径相同的 (封面按 http(s) 的路径比,换域名也算同一张图)。两端只各自提供投影:Android 的RelatedContentUseCase.kt现在把Content投成RelatedWorkKey,Windows 的desktopRelatedWorks(seed, candidates)把SourceContent投成同一结构,所以“什么算作品自己”这条规则只有一份。封面路径的取法为了留在commonMain没有用java.net.URI(compileCommonMainKotlinMetadata会拒绝),改成纯字符串切 scheme/host 再截 query/fragment。desktop-runtime新增DesktopRelatedContent.kt:desktopRelatedWorks在共享规则之上只多加一件事—— 按来源:id:url去重,同一部作品在来源里列两次也只出一张卡。DesktopServicesSettings加relatedContent(Android 的KEY_RELATED_MANGA = "related_manga",默认开, 存desktop_services);服务页在推荐行下面多一行services-related-manga,标题/摘要都用 Android 原句。DesktopController在detailsOnIo末尾调loadRelatedWorks(content):先清空列表、置relatedLoading, 在独立的relatedJob上跑(不进action{}/navigate{}的门,页面在等的时候照样能点)。 取数据先试session.sources.getRelated(seed)(parser 插件与将来的 host 能用),失败或空就用session.sources.getList(name, 0, null, SourceFilter(query = ...))走RelatedContentRules那套搜索回退, 最后过desktopRelatedWorks。related_manga关着时直接空表且不发请求;写回状态前核对content.id与来源名,慢回来的旧请求不会覆盖新作品。切开关时如果详情还开着,立刻按新值重取或清空;dismissDetails关掉页面时取消relatedJob并清空列表,不会有关掉的页面还在后台搜索 6 个查询。- 一天里最关键的一条:Mihon 的桌面 host 根本不给相关列表(
MihonSourceRuntime.getRelated直接抛SourceOperationUnsupportedException),所以 Windows 上绝大多数来源唯一的路径就是搜索回退——这也正是 Android 对!supportsRelatedMangas扩展的做法,行为与手机一致,而不是“开关打开但永远空白”。 - 界面
DesktopDetailsRelated.kt:只有拿到结果或还在加载时才出现(没相关作品的作品页面不会多一块空白), 标题用details_related_works,加载时标题旁一个小进度圈,卡片是 96dp 宽的 2:3 封面 + 两行标题 + 来源名, 点击controller.details(work);整块testTag是details-related、卡片details-related:<id>。
验证:
core-domain:RelatedContentRulesTest5 个用例(标题/别名/词与去重、单字丢弃与 6 个上限、空标题、 候选判定含别名、最小组与空输入);TitleKeysTest4 个用例(大小写/标点/空白、来源名尾巴被去掉、 别的括号尾巴保留、空括号不切标题);RelatedWorkRulesTest5 个用例(同 id/同 URL/同公开 URL 都算自己、 同标题配同一张封面路径(含换域名带 query)算同一部、不同标题或不同封面保留、别的来源同 id 不算自己、 没有封面时不能只按标题判同)。前者:core-domain:compileCommonMainKotlinMetadata通过,规则可移植。- Android:
RelatedContentUseCaseTest2 个用例在改用共享规则后仍然通过,TitleSimilarityTest(10)、TitleNormalizerTest(6)也通过,RelatedContentSearchFallback换成RelatedContentRules后:app:compileDebugKotlin通过。 desktop-runtime:DesktopRelatedContentTest6 个用例(同 id/同 URL/同公开 URL 都丢、同标题不同封面保留、 同标题同封面换域名也丢、不同标题同封面保留、别的来源同 id 不算自己、重复项合成一张卡、related_manga默认开且能来回存取)。desktop-app真实窗口探针DesktopServicesProbe:详情页等relatedLoading结束,断言state.relatedWorks非空、details-related与第一张卡存在(截图details-related.png); 服务页断言services-related-manga行与 Android 的两个字符串存在,点一下确认开关关掉、列表立刻清空、desktop_services里存的是related_manga=false,再点回开并等列表重新填上(截图services-page-related-on.png), 最后再关一次并留在关的状态,让读阶段断言重启后relatedContent == false、related_manga == false。 探针能拿到结果靠的是OfflineSource.fetchSearchManga现在给搜索结果一个自己的 URL(/search?q=...): 之前搜索与热门共用/manga,而 Mihon 的 id 由 URL 派生,回退搜索会把作品自己当成“同一部”滤掉。
与 Android 不同的地方:Android 的相关列表是 Compose 的 LazyRow 而不是分页,Windows 同样一次只取一组结果; “什么算作品自己”的判断现在是同一份 RelatedWorkRules,Windows 只多一层去重;Android 还有“浏览记录推荐” (browse_tracking_recommendations)那条来源,它依赖整个 tracking-discovery 子系统,桌面仍然没有。
192. 来源开关对话框的选中态:全选 + 批量启用/禁用/测试选中来源(对齐 Android UnifiedSourceSelectionBar)(2026-10-25)
§174–§175 把 Android 来源开关的逐行开关与“按当前筛选结果全部启用/禁用”搬进 Windows 之后,统一来源页还剩一条能力: Android 长按来源卡进入选中模式,整条工具栏换成 已选 N / 全选 / 启用 / 禁用 / NSFW / 测试 / 删除 (UnifiedSourcesScreenBody.UnifiedSourceSelectionBar)。这一节补上其中除删除与 NSFW 之外的三项。
- 纯规则写在
DesktopSourceSwitch.kt(与 §175 的filterSourceSwitchEntries同一文件):toggleSourceSelection(selected, name)点一下加入、再点移出,空集合就是退出选中模式(对应 Android 的isSelectionMode = selectedSourceIds.isNotEmpty());toggleVisibleSourceSelection(selected, entries)是 Android 的 全选芯片——可见行全在集合里就整体移出,否则全部加入,空列表原样返回;everyVisibleSourceSelected(selected, entries)决定芯片读“全选”还是“取消全选”,空列表不算全选。只有搜索与启用筛选留下的行参与,和上面那对 “全部启用/禁用”用的是同一份entries。 DesktopSourceSwitchTest增四条:加入/移出/清空;全选只覆盖可见行且能整体退回(查询把第三行筛掉时点全选不会带上它); 空列表既不全选也原样返回;标签只在可见行全选中时翻转(选中里混着被筛掉的行仍读“取消全选”,因为全选只代表这份列表)。 该类合计 11 条。- 入口与 Android 有意不同:Android 靠长按来源卡进入选中模式,鼠标用户没有理由在设置行上试长按,所以 Windows 的 “全选”按钮兼作入口——点一下即进入选中模式并选中全部可见行。进入后行换成整行
Role.Checkbox切换 (source-selected:<name>是行,右侧是Checkbox),再点“全选”读作“取消全选”;退出是“取消” (source-selection-close)或把选中清空。计数用 Android 的selected_count(source-selection-count)。 - 选中态的三个动作都是 Android 的:启用/禁用直接走 §175 已有的
setSourcesEnabled(names, enabled)(整批一次写盘、 一次状态刷新,“正在浏览的来源被关掉就退出浏览”这条对选中同样成立),写完清空选中并按结果切换筛选 (与确认框那条规则一致,否则刚点完按钮列表就空了)。测试那一个先showSourceTest()(进页面前重置上一次结果) 再startSourceTest(names):DesktopController.startSourceTest现在收一个可选的名字集合,对应 Android 的testSources(selectedSourceIds)(默认仍只跑已启用来源);池子取state.sources(已装)而不是enabledSources, 所以选中里可以有被关掉的来源,与 Android 选中列表一致。 - 删除这一片没做:Android 的删除要先按安装包折叠成
UnifiedSelectedSourceDeletePlan(选中若干来源 → 删掉声明它们的那个包),留待下一片;NSFW 芯片原计划也在这里,后来单独成 §193(每来源的成人标记覆盖 + 选中栏那颗芯片),所以本节只覆盖启用/禁用/测试选中来源三项。 - 探针(
DesktopRepositoriesProbe的 write 阶段):全选→ 出现source-selected:<name>与已选 1(截图repositories-write-selection.png),再点该行即退出模式且什么都没写;全选→禁用→disabledSources恰为那一个、选中行消失、筛选跟到“已禁用”且开关是关的;全选→启用→disabledSources清空; 最后切回“全部”继续原来那条单开关路径。
验证:
:desktop-app:test --tests=*SourceSwitch*:DesktopSourceSwitchTest11 条(含新增四条)全绿。:desktop-app:test:51 个套件 / 282 个用例(此前 278 + 新增 4)/ 0 失败;:desktop-runtime:test28 个套件 / 181 个用例 / 0 失败;:desktop-app:verifyDesktopStrings通过(文案全用 Android 键, 桌面 catalog 未增删,仍是 958 键)。
193. 每来源的成人标记覆盖:共享规则 + 选中栏 NSFW 芯片(对齐 Android SourceNsfwOverrides / setSourcesNsfw)(2026-10-25)
§192 的选中栏里 Android 还有一颗 NSFW 芯片,它既是 Android 唯一能改写“这个来源算不算成人”的入口,也决定 AdultContentFilterTarget 那七个过滤目标在哪里生效。Windows 之前把成人判定写死成 isNsfwSource() 里看 contentType 前缀 HENTAI_,没有任何可写的偏好,所以这一片先把 Android 的规则下沉到 core-domain,再给 Windows 补上存储、状态、芯片与对话框。
- 共享规则
core/extensions/SourceNsfwRules.kt(core-domain的commonMain):SelectedSourcesNsfwAction { NONE, SET_NSFW, SET_SFW, CHOOSE }与resolveSelectedSourcesNsfwAction(nsfwCount, selectedCount)逐条照 Android 的ContentSource/选中栏判定: 选中为空是NONE、没一个成人是SET_NSFW、全是成人是SET_SFW(nsfwCount >= selectedCount也算, 所以计数偏大不会漏判)、其余CHOOSE。SourceNsfwOverrideSet(nsfw, sfw)加resolve(name)(先查sfw再查nsfw,都查不到才null—— SFW 优先, 与 Android 的SourceNsfwOverrides.resolve一致)、withOverride(names, isNsfw)(true进nsfw出sfw、false反过来、null从两边摘掉,空白名字丢弃)与changedNsfwSources(names, isNsfw, isNsfwNow)(AndroidUnifiedSourcesViewModel.setSourcesNsfw只写真正变了的 id)。- 名字叫
SourceNsfwOverrideSet而不是SourceNsfwOverrides,因为 Android 还有一个同名的进程级持有对象 (core/model/SourceNsfwOverrides.kt的update/resolve),两者在AppSettings.kt里会撞 import。
- Android 侧改吃这份共享规则:
AppSettings.setSourceNsfwOverride现在把两个集合读成SourceNsfwOverrideSet、 用withOverride算出新值再写两个键(行为不变,只是规则不再有两份),UnifiedSourcesScreen.kt里本地的 enum 与resolveSelectedSourcesNsfwAction删掉后从core.extensions导入。SourceNsfwOverridesTest(8 条,含新增两条) 顺手钉住“共享集合驱动 app 读到的持有对象”和“只持久化真正变了的来源”。 - Windows 存储沿用 Android 的键与同一份 store:
desktop-runtime的KEY_SOURCE_NSFW_OVERRIDES = "source_nsfw_overrides"、KEY_SOURCE_SFW_OVERRIDES = "source_sfw_overrides"与desktopSourceNsfwOverrides(values)负责解码,DesktopSession.sourceNsfwOverrides()读desktop_extensions、setSourcesNsfw(names, isNsfw)一次edit写两个集合 (同disabled_sources的写法,崩溃也不会让一个名字同时落在两边)。DesktopAppState.sourceNsfwOverrides在open/refreshSources时填充,DesktopController.setSourcesNsfw先用changedNsfwSources与当前有效标记比对, 空集合直接返回,写完再读一次 store 刷新状态。 isNsfwSource(overrides)取代原来只看内容类型的版本:覆盖优先,其次contentType的HENTAI_前缀。 host 侧本来就把各生态自己的元数据折进了内容类型(MihonJarRegistry对metadata.isNsfw的包直接写HENTAI_$kind),所以“扩展自己声明成人”这一层在 Windows 上不缺,缺的只有用户覆盖。消费方:来源栏 (DesktopApp.kt)与来源网格(DesktopSourceGrid.kt)的SOURCES目标、以及推荐生成 (DesktopSuggestions.refresh(sources, settings, nsfwOverrides),excludeNsfw现在看的是同一份有效标记)。- 选中栏芯片(
source-selection-nsfw)夹在“禁用”和“测试源”之间,位置与 Android 相同:文案由selectedSourcesNsfwLabel(action)给出 Android 的unified_sources_mark_nsfw/unified_sources_unmark_nsfw/unified_sources_set_nsfw;SET_NSFW/SET_SFW直接写并退出选中模式(不清筛选,成人标记不会让行换边),CHOOSE弹 Android 的unified_sources_set_nsfw_title对话框,两行选项unified_sources_mark_as_nsfw/unified_sources_mark_as_sfw加取消。动作行从Row换成FlowRow,窄窗口下按钮换行而不是被裁掉。 - 探针(
DesktopRepositoriesProbe):安装的那个来源是普通漫画,所以选中后芯片读“标记NSFW”,点一下把sourceNsfwOverrides.nsfw写成它、sfw为空且退出模式;再选中时同一颗芯片变成“取消NSFW”,点一下换成sfw = {name};第三次点回“标记NSFW”并留在那个状态(截图repositories-write-nsfw-marked.png), 并核对 store 里的source_nsfw_overrides恰为该名字。离线进程再起来时sourceNsfwOverrides.nsfw仍是它, 且isNsfwSource(...)对该来源返回 true。 - 仍未对齐:选中栏的“删除”(Android 要先按安装包折叠成
UnifiedSelectedSourceDeletePlan);BOOKMARKS/SUGGESTIONS两个 NSFW 目标(桌面还没有对应页面);Mihon 真实的相关推荐 API(与本节无关,见 §191)。
验证:
:core-domain:jvmTest --tests=*SourceNsfwRules*:新增 7 条全绿(动作表、空集、双向覆盖、SFW 优先、批量搬移、 空白批次、changedNsfwSources)。:app:compileDebugKotlin与:app:testDebugUnitTest --tests=*SourceNsfwOverridesTest:编译通过、8 条全绿, 证明共享化没有改变 Android 行为。:desktop-app:test --tests=*DesktopSourceSwitchTest*14 条、:desktop-runtime:test --tests=*DesktopSourceSettingsTest*12 条全绿(含“覆盖优先于内容类型”“存的是 Android 的键、坏类型只丢那一项”“芯片动作随有效标记变化”)。:desktop-app:test --tests=*repository*:离线双进程探针通过,DESKTOP_UI_OK=write与离线断言均成立。- 全量基线:
:desktop-app:test51 个套件 / 285 个用例(此前 282 + 新增 3)/ 0 失败;:desktop-runtime:test28 个套件 / 183 个用例(此前 181 + 新增 2)/ 0 失败;:core-domain:jvmTest41 个套件 / 255 个用例 / 0 失败;:desktop-app:verifyDesktopStrings通过(文案全用 Android 键,桌面 catalog 未增删,仍是 958 键)。
194. 选中栏的「删除」:把选择折成要卸载的扩展包(对齐 Android UnifiedSelectedSourceDeletePlan)(2026-10-25)
Android 统一来源页的选中栏最后一个动作是删除。一个包常常声明多个来源,所以选中的来源要先折成“要卸载哪些包”; 如果声明它的是单文件 JAR 插件,就不能因为选中了其中一个来源就把整个插件删掉,只能报告并跳过。
共享规则
core-domain的core/extensions/SourceDeletionRules.kt:SourceDeletionCandidate(来源名 + 声明它的包 id 与显示名 + 是否可卸载)、SelectedSourcesDeletePlan(平行的packageIds/packageNames、skippedNames、hasPackages、isEmpty)、selectedSourcesDeletePlan(selectedSourceNames, candidates)。- 规则逐条对齐 Android 原实现:每个包只出现一次且按候选顺序(对话框要数包,调用方要按同一顺序卸载); 没有包的来源(内联 JSON 源)不产生任何东西;不可卸载的包只进
skippedNames,名字取包显示名、空了退回来源名。 SourceDeletionRulesTest(core-domain/src/jvmTest):6 条,覆盖折叠去重、顺序、跳过、无包来源、空选择与名字回落。
Android 改为消费共享规则
UnifiedSourcesDisplay.buildDeletePlan只剩投影:allSources/allPackages变成候选,包显示名按allPackages[id].name → item.packageName → item.title取,UnifiedSourceKind.JAR记为不可卸载。- 本地
UnifiedSelectedSourceDeletePlan删除,UnifiedSourcesScreen的DeleteSelectedSources与UnifiedSourcesDialogs直接用共享类型,四条弹窗文案与按钮顺序不变。
Windows
DesktopSourceSwitch.sourceDeletePlan(selected, installedEntries):每个已装包按它声明的来源展开成候选,包 id 用DesktopInstalledEntry.id、显示名用label。DesktopInstalledKind.PARSER(单文件解析器 JAR 插件)记为不可卸载, 与 Android 跳过 JAR 源同一理由;JSON/TVBox 来源不属于任何已装包,因此不参与删除(与 Android 的空packageId一致)。DesktopController.uninstallPackages(packageIds):在一个action{}里逐个session.uninstall(entry)——先删安装记录、 再卸 loader、再清disabled_sources里的死键与未引用产物;单个包失败只记录并继续,最后一次性刷新 sources/disabled/每来源成人覆盖/已安装列表/更新列表。成功消息用 Android 的extensions.uninstalled, 失败拼在桌面新增的extensions.uninstallFailed(en 与 zh-CN 两份桌面 catalog 都补了)。- 选中栏在“测试源”之后多出「删除」(
source-selection-delete,文案沿用 Android 的delete)。点击先算计划再弹 Android 的UnifiedDeleteSelectedSourcesDialog(source-switch-delete-dialog):包数与跳过的插件名走unified_sources_delete_selected_with_skipped_jars/_message/_only_skipped_jars/_no_packages四条原句。 有计划时确认(source-switch-delete-confirm)卸载并退出选中态,取消(source-switch-delete-cancel)留在栏里; 没有可删的包时只有一个common.ok(source-switch-delete-close),和 Android 一样只是告知。 - 既有差异保留:Android 靠长按进入选中态,Windows 用“全选”兼作入口,见计划 §192。
验证
:core-domain:jvmTest --tests=*SourceDeletionRules*:6 条全绿。:desktop-app:test --tests=*DesktopSourceSwitchTest*:17 条全绿(新增 3 条:一个包的多个来源折成一条、 解析器插件只报告不删、没有任何包声明的来源不产生计划)。:desktop-app:test --tests=*repository*:双进程探针通过。写进程选中全部来源后点「删除」,弹窗显示 “删除选中的 1 个扩展包?”(截图repositories-write-package-count.png),取消后包仍在,离线进程照旧读得到该来源。:app:compileDebugKotlin:通过(Android 侧只是改成消费共享规则与共享类型)。- 全量基线:
:desktop-app:test51 个套件 / 288 个用例(此前 285 + 新增 3)/ 0 失败;:desktop-runtime:test28 个套件 / 183 个用例(不变)/ 0 失败;:core-domain:jvmTest42 个套件 / 261 个用例(此前 41 / 255 + 新的SourceDeletionRulesTest6 条)/ 0 失败;:desktop-app:verifyDesktopStrings通过——桌面 catalog 增加 1 个仅桌面使用的extensions.uninstallFailed(卸载失败前缀),其余文案仍是 Android 键,共 959 键 / 959 条被引用。
195. Windows 的书签页(对齐 Android AppBookmarksRoute / AllBookmarksViewModel)(2026-10-25)
Android 的书签是顶层目的地(NavItem.BOOKMARKS,标签取 R.string.notes),页标题用 R.string.bookmarks。 Windows 此前只有详情页里的“选择书签”面板,没有“全部书签”这一层,disable_bookmarks_nsfw 也因此没有落点。 本切片补齐页面本身,并把成人过滤的第六个目标接上。
共享规则(core-library / desktop-runtime)
SourceBookmark末位补chapterTitle(AndroidBookmark.chapter.title,原本只有chapterId),SourceLibrary.bookmarkGroups()用ChaptersDao.findByIds一次查出标题再回填;新增removeBookmarks(pageIds)在单个immediateTransaction里删。SourceBookmarkGroup(作品 + 它的书签)与DesktopBookmarkShelf.kt的desktopBookmarkShelf(groups, hideNsfw): 丢掉空组、按hideNsfw滤掉成人作品、组内保持 DAO 的percent顺序,组间“最新保存的位置优先”、同值按标题。 Windows 这页没有 Android 的排序控件,所以顺序写成固定规则。DesktopNsfwTarget增加BOOKMARKS(Android 的七目标里 SUGGESTIONS 不映射,因为推荐页编辑的就是 Android 自己的suggestions_exclude_nsfw);DesktopNavItem增加BOOKMARKS(nav_main默认仍不含它, 与 Android 默认列表一致,MainNavigationRules.MAX_ITEMS是 5 所以要先移掉一项才能加)。
Windows 外壳
DesktopScreen.BOOKMARKS:showBookmarks()进入时reloadBookmarkShelf(),选中集与现存 pageId 求交;openShelfBookmark(group, bookmark)用本地find取整份章节表(源没了也能重开),把detailsOrigin记成BOOKMARKS,再startReading(..., incognito = true)——Android 用ReaderIntent…incognito()打开书签, 免得“回到保存的位置”覆写进度;dismissDetails回到BOOKMARKS时重读书架,覆盖详情里删掉书签的情况。DesktopBookmarksPage:bookmarks-count(未选中显示 Android 的reader.bookmarksCount,选中显示details.bookmarksSelectedCount)、bookmarks-select-all/-select-none/-select-delete、空态bookmarks-empty(no_bookmarks_yet+no_bookmarks_summary)、bookmarks-list按作品分节 (bookmark-group:<id>带数量与「更多」→ 详情,bookmark-row:<pageId>带章节标题/小说摘录/位置、bookmark-check:<pageId>)。- 入口三处:首页快捷卡第 4 格(
home-quick:3,排在收藏/历史/订阅之后,所以home-quick:0仍是收藏, §192 的平板探针不受影响)、主导航DesktopNavItem.BOOKMARKS(desktopNavItemId6、图标ic_bookmark、 标签AndroidStrings["notes"])、来源设置成人节的第六个目标(sources.nsfw.bookmarks,en/zh 都补了;sources.adultNote改成只说推荐页有自己的开关)。 - 两处刻意差异:Android 的
SelectionAction.SAVE(PageSaveHelper)在 Windows 没有对应管线,选择栏只留删除; Android 画海报卡片并在卡上显示保存的那一页,Windows 用文本行(章节、位置、小说摘录), 只有当用户真的打开某一条时才碰来源,一页几十条书签不会各自起一次下载。长按进入选中态改为每行常驻复选框, 与 §192 的处理一致。
验证
:desktop-runtime:test --tests=*DesktopBookmarkShelfTest*:4 条(最新优先、空组丢弃、成人过滤开关、组内顺序)。:desktop-app:test --tests=*DesktopBookmarksShelfTest*:双进程探针通过——写进程存一个第 4 页书签, 读进程从首页快捷卡进书签页,断言分节/行/复选框存在,点行后阅读器以无痕打开同一章节同一像素位置且 历史行与写进程留下的一模一样,返回详情再回书签页,全选 + 删除后书架与数据库都空、显示空态。:desktop-runtime:test187 条 0 失败(DesktopNavigationSettingsTest与DesktopBackgroundSettingsTest的枚举断言随新目标/新导航项一起更新);:desktop-app:verifyDesktopStrings通过,960 键 / 960 条被引用。
196. 滚轮与触控板滚动不再拖走锚定面板(issue #601)(2026-10-25)
用户报告(#601,Windows 2.3.2 / Windows 11 / Lenovo IdeaPad Slim 3):在阅读器里用触控板双指滚动章节列表, 章节面板反而一直开开合合,根本滚不动。原因在共享的 core-ui 锚定面板而不是 Windows 外壳:
根因
StableAnchoredSheetLayout的嵌套滚动回调把任何NestedScrollSource.UserInput的纵向增量先交给面板:onPreScroll只在“面板没有在拖、且已完全展开”时才放手,onPostScroll在内容到顶后继续接。面板不在Full锚点时,第一格滚轮就被dispatchNestedDelta吃掉去挪面板,列表拿到 0。- 滚轮/双指滚动以
PointerEventType.Scroll到达,没有按下事件,于是hasNestedDrag()变真后onPreFling的settle把面板往下一个锚点送,到Hidden就onDismissRequest——面板关闭;反向再滚又打开。 触屏拖动本来就该这样(Android 上就是拖着收面板),所以问题只出在“没有按下的滚动”被当成了拖动。
修法(共享,Android 接鼠标时同样受益)
StableSheetState记住“最近一次指针输入是不是只有滚动”:noteScrollInput()/notePressedInput(),isScrollOnlyPointer();onPreScroll/onPostScroll第一行在这个状态下直接返回Offset.Zero, 面板不再吞滚轮,hasNestedDrag()也不会被滚轮点亮。- 面板根节点用一个
Modifier.pointerInput在PointerEventPass.Initial上观察事件类型(父节点先于内容收到, 所以判定总在内容派发嵌套滚动之前):Scroll置位、Press清位。真实拖动(有按下)仍保留 Android 的 “拖列表到顶继续往下收面板”行为,滚轮/触控板永远只滚列表。 - 标题/芯片上的
anchoredDraggable(dragModifier)走的是按下拖动,不经过这条判定,没有改动。
验证
:desktop-app:test --tests=*StableSheetWheelScrollTest*(新增,2 条):在共享面板里放一个LazyColumn, 滚轮上下滚必须滚动列表且onDismissRequest一次都不触发(修复前该条红:列表不动,增量被面板吃掉); 另一条按下拖标题,面板仍必须真的下移、列表不动——保证没有把 Android 的收起手势一起关掉。:desktop-app:test --tests=*DesktopChapterPanelScrollTest*(新增):manga-directory探针在真实阅读器的 章节面板里多了一步滚轮(scroll(4f)后列表位置必须前进、面板必须还在、页码与导航不变)与反向大步滚(面板仍在), 随后原有拖动/筛选/网格/搜索步骤照旧。- 全量:
:desktop-app:test292 条 0 失败、:desktop-runtime:test187 条 0 失败、:core-domain:jvmTest261 条 0 失败、:desktop-app:verifyDesktopStrings960 键 / 960 被引用、:core-ui:compileCommonMainKotlinMetadata与:app:compileDebugKotlin通过。
197. Windows 补上 Android 的更新页(2026-10-25)
目标里的“补全功能”继续推进。Android 的 NavItem.UPDATED(UpdatedTopLevelRouteContent + UpdatesViewModel) 在 Windows 一直没有对应页面:订阅页只把新章节画成轮播,轮播的“全部”还是死链(onMoreClick = null)。 本轮把这一页接上,并把入口、清计数语义和导航项一起对齐。
数据(共享规则)
FeedUpdateRow是“每条追踪一行”的投影(v84 已无实体图谱),所以页面必须先按作品折起来:desktop-runtime新增DesktopUpdates.kt,desktopUpdateGroups(rows, hideNsfw, acceptsSource)以displayMangaId ?: mangaId为键,章节数相加(负数按 0)、lastChapterDate取最大、代表行取lastCheckTime最新的那条(标题/封面/来源/成人标记都来自它),ownerIds保留每条追踪以备“标为已读”。- 排序:置顶优先,随后新章节日期倒序、检查时间倒序、标题。
lastChapterDate缺失的作品不编造日期,排在最后 (Android 那边同样用updated_long_ago兜底)。 - 成人目标沿用订阅轮播的
sources.nsfw.updates;来源/内容类型筛选与标签黑名单走订阅页同一套acceptsFeedSource(从private改成internal复用)与TagTitleBlacklist。 desktopUpdatesSummary给出头部那句“N 部有更新 · M 个新章节”(updated_header_summary)。
页面(DesktopUpdatesPage)
- 头部:标题
updated、副标题是上面那句摘要、来源筛选条、检查更新按钮、updated_header_all_read(无更新时禁用)。 - 行:封面、标题(置顶画
ic_pin)、来源 · N 个新章节、日期(没有日期用updated_long_ago)、 “继续阅读”与“移除”;点行本身进详情(Android 的openTracked)。 - 追踪关着时(
trackedCategories == 0)显示订阅页同一块“追踪未开启”提示与一键开启;空态用 Android 的updated_header_up_to_date与其副标题。 - “移除”和“全部标为已读”是 Android 的同一个动作
UpdatesViewModel.remove→TrackingRepository.clearUpdates→core-domain的completePendingUpdates:清章节计数与待读标记,历史、进度与时间线日志都不动。 点开一行仍只是markSeen(Android 的clearReadUpdates)——Android 要等新章节真的读到末页才清计数, 探针也按这条断言,免得把“点开”写成“已读”。
入口与导航
- 轮播“全部”调
controller.showUpdates();共享UpdatedContentCarousel只新增一个可选moreModifier(桌面给按钮挂feed-updated-more,Android 不传,行为不变)。 DesktopNavItem.UPDATED(id 7、标签updated、图标ic_updated)可加入主导航。默认顺序仍是 Android 的 五项上限,所以和 Android 一样要靠“导航配置”把它换进去——订阅页轮播才是主要入口。- 详情页的返回起点
DetailsOrigins与dismissDetails的刷新条件都加入 UPDATED:从更新页进详情再返回, 仍在更新页且计数是最新的。
验证
:desktop-runtime:test --tests=*DesktopUpdatesTest*(新增 4 条):两部作品两条追踪的折叠(ownerIds、 章节数相加、代表行的来源、日期取最大、按日期倒序)、负数计数按 0 计、置顶优先且成人过滤生效、 来源过滤先于折叠并影响摘要。*DesktopNavigationSettingsTest*的“可添加项”断言随新导航项更新。:desktop-app:test --tests=*DesktopUpdatesPageTest*(新增探针,updates模式):导入 fixture 来源、收藏一部作品、 直接写一条chapters_new = 2的 tracks 行,从订阅轮播的“全部”进更新页,断言标题句、行与章节数; 点行进详情且计数仍为 2,返回仍在更新页;行“移除”后计数归零、进度不变、出现空态; 再种 3 章用“全部标为已读”清空;“继续阅读”打开阅读器并加载出页面。- 全量:
:desktop-app:test293 条 0 失败(4 跳过)、:desktop-runtime:test191 条 0 失败(2 跳过)、:core-domain:jvmTest261 条 0 失败、:desktop-app:verifyDesktopStrings960 键 / 960 被引用、:core-ui:compileCommonMainKotlinMetadata与:app:compileDebugKotlin通过。
198. Windows 补上 Android 的推荐页(对齐 Android NavItem.SUGGESTIONS / SuggestionsTopLevelRouteContent)(2026-10-25)
承接 §179 的推荐设置(worker 规则已对齐):Android 在 settings.isSuggestionsEnabled 时提供 NavItem.SUGGESTIONS 顶层页(MainShellScene.kt 的 SuggestionsTopLevelRouteContent + SuggestionsHeaderCard + SuggestionsListQuickFilter),Windows 此前只有首页的推荐轮播和推荐设置,没有“把整份推荐当列表看”的页面。 本轮补上页面、快捷筛选和导航项。
数据(共享规则)
desktop-runtime新增DesktopSuggestionFilters.kt。DesktopSuggestionFilter是 AndroidSuggestionsListQuickFilter的三类芯片:Tag(title)、Source(name)、Adult/Safe(Macro.NSFW与 SFW)。desktopSuggestionFilters(items, includeAdultChips, tagLimit = 5, sourceLimit = 3)照 Android 的顺序拼芯片: 先getTopTags(5),再两个宏(仅当!settings.isSuggestionsExcludeNsfw,即includeAdultChips), 最后getTopSources(3);空列表没有芯片。- 计数与 Android 的 SQL 不同但结论相同:Android 对
manga_tags/manga.source分组计数,Windows 手里已是SourceContent列表,所以按标题分组(大小写不敏感、同一部作品重复标签只算一次、同数时按标题升序, 保留首次出现的写法),来源按source.name计数。标签芯片因此按标题匹配,而不是 tag 行 id——页面画的就是标题。 SourceContent.acceptsSuggestionFilters(filters)是 Android DAO 的 AND 语义:每个芯片都必须命中, 空集合不过滤;Adult/Safe走core-source的isNsfw(ADULT或HENTAI_前缀来源类型), 所以两个宏同时选中必然空列表。desktopSuggestedContents(items, filters)是页面用的投影(空集合原样返回)。
页面(DesktopSuggestionsPage)
- 头部:
TopBarTitleBlock(suggestions, subtitle = suggestions_header_subtitle)、suggestions_header_refresh(Android 头部卡的“换一批”,走refreshSuggestions(),生成中禁用)与进入推荐设置的链接。 - 未开启推荐时页面不假装有内容,而是显示
home.suggestionsOffDetail+ 一键开启(enableSuggestions()); 开启但还没生成时是home.suggestionsDetail+ 生成按钮;生成中头部下方有进度条与message.generatingSuggestions。 - 主体是
FlowRow的两个宏/标签/来源芯片(suggestion-chip:<nsfw|sfw|tag:…|source:…>,同一芯片再点即取消) 加ContentGrid(沿用书架的library_list_mode网格/列表两种布局)。点卡片进详情,DetailsOrigins与dismissDetails都加入 SUGGESTIONS,返回时仍是这张列表。 DesktopAppState.suggestionFilters只活在内存里(Android 的 quick filter 也不持久化)。refreshSuggestions()的完工回调与suggestionSettings()都会按新列表/新的 nsfw 开关把已选芯片里 不再提供的那些丢掉,等价于 Android 每次重载列表重算可选项。
入口与导航
- 首页推荐轮播的“全部”从“重新生成”改成
controller.showSuggestions()(Android 首页推荐栏的“全部”打开顶层页), 轮播标题右侧的刷新按钮仍是refreshSuggestions()。 DesktopNavItem.SUGGESTIONS(id 8、标签suggestions、图标ic_suggestion)可加入主导航;和 Android 一样 总是可添加(NavConfigViewModel.availableItems不按isAvailable过滤),配置页对“已配置但当前不可用”的行 显示 Android 的suggestions_unavailable_text(DesktopNavItem.isAvailable(suggestionsEnabled))。 默认顺序仍是五项上限,所以入口是首页轮播或导航配置。- 页面自己承担“未开启”的提示,所以侧栏照旧无条件画已配置项(与 Android 底层导航条不去过滤一致)。
与 Android 的差异
- 列表模式复用书架的单一
library_list_mode:Android 的ListConfigSection.Suggestions是每页一份的列表配置, Windows 没有那套每页配置表,不为这一页新起一个键。 - 推荐页没有 Android 的 library 搜索横幅(Windows 的搜索在浏览页),也没有内容类型/来源标签筛选条—— Android 在这一页同样隐藏了它们,只留快捷筛选芯片。
- 标签芯片按标题匹配(见上),Android 按 tag 行 id;对用户可见的结果一致。
验证
:desktop-runtime:test --tests=*DesktopSuggestionFiltersTest*(新增 5 条):芯片顺序与上限(5 标签 → 2 宏 → 3 来源)、includeAdultChips = false时不出现宏、同一作品重复标签只算一次且同数时稳定、acceptsSuggestionFilters的 AND 语义(Tag/Source/Adult/Safe、Adult + Safe为空)、标题匹配忽略大小写与首尾空格。*DesktopNavigationSettingsTest*的“可添加项”断言加入 SUGGESTIONS,并新增一条“只有推荐项依赖开关”的断言。:desktop-app:test --tests=*DesktopSuggestionsPageTest*(新增探针,suggestions模式):导入 fixture 来源、 收藏一部作品、在首页轮播里开启并生成,从“全部”进推荐页;断言头部、网格与芯片; 挑一个真能切分当前列表的芯片(某标签或某个宏)点选,断言第一行里命中的卡片还在、不命中的消失, 再点一次全部回来;换一批重建后页面仍可用;推荐设置进入设置页;点卡片进详情、返回同一页; 把导航项换进侧栏后nav:推荐能进;关掉推荐后页面显示开启提示、导航配置页的该行显示suggestions_unavailable_text,再用页面上的按钮开启并生成。DesktopAppTest(navconfig探针)补一条:添加对话框里有nav-config-add-option:SUGGESTIONS, 即推荐关着也仍可添加。- 全量:
:desktop-app:test294 条 0 失败(4 跳过)、:desktop-runtime:test197 条 0 失败(2 跳过)、:core-domain:jvmTest261 条 0 失败、:desktop-app:verifyDesktopStrings960 键 / 960 被引用、:core-ui:compileCommonMainKotlinMetadata与:app:compileDebugKotlin通过。
199. 浏览页:结果自动加载下一批 + 来源图标(对齐 Android Paging 与 FaviconFetcher)(2026-10-25)
用户报的两个 Windows 浏览页问题:一是内容源瓦片只有首字母,安卓那边是有图标的;二是每个来源的作品列表被做成了 强制分页(要点“下一页”),安卓是滚到哪就接着加载。两处都按安卓的行为重做。
结果页:滚到底自动加载下一批
DesktopAppState新增browsePageSize(上一批实际条数)与browseExhausted;DesktopController.browseNextPage()成为自动与手动共用的“下一批”入口,browseOnIo(offset, query, order, changes, append)追加而不是替换。ContentGrid增加可选onReachEnd/endReached:列表末尾追加一个占满整行的页脚 item,LaunchedEffect(contents.size)在页脚被组合时触发一次请求——与 Android Paging 的 loader/end item 同义, 所以列表还没填满屏幕时也会继续加载,直到内容撑满或来源给出空页;页脚同时画 18dp 进度圈(content-loading)。- 结束判定只用“空批次”:
MihonSourceRuntime.getList不透传hasNextPage,因此browseExhausted = fresh.isEmpty()(按(id, source.name)去重后仍为空),空批次不推进offset/pageOffsets;页脚改成browse.noMorePages, 上一批/下一批按钮在结束时禁用而不是消失。 - 手动“上一批/下一批”保留:上一批仍是替换语义(回看某一批),下一批与自动加载走同一条 append 路径; 中间显示
browse.pageNumber(Android 分页行的“第 N 批”)。
来源图标:先扩展 APK 自带的,再网站 favicon
- 用户指出 Mihon/Aniyomi/Tsundoku 扩展本来就是带图标的 APK,安卓
FaviconFetcher.fetchPluginIcon走的是PackageManager.getApplicationIcon(DataSource.DISK,不联网)。Windows 的 APK→JAR 转换会丢掉res/, 所以图标必须在转换前取出:desktop-runtime新增DesktopExtensionIcons,安装 APK 时从包里挑启动图标 (res/mipmap-*的ic_launcher,密度越高越优先,跳过mipmap-anydpi-v26的自适应 XML 与.9.png九宫格, 只认真正的图片字节),存成与不可变产物同目录的<sha256>.icon;DesktopRepositories.packagedIcon(sha256)供 UI 查询。来源名(MIHON_<id>/TSUNDOKU_<id>/ANIYOMI_<id>)经registry.installed()反查到扩展再取图标。 DesktopFavicons因此多一条packaged候选并排在所有地址之前:扩展图标是本地权威结果,命中时不发任何请求。 解析插件(kototoro/kotatsu/Tsuki)仍按安卓问插件自己的getFavicons(),TVBox/JSON 来源用它文档里的iconUrl, 都没有时才对网站下手(siteUrl→<host>/favicon.ico→ 该页的<link rel="icon">)。原来为 Mihon/Aniyomi 加的“扩展baseUrl反推 favicon”已删除——那正是用户否掉的做法。- 抓取入口抽成
fetch: suspend (String) -> Response?(DesktopFavicons.httpFetcher(client)才是 OkHttp 实现, 复用会话的代理/Cookie/镜像),磁盘缓存<sha256>.icon、7 天 miss、内存 LRU 64、并发 3 与 10 秒超时不变; 解不出来仍是首字母瓦片。
与 Android 的差异
- 扩展图标只从 APK 的
res/直接取位图,不解析resources.arsc:只有自适应图标 XML 而没有位图回退的极端扩展 会退回首字母(安卓能靠 PackageManager 合成)。 - Cloudstream 插件(
.cs3)的仓库图标地址还没进索引,仍是首字母瓦片。 - 结束判定依赖空页而非
hasNextPage(MihonSourceRuntime本来就不透传),所以来源在最后一页仍报hasNextPage = true时会多问一次才判定结束。 - 自动加载没有“先填满一屏再停”的显式阈值,和安卓一样由页脚是否组合决定;因此“一页只有 1 条”的假来源会一直追加 (fixture 已改成真实页大小 40 条并如实终止),产品代码不为测试留开关。
验证
:desktop-app:test --tests=*DesktopFaviconsTest*(6 条):源自己声明的图标取一次后走磁盘、站点起始页 →/favicon.ico、起始页的<link rel="shortcut icon">、随源打包的图标优先且零请求、miss 记住并在 8 天后重试、faviconOf/iconLinks的派生规则。:desktop-runtime:test --tests=*DesktopExtensionIconsTest*(3 条):ic_launcher与密度优先级、.9.png/自适应 XML/假图片不被当图标、图标落在产物旁且不重写。:desktop-app:test --tests=*DesktopBrowsePagingTest*:自动追加、空页结束、去重与上一批替换。DesktopParserProbe(writing探针)改为断言不点按钮就拿到 fixture 全部 4 部作品、offset == 1、browse-no-more出现且下一批禁用,再由上一批回到第一批;DesktopCloudstreamProbe断言两个首页分区两批自动到位;fixture/desktop/FilterSource改成真实页大小(40 条、唯一地址、第 3 页起空页),既让筛选探针的“最后一次请求” 断言仍然成立,也让分页真的能结束。- 全量:
:desktop-app:test301 条 0 失败(4 跳过)、:desktop-runtime:test200 条 0 失败(2 跳过)、:desktop-app:verifyDesktopStrings961 键 / 961 被引用、:core-ui:compileCommonMainKotlinMetadata与:app:compileDebugKotlin通过。
200. 少选项下拉框改标签按钮(core-ui 共享 chip 行)(2026-10-25)
用户反馈:PC 屏幕够宽,可选项很少的下拉框应该直接铺成标签按钮,而不是先点开菜单再选。安卓侧的筛选抽屉本来 就是这么画的(FilterSheetRoute 里私有的 CompactFilterChipFlow/CompactFilterChip),所以这一刀既是 Windows 的可用性改进,也是一次把安卓私有控件提取成共享代码的机会。
实现
core-ui新增core/ui/chips/ChoiceChips.kt:ChoiceChipFlow(FlowRow,默认 6dp 间距)与ChoiceChip(FilterChip,最小高度 36dp、labelLarge字重、可选trailingIcon),即安卓那两个私有 helper 的逐行等价物。- 安卓
FilterSheetRoute的CompactFilterChipFlow/CompactFilterChip改为委托共享实现,只保留自己的tvChipRing();调用点一个没动,行为与观感不变(5 处 chip 区共用同一份)。 - Windows 的来源筛选对话框(
DesktopFilters.kt)把SELECT/SORT控件按选项数分成两种画法:node.values.size <= CHIP_OPTION_LIMIT(5)时铺成 chip 行,一次点击即选中;更长的列表仍是OutlinedButton+DropdownMenu两步(标签几十个的源不该变成一屏 chip)。SORT的方向按钮与「清除」放在 同一个 chip 行里(与菜单行布局一致,也避免多占一行把列表末尾挤出组合窗口),tag 仍是filter:<id>:direction/filter:<id>:clear,选项 chip 是filter:<id>:option:<n>(与菜单项同名)。 - 值仍然只写原生索引:
SourceFilterValue.Choice(index)/SourceFilterValue.Sort(index, ascending),与菜单路径 完全同一份回调,所以来源侧看到的筛选参数不变。
与 Android 的差异
- 阈值是本端定的(5):安卓筛选抽屉按控件类型直接铺 chip、不设上限,Windows 保留菜单给长列表。
- 顶栏 pill(内容类型、标签菜单)与各行溢出菜单没有改动——它们本来就是安卓的菜单形态,不是“该铺开的少选项下拉框”。
验证
:desktop-app:test --tests=*DesktopFilterChipsTest*(3 条):3 个选项铺 chip 且一次点击就选中并回读选中态、 7 个选项仍是菜单(未展开时没有 option 节点,展开后选第 7 项)、chip 排序的方向按钮与清除按钮。DesktopFilterProbe(filters与filters-narrow两档)改为直接点 option chip,并把原来断言按钮文字「未选择」 改成断言 chip 未选中;整条链路(默认值、草稿丢弃、失败恢复、分页、来源隔离)仍通过。:app:compileDebugKotlin(安卓委托共享 chip 后)、:core-ui:compileCommonMainKotlinMetadata、:desktop-app:verifyDesktopStrings通过。- 全量:
:desktop-app:test304 条 0 失败(4 跳过,比 §199 多出新的 3 条 chip 用例)。
201. Windows 连续阅读器补「条漫分割」(webtoon_gaps)(2026-10-25)
安卓阅读设置里的「条漫分割」(AppSettings.KEY_WEBTOON_GAPS,默认关)在 Windows 一直没有对应能力:连续阅读器把 页间距写死成 8dp,阅读偏好里也没有这个键,想把条漫的页与页分开只能改代码。这一刀把开关补齐, 并把「页间距」收敛成一个值。
实现
DesktopReaderSettings新增webtoonGaps(默认 false,与安卓同默认);DesktopController.readerSettings把它 写进阅读偏好 store 的webtoon_gaps,restoredReaderSettings读回。它与crop_paged/crop_continuous同段, 切换只改页间空白,不重载已经加载的页面(只有cropActive/upscale才cancelPrefetch())。- 连续阅读器(
DesktopScrollReader)里同一个gap同时喂给共享DesktopScrollLayout(VerticalReaderScene的spacing)和LazyColumn的Arrangement.spacedBy:关时 0dp(原来是写死的 8dp),开时WEBTOON_PAGE_GAP_DP = 24(安卓R.dimen.webtoon_pages_gap的值)。两处必须同值,否则历史锚点与实际画面会错位。 - 阅读设置页(更多 → 阅读设置)的「页面」节在连续模式下多一行开关,tag
reader-setting-webtoon-gaps, 文案直接用安卓的webtoon_gaps/webtoon_gaps_summary(prepareDesktopStrings早就把这个键导出了)。
与 Android 的差异
- 默认页间距从 8dp 变成 0dp(安卓默认就是页与页相接),这是这一刀唯一会改变既有观感的地方。
- 安卓的阅读设置一直显示这个开关(它在条漫分组里),Windows 沿用设置页「只显示当前模式用得到的开关」的既有约定 (与「双页封面页」只在分页模式出现一致),所以只有切到连续阅读才看得到它。
- 阅读器侧面板不加这一项:安卓也只在设置页给这个开关;设置页与侧面板改的是同一份
DesktopReaderSettings, 阅读中改也是即时生效。
验证
- 新增
DesktopReaderWebtoonGapsTest(探针reader-webtoon-gaps,真实 fixture 扩展 + 真实连续阅读器):默认两页 之间量到的缝为 0px;打开开关后缝等于 24dp(按当前 density 折算,容差 1.5px);关回去又变回 0px;最后回到阅读 设置页点该行,assertIsOn/assertIsOff与readerSettings.webtoonGaps一致(开关与阅读器读到的是同一份状态)。 - 全量:
:desktop-app:test305 条 0 失败(4 跳过,比 §200 多出新的 1 条);:desktop-app:verifyDesktopStrings961 键 / 961 被引用通过。
202. Windows 阅读器补「章节标题在底部」(reader_chapter_title_bottom)(2026-10-25)
实现
DesktopReaderSettings增chapterTitleAtBottom(默认false,与 AndroidAppSettings.isReaderChapterTitleAtBottom同默认),读写阅读偏好 store 的reader_chapter_title_bottom(AndroidKEY_READER_CHAPTER_TITLE_BOTTOM同名键)。DesktopReaderChrome把该值传给共享ReaderChromeTopBar:开着时顶栏那枚ReaderChapterTitleChip不再画; 同时在底部 chrome 里、进度坞上方按FloatingControlInset基线画一枚同样的共享药丸(与右侧悬浮按钮同高, AndroidComposeReaderActivityScaffold的底部药丸就是这么摆的),点它仍开章节面板,语义标签reader-chapter-title-bottom,所以开着时全画面只有一枚药丸。- 阅读器侧面板用的是共享面板:
DesktopReaderOptionsFeatures.chapterTitleAtBottom从false改成true,toOptionsState带上该值,onChapterTitleAtBottomChanged写回同一份设置;共享面板这行加了reader-options-chapter-title-bottom标签(Android 行为不变,只多一个测试锚点)。 - 阅读设置页「页面」节也加了一行:文案用 Android 的
reader_chapter_title_at_bottom/_summary(Android 阅读设置页里就是这一对),测试标签reader-setting-chapter-title-bottom。
与 Android 的差异
- Android 把底部药丸与进度坞放进同一个
AnimatedVisibility;Windows 本来就把顶栏和底部 chrome 各自做动画, 所以药丸跟底部 chrome 一起出现/消失,控件隐藏时的 info bar 不变(仍是章节名 + 页码)。 - 药丸宽度仍由共享组件限制(
widthIn(min = 148.dp, max = 176.dp)),窗口再宽也不会拉长,与顶栏那枚一致。
验证
- 新增
DesktopReaderTitleBottomTest(探针reader-title-bottom,真实 fixture 扩展 + 真实阅读器,7.6 s): 默认reader-chapter-title-bottom不存在、reader-chapters药丸的上沿在画面一半以上;打开设置后底部药丸出现、reader-chapters在全画面只剩一个且落在下半;最后在阅读设置页点该行,assertIsOn/assertIsOff与readerSettings.chapterTitleAtBottom一致。 - 全量:
:desktop-app:test306 条 0 失败(4 跳过,比 §201 多出新的 1 条);:desktop-app:verifyDesktopStrings961 键 / 961 被引用通过。
203. Windows 阅读器补「减少页面预加载」(reader_reduce_offscreen_quality)(2026-10-25)
实现
- 共享
ReaderOptionsFeatures原先只有一个performance旗标代表 Android 性能分组的两行;现在拆成optimization(「限制图片内存缓存」,Androidreader_optimize)与preloadReduction(「减少页面预加载」, Androidreader_reduce_page_preloading)两个旗标。AndroidReaderOptionsFeatures只依赖默认值,所以安卓照旧画两行。 DesktopReaderSettings增reducePagePreloading(默认false,与 AndroidAppSettings.isReaderPreloadReductionEnabled同默认),读写阅读偏好 store 的reader_reduce_offscreen_quality(AndroidKEY_READER_REDUCE_PRELOAD就是这把键——安卓的标签说“预加载”,存的却是视口外页)。prefetchAroundSlot按开关改窗口:关时 reader-core 的PagedSceneResourceWindowStrategy(lookaheadSlots = 2, prepareAdjacentSlots = true)(当前槽 + 前后各两槽,相邻槽预呈现), 开时lookaheadSlots = 1, prepareAdjacentSlots = false(当前槽 + 前后各一槽、只取源),并且不再在章末提前预加载下一章。 开关一改,若正停在阅读器就立刻cancelPrefetch()再按新窗口重排;已经加载的页留在readerImages里,不会被丢掉。- 阅读器侧面板用共享面板:
DesktopReaderOptionsFeatures.preloadReduction = true,toOptionsState带上该值,onPreloadReductionChanged写回同一份设置,共享面板这行标签reader-options-preload-reduction。 - 阅读设置页「页面」节也加了一行(紧跟在「章节标题在底部」之后),文案用 Android 的
reader_reduce_page_preloading/_summary,标签reader-setting-reduce-preloading。
与 Android 的差异
- 只在分页(单页/双页)生效:连续阅读器本来就不保留视口外的页(
prefetchAroundSlot在CONTINUOUS直接返回), 没有可减的预加载,开关照存照显示但不影响连续模式。 - Android 的「限制图片内存缓存(beta)」(
reader_optimize)在 Windows 没有对应管线(桌面图片缓存是下载目录里的 blob 加图片预算的内存 LRU,不是安卓那种页面位图缓存),所以共享面板的optimization保持false,那一行不出现; 旗标拆开就是为了让这一行能单独隐藏。 - 共享
ReaderOptionsPageList多一个reader-options-list标签,供探针在懒加载列表里滚到末尾的性能分组(安卓无影响)。
验证
- 新增
DesktopPreloadReductionTest(探针preload-reduction,真实 fixture 扩展 + 真实阅读器,约 20 s): fixture 章节 5 页,默认读出的页索引恰好{0, 1, 2}(当前页加后两槽);侧面板「布局」页滚到底打开开关后readerSettings.reducePagePreloading为真、退出重进同一章读出的只剩{0, 1};最后在阅读设置页assertIsOn→ 点掉 →assertIsOff,把默认值写回,探针结束打印DESKTOP_UI_OK=preload-reduction与NETWORK_REQUESTS=0。 - 全量:
:desktop-app:test307 条 0 失败(4 跳过,比 §202 多出新的 1 条);:desktop-app:verifyDesktopStrings961 键 / 961 被引用通过。
204. Windows 阅读器补「连续(横向)」模式与方向开关(2026-10-25)
实现
DesktopReaderMode多一个CONTINUOUS_HORIZONTAL(安卓continuous_horizontal),并给出两个派生判定:isContinuous(两种连续模式都滚条带,取代原来各处对CONTINUOUS的等值比较——裁剪生效、自动跨章让位、 键盘让位、滚动操作接受、阅读设置页的行取舍)与isVerticalStrip(只有竖条有「条漫间隙」)。- 条带几何抽成
DesktopStripLayout(scene/extent(index)/frame(first, offset)):竖条仍是DesktopScrollLayout(VerticalReaderScene),横条是新的DesktopHorizontalScrollLayout,包 reader-core 的HorizontalReaderScene——页面按窗口高度适配,几何提示依次取下载索引的显示尺寸、图片实际尺寸,最后退到Estimated(0.7f);reversed同时决定场景的RIGHT_TO_LEFT与LazyRow(reverseLayout = true),所以第 0 页 永远贴在自己那一侧的边沿,历史偏移仍从页面阅读方向的起始边量(intraPageOffsetPx)。 DesktopScrollReader在同一份LazyListState上换轴:LazyRow+HorizontalScrollbar、横条给自己留底部 16dp 的滚动条槽、页面fillMaxHeight().width(extent);鼠标只有竖轮,横条把滚轮 delta 手动映射到自己的轴上 (每单位 48dp),拖动/甩动取dx乘方向符号,DirectionRight/Left、PageDown/PageUp、空格按一屏 90% 走,MoveHome/End走章首页尾;LazyColumn那条路径不动。- 模式照旧存阅读偏好的
mode键(枚举名),方向用安卓自己的reader_continuous_horizontal_reversed(DesktopReaderSettings.continuousHorizontalReversed,追加为最后一个字段,默认false),关章重进由restoredReaderSettings读回。 - 共享面板因此多出第五个模式芯片(文案直接取安卓
continuous_horizontal),该模式下「布局」页出现安卓的 「反向」开关(新标签reader-options-horizontal-reversed);阅读设置页的模式对话框照DesktopReaderMode.entries自动多一项,裁剪/动画按isContinuous生效,「条漫间隙」行只在竖条出现,横条既无间隙也无那一行。
与 Android 的差异
- 安卓横条按方向切两个场景实例;Windows 只有一个
LazyRow,方向是数据(reversed)而不是两套组件, 但页面顺序、贴边与历史偏移的语义相同。 - 「反向」开关和安卓一样只在阅读器侧面板,不放进阅读设置页(§202/§203 那两行在设置页)。
- 横条没有「条漫间隙」:安卓
webtoon_gaps只作用于 WEBTOON,Windows 用isVerticalStrip保持同一范围。
验证
- 新增
DesktopHorizontalStripTest(探针reader-horizontal-strip,真实 fixture 扩展 + 真实阅读器,约 10 s): 从侧面板选「连续(横向)」后断言阅读模式与reader-scroll-list,页 0 与页 1 顶边齐平、页 1 在页 0 右侧、 页高等同条带高、宽高比仍是 fixture 的 400x600;在reader-viewport上滚一格真的把条带推走(页索引或页内 像素变),面板「反向」开关打开后下一页跑到左边,关章写进历史的正是那一页与像素位,重进后模式、方向与位置 一起恢复,最后把开关关回去、顺序回到从左到右。截图reader-horizontal-strip-left-to-right.png与-reversed.png也留在build/reports/desktop-smoke里供肉眼核对。 - 全量:
:desktop-app:test308 条 0 失败(4 跳过,比 §203 多出新的 1 条);:desktop-app:verifyDesktopStrings961 键 / 961 被引用通过(无新桌面文案键)。
205. Windows 补「自动检测阅读模式」与每作品阅读模式记忆(reader_mode_detect)(2026-10-25)
实现
- 安卓
DetectReaderModeUseCase的判定抽到 reader-core 的ReaderModeDetection(纯 Kotlin,三个成员):probePageIndex(pageCount) = (pageCount * 0.3).roundToInt()(读 30% 处那一页,跳过卷首彩页/封面)、isWebtoonPage(width, height) = width * MIN_WEBTOON_RATIO < height(MIN_WEBTOON_RATIO = 1.8f,严格小于), 以及MIN_WEBTOON_RATIO常量本身。安卓侧因此删掉自己的(pages.size * 0.3).roundToInt()与私有MIN_WEBTOON_RATIO,只留“取 URL → 读BitmapFactory尺寸”这一步(AndroidSize/BitmapFactory绑定),jvmTest的ReaderModeDetectionTest覆盖 0..40 页的索引范围与 1.8 边界(400x720 不算条漫)。 - 顺带修
ReaderCoreIsolationGuardTest:412540695把共享解码规划搬进 reader-core 的org.skepsun.kototoro.reader.image之后,文本扫描仍把除reader.core之外的org.skepsun.kototoro.*都算应用层泄漏,:reader-core:jvmTest因此一直红在 10 个 package 声明上;允许列表与正则的前瞻各加reader.image。 - Windows 侧
DesktopReaderSettings增readerModeDetection(键reader_mode_detect,默认true,与AppSettings.isReaderModeDetectionEnabled同默认),阅读设置页「页面」节紧跟「阅读模式」一行(标签reader-setting-detect-mode,文案直接用安卓detect_reader_mode/detect_reader_mode_summary)。 readOnIo在取到页列表后先解析本次要用的模式:先看每作品记忆,没有记忆且开关打开、当前不是连续模式时, 用session.readerImage(30% 页)取尺寸判定(失败/取不到就退回当前模式),判为条漫则用CONTINUOUS, 与安卓一样把结论写进记忆(正反两向都写),再把这个模式放进readerSettings交给分页/条带两条装载路径。- 每作品记忆对应安卓
ContentDataRepository.saveReaderMode写preferences.mode的那一列:Windows 没有 Room 侧的MangaPrefsEntity读者,所以在阅读偏好 store 里加一份reader_modes字符串集合 ("<contentId>=<MODE>"),rememberedReaderMode/rememberReaderMode读写它;用户手动换模式时readerSettings()在正停在阅读器且模式变了的条件下也写这条(对应安卓ReaderViewModel.switchMode的saveReaderMode),所以换过的模式下次打开仍是它。 - 记忆中读出的模式只属于那一部作品:
backToDetails()离开阅读器时把readerSettings.mode换回偏好里的值 (restoredReaderSettings().mode,手动改过的模式已在偏好里,所以这一换是空操作),下一部作品仍从偏好起步。
与 Android 的差异
- 安卓的模式 id(STANDARD/REVERSED/VERTICAL/WEBTOON/CONTINUOUS_HORIZONTAL)与 Windows 的
DesktopReaderMode(SINGLE/DOUBLE/CONTINUOUS/CONTINUOUS_HORIZONTAL)不是一一对应,所以记忆不写MangaPrefsEntity.mode,而是桌面自己的reader_modes;判为条漫时安卓返回 WEBTOON、Windows 返回CONTINUOUS(竖条),语义一致。 - 检测只看“是不是条漫”:安卓的默认模式若已是 WEBTOON 就跳过检测,Windows 的判定同理是“连续模式跳过” (
mode.isContinuous),因为两种连续模式都已经是在读条带。 - 探针页会真的下载一次(和安卓一样是多读一张图);下载逻辑走
session.readerImage,因此顺带写进阅读图片索引。
验证
- 新增
DesktopModeDetectTest(探针reader-mode-detect,真实 fixture 扩展 + 真实阅读器):fixture 加fixture.reader.probe.tall(第 2 页变 400x2400,五页章节的 30% 正好是它);关掉开关 → 打开作品没有reader-scroll-list(仍是分页);打开开关 → 出现条带(检测生效);再关掉开关 → 仍是条带(记忆生效,且离开 阅读器后偏好里的模式仍是分页,说明记忆没漏成全局默认);最后在阅读设置页assertIsOff→ 点成assertIsOn, 把默认值写回。 DesktopProbes的白名单原本只透传kototoro.*,现在也透传fixture.*(fixture 源就是靠这些开关改行为的); 探针用的fixture.reader.probe.tall在finally里还原,否则同一测试 JVM 里后面的探针会读到条漫页 (§203 的预加载窗口断言会因此看到条带)。:reader-core:jvmTest全绿(新增 3 条);:app:compileDebugKotlin通过;全量:desktop-app:test与:desktop-app:verifyDesktopStrings见下。
206. iOS 小说翻译(系统 Translation 框架)与重复的扩展更新提示(2026-10-25)
界面去重:扩展页原先同时有“发现 N 个扩展可更新”横幅(含“全部更新”)和“可更新 · N”分组标题(又一个“全部更新”), 横幅已删除,只留分组标题;设置页“仓库与扩展”行的自绘橙色胶囊改为系统 .badge,与“设置”Tab 角标同源。
共享层:reader-novel 新增 withNovelTranslations(translations, mode)(按块下标挂译文,保留 sourceRange) 和 NovelDocumentBlock.Text.displayLines(与 Android ComposeNovelReader 同规则:仅译文 / 原文在上的双语); buildNovelPageSegments 改为排版 displayLines,双语块内两行用同一段间分隔与缩进,contentStart 仍指原文起点。 ios-shared 的 IosNovelDocument.translated(translations, bilingual) 复用同一 styled 文本,所以历史 scroll、 书签摘录定位与分页 position 都按原章节偏移计算;译文块不再套书籍样式,双语原文段给出 IosNovelStyleRun.secondary。 translatableTexts 只交出含字母的文本块。
iOS:NovelTranslation.swift 用 NLLanguageRecognizer 识别正文语言(源语言为 nil 时 prepareTranslation() 会报 unsupportedLanguagePairing),同语言直接提示“正文已是目标语言”;.translationTask 拿到会话后每 24 行一批 translations(from:),逐批写回并按“作品|章节|目标语言”缓存在 Caches/novel-translations。阅读器“更多”面板与 可选悬浮按钮提供“翻译本章/显示原文”,翻译开着时换章自动续译;阅读设置新增“翻译”节(仅译文/双语对照、目标语言 取 LanguageAvailability().supportedLanguages、清除缓存)。滚动模式按行替换/双语显示,分页模式经共享分段重新排版 并以当前块重新定位。
验证::reader-novel:jvmTest --tests *NovelPageSegmentsTest*(4/4);iOS 模拟器构建通过; LocalImportTests/testNovelTranslationLaysOutBilingualPages 通过(KOTOTORO_TRANSLATION_FIXTURE=1 用标记译文代替系统 翻译器,截图确认原文变灰、译文在下、页码不跳)。模拟器上系统翻译会弹出“在模拟设备上不受支持”,真机翻译待验证。
207. iOS 全局标签黑名单与敏感类别高亮(共享 core-domain 规则,2026-10-25)
ios-shared 新增 IosTags:isWarned 直接用 TagWarnings,blacklist(selected) 包装 TagTitleBlacklist, categories(language) 按 TaxonomyCategory 列出词库(去掉 deprecated),selection(title) 把标题解析成词库 ID, 解析不到则存 Android 同格式的 raw: 键。iOS 以 Android 同键 global_tag_blacklist(字符串数组)与 tags_warnings (默认开)保存。
- 设置 → 内容过滤 → 标签黑名单(
TagBlacklistView):常驻搜索栏(词库约 300 项,下拉才出现的搜索框不好用)、按类别 分组的芯片、长按看另一语言名与别名、词库不认识的词可“屏蔽‘…’”、清空需确认。 - 详情页标签:命中警示词表时换橙色;长按“加入标签黑名单”(Android 只能去设置页里找词,这里就地加更直接)。
- 过滤放在共享的
ContentCollection,作品库、来源浏览/搜索结果、推荐都经过它。范围与 Android 一致(Android 收藏/ 历史书架同样会隐藏),但 Android 是静默隐藏;iOS 在列表末尾显示“已按标签黑名单隐藏 N 部”,以免被误认为作品丢失。 - 分页修正:iOS 靠“调用方最后一项出现”加载下一页,如果最后一项(或整页)被隐藏就永远不会再翻页;末尾的提示行出现时 会代为回传调用方的最后一项(
.id(items.count)让每页都会重新触发)。Android 由列表的加载尾项触发,没有这个问题。
验证:模拟器构建通过;SuggestionsTests/testTagBlacklistSelectsCatalogAndCustomTagsAndPersists 通过(选中“恐怖”、 添加自定义词、重启后计数仍为 2、清空),截图已看过。列表过滤没有做端到端测试,因为本地测试数据没有标签。 待定:是否给 Android/Windows 的书架也加“已隐藏 N 部”提示(需要三端一起改)。
208. iOS 无痕阅读:全局开关 + 成人内容三态(2026-10-25)
- 成人作品的判定走共享的
SourceContent.isNsfw(IosSourceContent.isNsfw),同时替换了 Swift 里两处手写的HENTAI_/ADULT判断(ContentCollection的 18+ 角标、全局搜索的成人过滤)。来源级别的isAdult判断不受影响。 - 设置 → 内容过滤:Android 同键
incognito(全局无痕)与incognito_nsfw(ENABLED/ASK/DISABLED,默认 ASK,存枚举名)。 全局无痕开着时三态选择置灰(不起作用)。 IncognitoGate在阅读器创建之前决定是否无痕(与 Windows 一致;Android 在阅读器内弹窗)。这样阅读器打开时那次 保存也不会被记录。ASK 时显示整页询问:无痕阅读 / 正常阅读 / 取消,以及“不再询问”(写回三态)。DetailsReaderDestination(漫画、小说)与新的VideoPlayerDestination(详情页和目录页的视频播放)都经过它。- 无痕会话中三个阅读器的
persist()直接返回:不写历史和进度,统计记录器拿不到 library 所以也不计时,离开时不同步 到追踪网站。书签与下载属于主动操作,照常进行。进入时显示 2.5 秒的“无痕模式”提示。 - 没有把三态判定提成共享函数:只有三行,Android 的
TriStateOption/Windows 的DesktopTriState都已接进各自的设置存储, 迁移收益很小。
验证:模拟器构建通过;LocalImportTests/testIncognitoReadingKeepsTheSavedPosition 通过(正常阅读到 0% → 全局无痕下读到 70% → 重新打开仍是 0%,并出现无痕提示)。ASK 询问页没有 UI 测试,因为本地测试数据都不是成人内容。
209. iOS 漫画页面 OCR 翻译(Vision + 系统 Translation,2026-10-25)
对照表第 1 项。ReaderPageTranslation.swift:
PageTextRecognizer:VisionRecognizeTextRequest(accurate、自动识别语言),只保留含字母的行;高宽比 > 2.2 视为 竖排列。相邻行合并成气泡:距离按两行中较粗的一行计算(不带上伸部的行如 “at noon.” 框更矮,只按自身行高判断会 拆成两段),竖排列从右到左、横排从上到下拼接,含汉字时不加空格。PageTranslationRenderer:在与原图同尺寸的画布上,用气泡边缘较亮一半采样的纸色盖住原文,再以能放下的最大字号居中 写入译文;竖排气泡横向加宽。同尺寸意味着阅读器换图时不会重新排版。ReaderPageTranslator:识别不需要会话,页面加载后即可排队识别;第一页识别出的文字用NLLanguageRecognizer定源语言, 再建TranslationSession.Configuration(与 §206 同理,源语言为 nil 时会失败;同语言直接提示)。.translationTask中按页批量翻译,渲染放到后台线程。- 接入:
SourceReaderModel.sync()在翻译开启时按pageIdentity用译后图代替原图,并清掉 artifact(否则高清分块会把原文 画回来);分页(ReaderPageTile)与条漫(ReaderStripPageView)都读同一个ReaderPageState,所以两条路径都生效。 页面的无障碍标签附上译文,VoiceOver 能读出翻译。入口是“更多 → 快捷 → 翻译页面/显示原文”,顶部胶囊显示进度。目标语言 沿用小说翻译设置。
测试数据:stage-picker-fixtures.py 新增 fixture_reader_text.cbz(Pillow 画的英文气泡,没有 Pillow 时跳过)。 验证:LocalImportTests/testMangaPageTranslationRecognizesAndReplacesBubbles 通过(模拟器上 Vision 真实识别, KOTOTORO_TRANSLATION_FIXTURE=1 代替系统翻译器)。首轮截图发现两个问题:覆盖块是灰色(采样的位图行序翻反了)、第二个 气泡被拆成两块,修复后截图确认白底、合并正确。已知问题:覆盖块的圆角略压到气泡描边;对竖排日文的效果在模拟器上 未验证;系统翻译本身需真机验证。
210. iOS 小说朗读、本章搜索、文本替换规则;Android 朗读切分修复(2026-10-25)
对照表第 2–4 项。
朗读(#2):NovelSpeech.swift 用 AVSpeechSynthesizer,从当前段开始每段一个 utterance(开着翻译时读译文), .playback/.spokenAudio 音频会话(Info.plist 已有 audio 后台模式)。开始读某段时阅读位置跟到该段;整章读完自动打开 下一章接着读,手动换章则停止。控制条(上一段/暂停/下一段/停止)在阅读控件收起时也保留。可作为悬浮按钮。 阅读设置新增“朗读”节:语速、声音(默认按正文语言自动选,NLLanguageRecognizer 识别后转成 zh-CN 这种标签)。
本章搜索(#3):“更多 → 搜索本章”,按显示文本搜索(开着翻译时原文和译文都能搜到),列出带上下文的匹配段落, 点选经 model.jump(to:) 跳转,分页/滚动都定位到该段。
替换规则(#4):
- 共享:
DesktopReplaceRules移入core-library/commonMain改名为ReplaceRuleStore(时钟改为可注入的kotlin.time.Clock),desktop-runtime里保留同名 typealias,Windows 调用方不变。 ios-shared的IosReplaceRules(IosLibrary.replaceRules):增删改、全开/全关、Legado JSON 导入导出、编辑器预览;apply(document, content)按 Android 同款作用域(书名 + 来源名)取正文规则,整章按行拼接后执行一遍(每条规则的超时 按章计算),如果规则增删了换行导致行数对不上,就退回逐行执行;title()应用章节标题规则。IosNovelDocument.withLines改写行文本但保留sourceRange,被改写的行不再套书籍样式(偏移已失配)。- iOS 页面
ReplaceRulesView:分组列表 + 开关、滑动删除、编辑器(Android 的字段 + 实时预览)、导入/导出。入口在 设置 → 阅读,以及小说阅读设置。规则变更时 bumpreplaceRules.revision,打开中的阅读器保存位置后重新加载当前章。
Android 修复(朗读切分 Tokenizer):
- 角色音色写死为三个 Azure 语音名,HTTP 朗读引擎拼
时优先用它,非 Edge/Azure 的 Legado 朗读源在对话句上 会请求失败或换错声音。改为tokenize(text, speakerVoices = emptyList()):没有传入可用音色时不分配,引擎使用用户选的声音。 SpeakerResolver原先每段新建,同一角色换段就换声音;改为整章一个。- 句子按
。!?切分会把“你好。”张三说。切成“你好。与”张三说。,两段都被当作对话,说话人名还会带上引号; 现在句末右引号留在本句,说话人名排除引号字符。
验证:
- iOS UI:
testNovelReadAloudFollowsTheSpokenLine(出现控制条、跳段后进度变化、停止后控制条消失)、testNovelChapterSearchJumpsToTheMatch(搜“第30段”只有 1 条结果,点选后分页显示该段)、testReplaceRuleRewritesNovelText(设置里新建“晨光→夕阳”,阅读器正文变为“夕阳落在书页上”且不再出现“晨光”,最后删除全部规则)均通过。 过程中的失败都是测试本身的问题:朗读按钮已经收起面板、测试却还去点关闭;上一个测试留下的阅读位置影响了共享的 初始化辅助方法;清理步骤停在了没有 Tab 栏的详情页;一次失败留下的规则让清理少删一条。这些都已改为不依赖前一个测试的状态。 :desktop-runtime:test --tests *DesktopReplaceRulesTest*5/5(store 移到共享模块后)。- Android:
:app:testDebugUnitTest --tests *TokenizerTest*2/2(不传音色池时对话不带角色音色;同一角色跨段保持同一声音、 新角色换下一个声音)。首轮该测试暴露了上面第 3 条切分问题。
211. iOS 笔记、保存筛选、成人内容分目标过滤、下载设置、WebDAV 备份(2026-10-25)
对照表第 5、6、8、9、10 项。
笔记(#5)
- 共享:
core-domain新增summarizeBookNotes/BookNotesCounts(划线 + 媒体笔记 + 书签的每作品计数,空白想法算摘录);core-library新增BookNotesStore(漫画裁切/视频时间点的增删改查,读写共享media_notes表)。 - Android:
BookNotesRepository.observeSummaries计数改用共享函数,作品按 id 建一次索引,替换原来每个作品在bookmarksMap.keys里线性查找(O(n²))。 - iOS:
IosNotes(IosLibrary.notes);裁切面板可“存为笔记”(附想法,记录页面内的相对矩形)或导出图片;视频播放器 “记笔记”保存时间点,能从本地文件截帧(HLS 等流媒体取不到帧时只存时间点);图片存 Application Support/notes,删除 笔记时一并删除。设置 → 阅读 → 笔记:作品列表(摘录/想法/书签计数)→ 作品笔记(图片、位置、想法;滑动编辑/删除; 点裁切笔记打开阅读器并定位到该页)。
命名保存筛选(#6):筛选面板顶部“已保存的筛选”:保存当前筛选、点选载入、长按重命名/删除/设为打开来源时自动应用 (每个来源最多一个)。载入都经过 BrowseFilter.reconciled(with:),来源已不再提供的标签或扩展控件会被丢弃,不会被误用。 Android 用 name.hashCode() 当 id,不同名字可能互相覆盖、重命名会改变 id;iOS 以完整名称为键。名称输入框的 alert 必须挂在具体的行上:挂在 Form 的 Section 上时 SwiftUI 不会弹出(首轮 UI 测试即失败于此)。
成人内容分目标过滤(#8):设置 → 内容过滤 → 成人内容过滤。来源/浏览沿用已有的“包含成人内容源”开关(Android 的 no_nsfw),另加作品库、阅读记录、更新记录、推荐、笔记与书签 5 个目标,键与 Android 同名(favourites_exclude_nsfw 等)。 判定统一用共享的 isNsfw,过滤点:ContentCollection(按列表前缀区分作品库/推荐)、记录页时间线(阅读 vs 更新事件)、 笔记总览。没有端到端测试(本地测试数据都不是成人内容)。
下载设置(#9):设置 → 下载设置:请求间隔、每页尝试次数、重试间隔,键与 Android 同名,上下限取共享 DownloadPolicy(IosDownloadSettings 桥接;尝试次数沿用 Android 语义:存的是总次数)。请求间隔默认 0(同 Windows: Android 的 1.6 s 只对要求减速的来源生效,共享源契约没有这个标记)。没有搬线程数和同时下载作品数:iOS 有意逐页下载, 以控制解释执行的 JVM 上扩展与解码的内存,这两项在 iOS 上只会增加风险。漫画下载循环每页先等请求间隔,图片请求按 设定次数重试(取消不重试)。
WebDAV 备份(#10)
- 共享:
core-library新增WebDavSettings(Android 的键与默认值,含isDue(lastUploadAt, now))、webDavEndpoint(仅在填写用户名时发送 Basic 凭据,kotlin.io.encoding.Base64)与WebDavLibrarySync(探测、列表、流式上传/下载、 按共享规则保留、远端命名),core-library因此依赖core-net。Windows 的DesktopWebDav.kt本可改为它的薄封装, 但desktop-app在这台 Mac 上无法编译(需要 Windows 上的-PmihonCompatibilityDirectory),没有验证的改动已撤回, 迁移留给 Windows 构建时做。 - iOS:
IosWebDav(IosLibrary.webDav)用 POSIX 文件 IO 流式读写;页面设置 → WebDAV 备份:服务器/目录/用户名/密码 (密码存钥匙串;Android 存在普通偏好里)、云端保留份数、自动备份间隔、测试连接、立即上传、云端列表 → 下载 → 复用 备份预览 → 合并恢复。自动备份在应用回到前台时按“距上次成功上传”判断,失败后 10 分钟内不再重试(同 Windows)。 - 测试工具:
Scripts/serve-webdav-fixture.py(127.0.0.1:8766,PROPFIND/PUT/GET/DELETE,必须携带 Basic 凭据)。 - 编译器问题:
WebDavSyncView里if let preview { … Task { … preview … } }这种同名遮蔽、并在嵌套闭包中使用的写法会让 Swift 6.4 的类型检查器断言崩溃,绑定改名后解决。
212. iOS GitHub 镜像、后台更新检测、应用锁、小说划线;Android 应用密码改为 PBKDF2(2026-10-25,进行中)
- GitHub 镜像(#11):
IosGitHubMirror桥接共享GitHubMirrorCatalog,Android 同键github_mirror;只改写扩展安装包下载, 仓库索引与 api.github.com 不改写(同 Windows)。设置 → 网络 → GitHub 镜像,并行测速(6 s)。代理判定为不适用(系统代理/VPN)。 UI 测试testGitHubMirrorChoicePersistsAndProbes通过。 - 后台更新(#7):
BackgroundUpdates+.backgroundTask(.appRefresh);进入后台时提交请求,系统执行时检查收藏更新并发本地通知。 Info.plist 增加fetch后台模式与BGTaskSchedulerPermittedIdentifiers。冷启动的后台唤醒没有扩展运行时,只排下一次。 - 应用锁(#16):
AppLock用 LocalAuthentication(面容/触控 ID/设备密码),不存密码;启动与回到前台时锁定,非活跃时遮盖。testAppLockAndBackgroundUpdateSettings中锁屏遮盖与“未认证不能开启”两段通过,后台更新开关一段因 Settings 中的位置 定位失败,测试已改为从顶部重新进入,尚未重跑。 - Android:应用锁密码原为无盐 MD5,新增
AppPasswordHash(加盐 PBKDF2-HMAC-SHA256,旧 MD5 首次解锁成功后升级),AppPasswordHashTest2/2 通过。 - 小说划线(#18):共享
reader-novel/NovelMarkings.kt(relocateNovelMarking等,Android 原函数改为委托,NovelTextSelectionTest10/10、NovelMarkingsTest4/4)、core-library的NovelMarkingStore、iOSIosNovelMarkings; 分页选中菜单“划线”、更多面板“划线当前段”、笔记页列出划线。已编译,未跑 UI 测试。 - 保存筛选 UI 测试最终通过(需在视频空间启动、先清理上次遗留的筛选)。
213. iOS 自定义空间、剩余阅读时间、划线与应用锁测试收尾(2026-10-25)
- 自定义空间(#13):
core-domain新增CustomSpace(按 Android 在space_definitions中的存储格式解码/编码,类型/语言/来源 类型之间为“且”、同一项内为“或”、留空不限,mediaKind给出单一媒体类型),CustomSpacesTest2/2。iOSIosSpaces(IosLibrary.spaces,iOS 生态映射到 Android 来源类型:MIHON/ANIYOMI/TSUNDOKU 同名,CMS→JSON_TVBOX,本地→NATIVE, JAR 解析器→EXTERNAL);CustomSpaceStore+ContentSpace.kind/admits:space.kind可以是自定义空间 id,按媒体类型筛选的 页面沿用原逻辑,来源列表、全局搜索、ContentCollection(作品库/浏览结果/推荐)与记录时间线再按来源语言与类型限定。切换面板 列出自定义空间并可进入“管理自定义空间”;设置 → 内容空间 下也有入口。testCustomSpaceCanBeCreatedSelectedAndDeleted通过, 截图确认“漫画 + 本地”空间的浏览页只剩本地漫画来源。未做:Android 的每空间独立导航状态。 - 剩余阅读时间(#17 的一部分):
core-domain新增ReadingTimeEstimate,只为漫画类作品估算(每章 20 页 × 测得的每页时间), 小说(“页”是行)与视频不估算;AndroidReadingTimeUseCase改用它(原先对小说/视频也给出无意义的数字),ReadingTimeEstimateTest2/2。iOS 详情页章节行显示“剩余约 …”,testTimeLeftShowsForComicsOnly通过。 - 小说划线 UI 测试
testNovelParagraphHighlightAppearsInNotes通过(更多面板划线当前段 → 笔记页出现 → 删除)。 - 应用锁/后台更新测试
testAppLockAndBackgroundUpdateSettings通过(宽行里的开关要点在行尾)。
214. iOS 作品库筛选、分享页面、PDF 导入(2026-10-25)
- 作品库筛选:
core-domain新增ShelfFilter/ShelfRow(阅读进度、下载、快捷、出版状态、来源、标签;组内“或”、组间“且”; “在读”与“读完”共用 0.999 阈值,避免 WindowsDesktopLibrarySelection里 99.95% 同时属于两组的问题),ShelfFilterTest2/2; iOSIosShelf+LibraryFilterSheet,作品库顶栏“筛选 N”。Windows 迁移到共享规则待 Windows 构建。testLibraryFilterCountsAndClears通过。 - 漫画阅读器“分享页面”(系统分享面板)。
- PDF 导入:
PdfImport用 PDFKit 把每页渲染为 JPEG,写成无压缩 CBZ(StoredZip,固定时间戳,同一 PDF 得到相同字节, 重复导入沿用同一作品身份)后走原 CBZ 导入;fixture_pdf.pdf(Pillow 生成)+testPdfImportsAsAComic通过。 - 弹幕:按要求不做,期间写的共享协议、iOS 弹层与测试接口已全部撤回。
215. iOS 小组件、视频文件夹导入、按 HIG 调整界面与朗读的系统播放控制(2026-10-25)
- 视频文件夹:
core-local的LocalSourceRuntime.importVideoFolder把一个文件夹的视频按自然顺序导入为一部作品、每个文件一集 (LocalItem新增带默认值的episodes/contentHash,旧数据与 Android 不受影响;同一组文件重复导入沿用作品身份),LocalSourceRuntimeTest15/15(含新用例),Android 编译通过。iOS 本地视频来源顶栏“导入视频文件夹”(系统文件夹选择器, 逐个用 AVFoundation 校验,跳过不能播放的)。testVideoFolderImportsAsEpisodes通过。 - 小组件:新 WidgetKit 扩展
KototoroWidget(App Groupgroup.org.skepsun.kototoro.ios)。App 在阅读记录变化时把最近 3 部作品 (标题、进度、240 px 封面)写进共享容器并刷新时间线;组件支持桌面小/中尺寸与锁屏矩形/圆形/单行,按 HIG 使用系统材质、ContainerRelativeShape、着色模式下封面去饱和,中尺寸每部作品单独深链kototoro://continue?key=…(App 拦截后直接继续阅读), 无记录时组件库显示示例。已确认扩展嵌入 App、共享容器内生成recent.json与封面。真机需要在开发者账号中启用该 App Group (自动签名会处理)。 - 按 HIG 调整:作品库筛选、自定义空间编辑、标签黑名单、已保存筛选由安卓式“胶囊”改为 iOS 表单惯例——单选用分段
Picker, 多选用带勾选的行(CheckRow),长列表(标签、语言)进入可搜索的子页面,详情放进上下文菜单,删除/重命名用左滑操作。 - 朗读接入
MPNowPlayingInfoCenter与MPRemoteCommandCenter:锁屏与控制中心显示作品/当前段落,可暂停/继续、上一段/下一段。 - 不适用 iOS 的项(理由记入对照表):DLNA(iOS 用 AirPlay,且需多播审批权限)、系统后台下载(页面经进程内扩展运行时获取)、 CBR/7z(无系统解压支持、RAR 许可受限)。
216. iOS 收藏角标读完后不清除:v86 前没有章节身份的更新计数(2026-10-28)
- 根因:收藏卡片右上角的新章节数字只来自
tracks.chapters_new(IosLibrary.updates()→IosUpdate.newChapters), 而 v86 之后计数只由带章节身份的track_logs.chapter_metadata驱动——SourceLibrary.recordPage只在最后一页调用completeUpdateChapter(按身份把该章标为读完)再由refreshPendingUpdateCount重算。身份改造之前写入的行chapter_metadata为空,没有任何可完成的章节,于是阅读只写历史、计数永远不动:真机上 MATO SEIHEI NO SLAVE(11) 与 「保护同伴死了20年后…」(13) 的 history 已经 percent=1.0,角标还在,86 个角标全部属于这类行。 - Android 的
TrackingRepository.updateReadChapter一直有第二条路:没有身份时按分支位置重算计数。该规则此前没进共享层, 所以 iOS/Windows 的共享阅读路径缺这一段。新增core-domain的recomputeLegacyUpdateCounter:计数器代表分支末尾的 N 章,读到其中第 k 章就把计数改成它之后的章节数(branch.lastIndex - index),读到最后一条即 0,并把锚点lastChapterId挪到分支末章;清 0 时把该作品的通知标为已读。SourceLibrary.recordPage每记录一页都调用它 (与 Android 的历史写入一致),带身份的计数完全不受影响(有任何一条日志带身份就跳过)。 - 只判定位置、不用
history.percent判“读完”:保存的 percent 可能早于后来发现的新章节,位置法在新章节到来后仍然成立。 - 已经读完又不会再打开的作品:
repairLegacyUpdateCounters()在SourceTracker.checkAll()开头跑一次(iOS 后台更新 / 手动“检查更新”与 Windows 更新检查共用),用保存的阅读位置修无身份计数;位置所在章节已不在章节表里的行保持不动 (Android 也一样),21 个无历史的角标不动。 - 验证:
DesktopTrackerTest新增两例(读到计数尾部第 3 新章只留 2、读末章清零并标记通知已读;检查时 repair 把已读完的 旧角标清掉)全绿,:core-domain:jvmTest、:core-library:jvmTest、:desktop-runtime:test通过;IosProductTest.aBadgeFromBeforeChaptersCarriedIdentitiesClearsWhenTheNewestChapterIsRead在 iOS 模拟器通过。 在真机数据库副本上重放:MATO 11→0、保护同伴 13→0(通知各少一条),读计数尾部第 3 新章 11→2;repair 在 219 条 track / 86 个角标的真实库上改了 31 行、清掉 4 个角标。IosProductTest.novelChaptersDownloadButVideoIsRefused是视频下载 落地后失效的旧断言(干净工作树同样失败),与本次改动无关。