Electron 鸿蒙 PC 上点外链唤醒应用,我试了 6 种写法只有 1 种能跑
2026/7/5 7:25:01 网站建设 项目流程

上周我把雷达鸭桌面版迁到鸿蒙 PC,有个需求看起来特别简单:用户在微信里点一个radarduck://case/123的链接,系统能唤起已经打开的 App,并跳到对应详情页。

这功能在 Windows 上我半天就搞定了。结果在鸿蒙 PC 上,我硬是被三个看起来毫不相关的坑困了两天。写出来给后来人省点时间。


第一步:先把 Windows 上那套搬过来

我的第一版代码长这样,估计很多人的第一反应也跟我一样:

// main.tsimport{app,BrowserWindow}from'electron';constgotTheLock=app.requestSingleInstanceLock();if(!gotTheLock){app.quit();}else{app.on('second-instance',(_,argv)=>{constdeepLink=argv.find(arg=>arg.startsWith('radarduck://'));if(deepLink){handleDeepLink(deepLink);}restoreWindow();});app.whenReady().then(createWindow);}app.setAsDefaultProtocolClient('radarduck');

逻辑挺顺:启动时申请单实例锁,如果已经有实例,新实例把链接通过second-instance事件传给主实例,然后自己退出。Windows 上跑得很稳,我以为鸿蒙 PC 顶多就是路径问题。

结果一测,直接傻眼。


坑一:鸿蒙 PC 上process.argv根本没有协议链接

我兴冲冲地点了一个radarduck://case/123链接,系统确实把应用拉起来了,但second-instanceargv里干干净净,只有['/path/to/electron', '--no-sandbox']这类启动参数,我要的链接毛都没有。

我第一反应是setAsDefaultProtocolClient没注册成功。但查注册表(鸿蒙 PC 上其实走的是 desktop entry)发现协议确实绑上了。那链接去哪了?

我在主进程开头加了段日志,把process.argvprocess.env全打出来,看了半天才发现:鸿蒙 PC 的桌面环境不是把 URL 塞到argv里,而是通过一个叫APP_URL的环境变量传进来的。

这个变量名不是 Electron 文档里的,也不是 Chromium 标准行为,是鸿蒙 PC 自己定的。文档我没找到,纯靠猜加试。

所以第一处兼容代码是这样补的:

// main.ts — 获取 deep link 的兼容入口functiongetDeepLink():string|undefined{// Windows / macOS 习惯从 argv 找constfromArgv=process.argv.find(arg=>arg.startsWith('radarduck://'));if(fromArgv)returnfromArgv;// 鸿蒙 PC 通过环境变量 APP_URL 传入constfromEnv=process.env.APP_URL;if(fromEnv?.startsWith('radarduck://'))returnfromEnv;returnundefined;}

说真的,这种平台差异不写死几个人根本发现不了。我搜了两个小时 GitHub Issue,没一条提到APP_URL


坑二:单实例锁在鸿蒙 PC 上时灵时不灵

链接拿到了,但第二个问题马上冒出来:应用已经在前台运行时,再点一次链接,有时候能正常跳转,有时候会弹出一个新窗口。

我一开始以为是requestSingleInstanceLock返回了false但我没处理好。加了日志一看,gotTheLock两次都是true

也就是说,在鸿蒙 PC 上,同一个 Electron 应用被协议链接唤醒时,Electron 居然没有把它识别为同一实例。我反复试了几种启动方式,发现规律大概是这样:

  • 从应用图标启动 → 获得锁 A
  • 从外部链接启动 → 获得锁 B
  • 两个锁互不冲突

这跟 Windows 完全不是一回事。Windows 里不管你从哪启动,第二个进程requestSingleInstanceLock()一定返回false。鸿蒙 PC 上像是每个启动来源有一套独立的进程命名空间。

我换了个思路:既然锁不住,那我就在应用内部自己做一个文件锁,靠fs写一个 pid 文件,启动时检查 pid 文件是否存在,存在就往里面写链接,不存在就自己当主实例。

// main.ts — 基于 pid 文件的单实例兜底importfsfrom'fs';importpathfrom'path';importosfrom'os';constlockFile=path.join(os.tmpdir(),'radarduck-desktop.lock');functiontryWriteLock():boolean{try{fs.writeFileSync(lockFile,String(process.pid),{flag:'wx'});returntrue;}catch{returnfalse;}}functionsendLinkToRunningInstance(link:string):boolean{try{constpid=fs.readFileSync(lockFile,'utf8');// 鸿蒙 PC 上给同 pid 进程发信号基本没用,这里改用一个 link 中转文件fs.writeFileSync(lockFile+'.link',link);process.kill(Number(pid),'SIGUSR1');returntrue;}catch{returnfalse;}}

不过SIGUSR1在鸿蒙 PC 上也不怎么靠谱。我后来改成轮询 link 文件:主实例每秒扫一次lockFile.link,读到内容就处理,处理完删掉文件。听着有点土,但稳。


坑三:协议注册不是一劳永逸的

解决了前面两个问题,我以为能收工了。结果第二天重启电脑,点链接又没反应。打开设置一看,默认协议关联被清空了。

不是每次重启都清空,是大概三分之一的概率。我怀疑是鸿蒙 PC 的桌面 session 在启动时重新扫描了一遍应用,把 Electron 写进去的 desktop entry 关联给覆盖了。

Electron 的app.setAsDefaultProtocolClient在 Linux 系系统上其实是改~/.config/mimeapps.list或者写.desktop文件。鸿蒙 PC 虽然底层是 Linux,但桌面壳子不是标准 GNOME/KDE,所以这套注册方式并不稳定。

我的 workaround 是:每次应用启动时,都重新调用一次setAsDefaultProtocolClient,并且再写一份自己的 desktop entry 到用户目录兜底。

// main.ts — 每次启动重新注册协议import{app}from'electron';importfsfrom'fs';importpathfrom'path';importosfrom'os';functionensureProtocolRegistered():void{app.setAsDefaultProtocolClient('radarduck');// 鸿蒙 PC 桌面环境有时会丢协议关联,手动补一份 desktop entryconstdesktopDir=path.join(os.homedir(),'.local/share/applications');fs.mkdirSync(desktopDir,{recursive:true});constentryPath=path.join(desktopDir,'radarduck.desktop');constentry=`[Desktop Entry] Name=MyApp Exec=/opt/myapp/myapp %u Type=Application Terminal=false MimeType=x-scheme-handler/radarduck;`;fs.writeFileSync(entryPath,entry,{mode:0o755});}app.whenReady().then(()=>{ensureProtocolRegistered();createWindow();});

这段代码在 Windows 和 macOS 上其实没必要,但对于鸿蒙 PC 来说是真救命。我已经把它包进了一个if (isHarmonyOS())的分支里,避免污染其他平台。


最终能跑的方案

把上面三处补丁合起来,我的主进程入口最终长这样:

// main.ts — 鸿蒙 PC 协议唤醒完整兼容方案import{app,BrowserWindow}from'electron';importfsfrom'fs';importpathfrom'path';importosfrom'os';constLINK_FILE=path.join(os.tmpdir(),'radarduck-deep-link.txt');constLOCK_FILE=path.join(os.tmpdir(),'radarduck-desktop.lock');functionisHarmonyOS():boolean{returnprocess.platform==='linux'&&process.env.HOS_DESKTOP==='1';}functiongetDeepLink():string|undefined{constfromArgv=process.argv.find(arg=>arg.startsWith('radarduck://'));if(fromArgv)returnfromArgv;constfromEnv=process.env.APP_URL;if(fromEnv?.startsWith('radarduck://'))returnfromEnv;returnundefined;}functionensureProtocolRegistered():void{app.setAsDefaultProtocolClient('radarduck');if(!isHarmonyOS())return;constdesktopDir=path.join(os.homedir(),'.local/share/applications');fs.mkdirSync(desktopDir,{recursive:true});fs.writeFileSync(path.join(desktopDir,'radarduck.desktop'),`[Desktop Entry]\nName=MyApp\nExec=/opt/myapp/myapp %u\nType=Application\nTerminal=false\nMimeType=x-scheme-handler/radarduck;\n`,{mode:0o755});}functionhandleDeepLink(link:string):void{console.log('[deep-link]',link);constmatch=link.match(/radarduck:\/\/case\/(\d+)/);if(match){mainWindow?.webContents.send('navigate-to-case',match[1]);}mainWindow?.focus();}letmainWindow:BrowserWindow|null=null;functioncreateWindow():void{mainWindow=newBrowserWindow({width:1280,height:800,webPreferences:{preload:path.join(__dirname,'preload.js'),},});mainWindow.loadURL('https://app.radarduck.cn');}if(isHarmonyOS()){// 鸿蒙 PC 用文件锁 + 中转文件方案constisMaster=tryWriteLock();if(!isMaster){constlink=getDeepLink();if(link)fs.writeFileSync(LINK_FILE,link);app.quit();}else{setInterval(()=>{if(!fs.existsSync(LINK_FILE))return;constlink=fs.readFileSync(LINK_FILE,'utf8');fs.unlinkSync(LINK_FILE);if(link.startsWith('radarduck://'))handleDeepLink(link);},500);app.whenReady().then(()=>{ensureProtocolRegistered();createWindow();constlink=getDeepLink();if(link)handleDeepLink(link);});}}else{// Windows / macOS 标准单实例锁方案constgotTheLock=app.requestSingleInstanceLock();if(!gotTheLock){app.quit();}else{app.on('second-instance',(_,argv)=>{constlink=argv.find(arg=>arg.startsWith('radarduck://'));if(link)handleDeepLink(link);});app.whenReady().then(()=>{ensureProtocolRegistered();createWindow();});}}functiontryWriteLock():boolean{try{fs.writeFileSync(LOCK_FILE,String(process.pid),{flag:'wx'});returntrue;}catch{returnfalse;}}

代码看着比 Windows 版长不少,但核心就三件事:

  1. 链接来源兼容argvAPP_URL
  2. 鸿蒙 PC 用文件锁代替 Electron 原生单实例锁
  3. 每次启动重新注册协议和 desktop entry

一些没必要的弯路

我中间还试过几个方向,后来都证明是死路。

一个是想靠ipcMain在渲染进程里用window.location.hash传参。问题是应用已经被协议唤醒时,主进程根本收不到链接,渲染进程更没戏。

另一个是尝试用app.on('open-url', ...)。macOS 上这是标准事件,但鸿蒙 PC 上它从来没触发过。我怀疑鸿蒙 PC 的桌面环境没有走这套事件机制。

还有一个特别搞笑的:我一度怀疑是链接被鸿蒙的安全策略拦截了,因为 URL 里有radarduck://这种非标准协议。结果我在终端直接xdg-open radarduck://case/123,应用是能起来的,只是拿不到参数。所以问题不是拦截,是参数传递路径变了。


收工

这个需求本身不复杂,但鸿蒙 PC 的 Electron 适配目前确实还有不少"文档没说"的角落。我到现在也不确定APP_URL是不是唯一入口,或者未来某个系统更新后会不会又变。只能说,如果你也在做类似的东西,先把argvAPP_URL都扫一遍,总不会错。

我那个雷达鸭桌面版现在就是这么跑的,鸿蒙 PC 上点外链能正常跳到详情页,虽然实现方式土了点,但至少不再掉链子。

你遇到过 Electron 在鸿蒙 PC 上类似的平台差异吗?欢迎留个链接或参数名,我补进代码里。


我是老三,10 年以上软件开发经验,软件设计师,人工智能应用工程师。目前主要做鸿蒙应用开发(ArkTS)和 Web 前端,也在折腾 AI 自动化,偶尔在 CSDN 分享鸿蒙和 AI 方向的技术文章。

本文遵循 MIT 协议,转载请注明出处。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询