fix(desktop): support terminal clipboard shortcuts (#837)

Tested: cd desktop && bun run test --run src/lib/desktopHost/contract.test.ts src/pages/TerminalSettings.test.tsx src/lib/desktopHost/electronHost.test.ts electron/ipc/capabilities.test.ts
Tested: cd desktop && bun run lint
Tested: bun run check:desktop
Tested: bun run check:native
Not-tested: bun run check:coverage
Confidence: high
Scope-risk: moderate
This commit is contained in:
程序员阿江(Relakkes) 2026-06-15 22:50:44 +08:00
parent 3e6fe7d6bb
commit 8d26c2c154
11 changed files with 245 additions and 2 deletions

View File

@ -21,6 +21,9 @@ describe('Electron IPC capabilities', () => {
it('validates structured payloads before they reach ipcRenderer.invoke', () => {
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.shellOpen, 'https://example.com')).toBe(true)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.shellOpen, { url: 'https://example.com' })).toBe(false)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.clipboardReadText, undefined)).toBe(true)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.clipboardWriteText, 'paste me')).toBe(true)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.clipboardWriteText, { text: 'paste me' })).toBe(false)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.traceOpenWindow, '4673a448-9e2c-475e-898d-9aa0ee2d1ab7')).toBe(true)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.traceOpenWindow, '../escape')).toBe(false)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.windowClose, undefined)).toBe(true)

View File

@ -73,6 +73,8 @@ export const ELECTRON_IPC_VALIDATORS = {
[ELECTRON_IPC_CHANNELS.appGetVersion]: noPayload,
[ELECTRON_IPC_CHANNELS.runtimeGetServerUrl]: noPayload,
[ELECTRON_IPC_CHANNELS.commandInvoke]: commandInvoke,
[ELECTRON_IPC_CHANNELS.clipboardReadText]: noPayload,
[ELECTRON_IPC_CHANNELS.clipboardWriteText]: stringPayload,
[ELECTRON_IPC_CHANNELS.shellOpen]: stringPayload,
[ELECTRON_IPC_CHANNELS.shellOpenPath]: stringPayload,
[ELECTRON_IPC_CHANNELS.traceOpenWindow]: sessionIdPayload,

View File

@ -2,6 +2,8 @@ export const ELECTRON_IPC_CHANNELS = {
appGetVersion: 'desktop:app:get-version',
runtimeGetServerUrl: 'desktop:runtime:get-server-url',
commandInvoke: 'desktop:command:invoke',
clipboardReadText: 'desktop:clipboard:read-text',
clipboardWriteText: 'desktop:clipboard:write-text',
shellOpen: 'desktop:shell:open',
shellOpenPath: 'desktop:shell:open-path',
traceOpenWindow: 'desktop:trace:open-window',

View File

@ -1,4 +1,4 @@
import { app, BrowserWindow, ipcMain, Notification, screen, session, WebContentsView } from 'electron'
import { app, BrowserWindow, clipboard, ipcMain, Notification, screen, session, WebContentsView } from 'electron'
import { autoUpdater } from 'electron-updater'
import path from 'node:path'
import { ELECTRON_EVENT_CHANNELS, ELECTRON_INTERNAL_CHANNELS, ELECTRON_IPC_CHANNELS, type ElectronIpcChannel } from './ipc/channels'
@ -257,6 +257,8 @@ function registerIpcHandlers() {
registerHandler(ELECTRON_IPC_CHANNELS.appGetVersion, () => app.getVersion())
registerHandler(ELECTRON_IPC_CHANNELS.runtimeGetServerUrl, () => getServerRuntime().getServerUrl())
registerHandler(ELECTRON_IPC_CHANNELS.commandInvoke, (_event, payload) => handleCommandInvoke(payload))
registerHandler(ELECTRON_IPC_CHANNELS.clipboardReadText, () => clipboard.readText())
registerHandler(ELECTRON_IPC_CHANNELS.clipboardWriteText, (_event, payload) => clipboard.writeText(String(payload)))
registerHandler(ELECTRON_IPC_CHANNELS.shellOpen, (_event, payload) => openExternalUrl(String(payload)))
registerHandler(ELECTRON_IPC_CHANNELS.shellOpenPath, (_event, payload) => openSystemPath(String(payload)))
registerHandler(ELECTRON_IPC_CHANNELS.traceOpenWindow, (_event, payload) => openTraceWindow(String(payload)))

View File

@ -9,6 +9,7 @@ import { buildTraceWindowUrl } from '../traceLaunch'
const browserCapabilities: DesktopHostCapabilities = {
appMode: false,
clipboard: false,
dialogs: false,
notifications: false,
previewWebview: false,
@ -54,6 +55,21 @@ export const browserHost: DesktopHost = {
unsupported('Native commands')
},
},
clipboard: {
async readText() {
if (typeof navigator !== 'undefined' && navigator.clipboard?.readText) {
return navigator.clipboard.readText()
}
unsupported('Reading clipboard text')
},
async writeText(text) {
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text)
return
}
unsupported('Writing clipboard text')
},
},
events: {
async listen() {
return noopUnlisten

View File

@ -9,6 +9,7 @@ describe('desktop host contract', () => {
expect(browserHost.isDesktop).toBe(false)
expect(browserHost.capabilities).toEqual({
appMode: false,
clipboard: false,
dialogs: false,
notifications: false,
previewWebview: false,

View File

@ -23,6 +23,20 @@ describe('electron desktop host', () => {
})
})
it('routes clipboard reads and writes through narrow IPC channels', async () => {
const invoke = vi.fn().mockResolvedValueOnce('from clipboard').mockResolvedValueOnce(undefined)
const host = createElectronHost({
invoke,
subscribe: vi.fn(),
})
await expect(host.clipboard.readText()).resolves.toBe('from clipboard')
await host.clipboard.writeText('to clipboard')
expect(invoke).toHaveBeenNthCalledWith(1, ELECTRON_IPC_CHANNELS.clipboardReadText, undefined)
expect(invoke).toHaveBeenNthCalledWith(2, ELECTRON_IPC_CHANNELS.clipboardWriteText, 'to clipboard')
})
it('rejects invalid preload payloads before invoking Electron IPC', async () => {
const invoke = vi.fn()
const host = createElectronHost({

View File

@ -63,6 +63,7 @@ export function createElectronHost(bridge: ElectronHostBridge): DesktopHost {
isDesktop: true,
capabilities: {
appMode: true,
clipboard: true,
dialogs: true,
notifications: true,
previewWebview: true,
@ -81,6 +82,10 @@ export function createElectronHost(bridge: ElectronHostBridge): DesktopHost {
commands: {
invoke: (command, args) => invoke(ELECTRON_IPC_CHANNELS.commandInvoke, { command, args }),
},
clipboard: {
readText: () => invoke(ELECTRON_IPC_CHANNELS.clipboardReadText),
writeText: text => invoke(ELECTRON_IPC_CHANNELS.clipboardWriteText, text),
},
events: {
listen: (_eventName, handler) => subscribe(ELECTRON_EVENT_CHANNELS.event, handler),
},

View File

@ -7,6 +7,7 @@ export type DesktopHostKind = 'browser' | 'electron'
export type DesktopHostCapability =
| 'appMode'
| 'clipboard'
| 'dialogs'
| 'notifications'
| 'previewWebview'
@ -153,6 +154,10 @@ export type DesktopHost = {
commands: {
invoke<T>(command: string, args?: Record<string, unknown>): Promise<T>
}
clipboard: {
readText(): Promise<string>
writeText(text: string): Promise<void>
}
events: {
listen<T>(eventName: string, handler: (payload: T) => void): Promise<DesktopHostUnlisten>
}

View File

@ -16,6 +16,10 @@ const terminalMocks = vi.hoisted(() => {
write: vi.fn(),
writeln: vi.fn(),
clear: vi.fn(),
focus: vi.fn(),
getSelection: vi.fn(),
hasSelection: vi.fn(),
paste: vi.fn(),
}
const fitInstance = {
fit: vi.fn(),
@ -86,6 +90,12 @@ describe('TerminalSettings', () => {
terminalMocks.terminalInstance.write.mockClear()
terminalMocks.terminalInstance.writeln.mockClear()
terminalMocks.terminalInstance.clear.mockClear()
terminalMocks.terminalInstance.focus.mockClear()
terminalMocks.terminalInstance.getSelection.mockReset()
terminalMocks.terminalInstance.hasSelection.mockReset()
terminalMocks.terminalInstance.paste.mockClear()
terminalMocks.terminalInstance.getSelection.mockReturnValue('')
terminalMocks.terminalInstance.hasSelection.mockReturnValue(false)
terminalMocks.fitInstance.fit.mockClear()
terminalMocks.onOutput.mockResolvedValue(vi.fn())
terminalMocks.onExit.mockResolvedValue(vi.fn())
@ -215,6 +225,96 @@ describe('TerminalSettings', () => {
expect(terminalMocks.terminalInstance.write).not.toHaveBeenCalledWith('ignored\r\n')
})
it('copies the terminal selection through the desktop clipboard on Windows shortcuts', async () => {
vi.spyOn(navigator, 'platform', 'get').mockReturnValue('Win32')
terminalMocks.available = true
terminalMocks.terminalInstance.hasSelection.mockReturnValue(true)
terminalMocks.terminalInstance.getSelection.mockReturnValue('Microsoft Windows [Version 10.0.19045]')
const writeText = vi.fn().mockResolvedValue(undefined)
window.desktopHost = {
...browserHost,
capabilities: { ...browserHost.capabilities, clipboard: true },
clipboard: {
readText: vi.fn(),
writeText,
},
}
render(<TerminalSettings />)
await waitFor(() => expect(terminalMocks.spawn).toHaveBeenCalled())
fireEvent.keyDown(screen.getByTestId('settings-terminal-frame'), { key: 'c', ctrlKey: true })
await waitFor(() => {
expect(writeText).toHaveBeenCalledWith('Microsoft Windows [Version 10.0.19045]')
})
fireEvent.keyDown(screen.getByTestId('settings-terminal-frame'), { key: 'C', ctrlKey: true, shiftKey: true })
fireEvent.keyDown(screen.getByTestId('settings-terminal-frame'), { key: 'Insert', ctrlKey: true })
await waitFor(() => {
expect(writeText).toHaveBeenCalledTimes(3)
})
expect(terminalMocks.terminalInstance.focus).toHaveBeenCalled()
})
it('pastes clipboard text into xterm on Windows paste shortcuts', async () => {
vi.spyOn(navigator, 'platform', 'get').mockReturnValue('Win32')
terminalMocks.available = true
const readText = vi.fn().mockResolvedValue('dir')
window.desktopHost = {
...browserHost,
capabilities: { ...browserHost.capabilities, clipboard: true },
clipboard: {
readText,
writeText: vi.fn(),
},
}
render(<TerminalSettings />)
await waitFor(() => expect(terminalMocks.spawn).toHaveBeenCalled())
fireEvent.keyDown(screen.getByTestId('settings-terminal-frame'), { key: 'v', ctrlKey: true })
await waitFor(() => {
expect(terminalMocks.terminalInstance.paste).toHaveBeenCalledWith('dir')
})
fireEvent.keyDown(screen.getByTestId('settings-terminal-frame'), { key: 'V', ctrlKey: true, shiftKey: true })
fireEvent.keyDown(screen.getByTestId('settings-terminal-frame'), { key: 'Insert', shiftKey: true })
await waitFor(() => {
expect(terminalMocks.terminalInstance.paste).toHaveBeenCalledTimes(3)
})
expect(terminalMocks.terminalInstance.focus).toHaveBeenCalled()
})
it('does not intercept Ctrl+C as copy when the Windows terminal has no selection', async () => {
vi.spyOn(navigator, 'platform', 'get').mockReturnValue('Win32')
terminalMocks.available = true
const writeText = vi.fn().mockResolvedValue(undefined)
window.desktopHost = {
...browserHost,
capabilities: { ...browserHost.capabilities, clipboard: true },
clipboard: {
readText: vi.fn(),
writeText,
},
}
render(<TerminalSettings />)
await waitFor(() => expect(terminalMocks.spawn).toHaveBeenCalled())
const event = new window.KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
ctrlKey: true,
key: 'c',
})
screen.getByTestId('settings-terminal-frame').dispatchEvent(event)
expect(event.defaultPrevented).toBe(false)
expect(writeText).not.toHaveBeenCalled()
})
it('can preserve and reattach a running terminal runtime across unmounts', async () => {
terminalMocks.available = true

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useId, useMemo, useRef, useState, type WheelEvent } from 'react'
import { useCallback, useEffect, useId, useMemo, useRef, useState, type KeyboardEvent, type WheelEvent } from 'react'
import { Info } from 'lucide-react'
import { useTranslation, type TranslationKey } from '../i18n'
import { terminalApi } from '../api/terminal'
@ -312,6 +312,24 @@ export function TerminalSettings({
scroller.scrollBy({ top: event.deltaY, left: event.deltaX })
}, [])
const handleTerminalKeyDownCapture = useCallback((event: KeyboardEvent<HTMLDivElement>) => {
const terminal = runtime.terminal
if (!terminal) return
if (isTerminalCopyShortcut(event, terminal)) {
event.preventDefault()
event.stopPropagation()
void copyTerminalSelection(terminal).catch(() => {})
return
}
if (isTerminalPasteShortcut(event)) {
event.preventDefault()
event.stopPropagation()
void pasteClipboardIntoTerminal(terminal).catch(() => {})
}
}, [runtime])
const savePreferences = async () => {
setPreferencesError(null)
setPreferencesSaved(false)
@ -523,6 +541,7 @@ export function TerminalSettings({
) : (
<div
data-testid="settings-terminal-frame"
onKeyDownCapture={handleTerminalKeyDownCapture}
onWheelCapture={handleTerminalWheelCapture}
className="min-h-0 flex-1 overflow-hidden rounded-[var(--radius-sm)] border border-[var(--color-terminal-border)] bg-[var(--color-terminal-bg)] shadow-[var(--shadow-dropdown)]"
>
@ -537,6 +556,80 @@ export function TerminalSettings({
)
}
type TerminalKeyboardEvent = Pick<KeyboardEvent<HTMLElement>, 'altKey' | 'ctrlKey' | 'key' | 'metaKey' | 'shiftKey'>
type ClipboardTerminal = {
focus(): void
getSelection(): string
hasSelection(): boolean
paste(data: string): void
}
function isApplePlatform() {
if (typeof navigator === 'undefined') return false
return /Mac|iPhone|iPad|iPod/i.test(navigator.platform)
}
function isWindowsPlatform() {
if (typeof navigator === 'undefined') return false
return /Win/i.test(navigator.platform || navigator.userAgent)
}
function normalizedKey(event: TerminalKeyboardEvent) {
return event.key.toLowerCase()
}
function isTerminalCopyShortcut(event: TerminalKeyboardEvent, terminal: ClipboardTerminal) {
if (event.altKey || !terminal.hasSelection()) return false
const key = normalizedKey(event)
if (isApplePlatform()) {
return event.metaKey && !event.ctrlKey && key === 'c'
}
if (key === 'insert') {
return event.ctrlKey && !event.shiftKey && !event.metaKey
}
if (isWindowsPlatform() && event.ctrlKey && !event.metaKey && !event.shiftKey && key === 'c') {
return true
}
return event.ctrlKey && !event.metaKey && event.shiftKey && key === 'c'
}
function isTerminalPasteShortcut(event: TerminalKeyboardEvent) {
if (event.altKey) return false
const key = normalizedKey(event)
if (isApplePlatform()) {
return event.metaKey && !event.ctrlKey && key === 'v'
}
if (key === 'insert') {
return event.shiftKey && !event.ctrlKey && !event.metaKey
}
if (isWindowsPlatform() && event.ctrlKey && !event.metaKey && !event.shiftKey && key === 'v') {
return true
}
return event.ctrlKey && !event.metaKey && event.shiftKey && key === 'v'
}
async function copyTerminalSelection(terminal: ClipboardTerminal) {
const text = terminal.getSelection()
if (!text) return
await getDesktopHost().clipboard.writeText(text)
terminal.focus()
}
async function pasteClipboardIntoTerminal(terminal: ClipboardTerminal) {
const text = await getDesktopHost().clipboard.readText()
if (!text) return
terminal.paste(text)
terminal.focus()
}
function TerminalHelpHint({ compact = false }: { compact?: boolean }) {
const t = useTranslation()
const tooltipId = useId()