Electron在鸿蒙PC上调系统对话框我被路径格式和编码坑了四次上周接了个需求要在 Electron 应用里加一套文件导入导出的流程。功能本身不复杂——弹个文件选择器、保存框、再来个确认提示完事儿。我在 Windows 和 macOS 上半小时就调通了心想移植到鸿蒙 PC 上应该也是复制粘贴的事。结果这一调就是两天四个坑个个都踩得结结实实。今天把过程记下来给后面趟这条路的同学提个醒。坑一showOpenDialog 返回的路径根本不是你以为的格式代码在 Windows 上跑得稳稳的const{filePaths}awaitdialog.showOpenDialog({properties:[openFile],filters:[{name:JSON,extensions:[json]}]});console.log(filePaths[0]);// D:\data\config.json拿到路径直接fs.readFileSync(filePaths[0])一把梭。放到鸿蒙 PC 上一跑filePaths[0]打印出来是file:///data/app/el2/100/base/com.example.myapp/haps/entry/files/config.json。我一开始没当回事以为只是路径前缀不一样照样传进fs去读。结果fs.readFileSync直接抛错ENOENT: no such file or directory。我愣了几秒心想这路径不是刚选出来的吗文件不存在后来反复比对才发现鸿蒙 PC 上的文件选择器返回的是URI 格式而不是本地绝对路径。更坑的是这个 URI 指向的是应用沙箱内的虚拟路径你直接用 Node.js 的fs模块是读不到的——它必须经过鸿蒙的fileIO或fs的特定适配层转换。我的 workaround 是封装了一层路径解析器// dialog-utils.jsconst{dialog}require(electron);constpathrequire(path);asyncfunctionshowOpenDialogSafe(options{}){constresultawaitdialog.showOpenDialog({...options,// 鸿蒙 PC 上必须显式指定编码否则中文文件名可能乱码title:options.title||选择文件});if(result.canceled||!result.filePaths.length)returnnull;constrawPathresult.filePaths[0];// 鸿蒙返回 file:// URI需要转换if(rawPath.startsWith(file://)){// 去掉协议头注意鸿蒙上 URI 编码可能是 percent-encodedconstdecodeddecodeURIComponent(rawPath.slice(7));returndecoded;}returnrawPath;}module.exports{showOpenDialogSafe};但光是解析 URI 还不够。鸿蒙 PC 的沙箱机制意味着如果你选的是用户相册或 Downloads 目录下的文件应用在默认情况下是没有读取权限的。调用showOpenDialog本身会触发一次临时授权但这个授权只在当前进程生命周期内有效。也就是说如果你把路径存到数据库里下次启动应用再读就会报权限 denied。我因为这个重启后读取失败的问题又查了半下午文档。最后的方案有点脏但管用在用户选择文件后立即把文件复制到应用沙箱的files目录下后续只操作沙箱内的副本。这样虽然多了一次 IO但至少不会半路掉链子。constfsrequire(fs);const{showOpenDialogSafe}require(./dialog-utils);asyncfunctionimportConfigFile(){constsourcePathawaitshowOpenDialogSafe({filters:[{name:Config,extensions:[json]}]});if(!sourcePath)return;constdestPathpath.join(app.getPath(userData),imports,path.basename(sourcePath));// 确保目录存在fs.mkdirSync(path.dirname(destPath),{recursive:true});// 复制到沙箱内后续只操作 destPathfs.copyFileSync(sourcePath,destPath);returndestPath;}坑二showSaveDialog 的默认文件名中文全变问号文件导出功能调的是dialog.showSaveDialog。我给它设了个默认文件名awaitdialog.showSaveDialog({defaultPath:我的备份_2025-06-01.json});在 Windows 和 macOS 上弹出的保存框里文件名显示正常。鸿蒙 PC 上一看保存框里的默认文件名变成了????_2025-06-01.json。四个问号整整齐齐。我第一反应是编码问题试了几种方案把字符串转成 Buffer 再传、用encodeURIComponent、甚至怀疑是字体渲染的锅。后来发现都不是。鸿蒙 PC 上的 Electron 对话框在处理defaultPath时内部会把路径字符串按 ASCII 截断中文字符直接变成?。这其实是 Chromium 在鸿蒙平台上对base::FilePath的处理有 bug中文被当成多字节字符截断了。官方 issue 区有人提过但还没修。我的 workaround 是干脆不传defaultPath等对话框返回后再用fs.rename改文件名。虽然多了一步但至少用户看到的文件名是对的。asyncfunctionexportWithSafeName(suggestedName,data){// 鸿蒙上 defaultPath 中文会乱码先用英文占位constplaceholderexport_${Date.now()}.json;const{filePath,canceled}awaitdialog.showSaveDialog({defaultPath:placeholder,filters:[{name:JSON,extensions:[json]}]});if(canceled||!filePath)return;// 写入内容fs.writeFileSync(filePath,JSON.stringify(data,null,2),utf-8);// 如果用户没改默认名尝试重命名为建议的中文名constfinalNamepath.join(path.dirname(filePath),suggestedName);if(filePath.includes(placeholder)!fs.existsSync(finalName)){fs.renameSync(filePath,finalName);returnfinalName;}returnfilePath;}说实话这 workaround 挺挫的但现阶段没更好的办法。如果你也踩了这个坑别在defaultPath里放中文放了也是白费。坑三showMessageBox 的按钮顺序确定和取消反了这个坑最隐蔽。我在代码里写了个确认删除的提示框const{response}awaitdialog.showMessageBox({type:warning,buttons:[取消,确定删除],defaultId:0,cancelId:0,message:确定要删除这条记录吗此操作不可撤销。});if(response1){// 执行删除}在 Windows 上buttons: [取消, 确定删除]渲染出来左边是取消右边是确定删除response 1对应用户点了右边。macOS 上顺序会反过来右边是第一个按钮但 macOS 有defaultId和cancelId的机制写惯了也知道怎么处理。鸿蒙 PC 上的行为让我彻底懵了。弹出来的对话框里左边是确定删除右边是取消——和 Windows 完全相反。更要命的是response的返回值并不是按视觉位置来的而是按buttons数组的原始索引。也就是说不管按钮在 UI 上怎么排用户点确定删除永远返回1点取消永远返回0。这本身不算 bug因为 API 行为是一致的。问题在于鸿蒙 PC 上的按钮布局规则和 Windows 不同但 Electron 的文档里根本没提这一茬。我一开始以为response 1对应的是右边的按钮结果用户在鸿蒙 PC 上点了左边的确定删除代码却走了取消分支。要不是我测试时多点了几次这 bug 就直接带到生产环境了。后来我干脆不用response索引来判断了直接用checkboxLabel和更明确的按钮文案来规避歧义。但更稳妥的做法是显式指定noLink: true让按钮按数组顺序从左到右排列const{response}awaitdialog.showMessageBox({type:warning,buttons:[取消,确定删除],// 关键鸿蒙 PC 上建议加这个noLink:true,message:确定要删除这条记录吗});// 鸿蒙上 noLink 为 true 时按钮严格按数组顺序从左到右// 取消0, 确定删除1和 Windows 行为一致if(response1){awaitdeleteRecord();}加了noLink: true之后鸿蒙 PC 上的按钮顺序终于和 Windows 对齐了。但noLink在 macOS 上的效果又不一样——macOS 上它会变成普通的确定/取消样式。所以如果你要跨平台统一行为最好还是在运行时判断一下平台const{response}awaitdialog.showMessageBox({type:warning,buttons:[取消,确定删除],noLink:process.platform!darwin,message:确定要删除这条记录吗});坑四webContents.print() 静默失败连报错都没有最后一个坑是打印功能。我们的应用需要支持把报表导出为 PDF 并直接调起系统打印对话框。Electron 提供了webContents.print()和webContents.printToPDF()前者调系统打印对话框后者生成 PDF Buffer。printToPDF()在鸿蒙 PC 上倒是一切正常但print()就诡异了——调用之后没有任何反应不弹打印对话框也不抛异常Promise 直接 resolve返回true。我一度怀疑是我代码写错了在 Windows 上跑了一遍打印对话框秒弹。查了半天发现鸿蒙 PC 目前没有完全实现 Chromium 的打印对话框接口。webContents.print()底层依赖的是系统的打印栈而鸿蒙 PC 的打印服务架构和 Linux CUPS 不完全兼容Electron 还没有做完整适配。所以你调用print()时它直接走了一个空实现返回成功但实际上什么都没做。我的 fallback 方案是先printToPDF()生成 PDF保存到临时目录再用shell.openPath()调系统默认的 PDF 阅读器打开让用户从阅读器里手动打印。虽然多了一步打开 PDF但至少功能能用。const{shell}require(electron);constfsrequire(fs);constpathrequire(path);asyncfunctionprintOrFallback(webContents){constisHarmonyprocess.platformlinuxrequire(os).release().toLowerCase().includes(harmony);if(!isHarmony){// Windows/macOS/Linux 桌面直接打印awaitwebContents.print({silent:false});return;}// 鸿蒙 PC fallback生成 PDF - 打开 - 用户手动打印constpdfPathpath.join(app.getPath(temp),print_${Date.now()}.pdf);constdataawaitwebContents.printToPDF({marginsType:1,pageSize:A4,printBackground:true});fs.writeFileSync(pdfPath,data);// 用系统默认应用打开 PDFconst{error}awaitshell.openPath(pdfPath);if(error){dialog.showErrorBox(打印失败,无法打开 PDF${error});}}这里判断鸿蒙平台的小技巧是看os.release()里有没有harmony字样。因为鸿蒙 PC 上的 Electron 目前是通过 Linux 兼容层运行的process.platform返回的是linux必须结合内核版本字符串才能区分。写在最后这四个坑看起来都是小事但堆在一起足够把一个人磨两天。我的感受是Electron 在鸿蒙 PC 上的适配还处于能跑但不好跑的阶段。dialog这种基础模块在三个平台上的行为差异比官方文档描述的要大得多。如果让我总结几条经验鸿蒙上返回的路径大概率是 URI别直接丢给fs。对话框里别放中文defaultPath放了也白放。showMessageBox的按钮顺序和视觉布局不一定对应上noLink保平安。print()在鸿蒙上基本是废的提前准备好printToPDF的 fallback。最后如果你也在做 Electron 鸿蒙 PC 的适配建议把对话框相关的操作全部封装到一个dialog-wrapper.js里隔离平台差异。别像我一样在每个页面里裸调dialogAPI不然修 bug 的时候得全局替换痛苦指数直接翻倍。本文遵循 MIT 协议。转载请联系作者获取授权并注明出处和原文链接。未经授权禁止用于商业用途。
Electron在鸿蒙PC上调系统对话框,我被路径格式和编码坑了四次
Electron在鸿蒙PC上调系统对话框我被路径格式和编码坑了四次上周接了个需求要在 Electron 应用里加一套文件导入导出的流程。功能本身不复杂——弹个文件选择器、保存框、再来个确认提示完事儿。我在 Windows 和 macOS 上半小时就调通了心想移植到鸿蒙 PC 上应该也是复制粘贴的事。结果这一调就是两天四个坑个个都踩得结结实实。今天把过程记下来给后面趟这条路的同学提个醒。坑一showOpenDialog 返回的路径根本不是你以为的格式代码在 Windows 上跑得稳稳的const{filePaths}awaitdialog.showOpenDialog({properties:[openFile],filters:[{name:JSON,extensions:[json]}]});console.log(filePaths[0]);// D:\data\config.json拿到路径直接fs.readFileSync(filePaths[0])一把梭。放到鸿蒙 PC 上一跑filePaths[0]打印出来是file:///data/app/el2/100/base/com.example.myapp/haps/entry/files/config.json。我一开始没当回事以为只是路径前缀不一样照样传进fs去读。结果fs.readFileSync直接抛错ENOENT: no such file or directory。我愣了几秒心想这路径不是刚选出来的吗文件不存在后来反复比对才发现鸿蒙 PC 上的文件选择器返回的是URI 格式而不是本地绝对路径。更坑的是这个 URI 指向的是应用沙箱内的虚拟路径你直接用 Node.js 的fs模块是读不到的——它必须经过鸿蒙的fileIO或fs的特定适配层转换。我的 workaround 是封装了一层路径解析器// dialog-utils.jsconst{dialog}require(electron);constpathrequire(path);asyncfunctionshowOpenDialogSafe(options{}){constresultawaitdialog.showOpenDialog({...options,// 鸿蒙 PC 上必须显式指定编码否则中文文件名可能乱码title:options.title||选择文件});if(result.canceled||!result.filePaths.length)returnnull;constrawPathresult.filePaths[0];// 鸿蒙返回 file:// URI需要转换if(rawPath.startsWith(file://)){// 去掉协议头注意鸿蒙上 URI 编码可能是 percent-encodedconstdecodeddecodeURIComponent(rawPath.slice(7));returndecoded;}returnrawPath;}module.exports{showOpenDialogSafe};但光是解析 URI 还不够。鸿蒙 PC 的沙箱机制意味着如果你选的是用户相册或 Downloads 目录下的文件应用在默认情况下是没有读取权限的。调用showOpenDialog本身会触发一次临时授权但这个授权只在当前进程生命周期内有效。也就是说如果你把路径存到数据库里下次启动应用再读就会报权限 denied。我因为这个重启后读取失败的问题又查了半下午文档。最后的方案有点脏但管用在用户选择文件后立即把文件复制到应用沙箱的files目录下后续只操作沙箱内的副本。这样虽然多了一次 IO但至少不会半路掉链子。constfsrequire(fs);const{showOpenDialogSafe}require(./dialog-utils);asyncfunctionimportConfigFile(){constsourcePathawaitshowOpenDialogSafe({filters:[{name:Config,extensions:[json]}]});if(!sourcePath)return;constdestPathpath.join(app.getPath(userData),imports,path.basename(sourcePath));// 确保目录存在fs.mkdirSync(path.dirname(destPath),{recursive:true});// 复制到沙箱内后续只操作 destPathfs.copyFileSync(sourcePath,destPath);returndestPath;}坑二showSaveDialog 的默认文件名中文全变问号文件导出功能调的是dialog.showSaveDialog。我给它设了个默认文件名awaitdialog.showSaveDialog({defaultPath:我的备份_2025-06-01.json});在 Windows 和 macOS 上弹出的保存框里文件名显示正常。鸿蒙 PC 上一看保存框里的默认文件名变成了????_2025-06-01.json。四个问号整整齐齐。我第一反应是编码问题试了几种方案把字符串转成 Buffer 再传、用encodeURIComponent、甚至怀疑是字体渲染的锅。后来发现都不是。鸿蒙 PC 上的 Electron 对话框在处理defaultPath时内部会把路径字符串按 ASCII 截断中文字符直接变成?。这其实是 Chromium 在鸿蒙平台上对base::FilePath的处理有 bug中文被当成多字节字符截断了。官方 issue 区有人提过但还没修。我的 workaround 是干脆不传defaultPath等对话框返回后再用fs.rename改文件名。虽然多了一步但至少用户看到的文件名是对的。asyncfunctionexportWithSafeName(suggestedName,data){// 鸿蒙上 defaultPath 中文会乱码先用英文占位constplaceholderexport_${Date.now()}.json;const{filePath,canceled}awaitdialog.showSaveDialog({defaultPath:placeholder,filters:[{name:JSON,extensions:[json]}]});if(canceled||!filePath)return;// 写入内容fs.writeFileSync(filePath,JSON.stringify(data,null,2),utf-8);// 如果用户没改默认名尝试重命名为建议的中文名constfinalNamepath.join(path.dirname(filePath),suggestedName);if(filePath.includes(placeholder)!fs.existsSync(finalName)){fs.renameSync(filePath,finalName);returnfinalName;}returnfilePath;}说实话这 workaround 挺挫的但现阶段没更好的办法。如果你也踩了这个坑别在defaultPath里放中文放了也是白费。坑三showMessageBox 的按钮顺序确定和取消反了这个坑最隐蔽。我在代码里写了个确认删除的提示框const{response}awaitdialog.showMessageBox({type:warning,buttons:[取消,确定删除],defaultId:0,cancelId:0,message:确定要删除这条记录吗此操作不可撤销。});if(response1){// 执行删除}在 Windows 上buttons: [取消, 确定删除]渲染出来左边是取消右边是确定删除response 1对应用户点了右边。macOS 上顺序会反过来右边是第一个按钮但 macOS 有defaultId和cancelId的机制写惯了也知道怎么处理。鸿蒙 PC 上的行为让我彻底懵了。弹出来的对话框里左边是确定删除右边是取消——和 Windows 完全相反。更要命的是response的返回值并不是按视觉位置来的而是按buttons数组的原始索引。也就是说不管按钮在 UI 上怎么排用户点确定删除永远返回1点取消永远返回0。这本身不算 bug因为 API 行为是一致的。问题在于鸿蒙 PC 上的按钮布局规则和 Windows 不同但 Electron 的文档里根本没提这一茬。我一开始以为response 1对应的是右边的按钮结果用户在鸿蒙 PC 上点了左边的确定删除代码却走了取消分支。要不是我测试时多点了几次这 bug 就直接带到生产环境了。后来我干脆不用response索引来判断了直接用checkboxLabel和更明确的按钮文案来规避歧义。但更稳妥的做法是显式指定noLink: true让按钮按数组顺序从左到右排列const{response}awaitdialog.showMessageBox({type:warning,buttons:[取消,确定删除],// 关键鸿蒙 PC 上建议加这个noLink:true,message:确定要删除这条记录吗});// 鸿蒙上 noLink 为 true 时按钮严格按数组顺序从左到右// 取消0, 确定删除1和 Windows 行为一致if(response1){awaitdeleteRecord();}加了noLink: true之后鸿蒙 PC 上的按钮顺序终于和 Windows 对齐了。但noLink在 macOS 上的效果又不一样——macOS 上它会变成普通的确定/取消样式。所以如果你要跨平台统一行为最好还是在运行时判断一下平台const{response}awaitdialog.showMessageBox({type:warning,buttons:[取消,确定删除],noLink:process.platform!darwin,message:确定要删除这条记录吗});坑四webContents.print() 静默失败连报错都没有最后一个坑是打印功能。我们的应用需要支持把报表导出为 PDF 并直接调起系统打印对话框。Electron 提供了webContents.print()和webContents.printToPDF()前者调系统打印对话框后者生成 PDF Buffer。printToPDF()在鸿蒙 PC 上倒是一切正常但print()就诡异了——调用之后没有任何反应不弹打印对话框也不抛异常Promise 直接 resolve返回true。我一度怀疑是我代码写错了在 Windows 上跑了一遍打印对话框秒弹。查了半天发现鸿蒙 PC 目前没有完全实现 Chromium 的打印对话框接口。webContents.print()底层依赖的是系统的打印栈而鸿蒙 PC 的打印服务架构和 Linux CUPS 不完全兼容Electron 还没有做完整适配。所以你调用print()时它直接走了一个空实现返回成功但实际上什么都没做。我的 fallback 方案是先printToPDF()生成 PDF保存到临时目录再用shell.openPath()调系统默认的 PDF 阅读器打开让用户从阅读器里手动打印。虽然多了一步打开 PDF但至少功能能用。const{shell}require(electron);constfsrequire(fs);constpathrequire(path);asyncfunctionprintOrFallback(webContents){constisHarmonyprocess.platformlinuxrequire(os).release().toLowerCase().includes(harmony);if(!isHarmony){// Windows/macOS/Linux 桌面直接打印awaitwebContents.print({silent:false});return;}// 鸿蒙 PC fallback生成 PDF - 打开 - 用户手动打印constpdfPathpath.join(app.getPath(temp),print_${Date.now()}.pdf);constdataawaitwebContents.printToPDF({marginsType:1,pageSize:A4,printBackground:true});fs.writeFileSync(pdfPath,data);// 用系统默认应用打开 PDFconst{error}awaitshell.openPath(pdfPath);if(error){dialog.showErrorBox(打印失败,无法打开 PDF${error});}}这里判断鸿蒙平台的小技巧是看os.release()里有没有harmony字样。因为鸿蒙 PC 上的 Electron 目前是通过 Linux 兼容层运行的process.platform返回的是linux必须结合内核版本字符串才能区分。写在最后这四个坑看起来都是小事但堆在一起足够把一个人磨两天。我的感受是Electron 在鸿蒙 PC 上的适配还处于能跑但不好跑的阶段。dialog这种基础模块在三个平台上的行为差异比官方文档描述的要大得多。如果让我总结几条经验鸿蒙上返回的路径大概率是 URI别直接丢给fs。对话框里别放中文defaultPath放了也白放。showMessageBox的按钮顺序和视觉布局不一定对应上noLink保平安。print()在鸿蒙上基本是废的提前准备好printToPDF的 fallback。最后如果你也在做 Electron 鸿蒙 PC 的适配建议把对话框相关的操作全部封装到一个dialog-wrapper.js里隔离平台差异。别像我一样在每个页面里裸调dialogAPI不然修 bug 的时候得全局替换痛苦指数直接翻倍。本文遵循 MIT 协议。转载请联系作者获取授权并注明出处和原文链接。未经授权禁止用于商业用途。