Electron 鸿蒙 PC 上点外链唤醒应用,我试了 6 种写法只有 1 种能跑

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

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


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

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

typescript 复制代码
// main.ts
import { app, BrowserWindow } from 'electron';

const gotTheLock = app.requestSingleInstanceLock();

if (!gotTheLock) {
  app.quit();
} else {
  app.on('second-instance', (_, argv) => {
    const deepLink = 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 自己定的。文档我没找到,纯靠猜加试。

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

typescript 复制代码
// main.ts --- 获取 deep link 的兼容入口
function getDeepLink(): string | undefined {
  // Windows / macOS 习惯从 argv 找
  const fromArgv = process.argv.find(arg => arg.startsWith('radarduck://'));
  if (fromArgv) return fromArgv;

  // 鸿蒙 PC 通过环境变量 APP_URL 传入
  const fromEnv = process.env.APP_URL;
  if (fromEnv?.startsWith('radarduck://')) return fromEnv;

  return undefined;
}

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


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

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

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

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

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

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

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

typescript 复制代码
// main.ts --- 基于 pid 文件的单实例兜底
import fs from 'fs';
import path from 'path';
import os from 'os';

const lockFile = path.join(os.tmpdir(), 'radarduck-desktop.lock');

function tryWriteLock(): boolean {
  try {
    fs.writeFileSync(lockFile, String(process.pid), { flag: 'wx' });
    return true;
  } catch {
    return false;
  }
}

function sendLinkToRunningInstance(link: string): boolean {
  try {
    const pid = fs.readFileSync(lockFile, 'utf8');
    // 鸿蒙 PC 上给同 pid 进程发信号基本没用,这里改用一个 link 中转文件
    fs.writeFileSync(lockFile + '.link', link);
    process.kill(Number(pid), 'SIGUSR1');
    return true;
  } catch {
    return false;
  }
}

不过 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 到用户目录兜底。

typescript 复制代码
// main.ts --- 每次启动重新注册协议
import { app } from 'electron';
import fs from 'fs';
import path from 'path';
import os from 'os';

function ensureProtocolRegistered(): void {
  app.setAsDefaultProtocolClient('radarduck');

  // 鸿蒙 PC 桌面环境有时会丢协议关联,手动补一份 desktop entry
  const desktopDir = path.join(os.homedir(), '.local/share/applications');
  fs.mkdirSync(desktopDir, { recursive: true });

  const entryPath = path.join(desktopDir, 'radarduck.desktop');
  const entry = `[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()) 的分支里,避免污染其他平台。


最终能跑的方案

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

typescript 复制代码
// main.ts --- 鸿蒙 PC 协议唤醒完整兼容方案
import { app, BrowserWindow } from 'electron';
import fs from 'fs';
import path from 'path';
import os from 'os';

const LINK_FILE = path.join(os.tmpdir(), 'radarduck-deep-link.txt');
const LOCK_FILE = path.join(os.tmpdir(), 'radarduck-desktop.lock');

function isHarmonyOS(): boolean {
  return process.platform === 'linux' && process.env.HOS_DESKTOP === '1';
}

function getDeepLink(): string | undefined {
  const fromArgv = process.argv.find(arg => arg.startsWith('radarduck://'));
  if (fromArgv) return fromArgv;

  const fromEnv = process.env.APP_URL;
  if (fromEnv?.startsWith('radarduck://')) return fromEnv;

  return undefined;
}

function ensureProtocolRegistered(): void {
  app.setAsDefaultProtocolClient('radarduck');
  if (!isHarmonyOS()) return;

  const desktopDir = 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 }
  );
}

function handleDeepLink(link: string): void {
  console.log('[deep-link]', link);
  const match = link.match(/radarduck:\/\/case\/(\d+)/);
  if (match) {
    mainWindow?.webContents.send('navigate-to-case', match[1]);
  }
  mainWindow?.focus();
}

let mainWindow: BrowserWindow | null = null;

function createWindow(): void {
  mainWindow = new BrowserWindow({
    width: 1280,
    height: 800,
    webPreferences: {
      preload: path.join(__dirname, 'preload.js'),
    },
  });
  mainWindow.loadURL('https://app.radarduck.cn');
}

if (isHarmonyOS()) {
  // 鸿蒙 PC 用文件锁 + 中转文件方案
  const isMaster = tryWriteLock();
  if (!isMaster) {
    const link = getDeepLink();
    if (link) fs.writeFileSync(LINK_FILE, link);
    app.quit();
  } else {
    setInterval(() => {
      if (!fs.existsSync(LINK_FILE)) return;
      const link = fs.readFileSync(LINK_FILE, 'utf8');
      fs.unlinkSync(LINK_FILE);
      if (link.startsWith('radarduck://')) handleDeepLink(link);
    }, 500);

    app.whenReady().then(() => {
      ensureProtocolRegistered();
      createWindow();
      const link = getDeepLink();
      if (link) handleDeepLink(link);
    });
  }
} else {
  // Windows / macOS 标准单实例锁方案
  const gotTheLock = app.requestSingleInstanceLock();
  if (!gotTheLock) {
    app.quit();
  } else {
    app.on('second-instance', (_, argv) => {
      const link = argv.find(arg => arg.startsWith('radarduck://'));
      if (link) handleDeepLink(link);
    });

    app.whenReady().then(() => {
      ensureProtocolRegistered();
      createWindow();
    });
  }
}

function tryWriteLock(): boolean {
  try {
    fs.writeFileSync(LOCK_FILE, String(process.pid), { flag: 'wx' });
    return true;
  } catch {
    return false;
  }
}

代码看着比 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 协议,转载请注明出处。

相关推荐
天才熊猫君2 小时前
配置与数据分离:一种可视化搭建的属性编辑方案
前端·javascript
林希_Rachel_傻希希2 小时前
web性能之相关路径——AI总结
前端·javascript·面试
TrisighT2 小时前
Electron 跑鸿蒙 PC 上,这 4 个 API 的行为跟 Windows 完全不一样——但文档一行都没写
windows·electron·harmonyos
竹林8182 小时前
用 wagmi v2 踩坑两天,我终于搞懂了多链钱包切换在 DeFi 前端中的正确姿势
前端·javascript
用户2136610035722 小时前
Vue项目搜索功能与面包屑导航
前端·javascript
星栈2 小时前
LiveView 的实时通信,爽是爽,但 PubSub 和广播也最容易把自己绕晕
前端·前端框架·elixir
用户2930750976692 小时前
告别关键词匹配,拥抱向量语义 —— RAG 搜索从零到一
前端
独孤留白2 小时前
从C到Rust:告别 C 的"指针 + 长度"手动模式
前端·rust
掘金安东尼3 小时前
中小厂前端候选人简历面试拆解:从 HR 面、技术面到主管面的双赢提问法
前端·面试