feat(app): 新增 ./cmd expo dev 同时启动前端与 Metro,appexpo 命令更名为 expo

- ./cmd expo [build|dev|npm],与 ./cmd electron 同规范;CI 同步改用 ./cmd expo build
- expo dev:Metro 端口自动找空闲(指定 EXPO_PACKAGER_PROXY_URL 时固定并在占用时报错),地址按 VSCODE_PROXY_URI 推导或命令行传入
- vite 开发反代开启 gzip,减少手机经公网加载开发前端的体积
- resources/app:DooTask (dev) 变体
This commit is contained in:
kuaifan 2026-10-21 18:41:54 +00:00
parent aca8fc6b24
commit 3192e43db3
5 changed files with 99 additions and 36 deletions

View File

@ -96,7 +96,7 @@ jobs:
run: npm install --include=dev --no-package-lock --no-audit --no-fund
- name: Build and copy web assets
run: ./cmd appexpo build
run: ./cmd expo build
- name: Upload bundled web assets
uses: actions/upload-artifact@v4

39
cmd
View File

@ -417,13 +417,14 @@ electron_operate() {
env BUILD_FRONTEND=$BUILD_FRONTEND node ./electron/build.js $argv
}
# Expo App 前端:同 electron 使用本机 Node,不依赖 Docker。
appexpo_operate() {
# Expo App:同 electron 使用本机 Node,不依赖 Docker。
expo_operate() {
local usage="用法: ./cmd expo [build] | ./cmd expo dev | ./cmd expo npm <参数...>"
check_node
case "${1:-build}" in
"build")
"build"|"dev")
if [[ $# -gt 1 ]]; then
error "用法: ./cmd appexpo [build] | ./cmd appexpo npm <参数...>"
error "$usage"
return 1
fi
if [ ! -d "./node_modules" ]; then
@ -432,14 +433,22 @@ appexpo_operate() {
if [ ! -d "./electron/node_modules" ]; then
(cd electron && npm install --no-package-lock) || return $?
fi
node ./electron/build.js app-expo build
if [ "$1" == "dev" ]; then
if [ ! -d "./resources/app/node_modules" ]; then
(cd resources/app && npm install --no-audit --no-fund) || return $?
fi
switch_debug dev
node ./electron/build.js app-expo dev
else
node ./electron/build.js app-expo build
fi
;;
"npm")
shift
(cd "${WORK_DIR}/resources/app" && npm "$@")
;;
*)
error "用法: ./cmd appexpo [build] | ./cmd appexpo npm <参数...>"
error "$usage"
return 1
;;
esac
@ -699,8 +708,9 @@ DooTask 管理脚本
serve, dev 启动开发模式
build, prod 生产环境构建
electron 构建桌面应用
appexpo [build] 编译并同步 Expo App 前端
appexpo npm <参数...> 在 Expo 子模块执行本机 npm
expo [build] 编译并同步 Expo App 前端
expo dev 启动 App 开发模式(Metro + 前端)
expo npm <参数...> 在 Expo 子模块执行本机 npm
🔧 服务管理:
up [服务名] 启动容器
@ -752,8 +762,9 @@ Usage: ./cmd <command> [options]
serve, dev Start dev mode
build, prod Production build
electron Build desktop app
appexpo [build] Build and copy Expo App web assets
appexpo npm <args...> Run local npm in the Expo submodule
expo [build] Build and copy Expo App web assets
expo dev Start App dev mode (Metro + web)
expo npm <args...> Run local npm in the Expo submodule
🔧 Services:
up [service] Start containers
@ -1216,8 +1227,8 @@ if [[ "$1" == "help" ]] || [[ "$1" == "--help" ]] || [[ "$1" == "-h" ]] || [[ $#
exit 0
fi
# 非electron、appexpo和permission命令需要检查Docker环境
if [[ "$1" != "electron" ]] && [[ "$1" != "appexpo" ]] && [[ "$1" != "permission" ]]; then
# 非electron、expo和permission命令需要检查Docker环境
if [[ "$1" != "electron" ]] && [[ "$1" != "expo" ]] && [[ "$1" != "permission" ]]; then
check_docker
env_init
fi
@ -1276,9 +1287,9 @@ case "$1" in
shift 1
web_build prod
;;
"appexpo")
"expo")
shift 1
appexpo_operate "$@"
expo_operate "$@"
exit $?
;;
"electron")

87
electron/build.js vendored
View File

@ -509,31 +509,78 @@ if (["dev"].includes(argv[2])) {
child_process.spawn("npx", ["vite", "--", "fromcmd", "electronDev"], {stdio: "inherit"});
child_process.spawn("npm", ["run", "start-quiet"], {stdio: "inherit", cwd: "electron"});
} else if (argv[2] === 'app-expo') {
if ((argv[3] && argv[3] !== 'build') || argv.length > 4) {
console.error("用法: node ./electron/build.js app-expo [build]");
if ((argv[3] && !['build', 'dev'].includes(argv[3])) || argv.length > 4) {
console.error("用法: node ./electron/build.js app-expo [build|dev]");
process.exit(1);
}
if (!fs.existsSync(path.resolve(__dirname, "../resources/app/app.config.ts"))) {
const appDir = path.resolve(__dirname, "../resources/app");
if (!fs.existsSync(path.resolve(appDir, "app.config.ts"))) {
console.error("resources/app 未找到,请先初始化 Expo 子模块");
process.exit(1);
}
startBuild({
name: 'App',
id: 'app-expo',
// public 是前端“尚未设置服务器”的占位域名,不是静态资源的 origin。
// electronBuild 的 ./ 资源路径在 localhost:22223/ 下加载,路由自动使用 hash。
url: 'http://public/',
configure: {
platform: '',
archs: [],
publish: false,
release: true,
notarize: false,
}
}).catch(error => {
console.error(error.message || error);
process.exitCode = 1;
});
if (argv[3] === 'dev') {
// 开发模式:前端 vite + Metro;键盘交给 Metro(r 重载等),任一退出则全部结束
// 地址优先取命令行环境变量,其次按 VSCODE_PROXY_URI 端口模板推导,都没有则 Metro 由 Expo 自动取局域网地址、前端取本机局域网 IP
// 手动指定 EXPO_PACKAGER_PROXY_URL 时端口固定(METRO_PORT,默认 2220),否则从 2220 起自动找空闲端口
const appPort = env.parsed.APP_PORT;
const proxyUri = process.env.VSCODE_PROXY_URI || '';
const proxyUrl = port => proxyUri.includes('{{port}}') ? proxyUri.replace(/\{\{port\}\}/g, port).replace(/\/+$/, '') : '';
const lanIp = Object.values(require('os').networkInterfaces()).flat().find(item => item.family === 'IPv4' && !item.internal)?.address;
const webUrl = process.env.EXPO_PUBLIC_WEB_URL || proxyUrl(appPort) || (lanIp ? `http://${lanIp}:${appPort}` : '');
const fixedPort = !!process.env.EXPO_PACKAGER_PROXY_URL;
const isPortFree = port => new Promise(resolve => {
const server = require('net').createServer();
server.once('error', () => resolve(false));
server.listen(port, () => server.close(() => resolve(true)));
});
(async () => {
let metroPort = Number(process.env.METRO_PORT) || 2220;
// 跳过前端端口 APP_PORT:nginx 未启动时它也是空闲的,被 Metro 占用会导致 nginx 起不来
while (metroPort == appPort || !await isPortFree(metroPort)) {
if (fixedPort || metroPort >= 2299) {
console.error(`Metro 端口 ${metroPort} 已被占用,请先关闭旧的 Metro`);
process.exit(1);
}
metroPort++;
}
const packagerUrl = process.env.EXPO_PACKAGER_PROXY_URL || proxyUrl(metroPort);
console.log(`Metro 地址:${packagerUrl || '自动(局域网)'}`);
console.log(`前端地址:${webUrl || '本地 Web 服务'}`);
const appEnv = {...process.env, EXPO_PUBLIC_WEB_URL: webUrl};
if (packagerUrl) appEnv.EXPO_PACKAGER_PROXY_URL = packagerUrl;
const children = [
child_process.spawn("npx", ["vite", "--", "fromcmd"], {stdio: ["ignore", "inherit", "inherit"]}),
child_process.spawn("npx", ["expo", "start", "--dev-client", "--port", String(metroPort)], {stdio: "inherit", cwd: appDir, env: appEnv}),
];
let exiting = false;
const stopAll = (code) => {
if (exiting) return;
exiting = true;
process.exitCode = code;
children.forEach(child => child.exitCode === null && child.kill('SIGTERM'));
};
children.forEach(child => child.on('exit', code => stopAll(code || 0)));
['SIGINT', 'SIGTERM'].forEach(signal => process.on(signal, () => stopAll(0)));
})();
} else {
startBuild({
name: 'App',
id: 'app-expo',
// public 是前端“尚未设置服务器”的占位域名,不是静态资源的 origin。
// electronBuild 的 ./ 资源路径在 localhost:22223/ 下加载,路由自动使用 hash。
url: 'http://public/',
configure: {
platform: '',
archs: [],
publish: false,
release: true,
notarize: false,
}
}).catch(error => {
console.error(error.message || error);
process.exitCode = 1;
});
}
} else if (["android-upload"].includes(argv[2])) {
// 上传安卓文件到 R2 draft(GitHub Actions):参数为 APK 所在目录(app-build.yml 产出的 app-android)
(async () => {

@ -1 +1 @@
Subproject commit d2ff9a54a0cfc1ea9942531193cc8265d80b7135
Subproject commit cc14a508968a299146091ac44c8f421acb3358a3

5
vite.config.js vendored
View File

@ -74,6 +74,11 @@ function devProxyPlugin(env, devBase) {
` proxy_set_header Connection $connection_upgrade;`,
` proxy_read_timeout 1d;`,
` proxy_buffering off;`,
// vite 开发服务不压缩,模块总量十几 MB,手机经公网加载时尤其慢
` gzip on;`,
` gzip_proxied any;`,
` gzip_min_length 1024;`,
` gzip_types application/javascript text/javascript text/css application/json;`,
` proxy_pass http://host.docker.internal:${port};`,
`}`,
``,