diff --git a/dsh-plugin-desktop/src/client/ExtendedTitlebar.tsx b/dsh-plugin-desktop/src/client/ExtendedTitlebar.tsx index 4733daf651..34ec25022e 100644 --- a/dsh-plugin-desktop/src/client/ExtendedTitlebar.tsx +++ b/dsh-plugin-desktop/src/client/ExtendedTitlebar.tsx @@ -1,18 +1,27 @@ /** Independent Desktop frame portalled above the upstream content viewport. */ import { createPortal } from 'react-dom' +import { RefreshCw } from 'lucide-react' +import { useState } from 'react' import type { InjectFace, PropsLocale, PropsRuntime, } from '@deepseek-ai/dsh-client-ui-slots' import type { DesktopSettingsApi } from './desktop-settings-api.ts' import type { DesktopClientEnvironment } from './environment.ts' import { DesktopNativeActions } from './DesktopNativeActions.tsx' +import { Button } from '../native-ui/components/ui/button.tsx' +import type { DesktopSettingsLocaleKey } from './desktop-settings-locales.ts' +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from '../native-ui/components/ui/hover-card.tsx' export interface DesktopFrameTitlebarInjected { readonly environment: DesktopClientEnvironment readonly api: Pick< DesktopSettingsApi, - 'openTerminal' | 'restart' | 'restartToRecovery' | 'reloadRenderer' | 'toggleDeveloperTools' + 'openTerminal' | 'restart' | 'restartToRecovery' | 'reloadRenderer' | 'toggleDeveloperTools' | 'checkForUpdates' > } @@ -20,6 +29,57 @@ export type DesktopFrameTitlebarProps = PropsRuntime<'shell.overlay'> & PropsLocale<'desktop.settings'> & InjectFace +export function DesktopVersionControl({ + version, + checkForUpdates, + t, +}: { + readonly version: string + readonly checkForUpdates: () => Promise + readonly t: (key: DesktopSettingsLocaleKey) => string +}) { + const [checking, setChecking] = useState(false) + const [failed, setFailed] = useState(false) + const runCheck = (): void => { + if (checking) return + setChecking(true) + setFailed(false) + void checkForUpdates() + .catch(() => { setFailed(true) }) + .finally(() => { setChecking(false) }) + } + const visibleVersion = `v${version}` + return ( + + } + aria-label={`${t('currentVersion')} ${visibleVersion}`} + > + {visibleVersion} + + +
+ {t('currentVersion')} + {visibleVersion} +
+ + {failed && {t('checkForUpdatesError')}} +
+
+ ) +} + /** Horizontal frame surface; the unrelated upstream content starts below it. */ export function DesktopFrameTitlebar({ api, environment, t }: DesktopFrameTitlebarProps) { return createPortal(( @@ -31,6 +91,7 @@ export function DesktopFrameTitlebar({ api, environment, t }: DesktopFrameTitleb >
DSH Desktop + {t(environment.mode === 'compatibility' ? 'compatibilityMode' : 'extendedMode')} diff --git a/dsh-plugin-desktop/src/client/contracts.ts b/dsh-plugin-desktop/src/client/contracts.ts index 73e47b6eb9..9e742c6d4a 100644 --- a/dsh-plugin-desktop/src/client/contracts.ts +++ b/dsh-plugin-desktop/src/client/contracts.ts @@ -36,6 +36,8 @@ export interface DesktopWindowDragRegion { /** Generation-stable native window geometry exposed to Desktop client plugins. */ export interface DesktopWindowService { + /** Installed Desktop product version for this renderer generation. */ + readonly version: string readonly mode: 'compatibility' | 'extended' | 'advanced' readonly platform: 'darwin' | 'win32' | 'linux' /** Capability-gated native material active behind this renderer. */ diff --git a/dsh-plugin-desktop/src/client/desktop-settings-api.ts b/dsh-plugin-desktop/src/client/desktop-settings-api.ts index 6f5ead54a1..4a644ee77d 100644 --- a/dsh-plugin-desktop/src/client/desktop-settings-api.ts +++ b/dsh-plugin-desktop/src/client/desktop-settings-api.ts @@ -10,6 +10,7 @@ const RESTART_PATH = '/api/desktop/restart' const RECOVERY_RESTART_PATH = '/api/desktop/restart/recovery' const RENDERER_RELOAD_PATH = '/api/desktop/developer/reload' const DEVELOPER_TOOLS_TOGGLE_PATH = '/api/desktop/developer/devtools' +const UPDATE_CHECK_PATH = '/api/desktop/updates/check' const MAX_PROFILES = 256 const MAX_PROFILE_NAME_LENGTH = 255 @@ -57,6 +58,7 @@ export interface DesktopSettingsApi { restartToRecovery(): Promise reloadRenderer(): Promise toggleDeveloperTools(): Promise + checkForUpdates(): Promise } type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise @@ -199,6 +201,9 @@ export function createDesktopSettingsApi(fetcher: FetchLike = globalThis.fetch.b async toggleDeveloperTools() { parseDesktopActionAcceptance(await readResponse(await post(fetcher, DEVELOPER_TOOLS_TOGGLE_PATH, {}))) }, + async checkForUpdates() { + parseDesktopActionAcceptance(await readResponse(await post(fetcher, UPDATE_CHECK_PATH, {}))) + }, }) } @@ -213,4 +218,5 @@ export const desktopSettingsPaths = Object.freeze({ recoveryRestart: RECOVERY_RESTART_PATH, rendererReload: RENDERER_RELOAD_PATH, developerToolsToggle: DEVELOPER_TOOLS_TOGGLE_PATH, + updateCheck: UPDATE_CHECK_PATH, }) diff --git a/dsh-plugin-desktop/src/client/desktop-settings-locales.ts b/dsh-plugin-desktop/src/client/desktop-settings-locales.ts index 96fa8d9085..bcf8a26bc5 100644 --- a/dsh-plugin-desktop/src/client/desktop-settings-locales.ts +++ b/dsh-plugin-desktop/src/client/desktop-settings-locales.ts @@ -11,6 +11,10 @@ export const zh = { openTerminal: '打开 DSH 终端', openingTerminal: '正在打开…', openTerminalError: '暂时无法打开 DSH 终端。你仍可以从应用菜单中打开。', + currentVersion: '当前版本', + checkForUpdates: '检查更新', + checkingForUpdates: '正在检查…', + checkForUpdatesError: '暂时无法检查更新,请稍后再试。', restartDesktop: '重启', restartingDesktop: '正在重启…', restartOptions: '重启选项', @@ -89,6 +93,10 @@ export const en: Record = { openTerminal: 'Open DSH Terminal', openingTerminal: 'Opening…', openTerminalError: 'DSH Terminal could not be opened just now. You can still open it from the application menu.', + currentVersion: 'Current version', + checkForUpdates: 'Check for updates', + checkingForUpdates: 'Checking…', + checkForUpdatesError: 'Updates could not be checked just now. Please try again shortly.', restartDesktop: 'Restart', restartingDesktop: 'Restarting…', restartOptions: 'Restart options', diff --git a/dsh-plugin-desktop/src/client/environment.ts b/dsh-plugin-desktop/src/client/environment.ts index 1e11ec13a6..0e2825cf63 100644 --- a/dsh-plugin-desktop/src/client/environment.ts +++ b/dsh-plugin-desktop/src/client/environment.ts @@ -9,6 +9,8 @@ export type DesktopClientMaterial = 'off' | 'transparent' | 'acrylic' | 'mica' /** Validated renderer environment supplied by the Electron Host. */ export interface DesktopClientEnvironment { + /** Installed Desktop product version supplied by the Electron Host. */ + version: string /** Active shell mode for this BrowserWindow lifetime. */ mode: DesktopClientMode /** Electron Host platform used for native spacing and drag regions. */ @@ -22,6 +24,7 @@ export interface DesktopClientEnvironment { const MODES = new Set(['compatibility', 'extended', 'advanced']) const PLATFORMS = new Set(['darwin', 'win32', 'linux']) const MATERIALS = new Set(['off', 'transparent', 'acrylic', 'mica']) +const VERSION_PATTERN = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u /** * Validate the Electron-owned query marker before any desktop client effects run. @@ -33,6 +36,7 @@ export function parseDesktopClientEnvironment(search: string): DesktopClientEnvi const mode = params.get('dsh-desktop-mode') const platform = params.get('dsh-desktop-platform') const material = params.get('dsh-desktop-material') + const version = params.get('dsh-desktop-version') if (mode === null && platform === null) return undefined if (!MODES.has(mode as DesktopClientMode)) { throw new Error(`dsh-plugin-desktop: invalid or missing dsh-desktop-mode ${JSON.stringify(mode)}`) @@ -43,6 +47,9 @@ export function parseDesktopClientEnvironment(search: string): DesktopClientEnvi if (!MATERIALS.has(material as DesktopClientMaterial)) { throw new Error(`dsh-plugin-desktop: invalid or missing dsh-desktop-material ${JSON.stringify(material)}`) } + if (version === null || version.length > 64 || !VERSION_PATTERN.test(version)) { + throw new Error(`dsh-plugin-desktop: invalid or missing dsh-desktop-version ${JSON.stringify(version)}`) + } const micaMarker = params.get('dsh-desktop-mica') const micaSupported = platform === 'win32' ? micaMarker === '1' ? true : micaMarker === '0' ? false : undefined @@ -57,6 +64,7 @@ export function parseDesktopClientEnvironment(search: string): DesktopClientEnvi throw new Error('dsh-plugin-desktop: renderer material is incompatible with its mode or platform') } return { + version, mode: mode as DesktopClientMode, platform: platform as DesktopClientPlatform, material: material as DesktopClientMaterial, diff --git a/dsh-plugin-desktop/src/client/extended-styles.ts b/dsh-plugin-desktop/src/client/extended-styles.ts index 1944b62421..0fae6ea9ea 100644 --- a/dsh-plugin-desktop/src/client/extended-styles.ts +++ b/dsh-plugin-desktop/src/client/extended-styles.ts @@ -117,6 +117,97 @@ body[data-dsh-desktop-mode="extended"]:not([data-dsh-desktop-material="off"]) { pointer-events: none; } .dshDesktopFrameProduct { font-size: 13px; font-weight: 600; white-space: nowrap; } +.dshDesktopFrameVersion { + min-height: 22px; + padding: 2px 5px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--dsw-alias-label-secondary); + cursor: default; + font: inherit; + font-size: 11px; + pointer-events: auto; + white-space: nowrap; + -webkit-app-region: no-drag; +} +.dshDesktopFrameVersion:hover, +.dshDesktopFrameVersion[data-popup-open], +.dshDesktopFrameVersion:focus-visible { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-primary); +} +.dshDesktopFrameVersion:focus-visible { + outline: 2px solid var(--dsw-alias-brand-primary); + outline-offset: 1px; +} +.dshShadcnHoverCardPositioner { + z-index: 2147483647; + outline: none; + -webkit-app-region: no-drag; +} +.dshShadcnHoverCardContent { + transform-origin: var(--transform-origin); + transition: opacity 120ms ease, transform 120ms ease; +} +.dshShadcnHoverCardContent[data-starting-style], +.dshShadcnHoverCardContent[data-ending-style] { + opacity: 0; + transform: scale(.98); +} +.dshDesktopVersionPopover { + display: grid; + gap: 9px; + box-sizing: border-box; + width: 210px; + padding: 10px; + border: 1px solid var(--dsw-alias-border-l1); + border-radius: 10px; + background: var(--dsw-alias-bg-layer-1); + box-shadow: 0 12px 32px color-mix(in srgb, #000 28%, transparent); + -webkit-app-region: no-drag; +} +.dshDesktopVersionPopoverHeader { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + color: var(--dsw-alias-label-secondary); + font-size: 11px; +} +.dshDesktopVersionPopoverHeader strong { + color: var(--dsw-alias-label-primary); + font-size: 12px; + font-weight: 600; +} +.dshDesktopVersionCheckButton { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + min-height: 30px; + padding: 5px 9px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 7px; + background: color-mix(in srgb, var(--dsw-alias-bg-base) 48%, transparent); + color: var(--dsw-alias-label-primary); + cursor: pointer; + font: inherit; + font-size: 12px; + -webkit-app-region: no-drag; +} +.dshDesktopVersionCheckButton:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); } +.dshDesktopVersionCheckButton:focus-visible { + outline: 2px solid var(--dsw-alias-brand-primary); + outline-offset: 1px; +} +.dshDesktopVersionCheckButton:disabled { cursor: default; opacity: .55; } +.dshDesktopVersionCheckButton svg { width: 14px; height: 14px; stroke-width: 1.8; } +.dshDesktopVersionCheckError { + color: var(--dsw-alias-state-error-primary); + font-size: 11px; + line-height: 1.4; +} .dshDesktopFrameMode { padding: 2px 8px; border: 1px solid var(--dsw-alias-border-l2); diff --git a/dsh-plugin-desktop/src/desktop-settings-contract.ts b/dsh-plugin-desktop/src/desktop-settings-contract.ts index c4d5e744d4..bccb3fba7a 100644 --- a/dsh-plugin-desktop/src/desktop-settings-contract.ts +++ b/dsh-plugin-desktop/src/desktop-settings-contract.ts @@ -32,6 +32,9 @@ export const DESKTOP_RENDERER_RELOAD_PATH = '/api/desktop/developer/reload' /** Toggle the mounted window's Developer Tools through the launcher. */ export const DESKTOP_DEVELOPER_TOOLS_TOGGLE_PATH = '/api/desktop/developer/devtools' +/** Run the generation-owned manual update check. */ +export const DESKTOP_UPDATE_CHECK_PATH = '/api/desktop/updates/check' + /** Export one local diagnostic archive through the launcher-owned flow. */ export const DESKTOP_DIAGNOSTICS_EXPORT_PATH = '/api/desktop/diagnostics/export' @@ -148,6 +151,14 @@ export interface DesktopDeveloperToolsToggleResponse { readonly accepted: true } +/** Exact empty body accepted by the manual update-check endpoint. */ +export type DesktopUpdateCheckRequest = Readonly> + +/** Successful completion of the interactive update-check flow. */ +export interface DesktopUpdateCheckResponse { + readonly accepted: true +} + /** Exact empty body accepted by the diagnostic-export endpoint. */ export type DesktopDiagnosticsExportRequest = Readonly> diff --git a/dsh-plugin-desktop/src/desktop-settings-route.ts b/dsh-plugin-desktop/src/desktop-settings-route.ts index fb64ccbad0..5b6f671c88 100644 --- a/dsh-plugin-desktop/src/desktop-settings-route.ts +++ b/dsh-plugin-desktop/src/desktop-settings-route.ts @@ -420,6 +420,30 @@ export async function handleDesktopDeveloperToolsToggleRequest( } } +/** Run the generation-owned interactive update flow from an exact empty request. */ +export async function handleDesktopUpdateCheckRequest( + req: IncomingMessage, + res: ServerResponse, + expectedOrigin: string, + checkNow: () => Promise, + reportError: (operation: string, cause: unknown) => void = () => {}, +): Promise { + if (req.method !== 'POST') return finishJson(res, 405, error('method not allowed'), 'POST') + if (!isSameOriginLoopbackRequest(req, expectedOrigin, true)) { + return finishJson(res, 403, error('forbidden')) + } + const value = await parsePostBody(req, res) + if (value === INVALID_BODY) return + if (!isEmptyRequest(value)) return finishJson(res, 400, error('invalid update check request')) + try { + await checkNow() + finishJson(res, 200, { accepted: true }) + } catch (cause) { + reportError('check for updates', cause) + finishJson(res, 500, error('updates could not be checked')) + } +} + /** Export diagnostics from an exact empty same-origin request. */ export async function handleDesktopDiagnosticsExportRequest( req: IncomingMessage, diff --git a/dsh-plugin-desktop/src/index.ts b/dsh-plugin-desktop/src/index.ts index 076c439343..beea11c668 100644 --- a/dsh-plugin-desktop/src/index.ts +++ b/dsh-plugin-desktop/src/index.ts @@ -149,12 +149,14 @@ export function desktopRendererUrl( port: number, mode: DesktopShellMode, platform: Context['desktopRuntime']['platform'], + appVersion: string, material: DesktopWindowMaterial = 'off', windowsBuild?: number, ): string { const url = new URL(`http://127.0.0.1:${String(port)}/`) url.searchParams.set('dsh-desktop-mode', mode) url.searchParams.set('dsh-desktop-platform', platform) + url.searchParams.set('dsh-desktop-version', appVersion) url.searchParams.set('dsh-desktop-material', material) if (mode === 'extended' || (mode === 'compatibility' && platform !== 'linux')) { // Body-level plugin portals do not inherit the framed root's geometry. @@ -345,7 +347,14 @@ export function apply(ctx: Context, config: Config): void { ...config, material, ...(runtime.windowsBuild === undefined ? {} : { windowsBuild: runtime.windowsBuild }), - url: desktopRendererUrl(ctx.webServer.port, config.mode, runtime.platform, material, runtime.windowsBuild), + url: desktopRendererUrl( + ctx.webServer.port, + config.mode, + runtime.platform, + runtime.updates.currentVersion, + material, + runtime.windowsBuild, + ), productName: 'DSH Desktop', windowTitle: 'DeepSeek Harness Desktop', iconPath, diff --git a/dsh-plugin-desktop/src/native-ui/components/ui/hover-card.tsx b/dsh-plugin-desktop/src/native-ui/components/ui/hover-card.tsx new file mode 100644 index 0000000000..131befd83d --- /dev/null +++ b/dsh-plugin-desktop/src/native-ui/components/ui/hover-card.tsx @@ -0,0 +1,45 @@ +import { PreviewCard as HoverCardPrimitive } from '@base-ui/react/preview-card' +import { cn } from '../../lib/utils.ts' + +function HoverCard(props: HoverCardPrimitive.Root.Props): JSX.Element { + return +} + +function HoverCardTrigger(props: HoverCardPrimitive.Trigger.Props): JSX.Element { + return +} + +function HoverCardContent({ + align = 'center', + alignOffset = 0, + className, + side = 'bottom', + sideOffset = 6, + ...props +}: HoverCardPrimitive.Popup.Props & Pick< + HoverCardPrimitive.Positioner.Props, + 'align' | 'alignOffset' | 'side' | 'sideOffset' +>): JSX.Element { + return ( + + + + + + ) +} + +export { HoverCard, HoverCardContent, HoverCardTrigger } diff --git a/dsh-plugin-desktop/src/update-lifecycle.ts b/dsh-plugin-desktop/src/update-lifecycle.ts index 4d6256cbe7..79c39163c4 100644 --- a/dsh-plugin-desktop/src/update-lifecycle.ts +++ b/dsh-plugin-desktop/src/update-lifecycle.ts @@ -36,6 +36,8 @@ export interface DesktopUpdateLifecycleOptions { /** Lifecycle handle for one generation's update operations. */ export interface DesktopUpdateLifecycle { + /** Run the same interactive update flow exposed by the native tray. */ + checkNow(): Promise dispose(): Promise } @@ -81,7 +83,7 @@ class DesktopUpdateLifecycleOwner implements DesktopUpdateLifecycle { group: 'status', order: 10, label: () => this.trayLabel(), - invoke: () => this.runManualCheck(), + invoke: () => this.checkNow(), }) if (options.adapter.isPackaged && options.policy.enabled) { this.scheduleBackgroundCheck(options.policy.initialDelayMs) @@ -103,6 +105,10 @@ class DesktopUpdateLifecycleOwner implements DesktopUpdateLifecycle { return this.disposeTask } + checkNow(): Promise { + return this.runManualCheck() + } + private async loadState(): Promise { try { const parsed = parseState(await readState(this.options.adapter.statePath)) diff --git a/dsh-plugin-desktop/src/updates.ts b/dsh-plugin-desktop/src/updates.ts index 31c8dd746d..ef022ffa3d 100644 --- a/dsh-plugin-desktop/src/updates.ts +++ b/dsh-plugin-desktop/src/updates.ts @@ -2,6 +2,9 @@ import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' +import type {} from '@deepseek-ai/dsh-host-webserver' +import { DESKTOP_UPDATE_CHECK_PATH } from './desktop-settings-contract.ts' +import { handleDesktopUpdateCheckRequest } from './desktop-settings-route.ts' import type {} from './runtime.ts' import { startDesktopUpdateLifecycle } from './update-lifecycle.ts' @@ -9,7 +12,7 @@ import { startDesktopUpdateLifecycle } from './update-lifecycle.ts' export const name = 'desktop-updates' /** Native adapter required for network, tray, confirmation, and installer access. */ -export const inject = ['desktopRuntime'] +export const inject = ['desktopRuntime', 'webServer'] const MAX_TIMER_DELAY_MS = 2_147_483_647 @@ -46,6 +49,25 @@ export function apply(ctx: Context, config: Config): void { locale: () => ctx.desktopRuntime.locale, registerTrayItem: item => ctx.desktopRuntime.registerTrayItem(item), }) - return () => lifecycle.dispose() + const rendererOrigin = `http://127.0.0.1:${String(ctx.webServer.port)}` + const unregister = ctx.webServer.register({ + kind: 'exact', + path: DESKTOP_UPDATE_CHECK_PATH, + handler: (req, res) => handleDesktopUpdateCheckRequest( + req, + res, + rendererOrigin, + () => lifecycle.checkNow(), + (operation, cause) => { + ctx.logger.error( + `dsh-plugin-desktop: failed to ${operation}: ${cause instanceof Error ? cause.message : String(cause)}`, + ) + }, + ), + }) + return async () => { + unregister() + await lifecycle.dispose() + } }, 'dsh-plugin-desktop: update polling, confirmation, and installer handoff') } diff --git a/dsh-plugin-desktop/tests/client-desktop-settings.spec.ts b/dsh-plugin-desktop/tests/client-desktop-settings.spec.ts index 1796264040..f5084aa75f 100644 --- a/dsh-plugin-desktop/tests/client-desktop-settings.spec.ts +++ b/dsh-plugin-desktop/tests/client-desktop-settings.spec.ts @@ -7,6 +7,7 @@ import { DesktopNativeActions, DesktopRestartMenuItems, } from '../src/client/DesktopNativeActions.tsx' +import { DesktopVersionControl } from '../src/client/ExtendedTitlebar.tsx' import { DesktopSettingsSection } from '../src/client/DesktopSettingsSection.tsx' import { DesktopTerminalSettingsAction } from '../src/client/DesktopTerminalSettingsAction.tsx' import { @@ -67,7 +68,8 @@ describe('Desktop settings API', () => { || path === desktopSettingsPaths.restart || path === desktopSettingsPaths.recoveryRestart || path === desktopSettingsPaths.rendererReload - || path === desktopSettingsPaths.developerToolsToggle) { + || path === desktopSettingsPaths.developerToolsToggle + || path === desktopSettingsPaths.updateCheck) { return json({ accepted: true }) } return path === desktopSettingsPaths.settings || path === desktopSettingsPaths.profileCreate || path === desktopSettingsPaths.profileDelete @@ -86,6 +88,7 @@ describe('Desktop settings API', () => { await expect(api.restartToRecovery()).resolves.toBeUndefined() await expect(api.reloadRenderer()).resolves.toBeUndefined() await expect(api.toggleDeveloperTools()).resolves.toBeUndefined() + await expect(api.checkForUpdates()).resolves.toBeUndefined() expect(fetcher.mock.calls.map(call => call[0])).toEqual([ desktopSettingsPaths.settings, @@ -98,6 +101,7 @@ describe('Desktop settings API', () => { desktopSettingsPaths.recoveryRestart, desktopSettingsPaths.rendererReload, desktopSettingsPaths.developerToolsToggle, + desktopSettingsPaths.updateCheck, ]) expect(fetcher.mock.calls[1]?.[1]).toMatchObject({ method: 'POST', @@ -147,6 +151,7 @@ describe('Desktop native action presentation', () => { restartToRecovery: vi.fn(async () => {}), reloadRenderer: vi.fn(async () => {}), toggleDeveloperTools: vi.fn(async () => {}), + checkForUpdates: vi.fn(async () => {}), } const t = (key: DesktopSettingsLocaleKey): string => en[key] @@ -163,6 +168,18 @@ describe('Desktop native action presentation', () => { expect(markup).toContain('aria-label="Developer options"') }) + it('renders the Host-supplied version through the shadcn hover-card trigger', () => { + const markup = renderToStaticMarkup(createElement(DesktopVersionControl, { + version: '2.0.3', + checkForUpdates: api.checkForUpdates, + t, + })) + + expect(markup).toContain('v2.0.3') + expect(markup).toContain('aria-label="Current version v2.0.3"') + expect(markup).toContain('data-slot="hover-card-trigger"') + }) + it('keeps explicit text labels in settings', () => { const markup = renderToStaticMarkup(createElement(DesktopNativeActions, { api, @@ -259,6 +276,7 @@ describe('Desktop settings Slot registration', () => { } as unknown as ClientContext applyDesktopSettings(ctx, { + version: '2.0.3', mode: 'compatibility', platform: 'darwin', material: 'off', diff --git a/dsh-plugin-desktop/tests/client-environment.spec.ts b/dsh-plugin-desktop/tests/client-environment.spec.ts index 30f2efeec4..ebae61e1a3 100644 --- a/dsh-plugin-desktop/tests/client-environment.spec.ts +++ b/dsh-plugin-desktop/tests/client-environment.spec.ts @@ -38,12 +38,12 @@ describe('desktop client environment', () => { }) it('accepts the Electron-owned kebab query markers', () => { - expect(parseDesktopClientEnvironment('?dsh-desktop-mode=advanced&dsh-desktop-platform=darwin&dsh-desktop-material=transparent')) - .toEqual({ mode: 'advanced', platform: 'darwin', material: 'transparent', micaSupported: false }) - expect(parseDesktopClientEnvironment('?dsh-desktop-platform=win32&dsh-desktop-mode=compatibility&dsh-desktop-material=off&dsh-desktop-mica=0')) - .toEqual({ mode: 'compatibility', platform: 'win32', material: 'off', micaSupported: false }) - expect(parseDesktopClientEnvironment('?dsh-desktop-mode=extended&dsh-desktop-platform=win32&dsh-desktop-material=mica&dsh-desktop-mica=1')) - .toEqual({ mode: 'extended', platform: 'win32', material: 'mica', micaSupported: true }) + expect(parseDesktopClientEnvironment('?dsh-desktop-mode=advanced&dsh-desktop-platform=darwin&dsh-desktop-version=2.0.3&dsh-desktop-material=transparent')) + .toEqual({ version: '2.0.3', mode: 'advanced', platform: 'darwin', material: 'transparent', micaSupported: false }) + expect(parseDesktopClientEnvironment('?dsh-desktop-platform=win32&dsh-desktop-mode=compatibility&dsh-desktop-version=2.0.3&dsh-desktop-material=off&dsh-desktop-mica=0')) + .toEqual({ version: '2.0.3', mode: 'compatibility', platform: 'win32', material: 'off', micaSupported: false }) + expect(parseDesktopClientEnvironment('?dsh-desktop-mode=extended&dsh-desktop-platform=win32&dsh-desktop-version=2.0.3&dsh-desktop-material=mica&dsh-desktop-mica=1')) + .toEqual({ version: '2.0.3', mode: 'extended', platform: 'win32', material: 'mica', micaSupported: true }) }) it.each([ @@ -52,7 +52,8 @@ describe('desktop client environment', () => { ['?dsh-desktop-platform=darwin', 'dsh-desktop-mode'], ['?dsh-desktop-mode=advanced&dsh-desktop-platform=android', 'dsh-desktop-platform'], ['?dsh-desktop-mode=advanced&dsh-desktop-platform=darwin', 'dsh-desktop-material'], - ['?dsh-desktop-mode=advanced&dsh-desktop-platform=win32&dsh-desktop-material=mica&dsh-desktop-mica=0', 'incompatible'], + ['?dsh-desktop-mode=advanced&dsh-desktop-platform=darwin&dsh-desktop-material=off', 'dsh-desktop-version'], + ['?dsh-desktop-mode=advanced&dsh-desktop-platform=win32&dsh-desktop-version=2.0.3&dsh-desktop-material=mica&dsh-desktop-mica=0', 'incompatible'], ])('fails loud for malformed marker %s', (search, field) => { expect(() => parseDesktopClientEnvironment(search)).toThrow(field) }) @@ -186,6 +187,7 @@ describe('advanced desktop layout', () => { try { applyAdvancedShell(ctx, { + version: '2.0.3', mode: 'advanced', platform: 'darwin', material: 'transparent', @@ -210,8 +212,9 @@ describe('advanced desktop layout', () => { it('reports generation-stable safe areas and drag geometry to client plugins', () => { expect(desktopWindowService({ - mode: 'compatibility', platform: 'darwin', material: 'off', micaSupported: false, + version: '2.0.3', mode: 'compatibility', platform: 'darwin', material: 'off', micaSupported: false, })).toEqual({ + version: '2.0.3', mode: 'compatibility', platform: 'darwin', material: 'off', @@ -225,9 +228,10 @@ describe('advanced desktop layout', () => { }, }) const mac = desktopWindowService({ - mode: 'advanced', platform: 'darwin', material: 'transparent', micaSupported: false, + version: '2.0.3', mode: 'advanced', platform: 'darwin', material: 'transparent', micaSupported: false, }) expect(mac).toEqual({ + version: '2.0.3', mode: 'advanced', platform: 'darwin', material: 'transparent', @@ -244,8 +248,9 @@ describe('advanced desktop layout', () => { expect(Object.isFrozen(mac.safeAreaInsets)).toBe(true) expect(Object.isFrozen(mac.dragRegion)).toBe(true) expect(desktopWindowService({ - mode: 'advanced', platform: 'win32', material: 'acrylic', micaSupported: false, + version: '2.0.3', mode: 'advanced', platform: 'win32', material: 'acrylic', micaSupported: false, })).toEqual({ + version: '2.0.3', mode: 'advanced', platform: 'win32', material: 'acrylic', @@ -259,8 +264,9 @@ describe('advanced desktop layout', () => { }, }) expect(desktopWindowService({ - mode: 'extended', platform: 'win32', material: 'mica', micaSupported: true, + version: '2.0.3', mode: 'extended', platform: 'win32', material: 'mica', micaSupported: true, })).toEqual({ + version: '2.0.3', mode: 'extended', platform: 'win32', material: 'mica', @@ -427,6 +433,7 @@ describe('independent Desktop frame', () => { try { applyExtendedShell(ctx, { + version: '2.0.3', mode: 'extended', platform: 'win32', material: 'acrylic', @@ -504,6 +511,7 @@ describe('independent Desktop frame', () => { try { applyFramedShell(ctx, { + version: '2.0.3', mode: 'compatibility', platform: 'darwin', material: 'transparent', diff --git a/dsh-plugin-desktop/tests/desktop-settings-api.spec.ts b/dsh-plugin-desktop/tests/desktop-settings-api.spec.ts index a98791ffbb..2ffc8f82d6 100644 --- a/dsh-plugin-desktop/tests/desktop-settings-api.spec.ts +++ b/dsh-plugin-desktop/tests/desktop-settings-api.spec.ts @@ -18,6 +18,7 @@ import { handleDesktopRendererReloadRequest, handleDesktopSettingsRequest, handleDesktopTerminalOpenRequest, + handleDesktopUpdateCheckRequest, desktopSettingsRouteConstants, } from '../src/desktop-settings-route.ts' import type { DesktopProfileSummary } from '../src/profile-manager.ts' @@ -533,6 +534,24 @@ describe('desktop settings HTTP boundary', () => { expect(openTerminal).toHaveBeenCalledOnce() }) + it('runs the shared interactive update flow only for an exact empty request', async () => { + const checkNow = vi.fn(async () => {}) + const accepted = response() + + await handleDesktopUpdateCheckRequest(jsonRequest({}), accepted, ORIGIN, checkNow) + + expect(accepted.statusCode).toBe(200) + expect(JSON.parse(accepted.body)).toEqual({ accepted: true }) + expect(checkNow).toHaveBeenCalledOnce() + + const rejected = response() + await handleDesktopUpdateCheckRequest( + jsonRequest({ version: '9.9.9' }), rejected, ORIGIN, checkNow, + ) + expect(rejected.statusCode).toBe(400) + expect(checkNow).toHaveBeenCalledOnce() + }) + it('queues an explicit restart only after an exact request has been acknowledged', async () => { const scheduleRestart = vi.fn() const controller = new DesktopSettingsController(bootstrap({ scheduleRestart })) diff --git a/dsh-plugin-desktop/tests/plugin.spec.ts b/dsh-plugin-desktop/tests/plugin.spec.ts index c8fb83ffc2..2b5e90a768 100644 --- a/dsh-plugin-desktop/tests/plugin.spec.ts +++ b/dsh-plugin-desktop/tests/plugin.spec.ts @@ -207,23 +207,26 @@ describe('desktop Host plugin', () => { }) it('builds the loopback root with validated renderer mode and platform markers', () => { - const url = new URL(desktopRendererUrl(43120, 'advanced', 'darwin')) + const url = new URL(desktopRendererUrl(43120, 'advanced', 'darwin', '2.0.3')) expect(url.origin).toBe('http://127.0.0.1:43120') expect(url.pathname).toBe('/') expect(Object.fromEntries(url.searchParams)).toEqual({ 'dsh-desktop-mode': 'advanced', 'dsh-desktop-platform': 'darwin', + 'dsh-desktop-version': '2.0.3', 'dsh-desktop-material': 'off', }) expect(Object.fromEntries(new URL(desktopRendererUrl( 43120, 'extended', 'win32', + '2.0.3', 'mica', 22_631, )).searchParams)).toEqual({ 'dsh-desktop-mode': 'extended', 'dsh-desktop-platform': 'win32', + 'dsh-desktop-version': '2.0.3', 'dsh-desktop-material': 'mica', 'dsh-desktop-titlebar-inset': '36', 'dsh-desktop-mica': '1', @@ -232,6 +235,7 @@ describe('desktop Host plugin', () => { 43120, 'compatibility', 'linux', + '2.0.3', )).searchParams)).not.toHaveProperty('dsh-desktop-titlebar-inset') }) @@ -250,7 +254,7 @@ describe('desktop Host plugin', () => { expect(loaderAwait).not.toHaveBeenCalled() expect(harness.shell()).toEqual(expect.objectContaining({ mode: 'compatibility', - url: 'http://127.0.0.1:43120/?dsh-desktop-mode=compatibility&dsh-desktop-platform=darwin&dsh-desktop-material=transparent&dsh-desktop-titlebar-inset=36', + url: 'http://127.0.0.1:43120/?dsh-desktop-mode=compatibility&dsh-desktop-platform=darwin&dsh-desktop-version=2.0.0&dsh-desktop-material=transparent&dsh-desktop-titlebar-inset=36', productName: 'DSH Desktop', windowTitle: 'DeepSeek Harness Desktop', readThemeSource: expect.any(Function), diff --git a/dsh-plugin-desktop/tests/updates.spec.ts b/dsh-plugin-desktop/tests/updates.spec.ts index 7296ba226b..3b9a76bc9c 100644 --- a/dsh-plugin-desktop/tests/updates.spec.ts +++ b/dsh-plugin-desktop/tests/updates.spec.ts @@ -82,6 +82,10 @@ async function createHarness(options: { } as unknown as DesktopRuntime const ctx = { desktopRuntime: runtime, + webServer: { + port: 43120, + register: () => () => {}, + }, logger: { warn: (...args: unknown[]) => { warnings.push(args) } }, effect: (register: () => (() => void | Promise)) => { disposer = register() @@ -111,7 +115,7 @@ afterEach(() => { describe('desktop update Host plugin', () => { it('exposes the packaged 60-second and six-hour background policy', () => { - expect(inject).toEqual(['desktopRuntime']) + expect(inject).toEqual(['desktopRuntime', 'webServer']) expect(Config({} as UpdateConfig)).toEqual({ enabled: true, initialDelayMs: 60_000,