From 12de8fbf58831ba97cd264ff6951f5cd65c07bfd Mon Sep 17 00:00:00 2001 From: yu <191972707@qq.com> Date: Tue, 27 Oct 2026 18:16:03 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=94=9F=E6=88=90=E5=8F=AF=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E7=9A=84=E5=8E=9F=E7=94=9FWindows=E5=AE=89=E8=A3=85?= =?UTF-8?q?=E5=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/installer/Program.cpp | 378 ++++++++++++++++++++++++++++++++++ scripts/package.mjs | 160 ++++++++++---- 2 files changed, 502 insertions(+), 36 deletions(-) create mode 100644 scripts/installer/Program.cpp diff --git a/scripts/installer/Program.cpp b/scripts/installer/Program.cpp new file mode 100644 index 0000000..ef44f81 --- /dev/null +++ b/scripts/installer/Program.cpp @@ -0,0 +1,378 @@ +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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(value.size()), nullptr, 0, nullptr, nullptr); + std::string output(size, '\0'); + WideCharToMultiByte(CP_UTF8, 0, value.data(), static_cast(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 buffer(32768); + DWORD length = GetModuleFileNameW(nullptr, buffer.data(), static_cast(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 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::min(remaining, bufferLimit)); + input.read(destination, chunk); + if (input.gcount() != chunk) fail(L"安装包数据提前结束。"); + destination += chunk; + remaining -= static_cast(chunk); + } +} + +Footer readFooter(std::ifstream& input, std::uint64_t fileLength) { + if (fileLength <= kFooterLength) fail(L"安装包缺少应用数据,请重新下载。"); + input.seekg(static_cast(fileLength - kFooterLength), std::ios::beg); + Footer footer; + input.read(reinterpret_cast(&footer.cliLength), sizeof(footer.cliLength)); + input.read(reinterpret_cast(&footer.guiLength), sizeof(footer.guiLength)); + input.read(reinterpret_cast(&footer.dllLength), sizeof(footer.dllLength)); + input.read(reinterpret_cast(&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 buffer(1024 * 1024); + std::uint64_t remaining = length; + while (remaining > 0) { + std::streamsize chunk = static_cast(std::min(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(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(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(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 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 buffer(32768); + DWORD length = static_cast(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(&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(&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; + } +} diff --git a/scripts/package.mjs b/scripts/package.mjs index 0c9305f..82d7cc6 100644 --- a/scripts/package.mjs +++ b/scripts/package.mjs @@ -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=", - "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");