上周我把雷达鸭桌面版迁到鸿蒙 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-instance 的 argv 里干干净净,只有 ['/path/to/electron', '--no-sandbox'] 这类启动参数,我要的链接毛都没有。
我第一反应是 setAsDefaultProtocolClient 没注册成功。但查注册表(鸿蒙 PC 上其实走的是 desktop entry)发现协议确实绑上了。那链接去哪了?
我在主进程开头加了段日志,把 process.argv 和 process.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 版长不少,但核心就三件事:
- 链接来源兼容
argv和APP_URL - 鸿蒙 PC 用文件锁代替 Electron 原生单实例锁
- 每次启动重新注册协议和 desktop entry
一些没必要的弯路
我中间还试过几个方向,后来都证明是死路。
一个是想靠 ipcMain 在渲染进程里用 window.location.hash 传参。问题是应用已经被协议唤醒时,主进程根本收不到链接,渲染进程更没戏。
另一个是尝试用 app.on('open-url', ...)。macOS 上这是标准事件,但鸿蒙 PC 上它从来没触发过。我怀疑鸿蒙 PC 的桌面环境没有走这套事件机制。
还有一个特别搞笑的:我一度怀疑是链接被鸿蒙的安全策略拦截了,因为 URL 里有 radarduck:// 这种非标准协议。结果我在终端直接 xdg-open radarduck://case/123,应用是能起来的,只是拿不到参数。所以问题不是拦截,是参数传递路径变了。
收工
这个需求本身不复杂,但鸿蒙 PC 的 Electron 适配目前确实还有不少"文档没说"的角落。我到现在也不确定 APP_URL 是不是唯一入口,或者未来某个系统更新后会不会又变。只能说,如果你也在做类似的东西,先把 argv 和 APP_URL 都扫一遍,总不会错。
我那个雷达鸭桌面版现在就是这么跑的,鸿蒙 PC 上点外链能正常跳到详情页,虽然实现方式土了点,但至少不再掉链子。
你遇到过 Electron 在鸿蒙 PC 上类似的平台差异吗?欢迎留个链接或参数名,我补进代码里。
我是老三,10 年以上软件开发经验,软件设计师,人工智能应用工程师。目前主要做鸿蒙应用开发(ArkTS)和 Web 前端,也在折腾 AI 自动化,偶尔在 CSDN 分享鸿蒙和 AI 方向的技术文章。
本文遵循 MIT 协议,转载请注明出处。