Electron在鸿蒙PC上读写剪贴板,我被格式兼容性问题搞崩溃了
Electron在鸿蒙PC上读写剪贴板,我被格式兼容性问题搞崩溃了
上周产品经理提了个"小需求":在Electron应用里支持图片复制粘贴和文件拖拽上传。我心想这能有多难?Electron的clipboard和drag-drop都是成熟API,写几行代码就能收工。结果在鸿蒙PC上跑了一圈,我直接怀疑人生——同样的代码,Windows上丝滑顺畅,鸿蒙上不是丢格式就是没反应。这篇文章记录我趟过的所有坑,代码可以直接抄。
第一个坑:读取图片剪贴板,拿到的是空Buffer
鸿蒙PC的剪贴板机制和Windows差异不小。我在Windows上惯用的写法是这样的:
const{clipboard,nativeImage}=require('electron');functionreadImageFromClipboard(){constimage=clipboard.readImage();if(image.isEmpty())returnnull;// Windows上这里能拿到PNG BufferconstpngBuffer=image.toPNG();returnpngBuffer;}这段代码在Windows上跑得稳稳的。扔到鸿蒙PC上,image.isEmpty()返回false,但toPNG()返回的Buffer长度是0。不是null,不是undefined,是实打实的0字节。我当时盯着调试器看了十分钟,以为自己眼花了。
后来我翻了Chromium在OpenHarmony上的移植代码,才发现问题:鸿蒙剪贴板对image/png的原生支持不完整。Chromium的剪贴板适配层在鸿蒙上优先走的是image/bmp格式,而Electron的nativeImage在转换路径上有个bug——当底层返回的不是PNG时,toPNG()直接崩成空Buffer。
解决方案是绕过readImage(),直接读原生Buffer再手动转换:
const{clipboard,nativeImage}=require('electron');functionreadImageFromClipboardHarmony(){// 鸿蒙PC上优先读 BMP 格式constavailableFormats=clipboard.availableFormats();letbuffer=null;letext='png';if(availableFormats.includes('image/png')){buffer=clipboard.readBuffer('image/png');}elseif(availableFormats.includes('image/bmp')){buffer=clipboard.readBuffer('image/bmp');ext='bmp';}elseif(availableFormats.includes('image/jpeg')){buffer=clipboard.readBuffer('image/jpeg');ext='jpg';}if(!buffer||buffer.length===0)returnnull;// 用 nativeImage.createFromBuffer 更稳constimage=nativeImage.createFromBuffer(buffer,{width:0,height:0});// 统一转成PNG输出return{buffer:image.toPNG(),originalExt:ext,size:image.getSize()};}关键点在于clipboard.readBuffer()配合nativeImage.createFromBuffer()。别用clipboard.readImage(),至少在鸿蒙PC上别用。这个问题我翻了Electron的issue列表,有人提过但没修,估计是鸿蒙用户量还不够大。
第二个坑:写入剪贴板的HTML内容,粘贴出去变成纯文本
我们的应用支持富文本编辑,用户复制一段带样式的内容,需要保留格式粘贴到微信、WPS这些地方。Windows上的写法:
clipboard.write({text:'纯文本备份',html:'<b>加粗文字</b>和普通文字',rtf:'{\\rtf1\\b 加粗文字}和普通文字'});Windows上没问题,HTML和RTF格式都能被其他应用识别。鸿蒙PC上呢?我试了一圈,WPS、备忘录、微信(鸿蒙版)全都没格式,粘贴出来全是纯文本。RTF格式直接没被写入剪贴板,clipboard.availableFormats()里根本看不到text/rtf。
查了一圈才发现,鸿蒙系统的剪贴板服务(Pasteboard)对多格式MIME的支持有限。系统剪贴板只保证text/plain和image/*的互通,HTML这类富文本格式在跨应用粘贴时会被系统层过滤掉。
目前的 workaround 是:在应用内部自己维护一个富文本粘贴板。用户复制时,同时写入系统剪贴板(纯文本)和应用内部缓存(完整HTML)。应用内部粘贴走缓存,跨应用粘贴 fallback 到纯文本。虽然不完美,但至少不会丢数据。
// 应用级富文本剪贴板管理器classRichClipboard{constructor(){this.internalCache=null;}writeRichText({text,html}){// 系统剪贴板只写纯文本clipboard.writeText(text);// 内部缓存保留完整格式this.internalCache={text,html,timestamp:Date.now()};}readRichText(){// 优先读内部缓存(30秒内有效)if(this.internalCache&&Date.now()-this.internalCache.timestamp<30000){returnthis.internalCache;}// fallback 到系统剪贴板return{text:clipboard.readText(),html:null};}}module.exports={RichClipboard};老实说这方案有点糙,但在鸿蒙系统剪贴板接口完善之前,也只能这么凑合。我考虑过用鸿蒙的分布式数据管理(DataAbility)做跨应用格式共享,但成本太高,为了一个剪贴板不值得。
第三个坑:文件拖拽进窗口,路径变成了不可用的URI
拖拽功能是另一个重灾区。我在渲染进程里的标准写法:
document.addEventListener('drop',(e)=>{e.preventDefault();constfiles=Array.from(e.dataTransfer.files);for(constfileoffiles){console.log(file.path);// Windows上能拿到绝对路径}});Windows上file.path直接返回C:\Users\xxx\file.jpg这种绝对路径,拿来就能用。鸿蒙PC上呢?file.path返回的是类似file://docs/storage/xxx的URI,而且这URI还不是标准的file:///协议,是鸿蒙自己的文件系统抽象路径。更坑的是,这个路径在Electron的主进程里直接用fs.readFile()读,会直接报ENOENT。
我一开始以为是路径编码问题,试了decodeURI、path.normalize、fs.realpath,全都不行。后来在鸿蒙开发者论坛翻到一个帖子,才搞明白:鸿蒙PC上的拖拽文件路径需要先经过系统文件桥接服务转换。
正确的处理方式是在主进程里用shell模块或者dialog.showOpenDialog的逻辑来处理,但拖拽场景下没有dialog。最终的解决方案是:通过ipcRenderer把拖拽的URI传给主进程,主进程调用鸿蒙的fileio接口或者Electron的app.getPath()做路径映射。
不过Electron本身提供了webContents.startDrag()和drag事件的处理,我试了一种更干净的方案——直接用HTML5的FileReader读文件内容,不依赖路径:
// 渲染进程:拖拽处理(跨平台通用)asyncfunctionhandleDrop(e){e.preventDefault();constitems=Array.from(e.dataTransfer.items);constfiles=[];for(constitemofitems){if(item.kind==='file'){constfile=item.getAsFile();// 不依赖 file.path,直接读内容constarrayBuffer=awaitfile.arrayBuffer();constbuffer=Buffer.from(arrayBuffer);files.push({name:file.name,size:file.size,type:file.type,buffer,// 直接拿到Buffer// path 在鸿蒙上不可靠,干脆不用path:process.platform==='win32'?file.path:null});}}// 通过 IPC 把Buffer传给主进程处理ipcRenderer.invoke('process-dropped-files',files);}document.addEventListener('drop',handleDrop);document.addEventListener('dragover',(e)=>e.preventDefault());主进程这边:
const{ipcMain,app}=require('electron');constfs=require('fs');constpath=require('path');constos=require('os');ipcMain.handle('process-dropped-files',async(event,files)=>{constresults=[];for(constfileoffiles){letsavedPath=null;if(file.path&&fs.existsSync(file.path)){// Windows 或可靠路径,直接用savedPath=file.path;}else{// 鸿蒙PC:先写到应用临时目录consttempDir=app.getPath('temp');savedPath=path.join(tempDir,`drag-${Date.now()}-${file.name}`);// file.buffer 是 ArrayBuffer 序列化后的对象,需要转回 Bufferconstbuffer=Buffer.from(file.buffer);fs.writeFileSync(savedPath,buffer);}results.push({name:file.name,path:savedPath,size:file.size});}returnresults;});这个方案的核心是放弃对file.path的依赖。在鸿蒙上,File对象的arrayBuffer()方法是能正常工作的,先把文件内容读到内存,再通过IPC传给主进程,主进程写到临时目录。虽然多了一步拷贝,但至少稳定可用。
第四个坑:从应用内拖拽文件出去,鸿蒙桌面根本不认
拖拽进来有问题,拖拽出去问题更大。用户想把应用里的图片拖到桌面或者微信聊天窗口,我一开始用的标准API:
const{ipcRenderer}=require('electron');functionstartDragOut(filePath){ipcRenderer.send('start-drag',filePath);}const{ipcMain}=require('electron');ipcMain.on('start-drag',(event,filePath)=>{event.sender.startDrag({file:filePath,icon:'/path/to/drag-icon.png'});});Windows上这代码没问题。鸿蒙PC上呢?鼠标拖出去,光标变成禁止符号,啥应用都接不住。我一开始以为是icon路径的问题,换了绝对路径、base64、nativeImage,全都不行。
后来我在webContents的drag-enter、drag-leave事件里加了一堆日志,发现Electron的startDrag在鸿蒙上走的还是Chromium的拖拽协议,但鸿蒙桌面的窗口管理器(WindowManager)对外部拖拽的支持和Linux桌面(Wayland/X11)不一样。鸿蒙的桌面环境更接近移动端的Drop机制,而不是传统PC的DND协议。
目前的结论:Electron在鸿蒙PC上暂时不支持向外部应用拖拽文件。这不是Electron的bug,而是鸿蒙桌面系统层没有实现完整的XDG拖放协议。我在鸿蒙开发者社区问过,官方回复是"后续版本会完善桌面级拖拽能力"。
现在的 workaround 比较无奈:拖拽改为复制到剪贴板+提示用户手动粘贴,或者唤起系统保存对话框:
const{dialog,shell}=require('electron');asyncfunctionexportFileFallback(filePath){const{filePath:savePath}=awaitdialog.showSaveDialog({defaultPath:path.basename(filePath),filters:[{name:'Images',extensions:['png','jpg']},{name:'All Files',extensions:['*']}]});if(savePath){fs.copyFileSync(filePath,savePath);shell.showItemInFolder(savePath);}}虽然不是拖拽那么丝滑,但至少能用。我在这块花了整整一天,最后写了个内部文档,结论就一句话:鸿蒙上 outbound drag 别折腾了,等系统升级。
完整封装:一个跨平台的剪贴板+拖拽工具库
把这些踩坑经验汇总一下,我封装了一个内部工具库,判断平台走不同逻辑:
const{clipboard,nativeImage}=require('electron');classPlatformClipboard{staticreadImage(){constisHarmony=process.platform==='linux'&&require('fs').existsSync('/etc/openharmony-release');if(isHarmony){constformats=clipboard.availableFormats();letbuffer=null;if(formats.includes('image/png')){buffer=clipboard.readBuffer('image/png');}elseif(formats.includes('image/bmp')){buffer=clipboard.readBuffer('image/bmp');}if(buffer&&buffer.length>0){returnnativeImage.createFromBuffer(buffer);}returnnull;}// Windows / macOS 走标准APIconstimg=clipboard.readImage();returnimg.isEmpty()?null:img;}staticwriteRichText({text,html}){constisHarmony=process.platform==='linux'&&require('fs').existsSync('/etc/openharmony-release');if(isHarmony){// 鸿蒙只写纯文本clipboard.writeText(text);// 内部缓存HTML(由调用方维护)return;}clipboard.write({text,html,rtf:htmlToRtf(html)});}}module.exports={PlatformClipboard};判断鸿蒙平台我用了个土办法——检查/etc/openharmony-release文件。Electron在鸿蒙PC上报告的process.platform是linux,所以得靠额外的特征来区分。如果你有更好的判断方式,评论区告诉我。
踩坑总结
这篇文章没有"综上所述",直接说人话:
Electron在鸿蒙PC上的剪贴板和拖拽支持,目前只能算"勉强能用。图片剪贴板要绕过readImage()走readBuffer()`,富文本HTML基本别指望跨应用保留格式,文件拖拽进来的路径不可靠最好直接读Buffer,拖拽出去干脆不支持。这些问题大部分是鸿蒙系统层的能力缺口,不是Electron能单独解决的。
我的建议是:如果你的Electron应用重度依赖剪贴板和拖拽,在鸿蒙PC上一定要做充分的降级策略。别指望一套代码跑所有平台,鸿蒙这块还得单独适配。好在HarmonyOS NEXT的迭代速度挺快,好几个问题我在内测版和公测版之间看到了改善。也许再过两个版本,这篇文章里的一半坑就不存在了。
如果你也在做Electron+鸿蒙,欢迎在评论区交流。这篇文章遵循MIT协议,代码随便抄,有问题一起填坑。
本文遵循 MIT 开源协议。转载请联系作者并注明出处,代码示例可直接复制使用。
