Initial clean project import
This commit is contained in:
@@ -0,0 +1,279 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json",
|
||||
"appId": "IP",
|
||||
"publish": {
|
||||
"provider": "generic",
|
||||
"url": "https://xiazaigengxin-1417293730.cos.ap-guangzhou.myqcloud.com/updates"
|
||||
},
|
||||
"asar": true,
|
||||
"asarUnpack": [
|
||||
"scripts/**/*"
|
||||
],
|
||||
"npmRebuild": true,
|
||||
"productName": "天英超级IP智能体",
|
||||
"directories": {
|
||||
"output": "dist-release-final",
|
||||
"buildResources": "electron/resources/build"
|
||||
},
|
||||
"afterPack": "./scripts/build_optimize.cjs",
|
||||
"files": [
|
||||
"dist",
|
||||
"dist-electron",
|
||||
{
|
||||
"from": "demo",
|
||||
"to": "demo",
|
||||
"filter": [
|
||||
"*.md",
|
||||
"*.html"
|
||||
]
|
||||
},
|
||||
"scripts",
|
||||
"!node_modules/date-fns{,/**/*}",
|
||||
"!node_modules/fluent-ffmpeg{,/**/*}",
|
||||
"!node_modules/ttf2woff2{,/**/*}",
|
||||
"!node_modules/vue-timeago3{,/**/*}",
|
||||
"!node_modules/pdfkit{,/**/*}",
|
||||
"!node_modules/fontkit{,/**/*}",
|
||||
"!node_modules/@resvg{,/**/*}",
|
||||
"!node_modules/moment{,/**/*}",
|
||||
"!node_modules/tldts{,/**/*}",
|
||||
"!node_modules/polished{,/**/*}",
|
||||
"!node_modules/caniuse-lite{,/**/*}",
|
||||
"!node_modules/csso{,/**/*}",
|
||||
"!node_modules/cheerio{,/**/*}",
|
||||
"!node_modules/svgo{,/**/*}",
|
||||
"!node_modules/@jimp{,/**/*}",
|
||||
"!node_modules/jimp{,/**/*}",
|
||||
"!node_modules/lit{,/**/*}",
|
||||
"!node_modules/emoji-mart{,/**/*}",
|
||||
"!node_modules/@mswjs{,/**/*}",
|
||||
"!node_modules/graphql{,/**/*}",
|
||||
"!node_modules/mermaid{,/**/*}",
|
||||
"!node_modules/@lobehub{,/**/*}",
|
||||
"!node_modules/@emoji-mart{,/**/*}",
|
||||
"!node_modules/@arco-design{,/**/*}",
|
||||
"!node_modules/lucide-react{,/**/*}",
|
||||
"!node_modules/typescript{,/**/*}",
|
||||
"!node_modules/@shikijs{,/**/*}",
|
||||
"!node_modules/@vue{,/**/*}",
|
||||
"!node_modules/@babel{,/**/*}",
|
||||
"!node_modules/@mermaid-js{,/**/*}",
|
||||
"!node_modules/@splinetool{,/**/*}",
|
||||
"!node_modules/concurrently{,/**/*}",
|
||||
"!node_modules/tailwindcss{,/**/*}",
|
||||
"!node_modules/msw{,/**/*}",
|
||||
"!node_modules/cytoscape{,/**/*}",
|
||||
"!node_modules/cytoscape-fcose{,/**/*}",
|
||||
"!node_modules/framer-motion{,/**/*}",
|
||||
"!node_modules/katex{,/**/*}",
|
||||
"!node_modules/rxjs{,/**/*}",
|
||||
"!node_modules/langium{,/**/*}",
|
||||
"!node_modules/openai{,/**/*}",
|
||||
"!node_modules/@emotion{,/**/*}",
|
||||
"!node_modules/d3{,/**/*}",
|
||||
"!node_modules/vue{,/**/*}",
|
||||
"!node_modules/vue-router{,/**/*}",
|
||||
"!node_modules/vue-i18n{,/**/*}",
|
||||
"!node_modules/pinia{,/**/*}",
|
||||
"!node_modules/xgplayer{,/**/*}",
|
||||
"!node_modules/wavesurfer.js{,/**/*}",
|
||||
"!node_modules/showdown{,/**/*}",
|
||||
"!node_modules/dompurify{,/**/*}",
|
||||
"!node_modules/file-saver{,/**/*}",
|
||||
"!node_modules/qrcode{,/**/*}",
|
||||
"!node_modules/canvg{,/**/*}",
|
||||
"!node_modules/svg2img{,/**/*}",
|
||||
"!node_modules/svgtofont{,/**/*}",
|
||||
"!node_modules/mddir{,/**/*}",
|
||||
"!node_modules/@types{,/**/*}",
|
||||
"!node_modules/vite{,/**/*}",
|
||||
"!node_modules/vite-plugin-electron{,/**/*}",
|
||||
"!node_modules/vite-plugin-electron-renderer{,/**/*}",
|
||||
"!node_modules/@vitejs{,/**/*}",
|
||||
"!node_modules/vue-tsc{,/**/*}",
|
||||
"!node_modules/less{,/**/*}",
|
||||
"!node_modules/postcss{,/**/*}",
|
||||
"!node_modules/autoprefixer{,/**/*}",
|
||||
"!node_modules/sass-embedded{,/**/*}",
|
||||
"!node_modules/sass-embedded-win32-x64{,/**/*}",
|
||||
"!node_modules/electron-builder{,/**/*}",
|
||||
"!node_modules/app-builder-bin{,/**/*}",
|
||||
"!node_modules/7zip-bin{,/**/*}",
|
||||
"!node_modules/wait-on{,/**/*}"
|
||||
],
|
||||
"win": {
|
||||
"icon": "electron/resources/build/logo.ico",
|
||||
"target": [
|
||||
{
|
||||
"target": "dir",
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"artifactName": "${productName}-${version}-win-${arch}.${ext}",
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "electron/resources/extra",
|
||||
"to": "extra",
|
||||
"filter": [
|
||||
"common/default-config.json",
|
||||
"common/resource-manifest.json",
|
||||
"common/shuiziren/**/*",
|
||||
"common/soundEffects/**/*",
|
||||
"common/config/default-templates.json",
|
||||
"common/config/default-subtitle-templates.json",
|
||||
"common/config/preset_templates.json",
|
||||
"common/config/default-ipagent-config.json",
|
||||
"common/config/system-templates.json",
|
||||
"common/python-scripts/**/*",
|
||||
"common/soundEffects/**/*",
|
||||
"stickers/**/*",
|
||||
"common/cover-templates/huangbai-shishi.jpg",
|
||||
"common/cover-templates/landi-baizi.jpg",
|
||||
"common/cover-templates/dasi-baobao.jpg",
|
||||
"common/cover-templates/xiehanghuangbai.jpg",
|
||||
"common/cover-templates/fenghong-xubai.jpg",
|
||||
"common/cover-templates/renyixujzhong.jpg",
|
||||
"common/cover-templates/qinglan-xushi.png",
|
||||
"common/cover-templates/honghang-xushi.png",
|
||||
"common/cover-templates/baihang-xushi.png",
|
||||
"common/cover-templates/subtitle-preview-template_system_11.png",
|
||||
"common/cover-templates/subtitle-preview-template_system_classic_11.png",
|
||||
"common/cover-templates/subtitle-preview-template_system_22.png",
|
||||
"common/cover-templates/subtitle-preview-template_system_classic_22.png",
|
||||
"common/cover-templates/subtitle-preview-template_system_33.png",
|
||||
"common/cover-templates/subtitle-preview-template_system_classic_33.png",
|
||||
"common/cover-templates/subtitle-preview-template_system_44.png",
|
||||
"common/cover-templates/subtitle-preview-template_system_classic_44.png",
|
||||
"common/cover-templates/subtitle-preview-template_system_55.png",
|
||||
"common/cover-templates/subtitle-preview-template_system_classic_55.png",
|
||||
"common/cover-templates/subtitle-preview-template_system_66.png",
|
||||
"common/cover-templates/subtitle-preview-template_system_classic_66.png",
|
||||
"common/cover-templates/subtitle-preview-template_system_77.png",
|
||||
"common/cover-templates/subtitle-preview-template_system_classic_77.png",
|
||||
"common/cover-templates/subtitle-preview-template_system_88.png",
|
||||
"common/cover-templates/subtitle-preview-template_system_classic_88.png"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "electron/resources/voice-preview-cache",
|
||||
"to": "voice-preview-cache"
|
||||
},
|
||||
{
|
||||
"from": "electron/resources/build",
|
||||
"to": "build",
|
||||
"filter": [
|
||||
"tray.png",
|
||||
"tray.ico",
|
||||
"logo.png",
|
||||
"logo.ico"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"nsis": {
|
||||
"artifactName": "${productName}-${version}-win-setup-${arch}.${ext}",
|
||||
"shortcutName": "${productName}",
|
||||
"uninstallDisplayName": "${productName}",
|
||||
"oneClick": false,
|
||||
"perMachine": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"deleteAppDataOnUninstall": false,
|
||||
"differentialPackage": false,
|
||||
"include": "scripts/installer.nsh",
|
||||
"warningsAsErrors": false,
|
||||
"runAfterFinish": true,
|
||||
"allowElevation": true,
|
||||
"installerIcon": "electron/resources/build/logo.ico",
|
||||
"uninstallerIcon": "electron/resources/build/logo.ico",
|
||||
"installerHeaderIcon": "electron/resources/build/logo.ico"
|
||||
},
|
||||
"appx": {
|
||||
"identityName": "IP",
|
||||
"publisher": "CN=XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXX",
|
||||
"publisherDisplayName": "天英超级IP智能体",
|
||||
"languages": [
|
||||
"zh-CN",
|
||||
"en-US",
|
||||
"zh-TW"
|
||||
]
|
||||
},
|
||||
"mac": {
|
||||
"icon": "logo.icns",
|
||||
"target": [
|
||||
{
|
||||
"target": "dir",
|
||||
"arch": [
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"artifactName": "${productName}-${version}-mac-${arch}.${ext}",
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "electron/resources/extra",
|
||||
"to": "extra",
|
||||
"filter": [
|
||||
"common",
|
||||
"osx"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "electron/resources/voice-preview-cache",
|
||||
"to": "voice-preview-cache"
|
||||
}
|
||||
],
|
||||
"x64ArchFiles": "Contents/Resources/extra/**/*",
|
||||
"entitlementsInherit": "./entitlements.mac.plist",
|
||||
"entitlements": "./entitlements.mac.plist",
|
||||
"extendInfo": {
|
||||
"NSDocumentsFolderUsageDescription": "Application requests access to the user's Documents folder.",
|
||||
"NSDownloadsFolderUsageDescription": "Application requests access to the user's Downloads folder."
|
||||
},
|
||||
"type": "development",
|
||||
"notarize": false,
|
||||
"darkModeSupport": false,
|
||||
"hardenedRuntime": true,
|
||||
"identity": "Xi'an Yanyi Information Technology Co., Ltd (Q96H3H33RK)"
|
||||
},
|
||||
"linux": {
|
||||
"icon": "logo.icns",
|
||||
"desktop": "logo",
|
||||
"maintainer": "天英超级IP智能体",
|
||||
"category": "Utility",
|
||||
"target": [
|
||||
{
|
||||
"target": "AppImage",
|
||||
"arch": [
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
},
|
||||
{
|
||||
"target": "deb",
|
||||
"arch": [
|
||||
"x64",
|
||||
"arm64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"artifactName": "${productName}-${version}-linux-${arch}.${ext}",
|
||||
"extraResources": [
|
||||
{
|
||||
"from": "electron/resources/extra",
|
||||
"to": "extra",
|
||||
"filter": [
|
||||
"common",
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
{
|
||||
"from": "electron/resources/voice-preview-cache",
|
||||
"to": "voice-preview-cache"
|
||||
}
|
||||
]
|
||||
},
|
||||
"afterSign": "./scripts/notarize.cjs"
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>OEM 批量打包工具</title>
|
||||
<link rel="stylesheet" href="./style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="page">
|
||||
<header class="toolbar">
|
||||
<div>
|
||||
<h1>OEM 批量打包工具</h1>
|
||||
<p>每个卡片独立配置软件名称、请求地址、版本号和 4 张图。点击图片格选择,也可以直接把图片拖到对应格子。</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button id="addJobBtn" type="button">新增贴牌</button>
|
||||
<button id="openOutputBtn" type="button">打开输出目录</button>
|
||||
<button id="cancelBtn" class="danger" type="button" disabled>停止当前打包</button>
|
||||
<button id="runBtn" class="primary" type="button">一键 OEM + 打包</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section id="jobGrid" class="grid"></section>
|
||||
|
||||
<section class="log-panel">
|
||||
<div class="log-head">
|
||||
<strong>实时日志</strong>
|
||||
<span id="outputDir"></span>
|
||||
</div>
|
||||
<pre id="logBox"></pre>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<template id="jobTemplate">
|
||||
<article class="card">
|
||||
<div class="card-head">
|
||||
<label class="check"><input data-field="enabled" type="checkbox" checked /> <span class="index"></span></label>
|
||||
<span class="status">idle</span>
|
||||
</div>
|
||||
|
||||
<label>软件名称</label>
|
||||
<input data-field="brandName" placeholder="例如 焰音AI口播智能体" />
|
||||
|
||||
<div class="two">
|
||||
<div>
|
||||
<label>短名 / AppId</label>
|
||||
<input data-field="brandShortName" placeholder="WenGeSuperIP" />
|
||||
</div>
|
||||
<div>
|
||||
<label>版本号</label>
|
||||
<input data-field="version" placeholder="4.5.34" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label>显示名称</label>
|
||||
<input data-field="brandDisplayName" placeholder="默认同软件名称" />
|
||||
|
||||
<label>请求地址</label>
|
||||
<input data-field="apiBaseUrl" placeholder="http://host:port/api" />
|
||||
|
||||
<label>标语</label>
|
||||
<input data-field="brandSlogan" placeholder="一站式AI数字人系统" />
|
||||
|
||||
<div class="assets">
|
||||
<button data-asset="qrcodePath" class="asset" type="button"><img alt="" /><b>二维码</b><small>点击或拖入 qrcode.png</small></button>
|
||||
<button data-asset="logoPath" class="asset" type="button"><img alt="" /><b>Logo</b><small>点击或拖入 logo.png</small></button>
|
||||
<button data-asset="iconPath" class="asset" type="button"><img alt="" /><b>图标</b><small>点击或拖入 icon.png</small></button>
|
||||
<button data-asset="loginBgPath" class="asset" type="button"><img alt="" /><b>登录背景</b><small>点击或拖入 login-bg.png</small></button>
|
||||
</div>
|
||||
|
||||
<label>打包命令</label>
|
||||
<input data-field="buildCommand" placeholder="npm run build:win" />
|
||||
|
||||
<div class="card-actions">
|
||||
<button data-action="copy" type="button">复制</button>
|
||||
<button data-action="delete" type="button">删除</button>
|
||||
</div>
|
||||
<div class="message"></div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<script src="./renderer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,209 @@
|
||||
param(
|
||||
[string]$ProjectRoot = ""
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "SilentlyContinue"
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($ProjectRoot)) {
|
||||
$ProjectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path
|
||||
} else {
|
||||
$ProjectRoot = (Resolve-Path $ProjectRoot).Path
|
||||
}
|
||||
|
||||
$toolDir = Join-Path $ProjectRoot "tools\oem-batch-builder"
|
||||
$activeRunPath = Join-Path $toolDir "active-run.json"
|
||||
$runsDir = Join-Path $toolDir "runs"
|
||||
$currentPid = $PID
|
||||
$killed = New-Object System.Collections.Generic.List[int]
|
||||
|
||||
function Write-CleanupLog([string]$Message) {
|
||||
$stamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
|
||||
Write-Output "[$stamp] $Message"
|
||||
}
|
||||
|
||||
function Write-JsonNoBom($Path, $Value) {
|
||||
$json = $Value | ConvertTo-Json -Depth 10
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
[System.IO.File]::WriteAllText($Path, $json, $utf8NoBom)
|
||||
}
|
||||
|
||||
function Get-ProcByPid([int]$ProcId) {
|
||||
Get-CimInstance Win32_Process -Filter "ProcessId=$ProcId"
|
||||
}
|
||||
|
||||
function Test-IsProjectBuildProcess($Proc) {
|
||||
if (-not $Proc) { return $false }
|
||||
if ([int]$Proc.ProcessId -eq [int]$currentPid) { return $false }
|
||||
|
||||
$name = [string]$Proc.Name
|
||||
$cmd = [string]$Proc.CommandLine
|
||||
$exe = [string]$Proc.ExecutablePath
|
||||
$text = "$cmd`n$exe"
|
||||
$lower = $text.ToLowerInvariant()
|
||||
$rootLower = $ProjectRoot.ToLowerInvariant()
|
||||
|
||||
if ($name -ieq "OEMBatchBuilderTool.exe") { return $false }
|
||||
|
||||
if ($lower.Contains("tools\oem-batch-builder\worker.cjs")) { return $true }
|
||||
if ($lower.Contains("tools\oem-batch-builder\main.cjs")) { return $true }
|
||||
if ($lower.Contains("electron-builder") -and $lower.Contains("--win") -and $lower.Contains("--x64")) { return $true }
|
||||
if ($lower.Contains("npm.cmd run build:win") -or $lower.Contains("npm run build:win")) { return $true }
|
||||
|
||||
if (-not $lower.Contains($rootLower)) {
|
||||
return $false
|
||||
}
|
||||
|
||||
if ($lower.Contains("\dist-release-final\")) { return $true }
|
||||
if ($lower.Contains("\build\oem-batch-temp\")) { return $true }
|
||||
if ($lower.Contains("dist-release-final\win-unpacked")) { return $true }
|
||||
if ($lower.Contains("node_modules\.bin\electron-builder")) { return $true }
|
||||
if ($lower.Contains("electron-builder.cmd")) { return $true }
|
||||
if ($lower.Contains("electron-builder --win")) { return $true }
|
||||
if ($lower.Contains("npm.cmd run build:win")) { return $true }
|
||||
if ($lower.Contains("npm run build:win")) { return $true }
|
||||
if ($lower.Contains("npm.cmd run prepare-package")) { return $true }
|
||||
if ($lower.Contains("npm run prepare-package")) { return $true }
|
||||
if ($lower.Contains("npm.cmd run build")) { return $true }
|
||||
if ($lower.Contains("npm run build")) { return $true }
|
||||
if ($lower.Contains("vite build")) { return $true }
|
||||
if ($lower.Contains("apply-oem.cjs")) { return $true }
|
||||
if ($lower.Contains("scripts\prepare-package.cjs")) { return $true }
|
||||
if ($lower.Contains("scripts\build-resource-artifacts.cjs")) { return $true }
|
||||
if ($lower.Contains("scripts\fix-latest-yml.cjs")) { return $true }
|
||||
if ($lower.Contains("scripts\assemble-release-artifacts.cjs")) { return $true }
|
||||
if ($lower.Contains("scripts\clean-build-artifacts.cjs")) { return $true }
|
||||
|
||||
return $false
|
||||
}
|
||||
|
||||
function Test-IsWorkerPidProcess($Proc) {
|
||||
if (-not $Proc) { return $false }
|
||||
$cmd = ([string]$Proc.CommandLine).ToLowerInvariant()
|
||||
return $cmd.Contains("tools\oem-batch-builder\worker.cjs")
|
||||
}
|
||||
|
||||
function Test-ToolInstanceIsAlive() {
|
||||
$instances = @(Get-CimInstance Win32_Process | Where-Object {
|
||||
if (-not $_) { return $false }
|
||||
if ([int]$_.ProcessId -eq [int]$currentPid) { return $false }
|
||||
if ([string]$_.Name -ine "OEMBatchBuilderTool.exe") { return $false }
|
||||
$cmd = ([string]$_.CommandLine).ToLowerInvariant()
|
||||
$cmd.Contains("tools\oem-batch-builder")
|
||||
})
|
||||
if ($instances.Count -gt 0) {
|
||||
$ids = ($instances | ForEach-Object { $_.ProcessId }) -join ","
|
||||
Write-CleanupLog "OEM batch builder is already running. Skip cleanup and let single-instance focus it. PIDs=$ids"
|
||||
return $true
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
function Test-ActiveWorkerIsAlive() {
|
||||
if (-not (Test-Path $activeRunPath)) { return $false }
|
||||
try {
|
||||
$active = Get-Content -Path $activeRunPath -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if (-not $active.running) { return $false }
|
||||
|
||||
$candidatePids = New-Object System.Collections.Generic.List[int]
|
||||
if ($active.pid) { $candidatePids.Add([int]$active.pid) | Out-Null }
|
||||
if ($active.sessionDir) {
|
||||
$workerPidPath = Join-Path ([string]$active.sessionDir) "worker.pid"
|
||||
if (Test-Path $workerPidPath) {
|
||||
$pidText = (Get-Content -Path $workerPidPath -Raw).Trim()
|
||||
if ($pidText -match "^\d+$") { $candidatePids.Add([int]$pidText) | Out-Null }
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($candidatePid in $candidatePids) {
|
||||
$proc = Get-ProcByPid $candidatePid
|
||||
if ($proc -and (Test-IsWorkerPidProcess $proc)) {
|
||||
Write-CleanupLog "Active OEM build is still running. Skip cleanup. PID=$candidatePid Session=$($active.sessionDir)"
|
||||
return $true
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Write-CleanupLog "Failed to inspect active-run.json: $($_.Exception.Message)"
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
function Stop-Tree([int]$ProcId, [string]$Reason, [bool]$WorkerPidOnly = $false) {
|
||||
if ($ProcId -le 0 -or $ProcId -eq $currentPid) { return }
|
||||
if ($killed.Contains($ProcId)) { return }
|
||||
|
||||
$proc = Get-ProcByPid $ProcId
|
||||
if (-not $proc) { return }
|
||||
if ($WorkerPidOnly -and -not (Test-IsWorkerPidProcess $proc)) {
|
||||
Write-CleanupLog "Skip PID $ProcId ($($proc.Name)): $Reason points to a non-worker process"
|
||||
return
|
||||
}
|
||||
if (-not (Test-IsProjectBuildProcess $proc)) { return }
|
||||
|
||||
Write-CleanupLog "Killing PID $ProcId ($($proc.Name)): $Reason"
|
||||
& taskkill.exe /F /T /PID $ProcId | Out-Null
|
||||
$killed.Add($ProcId) | Out-Null
|
||||
}
|
||||
|
||||
function Stop-ActiveRunState([string]$Message) {
|
||||
if (-not (Test-Path $activeRunPath)) { return }
|
||||
try {
|
||||
$state = Get-Content -Path $activeRunPath -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($state.running -or $killed.Count -gt 0) {
|
||||
$state | Add-Member -NotePropertyName "running" -NotePropertyValue $false -Force
|
||||
$state | Add-Member -NotePropertyName "stoppedAt" -NotePropertyValue ((Get-Date).ToUniversalTime().ToString("o")) -Force
|
||||
$state | Add-Member -NotePropertyName "message" -NotePropertyValue $Message -Force
|
||||
Write-JsonNoBom $activeRunPath $state
|
||||
Write-CleanupLog "Marked active-run stopped: $Message"
|
||||
}
|
||||
} catch {
|
||||
Write-CleanupLog "Failed to update active-run.json: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
Write-CleanupLog "Cleanup started. ProjectRoot=$ProjectRoot"
|
||||
|
||||
if (Test-ToolInstanceIsAlive) {
|
||||
Write-CleanupLog "Cleanup skipped because the tool process is alive."
|
||||
exit 0
|
||||
}
|
||||
|
||||
if (Test-ActiveWorkerIsAlive) {
|
||||
Write-CleanupLog "Cleanup skipped because an active worker is alive."
|
||||
exit 0
|
||||
}
|
||||
|
||||
if (Test-Path $activeRunPath) {
|
||||
try {
|
||||
$active = Get-Content -Path $activeRunPath -Raw -Encoding UTF8 | ConvertFrom-Json
|
||||
if ($active.pid) { Stop-Tree ([int]$active.pid) "active-run pid" $true }
|
||||
if ($active.launcherPid) { Stop-Tree ([int]$active.launcherPid) "active-run launcherPid" }
|
||||
if ($active.sessionDir) {
|
||||
$workerPidPath = Join-Path ([string]$active.sessionDir) "worker.pid"
|
||||
if (Test-Path $workerPidPath) {
|
||||
$pidText = (Get-Content -Path $workerPidPath -Raw).Trim()
|
||||
if ($pidText -match "^\d+$") { Stop-Tree ([int]$pidText) "active session worker.pid" $true }
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Write-CleanupLog "Failed to read active-run.json: $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
|
||||
Get-CimInstance Win32_Process | Where-Object { Test-IsProjectBuildProcess $_ } | ForEach-Object {
|
||||
Stop-Tree ([int]$_.ProcessId) "matched OEM/build command"
|
||||
}
|
||||
|
||||
Start-Sleep -Milliseconds 800
|
||||
|
||||
$remaining = @(Get-CimInstance Win32_Process | Where-Object { Test-IsProjectBuildProcess $_ })
|
||||
if ($remaining.Count -gt 0) {
|
||||
foreach ($proc in $remaining) {
|
||||
Write-CleanupLog "Still running PID $($proc.ProcessId) ($($proc.Name))"
|
||||
}
|
||||
} else {
|
||||
Write-CleanupLog "Cleanup finished. Killed=$($killed.Count)"
|
||||
}
|
||||
|
||||
if ($killed.Count -gt 0) {
|
||||
Stop-ActiveRunState "Stale OEM build processes were cleaned before launching the tool. Killed=$($killed.Count)"
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$ProjectRoot,
|
||||
[Parameter(Mandatory = $true)][string]$WorkerPath,
|
||||
[Parameter(Mandatory = $true)][string]$JobsFile,
|
||||
[Parameter(Mandatory = $true)][string]$SessionDir
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
if (-not (Test-Path -LiteralPath $SessionDir)) {
|
||||
New-Item -ItemType Directory -Path $SessionDir -Force | Out-Null
|
||||
}
|
||||
|
||||
$node = (Get-Command node.exe -ErrorAction Stop).Source
|
||||
$stdout = Join-Path $SessionDir "launcher-worker.stdout.log"
|
||||
$stderr = Join-Path $SessionDir "launcher-worker.stderr.log"
|
||||
$pidFile = Join-Path $SessionDir "worker.pid"
|
||||
|
||||
$process = Start-Process `
|
||||
-FilePath $node `
|
||||
-ArgumentList @($WorkerPath, $JobsFile, $SessionDir) `
|
||||
-WorkingDirectory $ProjectRoot `
|
||||
-WindowStyle Hidden `
|
||||
-RedirectStandardOutput $stdout `
|
||||
-RedirectStandardError $stderr `
|
||||
-PassThru
|
||||
|
||||
Set-Content -LiteralPath $pidFile -Value $process.Id -Encoding UTF8
|
||||
Write-Output $process.Id
|
||||
@@ -0,0 +1,741 @@
|
||||
const { app, BrowserWindow, dialog, ipcMain, shell } = require('electron');
|
||||
const { spawn, spawnSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const projectRoot = path.resolve(__dirname, '..', '..');
|
||||
const outputDir = path.join(projectRoot, 'dist-release-final');
|
||||
const toolLogPath = path.join(__dirname, 'oem-tool.log');
|
||||
const statePath = path.join(__dirname, 'active-run.json');
|
||||
const toolAppName = 'OEM Batch Builder Tool';
|
||||
|
||||
app.setName(toolAppName);
|
||||
app.setAppUserModelId('com.oem.batch-builder-tool');
|
||||
|
||||
let mainWindow = null;
|
||||
let running = false;
|
||||
let currentProcess = null;
|
||||
let latestResults = [];
|
||||
let activePollTimer = null;
|
||||
let activeLogOffset = 0;
|
||||
let activeSessionDir = '';
|
||||
let activeResultFile = '';
|
||||
let activeWorkerLogFile = '';
|
||||
let activeStageStatusFile = '';
|
||||
let allowQuitWhileRunning = false;
|
||||
|
||||
const gotSingleInstanceLock = app.requestSingleInstanceLock();
|
||||
if (!gotSingleInstanceLock) {
|
||||
appendToolLog(`single-instance lock denied pid=${process.pid} argv=${safeJson(process.argv)}`);
|
||||
app.quit();
|
||||
}
|
||||
|
||||
appendToolLog(`process start pid=${process.pid} ppid=${process.ppid} cwd=${process.cwd()} argv=${safeJson(process.argv)}`);
|
||||
|
||||
function appendToolLog(message) {
|
||||
try {
|
||||
fs.appendFileSync(toolLogPath, `[${new Date().toISOString()}] ${message}\n`, 'utf8');
|
||||
} catch {
|
||||
// ignore logging failure
|
||||
}
|
||||
}
|
||||
|
||||
function safeJson(value) {
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function sendLog(payload) {
|
||||
const message = String(payload.message || '');
|
||||
if (payload.level === 'stdout' && shouldSuppressWindowLog(message)) return;
|
||||
const displayPayload = {
|
||||
...payload,
|
||||
message: message.length > 6000 ? `${message.slice(0, 6000)}\n... [full output is in build-output.log]` : message,
|
||||
};
|
||||
appendToolLog(`${payload.level || 'info'} ${payload.jobId ? `[${payload.jobId}] ` : ''}${displayPayload.message || ''}`);
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
mainWindow.webContents.send('oem-tool:log', {
|
||||
time: new Date().toISOString(),
|
||||
...displayPayload,
|
||||
});
|
||||
}
|
||||
|
||||
function shouldSuppressWindowLog(message) {
|
||||
const text = String(message || '');
|
||||
return text.includes('[OK] Copied qrcode')
|
||||
|| text.includes('[OK] Copied logo')
|
||||
|| text.includes('[OK] Copied login-bg')
|
||||
|| text.includes('[SKIP] pattern not matched')
|
||||
|| text.includes('Processing: F:');
|
||||
}
|
||||
|
||||
function readState() {
|
||||
try {
|
||||
if (!fs.existsSync(statePath)) return null;
|
||||
return JSON.parse(fs.readFileSync(statePath, 'utf8').replace(/^\uFEFF/, ''));
|
||||
} catch (error) {
|
||||
appendToolLog(`readState failed: ${error && error.message ? error.message : String(error)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeState(payload) {
|
||||
try {
|
||||
fs.writeFileSync(statePath, JSON.stringify(payload, null, 2), 'utf8');
|
||||
} catch (error) {
|
||||
appendToolLog(`writeState failed: ${error && error.message ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function clearStateIfFinished(payload) {
|
||||
writeState({
|
||||
running: false,
|
||||
finishedAt: new Date().toISOString(),
|
||||
outputDir,
|
||||
results: payload.results || [],
|
||||
message: payload.message || '',
|
||||
success: Boolean(payload.success),
|
||||
sessionDir: activeSessionDir,
|
||||
});
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
appendToolLog('createWindow start');
|
||||
const staleFailure = repairStaleRunningState();
|
||||
if (staleFailure) {
|
||||
appendToolLog(`repaired stale active run before window creation: ${staleFailure.message}`);
|
||||
}
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1320,
|
||||
height: 860,
|
||||
minWidth: 1120,
|
||||
minHeight: 720,
|
||||
title: toolAppName,
|
||||
backgroundColor: '#f6f7fb',
|
||||
show: false,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.cjs'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
});
|
||||
mainWindow.webContents.on('console-message', (_event, level, message, line, sourceId) => {
|
||||
appendToolLog(`renderer console level=${level} ${sourceId || ''}:${line || 0} ${message}`);
|
||||
});
|
||||
mainWindow.webContents.on('preload-error', (_event, preloadPath, error) => {
|
||||
appendToolLog(`preload-error ${preloadPath}: ${error && error.stack ? error.stack : error}`);
|
||||
});
|
||||
mainWindow.webContents.on('did-crash', (_event, killed) => {
|
||||
appendToolLog(`renderer did-crash killed=${killed}`);
|
||||
});
|
||||
mainWindow.webContents.on('destroyed', () => {
|
||||
appendToolLog('webContents destroyed');
|
||||
});
|
||||
mainWindow.on('unresponsive', () => {
|
||||
appendToolLog('window unresponsive');
|
||||
});
|
||||
mainWindow.on('responsive', () => {
|
||||
appendToolLog('window responsive');
|
||||
});
|
||||
|
||||
const showMainWindow = () => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
appendToolLog(`showMainWindow visible=${mainWindow.isVisible()} minimized=${mainWindow.isMinimized()}`);
|
||||
mainWindow.setTitle(toolAppName);
|
||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||
mainWindow.center();
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
mainWindow.moveTop();
|
||||
appendToolLog(`showMainWindow done visible=${mainWindow.isVisible()} bounds=${JSON.stringify(mainWindow.getBounds())}`);
|
||||
};
|
||||
|
||||
mainWindow.loadFile(path.join(__dirname, 'index.html'));
|
||||
showMainWindow();
|
||||
mainWindow.on('page-title-updated', event => {
|
||||
event.preventDefault();
|
||||
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.setTitle(toolAppName);
|
||||
});
|
||||
mainWindow.once('ready-to-show', showMainWindow);
|
||||
mainWindow.webContents.once('did-finish-load', () => setTimeout(showMainWindow, 50));
|
||||
setTimeout(showMainWindow, 2000);
|
||||
mainWindow.webContents.on('render-process-gone', (_event, details) => {
|
||||
appendToolLog(`render-process-gone: ${JSON.stringify(details)}`);
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.reload();
|
||||
}
|
||||
});
|
||||
mainWindow.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL) => {
|
||||
appendToolLog(`did-fail-load: ${errorCode} ${errorDescription} ${validatedURL}`);
|
||||
});
|
||||
mainWindow.on('close', event => {
|
||||
if (running && !allowQuitWhileRunning) {
|
||||
const payload = pollActiveSession();
|
||||
if (payload) {
|
||||
finishActiveSession({ ...payload, outputDir });
|
||||
return;
|
||||
}
|
||||
const pid = getKnownWorkerPid();
|
||||
if (!pid || !isProcessAlive(pid)) {
|
||||
markStaleRunStopped(`window close allowed because worker is not alive. pid=${pid || 'unknown'}`);
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
appendToolLog(`window close requested while worker is running; keep tool window open. pid=${pid}`);
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
mainWindow.on('closed', () => {
|
||||
appendToolLog('window closed');
|
||||
mainWindow = null;
|
||||
if (running) {
|
||||
setTimeout(() => {
|
||||
if (!mainWindow) createWindow();
|
||||
}, 300);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function readNewWorkerLogs(workerLogFile) {
|
||||
if (!fs.existsSync(workerLogFile)) return;
|
||||
const stat = fs.statSync(workerLogFile);
|
||||
if (stat.size <= activeLogOffset) return;
|
||||
|
||||
const fd = fs.openSync(workerLogFile, 'r');
|
||||
const buffer = Buffer.alloc(stat.size - activeLogOffset);
|
||||
fs.readSync(fd, buffer, 0, buffer.length, activeLogOffset);
|
||||
fs.closeSync(fd);
|
||||
activeLogOffset = stat.size;
|
||||
|
||||
const lines = buffer.toString('utf8').split(/\r?\n/).filter(Boolean);
|
||||
for (const line of lines) {
|
||||
try {
|
||||
sendLog(JSON.parse(line));
|
||||
} catch {
|
||||
sendLog({ level: 'info', message: line });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stopActivePoll() {
|
||||
if (activePollTimer) {
|
||||
clearInterval(activePollTimer);
|
||||
activePollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function setActiveSession(sessionDir) {
|
||||
activeSessionDir = sessionDir;
|
||||
activeResultFile = path.join(sessionDir, 'result.json');
|
||||
activeWorkerLogFile = path.join(sessionDir, 'worker.log');
|
||||
activeStageStatusFile = path.join(sessionDir, 'stage-status.json');
|
||||
activeLogOffset = 0;
|
||||
}
|
||||
|
||||
function readJsonFile(filePath) {
|
||||
try {
|
||||
if (!filePath || !fs.existsSync(filePath)) return null;
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, ''));
|
||||
} catch (error) {
|
||||
appendToolLog(`readJsonFile failed ${filePath}: ${error && error.message ? error.message : String(error)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readWorkerPidFromSession(sessionDir) {
|
||||
try {
|
||||
const pidFile = path.join(sessionDir, 'worker.pid');
|
||||
if (!fs.existsSync(pidFile)) return null;
|
||||
const parsed = Number(fs.readFileSync(pidFile, 'utf8').replace(/^\uFEFF/, '').trim());
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
||||
} catch (error) {
|
||||
appendToolLog(`readWorkerPidFromSession failed: ${error && error.message ? error.message : String(error)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function waitForWorkerPidFromSession(sessionDir, timeoutMs = 5000) {
|
||||
const started = Date.now();
|
||||
const sleepBuffer = new SharedArrayBuffer(4);
|
||||
const sleepView = new Int32Array(sleepBuffer);
|
||||
while (Date.now() - started < timeoutMs) {
|
||||
const pid = readWorkerPidFromSession(sessionDir);
|
||||
if (pid) return pid;
|
||||
Atomics.wait(sleepView, 0, 0, 100);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function makeJobsFailure(jobs, sessionDir, message) {
|
||||
const safeJobs = Array.isArray(jobs) ? jobs : [];
|
||||
return {
|
||||
success: false,
|
||||
results: safeJobs.map(job => ({
|
||||
id: job.id,
|
||||
brandName: job.brandName,
|
||||
version: job.version,
|
||||
success: false,
|
||||
outputDir,
|
||||
buildOutputFile: sessionDir ? path.join(sessionDir, 'build-output.log') : '',
|
||||
artifacts: [],
|
||||
message,
|
||||
})),
|
||||
outputDir,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
function getKnownWorkerPid() {
|
||||
const state = readState();
|
||||
if (currentProcess && currentProcess.pid) return currentProcess.pid;
|
||||
if (state && state.pid) return state.pid;
|
||||
if (activeSessionDir) return readWorkerPidFromSession(activeSessionDir);
|
||||
if (state && state.sessionDir) return readWorkerPidFromSession(state.sessionDir);
|
||||
return null;
|
||||
}
|
||||
|
||||
function repairStaleRunningState() {
|
||||
const state = readState();
|
||||
if (!state || !state.running || !state.sessionDir) return null;
|
||||
|
||||
const resultPath = state.resultFile || path.join(state.sessionDir, 'result.json');
|
||||
if (fs.existsSync(resultPath)) return null;
|
||||
|
||||
const pid = state.pid || readWorkerPidFromSession(state.sessionDir);
|
||||
if (pid && isProcessAlive(pid)) return null;
|
||||
const launcherStartedAt = state.launcherStartedAt ? Date.parse(state.launcherStartedAt) : 0;
|
||||
if (!pid && launcherStartedAt && Date.now() - launcherStartedAt < 15000) return null;
|
||||
|
||||
if (activeSessionDir !== state.sessionDir) {
|
||||
setActiveSession(state.sessionDir);
|
||||
}
|
||||
|
||||
const failed = makeMissingResultFailure({
|
||||
...state,
|
||||
pid,
|
||||
});
|
||||
finishActiveSession(failed);
|
||||
return failed;
|
||||
}
|
||||
|
||||
function markStaleRunStopped(message) {
|
||||
stopActivePoll();
|
||||
running = false;
|
||||
currentProcess = null;
|
||||
writeState({
|
||||
...(readState() || {}),
|
||||
running: false,
|
||||
stoppedAt: new Date().toISOString(),
|
||||
outputDir,
|
||||
sessionDir: activeSessionDir,
|
||||
message,
|
||||
});
|
||||
appendToolLog(message);
|
||||
}
|
||||
|
||||
function pollActiveSession() {
|
||||
if (!activeSessionDir) return null;
|
||||
readNewWorkerLogs(activeWorkerLogFile);
|
||||
if (!fs.existsSync(activeResultFile)) return null;
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(activeResultFile, 'utf8'));
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
results: latestResults,
|
||||
outputDir,
|
||||
message: error && error.message ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function makeMissingResultFailure(state) {
|
||||
const stage = readJsonFile(path.join(state.sessionDir, 'stage-status.json')) || readJsonFile(activeStageStatusFile);
|
||||
const jobs = Array.isArray(state.jobs) ? state.jobs : [];
|
||||
const firstJob = jobs[0] || {};
|
||||
const stageText = stage && stage.stage ? ` Last stage: ${stage.stage} (${stage.status || 'unknown'}).` : '';
|
||||
const message = `Worker process ${state.pid || 'unknown'} stopped without producing result.json.${stageText} See ${state.workerLogFile || activeWorkerLogFile}`;
|
||||
return {
|
||||
success: false,
|
||||
results: jobs.length > 0 ? jobs.map(job => ({
|
||||
id: job.id,
|
||||
brandName: job.brandName,
|
||||
version: job.version,
|
||||
success: false,
|
||||
outputDir,
|
||||
buildOutputFile: path.join(state.sessionDir, 'build-output.log'),
|
||||
artifacts: [],
|
||||
message,
|
||||
stage,
|
||||
})) : [{
|
||||
id: firstJob.id || 'oem-0',
|
||||
brandName: firstJob.brandName || 'OEM',
|
||||
success: false,
|
||||
outputDir,
|
||||
buildOutputFile: path.join(state.sessionDir, 'build-output.log'),
|
||||
artifacts: [],
|
||||
message,
|
||||
stage,
|
||||
}],
|
||||
outputDir,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
function finishActiveSession(payload) {
|
||||
stopActivePoll();
|
||||
latestResults = payload.results || [];
|
||||
running = false;
|
||||
currentProcess = null;
|
||||
clearStateIfFinished(payload);
|
||||
sendLog({ level: payload.success ? 'info' : 'error', message: payload.message || 'Build queue finished' });
|
||||
}
|
||||
|
||||
function finishStartupFailure(jobs, sessionDir, message) {
|
||||
finishActiveSession(makeJobsFailure(jobs, sessionDir, message));
|
||||
}
|
||||
|
||||
function findNodeExe() {
|
||||
const candidates = [
|
||||
process.env.npm_node_execpath,
|
||||
process.env.NODE_EXE,
|
||||
].filter(Boolean);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
}
|
||||
|
||||
const result = spawnSync('where.exe', ['node.exe'], {
|
||||
cwd: projectRoot,
|
||||
encoding: 'utf8',
|
||||
windowsHide: true,
|
||||
});
|
||||
const found = String(result.stdout || '')
|
||||
.split(/\r?\n/)
|
||||
.map(line => line.trim())
|
||||
.find(Boolean);
|
||||
if (found && fs.existsSync(found)) return found;
|
||||
return 'node.exe';
|
||||
}
|
||||
|
||||
function launchDetachedWorker(workerPath, relativeJobsFile, relativeSessionDir) {
|
||||
const sessionDir = path.resolve(projectRoot, relativeSessionDir);
|
||||
fs.mkdirSync(sessionDir, { recursive: true });
|
||||
|
||||
const nodeExe = findNodeExe();
|
||||
const stdoutFd = fs.openSync(path.join(sessionDir, 'worker-main.stdout.log'), 'a');
|
||||
const stderrFd = fs.openSync(path.join(sessionDir, 'worker-main.stderr.log'), 'a');
|
||||
appendToolLog(`launchDetachedWorker node=${nodeExe} worker=${workerPath} jobs=${relativeJobsFile} session=${relativeSessionDir}`);
|
||||
const worker = spawn(nodeExe, [workerPath, relativeJobsFile, relativeSessionDir], {
|
||||
cwd: projectRoot,
|
||||
windowsHide: true,
|
||||
detached: true,
|
||||
stdio: ['ignore', stdoutFd, stderrFd],
|
||||
});
|
||||
fs.closeSync(stdoutFd);
|
||||
fs.closeSync(stderrFd);
|
||||
|
||||
if (!worker.pid) {
|
||||
throw new Error('Build worker did not return a PID.');
|
||||
}
|
||||
fs.writeFileSync(path.join(sessionDir, 'worker.pid'), String(worker.pid), 'utf8');
|
||||
worker.unref();
|
||||
|
||||
return {
|
||||
launcherPid: null,
|
||||
workerPid: worker.pid,
|
||||
};
|
||||
}
|
||||
|
||||
function startActivePoll() {
|
||||
stopActivePoll();
|
||||
activePollTimer = setInterval(() => {
|
||||
const payload = pollActiveSession();
|
||||
if (payload) {
|
||||
finishActiveSession({ ...payload, outputDir });
|
||||
}
|
||||
}, 700);
|
||||
}
|
||||
|
||||
function isProcessAlive(pid) {
|
||||
if (!pid) return false;
|
||||
try {
|
||||
process.kill(Number(pid), 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.handle('oem-tool:diagnostic', async (_event, payload) => {
|
||||
const message = payload && payload.message ? payload.message : 'renderer diagnostic';
|
||||
appendToolLog(`renderer diagnostic: ${message} ${safeJson(payload && payload.payload ? payload.payload : {})}`);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
ipcMain.handle('oem-tool:pick-image', async () => {
|
||||
const result = await dialog.showOpenDialog(mainWindow, {
|
||||
properties: ['openFile'],
|
||||
filters: [{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'webp', 'ico', 'icns'] }],
|
||||
});
|
||||
if (result.canceled) return null;
|
||||
return result.filePaths[0] || null;
|
||||
});
|
||||
|
||||
ipcMain.handle('oem-tool:run-batch', async (_event, jobs) => {
|
||||
appendToolLog(`run-batch invoked jobs=${Array.isArray(jobs) ? jobs.length : 'invalid'}`);
|
||||
if (running) {
|
||||
appendToolLog('run-batch rejected: already running');
|
||||
return { success: false, message: 'Build queue is already running', results: latestResults, outputDir };
|
||||
}
|
||||
if (!Array.isArray(jobs) || jobs.length === 0) {
|
||||
appendToolLog('run-batch rejected: no jobs');
|
||||
return { success: false, message: 'No OEM jobs selected', results: [], outputDir };
|
||||
}
|
||||
|
||||
const sessionDir = path.join(__dirname, 'runs', `${Date.now()}`);
|
||||
const jobsFile = path.join(sessionDir, 'jobs.json');
|
||||
|
||||
try {
|
||||
fs.mkdirSync(sessionDir, { recursive: true });
|
||||
fs.writeFileSync(jobsFile, JSON.stringify(jobs, null, 2), 'utf8');
|
||||
|
||||
running = true;
|
||||
latestResults = [];
|
||||
setActiveSession(sessionDir);
|
||||
|
||||
const relativeSessionDir = path.relative(projectRoot, sessionDir);
|
||||
const relativeJobsFile = path.relative(projectRoot, jobsFile);
|
||||
const workerPath = path.join('tools', 'oem-batch-builder', 'worker.cjs');
|
||||
|
||||
writeState({
|
||||
running: true,
|
||||
startedAt: new Date().toISOString(),
|
||||
pid: null,
|
||||
launcherPid: null,
|
||||
outputDir,
|
||||
sessionDir,
|
||||
jobsFile,
|
||||
resultFile: activeResultFile,
|
||||
workerLogFile: activeWorkerLogFile,
|
||||
stageStatusFile: activeStageStatusFile,
|
||||
jobs,
|
||||
message: 'Starting worker',
|
||||
launcherStartedAt: new Date().toISOString(),
|
||||
});
|
||||
appendToolLog(`run-batch state written session=${sessionDir}`);
|
||||
|
||||
const launchInfo = launchDetachedWorker(workerPath, relativeJobsFile, relativeSessionDir);
|
||||
const workerPid = launchInfo.workerPid || readWorkerPidFromSession(sessionDir);
|
||||
appendToolLog(`run-batch launchDetachedWorker returned launcherPid=${launchInfo.launcherPid} workerPid=${workerPid || ''}`);
|
||||
currentProcess = workerPid ? { pid: workerPid } : null;
|
||||
|
||||
writeState({
|
||||
...(readState() || {}),
|
||||
running: true,
|
||||
pid: workerPid || null,
|
||||
launcherPid: launchInfo.launcherPid,
|
||||
outputDir,
|
||||
sessionDir,
|
||||
jobsFile,
|
||||
resultFile: activeResultFile,
|
||||
workerLogFile: activeWorkerLogFile,
|
||||
stageStatusFile: activeStageStatusFile,
|
||||
jobs,
|
||||
launcherStartedAt: new Date().toISOString(),
|
||||
message: workerPid ? `Worker started. pid=${workerPid}` : `Worker launcher started. launcherPid=${launchInfo.launcherPid}`,
|
||||
});
|
||||
sendLog({ level: 'info', message: workerPid ? `Started background worker. pid=${workerPid} jobs=${jobs.length}` : `Started worker launcher. pid=${launchInfo.launcherPid} jobs=${jobs.length}` });
|
||||
appendToolLog(`background worker launcherPid=${launchInfo.launcherPid} workerPid=${workerPid || ''} worker=${workerPath} jobs=${relativeJobsFile}`);
|
||||
startActivePoll();
|
||||
appendToolLog(`run-batch accepted session=${sessionDir} launcherPid=${launchInfo.launcherPid} workerPid=${workerPid || ''}`);
|
||||
return {
|
||||
success: true,
|
||||
accepted: true,
|
||||
running: true,
|
||||
sessionDir,
|
||||
outputDir,
|
||||
message: `Build started. Logs: ${activeWorkerLogFile}`,
|
||||
results: [],
|
||||
};
|
||||
} catch (error) {
|
||||
const message = `Failed to start build worker: ${error && error.message ? error.message : String(error)}`;
|
||||
appendToolLog(`${message}\n${error && error.stack ? error.stack : ''}`);
|
||||
if (running || activeSessionDir === sessionDir) {
|
||||
finishStartupFailure(jobs, sessionDir, message);
|
||||
} else {
|
||||
appendToolLog(message);
|
||||
}
|
||||
return { success: false, running: false, outputDir, message, results: latestResults };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('oem-tool:get-status', async () => {
|
||||
const staleFailure = repairStaleRunningState();
|
||||
if (staleFailure) {
|
||||
return {
|
||||
running: false,
|
||||
results: staleFailure.results || [],
|
||||
outputDir,
|
||||
sessionDir: activeSessionDir,
|
||||
message: staleFailure.message,
|
||||
};
|
||||
}
|
||||
|
||||
const state = readState();
|
||||
if (state && state.running && state.sessionDir) {
|
||||
if (!fs.existsSync(state.sessionDir)) {
|
||||
const failed = {
|
||||
success: false,
|
||||
results: [],
|
||||
outputDir,
|
||||
message: `Previous build state points to a missing session directory: ${state.sessionDir}`,
|
||||
};
|
||||
finishActiveSession(failed);
|
||||
return { running: false, results: [], outputDir, sessionDir: state.sessionDir, message: failed.message };
|
||||
}
|
||||
if (!state.pid) {
|
||||
const workerPid = readWorkerPidFromSession(state.sessionDir);
|
||||
if (workerPid) {
|
||||
state.pid = workerPid;
|
||||
writeState({ ...state, pid: workerPid });
|
||||
} else {
|
||||
const launcherStartedAt = state.launcherStartedAt ? Date.parse(state.launcherStartedAt) : 0;
|
||||
if (launcherStartedAt && Date.now() - launcherStartedAt < 15000) {
|
||||
return { running: true, results: latestResults, outputDir, sessionDir: state.sessionDir, workerLogFile: state.workerLogFile, message: 'Worker launcher is starting' };
|
||||
}
|
||||
const failed = makeMissingResultFailure(state);
|
||||
finishActiveSession(failed);
|
||||
return { running: false, results: failed.results, outputDir, sessionDir: state.sessionDir, message: failed.message };
|
||||
}
|
||||
}
|
||||
if (activeSessionDir !== state.sessionDir) {
|
||||
setActiveSession(state.sessionDir);
|
||||
running = true;
|
||||
startActivePoll();
|
||||
}
|
||||
const payload = pollActiveSession();
|
||||
if (payload) {
|
||||
finishActiveSession({ ...payload, outputDir });
|
||||
return { running: false, results: latestResults, outputDir, sessionDir: state.sessionDir };
|
||||
}
|
||||
if (state.pid && !isProcessAlive(state.pid)) {
|
||||
const failed = makeMissingResultFailure(state);
|
||||
finishActiveSession(failed);
|
||||
return { running: false, results: failed.results, outputDir, sessionDir: state.sessionDir, message: failed.message };
|
||||
}
|
||||
return { running: true, results: latestResults, outputDir, sessionDir: state.sessionDir, workerLogFile: state.workerLogFile, stage: readJsonFile(state.stageStatusFile) };
|
||||
}
|
||||
return { running, results: latestResults, outputDir, sessionDir: activeSessionDir };
|
||||
});
|
||||
|
||||
ipcMain.handle('oem-tool:cancel', async () => {
|
||||
const state = readState();
|
||||
const pid = currentProcess && currentProcess.pid ? currentProcess.pid : state && state.pid;
|
||||
if (!pid) return { success: false, message: 'No running build process' };
|
||||
spawn('taskkill', ['/F', '/T', '/PID', String(pid)], { windowsHide: true, stdio: 'ignore' });
|
||||
stopActivePoll();
|
||||
currentProcess = null;
|
||||
running = false;
|
||||
writeState({ running: false, cancelledAt: new Date().toISOString(), outputDir, sessionDir: activeSessionDir });
|
||||
sendLog({ level: 'warn', message: 'Build worker cancelled' });
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
ipcMain.handle('oem-tool:open-output-dir', async () => {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
await shell.openPath(outputDir);
|
||||
return { success: true, outputDir };
|
||||
});
|
||||
|
||||
app.on('second-instance', () => {
|
||||
appendToolLog('second-instance received');
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
createWindow();
|
||||
return;
|
||||
}
|
||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||
mainWindow.center();
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
});
|
||||
|
||||
if (gotSingleInstanceLock) {
|
||||
app.whenReady().then(() => {
|
||||
appendToolLog(`app ready pid=${process.pid}`);
|
||||
createWindow();
|
||||
}).catch(error => {
|
||||
appendToolLog(`app ready failed: ${error && error.stack ? error.stack : error}`);
|
||||
});
|
||||
}
|
||||
app.on('will-quit', () => {
|
||||
appendToolLog(`app will-quit running=${running} activeSession=${activeSessionDir || ''}`);
|
||||
});
|
||||
app.on('quit', (_event, exitCode) => {
|
||||
appendToolLog(`app quit exitCode=${exitCode} running=${running} activeSession=${activeSessionDir || ''}`);
|
||||
});
|
||||
app.on('before-quit', event => {
|
||||
appendToolLog(`before-quit running=${running} allowQuitWhileRunning=${allowQuitWhileRunning}`);
|
||||
if (running) {
|
||||
if (!allowQuitWhileRunning) {
|
||||
event.preventDefault();
|
||||
appendToolLog('before-quit ignored while worker is running');
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
createWindow();
|
||||
} else {
|
||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
writeState({
|
||||
...(readState() || {}),
|
||||
running: true,
|
||||
outputDir,
|
||||
sessionDir: activeSessionDir,
|
||||
resultFile: activeResultFile,
|
||||
workerLogFile: activeWorkerLogFile,
|
||||
stageStatusFile: activeStageStatusFile,
|
||||
});
|
||||
}
|
||||
});
|
||||
app.on('window-all-closed', () => {
|
||||
if (running && !allowQuitWhileRunning) {
|
||||
appendToolLog('window-all-closed ignored while worker is running');
|
||||
return;
|
||||
}
|
||||
app.quit();
|
||||
});
|
||||
|
||||
process.on('uncaughtException', error => {
|
||||
appendToolLog(`uncaughtException: ${error && error.stack ? error.stack : error}`);
|
||||
sendLog({ level: 'error', message: `Tool error: ${error && error.message ? error.message : String(error)}` });
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', error => {
|
||||
appendToolLog(`unhandledRejection: ${error && error.stack ? error.stack : error}`);
|
||||
sendLog({ level: 'error', message: `Tool error: ${error && error.message ? error.message : String(error)}` });
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
appendToolLog(`process SIGINT running=${running} activeSession=${activeSessionDir || ''}`);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
appendToolLog(`process SIGTERM running=${running} activeSession=${activeSessionDir || ''}`);
|
||||
});
|
||||
|
||||
process.on('beforeExit', code => {
|
||||
appendToolLog(`process beforeExit code=${code} running=${running} activeSession=${activeSessionDir || ''}`);
|
||||
});
|
||||
|
||||
process.on('exit', code => {
|
||||
appendToolLog(`process exit code=${code} running=${running} activeSession=${activeSessionDir || ''}`);
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "oem-batch-builder-tool",
|
||||
"version": "1.0.0",
|
||||
"main": "main.cjs",
|
||||
"private": true
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
|
||||
contextBridge.exposeInMainWorld('oemTool', {
|
||||
diagnostic: (message, payload) => ipcRenderer.invoke('oem-tool:diagnostic', { message, payload }),
|
||||
pickImage: () => ipcRenderer.invoke('oem-tool:pick-image'),
|
||||
pathToFileUrl: (filePath) => {
|
||||
if (!filePath) return '';
|
||||
const normalized = String(filePath).replace(/\\/g, '/').replace(/#/g, '%23');
|
||||
return `file:///${encodeURI(normalized)}`;
|
||||
},
|
||||
runBatch: (jobs) => ipcRenderer.invoke('oem-tool:run-batch', jobs),
|
||||
cancel: () => ipcRenderer.invoke('oem-tool:cancel'),
|
||||
openOutputDir: () => ipcRenderer.invoke('oem-tool:open-output-dir'),
|
||||
getStatus: () => ipcRenderer.invoke('oem-tool:get-status'),
|
||||
onLog: (callback) => {
|
||||
const listener = (_event, payload) => callback(payload);
|
||||
ipcRenderer.on('oem-tool:log', listener);
|
||||
return () => ipcRenderer.removeListener('oem-tool:log', listener);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,450 @@
|
||||
const STORAGE_KEY = 'oem-batch-builder-jobs-v3';
|
||||
const LEGACY_KEYS = ['oem-batch-builder-jobs-v2', 'oem-batch-builder-jobs-v1'];
|
||||
const defaultApiBaseUrl = 'http://47.107.86.179:3002/api';
|
||||
const defaultVersion = '4.5.34';
|
||||
|
||||
let jobs = [];
|
||||
let running = false;
|
||||
let statusPollTimer = null;
|
||||
let logLines = [];
|
||||
let logFlushScheduled = false;
|
||||
const MAX_LOG_LINES = 180;
|
||||
const MAX_LOG_MESSAGE_LENGTH = 1000;
|
||||
const FULL_PACKAGE_COMMAND = 'npm run build:win';
|
||||
|
||||
const grid = document.getElementById('jobGrid');
|
||||
const template = document.getElementById('jobTemplate');
|
||||
const logBox = document.getElementById('logBox');
|
||||
const outputDirText = document.getElementById('outputDir');
|
||||
const runBtn = document.getElementById('runBtn');
|
||||
const cancelBtn = document.getElementById('cancelBtn');
|
||||
|
||||
function diagnostic(message, payload = {}) {
|
||||
try {
|
||||
if (window.oemTool && typeof window.oemTool.diagnostic === 'function') {
|
||||
window.oemTool.diagnostic(message, payload).catch(() => {});
|
||||
}
|
||||
} catch {
|
||||
// ignore diagnostics failure
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('error', event => {
|
||||
diagnostic('window error', {
|
||||
message: event.message,
|
||||
filename: event.filename,
|
||||
lineno: event.lineno,
|
||||
colno: event.colno,
|
||||
stack: event.error && event.error.stack ? event.error.stack : '',
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener('unhandledrejection', event => {
|
||||
const reason = event.reason;
|
||||
diagnostic('unhandled rejection', {
|
||||
message: reason && reason.message ? reason.message : String(reason),
|
||||
stack: reason && reason.stack ? reason.stack : '',
|
||||
});
|
||||
});
|
||||
|
||||
function createJob(seed = {}) {
|
||||
const seedAssets = seed.assets || {};
|
||||
const buildCommand = normalizeBuildCommand(seed.buildCommand);
|
||||
const isSmokeDraft = /^ui-smoke-|^ui-buildwin-/.test(String(seed.id || ''));
|
||||
return {
|
||||
id: isSmokeDraft || !seed.id ? `oem-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` : seed.id,
|
||||
enabled: seed.enabled !== false,
|
||||
brandName: seed.brandName || '',
|
||||
brandSlogan: seed.brandSlogan || 'One-stop AI digital human system',
|
||||
brandShortName: seed.brandShortName || '',
|
||||
brandDisplayName: seed.brandDisplayName || '',
|
||||
apiBaseUrl: seed.apiBaseUrl || defaultApiBaseUrl,
|
||||
version: seed.version || defaultVersion,
|
||||
buildCommand,
|
||||
assets: {
|
||||
qrcodePath: seedAssets.qrcodePath || '',
|
||||
logoPath: seedAssets.logoPath || '',
|
||||
iconPath: seedAssets.iconPath || '',
|
||||
loginBgPath: seedAssets.loginBgPath || '',
|
||||
},
|
||||
status: isSmokeDraft ? 'idle' : (seed.status || 'idle'),
|
||||
message: isSmokeDraft ? '' : (seed.message || ''),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBuildCommand(command) {
|
||||
const trimmed = String(command || '').trim();
|
||||
if (!trimmed) return FULL_PACKAGE_COMMAND;
|
||||
|
||||
// Older smoke tests wrote plain npm run build into the shared Electron profile.
|
||||
// The OEM tool is for producing packages, so repair that polluted draft value.
|
||||
if (/^npm(?:\.cmd)?\s+run\s+build$/i.test(trimmed)) {
|
||||
return FULL_PACKAGE_COMMAND;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function saveDrafts() {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(jobs));
|
||||
}
|
||||
|
||||
function loadDrafts() {
|
||||
try {
|
||||
let raw = localStorage.getItem(STORAGE_KEY);
|
||||
for (const key of LEGACY_KEYS) {
|
||||
if (!raw) raw = localStorage.getItem(key);
|
||||
}
|
||||
const parsed = raw ? JSON.parse(raw) : null;
|
||||
jobs = Array.isArray(parsed) && parsed.length > 0 ? parsed.map(createJob) : [createJob()];
|
||||
jobs.forEach(repairJobPaths);
|
||||
saveDrafts();
|
||||
} catch {
|
||||
jobs = [createJob()];
|
||||
saveDrafts();
|
||||
}
|
||||
}
|
||||
|
||||
function fileName(filePath) {
|
||||
return String(filePath || '').split(/[\\/]/).pop() || '';
|
||||
}
|
||||
|
||||
function repairKnownMojibakePath(filePath) {
|
||||
return String(filePath || '')
|
||||
.replace(/cursor澶囦唤/g, 'cursor备份')
|
||||
.replace(/cursor锟斤拷锟斤拷/g, 'cursor备份');
|
||||
}
|
||||
|
||||
function repairJobPaths(job) {
|
||||
for (const key of ['qrcodePath', 'logoPath', 'iconPath', 'loginBgPath']) {
|
||||
job.assets[key] = repairKnownMojibakePath(job.assets[key]);
|
||||
}
|
||||
}
|
||||
|
||||
function isSupportedImage(filePath) {
|
||||
return /\.(png|jpe?g|webp|ico|icns)$/i.test(String(filePath || ''));
|
||||
}
|
||||
|
||||
function normalizeShortName(job) {
|
||||
if (!job.brandShortName.trim()) {
|
||||
job.brandShortName = (job.brandName || 'OEMApp')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^\w.-]/g, '')
|
||||
.slice(0, 48) || 'OEMApp';
|
||||
}
|
||||
if (!job.brandDisplayName.trim()) {
|
||||
job.brandDisplayName = job.brandName;
|
||||
}
|
||||
}
|
||||
|
||||
function validateJob(job) {
|
||||
normalizeShortName(job);
|
||||
if (!job.brandName.trim()) return 'Software name is required';
|
||||
if (!job.apiBaseUrl.trim()) return 'API base URL is required';
|
||||
if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(job.version)) return 'Version must be x.y.z';
|
||||
if (!job.assets.qrcodePath) return 'QR code image is missing';
|
||||
if (!job.assets.logoPath) return 'Logo image is missing';
|
||||
if (!job.assets.iconPath) return 'Icon image is missing';
|
||||
if (!job.assets.loginBgPath) return 'Login background image is missing';
|
||||
return '';
|
||||
}
|
||||
|
||||
function appendLog(payload) {
|
||||
const prefix = payload.jobId ? `[${payload.jobId}]` : '[batch]';
|
||||
let message = String(payload.message || '').trimEnd();
|
||||
if (!message) return;
|
||||
if (message.length > MAX_LOG_MESSAGE_LENGTH) {
|
||||
message = `${message.slice(0, MAX_LOG_MESSAGE_LENGTH)}\n... [log chunk truncated]`;
|
||||
}
|
||||
const stamp = new Date(payload.time || Date.now()).toLocaleTimeString();
|
||||
for (const line of message.split(/\r?\n/)) {
|
||||
logLines.push(`${stamp} ${prefix} ${line}`);
|
||||
}
|
||||
if (logLines.length > MAX_LOG_LINES) {
|
||||
logLines = logLines.slice(-MAX_LOG_LINES);
|
||||
}
|
||||
if (logFlushScheduled) return;
|
||||
logFlushScheduled = true;
|
||||
requestAnimationFrame(() => {
|
||||
logFlushScheduled = false;
|
||||
logBox.textContent = `${logLines.join('\n')}\n`;
|
||||
logBox.scrollTop = logBox.scrollHeight;
|
||||
});
|
||||
}
|
||||
|
||||
function updateRunState() {
|
||||
const enabledCount = jobs.filter(job => job.enabled).length;
|
||||
runBtn.disabled = running;
|
||||
cancelBtn.disabled = !running;
|
||||
runBtn.textContent = running ? 'Packaging...' : `OEM + Package (${enabledCount})`;
|
||||
}
|
||||
|
||||
function applyResults(result) {
|
||||
if (result && result.outputDir) outputDirText.textContent = result.outputDir;
|
||||
const resultMap = new Map((result && result.results || []).map(item => [item.id, item]));
|
||||
jobs.forEach(job => {
|
||||
const item = resultMap.get(job.id);
|
||||
if (!item) return;
|
||||
job.status = item.success ? 'success' : 'failed';
|
||||
job.message = item.message || (item.success ? 'Done' : 'Failed');
|
||||
});
|
||||
saveDrafts();
|
||||
render();
|
||||
}
|
||||
|
||||
function stopStatusPolling() {
|
||||
if (statusPollTimer) {
|
||||
clearInterval(statusPollTimer);
|
||||
statusPollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function startStatusPolling() {
|
||||
stopStatusPolling();
|
||||
statusPollTimer = setInterval(async () => {
|
||||
try {
|
||||
const status = await window.oemTool.getStatus();
|
||||
if (status && status.outputDir) outputDirText.textContent = status.outputDir;
|
||||
running = Boolean(status && status.running);
|
||||
if (status && Array.isArray(status.results) && status.results.length > 0) {
|
||||
applyResults(status);
|
||||
}
|
||||
if (!running) stopStatusPolling();
|
||||
updateRunState();
|
||||
} catch (error) {
|
||||
appendLog({ message: error && error.message ? error.message : String(error) });
|
||||
}
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
function setAsset(job, key, filePath) {
|
||||
if (!filePath) return;
|
||||
if (!isSupportedImage(filePath)) {
|
||||
appendLog({ jobId: job.id, message: `Unsupported image type: ${filePath}` });
|
||||
return;
|
||||
}
|
||||
job.assets[key] = filePath;
|
||||
job.status = 'idle';
|
||||
job.message = '';
|
||||
saveDrafts();
|
||||
render();
|
||||
}
|
||||
|
||||
function renderAssetButton(button, job, key) {
|
||||
const img = button.querySelector('img');
|
||||
const small = button.querySelector('small');
|
||||
const filePath = job.assets[key];
|
||||
|
||||
button.classList.toggle('filled', Boolean(filePath));
|
||||
button.title = filePath || 'Click to select or drag an image here';
|
||||
|
||||
if (filePath) {
|
||||
img.src = window.oemTool.pathToFileUrl(filePath);
|
||||
img.style.display = 'block';
|
||||
small.textContent = fileName(filePath);
|
||||
} else {
|
||||
img.removeAttribute('src');
|
||||
img.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
grid.innerHTML = '';
|
||||
|
||||
jobs.forEach((job, index) => {
|
||||
const node = template.content.firstElementChild.cloneNode(true);
|
||||
node.classList.add(job.status);
|
||||
node.querySelector('.index').textContent = `#${index + 1}`;
|
||||
node.querySelector('.status').textContent = job.status;
|
||||
node.querySelector('.message').textContent = job.message || '';
|
||||
|
||||
node.querySelectorAll('[data-field]').forEach(input => {
|
||||
const field = input.dataset.field;
|
||||
if (input.type === 'checkbox') {
|
||||
input.checked = Boolean(job[field]);
|
||||
} else {
|
||||
input.value = job[field] || '';
|
||||
}
|
||||
|
||||
input.addEventListener('change', () => {
|
||||
job[field] = input.type === 'checkbox' ? input.checked : input.value;
|
||||
if (job.status === 'success' || job.status === 'failed') job.status = 'idle';
|
||||
saveDrafts();
|
||||
render();
|
||||
});
|
||||
});
|
||||
|
||||
node.querySelectorAll('[data-asset]').forEach(button => {
|
||||
const key = button.dataset.asset;
|
||||
renderAssetButton(button, job, key);
|
||||
|
||||
button.addEventListener('click', async () => {
|
||||
const filePath = await window.oemTool.pickImage();
|
||||
setAsset(job, key, filePath);
|
||||
});
|
||||
|
||||
button.addEventListener('dragover', event => {
|
||||
event.preventDefault();
|
||||
button.classList.add('dragging');
|
||||
});
|
||||
button.addEventListener('dragleave', () => {
|
||||
button.classList.remove('dragging');
|
||||
});
|
||||
button.addEventListener('drop', event => {
|
||||
event.preventDefault();
|
||||
button.classList.remove('dragging');
|
||||
const file = event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0];
|
||||
setAsset(job, key, file && file.path ? file.path : '');
|
||||
});
|
||||
});
|
||||
|
||||
node.querySelector('[data-action="copy"]').addEventListener('click', () => {
|
||||
jobs.push(createJob({
|
||||
...job,
|
||||
id: undefined,
|
||||
brandName: `${job.brandName || 'OEM'} Copy`,
|
||||
status: 'idle',
|
||||
message: '',
|
||||
}));
|
||||
saveDrafts();
|
||||
render();
|
||||
});
|
||||
|
||||
node.querySelector('[data-action="delete"]').addEventListener('click', () => {
|
||||
jobs = jobs.filter(item => item.id !== job.id);
|
||||
if (jobs.length === 0) jobs.push(createJob());
|
||||
saveDrafts();
|
||||
render();
|
||||
});
|
||||
|
||||
grid.appendChild(node);
|
||||
});
|
||||
|
||||
updateRunState();
|
||||
}
|
||||
|
||||
async function runBatch() {
|
||||
diagnostic('runBatch clicked', { running, jobs: jobs.length, enabled: jobs.filter(job => job.enabled).length });
|
||||
if (running) return;
|
||||
|
||||
const activeJobs = jobs.filter(job => job.enabled);
|
||||
if (activeJobs.length === 0) {
|
||||
appendLog({ message: 'Select at least one OEM job' });
|
||||
return;
|
||||
}
|
||||
|
||||
for (const job of activeJobs) {
|
||||
const error = validateJob(job);
|
||||
if (error) {
|
||||
job.status = 'failed';
|
||||
job.message = error;
|
||||
saveDrafts();
|
||||
render();
|
||||
appendLog({ jobId: job.id, message: `${job.brandName || job.id}: ${error}` });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
logBox.textContent = '';
|
||||
logLines = [];
|
||||
running = true;
|
||||
activeJobs.forEach(job => {
|
||||
job.status = 'running';
|
||||
job.message = 'Starting background package task';
|
||||
});
|
||||
saveDrafts();
|
||||
render();
|
||||
|
||||
try {
|
||||
diagnostic('runBatch invoking ipc', { activeJobs: activeJobs.length });
|
||||
const result = await window.oemTool.runBatch(activeJobs.map(job => ({
|
||||
id: job.id,
|
||||
brandName: job.brandName,
|
||||
brandSlogan: job.brandSlogan,
|
||||
brandShortName: job.brandShortName,
|
||||
brandDisplayName: job.brandDisplayName,
|
||||
apiBaseUrl: job.apiBaseUrl,
|
||||
version: job.version,
|
||||
assets: job.assets,
|
||||
buildCommand: normalizeBuildCommand(job.buildCommand),
|
||||
})));
|
||||
diagnostic('runBatch ipc resolved', {
|
||||
accepted: Boolean(result && result.accepted),
|
||||
running: Boolean(result && result.running),
|
||||
message: result && result.message ? result.message : '',
|
||||
});
|
||||
|
||||
if (result && result.outputDir) outputDirText.textContent = result.outputDir;
|
||||
if (result && result.accepted) {
|
||||
appendLog({ message: result.message || 'Build started in background' });
|
||||
activeJobs.forEach(job => {
|
||||
job.status = 'running';
|
||||
job.message = 'Running in background';
|
||||
});
|
||||
saveDrafts();
|
||||
render();
|
||||
startStatusPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
running = false;
|
||||
applyResults(result || {});
|
||||
} catch (error) {
|
||||
diagnostic('runBatch ipc rejected', {
|
||||
message: error && error.message ? error.message : String(error),
|
||||
stack: error && error.stack ? error.stack : '',
|
||||
});
|
||||
appendLog({ message: error && error.message ? error.message : String(error) });
|
||||
activeJobs.forEach(job => {
|
||||
if (job.status === 'running') {
|
||||
job.status = 'failed';
|
||||
job.message = 'Package failed';
|
||||
}
|
||||
});
|
||||
running = false;
|
||||
saveDrafts();
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('dragover', event => event.preventDefault());
|
||||
document.addEventListener('drop', event => event.preventDefault());
|
||||
|
||||
document.getElementById('addJobBtn').addEventListener('click', () => {
|
||||
jobs.push(createJob());
|
||||
saveDrafts();
|
||||
render();
|
||||
});
|
||||
|
||||
document.getElementById('openOutputBtn').addEventListener('click', async () => {
|
||||
const result = await window.oemTool.openOutputDir();
|
||||
if (result && result.outputDir) outputDirText.textContent = result.outputDir;
|
||||
});
|
||||
|
||||
cancelBtn.addEventListener('click', async () => {
|
||||
await window.oemTool.cancel();
|
||||
running = false;
|
||||
stopStatusPolling();
|
||||
updateRunState();
|
||||
});
|
||||
|
||||
runBtn.addEventListener('click', runBatch);
|
||||
window.oemTool.onLog(appendLog);
|
||||
|
||||
loadDrafts();
|
||||
render();
|
||||
window.oemTool.getStatus().then(status => {
|
||||
if (status && status.outputDir) outputDirText.textContent = status.outputDir;
|
||||
running = Boolean(status && status.running);
|
||||
if (running) {
|
||||
jobs.forEach(job => {
|
||||
if (job.enabled && job.status !== 'success' && job.status !== 'failed') {
|
||||
job.status = 'running';
|
||||
job.message = 'Running in background';
|
||||
}
|
||||
});
|
||||
startStatusPolling();
|
||||
} else if (status && Array.isArray(status.results) && status.results.length > 0) {
|
||||
applyResults(status);
|
||||
}
|
||||
updateRunState();
|
||||
}).catch(error => appendLog({ message: error && error.message ? error.message : String(error) }));
|
||||
@@ -0,0 +1,27 @@
|
||||
@echo off
|
||||
setlocal
|
||||
cd /d "%~dp0\..\.."
|
||||
set "LAUNCHER_LOG=tools\oem-batch-builder\launcher.log"
|
||||
set "ELECTRON_SOURCE=node_modules\electron\dist\electron.exe"
|
||||
set "TOOL_EXE=node_modules\electron\dist\OEMBatchBuilderTool.exe"
|
||||
set "TOOL_APP_DIR=tools\oem-batch-builder"
|
||||
echo [%date% %time%] launcher cwd=%cd%>> "%LAUNCHER_LOG%"
|
||||
if exist "tools\oem-batch-builder\kill-stale-build-processes.ps1" (
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "tools\oem-batch-builder\kill-stale-build-processes.ps1" -ProjectRoot "%cd%" >> "%LAUNCHER_LOG%" 2>&1
|
||||
)
|
||||
if not exist "%ELECTRON_SOURCE%" (
|
||||
echo [%date% %time%] Electron not found. Please run npm install first.>> "%LAUNCHER_LOG%"
|
||||
echo Electron not found. Please run npm install first.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
if not exist "%TOOL_EXE%" copy /Y "%ELECTRON_SOURCE%" "%TOOL_EXE%" >nul
|
||||
if not exist "%TOOL_APP_DIR%\package.json" (
|
||||
echo [%date% %time%] OEM tool package not found: %TOOL_APP_DIR%\package.json>> "%LAUNCHER_LOG%"
|
||||
echo OEM tool package not found: %TOOL_APP_DIR%\package.json
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
echo [%date% %time%] starting OEM batch builder>> "%LAUNCHER_LOG%"
|
||||
"%TOOL_EXE%" "%TOOL_APP_DIR%" >> "%LAUNCHER_LOG%" 2>&1
|
||||
echo [%date% %time%] OEM batch builder exited code=%ERRORLEVEL%>> "%LAUNCHER_LOG%"
|
||||
@@ -0,0 +1,236 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Microsoft YaHei", "Segoe UI", Arial, sans-serif;
|
||||
background: #f6f7fb;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.page {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 6px 0 0;
|
||||
color: #667085;
|
||||
}
|
||||
|
||||
.actions,
|
||||
.card-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
button {
|
||||
height: 34px;
|
||||
border: 1px solid #d0d5dd;
|
||||
border-radius: 6px;
|
||||
padding: 0 12px;
|
||||
background: #fff;
|
||||
color: #344054;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.primary {
|
||||
border-color: #2563eb;
|
||||
background: #2563eb;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.danger {
|
||||
border-color: #dc2626;
|
||||
background: #dc2626;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(330px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #fff;
|
||||
border: 1px solid #e4e7ec;
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
box-shadow: 0 8px 24px rgba(16, 24, 40, 0.04);
|
||||
}
|
||||
|
||||
.card.running {
|
||||
border-color: #2563eb;
|
||||
}
|
||||
|
||||
.card.success {
|
||||
border-color: #16a34a;
|
||||
}
|
||||
|
||||
.card.failed {
|
||||
border-color: #dc2626;
|
||||
}
|
||||
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.status {
|
||||
color: #667085;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin: 10px 0 4px;
|
||||
color: #475467;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
border: 1px solid #d0d5dd;
|
||||
border-radius: 6px;
|
||||
padding: 0 10px;
|
||||
outline: none;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
|
||||
}
|
||||
|
||||
.two {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 120px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.assets {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.asset {
|
||||
height: 112px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.asset.filled {
|
||||
border-color: #16a34a;
|
||||
background: #f0fdf4;
|
||||
}
|
||||
|
||||
.asset.dragging {
|
||||
border-color: #2563eb;
|
||||
background: #eff6ff;
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
|
||||
}
|
||||
|
||||
.asset img {
|
||||
display: none;
|
||||
width: 100%;
|
||||
height: 54px;
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.asset b {
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.asset small {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
color: #667085;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.message {
|
||||
min-height: 16px;
|
||||
margin-top: 10px;
|
||||
color: #b42318;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.log-panel {
|
||||
margin-top: 16px;
|
||||
background: #101828;
|
||||
color: #d0d5dd;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.log-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
background: #1d2939;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
#outputDir {
|
||||
color: #98a2b3;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
pre {
|
||||
height: 280px;
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
overflow: auto;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
const { _electron: electron } = require('playwright');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const projectRoot = path.resolve(__dirname, '..', '..');
|
||||
const electronSourceExe = path.join(projectRoot, 'node_modules', 'electron', 'dist', 'electron.exe');
|
||||
const electronExe = path.join(projectRoot, 'node_modules', 'electron', 'dist', 'OEMBatchBuilderTool.exe');
|
||||
const mainScript = path.join(projectRoot, 'tools', 'oem-batch-builder', 'main.cjs');
|
||||
const assetsDir = path.join(projectRoot, 'oem-assets');
|
||||
const smokeUserDataDir = path.join(projectRoot, 'tools', 'oem-batch-builder', 'runs', 'ui-smoke-buildwin-user-data');
|
||||
|
||||
if (!fs.existsSync(electronExe) || fs.statSync(electronExe).mtimeMs < fs.statSync(electronSourceExe).mtimeMs) {
|
||||
fs.copyFileSync(electronSourceExe, electronExe);
|
||||
}
|
||||
|
||||
function asset(name) {
|
||||
const filePath = path.join(assetsDir, name);
|
||||
if (!fs.existsSync(filePath)) throw new Error(`Missing test asset: ${filePath}`);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const app = await electron.launch({
|
||||
executablePath: electronExe,
|
||||
args: [`--user-data-dir=${smokeUserDataDir}`, mainScript],
|
||||
cwd: projectRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
ELECTRON_ENABLE_LOGGING: '1',
|
||||
},
|
||||
});
|
||||
|
||||
const page = await app.firstWindow();
|
||||
await page.waitForSelector('#runBtn', { timeout: 15000 });
|
||||
|
||||
const job = {
|
||||
id: `ui-buildwin-${Date.now()}`,
|
||||
enabled: true,
|
||||
brandName: 'UI BuildWin Test',
|
||||
brandSlogan: 'Smoke',
|
||||
brandShortName: 'UIBuildWinTest',
|
||||
brandDisplayName: 'UI BuildWin Test',
|
||||
apiBaseUrl: 'http://152.136.232.83:3002/api',
|
||||
version: '4.9.9',
|
||||
buildCommand: 'npm run build:win',
|
||||
assets: {
|
||||
qrcodePath: asset('qrcode.png'),
|
||||
logoPath: asset('logo.png'),
|
||||
iconPath: asset('icon.png'),
|
||||
loginBgPath: asset('login-bg.png'),
|
||||
},
|
||||
status: 'idle',
|
||||
message: '',
|
||||
};
|
||||
|
||||
await page.evaluate((payload) => {
|
||||
localStorage.setItem('oem-batch-builder-jobs-v3', JSON.stringify([payload]));
|
||||
location.reload();
|
||||
}, job);
|
||||
|
||||
await page.waitForSelector('#runBtn', { timeout: 15000 });
|
||||
await page.click('#runBtn');
|
||||
await page.waitForTimeout(60000);
|
||||
|
||||
const processAlive = !app.process().killed;
|
||||
const windows = app.windows().filter(win => !win.isClosed());
|
||||
const statusText = await page.locator('.status').first().textContent().catch(() => '');
|
||||
const logText = await page.locator('#logBox').textContent().catch(() => '');
|
||||
|
||||
console.log(JSON.stringify({
|
||||
processAlive,
|
||||
windowCount: windows.length,
|
||||
statusText,
|
||||
logTail: String(logText || '').slice(-1200),
|
||||
}, null, 2));
|
||||
|
||||
if (!processAlive || windows.length === 0) {
|
||||
throw new Error('OEM build:win UI exited or window closed');
|
||||
}
|
||||
|
||||
await app.close();
|
||||
})();
|
||||
@@ -0,0 +1,86 @@
|
||||
const { _electron: electron } = require('playwright');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const projectRoot = path.resolve(__dirname, '..', '..');
|
||||
const electronExe = path.join(projectRoot, 'node_modules', 'electron', 'dist', 'electron.exe');
|
||||
const mainScript = path.join(projectRoot, 'tools', 'oem-batch-builder', 'main.cjs');
|
||||
const assetsDir = path.join(projectRoot, 'oem-assets');
|
||||
const smokeUserDataDir = path.join(projectRoot, 'tools', 'oem-batch-builder', 'runs', 'ui-smoke-user-data');
|
||||
|
||||
function asset(name) {
|
||||
const filePath = path.join(assetsDir, name);
|
||||
if (!fs.existsSync(filePath)) throw new Error(`Missing test asset: ${filePath}`);
|
||||
return filePath;
|
||||
}
|
||||
|
||||
async function runCase(label, buildCommand, waitMs) {
|
||||
const app = await electron.launch({
|
||||
executablePath: electronExe,
|
||||
args: [`--user-data-dir=${smokeUserDataDir}`, mainScript],
|
||||
cwd: projectRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
ELECTRON_ENABLE_LOGGING: '1',
|
||||
},
|
||||
});
|
||||
|
||||
const page = await app.firstWindow();
|
||||
await page.waitForSelector('#runBtn', { timeout: 15000 });
|
||||
|
||||
const job = {
|
||||
id: `ui-smoke-${Date.now()}`,
|
||||
enabled: true,
|
||||
brandName: 'UI Smoke Test',
|
||||
brandSlogan: 'Smoke',
|
||||
brandShortName: 'UISmokeTest',
|
||||
brandDisplayName: 'UI Smoke Test',
|
||||
apiBaseUrl: 'http://152.136.232.83:3002/api',
|
||||
version: '4.9.9',
|
||||
buildCommand,
|
||||
assets: {
|
||||
qrcodePath: asset('qrcode.png'),
|
||||
logoPath: asset('logo.png'),
|
||||
iconPath: asset('icon.png'),
|
||||
loginBgPath: asset('login-bg.png'),
|
||||
},
|
||||
status: 'idle',
|
||||
message: '',
|
||||
};
|
||||
|
||||
await page.evaluate((payload) => {
|
||||
localStorage.setItem('oem-batch-builder-jobs-v3', JSON.stringify([payload]));
|
||||
location.reload();
|
||||
}, job);
|
||||
|
||||
await page.waitForSelector('#runBtn', { timeout: 15000 });
|
||||
await page.click('#runBtn');
|
||||
await page.waitForTimeout(waitMs);
|
||||
|
||||
const processAlive = !app.process().killed;
|
||||
const windows = app.windows().filter(win => !win.isClosed());
|
||||
const logText = await page.locator('#logBox').textContent().catch(() => '');
|
||||
const statusText = await page.locator('.status').first().textContent().catch(() => '');
|
||||
|
||||
console.log(JSON.stringify({
|
||||
label,
|
||||
processAlive,
|
||||
windowCount: windows.length,
|
||||
statusText,
|
||||
logTail: String(logText || '').slice(-1000),
|
||||
}, null, 2));
|
||||
|
||||
if (!processAlive || windows.length === 0) {
|
||||
throw new Error(`${label}: OEM tool exited or window closed`);
|
||||
}
|
||||
|
||||
await app.close();
|
||||
}
|
||||
|
||||
(async () => {
|
||||
await runCase('short-command', 'node -e "console.log(123)"', 5000);
|
||||
await runCase('npm-build-start', 'npm run build', 45000);
|
||||
})().catch(error => {
|
||||
console.error(error && error.stack ? error.stack : error);
|
||||
process.exit(1);
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 118 KiB |
@@ -0,0 +1,497 @@
|
||||
const { spawn } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const projectRoot = process.cwd();
|
||||
const jobsFile = path.resolve(projectRoot, process.argv[2]);
|
||||
const sessionDir = path.resolve(projectRoot, process.argv[3]);
|
||||
const outputDir = path.join(projectRoot, 'dist-release-final');
|
||||
const buildTempDir = path.join(projectRoot, 'build', 'oem-batch-temp');
|
||||
const logFile = path.join(sessionDir, 'worker.log');
|
||||
const resultFile = path.join(sessionDir, 'result.json');
|
||||
const buildOutputFile = path.join(sessionDir, 'build-output.log');
|
||||
const stageStatusFile = path.join(sessionDir, 'stage-status.json');
|
||||
|
||||
let resultWritten = false;
|
||||
let lastProgressLogAt = 0;
|
||||
const HEARTBEAT_INTERVAL_MS = 30000;
|
||||
const liveChildren = new Map();
|
||||
|
||||
function ensureSessionDir() {
|
||||
fs.mkdirSync(sessionDir, { recursive: true });
|
||||
fs.mkdirSync(buildTempDir, { recursive: true });
|
||||
}
|
||||
|
||||
function formatError(error) {
|
||||
if (!error) return 'Unknown error';
|
||||
if (error.stack) return error.stack;
|
||||
if (error.message) return error.message;
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function writeLog(payload) {
|
||||
ensureSessionDir();
|
||||
fs.appendFileSync(logFile, JSON.stringify({
|
||||
time: new Date().toISOString(),
|
||||
...payload,
|
||||
}) + '\n', 'utf8');
|
||||
}
|
||||
|
||||
function appendBuildOutput(chunk) {
|
||||
ensureSessionDir();
|
||||
fs.appendFileSync(buildOutputFile, chunk, 'utf8');
|
||||
}
|
||||
|
||||
function summarizeOutput(text) {
|
||||
const lines = String(text || '')
|
||||
.split(/\r?\n/)
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean);
|
||||
if (lines.length === 0) return '';
|
||||
const lastLine = lines[lines.length - 1];
|
||||
return lastLine.length > 700 ? `${lastLine.slice(0, 700)}...` : lastLine;
|
||||
}
|
||||
|
||||
function formatElapsed(ms) {
|
||||
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
|
||||
}
|
||||
|
||||
function stripAnsi(text) {
|
||||
return String(text || '').replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, '');
|
||||
}
|
||||
|
||||
function shouldMirrorOutput(text) {
|
||||
const value = stripAnsi(text);
|
||||
if (!value.trim()) return false;
|
||||
if (/^\s*(Compressing|Processing):\s+/m.test(value)) return false;
|
||||
if (/^\s*dist[\\/]/m.test(value)) return false;
|
||||
if (/^\s*dist-electron[\\/]/m.test(value)) return false;
|
||||
if (!/(error|failed|exception|eperm|eacces|ebusy|enoent|enotempty|cannot|unsafe)/i.test(value)) {
|
||||
if (value.includes('\\dist-release-final\\win-unpacked\\resources\\')) return false;
|
||||
if (value.includes('/dist-release-final/win-unpacked/resources/')) return false;
|
||||
}
|
||||
if (value.includes('[plugin:vite:reporter]')) return false;
|
||||
if (value.includes('dynamic import will not move module into another chunk')) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function writeCommandOutputLog(job, level, text) {
|
||||
appendBuildOutput(text);
|
||||
if (shouldMirrorOutput(text)) {
|
||||
writeLog({ jobId: job.id, level, message: text });
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (now - lastProgressLogAt < 5000) return;
|
||||
lastProgressLogAt = now;
|
||||
const summary = summarizeOutput(text);
|
||||
if (summary) {
|
||||
writeLog({ jobId: job.id, level: 'info', message: `[${level}] ${summary}` });
|
||||
}
|
||||
}
|
||||
|
||||
function writeResult(payload) {
|
||||
ensureSessionDir();
|
||||
fs.writeFileSync(resultFile, JSON.stringify(payload, null, 2), 'utf8');
|
||||
resultWritten = true;
|
||||
}
|
||||
|
||||
function writeStageStatus(payload) {
|
||||
ensureSessionDir();
|
||||
fs.writeFileSync(stageStatusFile, JSON.stringify({
|
||||
time: new Date().toISOString(),
|
||||
...payload,
|
||||
}, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
function sanitizeIdentifier(value, fallback) {
|
||||
const normalized = String(value || '')
|
||||
.trim()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^\w.-]/g, '');
|
||||
return normalized || fallback;
|
||||
}
|
||||
|
||||
function jsString(value) {
|
||||
return JSON.stringify(String(value || ''));
|
||||
}
|
||||
|
||||
function normalizeApiBaseUrl(value) {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) return '';
|
||||
const normalized = raw
|
||||
.replace(/\/+$/, '')
|
||||
.replace(/\/+api$/i, '/api');
|
||||
return /\/api$/i.test(normalized) ? normalized : `${normalized}/api`;
|
||||
}
|
||||
|
||||
function validateJob(job) {
|
||||
if (!job.brandName || !job.brandName.trim()) {
|
||||
throw new Error('Software name is required.');
|
||||
}
|
||||
job.apiBaseUrl = normalizeApiBaseUrl(job.apiBaseUrl);
|
||||
if (!job.apiBaseUrl) {
|
||||
throw new Error('API base URL is required.');
|
||||
}
|
||||
try {
|
||||
new URL(job.apiBaseUrl);
|
||||
} catch {
|
||||
throw new Error(`Invalid API base URL: ${job.apiBaseUrl}`);
|
||||
}
|
||||
if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(job.version || '')) {
|
||||
throw new Error(`Invalid version: ${job.version || '(empty)'}`);
|
||||
}
|
||||
|
||||
const assets = job.assets || {};
|
||||
const required = [
|
||||
['qrcodePath', 'QR code'],
|
||||
['logoPath', 'Logo'],
|
||||
['iconPath', 'Icon'],
|
||||
['loginBgPath', 'Login background'],
|
||||
];
|
||||
for (const [key, label] of required) {
|
||||
if (!assets[key] || !fs.existsSync(assets[key])) {
|
||||
throw new Error(`${label} file does not exist: ${assets[key] || '(empty)'}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function repairKnownMojibakePath(filePath) {
|
||||
return String(filePath || '')
|
||||
.replace(/cursor澶囦唤/g, 'cursor备份')
|
||||
.replace(/cursor锟斤拷锟斤拷/g, 'cursor备份');
|
||||
}
|
||||
|
||||
function repairJobPaths(job) {
|
||||
if (!job.assets) return;
|
||||
for (const key of ['qrcodePath', 'logoPath', 'iconPath', 'loginBgPath']) {
|
||||
job.assets[key] = repairKnownMojibakePath(job.assets[key]);
|
||||
}
|
||||
}
|
||||
|
||||
function copyAssets(job) {
|
||||
const assetsDir = path.join(projectRoot, 'oem-assets');
|
||||
fs.mkdirSync(assetsDir, { recursive: true });
|
||||
fs.copyFileSync(job.assets.qrcodePath, path.join(assetsDir, 'qrcode.png'));
|
||||
fs.copyFileSync(job.assets.logoPath, path.join(assetsDir, 'logo.png'));
|
||||
fs.copyFileSync(job.assets.iconPath, path.join(assetsDir, 'icon.png'));
|
||||
fs.copyFileSync(job.assets.loginBgPath, path.join(assetsDir, 'login-bg.png'));
|
||||
}
|
||||
|
||||
function writeOemConfig(job) {
|
||||
const shortName = sanitizeIdentifier(job.brandShortName || job.brandName, 'OEMApp');
|
||||
const displayName = job.brandDisplayName && job.brandDisplayName.trim()
|
||||
? job.brandDisplayName.trim()
|
||||
: job.brandName.trim();
|
||||
const slogan = job.brandSlogan && job.brandSlogan.trim() ? job.brandSlogan.trim() : '';
|
||||
|
||||
const content = `module.exports = {
|
||||
appVersion: ${jsString(job.version)},
|
||||
|
||||
brandName: ${jsString(job.brandName)},
|
||||
brandSlogan: ${jsString(slogan)},
|
||||
brandShortName: ${jsString(shortName)},
|
||||
brandDisplayName: ${jsString(displayName)},
|
||||
|
||||
qrcodePath: "./oem-assets/qrcode.png",
|
||||
logoPath: "./oem-assets/logo.png",
|
||||
iconPath: "./oem-assets/icon.png",
|
||||
loginBgPath: "./oem-assets/login-bg.png",
|
||||
|
||||
apiBaseUrl: ${jsString(job.apiBaseUrl)},
|
||||
|
||||
website: "",
|
||||
websiteGithub: "",
|
||||
websiteGitee: "",
|
||||
};
|
||||
`;
|
||||
fs.writeFileSync(path.join(projectRoot, 'oem-config.cjs'), content, 'utf8');
|
||||
}
|
||||
|
||||
function listArtifacts(afterMs) {
|
||||
if (!fs.existsSync(outputDir)) return [];
|
||||
const releaseAssetsDir = path.join(outputDir, 'release-assets');
|
||||
const searchRoot = fs.existsSync(releaseAssetsDir) ? releaseAssetsDir : outputDir;
|
||||
const files = [];
|
||||
const walk = (dir) => {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === 'win-unpacked') continue;
|
||||
walk(full);
|
||||
} else if (entry.isFile()) {
|
||||
files.push(full);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(searchRoot);
|
||||
return files
|
||||
.filter(filePath => {
|
||||
try {
|
||||
const stat = fs.statSync(filePath);
|
||||
return stat.isFile() && stat.mtimeMs >= afterMs - 3000;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
.filter(filePath => /\.(exe|msi|dmg|zip|7z|blockmap|yml)$/i.test(filePath))
|
||||
.sort();
|
||||
}
|
||||
|
||||
function resolveBuildCommand(command) {
|
||||
const trimmed = String(command || '').trim();
|
||||
const npmRunMatch = /^npm(?:\.cmd)?\s+run\s+([^\s]+)$/i.exec(trimmed);
|
||||
if (npmRunMatch) {
|
||||
return { file: 'cmd.exe', args: ['/d', '/s', '/c', `npm.cmd run ${npmRunMatch[1]}`], shell: false };
|
||||
}
|
||||
return { file: 'cmd.exe', args: ['/d', '/s', '/c', trimmed], shell: false };
|
||||
}
|
||||
|
||||
function resolveExternalNsisDir() {
|
||||
const nsisDir = 'C:\\Program Files (x86)\\NSIS';
|
||||
if (fs.existsSync(path.join(nsisDir, 'Bin', 'makensis.exe'))) {
|
||||
return nsisDir;
|
||||
}
|
||||
return process.env.ELECTRON_BUILDER_NSIS_DIR || '';
|
||||
}
|
||||
|
||||
function runCommand(command, job, label = command) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const startMs = Date.now();
|
||||
lastProgressLogAt = 0;
|
||||
const resolved = resolveBuildCommand(command);
|
||||
writeStageStatus({ jobId: job.id, stage: label, command, status: 'running', startedAt: new Date().toISOString() });
|
||||
writeLog({ jobId: job.id, level: 'info', message: `Run build command: ${label}` });
|
||||
writeLog({ jobId: job.id, level: 'info', message: `Spawn: ${resolved.file} ${resolved.args.join(' ')}` });
|
||||
|
||||
const child = spawn(resolved.file, resolved.args, {
|
||||
cwd: projectRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
APP_VERSION: job.version,
|
||||
OEM_VERSION: job.version,
|
||||
OEM_BATCH_BUILD: '1',
|
||||
EXTERNAL_ELECTRON_DEV: '1',
|
||||
TEMP: buildTempDir,
|
||||
TMP: buildTempDir,
|
||||
TMPDIR: buildTempDir,
|
||||
ELECTRON_BUILDER_NSIS_DIR: resolveExternalNsisDir(),
|
||||
},
|
||||
windowsHide: true,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
shell: resolved.shell,
|
||||
});
|
||||
if (child.pid) liveChildren.set(child.pid, label);
|
||||
|
||||
writeLog({ jobId: job.id, level: 'info', message: `Build process pid=${child.pid || 'unknown'}` });
|
||||
|
||||
const heartbeatTimer = setInterval(() => {
|
||||
writeLog({
|
||||
jobId: job.id,
|
||||
level: 'info',
|
||||
message: `Still running: ${label} elapsed ${formatElapsed(Date.now() - startMs)}`,
|
||||
});
|
||||
}, HEARTBEAT_INTERVAL_MS);
|
||||
if (typeof heartbeatTimer.unref === 'function') heartbeatTimer.unref();
|
||||
|
||||
child.stdout.on('data', data => {
|
||||
const text = data.toString();
|
||||
writeCommandOutputLog(job, 'stdout', text);
|
||||
});
|
||||
child.stderr.on('data', data => {
|
||||
const text = data.toString();
|
||||
writeCommandOutputLog(job, 'stderr', text);
|
||||
});
|
||||
child.on('error', error => {
|
||||
clearInterval(heartbeatTimer);
|
||||
if (child.pid) liveChildren.delete(child.pid);
|
||||
writeLog({ jobId: job.id, level: 'error', message: `Build process error: ${formatError(error)}` });
|
||||
writeStageStatus({ jobId: job.id, stage: label, command, status: 'error', error: formatError(error), finishedAt: new Date().toISOString() });
|
||||
reject(error);
|
||||
});
|
||||
child.on('exit', (code, signal) => {
|
||||
writeLog({ jobId: job.id, level: code === 0 ? 'info' : 'error', message: `Build process exit code=${code} signal=${signal || ''}` });
|
||||
});
|
||||
child.on('close', (code, signal) => {
|
||||
clearInterval(heartbeatTimer);
|
||||
if (child.pid) liveChildren.delete(child.pid);
|
||||
writeLog({ jobId: job.id, level: code === 0 ? 'info' : 'error', message: `Build process close code=${code} signal=${signal || ''}` });
|
||||
writeStageStatus({ jobId: job.id, stage: label, command, status: code === 0 ? 'success' : 'failed', code, signal, finishedAt: new Date().toISOString() });
|
||||
resolve({ code, signal, artifacts: listArtifacts(startMs) });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function runBuildCommand(command, job) {
|
||||
const normalized = String(command || '').trim().toLowerCase();
|
||||
if (normalized === 'npm run build:win' || normalized === 'npm.cmd run build:win') {
|
||||
const steps = [
|
||||
['clean oem package output', 'node scripts/clean-oem-package-output.cjs'],
|
||||
['prepare-package', 'node apply-oem.cjs && py -3 scripts/export-runtime-system-templates.py && node scripts/prepare-package.cjs'],
|
||||
['build renderer/main/preload', 'node scripts/clean-build-artifacts.cjs && node_modules\\.bin\\vite.cmd build'],
|
||||
['build resource artifacts', 'node scripts/build-resource-artifacts.cjs'],
|
||||
['make oem builder config', 'node scripts/make-oem-builder-config.cjs'],
|
||||
['electron-builder win x64', 'node_modules\\.bin\\electron-builder.cmd --config tools\\oem-batch-builder\\electron-builder.oem.generated.json --win --x64'],
|
||||
['hydrate oem portable bundles', 'node scripts/hydrate-oem-portable-bundles.cjs'],
|
||||
['build full offline installer', 'node scripts/build-oem-inno-installer.cjs'],
|
||||
['assemble release artifacts', 'node scripts/assemble-release-artifacts.cjs'],
|
||||
];
|
||||
|
||||
let artifacts = [];
|
||||
for (const [label, stepCommand] of steps) {
|
||||
const result = await runCommand(stepCommand, job, label);
|
||||
artifacts = result.artifacts || artifacts;
|
||||
if (result.code !== 0) {
|
||||
return { ...result, artifacts };
|
||||
}
|
||||
}
|
||||
return { code: 0, signal: null, artifacts: listArtifacts(Date.now() - 24 * 60 * 60 * 1000) };
|
||||
}
|
||||
|
||||
return runCommand(command, job);
|
||||
}
|
||||
|
||||
async function runOneJob(rawJob, index) {
|
||||
const job = {
|
||||
...rawJob,
|
||||
id: rawJob.id || `oem-${Date.now()}-${index}`,
|
||||
buildCommand: rawJob.buildCommand || 'npm run build:win',
|
||||
};
|
||||
|
||||
writeLog({ jobId: job.id, level: 'info', message: `Prepare ${job.brandName} v${job.version}` });
|
||||
repairJobPaths(job);
|
||||
validateJob(job);
|
||||
copyAssets(job);
|
||||
writeOemConfig(job);
|
||||
|
||||
const { code, signal, artifacts } = await runBuildCommand(job.buildCommand, job);
|
||||
const success = code === 0;
|
||||
const result = {
|
||||
id: job.id,
|
||||
brandName: job.brandName,
|
||||
version: job.version,
|
||||
success,
|
||||
code,
|
||||
signal,
|
||||
outputDir,
|
||||
buildOutputFile,
|
||||
artifacts,
|
||||
message: success ? 'Build finished' : `Build failed, exit code ${code}${signal ? `, signal ${signal}` : ''}`,
|
||||
};
|
||||
writeLog({ jobId: job.id, level: success ? 'info' : 'error', message: result.message });
|
||||
if (artifacts.length > 0) {
|
||||
writeLog({ jobId: job.id, level: 'info', message: `Artifacts:\n${artifacts.join('\n')}` });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
process.on('uncaughtException', error => {
|
||||
const message = formatError(error);
|
||||
try {
|
||||
writeLog({ level: 'error', message: `Worker uncaughtException: ${message}` });
|
||||
if (!resultWritten) {
|
||||
writeResult({
|
||||
success: false,
|
||||
results: [],
|
||||
outputDir,
|
||||
buildOutputFile,
|
||||
message: `Worker crashed: ${message}`,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', error => {
|
||||
const message = formatError(error);
|
||||
writeLog({ level: 'error', message: `Worker unhandledRejection: ${message}` });
|
||||
if (!resultWritten) {
|
||||
writeResult({
|
||||
success: false,
|
||||
results: [],
|
||||
outputDir,
|
||||
buildOutputFile,
|
||||
message: `Worker rejected: ${message}`,
|
||||
});
|
||||
}
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
process.on('exit', code => {
|
||||
try {
|
||||
writeLog({ level: 'info', message: `Worker exit code=${code} resultWritten=${resultWritten} liveChildren=${JSON.stringify([...liveChildren.entries()])}` });
|
||||
} catch {
|
||||
// ignore exit logging failure
|
||||
}
|
||||
if (!resultWritten) {
|
||||
try {
|
||||
writeResult({
|
||||
success: false,
|
||||
results: [],
|
||||
outputDir,
|
||||
buildOutputFile,
|
||||
message: `Worker exited before writing a result. Exit code ${code}. See ${logFile}`,
|
||||
});
|
||||
} catch {
|
||||
// Exit handlers cannot reliably recover from filesystem failures.
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
process.on('beforeExit', code => {
|
||||
writeLog({ level: 'info', message: `Worker beforeExit code=${code} resultWritten=${resultWritten} liveChildren=${JSON.stringify([...liveChildren.entries()])}` });
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
writeLog({ level: 'error', message: `Worker received SIGTERM resultWritten=${resultWritten} liveChildren=${JSON.stringify([...liveChildren.entries()])}` });
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
writeLog({ level: 'error', message: `Worker received SIGINT resultWritten=${resultWritten} liveChildren=${JSON.stringify([...liveChildren.entries()])}` });
|
||||
});
|
||||
|
||||
(async () => {
|
||||
ensureSessionDir();
|
||||
const parsedJobs = JSON.parse(fs.readFileSync(jobsFile, 'utf8').replace(/^\uFEFF/, ''));
|
||||
const jobs = Array.isArray(parsedJobs) ? parsedJobs : [parsedJobs];
|
||||
const results = [];
|
||||
|
||||
writeLog({ level: 'info', message: `Batch started, jobs=${jobs.length}` });
|
||||
for (let i = 0; i < jobs.length; i++) {
|
||||
try {
|
||||
results.push(await runOneJob(jobs[i], i));
|
||||
} catch (error) {
|
||||
const failed = {
|
||||
id: jobs[i] && jobs[i].id ? jobs[i].id : `oem-${i}`,
|
||||
brandName: jobs[i] && jobs[i].brandName ? jobs[i].brandName : `OEM ${i + 1}`,
|
||||
success: false,
|
||||
outputDir,
|
||||
buildOutputFile,
|
||||
artifacts: [],
|
||||
message: formatError(error),
|
||||
};
|
||||
results.push(failed);
|
||||
writeLog({ jobId: failed.id, level: 'error', message: failed.message });
|
||||
}
|
||||
}
|
||||
|
||||
const payload = { success: results.every(item => item.success), results, outputDir, buildOutputFile };
|
||||
writeResult(payload);
|
||||
writeLog({ level: 'info', message: 'Batch finished' });
|
||||
})().catch(error => {
|
||||
const payload = {
|
||||
success: false,
|
||||
results: [],
|
||||
outputDir,
|
||||
buildOutputFile,
|
||||
message: formatError(error),
|
||||
};
|
||||
writeResult(payload);
|
||||
writeLog({ level: 'error', message: payload.message });
|
||||
process.exitCode = 1;
|
||||
});
|
||||
Reference in New Issue
Block a user