From ab62f09c22560e3649901e32f1a41fe51888ba75 Mon Sep 17 00:00:00 2001 From: winewill <324803222+winewill@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:38:05 +0800 Subject: [PATCH 1/3] fix(desktop): report Windows extraction failures with 7-Zip evidence The installer showed "Failed to decompress files" and discarded 7-Zip's error output, so a failure such as security software quarantining an extracted file could not be told apart from a full disk or a broken download. - InstallerExtract returns negated Win32 errors for launch failures so they no longer collide with 7-Zip exit codes; 7-Zip writes UTF-8 errors. - window-frame.dll composes a report (result, archive, destination, Windows version, full 7-Zip output), saves it under the updater cache directory, and presents a task dialog with the first error line, a collapsible bounded excerpt, and a Copy error details button. Silent installs keep only the report; a missing task dialog falls back to a message box. - Unsigned Windows builds name their installer *-unsigned.exe so a shared file cannot pass for a release artifact; version discovery still counts them. - The directory smoke asserts the saved report on a broken archive and smoke-windows.ps1 accepts the prepared DLL. Refs #4893 --- apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 6 +- apps/desktop/README.zh.md | 6 +- apps/desktop/electron-builder.config.d.mts | 1 + apps/desktop/installer/extract-report.h | 277 ++++++++++++++++++ apps/desktop/installer/extract.h | 42 +-- apps/desktop/installer/strings.nsh | 16 + apps/desktop/installer/window-frame.cpp | 25 ++ .../desktop-build-version-discovery.ts | 4 +- .../scripts/electron-builder-config.mjs | 3 +- .../desktop/scripts/installer-directories.nsh | 6 +- apps/desktop/scripts/installer.nsh | 33 +++ .../scripts/prepare-windows-installer.ps1 | 10 + .../scripts/smoke-installer-directories.ps1 | 20 +- apps/desktop/scripts/smoke-windows.ps1 | 8 +- .../desktop-build-version-discovery.spec.ts | 9 + .../fixtures/installer-directory-smoke.nsi | 3 + .../desktop/tests/installer-packaging.spec.ts | 13 + .../windows-installer-extract-report.cpp | 114 +++++++ .../tests/windows-update-publisher.spec.ts | 1 + 20 files changed, 570 insertions(+), 31 deletions(-) create mode 100644 apps/desktop/installer/extract-report.h create mode 100644 apps/desktop/tests/windows-installer-extract-report.cpp diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 1281bcb68f..4765413cbe 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -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: 813813cf2292a6cfece78a273dcf6843093a95cf -README.zh.md: e63557e98c0a19ecd9bf9e6c9497ce074ce8b57b +README.md: c90e8162f785bb0aa8e25b755fa278725d1dd034 +README.zh.md: d78fc7ada24826f317877162ecf26cad21b519e3 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 813813cf22..c90e8162f7 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -190,14 +190,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 `%LOCALAPPDATA%\deepseek harness-updater\installer-logs\extract-failure-.log` 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--win-x64-unsigned.exe` so they cannot be mistaken for release artifacts. + ### Upload updates diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index e63557e98c..d78fc7ada2 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -190,14 +190,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%\deepseek harness-updater\installer-logs\extract-failure-<时间戳>.log`,并在弹窗中显示首条错误行和 **复制错误信息** 按钮;静默安装只写入报告。未签名的 Windows 构建(`DSH_DESKTOP_UNSIGNED=1`)会将安装包命名为 `deepseek-harness-<版本>-win-x64-unsigned.exe`,以免被误当作发布产物。 + ### 上传更新 diff --git a/apps/desktop/electron-builder.config.d.mts b/apps/desktop/electron-builder.config.d.mts index c35e702674..d1cae61b6a 100644 --- a/apps/desktop/electron-builder.config.d.mts +++ b/apps/desktop/electron-builder.config.d.mts @@ -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 diff --git a/apps/desktop/installer/extract-report.h b/apps/desktop/installer/extract-report.h new file mode 100644 index 0000000000..eb83f431a5 --- /dev/null +++ b/apps/desktop/installer/extract-report.h @@ -0,0 +1,277 @@ +// 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 +#include +#include +#include +#include +#include + +namespace extract_report { + +constexpr size_t kOutputLimit = 256 * 1024; +constexpr size_t kHeadlineLimit = 160; +constexpr size_t kExcerptLineLimit = 120; +constexpr size_t kExcerptLines = 10; +constexpr size_t kExcerptChars = 1000; + +// 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 Lines(const std::wstring& text) { + std::vector 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; +} + +constexpr wchar_t kEllipsis = 0x2026; + +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(&buffer), 0, nullptr); + std::wstring message = length && buffer ? Trim(std::wstring(buffer, length)) : L""; + if (buffer) LocalFree(buffer); + return message; +} + +// Positive results are 7-Zip exit codes; negative results are negated Win32 errors from launching or supervising it. +inline std::wstring DescribeResult(int code) { + if (code >= 0) return L"7-Zip exit code " + std::to_wstring(code) + L" (" + SevenZipMeaning(code) + L")"; + const DWORD error = static_cast(-static_cast(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")"); +} + +// 7-Zip prefixes the failing operation and path with "ERROR:"; the bare archive name it prints first says nothing. +inline std::wstring Headline(int code, const std::wstring& output) { + 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 std::wstring detail = Trim(line.substr(line.find(L':', marker) == std::wstring::npos ? line.size() : line.find(L':', marker) + 1)); + if (detail.empty()) continue; + if (detail.find(L" : ") != std::wstring::npos || 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\n7-Zip output:\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 dialog stays compact: long lines are cut and a trailing note counts what the report still holds. +inline std::wstring Excerpt(const std::wstring& report, size_t maxLines = kExcerptLines, size_t maxChars = kExcerptChars) { + const std::vector lines = Lines(Trim(report)); + 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; +} + +inline std::wstring Decode(const std::string& bytes) { + if (bytes.empty()) return L""; + for (UINT codePage : {static_cast(CP_UTF8), static_cast(CP_ACP)}) { + const DWORD flags = codePage == CP_UTF8 ? MB_ERR_INVALID_CHARS : 0; + const int length = MultiByteToWideChar(codePage, flags, bytes.data(), static_cast(bytes.size()), nullptr, 0); + if (length <= 0) continue; + std::wstring text(static_cast(length), L'\0'); + MultiByteToWideChar(codePage, flags, bytes.data(), static_cast(bytes.size()), &text[0], length); + return text; + } + return L"(undecodable 7-Zip output)"; +} + +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; + while (bytes.size() < kOutputLimit && ReadFile(file, buffer, sizeof(buffer), &count, nullptr) && count) { + bytes.append(buffer, std::min(count, kOutputLimit - bytes.size())); + } + CloseHandle(file); + return Decode(bytes); +} + +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(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(text.size()), nullptr, 0, nullptr, nullptr); + if (length <= 0) return false; + std::string bytes("\xEF\xBB\xBF", 3); + bytes.resize(3 + static_cast(length)); + WideCharToMultiByte(CP_UTF8, 0, text.data(), static_cast(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(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(data); + if (notification == TDN_BUTTON_CLICKED && static_cast(wparam) == kCopyButton && state) { + if (CopyToClipboard(dialog, *state->report)) { + SendMessageW(dialog, TDM_SET_ELEMENT_TEXT, TDE_FOOTER, reinterpret_cast(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(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(&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 diff --git a/apps/desktop/installer/extract.h b/apps/desktop/installer/extract.h index 86c5161824..af30077912 100644 --- a/apps/desktop/installer/extract.h +++ b/apps/desktop/installer/extract.h @@ -1,5 +1,6 @@ // The pinned 7-Zip executable owns extraction; this pipe reader publishes its work percentage. #pragma once +#include #include #include #include "extract-progress.h" @@ -32,25 +33,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(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 +65,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 +86,18 @@ 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(100)); - return result; + return result > INT_MAX ? INT_MAX : static_cast(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) { +// Positive results are 7-Zip exit codes; negative results are negated Win32 errors from launching or supervising it. +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); } } diff --git a/apps/desktop/installer/strings.nsh b/apps/desktop/installer/strings.nsh index bea3ac97d9..f2f2b08191 100644 --- a/apps/desktop/installer/strings.nsh +++ b/apps/desktop/installer/strings.nsh @@ -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} "错误信息已复制到剪贴板。" diff --git a/apps/desktop/installer/window-frame.cpp b/apps/desktop/installer/window-frame.cpp index 7f47563e81..0a81fae33e 100644 --- a/apps/desktop/installer/window-frame.cpp +++ b/apps/desktop/installer/window-frame.cpp @@ -12,10 +12,35 @@ #include #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) + 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); diff --git a/apps/desktop/scripts/desktop-build-version-discovery.ts b/apps/desktop/scripts/desktop-build-version-discovery.ts index 32ed5a37b2..6bb7a1b045 100644 --- a/apps/desktop/scripts/desktop-build-version-discovery.ts +++ b/apps/desktop/scripts/desktop-build-version-discovery.ts @@ -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-(?.+)-(?: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-(?.+)-(?:mac|win)-(?:arm64|x64)(?:-unsigned)?\.(?:exe|dmg|zip)$/u /** Inputs that decide which versions are already taken. */ export interface DesktopBuildVersionSuggestionOptions { diff --git a/apps/desktop/scripts/electron-builder-config.mjs b/apps/desktop/scripts/electron-builder-config.mjs index e3873de35f..2cee72d23d 100644 --- a/apps/desktop/scripts/electron-builder-config.mjs +++ b/apps/desktop/scripts/electron-builder-config.mjs @@ -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, diff --git a/apps/desktop/scripts/installer-directories.nsh b/apps/desktop/scripts/installer-directories.nsh index 881bdc8b20..49d4a2fb82 100644 --- a/apps/desktop/scripts/installer-directories.nsh +++ b/apps/desktop/scripts/installer-directories.nsh @@ -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} diff --git a/apps/desktop/scripts/installer.nsh b/apps/desktop/scripts/installer.nsh index 8d0378ebc0..7ddda5aa85 100644 --- a/apps/desktop/scripts/installer.nsh +++ b/apps/desktop/scripts/installer.nsh @@ -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" @@ -132,6 +133,38 @@ ManifestDPIAware true ${EndIf} !macroend +; The report outlives $PLUGINSDIR so a user can send it; silent installs keep only the file. +!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} "" "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 !ifdef BUILD_UNINSTALLER InitPluginsDir diff --git a/apps/desktop/scripts/prepare-windows-installer.ps1 b/apps/desktop/scripts/prepare-windows-installer.ps1 index 0209e74d78..98e02cf9a6 100644 --- a/apps/desktop/scripts/prepare-windows-installer.ps1 +++ b/apps/desktop/scripts/prepare-windows-installer.ps1 @@ -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')) { diff --git a/apps/desktop/scripts/smoke-installer-directories.ps1 b/apps/desktop/scripts/smoke-installer-directories.ps1 index 2fa8eabfb5..283f168016 100644 --- a/apps/desktop/scripts/smoke-installer-directories.ps1 +++ b/apps/desktop/scripts/smoke-installer-directories.ps1 @@ -42,8 +42,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 +77,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-\d{8}-\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 { diff --git a/apps/desktop/scripts/smoke-windows.ps1 b/apps/desktop/scripts/smoke-windows.ps1 index 5f3c0372e8..8ae29ad614 100644 --- a/apps/desktop/scripts/smoke-windows.ps1 +++ b/apps/desktop/scripts/smoke-windows.ps1 @@ -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') diff --git a/apps/desktop/tests/desktop-build-version-discovery.spec.ts b/apps/desktop/tests/desktop-build-version-discovery.spec.ts index 490227416e..8438e076ad 100644 --- a/apps/desktop/tests/desktop-build-version-discovery.spec.ts +++ b/apps/desktop/tests/desktop-build-version-discovery.spec.ts @@ -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`) + }) }) diff --git a/apps/desktop/tests/fixtures/installer-directory-smoke.nsi b/apps/desktop/tests/fixtures/installer-directory-smoke.nsi index 2269a06d38..3afd73a433 100644 --- a/apps/desktop/tests/fixtures/installer-directory-smoke.nsi +++ b/apps/desktop/tests/fixtures/installer-directory-smoke.nsi @@ -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" diff --git a/apps/desktop/tests/installer-packaging.spec.ts b/apps/desktop/tests/installer-packaging.spec.ts index ef8d377e92..3e42ed5a63 100644 --- a/apps/desktop/tests/installer-packaging.spec.ts +++ b/apps/desktop/tests/installer-packaging.spec.ts @@ -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) diff --git a/apps/desktop/tests/windows-installer-extract-report.cpp b/apps/desktop/tests/windows-installer-extract-report.cpp new file mode 100644 index 0000000000..ea73fd2fd1 --- /dev/null +++ b/apps/desktop/tests/windows-installer-extract-report.cpp @@ -0,0 +1,114 @@ +// 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 +#include +#include +#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(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(bytes.size()), &written, nullptr); + assert(wrote); + CloseHandle(file); +} + +int wmain() { + using namespace extract_report; + + // Result codes distinguish 7-Zip's own exit codes from Win32 failures around its process. + 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")); + + // The headline prefers the line naming the failing operation over the archive name 7-Zip prints first. + const std::wstring broken = L"ERROR: broken.7z\r\nbroken.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) == L"Open ERROR: Cannot open the file as [7z] 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) == Trim(locked)); + assert(Headline(-ERROR_ACCESS_DENIED, L"") == DescribeResult(-ERROR_ACCESS_DENIED)); + assert(Headline(1, L"ERROR: only-a-path\r\n") == L"ERROR: only-a-path"); + const std::wstring longLine = L"ERROR: Cannot open output file : " + std::wstring(400, L'x'); + const std::wstring headline = Headline(2, longLine); + 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 is bounded by lines and characters and tells how much the saved report still holds. + 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")); + assert(Lines(excerpt).size() == kExcerptLines + 1); + assert(excerpt.size() <= kExcerptChars + 64); + assert(Contains(excerpt, L" (" + std::to_wstring(48 - kExcerptLines) + L" more lines in the saved report)")); + assert(Excerpt(L"one\r\ntwo") == L"one\r\ntwo"); + assert(Excerpt(std::wstring(2000, L'x') + L"\r\nnext").size() < kExcerptLineLimit + 64); + + // 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; +} diff --git a/apps/desktop/tests/windows-update-publisher.spec.ts b/apps/desktop/tests/windows-update-publisher.spec.ts index 2c27e600a1..a1af8d5416 100644 --- a/apps/desktop/tests/windows-update-publisher.spec.ts +++ b/apps/desktop/tests/windows-update-publisher.spec.ts @@ -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']) From bed4a2b5653f8766293a1c7e8885a0e5445be332 Mon Sep 17 00:00:00 2001 From: winewill <324803222+winewill@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:30:56 +0800 Subject: [PATCH 2/3] fix(desktop): address review of the extraction failure report - Headline skips the archive path 7-Zip echoes before an open failure (also when the path contains spaces) and prefers the operation line. - Raw process statuses such as 0xC0000005 are kept and printed in hex instead of being clamped to INT_MAX. - The expanded panel excerpts the result and 7-Zip's own lines rather than the report header. - The saved report keeps up to 16 MiB of output, records any cut, and trims a partial UTF-8 sequence before decoding. - The details view no longer reads the UTF-8 log as ANSI; the test seam define and the zero-padded GetTime order are documented; the smoke asserts a year-first report name and normalizes -FrameLibrary. - README names the real updater cache directory. --- apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 2 +- apps/desktop/README.zh.md | 2 +- apps/desktop/installer/extract-report.h | 90 ++++++++++++++----- apps/desktop/installer/extract.h | 7 +- apps/desktop/installer/window-frame.cpp | 2 +- apps/desktop/scripts/installer.nsh | 15 ++-- .../scripts/smoke-installer-directories.ps1 | 3 +- .../windows-installer-extract-report.cpp | 48 +++++++--- 9 files changed, 121 insertions(+), 52 deletions(-) diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index f33e5b54c0..a1081d564f 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -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: adb17f4cd3615e407baa7993bec17f8ad70bb1a9 -README.zh.md: 1410e08eec9bbf5c271ea74900e9f1e4562697b0 +README.md: 81871171931a0d49a9063b6514ea1afc897958f2 +README.zh.md: b3931a91c6e299400d093cb6b0b9ada31c435443 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index adb17f4cd3..8187117193 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -195,7 +195,7 @@ pwsh -NoProfile -File apps/desktop/scripts/smoke-windows.ps1 -Makensis $Makensis 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 `%LOCALAPPDATA%\deepseek harness-updater\installer-logs\extract-failure-.log` 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--win-x64-unsigned.exe` so they cannot be mistaken for release artifacts. +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%\-updater\installer-logs\extract-failure-.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--win-x64-unsigned.exe` so they cannot be mistaken for release artifacts. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 1410e08eec..b3931a91c6 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -195,7 +195,7 @@ pwsh -NoProfile -File apps/desktop/scripts/smoke-windows.ps1 -Makensis $Makensis Windows 安装器先将新版本解压到安装目录旁边,再退出旧应用并通过同卷目录改名完成替换。同路径升级在替换成功前保留旧目录;解压失败时旧版不变,替换失败时尝试恢复旧目录。安装器在启动前清理旧版备份。强制结束安装器或断电可能留下 `.new-*` 或 `.old-*` 目录;不同安装位置或安装范围迁移仍使用 electron-builder 的旧卸载器流程。 -解压失败时,安装器会把 7-Zip 的结果和完整错误输出写入 `%LOCALAPPDATA%\deepseek harness-updater\installer-logs\extract-failure-<时间戳>.log`,并在弹窗中显示首条错误行和 **复制错误信息** 按钮;静默安装只写入报告。未签名的 Windows 构建(`DSH_DESKTOP_UNSIGNED=1`)会将安装包命名为 `deepseek-harness-<版本>-win-x64-unsigned.exe`,以免被误当作发布产物。 +解压失败时,安装器会把 7-Zip 的结果和完整错误输出写入更新缓存目录 `%LOCALAPPDATA%\<按包名派生>-updater\installer-logs\extract-failure-<时间戳>.log`(当前为 `@deepseek-aidsh-desktop-updater`),并在弹窗中显示首条错误行和 **复制错误信息** 按钮;静默安装只写入报告。未签名的 Windows 构建(`DSH_DESKTOP_UNSIGNED=1`)会将安装包命名为 `deepseek-harness-<版本>-win-x64-unsigned.exe`,以免被误当作发布产物。 diff --git a/apps/desktop/installer/extract-report.h b/apps/desktop/installer/extract-report.h index eb83f431a5..c885816c75 100644 --- a/apps/desktop/installer/extract-report.h +++ b/apps/desktop/installer/extract-report.h @@ -10,11 +10,13 @@ namespace extract_report { -constexpr size_t kOutputLimit = 256 * 1024; +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) { @@ -49,8 +51,6 @@ inline std::vector Lines(const std::wstring& text) { return lines; } -constexpr wchar_t kEllipsis = 0x2026; - inline std::wstring Truncate(const std::wstring& text, size_t limit) { if (text.size() <= limit) return text; return text.substr(0, limit - 1) + kEllipsis; @@ -65,24 +65,47 @@ inline std::wstring Win32Message(DWORD error) { return message; } -// Positive results are 7-Zip exit codes; negative results are negated Win32 errors from launching or supervising it. -inline std::wstring DescribeResult(int code) { - if (code >= 0) return L"7-Zip exit code " + std::to_wstring(code) + L" (" + SevenZipMeaning(code) + L")"; - const DWORD error = static_cast(-static_cast(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")"); +// 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(code)); + return text; } -// 7-Zip prefixes the failing operation and path with "ERROR:"; the bare archive name it prints first says nothing. -inline std::wstring Headline(int code, const std::wstring& output) { +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(code) <= kMaxWin32Error) { + const DWORD error = static_cast(-static_cast(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 " ERROR: " or "ERROR: : : "; 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 std::wstring detail = Trim(line.substr(line.find(L':', marker) == std::wstring::npos ? line.size() : line.find(L':', marker) + 1)); - if (detail.empty()) continue; - if (detail.find(L" : ") != std::wstring::npos || detail.find(L' ') != std::wstring::npos) return Truncate(line, kHeadlineLimit); + 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); @@ -97,7 +120,7 @@ inline std::wstring Compose(int code, const std::wstring& archive, const std::ws report += L"Archive: " + archive + L"\r\n"; report += L"Destination: " + destination + L"\r\n"; report += L"Windows: " + windowsVersion + L"\r\n"; - report += L"\r\n7-Zip output:\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"; @@ -107,9 +130,16 @@ inline std::wstring Compose(int code, const std::wstring& archive, const std::ws return report; } -// The dialog stays compact: long lines are cut and a trailing note counts what the report still holds. +// 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) { - const std::vector lines = Lines(Trim(report)); + std::vector 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) { @@ -124,6 +154,16 @@ inline std::wstring Excerpt(const std::wstring& report, size_t maxLines = kExcer 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(bytes[bytes.size() - 1 - trailing]) & 0xC0) == 0x80) ++trailing; + if (trailing >= bytes.size()) return; + const unsigned char lead = static_cast(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(CP_UTF8), static_cast(CP_ACP)}) { @@ -137,17 +177,27 @@ inline std::wstring Decode(const std::string& bytes) { 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; - while (bytes.size() < kOutputLimit && ReadFile(file, buffer, sizeof(buffer), &count, nullptr) && count) { - bytes.append(buffer, std::min(count, kOutputLimit - bytes.size())); + 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); - return Decode(bytes); + 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() { diff --git a/apps/desktop/installer/extract.h b/apps/desktop/installer/extract.h index af30077912..f63a94061b 100644 --- a/apps/desktop/installer/extract.h +++ b/apps/desktop/installer/extract.h @@ -1,6 +1,5 @@ // The pinned 7-Zip executable owns extraction; this pipe reader publishes its work percentage. #pragma once -#include #include #include #include "extract-progress.h" @@ -92,11 +91,13 @@ static int ExtractApplication(HWND parent, LPCWSTR tool, LPCWSTR archive, LPCWST DWORD result; if (!GetExitCodeProcess(process.value, &result)) return LaunchFailure(GetLastError()); if (result == 0 && parent) SetPropW(parent, L"HarnessInstaller.ExtractProgress", reinterpret_cast(100)); - return result > INT_MAX ? INT_MAX : static_cast(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(result); } // Runs on the NSIS worker; only an exit code of zero permits directory promotion. -// Positive results are 7-Zip exit codes; negative results are negated Win32 errors from launching or supervising it. +// 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 LaunchFailure(ERROR_NOT_ENOUGH_MEMORY); } diff --git a/apps/desktop/installer/window-frame.cpp b/apps/desktop/installer/window-frame.cpp index 0a81fae33e..889ede54f0 100644 --- a/apps/desktop/installer/window-frame.cpp +++ b/apps/desktop/installer/window-frame.cpp @@ -32,7 +32,7 @@ extern "C" __declspec(dllexport) int __cdecl InstallerReportExtractFailure( 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) + L"\r\n\r\n" + hint; + 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; diff --git a/apps/desktop/scripts/installer.nsh b/apps/desktop/scripts/installer.nsh index 7ddda5aa85..5980287859 100644 --- a/apps/desktop/scripts/installer.nsh +++ b/apps/desktop/scripts/installer.nsh @@ -121,19 +121,13 @@ 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 - ${EndIf} !macroend -; The report outlives $PLUGINSDIR so a user can send it; silent installs keep only the file. +; 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 @@ -146,6 +140,7 @@ ManifestDPIAware true 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 diff --git a/apps/desktop/scripts/smoke-installer-directories.ps1 b/apps/desktop/scripts/smoke-installer-directories.ps1 index 283f168016..092750b2c1 100644 --- a/apps/desktop/scripts/smoke-installer-directories.ps1 +++ b/apps/desktop/scripts/smoke-installer-directories.ps1 @@ -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' @@ -84,7 +85,7 @@ try { # 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-\d{8}-\d{6}\.log$') { + 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 diff --git a/apps/desktop/tests/windows-installer-extract-report.cpp b/apps/desktop/tests/windows-installer-extract-report.cpp index ea73fd2fd1..355d16dd94 100644 --- a/apps/desktop/tests/windows-installer-extract-report.cpp +++ b/apps/desktop/tests/windows-installer-extract-report.cpp @@ -36,21 +36,28 @@ static void WriteBytes(const std::wstring& path, const std::string& bytes) { int wmain() { using namespace extract_report; - // Result codes distinguish 7-Zip's own exit codes from Win32 failures around its process. + // 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(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 over the archive name 7-Zip prints first. - const std::wstring broken = L"ERROR: broken.7z\r\nbroken.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) == L"Open ERROR: Cannot open the file as [7z] archive"); + // 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) == Trim(locked)); - assert(Headline(-ERROR_ACCESS_DENIED, L"") == DescribeResult(-ERROR_ACCESS_DENIED)); - assert(Headline(1, L"ERROR: only-a-path\r\n") == L"ERROR: only-a-path"); + 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); + 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. @@ -62,15 +69,30 @@ int wmain() { 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 is bounded by lines and characters and tells how much the saved report still holds. + // 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")); - assert(Lines(excerpt).size() == kExcerptLines + 1); + const std::vector 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(48 - kExcerptLines) + L" more lines in the saved report)")); - assert(Excerpt(L"one\r\ntwo") == L"one\r\ntwo"); - assert(Excerpt(std::wstring(2000, L'x') + L"\r\nnext").size() < kExcerptLineLimit + 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"); From d8924486ad036e644a9aef10ada5aa76801654d8 Mon Sep 17 00:00:00 2001 From: winewill <324803222+winewill@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:21:06 +0800 Subject: [PATCH 3/3] fix(desktop): declare the cordis peer the packaged application needs apps/desktop imports @deepseek-ai/dsh-api-gateway/stream-protocol, whose import chain reaches @deepseek-ai/dsh-typert-protocol and then @deepseek-ai/cordis. Both intermediate packages declare cordis as a peerDependency and nothing satisfied it for the application closure, so electron-builder omitted cordis from app.asar and the packaged main process died before opening a window with ERR_MODULE_NOT_FOUND. A workspace checkout resolves the peer through pnpm's own links, so development runs and the unit lanes stayed green while every packaged build since efbc8e26b6 (2026-09-20) failed to launch. The lockfile additionally drops a stale duplicate glob@7.2.0 in favour of the 7.2.3 it already carries; any real pnpm install removes it. Verified with a signed win-x64 installer built from this tree: app.asar now ships cordis and @standard-schema/spec, and the installed application opens its main window. --- apps/desktop/package.json | 3 ++- pnpm-lock.yaml | 20 +++++--------------- 2 files changed, 7 insertions(+), 16 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 0840c6cb04..d7c8fbdd09 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -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:^", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5a01769828..d42d7b6be3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -556,6 +556,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 @@ -18590,13 +18593,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 i@izs.me hasBin: true - glob@7.2.0: - 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 i@izs.me - glob@7.2.3: 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 i@izs.me global-agent@3.0.0: resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} @@ -26008,15 +26007,6 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 1.11.1 - glob@7.2.0: - 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 - glob@7.2.3: dependencies: fs.realpath: 1.0.0 @@ -27750,7 +27740,7 @@ snapshots: rimraf@2.6.3: dependencies: - glob: 7.2.0 + glob: 7.2.3 roarr@2.15.4: dependencies: