- 桌面应用
- 开发工具
【免费下载链接】MooTool
Handy tool set for developers
导读
本文围绕 MooTool 下一代 Compose 桌面版中 DIFF-189 文档 记载的一项关键兼容性改造展开:把旧 Java 版二维码工具的「上次导出目录」(func.qrCode.qrCodeSaveAsPath)迁入 Compose 版全局统一的「工具默认导出目录」(tools.exportDirectory),并让二维码生成页的保存对话框正确复用该目录。读完本文,你将掌握 legacyconfig.setting的解析与补丁应用机制、tools.exportDirectory的存储与校验规则、AWTFileDialog在 Compose 桌面端的封装方式,以及如何用applyPatch候选链与集成测试验证这一迁移行为。
一、改造背景:两条导出目录来源的归一
1.1 Java 旧版的「记住上次保存目录」
在 Java(Swing)版 MooTool 中,二维码工具维护着一个独立的「上次保存路径」配置项:
- 配置键:
func.qrCode.qrCodeSaveAsPath - 存储位置:Java 版的
config.setting(INI 风格配置文件,位于config/config.setting) - 行为:用户在二维码生成 Tab 保存图片后,该键会被更新为本次保存文件的父目录;下次打开保存对话框时以此目录为初始位置
这一行为在 Compose 版对应的实现中同样被继承:生成 Tab 的「保存」成功后会将父目录写回导出目录配置(详见后文「四、F23 运行时行为」)。
1.2 Compose 版的全局导出目录
Compose 版不再为每个工具各自维护一个导出路径,而是引入全局配置项:
- 配置键:
tools.exportDirectory(对应AppSettings.tools中的ToolSettings.exportDirectory) - 默认值:空字符串,表示使用产品默认行为
- 适用范围:随手记(QuickNote)、JSON、图片、Host、二维码等多个工具共用
从 Settings.kt 可以看到ToolSettings的定义:
@Serializable data class ToolSettings( val qrCodeSize: Int = 300, val qrErrorCorrection: String = "M", val randomStringLength: Int = 16, val exportDirectory: String = "", val translationProvider: String = "google", val translationSourceLang: String = "auto", val translationTargetLang: String = "zh-CN" )这一全局合并的决策源自同目录下的 DIFF-142(export-directory-absolute-path.md),它明确了两条语义:留空表示默认行为,非空必须为绝对路径,并规定设置页提交时复用VaultPathConfig校验、加载时清除已保存的相对路径。
二、迁移机制:qrCodeSaveAsPath进入applyPatch导出目录候选链
2.1 迁移入口:applyPatch
旧 Java 配置的导入发生在「设置 → 迁移」流程中。当用户选择旧版 MooTool 源码目录后,CrossProductImporter会扫描并解析config.setting,随后通过LegacyJavaSettings.applyPatch将可迁移的配置项合并进当前的AppSettings。调用链位于 MigrationSettingsPanel.kt:
if (LegacyJavaSettings.hasMigratableSettings(scanned.legacyJavaConfig)) { next = LegacyJavaSettings.applyPatch(next, scanned.legacyJavaConfig) }2.2 候选链的优先级顺序
在 LegacyJavaSettings.kt 中,导出目录的合并遵循一条「按工具优先级依次回退」的候选链:
var tools = next.tools ... val exportDirectory = value(config, "func.quickNote", "quickNoteExportPath") .ifEmpty { value(config, "func.jsonBeauty", "jsonBeautyExportPath") } .ifEmpty { value(config, "func.image", "imageExportPath") } .ifEmpty { value(config, "func.host", "hostExportPath") } .ifEmpty { value(config, "func.qrCode", "qrCodeSaveAsPath") } if (exportDirectory.isNotEmpty() && Path.of(exportDirectory).isAbsolute()) { tools = tools.copy(exportDirectory = exportDirectory) }DIFF-189 的改动正是把qrCodeSaveAsPath追加到这条候选链的末尾,作为最后一个兜底来源。候选链的完整顺序为:
| 优先级 | legacy 配置键 | 来源工具 |
|---|---|---|
| 1 | func.quickNote.quickNoteExportPath | 随手记 |
| 2 | func.jsonBeauty.jsonBeautyExportPath | JSON |
| 3 | func.image.imageExportPath | 图片 |
| 4 | func.host.hostExportPath | Host |
| 5 | func.qrCode.qrCodeSaveAsPath | 二维码(DIFF-189 新增) |
需要注意两个关键条件:
- 绝对路径校验:候选链只接受绝对路径。
if (exportDirectory.isNotEmpty() && Path.of(exportDirectory).isAbsolute())—— 相对路径或空值都会被忽略,这保证了写入tools.exportDirectory的值总是合法的绝对路径。 - 非空优先:链上更靠前的工具只要非空即短路生效,二维码路径只在前四项都为空时才会被采用。
顺带一提,applyPatch同时也迁移了二维码工具的其他 legacy 配置,例如qrCodeSize(默认 300)和qrCodeErrorCorrection(映射到 L/M/Q/H,见legacyQrCorrection),这些与导出目录共同完成了二维码工具设置的整体迁移。
2.3 为什么需要绝对路径校验
Compose 版的路径语义与 Vault 目录保持一致:配置值为空时使用产品默认目录,非空时必须是绝对路径。这一校验同时存在于三个层面:
- 迁移层:上述
applyPatch的isAbsolute判断; - 存储层:
SettingsRepository.sanitizeLoadedSettings在加载设置时调用VaultPathConfig.effectiveCustomRoot清理非法值,见 SettingsRepository.kt; - 设置页提交层:设置页的导出目录输入框通过
DirectorySettingRow提交,同样复用路径校验逻辑。
VaultPathConfig.kt 的实现提供了统一的判定:
fun normalizedCustomRoot(value: String): String? { val trimmed = value.trim() if (trimmed.isEmpty()) return "" val path = runCatching { Path.of(trimmed).normalize() }.getOrNull() ?: return null if (!path.isAbsolute) return null return path.toString() } fun effectiveCustomRoot(value: String): String = normalizedCustomRoot(value) ?: ""三、存储与持久化:设置加载时的自动净化
迁移写入的tools.exportDirectory最终会随AppSettings一起以 JSON 形式持久化。为保证配置始终合法,SettingsRepository.load会在加载时对导出目录做归一化处理:
- 空值 → 保持空字符串(默认行为)
- 合法绝对路径 → 原样保留
- 相对路径 / 非法路径 → 清空为
""
对应的测试位于 SettingsVaultPathSanitizeTest.kt:
tools = AppSettings.Default.tools.copy(exportDirectory = "exports/out") ... val loaded = SettingsRepository(directories).load() assertEquals("", loaded.tools.exportDirectory)这意味着即使外部手工编辑设置文件写入了相对路径,应用加载后也会将其清除,从而保证运行期文件对话框不会使用一个无法解析的目录。
四、F23 运行时行为:保存对话框与目录回写
4.1 保存对话框的初始目录
迁移完成只是第一步,DIFF-189 还规定二维码生成 Tab 在运行时也要遵循统一的导出目录。Compose 版二维码页面在 QrCodeScreen.kt 中,生成 PNG 后点击「保存」时:
MooButton(container.t("common.save"), enabled = QrWiringPresentation.hasPngOutput(session.pngBytes), p5Toolbar = true, onClick = { val bytes = session.pngBytes ?: return@MooButton val file = pickSavePngFile(container, container.t("common.save")) ?: return@MooButton when (val outcome = QrWiringPresentation.runWritePngFile(file, bytes)) { QrWiringPresentation.WritePngOutcome.Success -> { persistToolsExportDirectory(container, file) session.notice = container.t("common.save") ... } ... } })其中pickSavePngFile与选择 Logo、识别图片所用的pickImageFile一样,都收敛到统一的 ToolsExportDirectory.kt 中的chooseFileWithExportDirectory:
fun chooseFileWithExportDirectory( container: AppContainer, save: Boolean, title: String, defaultFileName: String = "", ): File? { val exportDir = VaultPathConfig.effectiveCustomRoot(container.settings.value.tools.exportDirectory) val dialog = FileDialog(null as Frame?, title, if (save) FileDialog.SAVE else FileDialog.LOAD) if (defaultFileName.isNotBlank()) { dialog.file = defaultFileName } if (exportDir.isNotBlank()) { val dir = File(exportDir) if (dir.isDirectory) { dialog.directory = exportDir } else { dir.parentFile?.takeIf { it.isDirectory }?.let { dialog.directory = it.absolutePath } } } dialog.isVisible = true val directory = dialog.directory ?: return null val name = dialog.file ?: return null return File(directory, name) }这里有几个值得注意的工程细节:
- 初始目录:以
VaultPathConfig.effectiveCustomRoot(exportDirectory)计算,即迁移写入(或用户在设置页配置)的tools.exportDirectory; - AWT FileDialog 封装:Compose 桌面版复用
java.awt.FileDialog作为系统保存/打开对话框,SAVE与LOAD两种模式共用同一入口; - 目录容错:若配置的目录已被删除(
dir.isDirectory为 false),会自动回退到其父目录(若父目录仍存在),避免对话框打开到一个不存在的路径; - 默认文件名:保存 PNG 时默认文件名固定为
mootool-qrcode.png,见 QrCodeScreen.kt,且当用户输入的文件名缺少扩展名时会自动补上.png。
4.2 保存成功后回写父目录
保存成功后调用persistToolsExportDirectory(container, file),将所选文件的父目录回写到tools.exportDirectory(见 ToolsExportDirectory.kt):
fun persistToolsExportDirectory(container: AppContainer, file: File) { val parent = when { file.isDirectory -> file.absolutePath.takeIf { it.isNotBlank() } else -> file.parentFile?.absolutePath?.takeIf { it.isNotBlank() } } ?: return if (!java.nio.file.Path.of(parent).isAbsolute) return if (container.settings.value.tools.exportDirectory == parent) return container.updateSettings { current -> current.copy(tools = current.tools.copy(exportDirectory = parent)) } }这一「保存后更新导出目录」的运行时行为,正是对齐 Java 旧版「保存成功后更新qrCodeSaveAsPath」的语义 —— 二者都实现了「记住上次导出目录」的用户体验。同时该函数内置了双重防护:父目录必须为绝对路径、且与当前值不同时才触发设置更新,避免无意义的重复写入。
4.3 二维码工具 IO 的统一收口
从 DIFF-657(qrcode-export-directory-slice.md)可以看到,二维码工具的 Logo 选择、识别图片选择、PNG 保存三条 IO 路径统一收口到chooseFileWithExportDirectory+persistToolsExportDirectory,取代了此前各页面本地的裸FileDialog。因此,无论迁移配置、设置页配置还是运行中保存产生的新目录,都能在二维码工具的这三类操作中被一致地当作初始目录使用。
五、验证方式:单元测试与离线测试命令
5.1 迁移行为测试
DIFF-189 的核心验证用例是LegacyJavaSettingsTest.applyPatchUsesQrSaveAsExportDirectory,位于 LegacyJavaSettingsTest.kt:
@Test fun applyPatchUsesQrSaveAsExportDirectory() { val root = createTempDirectory("legacy-java-qr-save-") val saveDir = root.resolve("qr-out").also { it.toFile().mkdirs() } val config = LegacyJavaSettings.parse( """ [func.qrCode] qrCodeSaveAsPath=${saveDir} """.trimIndent() ) val patched = LegacyJavaSettings.applyPatch(AppSettings.Default, config) assertEquals(saveDir.toString(), patched.tools.exportDirectory) root.toFile().deleteRecursively() }该测试的验证路径完整覆盖了 DIFF-189 的迁移链路:
- 用
LegacyJavaSettings.parse解析一段仅含[func.qrCode] qrCodeSaveAsPath=<绝对路径>的 INI 片段; - 调用
applyPatch(AppSettings.Default, config); - 断言
patched.tools.exportDirectory等于该绝对路径。
由于AppSettings.Default中前四个候选键(quickNote/json/image/host)均为空,二维码路径通过候选链兜底生效,从而证明了 DIFF-189 的迁移逻辑成立。若传入的是相对路径(如qr-out),则因不满足isAbsolute条件而不会写入导出目录 —— 这一边界行为也与前面分析的绝对路径校验一致。
5.2 运行全部桌面端测试
文档同时给出了离线运行测试的命令:
./gradlew :composeApp:desktopTest --offline该命令在next-compose目录下执行(需 JDK 21 工具链),会运行desktopTest下的全部测试,其中包括上述LegacyJavaSettingsTest以及SettingsVaultPathSanitizeTest、VaultPathConfigTest等路径校验相关用例。若只想验证本改动,也可以配合--tests参数精确过滤:
./gradlew :composeApp:desktopTest --offline --tests "*LegacyJavaSettingsTest"六、整体链路小结
把 DIFF-189 的改造串联起来,二维码导出目录在 Compose 版中的完整生命周期是:
- 迁移阶段:用户从「设置 → 迁移」导入旧 Java 版目录时,
LegacyJavaSettings.parse解析config.setting,applyPatch将func.qrCode.qrCodeSaveAsPath(绝对路径)经候选链写入tools.exportDirectory; - 持久化阶段:设置以 JSON 保存,
SettingsRepository.load加载时通过effectiveCustomRoot净化非法相对路径; - 运行阶段:二维码生成 Tab 的「保存」通过
chooseFileWithExportDirectory以tools.exportDirectory为初始目录打开系统保存对话框;保存成功后persistToolsExportDirectory将父目录写回,实现「记住上次导出目录」; - 校验阶段:
applyPatchUsesQrSaveAsExportDirectory等测试用例保障迁移行为不回归。
这一改造的意义在于:Compose 版把原来散落在各工具(随手记、JSON、图片、Host、二维码)的独立导出路径配置统一收敛为一个全局tools.exportDirectory,既保留了 Java 旧版「记住上次保存位置」的体验,又为后续所有工具的导出/导入路径提供了单一、可校验的配置来源。
参考资料与延伸阅读
- DIFF-189 原文:本文主体依据
- LegacyJavaSettings.kt:
applyPatch候选链与 legacy 配置解析实现 - LegacyJavaSettingsTest.kt:迁移行为测试
- ToolsExportDirectory.kt:文件对话框统一封装与目录回写
- QrCodeScreen.kt:二维码页面保存/选择 IO
- VaultPathConfig.kt:绝对路径校验与回退语义
- SettingsRepository.kt:设置加载净化
- SettingsVaultPathSanitizeTest.kt:路径净化测试
- DIFF-142:导出目录绝对路径语义的基线决策
- DIFF-657:二维码工具 IO 统一收口的关联切片
- 桌面应用
- 开发工具
【免费下载链接】MooTool
Handy tool set for developers
相关推荐
MooTool Compose 版 DIFF-180:Java 版 ConsoleUtil 控制台草稿(计算器/二维码)迁移实现解析
MooTool Compose 版 DIFF 180:Java 版 ConsoleUtil 控制台草稿(计算器/二维码)迁移实现解析 本文聚焦 MooTool
桌面应用开发工具MooTool(Compose 版)遗留 Java 版二维码 Logo 与识别图路径迁入指南(DIFF-183)
MooTool(Compose 版)遗留 Java 版二维码 Logo 与识别图路径迁入指南(DIFF 183) 导读 MooTool 的新版桌面客户端(Com
桌面应用开发工具Learn Harness Engineering 第三讲:让代码仓库成为 AI Agent 的唯一事实来源(System of Record)
Learn Harness Engineering 第三讲:让代码仓库成为 AI Agent 的唯一事实来源(System of Record) 导读 :本讲解
桌面应用开发工具
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考