将 Web 应用封装为桌面应用

Electron 简介

Electron 是基于 Node.js 的桌面应用框架,采用 Chromium / Node.js / Native API 架构,常用于封装 Vue / React 应用为桌面应用,也可直接对 url 进行封装。

本文采用 url 封装方式将 Streamlit Web 应用封装桌面应用。需要 Electron 中同时打包第三方 Web 应用,对 localhost:8501 进行封装,运行时首先拉起第三方应用,退出时一并结束。

安装

安装 Node.js / npm.

依赖库

axios 为主进程所依赖,不能放在 dev-dependcies 中,否则 Electron-Builder 不打包。

1
2
npm install --save-dev electron electron-builder
npm install --save axios

Electron-Builder 依赖

可能需要添加 winCode 签名文件。

运行

项目结构见 §1. 将 Streamlit 无头应用置于 resources/ 目录下。

初始化

1
npm init

采用 .bat 拉起第三方应用 + .js 拉起 .bat 的方式。这是由于没有找到很好的用 Electron 直接拉起第三方应用,并获取其 PID 的方式。(PID 用于桌面应用退出后杀死 Web 应用)

main.bat

1
2
3
4
start resources/run_index.exe
for /f "tokens=2 delims=," %%a in ('tasklist /nh /fi "imagename eq run_index.exe" /fo csv') do set pid=%%a
echo %pid%
:: pause :: 单独测试时可以加上 pause, 防止窗口一闪而过

main.js

main.js 是 Electron 的入口文件,笔者已经调通,可以直接套用。

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
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
const { app, BrowserWindow, ipcMain } = require('electron')
const { spawn, exec } = require('child_process')
const axios = require('axios')

let webServerProcess = null;
let intervalId;
let processId;

// Promise 保证第三方应用结束
async function killit() {
return new Promise((resolve, reject) => {
// 这里使用 spawn 无效,笔者不明所以,直接换成 exec 了
// 检查后发现 8531 是 PID 的某个子进程所占用,taskkill /t 递归退出
exec(`taskkill /pid ${processId} /f /t`, (error, stdout, stderr) => {
if (error) {
console.error('Error killing process:', error);
reject(error);
return;
}
if (stderr) {
console.error('Error killing process:', stderr);
reject(stderr);
return;
}
resolve();
});
});
}

app.on('ready', () => {
// 拉起 main.bat, '/c' 要带上
// 这里不直接拉起 .exe 的原因是获取不到其 PID,退出时无法一并结束
const script = spawn('cmd.exe', ['/c', 'main.bat']);

// 创建浏览器窗口
const win = new BrowserWindow({
width: 800,
height: 600,
autoHideMenuBar: true,
alwaysOnTop: true,
x: 0,
y: 0
});

// 异步监听 Streamlit 端口,当前项目配置为 8531
script.stdout.on('data', async (data) => {
// 从 .bat 获取 PID
const pid = data.toString().trim();
processId = pid;

// 设置定时器,每隔 1 秒检查端口 8531 是否可用
intervalId = setInterval(async () => {
try {
await axios.get('http://localhost:8531');
win.loadURL('http://localhost:8531');
clearInterval(intervalId); // 监听 8531 OK 后删除 intervalId,避免持续监听
} catch (error) {
console.error('Error accessing port 8531:', error);
}
}, 1000); // 扫描间隔 1s
});

// 处理标准错误输出
script.stderr.on('data', (data) => {
console.error(`Error: ${data}`);
});

// 处理脚本退出事件
script.on('exit', (code) => {
console.log(`Script exited with code ${code}`);
});
});

// 这里 await 是因为观察到 taskkill 未执行时 electron 即退出。
// 通过 Promise 保证先结束第三方应用再结束 electron.
app.on('window-all-closed', async function () {
try {
await killit();
} catch (error) {
console.error('Error killing process:', error);
}
if (intervalId) {
clearInterval(intervalId);
}
app.quit();
});

app.on('before-quit', () => {
// 在应用退出前的操作
});

至此,运行 electron . 即可启动桌面应用。

Electron Builder

基于 Electron 封装桌面应用的方式很多,如 Packager, Builder 等。这里采用 Electron Builder.

主要关注 package.json 中的 build/extraResources 字段,在应用中一并封装第三方 Web 应用和 main.bat 脚本。在 extraResources 中配置数据文件路径,注意封装后数据文件的默认路径为 resources,所以想要 main.js 搜索文件的路径不变,则 ./* 封装后的路径应为 ../*,类似地,resources/* 封装后的路径应为 ./*

区别于常见的 nsis 打包,设置 target=portable 打包为单个文件。

package.json 内容如下。

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
{
"name": "trainingtools-v0.6",
"version": "1.0.0",
"main": "main.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "electron .",
"build-win": "electron-builder --win"
},
"build": {
"appId": "com.trainingtools.desktop",
"productName": "trainingtools-v0.6",
"win": {
"icon": "./static/icon/hisilicon-256-256.ico",
"target": [
{
"target": "portable"
}
],
"extraResources": [
{
"from": "./main.bat",
"to": "../main.bat"
},
{
"from": "./resources/run_index.exe",
"to": "./run_index.exe"
}
]
}
},
"author": "User@email",
"license": "ISC",
"description": "DataEyeTraining",
"devDependencies": {
"electron": "^32.1.2",
"electron-builder": "^25.0.5"
},
"dependencies": {
"axios": "^1.7.7"
}
}

运行 npm run build-win 进行 Windows 桌面应用打包。

关于 Icon

Electron-Builder 要求 icon 至少提供 256×256 尺寸。此外,默认产物路径下偶发 icon 显示异常,可重命名或移到其他目录。

Q & A

cannot find module

axios 是运行时依赖,不可装在 dev 下

装包 response code 404

源问题,索引与实际包仓库不匹配

引用 axios 报错 TypeError cannot read properties…

由引用格式引起,axios 外面禁止加大括号

体积问题

Electron 打包时可能存在缓存积压,同一目录多次打包后体积暴涨。所以在正式打包前删除 node_modules 和 package-lock.json 重装所有依赖。

其他打包方案

Nativefier

封装了 Electron 打包 url 的能力,可以使用 Nativefier + Streamlit Share 的方式直接封装 Streamlit App.

踩坑

  • Node.js 的 spawn 用于执行命令,其无法返回所拉起脚本的真正 PID