Merge remote-tracking branch 'origin/master' into refactor/unify-time-vocabulary

This commit is contained in:
Chinesezjc committed 2026-09-24 13:53:58 +08:00
commit d40567afaf
21 files changed
+298 -50

No files matched your search

+4 -4
View File
@@ -33,8 +33,8 @@
en: f461b255133384cc
zh: 2b9db718f22fdd04
/deepseek-harness-desktop/package/runtime-file-selection:
en: 7d72469be4975afe
zh: 2f644b582cb581be
en: f0e345fd89868803
zh: fd2c775ad23fa2f0
/deepseek-harness-desktop/package/upload-updates:
en: c64d7fe2fe2534b7
zh: 2188e1ae99ab4b33
@@ -48,8 +48,8 @@
en: 8efe3498dbe8686d
zh: a88c16f20a9430f3
/deepseek-harness-desktop/updates:
en: acbd12b8fc5ec5fd
zh: 031e15f931479f27
en: a40f7aa2b00ed6fb
zh: d852a2d9de576dc5
/deepseek-harness-desktop/updates/mandatory-update-policy:
en: 2f789a4949fafb12
zh: d8b28c01cfee11b7
+3 -1
View File
@@ -205,7 +205,7 @@ Windows release qualification also runs [directory and replacement checks](scrip
pwsh -NoProfile -File apps/desktop/scripts/smoke-windows.ps1 -Makensis $Makensis -SevenZip $SevenZip -PluginDir $PluginDir -FrameLibrary apps/desktop/.desktop-build/targets/win-x64/installer-ui/window-frame.dll
```
The Windows installer extracts the new version beside the installation directory, stops the old application, and replaces directories through same-volume renames. Same-path upgrades preserve the old directory until promotion succeeds; extraction failure leaves it intact, and promotion failure attempts to restore it. The installer removes the old backup before launch. Forced termination or power loss can leave `.new-*` or `.old-*` directories; installation-location and scope migrations retain electron-builder's old-uninstaller flow.
The Windows installer checks for a running application at startup and after destination selection, before extracting the new version beside the installation directory. It checks again before replacing directories through same-volume renames. A running application blocks installation; update launches allow up to ten seconds for it to exit. Same-path upgrades preserve the old directory until promotion succeeds; extraction failure leaves it intact, and promotion failure attempts to restore it. The installer removes the old backup before launch. Forced termination or power loss can leave `.new-*` or `.old-*` directories; installation-location and scope migrations retain electron-builder's old-uninstaller flow.
When extraction fails, the installer writes a report with the 7-Zip result and its complete error output to the updater cache directory as `%LOCALAPPDATA%\<package-derived name>-updater\installer-logs\extract-failure-<timestamp>.log` (currently `@deepseek-aidsh-desktop-updater`) and shows the first error line with a **Copy error details** button; silent installs write only the report. Unsigned Windows builds (`DSH_DESKTOP_UNSIGNED=1`) name their installer `deepseek-harness-<version>-win-x64-unsigned.exe` so they cannot be mistaken for release artifacts.
@@ -354,6 +354,8 @@ An unpacked artifact contains Electron, the materialized dsh production tree, pn
## Updates
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.
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 background blur, 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 backdrop and background blur.
+3 -1
View File
@@ -207,7 +207,7 @@ Windows 发布验收还需在 Desktop 构建后手动运行[目录和替换检
pwsh -NoProfile -File apps/desktop/scripts/smoke-windows.ps1 -Makensis $Makensis -SevenZip $SevenZip -PluginDir $PluginDir -FrameLibrary apps/desktop/.desktop-build/targets/win-x64/installer-ui/window-frame.dll
```
Windows 安装器先将新版本解压到安装目录旁边,再退出旧应用并通过同卷目录改名完成替换。同路径升级在替换成功前保留旧目录;解压失败时旧版不变,替换失败时尝试恢复旧目录。安装器在启动前清理旧版备份。强制结束安装器或断电可能留下 `.new-*` 或 `.old-*` 目录;不同安装位置或安装范围迁移仍使用 electron-builder 的旧卸载器流程。
Windows 安装器在启动时和选定目标目录后检查应用是否正在运行,通过检查后才将新版本解压到安装目录旁边。通过同卷目录改名替换前,安装器会再次检查。运行中的应用会阻止安装;更新启动允许等待应用退出,最长十秒。同路径升级在替换成功前保留旧目录;解压失败时旧版不变,替换失败时尝试恢复旧目录。安装器在启动前清理旧版备份。强制结束安装器或断电可能留下 `.new-*` 或 `.old-*` 目录;不同安装位置或安装范围迁移仍使用 electron-builder 的旧卸载器流程。
解压失败时,安装器会把 7-Zip 的结果和完整错误输出写入更新缓存目录 `%LOCALAPPDATA%\<按包名派生>-updater\installer-logs\extract-failure-<时间戳>.log`(当前为 `@deepseek-aidsh-desktop-updater`),并在弹窗中显示首条错误行和 **复制错误信息** 按钮;静默安装只写入报告。未签名的 Windows 构建(`DSH_DESKTOP_UNSIGNED=1`)会将安装包命名为 `deepseek-harness-<版本>-win-x64-unsigned.exe`,以免被误当作发布产物。
@@ -356,6 +356,8 @@ macOS 打包在组装 App 时、代码签名前写入 `Contents/Resources/app-up
## 更新
Windows 下载完成后的更新确认说明应用会在安装期间关闭、完成后自动打开,并提示期间不要重复启动。安装器携带 `--updated` 重启应用并直接打开工作区时,壳会将主窗口前置并聚焦一次,不启用永久置顶。启动进入欢迎页时会清除此请求,使后续登录保留正常的激活行为。普通启动和其他平台不执行此前置步骤。
原生更新浮层在文档就绪且父窗口可见时显示,并在父窗口再次显示时恢复。关闭浮层会释放背景模糊、输入拦截和父窗口监听。[本地窗口验证](tests/README.zh.md#verification-overlay)无需启动工作区即可检查这些切换。
打包应用在启动后异步检查固定 Nightly。常规轮询以十分钟为基础间隔,每次独立采样 ±20% 的随机抖动。每次检查失败将基础延迟翻倍,上限为一小时;成功后重置。随机延迟不超过该上限,并从全部复用调用结算后开始计时。本地化的“检查更新…”菜单项(Windows 可从顶栏的“应用”菜单进入)立即执行,并复用正在进行的检查。回到前台和系统恢复时遵守相同的单调时钟截止时间。新收到的强更策略也会立即请求检查更新清单。自动检查从不弹窗或下载安装包。手动检查显示正在检查、失败或包含已安装版本号的无更新反馈。常规更新弹窗原位渐入渐出;连续弹窗替换卡片内容并重置其滚动位置,保留蒙层与背景模糊。
+1
View File
@@ -50,6 +50,7 @@ Function InstallerBeforeInstall
SetErrorLevel 2
Quit
${EndIf}
Call InstallerCheckAppRunning
FunctionEnd
Function InstallerProgressShow
+4
View File
@@ -23,6 +23,9 @@ ManifestDPIAware true
!include "${INSTALLER_SOURCE_DIR}\theme.nsh"
!include "${INSTALLER_SOURCE_DIR}\pages.nsh"
!include "${INSTALLER_SOURCE_DIR}\lifecycle.nsh"
Function InstallerCheckAppRunning
!insertmacro customCheckAppRunning
FunctionEnd
!endif
!macroend
@@ -63,6 +66,7 @@ ManifestDPIAware true
File "/oname=$PLUGINSDIR\brand-dark.bmp" "${INSTALLER_BUILD_DIR}\brand-dark.bmp"
File "/oname=$PLUGINSDIR\brand-dark-2x.bmp" "${INSTALLER_BUILD_DIR}\brand-dark-2x.bmp"
File "/oname=$PLUGINSDIR\window-frame.dll" "${INSTALLER_BUILD_DIR}\window-frame.dll"
Call InstallerCheckAppRunning
${If} ${Silent}
Call InstallerPreflight
${If} $InstallerError != ""
+22
View File
@@ -78,6 +78,8 @@ export const en = {
updateDownload: 'Download update',
updateDownloadedTitle: 'DeepSeek Harness v{version} downloaded',
updateDownloadedDetail: 'The update package has downloaded. Select “Install and Restart” to restart the app and begin installation.',
updateDownloadedTitleWindows: 'New version v{version} is ready',
updateDownloadedDetailWindows: 'The app will close temporarily during the update and reopen automatically when it is complete.\n\nThe update may take some time. Please wait and do not launch the app again during installation.',
updateClose: 'Close',
updateAcknowledge: 'OK',
updateLater: 'Update later',
@@ -211,6 +213,8 @@ export const zh = {
updateDownload: '下载更新',
updateDownloadedTitle: 'DeepSeek Harness v{version} 下载完成',
updateDownloadedDetail: '安装包已下载完毕,点击“安装并重启”,即刻重启客户端,开始部署。',
updateDownloadedTitleWindows: '新版本 v{version} 已准备就绪',
updateDownloadedDetailWindows: '更新期间应用将暂时关闭,完成后会自动打开。\n\n更新可能需要一些时间,请耐心等待,期间请勿重复启动应用。',
updateClose: '关闭',
updateAcknowledge: '确定',
updateLater: '稍后更新',
@@ -299,3 +303,21 @@ export function formatDesktopMessage(
): string {
return message.replaceAll(/\{([^{}]+)\}/gu, (placeholder, key: string) => values[key] ?? placeholder)
}
/**
* Select localized copy for an ordinary downloaded-update confirmation.
* @param messages - Selected Desktop dictionary.
* @param version - Prepared update version without a leading v.
* @param platform - Operating system presenting the confirmation.
* @returns The versioned title and installation guidance.
*/
export function desktopUpdateReadyConfirmation(
messages: DesktopMessages,
version: string,
platform: string,
): { message: string; detail: string } {
return {
message: formatDesktopMessage(platform === 'win32' ? messages.updateDownloadedTitleWindows : messages.updateDownloadedTitle, { version }),
detail: platform === 'win32' ? messages.updateDownloadedDetailWindows : messages.updateDownloadedDetail,
}
}
+13 -4
View File
@@ -28,7 +28,7 @@ import { installDesktopDirectoryPicker } from './directory-picker.ts'
import { installMicrophonePermissions } from './microphone-permissions.ts'
import { DesktopBackendController } from './backend-controller.ts'
import { DESKTOP_IPC, SCHEME, assertDesktopSender, type DesktopUpdateState } from './ipc.ts'
import { formatDesktopMessage, resolveDesktopLocale, resolveDesktopStartupLocale } from './locale.ts'
import { desktopUpdateReadyConfirmation, formatDesktopMessage, resolveDesktopLocale, resolveDesktopStartupLocale } from './locale.ts'
import { claimDesktopSingleInstance } from './single-instance.ts'
import { DesktopUpdateCoordinator } from './update-coordinator.ts'
import { serveWebDocument, authenticateWebHost, forwardWebRequest } from './web-document.ts'
@@ -311,6 +311,8 @@ async function main(): Promise<void> {
let mainWindow: BrowserWindow | undefined
let welcomeWindow: BrowserWindow | undefined
let enteredWorkspace = false
// NSIS passes --updated when it launches the application after installation.
let raiseAfterUpdate = process.platform === 'win32' && process.argv.includes('--updated')
let shellInstallerOwnsQuit = false
let requireCleanStop = false
let updateStoppedHost = false
@@ -527,11 +529,11 @@ async function main(): Promise<void> {
const host = backend.host
if (host === undefined) throw new DesktopUpdatePreparationError('tasks-unavailable', locale.messages.updateTasksUnavailable)
const active = await host.updateTasks('inspect')
const ready = desktopUpdateReadyConfirmation(locale.messages, updates.state.version ?? '', process.platform)
const confirmation: Electron.MessageBoxOptions = {
type: active ? 'warning' : 'info', title: locale.messages.updateTitle,
message: active ? locale.messages.updateActiveTasks : formatDesktopMessage(locale.messages.updateDownloadedTitle, { version: updates.state.version ?? '' }),
detail: active ? locale.messages.updateActiveTasksDetail
: locale.messages.updateDownloadedDetail,
message: active ? locale.messages.updateActiveTasks : ready.message,
detail: active ? locale.messages.updateActiveTasksDetail : ready.detail,
buttons: active ? [locale.messages.updateStopTasks, locale.messages.updateLater] : [locale.messages.installAndRestart],
defaultId: 1, cancelId: 1,
}
@@ -979,6 +981,11 @@ async function main(): Promise<void> {
window.webContents.send(DESKTOP_IPC.enterWorkspace)
}
welcomeWindow = undefined
if (raiseAfterUpdate) {
raiseAfterUpdate = false
window.moveTop()
window.focus()
}
if (activate && development && process.env.DSH_DESKTOP_OPEN_DEVTOOLS !== '0') {
window.webContents.openDevTools({ mode: 'detach' })
}
@@ -1046,6 +1053,8 @@ async function main(): Promise<void> {
windowsLanguage = locale.id
refreshApplicationMenu()
if (!enteredWorkspace && needsWelcome({ loggedIn: state.loggedIn, hasApiKey: state.hasApiKey })) {
// A later login must retain its own activation policy instead of replaying startup focus.
raiseAfterUpdate = false
await showWelcome()
} else {
await enterWorkspace()
@@ -0,0 +1,7 @@
{
"message": "DeepSeek Harness v0.1.99 downloaded",
"detail": "The update package has downloaded. Select “Install and Restart” to restart the app and begin installation.",
"buttons": [
"Install and Restart"
]
}
@@ -0,0 +1,7 @@
{
"message": "DeepSeek Harness v0.1.99 下载完成",
"detail": "安装包已下载完毕,点击“安装并重启”,即刻重启客户端,开始部署。",
"buttons": [
"安装并重启"
]
}
@@ -0,0 +1,7 @@
{
"message": "New version v0.1.99 is ready",
"detail": "The app will close temporarily during the update and reopen automatically when it is complete.\n\nThe update may take some time. Please wait and do not launch the app again during installation.",
"buttons": [
"Install and Restart"
]
}
@@ -0,0 +1,7 @@
{
"message": "新版本 v0.1.99 已准备就绪",
"detail": "更新期间应用将暂时关闭,完成后会自动打开。\n\n更新可能需要一些时间,请耐心等待,期间请勿重复启动应用。",
"buttons": [
"安装并重启"
]
}
@@ -10,8 +10,9 @@
"registered-directory-with-trailing-separators",
"registered-directory-and-checked-launch",
"launch-failure-retry-and-prompt-dismissal",
"progress-remains-monotonic-across-native-resets",
"running-app-preserved-and-native-progress-hidden",
"running-app-rejected-before-welcome-and-extraction",
"silent-running-app-rejected-before-extraction",
"app-started-on-welcome-rejected-before-extraction",
"silent-update-retains-directory-and-ignores-unrelated-process",
"invalid-destination-rejection",
"uninstall"
+4 -3
View File
@@ -6,7 +6,7 @@ import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { app, BrowserWindow, protocol } from 'electron'
import { DesktopUpdateDialog } from '../../lib/types/update-dialog.js'
import { formatDesktopMessage, resolveDesktopLocale } from '../../lib/types/locale.js'
import { desktopUpdateReadyConfirmation, resolveDesktopLocale } from '../../lib/types/locale.js'
async function rendered(window) {
await window.webContents.executeJavaScript(`new Promise((resolve, reject) => {
@@ -38,10 +38,11 @@ export async function qualifyUpdateDialogs(root, fixture) {
await f.coordinator.download(available.version)
assert.equal(f.installations.length, 0)
for (const active of [false, true]) {
const ready = desktopUpdateReadyConfirmation(messages, available.version, process.platform)
const options = {
title: messages.updateTitle,
message: active ? messages.updateActiveTasks : formatDesktopMessage(messages.updateDownloadedTitle, { version: available.version }),
detail: active ? messages.updateActiveTasksDetail : messages.updateDownloadedDetail,
message: active ? messages.updateActiveTasks : ready.message,
detail: active ? messages.updateActiveTasksDetail : ready.detail,
buttons: active ? [messages.updateStopTasks, messages.updateLater] : [messages.installAndRestart], cancelId: 1,
}
f.restart(async () => (await dialogs.show(parent, options)).response === 0)
+4 -3
View File
@@ -11,7 +11,7 @@ import updaterModule from 'electron-updater'
import { createUpdateServer } from './update-server.mjs'
import { fixture } from './workspace-update-adapters.mjs'
import { DesktopUpdateHttpExecutor } from '../../lib/types/update-http-executor.js'
import { resolveDesktopLocale } from '../../lib/types/locale.js'
import { desktopUpdateReadyConfirmation, resolveDesktopLocale } from '../../lib/types/locale.js'
const root = process.env.DSH_WORKSPACE_UPDATE_ROOT
assert.ok(root)
@@ -252,7 +252,8 @@ async function qualify() {
assert.equal(BrowserWindow.getAllWindows().some(window => window.webContents.getURL() === 'dsh-app://shell/update-dialog.html'), false)
await screenshot(mainWindow, 'downloading.png')
server.release()
const ready = await dialogWith(messages.updateDownloadedTitle.replace('{version}', '0.1.6-nightly.1'))
const confirmation = desktopUpdateReadyConfirmation(messages, '0.1.6-nightly.1', process.platform)
const ready = await dialogWith(confirmation.message)
assert.equal(fixture.installations.length, 0)
await screenshot(ready, 'install-confirmation.png')
cases.push('sidebar-retry-direct-download-and-separate-install-dialog')
@@ -337,7 +338,7 @@ async function qualify() {
await waitFor(() => fixture.host !== stoppedHost && fixture.readyHosts.has(fixture.host), 'replacement Host after non-graceful shutdown')
await waitFor(async () => !await fixture.host.updateTasks('inspect'), 'replacement Host ready without active work')
await press(mainWindow, `document.querySelector('button[data-error="true"]')`)
const retryInstall = await dialogWith(messages.updateDownloadedTitle.replace('{version}', '0.1.6-nightly.1'))
const retryInstall = await dialogWith(confirmation.message)
assert.equal(fixture.installations.length, 0)
await screenshot(retryInstall, 'recovered-install-confirmation.png')
await press(retryInstall, `document.getElementById('close')`)
+49 -1
View File
@@ -83,9 +83,12 @@ const harness = await vi.hoisted(async () => {
if (channel === 'dsh-desktop:mandatory-state' && state.policy?.blocking) policyBlocked.resolve()
}),
})
readonly show = vi.fn()
readonly shown = deferred()
readonly show = vi.fn(() => { this.shown.resolve() })
readonly hide = vi.fn()
readonly focus = vi.fn()
readonly moveTop = vi.fn()
readonly setAlwaysOnTop = vi.fn()
readonly restore = vi.fn()
readonly setSize = vi.fn()
readonly getBounds = vi.fn(() => ({ x: 0, y: 0, width: 800, height: 700 }))
@@ -995,6 +998,51 @@ describe('desktop main startup', () => {
return harness.hosts[0]!
}
it.each([
['win32', ['--updated'], true],
['win32', [], false],
['darwin', ['--updated'], false],
['linux', ['--updated'], false],
] as const)('raises the first workspace only for a Windows installer restart (%s, %j)', async (platform, args, raises) => {
vi.stubGlobal('process', { ...process, platform, argv: ['desktop', ...args] })
await import('../src/main.ts')
await harness.preparing.promise
const window = harness.windows[0]!
expect(window.moveTop).not.toHaveBeenCalled()
harness.prepared.resolve()
await harness.hostStarted.promise
harness.hosts[0]!.ready.resolve()
await window.shown.promise
expect(window.show).toHaveBeenCalledOnce()
expect(window.moveTop).toHaveBeenCalledTimes(raises ? 1 : 0)
expect(window.focus).toHaveBeenCalledTimes(raises ? 1 : 0)
expect(window.setAlwaysOnTop).not.toHaveBeenCalled()
if (raises) {
expect(window.show.mock.invocationCallOrder[0]).toBeLessThan(window.moveTop.mock.invocationCallOrder[0]!)
}
window.destroy()
harness.app.emit('second-instance')
const replacement = harness.windows[1]!
await replacement.shown.promise
expect(replacement.show).toHaveBeenCalledOnce()
expect(replacement.moveTop).not.toHaveBeenCalled()
expect(replacement.setAlwaysOnTop).not.toHaveBeenCalled()
})
it.each([
['win32', 'zh-CN'], ['win32', 'en-US'], ['darwin', 'zh-CN'], ['darwin', 'en-US'],
] as const)('records the restart confirmation on %s in %s', async (platform, language) => {
vi.stubGlobal('process', { ...process, platform })
vi.spyOn(harness.app, 'getPreferredSystemLanguages').mockReturnValue([language])
await readyForUpdate()
harness.updateState = { phase: 'ready', version: '0.1.99' }
harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 1 })
await expect(harness.prepareUpdate()).resolves.toBe(false)
const { message, detail, buttons } = harness.dialog.showMessageBox.mock.lastCall![0] as MessageBoxOptions
await expect(JSON.stringify({ message, detail, buttons }, null, 2) + '\n')
.toMatchFileSnapshot(`./expected/update-restart-${platform}-${language}.json`)
})
it('hides the workspace before intentional Host shutdown can look like reconnection', async () => {
const host = await readyForUpdate()
const window = harness.windows[0]!
+14 -1
View File
@@ -26,6 +26,7 @@ const state = vi.hoisted(() => ({
showWorkspace: vi.fn(),
showInactiveWorkspace: vi.fn(),
focusWorkspace: vi.fn(),
moveTopWorkspace: vi.fn(),
openDevTools: vi.fn(),
closeWelcome: vi.fn(),
welcomeLocale: undefined as DesktopLocale | undefined,
@@ -77,6 +78,7 @@ vi.mock('electron', () => ({
isMinimized() { return false }
restore = vi.fn()
focus = state.focusWorkspace
moveTop = state.moveTopWorkspace
hide = vi.fn()
show = state.showWorkspace
showInactive = state.showInactiveWorkspace
@@ -157,10 +159,18 @@ afterEach(() => {
vi.clearAllTimers()
vi.useRealTimers()
vi.unstubAllEnvs()
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
it('starts the Host for welcome onboarding and opens the workspace on skip without quitting', async () => {
it.each([false, true])('starts welcome onboarding without carrying update focus into login or skip (Windows update=%s)', async (updated) => {
vi.resetModules()
vi.clearAllMocks()
state.preference = 'zh'
state.hasApiKey = false
state.operations = undefined
state.accountState.mockResolvedValue({ status: 'signed-out', attempt: null, links: { usageUrl: '', topUpUrl: '' } })
if (updated) vi.stubGlobal('process', { ...process, platform: 'win32', argv: ['desktop', '--updated'] })
vi.useFakeTimers()
vi.stubEnv('DSH_DESKTOP_DEV_PROJECT_DIR', '/development-profile')
vi.stubEnv('DSH_DESKTOP_NODE_BINARY', '/runtime/node')
@@ -217,6 +227,8 @@ it('starts the Host for welcome onboarding and opens the workspace on skip witho
await state.operations!.skip()
expect(state.loadWorkspace).not.toHaveBeenCalled()
expect(state.showWorkspace).toHaveBeenCalledOnce()
expect(state.moveTopWorkspace).not.toHaveBeenCalled()
expect(state.focusWorkspace).not.toHaveBeenCalled()
expect(state.windowOptions).toMatchObject({
...(process.platform === 'darwin' ? { titleBarStyle: 'hiddenInset', trafficLightPosition: { x: 16, y: 18 }, vibrancy: 'sidebar' } : {}),
webPreferences: { contextIsolation: true, sandbox: true },
@@ -271,6 +283,7 @@ it('starts the Host for welcome onboarding and opens the workspace on skip witho
await vi.waitFor(() => { expect(state.showInactiveWorkspace).toHaveBeenCalledOnce() })
expect(state.showWorkspace).not.toHaveBeenCalled()
expect(state.focusWorkspace).not.toHaveBeenCalled()
expect(state.moveTopWorkspace).not.toHaveBeenCalled()
expect(state.openDevTools).not.toHaveBeenCalled()
state.appListeners.get('open-url')!({ preventDefault: vi.fn() }, 'dsh://open')
expect(state.showWorkspace).toHaveBeenCalledOnce()
+26 -24
View File
@@ -77,6 +77,7 @@ function Finish-Setup([Diagnostics.Process]$Process, [bool]$Launch, [string]$The
while ([InstallerCapture]::FindButton($Process.Id, $copy.INSTALLER_FINISH) -eq [IntPtr]::Zero) {
if ($Process.HasExited -or $timer.Elapsed.TotalSeconds -gt 30) { throw 'Installer did not complete' }
$visible = [InstallerCapture]::VisibleText($Process.Id)
if ($visible.Contains('msctls_progress32') -ne $expected.nativeProgressVisible) { throw 'Stock green progress bar is visible' }
if ($visible -match 'HarnessInstallerProgress[^\r\n]*?(\d+)%') {
$percent = [int]$Matches[1]
if ($percent -lt $previous) { throw 'Installation progress went backwards' }
@@ -181,33 +182,34 @@ try {
$results.Add('registered-directory-and-checked-launch')
$results.Add('launch-failure-retry-and-prompt-dismissal')
$process = Start-Setup dark
Click-Control $process $copy.INSTALLER_INSTALL
[void](Wait-Control $process $copy.INSTALLER_RUNNING -Dialog)
$visible = [InstallerCapture]::VisibleText($process.Id)
if ($visible.Contains('msctls_progress32') -ne $expected.nativeProgressVisible) { throw 'Stock green progress bar is visible' }
if (-not $visible.Contains('HarnessInstallerProgress')) { throw 'Custom progress page is missing' }
$window = [InstallerCapture]::Find($process.Id)
$source = [InstallerCapture]::FindClass($window, 'msctls_progress32')
if ($source -eq [IntPtr]::Zero) { throw 'Stock progress source is missing' }
# Directory staging finishes before the running-app prompt; promotion has not started.
if ([InstallerCapture]::GetProp($window, 'HarnessInstaller.Stage').ToInt32() -ne 1) { throw 'Running-app prompt reached the wrong installation stage' }
$previous = [InstallerCapture]::Progress($window)
foreach ($sample in @(@(100, 95), @(100, 59), @(1000, 0), @(1000, 950), @(100, 59), @(100, 100))) {
[void][InstallerCapture]::SendMessage($source, 0x406, [IntPtr]::Zero, [IntPtr]$sample[0])
[void][InstallerCapture]::SendMessage($source, 0x402, [IntPtr]$sample[1], [IntPtr]::Zero)
$percent = [InstallerCapture]::Progress($window)
if ($percent -lt $previous -or $percent -ge 100) { throw "Progress regressed or completed before success: $previous -> $percent" }
$previous = $percent
function Assert-RunningRejected([Diagnostics.Process]$Setup) {
[void](Wait-Control $Setup $copy.INSTALLER_RUNNING -Dialog)
$visible = [InstallerCapture]::VisibleText($Setup.Id)
if ($visible.Contains('HarnessInstallerProgress')) { throw 'Running-app rejection started progress UI' }
if (Get-ChildItem -LiteralPath $OutputDirectory -Directory -Filter 'Installed App.new-*') { throw 'Running-app rejection staged payload files' }
Dismiss $Setup $copy.INSTALLER_RUNNING
if (-not $Setup.WaitForExit(10000) -or $Setup.ExitCode -ne 2 -or $app.HasExited) { throw 'Running application was not preserved' }
}
if ($previous -gt 94) { throw 'Internal progress escaped the extraction stage' }
$results.Add('progress-remains-monotonic-across-native-resets')
[void][InstallerCapture]::Save([InstallerCapture]::Find($process.Id), (Join-Path $OutputDirectory 'dark-progress.png'))
Dismiss $process $copy.INSTALLER_RUNNING
if (-not $process.WaitForExit(10000) -or $app.HasExited) { throw 'Running application was not preserved' }
$process = Start-Process -FilePath $Installer -ArgumentList ('/THEME=dark /D=' + $installPath) -PassThru -WindowStyle Hidden
$processes.Add($process)
Assert-RunningRejected $process
$results.Add('running-app-rejected-before-welcome-and-extraction')
Run-Silent ('/S /D=' + $installPath) 2
if ($app.HasExited) { throw 'Silent rejection stopped the running application' }
if (Get-ChildItem -LiteralPath $OutputDirectory -Directory -Filter 'Installed App.new-*') { throw 'Silent rejection staged payload files' }
$results.Add('silent-running-app-rejected-before-extraction')
Dismiss $app 'Installer test application is running.'
if (-not $app.WaitForExit(10000)) { throw 'Test application did not exit' }
$results.Add('running-app-preserved-and-native-progress-hidden')
$process = Start-Setup dark
$app = Start-Process -FilePath $appPath -PassThru -WindowStyle Hidden
$processes.Add($app)
[void](Wait-Control $app 'Installer test application is running.' -Dialog)
Click-Control $process $copy.INSTALLER_INSTALL
Assert-RunningRejected $process
Dismiss $app 'Installer test application is running.'
if (-not $app.WaitForExit(10000)) { throw 'Test application did not exit' }
$results.Add('app-started-on-welcome-rejected-before-extraction')
$otherPath = Join-Path $OutputDirectory 'Other Installation'
New-Item -ItemType Directory -Path $otherPath | Out-Null
@@ -652,17 +652,28 @@ function internalModules(): InternalModules {
}
}
/**
* Replace a resolver error's message and the same text in its stack.
* Node's module-hooks thread serializes errors and returns the stack accessor as a read-only configurable
* data property, so a rejected stack assignment redefines the property value.
* @param error - resolver error to update in place.
* @param message - replacement message.
*/
function replaceErrorMessage(error: Error, message: string): void {
const { message: originalMessage, stack } = error
error.message = message
/* v8 ignore next -- Node's resolver errors always carry a stack */
if (stack === undefined) return
const replaced = stack.replace(originalMessage, message)
if (!Reflect.set(error, 'stack', replaced)) Object.defineProperty(error, 'stack', { value: replaced })
}
function throwWithImporter(error: unknown, routedParent: string, parent: string): never {
const code = (error as NodeJS.ErrnoException).code
if (error instanceof Error && (code === 'ERR_MODULE_NOT_FOUND' || code === 'ERR_PACKAGE_PATH_NOT_EXPORTED')) {
const routedPath = fileURLToPath(routedParent)
const parentPath = fileURLToPath(parent)
const originalMessage = error.message
const message = originalMessage.replaceAll(routedParent, parent).replaceAll(routedPath, parentPath)
const stack = error.stack
error.message = message
/* v8 ignore next -- Node's resolver errors always carry a stack */
if (stack !== undefined) error.stack = stack.replace(originalMessage, message)
replaceErrorMessage(error, error.message.replaceAll(routedParent, parent).replaceAll(routedPath, parentPath))
}
throw error
}
@@ -0,0 +1,22 @@
/** Node resolver errors with the stack property that Node's module-hooks thread returns. */
import { registerHooks, type ModuleHooks } from 'node:module'
/**
* Register a synchronous resolve hook that rethrows Node's resolver errors with the `stack` property an
* asynchronous `module.register()` hook chain returns: Node serializes the error on its hooks thread, and the
* main thread receives the stack accessor as a read-only configurable data property.
* @returns the hook registration, which the test deregisters.
*/
export function registerHooksThreadStacks(): ModuleHooks {
return registerHooks({
resolve(specifier, context, nextResolve) {
try {
return nextResolve(specifier, context)
} catch (error) {
if (error instanceof Error) Object.defineProperty(error, 'stack', { value: error.stack, writable: false })
throw error
}
},
})
}
@@ -8,6 +8,8 @@ import { pathToFileURL } from 'node:url'
import { ModuleLoader } from '@deepseek-ai/cordis-plugin-loader'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { readPluginMeta, resolvePluginResource } from '../src/package-meta.ts'
import { installRuntimeInterception } from '../src/profile-resolution/resolver.ts'
import { registerHooksThreadStacks } from './hooks-thread-stack.ts'
let root: string
let dir: string
@@ -433,3 +435,58 @@ describe('plugin locale display metadata', () => {
expect(readPluginMeta('localized', parentURL)?.error).toContain('duplicates locale en')
})
})
describe('plugin display metadata with read-only resolver stacks', () => {
const cleanups: (() => void)[] = []
let bundle: string
let profileURL: string
beforeEach(() => {
bundle = join(root, 'bundle')
file(join(bundle, 'package.json'), JSON.stringify({ name: 'bundle', dependencies: { plain: '*', translated: '*' } }))
file(join(bundle, 'node_modules', 'plain', 'package.json'), JSON.stringify({
name: 'plain', description: 'Plain introduction', exports: { '.': './index.js', './package.json': './package.json' },
}))
file(join(bundle, 'node_modules', 'translated', 'package.json'), JSON.stringify({
name: 'translated', exports: { '.': './index.js', './locale/*.json': './locale/*.json', './package.json': './package.json' },
}))
dictionary('en', { meta: { title: 'Translated' } }, join(bundle, 'node_modules', 'translated', 'locale'))
const profilesDir = join(root, 'profiles')
const profileDir = join(profilesDir, 'web')
mkdirSync(profileDir, { recursive: true })
profileURL = `${pathToFileURL(profileDir).href}/`
const registration = installRuntimeInterception({
profilesDir, profileDir, localPackageNames: [], linkedRoots: [],
entries: ['plain', 'translated'].map(name => ({
name, version: undefined, scope: 'profile' as const,
packageDir: join(bundle, 'node_modules', name), declarer: join(bundle, 'package.json'),
})),
})
cleanups.push(() => { registration.dispose() })
const hooks = registerHooksThreadStacks()
cleanups.push(() => { hooks.deregister() })
})
afterEach(() => {
for (const cleanup of cleanups.splice(0).reverse()) cleanup()
})
function parentFor(lookup: 'profile' | 'native'): string {
return lookup === 'profile' ? profileURL : pathToFileURL(join(bundle, 'entry.mjs')).href
}
it.each(['profile', 'native'] as const)('falls back to package fields when %s resolution finds no locale resources', (lookup) => {
expect(readPluginMeta('plain', parentFor(lookup))).toEqual({ title: 'plain', description: 'Plain introduction' })
})
it.each(['profile', 'native'] as const)('reads exported locale resources through %s resolution', (lookup) => {
expect(readPluginMeta('translated', parentFor(lookup))).toEqual({ title: { en: 'Translated' } })
})
it('reports invalid export targets reached through the profile', () => {
file(join(bundle, 'node_modules', 'plain', 'package.json'), JSON.stringify({
name: 'plain', exports: { './locale/*.json': '../outside/*.json' },
}))
expect(readPluginMeta('plain', profileURL)?.error).toContain('Invalid "exports" target')
})
})
@@ -27,6 +27,7 @@ import {
type Profile,
type RuntimeResolution,
} from '../src/profile.ts'
import { registerHooksThreadStacks } from './hooks-thread-stack.ts'
const roots: string[] = []
const registrations: RuntimeInterception[] = []
@@ -1482,6 +1483,29 @@ describe('runtime resolution', { concurrent: false }, () => {
.toContain(` imported from ${fileURLToPath(parent)}`)
})
it('reports routed ESM failures from the original importer when Node returns a read-only stack', async () => {
const f = fixture()
const parent = pathToFileURL(join(f.profile.dir, 'entry.mjs')).href
const nativeMessage = thrownMessage(() => resolveFrom('unavailable-lib', parent))
const registration = installRuntimeInterception(await resolutionOf(f))
registrations.push(registration)
const hooks = registerHooksThreadStacks()
try {
const missing = thrownError(() => resolveFrom('unavailable-lib', parent))
expect(missing).toMatchObject({ code: 'ERR_MODULE_NOT_FOUND', message: nativeMessage })
expect(missing.stack).toContain(nativeMessage)
const unexported = thrownError(() => resolveFrom('@deepseek-ai/dsh-core/private', parent))
expect(unexported.code).toBe('ERR_PACKAGE_PATH_NOT_EXPORTED')
expect(unexported.message).toContain(` imported from ${fileURLToPath(parent)}`)
expect(unexported.stack).toContain(unexported.message)
const imported = importFrom('@deepseek-ai/dsh-core/private', parent)
await expect(imported).rejects.toMatchObject({ code: 'ERR_PACKAGE_PATH_NOT_EXPORTED' })
await expect(imported).rejects.toThrow(` imported from ${fileURLToPath(parent)}`)
} finally {
hooks.deregister()
}
})
it('leaves an invalid resolution manifest error to Node', async () => {
const f = fixture()
const resolution = await createRuntimeResolution({