From 669e24bbb95d14b46cc520664813dfc31bf14f84 Mon Sep 17 00:00:00 2001 From: winewill Date: Mon, 19 Oct 2026 16:23:32 +0800 Subject: [PATCH 01/12] fix(desktop): leave macOS fullscreen before update confirmation --- apps/desktop/README.i18n.yaml | 4 +-- apps/desktop/README.md | 2 ++ apps/desktop/README.zh.md | 2 ++ apps/desktop/src/main.ts | 5 ++-- apps/desktop/src/update-window.ts | 24 +++++++++++++++++ apps/desktop/tests/main-startup.spec.ts | 35 +++++++++++++++++++++++++ 6 files changed, 68 insertions(+), 4 deletions(-) create mode 100644 apps/desktop/src/update-window.ts diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 6060f79da9..bc1efc5253 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -57,8 +57,8 @@ en: 8cb4b7babbd8714d zh: b4aebc1d2d152e01 /deepseek-harness-desktop/updates: - en: 4788b6b60489ef92 - zh: 6312c5590efa27f1 + en: 5eee2b261492c877 + zh: 52203946dea76102 /deepseek-harness-desktop/updates/local-desktop-settings: en: 2e57457c44c85b5f zh: 5b8510a3dc4c6c9d diff --git a/apps/desktop/README.md b/apps/desktop/README.md index e0bb92c5b7..71d59f3803 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -267,6 +267,8 @@ Use `node apps/desktop/scripts/test-windows-installer.mjs --uninstall-only --com On Windows, the downloaded-update confirmation explains that the application closes during installation, reopens automatically, and should not be launched again while updating. An installer restart carrying `--updated` raises and focuses the main window once when startup opens the workspace directly, without enabling always-on-top. Startup into the welcome page discards that request so a later login keeps its normal activation behavior. Ordinary launches and other platforms do not use this foregrounding step. +On macOS, installation preparation waits for the confirmation owner to leave fullscreen before displaying the restart confirmation. Cancelling leaves the window in normal mode; destroying it during the native transition cancels installation before task teardown. This keeps installation failure recovery in a usable normal window. + Native update overlays wait for a ready document and a visible parent, and reappear when that parent is shown again. Closing an overlay releases its input interception and parent listeners. The [local window qualification](tests/README.md#verification-overlay) exercises these transitions without starting a workspace. Packaged applications check fixed Nightly asynchronously at startup. Ordinary polling uses a ten-minute base interval with independently sampled ±20% jitter. Each check failure doubles the base delay up to one hour; success resets it. The randomized delay is bounded by that cap and starts after all joined callers settle. Foreground and system-resume checks respect the same monotonic deadline; the localized Check for Updates menu item, including in the Windows caption's Application menu, runs immediately and joins an in-flight check. A newly received mandatory policy also requests an immediate feed check. Automatic checks never open dialogs or download packages. Manual checks display checking, failure, or no-update feedback with the installed version. Ordinary update dialogs fade in and out in place; consecutive prompts replace the card content and reset its scroll position while retaining the dark translucent backdrop without blurring the parent page. On Windows, dismissal hides the modal before destroying it so the parent is enabled before native window activation changes. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 430ac4ad3d..d56eb16854 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -267,6 +267,8 @@ Windows 卸载程序会随应用一起删除 Electron 用户数据目录(`%APP Windows 下载完成后的更新确认说明应用会在安装期间关闭、完成后自动打开,并提示期间不要重复启动。安装器携带 `--updated` 重启应用并直接打开工作区时,壳会将主窗口前置并聚焦一次,不启用永久置顶。启动进入欢迎页时会清除此请求,使后续登录保留正常的激活行为。普通启动和其他平台不执行此前置步骤。 +macOS 在安装准备阶段等待确认窗口的所属主窗口退出全屏,再显示重启确认。取消确认后窗口保持普通模式;原生切换期间销毁窗口会在任务停止前取消安装。安装失败后的恢复因此在可用的普通窗口中进行。 + 原生更新浮层在文档就绪且父窗口可见时显示,并在父窗口再次显示时恢复。关闭浮层会释放输入拦截和父窗口监听。[本地窗口验证](tests/README.zh.md#verification-overlay)无需启动工作区即可检查这些切换。 打包应用在启动后异步检查固定 Nightly。常规轮询以十分钟为基础间隔,每次独立采样 ±20% 的随机抖动。每次检查失败将基础延迟翻倍,上限为一小时;成功后重置。随机延迟不超过该上限,并从全部复用调用结算后开始计时。本地化的“检查更新…”菜单项(Windows 可从顶栏的“应用”菜单进入)立即执行,并复用正在进行的检查。回到前台和系统恢复时遵守相同的单调时钟截止时间。新收到的强更策略也会立即请求检查更新清单。自动检查从不弹窗或下载安装包。手动检查显示正在检查、失败或包含已安装版本号的无更新反馈。常规更新弹窗原位渐入渐出;连续弹窗替换卡片内容并重置其滚动位置,保留黑色半透明蒙层,不模糊父页面。Windows 在销毁模态窗口前先隐藏它,确保原生窗口激活切换前父窗口已恢复启用。 diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 2afdfd7194..9d410186ce 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -35,6 +35,7 @@ import { desktopUpdateReadyConfirmation, formatDesktopMessage, resolveDesktopLoc import { claimDesktopSingleInstance } from './single-instance.ts' import { desktopUpdateChannel } from './update-channel.ts' import { DesktopUpdateCoordinator } from './update-coordinator.ts' +import { prepareDesktopUpdateWindow } from './update-window.ts' import { DesktopCommandManager } from './command-management.ts' import { serveWebDocument, authenticateWebHost, forwardWebRequest } from './web-document.ts' import { DesktopFatalRecovery } from './fatal-recovery.ts' @@ -595,6 +596,8 @@ async function main(): Promise { const host = backend.host if (host === undefined) throw new DesktopUpdatePreparationError('tasks-unavailable', locale.messages.updateTasksUnavailable) const active = await host.updateTasks('inspect') + const parent = currentDialogWindow() + if (parent === undefined || !await prepareDesktopUpdateWindow(parent)) return false const ready = desktopUpdateReadyConfirmation(locale.messages, updates.state.version ?? '', process.platform) const confirmation: Electron.MessageBoxOptions = { type: active ? 'warning' : 'info', title: locale.messages.updateTitle, @@ -606,8 +609,6 @@ async function main(): Promise { if (isMandatory()) { if (!await mandatoryUI?.confirm(updates.state.version ?? '', active)) return false } else { - const parent = currentDialogWindow() - if (parent === undefined) return false const result = await updateDialog.show(parent, confirmation) if (result.response !== 0 || isMandatory()) return false } diff --git a/apps/desktop/src/update-window.ts b/apps/desktop/src/update-window.ts new file mode 100644 index 0000000000..1c8fb3aae7 --- /dev/null +++ b/apps/desktop/src/update-window.ts @@ -0,0 +1,24 @@ +/** Native window preparation before an update confirmation can stop the Host. */ +import type { BrowserWindow } from 'electron' + +/** + * Leave macOS fullscreen before creating or dismissing an installation overlay. + * @param window - Product window that owns the update confirmation. + * @returns Whether the window survived the native transition; destruction cancels installation. + */ +export async function prepareDesktopUpdateWindow(window: BrowserWindow): Promise { + if (window.isDestroyed()) return false + if (process.platform !== 'darwin' || !window.isFullScreen()) return true + return new Promise((resolve) => { + const finish = (available: boolean): void => { + window.off('leave-full-screen', left) + window.off('closed', closed) + resolve(available) + } + const left = (): void => { finish(!window.isDestroyed()) } + const closed = (): void => { finish(false) } + window.once('leave-full-screen', left) + window.once('closed', closed) + window.setFullScreen(false) + }) +} diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index d9a8f3103b..e0f627193c 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -1786,6 +1786,41 @@ describe('desktop main startup', () => { expect(host.stop).not.toHaveBeenCalled() }) + it('waits for macOS to leave fullscreen before showing installation confirmation or stopping the Host', async () => { + vi.stubGlobal('process', { ...process, platform: 'darwin' }) + const host = await readyForUpdate() + const window = harness.windows[0]! + window.fullscreen = true + window.setFullScreen.mockImplementation(() => {}) + harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 0 }) + const preparing = harness.prepareUpdate() + await vi.waitFor(() => { expect(window.setFullScreen).toHaveBeenCalledWith(false) }) + expect(harness.dialog.showMessageBox).not.toHaveBeenCalled() + expect(host.stop).not.toHaveBeenCalled() + expect(host.updateTasks.mock.calls).toEqual([['inspect']]) + window.fullscreen = false + window.emit('leave-full-screen') + await host.stopping.promise + host.exited.resolve() + await expect(preparing).resolves.toBe(true) + expect(window.listenerCount('leave-full-screen')).toBe(1) + }) + + it('cancels installation if the fullscreen owner closes during the native transition', async () => { + vi.stubGlobal('process', { ...process, platform: 'darwin' }) + const host = await readyForUpdate() + const window = harness.windows[0]! + window.fullscreen = true + window.setFullScreen.mockImplementation(() => {}) + const preparing = harness.prepareUpdate() + await vi.waitFor(() => { expect(window.setFullScreen).toHaveBeenCalledWith(false) }) + window.destroy() + await expect(preparing).resolves.toBe(false) + expect(harness.dialog.showMessageBox).not.toHaveBeenCalled() + expect(host.stop).not.toHaveBeenCalled() + expect(window.listenerCount('leave-full-screen')).toBe(1) + }) + it('waits for Platform storage cleanup before the installer takes over', async () => { const host = await readyForUpdate() harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 0 }) From fb1bfd0e69472a04aa5e0d7b11614960dad44198 Mon Sep 17 00:00:00 2001 From: winewill Date: Mon, 19 Oct 2026 16:31:38 +0800 Subject: [PATCH 02/12] fix(desktop): smooth Windows installer rendering and caption controls --- apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 4 +- apps/desktop/README.zh.md | 4 +- apps/desktop/installer/pages.nsh | 11 ++ apps/desktop/installer/theme.nsh | 3 + apps/desktop/installer/window-frame.cpp | 170 +++++++++++++++--- .../scripts/prepare-windows-installer.ps1 | 12 +- .../scripts/test-windows-installer.mjs | 11 ++ .../tests/windows-installer-render.cpp | 160 +++++++++++++++++ 9 files changed, 348 insertions(+), 31 deletions(-) create mode 100644 apps/desktop/tests/windows-installer-render.cpp diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index a46786a046..084720fb4f 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -54,8 +54,8 @@ en: ac31ebdd9de54f34 zh: 921e534d5c4b3a16 /deepseek-harness-desktop/windows-installer-interface: - en: 8cb4b7babbd8714d - zh: b4aebc1d2d152e01 + en: 23c57225f3de2350 + zh: b65b5363791f4c03 /deepseek-harness-desktop/updates: en: b83a37681e23a3cc zh: e11ab8381fa4362d diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 14bfd38f14..6154b40e74 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -253,9 +253,9 @@ The Windows installer uses native NSIS pages with light and dark palettes, syste The theme follows Windows at startup; `/THEME=light`, `/THEME=dark`, and `/THEME=auto` select a palette explicitly. The window appears after its branded controls are ready. When the welcome page first appears, the installer moves above ordinary windows once and flashes its taskbar button if another window has focus; it does not remain always on top. Progress reads the pinned 7-Zip extractor’s percentage; directory promotion, registration, and cleanup retain bounded estimates. The weighted percentage does not predict remaining time. After NSIS reports success, the bar fills over 600 ms and displays 100% briefly before the finish page appears; the transition targets 750 ms. The finish page preserves the window position. Finish dismisses the installer before launching the installed executable; a launch failure restores the page for retry. Directory replacement and failure recovery follow the [installation checks](packaging/README.md#runtime-file-selection). First-launch profile preparation remains a separate Desktop operation. -Windows packaging compiles an x86 Win32/GDI+ helper with Visual C++ Build Tools and a Windows SDK; signed builds sign this helper through the configured Windows signer. The preparation hook leaves production dependency collection to electron-builder on every platform. +Windows packaging compiles an x86 Win32/GDI+ helper with Visual C++ Build Tools and a Windows SDK; signed builds sign this helper through the configured Windows signer. The progress page keeps one opaque pixel buffer for its lifetime, draws static artwork once, and copies repainted pixels with GDI. The preparation hook leaves production dependency collection to electron-builder on every platform. -Run `pnpm --dir apps/desktop run test:installer` from the repository root on an interactive Windows x64 desktop to build and exercise a small native test payload through the production installer configuration. Each run uses a unique product identity and sequentially exercises English-only and Chinese-only installer variants, selecting test labels from the displayed welcome button. Both variants install into private directories and uninstall after testing; screenshots and results remain under `.desktop-build/installer-tests/`. The checks include upgrades to registered paths with trailing separators and rejection of drive roots. The optional `--signed` flag uses the [Windows EV configuration](windows-signing/README.md#signing-configuration) to sign test executables and the helper before embedding them; it does not enable an update feed. +Run `pnpm --dir apps/desktop run test:installer` from the repository root on an interactive Windows x64 desktop to build and exercise a small native test payload through the production installer configuration. Each run uses a unique product identity and sequentially exercises English-only and Chinese-only installer variants, selecting test labels from the displayed welcome button. Both variants install into private directories and uninstall after testing; screenshots and results remain under `.desktop-build/installer-tests/`. The checks include upgrades to registered paths with trailing separators and rejection of drive roots. Native rendering checks cover light/dark palettes at 100% and 200% scale, the painted 100% completion state, caption-button hover/pressed colors and release/cancellation, minimize/restore geometry, and GDI resource release; repaint timings are diagnostics in `render-results.log`, not CI performance limits. Run the generated `ui/render-test.exe [light|dark] [interactive-title]` with a title to inspect the real progress page without installing a payload. The optional `--signed` flag uses the [Windows EV configuration](windows-signing/README.md#signing-configuration) to sign test executables and the helper before embedding them; it does not enable an update feed. The Windows uninstaller removes the Electron user-data directory (browser storage and caches under `%APPDATA%`, nested under the package scope), the `%APPDATA%` product directory, and the updater download cache under `%LOCALAPPDATA%` together with the application, then removes ordinary empty scope directories below `%APPDATA%`. The Harness home (`~/.dsh` or `DSH_HOME`: sessions, settings, credentials, plugins) is never touched, and a `DSH_HOME` published as a Windows environment variable additionally protects any target overlapping it. Silent uninstalls remove the same data; an uninstaller started with `--updated` or `/KEEP_APP_DATA`, as electron-builder does for in-place updates and for replacing an older installation from another directory, retains it. Removal runs through the native helper, which refuses protected Windows folders and paths overlapping the installation or the home, requires a fixed local drive, leaves a linked root or ancestor in place, unlinks reparse points without entering their targets, clears read-only attributes, and keeps deleting siblings after a locked file; residue never stops uninstallation and is not reported. Installation records `InstallLocation` on the Windows uninstall entry as standard inventory metadata; on Windows 11, Uninstall in the Start menu context menu opens the installed-apps list for every Win32 application, and only MSIX packages uninstall directly from there. The uninstaller declares DPI awareness and uses Microsoft YaHei UI for Chinese. This behavior is Windows-only. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 64cc5f7b22..e35133ba31 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -253,9 +253,9 @@ Windows 安装程序使用原生 NSIS 页面,提供亮暗配色、系统阴影 主题在启动时跟随 Windows;可用 `/THEME=light`、`/THEME=dark` 和 `/THEME=auto` 显式选择配色。窗口在品牌控件准备完成后显示。欢迎页首次出现时,安装窗口会一次性移到普通窗口前方;若焦点在其他窗口,任务栏按钮会闪烁提示,但安装窗口不会始终置顶。进度读取锁定版本的 7-Zip 解压器百分比;目录替换、注册和清理仍使用有界估算。加权百分比不代表剩余时间。NSIS 报告成功后,进度条用 600 毫秒补满并短暂显示 100%,再显示完成页;切换目标时长为 750 毫秒。完成页保留窗口位置。点击完成后,安装程序先隐藏窗口,再启动已安装的可执行文件;启动失败会恢复页面以供重试。目录替换和失败恢复遵循[安装检查](packaging/README.zh.md#runtime-file-selection)。首次启动的配置档案准备仍属于独立的 Desktop 操作。 -Windows 打包使用 Visual C++ Build Tools 和 Windows SDK 编译 x86 Win32/GDI+ 辅助库;签名构建通过已配置的 Windows 签名器对该库签名。准备钩子在所有平台上均由 electron-builder 继续负责收集生产依赖。 +Windows 打包使用 Visual C++ Build Tools 和 Windows SDK 编译 x86 Win32/GDI+ 辅助库;签名构建通过已配置的 Windows 签名器对该库签名。进度页在其生命周期内保留一个不透明像素缓冲,只绘制一次静态图像,并通过 GDI 复制重绘的像素。准备钩子在所有平台上均由 electron-builder 继续负责收集生产依赖。 -在有交互式桌面的 Windows x64 上,从仓库根目录运行 `pnpm --dir apps/desktop run test:installer`,可将小型原生测试载荷接入正式安装配置并执行验证。每次运行使用独立产品身份,依次验证仅英文和仅中文的安装器变体,并根据实际显示的欢迎页按钮选择测试文案。两个变体均安装到私有目录并在测试后卸载;截图和结果保留在 `.desktop-build/installer-tests/` 下。检查包含末尾带分隔符的已登记路径升级,以及磁盘根目录拒绝。可选的 `--signed` 标志使用[Windows EV 配置](windows-signing/README.zh.md#signing-configuration),在嵌入前对测试程序和辅助库签名;它不会启用更新源。 +在有交互式桌面的 Windows x64 上,从仓库根目录运行 `pnpm --dir apps/desktop run test:installer`,可将小型原生测试载荷接入正式安装配置并执行验证。每次运行使用独立产品身份,依次验证仅英文和仅中文的安装器变体,并根据实际显示的欢迎页按钮选择测试文案。两个变体均安装到私有目录并在测试后卸载;截图和结果保留在 `.desktop-build/installer-tests/` 下。检查包含末尾带分隔符的已登记路径升级,以及磁盘根目录拒绝。原生绘制检查覆盖 100% 和 200% 缩放下的浅色/深色配色、绘制到 100% 的完成状态、标题栏按钮的悬停/按下颜色和松开触发与取消、最小化恢复后的窗口位置和尺寸,以及 GDI 资源释放;`render-results.log` 中的重绘耗时仅供诊断,不作为 CI 性能阈值。运行生成的 `ui/render-test.exe [light|dark] [interactive-title]` 并指定标题,可在不安装载荷的情况下查看真实进度页。可选的 `--signed` 标志使用[Windows EV 配置](windows-signing/README.zh.md#signing-configuration),在嵌入前对测试程序和辅助库签名;它不会启用更新源。 Windows 卸载程序会随应用一起删除 Electron 用户数据目录(`%APPDATA%` 下按包作用域嵌套的浏览器存储与缓存)、`%APPDATA%` 下的产品目录,以及 `%LOCALAPPDATA%` 下的更新下载缓存,随后移除 `%APPDATA%` 之下普通且已空的作用域目录。Harness 主目录(`~/.dsh` 或 `DSH_HOME`:会话、设置、凭据、插件)不会被触碰;以 Windows 环境变量发布的 `DSH_HOME` 还会保护所有与其重叠的目标。静默卸载删除相同的数据;以 `--updated` 或 `/KEEP_APP_DATA` 启动的卸载程序保留数据,electron-builder 在原地更新和从其他目录替换旧安装时正是这样启动它。删除通过原生辅助程序执行:它拒绝受保护的 Windows 目录以及与安装目录或主目录重叠的路径,要求固定的本地驱动器,链接的根目录或祖先目录原样保留,遇到重解析点只解除链接而不进入目标,清除只读属性,并在遇到被占用文件后继续删除其余兄弟项;残余不会中止卸载,也不会提示。安装时在 Windows 卸载注册项上记录 `InstallLocation` 作为标准的清单元数据;在 Windows 11 上,开始菜单右键菜单中的“卸载”对所有 Win32 应用都会打开已安装应用列表,只有 MSIX 包能从那里直接卸载。卸载程序声明 DPI 感知,中文使用微软雅黑 UI。此行为仅适用于 Windows。 diff --git a/apps/desktop/installer/pages.nsh b/apps/desktop/installer/pages.nsh index 13ce171cc3..1044f05324 100644 --- a/apps/desktop/installer/pages.nsh +++ b/apps/desktop/installer/pages.nsh @@ -18,6 +18,8 @@ Var InstallerEditFrame Var InstallerBrowse Var InstallerLaunch Var InstallerExpanded +Var InstallerMinimizeButton +Var InstallerCloseButton !include "${__FILEDIR__}\path.nsh" !include "${__FILEDIR__}\drawing.nsh" @@ -59,11 +61,13 @@ Function InstallerCreate !insertmacro InstallerControlColors $4 ${NSD_CreateButton} 0 0 0 0 "−" Pop $4 + StrCpy $InstallerMinimizeButton $4 !insertmacro InstallerPlace $4 504 8 40 32 ${NSD_OnClick} $4 InstallerMinimize ${NSD_OnNotify} $4 InstallerPaintButton ${NSD_CreateButton} 0 0 0 0 "×" Pop $4 + StrCpy $InstallerCloseButton $4 !insertmacro InstallerPlace $4 548 8 40 32 ${NSD_OnClick} $4 InstallerClose ${NSD_OnNotify} $4 InstallerPaintButton @@ -321,6 +325,13 @@ Function InstallerPaintButton ${If} $R1 != 0 StrCpy $R3 $InstallerControlHover ${EndIf} + ${If} $R0 == $InstallerMinimizeButton + ${OrIf} $R0 == $InstallerCloseButton + IntOp $R1 $R9 & 1 + ${If} $R1 != 0 + StrCpy $R3 $InstallerControlPressed + ${EndIf} + ${EndIf} ${EndIf} System::Call 'gdiplus::GdipCreateSolidFill(i R3, *p .R1)' System::Call 'gdiplus::GdipCreateFromHDC(p R4, *p .R5)' diff --git a/apps/desktop/installer/theme.nsh b/apps/desktop/installer/theme.nsh index a02214455c..ff3b1bbfef 100644 --- a/apps/desktop/installer/theme.nsh +++ b/apps/desktop/installer/theme.nsh @@ -35,6 +35,7 @@ Var InstallerPrimary Var InstallerPrimaryHover Var InstallerPrimaryPressed Var InstallerControlHover +Var InstallerControlPressed Var InstallerTrack Var InstallerButtonText Var InstallerBorder @@ -71,6 +72,7 @@ Function InstallerResolveTheme StrCpy $InstallerPrimaryHover 0xFFE9ECF2 StrCpy $InstallerPrimaryPressed 0xFFD4DBE9 StrCpy $InstallerControlHover 0xFF303034 + StrCpy $InstallerControlPressed 0xFF45454B StrCpy $InstallerTrack 0xFF61666B StrCpy $InstallerButtonText 0x15110F StrCpy $InstallerBorder 0xFF61666B @@ -84,6 +86,7 @@ Function InstallerResolveTheme StrCpy $InstallerPrimaryHover ${INSTALLER_PRIMARY_HOVER} StrCpy $InstallerPrimaryPressed ${INSTALLER_PRIMARY_PRESSED} StrCpy $InstallerControlHover ${INSTALLER_CONTROL_HOVER} + StrCpy $InstallerControlPressed 0xFFD4DBE9 StrCpy $InstallerTrack ${INSTALLER_TRACK_COLOR} StrCpy $InstallerButtonText 0xFFFFFF StrCpy $InstallerBorder 0xFFBBC0C8 diff --git a/apps/desktop/installer/window-frame.cpp b/apps/desktop/installer/window-frame.cpp index 04d258357d..38dfc7261b 100644 --- a/apps/desktop/installer/window-frame.cpp +++ b/apps/desktop/installer/window-frame.cpp @@ -113,7 +113,22 @@ struct ProgressPage { UINT dpi; ULONG_PTR gdiplus; Image* brand; + Bitmap* buffer; + HDC bufferDc; + HBITMAP bufferBitmap; + HGDIOBJ previousBitmap; + UINT pressedCommand; + UINT hoveredCommand; WCHAR captions[5][128]; + + ~ProgressPage() { + delete buffer; + delete brand; + if (previousBitmap) SelectObject(bufferDc, previousBitmap); + if (bufferBitmap) DeleteObject(bufferBitmap); + if (bufferDc) DeleteDC(bufferDc); + if (gdiplus) GdiplusShutdown(gdiplus); + } }; // NSIS shows its page after MUI's SHOW callback; keep its controls off screen. @@ -139,6 +154,47 @@ static void FillProgress(Graphics& graphics, Brush& brush, REAL width) { graphics.FillPath(&brush, &path); } +static UINT CaptionCommand(const ProgressPage& page, LPARAM position) { + const int x = static_cast(LOWORD(position)); + const int y = static_cast(HIWORD(position)); + if (y < 0 || y >= MulDiv(48, page.dpi, 96) || x < MulDiv(504, page.dpi, 96) || x >= MulDiv(600, page.dpi, 96)) return 0; + return x >= MulDiv(548, page.dpi, 96) ? SC_CLOSE : SC_MINIMIZE; +} + +static void PaintCaptionButtons(Graphics& graphics, const ProgressPage& page) { + SolidBrush background(page.dark ? Color(255, 21, 21, 23) : Color(255, 255, 255, 255)); + graphics.FillRectangle(&background, 504, 0, 96, 48); + SolidBrush ink(page.dark ? Color(255, 255, 255, 255) : Color(255, 15, 17, 21)); + FontFamily family(L"Microsoft YaHei UI"); + Font font(&family, 16, FontStyleRegular, UnitPixel); + StringFormat centered; + centered.SetAlignment(StringAlignmentCenter); + centered.SetLineAlignment(StringAlignmentCenter); + for (UINT command : {SC_MINIMIZE, SC_CLOSE}) { + const int x = command == SC_MINIMIZE ? 504 : 548; + if (page.hoveredCommand == command && (!page.pressedCommand || page.pressedCommand == command)) { + // Match the secondary caption controls in theme.nsh and pages.nsh. + const bool pressed = page.pressedCommand == command; + SolidBrush fill(page.dark ? (pressed ? Color(255, 69, 69, 75) : Color(255, 48, 48, 52)) + : (pressed ? Color(255, 212, 219, 233) : Color(255, 238, 240, 242))); + GraphicsPath path; + path.AddArc(x, 8, 20, 20, 180, 90); + path.AddArc(x + 20, 8, 20, 20, 270, 90); + path.AddArc(x + 20, 20, 20, 20, 0, 90); + path.AddArc(x, 20, 20, 20, 90, 90); + path.CloseFigure(); + graphics.FillPath(&fill, &path); + } + graphics.DrawString(command == SC_MINIMIZE ? L"\x2212" : L"\x00d7", -1, + &font, RectF(static_cast(x), 8, 40, 32), ¢ered, &ink); + } +} + +static void InvalidateCaptionButtons(HWND window, const ProgressPage& page) { + RECT bounds{MulDiv(504, page.dpi, 96), 0, MulDiv(600, page.dpi, 96), MulDiv(48, page.dpi, 96)}; + InvalidateRect(window, &bounds, FALSE); +} + static LRESULT CALLBACK ProgressProc(HWND window, UINT message, WPARAM wparam, LPARAM lparam) { auto* page = reinterpret_cast(GetWindowLongPtrW(window, GWLP_USERDATA)); if (message == WM_CREATE) { @@ -148,29 +204,66 @@ static LRESULT CALLBACK ProgressProc(HWND window, UINT message, WPARAM wparam, L } if (message == WM_ERASEBKGND) return 1; if (message == WM_TIMER) { InvalidateRect(window, nullptr, FALSE); return 0; } - if (message == WM_LBUTTONDOWN && page) { - const int x = LOWORD(lparam) * 96 / page->dpi; - const int y = HIWORD(lparam) * 96 / page->dpi; - if (y < 48) { - HWND parent = GetParent(window); - if (x >= 548) PostMessageW(parent, WM_CLOSE, 0, 0); - else if (x >= 504) ShowWindow(parent, SW_MINIMIZE); - else { ReleaseCapture(); SendMessageW(parent, WM_NCLBUTTONDOWN, HTCAPTION, 0); } + if (message == WM_MOUSEMOVE && page) { + TRACKMOUSEEVENT tracking{sizeof(tracking), TME_LEAVE, window, 0}; + TrackMouseEvent(&tracking); + const UINT hovered = CaptionCommand(*page, lparam); + if (page->hoveredCommand != hovered) { + page->hoveredCommand = hovered; + InvalidateCaptionButtons(window, *page); } return 0; } + if (message == WM_MOUSELEAVE && page) { + page->hoveredCommand = 0; + InvalidateCaptionButtons(window, *page); + return 0; + } + if (message == WM_LBUTTONDOWN && page) { + const int x = static_cast(LOWORD(lparam)); + const int y = static_cast(HIWORD(lparam)); + page->pressedCommand = CaptionCommand(*page, lparam); + if (page->pressedCommand) { + page->hoveredCommand = page->pressedCommand; + SetCapture(window); + InvalidateCaptionButtons(window, *page); + } + else if (x >= 0 && x < MulDiv(504, page->dpi, 96) && y >= 0 && y < MulDiv(48, page->dpi, 96)) { + HWND parent = GetParent(window); + ReleaseCapture(); SendMessageW(parent, WM_NCLBUTTONDOWN, HTCAPTION, 0); + } + return 0; + } + if (message == WM_LBUTTONUP && page) { + const UINT command = page->pressedCommand; + page->pressedCommand = 0; + page->hoveredCommand = CaptionCommand(*page, lparam); + InvalidateCaptionButtons(window, *page); + const bool activate = command && GetCapture() == window && CaptionCommand(*page, lparam) == command; + if (GetCapture() == window) ReleaseCapture(); + if (activate) { + if (command == SC_CLOSE) PostMessageW(GetParent(window), WM_CLOSE, 0, 0); + else ShowWindow(GetParent(window), SW_MINIMIZE); + } + return 0; + } + if ((message == WM_CAPTURECHANGED || message == WM_CANCELMODE) && page) { + page->pressedCommand = 0; + InvalidateCaptionButtons(window, *page); + if (message == WM_CANCELMODE && GetCapture() == window) ReleaseCapture(); + return 0; + } if (message == WM_PAINT && page) { PAINTSTRUCT paint; HDC dc = BeginPaint(window, &paint); { - Bitmap buffer(MulDiv(600, page->dpi, 96), MulDiv(600, page->dpi, 96), PixelFormat32bppPARGB); - Graphics graphics(&buffer); + Graphics graphics(page->buffer); graphics.ScaleTransform(page->dpi / 96.0f, page->dpi / 96.0f); - graphics.Clear(page->dark ? Color(255, 21, 21, 23) : Color(255, 255, 255, 255)); graphics.SetSmoothingMode(SmoothingModeAntiAlias); - graphics.SetInterpolationMode(InterpolationModeHighQualityBicubic); graphics.SetPixelOffsetMode(PixelOffsetModeHalf); - graphics.DrawImage(page->brand, Rect(0, 174, 600, 196)); + PaintCaptionButtons(graphics, *page); + SolidBrush background(page->dark ? Color(255, 21, 21, 23) : Color(255, 255, 255, 255)); + graphics.FillRectangle(&background, 48, 480, 504, 56); const int stage = static_cast(reinterpret_cast(GetPropW(GetParent(window), L"HarnessInstaller.Stage"))); const double fraction = reinterpret_cast(GetPropW(GetParent(window), L"HarnessInstaller.ExtractProgress")) / 100.0; page->progress.Advance(stage, fraction, GetTickCount64()); @@ -188,20 +281,16 @@ static LRESULT CALLBACK ProgressProc(HWND window, UINT message, WPARAM wparam, L wsprintfW(caption, page->captions[page->progress.CaptionStage()], percent); SetWindowTextW(window, caption); graphics.DrawString(caption, -1, &font, RectF(48, 512, 504, 22), ¢ered, &ink); - Font controls(&family, 16, FontStyleRegular, UnitPixel); - graphics.DrawString(L"\x2212", -1, &controls, RectF(504, 8, 40, 32), ¢ered, &ink); - graphics.DrawString(L"\x00d7", -1, &controls, RectF(548, 8, 40, 32), ¢ered, &ink); - Graphics screen(dc); - screen.DrawImage(&buffer, 0, 0); } + BitBlt(dc, paint.rcPaint.left, paint.rcPaint.top, + paint.rcPaint.right - paint.rcPaint.left, paint.rcPaint.bottom - paint.rcPaint.top, + page->bufferDc, paint.rcPaint.left, paint.rcPaint.top, SRCCOPY); EndPaint(window, &paint); if (page->progress.value == 100) SetPropW(GetParent(window), L"HarnessInstaller.CompletedPercent", reinterpret_cast(100)); return 0; } if (message == WM_NCDESTROY && page) { KillTimer(window, 1); - delete page->brand; - GdiplusShutdown(page->gdiplus); delete page; } return DefWindowProcW(window, message, wparam, lparam); @@ -224,7 +313,7 @@ extern "C" __declspec(dllexport) HWND __cdecl InstallerShowProgress(HWND parent, if (GdiplusStartup(&page->gdiplus, &startup, nullptr) != Ok) { delete page; return nullptr; } HWND stockPage = GetParent(source); if (!SetWindowSubclass(stockPage, HiddenPageProc, 1, 0)) { - GdiplusShutdown(page->gdiplus); delete page; return nullptr; + delete page; return nullptr; } ShowWindow(stockPage, SW_HIDE); page->dark = dark != FALSE; @@ -233,11 +322,41 @@ extern "C" __declspec(dllexport) HWND __cdecl InstallerShowProgress(HWND parent, for (int i = 0; i < 5; ++i) lstrcpynW(page->captions[i], captions[i], 128); page->brand = new Image(brand); if (page->brand->GetLastStatus() != Ok) { - delete page->brand; GdiplusShutdown(page->gdiplus); delete page; return nullptr; + delete page; return nullptr; } + const int size = MulDiv(600, dpi, 96); + BITMAPINFO info = {}; + info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); + info.bmiHeader.biWidth = size; + info.bmiHeader.biHeight = -size; + info.bmiHeader.biPlanes = 1; + info.bmiHeader.biBitCount = 32; + info.bmiHeader.biCompression = BI_RGB; + void* pixels = nullptr; + page->bufferDc = CreateCompatibleDC(nullptr); + page->bufferBitmap = CreateDIBSection(nullptr, &info, DIB_RGB_COLORS, &pixels, nullptr, 0); + if (!page->bufferDc || !page->bufferBitmap) { delete page; return nullptr; } + page->previousBitmap = SelectObject(page->bufferDc, page->bufferBitmap); + if (!page->previousBitmap) { delete page; return nullptr; } + // Opaque pixels are shared by GDI+ drawing and the window's GDI blit. + page->buffer = new Bitmap(size, size, size * 4, PixelFormat32bppPARGB, static_cast(pixels)); + if (page->buffer->GetLastStatus() != Ok) { delete page; return nullptr; } + { + // Static artwork stays in the buffer for this page's lifetime. + Graphics graphics(page->buffer); + graphics.ScaleTransform(dpi / 96.0f, dpi / 96.0f); + graphics.Clear(page->dark ? Color(255, 21, 21, 23) : Color(255, 255, 255, 255)); + graphics.SetSmoothingMode(SmoothingModeAntiAlias); + graphics.SetInterpolationMode(InterpolationModeHighQualityBicubic); + graphics.SetPixelOffsetMode(PixelOffsetModeHalf); + graphics.DrawImage(page->brand, Rect(0, 174, 600, 196)); + PaintCaptionButtons(graphics, *page); + } + delete page->brand; + page->brand = nullptr; HWND window = CreateWindowExW(0, type.lpszClassName, L"", WS_CHILD | WS_VISIBLE, 0, 0, MulDiv(600, dpi, 96), MulDiv(600, dpi, 96), parent, nullptr, module, page); - if (!window) { delete page->brand; GdiplusShutdown(page->gdiplus); delete page; } + if (!window) delete page; return window; } @@ -268,6 +387,8 @@ extern "C" __declspec(dllexport) BOOL __cdecl InstallerFinishProgress(HWND windo static LRESULT CALLBACK FrameProc(HWND window, UINT message, WPARAM wparam, LPARAM lparam, UINT_PTR id, DWORD_PTR) { if (message == WM_NCCALCSIZE && wparam) return 0; + // Activation must not repaint the standard frame over the custom client area. + if (message == WM_NCACTIVATE) return DefSubclassProc(window, message, wparam, -1); if (message == WM_NCHITTEST) { const LRESULT hit = DefSubclassProc(window, message, wparam, lparam); // The installer has a fixed size; its page provides the drag area. @@ -291,7 +412,8 @@ extern "C" __declspec(dllexport) HRESULT __cdecl InstallerApplyFrame(HWND window ShowWindow(child, SW_HIDE); } } - SetWindowLongW(window, GWL_STYLE, GetWindowLongW(window, GWL_STYLE) | WS_THICKFRAME); + // DWM retains native transitions; WM_NCCALCSIZE removes the visible caption. + SetWindowLongW(window, GWL_STYLE, GetWindowLongW(window, GWL_STYLE) | WS_THICKFRAME | WS_CAPTION); const DWMNCRENDERINGPOLICY policy = DWMNCRP_ENABLED; HRESULT result = DwmSetWindowAttribute(window, DWMWA_NCRENDERING_POLICY, &policy, sizeof(policy)); if (FAILED(result)) return result; diff --git a/apps/desktop/scripts/prepare-windows-installer.ps1 b/apps/desktop/scripts/prepare-windows-installer.ps1 index 98e02cf9a6..b848f9bef4 100644 --- a/apps/desktop/scripts/prepare-windows-installer.ps1 +++ b/apps/desktop/scripts/prepare-windows-installer.ps1 @@ -14,7 +14,7 @@ $visualStudio = & $vswhere -latest -products '*' -requires Microsoft.VisualStudi if (-not $visualStudio) { throw 'Visual Studio C++ Build Tools are missing.' } $vcvars = Join-Path $visualStudio 'VC/Auxiliary/Build/vcvars32.bat' $compileScript = Join-Path $output 'compile-frame.cmd' -$compileLines = @('@echo off', ('call "{0}" >nul' -f $vcvars), 'if errorlevel 1 exit /b %errorlevel%', ('cl /nologo /LD /MT /O1 /W4 /WX /EHsc "{0}" /Fo"{1}" /link /OUT:"{2}" /IMPLIB:"{3}" user32.lib comctl32.lib dwmapi.lib gdiplus.lib ole32.lib shell32.lib uuid.lib advapi32.lib' -f $source, (Join-Path $output 'window-frame.obj'), $library, (Join-Path $output 'window-frame.lib'))) +$compileLines = @('@echo off', ('call "{0}" >nul' -f $vcvars), 'if errorlevel 1 exit /b %errorlevel%', ('cl /nologo /LD /MT /O1 /W4 /WX /EHsc "{0}" /Fo"{1}" /link /OUT:"{2}" /IMPLIB:"{3}" user32.lib gdi32.lib comctl32.lib dwmapi.lib gdiplus.lib ole32.lib shell32.lib uuid.lib advapi32.lib' -f $source, (Join-Path $output 'window-frame.obj'), $library, (Join-Path $output 'window-frame.lib'))) [IO.File]::WriteAllLines($compileScript, $compileLines, [Text.Encoding]::Default) & $env:ComSpec /d /c $compileScript if ($LASTEXITCODE -ne 0) { throw 'Native installer helper compilation failed.' } @@ -50,6 +50,12 @@ if ($TestProgress -or $CompileProgressOnly) { & $presentationExecutable if ($LASTEXITCODE -ne 0) { throw 'Installer presentation regression failed.' } } + $renderSource = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../tests/windows-installer-render.cpp')) + $renderExecutable = Join-Path $output 'render-test.exe' + $renderScript = Join-Path $output 'compile-render-test.cmd' + [IO.File]::WriteAllLines($renderScript, @('@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 gdi32.lib' -f $renderSource, (Join-Path $output 'render-test.obj'), $renderExecutable, (Join-Path $output 'window-frame.lib'))), [Text.Encoding]::Default) + & $env:ComSpec /d /c $renderScript + if ($LASTEXITCODE -ne 0) { throw 'Installer render test compilation 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' @@ -77,4 +83,8 @@ foreach ($asset in @('brand', 'brand-2x', 'brand-dark', 'brand-dark-2x', 'uninst } finally { $bitmap.Dispose() } } finally { $image.Dispose() } } +if ($TestProgress) { + & $renderExecutable (Join-Path $output 'brand-2x.bmp') 192 + if ($LASTEXITCODE -ne 0) { throw 'Installer rendering regression failed.' } +} Write-Output "Prepared native installer resources: $output" diff --git a/apps/desktop/scripts/test-windows-installer.mjs b/apps/desktop/scripts/test-windows-installer.mjs index 03b601b187..c28a1688d9 100644 --- a/apps/desktop/scripts/test-windows-installer.mjs +++ b/apps/desktop/scripts/test-windows-installer.mjs @@ -58,10 +58,12 @@ try { join(appRoot, 'scripts', 'prepare-windows-installer.ps1'), '-OutputDirectory', join(output, 'ui'), '-CompileProgressOnly'], childOptions) const progressTest = join(output, 'ui', 'progress-test.exe') const presentationTest = join(output, 'ui', 'presentation-test.exe') + const renderTest = join(output, 'ui', 'render-test.exe') const cleanupTest = join(output, 'ui', 'data-cleanup-test.exe') if (sign) { await sign({ path: progressTest, hash: 'sha256', isNest: false }) await sign({ path: presentationTest, hash: 'sha256', isNest: false }) + await sign({ path: renderTest, hash: 'sha256', isNest: false }) await sign({ path: cleanupTest, hash: 'sha256', isNest: false }) await sign({ path: join(output, 'ui', 'window-frame.dll'), hash: 'sha256', isNest: false }) installWindowsNsisBootstrapSigner({ sign }) @@ -69,6 +71,15 @@ try { if (!uninstallOnly) { await execute(progressTest, [], childOptions) await execute(presentationTest, [], childOptions) + const renderResults = [] + for (const dpi of [96, 192]) { + for (const theme of ['light', 'dark']) { + const brand = theme === 'dark' ? 'brand-dark-2x.bmp' : 'brand-2x.bmp' + const result = await execute(renderTest, [join(output, 'ui', brand), String(dpi), theme], childOptions) + renderResults.push(result.stdout) + } + } + await writeFile(join(output, 'render-results.log'), renderResults.join('')) } await execute(cleanupTest, [join(output, 'ui', `cleanup-${id}`)], childOptions) const payloadSource = join(output, 'payload.nsi') diff --git a/apps/desktop/tests/windows-installer-render.cpp b/apps/desktop/tests/windows-installer-render.cpp new file mode 100644 index 0000000000..19c8a1662c --- /dev/null +++ b/apps/desktop/tests/windows-installer-render.cpp @@ -0,0 +1,160 @@ +// Exercises the shipped progress window without extracting or installing a payload. +#define WIN32_LEAN_AND_MEAN +#define UNICODE +#define NOMINMAX +#include +#include +#include +#include + +extern "C" __declspec(dllimport) HRESULT __cdecl InstallerApplyFrame(HWND window); +extern "C" __declspec(dllimport) HWND __cdecl InstallerShowProgress(HWND parent, HWND source, + BOOL dark, UINT dpi, const WCHAR* brand, const WCHAR* preparing, const WCHAR* extracting, + const WCHAR* copying, const WCHAR* registering, const WCHAR* cleaning); +extern "C" __declspec(dllimport) BOOL __cdecl InstallerFinishProgress(HWND window); + +static LRESULT CALLBACK WindowProc(HWND window, UINT message, WPARAM wparam, LPARAM lparam) { + if (message == WM_DESTROY) { PostQuitMessage(0); return 0; } + return DefWindowProcW(window, message, wparam, lparam); +} + +int wmain(int argc, wchar_t** argv) { + if (argc < 3) { + std::fprintf(stderr, "Usage: render-test.exe [light|dark] [interactive-title]\n"); + return 1; + } + const int dpi = _wtoi(argv[2]); + if (dpi < 96 || dpi > 384) return 2; + const bool dark = argc > 3 && wcscmp(argv[3], L"dark") == 0; + const bool interactive = argc > 4; + SetProcessDPIAware(); + WNDCLASSW type = {}; + type.lpfnWndProc = WindowProc; + type.hInstance = GetModuleHandleW(nullptr); + type.lpszClassName = L"HarnessInstallerRenderTest"; + type.hCursor = LoadCursorW(nullptr, IDC_ARROW); + if (!RegisterClassW(&type)) return 3; + HWND window = CreateWindowW(type.lpszClassName, interactive ? argv[4] : L"Installer rendering test", + WS_POPUP | WS_SYSMENU | WS_MINIMIZEBOX, 100, 100, MulDiv(600, dpi, 96), MulDiv(600, dpi, 96), + nullptr, nullptr, type.hInstance, nullptr); + if (!window || FAILED(InstallerApplyFrame(window))) return 4; + HWND stock = CreateWindowW(L"STATIC", L"", WS_CHILD, 0, 0, 1, 1, window, nullptr, type.hInstance, nullptr); + HWND source = CreateWindowW(L"STATIC", L"", WS_CHILD, 0, 0, 1, 1, stock, nullptr, type.hInstance, nullptr); + SetPropW(window, L"HarnessInstaller.Stage", reinterpret_cast(1)); + SetPropW(window, L"HarnessInstaller.ExtractProgress", reinterpret_cast(50)); + const auto createProgress = [&]() { + return InstallerShowProgress(window, source, dark, static_cast(dpi), argv[1], + L"Preparing %d%%", L"Extracting %d%%", L"Copying %d%%", L"Registering %d%%", L"Cleaning %d%%"); + }; + ShowWindow(window, SW_SHOWNOACTIVATE); + // Warm retained font and window-animation resources before checking page-owned GDI objects. + HWND warmup = createProgress(); + if (!warmup) { DestroyWindow(window); return 5; } + UpdateWindow(warmup); + ShowWindow(window, SW_MINIMIZE); + ShowWindow(window, SW_RESTORE); + UpdateWindow(warmup); + DestroyWindow(warmup); + const DWORD initialGdi = GetGuiResources(GetCurrentProcess(), GR_GDIOBJECTS); + HWND progress = createProgress(); + if (!progress) { DestroyWindow(window); return 5; } + UpdateWindow(progress); + if (interactive) { + MSG message; + BOOL result; + while ((result = GetMessageW(&message, nullptr, 0, 0)) > 0) { + TranslateMessage(&message); + DispatchMessageW(&message); + } + return result == 0 ? 0 : 7; + } + RECT originalBounds, restoredBounds; + GetWindowRect(window, &originalBounds); + const auto captionPixel = [&](int x) { + UpdateWindow(progress); + HDC dc = GetDC(progress); + const COLORREF pixel = GetPixel(dc, MulDiv(x + 8, dpi, 96), MulDiv(24, dpi, 96)); + ReleaseDC(progress, dc); + return pixel; + }; + const COLORREF normalColor = dark ? RGB(21, 21, 23) : RGB(255, 255, 255); + const COLORREF hoverColor = dark ? RGB(48, 48, 52) : RGB(238, 240, 242); + const COLORREF pressedColor = dark ? RGB(69, 69, 75) : RGB(212, 219, 233); + bool captionStates = true; + for (int x : {504, 548}) { + const LPARAM point = MAKELPARAM(MulDiv(x + 20, dpi, 96), MulDiv(24, dpi, 96)); + captionStates = captionStates && captionPixel(x) == normalColor; + SendMessageW(progress, WM_MOUSEMOVE, 0, point); + captionStates = captionStates && captionPixel(x) == hoverColor; + SendMessageW(progress, WM_LBUTTONDOWN, MK_LBUTTON, point); + captionStates = captionStates && captionPixel(x) == pressedColor; + SendMessageW(progress, WM_MOUSEMOVE, MK_LBUTTON, MAKELPARAM(-1, -1)); + captionStates = captionStates && captionPixel(x) == normalColor; + SendMessageW(progress, WM_MOUSEMOVE, MK_LBUTTON, point); + captionStates = captionStates && captionPixel(x) == pressedColor; + SendMessageW(progress, WM_CANCELMODE, 0, 0); + captionStates = captionStates && captionPixel(x) == hoverColor; + SendMessageW(progress, WM_MOUSELEAVE, 0, 0); + captionStates = captionStates && captionPixel(x) == normalColor; + } + const LPARAM minimizePoint = MAKELPARAM(MulDiv(524, dpi, 96), MulDiv(24, dpi, 96)); + SendMessageW(progress, WM_LBUTTONDOWN, MK_LBUTTON, minimizePoint); + bool buttonRelease = !IsIconic(window) && GetCapture() == progress; + SendMessageW(progress, WM_LBUTTONUP, 0, MAKELPARAM(-1, -1)); + buttonRelease = buttonRelease && !IsIconic(window) && GetCapture() != progress; + SendMessageW(progress, WM_LBUTTONDOWN, MK_LBUTTON, minimizePoint); + ReleaseCapture(); + SendMessageW(progress, WM_LBUTTONUP, 0, minimizePoint); + buttonRelease = buttonRelease && !IsIconic(window); + SendMessageW(progress, WM_LBUTTONDOWN, MK_LBUTTON, minimizePoint); + SendMessageW(progress, WM_CANCELMODE, 0, 0); + SendMessageW(progress, WM_LBUTTONUP, 0, minimizePoint); + buttonRelease = buttonRelease && !IsIconic(window) && GetCapture() != progress; + SendMessageW(progress, WM_LBUTTONDOWN, MK_LBUTTON, minimizePoint); + SendMessageW(progress, WM_LBUTTONUP, 0, minimizePoint); + const bool minimized = IsIconic(window) != FALSE; + ShowWindow(window, SW_RESTORE); + GetWindowRect(window, &restoredBounds); + const bool restored = !IsIconic(window) && EqualRect(&originalBounds, &restoredBounds); + const LPARAM closePoint = MAKELPARAM(MulDiv(568, dpi, 96), MulDiv(24, dpi, 96)); + MSG closeMessage; + SendMessageW(progress, WM_LBUTTONDOWN, MK_LBUTTON, closePoint); + bool closeRelease = !PeekMessageW(&closeMessage, window, WM_CLOSE, WM_CLOSE, PM_REMOVE); + SendMessageW(progress, WM_LBUTTONUP, 0, minimizePoint); + closeRelease = closeRelease && !PeekMessageW(&closeMessage, window, WM_CLOSE, WM_CLOSE, PM_REMOVE); + SendMessageW(progress, WM_LBUTTONDOWN, MK_LBUTTON, closePoint); + SendMessageW(progress, WM_LBUTTONUP, 0, closePoint); + closeRelease = closeRelease && PeekMessageW(&closeMessage, window, WM_CLOSE, WM_CLOSE, PM_REMOVE); + LARGE_INTEGER frequency, started, finished; + QueryPerformanceFrequency(&frequency); + std::vector samples; + // Full repaint is the exposure/restore path; fixture startup is excluded. + for (int frame = 0; frame < 300; ++frame) { + InvalidateRect(progress, nullptr, FALSE); + QueryPerformanceCounter(&started); + UpdateWindow(progress); + QueryPerformanceCounter(&finished); + samples.push_back(1000.0 * (finished.QuadPart - started.QuadPart) / frequency.QuadPart); + } + double total = 0; + for (double sample : samples) total += sample; + std::printf("samples_ms=["); + for (size_t index = 0; index < samples.size(); ++index) { + std::printf("%s%.3f", index ? "," : "", samples[index]); + } + std::printf("]\n"); + std::sort(samples.begin(), samples.end()); + std::printf("dpi=%d dark=%d frames=300 mean_ms=%.3f p50_ms=%.3f p95_ms=%.3f max_ms=%.3f\n", + dpi, dark, total / samples.size(), samples[150], samples[285], samples.back()); + const bool completed = InstallerFinishProgress(progress) && + GetPropW(window, L"HarnessInstaller.CompletedPercent") == reinterpret_cast(100); + WCHAR caption[160] = {}; + GetWindowTextW(progress, caption, ARRAYSIZE(caption)); + const bool finalCaption = wcscmp(caption, L"Extracting 100%") == 0; + DestroyWindow(progress); + const DWORD finalGdi = GetGuiResources(GetCurrentProcess(), GR_GDIOBJECTS); + DestroyWindow(window); + std::printf("completed=%d final_caption=%d caption_states=%d button_release=%d close_release=%d minimized=%d restored=%d gdi_before=%lu gdi_after=%lu\n", + completed, finalCaption, captionStates, buttonRelease, closeRelease, minimized, restored, initialGdi, finalGdi); + return completed && finalCaption && captionStates && buttonRelease && closeRelease && minimized && restored && finalGdi <= initialGdi ? 0 : 6; +} From 1c6194983849fd42f102c4f97d63e7535ef37527 Mon Sep 17 00:00:00 2001 From: winewill Date: Mon, 19 Oct 2026 16:36:03 +0800 Subject: [PATCH 03/12] fix(desktop): bound fullscreen preparation and cover mandatory updates --- apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 2 +- apps/desktop/README.zh.md | 2 +- apps/desktop/src/update-window.ts | 29 +++++++---- apps/desktop/tests/main-startup.spec.ts | 31 +++++++++--- apps/desktop/tests/update-window.spec.ts | 64 ++++++++++++++++++++++++ 6 files changed, 113 insertions(+), 19 deletions(-) create mode 100644 apps/desktop/tests/update-window.spec.ts diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 6f87e7955a..ba22430ddc 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -57,8 +57,8 @@ en: 8cb4b7babbd8714d zh: b4aebc1d2d152e01 /deepseek-harness-desktop/updates: - en: 26bb297c9409ba6a - zh: 70c192cb146e586f + en: e62149bde76b6fc0 + zh: 23661c539ad3f513 /deepseek-harness-desktop/updates/local-desktop-settings: en: 2e57457c44c85b5f zh: 5b8510a3dc4c6c9d diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 77f8cd2811..af37b26bd2 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -267,7 +267,7 @@ Use `node apps/desktop/scripts/test-windows-installer.mjs --uninstall-only --com On Windows, the downloaded-update confirmation explains that the application closes during installation, reopens automatically, and should not be launched again while updating. An installer restart carrying `--updated` raises and focuses the main window once when startup opens the workspace directly, without enabling always-on-top. Startup into the welcome page discards that request so a later login keeps its normal activation behavior. Ordinary launches and other platforms do not use this foregrounding step. -On macOS, installation preparation waits for the confirmation owner to leave fullscreen before displaying the restart confirmation. Cancelling leaves the window in normal mode; destroying it during the native transition cancels installation before task teardown. This keeps installation failure recovery in a usable normal window. +On macOS, installation preparation waits for the product window to leave fullscreen before displaying the restart confirmation. Cancelling leaves the window in normal mode; destroying it during the native transition cancels installation before task teardown. If macOS does not report completion within five seconds, preparation fails without stopping the Host; exit fullscreen and retry. Installation failure recovery uses the normal window. Native update overlays wait for a ready document and a visible parent, and reappear when that parent is shown again. Closing an overlay releases its input interception and parent listeners. The [local window qualification](tests/README.md#verification-overlay) exercises these transitions without starting a workspace. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 10cc8646fa..13a8565a86 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -267,7 +267,7 @@ Windows 卸载程序会随应用一起删除 Electron 用户数据目录(`%APP Windows 下载完成后的更新确认说明应用会在安装期间关闭、完成后自动打开,并提示期间不要重复启动。安装器携带 `--updated` 重启应用并直接打开工作区时,壳会将主窗口前置并聚焦一次,不启用永久置顶。启动进入欢迎页时会清除此请求,使后续登录保留正常的激活行为。普通启动和其他平台不执行此前置步骤。 -macOS 在安装准备阶段等待确认窗口的所属主窗口退出全屏,再显示重启确认。取消确认后窗口保持普通模式;原生切换期间销毁窗口会在任务停止前取消安装。安装失败后的恢复因此在可用的普通窗口中进行。 +macOS 在安装准备阶段等待产品主窗口退出全屏,再显示重启确认。取消确认后窗口保持普通模式;原生切换期间销毁窗口会在任务停止前取消安装。如果 macOS 未在五秒内报告切换完成,安装准备会报错且不停止 Host;退出全屏后可重试。安装失败后的恢复使用普通窗口。 原生更新浮层在文档就绪且父窗口可见时显示,并在父窗口再次显示时恢复。关闭浮层会释放输入拦截和父窗口监听。[本地窗口验证](tests/README.zh.md#verification-overlay)无需启动工作区即可检查这些切换。 diff --git a/apps/desktop/src/update-window.ts b/apps/desktop/src/update-window.ts index 1c8fb3aae7..ace30ee3b3 100644 --- a/apps/desktop/src/update-window.ts +++ b/apps/desktop/src/update-window.ts @@ -1,24 +1,35 @@ /** Native window preparation before an update confirmation can stop the Host. */ import type { BrowserWindow } from 'electron' +type UpdateWindow = Pick & Pick + +/** A stalled native transition refuses installation while the current Host remains running. */ +export class DesktopUpdateFullscreenTimeoutError extends Error { + constructor() { super('macOS did not finish leaving fullscreen. Exit fullscreen and retry the update.') } +} + /** - * Leave macOS fullscreen before creating or dismissing an installation overlay. + * Leave macOS fullscreen before displaying installation confirmation. * @param window - Product window that owns the update confirmation. - * @returns Whether the window survived the native transition; destruction cancels installation. + * @returns Whether the window survived the completed native transition; destruction cancels installation. + * @throws {DesktopUpdateFullscreenTimeoutError} If macOS does not report completion within five seconds. */ -export async function prepareDesktopUpdateWindow(window: BrowserWindow): Promise { +export async function prepareDesktopUpdateWindow(window: UpdateWindow): Promise { if (window.isDestroyed()) return false if (process.platform !== 'darwin' || !window.isFullScreen()) return true - return new Promise((resolve) => { - const finish = (available: boolean): void => { + return new Promise((resolve, reject) => { + const cleanup = (): void => { + clearTimeout(deadline) window.off('leave-full-screen', left) window.off('closed', closed) - resolve(available) } - const left = (): void => { finish(!window.isDestroyed()) } - const closed = (): void => { finish(false) } + const left = (): void => { cleanup(); resolve(true) } + const closed = (): void => { cleanup(); resolve(false) } + // Bound a missing native completion event; elapsed time never authorizes installation. + const deadline = setTimeout(() => { cleanup(); reject(new DesktopUpdateFullscreenTimeoutError()) }, 5000) window.once('leave-full-screen', left) window.once('closed', closed) - window.setFullScreen(false) + try { window.setFullScreen(false) } + catch (error) { cleanup(); reject(error) } }) } diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index e0f627193c..b49711f5ed 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -1786,30 +1786,50 @@ describe('desktop main startup', () => { expect(host.stop).not.toHaveBeenCalled() }) - it('waits for macOS to leave fullscreen before showing installation confirmation or stopping the Host', async () => { + it.each([false, true])('waits for macOS to leave fullscreen before installation confirmation, mandatory: %s', async (mandatory) => { vi.stubGlobal('process', { ...process, platform: 'darwin' }) + if (mandatory) { + harness.embeddedPolicy = { origin: 'https://policy.example.com', allowedPageOrigins: ['https://downloads.example.com'] } + vi.stubGlobal('fetch', vi.fn(async () => Response.json({ code: 40005, data: { + show_content: { title: 'Update required', detail: 'Please update' }, desktop_app_link: 'https://downloads.example.com/', + } }))) + } const host = await readyForUpdate() + if (mandatory) await harness.policyBlocked.promise const window = harness.windows[0]! + const dialogOwner = mandatory ? harness.windows.find(item => item.urls.includes('dsh-app://shell/mandatory-update.html'))! : window + const baseline = { leave: window.listenerCount('leave-full-screen'), closed: window.listenerCount('closed') } window.fullscreen = true window.setFullScreen.mockImplementation(() => {}) harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 0 }) const preparing = harness.prepareUpdate() await vi.waitFor(() => { expect(window.setFullScreen).toHaveBeenCalledWith(false) }) expect(harness.dialog.showMessageBox).not.toHaveBeenCalled() + if (mandatory) expect(harness.handlers.get(MANDATORY_IPC.status)!({ sender: dialogOwner.webContents, senderFrame: dialogOwner.webContents.mainFrame })).not.toHaveProperty('confirmation') expect(host.stop).not.toHaveBeenCalled() expect(host.updateTasks.mock.calls).toEqual([['inspect']]) window.fullscreen = false window.emit('leave-full-screen') + if (mandatory) await answerMandatory('install', dialogOwner) await host.stopping.promise host.exited.resolve() await expect(preparing).resolves.toBe(true) - expect(window.listenerCount('leave-full-screen')).toBe(1) + expect(window.listenerCount('leave-full-screen')).toBe(baseline.leave) + expect(window.listenerCount('closed')).toBe(baseline.closed) }) - it('cancels installation if the fullscreen owner closes during the native transition', async () => { + it.each([false, true])('cancels installation when the fullscreen product window closes, mandatory: %s', async (mandatory) => { vi.stubGlobal('process', { ...process, platform: 'darwin' }) + if (mandatory) { + harness.embeddedPolicy = { origin: 'https://policy.example.com', allowedPageOrigins: ['https://downloads.example.com'] } + vi.stubGlobal('fetch', vi.fn(async () => Response.json({ code: 40005, data: { + show_content: { title: 'Update required', detail: 'Please update' }, desktop_app_link: 'https://downloads.example.com/', + } }))) + } const host = await readyForUpdate() + if (mandatory) await harness.policyBlocked.promise const window = harness.windows[0]! + const baseline = { leave: window.listenerCount('leave-full-screen'), closed: window.listenerCount('closed') } window.fullscreen = true window.setFullScreen.mockImplementation(() => {}) const preparing = harness.prepareUpdate() @@ -1818,7 +1838,7 @@ describe('desktop main startup', () => { await expect(preparing).resolves.toBe(false) expect(harness.dialog.showMessageBox).not.toHaveBeenCalled() expect(host.stop).not.toHaveBeenCalled() - expect(window.listenerCount('leave-full-screen')).toBe(1) + expect(window.listenerCount('leave-full-screen')).toBe(baseline.leave) }) it('waits for Platform storage cleanup before the installer takes over', async () => { @@ -1865,8 +1885,7 @@ describe('desktop main startup', () => { expect(host.stop).not.toHaveBeenCalled() }) - async function answerMandatory(action: 'install' | 'later') { - const modal = harness.windows[0]! + async function answerMandatory(action: 'install' | 'later', modal = harness.windows[0]!) { const event = { sender: modal.webContents, senderFrame: modal.webContents.mainFrame } await vi.waitFor(() => { expect(harness.handlers.get(MANDATORY_IPC.status)!(event)).toHaveProperty('confirmation') diff --git a/apps/desktop/tests/update-window.spec.ts b/apps/desktop/tests/update-window.spec.ts new file mode 100644 index 0000000000..dc2c618fe0 --- /dev/null +++ b/apps/desktop/tests/update-window.spec.ts @@ -0,0 +1,64 @@ +import { EventEmitter } from 'node:events' +import { afterEach, expect, it, vi } from 'vitest' +import { DesktopUpdateFullscreenTimeoutError, prepareDesktopUpdateWindow } from '../src/update-window.ts' + +afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals() }) + +function setup(platform = 'darwin', fullscreen = true, destroyed = false) { + vi.stubGlobal('process', { ...process, platform }) + const window = Object.assign(new EventEmitter(), { + isDestroyed: () => destroyed, + isFullScreen: () => fullscreen, + setFullScreen: vi.fn(), + }) + return { window, prepare: () => prepareDesktopUpdateWindow(window) } +} + +it.each(['darwin', 'win32', 'linux'])('does not transition an ordinary %s window', async (platform) => { + const f = setup(platform, false) + await expect(f.prepare()).resolves.toBe(true) + expect(f.window.setFullScreen).not.toHaveBeenCalled() +}) + +it('preserves Windows fullscreen and refuses a destroyed product window', async () => { + const windows = setup('win32') + await expect(windows.prepare()).resolves.toBe(true) + expect(windows.window.setFullScreen).not.toHaveBeenCalled() + const destroyed = setup('darwin', true, true) + await expect(destroyed.prepare()).resolves.toBe(false) + expect(destroyed.window.setFullScreen).not.toHaveBeenCalled() +}) + +it.each(['leave-full-screen', 'closed'])('settles on %s and releases both listeners and the deadline', async (event) => { + vi.useFakeTimers() + const f = setup() + const preparing = f.prepare() + expect(f.window.setFullScreen).toHaveBeenCalledWith(false) + f.window.emit(event) + await expect(preparing).resolves.toBe(event === 'leave-full-screen') + expect(f.window.eventNames()).toEqual([]) + expect(vi.getTimerCount()).toBe(0) +}) + +it('rejects a stalled native transition and allows a later attempt to complete', async () => { + vi.useFakeTimers() + const f = setup() + const failure = expect(f.prepare()).rejects.toBeInstanceOf(DesktopUpdateFullscreenTimeoutError) + await vi.advanceTimersByTimeAsync(5000) + await failure + expect(f.window.eventNames()).toEqual([]) + expect(vi.getTimerCount()).toBe(0) + const retry = f.prepare() + f.window.emit('leave-full-screen') + await expect(retry).resolves.toBe(true) + expect(vi.getTimerCount()).toBe(0) +}) + +it('releases native listeners when requesting the transition throws', async () => { + vi.useFakeTimers() + const f = setup() + f.window.setFullScreen.mockImplementation(() => { throw new Error('native window unavailable') }) + await expect(f.prepare()).rejects.toThrow('native window unavailable') + expect(f.window.eventNames()).toEqual([]) + expect(vi.getTimerCount()).toBe(0) +}) From e10b502e42efb8cd6614e8e5fe0d5b873b8e4d07 Mon Sep 17 00:00:00 2001 From: winewill Date: Mon, 19 Oct 2026 16:45:49 +0800 Subject: [PATCH 04/12] fix(desktop): preserve native fullscreen exceptions through executor --- apps/desktop/src/update-window.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/update-window.ts b/apps/desktop/src/update-window.ts index ace30ee3b3..15f32630a5 100644 --- a/apps/desktop/src/update-window.ts +++ b/apps/desktop/src/update-window.ts @@ -30,6 +30,6 @@ export async function prepareDesktopUpdateWindow(window: UpdateWindow): Promise< window.once('leave-full-screen', left) window.once('closed', closed) try { window.setFullScreen(false) } - catch (error) { cleanup(); reject(error) } + catch (error) { cleanup(); throw error } }) } From 7f5007bca47ac237fb4f9c28ba4d682799767315 Mon Sep 17 00:00:00 2001 From: winewill Date: Mon, 19 Oct 2026 16:54:25 +0800 Subject: [PATCH 05/12] fix(desktop): localize fullscreen timeout recovery guidance --- apps/desktop/README.i18n.yaml | 4 ++-- apps/desktop/README.md | 2 +- apps/desktop/README.zh.md | 2 +- apps/desktop/renderer/mandatory-update.js | 1 + apps/desktop/src/ipc.ts | 3 ++- apps/desktop/src/locale.ts | 2 ++ apps/desktop/src/main.ts | 2 +- apps/desktop/src/update-presentation.ts | 1 + apps/desktop/src/update-window.ts | 13 +++++------ apps/desktop/tests/main-startup.spec.ts | 23 +++++++++++++++++++ apps/desktop/tests/update-coordinator.spec.ts | 6 ++--- .../tests/update-error-renderer.spec.ts | 16 +++++++++++++ .../desktop/tests/update-presentation.spec.ts | 1 + apps/desktop/tests/update-window.spec.ts | 12 ++++++---- .../ui-settings-general/README.i18n.yaml | 4 ++-- packages/client/ui-settings-general/README.md | 2 +- .../client/ui-settings-general/README.zh.md | 2 +- .../src/client/DesktopUpdateIndicator.tsx | 1 + .../ui-settings-general/src/client/locales.ts | 2 ++ .../client/ui-settings-general/src/types.ts | 1 + .../tests/desktop-update.client.spec.tsx | 21 +++++++++++++++++ 21 files changed, 97 insertions(+), 24 deletions(-) diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index ba22430ddc..b39958c1c1 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -57,8 +57,8 @@ en: 8cb4b7babbd8714d zh: b4aebc1d2d152e01 /deepseek-harness-desktop/updates: - en: e62149bde76b6fc0 - zh: 23661c539ad3f513 + en: 87db37529b51da62 + zh: 4fc0d752e437e7d6 /deepseek-harness-desktop/updates/local-desktop-settings: en: 2e57457c44c85b5f zh: 5b8510a3dc4c6c9d diff --git a/apps/desktop/README.md b/apps/desktop/README.md index af37b26bd2..35da9505b2 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -267,7 +267,7 @@ Use `node apps/desktop/scripts/test-windows-installer.mjs --uninstall-only --com On Windows, the downloaded-update confirmation explains that the application closes during installation, reopens automatically, and should not be launched again while updating. An installer restart carrying `--updated` raises and focuses the main window once when startup opens the workspace directly, without enabling always-on-top. Startup into the welcome page discards that request so a later login keeps its normal activation behavior. Ordinary launches and other platforms do not use this foregrounding step. -On macOS, installation preparation waits for the product window to leave fullscreen before displaying the restart confirmation. Cancelling leaves the window in normal mode; destroying it during the native transition cancels installation before task teardown. If macOS does not report completion within five seconds, preparation fails without stopping the Host; exit fullscreen and retry. Installation failure recovery uses the normal window. +On macOS, installation preparation waits for the product window to leave fullscreen before displaying the restart confirmation. Cancelling leaves the window in normal mode; destroying it during the native transition cancels installation before task teardown. If macOS does not report completion within five seconds, preparation fails without stopping the Host; the localized error asks the user to exit fullscreen and retry. Installation failure recovery uses the normal window. Native update overlays wait for a ready document and a visible parent, and reappear when that parent is shown again. Closing an overlay releases its input interception and parent listeners. The [local window qualification](tests/README.md#verification-overlay) exercises these transitions without starting a workspace. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 13a8565a86..faad986a4c 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -267,7 +267,7 @@ Windows 卸载程序会随应用一起删除 Electron 用户数据目录(`%APP Windows 下载完成后的更新确认说明应用会在安装期间关闭、完成后自动打开,并提示期间不要重复启动。安装器携带 `--updated` 重启应用并直接打开工作区时,壳会将主窗口前置并聚焦一次,不启用永久置顶。启动进入欢迎页时会清除此请求,使后续登录保留正常的激活行为。普通启动和其他平台不执行此前置步骤。 -macOS 在安装准备阶段等待产品主窗口退出全屏,再显示重启确认。取消确认后窗口保持普通模式;原生切换期间销毁窗口会在任务停止前取消安装。如果 macOS 未在五秒内报告切换完成,安装准备会报错且不停止 Host;退出全屏后可重试。安装失败后的恢复使用普通窗口。 +macOS 在安装准备阶段等待产品主窗口退出全屏,再显示重启确认。取消确认后窗口保持普通模式;原生切换期间销毁窗口会在任务停止前取消安装。如果 macOS 未在五秒内报告切换完成,安装准备会报错且不停止 Host;本地化错误提示要求退出全屏后重试。安装失败后的恢复使用普通窗口。 原生更新浮层在文档就绪且父窗口可见时显示,并在父窗口再次显示时恢复。关闭浮层会释放输入拦截和父窗口监听。[本地窗口验证](tests/README.zh.md#verification-overlay)无需启动工作区即可检查这些切换。 diff --git a/apps/desktop/renderer/mandatory-update.js b/apps/desktop/renderer/mandatory-update.js index 24e1f88041..eed28f7718 100644 --- a/apps/desktop/renderer/mandatory-update.js +++ b/apps/desktop/renderer/mandatory-update.js @@ -21,6 +21,7 @@ function render(view) { 'stop-failed': messages.updateStopFailed, 'tasks-changed': messages.updateTasksChanged, 'tasks-unavailable': messages.updateTasksUnavailable, + 'fullscreen-timeout': messages.updateFullscreenTimeout, } const fallback = failed || update.phase === 'idle' || view.error !== undefined let title = policy.title ?? messages.mandatoryTitle diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index fc19021fa6..ffa7884a98 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -34,7 +34,7 @@ export const DESKTOP_IPC = { } as const /** Desktop release update state rendered by desktop-owned UI. */ -export type DesktopUpdatePreparationFailureKind = 'stop-failed' | 'tasks-changed' | 'tasks-unavailable' +export type DesktopUpdatePreparationFailureKind = 'stop-failed' | 'tasks-changed' | 'tasks-unavailable' | 'fullscreen-timeout' export interface DesktopUpdateState { readonly phase: 'idle' | 'checking' | 'available' | 'downloading' | 'verifying' | 'installing' | 'ready' | 'error' @@ -59,6 +59,7 @@ export type DesktopUpdateFailureKind = | 'stop-failed' | 'tasks-changed' | 'tasks-unavailable' + | 'fullscreen-timeout' /** Semantic status content; actions open main-process confirmation dialogs only. */ export interface DesktopUpdatePresentation { diff --git a/apps/desktop/src/locale.ts b/apps/desktop/src/locale.ts index 5d9d4607c7..3c60af45d8 100644 --- a/apps/desktop/src/locale.ts +++ b/apps/desktop/src/locale.ts @@ -128,6 +128,7 @@ export const en = { updateStopTasks: 'Stop tasks and update', updateTasksChanged: 'New tasks have started. Confirm again to stop the tasks and update.', updateTasksUnavailable: 'Task status is unavailable. Try updating again when the workspace is ready.', + updateFullscreenTimeout: 'Could not exit fullscreen. Exit fullscreen, then retry the update.', updateStopFailed: 'Could not safely stop the tasks. The update has not been installed. Please try again later.', updateTechnicalDetails: 'View technical details', updateTitle: 'DeepSeek Harness Update', @@ -299,6 +300,7 @@ export const zh = { updateStopTasks: '停止任务并更新', updateTasksChanged: '有新任务开始运行,请重新确认是否停止任务并更新。', updateTasksUnavailable: '无法确认任务状态,请在工作区就绪后重试更新。', + updateFullscreenTimeout: '无法退出全屏,请退出全屏后重试更新', updateStopFailed: '未能安全停止任务,更新尚未安装,请稍后重试。', updateTechnicalDetails: '查看技术详情', updateTitle: 'DeepSeek Harness 更新', diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 9d410186ce..af531e9d1d 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -597,7 +597,7 @@ async function main(): Promise { if (host === undefined) throw new DesktopUpdatePreparationError('tasks-unavailable', locale.messages.updateTasksUnavailable) const active = await host.updateTasks('inspect') const parent = currentDialogWindow() - if (parent === undefined || !await prepareDesktopUpdateWindow(parent)) return false + if (parent === undefined || !await prepareDesktopUpdateWindow(parent, locale.messages.updateFullscreenTimeout)) return false const ready = desktopUpdateReadyConfirmation(locale.messages, updates.state.version ?? '', process.platform) const confirmation: Electron.MessageBoxOptions = { type: active ? 'warning' : 'info', title: locale.messages.updateTitle, diff --git a/apps/desktop/src/update-presentation.ts b/apps/desktop/src/update-presentation.ts index 42119d93a2..670b6eb4b6 100644 --- a/apps/desktop/src/update-presentation.ts +++ b/apps/desktop/src/update-presentation.ts @@ -33,6 +33,7 @@ export function desktopUpdateErrorSummary(state: DesktopUpdateState, messages: D 'stop-failed': messages.updateStopFailed, 'tasks-changed': messages.updateTasksChanged, 'tasks-unavailable': messages.updateTasksUnavailable, + 'fullscreen-timeout': messages.updateFullscreenTimeout, } return summaries[kind] } diff --git a/apps/desktop/src/update-window.ts b/apps/desktop/src/update-window.ts index 15f32630a5..6a695d3cbd 100644 --- a/apps/desktop/src/update-window.ts +++ b/apps/desktop/src/update-window.ts @@ -1,20 +1,19 @@ /** Native window preparation before an update confirmation can stop the Host. */ import type { BrowserWindow } from 'electron' +import { DesktopUpdatePreparationError } from './update-error.ts' type UpdateWindow = Pick & Pick -/** A stalled native transition refuses installation while the current Host remains running. */ -export class DesktopUpdateFullscreenTimeoutError extends Error { - constructor() { super('macOS did not finish leaving fullscreen. Exit fullscreen and retry the update.') } -} +const FULLSCREEN_EXIT_TIMEOUT_MS = 5000 /** * Leave macOS fullscreen before displaying installation confirmation. * @param window - Product window that owns the update confirmation. + * @param timeoutMessage - Locale-owned guidance to exit fullscreen and retry. * @returns Whether the window survived the completed native transition; destruction cancels installation. - * @throws {DesktopUpdateFullscreenTimeoutError} If macOS does not report completion within five seconds. + * @throws {DesktopUpdatePreparationError} If macOS does not report completion within five seconds. */ -export async function prepareDesktopUpdateWindow(window: UpdateWindow): Promise { +export async function prepareDesktopUpdateWindow(window: UpdateWindow, timeoutMessage: string): Promise { if (window.isDestroyed()) return false if (process.platform !== 'darwin' || !window.isFullScreen()) return true return new Promise((resolve, reject) => { @@ -26,7 +25,7 @@ export async function prepareDesktopUpdateWindow(window: UpdateWindow): Promise< const left = (): void => { cleanup(); resolve(true) } const closed = (): void => { cleanup(); resolve(false) } // Bound a missing native completion event; elapsed time never authorizes installation. - const deadline = setTimeout(() => { cleanup(); reject(new DesktopUpdateFullscreenTimeoutError()) }, 5000) + const deadline = setTimeout(() => { cleanup(); reject(new DesktopUpdatePreparationError('fullscreen-timeout', timeoutMessage)) }, FULLSCREEN_EXIT_TIMEOUT_MS) window.once('leave-full-screen', left) window.once('closed', closed) try { window.setFullScreen(false) } diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index b49711f5ed..e05c16b063 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -1818,6 +1818,29 @@ describe('desktop main startup', () => { expect(window.listenerCount('closed')).toBe(baseline.closed) }) + it('keeps the Host running after a fullscreen timeout and accepts a later retry', async () => { + vi.stubGlobal('process', { ...process, platform: 'darwin' }) + const host = await readyForUpdate() + const window = harness.windows[0]! + window.fullscreen = true + window.setFullScreen.mockImplementation(() => {}) + const preparing = harness.prepareUpdate() + const rejected = expect(preparing).rejects.toMatchObject({ kind: 'fullscreen-timeout', + message: 'Could not exit fullscreen. Exit fullscreen, then retry the update.' }) + await vi.waitFor(() => { expect(window.setFullScreen).toHaveBeenCalledWith(false) }) + await vi.advanceTimersByTimeAsync(5000) + await rejected + expect(harness.dialog.showMessageBox).not.toHaveBeenCalled() + expect(host.updateTasks.mock.calls).toEqual([['inspect']]) + expect(host.stop).not.toHaveBeenCalled() + window.setFullScreen.mockImplementation((flag) => { window.fullscreen = flag; window.emit('leave-full-screen') }) + harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 0 }) + const retrying = harness.prepareUpdate() + await host.stopping.promise + host.exited.resolve() + await expect(retrying).resolves.toBe(true) + }) + it.each([false, true])('cancels installation when the fullscreen product window closes, mandatory: %s', async (mandatory) => { vi.stubGlobal('process', { ...process, platform: 'darwin' }) if (mandatory) { diff --git a/apps/desktop/tests/update-coordinator.spec.ts b/apps/desktop/tests/update-coordinator.spec.ts index 1c6e9da1c7..3aef0bc60f 100644 --- a/apps/desktop/tests/update-coordinator.spec.ts +++ b/apps/desktop/tests/update-coordinator.spec.ts @@ -73,13 +73,13 @@ function fixture(channel: 'nightly' | 'latest' = 'nightly', version = '1.1.0-alp } describe('desktop update coordinator', () => { - it('keeps safe preparation diagnostics separate and clears them on an explicit retry', async () => { + it.each(['stop-failed', 'fullscreen-timeout'] as const)('keeps safe %s diagnostics separate and clears them on an explicit retry', async (kind) => { const f = fixture() await f.coordinator.check() await f.coordinator.download('1.1.0-rc.2') - f.beforeRestart.mockRejectedValueOnce(new DesktopUpdatePreparationError('stop-failed', zh.updateStopFailed, 'exit 0; shutdown acknowledged false')) + f.beforeRestart.mockRejectedValueOnce(new DesktopUpdatePreparationError(kind, zh.updateStopFailed, 'exit 0; shutdown acknowledged false')) expect(await f.coordinator.install('1.1.0-rc.2')).toEqual({ phase: 'error', version: '1.1.0-rc.2', - failedOperation: 'install', preparationFailure: 'stop-failed', message: zh.updateStopFailed, + failedOperation: 'install', preparationFailure: kind, message: zh.updateStopFailed, technicalDetails: 'exit 0; shutdown acknowledged false' }) expect(f.quitAndInstall).not.toHaveBeenCalled() f.beforeRestart.mockResolvedValueOnce(false) diff --git a/apps/desktop/tests/update-error-renderer.spec.ts b/apps/desktop/tests/update-error-renderer.spec.ts index 54bc5d5e2e..b2778c3b15 100644 --- a/apps/desktop/tests/update-error-renderer.spec.ts +++ b/apps/desktop/tests/update-error-renderer.spec.ts @@ -243,3 +243,19 @@ it.each(['en', 'zh-CN'])('records mandatory update guidance and actions across i }) await expect(JSON.stringify(snapshots, null, 2) + '\n').toMatchFileSnapshot(`./expected/update-guidance-${language}.json`) }) + +it.each(['en', 'zh-CN'])('shows fullscreen recovery guidance directly in the mandatory update page: %s', async (language) => { + const p = page('mandatory-update') + const locale = resolveDesktopLocale(language) + const state: MandatoryUpdateView = { locale, deferred: false, policy: { blocking: true, checking: false }, + update: { phase: 'error', failedOperation: 'install', preparationFailure: 'fullscreen-timeout', + version: '0.1.6-nightly.1', message: 'different shell locale' } } + const action = vi.fn(async () => {}) + Object.defineProperty(p.dom.window, 'dshMandatoryUpdate', { value: { + status: async () => state, action, subscribe: () => () => {}, + } satisfies MandatoryUpdateApi }) + p.run() + await expect.poll(() => p.element('error').textContent).toBe(locale.messages.updateFullscreenTimeout) + expect((p.element('technical-details') as HTMLDetailsElement).open).toBe(false) + expect(action).not.toHaveBeenCalled() +}) diff --git a/apps/desktop/tests/update-presentation.spec.ts b/apps/desktop/tests/update-presentation.spec.ts index 9d3ce5009e..9c53a9ebec 100644 --- a/apps/desktop/tests/update-presentation.spec.ts +++ b/apps/desktop/tests/update-presentation.spec.ts @@ -32,6 +32,7 @@ it('keeps raw diagnostics out of error tooltips in both locales', () => { expect(desktopUpdateErrorSummary({ phase: 'error', failedOperation: 'install', preparationFailure: 'stop-failed', message: 'translated or changed copy', technicalDetails: 'private diagnostic' }, messages)).toBe(messages.updateStopFailed) for (const [preparationFailure, expected] of [ + ['fullscreen-timeout', messages.updateFullscreenTimeout], ['tasks-changed', messages.updateTasksChanged], ['tasks-unavailable', messages.updateTasksUnavailable], ] as const) { expect(desktopUpdateErrorSummary({ phase: 'error', failedOperation: 'install', preparationFailure, diff --git a/apps/desktop/tests/update-window.spec.ts b/apps/desktop/tests/update-window.spec.ts index dc2c618fe0..c755a76e92 100644 --- a/apps/desktop/tests/update-window.spec.ts +++ b/apps/desktop/tests/update-window.spec.ts @@ -1,6 +1,7 @@ import { EventEmitter } from 'node:events' import { afterEach, expect, it, vi } from 'vitest' -import { DesktopUpdateFullscreenTimeoutError, prepareDesktopUpdateWindow } from '../src/update-window.ts' +import { prepareDesktopUpdateWindow } from '../src/update-window.ts' +import { en } from '../src/locale.ts' afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals() }) @@ -11,7 +12,7 @@ function setup(platform = 'darwin', fullscreen = true, destroyed = false) { isFullScreen: () => fullscreen, setFullScreen: vi.fn(), }) - return { window, prepare: () => prepareDesktopUpdateWindow(window) } + return { window, prepare: () => prepareDesktopUpdateWindow(window, en.updateFullscreenTimeout) } } it.each(['darwin', 'win32', 'linux'])('does not transition an ordinary %s window', async (platform) => { @@ -20,10 +21,13 @@ it.each(['darwin', 'win32', 'linux'])('does not transition an ordinary %s window expect(f.window.setFullScreen).not.toHaveBeenCalled() }) -it('preserves Windows fullscreen and refuses a destroyed product window', async () => { +it('preserves Windows fullscreen', async () => { const windows = setup('win32') await expect(windows.prepare()).resolves.toBe(true) expect(windows.window.setFullScreen).not.toHaveBeenCalled() +}) + +it('refuses a destroyed product window', async () => { const destroyed = setup('darwin', true, true) await expect(destroyed.prepare()).resolves.toBe(false) expect(destroyed.window.setFullScreen).not.toHaveBeenCalled() @@ -43,7 +47,7 @@ it.each(['leave-full-screen', 'closed'])('settles on %s and releases both listen it('rejects a stalled native transition and allows a later attempt to complete', async () => { vi.useFakeTimers() const f = setup() - const failure = expect(f.prepare()).rejects.toBeInstanceOf(DesktopUpdateFullscreenTimeoutError) + const failure = expect(f.prepare()).rejects.toMatchObject({ kind: 'fullscreen-timeout', message: en.updateFullscreenTimeout }) await vi.advanceTimersByTimeAsync(5000) await failure expect(f.window.eventNames()).toEqual([]) diff --git a/packages/client/ui-settings-general/README.i18n.yaml b/packages/client/ui-settings-general/README.i18n.yaml index a458bb8cf0..a265b7029e 100644 --- a/packages/client/ui-settings-general/README.i18n.yaml +++ b/packages/client/ui-settings-general/README.i18n.yaml @@ -15,8 +15,8 @@ en: 2843d46e95311bce zh: ec11bdeb5d3ce818 /deepseek-ai-dsh-client-ui-settings-general/use-this-package: - en: 2bc21e57fe17eab9 - zh: e441cd313f747b22 + en: 4285513efdb1be68 + zh: b28089509254e8d5 /deepseek-ai-dsh-client-ui-settings-general/use-this-package/the-general-section: en: 8b8a7c6287bb303b zh: 19535112531aedd5 diff --git a/packages/client/ui-settings-general/README.md b/packages/client/ui-settings-general/README.md index b005515ea4..cf0c202f62 100644 --- a/packages/client/ui-settings-general/README.md +++ b/packages/client/ui-settings-general/README.md @@ -35,7 +35,7 @@ When the page reconnects to a Host running another release, a **Service version When the section navigation exceeds the panel's available height, the list scrolls independently of the settings content and keeps the Settings title fixed. -In Desktop, the account-row update control shows availability, progress, verification, readiness, and persistent retry feedback. It shares the connection indicator’s 28px height, 8px corners, 14px icon slot, 4px icon gap, and 12px medium text with an 18px line height; update-state border, fill, and text colors remain independently defined. Retry text and its dot use the same brand blue as other update labels; numeric download progress has no ellipsis. The preload carries semantic phase, version, progress, and classified failures; the component resolves every visible and accessible string from the active `settings` locale, including after an in-application language change. Selecting an available update starts downloading; installation requires a separate shell-owned confirmation. A collapsed sidebar shows the same status as a brand-blue dot on its top expand button, including failures. Connection feedback takes priority except during shell-reported installation, when the expected backend disconnect must not hide update status. Failure restores connection feedback. Both controls share one carrier subscription; browser code cannot choose packages or authorize installation. [Desktop updates](../../../apps/desktop/README.md) owns the release workflow. Checking, verifying, and preparing-to-restart tooltips show only the supplied version; without a version, they retain the state label. Desktop update tooltips preserve the complete version and prerelease suffix without adding v or V; their error summaries match the shell update dialogs. +In Desktop, the account-row update control shows availability, progress, verification, readiness, and persistent retry feedback. It shares the connection indicator’s 28px height, 8px corners, 14px icon slot, 4px icon gap, and 12px medium text with an 18px line height; update-state border, fill, and text colors remain independently defined. Retry text and its dot use the same brand blue as other update labels; numeric download progress has no ellipsis. The preload carries semantic phase, version, progress, and classified failures; the component resolves every visible and accessible string from the active `settings` locale, including after an in-application language change. Selecting an available update starts downloading; installation requires a separate shell-owned confirmation. A collapsed sidebar shows the same status as a brand-blue dot on its top expand button, including failures. Connection feedback takes priority except during shell-reported installation, when the expected backend disconnect must not hide update status. Failure restores connection feedback. Both controls share one carrier subscription; browser code cannot choose packages or authorize installation. [Desktop updates](../../../apps/desktop/README.md) owns the release workflow. Checking, verifying, and preparing-to-restart tooltips show only the supplied version; without a version, they retain the state label. Desktop update tooltips preserve the complete version and prerelease suffix without adding v or V; their error summaries match the shell update dialogs, including guidance to exit fullscreen and retry after a fullscreen-transition timeout. Settings visibility and section selection live in the shell owner store. The shell supplies the effective Settings binding to the contributed launcher for menu keycaps and `aria-keyshortcuts`; the fallback button uses the same binding for hover and keyboard-focus hints and `aria-keyshortcuts`. The Settings command (Desktop: `Mod+,`; Windows/macOS Web: `Mod+Alt+,`) toggles the dialog when Settings is in front or no modal is open; the sidebar control opens the same dialog. The command cannot open or close Settings while the shortcut reference or another modal is in front. Held-key repeats do nothing. Initial focus lands on the selected section in the navigation, or on the title when no sections are available, without drawing a focus outline. Tab and directional navigation retain their visible focus indicators. `Mod+/` can open the shortcut reference above Settings; `Escape` closes the top dialog and restores focus to its invoking control. Return focus omits outlines after shortcut, Escape, or pointer dismissal. diff --git a/packages/client/ui-settings-general/README.zh.md b/packages/client/ui-settings-general/README.zh.md index 8628916c7b..616bfd3c84 100644 --- a/packages/client/ui-settings-general/README.zh.md +++ b/packages/client/ui-settings-general/README.zh.md @@ -35,7 +35,7 @@ kind: "package-reference" 当分区导航超出面板可用高度时,列表独立于设置内容滚动,并保持「设置」标题固定。 -在 Desktop 中,账户行更新控件显示可用版本、进度、验证、就绪和持久重试反馈。它与连接指示器共用 28px 高度、8px 圆角、14px 图标占位、4px 图文间距,以及 12px 中等字重文字和 18px 行高;更新状态的边框、填充和文字颜色独立定义。重试文字及其圆点与其他更新文案使用相同的品牌蓝色;数字下载进度不带省略号。预加载仅传递语义化阶段、版本、进度和失败类别;组件从当前 `settings` locale 解析全部可见与无障碍文案,并在应用内语言变化后同步更新。选择可用更新即开始下载;安装需要独立的壳拥有的确认。侧栏收起时,其顶部展开按钮以品牌蓝色圆点显示相同状态,包括失败状态。连接反馈通常优先展示,但壳报告正在安装时,预期的后端断开不能遮盖更新状态;失败后恢复连接反馈。两个控件共用一次载体订阅;浏览器代码不能选择安装包或授权安装。[Desktop 更新](../../../apps/desktop/README.zh.md)负责发布流程。检查中、校验中和准备重启状态的悬浮提示只显示已提供的版本号;没有版本号时保留状态文案。桌面更新提示完整保留版本号及预发布后缀,不额外添加 v 或 V;错误提示与桌面升级弹窗保持一致。 +在 Desktop 中,账户行更新控件显示可用版本、进度、验证、就绪和持久重试反馈。它与连接指示器共用 28px 高度、8px 圆角、14px 图标占位、4px 图文间距,以及 12px 中等字重文字和 18px 行高;更新状态的边框、填充和文字颜色独立定义。重试文字及其圆点与其他更新文案使用相同的品牌蓝色;数字下载进度不带省略号。预加载仅传递语义化阶段、版本、进度和失败类别;组件从当前 `settings` locale 解析全部可见与无障碍文案,并在应用内语言变化后同步更新。选择可用更新即开始下载;安装需要独立的壳拥有的确认。侧栏收起时,其顶部展开按钮以品牌蓝色圆点显示相同状态,包括失败状态。连接反馈通常优先展示,但壳报告正在安装时,预期的后端断开不能遮盖更新状态;失败后恢复连接反馈。两个控件共用一次载体订阅;浏览器代码不能选择安装包或授权安装。[Desktop 更新](../../../apps/desktop/README.zh.md)负责发布流程。检查中、校验中和准备重启状态的悬浮提示只显示已提供的版本号;没有版本号时保留状态文案。桌面更新提示完整保留版本号及预发布后缀,不额外添加 v 或 V;错误提示与桌面升级弹窗保持一致,包括全屏切换超时后退出全屏并重试的指引。 设置的可见状态和分区选择由 shell owner store 保存。外壳将设置命令的有效绑定传给入口贡献,用于菜单键帽和 `aria-keyshortcuts`;回退按钮使用相同绑定提供悬停、键盘聚焦提示和 `aria-keyshortcuts`。设置位于最前方或没有模态弹窗时,设置命令(Desktop 为 `Mod+,`,Windows/macOS Web 为 `Mod+Alt+,`)切换设置弹窗的开关;侧栏控件打开同一弹窗。快捷键速查或其他模态弹窗位于前方时,该命令不能打开或关闭设置。长按产生的重复按键不执行操作。初始焦点落在导航中选中的分区;没有可用分区时落在标题上,均不绘制焦点外轮廓线。Tab 和方向键导航保留可见焦点提示。`Mod+/` 可在设置上方打开快捷键速查;`Escape` 关闭最上层弹窗,焦点返回触发该弹窗的控件。通过快捷键、Esc 或鼠标关闭后的回焦均不绘制外轮廓线。 diff --git a/packages/client/ui-settings-general/src/client/DesktopUpdateIndicator.tsx b/packages/client/ui-settings-general/src/client/DesktopUpdateIndicator.tsx index 588b3befdb..728fee6537 100644 --- a/packages/client/ui-settings-general/src/client/DesktopUpdateIndicator.tsx +++ b/packages/client/ui-settings-general/src/client/DesktopUpdateIndicator.tsx @@ -37,6 +37,7 @@ function updateCopy(state: DesktopUpdatePresentation, t: SettingsTranslate): { l 'stop-failed': t('desktop.update.stopFailed'), 'tasks-changed': t('desktop.update.tasksChanged'), 'tasks-unavailable': t('desktop.update.tasksUnavailable'), + 'fullscreen-timeout': t('desktop.update.fullscreenTimeout'), } return { label, detail: failures[state.failure ?? 'install'] } } diff --git a/packages/client/ui-settings-general/src/client/locales.ts b/packages/client/ui-settings-general/src/client/locales.ts index f12d407a74..7d189d496c 100644 --- a/packages/client/ui-settings-general/src/client/locales.ts +++ b/packages/client/ui-settings-general/src/client/locales.ts @@ -19,6 +19,7 @@ export const zh = { 'desktop.update.checkNetworkFailed': '检查更新失败,请检查网络连接后重试。', 'desktop.update.downloadNetworkFailed': '下载更新失败,请检查网络连接后重试。', 'desktop.update.installNetworkFailed': '安装更新失败,请检查网络连接后重试。', + 'desktop.update.fullscreenTimeout': '无法退出全屏,请退出全屏后重试更新', 'desktop.update.stopFailed': '未能安全停止任务,更新尚未安装,请稍后重试。', 'desktop.update.tasksChanged': '有新任务开始运行,请重新确认是否停止任务并更新。', 'desktop.update.tasksUnavailable': '无法确认任务状态,请在工作区就绪后重试更新。', @@ -67,6 +68,7 @@ export const en = { 'desktop.update.checkNetworkFailed': 'Could not check for updates. Check your connection and try again.', 'desktop.update.downloadNetworkFailed': 'Could not download the update. Check your connection and try again.', 'desktop.update.installNetworkFailed': 'Could not install the update. Check your connection and try again.', + 'desktop.update.fullscreenTimeout': 'Could not exit fullscreen. Exit fullscreen, then retry the update.', 'desktop.update.stopFailed': 'Could not safely stop the tasks. The update has not been installed. Please try again later.', 'desktop.update.tasksChanged': 'New tasks have started. Confirm again to stop the tasks and update.', 'desktop.update.tasksUnavailable': 'Task status is unavailable. Try updating again when the workspace is ready.', diff --git a/packages/client/ui-settings-general/src/types.ts b/packages/client/ui-settings-general/src/types.ts index f9dae34f87..9aa0ea95e3 100644 --- a/packages/client/ui-settings-general/src/types.ts +++ b/packages/client/ui-settings-general/src/types.ts @@ -11,6 +11,7 @@ export type DesktopUpdateFailureKind = | 'stop-failed' | 'tasks-changed' | 'tasks-unavailable' + | 'fullscreen-timeout' /** Public semantic preload fields consumed by the settings row. */ export interface DesktopUpdatePresentation { diff --git a/packages/client/ui-settings-general/tests/desktop-update.client.spec.tsx b/packages/client/ui-settings-general/tests/desktop-update.client.spec.tsx index 505a02d969..bfcebc3c0e 100644 --- a/packages/client/ui-settings-general/tests/desktop-update.client.spec.tsx +++ b/packages/client/ui-settings-general/tests/desktop-update.client.spec.tsx @@ -177,3 +177,24 @@ it.each([ expect((await screen.findByRole('tooltip')).textContent).toBe(label) } finally { f.view.unmount(); f.status.resolve({ phase: 'idle' }) } }) + +it('localizes fullscreen timeout guidance and preserves the retry action', async () => { + const f = fixture() + const copy: string[] = [] + try { + await f.emit({ phase: 'error', failure: 'fullscreen-timeout', version: available.version }) + for (const dictionary of [zh, en]) { + f.view.rerender() + fireEvent.focus(screen.getByRole('button')) + copy.push((await screen.findByRole('tooltip')).textContent ?? '') + } + expect(copy).toMatchInlineSnapshot(` + [ + "无法退出全屏,请退出全屏后重试更新", + "Could not exit fullscreen. Exit fullscreen, then retry the update.", + ] + `) + fireEvent.click(screen.getByRole('button')) + expect(f.open).toHaveBeenCalledOnce() + } finally { f.view.unmount(); f.status.resolve(available) } +}) From f2e0d7db1b3e85cb4b094ca35e8eef4127a7c484 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 19 Oct 2026 16:59:30 +0800 Subject: [PATCH 06/12] feat(llm): raise custom model defaults to 1M context and 128K output --- .../models-settings/catalog-inputs.expected.md | 4 ++-- .../models-settings/declared-edit.expected.md | 4 ++-- .../models-settings/image-only.expected.md | 4 ++-- apps/web/tests/models-settings.e2e.ts | 3 +++ docs/config-catalog.md | 4 ++-- docs/config-catalog.zh.md | 4 ++-- .../pi-ai-default-capacities/guide.i18n.yaml | 16 ++++++++++++++++ .../pi-ai-default-capacities/guide.md | 18 ++++++++++++++++++ .../pi-ai-default-capacities/guide.zh.md | 18 ++++++++++++++++++ .../client/ui-settings-models/README.i18n.yaml | 4 ++-- packages/client/ui-settings-models/README.md | 2 ++ .../client/ui-settings-models/README.zh.md | 2 ++ .../src/client/ModelListEditor.tsx | 13 +++++-------- .../tests/provider-form.client.spec.tsx | 2 ++ packages/llm/llm-pi-ai/README.i18n.yaml | 4 ++-- packages/llm/llm-pi-ai/README.md | 4 ++-- packages/llm/llm-pi-ai/README.zh.md | 4 ++-- packages/llm/llm-pi-ai/src/config.ts | 8 ++++---- packages/llm/llm-pi-ai/tests/catalog.spec.ts | 2 +- 19 files changed, 89 insertions(+), 31 deletions(-) create mode 100644 docs/upgrade-guide/v0.2.1-rc.2/pi-ai-default-capacities/guide.i18n.yaml create mode 100644 docs/upgrade-guide/v0.2.1-rc.2/pi-ai-default-capacities/guide.md create mode 100644 docs/upgrade-guide/v0.2.1-rc.2/pi-ai-default-capacities/guide.zh.md diff --git a/apps/web/tests/expected/models-settings/catalog-inputs.expected.md b/apps/web/tests/expected/models-settings/catalog-inputs.expected.md index e81a95fdf1..de6a45d25c 100644 --- a/apps/web/tests/expected/models-settings/catalog-inputs.expected.md +++ b/apps/web/tests/expected/models-settings/catalog-inputs.expected.md @@ -42,11 +42,11 @@ - button "删除模型 1" - text: 上下文窗口 - textbox "上下文窗口 1": - - /placeholder: 256K + - /placeholder: 1M - text: 272K - text: 最大输出 token 数 - textbox "最大输出 token 数 1": - - /placeholder: 32K + - /placeholder: 128K - text: 128K - group "输入类型 1": - text: 输入类型 diff --git a/apps/web/tests/expected/models-settings/declared-edit.expected.md b/apps/web/tests/expected/models-settings/declared-edit.expected.md index 8210c7907d..73e8fc9a47 100644 --- a/apps/web/tests/expected/models-settings/declared-edit.expected.md +++ b/apps/web/tests/expected/models-settings/declared-edit.expected.md @@ -49,10 +49,10 @@ - button "删除模型 1" - text: 上下文窗口 - textbox "上下文窗口 1": - - /placeholder: 256K + - /placeholder: 1M - text: 最大输出 token 数 - textbox "最大输出 token 数 1": - - /placeholder: 32K + - /placeholder: 128K - group "输入类型 1": - text: 输入类型 - checkbox "文本" [checked] diff --git a/apps/web/tests/expected/models-settings/image-only.expected.md b/apps/web/tests/expected/models-settings/image-only.expected.md index bbeacdc7be..e0b4eb62cf 100644 --- a/apps/web/tests/expected/models-settings/image-only.expected.md +++ b/apps/web/tests/expected/models-settings/image-only.expected.md @@ -49,10 +49,10 @@ - button "删除模型 1" - text: 上下文窗口 - textbox "上下文窗口 1": - - /placeholder: 256K + - /placeholder: 1M - text: 最大输出 token 数 - textbox "最大输出 token 数 1": - - /placeholder: 32K + - /placeholder: 128K - group "输入类型 1": - text: 输入类型 - checkbox "文本" diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index 0abc01dcd1..9b5da0d115 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -308,6 +308,8 @@ describe('web e2e: Models settings page configures a dormant provider', () => { await custom.getByRole('button', { name: '添加模型', exact: true }).click() await custom.getByLabel('模型 ID 1').fill('acme-large') await custom.getByRole('button', { name: '模型选项 1' }).click() + expect(await custom.getByLabel('上下文窗口 1').getAttribute('placeholder')).toBe('1M') + expect(await custom.getByLabel('最大输出 token 数 1').getAttribute('placeholder')).toBe('128K') expect(await custom.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).isChecked()).toBe(false) await custom.getByRole('group', { name: '输入类型 1' }).getByRole('checkbox', { name: '图片' }).check() // The switch seeds off, low, medium, and high rows; this route serves no medium. @@ -322,6 +324,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => { expect(document).toContain('acme-gateway:') expect(document).toContain('reasoningEfforts:') await expect(scaffold.ctx.llm.resolveModelInfo('acme-gateway', 'acme-large')).resolves.toMatchObject({ + context: { contextWindow: 1_000_000 }, inputModalities: ['text', 'image'], reasoning: { efforts: [{ id: 'off', name: 'Off' }, { id: 'low', name: 'Low' }, { id: 'high', name: 'High' }] }, }) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 495b641086..21a7381a3f 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1888,13 +1888,13 @@ export interface PiAiProviderProfile { compat?: PiAiCompatProfile /** * Context capacity for a model this route lists that neither the entry nor - * the installed catalog sizes (default 262,144). A guess by construction, so + * the installed catalog sizes (default 1,000,000). A guess by construction, so * a deployment whose gateway serves smaller models corrects it here. */ defaultContextWindow?: number /** * Output capability for a model this route lists that neither the entry nor - * the installed catalog sizes (default 32,768). This sizes the model; it + * the installed catalog sizes (default 128,000). This sizes the model; it * never becomes a per-request cap on its own. */ defaultMaxTokens?: number diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index c2e07458bb..4c0748acc0 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1890,13 +1890,13 @@ export interface PiAiProviderProfile { compat?: PiAiCompatProfile /** * Context capacity for a model this route lists that neither the entry nor - * the installed catalog sizes (default 262,144). A guess by construction, so + * the installed catalog sizes (default 1,000,000). A guess by construction, so * a deployment whose gateway serves smaller models corrects it here. */ defaultContextWindow?: number /** * Output capability for a model this route lists that neither the entry nor - * the installed catalog sizes (default 32,768). This sizes the model; it + * the installed catalog sizes (default 128,000). This sizes the model; it * never becomes a per-request cap on its own. */ defaultMaxTokens?: number diff --git a/docs/upgrade-guide/v0.2.1-rc.2/pi-ai-default-capacities/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.2/pi-ai-default-capacities/guide.i18n.yaml new file mode 100644 index 0000000000..5a624e1331 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/pi-ai-default-capacities/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.2/pi-ai-default-capacities/guide.md +/: + en: 61a3259e0c1e7893 + zh: 0c96fdeebcb2368f +/pi-ai-default-model-capacities: + en: cc30c7c81bcb89eb + zh: ee0b1b5f82676f9e +/pi-ai-default-model-capacities/change: + en: a47430fe02282532 + zh: 4f8d65358bb36431 +/pi-ai-default-model-capacities/migration: + en: ca3e5882f43f88c3 + zh: 89d939f046a6ee1d diff --git a/docs/upgrade-guide/v0.2.1-rc.2/pi-ai-default-capacities/guide.md b/docs/upgrade-guide/v0.2.1-rc.2/pi-ai-default-capacities/guide.md new file mode 100644 index 0000000000..fe2a2d9ce4 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/pi-ai-default-capacities/guide.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "Undescribed pi-ai models inherit larger context and output capacities unless the provider configures explicit defaults." +--- + +# pi-ai default model capacities + +English | [中文](guide.zh.md) + +## Change + +For models whose capacity is absent from both configuration and the installed catalog, `llm-pi-ai` raises `defaultContextWindow` from 262,144 to 1,000,000 tokens and `defaultMaxTokens` from 32,768 to 128,000 tokens. A larger assumed context window can delay automatic compaction. Explicit model capacities, catalog values, and provider defaults retain their precedence. The output-capacity fallback does not become a Harness request-level `maxTokens` default. + +## Migration + +1. Check the actual limits of custom endpoints. In the `llm-pi-ai` configuration in `cordis.patch.yml`, set `providers..defaultContextWindow` and `providers..defaultMaxTokens` when those limits differ. To retain the previous defaults, use `262144` and `32768` respectively. +2. For models with different limits on the same provider, set `contextWindow` and `maxTokens` on their `providers..models` entries. An explicitly configured model `maxTokens` also supplies the Harness request default. +3. Reopen the profile configuration and confirm the saved numeric values. See the [provider configuration reference](../../../../packages/llm/llm-pi-ai/README.md). diff --git a/docs/upgrade-guide/v0.2.1-rc.2/pi-ai-default-capacities/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.2/pi-ai-default-capacities/guide.zh.md new file mode 100644 index 0000000000..ddf3d8af35 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/pi-ai-default-capacities/guide.zh.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "配置和目录均未描述容量的 pi-ai 模型会继承更大的上下文与输出容量,除非提供方显式配置默认值。" +--- + +# pi-ai 默认模型容量 + +[English](guide.md) | 中文 + +## 变更 + +对于配置和已安装目录均未提供容量的模型,`llm-pi-ai` 将 `defaultContextWindow` 从 262,144 提高到 1,000,000 tokens,将 `defaultMaxTokens` 从 32,768 提高到 128,000 tokens。更大的假定上下文窗口可能推迟自动压缩。显式模型容量、目录值和提供方默认值仍保留原有优先级。输出容量回退值不会成为 Harness 请求级 `maxTokens` 默认值。 + +## 迁移 + +1. 核对自定义端点的实际限制。如果限制不同,在 `cordis.patch.yml` 的 `llm-pi-ai` 配置中设置 `providers..defaultContextWindow` 和 `providers..defaultMaxTokens`。如需保留原默认值,分别填写 `262144` 和 `32768`。 +2. 同一提供方的模型限制不同时,在相应 `providers..models` 条目上设置 `contextWindow` 和 `maxTokens`。显式配置的模型 `maxTokens` 同时提供 Harness 请求默认值。 +3. 重新打开 profile 配置,确认保存的数值。参见[提供方配置参考](../../../../packages/llm/llm-pi-ai/README.zh.md)。 diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index 58750ae463..f48e27b281 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/packages/client/ui-settings-models/README.i18n.yaml @@ -21,8 +21,8 @@ en: f4817eb2e3537ed9 zh: e589c193fa85473f /deepseek-ai-dsh-client-ui-settings-models/use-this-package/editing-a-provider: - en: 3af73644655c8b7b - zh: d689eba772100c63 + en: 2ab40f99bd12dc17 + zh: 2b5fd79885cb85fb /deepseek-ai-dsh-client-ui-settings-models/use-this-package/adding-and-deleting-providers: en: 9d533c65c82cd4a4 zh: 0a5da334e127c035 diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index 1b07faff50..c913910fe2 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -61,6 +61,8 @@ Expand **Customized settings → Model options** to edit each model. Both provid A model row on a hand-declared pi-ai route also edits its **Reasoning levels**, the `reasoningEfforts` the adapter already reads from `cordis.patch.yml`; the **?** beside the label explains the two columns. With the switch off the field is absent, so the model does not reason and the model picker offers no level; a stored `false` is kept until the switch is used. Turning it on always starts from rows for off, low, medium, and high; it does not restore levels removed earlier, and **Cancel** discards the edit instead. Each row pairs a level select — the keys `llm-pi-ai`'s configuration schema accepts in `reasoningEfforts`, read from the namespace schema like the protocol choices and today `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`, each usable once — with the **API value** sent for it, which starts as the level name; a field cleared while typing stores the level name and shows it once the field is left. Off only turns reasoning off, so its value field is disabled; a value already written for it in `cordis.patch.yml` is shown and kept. Changing a row's level resets a value still spelled as the old level; a custom value carries over only between two thinking levels, so changing a row to or from Off resets it. A stored key the schema does not list keeps its row and stays selectable there. Rows can be added and deleted and are saved in row order, while the model picker lists the levels in escalation order. The section refuses what the adapter refuses: no level besides Off — including a `reasoningEfforts:` left without a value, which shows the switch on with no rows — or a value that is missing on a level besides Off or empty on any level. A refused value marks its field invalid, and leaving that field fills in the level name. The rows keep no state of their own, so a model row reused after a deletion, a reset, or a fold shows only its own model. Routes served from the installed catalog and the DeepSeek card keep the adapter's levels and show no reasoning control. +Empty pi-ai capacity fields display `1M` for the context window and `128K` for maximum output tokens. These hints use decimal K/M units; leaving the fields empty preserves adapter inheritance. Provider-level defaults can differ from the hints. + ### Adding and deleting providers **Add model provider** is the one entry to the add card, offered while any editor-backed settings namespace is mounted and enabled while either mode below can proceed. The card opens on a segmented mode switch with a one-line purpose beneath it: **Third-party model provider** carries the dormant-directory provider select over the provider editor — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists — and **Custom model API** carries the create form for a route pi-ai does not ship: a relay, a self-hosted server, or any other OpenAI- or Anthropic-compatible endpoint. A mode is offered only while its namespace is mounted; with one mode the card shows that form alone under the mode's name, and a mode whose namespace has nothing left to adopt or no protocol to declare is disabled with the reason as its hover text. A panel mounts the first time its mode is shown and stays mounted, hidden, while the card is open and its mode stays offered, so switching modes discards neither draft; the switch locks while either panel has a write or an endpoint interrogation in flight, since a switch underneath one would orphan its answer. Each tab controls its panel through ids, and closing either panel forgets the catalog draft's target. The custom form asks for a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model, because nothing can default those. The protocol picker names each protocol by its product name — OpenAI Chat Completions, OpenAI Responses, Anthropic Messages — while the stored value stays the schema identifier; a protocol the adapter adds before this page names it shows that identifier. The endpoint placeholder follows the selected protocol: OpenAI Chat Completions and Responses show `https://gateway.example/v1`, while Anthropic Messages shows `https://gateway.example` because its SDK appends `/v1/messages`. Changing the protocol preserves any address already entered. The endpoint must be a parseable HTTP or HTTPS URL; localhost, IPv4 and IPv6 literals, and custom ports remain valid. A syntax error blocks both discovery and creation at the field, while a request failure remains a separate provider error. **Fetch available models** asks the `llm/discoverModels` Remote about the endpoint the form shows, so adding a provider is one pass instead of save-then-return; the reply opens a searchable picker rather than being written, and nothing is written until **Add selected**. Each candidate displays its original model id in a monospace font to distinguish models with the same name. Rows stay on one line; hovering reveals the model name, falling back to the full id when no name is supplied. Selection and configuration retain the original model id. Each selected candidate copies its id, display name, context window, output-token cap, and disclosed input types into the editable row when disclosed, while an existing row retains its user-tuned values. Search matches model ids and optional display names without clearing hidden selections. **Select all** adds the visible results, while **Deselect all** clears the entire selection so hidden results cannot be adopted accidentally. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its confirmation dialog names the provider. diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index f272908232..dee1384e0b 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -61,6 +61,8 @@ API 密钥输入框初始为空,并通过 `autocomplete="new-password"` 请求 手工声明的 pi-ai 路由上,模型行还会编辑**推理等级**,即适配器本来就从 `cordis.patch.yml` 读取的 `reasoningEfforts`;标签旁的 **?** 说明两列的含义。开关关闭时该字段不存在,模型不推理,模型选择器不提供任何档位;已存储的 `false` 在开关被操作前保持不变。打开开关总是从 off、low、medium 和 high 四行开始,不会恢复之前删掉的档位;要放弃修改请点**取消**。每行把一个档位选择框——可选值是 `llm-pi-ai` 配置 schema 允许作为 `reasoningEfforts` 键的档位,与协议选项一样从命名空间 schema 读取,目前为 `off`、`minimal`、`low`、`medium`、`high`、`xhigh`、`max`,每个档位只能用一次——与发送给它的 **API 参数值**配成一对,参数值初始为档位名;输入时清空的字段会存为档位名,离开字段后显示出来。Off 只表示关闭思考,因此它的参数值输入框是禁用的;`cordis.patch.yml` 中已为它写好的值会显示并保留。修改某行的档位时,仍是旧档位名的参数值会随之重置;自定义值只在两个思考档位之间保留,切到或切离 Off 都会重置。schema 未列出的已存键会保留自己的行,并可在该行中继续选择。行可以增删,并按行的顺序保存,模型选择器则按升级顺序列出档位。本节拒绝适配器会拒绝的配置:没有 Off 以外的档位(包括未写值的 `reasoningEfforts:`,此时开关显示为打开且没有任何行),或者 Off 以外的档位缺少参数值、任意档位的参数值为空字符串。被拒绝的值会把对应输入框标为无效,离开该输入框时会填入档位名。各行不保留自己的状态,因此删除、恢复默认或折叠之后被复用的模型行只显示它自己的模型。走已安装目录的路由和 DeepSeek 卡片沿用适配器的档位,不显示推理等级控件。 +pi-ai 容量字段为空时,上下文窗口显示 `1M`,最大输出 token 数显示 `128K`。这些提示使用十进制 K/M 单位;保留空值会继续继承适配器配置。提供方级默认值可能与提示不同。 + ### 新增与删除提供商 **添加模型提供商**是新增卡片的唯一入口:只要任一带编辑器的设置 namespace 已挂载就会出现,下述两种方式任一可继续时才可点击。卡片打开后顶部是分段式的方式切换,下方一行小字说明所选方式的用途:**第三方模型提供商**承载休眠目录提供商选择框与提供商编辑器——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog;**自定义模型 API**承载为 pi-ai 不提供的路由(中转站、自部署服务或其他兼容 OpenAI / Anthropic 协议的接口)准备的创建表单。某种方式只在其 namespace 已挂载时提供;只剩一种方式时卡片以该方式为标题直接显示表单;namespace 已无可采用的提供商或没有可声明的协议时,对应方式会被禁用并把原因作为悬停提示。面板在其方式首次显示时挂载,之后在卡片打开且该方式仍被提供期间保持挂载但隐藏,因此切换方式不会丢掉任何一边的草稿;任一面板有写入或端点探测进行中时滑块锁定,因为此时切换会让结果落到看不见的面板上。每个 tab 通过 id 控制其面板,关闭任一面板都会忘记目录草稿的目标。自定义表单会索要唯一的 **Provider ID**、端点、协议与至少一个可唯一识别的模型,因为没有东西能为它们兜底。协议选择框以产品名称显示各协议——OpenAI Chat Completions、OpenAI Responses、Anthropic Messages——存储的值仍是 schema 标识符;适配器新增而本页尚未命名的协议直接显示其标识符。端点占位示例随所选协议变化:OpenAI Chat Completions 与 Responses 显示 `https://gateway.example/v1`,Anthropic Messages 显示 `https://gateway.example`,因为其 SDK 会追加 `/v1/messages`。切换协议会保留已输入的地址。端点必须是可解析的 HTTP 或 HTTPS URL;localhost、IPv4 与 IPv6 字面地址以及自定义端口仍然有效。语法错误会在字段处阻止询问与创建,请求失败则继续作为独立的提供商错误显示。**获取可用模型**通过 `llm/discoverModels` Remote 查询表单显示的端点,因此新增提供商一次即可完成,而非先保存再返回;回复打开的是可搜索选择器而非直接写入,只有点击**添加所选**才会写入。候选项使用等宽字体显示原始模型 id,以区分同名模型。每行保持单行,悬停可查看模型名称,未提供名称时回退到完整 id。勾选与配置仍使用原始模型 id。每个选中候选会在提供商公布相应信息时,把 id、显示名、上下文窗口、最大输出 token 数和已公布的输入类型复制进可编辑行;已经存在的行保留用户调整过的值。搜索会匹配模型 id 与可选显示名称,且不会清除隐藏项的勾选状态。**全选**会加入可见结果,而**取消全选**会清空全部勾选,以免意外采用隐藏结果。只有用户层单独携带某行时,该行才可删除(删除会恢复组合基线),其确认对话框会指名该提供商。 diff --git a/packages/client/ui-settings-models/src/client/ModelListEditor.tsx b/packages/client/ui-settings-models/src/client/ModelListEditor.tsx index c80f638f7c..4e6633ee5f 100644 --- a/packages/client/ui-settings-models/src/client/ModelListEditor.tsx +++ b/packages/client/ui-settings-models/src/client/ModelListEditor.tsx @@ -112,16 +112,13 @@ type CapacityField = 'contextWindow' | 'maxTokens' * What an empty capacity field is worth, shown as its placeholder so a row left * blank does not read as a model with no capacity at all. * - * The magnitudes are the adapter's own route-level fallbacks (`llm-pi-ai`'s - * `defaultContextWindow` and `defaultMaxTokens`), spelled the way a person - * would say them. They are a hint, not a mirror: this page counts `K` as 1000, - * so typing `256K` stores 256000 while leaving the field blank keeps the - * adapter's 262144. A deployment that overrides those defaults is not - * reflected here — nothing on this page can read them. + * These hints match llm-pi-ai's built-in `defaultContextWindow` and + * `defaultMaxTokens` in decimal K/M notation. Provider overrides are not + * available to this component and are not reflected in the hints. */ const CAPACITY_HINT: Readonly> = { - contextWindow: '256K', - maxTokens: '32K', + contextWindow: '1M', + maxTokens: '128K', } /** diff --git a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx index 7a49fa4505..6f80b089b9 100644 --- a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx @@ -310,6 +310,8 @@ describe('model list editing', () => { fireEvent.click(screen.getByRole('button', { name: en.addModel })) fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'acme-large' } }) expandModel(1) + expect(screen.getByLabelText(`${en.contextWindow} 1`).placeholder).toBe('1M') + expect(screen.getByLabelText(`${en.maxTokens} 1`).placeholder).toBe('128K') fireEvent.change(screen.getByLabelText(`${en.contextWindow} 1`), { target: { value: '65536' } }) fireEvent.click(within(screen.getByRole('group', { name: `${en.modelInputTypes} 1` })).getByRole('checkbox', { name: en.modelInputImage })) fireEvent.change(screen.getByLabelText(`${en.modelName} 1`), { target: { value: 'Acme' } }) diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 1a5caa57c6..636808aaba 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/README.i18n.yaml @@ -21,8 +21,8 @@ en: f3b761c7c7ecb82f zh: e10d999aa348ef86 /deepseek-ai-dsh-llm-pi-ai/use-this-package/configure-provider-routes: - en: c5d28300fa2cfc6f - zh: c162855dcb54b65b + en: 738edb4ae053740d + zh: cc6853e2bdac01ec /deepseek-ai-dsh-llm-pi-ai/use-this-package/sign-in-to-a-provider: en: c36f66438638ae08 zh: 066a2fc4cb97c86f diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index c30a414465..2b96d6ac22 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -81,8 +81,8 @@ Each profile may set a `retryPolicy`; omission uses normal mode with five retrie | `models` | installed catalog | Replaces the route's catalog wholesale; each entry defaults from the installed model | | `modelOverrides` | none | Reshapes individual installed-catalog models without replacing the rest | | `compat` | catalog detection | Wire-compatibility switches for unrecognized endpoints | -| `defaultContextWindow` | `262,144` | Capacity fallback for undescribed models | -| `defaultMaxTokens` | `32,768` | Output-cap fallback for undescribed models | +| `defaultContextWindow` | `1,000,000` | Capacity fallback for undescribed models | +| `defaultMaxTokens` | `128,000` | Output-cap fallback for undescribed models | | `requestImagePixelBudget` | `4,194,304` | Total-pixel budget for each deterministic request image | | `requestImageMaxBytes` | `1 MiB` | Encoded-byte target for each request image before base64 expansion | | `maxRequestImageBytes` | `20 MiB` | Aggregate base64 image-payload bound; a request whose retained images exceed it fails with `IMAGE_OFFLOAD_REQUIRED` | diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 0d7d5c22fe..db45c65a73 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -81,8 +81,8 @@ kind: "package-reference" | `models` | 已安装目录 | 整体替换路由目录;每个条目从已安装模型取默认值 | | `modelOverrides` | 无 | 重塑个别已安装目录模型,而不替换其余模型 | | `compat` | 目录检测 | 无法识别端点的协议兼容开关 | -| `defaultContextWindow` | `262,144` | 未描述模型的容量回退 | -| `defaultMaxTokens` | `32,768` | 未描述模型的输出上限回退 | +| `defaultContextWindow` | `1,000,000` | 未描述模型的容量回退 | +| `defaultMaxTokens` | `128,000` | 未描述模型的输出上限回退 | | `requestImagePixelBudget` | `4,194,304` | 每张确定性请求图片的总像素预算 | | `requestImageMaxBytes` | `1 MiB` | 每张请求图片在 base64 扩展前的编码字节目标 | | `maxRequestImageBytes` | `20 MiB` | base64 图片载荷总上限,保留图片超过时请求以 `IMAGE_OFFLOAD_REQUIRED` 失败 | diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index b07cd57bfb..3731e1bcc0 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -62,10 +62,10 @@ export const DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET = 2048 * 2048 export const DEFAULT_REQUEST_IMAGE_MAX_BYTES = 1024 * 1024 /** Context capacity assumed for a model neither configuration nor the catalog sizes. */ -export const DEFAULT_CONTEXT_WINDOW = 262_144 +export const DEFAULT_CONTEXT_WINDOW = 1_000_000 /** Output capability assumed for a model neither configuration nor the catalog sizes. */ -export const DEFAULT_MAX_TOKENS = 32_768 +export const DEFAULT_MAX_TOKENS = 128_000 /** * Modalities assumed for a model neither configuration nor the catalog @@ -127,13 +127,13 @@ export interface PiAiProviderProfile { compat?: PiAiCompatProfile /** * Context capacity for a model this route lists that neither the entry nor - * the installed catalog sizes (default 262,144). A guess by construction, so + * the installed catalog sizes (default 1,000,000). A guess by construction, so * a deployment whose gateway serves smaller models corrects it here. */ defaultContextWindow?: number /** * Output capability for a model this route lists that neither the entry nor - * the installed catalog sizes (default 32,768). This sizes the model; it + * the installed catalog sizes (default 128,000). This sizes the model; it * never becomes a per-request cap on its own. */ defaultMaxTokens?: number diff --git a/packages/llm/llm-pi-ai/tests/catalog.spec.ts b/packages/llm/llm-pi-ai/tests/catalog.spec.ts index e142027ae6..32bb8f3993 100644 --- a/packages/llm/llm-pi-ai/tests/catalog.spec.ts +++ b/packages/llm/llm-pi-ai/tests/catalog.spec.ts @@ -170,7 +170,7 @@ describe('hand-declared providers', () => { resolved.get(route)?.piProvider?.getModels() ?? [] expect(modelsOf('acme-gateway')).toMatchObject([ - { id: 'bare', contextWindow: 262_144, maxTokens: 32_768 }, + { id: 'bare', contextWindow: 1_000_000, maxTokens: 128_000 }, { id: 'sized', contextWindow: 8192, maxTokens: 512 }, ]) // The fallback is a guess, so a deployment whose gateway serves smaller From 798f002b19ce7ee63da12bb930aad78a79d67967 Mon Sep 17 00:00:00 2001 From: winewill Date: Mon, 19 Oct 2026 17:01:48 +0800 Subject: [PATCH 07/12] test(desktop): preserve native transition error identity --- apps/desktop/tests/update-window.spec.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/desktop/tests/update-window.spec.ts b/apps/desktop/tests/update-window.spec.ts index c755a76e92..bc1cf2dba7 100644 --- a/apps/desktop/tests/update-window.spec.ts +++ b/apps/desktop/tests/update-window.spec.ts @@ -61,8 +61,9 @@ it('rejects a stalled native transition and allows a later attempt to complete', it('releases native listeners when requesting the transition throws', async () => { vi.useFakeTimers() const f = setup() - f.window.setFullScreen.mockImplementation(() => { throw new Error('native window unavailable') }) - await expect(f.prepare()).rejects.toThrow('native window unavailable') + const nativeError = new Error('native window unavailable') + f.window.setFullScreen.mockImplementation(() => { throw nativeError }) + await expect(f.prepare()).rejects.toBe(nativeError) expect(f.window.eventNames()).toEqual([]) expect(vi.getTimerCount()).toBe(0) }) From f64dadb1dd4be0bffe4c4be81082a14c65e8b20f Mon Sep 17 00:00:00 2001 From: winewill Date: Mon, 19 Oct 2026 17:14:18 +0800 Subject: [PATCH 08/12] test(web): match page-owned clock cleanup API --- apps/web/tests/collapse-timing.e2e.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/web/tests/collapse-timing.e2e.ts b/apps/web/tests/collapse-timing.e2e.ts index b32954a43f..a19b71192b 100644 --- a/apps/web/tests/collapse-timing.e2e.ts +++ b/apps/web/tests/collapse-timing.e2e.ts @@ -39,7 +39,6 @@ describe.skipIf(MODE === 'record')('web e2e: recorded collapse timing', () => { let prompt: string let replayDir: string let hostClock: ReturnType | undefined - let unpinBrowserClock: (() => void) | undefined beforeAll(async () => { // Seeded and live prompt timestamps share the renderer's fixture day, so a run @@ -64,7 +63,7 @@ describe.skipIf(MODE === 'record')('web e2e: recorded collapse timing', () => { await seedSession(scaffold, fixture, SEED_ID, undefined, { createdAt: WEB_FIXTURE_TIME }) browser = await chromium.launch({ headless: true }) page = await newEnglishPage(browser) - if (MODE !== 'record') unpinBrowserClock = await pinBrowserClock(page) + if (MODE !== 'record') await pinBrowserClock(page) tripwire = watchConsole(page) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.getByRole('button', { name: 'Account menu', exact: true }).waitFor() @@ -76,7 +75,6 @@ describe.skipIf(MODE === 'record')('web e2e: recorded collapse timing', () => { await browser?.close().catch((error: unknown) => failures.push(error)) await scaffold?.close().catch((error: unknown) => failures.push(error)) } finally { - unpinBrowserClock?.() hostClock?.mockRestore() } if (replayDir !== undefined) { From 69207c4629c41e266695d1b87b313ad703b2243c Mon Sep 17 00:00:00 2001 From: winewill Date: Mon, 19 Oct 2026 17:40:55 +0800 Subject: [PATCH 09/12] fix(desktop): harden installer rendering lifecycle and tests --- apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 2 +- apps/desktop/README.zh.md | 2 +- apps/desktop/installer/lifecycle.nsh | 2 +- apps/desktop/installer/theme.nsh | 9 +- apps/desktop/installer/window-frame.cpp | 41 ++++--- .../scripts/prepare-windows-installer.ps1 | 4 - .../scripts/test-windows-installer.mjs | 10 +- .../tests/windows-installer-render.cpp | 104 +++++++++++++----- 9 files changed, 121 insertions(+), 57 deletions(-) diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index 084720fb4f..a53151b185 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -54,8 +54,8 @@ en: ac31ebdd9de54f34 zh: 921e534d5c4b3a16 /deepseek-harness-desktop/windows-installer-interface: - en: 23c57225f3de2350 - zh: b65b5363791f4c03 + en: 9b90a2651a6d2b52 + zh: 86d4d9c4358aad80 /deepseek-harness-desktop/updates: en: b83a37681e23a3cc zh: e11ab8381fa4362d diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 6154b40e74..d15bb74c36 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -255,7 +255,7 @@ The theme follows Windows at startup; `/THEME=light`, `/THEME=dark`, and `/THEME Windows packaging compiles an x86 Win32/GDI+ helper with Visual C++ Build Tools and a Windows SDK; signed builds sign this helper through the configured Windows signer. The progress page keeps one opaque pixel buffer for its lifetime, draws static artwork once, and copies repainted pixels with GDI. The preparation hook leaves production dependency collection to electron-builder on every platform. -Run `pnpm --dir apps/desktop run test:installer` from the repository root on an interactive Windows x64 desktop to build and exercise a small native test payload through the production installer configuration. Each run uses a unique product identity and sequentially exercises English-only and Chinese-only installer variants, selecting test labels from the displayed welcome button. Both variants install into private directories and uninstall after testing; screenshots and results remain under `.desktop-build/installer-tests/`. The checks include upgrades to registered paths with trailing separators and rejection of drive roots. Native rendering checks cover light/dark palettes at 100% and 200% scale, the painted 100% completion state, caption-button hover/pressed colors and release/cancellation, minimize/restore geometry, and GDI resource release; repaint timings are diagnostics in `render-results.log`, not CI performance limits. Run the generated `ui/render-test.exe [light|dark] [interactive-title]` with a title to inspect the real progress page without installing a payload. The optional `--signed` flag uses the [Windows EV configuration](windows-signing/README.md#signing-configuration) to sign test executables and the helper before embedding them; it does not enable an update feed. +Run `pnpm --dir apps/desktop run test:installer` from the repository root on an interactive Windows x64 desktop to build and exercise a small native test payload through the production installer configuration. Each run uses a unique product identity and sequentially exercises English-only and Chinese-only installer variants, selecting test labels from the displayed welcome button. Both variants install into private directories and uninstall after testing; screenshots and results remain under `.desktop-build/installer-tests/`. The checks include upgrades to registered paths with trailing separators and rejection of drive roots. The renderer receives caption colors from the NSIS theme; pixel checks print into an off-screen bitmap and also run with the progress child hidden. Native rendering checks cover light/dark palettes at 100% and 200% scale, the painted 100% completion state, caption-button hover/pressed colors and release/cancellation, minimize/restore geometry, and GDI resource release; repaint timings are diagnostics in `render-results.log`, not CI performance limits. Run the generated `ui/render-test.exe [interactive-title]` with a title to inspect the real progress page without installing a payload. The optional `--signed` flag uses the [Windows EV configuration](windows-signing/README.md#signing-configuration) to sign test executables and the helper before embedding them; it does not enable an update feed. The Windows uninstaller removes the Electron user-data directory (browser storage and caches under `%APPDATA%`, nested under the package scope), the `%APPDATA%` product directory, and the updater download cache under `%LOCALAPPDATA%` together with the application, then removes ordinary empty scope directories below `%APPDATA%`. The Harness home (`~/.dsh` or `DSH_HOME`: sessions, settings, credentials, plugins) is never touched, and a `DSH_HOME` published as a Windows environment variable additionally protects any target overlapping it. Silent uninstalls remove the same data; an uninstaller started with `--updated` or `/KEEP_APP_DATA`, as electron-builder does for in-place updates and for replacing an older installation from another directory, retains it. Removal runs through the native helper, which refuses protected Windows folders and paths overlapping the installation or the home, requires a fixed local drive, leaves a linked root or ancestor in place, unlinks reparse points without entering their targets, clears read-only attributes, and keeps deleting siblings after a locked file; residue never stops uninstallation and is not reported. Installation records `InstallLocation` on the Windows uninstall entry as standard inventory metadata; on Windows 11, Uninstall in the Start menu context menu opens the installed-apps list for every Win32 application, and only MSIX packages uninstall directly from there. The uninstaller declares DPI awareness and uses Microsoft YaHei UI for Chinese. This behavior is Windows-only. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index e35133ba31..bd2ebf5c30 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -255,7 +255,7 @@ Windows 安装程序使用原生 NSIS 页面,提供亮暗配色、系统阴影 Windows 打包使用 Visual C++ Build Tools 和 Windows SDK 编译 x86 Win32/GDI+ 辅助库;签名构建通过已配置的 Windows 签名器对该库签名。进度页在其生命周期内保留一个不透明像素缓冲,只绘制一次静态图像,并通过 GDI 复制重绘的像素。准备钩子在所有平台上均由 electron-builder 继续负责收集生产依赖。 -在有交互式桌面的 Windows x64 上,从仓库根目录运行 `pnpm --dir apps/desktop run test:installer`,可将小型原生测试载荷接入正式安装配置并执行验证。每次运行使用独立产品身份,依次验证仅英文和仅中文的安装器变体,并根据实际显示的欢迎页按钮选择测试文案。两个变体均安装到私有目录并在测试后卸载;截图和结果保留在 `.desktop-build/installer-tests/` 下。检查包含末尾带分隔符的已登记路径升级,以及磁盘根目录拒绝。原生绘制检查覆盖 100% 和 200% 缩放下的浅色/深色配色、绘制到 100% 的完成状态、标题栏按钮的悬停/按下颜色和松开触发与取消、最小化恢复后的窗口位置和尺寸,以及 GDI 资源释放;`render-results.log` 中的重绘耗时仅供诊断,不作为 CI 性能阈值。运行生成的 `ui/render-test.exe [light|dark] [interactive-title]` 并指定标题,可在不安装载荷的情况下查看真实进度页。可选的 `--signed` 标志使用[Windows EV 配置](windows-signing/README.zh.md#signing-configuration),在嵌入前对测试程序和辅助库签名;它不会启用更新源。 +在有交互式桌面的 Windows x64 上,从仓库根目录运行 `pnpm --dir apps/desktop run test:installer`,可将小型原生测试载荷接入正式安装配置并执行验证。每次运行使用独立产品身份,依次验证仅英文和仅中文的安装器变体,并根据实际显示的欢迎页按钮选择测试文案。两个变体均安装到私有目录并在测试后卸载;截图和结果保留在 `.desktop-build/installer-tests/` 下。检查包含末尾带分隔符的已登记路径升级,以及磁盘根目录拒绝。绘制器接收 NSIS 主题传入的标题栏颜色;像素检查绘制到离屏位图,并在隐藏进度子窗口时验证。原生绘制检查覆盖 100% 和 200% 缩放下的浅色/深色配色、绘制到 100% 的完成状态、标题栏按钮的悬停/按下颜色和松开触发与取消、最小化恢复后的窗口位置和尺寸,以及 GDI 资源释放;`render-results.log` 中的重绘耗时仅供诊断,不作为 CI 性能阈值。运行生成的 `ui/render-test.exe [interactive-title]` 并指定标题,可在不安装载荷的情况下查看真实进度页。可选的 `--signed` 标志使用[Windows EV 配置](windows-signing/README.zh.md#signing-configuration),在嵌入前对测试程序和辅助库签名;它不会启用更新源。 Windows 卸载程序会随应用一起删除 Electron 用户数据目录(`%APPDATA%` 下按包作用域嵌套的浏览器存储与缓存)、`%APPDATA%` 下的产品目录,以及 `%LOCALAPPDATA%` 下的更新下载缓存,随后移除 `%APPDATA%` 之下普通且已空的作用域目录。Harness 主目录(`~/.dsh` 或 `DSH_HOME`:会话、设置、凭据、插件)不会被触碰;以 Windows 环境变量发布的 `DSH_HOME` 还会保护所有与其重叠的目标。静默卸载删除相同的数据;以 `--updated` 或 `/KEEP_APP_DATA` 启动的卸载程序保留数据,electron-builder 在原地更新和从其他目录替换旧安装时正是这样启动它。删除通过原生辅助程序执行:它拒绝受保护的 Windows 目录以及与安装目录或主目录重叠的路径,要求固定的本地驱动器,链接的根目录或祖先目录原样保留,遇到重解析点只解除链接而不进入目标,清除只读属性,并在遇到被占用文件后继续删除其余兄弟项;残余不会中止卸载,也不会提示。安装时在 Windows 卸载注册项上记录 `InstallLocation` 作为标准的清单元数据;在 Windows 11 上,开始菜单右键菜单中的“卸载”对所有 Win32 应用都会打开已安装应用列表,只有 MSIX 包能从那里直接卸载。卸载程序声明 DPI 感知,中文使用微软雅黑 UI。此行为仅适用于 Windows。 diff --git a/apps/desktop/installer/lifecycle.nsh b/apps/desktop/installer/lifecycle.nsh index 525fde4080..521bfe20fe 100644 --- a/apps/desktop/installer/lifecycle.nsh +++ b/apps/desktop/installer/lifecycle.nsh @@ -62,7 +62,7 @@ Function InstallerProgressShow StrCpy $0 1 StrCpy $1 "$PLUGINSDIR\brand-dark-2x.bmp" ${EndIf} - System::Call '$PLUGINSDIR\window-frame.dll::InstallerShowProgress(p $HWNDPARENT, p $mui.InstFilesPage.ProgressBar, i r0, i $InstallerDpi, w r1, w "$(INSTALLER_NATIVE_PROGRESS)", w "$(INSTALLER_PROGRESS_EXTRACT)", w "$(INSTALLER_PROGRESS_COPY)", w "$(INSTALLER_PROGRESS_REGISTER)", w "$(INSTALLER_PROGRESS_CLEAN)") p.s ?c' + System::Call '$PLUGINSDIR\window-frame.dll::InstallerShowProgress(p $HWNDPARENT, p $mui.InstFilesPage.ProgressBar, i r0, i $InstallerDpi, i $InstallerControlHover, i $InstallerControlPressed, w r1, w "$(INSTALLER_NATIVE_PROGRESS)", w "$(INSTALLER_PROGRESS_EXTRACT)", w "$(INSTALLER_PROGRESS_COPY)", w "$(INSTALLER_PROGRESS_REGISTER)", w "$(INSTALLER_PROGRESS_CLEAN)") p.s ?c' Pop $InstallerProgressWindow ${If} $InstallerProgressWindow == 0 MessageBox MB_OK|MB_ICONSTOP "$(INSTALLER_UI_ERROR)" diff --git a/apps/desktop/installer/theme.nsh b/apps/desktop/installer/theme.nsh index ff3b1bbfef..a80c5761b0 100644 --- a/apps/desktop/installer/theme.nsh +++ b/apps/desktop/installer/theme.nsh @@ -22,6 +22,9 @@ !define INSTALLER_PRIMARY_HOVER 0xFF2D3135 !define INSTALLER_PRIMARY_PRESSED 0xFF000000 !define INSTALLER_CONTROL_HOVER 0xFFEEF0F2 +!define INSTALLER_CONTROL_PRESSED 0xFFD4DBE9 +!define INSTALLER_DARK_CONTROL_HOVER 0xFF303034 +!define INSTALLER_DARK_CONTROL_PRESSED 0xFF45454B !define INSTALLER_TRACK_COLOR 0xFFE9ECF2 !define INSTALLER_TEXT_COLORREF 0x15110F @@ -71,8 +74,8 @@ Function InstallerResolveTheme StrCpy $InstallerPrimary 0xFFF9FAFB StrCpy $InstallerPrimaryHover 0xFFE9ECF2 StrCpy $InstallerPrimaryPressed 0xFFD4DBE9 - StrCpy $InstallerControlHover 0xFF303034 - StrCpy $InstallerControlPressed 0xFF45454B + StrCpy $InstallerControlHover ${INSTALLER_DARK_CONTROL_HOVER} + StrCpy $InstallerControlPressed ${INSTALLER_DARK_CONTROL_PRESSED} StrCpy $InstallerTrack 0xFF61666B StrCpy $InstallerButtonText 0x15110F StrCpy $InstallerBorder 0xFF61666B @@ -86,7 +89,7 @@ Function InstallerResolveTheme StrCpy $InstallerPrimaryHover ${INSTALLER_PRIMARY_HOVER} StrCpy $InstallerPrimaryPressed ${INSTALLER_PRIMARY_PRESSED} StrCpy $InstallerControlHover ${INSTALLER_CONTROL_HOVER} - StrCpy $InstallerControlPressed 0xFFD4DBE9 + StrCpy $InstallerControlPressed ${INSTALLER_CONTROL_PRESSED} StrCpy $InstallerTrack ${INSTALLER_TRACK_COLOR} StrCpy $InstallerButtonText 0xFFFFFF StrCpy $InstallerBorder 0xFFBBC0C8 diff --git a/apps/desktop/installer/window-frame.cpp b/apps/desktop/installer/window-frame.cpp index 38dfc7261b..9c9a9e4f81 100644 --- a/apps/desktop/installer/window-frame.cpp +++ b/apps/desktop/installer/window-frame.cpp @@ -119,6 +119,8 @@ struct ProgressPage { HGDIOBJ previousBitmap; UINT pressedCommand; UINT hoveredCommand; + ARGB controlHover; + ARGB controlPressed; WCHAR captions[5][128]; ~ProgressPage() { @@ -173,10 +175,8 @@ static void PaintCaptionButtons(Graphics& graphics, const ProgressPage& page) { for (UINT command : {SC_MINIMIZE, SC_CLOSE}) { const int x = command == SC_MINIMIZE ? 504 : 548; if (page.hoveredCommand == command && (!page.pressedCommand || page.pressedCommand == command)) { - // Match the secondary caption controls in theme.nsh and pages.nsh. const bool pressed = page.pressedCommand == command; - SolidBrush fill(page.dark ? (pressed ? Color(255, 69, 69, 75) : Color(255, 48, 48, 52)) - : (pressed ? Color(255, 212, 219, 233) : Color(255, 238, 240, 242))); + SolidBrush fill(Color(pressed ? page.controlPressed : page.controlHover)); GraphicsPath path; path.AddArc(x, 8, 20, 20, 180, 90); path.AddArc(x + 20, 8, 20, 20, 270, 90); @@ -253,9 +253,11 @@ static LRESULT CALLBACK ProgressProc(HWND window, UINT message, WPARAM wparam, L if (message == WM_CANCELMODE && GetCapture() == window) ReleaseCapture(); return 0; } - if (message == WM_PAINT && page) { - PAINTSTRUCT paint; - HDC dc = BeginPaint(window, &paint); + if ((message == WM_PAINT || message == WM_PRINTCLIENT) && page) { + PAINTSTRUCT paint = {}; + const bool printing = message == WM_PRINTCLIENT; + HDC dc = printing ? reinterpret_cast(wparam) : BeginPaint(window, &paint); + if (printing) GetClientRect(window, &paint.rcPaint); { Graphics graphics(page->buffer); graphics.ScaleTransform(page->dpi / 96.0f, page->dpi / 96.0f); @@ -285,7 +287,7 @@ static LRESULT CALLBACK ProgressProc(HWND window, UINT message, WPARAM wparam, L BitBlt(dc, paint.rcPaint.left, paint.rcPaint.top, paint.rcPaint.right - paint.rcPaint.left, paint.rcPaint.bottom - paint.rcPaint.top, page->bufferDc, paint.rcPaint.left, paint.rcPaint.top, SRCCOPY); - EndPaint(window, &paint); + if (!printing) EndPaint(window, &paint); if (page->progress.value == 100) SetPropW(GetParent(window), L"HarnessInstaller.CompletedPercent", reinterpret_cast(100)); return 0; } @@ -297,8 +299,9 @@ static LRESULT CALLBACK ProgressProc(HWND window, UINT message, WPARAM wparam, L } // Runs on the NSIS UI thread; the stock installation section runs on its worker. +// Caption colors are opaque ARGB from NSIS; failure preserves the stock page's visibility. extern "C" __declspec(dllexport) HWND __cdecl InstallerShowProgress(HWND parent, HWND source, - BOOL dark, UINT dpi, const WCHAR* brand, const WCHAR* preparing, const WCHAR* extracting, + BOOL dark, UINT dpi, ARGB controlHover, ARGB controlPressed, const WCHAR* brand, const WCHAR* preparing, const WCHAR* extracting, const WCHAR* copying, const WCHAR* registering, const WCHAR* cleaning) { HINSTANCE module = GetModuleHandleW(nullptr); WNDCLASSW type = {}; @@ -311,17 +314,14 @@ extern "C" __declspec(dllexport) HWND __cdecl InstallerShowProgress(HWND parent, if (!page) return nullptr; GdiplusStartupInput startup; if (GdiplusStartup(&page->gdiplus, &startup, nullptr) != Ok) { delete page; return nullptr; } - HWND stockPage = GetParent(source); - if (!SetWindowSubclass(stockPage, HiddenPageProc, 1, 0)) { - delete page; return nullptr; - } - ShowWindow(stockPage, SW_HIDE); page->dark = dark != FALSE; page->dpi = dpi; + page->controlHover = controlHover; + page->controlPressed = controlPressed; const WCHAR* captions[] = {preparing, extracting, copying, registering, cleaning}; for (int i = 0; i < 5; ++i) lstrcpynW(page->captions[i], captions[i], 128); page->brand = new Image(brand); - if (page->brand->GetLastStatus() != Ok) { + if (!page->brand || page->brand->GetLastStatus() != Ok) { delete page; return nullptr; } const int size = MulDiv(600, dpi, 96); @@ -340,7 +340,7 @@ extern "C" __declspec(dllexport) HWND __cdecl InstallerShowProgress(HWND parent, if (!page->previousBitmap) { delete page; return nullptr; } // Opaque pixels are shared by GDI+ drawing and the window's GDI blit. page->buffer = new Bitmap(size, size, size * 4, PixelFormat32bppPARGB, static_cast(pixels)); - if (page->buffer->GetLastStatus() != Ok) { delete page; return nullptr; } + if (!page->buffer || page->buffer->GetLastStatus() != Ok) { delete page; return nullptr; } { // Static artwork stays in the buffer for this page's lifetime. Graphics graphics(page->buffer); @@ -354,9 +354,16 @@ extern "C" __declspec(dllexport) HWND __cdecl InstallerShowProgress(HWND parent, } delete page->brand; page->brand = nullptr; - HWND window = CreateWindowExW(0, type.lpszClassName, L"", WS_CHILD | WS_VISIBLE, + HWND window = CreateWindowExW(0, type.lpszClassName, L"", WS_CHILD, 0, 0, MulDiv(600, dpi, 96), MulDiv(600, dpi, 96), parent, nullptr, module, page); - if (!window) delete page; + if (!window) { delete page; return nullptr; } + // Keep the stock page available until the replacement is fully constructed. + HWND stockPage = GetParent(source); + if (!SetWindowSubclass(stockPage, HiddenPageProc, 1, 0)) { + DestroyWindow(window); return nullptr; + } + ShowWindow(stockPage, SW_HIDE); + ShowWindow(window, SW_SHOW); return window; } diff --git a/apps/desktop/scripts/prepare-windows-installer.ps1 b/apps/desktop/scripts/prepare-windows-installer.ps1 index b848f9bef4..5195891033 100644 --- a/apps/desktop/scripts/prepare-windows-installer.ps1 +++ b/apps/desktop/scripts/prepare-windows-installer.ps1 @@ -83,8 +83,4 @@ foreach ($asset in @('brand', 'brand-2x', 'brand-dark', 'brand-dark-2x', 'uninst } finally { $bitmap.Dispose() } } finally { $image.Dispose() } } -if ($TestProgress) { - & $renderExecutable (Join-Path $output 'brand-2x.bmp') 192 - if ($LASTEXITCODE -ne 0) { throw 'Installer rendering regression failed.' } -} Write-Output "Prepared native installer resources: $output" diff --git a/apps/desktop/scripts/test-windows-installer.mjs b/apps/desktop/scripts/test-windows-installer.mjs index c28a1688d9..27c6381a95 100644 --- a/apps/desktop/scripts/test-windows-installer.mjs +++ b/apps/desktop/scripts/test-windows-installer.mjs @@ -72,10 +72,18 @@ try { await execute(progressTest, [], childOptions) await execute(presentationTest, [], childOptions) const renderResults = [] + const themeSource = await readFile(join(appRoot, 'installer', 'theme.nsh'), 'utf8') + const captionColor = (name) => { + const match = themeSource.match(new RegExp(`^!define ${name} (0x[0-9A-Fa-f]{8})\\r?$`, 'm')) + if (!match) throw new Error(`Missing installer caption color: ${name}`) + return match[1] + } for (const dpi of [96, 192]) { for (const theme of ['light', 'dark']) { const brand = theme === 'dark' ? 'brand-dark-2x.bmp' : 'brand-2x.bmp' - const result = await execute(renderTest, [join(output, 'ui', brand), String(dpi), theme], childOptions) + const prefix = theme === 'dark' ? 'INSTALLER_DARK_CONTROL' : 'INSTALLER_CONTROL' + const result = await execute(renderTest, [join(output, 'ui', brand), String(dpi), theme, + captionColor(`${prefix}_HOVER`), captionColor(`${prefix}_PRESSED`)], childOptions) renderResults.push(result.stdout) } } diff --git a/apps/desktop/tests/windows-installer-render.cpp b/apps/desktop/tests/windows-installer-render.cpp index 19c8a1662c..8d9d8ef91e 100644 --- a/apps/desktop/tests/windows-installer-render.cpp +++ b/apps/desktop/tests/windows-installer-render.cpp @@ -9,7 +9,7 @@ extern "C" __declspec(dllimport) HRESULT __cdecl InstallerApplyFrame(HWND window); extern "C" __declspec(dllimport) HWND __cdecl InstallerShowProgress(HWND parent, HWND source, - BOOL dark, UINT dpi, const WCHAR* brand, const WCHAR* preparing, const WCHAR* extracting, + BOOL dark, UINT dpi, DWORD controlHover, DWORD controlPressed, const WCHAR* brand, const WCHAR* preparing, const WCHAR* extracting, const WCHAR* copying, const WCHAR* registering, const WCHAR* cleaning); extern "C" __declspec(dllimport) BOOL __cdecl InstallerFinishProgress(HWND window); @@ -19,14 +19,20 @@ static LRESULT CALLBACK WindowProc(HWND window, UINT message, WPARAM wparam, LPA } int wmain(int argc, wchar_t** argv) { - if (argc < 3) { - std::fprintf(stderr, "Usage: render-test.exe [light|dark] [interactive-title]\n"); + if (argc < 6) { + std::fprintf(stderr, "Usage: render-test.exe [interactive-title]\n"); return 1; } const int dpi = _wtoi(argv[2]); if (dpi < 96 || dpi > 384) return 2; - const bool dark = argc > 3 && wcscmp(argv[3], L"dark") == 0; - const bool interactive = argc > 4; + const bool dark = wcscmp(argv[3], L"dark") == 0; + if (!dark && wcscmp(argv[3], L"light") != 0) return 2; + wchar_t* end = nullptr; + const DWORD hoverArgb = wcstoul(argv[4], &end, 0); + if (!*argv[4] || *end || (hoverArgb >> 24) != 255) return 2; + const DWORD pressedArgb = wcstoul(argv[5], &end, 0); + if (!*argv[5] || *end || (pressedArgb >> 24) != 255) return 2; + const bool interactive = argc > 6; SetProcessDPIAware(); WNDCLASSW type = {}; type.lpfnWndProc = WindowProc; @@ -34,19 +40,30 @@ int wmain(int argc, wchar_t** argv) { type.lpszClassName = L"HarnessInstallerRenderTest"; type.hCursor = LoadCursorW(nullptr, IDC_ARROW); if (!RegisterClassW(&type)) return 3; - HWND window = CreateWindowW(type.lpszClassName, interactive ? argv[4] : L"Installer rendering test", + HWND window = CreateWindowW(type.lpszClassName, interactive ? argv[6] : L"Installer rendering test", WS_POPUP | WS_SYSMENU | WS_MINIMIZEBOX, 100, 100, MulDiv(600, dpi, 96), MulDiv(600, dpi, 96), nullptr, nullptr, type.hInstance, nullptr); if (!window || FAILED(InstallerApplyFrame(window))) return 4; - HWND stock = CreateWindowW(L"STATIC", L"", WS_CHILD, 0, 0, 1, 1, window, nullptr, type.hInstance, nullptr); + HWND stock = CreateWindowW(L"STATIC", L"", WS_CHILD | WS_VISIBLE, 0, 0, 1, 1, window, nullptr, type.hInstance, nullptr); HWND source = CreateWindowW(L"STATIC", L"", WS_CHILD, 0, 0, 1, 1, stock, nullptr, type.hInstance, nullptr); SetPropW(window, L"HarnessInstaller.Stage", reinterpret_cast(1)); SetPropW(window, L"HarnessInstaller.ExtractProgress", reinterpret_cast(50)); const auto createProgress = [&]() { - return InstallerShowProgress(window, source, dark, static_cast(dpi), argv[1], + return InstallerShowProgress(window, source, dark, static_cast(dpi), hoverArgb, pressedArgb, argv[1], L"Preparing %d%%", L"Extracting %d%%", L"Copying %d%%", L"Registering %d%%", L"Cleaning %d%%"); }; ShowWindow(window, SW_SHOWNOACTIVATE); + bool clientFrame = true; + for (int x : {8, 300, 580}) { + POINT point{MulDiv(x, dpi, 96), MulDiv(24, dpi, 96)}; + ClientToScreen(window, &point); + clientFrame = SendMessageW(window, WM_NCHITTEST, 0, MAKELPARAM(point.x, point.y)) == HTCLIENT && clientFrame; + } + HWND failed = InstallerShowProgress(window, source, dark, static_cast(dpi), hoverArgb, pressedArgb, L"", + L"Preparing %d%%", L"Extracting %d%%", L"Copying %d%%", L"Registering %d%%", L"Cleaning %d%%"); + ShowWindow(stock, SW_SHOW); + const bool failurePreservesStock = !failed && IsWindowVisible(stock); + if (failed) DestroyWindow(failed); // Warm retained font and window-animation resources before checking page-owned GDI objects. HWND warmup = createProgress(); if (!warmup) { DestroyWindow(window); return 5; } @@ -70,33 +87,64 @@ int wmain(int argc, wchar_t** argv) { } RECT originalBounds, restoredBounds; GetWindowRect(window, &originalBounds); - const auto captionPixel = [&](int x) { - UpdateWindow(progress); - HDC dc = GetDC(progress); + const auto captionPixel = [&](int x) -> COLORREF { + // Print into a private bitmap: visible-window clipping must not affect pixel assertions. + HDC dc = CreateCompatibleDC(nullptr); + BITMAPINFO info = {}; + info.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); + info.bmiHeader.biWidth = MulDiv(600, dpi, 96); + info.bmiHeader.biHeight = -MulDiv(600, dpi, 96); + info.bmiHeader.biPlanes = 1; + info.bmiHeader.biBitCount = 32; + void* pixels = nullptr; + HBITMAP bitmap = CreateDIBSection(nullptr, &info, DIB_RGB_COLORS, &pixels, nullptr, 0); + if (!dc || !bitmap) { + if (dc) DeleteDC(dc); + if (bitmap) DeleteObject(bitmap); + return CLR_INVALID; + } + HGDIOBJ previous = SelectObject(dc, bitmap); + if (!previous) { DeleteObject(bitmap); DeleteDC(dc); return CLR_INVALID; } + SendMessageW(progress, WM_PRINTCLIENT, reinterpret_cast(dc), PRF_CLIENT); const COLORREF pixel = GetPixel(dc, MulDiv(x + 8, dpi, 96), MulDiv(24, dpi, 96)); - ReleaseDC(progress, dc); + SelectObject(dc, previous); + DeleteObject(bitmap); + DeleteDC(dc); return pixel; }; const COLORREF normalColor = dark ? RGB(21, 21, 23) : RGB(255, 255, 255); - const COLORREF hoverColor = dark ? RGB(48, 48, 52) : RGB(238, 240, 242); - const COLORREF pressedColor = dark ? RGB(69, 69, 75) : RGB(212, 219, 233); + const auto colorref = [](DWORD argb) { return RGB((argb >> 16) & 255, (argb >> 8) & 255, argb & 255); }; + const COLORREF hoverColor = colorref(hoverArgb); + const COLORREF pressedColor = colorref(pressedArgb); + const auto expectPixel = [&](int x, COLORREF expected) { + const COLORREF actual = captionPixel(x); + if (actual != CLR_INVALID && actual == expected) return true; + std::fprintf(stderr, "caption pixel x=%d dpi=%d expected=0x%08lx actual=0x%08lx\n", x, dpi, expected, actual); + return false; + }; bool captionStates = true; + ShowWindow(progress, SW_HIDE); for (int x : {504, 548}) { const LPARAM point = MAKELPARAM(MulDiv(x + 20, dpi, 96), MulDiv(24, dpi, 96)); - captionStates = captionStates && captionPixel(x) == normalColor; + captionStates = expectPixel(x, normalColor) && captionStates; SendMessageW(progress, WM_MOUSEMOVE, 0, point); - captionStates = captionStates && captionPixel(x) == hoverColor; + captionStates = expectPixel(x, hoverColor) && captionStates; SendMessageW(progress, WM_LBUTTONDOWN, MK_LBUTTON, point); - captionStates = captionStates && captionPixel(x) == pressedColor; + captionStates = expectPixel(x, pressedColor) && captionStates; + const int other = x == 504 ? 548 : 504; + SendMessageW(progress, WM_MOUSEMOVE, MK_LBUTTON, MAKELPARAM(MulDiv(other + 20, dpi, 96), MulDiv(24, dpi, 96))); + captionStates = expectPixel(other, normalColor) && captionStates; + captionStates = expectPixel(x, normalColor) && captionStates; SendMessageW(progress, WM_MOUSEMOVE, MK_LBUTTON, MAKELPARAM(-1, -1)); - captionStates = captionStates && captionPixel(x) == normalColor; + captionStates = expectPixel(x, normalColor) && captionStates; SendMessageW(progress, WM_MOUSEMOVE, MK_LBUTTON, point); - captionStates = captionStates && captionPixel(x) == pressedColor; + captionStates = expectPixel(x, pressedColor) && captionStates; SendMessageW(progress, WM_CANCELMODE, 0, 0); - captionStates = captionStates && captionPixel(x) == hoverColor; + captionStates = expectPixel(x, hoverColor) && captionStates; SendMessageW(progress, WM_MOUSELEAVE, 0, 0); - captionStates = captionStates && captionPixel(x) == normalColor; + captionStates = expectPixel(x, normalColor) && captionStates; } + ShowWindow(progress, SW_SHOW); const LPARAM minimizePoint = MAKELPARAM(MulDiv(524, dpi, 96), MulDiv(24, dpi, 96)); SendMessageW(progress, WM_LBUTTONDOWN, MK_LBUTTON, minimizePoint); bool buttonRelease = !IsIconic(window) && GetCapture() == progress; @@ -129,7 +177,8 @@ int wmain(int argc, wchar_t** argv) { QueryPerformanceFrequency(&frequency); std::vector samples; // Full repaint is the exposure/restore path; fixture startup is excluded. - for (int frame = 0; frame < 300; ++frame) { + constexpr int frameCount = 300; + for (int frame = 0; frame < frameCount; ++frame) { InvalidateRect(progress, nullptr, FALSE); QueryPerformanceCounter(&started); UpdateWindow(progress); @@ -144,8 +193,9 @@ int wmain(int argc, wchar_t** argv) { } std::printf("]\n"); std::sort(samples.begin(), samples.end()); - std::printf("dpi=%d dark=%d frames=300 mean_ms=%.3f p50_ms=%.3f p95_ms=%.3f max_ms=%.3f\n", - dpi, dark, total / samples.size(), samples[150], samples[285], samples.back()); + std::printf("fixture=standalone-progress dpi=%d dark=%d frames=%d screen=%dx%d foreground=%d mean_ms=%.3f p50_ms=%.3f p95_ms=%.3f max_ms=%.3f\n", + dpi, dark, frameCount, GetSystemMetrics(SM_CXSCREEN), GetSystemMetrics(SM_CYSCREEN), GetForegroundWindow() == window, + total / samples.size(), samples[samples.size() / 2], samples[samples.size() * 95 / 100], samples.back()); const bool completed = InstallerFinishProgress(progress) && GetPropW(window, L"HarnessInstaller.CompletedPercent") == reinterpret_cast(100); WCHAR caption[160] = {}; @@ -154,7 +204,7 @@ int wmain(int argc, wchar_t** argv) { DestroyWindow(progress); const DWORD finalGdi = GetGuiResources(GetCurrentProcess(), GR_GDIOBJECTS); DestroyWindow(window); - std::printf("completed=%d final_caption=%d caption_states=%d button_release=%d close_release=%d minimized=%d restored=%d gdi_before=%lu gdi_after=%lu\n", - completed, finalCaption, captionStates, buttonRelease, closeRelease, minimized, restored, initialGdi, finalGdi); - return completed && finalCaption && captionStates && buttonRelease && closeRelease && minimized && restored && finalGdi <= initialGdi ? 0 : 6; + std::printf("client_frame=%d stock_preserved=%d completed=%d final_caption=%d caption_states=%d button_release=%d close_release=%d minimized=%d restored=%d gdi_before=%lu gdi_after=%lu\n", + clientFrame, failurePreservesStock, completed, finalCaption, captionStates, buttonRelease, closeRelease, minimized, restored, initialGdi, finalGdi); + return clientFrame && failurePreservesStock && completed && finalCaption && captionStates && buttonRelease && closeRelease && minimized && restored && finalGdi <= initialGdi ? 0 : 6; } From 54eed06c6f96fe3dcc91607056586e786a6f4f49 Mon Sep 17 00:00:00 2001 From: zhc Date: Mon, 19 Oct 2026 14:15:21 +0800 Subject: [PATCH 10/12] feat(gui): support image panning and original-file drag sharing --- apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 2 + apps/desktop/README.zh.md | 2 + apps/desktop/src/image-drag-files.ts | 96 ++++++++++++++ apps/desktop/src/image-drag.ts | 74 +++++++++++ apps/desktop/src/ipc.ts | 8 ++ apps/desktop/src/main.ts | 14 ++- apps/desktop/src/preload-app.ts | 7 ++ apps/desktop/tests/image-drag-files.spec.ts | 48 +++++++ apps/desktop/tests/image-drag.spec.ts | 88 +++++++++++++ apps/desktop/tests/main-startup.spec.ts | 4 +- apps/desktop/tests/preload-app.spec.ts | 16 +++ apps/web/tests/document-preview.e2e.ts | 23 +++- apps/web/tests/markdown-images.e2e.ts | 9 ++ .../client/ui-attachment/README.i18n.yaml | 4 +- packages/client/ui-attachment/README.md | 2 + packages/client/ui-attachment/README.zh.md | 2 + .../client/ui-attachment/src/MessageImage.tsx | 8 +- .../src/client/ComposerAttachments.tsx | 7 +- .../ui-attachment/src/client/drop-events.ts | 8 +- .../client/ui-attachment/src/client/labels.ts | 1 + .../tests/message-image.client.spec.tsx | 1 + .../ui-chat/src/client/chat/ChatView.tsx | 2 +- packages/client/ui-chat/src/client/locale.ts | 2 + .../src/client/contract/slots.ts | 2 + .../ui-conversation/src/client/locales.ts | 2 + packages/client/ui-layout/package.json | 3 +- .../client/ui-layout/src/client/AppFrame.tsx | 5 +- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 + packages/client/ui-primitives/README.zh.md | 2 + .../ui-primitives/src/ImageDragProvider.tsx | 60 +++++++++ .../client/ui-primitives/src/image-drag.ts | 110 ++++++++++++++++ packages/client/ui-primitives/src/index.ts | 3 + .../src/markdown/MarkdownDelegate.tsx | 2 +- .../ui-primitives/src/markdown/render.tsx | 6 +- .../tests/image-drag.client.spec.tsx | 119 ++++++++++++++++++ .../markdown-image-entries.client.spec.tsx | 2 +- .../README.i18n.yaml | 4 +- .../ui-sidebar-documentpreview/README.md | 2 +- .../ui-sidebar-documentpreview/README.zh.md | 2 +- .../src/client/image/ImageBody.module.css | 3 + .../src/client/image/ImageBody.tsx | 25 +++- .../src/client/image/locales.ts | 2 + .../src/client/image/pan.ts | 78 ++++++++++++ .../tests/image-body.client.spec.tsx | 4 +- .../tests/image-pan.client.spec.ts | 84 +++++++++++++ .../src/client/slot-catalog.ts | 54 ++++---- pnpm-lock.yaml | 3 + .../web/document-preview/document.expected.md | 2 + 50 files changed, 954 insertions(+), 63 deletions(-) create mode 100644 apps/desktop/src/image-drag-files.ts create mode 100644 apps/desktop/src/image-drag.ts create mode 100644 apps/desktop/tests/image-drag-files.spec.ts create mode 100644 apps/desktop/tests/image-drag.spec.ts create mode 100644 packages/client/ui-primitives/src/ImageDragProvider.tsx create mode 100644 packages/client/ui-primitives/src/image-drag.ts create mode 100644 packages/client/ui-primitives/tests/image-drag.client.spec.tsx create mode 100644 packages/client/ui-sidebar-documentpreview/src/client/image/pan.ts create mode 100644 packages/client/ui-sidebar-documentpreview/tests/image-pan.client.spec.ts diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index a46786a046..89ac4aea9f 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -33,8 +33,8 @@ en: 372297005b57a08b zh: 074a635ac17e4362 /deepseek-harness-desktop/installation-ownership: - en: 22153a298a6662e1 - zh: 3600803c8c9d0274 + en: fffd3892a309aeae + zh: 4127b7f511c70e75 /deepseek-harness-desktop/installation-ownership/runtime-and-plugin-activation: en: a1a47b09f4a72ba4 zh: 7b1cf27f0b72cf31 diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 14bfd38f14..34f0946ec2 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -142,6 +142,8 @@ Electron owns `$DSH_HOME/profiles/desktop`. Its `dependencies` contains packages The application preload exposes boot readiness, fatal startup reporting, native directory selection, the `__DSH_HOST_PATHS__` bridge for composer path references, and the lease-scoped Browser bridge only to `dsh-app://app` documents. The same preload forwards the main-process machine description from `dshDesktop.deviceInfo()` as `name=value` fields separated by `; `: `platform`, `os`, `app_arch` (the architecture the application binary runs as, which differs from the hardware architecture under emulation), `cpu`, and `memory_gib` (total physical memory in GiB with one decimal). A field is omitted when its source is unavailable. The description carries no hostname, user name, or serial number. Product documents use the shared authenticated HTTP APIs and receive the Desktop marker, update presentation, and an action that opens native confirmation; they cannot choose artifacts or authorize installation. Plugin management uses the Web application's authenticated HTTP APIs. Electron serves update-dialog documents and assets locally at `dsh-app://shell/`, independently of Host readiness. Electron exposes no plugin-management IPC or separate management document. No renderer receives filesystem access, raw Electron IPC, a shell, or arbitrary pnpm arguments. +The image-drag bridge accepts original PNG, JPEG, GIF, WebP, BMP, ICO, or SVG bytes up to 64 MiB from the owned application main frame. It prepares a private temporary file and icon before `startDrag`, exposes only an opaque handle, and never accepts renderer-supplied filesystem paths. Unused preparations are released with their source image; files handed to the OS remain readable until application exit because receivers may read them after the drag returns. Shutdown removes the temporary files. Native drag errors are reported once by the application’s localized image feedback. + Only the main application window enables ``. Guest attachment must match a main-issued lease and partition; guests keep sandbox, context isolation and Web security without Node integration or guest preload. Browser IPC listeners are created only for the application document. [Sidebar Browser](../../packages/client/ui-sidebar-browser/README.md) describes storage grouping and guest limitations; Host authentication remains required independently of URL filtering. The `dsh-app://shell/` origin serves packaged update documents, scripts, and styles without contacting the Host. Static requests retain GET/HEAD, path-containment, and MIME handling; each update document keeps its isolated preload and owned-window IPC checks. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 64cc5f7b22..7f26bd720a 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -142,6 +142,8 @@ Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm 应用 preload 只向 `dsh-app://app` 文档暴露启动就绪、致命启动失败上报、原生目录选择、用于 composer 路径引用的 `__DSH_HOST_PATHS__` 桥接和租约范围内的 Browser 桥接。同一个 preload 通过 `dshDesktop.deviceInfo()` 转发主进程采集的机器描述,按 `name=value` 字段以 `; ` 分隔:`platform`、`os`、`app_arch`(应用二进制实际运行的架构,模拟运行时与硬件架构不同)、`cpu` 和 `memory_gib`(物理内存总量,GiB,保留一位小数)。取值不可用时省略对应字段。该描述不包含主机名、用户名或序列号。产品页面还获得 Desktop 标记、更新展示数据和打开原生确认的操作,不能选择安装产物或授权安装。插件管理使用 Web 应用经过认证的 HTTP API;Electron 在 `dsh-app://shell/` 本地提供更新弹窗文档和资源,不依赖 Host 就绪。Electron 不提供插件管理 IPC 或独立管理页面。任何渲染进程都不会获得文件系统访问、原始 Electron IPC、shell 或任意 pnpm 参数。 +图片拖放桥接只接受主应用所属主框架传入的 PNG、JPEG、GIF、WebP、BMP、ICO 或 SVG 原图字节,上限为 64 MiB。桥接在 `startDrag` 前准备私有临时文件和图标,只暴露不透明句柄,绝不接受渲染进程指定的文件系统路径。未使用的准备结果随来源图片释放;已交给操作系统的文件保留到应用退出,因为接收方可能在拖动返回后继续读取。退出时删除临时文件。原生拖放错误由应用的本地化图片反馈统一报告一次。 + 只有主应用窗口启用 ``。guest 挂载必须匹配主进程签发的租约和分区;guest 保持 sandbox、context isolation 和 Web security,不启用 Node integration 或 guest preload。Browser IPC 监听只为应用文档创建。[Sidebar Browser](../../packages/client/ui-sidebar-browser/README.zh.md) 说明存储分组和 guest 限制;Host 鉴权仍独立于 URL 过滤而必需。 `dsh-app://shell/` 无需联系 Host 即可提供打包的更新文档、脚本和样式。静态请求保留 GET/HEAD、路径范围和 MIME 处理;每个更新文档继续使用隔离 preload 和所属窗口的 IPC 校验。 diff --git a/apps/desktop/src/image-drag-files.ts b/apps/desktop/src/image-drag-files.ts new file mode 100644 index 0000000000..df01eeaad2 --- /dev/null +++ b/apps/desktop/src/image-drag-files.ts @@ -0,0 +1,96 @@ +/** Process-owned original image files staged for native copy drags. */ +import { randomUUID } from 'node:crypto' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' + +const IMAGE_EXTENSIONS: Readonly> = { + 'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp', + 'image/bmp': 'bmp', 'image/x-icon': 'ico', 'image/vnd.microsoft.icon': 'ico', 'image/svg+xml': 'svg', +} +// Limits the memory and temporary storage accepted from one renderer IPC request. +const MAX_IMAGE_BYTES = 64 * 1024 * 1024 + +interface ImageDragFile { + readonly directory: string + readonly path: string + used: boolean +} + +/** Original files belong to the application; handed-off files remain readable until app shutdown. */ +export class ImageDragFiles { + private readonly files = new Map() + private readonly pending = new Set>() + private disposed = false + + /** @param temporaryDirectory - OS temporary directory in which private export directories are allocated. */ + constructor(private readonly temporaryDirectory: string) {} + + /** + * Validate renderer bytes and stage one original file without accepting renderer filesystem paths. + * @param bytes - complete image bytes. + * @param name - suggested filename; directory characters and platform-reserved characters are removed. + * @param mediaType - supported image MIME type, which owns the output suffix. + * @returns application-local opaque handle; failures retain no usable handle or partial file. + */ + prepare(bytes: unknown, name: unknown, mediaType: unknown): Promise { + const extension = typeof mediaType === 'string' && Object.hasOwn(IMAGE_EXTENSIONS, mediaType) + ? IMAGE_EXTENSIONS[mediaType] : undefined + if (this.disposed || !(bytes instanceof Uint8Array) || bytes.byteLength === 0 || bytes.byteLength > MAX_IMAGE_BYTES + || typeof name !== 'string' || typeof mediaType !== 'string' || extension === undefined) { + return Promise.reject(new Error('Invalid image transfer')) + } + const stem = name.replace(/\.[^.]*$/u, '').replace(/[<>:"/\\|?*\x00-\x1f]/gu, '_').replace(/^[. ]+|[. ]+$/gu, '').slice(0, 120) || 'image' + const operation = this.write(bytes, `${stem}.${extension}`) + this.pending.add(operation) + void operation.then(() => { this.pending.delete(operation) }, () => { this.pending.delete(operation) }) + return operation + } + + private async write(bytes: Uint8Array, name: string): Promise { + const directory = await mkdtemp(join(this.temporaryDirectory, 'dsh-image-drag-')) + try { + const path = join(directory, name) + await writeFile(path, bytes, { flag: 'wx', mode: 0o600 }) + if (this.disposed) throw new Error('Image transfer closed') + const handle = randomUUID() + this.files.set(handle, { directory, path, used: false }) + return handle + } catch (error) { + await rm(directory, { recursive: true, force: true }) + throw error + } + } + + /** + * Resolve and retain a prepared file for an OS-owned drag. + * @param handle - handle created by this application's preparation operation. + * @param retain - whether the OS is taking ownership; false only during preparation. + * @returns original file path, or undefined after release/disposal or for a foreign handle. + */ + take(handle: unknown, retain = true): string | undefined { + const file = typeof handle === 'string' ? this.files.get(handle) : undefined + if (file === undefined || this.disposed) return undefined + if (retain) file.used = true + return file.path + } + + /** + * Release a preparation that no longer has a mounted image consumer. + * @param handle - application-local handle; OS-used files stay until disposal. + */ + async release(handle: unknown): Promise { + const file = typeof handle === 'string' ? this.files.get(handle) : undefined + if (file === undefined || file.used) return + this.files.delete(handle as string) + await rm(file.directory, { recursive: true, force: true }) + } + + /** Stop preparation, await pending writes, and remove all owned files after native drags finish. */ + async dispose(): Promise { + this.disposed = true + await Promise.allSettled(this.pending) + const files = [...this.files.values()] + this.files.clear() + await Promise.all(files.map(file => rm(file.directory, { recursive: true, force: true }))) + } +} diff --git a/apps/desktop/src/image-drag.ts b/apps/desktop/src/image-drag.ts new file mode 100644 index 0000000000..da549f1108 --- /dev/null +++ b/apps/desktop/src/image-drag.ts @@ -0,0 +1,74 @@ +/** Native copy drags authorized only for the owning application window. */ +import { app, ipcMain, nativeImage, type BrowserWindow, type IpcMainInvokeEvent, type NativeImage } from 'electron' +import { DESKTOP_IPC, assertDesktopSender } from './ipc.ts' +import { ImageDragFiles } from './image-drag-files.ts' + +/** + * Register native image preparation and drag operations for the application window. + * @param getWindow - current application window; subframes and other windows are rejected. + * @returns cleanup that waits for preparation and removes all exported temporary files. + */ +export function installDesktopImageDrag(getWindow: () => BrowserWindow | undefined): () => Promise { + let disposed = false + const files = new ImageDragFiles(app.getPath('temp')) + const pending = new Set>() + const icons = new Map() + const owner = (event: IpcMainInvokeEvent): BrowserWindow => { + const window = getWindow() + if (disposed || window === undefined || window.isDestroyed() || event.sender !== window.webContents + || event.senderFrame !== window.webContents.mainFrame) throw new Error('Unowned image drag request') + assertDesktopSender(event, ['app']) + return window + } + const prepare = async (event: IpcMainInvokeEvent, bytes: unknown, name: unknown, mediaType: unknown): Promise => { + owner(event) + const handle = await files.prepare(bytes, name, mediaType) + try { + const file = files.take(handle, false) + if (file === undefined) throw new Error('Image transfer closed') + let icon = nativeImage.createFromPath(file) + if (icon.isEmpty()) icon = await app.getFileIcon(file, { size: 'normal' }) + else icon = icon.resize({ width: 64 }) + owner(event) + icons.set(handle, icon) + return handle + } catch (error) { + await files.release(handle) + throw error + } + } + ipcMain.handle(DESKTOP_IPC.imageDragPrepare, (event, bytes: unknown, name: unknown, mediaType: unknown) => { + const operation = prepare(event, bytes, name, mediaType) + pending.add(operation) + void operation.then(() => { pending.delete(operation) }, () => { pending.delete(operation) }) + return operation + }) + ipcMain.handle(DESKTOP_IPC.imageDragStart, (event, handle: unknown) => { + const window = owner(event) + const icon = typeof handle === 'string' ? icons.get(handle) : undefined + if (icon === undefined) return false + const file = files.take(handle) + if (file === undefined) return false + try { + window.webContents.startDrag({ file, icon }) + return true + } catch (_error) { + // The renderer reports one localized failure; no filesystem paths cross IPC. + return false + } + }) + ipcMain.handle(DESKTOP_IPC.imageDragRelease, async (event, handle: unknown) => { + owner(event) + if (typeof handle === 'string') icons.delete(handle) + await files.release(handle) + }) + return async () => { + disposed = true + ipcMain.removeHandler(DESKTOP_IPC.imageDragPrepare) + ipcMain.removeHandler(DESKTOP_IPC.imageDragStart) + ipcMain.removeHandler(DESKTOP_IPC.imageDragRelease) + await Promise.allSettled(pending) + icons.clear() + await files.dispose() + } +} diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index fc19021fa6..30218cc211 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -21,6 +21,9 @@ export const DESKTOP_IPC = { browserRelease: 'dsh-desktop:browser-release', browserOpenRequested: 'dsh-desktop:browser-open-requested', directoryPick: 'dsh-desktop:directory-pick', + imageDragPrepare: 'dsh-desktop:image-drag-prepare', + imageDragStart: 'dsh-desktop:image-drag-start', + imageDragRelease: 'dsh-desktop:image-drag-release', deviceInfo: 'dsh-desktop:device-info', localeBootstrap: 'dsh-desktop:locale-bootstrap', localeChanged: 'dsh-desktop:locale-changed', @@ -72,6 +75,11 @@ export interface DesktopUpdatePresentation { export interface DshDesktopProductApi { readonly protocolVersion: 1 readonly browser: DesktopBrowserBridge + readonly imageDrag: { + prepare(bytes: Uint8Array, name: string, mediaType: string): Promise + start(handle: string): Promise + release(handle: string): void + } readonly keyboard: DesktopKeyboardApi readonly shortcuts: DesktopShortcutsApi /** diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 2afdfd7194..cd93618175 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -26,6 +26,7 @@ import { resolveDesktopPaths } from './paths.ts' import { DesktopProjectManager } from './project-manager.ts' import { DesktopHostFatalError, DesktopHostProcess, DesktopHostUncleanExitError } from './host-process.ts' import { DesktopPlatformView, PLATFORM_IPC, platformBounds } from './platform-view.ts' +import { installDesktopImageDrag } from './image-drag.ts' import { installDesktopDirectoryPicker } from './directory-picker.ts' import { installMicrophonePermissions } from './microphone-permissions.ts' import { DesktopBackendController } from './backend-controller.ts' @@ -682,6 +683,9 @@ async function main(): Promise { }) installDesktopDirectoryPicker(() => mainWindow) + const disposeImageDrag = installDesktopImageDrag(() => mainWindow) + let installerImageCleanup: Promise | undefined + let installerImagesReleased = false installMicrophonePermissions(session.defaultSession, () => mainWindow?.webContents) const shortcuts = installDesktopShortcuts(() => mainWindow, app.getPath('userData'), process.platform === 'darwin' ? 'macos' : process.platform === 'win32' ? 'windows' : 'linux', () => { refreshApplicationMenu() }, window => updateOverlays.input(window)) @@ -1260,10 +1264,18 @@ async function main(): Promise { mandatoryUI?.dispose() void Promise.all([Promise.resolve(mandatoryPolicy?.dispose()).then(() => policyAuth?.dispose()), backend.close(), // A Platform cleanup failure is logged without cutting the remaining Host shutdown short. - platformView.dispose().catch((error: unknown) => { console.error(error) })]) + platformView.dispose().catch((error: unknown) => { console.error(error) }), disposeImageDrag()]) .catch((error: unknown) => { console.error(error) }).finally(() => { app.quit() }) } app.on('before-quit', (event) => { + if (shellInstallerOwnsQuit && !installerImagesReleased) { + event.preventDefault() + installerImageCleanup ??= disposeImageDrag().catch((error: unknown) => { console.error(error) }).then(() => { + installerImagesReleased = true + app.quit() + }) + return + } if (shellInstallerOwnsQuit) { shuttingDown = true quitConfirmation.dispose() diff --git a/apps/desktop/src/preload-app.ts b/apps/desktop/src/preload-app.ts index f590b8bc8b..0e7f4c0fc9 100644 --- a/apps/desktop/src/preload-app.ts +++ b/apps/desktop/src/preload-app.ts @@ -14,6 +14,13 @@ function createProductApi(): DshDesktopProductApi { return { protocolVersion: 1, browser: createDesktopBrowserBridge(), + imageDrag: { + prepare: (bytes, name, mediaType) => ipcRenderer.invoke(DESKTOP_IPC.imageDragPrepare, bytes, name, mediaType) as Promise, + start: handle => ipcRenderer.invoke(DESKTOP_IPC.imageDragStart, handle) as Promise, + release: (handle) => { void ipcRenderer.invoke(DESKTOP_IPC.imageDragRelease, handle).catch((_error: unknown) => { + // Main-process shutdown also releases every prepared file. + }) }, + }, deviceInfo: () => ipcRenderer.invoke(DESKTOP_IPC.deviceInfo) as Promise, keyboard: { closeWindow: revision => ipcRenderer.invoke(DESKTOP_IPC.shortcutsCloseWindow, revision) as Promise, diff --git a/apps/desktop/tests/image-drag-files.spec.ts b/apps/desktop/tests/image-drag-files.spec.ts new file mode 100644 index 0000000000..f231cea286 --- /dev/null +++ b/apps/desktop/tests/image-drag-files.spec.ts @@ -0,0 +1,48 @@ +import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { basename, join } from 'node:path' +import { expect, it, onTestFinished } from 'vitest' +import { ImageDragFiles } from '../src/image-drag-files.ts' + +async function files() { + const root = await mkdtemp(join(tmpdir(), 'dsh-drag-test-')) + const store = new ImageDragFiles(root) + onTestFinished(async () => { await store.dispose(); await rm(root, { recursive: true, force: true }) }) + return { root, store } +} + +it('exports original bytes and keeps a used file readable until disposal', async () => { + const { root, store } = await files() + const bytes = new Uint8Array([137, 80, 78, 71]) + const handle = await store.prepare(bytes, '../../original.png', 'image/png') + const path = store.take(handle)! + expect(basename(path)).toBe('_.._original.png') + expect(new Uint8Array(await readFile(path))).toEqual(bytes) + await store.release(handle) + expect(new Uint8Array(await readFile(path))).toEqual(bytes) + await store.dispose() + expect(await readdir(root)).toEqual([]) + expect(store.take(handle)).toBeUndefined() +}) + +it('rejects non-images, foreign handles and writes after disposal', async () => { + const { store } = await files() + await expect(store.prepare(new Uint8Array([1]), 'x.html', 'text/html')).rejects.toThrow('Invalid image transfer') + await expect(store.prepare(new Uint8Array(), 'x.png', 'image/png')).rejects.toThrow('Invalid image transfer') + await expect(store.prepare(new Uint8Array([1]), 'x.png', 'constructor')).rejects.toThrow('Invalid image transfer') + expect(store.take('foreign')).toBeUndefined() + await store.dispose() + await expect(store.prepare(new Uint8Array([1]), 'x.png', 'image/png')).rejects.toThrow('Invalid image transfer') +}) + +it('releases unused preparations and waits for a concurrent write before cleanup', async () => { + const { root, store } = await files() + const handle = await store.prepare(new Uint8Array([1]), 'x.png', 'image/png') + await store.release(handle) + expect(await readdir(root)).toEqual([]) + const pending = store.prepare(new Uint8Array([2]), 'y.png', 'image/png') + const rejected = expect(pending).rejects.toThrow('Image transfer closed') + await store.dispose() + await rejected + expect(await readdir(root)).toEqual([]) +}) diff --git a/apps/desktop/tests/image-drag.spec.ts b/apps/desktop/tests/image-drag.spec.ts new file mode 100644 index 0000000000..d9460fb02c --- /dev/null +++ b/apps/desktop/tests/image-drag.spec.ts @@ -0,0 +1,88 @@ +import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, onTestFinished, vi } from 'vitest' +import type { BrowserWindow, IpcMainInvokeEvent, NativeImage, WebContents, WebFrameMain } from 'electron' +import { DESKTOP_IPC } from '../src/ipc.ts' + +const electron = vi.hoisted(() => ({ + handle: vi.fn(), removeHandler: vi.fn(), getPath: vi.fn(), getFileIcon: vi.fn(), createFromPath: vi.fn(), +})) +vi.mock('electron', () => ({ + app: electron, + ipcMain: electron, + nativeImage: { createFromPath: electron.createFromPath }, +})) +const { installDesktopImageDrag } = await import('../src/image-drag.ts') +afterEach(() => { vi.resetAllMocks() }) + +type Handler = (event: IpcMainInvokeEvent, ...args: unknown[]) => unknown +async function fixture() { + const root = await mkdtemp(join(tmpdir(), 'dsh-native-image-drag-')) + onTestFinished(async () => { await rm(root, { recursive: true, force: true }) }) + electron.getPath.mockReturnValue(root) + const icon: NativeImage = Object.assign({} as NativeImage, { isEmpty: () => false, resize: () => icon }) + electron.createFromPath.mockReturnValue(icon) + const frame = Object.assign({} as WebFrameMain, { url: 'dsh-app://app/' }) + const window = Object.assign({} as BrowserWindow, { isDestroyed: () => false, + webContents: Object.assign({} as WebContents, { mainFrame: frame, startDrag: vi.fn() }) }) + let current: BrowserWindow | undefined = window + const dispose = installDesktopImageDrag(() => current) + onTestFinished(dispose) + const handlers = new Map(electron.handle.mock.calls.map(([channel, handler]) => [channel, handler])) + const event = Object.assign({} as IpcMainInvokeEvent, { sender: window.webContents, senderFrame: frame }) + const call = (channel: string, ...args: unknown[]) => (handlers.get(channel) as Handler)(event, ...args) + return { root, window, frame, event, icon, call, dispose, detach: () => { current = undefined } } +} + +it('stages original bytes before the gesture and synchronously starts an OS copy drag', async () => { + const f = await fixture() + const bytes = new Uint8Array([137, 80, 78, 71]) + const handle = await f.call(DESKTOP_IPC.imageDragPrepare, bytes, 'original.png', 'image/png') + expect(f.window.webContents.startDrag).not.toHaveBeenCalled() + expect(f.call(DESKTOP_IPC.imageDragStart, handle)).toBe(true) + const [request] = f.window.webContents.startDrag.mock.calls[0] as [{ file: string; icon: NativeImage }] + expect(request.icon).toBe(f.icon) + expect(new Uint8Array(await readFile(request.file))).toEqual(bytes) + await f.call(DESKTOP_IPC.imageDragRelease, handle) + expect(new Uint8Array(await readFile(request.file))).toEqual(bytes) + expect(f.call(DESKTOP_IPC.imageDragStart, handle)).toBe(false) + await f.dispose() + expect(await readdir(f.root)).toEqual([]) +}) + +it('rejects foreign senders, subframes, revoked handles and native failures', async () => { + const f = await fixture() + expect(f.call(DESKTOP_IPC.imageDragStart, 'foreign')).toBe(false) + const handle = await f.call(DESKTOP_IPC.imageDragPrepare, new Uint8Array([1]), 'x.png', 'image/png') + f.window.webContents.startDrag.mockImplementationOnce(() => { throw new Error('OS rejected drag') }) + expect(f.call(DESKTOP_IPC.imageDragStart, handle)).toBe(false) + f.frame.url = 'https://other.test/' + expect(() => f.call(DESKTOP_IPC.imageDragStart, handle)).toThrow('unowned renderer') + f.frame.url = 'dsh-app://app/' + Object.assign(f.event, { senderFrame: { url: 'dsh-app://app/' } }) + expect(() => f.call(DESKTOP_IPC.imageDragStart, handle)).toThrow('Unowned image drag request') + Object.assign(f.event, { senderFrame: f.frame, sender: {} }) + expect(() => f.call(DESKTOP_IPC.imageDragStart, handle)).toThrow('Unowned image drag request') + f.detach() + expect(() => f.call(DESKTOP_IPC.imageDragStart, handle)).toThrow('Unowned image drag request') +}) + +it('prepares the fallback icon before accepting a gesture and cancels late work on shutdown', async () => { + const f = await fixture() + electron.createFromPath.mockReturnValue({ isEmpty: () => true }) + const pending = Promise.withResolvers() + electron.getFileIcon.mockReturnValue(pending.promise) + const prepare = f.call(DESKTOP_IPC.imageDragPrepare, new Uint8Array([1]), 'x.svg', 'image/svg+xml') + const rejected = expect(prepare).rejects.toThrow('Unowned image drag request') + await vi.waitFor(() => { expect(electron.getFileIcon).toHaveBeenCalledOnce() }) + let stopped = false + const stopping = f.dispose().then(() => { stopped = true }) + await Promise.resolve() + expect(stopped).toBe(false) + pending.resolve(f.icon) + await rejected + await stopping + expect(f.window.webContents.startDrag).not.toHaveBeenCalled() + expect(await readdir(f.root)).toEqual([]) +}) diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index d9a8f3103b..5e60f0a3e6 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -1870,7 +1870,7 @@ describe('desktop main startup', () => { harness.app.quit() await harness.quitCompleted.promise expect(modal.isDestroyed()).toBe(true) - expect(harness.app.quit).toHaveBeenCalledTimes(2) + expect(harness.app.quit).toHaveBeenCalledTimes(3) // The installer owns the exit, so Platform cleanup starts without holding the quit open. expect(harness.platformDispose).toHaveBeenCalledOnce() inspected.resolve({ activeTasks: true, scheduledTasks: true }) @@ -1878,7 +1878,7 @@ describe('desktop main startup', () => { await vi.advanceTimersByTimeAsync(0) expect(harness.dialog.showMessageBox).toHaveBeenCalledTimes(pendingPhase === 'dialog' ? 1 : 0) expect(harness.platformDispose).toHaveBeenCalledOnce() - expect(harness.app.quit).toHaveBeenCalledTimes(2) + expect(harness.app.quit).toHaveBeenCalledTimes(3) disposal.resolve() }) diff --git a/apps/desktop/tests/preload-app.spec.ts b/apps/desktop/tests/preload-app.spec.ts index e238f583c1..b16b6e2341 100644 --- a/apps/desktop/tests/preload-app.spec.ts +++ b/apps/desktop/tests/preload-app.spec.ts @@ -254,3 +254,19 @@ it('forwards browser guest input only for the focused live webview lease', async expect(listener).toHaveBeenCalledOnce() off() }) + +it('prepares original image bytes and starts native drags without renderer filesystem paths', async () => { + vi.stubGlobal('location', new URL('dsh-app://app/')) + await import('../src/preload-app.ts') + const api = electron.contextBridge.exposeInMainWorld.mock.calls.find(([name]) => name === 'dshDesktop')?.[1] as DshDesktopProductApi + const bytes = new Uint8Array([137, 80, 78, 71]) + electron.ipcRenderer.invoke.mockResolvedValueOnce('image-handle').mockResolvedValueOnce(true).mockResolvedValueOnce(undefined) + await expect(api.imageDrag.prepare(bytes, 'original.png', 'image/png')).resolves.toBe('image-handle') + await expect(api.imageDrag.start('image-handle')).resolves.toBe(true) + api.imageDrag.release('image-handle') + expect(electron.ipcRenderer.invoke.mock.calls).toEqual([ + [DESKTOP_IPC.imageDragPrepare, bytes, 'original.png', 'image/png'], + [DESKTOP_IPC.imageDragStart, 'image-handle'], + [DESKTOP_IPC.imageDragRelease, 'image-handle'], + ]) +}) diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index 9953e112cc..c7326999f7 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -1082,7 +1082,7 @@ else process.exit(1); width: (node as HTMLImageElement).naturalWidth, height: (node as HTMLImageElement).naturalHeight, draggable: (node as HTMLImageElement).draggable, - }))).toEqual({ width: 1, height: 1, draggable: false }) + }))).toEqual({ width: 1, height: 1, draggable: true }) const centering = await tinyImage.evaluate((node) => { const image = node.getBoundingClientRect() const scroller = node.closest('[data-textpreview-body]')?.getBoundingClientRect() @@ -1105,6 +1105,14 @@ else process.exit(1); await page.getByRole('menuitem', { name: 'Fit width', exact: true }).click() await expect.poll(() => imageZoom.innerText()).toBe('100%') + await tinyImage.dragTo(input) + const draggedPreview = page.getByRole('group', { name: 'Pending attachments', exact: true }).getByRole('img') + await draggedPreview.waitFor({ state: 'visible', timeout: 5000 }) + const draggedBytes = await draggedPreview.evaluate(async (node: HTMLImageElement) => + Array.from(new Uint8Array(await (await fetch(node.src)).arrayBuffer()))) + expect(Buffer.from(draggedBytes)).toEqual(TINY_PNG) + expect(await input.innerText()).toBe('') + await page.getByRole('button', { name: 'Remove image tiny.png', exact: true }).click() await openFile('large.svg') await expect.poll(() => viewer.innerText()).toBe('Image') await viewer.click() @@ -1143,6 +1151,17 @@ else process.exit(1); await page.getByRole('menuitem', { name: '100%', exact: true }).click() await expect.poll(async () => (await largeImage.boundingBox())!.width).toBeCloseTo(1200, 0) expect(await imageScrollport.evaluate(node => node.scrollWidth > node.clientWidth)).toBe(true) + await imageScrollport.evaluate((node) => { node.scrollLeft = 0; node.scrollTop = 0 }) + const panBox = await imageScrollport.boundingBox() + if (panBox === null) throw new Error('image viewport missing') + await expect.poll(() => largeImage.evaluate(node => getComputedStyle(node).cursor)).toBe('grab') + await page.mouse.move(panBox.x + 180, panBox.y + 180) + await page.mouse.down() + await page.mouse.move(panBox.x + 100, panBox.y + 100, { steps: 8 }) + await expect.poll(() => imageScrollport.evaluate(node => [node.scrollLeft, node.scrollTop])).toEqual([80, 80]) + await expect.poll(() => largeImage.evaluate(node => getComputedStyle(node).cursor)).toBe('grabbing') + await page.mouse.up() + await expect.poll(() => largeImage.evaluate(node => getComputedStyle(node).cursor)).toBe('grab') svgZoom = await revealDocumentZoom(page, preview) await svgZoom.click() await page.getByRole('menuitem', { name: 'Fit width', exact: true }).click() @@ -1151,9 +1170,11 @@ else process.exit(1); expect(await page.locator('html').getAttribute('data-image-preview-escape')).toBeNull() sections.push([ '## Image zoom', '', + '- Contained sidebar PNG drags into the composer as an original-byte image attachment', '- Small PNG fit width remains at intrinsic size; 200% doubles it', '- SVG fit width -> 100% -> fit width toggles horizontal overflow: false -> true -> false', '- Image and Blob identities remain stable while zoom changes', + '- Overflowing image drag pans both axes by 80px; releasing restores the grab cursor', ].join('\n')) const releaseRead = Promise.withResolvers() diff --git a/apps/web/tests/markdown-images.e2e.ts b/apps/web/tests/markdown-images.e2e.ts index db3cad441a..d521b59dce 100644 --- a/apps/web/tests/markdown-images.e2e.ts +++ b/apps/web/tests/markdown-images.e2e.ts @@ -319,6 +319,15 @@ describe('web e2e: Markdown image rendering', () => { expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md']) + const input = page.locator('[data-composer-input]').first() + await workspaceImage.dragTo(input) + const dragged = page.getByRole('group', { name: 'Pending attachments', exact: true }).getByRole('img') + await dragged.waitFor({ state: 'visible' }) + expect(Buffer.from(await dragged.evaluate(async (node: HTMLImageElement) => + Array.from(new Uint8Array(await (await fetch(node.src)).arrayBuffer()))))).toEqual(PNG) + expect(await input.innerText()).toBe('') + expect(await page.getByRole('dialog', { name: 'Image preview', exact: true }).count()).toBe(0) + await page.getByRole('button', { name: 'Remove image valid.png', exact: true }).click() await link.evaluate((element) => { element.scrollIntoView({ block: 'end' }) }) await page.mouse.move(0, 0) await link.hover() diff --git a/packages/client/ui-attachment/README.i18n.yaml b/packages/client/ui-attachment/README.i18n.yaml index 123b686b4e..bac5a4d946 100644 --- a/packages/client/ui-attachment/README.i18n.yaml +++ b/packages/client/ui-attachment/README.i18n.yaml @@ -21,8 +21,8 @@ en: a77c8ec48e219aec zh: 756d4fe881403006 /deepseek-ai-dsh-client-ui-attachment/use-this-package/message-images-and-the-lightbox: - en: 8b3d7729152d8060 - zh: 815f932ccc9653f5 + en: 10518a60d83df5c9 + zh: f50541b6662ab72b /deepseek-ai-dsh-client-ui-attachment/use-this-package/drop-overlay: en: 836a62c8f73366ac zh: 35da7836eaf68a67 diff --git a/packages/client/ui-attachment/README.md b/packages/client/ui-attachment/README.md index 4fee29e123..330be4a286 100644 --- a/packages/client/ui-attachment/README.md +++ b/packages/client/ui-attachment/README.md @@ -37,6 +37,8 @@ In Chat, one user message presents files and images in a right-aligned wrapping Trajectory attachment rows request 48px square thumbnails that contain the complete image without cropping. Loading and retry icons keep the same box, with localized tooltips and accessible names; the image opens the same lightbox. A slot owner may supply a presentation-only image label for the thumbnail and lightbox without changing the durable reference or the cache lookup. +Loaded conversation images can be dragged into the composer as image attachments or to another application as original files. Dragging does not open the lightbox. The shared image-transfer owner retains Web drag bytes until the gesture ends; Desktop uses the system file drag. A failed preparation or drag keeps the image visible and reports a localized retry message. + ### Drop overlay While a file drag is over the page, the full-viewport overlay announces the drop: illustration, title, and a limits line when drops are accepted. The overlay only shows state — the owner's document-level listeners decide accept or reject, and the drop handler reports which dropped members are directories through the entry API so the owner can cite or refuse them. diff --git a/packages/client/ui-attachment/README.zh.md b/packages/client/ui-attachment/README.zh.md index 706a6d97c7..3a4d79a336 100644 --- a/packages/client/ui-attachment/README.zh.md +++ b/packages/client/ui-attachment/README.zh.md @@ -37,6 +37,8 @@ Chat 中的一条用户消息把文件与图片放在同一个靠右、可换行 Trajectory 附件行使用 48px 方形缩略图,完整缩放图片而不裁剪。加载与重试图标保持相同尺寸,提供本地化的工具提示和可访问名称;图片打开同一个灯箱。插槽持有方可以为缩略图与灯箱提供仅用于展示的图片名称,而不改变持久化引用或缓存查询。 +已加载的会话图片可拖入输入框作为图片附件,也可将原文件拖到其他应用。拖动不会打开灯箱。共享图片传递组件将 Web 拖动的原图字节保留到手势结束;Desktop 使用系统文件拖放。准备或拖放失败时保留图片,并显示本地化的重试提示。 + ### 拖放遮罩 文件拖到页面上方时,全视口遮罩显示拖放提示,包括插画和标题;接受拖放时还会显示一行限制说明。遮罩只呈现状态,是否接受由持有方的文档级监听器决定,拖放处理器通过 entry API 报告放下的成员中哪些是文件夹,以便持有方引用或拒绝它们。 diff --git a/packages/client/ui-attachment/src/MessageImage.tsx b/packages/client/ui-attachment/src/MessageImage.tsx index a067ffd446..62afb5b279 100644 --- a/packages/client/ui-attachment/src/MessageImage.tsx +++ b/packages/client/ui-attachment/src/MessageImage.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' -import { IconLoadingOutlineRegular, IconRefreshOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconLoadingOutlineRegular, IconRefreshOutlineRegular, useImageDrag } from '@deepseek-ai/dsh-client-ui-primitives' import { ImageLightbox } from '@deepseek-ai/dsh-client-ui-primitives' import type { MessageImageLabels } from '@deepseek-ai/dsh-client-ui-conversation/client' import css from './MessageImage.module.css' @@ -108,6 +108,7 @@ export function MessageImage({ image, load, variant, labels }: { }, [attachment, load, attempt]) const src = preview?.url ?? loaded + const drag = useImageDrag(src ?? '', preview?.name ?? attachment?.name ?? 'image', labels.dragFailed) const label = ('attachment' in image ? image.label : undefined) ?? preview?.name ?? attachment?.name ?? labels.image const loadingThumbnail = variant === 'thumbnail' && src === null @@ -135,6 +136,7 @@ export function MessageImage({ image, load, variant, labels }: { title={loadingThumbnail ? labels.loading : labels.open} aria-label={loadingThumbnail ? labels.loading : labels.openNamed(label)} aria-busy={loadingThumbnail || undefined} + onClickCapture={drag.onClickCapture} onClick={() => { if (src !== null) setOpen(true) }} > {src === null @@ -143,7 +145,9 @@ export function MessageImage({ image, load, variant, labels }: { {loadingThumbnail ? : labels.loading} ) - : {label}} + : {label}} {open && src !== null && } diff --git a/packages/client/ui-attachment/src/client/ComposerAttachments.tsx b/packages/client/ui-attachment/src/client/ComposerAttachments.tsx index 0065410b9d..e172778335 100644 --- a/packages/client/ui-attachment/src/client/ComposerAttachments.tsx +++ b/packages/client/ui-attachment/src/client/ComposerAttachments.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { ComposerAttachment, ComposerAttachmentsProps, ComposerImageAttachment, } from '@deepseek-ai/dsh-client-ui-conversation/client' -import { IconCloseFillRegular } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconCloseFillRegular, useImageDrop } from '@deepseek-ai/dsh-client-ui-primitives' import { AttachmentRail } from '../AttachmentRail.tsx' import type { AttachmentRailItem } from '../AttachmentRail.tsx' import { DropOverlay } from '../DropOverlay.tsx' @@ -23,6 +23,7 @@ export function ComposerAttachments({ }: ComposerAttachmentsProps) { const [preview, setPreview] = useState(null) const [dragActive, setDragActive] = useState(false) + const readImageDrag = useImageDrop() const dragDepth = useRef(0) const closePreview = useCallback(() => { setPreview(null) }, []) useEffect(() => { @@ -30,8 +31,8 @@ export function ComposerAttachments({ }, [attachments, preview]) useEffect(() => { - return installDocumentDropEvents(canAcceptDrop, onAddFiles, dragDepth, setDragActive) - }, [canAcceptDrop, onAddFiles]) + return installDocumentDropEvents(canAcceptDrop, onAddFiles, dragDepth, setDragActive, readImageDrag) + }, [canAcceptDrop, onAddFiles, readImageDrag]) const railItems = useMemo(() => attachments.map(attachment => ({ id: attachment.id, diff --git a/packages/client/ui-attachment/src/client/drop-events.ts b/packages/client/ui-attachment/src/client/drop-events.ts index 2f3da69372..938cd3bada 100644 --- a/packages/client/ui-attachment/src/client/drop-events.ts +++ b/packages/client/ui-attachment/src/client/drop-events.ts @@ -1,4 +1,5 @@ /** Document drag-and-drop listeners owned by one mounted attachment view. */ +import { IMAGE_DRAG_TYPE } from '@deepseek-ai/dsh-client-ui-primitives' import type { ComposerAttachmentsProps } from '@deepseek-ai/dsh-client-ui-conversation/client' /** @@ -25,6 +26,7 @@ function droppedDirectories(dataTransfer: DataTransfer, files: readonly File[]): * @param onAddFiles - attachment intake callback. * @param dragDepth - the view's retained nested-drag counter. * @param setDragActive - publish whether a file drag is active. + * @param readImageDrag - resolve an original image retained by this application during a browser drag. * @returns cleanup for exactly these listeners. */ export function installDocumentDropEvents( @@ -32,10 +34,11 @@ export function installDocumentDropEvents( onAddFiles: ComposerAttachmentsProps['onAddFiles'], dragDepth: { current: number }, setDragActive: (active: boolean) => void, + readImageDrag: (transfer: DataTransfer) => File | undefined, ): () => void { const fileTransfer = (event: globalThis.DragEvent): DataTransfer | null => { const dataTransfer = event.dataTransfer - if (dataTransfer === null || !dataTransfer.types.includes('Files')) return null + if (dataTransfer === null || (!dataTransfer.types.includes('Files') && !dataTransfer.types.includes(IMAGE_DRAG_TYPE))) return null return dataTransfer } const reset = (): void => { @@ -68,7 +71,8 @@ export function installDocumentDropEvents( event.preventDefault() reset() if (canAcceptDrop) { - const files = [...dataTransfer.files] + const image = dataTransfer.types.includes(IMAGE_DRAG_TYPE) ? readImageDrag(dataTransfer) : undefined + const files = image === undefined ? [...dataTransfer.files] : [image] onAddFiles(files, droppedDirectories(dataTransfer, files)) } } diff --git a/packages/client/ui-attachment/src/client/labels.ts b/packages/client/ui-attachment/src/client/labels.ts index 257a362d97..6e684ed6b0 100644 --- a/packages/client/ui-attachment/src/client/labels.ts +++ b/packages/client/ui-attachment/src/client/labels.ts @@ -21,6 +21,7 @@ export function lightboxLabels(t: TranslateNS<'conversation'>): ImageLightboxLab */ export function messageImageLabels(t: TranslateNS<'conversation'>): MessageImageLabels { return { + dragFailed: t('image.dragFailed'), image: t('image.label'), open: t('image.openOriginal'), openNamed: label => t('image.openOriginalLabel', { label }), diff --git a/packages/client/ui-attachment/tests/message-image.client.spec.tsx b/packages/client/ui-attachment/tests/message-image.client.spec.tsx index d447df5fd9..b55c65ecfc 100644 --- a/packages/client/ui-attachment/tests/message-image.client.spec.tsx +++ b/packages/client/ui-attachment/tests/message-image.client.spec.tsx @@ -18,6 +18,7 @@ afterEach(cleanup) const labels: MessageImageLabels = { image: '图片', + dragFailed: '图片尚未准备好拖出,请重试', open: '查看原图', openNamed: label => `${label},点击查看原图`, loading: '图片加载中…', diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index 1e0741f31f..6c38f26abd 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -86,7 +86,7 @@ export function ChatView({ const fileImages = useMemo(() => ({ resolve: (path: string) => fileMediaUrl(document.baseURI, resolveWorkspacePath(cwd, path)), labels: { - open: t('image.open'), loading: t('image.loading'), failed: t('image.failed'), + dragFailed: t('image.dragFailed'), open: t('image.open'), loading: t('image.loading'), failed: t('image.failed'), dialog: t('image.dialog'), close: t('image.close'), }, }), [cwd, t]) diff --git a/packages/client/ui-chat/src/client/locale.ts b/packages/client/ui-chat/src/client/locale.ts index 7a950132e2..095f62dd68 100644 --- a/packages/client/ui-chat/src/client/locale.ts +++ b/packages/client/ui-chat/src/client/locale.ts @@ -67,6 +67,7 @@ export const zh = { 'view.chat': '对话', 'image.open': '查看大图', 'image.loading': '加载图片…', + 'image.dragFailed': '图片尚未准备好拖出,请重试', 'image.failed': '图片无法预览', 'image.dialog': '图片预览', 'image.close': '关闭图片预览', @@ -269,6 +270,7 @@ export const en = { 'view.chat': 'Chat', 'image.open': 'View full image', 'image.loading': 'Loading image…', + 'image.dragFailed': 'The image is not ready to drag. Try again.', 'image.failed': 'Image preview unavailable', 'image.dialog': 'Image preview', 'image.close': 'Close image preview', diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index a02f5aa662..a5b99922eb 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -131,6 +131,8 @@ export interface MessageImageLabels { loading: string /** Retry-control label shown when the load fails. */ loadFailed: string + /** Failed original-image drag, with retry guidance. */ + dragFailed: string /** Lightbox strings forwarded to the opened preview. */ lightbox: ImageLightboxLabels } diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index f1f708073b..d7e432fdd6 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -46,6 +46,7 @@ export const zh = { 'image.label': '图片', 'image.loadFailed': '图片加载失败,点击重试', 'image.loading': '图片加载中…', + 'image.dragFailed': '图片尚未准备好拖出,请重试', 'image.preview': '原图预览', 'image.closePreview': '关闭原图预览', 'image.unsupportedType': '仅支持 PNG、JPG、WebP、GIF 格式的图片', @@ -426,6 +427,7 @@ export const en = { 'image.label': 'Image', 'image.loadFailed': 'Image failed to load; click to retry', 'image.loading': 'Loading image…', + 'image.dragFailed': 'The image is not ready to drag. Try again.', 'image.preview': 'Original image preview', 'image.closePreview': 'Close original image preview', 'image.unsupportedType': 'Only PNG, JPG, WebP, and GIF images are supported', diff --git a/packages/client/ui-layout/package.json b/packages/client/ui-layout/package.json index a233d3410a..ce216ab52f 100644 --- a/packages/client/ui-layout/package.json +++ b/packages/client/ui-layout/package.json @@ -56,7 +56,8 @@ "@deepseek-ai/dsh-client-ui-theme": "workspace:*", "@deepseek-ai/cordis": "workspace:~", "react": "^18.2.0", - "@deepseek-ai/dsh-client-shortcuts": "workspace:*" + "@deepseek-ai/dsh-client-shortcuts": "workspace:*", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:*" }, "files": [ "locale/*.json", diff --git a/packages/client/ui-layout/src/client/AppFrame.tsx b/packages/client/ui-layout/src/client/AppFrame.tsx index 5614947447..2a076acb14 100644 --- a/packages/client/ui-layout/src/client/AppFrame.tsx +++ b/packages/client/ui-layout/src/client/AppFrame.tsx @@ -20,6 +20,7 @@ import type { PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, } from '@deepseek-ai/dsh-client-ui-slots' import { CENTER_MIN, clampWidth, computeColumns, RIGHTBAR_DEFAULT_RATIO, RIGHTBAR_MAX_RATIO, RIGHTBAR_MIN, SIDEBAR_AUTO_COLLAPSE, SIDEBAR_COLLAPSED, SIDEBAR_DEFAULT } from './columns.ts' +import { ImageDragProvider } from '@deepseek-ai/dsh-client-ui-primitives' import { DocumentTitle } from './DocumentTitle.tsx' import type { createLayoutStore } from './stores.ts' import css from './AppFrame.module.css' @@ -256,7 +257,7 @@ export function AppFrame({ const leadingMounted = darwin && sidebarCollapsed return ( -
0 && ( )} -
+
) } diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 2e59aaf715..4599e98497 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -24,8 +24,8 @@ en: 28ac8f0b1edbcf81 zh: 5c4c324bf84a282f /deepseek-ai-dsh-client-ui-primitives/use-this-package/rendering-agent-output: - en: 12fe9d9b1410edef - zh: f2dfdc01170d7cce + en: 40a6ad9b8840aaf2 + zh: 2997fa3df59a4ec8 /deepseek-ai-dsh-client-ui-primitives/use-this-package/localizing-copy: en: 4aef65dea11de083 zh: abc22cd7c65cdf38 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 5b29187be1..f65d3900c3 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -111,6 +111,8 @@ The nearest `MarkdownDelegateProvider` supplies optional `openExternalLink` and `ImageLightbox` is the shared original-image modal with focus restoration and Escape dismissal. Like `Modal`, its mask leaves the Windows caption unpainted, and its close control sits 20px below the caption. The internal thumbnail renderer receives loading/failure labels from its owner. `HoverCard.inline` keeps a file link in the text flow and uses the shared menu material, keyboard-visible focus, and placement above or below the anchor without covering it. Escape closes an open thumbnail even when focus is elsewhere; subsequent Escape presses reach the owner. `MarkdownDelegateProvider.fileImages` supplies a decoded-path resolver and complete image labels: settled image links gain hover previews, and standalone images gain lightbox activation. Image-only anchors retain their single navigation target. The parser recovers only complete, unescaped, standalone local image references with bare spaces and an unambiguous supported image suffix; code and ambiguous destinations remain literal. +`ImageDragProvider` wraps the application and retains the active Web image transfer and failure toast. `useImageDrag` prepares original bytes from an authorized source or supplied `File`, cancels stale preparation, and uses the Desktop bridge when present. Web transfers carry an application-local token plus Chromium’s `DownloadURL`; `useImageDrop` resolves only the active token to its original `File`. External browser drops depend on the receiving application and browser support. No drag places an internal URL or alt text in `text/plain`. + ### Localizing copy The atoms cannot read the application locale, so every piece of user-facing copy arrives through required label props. `HoverCard`, `TerminalBlock`, `JsonTree`, `CodeBlock`, `MarkdownText`, `JsonBlock`, `ConnectionIndicator`, `Modal`, `DiffBlock`, `ReadBlock`, `SearchBlock`, and `WebBlock` accept complete localized labels. The package owns no language fallback; omission fails typechecking, and each feature maps its typed `t` seat into the primitive's label interface. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index c1593e273b..fac88aa250 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -111,6 +111,8 @@ kind: "package-library" `ImageLightbox` 是共享原图浮层,支持焦点恢复与 Esc 关闭。与 `Modal` 相同,其遮罩不覆盖 Windows 顶栏,关闭按钮位于顶栏下方 20px。包内缩略图渲染器由调用方提供加载及失败文案。`HoverCard.inline` 使文件链接保持在文本流内,并使用共享菜单材质、键盘可见焦点,并在锚点上方或下方定位而不遮挡锚点。即使焦点位于其他位置,Esc 也会关闭已打开的缩略图;后续 Esc 按键继续传给 owner。`MarkdownDelegateProvider.fileImages` 提供已解码路径解析器与完整图片文案:消息落定后的图片链接支持悬停预览,独立图片支持点击放大。仅包含图片的链接保留单一导航目标。解析器仅恢复完整、未转义、独立成段且带明确图片扩展名的含裸空格本地图片引用;代码与有歧义的目标保持原文。 +`ImageDragProvider` 包裹应用,持有当前 Web 图片传递状态和失败提示。`useImageDrag` 从已授权来源或传入的 `File` 准备原图字节,取消过期准备,并在 Desktop 桥接存在时使用它。Web 拖放携带应用内令牌和 Chromium 的 `DownloadURL`;`useImageDrop` 仅将当前令牌解析为原始 `File`。浏览器向外部拖放的结果取决于浏览器和接收应用的支持。拖放不会将内部 URL 或 alt 文本放入 `text/plain`。 + ### 本地化文案 这些原子组件无法读取应用 locale,因此每段面向用户的文案都必须通过 label prop 提供。`HoverCard`、`TerminalBlock`、`JsonTree`、`CodeBlock`、`MarkdownText`、`JsonBlock`、`ConnectionIndicator`、`Modal`、`DiffBlock`、`ReadBlock`、`SearchBlock` 与 `WebBlock` 接收完整的本地化 label。本包不拥有语言回退;遗漏会导致类型检查失败,各功能会把带类型的 `t` 席位映射到 primitive 的 label 接口。 diff --git a/packages/client/ui-primitives/src/ImageDragProvider.tsx b/packages/client/ui-primitives/src/ImageDragProvider.tsx new file mode 100644 index 0000000000..f6c2cece0a --- /dev/null +++ b/packages/client/ui-primitives/src/ImageDragProvider.tsx @@ -0,0 +1,60 @@ +/** Application-lifetime feedback for image drag operations. */ +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' +import { Toast } from './Toast.tsx' + +/** Browser drag token; file bytes remain in the owning mounted application. */ +export const IMAGE_DRAG_TYPE = 'application/x-dsh-image' + +interface ImageTransfer { + report: (text: string) => void + begin: (file: File) => string + end: () => void + read: (transfer: DataTransfer) => File | undefined +} + +const missingProvider = (): never => { throw new Error('Image dragging requires ImageDragProvider') } +const ImageDragFeedback = createContext({ + report: missingProvider, begin: missingProvider, end: missingProvider, read: missingProvider, +}) + +/** + * Keep drag failures visible when their source image or conversation unmounts. + * @param props - application children. + * @returns children and the current localized failure toast. + */ +export function ImageDragProvider({ children }: { readonly children: ReactNode }): ReactNode { + const active = useRef<{ token: string; file: File }>() + const sequence = useRef(0) + const [failure, setFailure] = useState<{ text: string; sequence: number }>() + const report = useCallback((text: string) => { setFailure({ text, sequence: ++sequence.current }) }, []) + const dismiss = useCallback(() => { setFailure(undefined) }, []) + const begin = useCallback((file: File): string => { + const token = Array.from(crypto.getRandomValues(new Uint8Array(16)), byte => byte.toString(16).padStart(2, '0')).join('') + active.current = { token, file } + return token + }, []) + const end = useCallback(() => { active.current = undefined }, []) + useEffect(() => { + window.addEventListener('dragend', end) + return () => { window.removeEventListener('dragend', end) } + }, [end]) + const read = useCallback((transfer: DataTransfer): File | undefined => { + const current = active.current + return current !== undefined && current.token === transfer.getData(IMAGE_DRAG_TYPE) ? current.file : undefined + }, []) + const value = useMemo(() => ({ report, begin, end, read }), [report, begin, end, read]) + return + {children} + {failure !== undefined && } + +} + +/** @returns the mounted application's image transfer owner. */ +export function useImageDragTransfer(): ImageTransfer { + return useContext(ImageDragFeedback) +} + +/** @returns a reader that resolves only this application's active original-image drag. */ +export function useImageDrop(): (transfer: DataTransfer) => File | undefined { + return useImageDragTransfer().read +} diff --git a/packages/client/ui-primitives/src/image-drag.ts b/packages/client/ui-primitives/src/image-drag.ts new file mode 100644 index 0000000000..80db3219bf --- /dev/null +++ b/packages/client/ui-primitives/src/image-drag.ts @@ -0,0 +1,110 @@ +/** Original image files supplied to browser and native drag sessions. */ +import { useCallback, useEffect, useRef, type DragEvent, type MouseEvent, type PointerEvent } from 'react' + +import { IMAGE_DRAG_TYPE, useImageDragTransfer } from './ImageDragProvider.tsx' + +/** Application-owned native image transfer; prepared handles never expose host paths. */ +export interface DesktopImageDrag { + /** @returns an opaque handle for the original file, retained until release or application exit. */ + prepare: (bytes: Uint8Array, name: string, mediaType: string) => Promise + /** Start a system copy drag from an already prepared handle. */ + start: (handle: string) => Promise + /** Release an unused handle; files already handed to the OS survive until application exit. */ + release: (handle: string) => void +} + +interface PreparedImage { + readonly file: File + readonly handle?: string +} + +interface ImageDragHandlers { + onPointerEnter: () => void + onPointerDown: (event: PointerEvent) => void + onDragStart: (event: DragEvent) => void + onDragEnd: () => void + onClickCapture: (event: MouseEvent) => void +} + +/** + * Prepare original bytes on hover and supply them synchronously when dragging begins. + * @param src - authorized image URL; changing it retires pending preparation. + * @param name - suggested original filename, without a directory. + * @param failureText - localized failure message, including a retry action. + * @param original - already loaded original file; omitted sources are read from their image URL. + * @returns drag handlers; cancellation never starts a late drag. + */ +export function useImageDrag(src: string, name: string, failureText: string, original?: File): ImageDragHandlers { + const desktop = (globalThis as typeof globalThis & { dshDesktop?: { imageDrag?: DesktopImageDrag } }).dshDesktop?.imageDrag + const state = useRef<{ source: string; prepared?: PreparedImage; pending?: Promise | undefined; abort: AbortController }>() + const dragged = useRef(false) + const transfer = useImageDragTransfer() + const reportFailure = transfer.report + const prepare = useCallback((): void => { + const current = state.current + if (src === '' || current === undefined || current.source !== src || current.pending !== undefined || current.prepared !== undefined) return + current.pending = (async () => { + let file = original + if (file === undefined) { + const response = await fetch(src, { signal: current.abort.signal, referrerPolicy: 'no-referrer' }) + if (!response.ok) throw new Error('Image read failed') + const blob = await response.blob() + if (!blob.type.startsWith('image/')) throw new Error('Image media type unavailable') + file = new File([blob], name, { type: blob.type }) + } + if (current.abort.signal.aborted) return + const handle = desktop === undefined ? undefined + : await desktop.prepare(new Uint8Array(await file.arrayBuffer()), file.name, file.type) + if (state.current !== current) { + if (handle !== undefined) desktop?.release(handle) + return + } + current.prepared = { file, ...handle === undefined ? {} : { handle } } + })().catch((_error: unknown) => { + // Preparation is speculative; a failed user gesture reports through the owning image. + }).finally(() => { current.pending = undefined }) + }, [src, name, original, desktop]) + useEffect(() => { + const current: NonNullable = { source: src, abort: new AbortController() } + state.current = current + if (src !== '' && (original !== undefined || new URL(src, location.href).origin === location.origin)) prepare() + return () => { + current.abort.abort() + const prepared = current.prepared + if (prepared?.handle !== undefined) desktop?.release(prepared.handle) + state.current = undefined + } + }, [src, name, original, desktop, prepare]) + const onDragStart = useCallback((event: DragEvent): void => { + if (event.defaultPrevented) return + dragged.current = true + const prepared = state.current?.source === src ? state.current.prepared : undefined + if (prepared === undefined) { + event.preventDefault() + prepare() + reportFailure(failureText) + return + } + event.dataTransfer.items.clear() + event.dataTransfer.effectAllowed = 'copy' + if (desktop !== undefined && prepared.handle !== undefined) { + event.preventDefault() + void desktop.start(prepared.handle).then((started) => { if (!started) reportFailure(failureText) }, + () => { reportFailure(failureText) }) + return + } + event.dataTransfer.setData(IMAGE_DRAG_TYPE, transfer.begin(prepared.file)) + event.dataTransfer.setData('DownloadURL', `${prepared.file.type}:${prepared.file.name}:${src}`) + }, [src, desktop, prepare, reportFailure, failureText, transfer]) + const onPointerDown = useCallback((event: PointerEvent): void => { + dragged.current = false + if (event.button === 0) prepare() + }, [prepare]) + const onClickCapture = useCallback((event: MouseEvent): void => { + if (!dragged.current) return + event.preventDefault() + event.stopPropagation() + dragged.current = false + }, []) + return { onPointerEnter: prepare, onPointerDown, onDragStart, onDragEnd: transfer.end, onClickCapture } +} diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index 18f9544405..28a3a71fd0 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -67,6 +67,9 @@ export { pointerModality } from './input-modality.ts' export { Toast } from './Toast.tsx' export { fileSizeText } from './file-size.ts' export { writeClipboard } from './clipboard.ts' +export { IMAGE_DRAG_TYPE, ImageDragProvider, useImageDrop } from './ImageDragProvider.tsx' +export { useImageDrag } from './image-drag.ts' +export type { DesktopImageDrag } from './image-drag.ts' export { SettingsForm } from './settings-form/SettingsForm.tsx' export type { SettingsFormLabels, SettingsFormProps } from './settings-form/SettingsForm.tsx' export { SettingsSecretField, SettingsValueField } from './settings-form/fields.tsx' diff --git a/packages/client/ui-primitives/src/markdown/MarkdownDelegate.tsx b/packages/client/ui-primitives/src/markdown/MarkdownDelegate.tsx index 681e8f5608..d65c435fea 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownDelegate.tsx +++ b/packages/client/ui-primitives/src/markdown/MarkdownDelegate.tsx @@ -14,7 +14,7 @@ export interface MarkdownDelegate { /** Image previews for decoded local paths in this owner's workspace. */ readonly fileImages?: { resolve: (path: string) => string | undefined - labels: ImageLightboxLabels & { open: string; loading: string; failed: string } + labels: ImageLightboxLabels & { open: string; loading: string; failed: string; dragFailed: string } } | undefined /** Ordinary HTTP(S) activation; absent handlers retain native anchor behavior. */ readonly openExternalLink?: MarkdownExternalLinkHandler | undefined diff --git a/packages/client/ui-primitives/src/markdown/render.tsx b/packages/client/ui-primitives/src/markdown/render.tsx index cdb9afcbda..05362b438e 100644 --- a/packages/client/ui-primitives/src/markdown/render.tsx +++ b/packages/client/ui-primitives/src/markdown/render.tsx @@ -29,6 +29,7 @@ import { renderTexToReact } from './katex.tsx' import { LinkIconMedium, classifyLinkPath } from '../LinkIcon.tsx' import { useMarkdownDelegate } from './MarkdownDelegate.tsx' import { HoverCard } from '../HoverCard.tsx' +import { useImageDrag } from '../image-drag.ts' import { ImageLightbox } from '../ImageLightbox.tsx' import { ImagePreview } from '../ImagePreview.tsx' import type { PositionedBlock } from './incremental.ts' @@ -650,14 +651,17 @@ function LoadedMarkdownImage({ src, alt, destination, preview }: { const [failed, setFailed] = useState(false) const [open, setOpen] = useState(false) const close = useCallback(() => { setOpen(false) }, []) + const drag = useImageDrag(preview === undefined ? '' : src, destination.split('/').at(-1)?.split(/[?#]/u)[0] || 'image', preview?.labels.dragFailed ?? '') if (failed) return {preview === undefined ? '' : `${preview.labels.failed} · `}{alt || destination} const img = {alt} { setFailed(true) }} loading="lazy" decoding="async" referrerPolicy="no-referrer" /> if (preview === undefined) return img return <> + onClickCapture={drag.onClickCapture} onClick={() => { setOpen(true) }}>{img} {open && } } diff --git a/packages/client/ui-primitives/tests/image-drag.client.spec.tsx b/packages/client/ui-primitives/tests/image-drag.client.spec.tsx new file mode 100644 index 0000000000..740178bd33 --- /dev/null +++ b/packages/client/ui-primitives/tests/image-drag.client.spec.tsx @@ -0,0 +1,119 @@ +// @vitest-environment jsdom +import { File as NodeFile } from 'node:buffer' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { IMAGE_DRAG_TYPE, ImageDragProvider, useImageDrop } from '../src/ImageDragProvider.tsx' +import { useImageDrag, type DesktopImageDrag } from '../src/image-drag.ts' + +afterEach(() => { cleanup(); vi.unstubAllGlobals(); vi.restoreAllMocks() }) + +function Image({ src, file, onOpen }: { src: string; file?: File; onOpen?: () => void }) { + const drag = useImageDrag(src, 'original.png', 'Image drag failed. Try again.', file) + return +} +function transfer() { + const add = vi.fn() + const values = new Map() + return { items: { add, clear: vi.fn() }, getData: (type: string) => values.get(type) ?? '', + setData: vi.fn((type: string, value: string) => { values.set(type, value) }), effectAllowed: 'none' } +} +function native() { + const bridge: DesktopImageDrag = { prepare: vi.fn(async () => 'handle'), start: vi.fn(async () => true), release: vi.fn() } + vi.stubGlobal('dshDesktop', { imageDrag: bridge }) + return bridge +} +function original() { + return new NodeFile([new Uint8Array([137, 80, 78, 71])], 'original.png', { type: 'image/png' }) as File +} + +it('hands original bytes to the browser without alt text or an internal URI payload', async () => { + const file = original() + const open = vi.fn() + render() + const dataTransfer = transfer() + await act(async () => { fireEvent.dragStart(screen.getByRole('img'), { dataTransfer }) }) + expect(dataTransfer.items.add).not.toHaveBeenCalled() + expect(dataTransfer.items.clear).toHaveBeenCalledOnce() + expect(dataTransfer.setData).toHaveBeenCalledWith(IMAGE_DRAG_TYPE, expect.any(String)) + expect(dataTransfer.effectAllowed).toBe('copy') + expect(dataTransfer.setData).toHaveBeenCalledWith('DownloadURL', 'image/png:original.png:blob:original') + fireEvent.click(screen.getByRole('button')) + expect(open).not.toHaveBeenCalled() + fireEvent.pointerDown(screen.getByRole('img'), { button: 0 }) + fireEvent.click(screen.getByRole('button')) + expect(open).toHaveBeenCalledOnce() +}) + +it('prepares before the gesture, starts native file dragging and releases its handle on unmount', async () => { + const bridge = native() + const file = original() + const view = render() + await act(async () => { await Promise.resolve() }) + expect(bridge.prepare).toHaveBeenCalledExactlyOnceWith(new Uint8Array([137, 80, 78, 71]), 'original.png', 'image/png') + const dataTransfer = transfer() + await act(async () => { expect(fireEvent.dragStart(screen.getByRole('img'), { dataTransfer })).toBe(false) }) + expect(bridge.start).toHaveBeenCalledExactlyOnceWith('handle') + expect(dataTransfer.items.add).not.toHaveBeenCalled() + view.unmount() + expect(bridge.release).toHaveBeenCalledExactlyOnceWith('handle') +}) + +it('cancels an unready gesture and releases late preparation without starting a delayed drag', async () => { + const bridge = native() + const pending = Promise.withResolvers() + vi.mocked(bridge.prepare).mockReturnValue(pending.promise) + const view = render() + await act(async () => { await Promise.resolve() }) + fireEvent.dragStart(screen.getByRole('img'), { dataTransfer: transfer() }) + expect(screen.getByRole('alert').textContent).toBe('Image drag failed. Try again.') + view.rerender(Image closed) + expect(screen.getByRole('alert').textContent).toBe('Image drag failed. Try again.') + await act(async () => { pending.resolve('late'); await pending.promise }) + expect(bridge.release).toHaveBeenCalledExactlyOnceWith('late') + expect(bridge.start).not.toHaveBeenCalled() +}) + +it('reports native failure even after its source image closes', async () => { + const bridge = native() + const pending = Promise.withResolvers() + vi.mocked(bridge.start).mockReturnValue(pending.promise) + const view = render() + await act(async () => { await Promise.resolve() }) + fireEvent.dragStart(screen.getByRole('img'), { dataTransfer: transfer() }) + view.rerender(Image closed) + await act(async () => { pending.resolve(false); await pending.promise }) + expect(screen.getByRole('alert').textContent).toBe('Image drag failed. Try again.') +}) + +it('never exports a failed read or starts a native drag after fetch cancellation', async () => { + const bridge = native() + const pending = Promise.withResolvers() + const fetchImage = vi.fn().mockReturnValue(pending.promise) + vi.stubGlobal('fetch', fetchImage) + const view = render() + fireEvent.pointerEnter(screen.getByRole('img')) + const request = fetchImage.mock.calls[0]?.[1] + view.rerender(Closed) + expect(request?.signal?.aborted).toBe(true) + await act(async () => { pending.resolve(new Response('unavailable', { status: 404 })); await pending.promise }) + expect(bridge.prepare).not.toHaveBeenCalled() + expect(bridge.start).not.toHaveBeenCalled() +}) + +function DropTarget({ receive }: { receive: (file: File | undefined) => void }) { + const read = useImageDrop() + return
{ event.preventDefault(); receive(read(event.dataTransfer)) }} /> +} + +it('resolves only the active application drag token and clears it after dragging ends', () => { + const file = original() + const receive = vi.fn() + render() + const dataTransfer = transfer() + fireEvent.dragStart(screen.getByRole('img'), { dataTransfer }) + fireEvent.drop(screen.getByTestId('drop'), { dataTransfer }) + expect(receive).toHaveBeenLastCalledWith(file) + fireEvent.dragEnd(screen.getByRole('img'), { dataTransfer }) + fireEvent.drop(screen.getByTestId('drop'), { dataTransfer }) + expect(receive).toHaveBeenLastCalledWith(undefined) +}) diff --git a/packages/client/ui-primitives/tests/markdown-image-entries.client.spec.tsx b/packages/client/ui-primitives/tests/markdown-image-entries.client.spec.tsx index 61626a483e..72cff3c14e 100644 --- a/packages/client/ui-primitives/tests/markdown-image-entries.client.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown-image-entries.client.spec.tsx @@ -7,7 +7,7 @@ import { MarkdownText } from './markdown-test-components.tsx' import { parseGfm, parseGfmWithMath } from '../src/markdown/parse.ts' afterEach(() => { cleanup(); vi.useRealTimers(); vi.restoreAllMocks() }) -const labels = { open: 'View full image', dialog: 'Image preview', close: 'Close preview', loading: 'Loading image', failed: 'Image unavailable' } +const labels = { dragFailed: 'Image drag failed', open: 'View full image', dialog: 'Image preview', close: 'Close preview', loading: 'Loading image', failed: 'Image unavailable' } const fileImages = { resolve: (path: string) => `https://example.com/api/file?path=${encodeURIComponent('/workspace/' + path)}`, labels } function mount(text: string, streaming = false) { const openFile = vi.fn() diff --git a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml index 97adbc6c21..88793e2a34 100644 --- a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml +++ b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml @@ -21,8 +21,8 @@ en: 57942270e23d3a8e zh: 6617124e652b3f67 /deepseek-ai-dsh-client-ui-sidebar-documentpreview/how-it-reads: - en: b056bbe0f74fe328 - zh: fd9ac88f08825afc + en: e197303166b5a86f + zh: 8d151e49321d67aa /deepseek-ai-dsh-client-ui-sidebar-documentpreview/excel-preview: en: b99b0e0b8efc2853 zh: 4327352d1334fc6b diff --git a/packages/client/ui-sidebar-documentpreview/README.md b/packages/client/ui-sidebar-documentpreview/README.md index 43ac41c129..31e5e58923 100644 --- a/packages/client/ui-sidebar-documentpreview/README.md +++ b/packages/client/ui-sidebar-documentpreview/README.md @@ -59,7 +59,7 @@ The body reads its record, navigation and lifetime through `useTabInfo().tab`. ` When Coding Tools is enabled, HTML fills the body edge to edge in a Blob iframe with exactly `sandbox="allow-scripts"`, without `allow-same-origin`; scripts cannot access the parent application's origin or file reader. The renderer loads directly declared relative `.js` classic scripts and `.css` stylesheets through its injected Remote callback, with fixed safety limits of 4 MiB per asset, 32 MiB total, and 64 distinct assets. Host code resolves the related path, and `readBytes` with `baseFile` returns native bytes. The dependency Resource is added after the read returns, using its `absolutePath` or a string `error.details.path` on failure; without a Host path, the Client does not guess one. Inside the renderer, base64 is used only to embed the iframe bootstrap payload in script text. A `` leaves dependency resolution to the browser, as do HTTPS resources. Local module imports, CSS `url()`/`@import`, and dynamic `fetch` do not use Host file access. Read failures, invalid UTF-8, or exceeded limits fail the preview rather than publishing a partial asset package. Replacing or unmounting the document releases its Blob URL. -PNG, JPEG, GIF, WebP, BMP, ICO, and SVG render through Blob URLs in an `` static-image context, rounded inside a 12px inset. Images default to fit width without enlarging content smaller than the pane; 100% uses the image's intrinsic CSS-pixel width. The shared zoom controls can produce horizontal and vertical scrolling, but do not provide drag-to-pan. Zoom changes retain the same `` and Blob URL, so animated images continue playing. Raster formats can soften above their intrinsic size, while SVG remains on the browser's vector rendering path. SVG markup never enters the application DOM or an iframe, so its scripts cannot execute or reach the parent page. Replacing or unmounting the image revokes its Blob URL. +PNG, JPEG, GIF, WebP, BMP, ICO, and SVG render through Blob URLs in an `` static-image context, rounded inside a 12px inset. Images default to fit width without enlarging content smaller than the pane; 100% uses the image's intrinsic CSS-pixel width. When either axis overflows the viewport, left-drag pans the image with grab/grabbing cursors. A fully contained image instead drags its original file into the composer or an external application; the gesture mode remains fixed until release. Ctrl+wheel and trackpad pinch retain the shared zoom behavior. Zoom changes retain the same `` and Blob URL, so animated images continue playing. Raster formats can soften above their intrinsic size, while SVG remains on the browser's vector rendering path. SVG markup never enters the application DOM or an iframe, so its scripts cannot execute or reach the parent page. Replacing or unmounting the image revokes its Blob URL. Shared copy comes from `sidebarDocumentPreview`; each builtin renderer owns its localized labels. PDF and converted Office previews use a cool light-grey background in light mode and a matte-black background in dark mode, with subtle page shadows and original document colors. PDF and image previews share a floating control with **Fit width**, 25%, 50%, 100%, 150%, and 200% choices plus 25% incremental zoom across the 25%–400% fixed range. Fit width is the default and tracks pane resizing; 100% means the PDF's 96-DPI page size or the image's intrinsic CSS-pixel size. On fine pointers, the control starts hidden, slides up when the pointer enters the bottom 72 CSS pixels, and slides down after a 420 ms leave delay; hover, keyboard focus, an open menu, and an active gesture keep it visible, while devices without hover keep it visible continuously. Chromium maps a macOS trackpad pinch to the same live, pointer-anchored path as Ctrl+wheel; the control displays the gesture percentage, and the settled value survives body remounts until the tab closes. HTML previews do not expose zoom controls. After zoom settles or fit width changes, nearby PDF pages redraw at the resolved scale multiplied by the device pixel ratio. The previous bitmap and selectable text stay visible until the replacement bitmap is ready; offscreen pages refresh when they approach the viewport. diff --git a/packages/client/ui-sidebar-documentpreview/README.zh.md b/packages/client/ui-sidebar-documentpreview/README.zh.md index daaf6288ab..c6f61e9db3 100644 --- a/packages/client/ui-sidebar-documentpreview/README.zh.md +++ b/packages/client/ui-sidebar-documentpreview/README.zh.md @@ -59,7 +59,7 @@ Web 和桌面端均通过代码工作工具选择 HTML 预览策略。渲染器 开启代码工作工具时,HTML 以贴合正文四边的 Blob iframe 运行,沙箱属性严格为 `sandbox="allow-scripts"`,不含 `allow-same-origin`;脚本无法访问父应用的源或文件读取接口。渲染器通过注入的 Remote 回调,加载直接声明的相对 `.js` 经典脚本和 `.css` 样式表;固定安全上限为单个资源 4 MiB、总计 32 MiB、64 个不同资源。Host 代码解析关联路径,带 `baseFile` 的 `readBytes` 返回原生字节。依赖 Resource 在读取返回后加入,使用返回的 `absolutePath`,失败时则使用字符串类型的 `error.details.path`;没有 Host 路径时,Client 不自行猜测。在渲染器内部,base64 仅用于把 iframe 引导载荷嵌入脚本文本。`` 将依赖解析交给浏览器,HTTPS 资源也由浏览器处理。本地模块 import、CSS `url()`/`@import` 和动态 `fetch` 不使用 Host 文件访问。读取失败、无效 UTF-8 或超出上限都使预览失败,不发布部分资源包。替换或卸载文档会释放其 Blob URL。 -PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `` 静态图片上下文中渲染,带 12px 内边距和圆角。图片默认适应宽度,但不会放大小于面板的内容;100% 使用图片的固有 CSS 像素宽度。共享缩放控件可产生横向和纵向滚动,但不提供拖拽平移。缩放不会替换 `` 或 Blob URL,因此动画图片会继续播放。位图超过固有尺寸后可能变虚,SVG 则继续使用浏览器的矢量渲染路径。SVG 标记绝不进入应用 DOM 或 iframe,因此其中的脚本无法执行,也无法访问父页面。替换或卸载图片会撤销其 Blob URL。 +PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `` 静态图片上下文中渲染,带 12px 内边距和圆角。图片默认适应宽度,但不会放大小于面板的内容;100% 使用图片的固有 CSS 像素宽度。图片在任一方向超出视口时,按住鼠标左键拖动可平移,指针显示为抓手或抓取状态。图片完全位于视口内时,拖动会将原文件交给输入框或外部应用;一次手势的模式保持到松开为止。Ctrl+滚轮和触控板捏合沿用共享缩放行为。缩放不会替换 `` 或 Blob URL,因此动画图片会继续播放。位图超过固有尺寸后可能变虚,SVG 则继续使用浏览器的矢量渲染路径。SVG 标记绝不进入应用 DOM 或 iframe,因此其中的脚本无法执行,也无法访问父页面。替换或卸载图片会撤销其 Blob URL。 共享文案来自 `sidebarDocumentPreview`;各内置渲染器拥有自己的本地化标签。PDF 与转换后的 Office 预览在浅色模式下使用冷调浅灰底色,在深色模式下使用哑黑底色,页面带有轻微阴影并保留文档原色。PDF 与图片预览共用浮动控件,提供**适应宽度**、25%、50%、100%、150% 和 200% 选项,并在 25%–400% 固定范围内按 25% 逐级缩放。适应宽度是默认模式并跟随面板尺寸;100% 表示 PDF 的 96 DPI 页面尺寸或图片的固有 CSS 像素尺寸。在精细指针设备上,控件初始隐藏;指针进入底部 72 个 CSS 像素时向上滑入,离开 420 ms 后向下退出;控件悬停、键盘焦点、菜单展开和手势进行期间保持显示,无 hover 的设备则始终显示。Chromium 将 macOS 触控板捏合映射到与 Ctrl+滚轮相同的实时指针锚定路径;控件同步显示手势比例,静止后的值在正文重新挂载后继续保留,直至 tab 关闭。HTML 预览不显示缩放控件。缩放稳定或适应宽度发生变化后,视口附近的 PDF 页面按最终缩放比例乘以设备像素比重绘。新位图完成前保留原有画面和可选文字层;屏幕外的页面在接近视口时更新。 diff --git a/packages/client/ui-sidebar-documentpreview/src/client/image/ImageBody.module.css b/packages/client/ui-sidebar-documentpreview/src/client/image/ImageBody.module.css index 414054d2aa..422f480d64 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/image/ImageBody.module.css +++ b/packages/client/ui-sidebar-documentpreview/src/client/image/ImageBody.module.css @@ -52,3 +52,6 @@ line-height: 1.5; white-space: normal; } + +.image[data-pan="available"] { cursor: grab; } +.image[data-pan="active"] { cursor: grabbing; } diff --git a/packages/client/ui-sidebar-documentpreview/src/client/image/ImageBody.tsx b/packages/client/ui-sidebar-documentpreview/src/client/image/ImageBody.tsx index 770c806bd5..8da0834551 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/image/ImageBody.tsx +++ b/packages/client/ui-sidebar-documentpreview/src/client/image/ImageBody.tsx @@ -1,5 +1,7 @@ /** Complete image bytes rendered in a shared zoom viewport. */ -import { useCallback, useEffect, useMemo, useState, type CSSProperties, type ReactNode } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from 'react' +import { useImageDrag } from '@deepseek-ai/dsh-client-ui-primitives' +import { bindImagePan } from './pan.ts' import type { PropsLocale, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' import { pathPartsOf } from '@deepseek-ai/dsh-util-workspace-path' import type { DocumentPreviewProps } from '../document/contract.ts' @@ -33,6 +35,7 @@ type ImageSource = readonly data: Uint8Array readonly mediaType: ImageMediaType readonly url: string + readonly file: File } | { readonly kind: 'failed'; readonly data: Uint8Array; readonly mediaType: ImageMediaType } @@ -72,14 +75,14 @@ export function ImageBody(props: ImageBodyProps): ReactNode { let url: string | undefined try { url = URL.createObjectURL(new Blob([data], { type: mediaType })) - setSource({ kind: 'ready', data, mediaType, url }) + setSource({ kind: 'ready', data, mediaType, url, file: new File([data], pathPartsOf(path).name, { type: mediaType }) }) } catch { setSource({ kind: 'failed', data, mediaType }) } return () => { if (url !== undefined) URL.revokeObjectURL(url) } - }, [data, mediaType]) + }, [data, mediaType, path]) if (data === undefined || mediaType === undefined) { return

{t('unsupported')}

@@ -93,15 +96,16 @@ export function ImageBody(props: ImageBodyProps): ReactNode { controls: t('zoomControls'), menu: t('zoomMenu'), out: t('zoomOut'), into: t('zoomIn'), fitWidth: t('zoomFitWidth'), value: percent => t('zoomValue', { percent }), } - return } /** SVG stays in the browser's static image mode because its bytes only reach an img Blob URL. */ -function LoadedImage({ url, name, preference, onPreference, labels, signal, scrollportRef, t }: { +function LoadedImage({ url, name, file, preference, onPreference, labels, signal, scrollportRef, t }: { readonly url: string readonly name: string + readonly file: File readonly preference: ZoomPreference readonly onPreference: (preference: ZoomPreference) => void readonly labels: ZoomLabels @@ -109,6 +113,12 @@ function LoadedImage({ url, name, preference, onPreference, labels, signal, scro readonly scrollportRef: ImageBodyProps['scrollportRef'] readonly t: ImageBodyProps['t'] }): ReactNode { + const drag = useImageDrag(url, name, t('dragFailed'), file) + const releasePan = useRef<() => void>() + const attachImage = useCallback((image: HTMLImageElement | null): void => { + releasePan.current?.() + releasePan.current = image === null ? undefined : bindImagePan(image) + }, []) const [state, setState] = useState<'loading' | 'ready' | 'failed'>('loading') const [width, setWidth] = useState() return