Skip to content

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 与 SwiftUI iosApp,共享模块的模拟器目标 原生编译/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 门槛负责证实或证伪
  • 关联文档:

1. 决策摘要 ​

  1. 共享 KMP 内核 + iOS 原生外壳 + Windows JVM 桌面外壳 + JVM 源运行时。 全量 KMP/Compose Multiplatform 不现实:代码库 35.7 万行里,现在能直接进 commonMain 的只有约 6~9%(§2.1)。
  2. iOS 版就是 §8.2 所说的"真实多平台消费者",KMP 在此立项。 首个模块是已经纯 Kotlin 的 reader-core(58 个文件、2638 行)。
  3. 源不用 Kotlin 重写,在 iOS 上由一个嵌入式 JVM 统一承载(§2.2):Mihon JAR、Kotatsu 解析器、 Legado 运行时都是 JVM 代码。上游 Keiyoushi 已为每个 APK 发布同名 JAR,Kotlin/JS 或源码级编译 路线因此失去前提。
  4. Android 现有的进程内源加载保持不变。 JVM host 与它共用同一份协议,用于 iOS、Windows 与契约测试。
  5. 共享模块不依赖 DI 框架(只用构造注入);Android 继续用 Hilt 装配,iOS 手动装配。
  6. 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 可用库)51822.3%22,2366.2%
L1 + 忽略 DI 注解53923.2%22,9746.4%
L2 + java.* 可用 expect/actual 或 stdlib 替换62426.9%27,0427.6%
L3 + Compose 若采用 CMP69429.9%32,9939.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 shim1386 / 1395(99.4%)可满足;干净 322,加小型 shim 1051,加 UI stub 1163,加 QuickJs 1189,加 WebView 1223,加图形 1386;静态失败 9 个
动态:加载、构造、listSources、getSearchFilters、getSettings1389 / 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 归档)与 TachiyomiAZ monorepo(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 分钟;monorepo ios.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. 待决策项 ​

#决策建议
D1iOS 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,请先确认现状
D4v1 范围仅漫画(Mihon JAR + Kotatsu 解析器);小说、动漫、翻译、视频后置
D6Content / ContentChapter / ContentPage 的二进制兼容垫片这三个类带着给旧版外部解析器 jar 用的 JVM 垫片(手写的 (…, mask, DefaultConstructorMarker) 合成构造器、copy$default,注释写明"parsers compiled before sourceData"),无法进入 commonMain。要么保留垫片、三个类留在 androidMain(iOS 侧用协议 DTO),要么在外部解析器 jar 全部重编后放弃垫片。这是影响外部生态的决定,建议先保留,等你确认旧 jar 的存量再定
D7java.util.Locale 出现在公开 APIContentListFilter、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~MG0:真机上的冷启动、内存、五类场景(纯 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 镜像LG2:iOS 真机跑通 Keiyoushi 抽样(覆盖 G0 的五类场景)
P3 iOS 最小应用库、浏览、搜索、详情、阅读、历史;Kototoro 与 Mihon .tachibk 备份恢复;WebDAVXL能读完一部漫画,并恢复 Android 备份
P4 对齐下载、更新检查、tracker、WKWebView 的 Cloudflare 求解、图像解扰 shim、偏好投影XL功能对齐清单
P5 可选小说(Legado 走 JVM)、翻译(Vision OCR + 共享 MT)、视频(AVPlayer)按需—

6. 模块抽取顺序与守卫 ​

  1. reader-core:已纯 Kotlin,仅依赖 kotlin.math,25 个测试文件,隔离守卫测试已禁止 java. 导入。
  2. core-model:不新建模块,直接把 :parser-api 原地转为 KMP(步骤与结果见 §11)。其中 org.koitharu.kotatsu.parsers.* 是外部解析器 jar 的二进制 ABI,原样留在 androidMain。 ContentRepository 里的 LocalMangaSource/TestContentSource 需要上提为抽象。
  3. 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 建库。
  4. 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 适配、事务与完整仓库仍由应用负责。
  5. 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:jvmTest20 个测试类、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,静态依赖图没有发现,是编译器报出来的

发现 ​

  1. schema 层很小但分散。 36 张表对应的实体与约 35 个 DAO 分布在约 15 个功能包;core-db 抽取是跨包搬迁。
  2. 只有一处设计层面的耦合: DAO 层依赖 UI 向的 ListFilterOption。其余全是机械替换。
  3. 旧迁移不需要改。 Room 2.8.4 的 Migration.migrate(SQLiteConnection) 对 SupportSQLiteConnection 委托给旧的 migrate(SupportSQLiteDatabase);Android 继续用框架驱动即可。若 Android 改用 BundledSQLiteDriver,旧迁移会抛 NotImplementedError,必须重写。
  4. 既有问题(与 KMP 无关): app/androidTest 里的 MangaDatabaseTest.migrateAll 要从 1.json 开始, 而仓库只保留了 23 个 schema 快照(32–37、39、40、42、68–73、76–80、82–84),所以该测试本来就无法通过。 上面的分段测试是在现有快照上做的等价验证。
  5. 单靠 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 的三个提交)。

做了什么 ​

  1. 原地转换(提交 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 在传递使用它们。
  2. 叶子类型进入 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-api commonMain 的 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:jvmTest150/150 通过
:app:testDebugUnitTest(全量)2823 个测试,1 个失败(见下,与本次无关)
模拟器(API 35)上 core.db 包的设备测试24 个:19 通过、4 失败、1 跳过。4 个失败在抽取之前的基线提交上以完全相同的原因失败(见下)
其中的迁移相关测试原有的 Migration83To84Test(2 个)、SourceOriginsMigration77To78Test(3 个,含 DAO 的增删改查)、新增的 MangaDatabaseSnapshotChainTest(按 23 个快照走 22 段 + 空迁移的负控制)全部通过

顺带发现的既有问题(不是本次引入的) ​

  1. MangaDatabaseTest 有 4 个失败:migrateAll、migrate65To66…、migrate74To75… 因缺 1.json/65.json/ 74.json 快照而抛 FileNotFoundException;versions 因迁移列表里的降级迁移 Migration24To23 破坏了 "起止版本连续"的断言(expected:<25> but was:<23>)。已在基线提交上重跑确认完全一致。
  2. ListSortOptionTest.favorites exposes seven criteria… 失败(expected:<7> but was:<8>):测试与被测代码是 同一个提交(f9953753d,2026-09-14)加入的,当时已在 devel;本分支没有改动排序相关的代码和测试。这是由 "输入未变"推出的结论,没有在基线上实际运行。
  3. 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:jvmTest2 个测试类,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 参数绕开输出目录占用):

powershell
./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:jvmTest6 个测试类,35/35 通过,0 跳过
:app:compileDebugKotlin、compileDebugUnitTestKotlin、compileDebugAndroidTestKotlin通过,0 个编译错误
应用相关包的 :app:testDebugUnitTest63 个测试类,345/345 通过,0 跳过;范围见下方命令
ContentIdentityKeys、GoogleDriveSyncMerger 对比抽取前 HEAD 源码忽略换行编码后逐行一致;纯业务逻辑未重写
git diff --check通过

新增覆盖固定旧版 JSON 夹具、协议字段名、重复压缩的幂等性、双向合并的删除标记、更新清零、设置版本与日志 清除边界。Android 应用继续使用抽取后的实现,原扩展描述合并和 UI 派生等使用方的测试也通过。

复验命令(本次沿用 §13 的本地 -I build/kmp-backup-validation.init.gradle,复用独立 Android 验证目录):

powershell
./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:jvmTest9 类,49/49 通过,0 跳过
:core-db:jvmTest2 类,9/9 通过,0 跳过
:app:compileDebugKotlin、compileDebugUnitTestKotlin、compileDebugAndroidTestKotlin通过
应用相关包 :app:testDebugUnitTest65 类,360/360 通过,0 跳过
git diff --check通过

6 个既有浏览分组测试随模型移入共享 JVM 测试;新增 8 个共享边界测试覆盖三类列表的标签身份差异、 完成阈值与并列排序、平台标签匹配、分类查找键、先排序后截取、待更新合成去重和来源位掩码。 应用侧保留 47 个既有派生器测试,继续从 UI 筛选映射与实际黑名单进入共享实现; 另验证来源分组及词库别名、中文标签、raw 选择的匹配适配。

复验沿用 §13 的本地独立构建目录,未清理或终止其他构建进程:

powershell
./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 和它的 UI ListModel 接口留在应用,没有再造共享枚举。
  • 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:jvmTest13 类,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 输出目录):

powershell
./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 与收藏行投影,复用完成归一化/有效性规则
recoverHistoryChapterIdparser 章节 ID 投影、历史实体更新与数据库写入
TitleMatchingRulesNFKC、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:jvmTest16 类,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 输出目录:

powershell
./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 通过构造参数接收 Ktor HttpClient;客户端生命周期、引擎和网络配置由平台管理,不依赖 Hilt、Room 或 parser。
  • Android GoogleDriveSyncNetworkModule 用 Hilt 提供 singleton API,并装配 Ktor OkHttp 引擎。 GoogleDriveSyncAuth 的 Google Identity/PendingIntent、账号设置与完整 Repository 仍在应用。 原 Repository 注入的 API 类型不变;构造改由 provider 完成。
  • 新增迁移必要的 ktor-client-core、Android ktor-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:jvmTest1 类,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 输出目录):

powershell
./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:jvmTest3 类,41/41 通过,0 跳过(新增 WebDAV 23 项,Google Drive 18 项回归)
:core-domain:jvmTest、:core-backup:jvmTest116/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 输出目录):

powershell
./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 配置
MangaUpdatesBaseHttpClient、MutableCookieJar、token/cookie 双路径授权、Room 与 Jsoup 网页解析
SimklBaseHttpClient、平台 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:jvmTest5 类,58/58 通过,0 跳过;新增 MALSync 17 项,既有网络 41 项回归
:core-domain:jvmTest、:core-backup:jvmTest116/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 输出目录):

powershell
./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:jvmTest6 类,70/70 通过,0 跳过;新增 MAL 12 项,既有网络 58 项回归
:core-domain:jvmTest、:core-backup:jvmTest116/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 输出目录):

powershell
./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.tsv1395 行:1389 OK、6 FAIL
harness_jre4.tsv1395 行:1389 OK、6 FAIL,与完整 JDK 一致
harness_network.tsv1395 行:750 OK、33 EMPTY、612 FAIL;没有重发联网请求
Harness.javainspect、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 的性能和语义验证。

后续实现必须继承的边界 ​

  1. 直接加载仓库提供的 JAR。 Android 继续安装 APK;iOS 不执行 DEX,也不做 dex2jar 或逐源 Kotlin/JS 改写。JVM host 承载原 extension-lib 1.4–1.6,旧 Rx 由兼容 API 的 suspend 默认实现回落。
  2. JNI 只传协议数据。 iOS 的 SwiftUI/KMP 不持有 Mihon、OkHttp 或 Jsoup 对象。 每个扩展的 URLClassLoader 负责命名空间隔离;一个进程内 VM,不提供安全隔离或原地重建 VM。
  3. 源身份不能改名。 上游 SourceFactory 展开后的 sourceId 必须独立寻址;Kototoro 继续使用 MIHON_{id} 的稳定键,不能照搬参考实现的 mihon.<id>,否则 Android 备份/收藏/历史无法对应。
  4. 保留不透明元数据与原页面上下文。 memo/sourceData 不重新解释;章节原始 page URL、 图片 fragment、headers 都可能参与解扰。封面请求与章节 HttpSource.imageRequest(Page) 语义不同。
  5. 图片仍经过扩展链。 实际保留的 ExtensionHost.java 包含 materializeImage,通过扩展自己的 OkHttp client 执行请求及拦截器。只导出图片 URL/headers 再交 Darwin 下载,无法覆盖图片解扰类源。 当前 core-source.getPageUrl 只解析地址,没有完成图片执行/物化能力。
  6. 浏览器和偏好是必要能力。 WKWebView、主 Looper、JavaScript 回调、CookieManager/OkHttp cookie jar 和同一 user agent 必须相互衔接;偏好设置不是可忽略的 UI stub,getSettings 实际执行设置构建。
  7. 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:jvmTest18/18 通过,0 跳过
应用源协议/映射测试2 类,16/16 通过,0 跳过
既有 ContentRepositoryFactoryTest18/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):

powershell
./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/ceiling1395/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.comicfury1.614
all.mangadex1.661
all.buondua1.61
en.akaicomic1.41

四个 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:test2 类,18/18 通过,0 跳过
:core-source:compileCommonMainKotlinMetadata、JVM/Android 编译通过
:core-source:jvmTest18/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 是独立编写的最小测试接口,不在生产发行包内。

复验命令:

powershell
./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。调用结果:

text
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 / JVM3 类,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,无站点网络调用。

text
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 / JVM3 类,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 写回/恢复。

text
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/真机门槛。

平台分工与推进顺序 ​

层WindowsiOS
业务内核复用 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/CookieWindows 平台桥,优先评估 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 路径/其余备份节/外部格式/完整图片格式与保留管理待完成
W2Windows BrowserTransport/JS/Cookie/graphics shim 与代表源请求、调试面板WebView2 桥/Provider(§34)、调试 UI(§35、§38)、POST/headers/base URL 与 SDK Cookie(§37)、可见来源挑战闭环(§39)已接入;真实挑战、完整 API、代表源与高级图形待完成
W3Compose 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。
text
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:

powershell
& "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 仓库工具链中。测试命令为:

powershell
./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 JVM35/35,0 跳过;新增类型 JSON roundtrip
source-host JVM63/63,0 跳过;新增文件存储 6、bridge 4、session 生命周期 2 项
desktop-runtime JVM15/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。 构建命令:

powershell
./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 JVM35/35,0 跳过;全操作 roundtrip 包含新设置协议
source-host JVM72/72,0 跳过;新增偏好执行器 9 项
desktop-runtime JVM15/15,0 跳过
app core/source 选定回归27/27,0 跳过;新增 Android 设置协议显式 unsupported 1 项
合计149/149,0 失败
common metadata / app compileDebugKotlin通过
powershell
./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 的修改正确投影。结果为:

text
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。

powershell
./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 实际 API5/5,0 失败/跳过;子进程 OS=Windows 11,离线 fixture NETWORK_REQUESTS=0
core-source JVM / common metadata35/35;metadata 通过
source-host JVM / 默认发行72/72;installDist 通过,未增加第三方 compat JAR
desktop-runtime JVM15/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 仍需独立完成。

日期: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.0 C# 编译器(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 项测试:
    1. bridge launches and reports Evergreen Edge WebView2 version:成功启动桥进程并识别 Evergreen Edge WebView2 运行时版本。
    2. bridge loads offline HTML and evaluates JavaScript expressions:加载离线 HTML 并通过 JS 表达式求值准确返回 DOM/标题。
    3. bridge sets and retrieves cookies for an origin:针对特定 origin 写入 Cookie 并验证能够准确提取(验证 cf_clearance 场景所需能力)。
    4. DesktopWebViewProvider integrates with android webkit WebView to evaluate JavaScript:通过真实 android.webkit.WebView 实例接驳 Provider,异步执行 JS 并通过 ValueCallback 获取返回值。
    5. bridge closes cleanly and terminates process:验证桥正常退出、子进程销毁以及关闭后拒绝对话的幂等保护。
  • 规避测试临时目录锁冲突:Edge 在异步退出时可能短时间持有 Crashpad 锁文件,测试配置使用专用子目录路径隔离,保证 JUnit 进程清理平稳。
检查结果
mihon-desktop-compat 单元测试10/10,0 失败/跳过(5 项平台探针 + 5 项 WebView2 桥/Provider 集成测试)
core-source JVM / common metadata35/35;通过
source-host JVM / 默认发行72/72;installDist 通过
desktop-runtime JVM15/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。本机可运行:

powershell
./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:

  1. 使用 Compose v2 UI 测试 API, 点击来源/中文搜索/详情/收藏/阅读/历史/标准选择设置,验证 listener 拒绝和保存;第二个进程恢复源设置/ 收藏/阅读进度。章节 PNG 仍由源 client/interceptor 解密,网络请求为 0;本机已配置桥时另执行真实 HTML/JS。 对 browse/search/details/reader/preferences 渲染截图并目视检查中文布局。
  2. 通过生产 main 创建实际原生 Window,分别在启动中与导入后触发关闭;退出后重开数据库/偏好、 移动测试数据目录,验证自己的句柄释放。WebView2 profile 的 Edge 子进程可能晚于桥退出释放, 包含浏览器的测试采用最长 10 秒有界等待,不能将其写成所有原生子进程已同步退出。

测试任务将 fixture JAR 与桥二进制作为输入,避免文件内容变化后误用 up-to-date 的旧探针结果。

检查结果
core-source JVM / common metadata35/35;metadata 通过
source-host JVM / 默认发行72/72;installDist 通过
desktop-runtime JVM20/20,含 5 项新库/历史测试
mihon-desktop-compat 实际 API / WebView210/10
desktop-app UI / 生产 Window2/2,四个 Windows JVM 验证 write/read/window/window-early
合计139/139,0 失败/跳过
默认 Android compileDebugKotlin通过,没有启用可选桌面模块
正式脚本启动已启动实际窗口,指定数据目录的 fixture 注册记录存在
powershell
./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 模拟器四项全部通过、零跳过:

  1. 同一漫画的不同收藏分类保留独立行;重复插入只更新匹配分类,历史只更新匹配 manga_id。
  2. 批量操作中无效外键导致整批回滚,之前更新的排序键也恢复。
  3. 旧 Work URI 不再暴露,SQLite 实际没有对应表。
  4. 调用生产 SyncHelper 的收藏及历史同步:先导出有效行和全部墓碑,再清除十天前墓碑及过期分类; 有效行和一小时前墓碑完整保留。
powershell
./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 总目标保持进行中。

日期: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 句柄最终释放。

powershell
./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 / metadata35/35;metadata 通过
source-host JVM / 默认发行72/72;installDist 通过
desktop-runtime JVM20/20
mihon-desktop-compat 实际 API / WebView219/19,含新增九项
desktop-app UI / 生产 Window2/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 用例:

  1. 真实原生窗口渲染中文页面;PNG 尺寸与 viewport 一致,背景像素匹配自有 HTML;调整窗口 后 DOM innerWidth 减小。隐藏后仍能执行 JS,FormClosing 后可重开,owner close 退出码为 0。
  2. 慢导航期间可显示/关闭窗口;停止导航后继续加载,正常退出。
  3. 原生 Cookie 删除确认只删匹配 domain/path,同名的另一路径与 domain Cookie 保留,最终 全部目标删除后查询为空。该项只覆盖桥的精确删除,不等于 SDK 同名多 path 已全部验证。
  4. 新的独立 JVM 桌面 UI 探针点击调试页按钮,打开 loopback 页面、用 JS 触发自有确认按钮, Cookie 写入 SDK cookie_store;隐藏/重开、无效 URL 后保留页面、离线 HTML 均通过。离开 面板取消慢导航后仍可使用原浏览器;Session 在可见窗口、未完成导航期间关闭,存储可重开, profile 最长 10 秒内释放句柄。测试等待 Compose 取消完成后再查询 browser,不阻塞测试调度器。
powershell
./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 / metadata35/35;metadata 通过
source-host JVM / 默认发行72/72;installDist 通过
desktop-runtime JVM20/20
mihon-desktop-compat 实际 API / WebView222/22
desktop-app UI / 生产 Window3/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 内的多个真实交互场景:

  1. 普通 403 不触发交互;挑战 POST 只发一次且不创建浏览器,两项 interceptor 边界测试。
  2. 原生 HTTP 403 默认失败,显式允许后页面可读;关闭本地服务后,允许 HTTP 错误也不掩盖连接失败。
  3. 原生交互超时,以及排队 Call 取消,两项独立 JVM 测试;取消排队请求后当前提示仍有效。 关闭平台后在自有临时目录检查 profile 句柄最终释放,不宣称所有 Chromium 子进程同步终止。
  4. 实际桌面加载独立 InteractiveSource 扩展:来源初始请求 → 浏览器导航 → 来源重试共三次, UA 一致,重试携带页面写入的测试 Cookie 并返回作品。随后验证 UI 取消无重试、动作 Job 取消、 旧提示 ID 无效、未解除挑战只重试一次、重新显示窗口,以及等待人工操作时退出并重开 v84 存储。
powershell
./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 / metadata35/35;metadata 通过
source-host JVM / 默认发行72/72;installDist 通过
desktop-runtime JVM20/20
mihon-desktop-compat 实际 API / WebView227/27
desktop-app UI / 生产 Window4/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 掩盖失败。
powershell
./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 / metadata35/35;metadata 通过
source-host JVM / 默认发行72/72;installDist 通过
desktop-runtime JVM21/21
mihon-desktop-compat 实际 API / WebView227/27
desktop-app UI / 场景适配 / 生产 Window7/7
reader-core JVM / metadata150/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 中五项 全部实际执行;其中一个初版曾因推断返回异常对象而未被发现,已修正并重跑,不能只以编译/总绿作证。

  1. cover JSON 保留超过 2^53 的 contentId/byteSize,结果没有 pageId/绝对 URL;拒绝越界路径/空大小。 旧 descriptor 缺少字段默认能力 false,未实现执行返回 unsupported;既有 Referer 行为保留。
  2. 两个缓存等待者共享一次请求,取消其中一个不停止另一个;最后一个取消终止 producer,可再次 请求;owner close 在 producer 清理完成后返回。失败可重试、不发布失败记录。
  3. 原 preferences 与文件重开后无网络恢复;来源缺少 locale 仍复用。自有 blob 被改坏后重新请求并 修复;260 个记录限制为 256 条,相同图片内容仍只有一个 blob。
  4. 独立 CoverSource 扩展的自定义请求、缺章节上下文后的 source.headers 回退、client XOR 解扰 和坏图片重试通过。列表 → 详情 → 收藏 → 阅读 → 历史的同一封面只有一次成功请求;独立 large URL 的请求/产物身份通过。SDK 当前没有 banner setter,实际详情沿用 thumbnail fallback; large URL 的独立调用测试不冒充该 SDK 原生 banner 元数据支持。
  5. 第二个 JVM 将终端请求设置为离线失败,浏览/收藏/历史仍渲染已验证的持久化封面,没有触发 封面网络执行。取消故意延迟的实际 OkHttp 请求后,下一来源操作等待其回调;释放后原 body 关闭,下一操作成功,图片目录没有 .part 残留。
powershell
./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 / metadata38/38;metadata 通过
source-host JVM / 默认发行72/72;installDist 通过,未增加第三方 HTTP/SDK 依赖
desktop-runtime JVM21/21
mihon-desktop-compat 实际 API / WebView227/27
desktop-app UI / 缓存 / 场景 / 生产 Window13/13
reader-core JVM / metadata150/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 当成输入内容。

powershell
./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 / metadata38/38;metadata 通过
source-host JVM / 默认发行72/72;installDist 通过
desktop-runtime JVM21/21
mihon-desktop-compat API / WebView227/27
desktop-app UI / 缓存 / 场景 / 生产 Window14/14
reader-core JVM / metadata150/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。

  1. 指针锚点在非零 slot 的缩放前后指向同一 scene 点,反算视口尺寸正确;极大有限倍率/平移 限界、缩小到基准、FIT_WIDTH 从顶部到下缘、native 双页 RTL 起点、resize 限界和无效数值均通过。
  2. 原始尺寸双页中离开基准视口的页面仍属于加载集合,完整 canonical group、翻页目标和起始 进度锚点保持正确;既有单/双页、RTL 与宽图独页场景继续通过。
  3. 实际 UI 点击按钮、键盘、鼠标双击/拖动、Ctrl+滚轮及两指触摸。核对 100%/125%/200%/500% 和倍率上限;使用实际 transformed bounds 与截图中白色页码质心移动验证页面缩放/拖动, 不只检查百分比文本,也不把 unscaled layout size 当作 graphicsLayer 缩放后的尺寸。
  4. FIT_WIDTH 的页面宽度等于视口、长页从顶部开始且普通滚轮可向下平移;KEEP_START 的自有页面 保持 400×600 原始像素;FIT_HEIGHT 宽页保持视口高度、超出宽度可平移,RTL 起点通过实际页码 像素位置核对。缩放/平移过程中历史与图片请求数不变,双页放大不改变 canonical pageIndex。
  5. 第二个 920×620 JVM 恢复 DOUBLE / RTL / FIT_WIDTH、页组 4–5 和 page=3 / percent=.5,倍率 回到 100%。重开共享 v84 存储通过;原阅读器错误/回看/重载/键盘/跨章与生产 Window 退出仍通过。
powershell
./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 / metadata38/38;metadata 通过
source-host JVM / 默认发行72/72;installDist 通过
desktop-runtime JVM21/21
mihon-desktop-compat API / WebView227/27
desktop-app UI / 缓存 / 场景 / 生产 Window16/16
reader-core JVM / metadata155/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 / metadata38/38;metadata 通过
source-host JVM / 默认发行72/72;installDist 通过
desktop-runtime JVM22/22
mihon-desktop-compat API / WebView227/27
desktop-app UI / 缓存 / 场景 / 生产 Window19/19
reader-core JVM / metadata155/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。

powershell
./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 / metadata38/38;metadata 通过
source-host JVM / 默认发行72/72;installDist 通过
desktop-runtime JVM22/22
mihon-desktop-compat API / WebView227/27
desktop-app UI / 缓存 / 场景 / 生产 Window25/25
reader-core JVM / metadata155/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),而非全图输出后裁剪的结果尺寸。

powershell
./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 / metadata38/38;metadata 通过
source-host JVM / 默认发行72/72;installDist 通过
desktop-runtime JVM22/22
mihon-desktop-compat API / WebView227/27
desktop-app UI / 缓存 / 场景 / 生产 Window28/28
reader-core JVM / metadata176/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。

powershell
./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 / metadata40/40;metadata 通过
source-host JVM / 默认发行72/72;installDist 通过
desktop-runtime JVM22/22
mihon-desktop-compat API / WebView227/27
desktop-app UI / 缓存 / 场景 / 生产 Window31/31
reader-core JVM / metadata176/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 或全量扩展验收。

powershell
./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 / metadata42/42;metadata 通过
source-host JVM / 默认发行72/72;installDist 通过
desktop-runtime JVM22/22
mihon-desktop-compat API / WebView227/27
desktop-app UI / 缓存 / 下载 / 场景 / 生产 Window37/37
reader-core JVM / metadata176/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 回归继续保留。

powershell
./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 / metadata44/44;metadata 通过
source-host JVM / 默认发行72/72;installDist 通过
desktop-runtime JVM22/22
mihon-desktop-compat API / WebView227/27
desktop-app UI / 缓存 / 下载 / 场景 / 生产 Window42/42
reader-core JVM / metadata176/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,不是代表库规模或内存/硬件性能验收。

powershell
./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 / metadata44/44;metadata 通过
source-host JVM / 默认发行74/74;installDist 通过
desktop-runtime JVM22/22
mihon-desktop-compat API / WebView227/27
desktop-app UI / 缓存 / 下载 / 场景 / 生产 Window45/45
reader-core JVM / metadata176/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 检查继续保留;测试图片是自有样本,不是全格式/代表站点/硬件峰值内存验收。

powershell
./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 / metadata44/44;metadata 通过
source-host JVM / 默认发行74/74;installDist 通过
desktop-runtime JVM22/22
mihon-desktop-compat API / WebView227/27
desktop-app UI / 缓存 / 下载 / 场景 / 生产 Window51/51
reader-core JVM / metadata179/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。

powershell
./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 / metadata44/44;metadata 通过
source-host JVM / 默认发行74/74;installDist 通过
desktop-runtime JVM22/22
mihon-desktop-compat API / WebView230/30
desktop-app UI / 缓存 / 下载 / 场景 / 生产 Window52/52
reader-core JVM / metadata179/179;metadata 通过
Windows 移动目录 / EXE / 随包 JVM / 生产 Window1/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 逐文件校验。

powershell
./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 / metadata44/44;metadata 通过
source-host JVM / 默认发行74/74;installDist 通过
desktop-runtime JVM33/33
mihon-desktop-compat API / WebView230/30
desktop-app UI / 缓存 / 下载 / 场景 / 生产 Window53/53
reader-core JVM / metadata179/179;metadata 通过
core-backup JVM / metadata、core-db metadata13/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 / metadata44/44;metadata 通过
source-host JVM / 默认发行74/74;installDist 通过
desktop-runtime JVM38/38,含新增 5 项;最终回滚断言修改后再执行全部 38 项通过
mihon-desktop-compat API / WebView230/30
desktop-app UI / 存储 / 原生窗口53/53
reader-core JVM / metadata179/179;metadata 通过
core-backup JVM / metadata、core-db metadata13/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 未完成。

复验命令 ​

powershell
./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,不要求重写站点解析器
UIdesktop-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-source48
source-host76
desktop-runtime44
mihon-desktop-compat31
desktop-app56
reader-core179
core-backup13
合计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 图标
TabletPreviewContentCoil、源请求上下文、本地化/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:jvmTest 5/5;完整 :desktop-app:test 70 项中 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:test 76 项: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 用 LocaleStringComparatorCollator 排序;无历史时“开始阅读”从选中分支第一章开始
withVolumeSections/shouldShowVolumeHeaders:卷号变化或新组名插入标题非 EPUB 路径委托,文案仍用 volume_/volume_unknown“第 N 卷”/“未知卷”与 Android 中文资源一致
ChapterSectionHeader、ChapterBranchChips、chapterBranchChipLabel(“分支 · 数量”)TV 焦点作为外部 modifier;折叠图标经 rememberVectorPainter双栏与单栏详情都显示分支 chip 与卷标题

桌面顺序与 Android 一致:选分支 → 正/倒序 → 搜索 → 在剩余结果上插入卷标题。阅读器内上一/下一章 原本已按分支导航(SourceChapterNavigation),未改。EPUB 卷分组、合并重复章节、隐藏已读等 Android 专属选项未迁移。

验证(日志 build/chapter-groups-*.log):

  • 新增 ChapterGroupingTest 4 项(首选分支优先级、选项/回落、比较器异常、卷与组名标题),以及桌面 details chapters are grouped by branch and volume like Android(真实 desktopChapterList、倒序、搜索保留 卷标题、失效分支回落、单分支不显示 chip、点击 chip 切换分支与标题)。
  • Android :app:compileDebugKotlin 通过(checkDebugDuplicateClasses 为 UP-TO-DATE),既有 ChaptersMapperVolumeHeaderTest 2/2。
  • :core-ui:jvmTest :desktop-app:test 81 项: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:test 82 项: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-db 7 个文件)共享。 domain 接口/模型基本无平台依赖,data 实现依赖 Hilt/AppSettings,SpaceSwitcher 等 UI 依赖 Android。 Windows 未实现、未共享 Space。

本轮实现:

位置内容
core-domain SourceTagtoggle(空→清空、已选→移除、否则加入)、menuOrder(已选在前)、accepts(OR,未知来源不匹配)、matchesOrigin(matches 委托,行为不变)
Android6 处页面切换与 SourceTagDropdown 排序改为委托(收藏页的 clear/set 变体保留)
WindowsDesktopSourceFilter: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:Android FeedSnapshotStore.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(含配色规则)、HomeListRailRowTV 焦点、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):

  • 新增 TrackRulesTest 3 项、HomeRulesTest 3 项、DesktopTrackerTest 2 项(真实 SQLite:建立基线、发现新章节、 写日志、订阅快照、失败保留计数、标记已读、关闭/开启追踪、按阅读分支计数)。
  • 平板探针(四种窗口宽度)新增:订阅页检查更新、模拟上次只见第一章后发现新章节、轮播/时间线/导航徽标、 主页 hero/历史/更新/快捷入口、从更新打开作品后计数清零、快捷入口跳转。
  • Android :app:compileDebugKotlin 通过;相关 Android 单元测试 13 项通过。
  • :core-domain:jvmTest 122、:core-ui:jvmTest 12、:desktop-runtime:test 63(2 跳过)、:desktop-app:test 76(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):Android TrackWorker.Scheduler 原公式 (18 / 批次数 整除后除以频率、四舍五入、至少 2 小时;频率 ≤0 为手动),Android 调度改为调用。
  • SUGGESTIONS_INTERVAL_HOURS = 6(Android 推荐周期任务改用)、parseSuggestionTags(Android AppSettings 的排除/偏好标签解析改用)。

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:test 17 项全部通过。
  • 桌面拦截器测试新增“明确挑战离屏解决后重试、不打扰用户”。
  • 新增真实 WebView2 探针 DesktopClearanceSolveTest:本地模拟托管挑战(403 + Server: cloudflare + #challenge-running,页面脚本写入 cf_clearance 后重载),约 3 s 内在隐藏窗口自动通过,SDK 请求返回内容, 没有人工弹窗;持续的勾选式挑战约 8 s 后转为人工验证。
  • DesktopChallengeProbe 的请求计数改为包含一次隐藏求解。
  • 结果:Android compileDebugKotlin 通过,Android Cloudflare 相关单元测试 16 项通过; :mihon-desktop-compat:test 34、:core-domain:jvmTest 129、:core-ui:jvmTest 12、:desktop-runtime:test 66(2 跳过)、 :desktop-app:test 77(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:test 86、:mihon-desktop-compat:test 34、:desktop-app:test 77(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 的浏览器方法,改用系统浏览器。
  • 运行库:官方 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 超时,代理未开)未能执行。
  • 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 通过。

未做(下一阶段候选)

  • 竖向翻页、长条(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:原 Android EdgeDetector 的白边检测算法,与平台无关。保留原有判定:以 100 px 块从四边扫描到第一个 非白像素(容差 16);任一边的空白超过页面三分之一就视为没有可裁的白边;大图按 1 / 0.75 / 0.5 / 0.25 缩小后扫描。 像素通过 BlockReader 读取:Android 用 Bitmap.getPixels,Windows 用 Skia 位图,各自的解码器不变。Android EdgeDetector 只保留解码和缓存,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:Android tap_grid 的键名(CENTER、CENTER_long、_init)、默认布局(左列和上中为上一页,右列和下中为下一页, 中间点按切换工具栏、长按显示菜单)、“全部禁用”,以及按区域读写动作。TapActions 从 ViewModel 的嵌套类移到这里。 Android TapGridSettings 的键名和默认值改为引用它。
  • ReaderTapGridConfigGrid:Android “阅读操作”页的九宫格(按点按动作着色、标出点按 / 长按动作、分隔线),文案和文字样式由调用方传入; Android ReaderTapGridConfigScreen 只保留顶栏、菜单和选择对话框。
  • readerTapGestures 新增可选的 onSecondaryTap:右键单击直接报告所在区域,不进入点按 / 长按判定。Android 不传,行为不变。

Windows

  • DesktopReaderSettings.tapGrid 持久化为 tap_grid.<Android 键名>:值为动作名,空字符串表示“无”,缺省时用共享默认值。
  • 分页和连续阅读器都按配置执行动作(performTapAction,对应 Android ReaderControlDelegate.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 个)生成为共享的 ReaderPanelIcons ImageVector,两端绘制同一套图标。
  • 面板宿主(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:jvmTest 19 项通过;: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:jvmTest 19 项通过;共享模块 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:jvmTest 19 项通过,共享 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:jvmTest 24 项通过,包括新增目录规则 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:jvmTest 24 项通过;: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 最终通过。首次检查遇到工作区另一处正在修改的 LNReaderRepoViewModel observeAsFlow 参数错误,该文件更新后复查成功;本轮没有修改这部分 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 Mobile ad8bb1b065bf230d193a1dfd0ebd39a8b1fedf53、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、源码、截图与日志都在 ignored iosApp/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 项通过:MihonSourceRuntimeTest 21 项、FileSourceImageStoreTest 8 项。 修正自有测试 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 移植 Android TVBoxRepository 的 CMS 分支:JSON 载荷遍历、XML(MiniXml,不展开 DOCTYPE/实体)、 vod_play_from/vod_play_url 线路解析、可播放地址判定、列表/详情 URL 变体、header 规则与 Referer。 tvbox://item|chapter URL、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、TabView tabBarMinimizeBehavior):浏览页影视/扩展分区与导入表单、源内 2:3 海报网格
    • 分类胶囊 + .searchable + 无限滚动;详情页按线路分段选择、剧集按钮网格;AVPlayerViewController 播放(原生控件/AirPlay/画中画),AVURLAssetHTTPHeaderFieldsKey 传站点 headers,播完同线路自动下一集。 Info.plist 允许任意 HTTP(用户 CMS 与媒体普遍为 http)并开启后台音频(画中画)。 漫画详情/阅读沿用原流程;扩展封面仍由源客户端下载,原生源用 AsyncImage。

验证

  • :source-tvbox:jvmTest 11 项通过(JSON/XML/搜索/分类/线路/ID 公式/导入/多仓/Base64/URL 解析与编码); :source-tvbox:compileCommonMainKotlinMetadata 通过。
  • :ios-shared:iosSimulatorArm64Test 6 项通过:新增真实 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 配置在 Kotlin Base64.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,可选字段,旧配置兼容);新 SPI SourceHostRuntimePlatform.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,真实网络):ExtensionEcosystemTests 5 项——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:test 94、:parser-host:test 23(2 跳过)、 :core-source:jvmTest 53 通过;新增会话回归验证插件标题进入管理回复。
  • :ios-shared:iosSimulatorArm64Test 18 项通过:新增导出→另一数据库预览/合并(同名分类合并、二次合并幂等、 书签/进度保留)、Python zipfile 流式生成的 Android 风格 ZIP(数据描述符 + 不支持的 settings 节)、 损坏/非 ZIP/缺失文件在写入前拒绝。iOS 导出文件经 unzip -t 与 Python zipfile 交叉读取通过。
  • 默认构建 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:iosSimulatorArm64Test 20 项通过,新增 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 s89 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:用 okio openZip 读取以下格式。
    • 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 字切分。
  • 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 项全部通过,其中新增:
    • IosExternalBackupTest 5 项:Android ID 公式在 Kotlin/Native 上一致,Mihon gzip 备份含分类、历史、未安装扩展, Aniyomi 识别,Venera SQLite 来源匹配,未知文件被拒绝;
    • IosBackupTest 中四个新节的往返测试;
    • IosLocalImportTest;
  • source-host 102 项;mihon-ios-compat 新增 DomainRewriteInterceptorTest 2 项;
  • core-backup、core-domain、core-library 的 jvmTest 和 Android :app:compileDebugKotlin 及备份相关单测通过;
  • UI 测试(真实扩展预览)通过:
    • testFilterPanelsAcrossEcosystems、testCmsFavouritesHistoryAndPlaybackResume;
    • BackupFlowTests 2 项,含通过系统文件选择器导入 Mihon 备份;
    • LocalImportTests 2 项: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 缓存来源清单(插件为制表符分隔文本,扩展为 LoadedMihonExtension JSON)。之后启动命中缓存只注册来源,不打开 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:test 104 项、:parser-host:test 25 项(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 关闭。
  • 验证:IosDigestProviderTest 6 项在 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:test 107 项通过。
  • 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:jvmTest 24 项、共享主页 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 便携包;windowsDistributionTest 1 项通过,验证随包 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:jvmTest 24 项、桌面定向测试 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 便携包的 windowsDistributionTest 1 项通过,含随包原生窗口/运行时检查; 连同前述测试共 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 主代码与测试源码的元数据编译通过;正式构建文件未改,随后再次执行正常主代码编译以恢复输出。
powershell
./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=plain

init 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/可见性),语义对齐 Android FavouritesRepository: 新建取 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_<小写类型名>,取不到回落类型名, 与 Android ContentType.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:M3 AlertDialog + 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 按 Android ContentState 顺序过滤,复选框,testTag library-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),与 Android resolveReadingStatus/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)与 Android KototoroContentCard 的 top-right 角标一致;语言角标仍在左下角。 角标同时带 contentDescription(library.newChapters → “%d 个新章节”/“%d new chapters”), testTag 为 content-new-chapters:<id>。
  • 显示规则集中在 newChaptersBadge(entry, history):Android FavouritesCardMapper 用的是 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), 与 Android SettingsSwitchPreference 一样整行可点且只暴露一个无障碍开关节点)、 SettingsValueRow(左侧标题/摘要,右侧当前值 + ReaderPanelIcons.ArrowForward,值最宽 260dp 后省略)、 SettingsInfoRow。行内边距 16dp/12dp,标题 bodyLarge+Medium、摘要 bodySmall/onSurfaceVariant。 只使用 MaterialTheme.colorScheme 角色,不引用桌面私有的 Ink/Muted,Android 与 iOS 可直接复用。
  • 文件在 core-ui commonMain,: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,testTag settings-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 改成单选对话框 (行 tag settings-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(“更多 → 功能 → 阅读设置”,行 tag reader-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 时是 原有浏览语义),进入后单击即勾选/取消,卡片左上角画勾选徽标(tag content-selected:<id>,需 useUnmergedTree,因为整卡是 merged 节点)。
  • 顶栏换成 DesktopLibrarySelectionBar(tag library-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,screen SOURCE_SETTINGS,入口 tag source-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 单行(tag source-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, 容器 tag content-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 把 Android json_sources 表保存的同一份配置文档写进 desktop_json_sources store 的单值 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 通过;DesktopLibrarySelectionTest 9 项与 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 快照里能看到新增的“分享”按钮排在“移出收藏”之前。
  • 验证:DesktopShareLinksTest 3 项、DesktopLibrarySelectionTest 9 项全绿;: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。
  • 验证:DesktopDownloadOptionsTest 9 项全绿;: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_ 前缀再比,与 Android MigrateUseCase 的判断一致)、按来源名去重; 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。
  • 单测 DesktopMigrationRulesTest 5 例:同内容类型筛掉自身来源、成人作品与普通作品可互相迁移(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。
  • 验证:DesktopMigrationRulesTest 5 项全绿;: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 通过;Android KototoroTaxonomyTest(8 项,含 “标准标签同时有简中与繁中写法”)与 GlobalTagBlacklistTest(6 项)在搬移后全绿; 新增 :core-domain:jvmTest 的 TagTaxonomyTest 6 项(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 的 TagTitleBlacklistTest 6 项与 TagTaxonomyTest 7 项(新增 “转换器晚装也会重建缓存目录”)全绿;desktop-runtime 新增 DesktopSourceSettingsTest 4 项(往返、 非法/空白值回退、规则命中、键名复用)绿;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 既有的无芯片卡片,不另加着色。
  • 验证:TagWarningsTest 2 项(110 条、大小写/空白/俄语、非模糊)、DesktopSourceSettingsTest 5 项 (新增“默认开 + 走共享词表 + 关掉只停着色 + 读回 false”)、DesktopTagWarningsUiTest 2 项 (开/关两种设置下 Rape/Гуро 有标记、Drama 无)、DesktopLibrarySelectionTest 10 项; Android :app:compileDebugKotlin 通过,GlobalTagBlacklistTest 6 + KototoroTaxonomyTest 8 绿 (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_ 开头(与 Android Content.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)。
  • 验证:DesktopSourceSettingsTest 8 项(新增“同键 + 默认 ASK + 存名字读回 + 成人规则只碰成人 + 未知/错类型退回 ASK”)、SharedTabletUiTest 2 项(共享预览卡“宿主不传就没有这行、传了就点得到”)、 DesktopIncognitoTest 6 项(对话框三答 + 不再询问勾选 + 详情按钮 + 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)。对应 Android StatsScreen 的卡片: 摘要(总时长/活跃天数/会话/作品 + 连续天数)、每日活动条形(附活跃天数说明)、时段分布(有小时数据才画)、 类型占比(多于一种类型才画)、作品榜(名次 + 封面 + 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 的 DesktopReadingStatsTest 6 项(停读写两表 + 跨作品不串 + 未 begin 不写 + 不足 5 秒只写 stats + abandon 不留 session + 清空只清统计 + 三种 period 的每日长度);desktop-app 的 DesktopReadingStatsPageTest 5 项(空态、摘要与榜单点击、周期行、清空确认、时长格式阈值)。 新探针 stats(DesktopAppTest 的“a read stop shows up in the statistics screen…”)走真实界面: 真读一章 → 更多 → 阅读统计 → 作品在榜且时长 > 0 → 切成 DAY(每日长度 1)→ 清空先弹框、取消保留、 确认后空态且历史仍在 → 返回更多。回归::desktop-runtime:test 16 套 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 同时带周期与类型,重新进入页面时保留上次的选择。
  • 验证:DesktopReadingStatsTest 7 项(新增:漫画与小说各读一段,ALL 两部都在、合计 60s;只筛漫画 20s/1 session; 只筛小说 40s;筛视频为空);DesktopReadingStatsPageTest 6 项(新增:四个 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)。实现与 Android StatsRepository.getContentSnapshot 同构:ReadingStatsPeriod.ALL 窗口 + 只含这一部作品的 kinds 映射, 交给共享 deriveReadingStatistics,所以日序列只含这部作品的时长、units 与首次阅读直接取派生出的作品记录。 作品没有统计时仍返回全零快照(kind 取 manga 行的 content type,缺行回落 MANGA),不像详情那样报错。
  • 界面:DesktopContentStatsDialog(标题 + Canvas 柱状图 + 首次阅读 + 三块指标 + 取消/详情), 柱状图照 Android ContentStatsBarChart:窗口装不下时按平均压缩柱子、虚线数值网格、圆角柱、粗基线, 颜色按内容类型(漫画 primary / 小说 tertiary / 视频 secondary),全零时显示 empty_stats_text。 指标标签复用 Android 的 stats_total_time/stats_sessions/stats_unit_pages|chapters|episodes, 首次阅读那行照 Android 只印一个相对时间(feedDateLabel 的桶与 Android DateTimeAgo 同构),不加前缀。
  • 状态与导航: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 全绿(DesktopReadingStatsTest 8 项,新增:同一部作品两段阅读 + 另一部作品一段, 单作品快照 45s/2 session/1 页/首次阅读 = 第一段起点/90 天序列只含它的时长;无统计的作品返回全零快照); :desktop-app:test 212 项 1 失败 4 跳过(唯一失败仍是外来 TV 提交的 SharedChapterDirectoryTest, 已知非本工作流);新增 DesktopContentStatsDialogTest 2 项(数值/标签/首次阅读行 + 取消与详情回调; 无统计时保留弹窗、空图表、无首次阅读行、单位按小说换章节); 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) 持有 settings StateFlow,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 全绿,新增 DesktopProxyTest 5 项(新 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:test 213 项 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 全绿,新增 DesktopStorageUsageTest 3 项(按顶层子项归属且 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(对齐 Android VideoDecoderMode: 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%)。背景只给 Android video_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 新增 DesktopVideoSettingsTest 3 项(默认值=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_network store 实例)。
  • 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 新增 DesktopGitHubMirrorTest 6 项(默认直连恒等、按 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 走 JDK HttpClient), 因为客户端、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") 与 ![alt](url)、\<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 MarkdownBlocksTest 18 项全绿(标题/围栏/列表/任务框/引用/分隔线/软硬换行/ 行内强调与链接/snake_case/未闭合标记/HTML 注释与标签/真实 GitHub release note 一段)。
    • :desktop-runtime:test --tests DesktopAppUpdateTest 15 项全绿,新增 “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 切换)与崩溃日志行(Android AboutSettingsScreen 的另外两项)。

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 那一份,没有第二份抄写。
  • 验证:
    • DesktopAboutLinksTest 4 项:正式版默认 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 DesktopAppUpdateTest 15 项全绿;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 自己的处理器不会被吞掉。
  • 接入点: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 的“更多”),返回回“更多”。
  • 验证:
    • DesktopCrashLogsTest 5 项(@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(新 ReplaceRuleRulesTest 7 项 + 原 ReplaceRuleJsonCodecTest 2 项): 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 目前按全局启用状态应用);词典规则(Android dictionary_rules,同一“小说”节里的另一行); 其余设置页对齐(Spaces、下载、备份周期、同步、追踪、服务、导航配置、通知、全景、内容目录、TTS 等)。

167. 共享词典规则(排序/重编号/导入合并)+ Windows 词典规则页(对齐 Android DictionaryRulesActivity)(2026-10-24) ​

  • 问题:§166 的“仍待”里第二条。“阅读设置 → 小说”节的第二行 dictionary_rules(摘要 dictionary_rules_summary) 对应 Android 的 DictionaryRulesActivity:一页表格、纸质相同的五行编辑对话框,加导入/导出/添加。 Windows 之前完全没有入口,dictionary_rules 表也就没人写,词典查询(Android DictionaryActivity)无从谈起。
  • 共享提取(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 幂等,读 classpath defaultData/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(新 DictionaryRuleRulesTest 5 项: 先号后名排序;重编号覆盖位置;导入同名覆盖、新名追加、位置由导入的 sortNumber 决定且重编号 (断言 [百度汉语, 本地词典, 海词英文],因为被顶掉的规则号是 5);导入保留原 enabled (顺序 [新, 关, 开] → [true, false, true]);空文档不改表、条数 0。加上搬过来的 codec 2 项)。
    • :desktop-runtime:test --tests *DesktopDictionaryRulesTest* 6/6(真实 DesktopRuntime.open + @TempDir: 新数据根就有随包的两条(sortNumber 0/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 目前只能维护规则表;小说阅读器的按书规则/逐条禁用 (Android NovelBookPreferences);其余设置页对齐(下载、备份周期、同步、追踪、服务、导航配置、通知、全景、 内容目录、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,标题取 Android downloads。一页一个 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 与 Android DownloadPolicy 改写后仍能编译,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 写自己的 store desktop_nav,键仍是 Android 的 nav_main, 值是逗号连接的枚举名,所以手改文档也要过 parse 的去重/截断/未知词过滤。
    • “更多”故意不在这份文档里:它是通往其他所有页面的唯一出口,侧栏渲染时永远追加,用户删不掉也排不到别处。
  • desktop-app:
    • DesktopSession.navigationPreferences;DesktopController 增加 navSettings、showNavigationConfig()、 saveNavigationSettings() 与 DesktopScreen.NAV_CONFIG(写盘失败在状态栏报 error.navSettingsInMemory); DesktopApp 补页面与标题两臂,标题取 Android main_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()(按 Android ContentType 的序号排,未知类型落到末尾)与 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 (已损坏)), 与 Android ContentSource.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_services store,读写形状同 §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_appearance store:theme / interface_style / language),只挑风格的向导会漏掉最显眼的两个设置;
    • Android 的权限页整页丢弃:桌面没有运行时授权,那一页的每一行(通知、安装未知应用、电池优化)都是 Android 独有能力;
    • Android 的空间页也丢弃:Windows 只有一个库、没有 space 概念;
    • 不自动弹出:Android 首次启动会直接盖住界面,Windows 若照做,任何新建数据根的测试(以及只想先看看的用户) 都会先撞上一个流程页,所以向导只能从“来源设置 → 设置向导”手动进入。
  • 界面(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),不在跑测中途弹浏览器窗口—— 需要人工过验证的来源正是这一页要列出来的东西,让用户在无人的跑测里点掉它反而是错的行为。
  • 三种内容类型走三条 PAGES 判定(Android 的 PAGES 只有图片一种):
    • DesktopReaderKind.PAGES:getPages 后只下载第一张图,下载成功才算 PASS(source_test_msg_pages);
    • NOVEL:getChapterContent 的 html 非空(source_test_msg_text_chapter);
    • VIDEO:getPages 给出流地址后 DesktopSession.probeUrl GET 一次,状态码成功才算 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、会话 percent 0。
  • 回归::desktop-runtime:test 161 个用例 0 失败、:core-backup:jvmTest 15 个 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): 文件名复用 Android BackupUtils.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:verifyDesktopStrings 954 键全通过)。
  • 回归:DesktopLocalBackupsTest 7 个用例(命名/往返解析/keep 覆盖同分钟与 .part 发布/列表排序/ 保留只删自己的文件/trim(0)/进行中的副本不进列表);DesktopWebDavAutoBackupTest 8 个用例,其中 “服务器 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 里,由页面自己显示。

验证:

  • DesktopWebDavAutoRestoreTest 5 个用例:最新备份被合并并把合并快照传回(puts == 1)、当天再调返回 NotDue 且零请求、次日再合并再上传;“空服务器也算查过”(Nothing + 当天不再请求); “失败的检查不算今天”(Failed 且时间仍是 0,把服务器修好后同一天能重试成功); “开关需要完整目的地与显式同意”(关着或服务器为空时 Disabled 且 requests == 0); “查过的时间能跨进程”(重开同一数据根仍是 NotDue 且不再请求)。
  • 真实窗口探针 backups-write/backups-read:断言 webdav-auto-restore 行存在、没填服务器时是禁用的、 填上服务器后变为可用且默认仍是关(截图 backups-write-auto-restore.png: 启动时自动恢复 / 应用每天首次启动时合并恢复 WebDAV 上最新备份一次,恢复完成后立即把合并后的快照上传回 WebDAV。)。
  • 回归:DesktopWebDavTest 10、DesktopWebDavAutoBackupTest 8、DesktopWebDavRoundTripTest 1 全绿; :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_webdav store 里。
  • 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 选择… 恢复默认 打开)。
  • 回归:DesktopWebDavTest 10、DesktopWebDavAutoBackupTest 9、DesktopWebDavAutoRestoreTest 5、 DesktopWebDavRoundTripTest 1、DesktopLocalBackupsTest 9 全绿;: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_cleanup store 的 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 的 DesktopChapterCleanupTest 5 个用例:话号乱序也能按顺序判定、当前章节与其后的章节保留、 另一个分支独立、当前章节不在列表里(或列表为空)算零已读、下载章节补进候选且不重复。
  • desktop-app 的 DesktopReadChaptersCleanupTest 2 个用例:写 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 那种“表里没有就现拉远端章节列表” 的回退,所以一部从没在桌面端打开过详情的作品不会被这一步判定。

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:RelatedContentRulesTest 5 个用例(标题/别名/词与去重、单字丢弃与 6 个上限、空标题、 候选判定含别名、最小组与空输入);TitleKeysTest 4 个用例(大小写/标点/空白、来源名尾巴被去掉、 别的括号尾巴保留、空括号不切标题);RelatedWorkRulesTest 5 个用例(同 id/同 URL/同公开 URL 都算自己、 同标题配同一张封面路径(含换域名带 query)算同一部、不同标题或不同封面保留、别的来源同 id 不算自己、 没有封面时不能只按标题判同)。前者 :core-domain:compileCommonMainKotlinMetadata 通过,规则可移植。
  • Android:RelatedContentUseCaseTest 2 个用例在改用共享规则后仍然通过,TitleSimilarityTest(10)、 TitleNormalizerTest(6)也通过,RelatedContentSearchFallback 换成 RelatedContentRules 后 :app:compileDebugKotlin 通过。
  • desktop-runtime:DesktopRelatedContentTest 6 个用例(同 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*:DesktopSourceSwitchTest 11 条(含新增四条)全绿。
  • :desktop-app:test:51 个套件 / 282 个用例(此前 278 + 新增 4)/ 0 失败;:desktop-runtime:test 28 个套件 / 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) (Android UnifiedSourcesViewModel.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:test 51 个套件 / 285 个用例(此前 282 + 新增 3)/ 0 失败;:desktop-runtime:test 28 个套件 / 183 个用例(此前 181 + 新增 2)/ 0 失败;:core-domain:jvmTest 41 个套件 / 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:test 51 个套件 / 288 个用例(此前 285 + 新增 3)/ 0 失败;:desktop-runtime:test 28 个套件 / 183 个用例(不变)/ 0 失败;:core-domain:jvmTest 42 个套件 / 261 个用例(此前 41 / 255 + 新的 SourceDeletionRulesTest 6 条)/ 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(Android Bookmark.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(desktopNavItemId 6、图标 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:test 187 条 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:test 292 条 0 失败、:desktop-runtime:test 187 条 0 失败、:core-domain:jvmTest 261 条 0 失败、 :desktop-app:verifyDesktopStrings 960 键 / 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:test 293 条 0 失败(4 跳过)、:desktop-runtime:test 191 条 0 失败(2 跳过)、 :core-domain:jvmTest 261 条 0 失败、:desktop-app:verifyDesktopStrings 960 键 / 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 是 Android SuggestionsListQuickFilter 的三类芯片: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:test 294 条 0 失败(4 跳过)、:desktop-runtime:test 197 条 0 失败(2 跳过)、 :core-domain:jvmTest 261 条 0 失败、:desktop-app:verifyDesktopStrings 960 键 / 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:test 301 条 0 失败(4 跳过)、:desktop-runtime:test 200 条 0 失败(2 跳过)、 :desktop-app:verifyDesktopStrings 961 键 / 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:test 304 条 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:test 305 条 0 失败(4 跳过,比 §200 多出新的 1 条);:desktop-app:verifyDesktopStrings 961 键 / 961 被引用通过。

202. Windows 阅读器补「章节标题在底部」(reader_chapter_title_bottom)(2026-10-25) ​

实现 ​

  • DesktopReaderSettings 增 chapterTitleAtBottom(默认 false,与 Android AppSettings.isReaderChapterTitleAtBottom 同默认),读写阅读偏好 store 的 reader_chapter_title_bottom(Android KEY_READER_CHAPTER_TITLE_BOTTOM 同名键)。
  • DesktopReaderChrome 把该值传给共享 ReaderChromeTopBar:开着时顶栏那枚 ReaderChapterTitleChip 不再画; 同时在底部 chrome 里、进度坞上方按 FloatingControlInset 基线画一枚同样的共享药丸(与右侧悬浮按钮同高, Android ComposeReaderActivityScaffold 的底部药丸就是这么摆的),点它仍开章节面板,语义标签 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:test 306 条 0 失败(4 跳过,比 §201 多出新的 1 条);:desktop-app:verifyDesktopStrings 961 键 / 961 被引用通过。

203. Windows 阅读器补「减少页面预加载」(reader_reduce_offscreen_quality)(2026-10-25) ​

实现 ​

  • 共享 ReaderOptionsFeatures 原先只有一个 performance 旗标代表 Android 性能分组的两行;现在拆成 optimization(「限制图片内存缓存」,Android reader_optimize)与 preloadReduction(「减少页面预加载」, Android reader_reduce_page_preloading)两个旗标。AndroidReaderOptionsFeatures 只依赖默认值,所以安卓照旧画两行。
  • DesktopReaderSettings 增 reducePagePreloading(默认 false,与 Android AppSettings.isReaderPreloadReductionEnabled 同默认),读写阅读偏好 store 的 reader_reduce_offscreen_quality (Android KEY_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:test 307 条 0 失败(4 跳过,比 §202 多出新的 1 条); :desktop-app:verifyDesktopStrings 961 键 / 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:test 308 条 0 失败(4 跳过,比 §203 多出新的 1 条); :desktop-app:verifyDesktopStrings 961 键 / 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 尺寸”这一步(Android Size/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:Vision RecognizeTextRequest(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 的字段 + 实时预览)、导入/导出。入口在 设置 → 阅读,以及小说阅读设置。规则变更时 bump replaceRules.revision,打开中的阅读器保存位置后重新加载当前章。

Android 修复(朗读切分 Tokenizer):

  1. 角色音色写死为三个 Azure 语音名,HTTP 朗读引擎拼 时优先用它,非 Edge/Azure 的 Legado 朗读源在对话句上 会请求失败或换错声音。改为 tokenize(text, speakerVoices = emptyList()):没有传入可用音色时不分配,引擎使用用户选的声音。
  2. SpeakerResolver 原先每段新建,同一角色换段就换声音;改为整章一个。
  3. 句子按 。!? 切分会把 “你好。”张三说。 切成 “你好。 与 ”张三说。,两段都被当作对话,说话人名还会带上引号; 现在句末右引号留在本句,说话人名排除引号字符。

验证:

  • 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 首次解锁成功后升级), AppPasswordHashTest 2/2 通过。
  • 小说划线(#18):共享 reader-novel/NovelMarkings.kt(relocateNovelMarking 等,Android 原函数改为委托, NovelTextSelectionTest 10/10、NovelMarkingsTest 4/4)、core-library 的 NovelMarkingStore、iOS IosNovelMarkings; 分页选中菜单“划线”、更多面板“划线当前段”、笔记页列出划线。已编译,未跑 UI 测试。
  • 保存筛选 UI 测试最终通过(需在视频空间启动、先清理上次遗留的筛选)。

213. iOS 自定义空间、剩余阅读时间、划线与应用锁测试收尾(2026-10-25) ​

  • 自定义空间(#13):core-domain 新增 CustomSpace(按 Android 在 space_definitions 中的存储格式解码/编码,类型/语言/来源 类型之间为“且”、同一项内为“或”、留空不限,mediaKind 给出单一媒体类型),CustomSpacesTest 2/2。iOS IosSpaces (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 页 × 测得的每页时间), 小说(“页”是行)与视频不估算;Android ReadingTimeUseCase 改用它(原先对小说/视频也给出无意义的数字), ReadingTimeEstimateTest 2/2。iOS 详情页章节行显示“剩余约 …”,testTimeLeftShowsForComicsOnly 通过。
  • 小说划线 UI 测试 testNovelParagraphHighlightAppearsInNotes 通过(更多面板划线当前段 → 笔记页出现 → 删除)。
  • 应用锁/后台更新测试 testAppLockAndBackgroundUpdateSettings 通过(宽行里的开关要点在行尾)。

214. iOS 作品库筛选、分享页面、PDF 导入(2026-10-25) ​

  • 作品库筛选:core-domain 新增 ShelfFilter/ShelfRow(阅读进度、下载、快捷、出版状态、来源、标签;组内“或”、组间“且”; “在读”与“读完”共用 0.999 阈值,避免 Windows DesktopLibrarySelection 里 99.95% 同时属于两组的问题),ShelfFilterTest 2/2; iOS IosShelf + 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 不受影响;同一组文件重复导入沿用作品身份), LocalSourceRuntimeTest 15/15(含新用例),Android 编译通过。iOS 本地视频来源顶栏“导入视频文件夹”(系统文件夹选择器, 逐个用 AVFoundation 校验,跳过不能播放的)。testVideoFolderImportsAsEpisodes 通过。
  • 小组件:新 WidgetKit 扩展 KototoroWidget(App Group group.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 是视频下载 落地后失效的旧断言(干净工作树同样失败),与本次改动无关。

Documentation for Kototoro