Initial clean project import

This commit is contained in:
cat-shark
2026-06-19 18:41:41 +08:00
commit a13b804c7a
1306 changed files with 220568 additions and 0 deletions
@@ -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"
}
+85
View File
@@ -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)"
}
+29
View File
@@ -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
+741
View File
@@ -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 || ''}`);
});
+6
View File
@@ -0,0 +1,6 @@
{
"name": "oem-batch-builder-tool",
"version": "1.0.0",
"main": "main.cjs",
"private": true
}
+20
View File
@@ -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);
},
});
+450
View File
@@ -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) }));
+27
View File
@@ -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%"
+236
View File
@@ -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();
})();
+86
View File
@@ -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

+497
View File
@@ -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;
});