From 539e25419ab0f30c86359fc6b2aecb938ef73d86 Mon Sep 17 00:00:00 2001 From: t4we fan Date: Wed, 26 Aug 2026 14:51:48 +0800 Subject: [PATCH 1/2] fix(desktop): make browser and profile setup explicit --- .../src/client/DesktopSettingsSection.tsx | 12 +- .../src/client/desktop-settings-locales.ts | 6 +- .../src/client/desktop-settings.ts | 11 +- dsh-plugin-desktop/src/desktop-network.ts | 21 ++-- .../src/desktop-settings-controller.ts | 2 +- dsh-plugin-desktop/src/index.ts | 24 ++-- dsh-plugin-desktop/src/main.ts | 4 +- .../src/native-ui/setup-wizard/App.tsx | 112 ++++++++++++++---- dsh-plugin-desktop/src/profile-manager.ts | 46 ++++--- dsh-plugin-desktop/src/profile.ts | 5 +- dsh-plugin-desktop/src/setup-wizard-copy.ts | 16 ++- .../src/setup-wizard-settings.ts | 37 +++--- .../tests/client-desktop-settings.spec.ts | 16 ++- dsh-plugin-desktop/tests/plugin.spec.ts | 8 +- .../tests/profile-manager.spec.ts | 49 +++++++- dsh-plugin-desktop/tests/profile.spec.ts | 58 +++++++-- .../tests/setup-wizard-copy.spec.ts | 8 +- .../tests/setup-wizard-native-ui.spec.ts | 48 +++++++- .../tests/setup-wizard-settings.spec.ts | 48 ++++++-- 19 files changed, 387 insertions(+), 144 deletions(-) diff --git a/dsh-plugin-desktop/src/client/DesktopSettingsSection.tsx b/dsh-plugin-desktop/src/client/DesktopSettingsSection.tsx index d516fd9c52..b10e080811 100644 --- a/dsh-plugin-desktop/src/client/DesktopSettingsSection.tsx +++ b/dsh-plugin-desktop/src/client/DesktopSettingsSection.tsx @@ -11,8 +11,8 @@ import type { import type { DesktopSettingsLocaleKey } from './desktop-settings-locales.ts' import type { DesktopClientPlatform } from './environment.ts' import { + desktopBrowserAccessAvailable, desktopBrowserAccessEnabled, - desktopShellModeForBrowserAccess, } from '../desktop-network.ts' /** Browser view of the Host `dsh-desktop` settings namespace. */ @@ -176,8 +176,8 @@ function ToggleRow({ } function profileState(profile: DesktopProfileView, t: Translate): string { - if (!profile.webCapable || !profile.selectable) return t('profileUnavailable') - return profile.exists ? t('profileReady') : t('profileMissing') + if (!profile.exists || !profile.webCapable || !profile.selectable) return t('profileUnavailable') + return t('profileReady') } const MARKET_OPTIONS: readonly { @@ -274,10 +274,11 @@ export function DesktopSettingsSection({ const storedMode = desktop.value?.mode ?? initialMode const configuredNetworkExposure = desktop.value?.networkExposure ?? 'loopback' const browserAccess = desktopBrowserAccessEnabled( + storedMode, desktop.value?.openBrowser ?? false, configuredNetworkExposure, ) - const mode = desktopShellModeForBrowserAccess(storedMode, browserAccess) + const mode = storedMode const networkExposure = browserAccess ? configuredNetworkExposure : 'loopback' const notificationValue = notifications.value ?? { enabled: true, @@ -353,6 +354,7 @@ export function DesktopSettingsSection({ const setBrowserAccess = (checked: boolean): void => { void run('web', async () => { if (checked) { + if (!desktopBrowserAccessAvailable(mode)) return await desktopSettings.set('openBrowser', true) requestRestart() return @@ -579,7 +581,7 @@ export function DesktopSettingsSection({

{t('browserCompatibilityNotice')}

diff --git a/dsh-plugin-desktop/src/client/desktop-settings-locales.ts b/dsh-plugin-desktop/src/client/desktop-settings-locales.ts index f85d3931a4..111ae43737 100644 --- a/dsh-plugin-desktop/src/client/desktop-settings-locales.ts +++ b/dsh-plugin-desktop/src/client/desktop-settings-locales.ts @@ -35,7 +35,6 @@ export const zh = { profileIntro: '切换启动时使用的 Profile,或创建一个支持桌面端的新 Profile。', profileReady: '可使用', profileUnavailable: '不适用于桌面版', - profileMissing: '将在启动时创建', activeProfile: '当前', profileName: 'Profile 名称', profileNamePlaceholder: '例如:work', @@ -80,7 +79,7 @@ export const zh = { webTitle: '浏览器与局域网', webIntro: '选择是否允许在浏览器中打开这个 Profile,以及 Web 界面的网络访问范围。', openBrowser: '允许在浏览器中打开', - browserCompatibilityNotice: '浏览器中只能使用兼容模式;开启后,这个 Profile 将切换到兼容模式。', + browserCompatibilityNotice: '浏览器访问仅在兼容模式下可用;如需开启,请先选择兼容模式。', lanAccess: '允许向局域网开放', browserUrls: '浏览器访问 URL', lanUrlsAfterRestart: '重启后将在这里显示实际的局域网 URL。', @@ -134,7 +133,6 @@ export const en: Record = { profileIntro: 'Switch the Profile used at startup or create a new Desktop-compatible Profile.', profileReady: 'Available', profileUnavailable: 'Not compatible with Desktop', - profileMissing: 'Created when first started', activeProfile: 'Current', profileName: 'Profile name', profileNamePlaceholder: 'For example: work', @@ -179,7 +177,7 @@ export const en: Record = { webTitle: 'Browser and local network', webIntro: 'Choose whether this Profile may be opened in a browser and which network interfaces may reach it.', openBrowser: 'Allow opening this Profile in a browser', - browserCompatibilityNotice: 'The browser only supports compatibility mode. Enabling browser access switches this Profile to compatibility mode.', + browserCompatibilityNotice: 'Browser access is only available in compatibility mode. Select compatibility mode first to enable it.', lanAccess: 'Allow access from the local network', browserUrls: 'Browser URLs', lanUrlsAfterRestart: 'The actual local-network URLs will appear here after restart.', diff --git a/dsh-plugin-desktop/src/client/desktop-settings.ts b/dsh-plugin-desktop/src/client/desktop-settings.ts index 4a6ce45f26..d3c64190f9 100644 --- a/dsh-plugin-desktop/src/client/desktop-settings.ts +++ b/dsh-plugin-desktop/src/client/desktop-settings.ts @@ -32,13 +32,18 @@ export async function persistDesktopModeSelection( desktopSettings: Pick, 'set'>, mode: DesktopShellSettings['mode'], ): Promise { - await desktopSettings.set('mode', mode) - if (mode === 'compatibility') return + if (mode === 'compatibility') { + await desktopSettings.set('mode', mode) + return + } // The titlebar is interactive before the settings mirror necessarily reaches // ready. Always withdraw both browser capabilities for a custom mode instead // of treating an unavailable or stale snapshot as browser access being off. - await desktopSettings.set('openBrowser', false) + // Withdraw the listener first so every intermediate persisted state remains + // valid while compatibility mode is still selected. await desktopSettings.set('networkExposure', 'loopback') + await desktopSettings.set('openBrowser', false) + await desktopSettings.set('mode', mode) } declare module '@deepseek-ai/dsh-client-ui-slots' { diff --git a/dsh-plugin-desktop/src/desktop-network.ts b/dsh-plugin-desktop/src/desktop-network.ts index 8941865f2e..5d83eee191 100644 --- a/dsh-plugin-desktop/src/desktop-network.ts +++ b/dsh-plugin-desktop/src/desktop-network.ts @@ -18,16 +18,21 @@ export function parseDesktopOpenBrowser(value: unknown): boolean { throw new Error('dsh-plugin-desktop: dsh-desktop.openBrowser must be a boolean') } +/** Browser access can only be granted by an explicitly selected compatibility shell. */ +export function desktopBrowserAccessAvailable(mode: DesktopShellMode): boolean { + return mode === 'compatibility' +} + /** - * Resolve browser access while preserving already-exposed legacy LAN setups. - * A legacy LAN listener was already browser-reachable, so it migrates to an - * explicit enabled preference instead of becoming a hidden exposure. + * Resolve browser access while preserving already-exposed legacy LAN setups + * only when the selected shell is compatible with an ordinary browser. */ export function desktopBrowserAccessEnabled( + mode: DesktopShellMode, storedOpenBrowser: boolean, exposure: DesktopNetworkExposure, ): boolean { - return storedOpenBrowser || exposure === 'lan' + return desktopBrowserAccessAvailable(mode) && (storedOpenBrowser || exposure === 'lan') } /** LAN exposure is meaningful only while ordinary-browser access is enabled. */ @@ -38,14 +43,6 @@ export function desktopNetworkExposureForBrowserAccess( return browserAccess ? exposure : 'loopback' } -/** Browser access uses the official compatibility layout for the whole generation. */ -export function desktopShellModeForBrowserAccess( - mode: DesktopShellMode, - browserAccess: boolean, -): DesktopShellMode { - return browserAccess ? 'compatibility' : mode -} - /** Parse the restart-applied listener exposure preference. */ export function parseDesktopNetworkExposure(value: unknown): DesktopNetworkExposure { if (value === undefined) return 'loopback' diff --git a/dsh-plugin-desktop/src/desktop-settings-controller.ts b/dsh-plugin-desktop/src/desktop-settings-controller.ts index 273faa8499..b967c8d360 100644 --- a/dsh-plugin-desktop/src/desktop-settings-controller.ts +++ b/dsh-plugin-desktop/src/desktop-settings-controller.ts @@ -68,7 +68,7 @@ export function projectDesktopSettingsProfile( name: profile.name, exists: profile.exists, webCapable: profile.webCapable, - selectable: profile.webCapable && profile.problem === undefined, + selectable: profile.exists && profile.webCapable && profile.problem === undefined, deletable, }) } diff --git a/dsh-plugin-desktop/src/index.ts b/dsh-plugin-desktop/src/index.ts index f8b495d30f..cbffba4e22 100644 --- a/dsh-plugin-desktop/src/index.ts +++ b/dsh-plugin-desktop/src/index.ts @@ -61,8 +61,8 @@ import type {} from './runtime.ts' import { DESKTOP_DEFAULT_WEB_PORT } from './desktop-port.ts' import { desktopBrowserAccessEnabled, + desktopBrowserAccessAvailable, desktopNetworkExposureForBrowserAccess, - desktopShellModeForBrowserAccess, desktopWebServerHost, type DesktopNetworkExposure, } from './desktop-network.ts' @@ -225,12 +225,11 @@ export function apply(ctx: Context, config: Config): void { { applies: 'restart', validate: (value) => { - const effectiveBrowserAccess = desktopBrowserAccessEnabled( - value.openBrowser, - value.networkExposure, - ) - const mode = desktopShellModeForBrowserAccess(value.mode, effectiveBrowserAccess) - if (mode !== 'compatibility' && runtime.platform === 'linux') { + if (!desktopBrowserAccessAvailable(value.mode) + && (value.openBrowser || value.networkExposure === 'lan')) { + throw new Error('dsh-plugin-desktop: browser and LAN access require compatibility mode') + } + if (value.mode !== 'compatibility' && runtime.platform === 'linux') { throw new Error('dsh-plugin-desktop: custom desktop shell modes are supported on macOS and Windows') } }, @@ -329,15 +328,15 @@ export function apply(ctx: Context, config: Config): void { let pending: ReturnType | undefined const stopWatching = settings.watch((next) => { const nextBrowserAccess = desktopBrowserAccessEnabled( + next.mode, next.openBrowser, next.networkExposure, ) - const nextMode = desktopShellModeForBrowserAccess(next.mode, nextBrowserAccess) const nextNetworkExposure = desktopNetworkExposureForBrowserAccess( nextBrowserAccess, next.networkExposure, ) - if (nextMode === config.mode + if (next.mode === config.mode && next.port === config.port && nextNetworkExposure === config.networkExposure && nextBrowserAccess === browserAccess.ordinaryBrowserEnabled @@ -409,11 +408,8 @@ export function apply(ctx: Context, config: Config): void { requestQuit: appExit, requestModeChange: async mode => { const current = settings.get() - const currentBrowserAccess = desktopBrowserAccessEnabled( - current.openBrowser, - current.networkExposure, - ) - await settings.update(mode !== 'compatibility' && currentBrowserAccess + const storedBrowserCapability = current.openBrowser || current.networkExposure === 'lan' + await settings.update(mode !== 'compatibility' && storedBrowserCapability ? { mode, openBrowser: false, networkExposure: 'loopback' } : { mode }) }, diff --git a/dsh-plugin-desktop/src/main.ts b/dsh-plugin-desktop/src/main.ts index ad4de36fce..cd4aea8154 100644 --- a/dsh-plugin-desktop/src/main.ts +++ b/dsh-plugin-desktop/src/main.ts @@ -791,7 +791,9 @@ async function start(): Promise { `${BIN_NAME}: requested Market provider ${prepared.market.requested} was disabled for this generation: ${prepared.marketFailure}`, ) } - const browserAccess = createDesktopBrowserAccess(prepared.openBrowser) + const browserAccess = createDesktopBrowserAccess( + prepared.mode === 'compatibility' && prepared.openBrowser, + ) const desktopPnpmBootstrap: DesktopPnpmBootstrap = { activeProfileName, activeProfileDir: prepared.profile.dir, diff --git a/dsh-plugin-desktop/src/native-ui/setup-wizard/App.tsx b/dsh-plugin-desktop/src/native-ui/setup-wizard/App.tsx index 7392e1eea3..688eee6c2f 100644 --- a/dsh-plugin-desktop/src/native-ui/setup-wizard/App.tsx +++ b/dsh-plugin-desktop/src/native-ui/setup-wizard/App.tsx @@ -118,9 +118,10 @@ export function decodeDesktopSetupWizardInput(search: string): DesktopSetupWizar } function normalizedSelection(input: DesktopSetupWizardInput): DesktopSetupWizardSelection { - const browserAccess = input.openBrowser || input.networkExposure === 'lan' + const mode = input.platform === 'linux' ? 'compatibility' : input.mode + const browserAccess = mode === 'compatibility' && (input.openBrowser || input.networkExposure === 'lan') return { - mode: input.platform === 'linux' || browserAccess ? 'compatibility' : input.mode, + mode, macosMaterial: input.macosMaterial, windowsMaterial: input.platform === 'win32' && input.windowsMaterial === 'mica' && !input.micaSupported ? 'acrylic' @@ -133,12 +134,13 @@ function normalizedSelection(input: DesktopSetupWizardInput): DesktopSetupWizard } function finish(selection: DesktopSetupWizardSelection): void { + const browserAccess = selection.mode === 'compatibility' && selection.openBrowser const url = new URL(`${SCHEME}//complete`) - url.searchParams.set('mode', selection.openBrowser ? 'compatibility' : selection.mode) + url.searchParams.set('mode', selection.mode) url.searchParams.set('macosMaterial', selection.macosMaterial) url.searchParams.set('windowsMaterial', selection.windowsMaterial) - url.searchParams.set('openBrowser', String(selection.openBrowser)) - url.searchParams.set('networkExposure', selection.openBrowser ? selection.networkExposure : 'loopback') + url.searchParams.set('openBrowser', String(browserAccess)) + url.searchParams.set('networkExposure', browserAccess ? selection.networkExposure : 'loopback') url.searchParams.set('market', selection.market) url.searchParams.set('notificationsEnabled', String(selection.notifications.enabled)) url.searchParams.set('notifyOnTurnCompletion', String(selection.notifications.notifyOnTurnCompletion)) @@ -379,28 +381,21 @@ function NotificationOptions({ function BrowserOptions({ copy, selection, - update, + requestBrowserAccess, requestExposure, }: { readonly copy: DesktopSetupWizardCopy readonly selection: DesktopSetupWizardSelection - readonly update: (selection: DesktopSetupWizardSelection) => void + readonly requestBrowserAccess: (enabled: boolean) => void readonly requestExposure: (exposure: DesktopSetupWizardNetworkExposure) => void }): JSX.Element { return
{ - update({ - ...selection, - mode: openBrowser ? 'compatibility' : selection.mode, - openBrowser, - networkExposure: openBrowser ? selection.networkExposure : 'loopback', - }) - }} + onChange={requestBrowserAccess} />

{copy.networkExposure}

@@ -411,12 +406,13 @@ function BrowserOptions({ className="mt-3" name="setup-network-exposure" onValueChange={value => { - if (value === 'loopback' || (value === 'lan' && selection.openBrowser)) requestExposure(value) + if (value === 'loopback' + || (value === 'lan' && selection.mode === 'compatibility' && selection.openBrowser)) requestExposure(value) }} value={selection.networkExposure} > - +
@@ -428,6 +424,7 @@ export function SetupWizardStepPage({ input, selection, update, + requestBrowserAccess, requestExposure, }: { readonly step: DesktopSetupWizardStep @@ -435,13 +432,14 @@ export function SetupWizardStepPage({ readonly input: DesktopSetupWizardInput readonly selection: DesktopSetupWizardSelection readonly update: (selection: DesktopSetupWizardSelection) => void + readonly requestBrowserAccess: (enabled: boolean) => void readonly requestExposure: (exposure: DesktopSetupWizardNetworkExposure) => void }): JSX.Element { if (step === 'mode') return if (step === 'material') return if (step === 'market') return if (step === 'notifications') return { update({ ...selection, notifications }) }} /> - if (step === 'browser') return + if (step === 'browser') return return
} @@ -565,6 +563,56 @@ export function SetupWizardLanConfirmation({ copy, confirm, cancel }: { } +export function SetupWizardBrowserCompatibilityConfirmation({ copy, confirm, cancel }: { + readonly copy: DesktopSetupWizardCopy + readonly confirm: () => void + readonly cancel: () => void +}): JSX.Element { + return { if (!open) cancel() }} open> + + + {copy.browserCompatibilityDialogTitle} + {copy.browserCompatibilityDialogBody} + + + }>{copy.cancelBrowserCompatibility} + + + + +} + +export function resolveDesktopSetupWizardBrowserAccessRequest( + selection: DesktopSetupWizardSelection, + enabled: boolean, +): { readonly action: 'confirm-compatibility' } | { + readonly action: 'update' + readonly selection: DesktopSetupWizardSelection +} { + if (enabled && selection.mode !== 'compatibility') { + return Object.freeze({ action: 'confirm-compatibility' as const }) + } + return Object.freeze({ + action: 'update' as const, + selection: { + ...selection, + openBrowser: enabled, + networkExposure: enabled ? selection.networkExposure : 'loopback', + }, + }) +} + +export function confirmDesktopSetupWizardBrowserCompatibility( + selection: DesktopSetupWizardSelection, +): DesktopSetupWizardSelection { + return { + ...selection, + mode: 'compatibility', + openBrowser: true, + networkExposure: 'loopback', + } +} + type LanConfirmationReason = 'select' | 'advance' | 'start' export function SetupWizardApp(): JSX.Element { @@ -575,12 +623,13 @@ export function SetupWizardApp(): JSX.Element { const [step, setStep] = useState('welcome') const [lanAcknowledged, setLanAcknowledged] = useState(false) const [confirmLan, setConfirmLan] = useState() + const [confirmBrowserCompatibility, setConfirmBrowserCompatibility] = useState(false) if (input === undefined || selection === undefined) { return <>
{copy.title}{copy.invalidState}
{ window.location.assign(`${SCHEME}//skip`) }} outlined />
} const requestExposure = (requested: DesktopSetupWizardNetworkExposure): void => { - if (requested === 'lan' && !selection.openBrowser) return + if (requested === 'lan' && (selection.mode !== 'compatibility' || !selection.openBrowser)) return if (desktopSetupWizardRequiresLanAcknowledgement( selection.networkExposure, requested, @@ -593,6 +642,16 @@ export function SetupWizardApp(): JSX.Element { setSelection({ ...selection, networkExposure: requested }) } + const requestBrowserAccess = (enabled: boolean): void => { + const result = resolveDesktopSetupWizardBrowserAccessRequest(selection, enabled) + if (result.action === 'confirm-compatibility') { + setConfirmBrowserCompatibility(true) + return + } + if (!enabled) setLanAcknowledged(false) + setSelection(result.selection) + } + const advance = (): void => { const next = nextDesktopSetupWizardStep(step) if (next === undefined) return @@ -630,7 +689,7 @@ export function SetupWizardApp(): JSX.Element { /> : step === 'success' ? - : } + : }
} + />} + {confirmBrowserCompatibility ? { setConfirmBrowserCompatibility(false) }} + confirm={() => { + setSelection(confirmDesktopSetupWizardBrowserCompatibility(selection)) + setLanAcknowledged(false) + setConfirmBrowserCompatibility(false) + }} + copy={copy} + /> : null} } diff --git a/dsh-plugin-desktop/src/profile-manager.ts b/dsh-plugin-desktop/src/profile-manager.ts index 83f97150ef..6883e6fd02 100644 --- a/dsh-plugin-desktop/src/profile-manager.ts +++ b/dsh-plugin-desktop/src/profile-manager.ts @@ -27,7 +27,6 @@ import { const BIN_NAME = 'dsh-plugin-desktop' const DEFAULT_PROFILE_NAME = 'desktop' -const WEB_PROFILE_NAME = 'web' const BASE_BUNDLE_NAME = '@deepseek-ai/dsh-base' const WEB_BUNDLE_NAME = '@deepseek-ai/dsh-web-app' const DESKTOP_BUNDLE_NAME = 'dsh-plugin-desktop' @@ -152,21 +151,6 @@ function existingProfile(name: string, home: string): DesktopProfileSummary { } } -/** Describe one profile that upstream app-boot will lazily initialize. */ -function virtualProfile(name: typeof DEFAULT_PROFILE_NAME | typeof WEB_PROFILE_NAME, home: string): DesktopProfileSummary { - const bundles = PROFILE_TEMPLATES.web - if (bundles === undefined) { - throw new Error(`${BIN_NAME}: installed dsh-app-boot has no web profile template`) - } - return { - name, - dir: resolveProfileDir(name, home), - exists: false, - bundles: [...bundles], - webCapable: true, - } -} - /** * Create a safe Web profile using only the shipped template. * @@ -211,9 +195,9 @@ export function createDesktopWebProfile(home: string, name: string): DesktopProf return existingProfile(name, home) } -/** Deterministic profile order with the product defaults first. */ +/** Deterministic profile order with the actual Desktop profile first. */ function compareProfiles(left: DesktopProfileSummary, right: DesktopProfileSummary): number { - const priority = (name: string): number => name === DEFAULT_PROFILE_NAME ? 0 : name === WEB_PROFILE_NAME ? 1 : 2 + const priority = (name: string): number => name === DEFAULT_PROFILE_NAME ? 0 : 1 const difference = priority(left.name) - priority(right.name) if (difference !== 0) return difference return left.name < right.name ? -1 : left.name > right.name ? 1 : 0 @@ -222,7 +206,7 @@ function compareProfiles(left: DesktopProfileSummary, right: DesktopProfileSumma /** * List profile manifests without initializing or modifying any profile. * @param home - Harness home containing the shared profile directory. - * @returns existing profiles plus lazy desktop and Web defaults. + * @returns only profiles whose manifests currently exist on disk. */ export function listDesktopProfiles(home: string): DesktopProfileSummary[] { const profilesDir = join(home, 'profiles') @@ -242,9 +226,6 @@ export function listDesktopProfiles(home: string): DesktopProfileSummary[] { } catch (cause) { if ((cause as NodeJS.ErrnoException).code !== 'ENOENT') throw cause } - for (const name of [DEFAULT_PROFILE_NAME, WEB_PROFILE_NAME] as const) { - if (!summaries.has(name)) summaries.set(name, virtualProfile(name, home)) - } return [...summaries.values()].sort(compareProfiles) } @@ -451,21 +432,34 @@ export function selectDesktopProfile(statePath: string, home: string, name: stri } /** - * Resolve the exact selected Profile. Startup failures are handled only by - * the Recovery window; this boundary never substitutes another Profile. + * Resolve the exact selected Profile. When no Profile manifests exist, create + * one real default Desktop Profile and recover selection to it. A missing + * selection is otherwise left for the Recovery window instead of being + * silently replaced while another Profile remains available. * @param statePath - desktop-owned state file outside the Harness profile tree. * @param home - Harness home used only for read-only profile discovery. * @returns profile decision persisted before profile preparation starts. */ export function beginDesktopProfileStartup(statePath: string, home: string): DesktopProfileStartup { const loaded = loadState(statePath) - const current = loaded.state + const discovered = listDesktopProfiles(home) + const noProfilesExist = discovered.length === 0 + let current = loaded.state + let recoveredState = loaded.recovered + if ((current.active === DEFAULT_PROFILE_NAME || noProfilesExist) + && !discovered.some(profile => profile.name === DEFAULT_PROFILE_NAME)) { + createDesktopWebProfile(home, DEFAULT_PROFILE_NAME) + } + if (noProfilesExist && current.active !== DEFAULT_PROFILE_NAME) { + current = defaultState() + recoveredState = true + } selectableProfile(home, current.active) const next: DesktopProfileStateV2 = { version: STATE_VERSION, active: current.active } writeState(statePath, next) return { profileName: current.active, state: next, - recoveredState: loaded.recovered, + recoveredState, } } diff --git a/dsh-plugin-desktop/src/profile.ts b/dsh-plugin-desktop/src/profile.ts index 0c6b0daad2..720ed0c34d 100644 --- a/dsh-plugin-desktop/src/profile.ts +++ b/dsh-plugin-desktop/src/profile.ts @@ -32,7 +32,6 @@ import { DESKTOP_DEFAULT_WEB_PORT } from './desktop-port.ts' import { desktopBrowserAccessEnabled, desktopNetworkExposureForBrowserAccess, - desktopShellModeForBrowserAccess, desktopWebServerHost, parseDesktopNetworkExposure, parseDesktopOpenBrowser, @@ -164,13 +163,15 @@ export function desktopStartupSettingsFromSettings(document: unknown): DesktopSt throw new Error(`${BIN_NAME}: ${DESKTOP_SETTINGS_NAMESPACE} settings must be a map`) } const values = section as Record + const mode = parseDesktopShellMode(values.mode) const networkExposure = parseDesktopNetworkExposure(values.networkExposure) const openBrowser = desktopBrowserAccessEnabled( + mode, parseDesktopOpenBrowser(values.openBrowser), networkExposure, ) return { - mode: desktopShellModeForBrowserAccess(parseDesktopShellMode(values.mode), openBrowser), + mode, port: parseDesktopPort(values.port), macosMaterial: parseMacosWindowMaterial(values.macosMaterial), windowsMaterial: parseWindowsWindowMaterial(values.windowsMaterial), diff --git a/dsh-plugin-desktop/src/setup-wizard-copy.ts b/dsh-plugin-desktop/src/setup-wizard-copy.ts index b4fefa5ca2..c2a0e2369c 100644 --- a/dsh-plugin-desktop/src/setup-wizard-copy.ts +++ b/dsh-plugin-desktop/src/setup-wizard-copy.ts @@ -32,6 +32,10 @@ export interface DesktopSetupWizardCopy { readonly browserBody: string readonly openBrowser: string readonly browserCompatibilityNotice: string + readonly browserCompatibilityDialogTitle: string + readonly browserCompatibilityDialogBody: string + readonly confirmBrowserCompatibility: string + readonly cancelBrowserCompatibility: string readonly networkExposure: string readonly networkExposureBody: string readonly loopback: string @@ -100,7 +104,11 @@ const COPY: Record = { browserTitle: 'Set up browser access', browserBody: 'Choose whether this Profile may be opened in a browser and who may reach it.', openBrowser: 'Allow opening this Profile in a browser', - browserCompatibilityNotice: 'Browser access is only available in compatibility mode. Enabling it switches this Profile to compatibility mode.', + browserCompatibilityNotice: 'Browser access is only available in compatibility mode.', + browserCompatibilityDialogTitle: 'Switch to compatibility mode?', + browserCompatibilityDialogBody: 'Opening this Profile in a browser requires compatibility mode. Continue to switch the window mode and enable browser access.', + confirmBrowserCompatibility: 'Switch and enable', + cancelBrowserCompatibility: 'Cancel', networkExposure: 'Network access', networkExposureBody: 'Loopback keeps access on this computer. LAN makes the client reachable from your local network.', loopback: 'This computer only', @@ -167,7 +175,11 @@ const COPY: Record = { browserTitle: '设置浏览器访问', browserBody: '选择是否允许在浏览器中打开这个 Profile,以及谁可以访问。', openBrowser: '允许在浏览器中打开', - browserCompatibilityNotice: '浏览器访问只能使用兼容模式;开启后,这个 Profile 将切换到兼容模式。', + browserCompatibilityNotice: '浏览器访问仅在兼容模式下可用。', + browserCompatibilityDialogTitle: '切换到兼容模式?', + browserCompatibilityDialogBody: '在浏览器中打开只能使用兼容模式。继续将把这个 Profile 的窗口模式切换为兼容模式,并开启浏览器访问。', + confirmBrowserCompatibility: '切换并开启', + cancelBrowserCompatibility: '取消', networkExposure: '网络访问范围', networkExposureBody: '仅本机访问只监听回环地址;局域网访问会让同一网络中的设备能够打开客户端。', loopback: '仅这台电脑', diff --git a/dsh-plugin-desktop/src/setup-wizard-settings.ts b/dsh-plugin-desktop/src/setup-wizard-settings.ts index e29372deb2..6766cf3205 100644 --- a/dsh-plugin-desktop/src/setup-wizard-settings.ts +++ b/dsh-plugin-desktop/src/setup-wizard-settings.ts @@ -1,6 +1,10 @@ /** Pre-Host reader and atomic writer for Desktop Setup Wizard preferences. */ -import { desktopBrowserAccessEnabled, desktopNetworkExposureForBrowserAccess, desktopShellModeForBrowserAccess } from './desktop-network.ts' +import { + desktopBrowserAccessAvailable, + desktopBrowserAccessEnabled, + desktopNetworkExposureForBrowserAccess, +} from './desktop-network.ts' import { closeSync, constants, @@ -190,13 +194,15 @@ function projectSettings( ): DesktopSetupWizardSettings { const desktop = section(root, DESKTOP_NAMESPACE) const notifications = section(root, NOTIFICATIONS_NAMESPACE) + const mode = parseMode(desktop.mode) const networkExposure = parseExposure(desktop.networkExposure) const openBrowser = desktopBrowserAccessEnabled( + mode, optionalBoolean(desktop, 'openBrowser', false), networkExposure, ) return Object.freeze({ - mode: desktopShellModeForBrowserAccess(parseMode(desktop.mode), openBrowser), + mode, macosMaterial: parseMacosWindowMaterial(desktop.macosMaterial), windowsMaterial: parseWindowsWindowMaterial(desktop.windowsMaterial), openBrowser, @@ -219,8 +225,9 @@ function normalizedUpdate( if (typeof value.openBrowser !== 'boolean') { throw new TypeError(`${BIN_NAME}: Setup Wizard openBrowser must be a boolean`) } + const openBrowser = desktopBrowserAccessAvailable(requestedMode) && value.openBrowser const networkExposure = desktopNetworkExposureForBrowserAccess( - value.openBrowser, + openBrowser, parseExposure(value.networkExposure), ) if (!isRecord(value.notifications)) { @@ -238,10 +245,10 @@ function normalizedUpdate( throw new TypeError(`${BIN_NAME}: Setup Wizard update must contain all five notification booleans`) } return Object.freeze({ - mode: desktopShellModeForBrowserAccess(requestedMode, value.openBrowser), + mode: requestedMode, macosMaterial: value.macosMaterial, windowsMaterial: value.windowsMaterial, - openBrowser: value.openBrowser, + openBrowser, networkExposure, notifications: Object.freeze({ enabled: value.notifications.enabled, @@ -353,8 +360,9 @@ export async function updateDesktopSetupWizardSettings( /** * Atomically migrate settings written with the former browser-handoff * semantics before the Host reads them. Existing LAN exposure becomes an - * explicit browser-access grant, and every enabled grant uses compatibility - * mode. Returns whether the durable document changed. + * explicit browser-access grant only for an already-selected compatibility + * mode. Incompatible modes retain their selection and withdraw browser/LAN + * access. Returns whether the durable document changed. */ export async function migrateDesktopBrowserAccessSettings( documentPath: string, @@ -368,13 +376,12 @@ export async function migrateDesktopBrowserAccessSettings( const storedMode = parseMode(desktop.mode) const storedOpenBrowser = optionalBoolean(desktop, 'openBrowser', false) const storedExposure = parseExposure(desktop.networkExposure) - const browserAccess = desktopBrowserAccessEnabled(storedOpenBrowser, storedExposure) - const mode = desktopShellModeForBrowserAccess(storedMode, browserAccess) + const browserAccess = desktopBrowserAccessEnabled(storedMode, storedOpenBrowser, storedExposure) + const networkExposure = desktopNetworkExposureForBrowserAccess(browserAccess, storedExposure) return { browserAccess, - mode, - needed: storedMode !== mode || storedOpenBrowser !== browserAccess, - storedExposure, + needed: storedOpenBrowser !== browserAccess || storedExposure !== networkExposure, + networkExposure, } } @@ -390,16 +397,14 @@ export async function migrateDesktopBrowserAccessSettings( let output: string if (loaded.format === 'yaml') { - loaded.yaml!.setIn([DESKTOP_NAMESPACE, 'mode'], migration.mode) loaded.yaml!.setIn([DESKTOP_NAMESPACE, 'openBrowser'], migration.browserAccess) - loaded.yaml!.setIn([DESKTOP_NAMESPACE, 'networkExposure'], migration.storedExposure) + loaded.yaml!.setIn([DESKTOP_NAMESPACE, 'networkExposure'], migration.networkExposure) output = loaded.yaml!.toString() } else { const root = structuredClone(loaded.root) const nextDesktop = { ...section(root, DESKTOP_NAMESPACE) } - nextDesktop.mode = migration.mode nextDesktop.openBrowser = migration.browserAccess - nextDesktop.networkExposure = migration.storedExposure + nextDesktop.networkExposure = migration.networkExposure root[DESKTOP_NAMESPACE] = nextDesktop output = `${JSON.stringify(root, undefined, 2)}\n` } diff --git a/dsh-plugin-desktop/tests/client-desktop-settings.spec.ts b/dsh-plugin-desktop/tests/client-desktop-settings.spec.ts index e8e6e28672..83c3e885db 100644 --- a/dsh-plugin-desktop/tests/client-desktop-settings.spec.ts +++ b/dsh-plugin-desktop/tests/client-desktop-settings.spec.ts @@ -76,15 +76,21 @@ describe('Desktop settings API', () => { it('names the section Desktop settings and describes browser opening as permission', () => { expect(zh.nav).toBe('桌面设置') expect(en.nav).toBe('Desktop settings') + expect(Object.values(zh)).not.toContain('将在启动时创建') + expect(Object.values(en)).not.toContain('Created when first started') expect(zh.openBrowser).toBe('允许在浏览器中打开') expect(zh.openBrowser).not.toMatch(/启动后|自动/u) expect(zh.webIntro).not.toMatch(/启动后|自动/u) expect(zh.browserCompatibilityNotice).toContain('兼容模式') - expect(zh.browserCompatibilityNotice).toMatch(/只(?:能|支持|可)/u) + expect(zh.browserCompatibilityNotice).toContain('仅在') + expect(zh.browserCompatibilityNotice).toContain('先选择') + expect(zh.browserCompatibilityNotice).not.toContain('切换到兼容模式') expect(en.openBrowser).toMatch(/allow.+(?:open|opening).+browser/iu) expect(en.openBrowser).not.toMatch(/after startup|automatically/iu) expect(en.webIntro).not.toMatch(/after startup|automatically/iu) expect(en.browserCompatibilityNotice).toMatch(/only.+compatibility mode/iu) + expect(en.browserCompatibilityNotice).toMatch(/select compatibility mode first/iu) + expect(en.browserCompatibilityNotice).not.toMatch(/switch(?:es|ing)?.+profile/iu) expect(zh.lanWarningBody).toContain('所有在你局域网内的人都能直接操作你的电脑') expect(en.lanWarningBody).toContain('Anyone on your local network can directly operate your computer') }) @@ -132,9 +138,9 @@ describe('Desktop settings API', () => { await persistDesktopModeSelection(scope, 'advanced') expect(set.mock.calls).toEqual([ - ['mode', 'advanced'], - ['openBrowser', false], ['networkExposure', 'loopback'], + ['openBrowser', false], + ['mode', 'advanced'], ]) }) @@ -156,9 +162,9 @@ describe('Desktop settings API', () => { await persistDesktopModeSelection(scope, 'extended') expect(set.mock.calls).toEqual([ - ['mode', 'extended'], - ['openBrowser', false], ['networkExposure', 'loopback'], + ['openBrowser', false], + ['mode', 'extended'], ]) }) diff --git a/dsh-plugin-desktop/tests/plugin.spec.ts b/dsh-plugin-desktop/tests/plugin.spec.ts index cd7078e604..62a310c6f7 100644 --- a/dsh-plugin-desktop/tests/plugin.spec.ts +++ b/dsh-plugin-desktop/tests/plugin.spec.ts @@ -420,7 +420,7 @@ describe('desktop Host plugin', () => { expect(harness.restart).toHaveBeenCalledOnce() }) - it('restarts when browser access changes but ignores a dormant custom mode behind access', async () => { + it('restarts when browser access changes and when a custom mode withdraws stale access', async () => { vi.useFakeTimers() const harness = createHarness() apply(harness.ctx, config) @@ -440,7 +440,7 @@ describe('desktop Host plugin', () => { { mode: 'compatibility', macosMaterial: 'transparent', windowsMaterial: 'acrylic', port: 43_120, openBrowser: true, networkExposure: 'loopback', logLevel: 'info' }, ) await vi.runAllTimersAsync() - expect(enabledHarness.restart).not.toHaveBeenCalled() + expect(enabledHarness.restart).toHaveBeenCalledOnce() }) it('requests one orderly restart after the configured Web port changes', async () => { @@ -537,11 +537,11 @@ describe('desktop Host plugin', () => { ...settings, mode: 'advanced', openBrowser: true, - })).not.toThrow() + })).toThrow('browser and LAN access require compatibility mode') expect(() => options?.validate?.({ ...settings, mode: 'advanced', networkExposure: 'lan', - })).not.toThrow() + })).toThrow('browser and LAN access require compatibility mode') }) }) diff --git a/dsh-plugin-desktop/tests/profile-manager.spec.ts b/dsh-plugin-desktop/tests/profile-manager.spec.ts index 10a86a3493..d2b4186fe8 100644 --- a/dsh-plugin-desktop/tests/profile-manager.spec.ts +++ b/dsh-plugin-desktop/tests/profile-manager.spec.ts @@ -91,7 +91,7 @@ describe('desktop profile discovery', () => { expect(existsSync(join(blockedHome, 'profiles', 'work'))).toBe(false) }) - it('lists lazy defaults and existing profiles without creating or changing manifests', () => { + it('lists only profiles with real manifests without creating missing defaults', () => { const home = temporaryRoot() const webDir = writeProfile(home, 'work', [ '@deepseek-ai/dsh-base', @@ -110,8 +110,6 @@ describe('desktop profile discovery', () => { const before = readFileSync(join(webDir, 'package.json'), 'utf8') expect(listDesktopProfiles(home)).toEqual([ - expect.objectContaining({ name: 'desktop', exists: false, webCapable: true }), - expect.objectContaining({ name: 'web', exists: false, webCapable: true }), expect.objectContaining({ name: 'broken', exists: true, webCapable: false, problem: expect.any(String) }), expect.objectContaining({ name: 'embedded-desktop', @@ -128,6 +126,7 @@ describe('desktop profile discovery', () => { }), expect.objectContaining({ name: 'wrong-order', exists: true, webCapable: false }), ]) + expect(listDesktopProfiles(temporaryRoot())).toEqual([]) expect(readFileSync(join(webDir, 'package.json'), 'utf8')).toBe(before) expect(readdirSync(join(home, 'profiles')).sort()).toEqual([ 'broken', @@ -249,6 +248,24 @@ describe('desktop profile deletion', () => { }) describe('desktop profile selection state', () => { + it('materializes one real desktop profile when no profiles exist', () => { + const root = temporaryRoot() + const home = join(root, 'harness') + const statePath = join(root, 'private', 'state.json') + + expect(listDesktopProfiles(home)).toEqual([]) + expect(beginDesktopProfileStartup(statePath, home)).toEqual({ + profileName: 'desktop', + state: { version: 2, active: 'desktop' }, + recoveredState: false, + }) + expect(listDesktopProfiles(home)).toEqual([ + expect.objectContaining({ name: 'desktop', exists: true, webCapable: true }), + ]) + expect(existsSync(join(home, 'profiles', 'desktop', 'package.json'))).toBe(true) + expect(existsSync(join(home, 'profiles', 'desktop', 'pnpm-workspace.yaml'))).toBe(true) + }) + it('defaults to desktop and queues only a directly Web-capable profile', () => { const root = temporaryRoot() const home = join(root, 'harness') @@ -315,9 +332,10 @@ describe('desktop profile selection state', () => { state: { version: 2, active: 'desktop' }, recoveredState: true, }) + expect(existsSync(join(home, 'profiles', 'desktop', 'pnpm-workspace.yaml'))).toBe(true) }) - it('recovers malformed or symlinked private state without touching profile files', () => { + it('recovers malformed state by materializing the real default profile', () => { const root = temporaryRoot() const home = join(root, 'harness') const stateDir = join(root, 'private') @@ -335,10 +353,11 @@ describe('desktop profile selection state', () => { active: 'desktop', }) expect(lstatSync(statePath).isSymbolicLink()).toBe(false) - expect(existsSync(join(home, 'profiles'))).toBe(false) + expect(existsSync(join(home, 'profiles', 'desktop', 'package.json'))).toBe(true) + expect(existsSync(join(home, 'profiles', 'desktop', 'pnpm-workspace.yaml'))).toBe(true) }) - it('does not substitute another profile when the selected profile disappears', () => { + it('recovers to a real desktop profile when the last selected profile disappears', () => { const root = temporaryRoot() const home = join(root, 'harness') const statePath = join(root, 'private', 'state.json') @@ -346,7 +365,25 @@ describe('desktop profile selection state', () => { selectDesktopProfile(statePath, home, 'work') rmSync(profileDir, { recursive: true }) + expect(beginDesktopProfileStartup(statePath, home)).toEqual({ + profileName: 'desktop', + state: { version: 2, active: 'desktop' }, + recoveredState: true, + }) + expect(readDesktopProfileState(statePath)).toEqual({ version: 2, active: 'desktop' }) + }) + + it('does not recreate a missing selected profile while another profile exists', () => { + const root = temporaryRoot() + const home = join(root, 'harness') + const statePath = join(root, 'private', 'state.json') + const workDir = writeProfile(home, 'work', ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app']) + writeProfile(home, 'other', ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app']) + selectDesktopProfile(statePath, home, 'work') + rmSync(workDir, { recursive: true }) + expect(() => beginDesktopProfileStartup(statePath, home)).toThrow('does not exist') + expect(listDesktopProfiles(home).map(profile => profile.name)).toEqual(['other']) expect(readDesktopProfileState(statePath)).toEqual({ version: 2, active: 'work' }) }) }) diff --git a/dsh-plugin-desktop/tests/profile.spec.ts b/dsh-plugin-desktop/tests/profile.spec.ts index ac5d5e395d..817c330651 100644 --- a/dsh-plugin-desktop/tests/profile.spec.ts +++ b/dsh-plugin-desktop/tests/profile.spec.ts @@ -604,7 +604,7 @@ virtualStoreDirMaxLength: 60 })) }) - it('forces browser access onto the compatibility layout and disables upstream browser handoff', () => { + it('keeps a custom layout and withdraws incompatible browser and LAN access', () => { const home = temporaryHome() writeFileSync(join(home, 'settings.yaml'), [ 'dsh-desktop:', @@ -618,13 +618,13 @@ virtualStoreDirMaxLength: 60 const prepared = prepareDesktopProfile(undefined, home, 'darwin') const rows = composeEntries([prepared.patches]) - expect(prepared.mode).toBe('compatibility') + expect(prepared.mode).toBe('advanced') expect(prepared.port).toBe(43_189) - expect(prepared.openBrowser).toBe(true) - expect(prepared.networkExposure).toBe('lan') + expect(prepared.openBrowser).toBe(false) + expect(prepared.networkExposure).toBe('loopback') expect(rows.find(row => row.id === 'desktop-shell')).toEqual(expect.objectContaining({ disabled: false, - config: expect.objectContaining({ mode: 'compatibility', port: 43_189 }), + config: expect.objectContaining({ mode: 'advanced', port: 43_189 }), })) expect(rows.find(row => row.id === 'webserver')).toEqual(expect.objectContaining({ name: '@deepseek-ai/dsh-host-webserver', @@ -632,7 +632,7 @@ virtualStoreDirMaxLength: 60 })) expect(rows.find(row => row.id === 'desktop-webserver')).toEqual(expect.objectContaining({ name: 'dsh-plugin-desktop/webserver', - config: { host: '0.0.0.0', port: 43_189 }, + config: { host: '127.0.0.1', port: 43_189 }, })) expect(rows.find(row => row.id === 'web-runtime')).toEqual(expect.objectContaining({ config: expect.objectContaining({ openBrowser: false }), @@ -640,9 +640,36 @@ virtualStoreDirMaxLength: 60 expect(rows.find(row => row.id === 'settings')).toEqual(expect.objectContaining({ config: expect.objectContaining({ dshHome: home }), })) - expect(rows.find(row => row.id === 'ui-layout')?.disabled).not.toBe(true) - expect(rows.find(row => row.id === 'ui-sidebar')?.disabled).not.toBe(false) - expect(rows.find(row => row.id === 'ui-conversation')?.disabled).not.toBe(false) + expect(rows.find(row => row.id === 'ui-layout')?.disabled).toBe(true) + expect(rows.find(row => row.id === 'ui-sidebar')?.disabled).toBe(false) + expect(rows.find(row => row.id === 'ui-conversation')?.disabled).toBe(false) + }) + + it('allows browser and LAN access when compatibility mode is already selected', () => { + const home = temporaryHome() + writeFileSync(join(home, 'settings.yaml'), [ + 'dsh-desktop:', + ' mode: compatibility', + ' port: 43189', + ' openBrowser: true', + ' networkExposure: lan', + '', + ].join('\n')) + + const prepared = prepareDesktopProfile(undefined, home, 'darwin') + const rows = composeEntries([prepared.patches]) + + expect(prepared).toMatchObject({ + mode: 'compatibility', + openBrowser: true, + networkExposure: 'lan', + }) + expect(rows.find(row => row.id === 'desktop-webserver')).toEqual(expect.objectContaining({ + config: { host: '0.0.0.0', port: 43_189 }, + })) + expect(rows.find(row => row.id === 'web-runtime')).toEqual(expect.objectContaining({ + config: expect.objectContaining({ openBrowser: false }), + })) }) it('replaces the official root layout for extended window mode while retaining its occupants', () => { @@ -700,13 +727,24 @@ virtualStoreDirMaxLength: 60 expect(desktopShellModeFromSettings({ unrelated: { enabled: true } })).toBe('compatibility') }) - it('migrates legacy LAN exposure to explicit compatibility browser access', () => { + it('treats legacy LAN exposure as browser access only in compatibility mode', () => { expect(desktopStartupSettingsFromSettings({ 'dsh-desktop': { mode: 'advanced', openBrowser: false, networkExposure: 'lan', }, + })).toMatchObject({ + mode: 'advanced', + openBrowser: false, + networkExposure: 'loopback', + }) + expect(desktopStartupSettingsFromSettings({ + 'dsh-desktop': { + mode: 'compatibility', + openBrowser: false, + networkExposure: 'lan', + }, })).toMatchObject({ mode: 'compatibility', openBrowser: true, diff --git a/dsh-plugin-desktop/tests/setup-wizard-copy.spec.ts b/dsh-plugin-desktop/tests/setup-wizard-copy.spec.ts index 7b33b1d561..f596f20deb 100644 --- a/dsh-plugin-desktop/tests/setup-wizard-copy.spec.ts +++ b/dsh-plugin-desktop/tests/setup-wizard-copy.spec.ts @@ -81,10 +81,16 @@ describe('Desktop Setup Wizard copy and contract', () => { expect(chinese.openBrowser).toBe('允许在浏览器中打开') expect(chinese.openBrowser).not.toMatch(/启动后|自动/u) expect(chinese.browserCompatibilityNotice).toContain('兼容模式') - expect(chinese.browserCompatibilityNotice).toMatch(/只(?:能|支持|可)/u) + expect(chinese.browserCompatibilityNotice).toContain('仅在') + expect(chinese.browserCompatibilityDialogBody).toContain('只能使用兼容模式') + expect(chinese.browserCompatibilityDialogBody).toContain('切换为兼容模式') + expect(chinese.confirmBrowserCompatibility).toBe('切换并开启') + expect(chinese.cancelBrowserCompatibility).toBe('取消') expect(english.openBrowser).toMatch(/allow.+(?:open|opening).+browser/iu) expect(english.openBrowser).not.toMatch(/after startup|automatically/iu) expect(english.browserCompatibilityNotice).toMatch(/only.+compatibility mode/iu) + expect(english.browserCompatibilityDialogBody).toMatch(/requires compatibility mode/iu) + expect(english.browserCompatibilityDialogBody).toMatch(/switch.+window mode/iu) }) it('requires confirmation only when loopback access is changed to LAN', () => { diff --git a/dsh-plugin-desktop/tests/setup-wizard-native-ui.spec.ts b/dsh-plugin-desktop/tests/setup-wizard-native-ui.spec.ts index b3cf59afa1..730db7cfb3 100644 --- a/dsh-plugin-desktop/tests/setup-wizard-native-ui.spec.ts +++ b/dsh-plugin-desktop/tests/setup-wizard-native-ui.spec.ts @@ -7,10 +7,13 @@ import type { DesktopSetupWizardSelection, } from '../src/setup-wizard-contract.ts' import { + confirmDesktopSetupWizardBrowserCompatibility, decodeDesktopSetupWizardInput, DESKTOP_SETUP_WIZARD_STEPS, nextDesktopSetupWizardStep, previousDesktopSetupWizardStep, + resolveDesktopSetupWizardBrowserAccessRequest, + SetupWizardBrowserCompatibilityConfirmation, SetupWizardLanConfirmation, SetupWizardNavigation, SetupWizardStepPage, @@ -28,7 +31,7 @@ const input: DesktopSetupWizardInput = { mode: 'extended', macosMaterial: 'transparent', windowsMaterial: 'acrylic', - openBrowser: true, + openBrowser: false, networkExposure: 'loopback', market: 'community-market', notifications: { @@ -56,6 +59,7 @@ function renderStep(step: Exclude<(typeof DESKTOP_SETUP_WIZARD_STEPS)[number], ' return renderToStaticMarkup(createElement(SetupWizardStepPage, { copy, input, + requestBrowserAccess: (_enabled: boolean) => {}, requestExposure: (_exposure: DesktopSetupWizardNetworkExposure) => {}, selection, step, @@ -218,7 +222,8 @@ describe('Setup Wizard setting pages', () => { expect(copy.openBrowser).not.toContain('启动后') expect(copy.openBrowser).not.toContain('自动') expect(copy.browserCompatibilityNotice).toContain('兼容模式') - expect(copy.browserCompatibilityNotice).toMatch(/只(?:能|支持|可)/u) + expect(copy.browserCompatibilityNotice).toContain('仅在') + expect(browser).not.toMatch(/]*disabled=""[^>]*aria-label="允许在浏览器中打开"/u) }) it('uses the shadcn Switch component for every wizard toggle', () => { @@ -301,6 +306,45 @@ describe('Setup Wizard navigation and completion', () => { }) describe('Setup Wizard native UI boundaries', () => { + it('asks before switching a custom mode to compatibility for browser access', () => { + expect(resolveDesktopSetupWizardBrowserAccessRequest(selection, true)).toEqual({ + action: 'confirm-compatibility', + }) + expect(resolveDesktopSetupWizardBrowserAccessRequest(selection, false)).toEqual({ + action: 'update', + selection: { ...selection, openBrowser: false, networkExposure: 'loopback' }, + }) + expect(confirmDesktopSetupWizardBrowserCompatibility({ + ...selection, + networkExposure: 'lan', + })).toEqual({ + ...selection, + mode: 'compatibility', + openBrowser: true, + networkExposure: 'loopback', + }) + + const dialog = SetupWizardBrowserCompatibilityConfirmation({ + copy, + confirm: () => {}, + cancel: () => {}, + }) + const dialogProps = dialog.props as { readonly children?: ReactNode; readonly open?: boolean } + const content = Children.toArray(dialogProps.children).find(isValidElement) as ReactElement | undefined + expect(dialogProps.open).toBe(true) + expect(content?.props).toMatchObject({ + 'aria-describedby': 'browser-compatibility-body', + 'aria-labelledby': 'browser-compatibility-title', + 'aria-modal': 'true', + role: 'alertdialog', + showCloseButton: false, + }) + const text = elementText(content) + expect(text).toContain('在浏览器中打开只能使用兼容模式') + expect(text).toContain(copy.confirmBrowserCompatibility) + expect(text).toContain(copy.cancelBrowserCompatibility) + }) + it('renders the LAN warning as an in-window alert dialog with explicit choices', () => { const dialog = SetupWizardLanConfirmation({ copy, diff --git a/dsh-plugin-desktop/tests/setup-wizard-settings.spec.ts b/dsh-plugin-desktop/tests/setup-wizard-settings.spec.ts index a0a6fab73f..f458279be4 100644 --- a/dsh-plugin-desktop/tests/setup-wizard-settings.spec.ts +++ b/dsh-plugin-desktop/tests/setup-wizard-settings.spec.ts @@ -222,6 +222,18 @@ describe('Desktop Setup Wizard settings document', () => { openBrowser: false, networkExposure: 'loopback', }) + + const incompatible = values({ mode: 'advanced', openBrowser: true, networkExposure: 'lan' }) + await expect(updateDesktopSetupWizardSettings(path, incompatible)).resolves.toMatchObject({ + mode: 'advanced', + openBrowser: false, + networkExposure: 'loopback', + }) + expect(readDesktopSetupWizardSettings(path)).toMatchObject({ + mode: 'advanced', + openBrowser: false, + networkExposure: 'loopback', + }) }) it('projects legacy LAN exposure as explicit compatibility browser access', () => { @@ -240,7 +252,7 @@ describe('Desktop Setup Wizard settings document', () => { }) }) - it('atomically migrates legacy browser handoff and LAN combinations', async () => { + it('atomically withdraws legacy browser handoff and LAN from custom modes', async () => { const root = temporaryDirectory() const yamlPath = join(root, 'legacy.yaml') writeFileSync(yamlPath, [ @@ -259,9 +271,9 @@ describe('Desktop Setup Wizard settings document', () => { expect(migrated).toContain('# preserve browser migration comments') expect(parseDocument(migrated).toJS()).toMatchObject({ 'dsh-desktop': { - mode: 'compatibility', - openBrowser: true, - networkExposure: 'lan', + mode: 'advanced', + openBrowser: false, + networkExposure: 'loopback', future: 'keep', }, }) @@ -278,14 +290,34 @@ describe('Desktop Setup Wizard settings document', () => { await expect(migrateDesktopBrowserAccessSettings(jsonPath)).resolves.toBe(true) expect(JSON.parse(readFileSync(jsonPath, 'utf8'))).toMatchObject({ 'dsh-desktop': { - mode: 'compatibility', - openBrowser: true, + mode: 'extended', + openBrowser: false, networkExposure: 'loopback', }, untouched: { value: 1 }, }) }) + it('migrates legacy LAN to explicit browser access only in compatibility mode', async () => { + const path = join(temporaryDirectory(), 'legacy.yaml') + writeFileSync(path, [ + 'dsh-desktop:', + ' mode: compatibility', + ' openBrowser: false', + ' networkExposure: lan', + '', + ].join('\n')) + + await expect(migrateDesktopBrowserAccessSettings(path)).resolves.toBe(true) + expect(parseDocument(readFileSync(path, 'utf8')).toJS()).toMatchObject({ + 'dsh-desktop': { + mode: 'compatibility', + openBrowser: true, + networkExposure: 'lan', + }, + }) + }) + it('does not acquire a writer lock when browser access settings are already normalized', async () => { const root = temporaryDirectory() const path = join(root, 'settings.yaml') @@ -342,8 +374,8 @@ describe('Desktop Setup Wizard settings document', () => { expect(readFileSync(path, 'utf8')).not.toBe(contents) expect(readFileSync(lockPath, 'utf8')).toBe('owner\n') expect(readDesktopSetupWizardSettings(path)).toMatchObject({ - mode: 'compatibility', - openBrowser: true, + mode: 'advanced', + openBrowser: false, networkExposure: 'loopback', }) }) From df2163ec36e50d4c1377c3ebc0cd0bc438aff8a4 Mon Sep 17 00:00:00 2001 From: t4we fan Date: Wed, 26 Aug 2026 14:59:42 +0800 Subject: [PATCH 2/2] fix(desktop): close setup recovery edge cases --- .../src/native-ui/setup-wizard/App.tsx | 26 ++++++++-- dsh-plugin-desktop/src/profile-manager.ts | 44 ++++++++++++++++- .../tests/profile-manager.spec.ts | 49 +++++++++++++++++++ .../tests/setup-wizard-native-ui.spec.ts | 16 ++++++ 4 files changed, 130 insertions(+), 5 deletions(-) diff --git a/dsh-plugin-desktop/src/native-ui/setup-wizard/App.tsx b/dsh-plugin-desktop/src/native-ui/setup-wizard/App.tsx index 688eee6c2f..a0ba662fca 100644 --- a/dsh-plugin-desktop/src/native-ui/setup-wizard/App.tsx +++ b/dsh-plugin-desktop/src/native-ui/setup-wizard/App.tsx @@ -613,7 +613,18 @@ export function confirmDesktopSetupWizardBrowserCompatibility( } } -type LanConfirmationReason = 'select' | 'advance' | 'start' +type LanConfirmationReason = 'select' | 'advance' | 'start' | 'skip' + +export function desktopSetupWizardSkipRequiresLanAcknowledgement( + selection: DesktopSetupWizardSelection, + acknowledged: boolean, +): boolean { + return desktopSetupWizardRequiresLanAcknowledgement( + selection.networkExposure, + selection.networkExposure, + acknowledged, + ) +} export function SetupWizardApp(): JSX.Element { const locale = localLocale(window.location.search) @@ -652,6 +663,14 @@ export function SetupWizardApp(): JSX.Element { setSelection(result.selection) } + const skip = (): void => { + if (desktopSetupWizardSkipRequiresLanAcknowledgement(selection, lanAcknowledged)) { + setConfirmLan('skip') + return + } + window.location.assign(`${SCHEME}//skip`) + } + const advance = (): void => { const next = nextDesktopSetupWizardStep(step) if (next === undefined) return @@ -683,7 +702,7 @@ export function SetupWizardApp(): JSX.Element { {step === 'welcome' ? { window.location.assign(`${SCHEME}//skip`) }} + onSkip={skip} onStart={() => { setStep('mode') }} profileName={input.profileName} /> @@ -698,7 +717,7 @@ export function SetupWizardApp(): JSX.Element { if (previous !== undefined) setStep(previous) }} onNext={advance} - onSkip={() => { window.location.assign(`${SCHEME}//skip`) }} + onSkip={skip} step={step} /> @@ -712,6 +731,7 @@ export function SetupWizardApp(): JSX.Element { setConfirmLan(undefined) if (reason === 'advance') setStep('success') if (reason === 'start') finish(next) + if (reason === 'skip') window.location.assign(`${SCHEME}//skip`) }} copy={copy} />} diff --git a/dsh-plugin-desktop/src/profile-manager.ts b/dsh-plugin-desktop/src/profile-manager.ts index 6883e6fd02..e2136e7109 100644 --- a/dsh-plugin-desktop/src/profile-manager.ts +++ b/dsh-plugin-desktop/src/profile-manager.ts @@ -36,6 +36,7 @@ const MAX_STATE_BYTES = 4 * 1024 const STATE_DIRECTORY_MODE = 0o700 const STATE_FILE_MODE = 0o600 const MAX_PROFILE_NAME_BYTES = 255 +const INTERNAL_PROFILE_STAGING_PATTERN = /^\..+\.(?:creating|deleting|incomplete)-\d+-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu /** One discovered or lazily available DSH profile. */ export interface DesktopProfileSummary { @@ -101,6 +102,7 @@ export function assertDesktopProfileName(name: string): void { if (typeof name !== 'string' || name.length === 0 || name.includes('/') || name.includes('\\') || name === '.' || name === '..' || name === 'node_modules' || Buffer.byteLength(name, 'utf8') > MAX_PROFILE_NAME_BYTES + || INTERNAL_PROFILE_STAGING_PATTERN.test(name) || /[\0-\x1f\x7f-\x9f]/.test(name) || /[<>:"|?*]/.test(name) || /[. ]$/.test(name) || /^(?:CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(?:\..*)?$/i.test(name)) { @@ -195,6 +197,43 @@ export function createDesktopWebProfile(home: string, name: string): DesktopProf return existingProfile(name, home) } +/** + * Publish the real default Profile while preserving an interrupted, incomplete + * directory under an internal sibling name. Ordinary files and symlinks fail + * closed instead of being replaced. + */ +function materializeDefaultDesktopProfile(home: string): DesktopProfileSummary { + const target = resolveProfileDir(DEFAULT_PROFILE_NAME, home) + if (existsSync(join(target, PROFILE_MANIFEST_FILENAME))) return existingProfile(DEFAULT_PROFILE_NAME, home) + + let incomplete: string | undefined + try { + const item = lstatSync(target) + if (!item.isDirectory() || item.isSymbolicLink()) { + throw new Error(`${BIN_NAME}: default profile path is not a real directory`) + } + incomplete = join(dirname(target), `.${basename(target)}.incomplete-${process.pid}-${randomUUID()}`) + renameSync(target, incomplete) + } catch (cause) { + if ((cause as NodeJS.ErrnoException).code !== 'ENOENT') throw cause + } + + try { + return createDesktopWebProfile(home, DEFAULT_PROFILE_NAME) + } catch (cause) { + if (incomplete !== undefined) { + try { + lstatSync(target) + } catch (targetCause) { + if ((targetCause as NodeJS.ErrnoException).code === 'ENOENT') { + try { renameSync(incomplete, target) } catch { /* preserve the creation failure */ } + } + } + } + throw cause + } +} + /** Deterministic profile order with the actual Desktop profile first. */ function compareProfiles(left: DesktopProfileSummary, right: DesktopProfileSummary): number { const priority = (name: string): number => name === DEFAULT_PROFILE_NAME ? 0 : 1 @@ -213,6 +252,7 @@ export function listDesktopProfiles(home: string): DesktopProfileSummary[] { const summaries = new Map() try { for (const entry of readdirSync(profilesDir, { withFileTypes: true })) { + if (INTERNAL_PROFILE_STAGING_PATTERN.test(entry.name)) continue if (entry.name === 'node_modules' || (!entry.isDirectory() && !entry.isSymbolicLink())) continue try { assertDesktopProfileName(entry.name) @@ -437,7 +477,7 @@ export function selectDesktopProfile(statePath: string, home: string, name: stri * selection is otherwise left for the Recovery window instead of being * silently replaced while another Profile remains available. * @param statePath - desktop-owned state file outside the Harness profile tree. - * @param home - Harness home used only for read-only profile discovery. + * @param home - Harness home used for discovery and zero-Profile default materialization. * @returns profile decision persisted before profile preparation starts. */ export function beginDesktopProfileStartup(statePath: string, home: string): DesktopProfileStartup { @@ -448,7 +488,7 @@ export function beginDesktopProfileStartup(statePath: string, home: string): Des let recoveredState = loaded.recovered if ((current.active === DEFAULT_PROFILE_NAME || noProfilesExist) && !discovered.some(profile => profile.name === DEFAULT_PROFILE_NAME)) { - createDesktopWebProfile(home, DEFAULT_PROFILE_NAME) + materializeDefaultDesktopProfile(home) } if (noProfilesExist && current.active !== DEFAULT_PROFILE_NAME) { current = defaultState() diff --git a/dsh-plugin-desktop/tests/profile-manager.spec.ts b/dsh-plugin-desktop/tests/profile-manager.spec.ts index d2b4186fe8..ecc58926ec 100644 --- a/dsh-plugin-desktop/tests/profile-manager.spec.ts +++ b/dsh-plugin-desktop/tests/profile-manager.spec.ts @@ -55,6 +55,8 @@ describe('desktop profile discovery', () => { expect(() => assertDesktopProfileName('CON.txt')).toThrow('invalid desktop profile name') expect(() => assertDesktopProfileName('name.')).toThrow('invalid desktop profile name') expect(() => assertDesktopProfileName('name ')).toThrow('invalid desktop profile name') + expect(() => assertDesktopProfileName('.web.creating-123-12345678-1234-4123-8123-123456789abc')) + .toThrow('invalid desktop profile name') expect(() => assertDesktopProfileName('é'.repeat(128))).toThrow('invalid desktop profile name') }) @@ -266,6 +268,35 @@ describe('desktop profile selection state', () => { expect(existsSync(join(home, 'profiles', 'desktop', 'pnpm-workspace.yaml'))).toBe(true) }) + it('ignores interrupted staging profiles when deciding that no real profile exists', () => { + const root = temporaryRoot() + const home = join(root, 'harness') + const statePath = join(root, 'private', 'state.json') + const stagingName = '.web.creating-123-12345678-1234-4123-8123-123456789abc' + writeProfile(home, stagingName, ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app']) + + expect(listDesktopProfiles(home)).toEqual([]) + expect(beginDesktopProfileStartup(statePath, home).profileName).toBe('desktop') + expect(listDesktopProfiles(home).map(profile => profile.name)).toEqual(['desktop']) + expect(existsSync(join(home, 'profiles', stagingName, 'package.json'))).toBe(true) + }) + + it('preserves an incomplete default directory while materializing the real desktop profile', () => { + const root = temporaryRoot() + const home = join(root, 'harness') + const statePath = join(root, 'private', 'state.json') + const partial = join(home, 'profiles', 'desktop') + mkdirSync(partial, { recursive: true }) + writeFileSync(join(partial, 'keep.txt'), 'recoverable\n') + + expect(beginDesktopProfileStartup(statePath, home).profileName).toBe('desktop') + expect(existsSync(join(partial, 'package.json'))).toBe(true) + const incomplete = readdirSync(join(home, 'profiles')).find(name => name.startsWith('.desktop.incomplete-')) + expect(incomplete).toBeDefined() + expect(readFileSync(join(home, 'profiles', incomplete!, 'keep.txt'), 'utf8')).toBe('recoverable\n') + expect(listDesktopProfiles(home).map(profile => profile.name)).toEqual(['desktop']) + }) + it('defaults to desktop and queues only a directly Web-capable profile', () => { const root = temporaryRoot() const home = join(root, 'harness') @@ -386,4 +417,22 @@ describe('desktop profile selection state', () => { expect(listDesktopProfiles(home).map(profile => profile.name)).toEqual(['other']) expect(readDesktopProfileState(statePath)).toEqual({ version: 2, active: 'work' }) }) + + it('does not recreate deleted inactive web or desktop profiles', () => { + const root = temporaryRoot() + const home = join(root, 'harness') + const statePath = join(root, 'private', 'state.json') + const bundles = ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'] + const desktopDir = writeProfile(home, 'desktop', bundles) + const webDir = writeProfile(home, 'web', bundles) + writeProfile(home, 'work', bundles) + selectDesktopProfile(statePath, home, 'work') + rmSync(desktopDir, { recursive: true }) + rmSync(webDir, { recursive: true }) + + expect(beginDesktopProfileStartup(statePath, home).profileName).toBe('work') + expect(listDesktopProfiles(home).map(profile => profile.name)).toEqual(['work']) + expect(existsSync(desktopDir)).toBe(false) + expect(existsSync(webDir)).toBe(false) + }) }) diff --git a/dsh-plugin-desktop/tests/setup-wizard-native-ui.spec.ts b/dsh-plugin-desktop/tests/setup-wizard-native-ui.spec.ts index 730db7cfb3..a6bb92fe2a 100644 --- a/dsh-plugin-desktop/tests/setup-wizard-native-ui.spec.ts +++ b/dsh-plugin-desktop/tests/setup-wizard-native-ui.spec.ts @@ -10,6 +10,7 @@ import { confirmDesktopSetupWizardBrowserCompatibility, decodeDesktopSetupWizardInput, DESKTOP_SETUP_WIZARD_STEPS, + desktopSetupWizardSkipRequiresLanAcknowledgement, nextDesktopSetupWizardStep, previousDesktopSetupWizardStep, resolveDesktopSetupWizardBrowserAccessRequest, @@ -306,6 +307,21 @@ describe('Setup Wizard navigation and completion', () => { }) describe('Setup Wizard native UI boundaries', () => { + it('does not let Skip bypass the LAN danger acknowledgement', () => { + const exposed = { + ...selection, + mode: 'compatibility' as const, + openBrowser: true, + networkExposure: 'lan' as const, + } + expect(desktopSetupWizardSkipRequiresLanAcknowledgement(exposed, false)).toBe(true) + expect(desktopSetupWizardSkipRequiresLanAcknowledgement(exposed, true)).toBe(false) + expect(desktopSetupWizardSkipRequiresLanAcknowledgement({ + ...exposed, + networkExposure: 'loopback', + }, false)).toBe(false) + }) + it('asks before switching a custom mode to compatibility for browser access', () => { expect(resolveDesktopSetupWizardBrowserAccessRequest(selection, true)).toEqual({ action: 'confirm-compatibility',