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..a0ba662fca 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,7 +563,68 @@ export function SetupWizardLanConfirmation({ copy, confirm, cancel }: {
}
-type LanConfirmationReason = 'select' | 'advance' | 'start'
+export function SetupWizardBrowserCompatibilityConfirmation({ copy, confirm, cancel }: {
+ readonly copy: DesktopSetupWizardCopy
+ readonly confirm: () => void
+ readonly cancel: () => void
+}): JSX.Element {
+ return
+}
+
+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' | '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)
@@ -575,12 +634,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 +653,24 @@ 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 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
@@ -624,13 +702,13 @@ export function SetupWizardApp(): JSX.Element {
{step === 'welcome'
? { window.location.assign(`${SCHEME}//skip`) }}
+ onSkip={skip}
onStart={() => { setStep('mode') }}
profileName={input.profileName}
/>
: step === 'success'
?
- : }
+ : }
{ window.location.assign(`${SCHEME}//skip`) }}
+ onSkip={skip}
step={step}
/>
@@ -653,7 +731,17 @@ 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}
- />}>
+ />}
+ {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..e2136e7109 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'
@@ -37,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 {
@@ -102,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)) {
@@ -152,21 +153,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 +197,46 @@ export function createDesktopWebProfile(home: string, name: string): DesktopProf
return existingProfile(name, home)
}
-/** Deterministic profile order with the product defaults first. */
+/**
+ * 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 : 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,13 +245,14 @@ 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')
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)
@@ -242,9 +266,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 +472,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.
+ * @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 {
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)) {
+ materializeDefaultDesktopProfile(home)
+ }
+ 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..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')
})
@@ -91,7 +93,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 +112,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 +128,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 +250,53 @@ 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('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')
@@ -315,9 +363,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 +384,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 +396,43 @@ 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' })
})
+
+ 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/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..a6bb92fe2a 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,14 @@ import type {
DesktopSetupWizardSelection,
} from '../src/setup-wizard-contract.ts'
import {
+ confirmDesktopSetupWizardBrowserCompatibility,
decodeDesktopSetupWizardInput,
DESKTOP_SETUP_WIZARD_STEPS,
+ desktopSetupWizardSkipRequiresLanAcknowledgement,
nextDesktopSetupWizardStep,
previousDesktopSetupWizardStep,
+ resolveDesktopSetupWizardBrowserAccessRequest,
+ SetupWizardBrowserCompatibilityConfirmation,
SetupWizardLanConfirmation,
SetupWizardNavigation,
SetupWizardStepPage,
@@ -28,7 +32,7 @@ const input: DesktopSetupWizardInput = {
mode: 'extended',
macosMaterial: 'transparent',
windowsMaterial: 'acrylic',
- openBrowser: true,
+ openBrowser: false,
networkExposure: 'loopback',
market: 'community-market',
notifications: {
@@ -56,6 +60,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 +223,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(/