mirror of
https://github.com/kuaifan/dootask.git
synced 2026-10-29 22:10:42 +00:00
- ./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) 变体
860 lines
34 KiB
JavaScript
Vendored
860 lines
34 KiB
JavaScript
Vendored
const fs = require('fs');
|
||
const fse = require('fs-extra');
|
||
const path = require('path')
|
||
const inquirer = require('inquirer');
|
||
const child_process = require('child_process');
|
||
const ora = require('ora');
|
||
const yauzl = require('yauzl');
|
||
const axios = require('axios');
|
||
const tar = require('tar');
|
||
const utils = require('./lib/utils');
|
||
const r2 = require('./lib/r2');
|
||
const { buildReleaseIndex } = require('./lib/release-index');
|
||
const config = require('../package.json')
|
||
const env = require('dotenv').config({ path: './.env' })
|
||
const argv = process.argv;
|
||
const {BUILD_FRONTEND, APPLEID, APPLEIDPASS, GITHUB_TOKEN, GITHUB_REPOSITORY} = process.env;
|
||
|
||
const electronDir = path.resolve(__dirname, "public");
|
||
const nativeCachePath = path.resolve(__dirname, ".native");
|
||
const devloadCachePath = path.resolve(__dirname, ".devload");
|
||
const packageFile = path.resolve(__dirname, "package.json");
|
||
const packageBakFile = path.resolve(__dirname, "package-bak.json");
|
||
const platforms = ["build-mac", "build-win"];
|
||
const architectures = ["arm64", "x64"];
|
||
|
||
let buildChecked = false,
|
||
updaterChecked = false;
|
||
|
||
const elapsedSeconds = (startTime) => `${((Date.now() - startTime) / 1000).toFixed(1)}s`;
|
||
|
||
/**
|
||
* 检测并下载更新器
|
||
*/
|
||
async function detectAndDownloadUpdater() {
|
||
const updaterDir = path.resolve(__dirname, "updater");
|
||
const latestVersionFile = path.join(updaterDir, "latest");
|
||
|
||
// 创建updater目录
|
||
if (!fs.existsSync(updaterDir)) {
|
||
fs.mkdirSync(updaterDir, { recursive: true });
|
||
}
|
||
|
||
try {
|
||
// 获取最新release
|
||
const spinner = ora('检查更新器...').start();
|
||
const response = await axios.get('https://api.github.com/repos/kuaifan/dootask-updater/releases/latest', {
|
||
headers: GITHUB_TOKEN ? { 'Authorization': `token ${GITHUB_TOKEN}` } : {}
|
||
});
|
||
|
||
if (!response.data || !response.data.assets) {
|
||
spinner.fail('检查更新器失败');
|
||
return;
|
||
}
|
||
|
||
// 检查版本是否需要更新
|
||
const latestVersion = response.data.tag_name || response.data.name;
|
||
let currentVersion = '';
|
||
if (fs.existsSync(latestVersionFile)) {
|
||
currentVersion = fs.readFileSync(latestVersionFile, 'utf8').trim();
|
||
}
|
||
|
||
// 如果版本不一致,清空updater目录(保留latest文件)
|
||
if (currentVersion !== latestVersion) {
|
||
const files = fs.readdirSync(updaterDir);
|
||
for (const file of files) {
|
||
if (file === 'latest') continue;
|
||
const filePath = path.join(updaterDir, file);
|
||
if (fs.lstatSync(filePath).isDirectory()) {
|
||
fs.rmSync(filePath, { recursive: true });
|
||
} else {
|
||
fs.unlinkSync(filePath);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 过滤出binary开头的zip文件
|
||
const assets = response.data.assets.filter(asset =>
|
||
asset.name.startsWith('binary_') && asset.name.endsWith('.zip')
|
||
);
|
||
|
||
spinner.succeed('检查更新器成功');
|
||
|
||
// 下载并解压每个文件
|
||
for (const asset of assets) {
|
||
const fileName = asset.name;
|
||
// 解析平台和架构信息 (binary_0.1.0_linux-x86_64.zip => linux/x86_64)
|
||
const match = fileName.match(/binary_[\d.]+_(.+)-(.+)\.zip$/);
|
||
if (!match) continue;
|
||
|
||
let [, platform, arch] = match;
|
||
|
||
// 平台名称映射
|
||
const platformMap = {
|
||
'macos': 'mac',
|
||
'windows': 'win'
|
||
};
|
||
platform = platformMap[platform] || platform;
|
||
|
||
// 架构名称映射
|
||
const archMap = {
|
||
'x86_64': 'x64'
|
||
};
|
||
arch = archMap[arch] || arch;
|
||
|
||
const targetDir = path.join(updaterDir, platform, arch);
|
||
const zipPath = path.join(updaterDir, fileName);
|
||
|
||
// 检查是否已经下载过
|
||
if (fs.existsSync(targetDir)) {
|
||
continue;
|
||
}
|
||
|
||
// 创建目标目录
|
||
fs.mkdirSync(targetDir, { recursive: true });
|
||
|
||
// 下载文件
|
||
const downloadSpinner = ora(`下载 ${fileName}...`).start();
|
||
try {
|
||
const writer = fs.createWriteStream(zipPath);
|
||
const response = await axios({
|
||
url: asset.browser_download_url,
|
||
method: 'GET',
|
||
responseType: 'stream',
|
||
headers: GITHUB_TOKEN ? { 'Authorization': `token ${GITHUB_TOKEN}` } : {}
|
||
});
|
||
|
||
response.data.pipe(writer);
|
||
|
||
await new Promise((resolve, reject) => {
|
||
writer.on('finish', resolve);
|
||
writer.on('error', reject);
|
||
});
|
||
|
||
// 解压文件
|
||
downloadSpinner.text = `解压 ${fileName}...`;
|
||
await new Promise((resolve, reject) => {
|
||
yauzl.open(zipPath, { lazyEntries: true }, (err, zipfile) => {
|
||
if (err) reject(err);
|
||
|
||
zipfile.readEntry();
|
||
zipfile.on('entry', (entry) => {
|
||
if (/\/$/.test(entry.fileName)) {
|
||
zipfile.readEntry();
|
||
} else {
|
||
zipfile.openReadStream(entry, (err, readStream) => {
|
||
if (err) reject(err);
|
||
|
||
const outputPath = path.join(targetDir, path.basename(entry.fileName));
|
||
const writer = fs.createWriteStream(outputPath);
|
||
readStream.pipe(writer);
|
||
writer.on('finish', () => {
|
||
fs.chmodSync(outputPath, 0o755);
|
||
zipfile.readEntry();
|
||
});
|
||
});
|
||
}
|
||
});
|
||
|
||
zipfile.on('end', resolve);
|
||
});
|
||
});
|
||
|
||
// 删除zip文件
|
||
fs.unlinkSync(zipPath);
|
||
downloadSpinner.succeed(`下载并解压 ${fileName} 成功`);
|
||
|
||
// 下载和解压成功后,保存最新版本号
|
||
fs.writeFileSync(latestVersionFile, latestVersion, 'utf8');
|
||
|
||
} catch (error) {
|
||
downloadSpinner.fail(`下载 ${fileName} 失败: ${error.message}`);
|
||
// 清理失败的下载
|
||
if (fs.existsSync(zipPath)) {
|
||
fs.unlinkSync(zipPath);
|
||
}
|
||
if (fs.existsSync(targetDir)) {
|
||
fs.rmdSync(targetDir, { recursive: true });
|
||
}
|
||
}
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('检查更新器失败:', error.message);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 下载并解压Drawio
|
||
* @param drawioDestDir
|
||
*/
|
||
async function downloadAndExtractDrawio(drawioDestDir) {
|
||
const tempDir = path.resolve(__dirname, ".temp");
|
||
const tarFilePath = path.join(tempDir, "drawio-latest.tar.gz");
|
||
|
||
try {
|
||
// 创建临时目录
|
||
if (!fs.existsSync(tempDir)) {
|
||
fs.mkdirSync(tempDir, { recursive: true });
|
||
}
|
||
|
||
const spinner = ora('下载最新的Drawio文件...').start();
|
||
|
||
// 1. 下载tar.gz文件
|
||
const response = await axios({
|
||
url: 'https://appstore.dootask.com/api/v1/download/drawio/latest',
|
||
method: 'GET',
|
||
responseType: 'stream'
|
||
});
|
||
|
||
const writer = fs.createWriteStream(tarFilePath);
|
||
response.data.pipe(writer);
|
||
|
||
await new Promise((resolve, reject) => {
|
||
writer.on('finish', resolve);
|
||
writer.on('error', reject);
|
||
});
|
||
|
||
spinner.text = '解压Drawio文件...';
|
||
|
||
// 2. 解压tar.gz文件到临时目录
|
||
const extractDir = path.join(tempDir, "extracted");
|
||
if (fs.existsSync(extractDir)) {
|
||
fse.removeSync(extractDir);
|
||
}
|
||
fs.mkdirSync(extractDir, { recursive: true });
|
||
|
||
await tar.x({
|
||
file: tarFilePath,
|
||
cwd: extractDir
|
||
});
|
||
|
||
// 3. 查找符合版本号格式的文件夹
|
||
const files = fs.readdirSync(extractDir);
|
||
const versionRegex = /^v?\d+(\.\d+){1,2}$/;
|
||
const versionDir = files.find(file => {
|
||
const filePath = path.join(extractDir, file);
|
||
return fs.lstatSync(filePath).isDirectory() && versionRegex.test(file);
|
||
});
|
||
|
||
if (!versionDir) {
|
||
throw new Error('未找到符合版本号格式的文件夹');
|
||
}
|
||
|
||
// 4. 查找webapp文件夹
|
||
const versionPath = path.join(extractDir, versionDir);
|
||
const webappPath = path.join(versionPath, 'webapp');
|
||
|
||
if (!fs.existsSync(webappPath)) {
|
||
throw new Error('未找到webapp文件夹');
|
||
}
|
||
|
||
// 5. 复制webapp文件夹内容到目标目录
|
||
fse.copySync(webappPath, drawioDestDir);
|
||
|
||
spinner.succeed('Drawio文件下载并解压完成');
|
||
|
||
// 清理临时文件
|
||
fse.removeSync(tempDir);
|
||
|
||
} catch (error) {
|
||
console.warn('下载Drawio失败,使用默认版本:', error.message);
|
||
// 清理临时文件
|
||
if (fs.existsSync(tempDir)) {
|
||
fse.removeSync(tempDir);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 克隆 Drawio
|
||
* @param systemInfo
|
||
*/
|
||
async function cloneDrawio(systemInfo) {
|
||
child_process.execSync("git submodule update --quiet --init --depth=1", {stdio: "inherit"});
|
||
const drawioSrcDir = path.resolve(__dirname, "../resources/drawio/src/main/webapp");
|
||
const drawioDestDir = path.resolve(electronDir, "drawio/webapp");
|
||
fse.copySync(drawioSrcDir, drawioDestDir)
|
||
await downloadAndExtractDrawio(drawioDestDir);
|
||
const preConfigFile = path.resolve(drawioDestDir, "js/PreConfig.js");
|
||
if (!fs.existsSync(preConfigFile)) {
|
||
console.error("克隆 Drawio 失败!");
|
||
process.exit()
|
||
}
|
||
let preConfigString = fs.readFileSync(preConfigFile, 'utf8');
|
||
preConfigString += "\nwindow.systemInfo = " + JSON.stringify(systemInfo) + ";\n";
|
||
preConfigString += fs.readFileSync(path.resolve(__dirname, "drawio.js"), 'utf8');
|
||
fs.writeFileSync(preConfigFile, preConfigString, 'utf8');
|
||
}
|
||
|
||
/**
|
||
* 获取更新日志
|
||
* @returns {string}
|
||
*/
|
||
function changeLog() {
|
||
let filePath = path.resolve(__dirname, "../CHANGELOG.md");
|
||
if (!fs.existsSync(filePath)) {
|
||
return "";
|
||
}
|
||
let content = fs.readFileSync(filePath, 'utf8')
|
||
let array = content.match(/## \[([0-9]+.+)\]/g)
|
||
if (!array) {
|
||
return ""
|
||
}
|
||
let start = content.indexOf(array[0]);
|
||
if (array.length > 5) {
|
||
content = content.substring(start, content.indexOf(array[5]))
|
||
} else {
|
||
content = content.substring(start)
|
||
}
|
||
return content;
|
||
}
|
||
|
||
/**
|
||
* 上传单个文件到 R2 的 draft/<version>/ 目录(带进度/spinner)
|
||
*/
|
||
async function uploadDraftFile(client, localFile, version) {
|
||
const filename = path.basename(localFile);
|
||
const key = `draft/${version}/${filename}`;
|
||
const startTime = Date.now();
|
||
const spinner = ora(`Upload [0%] ${filename}`).start();
|
||
try {
|
||
await r2.uploadFile(client, localFile, key, (loaded, total) => {
|
||
const pct = Math.min(99, Math.round((loaded / total) * 100)) + '%';
|
||
spinner.text = `Upload [${pct}] ${filename}`;
|
||
});
|
||
} catch (error) {
|
||
spinner.fail(`Upload [fail] ${filename} (${elapsedSeconds(startTime)}): ${error.message || error}`);
|
||
throw error;
|
||
}
|
||
spinner.succeed(`Upload [100%] ${filename} (${elapsedSeconds(startTime)})`);
|
||
}
|
||
|
||
/**
|
||
* 生成配置、编译应用
|
||
* @param data
|
||
*/
|
||
async function startBuild(data) {
|
||
if ((BUILD_FRONTEND === 'build' || data.id === 'app-expo') && !buildChecked) {
|
||
buildChecked = true
|
||
// Expo 每次重新编译,避免把上次桌面端或旧版本的文件带进内测包。
|
||
if (data.id === 'app-expo') {
|
||
fse.emptyDirSync(electronDir);
|
||
}
|
||
fs.mkdirSync(electronDir, { recursive: true });
|
||
fse.copySync(path.resolve(__dirname, "index.html"), path.resolve(electronDir, "index.html"))
|
||
child_process.execSync("npx vite build -- fromcmd electronBuild", {stdio: "inherit"});
|
||
}
|
||
//
|
||
const {platform, archs, publish, release, notarize} = data.configure
|
||
// system info
|
||
const systemInfo = {
|
||
title: data.name,
|
||
loginLogo: "",
|
||
debug: "no",
|
||
version: config.version,
|
||
origin: "./",
|
||
homeUrl: utils.formatUrl(data.url),
|
||
apiUrl: utils.formatUrl(data.url) + "api/",
|
||
}
|
||
// information
|
||
if (data.id === 'app-expo') {
|
||
console.log("\n=== 编译信息 ===");
|
||
console.log("名称:", data.name);
|
||
console.log("版本:", config.version + ` (${config.codeVerson})`);
|
||
console.log("===============\n");
|
||
} else {
|
||
console.log("\n=== 编译信息 ===");
|
||
console.log("名称:", data.name);
|
||
console.log("应用ID:", data.id);
|
||
console.log("版本:", config.version + ` (${config.codeVerson})`);
|
||
console.log("系统:", platform.replace('build-', '').toUpperCase());
|
||
console.log("架构:", archs.map(arch => arch.toUpperCase()).join(', '));
|
||
console.log("发布:", publish ? `是(${release ? '提示升级' : '静默升级'})` : '否');
|
||
if (platform === 'build-mac') {
|
||
console.log("公证:", notarize ? '是' : '否');
|
||
}
|
||
console.log("===============\n");
|
||
// drawio
|
||
await cloneDrawio(systemInfo)
|
||
// updater
|
||
if (!updaterChecked) {
|
||
updaterChecked = true
|
||
await detectAndDownloadUpdater()
|
||
}
|
||
}
|
||
// language
|
||
fse.copySync(path.resolve(__dirname, "../public/language"), path.resolve(electronDir, "language"))
|
||
// config.js
|
||
fs.writeFileSync(electronDir + "/config.js", "window.systemInfo = " + JSON.stringify(systemInfo), 'utf8');
|
||
fs.writeFileSync(electronDir + "/dark", '', 'utf8');
|
||
if (data.id !== 'app-expo') {
|
||
fs.writeFileSync(nativeCachePath, utils.formatUrl(data.url));
|
||
fs.writeFileSync(devloadCachePath, "", 'utf8');
|
||
}
|
||
// index.html
|
||
let manifestFile = path.resolve(electronDir, "manifest.json");
|
||
if (!fs.existsSync(manifestFile)) {
|
||
if (data.id === 'app-expo') {
|
||
throw new Error("manifest.json 未找到,Expo 前端构建失败");
|
||
}
|
||
console.error("manifest.json 未找到");
|
||
return;
|
||
}
|
||
let manifestContent = JSON.parse(fs.readFileSync(manifestFile, 'utf8'));
|
||
let indexFile = path.resolve(electronDir, "index.html");
|
||
let indexString = fs.readFileSync(indexFile, 'utf8');
|
||
indexString = indexString.replace(/<title>(.*?)<\/title>/g, `<title>${data.name}</title>`);
|
||
indexString = indexString.replace("<!--style-->", `<link rel="stylesheet" type="text/css" href="./${manifestContent['resources/assets/js/app.js']['css'][0]}">`);
|
||
indexString = indexString.replace("<!--script-->", `<script type="module" src="./${manifestContent['resources/assets/js/app.js']['file']}"></script>`);
|
||
fs.writeFileSync(indexFile, indexString, 'utf8');
|
||
//
|
||
if (data.id === 'app-expo') {
|
||
const appDir = path.resolve(__dirname, "../resources/app");
|
||
const webDir = path.join(appDir, "assets/web");
|
||
fse.emptyDirSync(webDir);
|
||
fse.copySync(electronDir, webDir);
|
||
fs.writeFileSync(path.join(appDir, "version.json"), JSON.stringify({
|
||
version: config.version,
|
||
codeVerson: config.codeVerson,
|
||
}, null, 2) + "\n", 'utf8');
|
||
console.log("Expo 前端资源已同步至 resources/app/assets/web/");
|
||
return;
|
||
}
|
||
const output = `dist/${data.id.replace(/\./g, '-')}/${platform}`
|
||
// package.json Backup
|
||
fse.copySync(packageFile, packageBakFile)
|
||
const recoveryPackage = (onlyRecovery) => {
|
||
fse.copySync(packageBakFile, packageFile)
|
||
if (onlyRecovery !== true) {
|
||
process.exit()
|
||
}
|
||
}
|
||
process.on('exit', recoveryPackage);
|
||
process.on('SIGINT', recoveryPackage);
|
||
process.on('SIGHUP', recoveryPackage);
|
||
// package.json Generated
|
||
const appConfig = require('./package.json')
|
||
let appName = utils.getDomain(data.url)
|
||
if (appName === "public") appName = "DooTask"
|
||
appConfig.name = data.name;
|
||
appConfig.version = config.version;
|
||
appConfig.appId = data.id;
|
||
appConfig.build.appId = data.id;
|
||
appConfig.build.artifactName = appName + "-v${version}-${os}-${arch}.${ext}";
|
||
appConfig.build.nsis.artifactName = appName + "-v${version}-${os}-${arch}.${ext}";
|
||
// changelog
|
||
appConfig.build.releaseInfo.releaseNotes = changeLog()
|
||
if (!release) {
|
||
appConfig.build.releaseInfo.releaseNotes = appConfig.build.releaseInfo.releaseNotes.replace(`## [${config.version}]`, `## [${config.version}-Silence]`)
|
||
}
|
||
// notarize
|
||
if (notarize && APPLEID && APPLEIDPASS) {
|
||
appConfig.build.afterSign = "./notarize.js"
|
||
}
|
||
// archs
|
||
if (archs.length > 0) {
|
||
appConfig.build.mac.target = appConfig.build.mac.target.map(target => {
|
||
if (target.arch) target.arch = target.arch.filter(arch => archs.includes(arch))
|
||
return target
|
||
})
|
||
appConfig.build.win.target = appConfig.build.win.target.map(target => {
|
||
if (target.arch) target.arch = target.arch.filter(arch => archs.includes(arch))
|
||
return target
|
||
})
|
||
}
|
||
// GitHub (build and publish)
|
||
if (publish === true && GITHUB_TOKEN && utils.strExists(GITHUB_REPOSITORY, "/")) {
|
||
const repository = GITHUB_REPOSITORY.split("/")
|
||
appConfig.build.publish = {
|
||
"releaseType": "release",
|
||
"provider": "github",
|
||
"owner": repository[0],
|
||
"repo": repository[1]
|
||
}
|
||
appConfig.build.directories.output = `${output}-github`;
|
||
fs.writeFileSync(packageFile, JSON.stringify(appConfig, null, 4), 'utf8');
|
||
child_process.execSync(`npm run ${platform}-publish`, {stdio: "inherit", cwd: "electron"});
|
||
}
|
||
// generic (build or publish) —— 有 R2_PUBLIC_URL 时自动更新源指向 R2 release/
|
||
appConfig.build.publish = r2.R2_PUBLIC_URL
|
||
? { provider: 'generic', url: `${r2.R2_PUBLIC_URL.replace(/\/+$/, '')}/release` }
|
||
: data.publish
|
||
appConfig.build.directories.output = `${output}-generic`;
|
||
fs.writeFileSync(packageFile, JSON.stringify(appConfig, null, 4), 'utf8');
|
||
child_process.execSync(`npm run ${platform}`, {stdio: "inherit", cwd: "electron"});
|
||
if (publish === true && r2.r2Configured()) {
|
||
const client = r2.createR2Client()
|
||
const outputDir = path.resolve(__dirname, appConfig.build.directories.output)
|
||
if (fs.existsSync(outputDir)) {
|
||
const files = fs.readdirSync(outputDir)
|
||
for (const filename of files) {
|
||
const localFile = path.join(outputDir, filename)
|
||
if (!fs.statSync(localFile).isFile()) continue
|
||
await uploadDraftFile(client, localFile, config.version)
|
||
}
|
||
}
|
||
}
|
||
// package.json Recovery
|
||
recoveryPackage(true)
|
||
}
|
||
|
||
/** ************************************************************************************/
|
||
/** ************************************************************************************/
|
||
/** ************************************************************************************/
|
||
|
||
if (["dev"].includes(argv[2])) {
|
||
// 开发模式
|
||
fs.writeFileSync(devloadCachePath, utils.formatUrl("localhost:" + env.parsed.APP_PORT), 'utf8');
|
||
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] && !['build', 'dev'].includes(argv[3])) || argv.length > 4) {
|
||
console.error("用法: node ./electron/build.js app-expo [build|dev]");
|
||
process.exit(1);
|
||
}
|
||
const appDir = path.resolve(__dirname, "../resources/app");
|
||
if (!fs.existsSync(path.resolve(appDir, "app.config.ts"))) {
|
||
console.error("resources/app 未找到,请先初始化 Expo 子模块");
|
||
process.exit(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 () => {
|
||
if (!r2.r2Configured()) {
|
||
console.error("缺少 R2_* 环境变量(R2_ACCESS_KEY_ID/R2_SECRET_ACCESS_KEY/R2_ENDPOINT/R2_BUCKET)")
|
||
process.exit(1)
|
||
}
|
||
const client = r2.createR2Client()
|
||
const releaseDir = path.resolve(argv[3] || path.resolve(__dirname, "../resources/app/android/app/build/outputs/apk/release"));
|
||
if (!fs.existsSync(releaseDir)) {
|
||
console.error("发布文件未找到")
|
||
process.exit(1)
|
||
}
|
||
const files = fs.readdirSync(releaseDir)
|
||
for (const filename of files) {
|
||
const localFile = path.join(releaseDir, filename)
|
||
if (/\.apk$/.test(filename) && fs.existsSync(localFile) && fs.statSync(localFile).isFile()) {
|
||
await uploadDraftFile(client, localFile, config.version)
|
||
}
|
||
}
|
||
})().catch(err => {
|
||
console.error(err.message || err)
|
||
process.exit(1)
|
||
})
|
||
} else if (["release"].includes(argv[2])) {
|
||
// R2 内提升:draft/<version> → release/(当前版扁平,旧版归档 release/<prev>/)
|
||
(async () => {
|
||
if (!r2.r2Configured()) {
|
||
console.error("缺少 R2_* 环境变量")
|
||
process.exit(1)
|
||
}
|
||
const client = r2.createR2Client()
|
||
const version = config.version
|
||
const draftPrefix = `draft/${version}/`
|
||
const draftKeys = await r2.listKeys(client, draftPrefix)
|
||
if (!draftKeys.length) {
|
||
console.error(`draft/${version}/ 为空,无法发布`)
|
||
process.exit(1)
|
||
}
|
||
const names = draftKeys.map(k => k.slice(draftPrefix.length))
|
||
|
||
// 读 manifest 取上一发布版
|
||
const manifest = JSON.parse(await r2.getText(client, 'manifest.json') || '{"draft":null,"release":null}')
|
||
const prev = manifest.release
|
||
|
||
// 1. 归档上一版扁平文件 → release/<prev>/
|
||
if (prev && prev !== version) {
|
||
const prevRootKeys = await r2.listKeys(client, 'release/', '/')
|
||
for (const key of prevRootKeys) {
|
||
const name = key.slice('release/'.length)
|
||
await r2.copyObject(client, key, `release/${prev}/${name}`)
|
||
}
|
||
}
|
||
|
||
// 2. 清空扁平根层(仅根层对象,版本归档子目录不动)
|
||
const rootKeys = await r2.listKeys(client, 'release/', '/')
|
||
await r2.deleteKeys(client, rootKeys)
|
||
|
||
// 3. 铺新扁平:安装包/blockmap/zip 先,latest*.yml 最后
|
||
const ymls = names.filter(n => /\.ya?ml$/i.test(n))
|
||
const others = names.filter(n => !/\.ya?ml$/i.test(n))
|
||
for (const name of others) await r2.copyObject(client, `${draftPrefix}${name}`, `release/${name}`)
|
||
for (const name of ymls) await r2.copyObject(client, `${draftPrefix}${name}`, `release/${name}`)
|
||
|
||
// 4. 下载索引
|
||
const index = buildReleaseIndex(names)
|
||
await r2.putText(client, 'release/index.json', JSON.stringify({ version, files: index }, null, 2))
|
||
|
||
// 5. 更新 manifest,清理 draft
|
||
await r2.putText(client, 'manifest.json', JSON.stringify({ draft: null, release: version }, null, 2))
|
||
await r2.deleteKeys(client, draftKeys)
|
||
|
||
console.log(`Release published: v${version}`)
|
||
})().catch(err => {
|
||
console.error(err.message || err)
|
||
process.exit(1)
|
||
})
|
||
} else if (["upload-changelog"].includes(argv[2])) {
|
||
// 上传 changelog 到 R2(GitHub Actions)
|
||
(async () => {
|
||
if (!r2.r2Configured()) {
|
||
console.error("缺少 R2_* 环境变量")
|
||
process.exit(1)
|
||
}
|
||
const changelogPath = path.resolve(__dirname, "../CHANGELOG.md")
|
||
if (!fs.existsSync(changelogPath)) {
|
||
console.error("CHANGELOG.md 未找到")
|
||
process.exit(1)
|
||
}
|
||
const client = r2.createR2Client()
|
||
const content = fs.readFileSync(changelogPath, 'utf8')
|
||
await r2.putText(client, 'changelog.md', content)
|
||
console.log('Changelog uploaded')
|
||
})().catch(err => {
|
||
console.error(err.message || err)
|
||
process.exit(1)
|
||
})
|
||
} else if (["all", "win", "mac"].includes(argv[2])) {
|
||
// 自动编译(GitHub Actions)
|
||
platforms.filter(p => {
|
||
return argv[2] === "all" || p.indexOf(argv[2]) !== -1
|
||
}).forEach(async platform => {
|
||
for (const data of config.app) {
|
||
data.configure = {
|
||
platform,
|
||
archs: architectures,
|
||
publish: true,
|
||
release: true,
|
||
notarize: false,
|
||
};
|
||
await startBuild(data);
|
||
}
|
||
});
|
||
} else {
|
||
// 手编译(默认)
|
||
let cachedConfig = {};
|
||
try {
|
||
const buildConfigPath = path.join(__dirname, '.build');
|
||
if (fs.existsSync(buildConfigPath)) {
|
||
const configContent = fs.readFileSync(buildConfigPath, 'utf-8');
|
||
if (configContent.trim()) {
|
||
cachedConfig = JSON.parse(configContent);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.warn('读取缓存配置失败:', error.message);
|
||
}
|
||
|
||
const questions = [
|
||
{
|
||
type: 'checkbox',
|
||
name: 'platform',
|
||
message: "选择编译系统",
|
||
choices: [
|
||
{
|
||
name: "MacOS",
|
||
value: platforms[0],
|
||
},
|
||
{
|
||
name: "Windows",
|
||
value: platforms[1]
|
||
}
|
||
],
|
||
default: (cachedConfig && cachedConfig.platform) || [],
|
||
validate: (answer) => {
|
||
if (answer.length < 1) {
|
||
return '请至少选择一个系统';
|
||
}
|
||
return true;
|
||
}
|
||
},
|
||
{
|
||
type: 'checkbox',
|
||
name: 'arch',
|
||
message: "选择系统架构",
|
||
choices: ({platform}) => {
|
||
const array = [
|
||
{
|
||
name: "arm64",
|
||
value: architectures[0],
|
||
},
|
||
{
|
||
name: "x64",
|
||
value: architectures[1]
|
||
}
|
||
]
|
||
if (platform.find(item => item === 'build-mac')) {
|
||
array.push({
|
||
name: "通用" + (platform.length > 1 ? " (仅MacOS)" : ""),
|
||
value: 'universal'
|
||
})
|
||
}
|
||
return array;
|
||
},
|
||
default: (cachedConfig && cachedConfig.arch) || [],
|
||
validate: (answer) => {
|
||
if (answer.length < 1) {
|
||
return '请至少选择一个架构';
|
||
}
|
||
return true;
|
||
}
|
||
},
|
||
{
|
||
type: 'list',
|
||
name: 'publish',
|
||
message: "选择是否发布",
|
||
choices: [{
|
||
name: "否",
|
||
value: false
|
||
}, {
|
||
name: "是",
|
||
value: true
|
||
}],
|
||
default: (cachedConfig && cachedConfig.publish !== undefined) ?
|
||
(cachedConfig.publish ? 1 : 0) : 0
|
||
},
|
||
{
|
||
type: 'list',
|
||
name: 'release',
|
||
message: "选择升级方式",
|
||
when: ({publish}) => publish,
|
||
choices: [{
|
||
name: "弹出提示",
|
||
value: true
|
||
}, {
|
||
name: "静默",
|
||
value: false
|
||
}],
|
||
default: (cachedConfig && cachedConfig.release !== undefined) ?
|
||
(cachedConfig.release ? 0 : 1) : 0
|
||
},
|
||
{
|
||
type: 'list',
|
||
name: 'notarize',
|
||
message: ({platform}) => platform.length > 1 ? "选择是否公证(仅MacOS)" : "选择是否公证",
|
||
when: ({platform}) => platform.find(item => item === 'build-mac'),
|
||
choices: [{
|
||
name: "否",
|
||
value: false
|
||
}, {
|
||
name: "是",
|
||
value: true
|
||
}],
|
||
default: (cachedConfig && cachedConfig.notarize !== undefined) ?
|
||
(cachedConfig.notarize ? 1 : 0) : 0
|
||
}
|
||
];
|
||
|
||
// 开始提问
|
||
const prompt = inquirer.createPromptModule();
|
||
prompt(questions)
|
||
.then(async answers => {
|
||
answers = Object.assign({
|
||
release: false,
|
||
notarize: false
|
||
}, answers);
|
||
|
||
// 缓存当前配置
|
||
try {
|
||
fs.writeFileSync(path.join(__dirname, '.build'), JSON.stringify(answers, null, 4), 'utf-8');
|
||
} catch (error) {
|
||
console.warn('保存配置缓存失败:', error.message);
|
||
}
|
||
|
||
// 发布判断环境变量
|
||
if (answers.publish) {
|
||
if (!r2.r2Configured() && !(GITHUB_TOKEN && utils.strExists(GITHUB_REPOSITORY, "/"))) {
|
||
console.error("发布需要 R2_* 或 GITHUB_TOKEN + GITHUB_REPOSITORY, 请检查环境变量!");
|
||
process.exit()
|
||
}
|
||
}
|
||
|
||
// 公证判断环境变量
|
||
if (answers.notarize === true) {
|
||
if (!APPLEID || !APPLEIDPASS) {
|
||
console.error("公证需要 Apple ID 和 Apple ID 密码, 请检查环境变量!");
|
||
process.exit()
|
||
}
|
||
}
|
||
|
||
// 开始构建
|
||
for (const platform of answers.platform) {
|
||
for (const data of config.app) {
|
||
data.configure = {
|
||
platform,
|
||
archs: answers.arch,
|
||
publish: answers.publish,
|
||
release: answers.release,
|
||
notarize: answers.notarize
|
||
};
|
||
await startBuild(data);
|
||
}
|
||
}
|
||
})
|
||
.catch(_ => { });
|
||
}
|