
鸿蒙 PC Markdown 编辑器文件系统:Core File Kit 与安全保存
本文聚焦授权 URI、流式 UTF-8 解码、短写检测、持久化完成点、外部冲突和保存失败恢复。完整示例代码:https://gitcode.com/VON-/codex_md_oh。
不把文件系统暴露给 Web
鸿蒙 PC 上的文档不应由 ArkWeb 直接扫描文件路径。OhMarkdown 由 ArkTS 通过 DocumentViewPicker 获得用户明确选择的 URI,再使用 Core File Kit 读写。Web 编辑器只看到已读取的文本,不拥有目录遍历能力。
选择器还限制一次只选一个文档,并优先展示 Markdown 和文本后缀:
ts
export async function pickMarkdownDocument(context: Context): Promise<OpenedDocument | undefined> {
const options = new picker.DocumentSelectOptions();
options.maxSelectNumber = 1;
options.fileSuffixFilters = ['Markdown|.md,.markdown,.mdown,.mkd,.txt'];
const documentPicker = new picker.DocumentViewPicker(context);
const selectedUris = await documentPicker.select(options);
if (selectedUris.length === 0) {
return undefined;
}
return readUtf8Document(selectedUris[0]);
}
代码来源:entry/src/main/ets/shared/services/DocumentService.ets
分块读取与 UTF-8 严格校验
当前技术验证将单文档上限设为 20MiB,每次读取 64KiB。TextDecoder 使用 fatal: true,非法 UTF-8 不会被静默替换成错误字符。同时检查实际读取字节数与 stat.size,用于识别读取期间文档被外部改变的情况。
ts
const decoder = util.TextDecoder.create('utf-8', { fatal: true, ignoreBOM: false });
const chunks: Array<string> = [];
let totalBytesRead: number = 0;
while (totalBytesRead < stat.size) {
const requestedBytes = Math.min(READ_CHUNK_BYTES, stat.size - totalBytesRead);
const chunk = new ArrayBuffer(requestedBytes);
const bytesRead = await fileIo.read(file.fd, chunk, { length: requestedBytes });
if (bytesRead === 0) {
break;
}
totalBytesRead += bytesRead;
chunks.push(decoder.decodeToString(new Uint8Array(chunk, 0, bytesRead), {
stream: totalBytesRead < stat.size
}));
}
代码来源:entry/src/main/ets/shared/services/DocumentService.ets
为什么写入后还要 truncate 和 fsync
ts
export async function writeUtf8Document(uri: string, content: string,
format: DocumentFormat = createDefaultDocumentFormat()): Promise<string> {
const file = await fileIo.open(uri, fileIo.OpenMode.READ_WRITE);
try {
const serializedContent = serializeDocument(content, format);
const expectedBytes = buffer.from(serializedContent, 'utf-8').length;
const writtenBytes = await fileIo.write(file.fd, serializedContent, { offset: 0, encoding: 'utf-8' });
if (writtenBytes !== expectedBytes) {
throw new Error('The complete document could not be written.');
}
await fileIo.truncate(file.fd, writtenBytes);
await fileIo.fsync(file.fd);
return file.name;
} finally {
await fileIo.close(file);
}
}
代码来源:entry/src/main/ets/shared/services/DocumentService.ets
如果新内容比旧文件短,只从 offset 0 覆盖会在文件尾部留下旧字节,因此必须 truncate。fsync 则要求系统将写入同步到存储设备,不只留在进程缓冲中。写入长度也按 UTF-8 字节而不是 JavaScript 字符数校验,否则中文文档会得到错误结论。
鸿蒙 PC 应用内的保存后状态
下图来自鸿蒙 PC / 2in1 模拟器中的应用内部界面。文档正常保存并重启后,编辑区回到干净会话,没有出现未完成保存或崩溃恢复提示;状态栏保留 UTF-8 与 LF 格式信息。系统选择器负责授予 URI,应用内部则负责呈现当前文档和持久化状态。

"可保存"不等于"已达最终安全保存"
基础实现完成用户 URI 的原位写入闭环,并增加保存前沙箱备份、外部修改冲突检测和失败后恢复。但受授权 URI 能力限制,目标文件仍不能保证像应用沙箱中的 AtomicFile 那样完成同目录临时文件原子替换。磁盘写满、中途异常、只读 URI 和权限失效仍需用故障注入持续验证。
URI 是授权句柄,不是普通路径字符串
系统选择器返回的 URI 表达用户对某个文档的明确授权。应用应保存和使用这个 URI,而不是从显示名称拼接一个猜测路径,更不能因为需要文件列表就申请无限制的全盘访问。URI 可能由不同文档提供方实现,其权限、生命周期和路径表现并不等同于 POSIX 文件。
这会影响错误处理:同一个 URI 可能在下次启动时权限失效,文档可能被外部移动,提供方也可能拒绝某种打开模式。应用不能把所有异常都显示为"文件不存在",需要区分用户取消、无权限、格式不支持、文件过大、读取中变化和写入失败。
文件名只能用于界面和默认导出名,不能作为文件身份。两个目录可以存在同名 README.md,重命名也不应让编辑器把它误认为另一份缓存。当前会话的核心标识应是授权 URI,工作区条目则保留父目录关系和真实子 URI。
打开流程为什么先检查再解码
读取一个 Markdown 文档看似只需要 read,实际至少包含以下步骤:
- 使用授权 URI 打开只读文件句柄。
- 获取
stat.size,在分配大缓冲区前执行 20MiB 上限检查。 - 以 64KiB 分块读取,避免一次分配与文件等大的 ArrayBuffer。
- 使用流式 UTF-8 解码,处理多字节字符跨块边界的情况。
- 比较累计读取字节与初始大小,识别读取过程中截断或变化。
- 检测 BOM 和行尾格式,正文与格式元数据分别保存。
- 无论成功失败都关闭文件句柄。
流式解码中的 stream 参数很关键。一个中文字符可能有三个 UTF-8 字节,刚好被两个 64KiB 块分开;如果每块独立解码,边界字符可能变成替换符。TextDecoder 保留未完成字节,直到下一块到达,最后一块再结束解码状态。
fatal: true 表示遇到非法 UTF-8 就失败,而不是用 � 悄悄替换。对编辑器而言,静默替换后再保存会永久改变用户文件,因此明确拒绝并提示"当前仅支持有效 UTF-8"更安全。未来支持其他编码时,也应通过可识别的编码策略打开,不能降低为任意字节猜测。
文件大小上限要在多个层次一致
20MiB 是当前单文档保护上限,5MiB 是 Web 编辑器的大文档降级阈值,两者含义不同。前者阻止应用读取超出当前验证范围的文件,后者允许继续打开但关闭高成本能力。
大小还存在字节和字符差异。stat.size 是文件字节数,JavaScript content.length 接近 UTF-16 代码单元数量,中文和 emoji 下二者不相等。读取上限应按字节判断,Bridge 快照上限和编辑扩展阈值则按内存模型选择并明确单位,保存完整性必须比较 UTF-8 编码后的字节数。
如果各层都写一个含义不明的 MAX_SIZE,很容易出现原生允许 20MiB、Bridge 只接受 5MiB、界面却仍承诺完整恢复。常量名称、错误文案和测试语料应把单位与目的写清楚。
原位写入为什么有截断风险
目标 URI 只提供读写句柄时,常见保存方式是从 offset 0 写入。如果新正文短于旧正文而不调用 truncate,尾部会残留旧字节。例如原文件是 # title\nlong paragraph,新文件只有 # title,覆盖前七个字节并不会自动删除剩余内容。
完整写入也不能只看 API 没有抛错。底层可能出现短写,所以实现计算 UTF-8 期望字节数并比较返回值。中文字符串的 content.length 不能代替字节数,否则多个汉字会导致错误判断。写完后执行 fsync,再把保存完成传回编辑器;只有到这个点,界面才有资格把 Modified 改为 Saved。
即便完成 write + truncate + fsync,进程如果在原位覆盖中途终止,目标文件仍可能已经部分改变。这就是为什么安全保存还需要保存前的旧内容副本。
保存前沙箱备份与启动恢复
当前实现会在写用户 URI 前重新读取磁盘版本,并把旧正文、BOM、行尾、文档 URI 和时间写入应用沙箱的待处理保存备份。备份使用 AtomicFile 提交,避免备份 JSON 自身只写了一半。随后才执行目标 URI 写入。
保存成功后清除备份;保存失败时立即尝试把旧版本写回目标 URI。如果即时恢复也失败,备份不会删除。应用下次启动会发现这份记录,提示用户保留当前文件或恢复先前版本。这样至少把"目标文件可能受损"的事实从一次临时错误变成可继续处理的状态。
这套方案不是目标文件的原子替换。真正理想的保存是在同一文件系统创建临时文件、完整写入并同步,再用原子 rename 替换目标。但系统授权 URI 不一定允许创建同目录临时文件或重命名。工程上不能声称不存在的原子能力,因此使用沙箱旧版本作为降级保护,并把保存途中强杀作为设备故障用例。
保存前重新读取用于发现外部冲突
桌面用户可能同时用终端、版本控制或另一款编辑器修改同一文件。如果 OhMarkdown 打开文件后一直只保留旧基线,几分钟后直接保存会覆盖外部变化。
当前保存流程会重新读取磁盘,比较内容与打开时记录的持久化基线,同时比较 BOM 和行尾元数据。如果磁盘版本已经变化,应用阻止覆盖并提示用户重新打开。此时宁可让用户手动合并,也不能把外部编辑静默抹掉。
未来可以提供三方合并:打开基线、本地未保存版本、当前磁盘版本。但"强制覆盖"必须是明确的二次动作,并最好先保留磁盘备份。仅比较修改时间不够可靠,因为时间精度、同步工具和内容相同重写都会产生边界;当前文本与格式比较虽然有成本,却更符合 20MiB 范围内的数据安全优先级。
BOM 与换行属于保存契约
文档打开后,编辑器正文与 DocumentFormat 分开保存。UTF-8 BOM 不进入 CodeMirror 光标空间,CRLF 则通过行分隔符配置和保存序列化保留。普通 LF、CRLF 文件编辑后应继续使用原格式;没有换行的单行文件保持 NONE 语义。
Mixed EOL 更复杂,因为编辑后新增行无法自动知道应使用哪一种历史风格。当前保存时明确询问用户将文档规范化为 LF 或 CRLF,而不是悄悄选择。这个决定会改变较多字节,因此必须出现在保存路径,并更新后续持久化基线。
格式状态显示在状态栏不是装饰,它让用户在保存前知道应用识别到的编码和行尾。后续字节级夹具会用 SHA-256 和十六进制验证 BOM、中文、尾随换行与 CRLF 往返,而不能只用编辑器显示内容相同作为结论。
未保存切换与系统取消也属于文件正确性
点击 Open、New、工作区中的另一个文件或关闭窗口时,如果当前文档已修改,应用必须先确认用户意图。系统选择器取消不应清空当前文档,也不应把状态改成错误;打开失败时原文档和撤销历史应保持不变。
保存对话框同样有取消路径。新文档在用户取消目标选择后仍然是未保存文档,恢复快照继续存在。只有实际写入成功,才记录新的 URI、文件名和持久化基线。把"用户取消"和"系统失败"分开,可以避免令人不安的错误提示,也让自动化用例更准确。
建议的文件故障测试矩阵
| 场景 | 预期结果 |
|---|---|
| 选择器取消 | 当前文档、脏状态和撤销栈不变 |
| 非法 UTF-8 | 明确拒绝打开,不生成替换字符 |
| 超过 20MiB | 在读取全文前拒绝并说明上限 |
| 新内容短于旧内容 | 保存后没有旧尾部字节 |
| 中文与 emoji | 实际写入字节数校验正确 |
| 保存前文件被外部修改 | 阻止覆盖并保留本地未保存内容 |
| 写入发生短写 | 不标记 Saved,尝试恢复旧版本 |
| 写入过程中强制停止 | 重启后检测待处理备份并提供恢复 |
| 只读或权限过期 URI | 显示可处理错误,草稿仍可恢复 |
| BOM/CRLF 文档 | 编辑保存后格式与正文符合策略 |
| Mixed EOL | 保存前明确选择 LF 或 CRLF |
| 磁盘空间不足 | 不清除恢复记录,不声称保存成功 |
这些用例要区分纯函数、模拟器和真机。序列化、格式检测可以自动化;系统选择器、授权 URI 和强杀需要鸿蒙设备;磁盘写满、断电和特殊文档提供方最好在可控环境做故障注入。
文件能力验收清单
- Web 页面没有直接文件访问,所有 URI 都来自用户授权或已授权工作区。
- 打开前检查字节大小,分块读取并使用严格流式 UTF-8 解码。
- 文件读取期间变化、非法编码和超限都有明确错误。
- 保存按 UTF-8 字节验证短写,随后 truncate 和 fsync。
- 保存成功点与界面 Saved 状态一致,不在 Bridge 接收时提前完成。
- 写入前保留原磁盘版本,失败后恢复,未解决备份在重启时可发现。
- 保存前检测外部修改,默认不覆盖未知的新磁盘内容。
- BOM、LF、CRLF、Mixed EOL 的策略可见且可测试。
- 取消、失败、权限失效不会清空当前编辑内容和恢复草稿。
- 测试报告说明 URI 提供方、HAP 哈希和设备,不把普通路径测试等同于系统授权测试。
鸿蒙 PC 文件编辑的底线不是"按钮点击后没有报错",而是用户能够知道打开了什么、写入了什么、发生冲突时保住了什么。把授权、编码、字节完整性、持久化完成点和故障恢复连成一条链,Markdown 编辑器才真正具备处理长期本地资料的资格。
文件操作需要串行化同一文档的写入
用户可能连续按保存、快捷键自动重复,或保存尚未完成时触发导出和关闭。两个写任务同时使用同一 URI,会让 truncate、fsync 和备份清理交错,最终状态不可预测。每个文档会话应只有一个持久化任务,重复保存可以合并为最新 revision 或排队,但不能并发覆盖。
串行化还要处理"保存期间继续编辑"。发送给文件服务的内容快照绑定 revision;写入完成后,如果当前 revision 已增加,只更新持久化基线为已写快照,界面仍保持 Modified。下一次保存再写最新内容。否则一次慢保存会错误清除后来输入的脏状态。
关闭窗口时若仍在持久化,应等待可控时间或明确提示,不直接销毁状态。强杀无法等待,因此恢复快照和保存备份必须覆盖这条异常路径。
持久化语义不能夸大 fsync
fsync 请求把文件数据同步到存储,但不同文档提供方、文件系统和设备仍可能有缓存或远端同步层。API 成功表示应用已完成可用的本地持久化步骤,不表示云盘已上传,也不保证设备物理损坏后一定恢复。
同样,保存备份位于应用沙箱,应用卸载会清除,设备损坏也无法帮助。产品文案应准确表达"保存到所选文档"和"已保留可恢复旧版本",不声称绝对防丢失。重要资料仍需要用户自己的版本控制或备份体系。
工程测试可以验证 API 返回、重新打开、进程强停和文件字节;无法模拟的硬件/提供方保证应明确留在边界之外。
文件夹工作区扩大了授权边界
打开单文件时 URI 权限只覆盖该文档;打开文件夹后,应用可以列出授权目录并构造子项 URI。目录树应只展示文件夹和支持的 Markdown/文本类型,按需展开,限制一次读取条目数,避免递归扫描大目录阻塞界面。
子项名称必须拒绝路径分隔符和非法目录跳转,URI 应通过结构化 API生成,不能字符串拼接。符号链接可能指向授权根之外,需要根据 Core File Kit 实际语义验证;在策略明确前,不递归跟随未知链接。
工作区授权不等于建立全盘索引。搜索和大纲应限制在用户选择范围,错误或权限变化只影响相关节点,不清空当前已打开正文。目录刷新发现当前文件被外部删除时,也要保留未保存编辑会话并允许另存。
新建与另存为有不同身份变化
新建文档最初没有 URI,恢复快照使用临时会话身份。第一次保存成功后才绑定选择器返回的 URI、文件名和持久化格式。用户取消选择时仍是未命名脏文档。
"保存"覆盖当前 URI,"另存为"创建新目标并在成功后决定是否把当前会话切换到新 URI。若新目标写入失败,原 URI、基线和标签不能提前改变。导出 HTML 则永远不改变 Markdown 会话身份。
覆盖已有目标时系统选择器可能自行确认,但应用仍应按目标 URI 做完整备份与写入。默认文件名要过滤路径字符和控制字符,扩展名策略清楚,不能直接使用不可信标题形成路径。
文件编码支持应采用显式扩展
当前严格支持 UTF-8,遇到非法序列拒绝。未来支持 GB18030、UTF-16 等编码时,需要可靠检测或用户选择,并把编码加入 DocumentFormat。自动猜测不可避免存在误判,保存前应让用户看到当前编码。
转换到 UTF-8属于主动格式转换,不应在普通保存中悄悄发生。原编码无法表示新字符时,要提供另存 UTF-8 或取消,不能用问号替换。BOM、字节序和换行都需要对应夹具。
实现上不要先用宽松 UTF-8 生成替换字符,再尝试其他编码,那会丢失原始字节证据。应基于原始字节选择解码器,失败时保持文件未改。
外部修改后的合并模型
阻止覆盖是安全的第一步,但桌面用户最终需要解决冲突。三方合并需要三份输入:打开时持久化基线、本地 CodeMirror 内容、保存前重新读取的磁盘内容。算法输出冲突块,由用户确认后形成新的本地正文。
合并不能只按行尾规范化后保存而忘记格式。基线、磁盘和本地各自的 BOM/行尾需要策略;通常以当前磁盘格式或用户选择为目标。二进制/非法编码、超大文件和基线缺失时降级为另存,而不是冒险自动合并。
冲突界面属于高风险功能,应在单文件保存和备份完全验证后实现。未完成前,明确提示"文件已在外部修改,请重新打开或另存为"比一个不可靠合并器更好。
文件服务的可测试性设计
系统 URI 很难稳定制造短写、空间不足和权限中途失效。文件服务可以把选择器、读写句柄和原子沙箱存储分层,使纯格式与状态逻辑使用内存替身测试,设备层再验证真实 API。
故障注入替身应能在 open、read、write、truncate、fsync 和 close 指定失败,返回短写或延迟完成。每个注入点断言:目标旧内容、沙箱备份、恢复快照、持久化基线和界面状态。设备内部构建再用暂停点命中强杀窗口。
替身不能让生产代码走完全不同路径。注入接口只替换底层操作,上层保存顺序和状态机保持一致;正式构建去除调试入口。
用户可见错误需要可行动
"I/O error"不能帮助用户保护资料。错误文案应说明发生在打开还是保存、原文件是否可能变化、未保存内容是否仍保留,以及下一步可以重试、另存还是恢复旧版本。
底层错误码进入诊断,界面不展示长堆栈。权限失效提示重新选择或另存;外部冲突提示重新打开;非法 UTF-8说明当前支持范围;文件过大说明上限和大文件策略;恢复失败则强调沙箱备份仍保留或已无法读取。
文案也不能做无法验证的保证。例如即时恢复写入失败时不能说"原文件已恢复",应说检测到未完成保存并保留旧版本备份。清楚的状态是数据安全的一部分。