mirror of
https://gh-proxy.org/https://github.com/klinsmaya/workdsh.git
synced 2026-11-09 09:57:05 +08:00
Merge pull request #640 from anywhere-labs/codex/browser-access-requires-compatibility
fix(desktop): require compatibility for browser access
This commit is contained in:
@@ -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' {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
},
|
||||
|
||||
@@ -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}</>
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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: '仅这台电脑',
|
||||
|
||||
@@ -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'],
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user