diff --git a/build/dsh-desktop.patch.yml b/build/dsh-desktop.patch.yml index 13083b3f..34c24083 100644 --- a/build/dsh-desktop.patch.yml +++ b/build/dsh-desktop.patch.yml @@ -7,3 +7,17 @@ - insert: - id: directory-picker-electron-desktop-surface name: '@deepseek-ai/dsh-client-ui-directory-picker-native' + + # DSH Desktop owns the fixed-target dsh-market installer and its lightweight + # settings entry. After dshmarket is installed and Harness restarts, the + # community plugin replaces the placeholder with its complete market UI. + - id: dsh-desktop-market-installer + name: dsh-desktop-market-installer + +# dsh-market normally offers to restart its own Node process after mutations. +# The desktop shell has the real lifecycle authority, so keep that behavior off +# whenever the optional plugin is present in the composed profile. +- id: dsh-market + config: + profile: web + allowRestart: false diff --git a/package-lock.json b/package-lock.json index 4e15c080..63fb6d5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "@deepseek-ai/dsh-subprocess": "0.1.0-rc.6", "@deepseek-ai/dsh-timeout": "0.1.0-rc.6", "@deepseek-ai/dsh-workflow": "0.1.0-rc.6", + "dsh-desktop-market-installer": "file:packages/dsh-desktop-market-installer", "electron-updater": "^6.8.9", "node": "24.9.0", "qrcode": "^1.5.4" @@ -8922,6 +8923,10 @@ "url": "https://dotenvx.com" } }, + "node_modules/dsh-desktop-market-installer": { + "resolved": "packages/dsh-desktop-market-installer", + "link": true + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -12591,6 +12596,22 @@ "node": ">=10.13.0" } }, + "node_modules/pnpm": { + "version": "10.34.5", + "resolved": "https://registry.npmjs.org/pnpm/-/pnpm-10.34.5.tgz", + "integrity": "sha512-pO4F8vc2WCVb1qiYWcBlpFwopX2u+uLIk6Fo7itzFow3uR6D5X6mdlStA/AwMXRkMOi84442LgQmBfuKvIAZLg==", + "license": "MIT", + "bin": { + "pnpm": "bin/pnpm.cjs", + "pnpx": "bin/pnpx.cjs" + }, + "engines": { + "node": ">=18.12" + }, + "funding": { + "url": "https://opencollective.com/pnpm" + } + }, "node_modules/postcss": { "version": "8.5.26", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", @@ -15140,6 +15161,17 @@ "type": "github", "url": "https://github.com/sponsors/wooorm" } + }, + "packages/dsh-desktop-market-installer": { + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "pnpm": "10.34.5" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6" + } } } } diff --git a/package.json b/package.json index ed341929..6e7f5fd9 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "@deepseek-ai/dsh-subprocess": "0.1.0-rc.6", "@deepseek-ai/dsh-timeout": "0.1.0-rc.6", "@deepseek-ai/dsh-workflow": "0.1.0-rc.6", + "dsh-desktop-market-installer": "file:packages/dsh-desktop-market-installer", "electron-updater": "^6.8.9", "node": "24.9.0", "qrcode": "^1.5.4" diff --git a/packages/dsh-desktop-market-installer/client.js b/packages/dsh-desktop-market-installer/client.js new file mode 100644 index 00000000..6391f752 --- /dev/null +++ b/packages/dsh-desktop-market-installer/client.js @@ -0,0 +1,712 @@ +window.__ModuleLoader__.load({ + id: 'dsh-desktop-market-installer', + factory: (require) => { + const module = { exports: {} } + const exports = module.exports + Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' }) + const React = require('react') + + const NS = 'settings.desktopMarketInstaller' + const STATUS_PATH = '/dsh-desktop/market-installer/status' + const INSTALL_PATH = '/dsh-desktop/market-installer/install' + const UNINSTALL_PATH = '/dsh-desktop/market-installer/uninstall' + const MARKET_REPOSITORY = 'https://github.com/dsh-market/dsh-market' + + const en = { + nav: 'Plugin market', + title: 'Plugin market', + intro: 'Install dsh-market to browse, search, install, and manage community plugins inside DSH Desktop.', + community: 'dsh-market is maintained by its community. Installing and using community plugins requires network access, and those plugins are not reviewed by DSH Desktop.', + version: 'Recommended version', + install: 'Install plugin market', + installing: 'Installing plugin market…', + installingHint: 'This can take a few minutes. Keep DSH Desktop open while pnpm downloads and configures the plugin.', + installed: 'Plugin market installed', + installedHint: 'Restart Harness once to load the complete market interface.', + restart: 'Restart Harness', + restarting: 'Restarting…', + retry: 'Try again', + incomplete: 'The previous installation is incomplete. Run the installer again to repair it.', + failed: 'Plugin market could not be installed.', + statusFailed: 'Could not read installation status.', + repository: 'View dsh-market on GitHub', + futureUpdates: 'After installation, dsh-market will notify you when its own updates are available.', + managementTab: 'Plugin market', + managementIntro: 'Manage the optional dsh-market integration installed by DSH Desktop.', + installedVersion: 'Installed version', + uninstall: 'Uninstall plugin market', + uninstalling: 'Uninstalling plugin market…', + uninstallingHint: 'Removing dsh-market from the web profile. Other plugins are not affected.', + uninstallConfirmTitle: 'Uninstall plugin market?', + uninstallConfirmDesc: 'Only dsh-market will be removed. Other plugins installed through the market will remain installed.', + uninstallConfirmNote: 'Restart Harness after removal to finish unloading the market interface.', + cancel: 'Cancel', + removed: 'Plugin market uninstalled', + removedHint: 'dsh-market has been removed. Restart Harness to finish.', + uninstallFailed: 'Plugin market could not be uninstalled.' + } + + const zh = { + nav: '插件市场', + title: '插件市场', + intro: '安装 dsh-market,在 DSH Desktop 内浏览、搜索、安装并管理社区插件。', + community: 'dsh-market 由社区维护。安装和使用社区插件需要联网,这些插件不由 DSH Desktop 审核。', + version: '推荐版本', + install: '安装插件市场', + installing: '正在安装插件市场…', + installingHint: '下载和配置可能需要几分钟,请保持 DSH Desktop 处于打开状态。', + installed: '插件市场已安装', + installedHint: '重启一次 Harness,即可加载完整的插件市场界面。', + restart: '重启 Harness', + restarting: '正在重启…', + retry: '重试', + incomplete: '上一次安装没有完成,请重新运行安装以修复。', + failed: '插件市场安装失败。', + statusFailed: '无法读取安装状态。', + repository: '在 GitHub 查看 dsh-market', + futureUpdates: '安装后,dsh-market 会在有新版本时提示并提供升级。', + managementTab: '插件市场', + managementIntro: '管理由 DSH Desktop 安装的可选 dsh-market 集成。', + installedVersion: '当前版本', + uninstall: '卸载插件市场', + uninstalling: '正在卸载插件市场…', + uninstallingHint: '正在从 web profile 中移除 dsh-market,其他插件不会受到影响。', + uninstallConfirmTitle: '卸载插件市场?', + uninstallConfirmDesc: '只会移除 dsh-market。通过插件市场安装的其他插件将继续保留。', + uninstallConfirmNote: '移除完成后需要重启 Harness,插件市场界面才会完全退出。', + cancel: '取消', + removed: '插件市场已卸载', + removedHint: 'dsh-market 已移除,请重启 Harness 完成卸载。', + uninstallFailed: '插件市场卸载失败。' + } + + const css = ` + .dshDesktopMarketSection{box-sizing:border-box;max-width:720px;color:var(--dsw-alias-label-primary);display:flex;flex-direction:column;gap:16px} + .dshDesktopMarketTitle{margin:0;font-size:20px;font-weight:600;line-height:30px} + .dshDesktopMarketIntro{margin:0;color:var(--dsw-alias-label-secondary);font-size:14px;line-height:22px} + .dshDesktopMarketCard{box-sizing:border-box;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-module-platform);border-radius:14px;padding:22px;display:flex;flex-direction:column;gap:18px} + .dshDesktopMarketMark{width:46px;height:46px;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;background:var(--dsw-alias-bg-layer-1);display:grid;grid-template-columns:repeat(2,10px);grid-auto-rows:10px;place-content:center;gap:4px} + .dshDesktopMarketMark span{display:block;border-radius:3px;background:var(--dsw-alias-label-primary)} + .dshDesktopMarketMark span:nth-child(4){opacity:.28} + .dshDesktopMarketHead{display:flex;align-items:flex-start;gap:14px} + .dshDesktopMarketIdentity{min-width:0;display:flex;flex-direction:column;gap:3px} + .dshDesktopMarketName{font-size:16px;font-weight:600;line-height:24px} + .dshDesktopMarketVersion{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px} + .dshDesktopMarketNotice{margin:0;padding-top:16px;border-top:1px solid var(--dsw-alias-border-l2);color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:19px} + .dshDesktopMarketStatus{display:flex;flex-direction:column;gap:5px} + .dshDesktopMarketStatusTitle{font-size:14px;font-weight:600;line-height:22px} + .dshDesktopMarketStatusDetail{margin:0;color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px;overflow-wrap:anywhere} + .dshDesktopMarketActions{display:flex;flex-wrap:wrap;align-items:center;gap:10px} + .dshDesktopMarketButton{box-sizing:border-box;height:36px;padding:0 16px;border:1px solid transparent;border-radius:18px;font:inherit;font-size:14px;font-weight:500;cursor:pointer} + .dshDesktopMarketPrimary{color:var(--dsw-alias-label-primary-foreground);background:var(--dsw-alias-button-primary-fill)} + .dshDesktopMarketPrimary:hover:not(:disabled){background:var(--dsw-alias-button-primary-hover)} + .dshDesktopMarketSecondary{color:var(--dsw-alias-label-primary);background:transparent;border-color:var(--dsw-alias-border-l3)} + .dshDesktopMarketSecondary:hover:not(:disabled){background:var(--dsw-alias-bg-layer-2)} + .dshDesktopMarketButton:disabled{cursor:default;opacity:.5} + .dshDesktopMarketButton:focus-visible,.dshDesktopMarketLink:focus-visible{outline:none;box-shadow:0 0 0 2px var(--dsw-alias-border-l3)} + .dshDesktopMarketLink{color:var(--dsw-alias-label-secondary);border-radius:6px;padding:5px 4px;font-size:13px;line-height:20px;text-decoration:none} + .dshDesktopMarketLink:hover{color:var(--dsw-alias-label-primary);text-decoration:underline} + .dshDesktopMarketSpinner{box-sizing:border-box;width:16px;height:16px;border:2px solid var(--dsw-alias-border-l2);border-top-color:var(--dsw-alias-label-primary);border-radius:50%;animation:dshDesktopMarketSpin .75s linear infinite} + .dshDesktopMarketBusy{display:flex;align-items:center;gap:9px} + .dshDesktopMarketError{color:var(--dsw-alias-state-error-primary)} + .dshDesktopMarketModalBackdrop{position:fixed;inset:0;z-index:1000;display:grid;place-items:center;padding:24px;background:rgba(0,0,0,.42)} + .dshDesktopMarketModal{box-sizing:border-box;width:min(440px,100%);padding:24px;border:1px solid var(--dsw-alias-border-l2);border-radius:16px;background:var(--dsw-alias-bg-layer-1);box-shadow:0 18px 48px rgba(0,0,0,.18);display:flex;flex-direction:column;gap:12px} + .dshDesktopMarketModalTitle{margin:0;font-size:18px;font-weight:600;line-height:26px} + .dshDesktopMarketModalText{margin:0;color:var(--dsw-alias-label-secondary);font-size:14px;line-height:22px} + .dshDesktopMarketModalNote{margin:0;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:19px} + .dshDesktopMarketModalActions{display:flex;justify-content:flex-end;gap:10px;padding-top:8px} + @keyframes dshDesktopMarketSpin{to{transform:rotate(360deg)}} + @media (prefers-reduced-motion:reduce){.dshDesktopMarketSpinner{animation:none}} + ` + + function installStyles() { + if (document.querySelector('style[data-plugin-css="dsh-desktop-market-installer"]')) return + const style = document.createElement('style') + style.dataset.plugin = 'dsh-desktop-market-installer' + style.dataset.pluginCss = 'dsh-desktop-market-installer' + style.textContent = css + document.head.appendChild(style) + } + + function marketAlreadyComposed() { + const entries = globalThis.__DSH_BOOT__?.entries + return Array.isArray(entries) && entries.some((entry) => entry?.id === 'dshmarket') + } + + async function readStatus() { + const response = await fetch(STATUS_PATH, { + method: 'GET', + credentials: 'same-origin', + cache: 'no-store' + }) + const payload = await response.json() + if (!response.ok) throw new Error(payload?.error || `HTTP ${response.status}`) + return payload + } + + function UninstallConfirm({ t, busy, onCancel, onConfirm }) { + React.useEffect(() => { + const onKeyDown = (event) => { + if (event.key === 'Escape' && !busy) onCancel() + } + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, [busy, onCancel]) + + return React.createElement( + 'div', + { + className: 'dshDesktopMarketModalBackdrop', + role: 'presentation', + onMouseDown: (event) => { + if (event.target === event.currentTarget && !busy) onCancel() + } + }, + React.createElement( + 'div', + { + className: 'dshDesktopMarketModal', + role: 'dialog', + 'aria-modal': 'true', + 'aria-labelledby': 'dsh-desktop-market-uninstall-title' + }, + React.createElement( + 'h3', + { + id: 'dsh-desktop-market-uninstall-title', + className: 'dshDesktopMarketModalTitle' + }, + t('uninstallConfirmTitle') + ), + React.createElement( + 'p', + { className: 'dshDesktopMarketModalText' }, + t('uninstallConfirmDesc') + ), + React.createElement( + 'p', + { className: 'dshDesktopMarketModalNote' }, + t('uninstallConfirmNote') + ), + React.createElement( + 'div', + { className: 'dshDesktopMarketModalActions' }, + React.createElement( + 'button', + { + type: 'button', + className: 'dshDesktopMarketButton dshDesktopMarketSecondary', + disabled: busy, + onClick: onCancel + }, + t('cancel') + ), + React.createElement( + 'button', + { + type: 'button', + className: 'dshDesktopMarketButton dshDesktopMarketPrimary', + disabled: busy, + onClick: onConfirm, + autoFocus: true + }, + busy ? t('uninstalling') : t('uninstall') + ) + ) + ) + ) + } + + function MarketManagementTab({ t }) { + const [status, setStatus] = React.useState() + const [error, setError] = React.useState() + const [confirming, setConfirming] = React.useState(false) + const [restarting, setRestarting] = React.useState(false) + + React.useEffect(() => { + let disposed = false + const load = async () => { + try { + const next = await readStatus() + if (disposed) return + setStatus(next) + setError(undefined) + } catch (failure) { + if (!disposed) setError(failure instanceof Error ? failure.message : String(failure)) + } + } + void load() + return () => { + disposed = true + } + }, []) + + React.useEffect(() => { + if (status?.phase !== 'uninstalling') return + let disposed = false + let timer + const poll = async () => { + try { + const next = await readStatus() + if (disposed) return + setStatus(next) + setError(undefined) + if (next.phase === 'uninstalling') timer = setTimeout(poll, 850) + else setConfirming(false) + } catch (failure) { + if (disposed) return + setError(failure instanceof Error ? failure.message : String(failure)) + timer = setTimeout(poll, 1_500) + } + } + timer = setTimeout(poll, 850) + return () => { + disposed = true + clearTimeout(timer) + } + }, [status?.phase]) + + const uninstall = async () => { + setError(undefined) + setStatus((current) => ({ ...current, phase: 'uninstalling' })) + try { + const response = await fetch(UNINSTALL_PATH, { + method: 'POST', + credentials: 'same-origin', + headers: { accept: 'application/json' } + }) + const payload = await response.json() + if (!response.ok && response.status !== 409) { + throw new Error(payload?.error || `HTTP ${response.status}`) + } + setStatus(payload) + if (payload.phase !== 'uninstalling') setConfirming(false) + } catch (failure) { + setConfirming(false) + setError(failure instanceof Error ? failure.message : String(failure)) + } + } + + const restart = async () => { + const bridge = globalThis.dshDesktop + if (!bridge || typeof bridge.restartHarness !== 'function') { + setError('Harness > Restart Harness') + return + } + setRestarting(true) + setError(undefined) + try { + await bridge.restartHarness() + } catch (failure) { + setRestarting(false) + setError(failure instanceof Error ? failure.message : String(failure)) + } + } + + const phase = status?.phase + const uninstalling = phase === 'uninstalling' + const removed = phase === 'uninstalled' || phase === 'absent' + const failed = phase === 'error' || phase === 'incomplete' || Boolean(error) + const installedVersion = status?.installedVersion || status?.dependency + + return React.createElement( + React.Fragment, + null, + React.createElement( + 'section', + { className: 'dshDesktopMarketSection' }, + React.createElement('h2', { className: 'dshDesktopMarketTitle' }, t('title')), + React.createElement('p', { className: 'dshDesktopMarketIntro' }, t('managementIntro')), + React.createElement( + 'div', + { className: 'dshDesktopMarketCard' }, + React.createElement( + 'div', + { className: 'dshDesktopMarketHead' }, + React.createElement( + 'div', + { className: 'dshDesktopMarketMark', 'aria-hidden': 'true' }, + React.createElement('span'), + React.createElement('span'), + React.createElement('span'), + React.createElement('span') + ), + React.createElement( + 'div', + { className: 'dshDesktopMarketIdentity' }, + React.createElement('span', { className: 'dshDesktopMarketName' }, 'dsh-market'), + installedVersion + ? React.createElement( + 'span', + { className: 'dshDesktopMarketVersion' }, + `${t('installedVersion')} · ${installedVersion}` + ) + : null + ) + ), + uninstalling + ? React.createElement( + 'div', + { className: 'dshDesktopMarketStatus' }, + React.createElement( + 'div', + { className: 'dshDesktopMarketBusy' }, + React.createElement('span', { + className: 'dshDesktopMarketSpinner', + 'aria-hidden': 'true' + }), + React.createElement( + 'span', + { className: 'dshDesktopMarketStatusTitle' }, + t('uninstalling') + ) + ), + React.createElement( + 'p', + { className: 'dshDesktopMarketStatusDetail' }, + status?.detail || t('uninstallingHint') + ) + ) + : removed + ? React.createElement( + 'div', + { className: 'dshDesktopMarketStatus' }, + React.createElement( + 'span', + { className: 'dshDesktopMarketStatusTitle' }, + t('removed') + ), + React.createElement( + 'p', + { className: 'dshDesktopMarketStatusDetail' }, + t('removedHint') + ) + ) + : failed + ? React.createElement( + 'div', + { className: 'dshDesktopMarketStatus' }, + React.createElement( + 'span', + { className: 'dshDesktopMarketStatusTitle dshDesktopMarketError' }, + t('uninstallFailed') + ), + error || status?.detail + ? React.createElement( + 'p', + { className: 'dshDesktopMarketStatusDetail dshDesktopMarketError' }, + error || status?.detail + ) + : null + ) + : null, + React.createElement( + 'div', + { className: 'dshDesktopMarketActions' }, + removed + ? React.createElement( + 'button', + { + type: 'button', + className: 'dshDesktopMarketButton dshDesktopMarketPrimary', + disabled: restarting, + onClick: () => void restart() + }, + restarting ? t('restarting') : t('restart') + ) + : React.createElement( + 'button', + { + type: 'button', + className: 'dshDesktopMarketButton dshDesktopMarketSecondary', + disabled: uninstalling || !status, + onClick: () => setConfirming(true) + }, + t('uninstall') + ), + React.createElement( + 'a', + { + className: 'dshDesktopMarketLink', + href: MARKET_REPOSITORY, + target: '_blank', + rel: 'noopener noreferrer' + }, + t('repository') + ) + ), + React.createElement('p', { className: 'dshDesktopMarketNotice' }, t('futureUpdates')) + ) + ), + confirming + ? React.createElement(UninstallConfirm, { + t, + busy: uninstalling, + onCancel: () => setConfirming(false), + onConfirm: () => void uninstall() + }) + : null + ) + } + + function MarketInstallerSection({ t }) { + const [status, setStatus] = React.useState() + const [error, setError] = React.useState() + const [restarting, setRestarting] = React.useState(false) + + React.useEffect(() => { + let disposed = false + const load = async () => { + try { + const next = await readStatus() + if (disposed) return + setStatus(next) + setError(undefined) + } catch (failure) { + if (!disposed) setError(failure instanceof Error ? failure.message : String(failure)) + } + } + void load() + return () => { + disposed = true + } + }, []) + + React.useEffect(() => { + if (status?.phase !== 'installing') return + let disposed = false + let timer + const poll = async () => { + try { + const next = await readStatus() + if (disposed) return + setStatus(next) + setError(undefined) + if (next.phase === 'installing') timer = setTimeout(poll, 850) + } catch (failure) { + if (disposed) return + setError(failure instanceof Error ? failure.message : String(failure)) + timer = setTimeout(poll, 1_500) + } + } + timer = setTimeout(poll, 850) + return () => { + disposed = true + clearTimeout(timer) + } + }, [status?.phase]) + + const install = async () => { + setError(undefined) + setStatus((current) => ({ + ...current, + phase: 'installing', + recommendedVersion: current?.recommendedVersion || '1.9.0' + })) + try { + const response = await fetch(INSTALL_PATH, { + method: 'POST', + credentials: 'same-origin', + headers: { accept: 'application/json' } + }) + const payload = await response.json() + if (!response.ok && response.status !== 409) { + throw new Error(payload?.error || `HTTP ${response.status}`) + } + setStatus(payload) + } catch (failure) { + setError(failure instanceof Error ? failure.message : String(failure)) + } + } + + const restart = async () => { + const bridge = globalThis.dshDesktop + if (!bridge || typeof bridge.restartHarness !== 'function') { + setError('Harness > Restart Harness') + return + } + setRestarting(true) + setError(undefined) + try { + await bridge.restartHarness() + } catch (failure) { + setRestarting(false) + setError(failure instanceof Error ? failure.message : String(failure)) + } + } + + const phase = status?.phase + const busy = phase === 'installing' + const installed = phase === 'installed' + const failed = phase === 'error' || phase === 'incomplete' || Boolean(error) + const version = status?.recommendedVersion || '1.9.0' + + return React.createElement( + 'section', + { className: 'dshDesktopMarketSection' }, + React.createElement('h2', { className: 'dshDesktopMarketTitle' }, t('title')), + React.createElement('p', { className: 'dshDesktopMarketIntro' }, t('intro')), + React.createElement( + 'div', + { className: 'dshDesktopMarketCard' }, + React.createElement( + 'div', + { className: 'dshDesktopMarketHead' }, + React.createElement( + 'div', + { className: 'dshDesktopMarketMark', 'aria-hidden': 'true' }, + React.createElement('span'), + React.createElement('span'), + React.createElement('span'), + React.createElement('span') + ), + React.createElement( + 'div', + { className: 'dshDesktopMarketIdentity' }, + React.createElement('span', { className: 'dshDesktopMarketName' }, 'dsh-market'), + React.createElement( + 'span', + { className: 'dshDesktopMarketVersion' }, + `${t('version')} · ${version}` + ) + ) + ), + busy + ? React.createElement( + 'div', + { className: 'dshDesktopMarketStatus' }, + React.createElement( + 'div', + { className: 'dshDesktopMarketBusy' }, + React.createElement('span', { + className: 'dshDesktopMarketSpinner', + 'aria-hidden': 'true' + }), + React.createElement( + 'span', + { className: 'dshDesktopMarketStatusTitle' }, + t('installing') + ) + ), + React.createElement( + 'p', + { className: 'dshDesktopMarketStatusDetail' }, + status?.detail || t('installingHint') + ) + ) + : installed + ? React.createElement( + 'div', + { className: 'dshDesktopMarketStatus' }, + React.createElement( + 'span', + { className: 'dshDesktopMarketStatusTitle' }, + t('installed') + ), + React.createElement( + 'p', + { className: 'dshDesktopMarketStatusDetail' }, + t('installedHint') + ) + ) + : failed + ? React.createElement( + 'div', + { className: 'dshDesktopMarketStatus' }, + React.createElement( + 'span', + { className: 'dshDesktopMarketStatusTitle dshDesktopMarketError' }, + phase === 'incomplete' ? t('incomplete') : t('failed') + ), + error || status?.detail + ? React.createElement( + 'p', + { className: 'dshDesktopMarketStatusDetail dshDesktopMarketError' }, + error || status?.detail + ) + : null + ) + : null, + React.createElement( + 'div', + { className: 'dshDesktopMarketActions' }, + installed + ? React.createElement( + 'button', + { + type: 'button', + className: 'dshDesktopMarketButton dshDesktopMarketPrimary', + disabled: restarting, + onClick: () => void restart() + }, + restarting ? t('restarting') : t('restart') + ) + : React.createElement( + 'button', + { + type: 'button', + className: 'dshDesktopMarketButton dshDesktopMarketPrimary', + disabled: busy || (!status && !error), + onClick: () => void install() + }, + failed ? t('retry') : t('install') + ), + React.createElement( + 'a', + { + className: 'dshDesktopMarketLink', + href: MARKET_REPOSITORY, + target: '_blank', + rel: 'noopener noreferrer' + }, + t('repository') + ) + ), + React.createElement('p', { className: 'dshDesktopMarketNotice' }, t('community')), + React.createElement('p', { className: 'dshDesktopMarketNotice' }, t('futureUpdates')) + ) + ) + } + + const inject = ['slots', 'locale'] + function apply(ctx) { + installStyles() + ctx.effect( + () => ctx.locale.register(NS, { zh, en }), + 'dsh-desktop-market-installer: copy dictionaries' + ) + const t = ctx.locale.bind(NS) + if (marketAlreadyComposed()) { + ctx.slots.inject('settings.plugins.tab', () => + ctx.slots.register( + { + name: 'settings.plugins.tab', + id: 'desktop-market-management', + order: 30, + label: () => t('managementTab'), + inject: () => ({ t }) + }, + MarketManagementTab + ) + ) + return + } + ctx.slots.inject('settings.section', () => + ctx.slots.register( + { + name: 'settings.section', + id: 'market', + order: 40, + label: () => t('nav'), + inject: () => ({ t }) + }, + MarketInstallerSection + ) + ) + } + + exports.apply = apply + exports.inject = inject + return module.exports + } +}) diff --git a/packages/dsh-desktop-market-installer/index.js b/packages/dsh-desktop-market-installer/index.js new file mode 100644 index 00000000..8de903f4 --- /dev/null +++ b/packages/dsh-desktop-market-installer/index.js @@ -0,0 +1,493 @@ +import { spawn } from 'node:child_process' +import { existsSync } from 'node:fs' +import { chmod, mkdir, readFile, rename, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { homedir } from 'node:os' +import { delimiter, dirname, join, resolve } from 'node:path' + +export const RECOMMENDED_MARKET_VERSION = '1.9.0' +export const MARKET_PACKAGE = 'dshmarket' +export const MARKET_PROFILE = 'web' +export const STATUS_PATH = '/dsh-desktop/market-installer/status' +export const INSTALL_PATH = '/dsh-desktop/market-installer/install' +export const UNINSTALL_PATH = '/dsh-desktop/market-installer/uninstall' + +const OPERATION_TIMEOUT_MS = 15 * 60 * 1000 +const MAX_LOG_BYTES = 32 * 1024 + +export const name = 'dsh-desktop-market-installer' +export const inject = ['webServer'] + +function dshHome() { + return process.env.DSH_HOME || join(homedir(), '.dsh') +} + +export function profileDirectory(home = dshHome()) { + return join(home, 'profiles', MARKET_PROFILE) +} + +function readObject(text) { + const value = JSON.parse(text) + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error('Expected a JSON object.') + } + return value +} + +async function readJson(path) { + try { + return readObject(await readFile(path, 'utf8')) + } catch (error) { + if (error?.code === 'ENOENT') return undefined + throw error + } +} + +export async function readMarketInstallation(home = dshHome()) { + const directory = profileDirectory(home) + const manifest = await readJson(join(directory, 'package.json')) + const dependency = manifest?.dependencies?.[MARKET_PACKAGE] + const installed = await readJson(join(directory, 'node_modules', MARKET_PACKAGE, 'package.json')) + return { + dependency: typeof dependency === 'string' ? dependency : undefined, + installedVersion: typeof installed?.version === 'string' ? installed.version : undefined + } +} + +function isLoopback(address) { + return ( + address === '127.0.0.1' || + address === '::1' || + address === '::ffff:127.0.0.1' + ) +} + +function hasForwardedAddress(req) { + return Boolean( + req.headers.forwarded || + req.headers['x-forwarded-for'] || + req.headers['x-real-ip'] || + req.headers['x-forwarded-host'] + ) +} + +export function isTrustedRequest(req, mutation = false) { + if (!isLoopback(req.socket.remoteAddress) || hasForwardedAddress(req)) return false + if (!mutation) return true + + const origin = req.headers.origin + const host = req.headers.host + if (typeof origin !== 'string' || typeof host !== 'string') return false + try { + const parsed = new URL(origin) + return parsed.protocol === 'http:' && parsed.host === host && isLoopback(parsed.hostname) + } catch { + return false + } +} + +function sendJson(res, status, payload) { + const body = JSON.stringify(payload) + res.writeHead(status, { + 'content-type': 'application/json; charset=utf-8', + 'cache-control': 'no-store', + 'content-length': Buffer.byteLength(body) + }) + res.end(body) +} + +function shellQuote(value) { + return `'${String(value).replaceAll("'", `'\\''`)}'` +} + +export function resolvePnpmEntry(requireFrom = import.meta.url) { + const require = createRequire(requireFrom) + // pnpm intentionally maps its package root export to package.json instead of + // exporting the package.json subpath. Resolving the root therefore gives us + // the stable package anchor without reaching through an unexported path. + const manifest = require.resolve('pnpm') + const root = dirname(manifest) + const candidates = [join(root, 'bin', 'pnpm.cjs'), join(root, 'bin', 'pnpm.mjs')] + const entry = candidates.find((candidate) => existsSync(candidate)) + if (!entry) throw new Error('The packaged pnpm entry was not found.') + return entry +} + +export async function ensurePnpmShim(home = dshHome()) { + const directory = join(home, '.desktop-bin') + await mkdir(directory, { recursive: true }) + const pnpmEntry = resolvePnpmEntry() + const executable = process.execPath + + if (process.platform === 'win32') { + const path = join(directory, 'pnpm.cmd') + await writeFile(path, `@\"${executable}\" \"${pnpmEntry}\" %*\r\n`, 'utf8') + } else { + const path = join(directory, 'pnpm') + await writeFile( + path, + `#!/bin/sh\nexec ${shellQuote(executable)} ${shellQuote(pnpmEntry)} \"$@\"\n`, + { encoding: 'utf8', mode: 0o755 } + ) + await chmod(path, 0o755) + } + + const current = process.env.PATH ?? '' + if (!current.split(delimiter).includes(directory)) { + process.env.PATH = current ? `${directory}${delimiter}${current}` : directory + } + return directory +} + +export function resolveDshEntry(argv = process.argv) { + const entry = argv[1] + if (!entry || !/[/\\]bin\.js$/u.test(entry)) { + throw new Error('The running DSH entry could not be identified.') + } + return resolve(entry) +} + +export function buildInstallArguments(dshEntry = resolveDshEntry()) { + return [ + dshEntry, + 'plugin', + '--profile', + MARKET_PROFILE, + 'add', + '--save-exact', + `${MARKET_PACKAGE}@${RECOMMENDED_MARKET_VERSION}` + ] +} + +export function buildUninstallArguments(dshEntry = resolveDshEntry()) { + return [dshEntry, 'plugin', '--profile', MARKET_PROFILE, 'remove', MARKET_PACKAGE] +} + +async function atomicWrite(path, contents) { + const temporary = `${path}.dsh-desktop-${process.pid}-${Date.now()}.tmp` + await writeFile(temporary, contents, 'utf8') + await rename(temporary, path) +} + +function killProcessTree(child) { + if (!child || child.exitCode !== null) return + if (process.platform === 'win32') { + spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { + windowsHide: true, + stdio: 'ignore' + }).unref() + return + } + try { + process.kill(-child.pid, 'SIGTERM') + } catch { + child.kill('SIGTERM') + } +} + +export function apply(ctx) { + const home = dshHome() + const directory = profileDirectory(home) + const manifestPath = join(directory, 'package.json') + let activeChild + let operationPromise + let phase = 'idle' + let detail + let restartRequired = false + + const status = async () => { + const installation = await readMarketInstallation(home) + if (phase === 'installing' || phase === 'uninstalling') { + return { + phase, + ...installation, + recommendedVersion: RECOMMENDED_MARKET_VERSION, + detail, + restartRequired: false + } + } + if (phase === 'uninstalled') { + return { + phase, + ...installation, + recommendedVersion: RECOMMENDED_MARKET_VERSION, + restartRequired: true + } + } + if (installation.installedVersion) { + return { + phase: 'installed', + ...installation, + recommendedVersion: RECOMMENDED_MARKET_VERSION, + restartRequired + } + } + if (phase === 'error') { + return { + phase, + ...installation, + recommendedVersion: RECOMMENDED_MARKET_VERSION, + detail, + restartRequired: false + } + } + return { + phase: installation.dependency ? 'incomplete' : 'absent', + ...installation, + recommendedVersion: RECOMMENDED_MARKET_VERSION, + restartRequired: false + } + } + + const runInstall = async () => { + phase = 'installing' + detail = undefined + restartRequired = false + const current = await readMarketInstallation(home) + if (current.installedVersion) { + phase = 'idle' + return + } + + await ensurePnpmShim(home) + await mkdir(directory, { recursive: true }) + let manifestSnapshot + try { + manifestSnapshot = await readFile(manifestPath, 'utf8') + } catch (error) { + if (error?.code !== 'ENOENT') throw error + } + + const child = spawn(process.execPath, buildInstallArguments(), { + cwd: directory, + env: { ...process.env, CI: 'true', NO_COLOR: '1' }, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + detached: process.platform !== 'win32' + }) + activeChild = child + + let output = '' + const append = (chunk) => { + output = `${output}${chunk.toString('utf8')}`.slice(-MAX_LOG_BYTES) + const lines = output.trim().split(/\r?\n/u) + detail = lines.at(-1)?.slice(0, 800) + } + child.stdout.on('data', append) + child.stderr.on('data', append) + + let timedOut = false + const timer = setTimeout(() => { + timedOut = true + killProcessTree(child) + }, OPERATION_TIMEOUT_MS) + + try { + const exit = await new Promise((resolveExit, rejectExit) => { + child.once('error', rejectExit) + child.once('exit', (code, signal) => resolveExit({ code, signal })) + }) + if (timedOut) throw new Error('Installation timed out after 15 minutes.') + if (exit.code !== 0) { + throw new Error( + detail || + `The installer exited with ${exit.signal ? `signal ${exit.signal}` : `code ${exit.code}`}.` + ) + } + + const installed = await readMarketInstallation(home) + if (!installed.installedVersion) { + throw new Error('pnpm completed, but dshmarket was not found in the web profile.') + } + phase = 'idle' + detail = undefined + restartRequired = true + } catch (error) { + if (manifestSnapshot !== undefined) { + await atomicWrite(manifestPath, manifestSnapshot).catch(() => undefined) + } + phase = 'error' + detail = error instanceof Error ? error.message : String(error) + ctx.logger.warn(error instanceof Error ? error : new Error(detail)) + } finally { + clearTimeout(timer) + if (activeChild === child) activeChild = undefined + } + } + + const runUninstall = async () => { + phase = 'uninstalling' + detail = undefined + restartRequired = false + const current = await readMarketInstallation(home) + if (!current.dependency && !current.installedVersion) { + phase = 'uninstalled' + restartRequired = true + return + } + + await ensurePnpmShim(home) + let manifestSnapshot + try { + manifestSnapshot = await readFile(manifestPath, 'utf8') + } catch (error) { + if (error?.code !== 'ENOENT') throw error + } + + const child = spawn(process.execPath, buildUninstallArguments(), { + cwd: directory, + env: { ...process.env, CI: 'true', NO_COLOR: '1' }, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + detached: process.platform !== 'win32' + }) + activeChild = child + + let output = '' + const append = (chunk) => { + output = `${output}${chunk.toString('utf8')}`.slice(-MAX_LOG_BYTES) + const lines = output.trim().split(/\r?\n/u) + detail = lines.at(-1)?.slice(0, 800) + } + child.stdout.on('data', append) + child.stderr.on('data', append) + + let timedOut = false + const timer = setTimeout(() => { + timedOut = true + killProcessTree(child) + }, OPERATION_TIMEOUT_MS) + + try { + const exit = await new Promise((resolveExit, rejectExit) => { + child.once('error', rejectExit) + child.once('exit', (code, signal) => resolveExit({ code, signal })) + }) + if (timedOut) throw new Error('Uninstallation timed out after 15 minutes.') + if (exit.code !== 0) { + throw new Error( + detail || + `The uninstaller exited with ${exit.signal ? `signal ${exit.signal}` : `code ${exit.code}`}.` + ) + } + + const removed = await readMarketInstallation(home) + if (removed.dependency || removed.installedVersion) { + throw new Error('pnpm completed, but dshmarket is still present in the web profile.') + } + phase = 'uninstalled' + detail = undefined + restartRequired = true + } catch (error) { + if (manifestSnapshot !== undefined) { + await atomicWrite(manifestPath, manifestSnapshot).catch(() => undefined) + } + phase = 'error' + detail = error instanceof Error ? error.message : String(error) + ctx.logger.warn(error instanceof Error ? error : new Error(detail)) + } finally { + clearTimeout(timer) + if (activeChild === child) activeChild = undefined + } + } + + ctx.effect(() => { + const disposeStatus = ctx.webServer.register({ + kind: 'exact', + path: STATUS_PATH, + handler: async (req, res) => { + if (req.method !== 'GET' || !isTrustedRequest(req)) { + sendJson(res, req.method === 'GET' ? 403 : 405, { error: 'Request rejected.' }) + return + } + sendJson(res, 200, await status()) + } + }) + const disposeInstall = ctx.webServer.register({ + kind: 'exact', + path: INSTALL_PATH, + handler: async (req, res) => { + if (req.method !== 'POST') { + sendJson(res, 405, { error: 'Method not allowed.' }) + return + } + if (!isTrustedRequest(req, true)) { + sendJson(res, 403, { error: 'Request rejected.' }) + return + } + if (operationPromise) { + sendJson(res, 409, await status()) + return + } + + const current = await readMarketInstallation(home) + if (current.installedVersion) { + sendJson(res, 200, await status()) + return + } + + operationPromise = runInstall() + .catch((error) => { + phase = 'error' + detail = error instanceof Error ? error.message : String(error) + ctx.logger.warn(error instanceof Error ? error : new Error(detail)) + }) + .finally(() => { + operationPromise = undefined + }) + sendJson(res, 202, await status()) + } + }) + const disposeUninstall = ctx.webServer.register({ + kind: 'exact', + path: UNINSTALL_PATH, + handler: async (req, res) => { + if (req.method !== 'POST') { + sendJson(res, 405, { error: 'Method not allowed.' }) + return + } + if (!isTrustedRequest(req, true)) { + sendJson(res, 403, { error: 'Request rejected.' }) + return + } + if (operationPromise) { + sendJson(res, 409, await status()) + return + } + + const current = await readMarketInstallation(home) + if (!current.dependency && !current.installedVersion) { + phase = 'uninstalled' + restartRequired = true + sendJson(res, 200, await status()) + return + } + + operationPromise = runUninstall() + .catch((error) => { + phase = 'error' + detail = error instanceof Error ? error.message : String(error) + ctx.logger.warn(error instanceof Error ? error : new Error(detail)) + }) + .finally(() => { + operationPromise = undefined + }) + sendJson(res, 202, await status()) + } + }) + + return async () => { + disposeUninstall() + disposeInstall() + disposeStatus() + killProcessTree(activeChild) + await operationPromise?.catch(() => undefined) + } + }, 'dsh-desktop-market-installer: fixed package routes') + + ctx.effect(() => { + void ensurePnpmShim(home).catch((error) => { + ctx.logger.warn(error instanceof Error ? error : new Error(String(error))) + }) + }, 'dsh-desktop-market-installer: packaged pnpm shim') +} diff --git a/packages/dsh-desktop-market-installer/package.json b/packages/dsh-desktop-market-installer/package.json new file mode 100644 index 00000000..73b6a989 --- /dev/null +++ b/packages/dsh-desktop-market-installer/package.json @@ -0,0 +1,31 @@ +{ + "name": "dsh-desktop-market-installer", + "version": "0.1.0", + "description": "DSH Desktop's fixed-target installer entry for the optional dsh-market community plugin.", + "private": true, + "type": "module", + "main": "./index.js", + "exports": { + ".": "./index.js", + "./client": "./client.js", + "./package.json": "./package.json" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-settings", + "@deepseek-ai/dsh-client-ui-settings-plugins" + ], + "platform": "web" + } + }, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "^4.0.1", + "@deepseek-ai/dsh-host-webserver": "^0.1.0-rc.6" + }, + "dependencies": { + "pnpm": "10.34.5" + } +} diff --git a/patches/@deepseek-ai+dsh+0.1.0-rc.6.patch b/patches/@deepseek-ai+dsh+0.1.0-rc.6.patch new file mode 100644 index 00000000..ee3e7ef8 --- /dev/null +++ b/patches/@deepseek-ai+dsh+0.1.0-rc.6.patch @@ -0,0 +1,11 @@ +diff --git a/node_modules/@deepseek-ai/dsh/package.json b/node_modules/@deepseek-ai/dsh/package.json +--- a/node_modules/@deepseek-ai/dsh/package.json ++++ b/node_modules/@deepseek-ai/dsh/package.json +@@ -20,6 +20,7 @@ + ], + "license": "MIT", + "dependencies": { ++ "dsh-desktop-market-installer": "0.1.0", + "commander": "^15.0.0", + "js-yaml": "^4.2.0", + "node-addon-require-builtin": "^0.1.4", diff --git a/patches/@deepseek-ai+dsh-client-ui-conversation+0.1.0-rc.6.patch b/patches/@deepseek-ai+dsh-client-ui-conversation+0.1.0-rc.6.patch new file mode 100644 index 00000000..9570a80d --- /dev/null +++ b/patches/@deepseek-ai+dsh-client-ui-conversation+0.1.0-rc.6.patch @@ -0,0 +1,256 @@ +diff --git a/node_modules/@deepseek-ai/dsh-client-ui-conversation/lib/client.js b/node_modules/@deepseek-ai/dsh-client-ui-conversation/lib/client.js +index 86e7906..7cd6351 100644 +--- a/node_modules/@deepseek-ai/dsh-client-ui-conversation/lib/client.js ++++ b/node_modules/@deepseek-ai/dsh-client-ui-conversation/lib/client.js +@@ -5194,6 +5194,15 @@ window.__ModuleLoader__.load({ + "dsh-turn-status-shimmer": "Md3f7G_dsh-turn-status-shimmer", + "openError": "Md3f7G_openError" + }; ++ const queryRailCss = ".dshQueryRail_root{z-index:7;width:46px;pointer-events:none;position:fixed}.dshQueryRail_scroller{width:40px;height:100%;padding:8px 2px;overflow-x:hidden;overflow-y:auto;overscroll-behavior-y:contain;scrollbar-width:none;flex-direction:column;align-items:flex-start;gap:2px;display:flex;position:relative}.dshQueryRail_scroller::-webkit-scrollbar{width:0;height:0;display:none}.dshQueryRail_item{width:34px;height:12px;min-height:12px;cursor:pointer;background:transparent;border:0;outline:none;flex:none;padding:0;position:relative}.dshQueryRail_item:before{content:\"\";width:10px;height:1px;background:var(--dsw-alias-label-dimmed);border-radius:2px;transition:width .14s ease,background-color .14s ease,opacity .14s ease;display:block}.dshQueryRail_item:hover:before,.dshQueryRail_item:focus-visible:before{width:20px;background:var(--dsw-alias-label-secondary)}.dshQueryRail_itemActive:before{width:28px;height:2px;background:var(--dsw-alias-label-primary)}.dshQueryRail_tooltip{width:276px;max-width:min(276px,calc(100vw - 64px));color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-module-platform);border:1px solid var(--dsw-alias-border-l2);box-shadow:var(--dsw-shadow-lv2);pointer-events:none;border-radius:12px;padding:10px 12px;position:absolute;left:42px}.dshQueryRail_tooltipLabel{color:var(--dsw-alias-label-caption);text-transform:uppercase;letter-spacing:.06em;margin-bottom:4px;font-size:11px;line-height:16px}.dshQueryRail_tooltipText{-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden;font-size:13px;line-height:19px;display:-webkit-box}@media(max-width:980px){.dshQueryRail_root{display:none}}@media(prefers-reduced-motion:reduce){.dshQueryRail_item:before{transition:none}}"; ++ const queryRailTagId = "@deepseek-ai/dsh-client-ui-conversation/QueryRail.css"; ++ if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(queryRailTagId) + "]") === null) { ++ const tag = document.createElement("style"); ++ tag.dataset.plugin = "@deepseek-ai/dsh-client-ui-conversation"; ++ tag.dataset.pluginCss = queryRailTagId; ++ tag.textContent = queryRailCss + ".dshQueryRail_scroller{pointer-events:auto}"; ++ document.head.appendChild(tag); ++ } + //#endregion + //#region lib/types/client/chat/ChatNodeSeat.js + /** Subscribe and dispatch one stable Context key without observing sibling Nodes. */ +@@ -5250,6 +5259,21 @@ window.__ModuleLoader__.load({ + for (const row of list.querySelectorAll("[data-chat-anchor-key]")) if (row.dataset.chatAnchorKey === key) return row; + return null; + } ++ /** Select the query whose turn currently owns the reading position. */ ++ function queryAtViewport(list, scrollport, queries) { ++ if (queries.length === 0) return null; ++ const viewport = scrollport.getBoundingClientRect(); ++ const visibleBottom = scrollport.querySelector("[data-composer-seat]")?.getBoundingClientRect().top ?? viewport.bottom; ++ const threshold = viewport.top + Math.max(72, (visibleBottom - viewport.top) * .55); ++ let active = queries[0].key; ++ for (const query of queries) { ++ const row = anchorElement(list, query.key); ++ if (row === null) continue; ++ if (row.getBoundingClientRect().top > threshold) break; ++ active = query.key; ++ } ++ return active; ++ } + /** Row position in scrollport coordinates (viewport-independent). */ + function flowTop(row, scrollport) { + return row.getBoundingClientRect().top - scrollport.getBoundingClientRect().top; +@@ -5325,6 +5349,106 @@ window.__ModuleLoader__.load({ + })] + }); + } ++ /** Compact, independently scrollable navigation for durable user queries. */ ++ function QueryRail({ queries, activeKey, listRef, columnRef, onNavigate }) { ++ const railRef = (0, react.useRef)(null); ++ const scrollerRef = (0, react.useRef)(null); ++ const [layout, setLayout] = (0, react.useState)(null); ++ const [hovered, setHovered] = (0, react.useState)(null); ++ (0, react.useLayoutEffect)(() => { ++ const list = listRef.current; ++ const column = columnRef.current; ++ if (queries.length < 2 || list === null || column === null) { ++ setLayout(null); ++ return; ++ } ++ const scrollport = scrollerOf(list); ++ const composer = scrollport.querySelector("[data-composer-seat]"); ++ const update = () => { ++ const viewport = scrollport.getBoundingClientRect(); ++ const bottom = Math.min(viewport.bottom, composer?.getBoundingClientRect().top ?? viewport.bottom) - 12; ++ const top = viewport.top + 12; ++ const height = Math.floor(bottom - top); ++ const next = height >= 144 ? { ++ top: Math.round(top), ++ left: Math.round(viewport.left + 12), ++ height ++ } : null; ++ setLayout((current) => current?.top === next?.top && current?.left === next?.left && current?.height === next?.height ? current : next); ++ }; ++ update(); ++ window.addEventListener("resize", update); ++ const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(update); ++ observer?.observe(scrollport); ++ observer?.observe(column); ++ if (composer !== null) observer?.observe(composer); ++ return () => { ++ window.removeEventListener("resize", update); ++ observer?.disconnect(); ++ }; ++ }, [queries.length, listRef, columnRef]); ++ (0, react.useLayoutEffect)(() => { ++ const scroller = scrollerRef.current; ++ if (scroller === null || activeKey === null) return; ++ const button = [...scroller.querySelectorAll("[data-query-key]")].find((candidate) => candidate.dataset.queryKey === activeKey); ++ if (button === void 0) return; ++ const top = button.offsetTop; ++ const bottom = top + button.offsetHeight; ++ if (top < scroller.scrollTop + 8) scroller.scrollTop = Math.max(0, top - 8); ++ else if (bottom > scroller.scrollTop + scroller.clientHeight - 8) scroller.scrollTop = bottom - scroller.clientHeight + 8; ++ }, [activeKey, layout?.height]); ++ if (layout === null) return null; ++ const showPreview = (query, button) => { ++ const rail = railRef.current; ++ if (rail === null) return; ++ const railBox = rail.getBoundingClientRect(); ++ const buttonBox = button.getBoundingClientRect(); ++ setHovered({ ++ query, ++ top: Math.max(8, Math.min(layout.height - 88, buttonBox.top - railBox.top - 12)) ++ }); ++ }; ++ return (0, react_jsx_runtime.jsxs)("nav", { ++ ref: railRef, ++ className: "dshQueryRail_root", ++ style: layout, ++ "data-query-rail": "", ++ "aria-label": document.documentElement.lang.startsWith("zh") ? "Query 导航" : "Query navigation", ++ onMouseLeave: () => setHovered(null), ++ children: [(0, react_jsx_runtime.jsx)("div", { ++ ref: scrollerRef, ++ className: "dshQueryRail_scroller", ++ onWheel: (event) => event.stopPropagation(), ++ onScroll: () => setHovered(null), ++ children: queries.map((query, index) => { ++ const active = query.key === activeKey; ++ const label = `Query ${index + 1}: ${query.preview}`; ++ return (0, react_jsx_runtime.jsx)("button", { ++ type: "button", ++ className: `dshQueryRail_item${active ? " dshQueryRail_itemActive" : ""}`, ++ "data-query-key": query.key, ++ "aria-label": label, ++ "aria-current": active ? "step" : void 0, ++ onClick: () => onNavigate(query.key), ++ onMouseEnter: (event) => showPreview(query, event.currentTarget), ++ onFocus: (event) => showPreview(query, event.currentTarget), ++ onBlur: () => setHovered(null) ++ }, query.key); ++ }) ++ }), hovered !== null && (0, react_jsx_runtime.jsxs)("div", { ++ className: "dshQueryRail_tooltip", ++ style: { top: hovered.top }, ++ role: "tooltip", ++ children: [(0, react_jsx_runtime.jsx)("div", { ++ className: "dshQueryRail_tooltipLabel", ++ children: `Query ${queries.findIndex((query) => query.key === hovered.query.key) + 1}` ++ }), (0, react_jsx_runtime.jsx)("div", { ++ className: "dshQueryRail_tooltipText", ++ children: hovered.query.preview ++ })] ++ })] ++ }); ++ } + /** + * The chat view slot entry: pure component over the composed props; each + * ordered business Node crosses the keyed renderer seat. +@@ -5342,11 +5466,25 @@ window.__ModuleLoader__.load({ + const loadingOlder = useSession((s) => s.loadingOlder); + const selectedCallId = useStore((s) => s.selection?.callId); + const pendingSteering = (0, react.useMemo)(() => inbox.filter((item) => item.placement === "steering"), [inbox]); ++ const queries = (0, react.useMemo)(() => order.flatMap((key) => { ++ const node = nodeStore.get(key); ++ if (node?.kind !== "user" && node?.kind !== "steering") return []; ++ const content = Array.isArray(node.data.content) ? contentParts(node.data.content) : { ++ text: "", ++ images: [], ++ rest: [] ++ }; ++ const text = content.text.replace(/\s+/g, " ").trim(); ++ const preview = (text || (content.images.length > 0 ? document.documentElement.lang.startsWith("zh") ? "图片 Query" : "Image query" : document.documentElement.lang.startsWith("zh") ? "无文本 Query" : "Query without text")).slice(0, 240); ++ return [{ key, preview }]; ++ }), [order, nodeStore]); + const runningTurnStart = (0, react.useMemo)(() => runningTurnStartTime(timeline), [timeline]); + const listRef = (0, react.useRef)(null); + const columnRef = (0, react.useRef)(null); + const atBottomRef = (0, react.useRef)(true); + const [atBottom, setAtBottom] = (0, react.useState)(true); ++ const [activeQueryKey, setActiveQueryKey] = (0, react.useState)(null); ++ const activeQueryKeyRef = (0, react.useRef)(null); + /** Last position delivered or written on the main thread. */ + const observedTopRef = (0, react.useRef)(0); + /** Paging anchor: semantic row/position at click, updated by reader scrolls +@@ -5366,6 +5504,12 @@ window.__ModuleLoader__.load({ + const lastNode = lastKey === null ? void 0 : nodeStore.get(lastKey); + const lastSteeringId = pendingSteering[pendingSteering.length - 1]?.id ?? null; + const followSig = `${openState}:${firstSeq}:${lastKey}:${order.length}:${running ? 1 : 0}:${lastSteeringId ?? ""}`; ++ const syncActiveQuery = (local, el) => { ++ const next = queryAtViewport(local, el, queries); ++ if (next === activeQueryKeyRef.current) return; ++ activeQueryKeyRef.current = next; ++ setActiveQueryKey(next); ++ }; + const toBottom = (el) => { + anchorRef.current = null; + el.scrollTop = el.scrollHeight; +@@ -5373,6 +5517,8 @@ window.__ModuleLoader__.load({ + atBottomRef.current = true; + setAtBottom(true); + chatScroll.save(null); ++ const local = listRef.current; ++ if (local !== null) syncActiveQuery(local, el); + }; + (0, react.useLayoutEffect)(() => { + const local = listRef.current; +@@ -5429,6 +5575,7 @@ window.__ModuleLoader__.load({ + /* v8 ignore next -- ref-null guard: the handler only fires while mounted. */ + if (local === null) return; + const el = scrollerOf(local); ++ syncActiveQuery(local, el); + const floor = Math.max(0, el.scrollHeight - el.clientHeight); + const movedByReader = Math.abs(el.scrollTop - Math.min(observedTopRef.current, floor)) > .5; + const isAtBottom = movedByReader ? floor - el.scrollTop <= 25 : atBottomRef.current; +@@ -5448,6 +5595,10 @@ window.__ModuleLoader__.load({ + else if (position !== null) chatScroll.save(position); + observedTopRef.current = el.scrollTop; + }; ++ (0, react.useLayoutEffect)(() => { ++ const local = listRef.current; ++ if (local !== null) syncActiveQuery(local, scrollerOf(local)); ++ }, [queries, openState]); + (0, react.useEffect)(() => { + const local = listRef.current; + /* v8 ignore next -- ref-null guard: effect runs after the list node commits. */ +@@ -5501,9 +5652,29 @@ window.__ModuleLoader__.load({ + } + loadOlder(); + }; ++ const scrollToQuery = (key) => { ++ const local = listRef.current; ++ if (local === null) return; ++ const row = anchorElement(local, key); ++ if (row === null) return; ++ const el = scrollerOf(local); ++ const reducedMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true; ++ el.scrollTo({ ++ top: Math.max(0, el.scrollTop + flowTop(row, el) - 24), ++ behavior: reducedMotion ? "auto" : "smooth" ++ }); ++ activeQueryKeyRef.current = key; ++ setActiveQueryKey(key); ++ }; + return (0, react_jsx_runtime.jsx)("div", { + className: ChatView_module_css_default.root, +- children: (0, react_jsx_runtime.jsxs)("div", { ++ children: [(0, react_jsx_runtime.jsx)(QueryRail, { ++ queries, ++ activeKey: activeQueryKey, ++ listRef, ++ columnRef, ++ onNavigate: scrollToQuery ++ }), (0, react_jsx_runtime.jsxs)("div", { + ref: listRef, + className: ChatView_module_css_default.scroll, + children: [(0, react_jsx_runtime.jsxs)("div", { +@@ -5568,7 +5739,7 @@ window.__ModuleLoader__.load({ + children: (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutline14, {}) + }) + })] +- }) ++ })] + }); + } + //#endregion diff --git a/patches/@deepseek-ai+dsh-host-apiproxy+0.1.0-rc.6.patch b/patches/@deepseek-ai+dsh-host-apiproxy+0.1.0-rc.6.patch index 0e6c0345..a9aeec10 100644 --- a/patches/@deepseek-ai+dsh-host-apiproxy+0.1.0-rc.6.patch +++ b/patches/@deepseek-ai+dsh-host-apiproxy+0.1.0-rc.6.patch @@ -1,5 +1,5 @@ diff --git a/node_modules/@deepseek-ai/dsh-host-apiproxy/lib/index.js b/node_modules/@deepseek-ai/dsh-host-apiproxy/lib/index.js -index 9e3afa7..1c97a67 100644 +index 9e3afa7..0a08a41 100644 --- a/node_modules/@deepseek-ai/dsh-host-apiproxy/lib/index.js +++ b/node_modules/@deepseek-ai/dsh-host-apiproxy/lib/index.js @@ -1,8 +1,8 @@ @@ -135,7 +135,52 @@ index 9e3afa7..1c97a67 100644 //#region lib/types/session-export.js /** * Host-side session-log download: streams one ZIP archive whose files are the -@@ -3299,6 +3403,127 @@ function createApiProxy(ctx, defaults) { +@@ -3205,7 +3309,13 @@ function createApiProxy(ctx, defaults) { + })); + }, + async pickDirectory(request, signal) { +- const capability = ctx.directoryPicker.capability(); ++ const directoryPicker = ctx.get("directoryPicker"); ++ if (!directoryPicker) return err(request, { ++ code: "directory-picker-unavailable", ++ message: "host.pickDirectory needs a composed directory picker", ++ details: { capability: "none" } ++ }); ++ const capability = directoryPicker.capability(); + if (capability.kind !== "native") return err(request, { + code: "directory-picker-unavailable", + message: `host.pickDirectory needs the native capability; the composed picker serves "${capability.kind}"`, +@@ -3227,7 +3337,13 @@ function createApiProxy(ctx, defaults) { + } + }, + async listDirectory(request, signal) { +- const capability = ctx.directoryPicker.capability(); ++ const directoryPicker = ctx.get("directoryPicker"); ++ if (!directoryPicker) return err(request, { ++ code: "directory-picker-unavailable", ++ message: "host.listDirectory needs a composed directory picker", ++ details: { capability: "none" } ++ }); ++ const capability = directoryPicker.capability(); + if (capability.kind !== "browse") return err(request, { + code: "directory-picker-unavailable", + message: `host.listDirectory needs the browse capability; the composed picker serves "${capability.kind}"`, +@@ -3245,7 +3361,13 @@ function createApiProxy(ctx, defaults) { + } + }, + async createDirectory(request) { +- const capability = ctx.directoryPicker.capability(); ++ const directoryPicker = ctx.get("directoryPicker"); ++ if (!directoryPicker) return err(request, { ++ code: "directory-picker-unavailable", ++ message: "host.createDirectory needs a composed directory picker", ++ details: { capability: "none" } ++ }); ++ const capability = directoryPicker.capability(); + if (capability.kind !== "browse") return err(request, { + code: "directory-picker-unavailable", + message: `host.createDirectory needs the browse capability; the composed picker serves "${capability.kind}"`, +@@ -3299,6 +3421,127 @@ function createApiProxy(ctx, defaults) { } }, agentPresets: { @@ -263,7 +308,7 @@ index 9e3afa7..1c97a67 100644 async list(request) { const presets = ctx.get("agentPresets"); if (presets === void 0) return ok(request, { -@@ -4993,6 +5218,34 @@ function toFetchHandler(api) { +@@ -4993,6 +5236,34 @@ function toFetchHandler(api) { rpcId: RpcId(randomUUID()), payload: {} }, req.signal)); @@ -298,3 +343,11 @@ index 9e3afa7..1c97a67 100644 if (path === "/api/session.export" && (req.method === "GET" || req.method === "HEAD")) { const parsed = sessionLogQuerySchema.safeParse(Object.fromEntries(url.searchParams)); if (!parsed.success) return new Response("missing or invalid sessionId query parameter", { status: 400 }); +@@ -5592,7 +5863,6 @@ var ApiProxyService = class extends Service { + "agentDefaultModel", + "agents", + "attachments", +- "directoryPicker", + "llm", + "sessions", + "subagents", diff --git a/src/main/index.ts b/src/main/index.ts index d727df99..d118507e 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -32,6 +32,7 @@ let mobileBridge: LanMobileBridge let launchDirectory: string let quitting = false let failureDialogVisible = false +let harnessLaunchOperation: Promise | undefined function isDevelopmentBuild(): boolean { if (!app.isPackaged) return true @@ -158,6 +159,20 @@ function harnessLocale(): 'en' | 'zh' { } } +function harnessThemePreference(): 'light' | 'dark' | 'system' { + try { + const settings = parse( + readFileSync(join(app.getPath('userData'), 'harness', 'settings.yaml'), 'utf8') + ) as { 'ui-theme'?: { preference?: unknown } } + const preference = settings['ui-theme']?.preference + return preference === 'light' || preference === 'dark' || preference === 'system' + ? preference + : 'system' + } catch { + return 'system' + } +} + function createWindow(): BrowserWindow { const window = new BrowserWindow({ width: 1380, @@ -218,9 +233,31 @@ async function showSplash(): Promise { window.focus() } -async function launchHarness(): Promise { - await showSplash() - await runtime.start(launchDirectory) +function launchHarness(): Promise { + if (harnessLaunchOperation) return harnessLaunchOperation + + harnessLaunchOperation = (async () => { + await showSplash() + await runtime.start(launchDirectory) + })().finally(() => { + harnessLaunchOperation = undefined + }) + return harnessLaunchOperation +} + +function registerHarnessHandlers(): void { + ipcMain.removeHandler('harness:restart') + ipcMain.handle('harness:restart', async (event) => { + if (!mainWindow || mainWindow.isDestroyed() || event.sender !== mainWindow.webContents) { + throw new Error('Harness restart is only available from the DSH Desktop window.') + } + if (runtime.snapshot().phase !== 'ready') { + throw new Error('Harness is not ready to restart.') + } + + await launchHarness() + return { ok: runtime.snapshot().phase === 'ready' } + }) } function showUnexpectedError(error: unknown): void { @@ -385,6 +422,7 @@ async function showMobilePairing(): Promise { } if (mobileWindow && !mobileWindow.isDestroyed()) mobileWindow.destroy() + nativeTheme.themeSource = harnessThemePreference() mobileWindow = new BrowserWindow({ width: 560, height: 700, @@ -393,6 +431,7 @@ async function showMobilePairing(): Promise { title: harnessLocale() === 'zh' ? '连接手机' : 'Connect Phone', icon: desktopIconPath(), parent: mainWindow, + backgroundColor: nativeTheme.shouldUseDarkColors ? '#141416' : '#ffffff', webPreferences: { contextIsolation: true, nodeIntegration: false, @@ -430,6 +469,7 @@ async function bootstrap(): Promise { } } }) + registerHarnessHandlers() mobileBridge = new LanMobileBridge({ harnessUrl: () => runtime.snapshot().url, locale: harnessLocale, diff --git a/src/main/mobile/lan-mobile-pages.ts b/src/main/mobile/lan-mobile-pages.ts index 6a0df675..b3d461cf 100644 --- a/src/main/mobile/lan-mobile-pages.ts +++ b/src/main/mobile/lan-mobile-pages.ts @@ -77,7 +77,7 @@ function updateRunning(events){let running=false;for(const entry of events){cons async function loadWorkspaces(){const previous=$('workspace').value,value=await rpc('workspace.list');workspaces=value.items||[];if(workspaces.length){$('workspace').innerHTML=workspaces.map(w=>'').join('');$('workspace').value=workspaces.some(w=>w.workspaceId===previous)?previous:workspaces[0].workspaceId}else $('workspace').innerHTML=''} function syncWorkspaceUi(){const selected=!!$('workspace').value;$('newSession').disabled=!selected;$('workspaceHint').hidden=selected;$('workspaceHint').textContent=selected?'':L.noWorkspaces;if(selected)$('listError').textContent=''} function relativeTime(value){const parsed=typeof value==='number'?value:Date.parse(value),elapsed=Math.max(0,Date.now()-(Number.isFinite(parsed)?parsed:Date.now())),minutes=Math.floor(elapsed/60000);if(minutes<1)return L.justNow;if(minutes<60)return minutes+L.minute;const hours=Math.floor(minutes/60);if(hours<24)return hours+L.hour;return Math.floor(hours/24)+L.day} -async function loadSessions(){try{const value=await rpc('session.list',{}),wid=$('workspace').value,w=workspaces.find(x=>x.workspaceId===wid),allowed=w?new Set(w.sessionIds):new Set(),items=(value.items||[]).filter(s=>allowed.has(s.sessionId)).sort((a,b)=>b.updatedAt-a.updatedAt);$('sessions').innerHTML=!wid?'':items.length?items.map(s=>{const title=titleFor(s);return''}).join(''):'
'+L.noSessions+'
';document.querySelectorAll('.row').forEach(b=>b.onclick=()=>openSession(b.dataset.id,b.dataset.title))}catch(e){$('listError').textContent=e.message}} +async function loadSessions(){try{const value=await rpc('session.list',{}),wid=$('workspace').value,w=workspaces.find(x=>x.workspaceId===wid),allowed=w?new Set(w.sessionIds):new Set(),archived=new Set(value.archivedSessionIds||[]),items=(value.items||[]).filter(s=>allowed.has(s.sessionId)&&!archived.has(s.sessionId)).sort((a,b)=>b.updatedAt-a.updatedAt);$('sessions').innerHTML=!wid?'':items.length?items.map(s=>{const title=titleFor(s);return''}).join(''):'
'+L.noSessions+'
';document.querySelectorAll('.row').forEach(b=>b.onclick=()=>openSession(b.dataset.id,b.dataset.title))}catch(e){$('listError').textContent=e.message}} async function createSession(){const workspaceId=$('workspace').value;if(!workspaceId){$('listError').textContent=L.chooseWorkspace;return}try{const created=await rpc('session.create',{workspaceId});await openSession(created.sessionId)}catch(e){$('listError').textContent=e.message}} function showToast(message){$('toast').textContent=message;$('toast').classList.add('show');clearTimeout(showToast.timer);showToast.timer=setTimeout(()=>$('toast').classList.remove('show'),1000)} async function refreshAll(){const button=$('refresh');button.disabled=true;button.classList.add('refreshing');try{await loadWorkspaces();syncWorkspaceUi();await loadSessions();showToast(L.refreshed)}catch(e){$('listError').textContent=e.message}finally{setTimeout(()=>{button.disabled=false;button.classList.remove('refreshing')},350)}} @@ -124,7 +124,7 @@ export function renderDesktopPairingPage(options: { expired: zh ? '二维码已过期,请重新打开此窗口刷新。' : 'QR expired. Reopen this window to refresh.' } return `${text.title}
DSH Desktop

${options.connected ? text.manageHeading : text.heading}

${options.connected ? text.manageHint : text.hint}

${text.connected}

${text.closeHint}

${options.qrSvg}
${escapeHtml(options.pairingUrl)}

${text.waiting}
` + :root{color-scheme:light;--bg:#fff;--surface:#fff;--panel:#f7f8fa;--ink:#18191c;--muted:#81858c;--line:#e5e7eb;--brand:#4d6bfe;--success-bg:#f2f8f4;--success-ink:#277347;--success-muted:#557565;--request-bg:#f5f7ff}@media(prefers-color-scheme:dark){:root{color-scheme:dark;--bg:#141416;--surface:#1d1d20;--panel:#202023;--ink:#f5f5f6;--muted:#95979d;--line:#303034;--brand:#6f86ff;--success-bg:#17261d;--success-ink:#75c991;--success-muted:#8ab99a;--request-bg:#1b2033}}*{box-sizing:border-box}html,body{min-height:100%;background:var(--bg)}body{margin:0;color:var(--ink);font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}.wrap{max-width:520px;margin:auto;padding:28px 32px;text-align:center}.brand{display:flex;align-items:center;justify-content:center;gap:9px;font-weight:600;margin-bottom:22px}.brand img{width:39px;height:22px;object-fit:contain}.brand .dark-logo{display:none}@media(prefers-color-scheme:dark){.brand .light-logo{display:none}.brand .dark-logo{display:block}}h1{font-size:26px;line-height:1.25;font-weight:600;margin:0 0 8px}p{margin:0;color:var(--muted)}.connection{display:none;flex-direction:column;align-items:center;margin:28px auto 0;max-width:390px;padding:22px;border-radius:14px;background:var(--success-bg);color:var(--success-ink)}.connection.show{display:flex}.connection-title{font-size:16px;font-weight:600}.connection-title:before{content:'✓';display:inline-grid;place-items:center;width:24px;height:24px;margin-right:9px;border-radius:50%;background:#35a867;color:white}.connection-hint{max-width:310px;margin-top:8px;color:var(--success-muted);font-size:13px}.connection-actions{display:flex;gap:8px;margin-top:18px}.connection-actions button{min-width:94px;border:1px solid var(--line);border-radius:9px;background:var(--surface);color:var(--ink);padding:8px 14px;cursor:pointer}.connection-actions .done{background:var(--ink);color:var(--bg);border-color:var(--ink)}.phone-connected .pairing-content{display:none}.manage-connected .connection-hint,.manage-connected .done{display:none}.manage-connected .connection-actions{margin-top:16px}.qr{display:inline-flex;background:#fff;padding:14px;border:1px solid var(--line);border-radius:16px;margin:22px 0 14px}.qr svg{width:220px;height:220px}.hint{font-size:13px}.url-row{display:flex;align-items:center;gap:8px;margin:14px auto 0;max-width:390px}.url{min-width:0;flex:1;font:12px/1.35 ui-monospace,SFMono-Regular,Menlo,monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;background:var(--panel);border-radius:9px;padding:9px 11px;text-align:left}.copy{border:1px solid var(--line);background:var(--surface);color:var(--ink);border-radius:9px;padding:8px 12px;cursor:pointer}.expires{font-size:12px;margin-top:10px}.request{display:none;margin-top:16px;padding:14px;border:1px solid var(--brand);border-radius:12px;background:var(--request-bg);text-align:left}.request.show{display:block}.request-title{display:flex;align-items:center;gap:8px;font-weight:600}.request-title:before{content:'';width:8px;height:8px;border-radius:50%;background:var(--brand)}#address{font-size:12px;color:var(--muted);margin:5px 0 0 16px}.actions{display:flex;justify-content:flex-end;gap:8px;margin-top:12px}.actions button{border:1px solid var(--line);border-radius:9px;background:var(--surface);color:var(--ink);padding:8px 14px;cursor:pointer}.actions .allow{background:var(--ink);color:var(--bg);border-color:var(--ink)}
DSH Desktop

${options.connected ? text.manageHeading : text.heading}

${options.connected ? text.manageHint : text.hint}

${text.connected}

${text.closeHint}

${options.qrSvg}
${escapeHtml(options.pairingUrl)}

${text.waiting}
` } export function renderPairingWaitPage(pairingId: string, locale: 'en' | 'zh'): string { diff --git a/src/preload/index.ts b/src/preload/index.ts index f0b1d517..06e3f71e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -89,6 +89,13 @@ function initializeUi(): void { mobileStatusTimer ??= window.setInterval(() => void refreshMobileStatus(), 1000) } +contextBridge.exposeInMainWorld( + 'dshDesktop', + Object.freeze({ + restartHarness: (): Promise<{ ok: boolean }> => ipcRenderer.invoke('harness:restart') + }) +) + function mount(): void { if (document.getElementById(ROOT_ID)) return diff --git a/test/directory-picker.test.ts b/test/directory-picker.test.ts index 613b6a4f..27e68ad3 100644 --- a/test/directory-picker.test.ts +++ b/test/directory-picker.test.ts @@ -30,4 +30,15 @@ describe('desktop Electron directory picker', () => { expect(dependencyPatch).toContain('window.dshDesktopDirectoryPicker') expect(dependencyPatch).toContain('DSH Desktop directory picker bridge is unavailable') }) + + it('keeps the Host API proxy active when the legacy picker service is absent', async () => { + const apiProxyPatch = await readFile( + 'patches/@deepseek-ai+dsh-host-apiproxy+0.1.0-rc.6.patch', + 'utf8' + ) + + expect(apiProxyPatch).toContain('const directoryPicker = ctx.get("directoryPicker")') + expect(apiProxyPatch).toContain('-\t\t"directoryPicker",') + expect(apiProxyPatch).toContain('details: { capability: "none" }') + }) }) diff --git a/test/lan-mobile-pages.test.ts b/test/lan-mobile-pages.test.ts index 22d08b35..a6efd437 100644 --- a/test/lan-mobile-pages.test.ts +++ b/test/lan-mobile-pages.test.ts @@ -58,6 +58,8 @@ describe('LAN mobile page', () => { expect(html).toContain("fetch('/api/status',{cache:'no-store'})") expect(html).toContain('setInterval(checkConnection,1500)') expect(html).toContain("status.classList.add('error-state')") + expect(html).toContain('archived=new Set(value.archivedSessionIds||[])') + expect(html).toContain('!archived.has(s.sessionId)') }) it('uses DSH styling on both pairing surfaces', () => { @@ -78,6 +80,10 @@ describe('LAN mobile page', () => { expect(() => new Function(script)).not.toThrow() } expect(desktop).toContain('/desktop/disconnect') + expect(desktop).toContain('prefers-color-scheme:dark') + expect(desktop).toContain('/brand-logo/dark') + expect(desktop).toContain('--bg:#141416') + expect(desktop).toContain('.qr{display:inline-flex;background:#fff') expect(desktop).toContain('Phone connected') expect(desktop).toContain('You can close this window now.') expect(desktop).toContain('onclick="window.close()">Done') diff --git a/test/market-installer.test.js b/test/market-installer.test.js new file mode 100644 index 00000000..2a4a7f8b --- /dev/null +++ b/test/market-installer.test.js @@ -0,0 +1,117 @@ +import { mkdtemp, mkdir, readFile, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + INSTALL_PATH, + MARKET_PACKAGE, + RECOMMENDED_MARKET_VERSION, + STATUS_PATH, + UNINSTALL_PATH, + buildInstallArguments, + buildUninstallArguments, + isTrustedRequest, + readMarketInstallation, + resolvePnpmEntry +} from '../packages/dsh-desktop-market-installer/index.js' + +describe('desktop plugin market installer', () => { + it('pins the only install target accepted by the host', () => { + expect(buildInstallArguments('/app/dsh/bin.js')).toEqual([ + '/app/dsh/bin.js', + 'plugin', + '--profile', + 'web', + 'add', + '--save-exact', + 'dshmarket@1.9.0' + ]) + expect(MARKET_PACKAGE).toBe('dshmarket') + expect(RECOMMENDED_MARKET_VERSION).toBe('1.9.0') + expect(STATUS_PATH).toBe('/dsh-desktop/market-installer/status') + expect(INSTALL_PATH).toBe('/dsh-desktop/market-installer/install') + expect(UNINSTALL_PATH).toBe('/dsh-desktop/market-installer/uninstall') + expect(buildUninstallArguments('/app/dsh/bin.js')).toEqual([ + '/app/dsh/bin.js', + 'plugin', + '--profile', + 'web', + 'remove', + 'dshmarket' + ]) + }) + + it('ships a resolvable pnpm binary instead of relying on the user PATH', () => { + expect(resolvePnpmEntry()).toMatch(/node_modules[/\\]pnpm[/\\]bin[/\\]pnpm\.(c|m)js$/u) + }) + + it('reports both the requested dependency and installed package version', async () => { + const home = await mkdtemp(join(tmpdir(), 'dsh-market-status-')) + const profile = join(home, 'profiles', 'web') + await mkdir(join(profile, 'node_modules', 'dshmarket'), { recursive: true }) + await writeFile( + join(profile, 'package.json'), + JSON.stringify({ dependencies: { dshmarket: '1.9.0' } }), + 'utf8' + ) + await writeFile( + join(profile, 'node_modules', 'dshmarket', 'package.json'), + JSON.stringify({ name: 'dshmarket', version: '1.9.0' }), + 'utf8' + ) + + await expect(readMarketInstallation(home)).resolves.toEqual({ + dependency: '1.9.0', + installedVersion: '1.9.0' + }) + }) + + it('allows loopback status reads but requires same-origin mutation requests', () => { + const request = (headers = {}, remoteAddress = '127.0.0.1') => ({ + headers, + socket: { remoteAddress } + }) + + expect(isTrustedRequest(request())).toBe(true) + expect( + isTrustedRequest( + request({ origin: 'http://127.0.0.1:51923', host: '127.0.0.1:51923' }), + true + ) + ).toBe(true) + expect( + isTrustedRequest( + request({ origin: 'https://attacker.example', host: '127.0.0.1:51923' }), + true + ) + ).toBe(false) + expect( + isTrustedRequest(request({ forwarded: 'for=127.0.0.1' }), false) + ).toBe(false) + expect(isTrustedRequest(request({}, '192.168.1.5'))).toBe(false) + }) + + it('registers a placeholder before install and a stable management tab afterward', async () => { + const client = await readFile( + join(process.cwd(), 'packages', 'dsh-desktop-market-installer', 'client.js'), + 'utf8' + ) + const desktopPatch = await readFile( + join(process.cwd(), 'build', 'dsh-desktop.patch.yml'), + 'utf8' + ) + const preload = await readFile(join(process.cwd(), 'src', 'preload', 'index.ts'), 'utf8') + + expect(client).toContain("entry?.id === 'dshmarket'") + expect(client).toContain("id: 'market'") + expect(client).toContain('order: 40') + expect(client).toContain("id: 'desktop-market-management'") + expect(client).toContain("name: 'settings.plugins.tab'") + expect(client).toContain( + '只会移除 dsh-market。通过插件市场安装的其他插件将继续保留。' + ) + expect(desktopPatch).toContain('name: dsh-desktop-market-installer') + expect(desktopPatch).toContain('allowRestart: false') + expect(preload).toContain("restartHarness: (): Promise<{ ok: boolean }>") + }) +}) diff --git a/test/query-rail-patch.test.ts b/test/query-rail-patch.test.ts new file mode 100644 index 00000000..bd1467cc --- /dev/null +++ b/test/query-rail-patch.test.ts @@ -0,0 +1,37 @@ +import { readFile } from 'node:fs/promises' +import path from 'node:path' +import { describe, expect, it } from 'vitest' + +const projectRoot = path.resolve(import.meta.dirname, '..') +const patchPath = path.join( + projectRoot, + 'patches', + '@deepseek-ai+dsh-client-ui-conversation+0.1.0-rc.6.patch' +) + +describe('conversation Query navigation rail', () => { + it('builds one navigation marker for every durable user query', async () => { + const patch = await readFile(patchPath, 'utf8') + + expect(patch).toContain('function QueryRail') + expect(patch).toContain('node?.kind !== "user" && node?.kind !== "steering"') + expect(patch).toContain('"data-query-key": query.key') + expect(patch).toContain('queryAtViewport(local, el, queries)') + expect(patch).toContain('onNavigate(query.key)') + expect(patch).toContain('behavior: reducedMotion ? "auto" : "smooth"') + }) + + it('keeps the rail independently scrollable and follows the active query', async () => { + const patch = await readFile(patchPath, 'utf8') + + expect(patch).toContain('overflow-y:auto') + expect(patch).toContain('overscroll-behavior-y:contain') + expect(patch).toContain('scrollbar-width:none') + expect(patch).toContain('dshQueryRail_scroller::-webkit-scrollbar{width:0;height:0;display:none}') + expect(patch).toContain('onWheel: (event) => event.stopPropagation()') + expect(patch).toContain('scroller.scrollTop = Math.max(0, top - 8)') + expect(patch).toContain('left: Math.round(viewport.left + 12)') + expect(patch).toContain('dshQueryRail_itemActive') + expect(patch).toContain('dshQueryRail_tooltipText') + }) +})