Electron通过协议url唤起应用

前言

对于一个 URL 来说,其实就是指定了一个协议(protocol),然后让系统用对应的应用去打开它。如 myapp://openapp?name=1&pwd=2,系统会去找到已经注册了 myapp 这个协议的应用,然后把 URL 当做参数传过去。

这样我们就可以在浏览器中通过一个 <a> 标签简单地唤起应用了。

1
<a href="myapp://openapp?name=1&pwd=2">打开应用</a>

单实例运行

首先,每次打开一个协议 URL,系统都会启动一个新的应用。这就需要应用自己去判断,把 URL 当做参数传给已有的应用,还是自己直接处理。

Electron 提供了一个简单的方法,来获取一个锁,只有第一个调用的实例才能获取成功,后面的其他实例则把参数传过去,然后退出就可以了。

1
2
3
4
5
6
7
8
const { app } = require('electron');

// 获取单实例锁
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
// 如果获取失败,说明已经有实例在运行了,直接退出
app.quit();
}

注册协议

我们期望通过协议来启动应用,所以要先注册一个协议到系统中,调用 API 即可:

1
2
3
4
5
6
7
8
9
10
const PROTOCOL = 'myapp';

const args = [];
if (!app.isPackaged) {
// 如果是开发阶段,需要把我们的脚本的绝对路径加入参数中
args.push(path.resolve(process.argv[1]));
}
// 加一个 `--` 以确保后面的参数不被 Electron 处理
args.push('--');
app.setAsDefaultProtocolClient(PROTOCOL, process.execPath, args);

这里的 args 是预定义的参数,对 macOS 没有作用,但是在 Windows 上却是必不可少的。

在 Windows 上启动一个协议URL时,实际上是用如下参数启动了我们的应用:

1
${process.execPath} ${...args} myapp://...

需要注意的是,在开发阶段,我们是通过 electron . 或者 electron path/to/script.js 来启动的应用,所以 process.argv[1] 是我们的脚本路径,传给系统时,这个参数也不能少,否则启动的就是一个纯粹的 Electron 壳,而不是我们的应用了。这时,这个参数就要通过这里的 args 一起注册到系统中了。

根据这个帖子,我们可以在预定义的参数最后加一个 -- ,来阻止其他参数直接被 Electron 处理。

获取参数

第二个实例运行的时候,自己就退出了,那么第一个实例如何能获取到启动第二个实例的参数呢?这里 macOS 和 Windows 上的行为是不一致的,需要分别处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 如果打开协议时,没有其他实例,则当前实例当做主实例,处理参数
handleArgv(process.argv);
// 其他实例启动时,主实例会通过 second-instance 事件接收其他实例的启动参数 `argv`
app.on('second-instance', (event, argv) => {
// Windows 下通过协议URL启动时,URL会作为参数,所以需要在这个事件里处理
if (process.platform === 'win32') {
handleArgv(argv);
}
});

// macOS 下通过协议URL启动时,主实例会通过 open-url 事件接收这个 URL
app.on('open-url', (event, urlStr) => {
handleUrl(urlStr);
});

Windows 下 argv 的处理有一个需要注意的地方,就是开发阶段要跳过前两个参数(execPath 和当前启动脚本):

1
2
3
4
5
6
7
8
function handleArgv(argv) {
const prefix = `${PROTOCOL}:`;
// 开发阶段,跳过前两个参数(`electron.exe .`)
// 打包后,跳过第一个参数(`myapp.exe`)
const offset = app.isPackaged ? 1 : 2;
const url = argv.find((arg, i) => i >= offset && arg.startsWith(prefix));
if (url) handleUrl(url);
}

完整的协议链接跟HTTP链接类似,如下:

myapp://openapp?name=1&pwd=2

但大部分情况下,我们可能只需要最后的 key-value 参数就可以了,所以可以省略成:

myapp://?name=1&pwd=2

链接可以通过全局对象 URL 来解析:

1
2
3
4
5
6
7
8
9
function handleUrl(urlStr) {
// myapp://?name=1&pwd=2
const urlObj = new URL(urlStr);
const { searchParams } = urlObj;
console.log(urlObj.search); // -> ?name=1&pwd=2
console.log(searchParams.get('name')); // -> 1
console.log(searchParams.get('pwd')); // -> 2
// 根据需要做其他事情
}

完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
const path = require('path');
const { app } = require('electron');

const gotTheLock = app.requestSingleInstanceLock();

if (!gotTheLock) {
app.quit();
}

const PROTOCOL = 'myapp';
const args = [];
if (!app.isPackaged) {
args.push(path.resolve(process.argv[1]));
}
args.push('--');
app.setAsDefaultProtocolClient(PROTOCOL, process.execPath, args);

// Windows
function handleArgv(argv) {
const prefix = `${PROTOCOL}:`;
// 开发阶段,跳过前两个参数(`electron.exe .`)
// 打包后,跳过第一个参数(`myapp.exe`)
const offset = app.isPackaged ? 1 : 2;
const url = argv.find((arg, i) => i >= offset && arg.startsWith(prefix));
if (url) handleUrl(url);
}

// Windows 第一个实例触发
handleArgv(process.argv);
// Windows 第二个实例触发
app.on('second-instance', (event, argv) => {
if (process.platform === 'win32') {
handleArgv(argv)
}
});

// macOS
app.on('open-url', (event, urlStr) => {
handleUrl(urlStr);
});

function handleUrl(urlStr) {
// myapp://?name=1&pwd=2
const urlObj = new URL(urlStr);
const { searchParams } = urlObj;
console.log(urlObj.search); // -> ?name=1&pwd=2
console.log(searchParams.get('name')); // -> 1
console.log(searchParams.get('pwd')); // -> 2
}

收到协议打开页面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let autologindata = null;
function handleUrl(urlStr) {
const urlObj = new URL(urlStr);
const {searchParams} = urlObj;
let userid = searchParams.get('userid');
let sectionid = searchParams.get("sectionid");
console.log(userid); // -> 1
console.log(sectionid); // -> 2
autologindata = {
"userid": userid,
"sectionid": sectionid
};
console.info(autologindata);
}

首页面设置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
const isDevelopment = !app.isPackaged;

function createWindow() {
loginWindow = new BrowserWindow({
width: 750,
height: 472,
frame: false,
show: false,
transparent: true,
resizable: false,
webPreferences: {
nodeIntegration: true,
enableRemoteModule: true,
webSecurity: false,
contextIsolation: false,
},
});

if (isDevelopment) {
loginWindow.loadURL(`file://${__dirname}/login.html`)
} else {
loginWindow.loadURL(format({
pathname: path.join(__dirname, 'login.html'),
protocol: 'file',
slashes: true
}))
}

loginWindow.once("ready-to-show", () => {
loginWindow.show();
if (autologindata) {
loginWindow.webContents.send("autologinEvent", autologindata);
}
});

// Open the DevTools.
// loginWindow.webContents.openDevTools();
}

这样设置的原因是

收到协议传输数据的时候页面还没创建,所以用外部变量保存接收的值,当窗口打开后再做处理

登录页面监听值

1
2
3
4
5
6
7
8
9
10
11
12
autologin_event() {
ipcRenderer.on("autologinEvent", (event, arg) => {
//自动登录
let {userid, sectionid} = arg;
this.userid = userid;
this.sectionid = sectionid;
logger.info(`自动登录 userid:${userid} sectionid:${sectionid}`);
localStorage.setItem("sectionid", this.sectionid);
this.autologin = 1;
this.toAutologin(userid, sectionid);
});
},

与单实例结合

我的程序是单实例的,如果打开第二个实例的时候,我这边的处理是显示第一个实例的窗口,

第一个实例正在打开的窗口可能是其他窗口,而协议的传参我只在登录处理了,所以这里就忽略了第二个实例的传参处理。

main.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
app.quit()
} else {
app.on('second-instance', (event, commandLine, workingDirectory) => {
// 当要打开第二个实例的时候 打开我们的窗口
if (loginWindow && !loginWindow.isDestroyed()) {
if (loginWindow.isMinimized()) {
loginWindow.restore()
}
loginWindow.focus()
}

if (classcenterWin && !classcenterWin.isDestroyed()) {
if (classcenterWin.isMinimized()) {
classcenterWin.restore()
}
classcenterWin.focus()
}
})

// 其它事件
app.on('ready', () => {
})
}
let autologindata = null;

const args = [];
if (!app.isPackaged) {
args.push(path.resolve(process.argv[1]));
}
args.push('--');
const PROTOCOL = 'xhlivepc';
app.setAsDefaultProtocolClient(PROTOCOL, process.execPath, args);

// Windows
function handleArgv(argv) {
const prefix = `${PROTOCOL}:`;
// 开发阶段,跳过前两个参数(`electron.exe .`)
// 打包后,跳过第一个参数(`myapp.exe`)
const offset = app.isPackaged ? 1 : 2;
const url = argv.find((arg, i) => i >= offset && arg.startsWith(prefix));
if (url) handleUrl(url);
}

// Windows 第一个实例触发
handleArgv(process.argv);

// macOS
app.on('open-url', (event, urlStr) => {
handleUrl(urlStr);
});

function handleUrl(urlStr) {
const urlObj = new URL(urlStr);
const {searchParams} = urlObj;
let userid = searchParams.get('userid');
let sectionid = searchParams.get("sectionid");
autologindata = {
"userid": userid,
"sectionid": sectionid
};
}

这里面删除了

1
2
3
4
5
6
// Windows 第二个实例触发
app.on('second-instance', (event, argv) => {
if (process.platform === 'win32') {
handleArgv(argv)
}
});

login.vue

登录页面这样处理

页面加载事件

1
2
3
4
5
6
7
mounted() {
this.autologin_event();
// 延迟处理让 获取更新时已获取到协议传参
setTimeout(() => {
this.init_version();
}, 500)
},

方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
methods: {
autologin_event() {
ipcRenderer.on("autologinEvent", (event, arg) => {
//自动登录
let {userid, sectionid} = arg;
this.userid = userid;
this.sectionid = sectionid;
logger.info(`自动登录 userid:${userid} sectionid:${sectionid}`);
localStorage.setItem("sectionid", this.sectionid);
this.autologin = 1;
});
},
async init_version() {
// 自动登录在检测更新后运行 如果有新版本不再自动登录
let that = this;
let needUpdate = false;
try {
let data = await api_apppc_get_new({})
let res = data.data;
if (res.code === 0) {
let obj = res.obj;
if (obj != null && obj.versioncode > versioncode) {
if (obj.versionpath) {
that.version_flag = true;
that.version_obj = obj;
needUpdate = true;
}
}
} else {
this.$Message.error("检测更新失败,请检查网络!")
}
} catch (e) {
this.$Message.error("检测更新失败,请检查网络!")
}

if (!needUpdate) {
if (this.autologin === 1) {
await this.toAutologin(this.userid, this.sectionid);
}
}
},
async toAutologin(userid, sectionid) {
//自动登录
},
}