fix: 生成可验证的原生Windows安装包
This commit is contained in:
parent
dd27ec27f2
commit
12de8fbf58
378
scripts/installer/Program.cpp
Normal file
378
scripts/installer/Program.cpp
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
|
||||
Loading…
Reference in New Issue
Block a user