diff --git a/dsh-plugin-desktop-beta/src/electron-runtime.ts b/dsh-plugin-desktop-beta/src/electron-runtime.ts index 1b7821cc95..130dc07e7d 100644 --- a/dsh-plugin-desktop-beta/src/electron-runtime.ts +++ b/dsh-plugin-desktop-beta/src/electron-runtime.ts @@ -128,7 +128,7 @@ export class ElectronDesktopRuntime implements DesktopRuntime { private restartRequest: Promise | undefined constructor( - private readonly restart: (target?: 'recovery') => Promise, + private readonly restart: (target?: 'recovery' | 'safe-mode') => Promise, private readonly onRendererBoot: (report: RendererBootReport) => boolean | void = () => {}, private readonly logger: DesktopLogger | undefined = undefined, workspaceVolumeQuery: WindowsVolumeQuery | undefined = undefined, @@ -478,7 +478,17 @@ export class ElectronDesktopRuntime implements DesktopRuntime { await request } - private async confirmAndRestart(target: 'normal' | 'recovery'): Promise { + async requestSafeModeRestart(): Promise { + if (this.quitting) return + if (this.restartRequest !== undefined) return await this.restartRequest + const request = this.confirmAndRestart('safe-mode').finally(() => { + if (this.restartRequest === request) this.restartRequest = undefined + }) + this.restartRequest = request + await request + } + + private async confirmAndRestart(target: 'normal' | 'recovery' | 'safe-mode'): Promise { const copy = desktopRestartConfirmationCopy(this.currentLocale, target) const options: Electron.MessageBoxOptions = { type: 'question', @@ -491,7 +501,7 @@ export class ElectronDesktopRuntime implements DesktopRuntime { noLink: true, } const result = await this.showDesktopMessageBox(options) - if (result.response === 0) await this.restart(target === 'recovery' ? 'recovery' : undefined) + if (result.response === 0) await this.restart(target === 'normal' ? undefined : target) } /** @inheritdoc */ diff --git a/dsh-plugin-desktop-beta/src/main.ts b/dsh-plugin-desktop-beta/src/main.ts index 291e030766..977e2954a9 100644 --- a/dsh-plugin-desktop-beta/src/main.ts +++ b/dsh-plugin-desktop-beta/src/main.ts @@ -540,11 +540,17 @@ async function start(): Promise { throw new Error('dsh-plugin-desktop: shutdown coordinator is not ready') } if (restartRequested) return + if (target === 'safe-mode') { + if (prepareSafeMode === undefined) throw new Error(`${BIN_NAME}: Safe Mode is unavailable`) + prepareSafeMode() + } restartRequested = true nativeExit.requestRelaunch( - target === 'recovery' - ? desktopRecoveryRelaunchArguments() - : desktopDefaultRelaunchArguments(), + target === 'safe-mode' + ? desktopSafeModeRelaunchArguments() + : target === 'recovery' + ? desktopRecoveryRelaunchArguments() + : desktopDefaultRelaunchArguments(), ) await shutdown.request(0) }, (report) => { @@ -833,6 +839,14 @@ async function start(): Promise { }, }) generation.own(() => { safeModeTray.dispose() }) + } else { + const safeModeTray = runtime.registerTrayItem({ + group: 'tools', + order: 100, + label: () => desktopTrayLabel(runtime.locale, 'enterSafeMode'), + invoke: () => runtime.requestSafeModeRestart(), + }) + generation.own(() => { safeModeTray.dispose() }) } const openCompatibilityProfileSelector = async (): Promise<'restart' | 'cancel' | 'unavailable'> => { const profileActions = startupRecoveryProfileActions diff --git a/dsh-plugin-desktop-beta/src/tray-locale.ts b/dsh-plugin-desktop-beta/src/tray-locale.ts index 6038092825..73b4b147b6 100644 --- a/dsh-plugin-desktop-beta/src/tray-locale.ts +++ b/dsh-plugin-desktop-beta/src/tray-locale.ts @@ -1,12 +1,14 @@ /** Desktop-owned native tray copy for the locales shipped by DSH. */ import type { DesktopLocale } from './runtime.ts' +import { desktopRecoveryCopy } from './recovery-copy.ts' export type DesktopTrayLabelKey = | 'addProfile' | 'checkForUpdates' | 'checkingForUpdates' | 'downloadingUpdate' + | 'enterSafeMode' | 'exportDiagnostics' | 'exitSafeMode' | 'openDesktop' @@ -26,6 +28,7 @@ const labels: Record 'Check for Updates…', checkingForUpdates: () => 'Checking for Updates…', downloadingUpdate: version => `Downloading DSH Desktop ${version}…`, + enterSafeMode: () => 'Enter Safe Mode…', exportDiagnostics: () => 'Export Diagnostics…', exitSafeMode: () => 'Exit Safe Mode and Restart…', openDesktop: productName => `Open ${productName}`, @@ -44,6 +47,7 @@ const labels: Record '检查更新…', checkingForUpdates: () => '正在检查更新…', downloadingUpdate: version => `正在下载 DSH Desktop ${version}…`, + enterSafeMode: () => '进入安全模式…', exportDiagnostics: () => '导出诊断信息…', exitSafeMode: () => '退出安全模式并重启…', openDesktop: productName => `打开 ${productName}`, @@ -149,7 +153,17 @@ export function desktopDiagnosticsPrivacyCopy(locale: DesktopLocale): DesktopDia /** Resolve the native confirmation shown before every ordinary relaunch request. */ export function desktopRestartConfirmationCopy( locale: DesktopLocale, - target: 'normal' | 'recovery' = 'normal', + target: 'normal' | 'recovery' | 'safe-mode' = 'normal', ): DesktopRestartConfirmationCopy { + if (target === 'safe-mode') { + const copy = desktopRecoveryCopy(locale) + return { + title: copy.confirmSafeMode, + message: copy.confirmSafeModeMessage, + detail: copy.confirmSafeModeBody, + confirm: copy.confirmSafeModeAction, + cancel: copy.cancel, + } + } return restartConfirmationCopy[locale][target] } diff --git a/dsh-plugin-desktop-beta/tests/desktop-copy.spec.ts b/dsh-plugin-desktop-beta/tests/desktop-copy.spec.ts index 96866bcd6b..e4f9da8cc8 100644 --- a/dsh-plugin-desktop-beta/tests/desktop-copy.spec.ts +++ b/dsh-plugin-desktop-beta/tests/desktop-copy.spec.ts @@ -2,9 +2,21 @@ import { describe, expect, it } from 'vitest' import { desktopNativeCopy } from '../src/native-dialog-copy.ts' import { desktopProfileCreateCopy } from '../src/profile-create-copy.ts' import { desktopRecoveryCopy } from '../src/recovery-copy.ts' -import { desktopTrayLabel } from '../src/tray-locale.ts' +import { desktopRestartConfirmationCopy, desktopTrayLabel } from '../src/tray-locale.ts' describe('Desktop product copy', () => { + it.each(['en', 'zh'] as const)('reuses the Safe Mode confirmation in %s native menus', locale => { + const copy = desktopRecoveryCopy(locale) + expect(desktopTrayLabel(locale, 'enterSafeMode')).toBe(locale === 'zh' ? '进入安全模式…' : 'Enter Safe Mode…') + expect(desktopRestartConfirmationCopy(locale, 'safe-mode')).toEqual({ + title: copy.confirmSafeMode, + message: copy.confirmSafeModeMessage, + detail: copy.confirmSafeModeBody, + confirm: copy.confirmSafeModeAction, + cancel: copy.cancel, + }) + }) + it('uses Profile consistently for product-level configuration sets', () => { expect(desktopProfileCreateCopy('zh')).toMatchObject({ title: '新建 Profile', diff --git a/dsh-plugin-desktop-beta/tests/electron-runtime.spec.ts b/dsh-plugin-desktop-beta/tests/electron-runtime.spec.ts index 309cf10267..9e23ee66d9 100644 --- a/dsh-plugin-desktop-beta/tests/electron-runtime.spec.ts +++ b/dsh-plugin-desktop-beta/tests/electron-runtime.spec.ts @@ -2,6 +2,7 @@ import { readFileSync, unlinkSync } from 'node:fs' import { basename, dirname, join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { DesktopShellSpec } from '../src/runtime.ts' +import { desktopTrayLabel } from '../src/tray-locale.ts' import { DESKTOP_FRAME_HEIGHT } from '../src/window-chrome.ts' const terminal = vi.hoisted(() => ({ open: vi.fn() })) @@ -1863,7 +1864,7 @@ describe('Electron desktop runtime', () => { vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') electron.dialog.showMessageBox.mockResolvedValueOnce({ response: 1, checkboxChecked: false }) const { ElectronDesktopRuntime } = await import('../src/electron-runtime.ts') - const restart = vi.fn(async (_target?: 'recovery') => {}) + const restart = vi.fn(async (_target?: 'recovery' | 'safe-mode') => {}) const runtime = new ElectronDesktopRuntime(restart) await runtime.requestRestart() @@ -1883,7 +1884,7 @@ describe('Electron desktop runtime', () => { vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') electron.dialog.showMessageBox.mockResolvedValueOnce({ response: 0, checkboxChecked: false }) const { ElectronDesktopRuntime } = await import('../src/electron-runtime.ts') - const restart = vi.fn(async (_target?: 'recovery') => {}) + const restart = vi.fn(async (_target?: 'recovery' | 'safe-mode') => {}) const runtime = new ElectronDesktopRuntime(restart) await runtime.requestRecoveryRestart() @@ -1895,6 +1896,91 @@ describe('Electron desktop runtime', () => { expect(restart).toHaveBeenCalledWith('recovery') }) + it.each([ + { platform: 'win32', surface: 'tray' }, + { platform: 'darwin', surface: 'tray' }, + { platform: 'darwin', surface: 'application' }, + ] as const)('enters Safe Mode from the localized $platform $surface menu', async ({ platform, surface }) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) + const { ElectronDesktopRuntime } = await import('../src/electron-runtime.ts') + const restart = vi.fn(async () => {}) + const runtime = new ElectronDesktopRuntime(restart) + const registration = runtime.registerTrayItem({ + group: 'tools', + order: 100, + label: () => desktopTrayLabel(runtime.locale, 'enterSafeMode'), + invoke: () => runtime.requestSafeModeRestart(), + }) + const release = runtime.schedule(spec) + await runtime.mountScheduled() + + type MenuCommand = { label?: string, enabled?: boolean, click?: () => void } + const menuItems = (): MenuCommand[] => surface === 'application' + ? (electron.applicationMenuTemplates.at(-1)?.[0] as { submenu: MenuCommand[] }).submenu + : electron.menuTemplates.at(-1) as MenuCommand[] + expect(menuItems()).toEqual(expect.arrayContaining([ + expect.objectContaining({ label: 'Enter Safe Mode…', enabled: true }), + ])) + + runtime.setLocalePreference('zh') + const command = menuItems().find(item => item.label === '进入安全模式…') + expect(command?.click).toEqual(expect.any(Function)) + command?.click?.() + + await vi.waitFor(() => { expect(restart).toHaveBeenCalledWith('safe-mode') }) + expect(restart).toHaveBeenCalledOnce() + expect(electron.dialog.showMessageBox.mock.calls.at(-1)?.at(-1)).toMatchObject({ + type: 'question', title: '进入安全模式?', buttons: ['重启到安全模式', '取消'], defaultId: 1, cancelId: 1, + }) + + registration.dispose() + expect(menuItems()).not.toEqual(expect.arrayContaining([ + expect.objectContaining({ label: '进入安全模式…' }), + ])) + await release() + }) + + it('cancels, coalesces, and suppresses Safe Mode restart requests while quitting', async () => { + const { ElectronDesktopRuntime } = await import('../src/electron-runtime.ts') + const restart = vi.fn(async () => {}) + const runtime = new ElectronDesktopRuntime(restart) + electron.dialog.showMessageBox.mockResolvedValueOnce({ response: 1, checkboxChecked: false }) + + await runtime.requestSafeModeRestart() + expect(restart).not.toHaveBeenCalled() + expect(electron.dialog.showMessageBox.mock.calls.at(-1)?.at(-1)).toMatchObject({ + title: 'Enter Safe Mode?', buttons: ['Restart in Safe Mode', 'Cancel'], defaultId: 1, cancelId: 1, + }) + + await Promise.all([ + runtime.requestSafeModeRestart(), + runtime.requestSafeModeRestart(), + runtime.requestRestart(), + runtime.requestRecoveryRestart(), + ]) + expect(electron.dialog.showMessageBox).toHaveBeenCalledTimes(2) + expect(restart).toHaveBeenCalledOnce() + expect(restart).toHaveBeenCalledWith('safe-mode') + + runtime.prepareToQuit() + await runtime.requestSafeModeRestart() + expect(electron.dialog.showMessageBox).toHaveBeenCalledTimes(2) + expect(restart).toHaveBeenCalledOnce() + }) + + it('allows retrying Safe Mode after preparation fails', async () => { + const { ElectronDesktopRuntime } = await import('../src/electron-runtime.ts') + const restart = vi.fn(async () => {}).mockRejectedValueOnce(new Error('Safe Mode preparation failed')) + const runtime = new ElectronDesktopRuntime(restart) + + await expect(runtime.requestSafeModeRestart()).rejects.toThrow('Safe Mode preparation failed') + await runtime.requestSafeModeRestart() + + expect(electron.dialog.showMessageBox).toHaveBeenCalledTimes(2) + expect(restart).toHaveBeenCalledTimes(2) + expect(restart).toHaveBeenLastCalledWith('safe-mode') + }) + it('uses Electron networking and confirmation-gated macOS update handoff', async () => { vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') const response = Response.json({ version: '2.1.0' }) diff --git a/dsh-plugin-desktop-beta/tests/package.spec.ts b/dsh-plugin-desktop-beta/tests/package.spec.ts index 06c143cc5e..aa75c26adf 100644 --- a/dsh-plugin-desktop-beta/tests/package.spec.ts +++ b/dsh-plugin-desktop-beta/tests/package.spec.ts @@ -131,6 +131,9 @@ describe('published package surface', () => { expect(main).toContain('failed to remove the Safe Mode environment') expect(main).toContain('desktopSafeModeRelaunchArguments()') expect(main).toContain("desktopTrayLabel(runtime.locale, 'exitSafeMode')") + expect(main).toContain("desktopTrayLabel(runtime.locale, 'enterSafeMode')") + expect(main).toContain('invoke: () => runtime.requestSafeModeRestart()') + expect(main).toContain('prepareSafeMode()\n }\n restartRequested = true') expect(main).toContain('notifyDesktopSafeModeActive(runtime, electronLogger)') expect(main).toContain('safeModePaths !== undefined && DESKTOP_SAFE_MODE_DEFAULTS.settings.notifications.enabled') expect(main).toContain('const setupWizardState = safeModePaths === undefined') diff --git a/dsh-plugin-desktop/src/electron-runtime.ts b/dsh-plugin-desktop/src/electron-runtime.ts index 1b7821cc95..130dc07e7d 100644 --- a/dsh-plugin-desktop/src/electron-runtime.ts +++ b/dsh-plugin-desktop/src/electron-runtime.ts @@ -128,7 +128,7 @@ export class ElectronDesktopRuntime implements DesktopRuntime { private restartRequest: Promise | undefined constructor( - private readonly restart: (target?: 'recovery') => Promise, + private readonly restart: (target?: 'recovery' | 'safe-mode') => Promise, private readonly onRendererBoot: (report: RendererBootReport) => boolean | void = () => {}, private readonly logger: DesktopLogger | undefined = undefined, workspaceVolumeQuery: WindowsVolumeQuery | undefined = undefined, @@ -478,7 +478,17 @@ export class ElectronDesktopRuntime implements DesktopRuntime { await request } - private async confirmAndRestart(target: 'normal' | 'recovery'): Promise { + async requestSafeModeRestart(): Promise { + if (this.quitting) return + if (this.restartRequest !== undefined) return await this.restartRequest + const request = this.confirmAndRestart('safe-mode').finally(() => { + if (this.restartRequest === request) this.restartRequest = undefined + }) + this.restartRequest = request + await request + } + + private async confirmAndRestart(target: 'normal' | 'recovery' | 'safe-mode'): Promise { const copy = desktopRestartConfirmationCopy(this.currentLocale, target) const options: Electron.MessageBoxOptions = { type: 'question', @@ -491,7 +501,7 @@ export class ElectronDesktopRuntime implements DesktopRuntime { noLink: true, } const result = await this.showDesktopMessageBox(options) - if (result.response === 0) await this.restart(target === 'recovery' ? 'recovery' : undefined) + if (result.response === 0) await this.restart(target === 'normal' ? undefined : target) } /** @inheritdoc */ diff --git a/dsh-plugin-desktop/src/main.ts b/dsh-plugin-desktop/src/main.ts index 582b0a2511..c576c5135b 100644 --- a/dsh-plugin-desktop/src/main.ts +++ b/dsh-plugin-desktop/src/main.ts @@ -513,11 +513,17 @@ async function start(): Promise { throw new Error('dsh-plugin-desktop: shutdown coordinator is not ready') } if (restartRequested) return + if (target === 'safe-mode') { + if (prepareSafeMode === undefined) throw new Error(`${BIN_NAME}: Safe Mode is unavailable`) + prepareSafeMode() + } restartRequested = true nativeExit.requestRelaunch( - target === 'recovery' - ? desktopRecoveryRelaunchArguments() - : desktopDefaultRelaunchArguments(), + target === 'safe-mode' + ? desktopSafeModeRelaunchArguments() + : target === 'recovery' + ? desktopRecoveryRelaunchArguments() + : desktopDefaultRelaunchArguments(), ) await shutdown.request(0) }, (report) => { @@ -791,6 +797,14 @@ async function start(): Promise { }, }) generation.own(() => { safeModeTray.dispose() }) + } else { + const safeModeTray = runtime.registerTrayItem({ + group: 'tools', + order: 100, + label: () => desktopTrayLabel(runtime.locale, 'enterSafeMode'), + invoke: () => runtime.requestSafeModeRestart(), + }) + generation.own(() => { safeModeTray.dispose() }) } const openCompatibilityProfileSelector = async (): Promise<'restart' | 'cancel' | 'unavailable'> => { const profileActions = startupRecoveryProfileActions diff --git a/dsh-plugin-desktop/src/tray-locale.ts b/dsh-plugin-desktop/src/tray-locale.ts index 6038092825..73b4b147b6 100644 --- a/dsh-plugin-desktop/src/tray-locale.ts +++ b/dsh-plugin-desktop/src/tray-locale.ts @@ -1,12 +1,14 @@ /** Desktop-owned native tray copy for the locales shipped by DSH. */ import type { DesktopLocale } from './runtime.ts' +import { desktopRecoveryCopy } from './recovery-copy.ts' export type DesktopTrayLabelKey = | 'addProfile' | 'checkForUpdates' | 'checkingForUpdates' | 'downloadingUpdate' + | 'enterSafeMode' | 'exportDiagnostics' | 'exitSafeMode' | 'openDesktop' @@ -26,6 +28,7 @@ const labels: Record 'Check for Updates…', checkingForUpdates: () => 'Checking for Updates…', downloadingUpdate: version => `Downloading DSH Desktop ${version}…`, + enterSafeMode: () => 'Enter Safe Mode…', exportDiagnostics: () => 'Export Diagnostics…', exitSafeMode: () => 'Exit Safe Mode and Restart…', openDesktop: productName => `Open ${productName}`, @@ -44,6 +47,7 @@ const labels: Record '检查更新…', checkingForUpdates: () => '正在检查更新…', downloadingUpdate: version => `正在下载 DSH Desktop ${version}…`, + enterSafeMode: () => '进入安全模式…', exportDiagnostics: () => '导出诊断信息…', exitSafeMode: () => '退出安全模式并重启…', openDesktop: productName => `打开 ${productName}`, @@ -149,7 +153,17 @@ export function desktopDiagnosticsPrivacyCopy(locale: DesktopLocale): DesktopDia /** Resolve the native confirmation shown before every ordinary relaunch request. */ export function desktopRestartConfirmationCopy( locale: DesktopLocale, - target: 'normal' | 'recovery' = 'normal', + target: 'normal' | 'recovery' | 'safe-mode' = 'normal', ): DesktopRestartConfirmationCopy { + if (target === 'safe-mode') { + const copy = desktopRecoveryCopy(locale) + return { + title: copy.confirmSafeMode, + message: copy.confirmSafeModeMessage, + detail: copy.confirmSafeModeBody, + confirm: copy.confirmSafeModeAction, + cancel: copy.cancel, + } + } return restartConfirmationCopy[locale][target] } diff --git a/dsh-plugin-desktop/tests/desktop-copy.spec.ts b/dsh-plugin-desktop/tests/desktop-copy.spec.ts index be96f92593..308f88e03c 100644 --- a/dsh-plugin-desktop/tests/desktop-copy.spec.ts +++ b/dsh-plugin-desktop/tests/desktop-copy.spec.ts @@ -2,9 +2,21 @@ import { describe, expect, it } from 'vitest' import { desktopNativeCopy } from '../src/native-dialog-copy.ts' import { desktopProfileCreateCopy } from '../src/profile-create-copy.ts' import { desktopRecoveryCopy } from '../src/recovery-copy.ts' -import { desktopTrayLabel } from '../src/tray-locale.ts' +import { desktopRestartConfirmationCopy, desktopTrayLabel } from '../src/tray-locale.ts' describe('Desktop product copy', () => { + it.each(['en', 'zh'] as const)('reuses the Safe Mode confirmation in %s native menus', locale => { + const copy = desktopRecoveryCopy(locale) + expect(desktopTrayLabel(locale, 'enterSafeMode')).toBe(locale === 'zh' ? '进入安全模式…' : 'Enter Safe Mode…') + expect(desktopRestartConfirmationCopy(locale, 'safe-mode')).toEqual({ + title: copy.confirmSafeMode, + message: copy.confirmSafeModeMessage, + detail: copy.confirmSafeModeBody, + confirm: copy.confirmSafeModeAction, + cancel: copy.cancel, + }) + }) + it('uses Profile consistently for product-level configuration sets', () => { expect(desktopProfileCreateCopy('zh')).toMatchObject({ title: '新建 Profile', diff --git a/dsh-plugin-desktop/tests/electron-runtime.spec.ts b/dsh-plugin-desktop/tests/electron-runtime.spec.ts index 03a6bf8dbd..54168af25e 100644 --- a/dsh-plugin-desktop/tests/electron-runtime.spec.ts +++ b/dsh-plugin-desktop/tests/electron-runtime.spec.ts @@ -2,6 +2,7 @@ import { unlinkSync } from 'node:fs' import { basename, dirname, join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { DesktopShellSpec } from '../src/runtime.ts' +import { desktopTrayLabel } from '../src/tray-locale.ts' import { DESKTOP_FRAME_HEIGHT } from '../src/window-chrome.ts' const terminal = vi.hoisted(() => ({ open: vi.fn() })) @@ -1860,7 +1861,7 @@ describe('Electron desktop runtime', () => { vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') electron.dialog.showMessageBox.mockResolvedValueOnce({ response: 1, checkboxChecked: false }) const { ElectronDesktopRuntime } = await import('../src/electron-runtime.ts') - const restart = vi.fn(async (_target?: 'recovery') => {}) + const restart = vi.fn(async (_target?: 'recovery' | 'safe-mode') => {}) const runtime = new ElectronDesktopRuntime(restart) await runtime.requestRestart() @@ -1880,7 +1881,7 @@ describe('Electron desktop runtime', () => { vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') electron.dialog.showMessageBox.mockResolvedValueOnce({ response: 0, checkboxChecked: false }) const { ElectronDesktopRuntime } = await import('../src/electron-runtime.ts') - const restart = vi.fn(async (_target?: 'recovery') => {}) + const restart = vi.fn(async (_target?: 'recovery' | 'safe-mode') => {}) const runtime = new ElectronDesktopRuntime(restart) await runtime.requestRecoveryRestart() @@ -1892,6 +1893,91 @@ describe('Electron desktop runtime', () => { expect(restart).toHaveBeenCalledWith('recovery') }) + it.each([ + { platform: 'win32', surface: 'tray' }, + { platform: 'darwin', surface: 'tray' }, + { platform: 'darwin', surface: 'application' }, + ] as const)('enters Safe Mode from the localized $platform $surface menu', async ({ platform, surface }) => { + vi.spyOn(process, 'platform', 'get').mockReturnValue(platform) + const { ElectronDesktopRuntime } = await import('../src/electron-runtime.ts') + const restart = vi.fn(async () => {}) + const runtime = new ElectronDesktopRuntime(restart) + const registration = runtime.registerTrayItem({ + group: 'tools', + order: 100, + label: () => desktopTrayLabel(runtime.locale, 'enterSafeMode'), + invoke: () => runtime.requestSafeModeRestart(), + }) + const release = runtime.schedule(spec) + await runtime.mountScheduled() + + type MenuCommand = { label?: string, enabled?: boolean, click?: () => void } + const menuItems = (): MenuCommand[] => surface === 'application' + ? (electron.applicationMenuTemplates.at(-1)?.[0] as { submenu: MenuCommand[] }).submenu + : electron.menuTemplates.at(-1) as MenuCommand[] + expect(menuItems()).toEqual(expect.arrayContaining([ + expect.objectContaining({ label: 'Enter Safe Mode…', enabled: true }), + ])) + + runtime.setLocalePreference('zh') + const command = menuItems().find(item => item.label === '进入安全模式…') + expect(command?.click).toEqual(expect.any(Function)) + command?.click?.() + + await vi.waitFor(() => { expect(restart).toHaveBeenCalledWith('safe-mode') }) + expect(restart).toHaveBeenCalledOnce() + expect(electron.dialog.showMessageBox.mock.calls.at(-1)?.at(-1)).toMatchObject({ + type: 'question', title: '进入安全模式?', buttons: ['重启到安全模式', '取消'], defaultId: 1, cancelId: 1, + }) + + registration.dispose() + expect(menuItems()).not.toEqual(expect.arrayContaining([ + expect.objectContaining({ label: '进入安全模式…' }), + ])) + await release() + }) + + it('cancels, coalesces, and suppresses Safe Mode restart requests while quitting', async () => { + const { ElectronDesktopRuntime } = await import('../src/electron-runtime.ts') + const restart = vi.fn(async () => {}) + const runtime = new ElectronDesktopRuntime(restart) + electron.dialog.showMessageBox.mockResolvedValueOnce({ response: 1, checkboxChecked: false }) + + await runtime.requestSafeModeRestart() + expect(restart).not.toHaveBeenCalled() + expect(electron.dialog.showMessageBox.mock.calls.at(-1)?.at(-1)).toMatchObject({ + title: 'Enter Safe Mode?', buttons: ['Restart in Safe Mode', 'Cancel'], defaultId: 1, cancelId: 1, + }) + + await Promise.all([ + runtime.requestSafeModeRestart(), + runtime.requestSafeModeRestart(), + runtime.requestRestart(), + runtime.requestRecoveryRestart(), + ]) + expect(electron.dialog.showMessageBox).toHaveBeenCalledTimes(2) + expect(restart).toHaveBeenCalledOnce() + expect(restart).toHaveBeenCalledWith('safe-mode') + + runtime.prepareToQuit() + await runtime.requestSafeModeRestart() + expect(electron.dialog.showMessageBox).toHaveBeenCalledTimes(2) + expect(restart).toHaveBeenCalledOnce() + }) + + it('allows retrying Safe Mode after preparation fails', async () => { + const { ElectronDesktopRuntime } = await import('../src/electron-runtime.ts') + const restart = vi.fn(async () => {}).mockRejectedValueOnce(new Error('Safe Mode preparation failed')) + const runtime = new ElectronDesktopRuntime(restart) + + await expect(runtime.requestSafeModeRestart()).rejects.toThrow('Safe Mode preparation failed') + await runtime.requestSafeModeRestart() + + expect(electron.dialog.showMessageBox).toHaveBeenCalledTimes(2) + expect(restart).toHaveBeenCalledTimes(2) + expect(restart).toHaveBeenLastCalledWith('safe-mode') + }) + it('uses Electron networking and confirmation-gated macOS update handoff', async () => { vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') const response = Response.json({ version: '2.1.0' }) diff --git a/dsh-plugin-desktop/tests/package.spec.ts b/dsh-plugin-desktop/tests/package.spec.ts index 2127f785a7..8aa992385a 100644 --- a/dsh-plugin-desktop/tests/package.spec.ts +++ b/dsh-plugin-desktop/tests/package.spec.ts @@ -102,6 +102,9 @@ describe('published package surface', () => { expect(main).toContain('failed to remove the Safe Mode environment') expect(main).toContain('desktopSafeModeRelaunchArguments()') expect(main).toContain("desktopTrayLabel(runtime.locale, 'exitSafeMode')") + expect(main).toContain("desktopTrayLabel(runtime.locale, 'enterSafeMode')") + expect(main).toContain('invoke: () => runtime.requestSafeModeRestart()') + expect(main).toContain('prepareSafeMode()\n }\n restartRequested = true') expect(main).toContain('notifyDesktopSafeModeActive(runtime, electronLogger)') expect(main).toContain('safeModePaths !== undefined && DESKTOP_SAFE_MODE_DEFAULTS.settings.notifications.enabled') expect(main).toContain('const setupWizardState = safeModePaths === undefined')