之前一直想做一个桌面app,在某些机缘巧合之下找到了一个值得一做的项目,又在某些机缘巧合之下集齐了linux/max/windows(win7)三大系统,于是开始了一个桌面应用的开发之旅。(代码较多,仅供参考)
项目技术栈
前端:ts + react + modern-js + ant-design/(ant-design/pro-components)
后端:ts + koa + lowdb + winston + webpack5
APP打包:electron + electron-builder
因为开发的是平台类的APP,所以前端使用antd作为组件库,后端更加倾向于轻量级的框架,koa和lowdb都是轻量级的库,都比较适合打包到app内。打包app采用的是electron-builder,主要是文档比较齐全,资料好找一些。
参考文档
electron-builder configuration
前后端打包
1. 前端打包:
shell
modern build
前端路由使用hash路由:
typescript
import { HashRouter } from '@modern-js/runtime/router';
理由:将前端代码打包到后端,使用hash路由可以避免路由冲突问题;使用elctron.loadFile
加载index.html静态文件时,使用hash路由不会出现链接跳转后找不到页面的情况,减少了一些额外的配置。
modern.config.ts配置:
typescript
import appTools, { defineConfig } from '@modern-js/app-tools';
// https://modernjs.dev/docs/apis/app/config
export default defineConfig({
runtime: {
router: true,
state: true,
},
plugins: [appTools()],
output: {
polyfill: 'ua',
assetPrefix: './dist',
cleanDistPath: true,
distPath: {
root: '<后端项目>/static/dist', // 将前端文件直接打包到后端的static/dist目录下
},
assetsRetry: {
max: 3,
crossOrigin: true,
},
convertToRem: true,
disableSourceMap: true,
},
});
这里convertToRem
设置为true,是考虑到窗口大小的变化,使用rem更加合适些,配置assetsRetry
可以避免资源加载出错的情况,增加资源加载容错率,当然在本地加载这个配置可以忽略不计。
config目录下添加favicon.ico或者favicon.png,为网站设置图标。
2. 后端打包:
设置静态目录
typescript
import * as koaStatic from 'koa-static';
...
app.use(
koaStatic('./static', {
gzip: true,
maxage: 180000,
index: 'index.html',
}),
);
webpack.config.js配置:
javascript
const path = require('path');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
function resolve(dir) {
return path.resolve(__dirname, dir);
}
module.exports = {
mode: 'production',
entry: {
main: './src/app.ts',
},
output: {
filename: 'app.js',
path: resolve('dist'),
libraryTarget: 'commonjs2',
},
// externals: [/^(?!\.|\/).+/i], //node 打包可去除一些警告
target: 'node', // 服务端打包
resolve: {
extensions: ['.ts', '.js'],
extensionAlias: {
'.js': ['.js', '.ts'],
},
modules: ['node_modules', 'src'], // 将依赖一起打包进去
alias: {
'@': resolve('src'), // 将 '@' 映射到 'src' 目录
},
alias: {
'@loadable': resolve('node_modules/@loadable'),
},
},
module: {
rules: [
{
test: /\.tsx?$/,
use: 'ts-loader',
include: [resolve('src')],
},
{
test: /\.js$/,
exclude: /(node_modules|bower_components)/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env'],
plugins: ['@babel/transform-runtime'], // 辅助代码从这里引用
},
},
},
],
},
plugins: [new CleanWebpackPlugin()],
};
APP打包
1. 项目准备
将后端static目录和app.js放入build/release目录下。
准备APP图标,icon.png:
shell
npm i -D electron-icon-builder
electron-icon-builder --input=./icon.png --output=build --flatten
这里的electron-icon-builder只在生成图标的时候使用到,可以考虑全局安装。
build目录下将会生成icons目录:
txt
|- build
|- icons
-1024x1024.png
-128x128.png
-16x16.png
-24x24.png
-256x256.png
-32x32.png
-48x48.png
-512x512.png
-64x64.png
-icon.icns
-icon.ico
其中build/icons用于linux图标配置,icon.icns用于mac图标配置,icon.ico用于windows图标配置。
2. 项目配置
.npmrc配置
ini
strict-peer-dependencies=false
registry=https://registry.npm.taobao.org
sass_binary_site=https://npm.taobao.org/mirrors/node-sass/
phantomjs_cdnurl=http://npm.taobao.org/mirrors/phantomjs
electron_mirror=http://npm.taobao.org/mirrors/electron/
添加app.desktop,用于linux桌面配置
desktop
[Desktop Entry]
Name=demo
Name[zh_CN]=app例子
Comment=app例子
Terminal=false
Exec=/opt/demo/bin/demo.sh %U
Icon=demo
Type=Application;IDE;
Categories=Utility;
Keywords=demo;
其中demo.sh用来启动app,这里参考微信linux版的启动脚本:
bash
#!/bin/bash
TMPFILE=/tmp/demo.list.tmp
function store_pidlist() {
ps -ef | grep demo | grep -v grep | grep -v demo.sh | awk '{print $2}' >${TMPFILE}
}
# 返回 0 表示app未运行;返回 1 表示app正在运行
function is_running() {
# 1. 获取当前app的进程数量,非零走判断
# 2. 如果有tmp文件,获取当前的进程list,比对判断,以tmp为准,tmp中的进程缺失则认定为退出
# 3. 如果没有tmp文件,非零就退
DEMO_NUMS=$(ps -ef | grep demo | grep -v grep | grep -v demo.sh | wc -l)
# 能检索到demo进程的情况下才做判断
if [ 0 -ne $DEMO_NUMS ]; then
if [ -f ${TMPFILE} ]; then
DEMOPIDList=($(ps -ef | grep demo | grep -v grep | grep -v demo.sh | awk '{print $2}'))
for item in $(cat ${TMPFILE}); do
# 如果有pid不在当前进程中,则返回0
[[ ${DEMOPIDList[@]/${item}/} != ${DEMOPIDList[@]} ]]
if [ $? -eq 1 ]; then
return 0
fi
done
return 1
else
# 如果文件丢失,直接认为demo未运行
return 0
fi
fi
return 0
}
is_running
if [ $? -eq 1 ]; then
echo "demo is already running..."
else
killall demo
nohup /opt/demo/demo >/dev/null 2>&1 &
sleep 7
store_pidlist
fi
package.json添加必要的字段:
json
{
"name": "demo",
"version": "1.0.0",
"description": "app例子 桌面版",
"main": "main.js",
"author": "xxx.xx",
"homepage": ".",
"productName": "app例子",
"email": "xxx@qq.com",
"scripts": {
"start": "electron .",
"build:deb": "electron-builder build --linux deb",
"build:dmg": "electron-builder build --mac dmg",
"build:nsis": "electron-builder build --win nsis",
"electron:generate-icons": "electron-icon-builder --input=./icon.png --output=build --flatten"
},
...
}
electron-builder.json配置:
json
{
"appId": "com.demo.app",
"productName": "app例子",
"copyright": "xxx.xx Copyright © 2023",
"files": ["main.js", "build/**/*"],
"extraFiles": ["bin/**/*", "app.desktop"],
"directories": {
"output": "./release-built"
},
"linux": {
"target": ["deb", "rpm"],
"maintainer": "xxxx.xx",
"icon": "build/icons/",
"category": "Utility",
"description": "app例子",
"desktop": "app.desktop",
"packageCategory": "GNOME;GTK;"
},
"win": {
"icon": "build/icons/icon.ico",
"artifactName": "${productName}-${platform}-${arch}-${version}.${ext}",
"requestedExecutionLevel": "highestAvailable",
"target": [
{
"target": "nsis",
"arch": ["x64", "ia32"]
},
{
"target": "zip",
"arch": ["x64", "ia32"]
}
]
},
"mac": {
"category": "public.app-category.utilities",
"icon": "build/icons/icon.icns",
"target": ["dmg", "zip"],
"artifactName": "${productName}-${platform}-${arch}-${version}.${ext}"
},
"nsis": {
"oneClick": false,
"allowElevation": true,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"createStartMenuShortcut": true,
"artifactName": "${name}-${version}-setup.${ext}",
"shortcutName": "app例子",
"uninstallDisplayName": "app例子",
"uninstallerIcon": "build/icons/icon.ico",
"perMachine": true,
"deleteAppDataOnUninstall": true,
"installerIcon": "build/icons/icon.ico",
"installerHeaderIcon": "build/icons/icon.ico"
},
"dmg": {
"contents": [
{
"x": 410,
"y": 190,
"type": "link",
"path": "/Applications"
},
{
"x": 130,
"y": 190,
"type": "file"
}
],
"window": {
"height": 380,
"width": 540
}
}
}
其中nsis和dmg分别是windows和mac下的安装包格式,这两个需要单独做配置。
3. main.js编写
typescript
const { app, BrowserWindow, Menu, globalShortcut } = require('electron');
const path = require('path');
const fs = require('fs');
const server = require('./build/release/app');
process.env['ELECTRON_DISABLE_SECURITY_WARNINGS'] = 'true';
// app.commandLine.appendSwitch('remote-debugging-port', '9222'); // 在主进程中添加这段代码
// 忽略访问得是https证书问题
app.commandLine.appendSwitch('--disable-http-cache');
app.commandLine.appendSwitch('ignore-certificate-errors', true);
function checkEnv() {
let envPath = path.join(__dirname, './.env');
if (fs.existsSync(envPath)) {
return path.join(__dirname, './');
}
const root = app.getPath('userData');
envPath = path.join(root, '.env');
if (!fs.existsSync(root)) {
fs.mkdirSync(root);
}
if (!fs.existsSync(envPath)) {
const list = [];
list.push(`PORT=3000`);
list.push(`ROOT=${path.join(root, 'release')}`);
list.push(`PATH_LOG=${app.getPath('logs')}`);
// list.push(`PATH_STATIC=${path.join(__dirname, './build/release/static')}`);
fs.writeFileSync(envPath, list.join('\n'), 'utf-8');
}
return root;
}
app.whenReady().then(() => {
try {
const p = checkEnv();
server.default(p);
} catch (e) {
console.error(e);
}
// 创建一个web窗口
const mainWin = new BrowserWindow({
width: 1400,
minWidth: 1200,
minHeight: 700,
height: 900,
icon: path.join(__dirname, './build/icons/256x256.png'),
webPreferences: {
worldSafeExecuteJavaScript: false,
// nodeIntegrationInWorker: true,
webSecurity: false,
// nodeIntegration: true,
// preload: './preload.js',
},
});
// 引入页面要展示的文件
// mainWin.loadFile(path.join(__dirname, './build/release/static/index.html'));
mainWin.loadURL('http://127.0.0.1:3000', {});
// 监听当前窗口关闭要做的事情
mainWin.on('close', () => {
console.log('close');
});
// 去掉默认菜单
Menu.setApplicationMenu(null);
// 注册快捷键
globalShortcut.register('Alt+CommandOrControl+Shift+D', () => {
mainWin.webContents.openDevTools({ mode: 'detach' }); //开启开发者工具
});
});
// 监听所有窗口关闭要做的事情
app.on('window-all-closed', () => {
console.log('window-all-closed');
app.quit(); // 窗口关闭api
});
4. win7打包兼容问题
- win7安装高版本node
我的电脑右击进入属性->高级系统设置->环境变量->系统变量中添加NODE_SKIP_PLATFORM_CHECK 值:1
我安装的是node-v18.14.2
- 打包安装exe文件,运行时出现:无法定位程序输入点GetPackageFamiliName于动态链接库KERNEL32.dll上
最终发现是electron版本太高,无法在win7上运行。最终降低版本 electron :21.4.4 , electron-builder: 22.1.0
- 将项目打包到win7上面运行
export_win.sh
bash
#!/bin/bash
CUR_PATH=$(pwd)
echo $CUR_PATH
TARGET_PATH="./release-built/output"
APP_PATH="./demo_app"
if [ ! -d "../release-built" ]; then
mkdir ../release-built
fi
if [ -d "$TARGET_PATH" ]; then
rm -rf $TARGET_PATH
fi
cp -r $APP_PATH $TARGET_PATH
rm -rf $TARGET_PATH/node_modules
rm $TARGET_PATH/package-lock.json
# 低版本兼容
# "electron": "^23.3.12", => 21.4.4
# "electron-builder": "^24.6.3" => 22.1.0
# 判断是否是mac
if [ $(uname) == "Darwin" ]; then
sed -i '' 's/"electron": ".*"/"electron": "^21.4.4"/g' $TARGET_PATH/package.json
sed -i '' 's/"electron-builder": ".*"/"electron-builder": "^22.1.0"/' $TARGET_PATH/package.json
else
sed -i 's/"electron": ".*"/"electron": "^21.4.4"/g' $TARGET_PATH/package.json
sed -i 's/"electron-builder": ".*"/"electron-builder": "^22.1.0"/g' $TARGET_PATH/package.json
fi
cd ./release-built
if [ -d "elease-demo.zip" ]; then
rm elease-demo.zip
fi
zip -r release-demo.zip output/
rm -rf output/
然后项目复制到windows上面,解压,打开powershell,cd到项目目录,执行:
shell
# npm install
npm i --verbose --electron_mirror=http://npm.taobao.org/mirrors/electron/
npm run build:nsis
5. app打包
linux平台运行:
shell
npm run build:deb
mac平台运行:
shell
npm run build:dmg
windows平台运行:
shell
npm run build:nsis
结语
这里吐槽一声,现在找资料真的太难了,那些博客写的东西千篇一律,无非就是东抄西,西抄东,想找到自己正在需要的信息真的很少。目前最好的方式还是github学习别人的代码,以及使用chatGPT或者其他AI工具,另外查看官网也是不错的选择。
目前还有一些没有完善的地方,这里做一个TODO-list:
- 登录逻辑:窗口切换问题
- 应用更新:数据库更新问题
- 数据同步:局域网数据同步