diff --git a/packages/desktop/apps/electron/src/main/__tests__/default-session-permissions.test.ts b/packages/desktop/apps/electron/src/main/__tests__/default-session-permissions.test.ts new file mode 100644 index 00000000000..17a8062d0df --- /dev/null +++ b/packages/desktop/apps/electron/src/main/__tests__/default-session-permissions.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'bun:test'; +import { canUseDefaultSessionClipboard } from '../default-session-permissions'; + +const trustedRequest = { + permission: 'clipboard-sanitized-write', + isMainFrame: true, + isWorkspaceWindow: true, + requestingUrl: 'file:///app/index.html', + devServerUrl: undefined, +}; + +describe('default session permissions', () => { + it('allows clipboard writes from the packaged app renderer', () => { + expect(canUseDefaultSessionClipboard(trustedRequest)).toBe(true); + }); + + it('allows clipboard writes from the configured Vite dev origin', () => { + expect( + canUseDefaultSessionClipboard({ + ...trustedRequest, + requestingUrl: 'http://localhost:5173/chat', + devServerUrl: 'http://localhost:5173', + }), + ).toBe(true); + }); + + it.each([ + ['clipboard reads', { permission: 'clipboard-read' }], + ['unrelated permissions', { permission: 'geolocation' }], + ['subframes', { isMainFrame: false }], + ['unregistered windows', { isWorkspaceWindow: false }], + ['external pages', { requestingUrl: 'https://example.com' }], + [ + 'a different dev port', + { + requestingUrl: 'http://localhost:5174/chat', + devServerUrl: 'http://localhost:5173', + }, + ], + ['requests without a URL', { requestingUrl: undefined }], + ])('rejects %s', (_label, overrides) => { + expect( + canUseDefaultSessionClipboard({ + ...trustedRequest, + ...overrides, + }), + ).toBe(false); + }); +}); diff --git a/packages/desktop/apps/electron/src/main/default-session-permissions.ts b/packages/desktop/apps/electron/src/main/default-session-permissions.ts new file mode 100644 index 00000000000..5fd0777b3a8 --- /dev/null +++ b/packages/desktop/apps/electron/src/main/default-session-permissions.ts @@ -0,0 +1,24 @@ +import { isTrustedRendererFrameUrl } from './voice/frame-trust'; + +export interface DefaultSessionClipboardRequest { + permission: string; + isMainFrame: boolean; + isWorkspaceWindow: boolean; + requestingUrl: string | undefined; + devServerUrl: string | undefined; +} + +export function canUseDefaultSessionClipboard({ + permission, + isMainFrame, + isWorkspaceWindow, + requestingUrl, + devServerUrl, +}: DefaultSessionClipboardRequest): boolean { + return ( + permission === 'clipboard-sanitized-write' && + isMainFrame && + isWorkspaceWindow && + isTrustedRendererFrameUrl(requestingUrl, devServerUrl) + ); +} diff --git a/packages/desktop/apps/electron/src/main/index.ts b/packages/desktop/apps/electron/src/main/index.ts index c26d913a8d6..2db73c81176 100644 --- a/packages/desktop/apps/electron/src/main/index.ts +++ b/packages/desktop/apps/electron/src/main/index.ts @@ -116,6 +116,7 @@ import { initNotificationService, initBadgeIcon, initInstanceBadge, updateBadgeC import { checkForUpdatesOnLaunch, setAutoUpdateEventSink, isUpdating } from './auto-update' import type { EventSink } from '@craft-agent/server-core/transport' import { validateGitBashPath, checkVCRedistInstalled } from '@craft-agent/server-core/services' +import { canUseDefaultSessionClipboard } from './default-session-permissions' // Initialize electron-log for renderer process support log.initialize() @@ -457,8 +458,25 @@ app.whenReady().then(async () => { isAudioOnlyMediaRequest(permission, details) && windowManager?.getWorkspaceForWindow(wc.id) != null, ) + const canWriteClipboard = ( + wc: { id: number } | null | undefined, + permission: string, + details: { isMainFrame?: boolean; requestingUrl?: string } | undefined, + ) => canUseDefaultSessionClipboard({ + permission, + isMainFrame: details?.isMainFrame === true, + isWorkspaceWindow: Boolean( + wc && windowManager?.getWorkspaceForWindow(wc.id) != null, + ), + requestingUrl: details?.requestingUrl, + devServerUrl: process.env.VITE_DEV_SERVER_URL, + }) session.defaultSession.setPermissionRequestHandler( (wc, permission, callback, details) => { + if (canWriteClipboard(wc, permission, details)) { + callback(true) + return + } if (!VOICE_PERMISSIONS.has(permission)) { mainLog.debug(`defaultSession: denied non-voice permission '${permission}'`) callback(false) @@ -472,6 +490,9 @@ app.whenReady().then(async () => { }, ) session.defaultSession.setPermissionCheckHandler((wc, permission, _origin, details) => { + if (canWriteClipboard(wc, permission, details)) { + return true + } if (!VOICE_PERMISSIONS.has(permission)) { mainLog.debug(`defaultSession: denied non-voice permission check '${permission}'`) return false diff --git a/packages/desktop/packages/ui/src/components/chat/TurnCard.tsx b/packages/desktop/packages/ui/src/components/chat/TurnCard.tsx index cf234d0e9e3..af95264698b 100644 --- a/packages/desktop/packages/ui/src/components/chat/TurnCard.tsx +++ b/packages/desktop/packages/ui/src/components/chat/TurnCard.tsx @@ -39,6 +39,7 @@ import { Tooltip, TooltipTrigger, TooltipContent } from '../tooltip' import { parseDiffFromFile, type FileContents } from '@pierre/diffs' import { getDiffStats, getUnifiedDiffStats } from '../code-viewer' import { TurnCardActionsMenu } from './TurnCardActionsMenu' +import { copyResponseText } from './copy-response' import { computeLastChildSet, groupActivitiesByParent, isActivityGroup, formatDuration, formatTokens, deriveTurnPhase, shouldShowThinkingIndicator, type ActivityGroup, type AssistantTurn } from './turn-utils' import { buildTurnTimelineItems, @@ -1815,7 +1816,7 @@ export function ResponseCard({ const [displayedText, setDisplayedText] = useState(text) const lastUpdateRef = useRef(Date.now()) // Copy to clipboard state - const [copied, setCopied] = useState(false) + const [copyStatus, setCopyStatus] = useState<'idle' | 'copied' | 'failed'>('idle') // Fullscreen state const [isFullscreen, setIsFullscreen] = useState(false) // Dark mode detection - scroll fade only shown in dark mode @@ -1910,12 +1911,14 @@ export function ResponseCard({ }) const handleCopy = useCallback(async () => { - try { - await navigator.clipboard.writeText(text) - setCopied(true) - setTimeout(() => setCopied(false), 2000) - } catch (err) { - console.error('Failed to copy:', err) + const result = await copyResponseText( + text, + (value) => navigator.clipboard.writeText(value), + ) + setCopyStatus(result.status) + setTimeout(() => setCopyStatus('idle'), 2000) + if (result.status === 'failed') { + console.error('Failed to copy response:', result.error) } }, [text]) @@ -2650,15 +2653,20 @@ export function ResponseCard({ onClick={handleCopy} className={cn( "turn-action-btn flex items-center gap-1.5 transition-colors select-none", - copied ? "text-success" : "text-muted-foreground hover:text-foreground", + copyStatus === 'copied' ? "text-success" : copyStatus === 'failed' ? "text-destructive" : "text-muted-foreground hover:text-foreground", "focus:outline-none focus-visible:underline", )} > - {copied ? ( + {copyStatus === 'copied' ? ( <> {t('common.copied')} + ) : copyStatus === 'failed' ? ( + <> + + {t('toast.copyFailed')} + ) : ( <> @@ -2712,13 +2720,15 @@ export function ResponseCard({ {!compactMode && !isPlan && showResponseActions && (
{ void handleCopy() }} > - {copied ? ( + {copyStatus === 'copied' ? ( + ) : copyStatus === 'failed' ? ( + ) : ( )} diff --git a/packages/desktop/packages/ui/src/components/chat/__tests__/copy-response.test.ts b/packages/desktop/packages/ui/src/components/chat/__tests__/copy-response.test.ts new file mode 100644 index 00000000000..12d1f0604a6 --- /dev/null +++ b/packages/desktop/packages/ui/src/components/chat/__tests__/copy-response.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it, mock } from 'bun:test'; +import { copyResponseText } from '../copy-response'; + +describe('copyResponseText', () => { + it('writes the complete response and reports success', async () => { + const writeText = mock(async () => {}); + + await expect( + copyResponseText('complete response', writeText), + ).resolves.toEqual({ status: 'copied' }); + expect(writeText).toHaveBeenCalledWith('complete response'); + }); + + it('reports clipboard failures', async () => { + const writeText = mock(async () => { + throw new Error('permission denied'); + }); + + await expect( + copyResponseText('response', writeText), + ).resolves.toMatchObject({ + status: 'failed', + error: expect.any(Error), + }); + }); +}); diff --git a/packages/desktop/packages/ui/src/components/chat/copy-response.ts b/packages/desktop/packages/ui/src/components/chat/copy-response.ts new file mode 100644 index 00000000000..cc11deb188f --- /dev/null +++ b/packages/desktop/packages/ui/src/components/chat/copy-response.ts @@ -0,0 +1,11 @@ +export async function copyResponseText( + text: string, + writeText: (value: string) => Promise, +): Promise<{ status: 'copied' } | { status: 'failed'; error: unknown }> { + try { + await writeText(text); + return { status: 'copied' }; + } catch (error) { + return { status: 'failed', error }; + } +}