build: 采用electron-builder标准NSIS安装器
This commit is contained in:
parent
f675cc0086
commit
cf05e4aa6f
BIN
build/icon.ico
Normal file
BIN
build/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
48
electron-builder.config.mjs
Normal file
48
electron-builder.config.mjs
Normal 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
2323
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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
79
scripts/generate-icon.mjs
Normal 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;
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
Loading…
Reference in New Issue
Block a user