Merge pull request #640 from anywhere-labs/codex/browser-access-requires-compatibility

fix(desktop): require compatibility for browser access
This commit is contained in:
t4we fan
2026-08-26 15:02:53 +08:00
committed by GitHub
19 changed files with 516 additions and 148 deletions
@@ -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({
<ToggleRow
label={t('openBrowser')}
checked={browserAccess}
disabled={!settingsWritable || busy !== undefined || restart !== 'none'}
disabled={!desktopBrowserAccessAvailable(mode) || !settingsWritable || busy !== undefined || restart !== 'none'}
onChange={setBrowserAccess}
/>
<p className="dshDesktopSettingsNotice">{t('browserCompatibilityNotice')}</p>
@@ -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<DesktopSettingsLocaleKey, string> = {
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<DesktopSettingsLocaleKey, string> = {
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.',
@@ -32,13 +32,18 @@ export async function persistDesktopModeSelection(
desktopSettings: Pick<SettingsScope<DesktopShellSettings>, 'set'>,
mode: DesktopShellSettings['mode'],
): Promise<void> {
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' {
+9 -12
View File
@@ -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'
@@ -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,
})
}
+10 -14
View File
@@ -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<typeof setImmediate> | 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 })
},
+3 -1
View File
@@ -791,7 +791,9 @@ async function start(): Promise<void> {
`${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,
@@ -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 <div className="space-y-5">
<ToggleRow
checked={selection.openBrowser}
checked={selection.mode === 'compatibility' && selection.openBrowser}
description={copy.browserCompatibilityNotice}
id="setup-open-browser"
label={copy.openBrowser}
onChange={openBrowser => {
update({
...selection,
mode: openBrowser ? 'compatibility' : selection.mode,
openBrowser,
networkExposure: openBrowser ? selection.networkExposure : 'loopback',
})
}}
onChange={requestBrowserAccess}
/>
<section>
<h2 className="text-sm font-semibold">{copy.networkExposure}</h2>
@@ -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}
>
<Choice body={copy.loopbackBody} id="setup-network-exposure-loopback" selected={selection.networkExposure === 'loopback'} title={copy.loopback} value="loopback" />
<Choice body={copy.lanBody} disabled={!selection.openBrowser} id="setup-network-exposure-lan" selected={selection.networkExposure === 'lan'} title={copy.lan} value="lan" />
<Choice body={copy.lanBody} disabled={selection.mode !== 'compatibility' || !selection.openBrowser} id="setup-network-exposure-lan" selected={selection.networkExposure === 'lan'} title={copy.lan} value="lan" />
</RadioGroup>
</section>
</div>
@@ -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 <Page step={step} subtitle={copy.presentationBody} title={copy.presentationTitle}><ModeOptions copy={copy} input={input} selection={selection} update={update} /></Page>
if (step === 'material') return <Page step={step} subtitle={copy.windowMaterialBody} title={copy.windowMaterial}><MaterialOptions copy={copy} input={input} selection={selection} update={update} /></Page>
if (step === 'market') return <Page step={step} subtitle={copy.marketBody} title={copy.marketTitle}><MarketOptions copy={copy} selection={selection} update={update} /></Page>
if (step === 'notifications') return <Page step={step} subtitle={copy.notificationsBody} title={copy.notificationsTitle}><NotificationOptions copy={copy} notifications={selection.notifications} update={notifications => { update({ ...selection, notifications }) }} /></Page>
if (step === 'browser') return <Page step={step} subtitle={copy.browserBody} title={copy.browserTitle}><BrowserOptions copy={copy} requestExposure={requestExposure} selection={selection} update={update} /></Page>
if (step === 'browser') return <Page step={step} subtitle={copy.browserBody} title={copy.browserTitle}><BrowserOptions copy={copy} requestBrowserAccess={requestBrowserAccess} requestExposure={requestExposure} selection={selection} /></Page>
return <div data-setup-step={step} />
}
@@ -565,7 +563,68 @@ export function SetupWizardLanConfirmation({ copy, confirm, cancel }: {
</Dialog>
}
type LanConfirmationReason = 'select' | 'advance' | 'start'
export function SetupWizardBrowserCompatibilityConfirmation({ copy, confirm, cancel }: {
readonly copy: DesktopSetupWizardCopy
readonly confirm: () => void
readonly cancel: () => void
}): JSX.Element {
return <Dialog onOpenChange={open => { if (!open) cancel() }} open>
<DialogContent aria-describedby="browser-compatibility-body" aria-labelledby="browser-compatibility-title" aria-modal="true" role="alertdialog" showCloseButton={false}>
<DialogHeader>
<DialogTitle id="browser-compatibility-title">{copy.browserCompatibilityDialogTitle}</DialogTitle>
<DialogDescription className="mt-2 text-foreground" id="browser-compatibility-body">{copy.browserCompatibilityDialogBody}</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose render={<Button autoFocus type="button" variant="outline" />}>{copy.cancelBrowserCompatibility}</DialogClose>
<Button onClick={confirm} type="button">{copy.confirmBrowserCompatibility}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
}
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<DesktopSetupWizardStep>('welcome')
const [lanAcknowledged, setLanAcknowledged] = useState(false)
const [confirmLan, setConfirmLan] = useState<LanConfirmationReason>()
const [confirmBrowserCompatibility, setConfirmBrowserCompatibility] = useState(false)
if (input === undefined || selection === undefined) {
return <><DesktopFrame /><main className="dshNativeContent flex h-screen items-center justify-center p-6"><div className="w-full max-w-lg space-y-4"><Alert variant="destructive"><AlertTriangle /><AlertTitle>{copy.title}</AlertTitle><AlertDescription>{copy.invalidState}</AlertDescription></Alert><div className="flex justify-end"><SetupWizardSkipDialog copy={copy} onSkip={() => { window.location.assign(`${SCHEME}//skip`) }} outlined /></div></div></main></>
}
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'
? <SetupWizardWelcome
copy={copy}
onSkip={() => { window.location.assign(`${SCHEME}//skip`) }}
onSkip={skip}
onStart={() => { setStep('mode') }}
profileName={input.profileName}
/>
: step === 'success'
? <SetupWizardSuccess copy={copy} onStart={startUsing} />
: <SetupWizardStepPage copy={copy} input={input} requestExposure={requestExposure} selection={selection} step={step} update={setSelection} />}
: <SetupWizardStepPage copy={copy} input={input} requestBrowserAccess={requestBrowserAccess} requestExposure={requestExposure} selection={selection} step={step} update={setSelection} />}
</div>
<SetupWizardNavigation
copy={copy}
@@ -639,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}
/>
</section></main>
@@ -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 ? <SetupWizardBrowserCompatibilityConfirmation
cancel={() => { setConfirmBrowserCompatibility(false) }}
confirm={() => {
setSelection(confirmDesktopSetupWizardBrowserCompatibility(selection))
setLanAcknowledged(false)
setConfirmBrowserCompatibility(false)
}}
copy={copy}
/> : null}</>
}
+61 -27
View File
@@ -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<string, DesktopProfileSummary>()
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,
}
}
+3 -2
View File
@@ -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<string, unknown>
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),
+14 -2
View File
@@ -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<DesktopLocale, DesktopSetupWizardCopy> = {
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<DesktopLocale, DesktopSetupWizardCopy> = {
browserTitle: '设置浏览器访问',
browserBody: '选择是否允许在浏览器中打开这个 Profile,以及谁可以访问。',
openBrowser: '允许在浏览器中打开',
browserCompatibilityNotice: '浏览器访问只能使用兼容模式;开启后,这个 Profile 将切换到兼容模式。',
browserCompatibilityNotice: '浏览器访问仅在兼容模式下可用。',
browserCompatibilityDialogTitle: '切换到兼容模式?',
browserCompatibilityDialogBody: '在浏览器中打开只能使用兼容模式。继续将把这个 Profile 的窗口模式切换为兼容模式,并开启浏览器访问。',
confirmBrowserCompatibility: '切换并开启',
cancelBrowserCompatibility: '取消',
networkExposure: '网络访问范围',
networkExposureBody: '仅本机访问只监听回环地址;局域网访问会让同一网络中的设备能够打开客户端。',
loopback: '仅这台电脑',
+21 -16
View File
@@ -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`
}
@@ -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'],
])
})
+4 -4
View File
@@ -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')
})
})
@@ -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)
})
})
+48 -10
View File
@@ -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,
@@ -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', () => {
@@ -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(/<button[^>]*disabled=""[^>]*aria-label="允许在浏览器中打开"/u)
})
it('uses the shadcn Switch component for every wizard toggle', () => {
@@ -301,6 +307,60 @@ 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',
})
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,
@@ -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',
})
})