fix: 生成可验证的原生Windows安装包

This commit is contained in:
yu 2026-10-27 18:16:03 +08:00
parent dd27ec27f2
commit 12de8fbf58
2 changed files with 502 additions and 36 deletions

View File

@ -0,0 +1,378 @@
#include <windows.h>
#include <shellapi.h>
#include <shlobj.h>
#include <shobjidl.h>
#include <tlhelp32.h>
#include <algorithm>
#include <chrono>
#include <cstdio>
#include <cstring>
#include <cwctype>
#include <cstdint>
#include <filesystem>
#include <fstream>
#include <string>
#include <thread>
#include <vector>
namespace fs = std::filesystem;
namespace {
constexpr char kMagic[] = "RPASTUDIOPAYLOAD";
constexpr std::size_t kMagicLength = 16;
constexpr std::size_t kFooterLength = sizeof(std::uint64_t) * 4 + kMagicLength;
struct Options {
bool silent = false;
bool noLaunch = false;
bool noShortcut = false;
fs::path target;
};
struct Footer {
std::uint64_t cliLength = 0;
std::uint64_t guiLength = 0;
std::uint64_t dllLength = 0;
std::uint64_t payloadLength = 0;
};
fs::path g_logPath;
std::wstring lower(std::wstring value) {
std::transform(value.begin(), value.end(), value.begin(), towlower);
return value;
}
std::string utf8(const std::wstring& value) {
if (value.empty()) return {};
int size = WideCharToMultiByte(CP_UTF8, 0, value.data(), static_cast<int>(value.size()), nullptr, 0, nullptr, nullptr);
std::string output(size, '\0');
WideCharToMultiByte(CP_UTF8, 0, value.data(), static_cast<int>(value.size()), output.data(), size, nullptr, nullptr);
return output;
}
void logLine(const std::wstring& message) {
try {
SYSTEMTIME now{};
GetLocalTime(&now);
char timestamp[32]{};
std::snprintf(timestamp, sizeof(timestamp), "%04u-%02u-%02uT%02u:%02u:%02u ", now.wYear, now.wMonth,
now.wDay, now.wHour, now.wMinute, now.wSecond);
std::ofstream output(g_logPath, std::ios::binary | std::ios::app);
output << timestamp << utf8(message) << "\r\n";
} catch (...) {
}
}
[[noreturn]] void fail(const std::wstring& message) {
logLine(L"ERROR: " + message);
throw message;
}
fs::path modulePath() {
std::vector<wchar_t> buffer(32768);
DWORD length = GetModuleFileNameW(nullptr, buffer.data(), static_cast<DWORD>(buffer.size()));
if (length == 0 || length >= buffer.size()) fail(L"无法读取安装包路径。");
return fs::path(std::wstring(buffer.data(), length));
}
fs::path environmentPath(const wchar_t* name) {
DWORD required = GetEnvironmentVariableW(name, nullptr, 0);
if (required == 0) return {};
std::vector<wchar_t> buffer(required);
GetEnvironmentVariableW(name, buffer.data(), required);
return fs::path(buffer.data());
}
Options parseOptions() {
Options options;
int count = 0;
LPWSTR* arguments = CommandLineToArgvW(GetCommandLineW(), &count);
if (!arguments) fail(L"无法解析安装参数。");
for (int index = 1; index < count; ++index) {
std::wstring argument = arguments[index];
std::wstring normalized = lower(argument);
if (normalized == L"/s" || normalized == L"/q" || normalized == L"--silent" || normalized == L"--quiet") {
options.silent = true;
} else if (normalized == L"/nolaunch" || normalized == L"--no-launch") {
options.noLaunch = true;
} else if (normalized == L"/noshortcut" || normalized == L"--no-shortcut") {
options.noShortcut = true;
} else if (normalized.rfind(L"/target=", 0) == 0 || normalized.rfind(L"--target=", 0) == 0) {
std::size_t equals = argument.find(L'=');
options.target = fs::path(argument.substr(equals + 1));
}
}
LocalFree(arguments);
if (options.target.empty()) {
fs::path localAppData = environmentPath(L"LOCALAPPDATA");
if (localAppData.empty()) fail(L"无法读取 LOCALAPPDATA。");
options.target = localAppData / L"Programs" / L"RPA Studio";
}
options.target = fs::absolute(options.target).lexically_normal();
return options;
}
void readExact(std::ifstream& input, char* destination, std::uint64_t length) {
constexpr std::size_t bufferLimit = 1024 * 1024;
std::uint64_t remaining = length;
while (remaining > 0) {
std::streamsize chunk = static_cast<std::streamsize>(std::min<std::uint64_t>(remaining, bufferLimit));
input.read(destination, chunk);
if (input.gcount() != chunk) fail(L"安装包数据提前结束。");
destination += chunk;
remaining -= static_cast<std::uint64_t>(chunk);
}
}
Footer readFooter(std::ifstream& input, std::uint64_t fileLength) {
if (fileLength <= kFooterLength) fail(L"安装包缺少应用数据,请重新下载。");
input.seekg(static_cast<std::streamoff>(fileLength - kFooterLength), std::ios::beg);
Footer footer;
input.read(reinterpret_cast<char*>(&footer.cliLength), sizeof(footer.cliLength));
input.read(reinterpret_cast<char*>(&footer.guiLength), sizeof(footer.guiLength));
input.read(reinterpret_cast<char*>(&footer.dllLength), sizeof(footer.dllLength));
input.read(reinterpret_cast<char*>(&footer.payloadLength), sizeof(footer.payloadLength));
char magic[kMagicLength]{};
input.read(magic, kMagicLength);
if (!input || std::memcmp(magic, kMagic, kMagicLength) != 0) fail(L"安装包数据校验失败,请重新下载。");
if (footer.cliLength == 0 || footer.guiLength == 0 || footer.dllLength == 0 || footer.payloadLength == 0)
fail(L"安装包数据长度无效。");
std::uint64_t total = footer.cliLength + footer.guiLength + footer.dllLength + footer.payloadLength;
if (total + kFooterLength >= fileLength) fail(L"安装包载荷无效。");
return footer;
}
void writeSegment(std::ifstream& input, const fs::path& outputPath, std::uint64_t length) {
std::ofstream output(outputPath, std::ios::binary | std::ios::trunc);
if (!output) fail(L"无法创建临时文件:" + outputPath.wstring());
std::vector<char> buffer(1024 * 1024);
std::uint64_t remaining = length;
while (remaining > 0) {
std::streamsize chunk = static_cast<std::streamsize>(std::min<std::uint64_t>(remaining, buffer.size()));
input.read(buffer.data(), chunk);
if (input.gcount() != chunk) fail(L"安装包数据提前结束。");
output.write(buffer.data(), chunk);
if (!output) fail(L"无法写入临时文件:" + outputPath.wstring());
remaining -= static_cast<std::uint64_t>(chunk);
}
}
void extractPayload(const fs::path& temporaryRoot, fs::path& cli, fs::path& gui, fs::path& dll, fs::path& payload) {
fs::path self = modulePath();
std::ifstream input(self, std::ios::binary | std::ios::ate);
if (!input) fail(L"无法读取安装包。");
std::uint64_t fileLength = static_cast<std::uint64_t>(input.tellg());
Footer footer = readFooter(input, fileLength);
std::uint64_t payloadStart = fileLength - kFooterLength - footer.cliLength - footer.guiLength - footer.dllLength - footer.payloadLength;
input.seekg(static_cast<std::streamoff>(payloadStart), std::ios::beg);
cli = temporaryRoot / L"7z.exe";
gui = temporaryRoot / L"7zG.exe";
dll = temporaryRoot / L"7z.dll";
payload = temporaryRoot / L"payload.7z";
writeSegment(input, cli, footer.cliLength);
writeSegment(input, gui, footer.guiLength);
writeSegment(input, dll, footer.dllLength);
writeSegment(input, payload, footer.payloadLength);
}
DWORD runProcess(const fs::path& executable, const std::wstring& arguments, bool hidden) {
std::wstring command = L"\"" + executable.wstring() + L"\" " + arguments;
std::vector<wchar_t> mutableCommand(command.begin(), command.end());
mutableCommand.push_back(L'\0');
STARTUPINFOW startup{};
startup.cb = sizeof(startup);
if (hidden) {
startup.dwFlags = STARTF_USESHOWWINDOW;
startup.wShowWindow = SW_HIDE;
}
PROCESS_INFORMATION process{};
DWORD flags = hidden ? CREATE_NO_WINDOW : 0;
if (!CreateProcessW(executable.c_str(), mutableCommand.data(), nullptr, nullptr, FALSE, flags, nullptr,
executable.parent_path().c_str(), &startup, &process)) {
fail(L"无法启动解压程序,Windows 错误码:" + std::to_wstring(GetLastError()));
}
WaitForSingleObject(process.hProcess, INFINITE);
DWORD exitCode = 1;
GetExitCodeProcess(process.hProcess, &exitCode);
CloseHandle(process.hThread);
CloseHandle(process.hProcess);
return exitCode;
}
bool samePath(const fs::path& left, const fs::path& right) {
return lower(fs::absolute(left).lexically_normal().wstring()) == lower(fs::absolute(right).lexically_normal().wstring());
}
void stopInstalledApplication(const fs::path& targetExecutable) {
HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if (snapshot == INVALID_HANDLE_VALUE) return;
PROCESSENTRY32W entry{};
entry.dwSize = sizeof(entry);
if (Process32FirstW(snapshot, &entry)) {
do {
HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_TERMINATE, FALSE, entry.th32ProcessID);
if (!process) continue;
std::vector<wchar_t> buffer(32768);
DWORD length = static_cast<DWORD>(buffer.size());
if (QueryFullProcessImageNameW(process, 0, buffer.data(), &length) &&
samePath(fs::path(std::wstring(buffer.data(), length)), targetExecutable)) {
TerminateProcess(process, 0);
WaitForSingleObject(process, 5000);
}
CloseHandle(process);
} while (Process32NextW(snapshot, &entry));
}
CloseHandle(snapshot);
}
void removeDirectoryWithRetry(const fs::path& directory) {
if (!fs::exists(directory)) return;
std::error_code error;
for (int attempt = 0; attempt < 6; ++attempt) {
fs::remove_all(directory, error);
if (!error && !fs::exists(directory)) return;
std::this_thread::sleep_for(std::chrono::milliseconds(500 * (attempt + 1)));
}
fail(L"无法更新安装目录,请关闭正在运行的 RPA Studio 后重试。\r\n" + directory.wstring());
}
void validateApplication(const fs::path& directory) {
fs::path executable = directory / L"RPA Studio.exe";
fs::path asar = directory / L"resources" / L"app.asar";
if (!fs::exists(executable) || fs::file_size(executable) < 1024 * 1024) fail(L"RPA Studio.exe 缺失或不完整。");
if (!fs::exists(asar) || fs::file_size(asar) < 1024 * 1024) fail(L"resources\\app.asar 缺失或不完整。");
}
void createShortcut(const fs::path& targetExecutable, const fs::path& workingDirectory) {
HRESULT initialized = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
IShellLinkW* shellLink = nullptr;
HRESULT result = CoCreateInstance(CLSID_ShellLink, nullptr, CLSCTX_INPROC_SERVER, IID_IShellLinkW,
reinterpret_cast<void**>(&shellLink));
if (FAILED(result) || !shellLink) {
if (SUCCEEDED(initialized)) CoUninitialize();
fail(L"无法创建桌面快捷方式。");
}
shellLink->SetPath(targetExecutable.c_str());
shellLink->SetWorkingDirectory(workingDirectory.c_str());
shellLink->SetDescription(L"RPA Studio");
wchar_t desktop[MAX_PATH]{};
if (FAILED(SHGetFolderPathW(nullptr, CSIDL_DESKTOPDIRECTORY, nullptr, SHGFP_TYPE_CURRENT, desktop))) {
shellLink->Release();
if (SUCCEEDED(initialized)) CoUninitialize();
fail(L"无法定位桌面目录。");
}
IPersistFile* persist = nullptr;
result = shellLink->QueryInterface(IID_IPersistFile, reinterpret_cast<void**>(&persist));
if (SUCCEEDED(result) && persist) {
result = persist->Save((fs::path(desktop) / L"RPA Studio.lnk").c_str(), TRUE);
persist->Release();
}
shellLink->Release();
if (SUCCEEDED(initialized)) CoUninitialize();
if (FAILED(result)) fail(L"桌面快捷方式保存失败。");
}
void copyDirectory(const fs::path& source, const fs::path& destination) {
std::error_code error;
fs::rename(source, destination, error);
if (!error) return;
error.clear();
fs::copy(source, destination, fs::copy_options::recursive | fs::copy_options::overwrite_existing, error);
if (error) fail(L"无法写入安装目录:" + destination.wstring());
fs::remove_all(source, error);
}
void install(const Options& options) {
fs::path temp = environmentPath(L"TEMP");
if (temp.empty()) temp = environmentPath(L"TMP");
if (temp.empty()) fail(L"无法定位临时目录。");
fs::path temporaryRoot = temp / (L"RPA-Studio-Install-" + std::to_wstring(GetCurrentProcessId()));
fs::path stage = temporaryRoot / L"app";
fs::path targetExecutable = options.target / L"RPA Studio.exe";
removeDirectoryWithRetry(temporaryRoot);
fs::create_directories(temporaryRoot);
try {
logLine(L"正在准备安装文件...");
fs::path cli, gui, dll, payload;
extractPayload(temporaryRoot, cli, gui, dll, payload);
fs::create_directories(stage);
logLine(L"正在解压应用...");
fs::path extractor = options.silent ? cli : gui;
std::wstring arguments = L"x \"" + payload.wstring() + L"\" -o\"" + stage.wstring() + L"\" -y";
if (runProcess(extractor, arguments, options.silent) != 0) fail(L"应用文件解压失败。");
validateApplication(stage);
logLine(L"正在写入安装目录...");
stopInstalledApplication(targetExecutable);
removeDirectoryWithRetry(options.target);
fs::create_directories(options.target.parent_path());
copyDirectory(stage, options.target);
validateApplication(options.target);
if (!options.noShortcut) {
logLine(L"正在创建桌面快捷方式...");
createShortcut(targetExecutable, options.target);
}
try {
fs::copy_file(g_logPath, options.target / L"install.log", fs::copy_options::overwrite_existing);
} catch (...) {
}
removeDirectoryWithRetry(temporaryRoot);
if (!options.noLaunch) {
logLine(L"正在启动 RPA Studio...");
ShellExecuteW(nullptr, L"open", targetExecutable.c_str(), nullptr, options.target.c_str(), SW_SHOWNORMAL);
}
} catch (...) {
std::error_code ignored;
fs::remove_all(temporaryRoot, ignored);
throw;
}
}
} // namespace
int WINAPI wWinMain(HINSTANCE, HINSTANCE, PWSTR, int) {
fs::path temp = environmentPath(L"TEMP");
if (temp.empty()) temp = fs::temp_directory_path();
g_logPath = temp / (L"RPA-Studio-Install-" + std::to_wstring(GetCurrentProcessId()) + L".log");
bool silent = false;
try {
Options options = parseOptions();
silent = options.silent;
logLine(L"installer started");
if (!options.silent) {
std::wstring prompt = L"将 RPA Studio __PRODUCT_VERSION__ 安装到当前 Windows 用户?\r\n\r\n安装位置:" +
options.target.wstring();
if (MessageBoxW(nullptr, prompt.c_str(), L"RPA Studio 安装", MB_YESNO | MB_ICONQUESTION | MB_SETFOREGROUND) != IDYES)
return 0;
}
install(options);
logLine(L"installation completed");
if (!options.silent)
MessageBoxW(nullptr, L"RPA Studio 已安装完成。", L"RPA Studio 安装", MB_OK | MB_ICONINFORMATION | MB_SETFOREGROUND);
return 0;
} catch (const std::wstring& error) {
if (g_logPath.empty()) return 1;
if (GetFileAttributesW(g_logPath.c_str()) == INVALID_FILE_ATTRIBUTES) logLine(L"ERROR: " + error);
if (!silent)
MessageBoxW(nullptr,
(L"安装失败。\r\n\r\n" + error + L"\r\n\r\n诊断日志:" + g_logPath.wstring()).c_str(),
L"RPA Studio 安装失败", MB_OK | MB_ICONERROR | MB_SETFOREGROUND);
return 1;
} catch (...) {
logLine(L"ERROR: unknown installer failure");
if (!silent)
MessageBoxW(nullptr, L"安装失败,发生未知错误。", L"RPA Studio 安装失败", MB_OK | MB_ICONERROR | MB_SETFOREGROUND);
return 1;
}
}

View File

@ -1,6 +1,7 @@
import { spawnSync } from "node:child_process";
import { spawn, spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import process from "node:process";
import AdmZip from "adm-zip";
@ -9,13 +10,18 @@ import packager from "@electron/packager";
const root = process.cwd();
const packageJson = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
const version = packageJson.version;
const releaseRoot = path.resolve(root, "release");
const releaseRoot = path.resolve(root, "release", "dist");
const productFolderName = `RPA-Studio-${version}-win-x64`;
const productFolder = path.join(releaseRoot, productFolderName);
const zipPath = path.join(releaseRoot, `${productFolderName}.zip`);
const setupPath = path.join(releaseRoot, `RPA-Studio-Setup-${version}-win-x64.exe`);
const installerPayload = path.join(releaseRoot, ".installer-payload");
const installerSed = path.join(releaseRoot, ".installer.sed");
const installerPayloadArchive = path.join(releaseRoot, ".installer-payload.7z");
const installerBootstrap = path.join(releaseRoot, ".installer-bootstrap.exe");
const installerSource = path.join(root, "scripts", "installer", "Program.cpp");
const payloadMagic = Buffer.from("RPASTUDIOPAYLOAD", "ascii");
const electronZipDir = path.join(root, "node_modules", ".cache", "rpa-packager");
const electronZipPath = path.join(electronZipDir, `electron-v${packageJson.devDependencies.electron}-win32-x64.zip`);
const sevenZipRoot = process.env.RPA_7ZIP_DIR || "C:/Program Files/7-Zip";
function assertInsideRelease(target) {
const relative = path.relative(releaseRoot, path.resolve(target));
@ -29,8 +35,40 @@ function run(command, args) {
if (result.status !== 0) throw new Error(`${command} ${args.join(" ")} 失败,退出码 ${result.status}`);
}
function delay(milliseconds) {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
function killProcessTree(pid) {
if (process.platform === "win32") {
spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
}
}
function removeTree(target) {
fs.rmSync(target, { recursive: true, force: true, maxRetries: 10, retryDelay: 500 });
}
async function verifyLaunch(executable, label) {
const userData = path.join(os.tmpdir(), `RPA-Studio-UserData-${Date.now()}-${Math.random().toString(16).slice(2)}`);
const child = spawn(executable, [`--user-data-dir=${userData}`], {
cwd: path.dirname(executable),
stdio: "ignore",
windowsHide: false
});
await delay(8000);
const exited = child.exitCode !== null || child.signalCode !== null;
if (!exited) {
killProcessTree(child.pid);
await delay(2000);
}
removeTree(userData);
if (exited) throw new Error(`${label} 启动后提前退出,退出码 ${child.exitCode}`);
console.log(`[package] ${label} launch verified`);
}
fs.mkdirSync(releaseRoot, { recursive: true });
for (const target of [productFolder, zipPath, setupPath, installerPayload, installerSed]) {
for (const target of [productFolder, zipPath, setupPath, installerPayloadArchive, installerBootstrap]) {
assertInsideRelease(target);
fs.rmSync(target, { recursive: true, force: true });
}
@ -44,6 +82,16 @@ run("npm", ["run", "test:e2e"]);
console.log("[package] build");
run("npm", ["run", "build"]);
if (!fs.existsSync(electronZipPath)) {
console.log("[package] local electron runtime cache");
const electronDist = path.join(root, "node_modules", "electron", "dist");
if (!fs.existsSync(path.join(electronDist, "electron.exe"))) throw new Error(`本机 Electron 运行时不完整:${electronDist}`);
fs.mkdirSync(electronZipDir, { recursive: true });
const electronZip = new AdmZip();
electronZip.addLocalFolder(electronDist);
electronZip.writeZip(electronZipPath);
}
console.log("[package] electron packager");
const outputs = await packager({
dir: root,
@ -55,6 +103,7 @@ const outputs = await packager({
prune: true,
asar: true,
electronVersion: packageJson.devDependencies.electron,
electronZipDir,
appVersion: version,
win32metadata: {
CompanyName: "RPA Studio",
@ -69,6 +118,7 @@ const outputs = await packager({
/^[/\\]docs($|[/\\])/,
/^[/\\]scripts($|[/\\])/,
/^[/\\]src($|[/\\])/,
/^[/\\]node_modules[/\\]\.cache($|[/\\])/,
/\.test\.[cm]?[jt]sx?$/
]
});
@ -137,6 +187,15 @@ fs.writeFileSync(path.join(productFolder, "安装到本机.cmd"), [
"start \"RPA Studio\" \"%TARGET%\\RPA Studio.exe\"",
"exit /b 0"
].join("\r\n"), "utf8");
const sevenZipLicense = path.join(sevenZipRoot, "License.txt");
if (fs.existsSync(sevenZipLicense)) fs.copyFileSync(sevenZipLicense, path.join(productFolder, "第三方许可-7-Zip.txt"));
console.log("[package] portable smoke");
const portableSmokeRoot = fs.mkdtempSync(path.join(os.tmpdir(), "RPA-Studio-Portable-Smoke-"));
const portableSmokeFolder = path.join(portableSmokeRoot, productFolderName);
fs.cpSync(productFolder, portableSmokeFolder, { recursive: true });
await verifyLaunch(path.join(portableSmokeFolder, "RPA Studio.exe"), "portable");
removeTree(portableSmokeRoot);
console.log("[package] zip");
const zip = new AdmZip();
@ -145,38 +204,67 @@ zip.writeZip(zipPath);
const zipSha256 = createHash("sha256").update(fs.readFileSync(zipPath)).digest("hex");
let setupSha256 = "";
const iexpress = path.join(process.env.SystemRoot || "C:/Windows", "System32", "iexpress.exe");
if (!fs.existsSync(iexpress)) throw new Error(`找不到 Windows IExpress:${iexpress}`);
console.log("[package] setup iexpress");
fs.mkdirSync(installerPayload, { recursive: true });
fs.copyFileSync(zipPath, path.join(installerPayload, "payload.zip"));
fs.writeFileSync(path.join(installerPayload, "install.cmd"), [
"@echo off",
"chcp 65001 >nul",
"set \"TARGET=%LOCALAPPDATA%\\Programs\\RPA Studio\"",
"set \"STAGE=%TEMP%\\RPA-Studio-Install-%RANDOM%\"",
"powershell -NoProfile -ExecutionPolicy Bypass -Command \"Expand-Archive -LiteralPath '%~dp0payload.zip' -DestinationPath '%STAGE%' -Force\"",
`robocopy "%STAGE%\\${productFolderName}" "%TARGET%" /E /R:2 /W:1 /NFL /NDL /NJH /NJS /NP >nul`,
"powershell -NoProfile -ExecutionPolicy Bypass -Command \"$w=New-Object -ComObject WScript.Shell;$s=$w.CreateShortcut([IO.Path]::Combine([Environment]::GetFolderPath('Desktop'),'RPA Studio.lnk'));$s.TargetPath=[IO.Path]::Combine($env:LOCALAPPDATA,'Programs','RPA Studio','RPA Studio.exe');$s.WorkingDirectory=[IO.Path]::Combine($env:LOCALAPPDATA,'Programs','RPA Studio');$s.Save()\"",
"start \"RPA Studio\" \"%TARGET%\\RPA Studio.exe\"",
"rmdir /s /q \"%STAGE%\"",
"exit /b 0"
].join("\r\n"), "utf8");
const sourceDir = `${installerPayload.replace(/\//g, "\\")}\\`;
fs.writeFileSync(installerSed, [
"[Version]", "Class=IEXPRESS", "SEDVersion=3", "[Options]", "PackagePurpose=InstallApp", "ShowInstallProgramWindow=1",
"HideExtractAnimation=0", "UseLongFileName=1", "InsideCompressed=0", "CAB_FixedSize=0", "CAB_ResvCodeSigning=0",
"RebootMode=N", "InstallPrompt=%InstallPrompt%", "DisplayLicense=", "FinishMessage=%FinishMessage%",
`TargetName=${setupPath}`, `FriendlyName=RPA Studio ${version}`, "AppLaunched=install.cmd", "PostInstallCmd=<None>",
"AdminQuietInstCmd=install.cmd", "UserQuietInstCmd=install.cmd", "SourceFiles=SourceFiles", "[Strings]",
`InstallPrompt=Install RPA Studio ${version} for the current Windows user?`, "FinishMessage=RPA Studio installation completed.",
"FILE0=payload.zip", "FILE1=install.cmd", "[SourceFiles]", `SourceFiles0=${sourceDir}`, "[SourceFiles0]", "%FILE0%=", "%FILE1%=", ""
].join("\r\n"), "utf8");
const builtSetup = spawnSync(iexpress, ["/N", installerSed], { cwd: releaseRoot, stdio: "inherit", windowsHide: true });
if (builtSetup.status !== 0 || !fs.existsSync(setupPath)) throw new Error(`IExpress 安装包生成失败,退出码 ${builtSetup.status}`);
console.log("[package] setup bootstrap");
const generatedInstallerSource = path.join(releaseRoot, ".installer.generated.cpp");
const source = fs.readFileSync(installerSource, "utf8").replaceAll("__PRODUCT_VERSION__", version);
fs.writeFileSync(generatedInstallerSource, source, "utf8");
const compiler = process.env.CXX || "g++";
const compiled = spawnSync(compiler, [
"-std=c++17", "-O2", "-static", "-mwindows", "-municode", generatedInstallerSource,
"-o", installerBootstrap, "-lole32", "-lshell32", "-luuid"
], { cwd: root, stdio: "inherit", windowsHide: true });
if (compiled.status !== 0 || compiled.error || !fs.existsSync(installerBootstrap)) {
throw new Error(`原生安装器编译失败,退出码 ${compiled.status}:${compiled.error?.message || "unknown error"}`);
}
const sevenCli = path.join(sevenZipRoot, "7z.exe");
const sevenGui = path.join(sevenZipRoot, "7zG.exe");
const sevenDll = path.join(sevenZipRoot, "7z.dll");
for (const required of [sevenCli, sevenGui, sevenDll]) {
if (!fs.existsSync(required)) throw new Error(`安装器缺少 7-Zip 构建依赖:${required}`);
}
const archived = spawnSync(sevenCli, ["a", "-t7z", installerPayloadArchive, ".", "-mx=9", "-mmt=on"], {
cwd: productFolder,
stdio: "inherit",
windowsHide: true
});
if (archived.status !== 0 || !fs.existsSync(installerPayloadArchive)) throw new Error(`安装载荷压缩失败,退出码 ${archived.status}`);
const setupParts = [installerBootstrap, sevenCli, sevenGui, sevenDll, installerPayloadArchive].map((file) => fs.readFileSync(file));
const lengthBuffers = setupParts.slice(1).map((content) => {
const buffer = Buffer.alloc(8);
buffer.writeBigUInt64LE(BigInt(content.length));
return buffer;
});
fs.writeFileSync(setupPath, Buffer.concat([
...setupParts,
...lengthBuffers,
payloadMagic
]));
setupSha256 = createHash("sha256").update(fs.readFileSync(setupPath)).digest("hex");
fs.rmSync(installerPayload, { recursive: true, force: true });
fs.rmSync(installerSed, { force: true });
for (const target of [installerPayloadArchive, installerBootstrap, generatedInstallerSource]) {
assertInsideRelease(target);
fs.rmSync(target, { recursive: true, force: true });
}
console.log("[package] installer smoke");
const installSmokeRoot = fs.mkdtempSync(path.join(os.tmpdir(), "RPA-Studio-Installer-Smoke-"));
const installSmokeSetup = path.join(installSmokeRoot, path.basename(setupPath));
const installSmokeTarget = path.join(installSmokeRoot, "RPA Studio");
fs.copyFileSync(setupPath, installSmokeSetup);
const installed = spawnSync(installSmokeSetup, ["/S", "/NOLAUNCH", "/NOSHORTCUT", `/TARGET=${installSmokeTarget}`], {
cwd: installSmokeRoot,
stdio: "inherit",
windowsHide: true,
timeout: 180000
});
if (installed.status !== 0 || installed.error) {
throw new Error(`安装包冒烟失败,退出码 ${installed.status}:${installed.error?.message || "unknown error"}`);
}
if (!fs.existsSync(path.join(installSmokeTarget, "RPA Studio.exe"))) throw new Error("安装包冒烟失败:未生成主程序");
await verifyLaunch(path.join(installSmokeTarget, "RPA Studio.exe"), "installed");
removeTree(installSmokeRoot);
const sums = [`${zipSha256} ${path.basename(zipPath)}`];
if (setupSha256) sums.push(`${setupSha256} ${path.basename(setupPath)}`);
fs.writeFileSync(path.join(releaseRoot, "SHA256SUMS.txt"), `${sums.join("\r\n")}\r\n`, "utf8");