Merge pull request #1553 from BradGroux/fix/native-command-delivery-1524

fix: deliver native menu commands to the mounted renderer
This commit is contained in:
Brad Groux 2026-09-07 18:50:23 -05:00 • committed by GitHub
commit 42dc7ec41d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 380 additions and 64 deletions

View file

@ -57,7 +57,12 @@ function dispatcher() {
const shell = {
openPath: vi.fn(async () => ''),
} as unknown as Shell;
const sendRendererCommand = vi.fn();
const sendRendererCommand = vi.fn(async (request) => ({
command: request.command,
accepted: true,
handledBy: 'renderer' as const,
message: undefined,
}));
const checkForUpdates = vi.fn(async () => updateStatus('idle'));
const downloadUpdate = vi.fn(async () => updateStatus('ready'));
const installUpdate = vi.fn(() => updateStatus('ready'));

View file

@ -0,0 +1,73 @@
import { EventEmitter } from 'node:events';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { IpcMain, WebContents } from 'electron';
import { sendAcknowledgedRendererCommand } from '../renderer-commands.js';
function harness() {
const ipc = new EventEmitter();
const target = Object.assign(new EventEmitter(), {
isDestroyed: () => false,
mainFrame: {},
send: vi.fn(),
});
const request = { command: 'new-task' as const, source: 'menu' as const };
const dispatch = () =>
sendAcknowledgedRendererCommand(ipc as IpcMain, target as unknown as WebContents, request, 100);
const receipt = (accepted = true) => ({
requestId: target.send.mock.calls[0][1].requestId,
accepted,
});
const event = { sender: target, senderFrame: target.mainFrame };
return { ipc, target, request, dispatch, receipt, event };
}
afterEach(() => vi.useRealTimers());
describe('renderer command receipts', () => {
it('waits for the matching window and request and removes listeners', async () => {
const h = harness();
const finished = vi.fn();
const result = h.dispatch().then(finished);
h.ipc.emit('desktop:menu-command-result', { sender: {} }, h.receipt());
h.ipc.emit('desktop:menu-command-result', h.event, { requestId: 'another', accepted: true });
await Promise.resolve();
expect(finished).not.toHaveBeenCalled();
h.ipc.emit('desktop:menu-command-result', h.event, h.receipt());
await result;
expect(finished).toHaveBeenCalledWith(
expect.objectContaining({ accepted: true, handledBy: 'renderer' })
);
expect(h.ipc.listenerCount('desktop:menu-command-result')).toBe(0);
expect(h.target.listenerCount('destroyed')).toBe(0);
});
it('returns renderer permission failures without claiming success', async () => {
const h = harness();
const result = h.dispatch();
h.ipc.emit('desktop:menu-command-result', h.event, {
...h.receipt(false),
message: 'Permission required',
});
await expect(result).resolves.toMatchObject({
accepted: false,
message: 'Permission required',
});
});
it('fails closed when setup or a locked renderer has no listener', async () => {
vi.useFakeTimers();
const h = harness();
const result = h.dispatch();
await vi.advanceTimersByTimeAsync(100);
await expect(result).resolves.toMatchObject({
accepted: false,
message: expect.stringContaining('unlock'),
});
expect(h.ipc.listenerCount('desktop:menu-command-result')).toBe(0);
});
it('settles a destroyed or missing window', async () => {
const h = harness();
const result = h.dispatch();
h.target.emit('destroyed');
await expect(result).resolves.toMatchObject({ accepted: false });
await expect(
sendAcknowledgedRendererCommand(h.ipc as IpcMain, undefined, h.request)
).resolves.toMatchObject({ accepted: false });
});
});

View file

@ -151,7 +151,9 @@ export interface DesktopCommandDispatcherOptions {
runtime: DesktopRuntime;
shell: Shell;
quit(): void;
sendRendererCommand(command: DesktopCommandDispatchRequest): void;
sendRendererCommand(
command: DesktopCommandDispatchRequest
): Promise<DesktopCommandDispatchResult>;
checkForUpdates(): Promise<DesktopUpdateStatus>;
downloadUpdate(): Promise<DesktopUpdateStatus>;
installUpdate(): DesktopUpdateStatus;
@ -168,19 +170,16 @@ export class DesktopCommandDispatcher {
switch (definition.nativeAction) {
case 'renderer':
this.options.sendRendererCommand(request);
return accepted(request, 'renderer');
return this.options.sendRendererCommand(request);
case 'restart-server':
await this.options.runtime.restartLocalServer();
this.options.sendRendererCommand(request);
return accepted(request, 'desktop');
case 'open-logs':
await this.options.shell.openPath(this.options.runtime.snapshot().logsDir);
return accepted(request, 'desktop');
case 'show-diagnostics':
case 'create-debug-bundle':
this.options.sendRendererCommand(request);
return accepted(request, 'renderer');
return this.options.sendRendererCommand(request);
case 'check-updates':
await this.options.checkForUpdates();
return accepted(request, 'desktop');
@ -194,12 +193,7 @@ export class DesktopCommandDispatcher {
this.options.showTestNotification();
return accepted(request, 'desktop');
case 'test-external-delivery':
this.options.sendRendererCommand(request);
return accepted(
request,
'renderer',
'External delivery test requires configured delivery.'
);
return this.options.sendRendererCommand(request);
case 'copy-diagnostics':
this.options.copyRedactedDiagnostics(this.options.runtime.snapshot());
return accepted(request, 'desktop');

View file

@ -6,6 +6,7 @@ import { createRequire } from 'node:module';
import { DESKTOP_APP_ID, DESKTOP_APP_NAME, DESKTOP_MIN_WINDOW } from './app-metadata.js';
import { registerDesktopBridge } from './bridge.js';
import { DesktopCommandDispatcher } from './commands.js';
import { sendAcknowledgedRendererCommand } from './renderer-commands.js';
import { extractDeepLinkFromArgv, parseDesktopDeepLink } from './deep-links.js';
import { configureDesktopMenu, dispatchDesktopMenuCommand } from './menu.js';
import { hasSameOriginNavigation, openValidatedExternalUrl } from './navigation.js';
@ -301,9 +302,8 @@ async function boot(): Promise<void> {
runtime,
shell,
quit: () => app.quit(),
sendRendererCommand: (command) => {
activeMainWindow()?.webContents.send(DESKTOP_BRIDGE_EVENTS.menuCommand.channel, command);
},
sendRendererCommand: (command) =>
sendAcknowledgedRendererCommand(ipcMain, activeMainWindow()?.webContents, command),
checkForUpdates: () =>
updateService?.checkForUpdates() ?? Promise.resolve(updateServiceFallback(packaged)),
downloadUpdate: () =>

View file

@ -1,4 +1,4 @@
import { Menu, type MenuItemConstructorOptions } from 'electron';
import { Menu, dialog, type MenuItemConstructorOptions } from 'electron';
import {
createDesktopCommandRequest,
@ -120,7 +120,23 @@ export function dispatchDesktopMenuCommand(
dispatcher: DesktopCommandDispatcher,
command: DesktopCommandName
): void {
void dispatcher.dispatch(createDesktopCommandRequest(command, 'menu'));
void dispatcher
.dispatch(createDesktopCommandRequest(command, 'menu'))
.then((result) => {
if (!result.accepted)
return dialog.showMessageBox({
type: 'info',
message: DESKTOP_COMMAND_REGISTRY[command].label,
detail: result.message ?? 'This action is unavailable. Open the workspace and try again.',
});
})
.catch(() =>
dialog.showMessageBox({
type: 'error',
message: 'Unable to complete the command',
detail: 'Check Setup & Diagnostics, then try again.',
})
);
}
function isCommandEnabled(

View file

@ -0,0 +1,70 @@
import { randomUUID } from 'node:crypto';
import type { IpcMain, WebContents } from 'electron';
import type {
DesktopCommandDispatchRequest,
DesktopCommandDispatchResult,
} from '../shared/desktop-bridge-contracts.js';
/** A receipt is tied to the exact renderer and request, never just a command name. */
export function sendAcknowledgedRendererCommand(
ipc: IpcMain,
target: WebContents | undefined,
request: DesktopCommandDispatchRequest,
timeoutMs = 3000
): Promise<DesktopCommandDispatchResult> {
const unavailable = (message: string): DesktopCommandDispatchResult => ({
command: request.command,
accepted: false,
handledBy: 'unsupported',
message,
});
if (!target || target.isDestroyed()) {
return Promise.resolve(
unavailable('Open the main window, finish setup and unlock the workspace, then try again.')
);
}
return new Promise((resolve) => {
const requestId = randomUUID();
const finish = (result: DesktopCommandDispatchResult) => {
clearTimeout(timer);
ipc.off('desktop:menu-command-result', acknowledge);
target.off('destroyed', closed);
resolve(result);
};
const closed = () =>
finish(unavailable('The window closed before handling the command. Open it and try again.'));
const acknowledge = (event: Electron.IpcMainEvent, receipt: unknown) => {
if (
event.sender !== target ||
event.senderFrame !== target.mainFrame ||
!receipt ||
typeof receipt !== 'object'
)
return;
const value = receipt as Record<string, unknown>;
if (value.requestId !== requestId || typeof value.accepted !== 'boolean') return;
finish({
command: request.command,
accepted: value.accepted,
handledBy: value.accepted ? 'renderer' : 'unsupported',
message: typeof value.message === 'string' ? value.message.slice(0, 500) : undefined,
});
};
const timer = setTimeout(
() =>
finish(
unavailable(
'The workspace did not handle this command. Finish setup or unlock it, then try again.'
)
),
timeoutMs
);
ipc.on('desktop:menu-command-result', acknowledge);
target.once('destroyed', closed);
try {
target.send('desktop:menu-command', { ...request, requestId });
} catch {
finish(unavailable('The window is unavailable. Open it and try again.'));
}
});
}

View file

@ -97,7 +97,9 @@ export interface VeritasDesktopApi {
onRunProgress(listener: BridgeEventListener<'runProgress'>): () => void;
onUpdateStatus(listener: BridgeEventListener<'updateStatus'>): () => void;
onNotificationAction(listener: BridgeEventListener<'notificationAction'>): () => void;
onMenuCommand(listener: BridgeEventListener<'menuCommand'>): () => void;
onMenuCommand(
listener: (request: DesktopCommandDispatchRequest) => { accepted: boolean; message?: string }
): () => void;
onUploadProgress(listener: BridgeEventListener<'uploadProgress'>): () => void;
onWorkProductExportProgress(
listener: BridgeEventListener<'workProductExportProgress'>
@ -185,7 +187,30 @@ const api: VeritasDesktopApi = {
onRunProgress: (listener) => onDesktopEvent('runProgress', listener),
onUpdateStatus: (listener) => onDesktopEvent('updateStatus', listener),
onNotificationAction: (listener) => onDesktopEvent('notificationAction', listener),
onMenuCommand: (listener) => onDesktopEvent('menuCommand', listener),
onMenuCommand: (listener) => {
const handler = (
_event: Electron.IpcRendererEvent,
request: DesktopCommandDispatchRequest & { requestId?: string }
) => {
if (!request.requestId) return;
try {
const result = listener(request);
ipcRenderer.send('desktop:menu-command-result', {
requestId: request.requestId,
accepted: result?.accepted === true,
message: result?.message,
});
} catch {
ipcRenderer.send('desktop:menu-command-result', {
requestId: request.requestId,
accepted: false,
message: 'Unable to open this action. Close the current dialog and try again.',
});
}
};
ipcRenderer.on('desktop:menu-command', handler);
return () => ipcRenderer.off('desktop:menu-command', handler);
},
onUploadProgress: (listener) => onDesktopEvent('uploadProgress', listener),
onWorkProductExportProgress: (listener) => onDesktopEvent('workProductExportProgress', listener),
onExternalDeliveryVerification: (listener) =>

View file

@ -308,3 +308,12 @@ Windows release and update support stay blocked until code signing and
signed-installer smoke coverage are in place. Linux release and updater support
are deferred until the project has clear checksum, provenance, install, and
AppImage/deb/rpm update policies.
### Native menu verification
The packaged native UI gate exercises New Task, Settings, Search, Command Center,
Import, Export, Create Backup, and Create Debug Bundle through Electron's installed
menu and the mounted renderer. File data commands and Debug Bundle open the existing
Maintenance flow; they do not automatically export, restore, or create files.
Renderer commands wait for an acknowledgement. Finish setup and unlock the workspace
before using them; unavailable actions display a reason instead of reporting success.

View file

@ -0,0 +1,63 @@
/* global window */
import assert from 'node:assert/strict';
import { expect } from '@playwright/test';
/** Uses the installed native menu, preload, and real mounted application. */
export async function verifyNativeMenuCommands(app, page) {
const cases = [
['New Task', 'Create Task'],
['Settings', 'Settings'],
['Search', 'Search'],
['Command Center', 'Command Center'],
['Import', 'Settings'],
['Export', 'Settings'],
['Create Backup', 'Settings'],
['Create Debug Bundle', 'Settings'],
];
const results = [];
for (const [label, surface] of cases) {
await app.evaluate(({ Menu }, label) => {
const find = (menu) => {
for (const item of menu.items) {
if (item.label === label) return item;
const nested = item.submenu && find(item.submenu);
if (nested) return nested;
}
};
const item = find(Menu.getApplicationMenu());
if (!item || !item.enabled) throw new Error(`Native command unavailable: ${label}`);
item.click();
}, label);
const dialog = page.getByRole('dialog');
await expect(dialog).toHaveCount(1);
await expect(dialog).toBeVisible();
if (surface === 'Settings') {
await expect(
dialog.getByRole('button', { name: 'Close settings', exact: true })
).toBeVisible();
if (['Import', 'Export', 'Create Backup', 'Create Debug Bundle'].includes(label)) {
await expect(
dialog.getByRole('heading', { name: 'Maintenance', exact: true })
).toBeVisible();
await expect(
dialog.getByRole('button', { name: 'Debug Bundle', exact: true })
).toBeVisible();
}
} else if (surface === 'Command Center') {
await expect(dialog.getByRole('textbox', { name: 'Search commands' })).toBeVisible();
} else if (surface === 'Search') {
await expect(dialog.getByRole('textbox', { name: 'Search Veritas' })).toBeVisible();
} else {
await expect(dialog.getByRole('textbox', { name: /title/i }).first()).toBeVisible();
}
results.push({ label, surface, dialogs: await dialog.count() });
await page.keyboard.press('Escape');
await expect(dialog).toHaveCount(0);
}
const unsupported = await page.evaluate(() =>
window.veritasDesktop.dispatchCommand({ command: 'export-work-product', source: 'menu' })
);
assert.equal(unsupported.accepted, false);
assert(unsupported.message);
return results;
}

View file

@ -6,6 +6,7 @@ import { mkdir, readFile, realpath, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { expect } from '@playwright/test';
import { createNativeSession } from './session.mjs';
import { verifyNativeMenuCommands } from './menu-commands.mjs';
import {
fileDigest,
evidenceFailures,
@ -652,6 +653,8 @@ async function checkSeededRendererFailures() {
}
try {
await launch();
report.menuCommands = await verifyNativeMenuCommands(app, page);
await persist();
for (const mode of modes) {
for (const state of states) {
const entry = { id: `${mode.id}/${state}`, status: 'running' };

View file

@ -134,11 +134,15 @@ function DesktopAwareAppShell({
setShowDesktopOnboarding: (open: boolean) => void;
}) {
const { isDesktopClient, bottomPanel } = useDesktopShell();
const [commandCenterOpen, setCommandCenterOpen] = useState(false);
return (
<Box className="desktop-app-shell min-h-screen bg-background">
<SkipToContent />
<Header />
<Header
onOpenDiagnostics={() => setShowDesktopOnboarding(true)}
onOpenCommandCenter={() => setCommandCenterOpen(true)}
/>
<Suspense fallback={<div className="h-7 border-b border-border bg-muted/30" aria-hidden />}>
<SystemHealthBar />
</Suspense>
@ -171,7 +175,7 @@ function DesktopAwareAppShell({
<DesktopBottomPanel />
</div>
<Toaster />
<CommandPalette />
<CommandPalette nativeOpen={commandCenterOpen} onNativeOpenChange={setCommandCenterOpen} />
<KeyboardShortcutsDialog />
<Suspense fallback={null}>
{!isDesktopClient && <MobileShell showChat={!bottomPanel} />}
@ -212,28 +216,9 @@ function AppContent() {
useEffect(() => {
const openDiagnostics = () => setShowDesktopOnboarding(true);
const desktop = (
window as Window & {
veritasDesktop?: {
onMenuCommand(listener: (payload: { command: string }) => void): () => void;
};
}
).veritasDesktop;
const unsubscribe = desktop?.onMenuCommand?.((payload) => {
if (
payload.command === 'open-onboarding' ||
payload.command === 'show-diagnostics' ||
payload.command === 'communication-health'
) {
setShowDesktopOnboarding(true);
}
});
window.addEventListener('veritas:open-diagnostics', openDiagnostics);
return () => {
unsubscribe?.();
window.removeEventListener('veritas:open-diagnostics', openDiagnostics);
};
}, []);

View file

@ -13,6 +13,7 @@ function ShellProbe() {
return (
<div>
<button onClick={shell.resetDesktopLayout}>Reset layout</button>
<output aria-label="left rail">{String(shell.leftRailOpen)}</output>
<output aria-label="right rail">{String(shell.rightRailOpen)}</output>
<output aria-label="bottom panel">{shell.bottomPanel ?? 'closed'}</output>
@ -34,8 +35,6 @@ function ShellProbe() {
}
describe('desktop shell recovery', () => {
let menuListener: ((payload: { command: string }) => void) | undefined;
beforeEach(() => {
const storage = new Map<string, string>();
Object.defineProperty(window, 'localStorage', {
@ -59,7 +58,7 @@ describe('desktop shell recovery', () => {
configurable: true,
value: {
onMenuCommand: vi.fn((listener: (payload: { command: string }) => void) => {
menuListener = listener;
void listener;
return vi.fn();
}),
},
@ -70,7 +69,6 @@ describe('desktop shell recovery', () => {
cleanup();
delete (window as Window & { veritasDesktop?: unknown }).veritasDesktop;
delete document.documentElement.dataset.client;
menuListener = undefined;
});
it('retires legacy bottom-dock state and defaults to a bounded right dock', () => {
@ -190,7 +188,7 @@ describe('desktop shell recovery', () => {
expect(screen.getByLabelText('right rail').textContent).toBe('true');
});
it('resets the complete desktop layout from the native recovery command', async () => {
it('resets the complete desktop layout through the shell action', async () => {
const user = userEvent.setup();
render(
@ -203,7 +201,7 @@ describe('desktop shell recovery', () => {
await user.click(screen.getByRole('button', { name: 'Open right rail' }));
await user.click(screen.getByRole('button', { name: 'Open board chat' }));
act(() => menuListener?.({ command: 'reset-layout' }));
await user.click(screen.getByRole('button', { name: 'Reset layout' }));
expect(screen.getByLabelText('left rail').textContent).toBe('true');
expect(screen.getByLabelText('right rail').textContent).toBe('false');

View file

@ -103,8 +103,21 @@ function nextEnabledIndex(
return -1;
}
export function CommandPalette() {
const [open, setOpen] = useState(false);
export function CommandPalette({
nativeOpen = false,
onNativeOpenChange,
}: { nativeOpen?: boolean; onNativeOpenChange?: (open: boolean) => void } = {}) {
const [open, setOpenState] = useState(false);
const setOpen = useCallback(
(next: boolean | ((current: boolean) => boolean)) => {
setOpenState(next);
onNativeOpenChange?.(false);
},
[onNativeOpenChange]
);
useEffect(() => {
if (nativeOpen) setOpenState(true);
}, [nativeOpen]);
const [searchOpen, setSearchOpen] = useState(false);
const [searchMounted, setSearchMounted] = useState(false);
const [query, setQuery] = useState('');
@ -232,7 +245,7 @@ export function CommandPalette() {
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
}, [setOpen]);
const handoff = useOverlayHandoff(open, executeCommand);
const runCommand = (cmd: CommandItem) => {

View file

@ -12,6 +12,7 @@ import {
export type DesktopBottomPanel = 'board-chat' | 'squad-chat';
interface DesktopShellContextValue {
resetDesktopLayout: () => void;
isDesktopClient: boolean;
leftRailOpen: boolean;
rightRailOpen: boolean;
@ -40,6 +41,7 @@ export const MIN_RIGHT_PANEL_WIDTH = 320;
export const MAX_RIGHT_PANEL_WIDTH = 640;
const DEFAULT_CONTEXT: DesktopShellContextValue = {
resetDesktopLayout: () => undefined,
isDesktopClient: false,
leftRailOpen: false,
rightRailOpen: false,
@ -327,21 +329,9 @@ export function DesktopShellProvider({ children }: { children: ReactNode }) {
};
}, [bottomPanel, closeBottomPanel, desktopClient, restorePanelFocus]);
useEffect(() => {
const desktop = (
window as Window & {
veritasDesktop?: {
onMenuCommand?: (listener: (payload: { command: string }) => void) => () => void;
};
}
).veritasDesktop;
return desktop?.onMenuCommand?.((payload) => {
if (payload.command === 'reset-layout') resetDesktopLayout();
});
}, [resetDesktopLayout]);
const value = useMemo<DesktopShellContextValue>(
() => ({
resetDesktopLayout,
isDesktopClient: desktopClient,
leftRailOpen: desktopClient ? leftRailOpen : false,
rightRailOpen: desktopClient ? rightRailOpen : false,
@ -356,6 +346,7 @@ export function DesktopShellProvider({ children }: { children: ReactNode }) {
}),
[
bottomPanel,
resetDesktopLayout,
closeBottomPanel,
desktopClient,
leftRailOpen,

View file

@ -1,3 +1,4 @@
import { flushSync } from 'react-dom';
import {
ActionIcon,
Badge,
@ -133,7 +134,10 @@ function VeritasMark({ className }: { className?: string }) {
);
}
export function Header() {
export function Header({
onOpenDiagnostics,
onOpenCommandCenter,
}: { onOpenDiagnostics?: () => void; onOpenCommandCenter?: () => void } = {}) {
const [createOpen, setCreateOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [settingsTab, setSettingsTab] = useState<string | undefined>();
@ -154,6 +158,7 @@ export function Header() {
const canOpenSettings = hasPermission('settings:read') || hasPermission('admin:manage');
const {
isDesktopClient,
resetDesktopLayout,
leftRailOpen,
rightRailOpen,
bottomPanel,
@ -339,6 +344,72 @@ export function Header() {
return () => window.removeEventListener('veritas:open-search', handleOpenSearch);
}, [openSearchDialog]);
// The only native command subscriber lives in the authenticated, mounted shell.
useEffect(() => {
const desktop = (
window as Window & {
veritasDesktop?: {
onMenuCommand?: (
listener: (request: { command: string }) => { accepted: boolean; message?: string }
) => () => void;
};
}
).veritasDesktop;
return desktop?.onMenuCommand?.(({ command }) => {
let action: (() => void) | undefined;
let message = 'This command is not available in this workspace.';
switch (command) {
case 'new-task':
if (canCreateTask) action = openCreateDialog;
else message = 'Task write permission is required to create a task.';
break;
case 'open-settings':
if (canOpenSettings) action = () => openSettingsDialog();
else message = 'Settings read permission is required to open Settings.';
break;
case 'open-search':
action = () => openSearchDialog();
break;
case 'open-command-center':
action = onOpenCommandCenter;
break;
case 'reset-layout':
action = resetDesktopLayout;
break;
case 'open-onboarding':
case 'show-diagnostics':
case 'communication-health':
action = onOpenDiagnostics;
break;
case 'import-data':
case 'export-data':
case 'create-backup':
case 'create-debug-bundle':
if (canOpenSettings && hasPermission('backup:read'))
action = () => openSettingsDialog('maintenance');
else message = 'Settings and backup read permission are required to open Maintenance.';
break;
case 'test-squad-webhook':
if (canOpenSettings) action = () => openSettingsDialog('notifications');
else message = 'Open Notifications in Settings to configure and test external delivery.';
break;
}
if (!action) return { accepted: false, message };
flushSync(action);
return { accepted: true };
});
}, [
canCreateTask,
canOpenSettings,
hasPermission,
openCreateDialog,
openSettingsDialog,
openSearchDialog,
onOpenCommandCenter,
onOpenDiagnostics,
resetDesktopLayout,
]);
const handleChromeDoubleClick = useCallback(
(event: MouseEvent<HTMLElement>) => {
if (!isDesktopClient) return;