Merge pull request #4913 from deepseek-harness/fix/windows-installer-extract-diagnostics
fix(desktop): report Windows extraction failures with 7-Zip evidence
This commit is contained in:
commit
af28c551f3
22 files changed
+654
-55
No files matched your search
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write apps/desktop/README.md
|
||||
README.md: 59f18ea269f303e267d5e9597d8748e2a8fed134
|
||||
README.zh.md: eb1594e032fea2da38f778e6b2217bd52587af91
|
||||
README.md: df95691f20d434baaeed7f0062989bd28df2b35e
|
||||
README.zh.md: ceefff9cc7d0aaac68f1dd86dcaadad99cfcbdcf
|
||||
@@ -187,14 +187,16 @@ The [Office conversion provider](../../packages/document/office-to-pdf/README.md
|
||||
|
||||
The packaged application runs compiled JavaScript and pre-generated Typert metadata; it does not compile TypeScript plugins. Source-level debugger navigation and editor declarations remain available in development packages. [Copy-policy tests](tests/runtime-file-policy.spec.ts) cover exclusions and retained assets; The [payload smoke](tests/fixtures/runtime-payload-smoke.mjs) runs under Electron RunAsNode before the Host smoke and final inventory verification. The payload smoke resolves the search tool’s ripgrep executable and verifies text search and file enumeration. Signed Windows builds run these checks after dependency signing; other builds run them during `prepare:dsh`. The [Host smoke](scripts/smoke-runtime.ts) creates DOCX, XLSX, and PPTX inputs with bundled Python, converts each through the real Office provider, and checks PDF output. Every assembled application, including directory and unsigned Windows builds, repeats the payload and Host checks against ASAR. Archive integrity checks compare the complete archived descriptor with preparation, file bytes and membership in both archive and unpacked trees, recorded executable flags for archived files, and physical permissions for unpacked files. Conversion failures stop packaging before a release record is written; macOS DMG/ZIP builds run these checks before notarization.
|
||||
|
||||
Windows release qualification also runs [directory and replacement checks](scripts/smoke-windows.ps1) manually after the Desktop build. Set `$Makensis`, `$SevenZip`, and `$PluginDir` to the pinned builder’s NSIS compiler, 7-Zip executable, and x86-unicode NSIS plugin directory. From the repository root, run the command below. It verifies directory replacement and rollback, and both locked-file replacement modes; it is not part of the unit-test lane.
|
||||
Windows release qualification also runs [directory and replacement checks](scripts/smoke-windows.ps1) manually after the Desktop build. Set `$Makensis`, `$SevenZip`, and `$PluginDir` to the pinned builder’s NSIS compiler, 7-Zip executable, and x86-unicode NSIS plugin directory; pass the prepared `window-frame.dll` as `-FrameLibrary` to exercise the native extraction path and its failure report as well. From the repository root, run the command below. It verifies directory replacement and rollback, and both locked-file replacement modes; it is not part of the unit-test lane.
|
||||
|
||||
```powershell
|
||||
pwsh -NoProfile -File apps/desktop/scripts/smoke-windows.ps1 -Makensis $Makensis -SevenZip $SevenZip -PluginDir $PluginDir
|
||||
pwsh -NoProfile -File apps/desktop/scripts/smoke-windows.ps1 -Makensis $Makensis -SevenZip $SevenZip -PluginDir $PluginDir -FrameLibrary apps/desktop/.desktop-build/targets/win-x64/installer-ui/window-frame.dll
|
||||
```
|
||||
|
||||
The Windows installer extracts the new version beside the installation directory, stops the old application, and replaces directories through same-volume renames. Same-path upgrades preserve the old directory until promotion succeeds; extraction failure leaves it intact, and promotion failure attempts to restore it. The installer removes the old backup before launch. Forced termination or power loss can leave `.new-*` or `.old-*` directories; installation-location and scope migrations retain electron-builder's old-uninstaller flow.
|
||||
|
||||
When extraction fails, the installer writes a report with the 7-Zip result and its complete error output to the updater cache directory as `%LOCALAPPDATA%\<package-derived name>-updater\installer-logs\extract-failure-<timestamp>.log` (currently `@deepseek-aidsh-desktop-updater`) and shows the first error line with a **Copy error details** button; silent installs write only the report. Unsigned Windows builds (`DSH_DESKTOP_UNSIGNED=1`) name their installer `deepseek-harness-<version>-win-x64-unsigned.exe` so they cannot be mistaken for release artifacts.
|
||||
|
||||
<a id="upload-updates"></a>
|
||||
|
||||
### Upload updates
|
||||
|
||||
@@ -187,14 +187,16 @@ Desktop 在本地打包工作区包,并通过目标捆绑的 Node 和 pnpm 安
|
||||
|
||||
打包应用运行编译后的 JavaScript 和预生成的 Typert 元数据,不编译 TypeScript 插件。源码级调试导航和编辑器声明仍可从开发包中获取。[复制规则测试](tests/runtime-file-policy.spec.ts)覆盖排除项和保留资源;[产物 smoke](tests/fixtures/runtime-payload-smoke.mjs) 在 Host smoke 和最终清单验证之前,使用 Electron RunAsNode 执行。产物 smoke 解析搜索工具使用的 ripgrep 可执行文件,并验证文本搜索和文件枚举。Windows 签名构建在依赖签名后运行这些检查;其他构建在 `prepare:dsh` 中运行。[Host smoke](scripts/smoke-runtime.ts) 使用捆绑的 Python 创建 DOCX、XLSX 和 PPTX 输入,通过真实 Office 提供方逐一转换并检查 PDF 输出。每个组装后的应用(包括目录包和 Windows 未签名构建)都会针对 ASAR 重复产物和 Host 检查。归档完整性检查将归档内完整描述符与准备结果比对,并核对归档和解包目录中的文件内容与清单、归档内文件记录的执行标志,以及解包文件的物理权限。转换失败会在写入发布记录前终止打包;macOS DMG/ZIP 构建在公证前执行这些检查。
|
||||
|
||||
Windows 发布验收还需在 Desktop 构建后手动运行[目录和替换检查](scripts/smoke-windows.ps1)。将 `$Makensis`、`$SevenZip` 和 `$PluginDir` 分别设为锁定版本构建器的 NSIS 编译器、7-Zip 可执行文件和 x86-unicode NSIS 插件目录。从仓库根目录运行以下命令。它验证 目录替换与回滚和两种文件占用替换方式;不属于单元测试通道。
|
||||
Windows 发布验收还需在 Desktop 构建后手动运行[目录和替换检查](scripts/smoke-windows.ps1)。将 `$Makensis`、`$SevenZip` 和 `$PluginDir` 分别设为锁定版本构建器的 NSIS 编译器、7-Zip 可执行文件和 x86-unicode NSIS 插件目录;通过 `-FrameLibrary` 传入已准备好的 `window-frame.dll`,即可同时覆盖原生解压路径及其失败报告。从仓库根目录运行以下命令。它验证 目录替换与回滚和两种文件占用替换方式;不属于单元测试通道。
|
||||
|
||||
```powershell
|
||||
pwsh -NoProfile -File apps/desktop/scripts/smoke-windows.ps1 -Makensis $Makensis -SevenZip $SevenZip -PluginDir $PluginDir
|
||||
pwsh -NoProfile -File apps/desktop/scripts/smoke-windows.ps1 -Makensis $Makensis -SevenZip $SevenZip -PluginDir $PluginDir -FrameLibrary apps/desktop/.desktop-build/targets/win-x64/installer-ui/window-frame.dll
|
||||
```
|
||||
|
||||
Windows 安装器先将新版本解压到安装目录旁边,再退出旧应用并通过同卷目录改名完成替换。同路径升级在替换成功前保留旧目录;解压失败时旧版不变,替换失败时尝试恢复旧目录。安装器在启动前清理旧版备份。强制结束安装器或断电可能留下 `.new-*` 或 `.old-*` 目录;不同安装位置或安装范围迁移仍使用 electron-builder 的旧卸载器流程。
|
||||
|
||||
解压失败时,安装器会把 7-Zip 的结果和完整错误输出写入更新缓存目录 `%LOCALAPPDATA%\<按包名派生>-updater\installer-logs\extract-failure-<时间戳>.log`(当前为 `@deepseek-aidsh-desktop-updater`),并在弹窗中显示首条错误行和 **复制错误信息** 按钮;静默安装只写入报告。未签名的 Windows 构建(`DSH_DESKTOP_UNSIGNED=1`)会将安装包命名为 `deepseek-harness-<版本>-win-x64-unsigned.exe`,以免被误当作发布产物。
|
||||
|
||||
<a id="upload-updates"></a>
|
||||
|
||||
### 上传更新
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { AfterPackContext, BeforePackContext } from 'app-builder-lib'
|
||||
/** Electron-builder fields asserted by the Desktop release tests. */
|
||||
export interface DesktopElectronBuilderConfig {
|
||||
readonly appId: string
|
||||
readonly artifactName: string
|
||||
readonly protocols: readonly [{ readonly name: 'DeepSeek Harness'; readonly schemes: readonly ['dsh'] }]
|
||||
readonly directories: {
|
||||
readonly output: string
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
// 7-Zip's error output is the primary evidence for an extraction failure; the report preserves it verbatim
|
||||
// on disk and in the clipboard while the dialog shows only a headline and a bounded excerpt.
|
||||
#pragma once
|
||||
#include <windows.h>
|
||||
#include <commctrl.h>
|
||||
#include <shlobj.h>
|
||||
#include <algorithm>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace extract_report {
|
||||
|
||||
constexpr size_t kOutputLimit = 16 * 1024 * 1024;
|
||||
constexpr size_t kHeadlineLimit = 160;
|
||||
constexpr size_t kExcerptLineLimit = 120;
|
||||
constexpr size_t kExcerptLines = 10;
|
||||
constexpr size_t kExcerptChars = 1000;
|
||||
constexpr wchar_t kEllipsis = 0x2026;
|
||||
constexpr wchar_t kOutputMarker[] = L"7-Zip output:";
|
||||
|
||||
// Exit codes documented by 7-Zip; the installer treats every non-zero value as fatal.
|
||||
inline const wchar_t* SevenZipMeaning(int code) {
|
||||
switch (code) {
|
||||
case 1: return L"warning: some files were not extracted";
|
||||
case 2: return L"fatal error";
|
||||
case 7: return L"command line error";
|
||||
case 8: return L"not enough memory";
|
||||
case 255: return L"stopped before completion";
|
||||
default: return L"unexpected exit code";
|
||||
}
|
||||
}
|
||||
|
||||
inline std::wstring Trim(const std::wstring& text) {
|
||||
const auto begin = text.find_first_not_of(L" \t\r\n");
|
||||
if (begin == std::wstring::npos) return L"";
|
||||
const auto end = text.find_last_not_of(L" \t\r\n");
|
||||
return text.substr(begin, end - begin + 1);
|
||||
}
|
||||
|
||||
inline std::vector<std::wstring> Lines(const std::wstring& text) {
|
||||
std::vector<std::wstring> lines;
|
||||
size_t start = 0;
|
||||
while (start <= text.size()) {
|
||||
const auto end = text.find(L'\n', start);
|
||||
std::wstring line = text.substr(start, end == std::wstring::npos ? std::wstring::npos : end - start);
|
||||
if (!line.empty() && line.back() == L'\r') line.pop_back();
|
||||
lines.push_back(line);
|
||||
if (end == std::wstring::npos) break;
|
||||
start = end + 1;
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
inline std::wstring Truncate(const std::wstring& text, size_t limit) {
|
||||
if (text.size() <= limit) return text;
|
||||
return text.substr(0, limit - 1) + kEllipsis;
|
||||
}
|
||||
|
||||
inline std::wstring Win32Message(DWORD error) {
|
||||
LPWSTR buffer = nullptr;
|
||||
const DWORD length = FormatMessageW(FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS,
|
||||
nullptr, error, 0, reinterpret_cast<LPWSTR>(&buffer), 0, nullptr);
|
||||
std::wstring message = length && buffer ? Trim(std::wstring(buffer, length)) : L"";
|
||||
if (buffer) LocalFree(buffer);
|
||||
return message;
|
||||
}
|
||||
|
||||
// Positive results up to 255 are 7-Zip exit codes and small negative results are negated Win32 errors from launching or
|
||||
// supervising it; anything else is the raw process status (for example an NTSTATUS from a crash), printed in hex.
|
||||
constexpr int kMaxSevenZipExitCode = 255;
|
||||
constexpr int kMaxWin32Error = 0xFFFF;
|
||||
|
||||
inline std::wstring HexStatus(int code) {
|
||||
WCHAR text[16];
|
||||
wsprintfW(text, L"0x%08X", static_cast<unsigned>(code));
|
||||
return text;
|
||||
}
|
||||
|
||||
inline std::wstring DescribeResult(int code) {
|
||||
if (code >= 0 && code <= kMaxSevenZipExitCode) {
|
||||
return L"7-Zip exit code " + std::to_wstring(code) + L" (" + SevenZipMeaning(code) + L")";
|
||||
}
|
||||
if (code < 0 && -static_cast<long long>(code) <= kMaxWin32Error) {
|
||||
const DWORD error = static_cast<DWORD>(-static_cast<long long>(code));
|
||||
std::wstring message = Win32Message(error);
|
||||
return L"Windows error " + std::to_wstring(error) + L" while running 7-Zip" + (message.empty() ? L"" : L" (" + message + L")");
|
||||
}
|
||||
return L"7-Zip terminated with status " + HexStatus(code);
|
||||
}
|
||||
|
||||
inline bool EqualsIgnoreCase(const std::wstring& left, const std::wstring& right) {
|
||||
return left.size() == right.size() && _wcsnicmp(left.c_str(), right.c_str(), left.size()) == 0;
|
||||
}
|
||||
|
||||
// 7-Zip reports failures as "<operation> ERROR: <reason>" or "ERROR: <operation> : <reason> : <path>"; it also echoes the
|
||||
// bare archive path after "ERROR:" before an open failure, which names nothing and is skipped.
|
||||
inline std::wstring Headline(int code, const std::wstring& output, const std::wstring& archive) {
|
||||
const auto separator = archive.find_last_of(L"\\/");
|
||||
const std::wstring archiveName = separator == std::wstring::npos ? archive : archive.substr(separator + 1);
|
||||
std::wstring fallback;
|
||||
for (const std::wstring& raw : Lines(output)) {
|
||||
const std::wstring line = Trim(raw);
|
||||
const auto marker = line.find(L"ERROR");
|
||||
if (marker == std::wstring::npos) continue;
|
||||
const auto colon = line.find(L':', marker);
|
||||
const std::wstring detail = colon == std::wstring::npos ? L"" : Trim(line.substr(colon + 1));
|
||||
if (detail.empty() || EqualsIgnoreCase(detail, archive) || EqualsIgnoreCase(detail, archiveName)) continue;
|
||||
if (detail.find(L" : ") != std::wstring::npos) return Truncate(line, kHeadlineLimit);
|
||||
if (fallback.empty()) fallback = line;
|
||||
}
|
||||
if (!fallback.empty()) return Truncate(fallback, kHeadlineLimit);
|
||||
return DescribeResult(code);
|
||||
}
|
||||
|
||||
inline std::wstring Compose(int code, const std::wstring& archive, const std::wstring& destination, const std::wstring& output,
|
||||
const std::wstring& timestamp, const std::wstring& windowsVersion) {
|
||||
std::wstring report = L"DeepSeek Harness installer: extraction failed\r\n";
|
||||
report += L"Time: " + timestamp + L"\r\n";
|
||||
report += L"Result: " + DescribeResult(code) + L"\r\n";
|
||||
report += L"Archive: " + archive + L"\r\n";
|
||||
report += L"Destination: " + destination + L"\r\n";
|
||||
report += L"Windows: " + windowsVersion + L"\r\n";
|
||||
report += L"\r\n" + std::wstring(kOutputMarker) + L"\r\n";
|
||||
const std::wstring trimmed = Trim(output);
|
||||
if (trimmed.empty()) {
|
||||
report += L"(none)\r\n";
|
||||
} else {
|
||||
for (const std::wstring& line : Lines(trimmed)) report += line + L"\r\n";
|
||||
}
|
||||
return report;
|
||||
}
|
||||
|
||||
// The expanded panel adds 7-Zip's own lines beneath the result; long lines are cut and a trailing note counts what the
|
||||
// saved report still holds. Header fields already appear in the report file and clipboard.
|
||||
inline std::wstring Excerpt(const std::wstring& report, size_t maxLines = kExcerptLines, size_t maxChars = kExcerptChars) {
|
||||
std::vector<std::wstring> lines;
|
||||
bool inOutput = false;
|
||||
for (const std::wstring& line : Lines(Trim(report))) {
|
||||
if (inOutput) lines.push_back(line);
|
||||
else if (line.rfind(L"Result: ", 0) == 0) lines.push_back(line);
|
||||
else if (line == kOutputMarker) inOutput = true;
|
||||
}
|
||||
std::wstring excerpt;
|
||||
size_t shown = 0;
|
||||
for (const std::wstring& line : lines) {
|
||||
const std::wstring cut = Truncate(line, kExcerptLineLimit);
|
||||
if (shown == maxLines || excerpt.size() + cut.size() + 2 > maxChars) break;
|
||||
excerpt += (shown ? L"\r\n" : L"") + cut;
|
||||
++shown;
|
||||
}
|
||||
if (shown < lines.size()) {
|
||||
excerpt += L"\r\n" + std::wstring(1, kEllipsis) + L" (" + std::to_wstring(lines.size() - shown) + L" more lines in the saved report)";
|
||||
}
|
||||
return excerpt;
|
||||
}
|
||||
|
||||
// A cut inside a multi-byte sequence would make the whole UTF-8 attempt fail; drop the incomplete tail first.
|
||||
inline void TrimPartialUtf8(std::string& bytes) {
|
||||
size_t trailing = 0;
|
||||
while (trailing < 3 && trailing < bytes.size() && (static_cast<unsigned char>(bytes[bytes.size() - 1 - trailing]) & 0xC0) == 0x80) ++trailing;
|
||||
if (trailing >= bytes.size()) return;
|
||||
const unsigned char lead = static_cast<unsigned char>(bytes[bytes.size() - 1 - trailing]);
|
||||
const size_t expected = lead >= 0xF0 ? 4 : lead >= 0xE0 ? 3 : lead >= 0xC0 ? 2 : 1;
|
||||
if (expected > trailing + 1) bytes.resize(bytes.size() - 1 - trailing);
|
||||
}
|
||||
|
||||
inline std::wstring Decode(const std::string& bytes) {
|
||||
if (bytes.empty()) return L"";
|
||||
for (UINT codePage : {static_cast<UINT>(CP_UTF8), static_cast<UINT>(CP_ACP)}) {
|
||||
const DWORD flags = codePage == CP_UTF8 ? MB_ERR_INVALID_CHARS : 0;
|
||||
const int length = MultiByteToWideChar(codePage, flags, bytes.data(), static_cast<int>(bytes.size()), nullptr, 0);
|
||||
if (length <= 0) continue;
|
||||
std::wstring text(static_cast<size_t>(length), L'\0');
|
||||
MultiByteToWideChar(codePage, flags, bytes.data(), static_cast<int>(bytes.size()), &text[0], length);
|
||||
return text;
|
||||
}
|
||||
return L"(undecodable 7-Zip output)";
|
||||
}
|
||||
|
||||
// The saved report keeps everything 7-Zip wrote; only pathological output beyond kOutputLimit is cut, and the cut is recorded.
|
||||
inline std::wstring ReadOutput(LPCWSTR path) {
|
||||
HANDLE file = CreateFileW(path, GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||
if (file == INVALID_HANDLE_VALUE) return L"";
|
||||
std::string bytes;
|
||||
char buffer[8192];
|
||||
DWORD count = 0;
|
||||
bool truncated = false;
|
||||
while (ReadFile(file, buffer, sizeof(buffer), &count, nullptr) && count) {
|
||||
if (bytes.size() + count > kOutputLimit) {
|
||||
bytes.append(buffer, kOutputLimit - bytes.size());
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
bytes.append(buffer, count);
|
||||
}
|
||||
CloseHandle(file);
|
||||
if (truncated) TrimPartialUtf8(bytes);
|
||||
std::wstring output = Decode(bytes);
|
||||
if (truncated) output += L"\r\n[7-Zip output truncated after " + std::to_wstring(kOutputLimit / (1024 * 1024)) + L" MiB]\r\n";
|
||||
return output;
|
||||
}
|
||||
|
||||
inline std::wstring Timestamp() {
|
||||
SYSTEMTIME now;
|
||||
GetLocalTime(&now);
|
||||
WCHAR text[32];
|
||||
wsprintfW(text, L"%04u-%02u-%02u %02u:%02u:%02u", now.wYear, now.wMonth, now.wDay, now.wHour, now.wMinute, now.wSecond);
|
||||
return text;
|
||||
}
|
||||
|
||||
inline std::wstring WindowsVersion() {
|
||||
typedef LONG(WINAPI * RtlGetVersionFn)(PRTL_OSVERSIONINFOW);
|
||||
HMODULE ntdll = GetModuleHandleW(L"ntdll.dll");
|
||||
auto getVersion = ntdll ? reinterpret_cast<RtlGetVersionFn>(GetProcAddress(ntdll, "RtlGetVersion")) : nullptr;
|
||||
RTL_OSVERSIONINFOW info = {};
|
||||
info.dwOSVersionInfoSize = sizeof(info);
|
||||
if (!getVersion || getVersion(&info) != 0) return L"unknown";
|
||||
WCHAR text[64];
|
||||
wsprintfW(text, L"%u.%u.%u", info.dwMajorVersion, info.dwMinorVersion, info.dwBuildNumber);
|
||||
return text;
|
||||
}
|
||||
|
||||
inline bool WriteUtf8(LPCWSTR path, const std::wstring& text) {
|
||||
std::wstring directory(path);
|
||||
const auto separator = directory.find_last_of(L"\\/");
|
||||
if (separator != std::wstring::npos) {
|
||||
directory.resize(separator);
|
||||
const int created = SHCreateDirectoryExW(nullptr, directory.c_str(), nullptr);
|
||||
if (created != ERROR_SUCCESS && created != ERROR_ALREADY_EXISTS && created != ERROR_FILE_EXISTS) return false;
|
||||
}
|
||||
const int length = WideCharToMultiByte(CP_UTF8, 0, text.data(), static_cast<int>(text.size()), nullptr, 0, nullptr, nullptr);
|
||||
if (length <= 0) return false;
|
||||
std::string bytes("\xEF\xBB\xBF", 3);
|
||||
bytes.resize(3 + static_cast<size_t>(length));
|
||||
WideCharToMultiByte(CP_UTF8, 0, text.data(), static_cast<int>(text.size()), &bytes[3], length, nullptr, nullptr);
|
||||
HANDLE file = CreateFileW(path, GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||
if (file == INVALID_HANDLE_VALUE) return false;
|
||||
DWORD written = 0;
|
||||
const bool complete = WriteFile(file, bytes.data(), static_cast<DWORD>(bytes.size()), &written, nullptr) && written == bytes.size();
|
||||
CloseHandle(file);
|
||||
return complete;
|
||||
}
|
||||
|
||||
inline bool CopyToClipboard(HWND owner, const std::wstring& text) {
|
||||
const size_t bytes = (text.size() + 1) * sizeof(wchar_t);
|
||||
HGLOBAL memory = GlobalAlloc(GMEM_MOVEABLE, bytes);
|
||||
if (!memory) return false;
|
||||
void* target = GlobalLock(memory);
|
||||
if (!target) { GlobalFree(memory); return false; }
|
||||
memcpy(target, text.c_str(), bytes);
|
||||
GlobalUnlock(memory);
|
||||
bool copied = false;
|
||||
for (int attempt = 0; attempt < 5 && !copied; ++attempt) {
|
||||
if (attempt) Sleep(50);
|
||||
if (!OpenClipboard(owner)) continue;
|
||||
copied = EmptyClipboard() && SetClipboardData(CF_UNICODETEXT, memory) != nullptr;
|
||||
CloseClipboard();
|
||||
}
|
||||
if (!copied) GlobalFree(memory);
|
||||
return copied;
|
||||
}
|
||||
|
||||
struct DialogStrings {
|
||||
LPCWSTR title;
|
||||
LPCWSTR heading;
|
||||
LPCWSTR copy;
|
||||
LPCWSTR expand;
|
||||
LPCWSTR collapse;
|
||||
LPCWSTR footer;
|
||||
LPCWSTR copied;
|
||||
};
|
||||
|
||||
struct DialogState {
|
||||
const std::wstring* report;
|
||||
const DialogStrings* strings;
|
||||
};
|
||||
|
||||
constexpr int kCopyButton = 1001;
|
||||
|
||||
inline HRESULT CALLBACK DialogCallback(HWND dialog, UINT notification, WPARAM wparam, LPARAM, LONG_PTR data) {
|
||||
auto* state = reinterpret_cast<DialogState*>(data);
|
||||
if (notification == TDN_BUTTON_CLICKED && static_cast<int>(wparam) == kCopyButton && state) {
|
||||
if (CopyToClipboard(dialog, *state->report)) {
|
||||
SendMessageW(dialog, TDM_SET_ELEMENT_TEXT, TDE_FOOTER, reinterpret_cast<LPARAM>(state->strings->copied));
|
||||
}
|
||||
return S_FALSE;
|
||||
}
|
||||
return S_OK;
|
||||
}
|
||||
|
||||
// The task dialog comes from the comctl32 v6 the installer already loads; without it a message box carries the headline.
|
||||
inline void Show(HWND parent, const std::wstring& headline, const std::wstring& excerpt, const std::wstring& report, const DialogStrings& strings) {
|
||||
typedef HRESULT(WINAPI * TaskDialogIndirectFn)(const TASKDIALOGCONFIG*, int*, int*, BOOL*);
|
||||
HMODULE comctl = GetModuleHandleW(L"comctl32.dll");
|
||||
if (!comctl) comctl = LoadLibraryW(L"comctl32.dll");
|
||||
auto taskDialog = comctl ? reinterpret_cast<TaskDialogIndirectFn>(GetProcAddress(comctl, "TaskDialogIndirect")) : nullptr;
|
||||
if (taskDialog) {
|
||||
DialogState state{&report, &strings};
|
||||
TASKDIALOG_BUTTON buttons[] = {{kCopyButton, strings.copy}};
|
||||
TASKDIALOGCONFIG config = {};
|
||||
config.cbSize = sizeof(config);
|
||||
config.hwndParent = parent;
|
||||
config.dwFlags = TDF_ALLOW_DIALOG_CANCELLATION | TDF_POSITION_RELATIVE_TO_WINDOW;
|
||||
config.dwCommonButtons = TDCBF_OK_BUTTON;
|
||||
config.pszWindowTitle = strings.title;
|
||||
config.pszMainIcon = TD_WARNING_ICON;
|
||||
config.pszMainInstruction = strings.heading;
|
||||
config.pszContent = headline.c_str();
|
||||
config.cButtons = 1;
|
||||
config.pButtons = buttons;
|
||||
config.nDefaultButton = IDOK;
|
||||
config.pszExpandedInformation = excerpt.c_str();
|
||||
config.pszExpandedControlText = strings.collapse;
|
||||
config.pszCollapsedControlText = strings.expand;
|
||||
config.pszFooterIcon = TD_INFORMATION_ICON;
|
||||
config.pszFooter = strings.footer;
|
||||
config.pfCallback = DialogCallback;
|
||||
config.lpCallbackData = reinterpret_cast<LONG_PTR>(&state);
|
||||
config.cxWidth = 320;
|
||||
int pressed = 0;
|
||||
if (SUCCEEDED(taskDialog(&config, &pressed, nullptr, nullptr))) return;
|
||||
}
|
||||
const std::wstring text = std::wstring(strings.heading) + L"\r\n\r\n" + headline + L"\r\n\r\n" + strings.footer;
|
||||
MessageBoxW(parent, text.c_str(), strings.title, MB_OK | MB_ICONEXCLAMATION | MB_SETFOREGROUND);
|
||||
}
|
||||
|
||||
} // namespace extract_report
|
||||
@@ -32,25 +32,30 @@ struct InstallerAttributes {
|
||||
}
|
||||
};
|
||||
|
||||
static DWORD ExtractApplication(HWND parent, LPCWSTR tool, LPCWSTR archive, LPCWSTR destination, LPCWSTR log) {
|
||||
// Win32 failures around the 7-Zip process return negated error codes so they never collide with 7-Zip's exit codes.
|
||||
static int LaunchFailure(DWORD error) {
|
||||
return -static_cast<int>(error ? error : ERROR_GEN_FAILURE);
|
||||
}
|
||||
|
||||
static int ExtractApplication(HWND parent, LPCWSTR tool, LPCWSTR archive, LPCWSTR destination, LPCWSTR log) {
|
||||
SECURITY_ATTRIBUTES security = {sizeof(security), nullptr, TRUE};
|
||||
InstallerHandle input, output;
|
||||
if (!CreatePipe(&input.value, &output.value, &security, 0)) return GetLastError();
|
||||
if (!SetHandleInformation(input.value, HANDLE_FLAG_INHERIT, 0)) return GetLastError();
|
||||
if (!CreatePipe(&input.value, &output.value, &security, 0)) return LaunchFailure(GetLastError());
|
||||
if (!SetHandleInformation(input.value, HANDLE_FLAG_INHERIT, 0)) return LaunchFailure(GetLastError());
|
||||
InstallerHandle errors(CreateFileW(log, GENERIC_WRITE, FILE_SHARE_READ, &security, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr));
|
||||
if (errors.value == INVALID_HANDLE_VALUE) return GetLastError();
|
||||
if (errors.value == INVALID_HANDLE_VALUE) return LaunchFailure(GetLastError());
|
||||
InstallerHandle nullInput(CreateFileW(L"NUL", GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, &security, OPEN_EXISTING, 0, nullptr));
|
||||
if (nullInput.value == INVALID_HANDLE_VALUE) return GetLastError();
|
||||
if (nullInput.value == INVALID_HANDLE_VALUE) return LaunchFailure(GetLastError());
|
||||
InstallerHandle job(CreateJobObjectW(nullptr, nullptr));
|
||||
if (!job.value) return GetLastError();
|
||||
if (!job.value) return LaunchFailure(GetLastError());
|
||||
JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits = {};
|
||||
limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
|
||||
if (!SetInformationJobObject(job.value, JobObjectExtendedLimitInformation, &limits, sizeof(limits))) return GetLastError();
|
||||
if (!SetInformationJobObject(job.value, JobObjectExtendedLimitInformation, &limits, sizeof(limits))) return LaunchFailure(GetLastError());
|
||||
InstallerAttributes attributes;
|
||||
if (!attributes.Initialize()) return GetLastError();
|
||||
if (!attributes.Initialize()) return LaunchFailure(GetLastError());
|
||||
HANDLE inherited[] = {nullInput.value, output.value, errors.value};
|
||||
if (!UpdateProcThreadAttribute(attributes.list, 0, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, inherited, sizeof(inherited), nullptr, nullptr) ||
|
||||
!UpdateProcThreadAttribute(attributes.list, 0, PROC_THREAD_ATTRIBUTE_JOB_LIST, &job.value, sizeof(job.value), nullptr, nullptr)) return GetLastError();
|
||||
!UpdateProcThreadAttribute(attributes.list, 0, PROC_THREAD_ATTRIBUTE_JOB_LIST, &job.value, sizeof(job.value), nullptr, nullptr)) return LaunchFailure(GetLastError());
|
||||
STARTUPINFOEXW startup = {};
|
||||
startup.StartupInfo.cb = sizeof(startup);
|
||||
startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES;
|
||||
@@ -59,10 +64,11 @@ static DWORD ExtractApplication(HWND parent, LPCWSTR tool, LPCWSTR archive, LPCW
|
||||
startup.StartupInfo.hStdError = errors.value;
|
||||
startup.lpAttributeList = attributes.list;
|
||||
// A final dot prevents a destination's trailing backslash from escaping its closing quote.
|
||||
std::wstring command = L"\"" + std::wstring(tool) + L"\" x -y -bso0 -bse2 -bsp1 -bb0 \"-o" + destination + L"\\.\" \"" + archive + L"\"";
|
||||
// Error output is UTF-8 so the failure report can quote paths from any code page.
|
||||
std::wstring command = L"\"" + std::wstring(tool) + L"\" x -y -bso0 -bse2 -bsp1 -bb0 -sccUTF-8 \"-o" + destination + L"\\.\" \"" + archive + L"\"";
|
||||
PROCESS_INFORMATION child = {};
|
||||
if (!CreateProcessW(tool, &command[0], nullptr, nullptr, TRUE,
|
||||
EXTENDED_STARTUPINFO_PRESENT | CREATE_NO_WINDOW, nullptr, nullptr, &startup.StartupInfo, &child)) return GetLastError();
|
||||
EXTENDED_STARTUPINFO_PRESENT | CREATE_NO_WINDOW, nullptr, nullptr, &startup.StartupInfo, &child)) return LaunchFailure(GetLastError());
|
||||
InstallerHandle process(child.hProcess), thread(child.hThread);
|
||||
output.Close();
|
||||
errors.Close();
|
||||
@@ -79,17 +85,20 @@ static DWORD ExtractApplication(HWND parent, LPCWSTR tool, LPCWSTR archive, LPCW
|
||||
if (readError != ERROR_BROKEN_PIPE && readError != ERROR_SUCCESS) {
|
||||
TerminateJobObject(job.value, readError);
|
||||
WaitForSingleObject(process.value, INFINITE);
|
||||
return readError;
|
||||
return LaunchFailure(readError);
|
||||
}
|
||||
if (WaitForSingleObject(process.value, INFINITE) != WAIT_OBJECT_0) return GetLastError();
|
||||
if (WaitForSingleObject(process.value, INFINITE) != WAIT_OBJECT_0) return LaunchFailure(GetLastError());
|
||||
DWORD result;
|
||||
if (!GetExitCodeProcess(process.value, &result)) return GetLastError();
|
||||
if (!GetExitCodeProcess(process.value, &result)) return LaunchFailure(GetLastError());
|
||||
if (result == 0 && parent) SetPropW(parent, L"HarnessInstaller.ExtractProgress", reinterpret_cast<HANDLE>(100));
|
||||
return result;
|
||||
// A crash status such as 0xC0000005 comes back bit-for-bit; the report prints anything beyond 7-Zip's exit codes in hex.
|
||||
return static_cast<int>(result);
|
||||
}
|
||||
|
||||
// Runs on the NSIS worker; only an exit code of zero permits directory promotion.
|
||||
extern "C" __declspec(dllexport) DWORD __cdecl InstallerExtract(HWND parent, LPCWSTR tool, LPCWSTR archive, LPCWSTR destination, LPCWSTR log) {
|
||||
// Results 0-255 are 7-Zip exit codes, small negative results are negated Win32 errors from launching or supervising it,
|
||||
// and any other value is the raw process status.
|
||||
extern "C" __declspec(dllexport) int __cdecl InstallerExtract(HWND parent, LPCWSTR tool, LPCWSTR archive, LPCWSTR destination, LPCWSTR log) {
|
||||
try { return ExtractApplication(parent, tool, archive, destination, log); }
|
||||
catch (const std::bad_alloc&) { return ERROR_NOT_ENOUGH_MEMORY; }
|
||||
catch (const std::bad_alloc&) { return LaunchFailure(ERROR_NOT_ENOUGH_MEMORY); }
|
||||
}
|
||||
@@ -39,3 +39,19 @@ LangString INSTALLER_DISK_SPACE ${LANG_ENGLISH} "There is not enough free disk s
|
||||
LangString INSTALLER_DISK_SPACE ${LANG_SIMPCHINESE} "此磁盘的可用空间不足,请选择其他位置。"
|
||||
LangString INSTALLER_PER_USER ${LANG_ENGLISH} "This installer supports the current user only. Uninstall the existing all-users installation first."
|
||||
LangString INSTALLER_PER_USER ${LANG_SIMPCHINESE} "此安装程序仅支持当前用户。请先卸载已有的所有用户安装版本。"
|
||||
LangString INSTALLER_EXTRACT_FAILED ${LANG_ENGLISH} "The application files could not be extracted."
|
||||
LangString INSTALLER_EXTRACT_FAILED ${LANG_SIMPCHINESE} "无法解压应用程序文件。"
|
||||
LangString INSTALLER_EXTRACT_HINT ${LANG_ENGLISH} "Security software may have blocked a file, or the disk may be full. Close this installer and try again. If it keeps failing, send us the error details below."
|
||||
LangString INSTALLER_EXTRACT_HINT ${LANG_SIMPCHINESE} "可能是安全软件拦截了文件,或磁盘空间不足。请关闭安装程序后重试;如仍然失败,请将下方错误信息发送给我们。"
|
||||
LangString INSTALLER_EXTRACT_COPY ${LANG_ENGLISH} "Copy error details"
|
||||
LangString INSTALLER_EXTRACT_COPY ${LANG_SIMPCHINESE} "复制错误信息"
|
||||
LangString INSTALLER_EXTRACT_EXPAND ${LANG_ENGLISH} "Show details"
|
||||
LangString INSTALLER_EXTRACT_EXPAND ${LANG_SIMPCHINESE} "显示详情"
|
||||
LangString INSTALLER_EXTRACT_COLLAPSE ${LANG_ENGLISH} "Hide details"
|
||||
LangString INSTALLER_EXTRACT_COLLAPSE ${LANG_SIMPCHINESE} "隐藏详情"
|
||||
LangString INSTALLER_EXTRACT_SAVED ${LANG_ENGLISH} "The full report was saved to %s"
|
||||
LangString INSTALLER_EXTRACT_SAVED ${LANG_SIMPCHINESE} "完整错误报告已保存到 %s"
|
||||
LangString INSTALLER_EXTRACT_UNSAVED ${LANG_ENGLISH} "The full report could not be saved. Use Copy error details to keep it."
|
||||
LangString INSTALLER_EXTRACT_UNSAVED ${LANG_SIMPCHINESE} "无法保存完整错误报告,请使用“复制错误信息”保留。"
|
||||
LangString INSTALLER_EXTRACT_COPIED ${LANG_ENGLISH} "Error details were copied to the clipboard."
|
||||
LangString INSTALLER_EXTRACT_COPIED ${LANG_SIMPCHINESE} "错误信息已复制到剪贴板。"
|
||||
@@ -12,10 +12,35 @@
|
||||
#include <algorithm>
|
||||
#include "progress.h"
|
||||
#include "extract.h"
|
||||
#include "extract-report.h"
|
||||
#include "uninstall-data.h"
|
||||
|
||||
using namespace Gdiplus;
|
||||
|
||||
// Saves the extraction failure report and, unless silent, presents its headline with a copy action.
|
||||
// Returns 1 when the report file was written and 0 otherwise; the dialog outcome never fails the caller.
|
||||
extern "C" __declspec(dllexport) int __cdecl InstallerReportExtractFailure(
|
||||
HWND parent, int code, LPCWSTR archive, LPCWSTR destination, LPCWSTR log, LPCWSTR reportPath, int show,
|
||||
LPCWSTR title, LPCWSTR heading, LPCWSTR hint, LPCWSTR copy, LPCWSTR expand, LPCWSTR collapse, LPCWSTR savedFormat, LPCWSTR unsaved, LPCWSTR copied) {
|
||||
try {
|
||||
const std::wstring output = extract_report::ReadOutput(log);
|
||||
const std::wstring report = extract_report::Compose(code, archive, destination, output,
|
||||
extract_report::Timestamp(), extract_report::WindowsVersion());
|
||||
const bool saved = extract_report::WriteUtf8(reportPath, report);
|
||||
if (show) {
|
||||
std::wstring footer = saved ? savedFormat : unsaved;
|
||||
const auto placeholder = footer.find(L"%s");
|
||||
if (saved && placeholder != std::wstring::npos) footer.replace(placeholder, 2, reportPath);
|
||||
const extract_report::DialogStrings strings{title, heading, copy, expand, collapse, footer.c_str(), copied};
|
||||
const std::wstring content = extract_report::Headline(code, output, archive) + L"\r\n\r\n" + hint;
|
||||
extract_report::Show(parent, content, extract_report::Excerpt(report), report, strings);
|
||||
}
|
||||
return saved ? 1 : 0;
|
||||
} catch (const std::bad_alloc&) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Returns a Win32 error code; no cleanup request may traverse a linked root or touch the protected root.
|
||||
extern "C" __declspec(dllexport) DWORD __cdecl UninstallRemoveData(LPCWSTR path, LPCWSTR installation, LPCWSTR protectedRoot) {
|
||||
return uninstall_data::Remove(path, installation, protectedRoot);
|
||||
|
||||
@@ -39,7 +39,8 @@
|
||||
"electron-updater": "^6.8.9",
|
||||
"semver": "^7.8.5",
|
||||
"ws": "^8.21.0",
|
||||
"@deepseek-ai/dsh-api-gateway": "workspace:^"
|
||||
"@deepseek-ai/dsh-api-gateway": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-app-boot": "workspace:^",
|
||||
|
||||
@@ -30,8 +30,8 @@ const LISTING_DEADLINE_MS = 8_000
|
||||
/** Objects one listing page may return. */
|
||||
const LISTING_PAGE_SIZE = 1000
|
||||
|
||||
/** Artifact name electron-builder writes for one build, on either platform. */
|
||||
const ARTIFACT = /(?:^|\/)deepseek-harness-(?<version>.+)-(?:mac|win)-(?:arm64|x64)\.(?:exe|dmg|zip)$/u
|
||||
/** Artifact name electron-builder writes for one build, on either platform; unsigned Windows builds add a suffix. */
|
||||
const ARTIFACT = /(?:^|\/)deepseek-harness-(?<version>.+)-(?:mac|win)-(?:arm64|x64)(?:-unsigned)?\.(?:exe|dmg|zip)$/u
|
||||
|
||||
/** Inputs that decide which versions are already taken. */
|
||||
export interface DesktopBuildVersionSuggestionOptions {
|
||||
|
||||
@@ -106,7 +106,8 @@ export function createElectronBuilderConfig(
|
||||
...packaged === undefined ? {} : { dshBuildCommit: packaged.commit, dshBuildDirty: packaged.dirty },
|
||||
},
|
||||
productName: 'DeepSeek Harness',
|
||||
artifactName: 'deepseek-harness-${version}-${os}-${arch}.${ext}',
|
||||
// Unsigned builds carry their own suffix so a shared file can never pass for a release artifact.
|
||||
artifactName: `deepseek-harness-\${version}-\${os}-\${arch}${unsigned ? '-unsigned' : ''}.\${ext}`,
|
||||
directories: { output: unsigned ? buildPaths.unsignedArtifacts : buildPaths.artifacts },
|
||||
asar: true,
|
||||
electronDist: buildPaths.electron,
|
||||
|
||||
@@ -17,7 +17,11 @@ Var dshNewMoved
|
||||
${If} $R0 != 0
|
||||
DetailPrint $R1
|
||||
Call dshRollbackDirectories
|
||||
MessageBox MB_OK|MB_ICONEXCLAMATION "$(decompressionFailed)" /SD IDOK
|
||||
!ifmacrodef customInstallerExtractFailed
|
||||
!insertmacro customInstallerExtractFailed "${FILE}"
|
||||
!else
|
||||
MessageBox MB_OK|MB_ICONEXCLAMATION "$(decompressionFailed)" /SD IDOK
|
||||
!endif
|
||||
SetErrorLevel 2
|
||||
Quit
|
||||
${EndIf}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
!include "LogicLib.nsh"
|
||||
!include "FileFunc.nsh"
|
||||
!define INSTALLER_SOURCE_DIR "${__FILEDIR__}\..\installer"
|
||||
!define /ifndef INSTALLER_BUILD_DIR "${__FILEDIR__}\..\.desktop-build\targets\win-x64\installer-ui"
|
||||
|
||||
@@ -120,16 +121,43 @@ ManifestDPIAware true
|
||||
System::Call /NOUNLOAD '$PLUGINSDIR\window-frame.dll::InstallerExtract(p $HWNDPARENT, w "$PLUGINSDIR\dsh-7za.exe", w "${Archive}", w "$INSTDIR", w "$PLUGINSDIR\extract.log") i.s ?c'
|
||||
System::Store "L"
|
||||
Pop $R0
|
||||
; The failure report owns 7-Zip's UTF-8 output; the details view only needs the result code.
|
||||
StrCpy $R1 "$R0"
|
||||
${If} $R0 != 0
|
||||
Push $0
|
||||
FileOpen $0 "$PLUGINSDIR\extract.log" r
|
||||
${IfNot} ${Errors}
|
||||
FileRead $0 $R1
|
||||
FileClose $0
|
||||
${EndIf}
|
||||
Pop $0
|
||||
!macroend
|
||||
|
||||
; The report outlives $PLUGINSDIR so a user can send it; silent installs keep only the file. The updater cache
|
||||
; directory is never an installation target and leaves with the application on uninstall.
|
||||
; The directory smoke fixture predefines DSH_INSTALLER_LOG_DIR to keep reports inside its scratch tree.
|
||||
!ifndef DSH_INSTALLER_LOG_DIR
|
||||
!define DSH_INSTALLER_LOG_DIR "$LOCALAPPDATA\${DSH_UPDATER_CACHE_NAME}\installer-logs"
|
||||
!endif
|
||||
|
||||
!macro customInstallerExtractFailed Archive
|
||||
Push $0
|
||||
Push $1
|
||||
Push $2
|
||||
Push $3
|
||||
Push $4
|
||||
Push $5
|
||||
Push $6
|
||||
; GetTime yields zero-padded day, month, year, weekday, hour, minute, second.
|
||||
${GetTime} "" "L" $0 $1 $2 $3 $4 $5 $6
|
||||
StrCpy $0 "${DSH_INSTALLER_LOG_DIR}\extract-failure-$2$1$0-$4$5$6.log"
|
||||
StrCpy $1 1
|
||||
${If} ${Silent}
|
||||
StrCpy $1 0
|
||||
${EndIf}
|
||||
System::Call '$PLUGINSDIR\window-frame.dll::InstallerReportExtractFailure(p $HWNDPARENT, i R0, w "${Archive}", w "$dshNewDirectory", w "$PLUGINSDIR\extract.log", w r0, i r1, w "$(^SetupCaption)", w "$(INSTALLER_EXTRACT_FAILED)", w "$(INSTALLER_EXTRACT_HINT)", w "$(INSTALLER_EXTRACT_COPY)", w "$(INSTALLER_EXTRACT_EXPAND)", w "$(INSTALLER_EXTRACT_COLLAPSE)", w "$(INSTALLER_EXTRACT_SAVED)", w "$(INSTALLER_EXTRACT_UNSAVED)", w "$(INSTALLER_EXTRACT_COPIED)") i.r2 ?c'
|
||||
${If} $2 == 1
|
||||
DetailPrint $0
|
||||
${EndIf}
|
||||
Pop $6
|
||||
Pop $5
|
||||
Pop $4
|
||||
Pop $3
|
||||
Pop $2
|
||||
Pop $1
|
||||
Pop $0
|
||||
!macroend
|
||||
|
||||
!macro customCheckAppRunning
|
||||
|
||||
@@ -50,6 +50,16 @@ if ($TestProgress -or $CompileProgressOnly) {
|
||||
& $presentationExecutable
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Installer presentation regression failed.' }
|
||||
}
|
||||
$reportSource = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../tests/windows-installer-extract-report.cpp'))
|
||||
$reportExecutable = Join-Path $output 'extract-report-test.exe'
|
||||
$reportScript = Join-Path $output 'compile-extract-report-test.cmd'
|
||||
[IO.File]::WriteAllLines($reportScript, @('@echo off', ('call "{0}" >nul' -f $vcvars), 'if errorlevel 1 exit /b %errorlevel%', ('cl /nologo /MT /W4 /WX /EHsc "{0}" /Fo"{1}" /Fe"{2}" /link "{3}" user32.lib shell32.lib' -f $reportSource, (Join-Path $output 'extract-report-test.obj'), $reportExecutable, (Join-Path $output 'window-frame.lib'))), [Text.Encoding]::Default)
|
||||
& $env:ComSpec /d /c $reportScript
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Extraction report test compilation failed.' }
|
||||
if ($TestProgress) {
|
||||
& $reportExecutable
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Extraction report regression failed.' }
|
||||
}
|
||||
}
|
||||
Add-Type -AssemblyName System.Drawing
|
||||
foreach ($asset in @('brand', 'brand-2x', 'brand-dark', 'brand-dark-2x', 'uninstaller-sidebar')) {
|
||||
|
||||
@@ -8,6 +8,7 @@ param(
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$Makensis = [System.IO.Path]::GetFullPath($Makensis)
|
||||
$SevenZip = [System.IO.Path]::GetFullPath($SevenZip)
|
||||
if ($FrameLibrary) { $FrameLibrary = [System.IO.Path]::GetFullPath($FrameLibrary) }
|
||||
$scratch = [System.IO.Directory]::CreateTempSubdirectory('dsh-directory-smoke-').FullName
|
||||
$fixture = Join-Path $PSScriptRoot '../tests/fixtures/installer-directory-smoke.nsi'
|
||||
|
||||
@@ -42,8 +43,9 @@ try {
|
||||
}
|
||||
$probe = Join-Path $caseRoot 'probe.exe'
|
||||
$caseArchive = if ($mode -eq 'broken') { $brokenArchive } else { $archive }
|
||||
$reportDir = Join-Path $caseRoot 'installer-logs'
|
||||
$compileArgs = @('/V2', "/DOUTPUT_FILE=$probe", "/DPAYLOAD_FILE=$caseArchive", "/DTARGET_DIR=$target", "/DDSH_SEVENZIP_PATH=$SevenZip")
|
||||
if ($FrameLibrary) { $compileArgs += "/DSOURCE_DLL=$FrameLibrary" }
|
||||
if ($FrameLibrary) { $compileArgs += @("/DSOURCE_DLL=$FrameLibrary", "/DREPORT_DIR=$reportDir") }
|
||||
if ($mode -eq 'missing-stage') { $compileArgs += '/DMISSING_STAGE' }
|
||||
if ($mode -eq 'cancelled') { $compileArgs += '/DCANCELLED' }
|
||||
Invoke-Checked $Makensis ($compileArgs + $fixture)
|
||||
@@ -76,9 +78,24 @@ try {
|
||||
if ((Test-Path -LiteralPath (Join-Path $target 'obsolete.txt')) -ne $expectObsolete) {
|
||||
throw "Directory smoke $mode did not preserve the expected old-only file state"
|
||||
}
|
||||
if (@(Get-ChildItem -LiteralPath $caseRoot -Directory | Where-Object Name -ne 'Application').Count -ne 0) {
|
||||
if (@(Get-ChildItem -LiteralPath $caseRoot -Directory | Where-Object Name -notin @('Application', 'installer-logs')).Count -ne 0) {
|
||||
throw "Directory smoke $mode left transaction directories behind"
|
||||
}
|
||||
if ($FrameLibrary) {
|
||||
# Only an extraction failure writes a report; it must quote 7-Zip's verdict so support can read the cause.
|
||||
$reports = @(if (Test-Path -LiteralPath $reportDir) { Get-ChildItem -LiteralPath $reportDir -File })
|
||||
if ($mode -eq 'broken') {
|
||||
if ($reports.Count -ne 1 -or $reports[0].Name -notmatch '^extract-failure-20\d{6}-\d{6}\.log$') {
|
||||
throw "Directory smoke $mode did not save exactly one extraction report"
|
||||
}
|
||||
$report = Get-Content -LiteralPath $reports[0].FullName -Raw -Encoding UTF8
|
||||
foreach ($expected in @('Result: 7-Zip exit code 2 (fatal error)', "Archive: $caseArchive", 'Cannot open the file as [7z] archive')) {
|
||||
if (-not $report.Contains($expected)) { throw "Directory smoke $mode report lacks '$expected'" }
|
||||
}
|
||||
} elseif ($reports.Count -ne 0) {
|
||||
throw "Directory smoke $mode saved an unexpected extraction report"
|
||||
}
|
||||
}
|
||||
Write-Output "Directory replacement smoke passed: $mode"
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
param(
|
||||
[Parameter(Mandatory)][string]$Makensis,
|
||||
[Parameter(Mandatory)][string]$SevenZip,
|
||||
[Parameter(Mandatory)][string]$PluginDir
|
||||
[Parameter(Mandatory)][string]$PluginDir,
|
||||
[string]$FrameLibrary
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$desktopRoot = Split-Path $PSScriptRoot -Parent
|
||||
@@ -15,7 +16,10 @@ function Invoke-Checked([string]$Executable, [string[]]$Arguments) {
|
||||
}
|
||||
|
||||
try {
|
||||
& (Join-Path $PSScriptRoot 'smoke-installer-directories.ps1') -Makensis $Makensis -SevenZip $SevenZip
|
||||
# The prepared window-frame.dll adds the native extraction path and its failure report to the directory checks.
|
||||
$directoryArguments = @{ Makensis = $Makensis; SevenZip = $SevenZip }
|
||||
if ($FrameLibrary) { $directoryArguments.FrameLibrary = $FrameLibrary }
|
||||
& (Join-Path $PSScriptRoot 'smoke-installer-directories.ps1') @directoryArguments
|
||||
$payload = Join-Path $scratch 'payload'
|
||||
New-Item -ItemType Directory -Path $payload | Out-Null
|
||||
[System.IO.File]::WriteAllText((Join-Path $payload 'locked.txt'), 'new runtime')
|
||||
|
||||
@@ -70,4 +70,13 @@ describe('desktop build version discovery', () => {
|
||||
await expect(suggestDesktopBuildVersion({ productVersion: PRERELEASE, target: 'mac-arm64', environment: {}, date: DATE, artifactsRoot }))
|
||||
.resolves.toBe(`${PRERELEASE}.${DATE}.4`)
|
||||
})
|
||||
|
||||
it('counts unsigned Windows artifacts written under their own suffix', async () => {
|
||||
const artifactsRoot = await artifactsWith([
|
||||
`deepseek-harness-${PRERELEASE}.${DATE}.2-win-x64-unsigned.exe`,
|
||||
`deepseek-harness-${PRERELEASE}.${DATE}.5-win-x64-unsigned.exe.blockmap`,
|
||||
])
|
||||
await expect(suggestDesktopBuildVersion({ productVersion: PRERELEASE, target: 'win-x64', environment: {}, date: DATE, artifactsRoot }))
|
||||
.resolves.toBe(`${PRERELEASE}.${DATE}.3`)
|
||||
})
|
||||
})
|
||||
@@ -11,6 +11,9 @@ LangString decompressionFailed ${LANG_ENGLISH} "Payload extraction failed"
|
||||
!insertmacro dshExtractPayload "${PAYLOAD_FILE}"
|
||||
!macroend
|
||||
!ifdef SOURCE_DLL
|
||||
LoadLanguageFile "${NSISDIR}\Contrib\Language files\SimpChinese.nlf"
|
||||
!include "..\..\installer\strings.nsh"
|
||||
!define DSH_INSTALLER_LOG_DIR "${REPORT_DIR}"
|
||||
!include "..\..\scripts\installer.nsh"
|
||||
!else
|
||||
!include "..\..\scripts\installer-directories.nsh"
|
||||
|
||||
@@ -57,6 +57,19 @@ describe('installer preparation preserves application dependencies', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('names unsigned Windows artifacts so they cannot pass for release builds', async () => {
|
||||
const { createElectronBuilderConfig } = await import('../scripts/electron-builder-config.mjs')
|
||||
const config = createElectronBuilderConfig({
|
||||
DSH_DESKTOP_APP_ID: 'com.example.installer',
|
||||
DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://policy.example.com',
|
||||
DSH_DESKTOP_MANDATORY_UPDATE_CONFIG: JSON.stringify({ allowedAuthOrigins: ['https://login.example.com'] }),
|
||||
DSH_DESKTOP_TARGET_PLATFORM: 'win32',
|
||||
DSH_DESKTOP_TARGET_ARCH: 'x64',
|
||||
DSH_DESKTOP_UNSIGNED: '1',
|
||||
}, 'win32', 'x64')
|
||||
expect(config.artifactName).toBe('deepseek-harness-${version}-${os}-${arch}-unsigned.${ext}')
|
||||
})
|
||||
|
||||
it('packages every preload entry point the shell loads', async () => {
|
||||
const { readdirSync, readFileSync } = await import('node:fs')
|
||||
const sourceDirectory = new URL('../src/', import.meta.url)
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
// Extraction failure reports keep 7-Zip's evidence intact while the dialog headline and excerpt stay bounded.
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#define UNICODE
|
||||
#include <windows.h>
|
||||
#include <cassert>
|
||||
#include <string>
|
||||
#include "../installer/extract-report.h"
|
||||
|
||||
extern "C" __declspec(dllimport) int __cdecl InstallerReportExtractFailure(
|
||||
HWND parent, int code, LPCWSTR archive, LPCWSTR destination, LPCWSTR log, LPCWSTR reportPath, int show,
|
||||
LPCWSTR title, LPCWSTR heading, LPCWSTR hint, LPCWSTR copy, LPCWSTR expand, LPCWSTR collapse, LPCWSTR savedFormat, LPCWSTR unsaved, LPCWSTR copied);
|
||||
|
||||
static bool Contains(const std::wstring& text, const std::wstring& needle) { return text.find(needle) != std::wstring::npos; }
|
||||
|
||||
static std::string ReadBytes(const std::wstring& path) {
|
||||
HANDLE file = CreateFileW(path.c_str(), GENERIC_READ, FILE_SHARE_READ, nullptr, OPEN_EXISTING, 0, nullptr);
|
||||
assert(file != INVALID_HANDLE_VALUE);
|
||||
std::string bytes(1 << 16, '\0');
|
||||
DWORD count = 0;
|
||||
const BOOL read = ReadFile(file, &bytes[0], static_cast<DWORD>(bytes.size()), &count, nullptr);
|
||||
assert(read);
|
||||
CloseHandle(file);
|
||||
bytes.resize(count);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
static void WriteBytes(const std::wstring& path, const std::string& bytes) {
|
||||
HANDLE file = CreateFileW(path.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, 0, nullptr);
|
||||
assert(file != INVALID_HANDLE_VALUE);
|
||||
DWORD written = 0;
|
||||
const BOOL wrote = WriteFile(file, bytes.data(), static_cast<DWORD>(bytes.size()), &written, nullptr);
|
||||
assert(wrote);
|
||||
CloseHandle(file);
|
||||
}
|
||||
|
||||
int wmain() {
|
||||
using namespace extract_report;
|
||||
|
||||
// Result codes distinguish 7-Zip's own exit codes, Win32 failures around its process, and raw crash statuses.
|
||||
assert(DescribeResult(2) == L"7-Zip exit code 2 (fatal error)");
|
||||
assert(DescribeResult(9) == L"7-Zip exit code 9 (unexpected exit code)");
|
||||
assert(DescribeResult(-5).rfind(L"Windows error 5 while running 7-Zip", 0) == 0);
|
||||
assert(Contains(DescribeResult(-ERROR_FILE_NOT_FOUND), L"Windows error 2"));
|
||||
assert(DescribeResult(static_cast<int>(0xC0000005)) == L"7-Zip terminated with status 0xC0000005");
|
||||
assert(DescribeResult(1000) == L"7-Zip terminated with status 0x000003E8");
|
||||
|
||||
// The headline prefers the line naming the failing operation; the archive path 7-Zip echoes first is skipped
|
||||
// even when it contains spaces.
|
||||
const std::wstring spacedArchive = L"C:\\Users\\Jane Doe\\AppData\\Local\\Temp\\nsa1F2C.tmp\\app-64.7z";
|
||||
const std::wstring broken = L"ERROR: " + spacedArchive + L"\r\napp-64.7z\r\nOpen ERROR: Cannot open the file as [7z] archive\r\n\r\n\r\nERRORS:\r\nIs not archive\r\n";
|
||||
assert(Headline(2, broken, spacedArchive) == L"Open ERROR: Cannot open the file as [7z] archive");
|
||||
assert(Headline(2, L"ERROR: app-64.7z\r\nOpen ERROR: Is not archive\r\n", spacedArchive) == L"Open ERROR: Is not archive");
|
||||
const std::wstring locked = L"ERROR: Cannot delete output file : The process cannot access the file. : C:\\Apps\\Harness.new-1\\resources\\app.node\r\n";
|
||||
assert(Headline(2, locked, spacedArchive) == Trim(locked));
|
||||
assert(Headline(2, L"ERROR: " + spacedArchive + L"\r\n" + locked, spacedArchive) == Trim(locked));
|
||||
assert(Headline(-ERROR_ACCESS_DENIED, L"", spacedArchive) == DescribeResult(-ERROR_ACCESS_DENIED));
|
||||
assert(Headline(2, L"ERROR: " + spacedArchive + L"\r\n", spacedArchive) == DescribeResult(2));
|
||||
assert(Headline(1, L"ERROR: only a note\r\n", spacedArchive) == L"ERROR: only a note");
|
||||
const std::wstring longLine = L"ERROR: Cannot open output file : " + std::wstring(400, L'x');
|
||||
const std::wstring headline = Headline(2, longLine, spacedArchive);
|
||||
assert(headline.size() == kHeadlineLimit && headline.back() == kEllipsis);
|
||||
|
||||
// The report carries the result, both paths, and 7-Zip's output verbatim.
|
||||
const std::wstring report = Compose(2, L"C:\\Temp\\app-64.7z", L"C:\\Apps\\Harness.new-1", locked, L"2026-09-22 10:59:49", L"10.0.26100");
|
||||
assert(Contains(report, L"Result: 7-Zip exit code 2 (fatal error)\r\n"));
|
||||
assert(Contains(report, L"Archive: C:\\Temp\\app-64.7z\r\n"));
|
||||
assert(Contains(report, L"Destination: C:\\Apps\\Harness.new-1\r\n"));
|
||||
assert(Contains(report, L"Windows: 10.0.26100\r\n"));
|
||||
assert(Contains(report, L"\r\n7-Zip output:\r\n" + Trim(locked) + L"\r\n"));
|
||||
assert(Contains(Compose(2, L"a", L"b", L" \r\n", L"t", L"w"), L"7-Zip output:\r\n(none)\r\n"));
|
||||
|
||||
// The excerpt shows the result and 7-Zip's own lines, bounded by lines and characters, and counts what it left out.
|
||||
std::wstring manyErrors;
|
||||
for (int i = 0; i < 40; ++i) manyErrors += L"ERROR: Cannot open output file : Access is denied. : C:\\Apps\\file" + std::to_wstring(i) + L".dll\r\n";
|
||||
const std::wstring excerpt = Excerpt(Compose(2, L"a", L"b", manyErrors, L"t", L"w"));
|
||||
const std::vector<std::wstring> excerptLines = Lines(excerpt);
|
||||
assert(excerptLines.size() == kExcerptLines + 1);
|
||||
assert(excerptLines[0] == L"Result: 7-Zip exit code 2 (fatal error)");
|
||||
assert(excerptLines[1].rfind(L"ERROR: Cannot open output file", 0) == 0);
|
||||
assert(excerpt.size() <= kExcerptChars + 64);
|
||||
assert(Contains(excerpt, L" (" + std::to_wstring(41 - kExcerptLines) + L" more lines in the saved report)"));
|
||||
assert(!Contains(excerpt, L"Archive: "));
|
||||
assert(Excerpt(Compose(2, L"a", L"b", L"one\r\ntwo", L"t", L"w")) == L"Result: 7-Zip exit code 2 (fatal error)\r\none\r\ntwo");
|
||||
assert(Excerpt(Compose(2, L"a", L"b", std::wstring(2000, L'x'), L"t", L"w")).size() < 64 + kExcerptLineLimit + 64);
|
||||
|
||||
// A cut inside a multi-byte sequence is trimmed back to a code point boundary.
|
||||
std::string partial("abc\xE6\x96\x87\xE4\xBB");
|
||||
TrimPartialUtf8(partial);
|
||||
assert(partial == "abc\xE6\x96\x87");
|
||||
std::string complete("abc\xE6\x96\x87");
|
||||
TrimPartialUtf8(complete);
|
||||
assert(complete == "abc\xE6\x96\x87");
|
||||
std::string ascii("abc");
|
||||
TrimPartialUtf8(ascii);
|
||||
assert(ascii == "abc");
|
||||
|
||||
// 7-Zip output arrives as UTF-8; anything else falls back to the system code page instead of vanishing.
|
||||
assert(Decode("Access denied: \xE6\x96\x87\xE4\xBB\xB6") == L"Access denied: \x6587\x4EF6");
|
||||
assert(!Decode("\xC1\xED\xD2\xBB : file").empty());
|
||||
assert(Decode("") == L"");
|
||||
|
||||
// The exported entry point writes the report beside a missing directory and survives a missing log file.
|
||||
WCHAR temp[MAX_PATH];
|
||||
const DWORD tempLength = GetTempPathW(MAX_PATH, temp);
|
||||
assert(tempLength);
|
||||
const std::wstring root = std::wstring(temp) + L"dsh-extract-report-" + std::to_wstring(GetCurrentProcessId());
|
||||
const BOOL created = CreateDirectoryW(root.c_str(), nullptr);
|
||||
assert(created);
|
||||
const std::wstring log = root + L"\\extract.log";
|
||||
WriteBytes(log, "ERROR: Cannot open output file : Access is denied. : C:\\Apps\\Harness.new-1\\\xE6\x96\x87\xE4\xBB\xB6.dll\r\n");
|
||||
const std::wstring reportPath = root + L"\\nested\\logs\\extract-failure.log";
|
||||
const int written = InstallerReportExtractFailure(nullptr, 2, L"C:\\Temp\\app-64.7z", L"C:\\Apps\\Harness.new-1", log.c_str(), reportPath.c_str(), 0,
|
||||
L"title", L"heading", L"hint", L"copy", L"expand", L"collapse", L"saved %s", L"unsaved", L"copied");
|
||||
assert(written == 1);
|
||||
const std::string saved = ReadBytes(reportPath);
|
||||
assert(saved.rfind("\xEF\xBB\xBF", 0) == 0);
|
||||
assert(saved.find("Result: 7-Zip exit code 2 (fatal error)") != std::string::npos);
|
||||
assert(saved.find("Access is denied. : C:\\Apps\\Harness.new-1\\\xE6\x96\x87\xE4\xBB\xB6.dll") != std::string::npos);
|
||||
const std::wstring missingLogReport = root + L"\\missing.log.txt";
|
||||
const int withoutLog = InstallerReportExtractFailure(nullptr, -ERROR_ACCESS_DENIED, L"a", L"b", (root + L"\\absent.log").c_str(), missingLogReport.c_str(), 0,
|
||||
L"title", L"heading", L"hint", L"copy", L"expand", L"collapse", L"saved %s", L"unsaved", L"copied");
|
||||
assert(withoutLog == 1);
|
||||
assert(ReadBytes(missingLogReport).find("Windows error 5 while running 7-Zip") != std::string::npos);
|
||||
assert(ReadBytes(missingLogReport).find("7-Zip output:\r\n(none)") != std::string::npos);
|
||||
// An unwritable report location reports failure without throwing.
|
||||
const int unwritable = InstallerReportExtractFailure(nullptr, 2, L"a", L"b", log.c_str(), (log + L"\\impossible.txt").c_str(), 0,
|
||||
L"title", L"heading", L"hint", L"copy", L"expand", L"collapse", L"saved %s", L"unsaved", L"copied");
|
||||
assert(unwritable == 0);
|
||||
|
||||
for (const std::wstring& file : {reportPath, missingLogReport, log}) DeleteFileW(file.c_str());
|
||||
RemoveDirectoryW((root + L"\\nested\\logs").c_str());
|
||||
RemoveDirectoryW((root + L"\\nested").c_str());
|
||||
const BOOL removed = RemoveDirectoryW(root.c_str());
|
||||
assert(removed);
|
||||
return 0;
|
||||
}
|
||||
@@ -90,6 +90,7 @@ describe('Windows update publisher', () => {
|
||||
DSH_DESKTOP_AUTO_UPDATE_ENV: 'production',
|
||||
}, 'win32', 'x64')
|
||||
expect(config.win.forceCodeSigning).toBe(true)
|
||||
expect(config.artifactName).toBe('deepseek-harness-${version}-${os}-${arch}.${ext}')
|
||||
expect(typeof config.win.signtoolOptions.sign).toBe('function')
|
||||
const manager = new WindowsSignToolManager({ platformSpecificBuildOptions: config.win, getCscLink: () => undefined })
|
||||
expect(await manager.computedPublisherName.value).toEqual(['CN=Publisher,O=Company,C=CN'])
|
||||
|
||||
Generated
+5
-15
@@ -560,6 +560,9 @@ importers:
|
||||
|
||||
apps/desktop:
|
||||
dependencies:
|
||||
'@deepseek-ai/cordis':
|
||||
specifier: workspace:^
|
||||
version: link:../../vendor/cordis
|
||||
'@deepseek-ai/dsh-api-gateway':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/api/gateway
|
||||
@@ -18639,13 +18642,9 @@ packages:
|
||||
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting [email protected]
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==}
|
||||
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting [email protected]
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
|
||||
deprecated: Glob versions prior to v9 are no longer supported
|
||||
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting [email protected]
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==}
|
||||
@@ -26057,15 +26056,6 @@ snapshots:
|
||||
package-json-from-dist: 1.0.1
|
||||
path-scurry: 1.11.1
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
fs.realpath: 1.0.0
|
||||
inflight: 1.0.6
|
||||
inherits: 2.0.4
|
||||
minimatch: 3.1.5
|
||||
once: 1.4.0
|
||||
path-is-absolute: 1.0.1
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
fs.realpath: 1.0.0
|
||||
@@ -27799,7 +27789,7 @@ snapshots:
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
glob: 7.2.0
|
||||
glob: 7.2.3
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
|
||||
Reference in new issue
Block a user