Merge pull request #7951 from deepseek-harness/fix/desktop-update-progress-width
fix(desktop): 稳定下载进度宽度并补充检查更新反馈
This commit is contained in:
commit
04188f67e7
14 files changed
+168
-26
No files matched your search
@@ -57,8 +57,8 @@
|
||||
en: f1302ed4a3ab58bb
|
||||
zh: cb53b9b349815028
|
||||
/deepseek-harness-desktop/updates:
|
||||
en: 67bf0a18faa7aa76
|
||||
zh: 782e14ce62bb939f
|
||||
en: 8b0b9efeb83727ef
|
||||
zh: 5ee95de48ceab392
|
||||
/deepseek-harness-desktop/updates/local-desktop-settings:
|
||||
en: dbe57394894b7368
|
||||
zh: a3bb788c2ce631a4
|
||||
|
||||
@@ -278,11 +278,11 @@ Ordinary update dialogs use an isolated shell-origin WebContentsView inside the
|
||||
|
||||
Ordinary update dialogs, the Windows first-close notice, and mandatory-update documents share the UI design tokens bundled with the shell. Before displaying a card, each document resolves Electron's application theme through `prefers-color-scheme` and updates its dark-theme attribute when the palette changes. Welcome and update documents use the same initialization script. Explicit light/dark preferences and system appearance share this path; the isolated document keeps its background transparent, and dark mode adds a 1 CSS px token-based inset outline without changing the card size; light mode has no outline.
|
||||
|
||||
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.
|
||||
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. During downloading or verification, **Check for Updates…** shows the state at the time the dialog opens; it does not refresh as the operation advances. Downloading explains that the app remains usable; verification has no download-completion instruction. Closing the status dialog leaves the update running. If any ordinary shell prompt is already open, the action focuses it without replacing its pending decision; close it before requesting update status. Failure to create the status surface uses a native dialog with the same status text and controls; presentation failures are logged without changing the update operation. 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.
|
||||
|
||||
`DSH_DESKTOP_UPDATE_CHECK_INTERVAL_MS` configures the ordinary base interval, and `DSH_DESKTOP_UPDATE_CHECK_MAX_BACKOFF_MS` configures the cap; both accept integer milliseconds from 1000 through 2147483647, with the cap at least the interval. An omitted cap defaults to the larger of one hour and the interval. `DSH_DESKTOP_UPDATE_CHECK_JITTER` sets the fractional jitter from 0 through 1, defaulting to `0.2`; the final delay is at least one second and never exceeds the cap. These settings do not change mandatory-policy polling or authorize download retries.
|
||||
|
||||
The lower-left account row displays localized availability, a spinner with download percentage, verification, readiness, or a persistent red retry action with an accessible tooltip. This Web-embedded copy follows the active in-application language; native dialogs use the Desktop shell locale. The collapsed sidebar shows a dot on its top expand button. Connection status takes priority. Selecting an available release starts downloading immediately. Successful preparation automatically opens a shell-owned restart confirmation; closing it retains readiness without reopening the dialog. Selecting the ready entry opens confirmation again. Running agents, queued input, and running or stopping jobs trigger an interruption warning in that confirmation. API requests alone do not trigger the warning. After approval, the Host locks new requests, drains admitted requests, and rechecks tasks, including work created by an admitted write. A drain that exceeds the control-request deadline refuses installation and unlocks admission. Unknown task status, newly started work without interruption approval, or unsuccessful graceful teardown prevents installation. The Host exits as soon as it acknowledges completed teardown, so handles left open by plugins do not count against the graceful deadline. Ordinary quit asks about interruptible work as described under Closing the window and quitting, then hides the product window before stopping the Host, ignores new focus requests during teardown, and never installs an update. The next launch reconciles the version-bound runtime through the existing startup and recovery path.
|
||||
The lower-left account row displays localized availability, a spinner with download percentage, verification, readiness, or a persistent red retry action with an accessible tooltip. This Web-embedded copy follows the active in-application language; native dialogs use the Desktop shell locale. The collapsed sidebar shows a dot on its top expand button. Connection status takes priority. Selecting an available release starts downloading immediately. Selecting its download percentage opens the same status dialog as Check for Updates, explaining that downloading runs in the background and the app remains usable. Successful preparation automatically opens a shell-owned restart confirmation; closing it retains readiness without reopening the dialog. Selecting the ready entry opens confirmation again. Running agents, queued input, and running or stopping jobs trigger an interruption warning in that confirmation. API requests alone do not trigger the warning. After approval, the Host locks new requests, drains admitted requests, and rechecks tasks, including work created by an admitted write. A drain that exceeds the control-request deadline refuses installation and unlocks admission. Unknown task status, newly started work without interruption approval, or unsuccessful graceful teardown prevents installation. The Host exits as soon as it acknowledges completed teardown, so handles left open by plugins do not count against the graceful deadline. Ordinary quit asks about interruptible work as described under Closing the window and quitting, then hides the product window before stopping the Host, ignores new focus requests during teardown, and never installs an update. The next launch reconciles the version-bound runtime through the existing startup and recovery path.
|
||||
|
||||
If task teardown fails after confirmed Host exit, installation is refused and the shell restores the current-version Host before allowing another restart confirmation. Installer launch failure after a clean Host stop uses the same recovery. After the replacement Host authenticates, the shell reloads the existing application URL so the Web page obtains its current port, cookie, and boot injections; page-load failure opens native fatal recovery. An unconfirmed process exit never permits a replacement Host. The downloaded target remains available for retry. A known mandatory policy remains blocking throughout recovery; unsuccessful Host restoration opens the native fatal-recovery dialog.
|
||||
|
||||
|
||||
@@ -278,11 +278,11 @@ macOS 在安装准备阶段等待产品主窗口退出全屏,再显示重启
|
||||
|
||||
普通更新弹窗、Windows 首次关闭提示和强更文档共用随壳打包的 UI design token。各文档在显示卡片前通过 `prefers-color-scheme` 解析 Electron 的应用主题,配色变化时同步暗色主题属性。欢迎页与更新文档复用同一初始化脚本。明确选择浅色/暗色及跟随系统共用此路径,隔离文档保持透明背景,仅暗色模式以基于 token 的 1 CSS px 内描边区分卡片,不改变尺寸;浅色模式不描边。
|
||||
|
||||
打包应用在启动后异步检查固定 Nightly。常规轮询以十分钟为基础间隔,每次独立采样 ±20% 的随机抖动。每次检查失败将基础延迟翻倍,上限为一小时;成功后重置。随机延迟不超过该上限,并从全部复用调用结算后开始计时。本地化的“检查更新…”菜单项(Windows 可从顶栏的“应用”菜单进入)立即执行,并复用正在进行的检查。回到前台和系统恢复时遵守相同的单调时钟截止时间。新收到的强更策略也会立即请求检查更新清单。自动检查从不弹窗或下载安装包。手动检查显示正在检查、失败或包含已安装版本号的无更新反馈。常规更新弹窗原位渐入渐出;连续弹窗替换卡片内容并重置其滚动位置,保留黑色半透明蒙层,不模糊父页面。
|
||||
打包应用在启动后异步检查固定 Nightly。常规轮询以十分钟为基础间隔,每次独立采样 ±20% 的随机抖动。每次检查失败将基础延迟翻倍,上限为一小时;成功后重置。随机延迟不超过该上限,并从全部复用调用结算后开始计时。本地化的“检查更新…”菜单项(Windows 可从顶栏的“应用”菜单进入)立即执行,并复用正在进行的检查。回到前台和系统恢复时遵守相同的单调时钟截止时间。新收到的强更策略也会立即请求检查更新清单。自动检查从不弹窗或下载安装包。手动检查显示正在检查、失败或包含已安装版本号的无更新反馈。下载或校验期间,“**检查更新…**”显示打开提示时的状态,不随操作进展刷新。下载提示说明可以继续使用应用;校验提示不再包含下载完成的说明。关闭状态提示不影响更新。若已有普通壳弹窗,点击会聚焦该弹窗,保留其待确认操作;关闭后可再次查看更新状态。状态界面创建失败时,使用相同状态文案和按钮的原生弹窗兜底;展示失败仅记日志,不改变更新操作。常规更新弹窗原位渐入渐出;连续弹窗替换卡片内容并重置其滚动位置,保留黑色半透明蒙层,不模糊父页面。
|
||||
|
||||
`DSH_DESKTOP_UPDATE_CHECK_INTERVAL_MS` 配置常规基础间隔,`DSH_DESKTOP_UPDATE_CHECK_MAX_BACKOFF_MS` 配置上限;两者均接受 1000 至 2147483647 的整数毫秒数,且上限不能小于间隔。省略上限时取一小时与间隔中的较大值。`DSH_DESKTOP_UPDATE_CHECK_JITTER` 配置 0 至 1 的抖动比例,默认 `0.2`;最终延迟至少一秒,且不超过上限。这些配置不改变强更策略轮询,也不授权下载重试。
|
||||
|
||||
左下角账户行显示本地化的更新可用状态、加载图标与下载百分比、验证、就绪状态,或带可访问提示的持久红色重试操作。嵌入 Web 界面的文案跟随应用内当前语言;原生弹窗使用 Desktop 壳语言。侧栏收起时,顶部展开按钮显示圆点。连接状态优先展示。选择可用版本即开始下载。准备成功后自动打开壳拥有的重启确认;关闭后保留就绪状态,不重复弹窗。选择就绪入口可再次打开确认。运行中的 agent、排队输入,以及运行中或停止中的后台任务都会在该确认中触发中断警告。仅有 API 请求不会触发警告。用户批准后,Host 锁定新请求,等待已接收的请求结束,再检查任务,包括已接收写操作创建的工作。等待超过控制请求截止时间时,拒绝安装并解除准入锁。任务状态未知、未获中断授权的新任务,或未成功完成正常收尾,都会阻止安装。Host 确认收尾完成后立即退出,插件遗留的打开句柄不会占用正常收尾期限。常规退出先按"关闭窗口与退出"一节所述询问可中断的工作,再在停止 Host 前隐藏产品窗口,在收尾期间忽略新的聚焦请求,且从不安装更新。下次启动通过已有的启动与恢复流程校准版本绑定的运行时。
|
||||
左下角账户行显示本地化的更新可用状态、加载图标与下载百分比、验证、就绪状态,或带可访问提示的持久红色重试操作。嵌入 Web 界面的文案跟随应用内当前语言;原生弹窗使用 Desktop 壳语言。侧栏收起时,顶部展开按钮显示圆点。连接状态优先展示。选择可用版本即开始下载。点击下载百分比会打开与“检查更新”相同的状态提示,说明更新正在后台下载,可以继续使用应用。准备成功后自动打开壳拥有的重启确认;关闭后保留就绪状态,不重复弹窗。选择就绪入口可再次打开确认。运行中的 agent、排队输入,以及运行中或停止中的后台任务都会在该确认中触发中断警告。仅有 API 请求不会触发警告。用户批准后,Host 锁定新请求,等待已接收的请求结束,再检查任务,包括已接收写操作创建的工作。等待超过控制请求截止时间时,拒绝安装并解除准入锁。任务状态未知、未获中断授权的新任务,或未成功完成正常收尾,都会阻止安装。Host 确认收尾完成后立即退出,插件遗留的打开句柄不会占用正常收尾期限。常规退出先按"关闭窗口与退出"一节所述询问可中断的工作,再在停止 Host 前隐藏产品窗口,在收尾期间忽略新的聚焦请求,且从不安装更新。下次启动通过已有的启动与恢复流程校准版本绑定的运行时。
|
||||
|
||||
若任务收尾失败但已确认 Host 退出,安装会被拒绝,壳会在允许再次确认重启前恢复当前版本的 Host。Host 正常停止后的安装器启动失败使用同一恢复路径。替代 Host 启动并完成认证后,壳重新加载原有应用地址,让 Web 页面获取当前端口、Cookie 和启动注入数据;页面加载失败时打开原生致命故障恢复弹窗。未确认进程退出时,绝不允许启动替代 Host。已下载目标保留以供重试。已知强更策略在恢复过程中继续阻塞;Host 恢复失败打开原生致命故障恢复弹窗。
|
||||
|
||||
|
||||
@@ -138,6 +138,8 @@ export const en = {
|
||||
updateClose: 'Close',
|
||||
updateAcknowledge: 'OK',
|
||||
updateLater: 'Update later',
|
||||
updateDownloadInProgress: 'Downloading update',
|
||||
updateDownloadDetail: 'The update is downloading in the background. You can continue using the app.',
|
||||
updateDownloading: 'Downloading {percent}%…',
|
||||
updateVerifying: 'Verifying update files…',
|
||||
updateInstalling: 'Preparing to restart…',
|
||||
@@ -331,6 +333,8 @@ export const zh = {
|
||||
updateClose: '关闭',
|
||||
updateAcknowledge: '确定',
|
||||
updateLater: '稍后更新',
|
||||
updateDownloadInProgress: '正在下载更新',
|
||||
updateDownloadDetail: '更新正在后台下载,你可以继续使用应用。',
|
||||
updateDownloading: '正在下载 {percent}%…',
|
||||
updateVerifying: '正在校验更新文件…',
|
||||
updateInstalling: '正在准备重启…',
|
||||
@@ -522,6 +526,8 @@ export const ja = {
|
||||
updateClose: '閉じる',
|
||||
updateAcknowledge: 'OK',
|
||||
updateLater: '後でアップデート',
|
||||
updateDownloadInProgress: 'アップデートをダウンロード中',
|
||||
updateDownloadDetail: 'アップデートをバックグラウンドでダウンロードしています。アプリは引き続き使用できます。',
|
||||
updateDownloading: 'ダウンロード中 {percent}%…',
|
||||
updateVerifying: 'アップデートファイルを検証中…',
|
||||
updateInstalling: '再起動の準備中…',
|
||||
@@ -713,6 +719,8 @@ export const es = {
|
||||
updateClose: 'Cerrar',
|
||||
updateAcknowledge: 'OK',
|
||||
updateLater: 'Actualizar más tarde',
|
||||
updateDownloadInProgress: 'Descargando actualización',
|
||||
updateDownloadDetail: 'La actualización se está descargando en segundo plano. Puede seguir usando la aplicación.',
|
||||
updateDownloading: 'Descargando {percent}%…',
|
||||
updateVerifying: 'Verificando archivos de actualización...',
|
||||
updateInstalling: 'Preparándose para reiniciar…',
|
||||
@@ -904,6 +912,8 @@ export const fr = {
|
||||
updateClose: 'Fermer',
|
||||
updateAcknowledge: 'OK',
|
||||
updateLater: 'Mettre à jour plus tard',
|
||||
updateDownloadInProgress: 'Téléchargement de la mise à jour',
|
||||
updateDownloadDetail: 'La mise à jour se télécharge en arrière-plan. Vous pouvez continuer à utiliser l’application.',
|
||||
updateDownloading: 'Téléchargement de {percent}%…',
|
||||
updateVerifying: 'Vérification des fichiers de mise à jour…',
|
||||
updateInstalling: 'Préparation du redémarrage…',
|
||||
@@ -1095,6 +1105,8 @@ export const de = {
|
||||
updateClose: 'Schließen',
|
||||
updateAcknowledge: 'OK',
|
||||
updateLater: 'Später aktualisieren',
|
||||
updateDownloadInProgress: 'Update wird heruntergeladen',
|
||||
updateDownloadDetail: 'Das Update wird im Hintergrund heruntergeladen. Sie können die App weiter verwenden.',
|
||||
updateDownloading: '{percent}% wird heruntergeladen…',
|
||||
updateVerifying: 'Aktualisierungsdateien werden überprüft…',
|
||||
updateInstalling: 'Neustart wird vorbereitet…',
|
||||
@@ -1286,6 +1298,8 @@ export const ru = {
|
||||
updateClose: 'Закрыть',
|
||||
updateAcknowledge: 'OK',
|
||||
updateLater: 'Обновить позже',
|
||||
updateDownloadInProgress: 'Загрузка обновления',
|
||||
updateDownloadDetail: 'Обновление загружается в фоновом режиме. Вы можете продолжать пользоваться приложением.',
|
||||
updateDownloading: 'Загрузка {percent}%…',
|
||||
updateVerifying: 'Проверка файлов обновлений…',
|
||||
updateInstalling: 'Подготовка к перезапуску…',
|
||||
@@ -1477,6 +1491,8 @@ export const pt = {
|
||||
updateClose: 'Fechar',
|
||||
updateAcknowledge: 'OK',
|
||||
updateLater: 'Atualizar mais tarde',
|
||||
updateDownloadInProgress: 'Baixando atualização',
|
||||
updateDownloadDetail: 'A atualização está sendo baixada em segundo plano. Você pode continuar usando o aplicativo.',
|
||||
updateDownloading: 'Baixando {percent}%…',
|
||||
updateVerifying: 'Verificando arquivos de atualização…',
|
||||
updateInstalling: 'Preparando para reiniciar…',
|
||||
|
||||
@@ -1012,6 +1012,21 @@ async function main(): Promise<void> {
|
||||
if (authenticationOperation !== undefined) {
|
||||
policyAuth?.focus(); updateDialog.focus()
|
||||
}
|
||||
if (!isMandatory() && (updates.state.phase === 'downloading' || updates.state.phase === 'verifying')) {
|
||||
if (updateDialog.isOpen) {
|
||||
updateDialog.focus()
|
||||
return Promise.resolve()
|
||||
}
|
||||
const options = { type: 'info' as const, title: locale.messages.updateCheckTitle,
|
||||
message: updates.state.phase === 'downloading' ? locale.messages.updateDownloadInProgress : locale.messages.updateVerifying,
|
||||
detail: updates.state.phase === 'downloading' ? locale.messages.updateDownloadDetail : '', buttons: [locale.messages.updateClose], cancelId: 0,
|
||||
}
|
||||
return ordinaryMessageBox(options).then(() => {}).catch(async (error: unknown) => {
|
||||
console.error(error)
|
||||
try { await dialog.showMessageBox(options) }
|
||||
catch (nativeError) { console.error(nativeError) }
|
||||
})
|
||||
}
|
||||
let failedOperation: 'check' | 'download' | 'install' = 'check'
|
||||
promptOperation ??= Promise.resolve().then(async () => {
|
||||
if (manual) updateJournal?.action('check-requested')
|
||||
|
||||
@@ -2259,7 +2259,23 @@ describe('desktop main startup', () => {
|
||||
const action = invoke(DESKTOP_IPC.updatesOpen, 'app')
|
||||
await started.promise
|
||||
const repeated = invoke(DESKTOP_IPC.updatesOpen, 'app')
|
||||
expect(harness.dialog.showMessageBox).not.toHaveBeenCalled()
|
||||
await repeated
|
||||
expect(harness.dialog.showMessageBox).toHaveBeenCalledWith(expect.objectContaining({
|
||||
message: en.updateDownloadInProgress, detail: en.updateDownloadDetail,
|
||||
}))
|
||||
harness.dialog.showMessageBox.mockClear()
|
||||
const checks = harness.updateCheck.mock.calls.length
|
||||
const check = applicationMenuItems().find(item => item.label === en.checkUpdatesMenu)!.click as () => void
|
||||
check()
|
||||
await vi.waitFor(() => { expect(harness.dialog.showMessageBox).toHaveBeenCalledWith(expect.objectContaining({
|
||||
message: en.updateDownloadInProgress, detail: en.updateDownloadDetail, buttons: [en.updateClose],
|
||||
})) })
|
||||
expect(harness.updateCheck).toHaveBeenCalledTimes(checks)
|
||||
harness.shellDialog.isOpen = true
|
||||
check()
|
||||
expect(harness.shellDialog.focus).toHaveBeenCalled()
|
||||
expect(harness.dialog.showMessageBox).toHaveBeenCalledOnce()
|
||||
harness.shellDialog.isOpen = false
|
||||
expect(harness.updateDownload).toHaveBeenCalledExactlyOnceWith('1.0.1-nightly.1', 0)
|
||||
expect(harness.updateInstall).not.toHaveBeenCalled()
|
||||
downloading.resolve({ phase: 'ready', version: '1.0.1-nightly.1' })
|
||||
@@ -2269,6 +2285,60 @@ describe('desktop main startup', () => {
|
||||
expect(harness.updateInstall).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each(['downloading', 'verifying'] as const)('explains %s when checking from the menu without another update operation', async (phase) => {
|
||||
await readyForUpdate()
|
||||
harness.updateState = { phase, version: '1.0.1-nightly.1', percent: 21 }
|
||||
const checks = harness.updateCheck.mock.calls.length
|
||||
const check = applicationMenuItems().find(item => item.label === en.checkUpdatesMenu)!.click as () => void
|
||||
check()
|
||||
await vi.waitFor(() => { expect(harness.dialog.showMessageBox).toHaveBeenCalledWith(expect.objectContaining({
|
||||
message: phase === 'downloading' ? en.updateDownloadInProgress : en.updateVerifying,
|
||||
detail: phase === 'downloading' ? en.updateDownloadDetail : '', buttons: [en.updateClose], cancelId: 0,
|
||||
})) })
|
||||
expect(harness.updateCheck).toHaveBeenCalledTimes(checks)
|
||||
expect(harness.updateDownload).not.toHaveBeenCalled()
|
||||
expect(harness.updateInstall).not.toHaveBeenCalled()
|
||||
expect(harness.updateState.phase).toBe(phase)
|
||||
check()
|
||||
await vi.waitFor(() => { expect(harness.dialog.showMessageBox).toHaveBeenCalledTimes(2) })
|
||||
expect(harness.updateCheck).toHaveBeenCalledTimes(checks)
|
||||
expect(harness.updateDownload).not.toHaveBeenCalled()
|
||||
expect(harness.updateInstall).not.toHaveBeenCalled()
|
||||
expect(harness.updateState.phase).toBe(phase)
|
||||
})
|
||||
|
||||
it.each(['downloading', 'verifying'] as const)('falls back to native %s status without changing the message or restarting the update', async (phase) => {
|
||||
await readyForUpdate()
|
||||
harness.updateState = { phase, version: '1.0.1-nightly.1', percent: 21 }
|
||||
const checks = harness.updateCheck.mock.calls.length
|
||||
harness.dialog.showMessageBox.mockRejectedValueOnce(new Error('overlay unavailable'))
|
||||
await invoke(DESKTOP_IPC.updatesOpen, 'app')
|
||||
expect(harness.dialog.showMessageBox).toHaveBeenCalledTimes(2)
|
||||
const first: unknown = harness.dialog.showMessageBox.mock.calls[0]!.at(-1)
|
||||
expect(first).toEqual(expect.objectContaining(harness.dialog.showMessageBox.mock.calls[1]!.at(-1)))
|
||||
expect(first).toEqual(expect.objectContaining({
|
||||
type: 'info', message: phase === 'downloading' ? en.updateDownloadInProgress : en.updateVerifying,
|
||||
detail: phase === 'downloading' ? en.updateDownloadDetail : '', buttons: [en.updateClose],
|
||||
}))
|
||||
expect(console.error).toHaveBeenCalledWith(expect.objectContaining({ message: 'overlay unavailable' }))
|
||||
expect(harness.dialog.showErrorBox).not.toHaveBeenCalled()
|
||||
expect(harness.updateCheck).toHaveBeenCalledTimes(checks)
|
||||
expect(harness.updateDownload).not.toHaveBeenCalled()
|
||||
expect(harness.updateInstall).not.toHaveBeenCalled()
|
||||
expect(harness.updateState.phase).toBe(phase)
|
||||
})
|
||||
|
||||
it('logs native status fallback failure without rejecting the user action', async () => {
|
||||
await readyForUpdate()
|
||||
harness.updateState = { phase: 'downloading', version: '1.0.1-nightly.1', percent: 21 }
|
||||
harness.dialog.showMessageBox.mockRejectedValueOnce(new Error('overlay unavailable'))
|
||||
.mockRejectedValueOnce(new Error('native unavailable'))
|
||||
await expect(invoke(DESKTOP_IPC.updatesOpen, 'app')).resolves.toBeUndefined()
|
||||
expect(console.error).toHaveBeenCalledWith(expect.objectContaining({ message: 'native unavailable' }))
|
||||
expect(harness.dialog.showErrorBox).not.toHaveBeenCalled()
|
||||
expect(harness.updateState.phase).toBe('downloading')
|
||||
})
|
||||
|
||||
it('does not lock or stop tasks when restart confirmation is dismissed', async () => {
|
||||
const host = await readyForUpdate()
|
||||
harness.dialog.showMessageBox.mockResolvedValueOnce({ response: 1 })
|
||||
|
||||
@@ -74,11 +74,10 @@ describe('web e2e: Desktop update workspace chrome', () => {
|
||||
await publish(progress)
|
||||
const downloading = page.getByRole('button', { name: '58%', exact: true })
|
||||
await downloading.waitFor()
|
||||
expect(await downloading.getAttribute('aria-disabled')).toBe('true')
|
||||
expect(await downloading.getAttribute('aria-disabled')).toBe('false')
|
||||
expect(await downloading.locator('svg').count()).toBe(1)
|
||||
// Force dispatch past Playwright's aria-disabled guard to verify the product's click guard.
|
||||
await downloading.click({ force: true })
|
||||
expect(await opens()).toBe(1)
|
||||
await downloading.click()
|
||||
await expect.poll(opens).toBe(2)
|
||||
await page.screenshot({ path: join(evidence, 'downloading.png') })
|
||||
|
||||
const collapse = locale === 'zh-CN' ? '收起侧边栏' : 'Collapse sidebar'
|
||||
@@ -118,21 +117,21 @@ describe('web e2e: Desktop update workspace chrome', () => {
|
||||
await expect.poll(() => tooltip.evaluateAll(elements => elements.map(element => getComputedStyle(element).opacity))).toEqual(['1'])
|
||||
await page.screenshot({ path: join(evidence, 'retry.png') })
|
||||
await retry.click()
|
||||
await expect.poll(opens).toBe(2)
|
||||
await expect.poll(opens).toBe(3)
|
||||
|
||||
await publish(presentDesktopUpdate({ phase: 'ready', version }))
|
||||
const ready = page.getByRole('button', { name: readyLabel, exact: true })
|
||||
await ready.waitFor()
|
||||
expect(await ready.getAttribute('aria-disabled')).toBe('false')
|
||||
expect(await opens()).toBe(2)
|
||||
expect(await opens()).toBe(3)
|
||||
await page.screenshot({ path: join(evidence, 'ready.png') })
|
||||
await ready.click()
|
||||
await expect.poll(opens).toBe(3)
|
||||
await expect.poll(opens).toBe(4)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
expect(await page.evaluate(() => (window as FixtureWindow).updateFixture.listeners.size)).toBe(1)
|
||||
await writeFile(join(evidence, 'result.json'), JSON.stringify({ locale, geometry, updateBackground, failedBackground,
|
||||
passed: true, explicitActions: 3, carrier: 'substituted', host: 'real Web composition',
|
||||
passed: true, explicitActions: 4, carrier: 'substituted', host: 'real Web composition',
|
||||
electron: false, installerExecuted: false }, null, 2) + '\n')
|
||||
console.log(`Desktop workspace chrome evidence: ${evidence}`)
|
||||
} catch (error) {
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: 83760d53b3ee5fb8
|
||||
zh: 30dd8802d4ffdcb7
|
||||
/deepseek-ai-dsh-client-ui-settings-general/use-this-package:
|
||||
en: d427d20b4dab6653
|
||||
zh: 84e80592f0a30a9f
|
||||
en: 8ae337011190f5f3
|
||||
zh: 508eca60c6fcdc34
|
||||
/deepseek-ai-dsh-client-ui-settings-general/use-this-package/the-general-section:
|
||||
en: de35e867b2a56d73
|
||||
zh: 6d4e5c912636f889
|
||||
|
||||
@@ -39,7 +39,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 browsers below 768px the strip scrolls along its own axis the same way.
|
||||
|
||||
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.
|
||||
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 and uses tabular digits with space for three digits and the percent sign to keep its width stable. 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 the download percentage opens the shell’s download-status dialog; closing it leaves the download running. 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. The account menu opens General settings; closing the dialog clears the section selection. Onboarding can open a specified section. Initial focus lands on the selected section in the navigation, or on the title when no sections are available, without drawing a focus outline. The section buttons precede header actions and Close in the Tab order. 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.
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ kind: "package-reference"
|
||||
|
||||
当分区导航超出面板可用高度时,列表独立于设置内容滚动,并保持「设置」标题固定;浏览器宽度低于 768px 时,条带以同样方式沿自身轴滚动。
|
||||
|
||||
在 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 顺序中,分区按钮先于头部操作和关闭按钮。Tab 和方向键导航保留可见焦点提示。`Mod+/` 可在设置上方打开快捷键速查;`Escape` 关闭最上层弹窗,焦点返回触发该弹窗的控件。通过快捷键、Esc 或鼠标关闭后的回焦均不绘制外轮廓线。
|
||||
|
||||
|
||||
@@ -20,6 +20,12 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.progress {
|
||||
min-inline-size: calc(3ch + 1em);
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
|
||||
@@ -73,14 +73,15 @@ export function DesktopUpdateIndicator({ wide, hidden, t, view, onOpen }: {
|
||||
const label = failed ? retryLabel : copy.label
|
||||
const error = failed || state?.phase === 'error' || state?.configurationError !== undefined
|
||||
const busy = opening || (state !== undefined && state.configurationError === undefined && BUSY_PHASES.has(state.phase))
|
||||
const disabled = opening || (busy && state?.phase !== 'downloading')
|
||||
return <Tooltip label={failed ? retryLabel : copy.detail} side="top">
|
||||
<button type="button" className={css.indicator}
|
||||
aria-label={label} aria-disabled={busy} onClick={() => { if (!busy) onOpen() }}>
|
||||
aria-label={label} aria-disabled={disabled} onClick={() => { if (!disabled) onOpen() }}>
|
||||
<span className={css.icon} aria-hidden="true">
|
||||
{error ? <span className={css.errorDot} />
|
||||
: busy ? <IconLoadingOutlineRegular className={css.spinner} size={14} /> : <IconDownloadOutlineRegular size={14} />}
|
||||
</span>
|
||||
<span>{label}</span>
|
||||
<span className={!error && state?.phase === 'downloading' ? css.progress : undefined}>{label}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ export class DesktopUpdateSource {
|
||||
readonly store = createSnapshotStore<DesktopUpdateView>({ failed: false, opening: false })
|
||||
private live = true
|
||||
private received = false
|
||||
private openingOperation: symbol | undefined
|
||||
private readonly unsubscribe: (() => void) | undefined
|
||||
|
||||
/** @param bridge - Optional isolated Electron API, absent in ordinary browsers. */
|
||||
@@ -15,7 +16,10 @@ export class DesktopUpdateSource {
|
||||
this.unsubscribe = bridge?.subscribe((presentation) => {
|
||||
if (!this.live) return
|
||||
this.received = true
|
||||
this.store.set({ ...this.store.getSnapshot(), presentation, failed: false })
|
||||
const previous = this.store.getSnapshot()
|
||||
const startedDownload = presentation.phase === 'downloading' && previous.presentation?.phase !== 'downloading'
|
||||
if (startedDownload) this.openingOperation = undefined
|
||||
this.store.set({ ...previous, presentation, failed: false, opening: startedDownload ? false : previous.opening })
|
||||
})
|
||||
void bridge?.status().then((presentation) => {
|
||||
if (this.live && !this.received) this.store.set({ ...this.store.getSnapshot(), presentation })
|
||||
@@ -29,12 +33,17 @@ export class DesktopUpdateSource {
|
||||
if (!this.live || this.bridge === undefined) return
|
||||
const state = this.store.getSnapshot()
|
||||
if (state.opening || (state.presentation !== undefined && state.presentation.configurationError === undefined
|
||||
&& ['checking', 'downloading', 'verifying', 'installing'].includes(state.presentation.phase))) return
|
||||
&& ['checking', 'verifying', 'installing'].includes(state.presentation.phase))) return
|
||||
const operation = Symbol()
|
||||
this.openingOperation = operation
|
||||
this.store.set({ ...state, opening: true })
|
||||
void this.bridge.open().catch(() => {
|
||||
if (this.live) this.store.set({ ...this.store.getSnapshot(), failed: true })
|
||||
if (this.live && this.openingOperation === operation) this.store.set({ ...this.store.getSnapshot(), failed: true })
|
||||
}).finally(() => {
|
||||
if (this.live) this.store.set({ ...this.store.getSnapshot(), opening: false })
|
||||
if (this.live && this.openingOperation === operation) {
|
||||
this.openingOperation = undefined
|
||||
this.store.set({ ...this.store.getSnapshot(), opening: false })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -71,8 +71,10 @@ it('keeps the newest event, hides for connection priority, and invokes only the
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
await f.emit({ phase: 'downloading', version: available.version, percent: 58 })
|
||||
f.view.rerender(<f.Indicator />)
|
||||
expect(screen.getByRole('button', { name: '58%' }).getAttribute('aria-disabled')).toBe('false')
|
||||
fireEvent.click(screen.getByRole('button', { name: '58%' }))
|
||||
expect(f.open).toHaveBeenCalledOnce()
|
||||
await act(async () => {})
|
||||
expect(f.open).toHaveBeenCalledTimes(2)
|
||||
await f.emit({ phase: 'error', version: available.version, failure: 'download' })
|
||||
const retry = screen.getByRole('button', { name: '重试更新' })
|
||||
fireEvent.focus(retry)
|
||||
@@ -214,3 +216,27 @@ it('keeps a configuration error actionable while download progress continues', a
|
||||
f.view.unmount()
|
||||
f.status.resolve({ phase: 'idle' })
|
||||
})
|
||||
|
||||
it('opens download status while the original download action remains pending and coalesces status clicks', async () => {
|
||||
const f = fixture()
|
||||
const download = Promise.withResolvers<undefined>()
|
||||
const statusDialog = Promise.withResolvers<undefined>()
|
||||
try {
|
||||
await act(async () => { f.status.resolve(available) })
|
||||
f.open.mockImplementationOnce(() => download.promise).mockImplementationOnce(() => statusDialog.promise)
|
||||
fireEvent.click(screen.getByRole('button', { name: '新版本' }))
|
||||
await f.emit({ phase: 'downloading', percent: 12, version: available.version })
|
||||
const progress = screen.getByRole('button', { name: '12%' })
|
||||
fireEvent.click(progress)
|
||||
fireEvent.click(progress)
|
||||
expect(f.open).toHaveBeenCalledTimes(2)
|
||||
await act(async () => { download.resolve(undefined) })
|
||||
expect(progress.getAttribute('aria-disabled')).toBe('true')
|
||||
await act(async () => { statusDialog.resolve(undefined) })
|
||||
expect(progress.getAttribute('aria-disabled')).toBe('false')
|
||||
} finally {
|
||||
f.view.unmount()
|
||||
download.resolve(undefined)
|
||||
statusDialog.resolve(undefined)
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user