build: 采用electron-builder标准NSIS安装器

This commit is contained in:
yu 2026-10-28 09:53:30 +08:00
parent f675cc0086
commit cf05e4aa6f
7 changed files with 1962 additions and 1092 deletions

BIN
build/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

@ -0,0 +1,48 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import os from "node:os";
import process from "node:process";
import { fileURLToPath } from "node:url";
const root = path.dirname(fileURLToPath(import.meta.url));
const packageJson = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8"));
/** @type {import('electron-builder').Configuration} */
export default {
appId: "com.rpastudio.desktop",
productName: "RPA Studio",
artifactName: "RPA-Studio-Setup-${version}-win-${arch}.${ext}",
directories: {
output: path.join(os.tmpdir(), "RPA-Studio-electron-builder"),
buildResources: path.join(root, "build")
},
files: [
"out/**/*",
"package.json"
],
asar: true,
compression: "maximum",
npmRebuild: false,
electronVersion: packageJson.devDependencies.electron,
electronDist: path.join(root, "node_modules", "electron", "dist"),
win: {
target: [{ target: "nsis", arch: ["x64"] }],
icon: path.join(root, "build", "icon.ico"),
signAndEditExecutable: true
},
nsis: {
oneClick: false,
perMachine: false,
allowElevation: false,
allowToChangeInstallationDirectory: true,
installerLanguages: ["zh_CN"],
language: "2052",
shortcutName: "RPA Studio",
uninstallDisplayName: "RPA Studio ${version}",
createDesktopShortcut: true,
createStartMenuShortcut: true,
runAfterFinish: true,
deleteAppDataOnUninstall: false
},
publish: null
};

2323
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -30,7 +30,6 @@
"zustand": "^5.0.2"
},
"devDependencies": {
"@electron/packager": "^18.3.6",
"@types/adm-zip": "^0.5.7",
"@types/node": "^22.10.2",
"@types/nodemailer": "^6.4.17",
@ -39,9 +38,11 @@
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.7.0",
"electron": "31.7.7",
"electron-builder": "^26.15.7",
"electron-vite": "^6.0.0",
"typescript": "^5.7.2",
"vite": "^7.1.0",
"vitest": "^2.1.8"
}
},
"author": "RPA Studio"
}

79
scripts/generate-icon.mjs Normal file
View File

@ -0,0 +1,79 @@
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { PNG } from "pngjs";
const root = process.cwd();
const output = path.join(root, "build", "icon.ico");
const size = 256;
const png = new PNG({ width: size, height: size });
const glyph = [
"0111000000",
"1000100000",
"1000100000",
"1000100000",
"1111100000",
"1000100000",
"1000100000",
"1000100000",
"1000100000",
"1000100000"
];
function insideRoundedSquare(x, y, radius) {
const clampX = Math.min(Math.max(x, radius), size - 1 - radius);
const clampY = Math.min(Math.max(y, radius), size - 1 - radius);
return (x - clampX) ** 2 + (y - clampY) ** 2 <= radius ** 2;
}
for (let y = 0; y < size; y += 1) {
for (let x = 0; x < size; x += 1) {
const index = (size * y + x) << 2;
const visible = insideRoundedSquare(x, y, 34);
const gradient = (x + y) / (2 * (size - 1));
png.data[index] = visible ? Math.round(37 + (30 - 37) * gradient) : 0;
png.data[index + 1] = visible ? Math.round(99 + (58 - 99) * gradient) : 0;
png.data[index + 2] = visible ? Math.round(235 + (138 - 235) * gradient) : 0;
png.data[index + 3] = visible ? 255 : 0;
if (visible) {
const glyphX = Math.round((x - 78) / 10);
const glyphY = Math.round((y - 68) / 12);
if (glyphY >= 0 && glyphY < glyph.length && glyphX >= 0 && glyphX < glyph[glyphY].length && glyph[glyphY][glyphX] === "1") {
png.data[index] = 255;
png.data[index + 1] = 255;
png.data[index + 2] = 255;
png.data[index + 3] = 255;
}
}
}
}
const chunks = [];
png.pack()
.on("data", (chunk) => chunks.push(chunk))
.on("end", () => {
const image = Buffer.concat(chunks);
const directory = Buffer.alloc(6);
directory.writeUInt16LE(0, 0);
directory.writeUInt16LE(1, 2);
directory.writeUInt16LE(1, 4);
const entry = Buffer.alloc(16);
entry[0] = 0;
entry[1] = 0;
entry[2] = 0;
entry[3] = 0;
entry.writeUInt16LE(1, 4);
entry.writeUInt16LE(32, 6);
entry.writeUInt32LE(image.length, 8);
entry.writeUInt32LE(22, 12);
fs.mkdirSync(path.dirname(output), { recursive: true });
fs.writeFileSync(output, Buffer.concat([directory, entry, image]));
console.log(`[icon] generated ${output} (${image.length + 22} bytes)`);
})
.on("error", (error) => {
console.error(error);
process.exitCode = 1;
});

View File

@ -1,445 +0,0 @@
#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 缺失或不完整。");
}
bool chooseInstallDirectory(const fs::path& currentTarget, fs::path& selectedTarget) {
HRESULT initialized = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
IFileOpenDialog* dialog = nullptr;
HRESULT result = CoCreateInstance(CLSID_FileOpenDialog, nullptr, CLSCTX_INPROC_SERVER, IID_IFileOpenDialog,
reinterpret_cast<void**>(&dialog));
if (FAILED(result) || !dialog) {
if (SUCCEEDED(initialized)) CoUninitialize();
fail(L"无法打开安装目录选择窗口。");
}
FILEOPENDIALOGOPTIONS options = 0;
dialog->GetOptions(&options);
dialog->SetOptions(options | FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_PATHMUSTEXIST | FOS_DONTADDTORECENT);
dialog->SetTitle(L"选择 RPA Studio 的安装位置");
dialog->SetOkButtonLabel(L"选择此文件夹");
dialog->SetFileName(L"RPA Studio");
fs::path initial = currentTarget.parent_path();
while (!initial.empty() && !fs::exists(initial)) initial = initial.parent_path();
if (!initial.empty()) {
IShellItem* initialItem = nullptr;
if (SUCCEEDED(SHCreateItemFromParsingName(initial.c_str(), nullptr, IID_IShellItem,
reinterpret_cast<void**>(&initialItem))) && initialItem) {
dialog->SetFolder(initialItem);
initialItem->Release();
}
}
result = dialog->Show(nullptr);
if (result == HRESULT_FROM_WIN32(ERROR_CANCELLED)) {
dialog->Release();
if (SUCCEEDED(initialized)) CoUninitialize();
return false;
}
if (FAILED(result)) {
dialog->Release();
if (SUCCEEDED(initialized)) CoUninitialize();
fail(L"安装目录选择失败。");
}
IShellItem* selectedItem = nullptr;
result = dialog->GetResult(&selectedItem);
dialog->Release();
if (FAILED(result) || !selectedItem) {
if (SUCCEEDED(initialized)) CoUninitialize();
fail(L"无法读取所选安装目录。");
}
PWSTR selectedPath = nullptr;
result = selectedItem->GetDisplayName(SIGDN_FILESYSPATH, &selectedPath);
selectedItem->Release();
if (FAILED(result) || !selectedPath) {
if (SUCCEEDED(initialized)) CoUninitialize();
fail(L"所选位置不是有效的本地文件夹。");
}
fs::path parent(selectedPath);
CoTaskMemFree(selectedPath);
if (SUCCEEDED(initialized)) CoUninitialize();
selectedTarget = lower(parent.filename().wstring()) == L"rpa studio" ? parent : parent / L"RPA Studio";
selectedTarget = fs::absolute(selectedTarget).lexically_normal();
return true;
}
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) {
fs::path selectedTarget;
if (!chooseInstallDirectory(options.target, selectedTarget)) return 0;
options.target = selectedTarget;
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

@ -5,23 +5,18 @@ import os from "node:os";
import path from "node:path";
import process from "node:process";
import AdmZip from "adm-zip";
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", "dist");
const builderWork = path.join(os.tmpdir(), "RPA-Studio-electron-builder");
const builderUnpacked = path.join(builderWork, "win-unpacked");
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 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";
const builderSetupPath = path.join(builderWork, `RPA-Studio-Setup-${version}-win-x64.exe`);
function assertInsideRelease(target) {
const relative = path.relative(releaseRoot, path.resolve(target));
@ -68,10 +63,18 @@ async function verifyLaunch(executable, label) {
}
fs.mkdirSync(releaseRoot, { recursive: true });
for (const target of [productFolder, zipPath, setupPath, installerPayloadArchive, installerBootstrap]) {
for (const entry of fs.readdirSync(releaseRoot)) {
if (entry.startsWith(".installer")) {
const legacyTarget = path.join(releaseRoot, entry);
assertInsideRelease(legacyTarget);
fs.rmSync(legacyTarget, { recursive: true, force: true });
}
}
for (const target of [productFolder, zipPath, setupPath]) {
assertInsideRelease(target);
fs.rmSync(target, { recursive: true, force: true });
}
removeTree(builderWork);
console.log("[package] typecheck");
run("npm", ["run", "typecheck"]);
@ -82,55 +85,31 @@ 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);
const electronDist = path.join(root, "node_modules", "electron", "dist");
if (!fs.existsSync(path.join(electronDist, "electron.exe"))) {
throw new Error(`本机 Electron 运行时不完整:${electronDist}`);
}
console.log("[package] electron packager");
const outputs = await packager({
dir: root,
name: "RPA Studio",
platform: "win32",
arch: "x64",
out: releaseRoot,
overwrite: true,
prune: true,
asar: true,
electronVersion: packageJson.devDependencies.electron,
electronZipDir,
appVersion: version,
win32metadata: {
CompanyName: "RPA Studio",
FileDescription: "RPA Studio Desktop Automation",
ProductName: "RPA Studio",
InternalName: "RPA Studio"
},
ignore: [
/^[/\\]\.git($|[/\\])/,
/^[/\\]release($|[/\\])/,
/^[/\\]_analysis($|[/\\])/,
/^[/\\]docs($|[/\\])/,
/^[/\\]scripts($|[/\\])/,
/^[/\\]src($|[/\\])/,
/^[/\\]node_modules[/\\]\.cache($|[/\\])/,
/\.test\.[cm]?[jt]sx?$/
]
});
console.log("[package] icon");
run("node", ["scripts/generate-icon.mjs"]);
const packaged = outputs[0];
if (!packaged || !fs.existsSync(path.join(packaged, "RPA Studio.exe"))) {
throw new Error(`打包产物无效:${packaged || "未返回目录"}`);
console.log("[package] electron-builder nsis");
run(path.join(root, "node_modules", ".bin", "electron-builder.cmd"), [
"--win",
"--x64",
"--config",
path.join(root, "electron-builder.config.mjs")
]);
if (!fs.existsSync(builderSetupPath)) {
throw new Error(`electron-builder 未生成安装包:${builderSetupPath}`);
}
if (path.resolve(packaged) !== path.resolve(productFolder)) {
assertInsideRelease(packaged);
fs.renameSync(packaged, productFolder);
if (!fs.existsSync(path.join(builderUnpacked, "RPA Studio.exe"))) {
throw new Error(`electron-builder 未生成应用目录:${builderUnpacked}`);
}
fs.cpSync(builderUnpacked, productFolder, { recursive: true });
fs.copyFileSync(builderSetupPath, setupPath);
removeTree(builderWork);
const git = spawnSync("git", ["rev-parse", "--short", "HEAD"], { cwd: root, encoding: "utf8" });
const revision = git.status === 0 ? git.stdout.trim() : "unknown";
@ -187,8 +166,6 @@ 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-"));
@ -202,71 +179,32 @@ const zip = new AdmZip();
zip.addLocalFolder(productFolder, productFolderName);
zip.writeZip(zipPath);
const zipSha256 = createHash("sha256").update(fs.readFileSync(zipPath)).digest("hex");
const setupSha256 = createHash("sha256").update(fs.readFileSync(setupPath)).digest("hex");
let setupSha256 = "";
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");
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-"));
console.log("[package] nsis smoke");
const installSmokeRoot = fs.mkdtempSync(path.join(os.tmpdir(), "RPA-Studio-NSIS-Smoke-"));
const installSmokeSetup = path.join(installSmokeRoot, path.basename(setupPath));
const installSmokeTarget = path.join(installSmokeRoot, "RPA Studio");
const installSmokeTarget = path.join(installSmokeRoot, "RPAStudio");
fs.copyFileSync(setupPath, installSmokeSetup);
const installed = spawnSync(installSmokeSetup, ["/S", "/NOLAUNCH", "/NOSHORTCUT", `/TARGET=${installSmokeTarget}`], {
const installed = spawnSync(installSmokeSetup, ["/S", `/D=${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"}`);
throw new Error(`NSIS 安装包冒烟失败,退出码 ${installed.status}:${installed.error?.message || "unknown error"}`);
}
if (!fs.existsSync(path.join(installSmokeTarget, "RPA Studio.exe"))) {
throw new Error("NSIS 安装包冒烟失败:未生成主程序");
}
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)}`);
const sums = [
`${zipSha256} ${path.basename(zipPath)}`,
`${setupSha256} ${path.basename(setupPath)}`
];
fs.writeFileSync(path.join(releaseRoot, "SHA256SUMS.txt"), `${sums.join("\r\n")}\r\n`, "utf8");
console.log(JSON.stringify({ productFolder, zipPath, setupPath: setupSha256 ? setupPath : null, zipSha256, setupSha256: setupSha256 || null }, null, 2));
console.log(JSON.stringify({ productFolder, zipPath, setupPath, zipSha256, setupSha256 }, null, 2));