fix: restore terminal paste and square workbar chrome

Terminal paste previously relied on the Renderer browser Clipboard API, which could be denied by Electron and leave the toolbar action unusable. Text clipboard access now goes through the trusted Main/Preload bridge, and the workbar tabs plus terminal toolbar use straight corners for a continuous docked appearance.

Release note: 修复终端工具栏粘贴可能因剪贴板权限被拒绝而失败的问题,并将工作栏页签与终端工具栏调整为直角样式。
This commit is contained in:
mesalogo
2026-09-03 17:12:27 +08:00
parent 7369c13bcf
commit b1e3101857
14 changed files with 120 additions and 8 deletions
+13
View File
@@ -242,6 +242,11 @@ describe('terminal IPC boundary', () => {
snapshot.sessionId
)
expect(
electronMocks.handlers.get(ipcChannels.clipboardReadText)?.(event)
).toBe('Get-Location\r')
expect(electronMocks.readClipboardText).toHaveBeenCalledOnce()
electronMocks.handlers.get(ipcChannels.clipboardWriteText)?.(
event,
'## Copied response'
@@ -249,6 +254,12 @@ describe('terminal IPC boundary', () => {
expect(electronMocks.writeClipboardText).toHaveBeenCalledWith(
'## Copied response'
)
expect(() =>
electronMocks.handlers.get(ipcChannels.clipboardReadText)?.({
sender: { ...webContents, id: 99 },
senderFrame: webContents.mainFrame
})
).toThrow('拒绝来自未知窗口的 IPC 请求')
expect(() =>
electronMocks.handlers.get(ipcChannels.clipboardWriteText)?.(
event,
@@ -319,6 +330,7 @@ const electronMocks = vi.hoisted(() => {
openPath: vi.fn(async () => ''),
showItemInFolder: vi.fn(),
openExternal: vi.fn(async () => undefined),
readClipboardText: vi.fn(() => 'Get-Location\r'),
writeClipboardText: vi.fn()
}
})
@@ -1131,6 +1143,7 @@ vi.mock('electron', () => ({
},
BrowserWindow: class {},
clipboard: {
readText: electronMocks.readClipboardText,
writeText: electronMocks.writeClipboardText
},
dialog: {
+5
View File
@@ -3300,6 +3300,11 @@ export function registerIpcHandlers(
}
})
registerHandler(ipcChannels.clipboardReadText, (event) => {
assertTrustedSender(event, window)
return clipboardTextSchema.parse(clipboard.readText())
})
registerHandler(ipcChannels.clipboardWriteText, (event, input) => {
assertTrustedSender(event, window)
clipboard.writeText(clipboardTextSchema.parse(input))
+2
View File
@@ -255,6 +255,8 @@ const desktopApi: DesktopApi = {
}
},
clipboard: {
readText: () =>
ipcRenderer.invoke(ipcChannels.clipboardReadText) as Promise<string>,
writeText: async (text: string) => {
await ipcRenderer.invoke(ipcChannels.clipboardWriteText, text)
}
+4 -2
View File
@@ -21,7 +21,7 @@ describe('sandboxed preload', () => {
expect(source).not.toMatch(/\bfrom\s+['"]zod['"]/u)
})
it('exposes only explicit clipboard text writing', () => {
it('exposes only explicit clipboard text reading and writing', () => {
const source = readFileSync(
join(process.cwd(), 'src', 'preload', 'index.ts'),
'utf8'
@@ -30,9 +30,11 @@ describe('sandboxed preload', () => {
source.match(
/clipboard: \{(?<body>[\s\S]*?)\r?\n {2}\},\r?\n {2}agent:/u
)?.groups?.body ?? ''
expect(clipboard).toContain('readText: ()')
expect(clipboard).toContain('ipcChannels.clipboardReadText')
expect(clipboard).toContain('writeText: async (text: string)')
expect(clipboard).toContain('ipcChannels.clipboardWriteText')
expect(clipboard).not.toMatch(/\b(?:read|image|html|rtf|bookmark)\b/iu)
expect(clipboard).not.toMatch(/\b(?:image|html|rtf|bookmark)\b/iu)
})
it('exposes only narrow browser-control methods', () => {
+1
View File
@@ -216,6 +216,7 @@ const api: DesktopApi = {
onOpenSettings: vi.fn(() => () => {})
},
clipboard: {
readText: vi.fn(async () => ''),
writeText: vi.fn(async () => {})
},
feedback: {
+57
View File
@@ -1,4 +1,6 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type {
TerminalEvent,
@@ -8,6 +10,7 @@ import {
TerminalPanel,
resolveTerminalFontFamily,
type TerminalAdapter,
type TerminalClipboardAdapter,
type TerminalEmulator,
type TerminalFactory
} from './TerminalPanel'
@@ -19,6 +22,16 @@ vi.mock('@xterm/addon-search', () => ({
}))
const sessionId = '00000000-0000-4000-8000-000000000201'
const terminalStylesheet = readFileSync(
join(
process.cwd(),
'src',
'renderer',
'src',
'terminal-panel.css'
),
'utf8'
)
function snapshot(
overrides: Partial<TerminalSnapshot> = {}
@@ -156,6 +169,50 @@ afterEach(() => {
})
describe('TerminalPanel', () => {
it('uses the Main-process clipboard bridge for copy and paste', async () => {
const backend = adapterHarness()
const emulator = emulatorHarness()
const clipboardAdapter: TerminalClipboardAdapter = {
readText: vi.fn(async () => 'Get-Location\r'),
writeText: vi.fn(async () => undefined)
}
render(
<TerminalPanel
adapter={backend.adapter}
clipboardAdapter={clipboardAdapter}
sessionId={sessionId}
target={{ type: 'local' }}
terminalFactory={emulator.factory}
/>
)
await screen.findByText('PowerShell 7')
fireEvent.click(screen.getByRole('button', { name: '复制选中内容' }))
await waitFor(() =>
expect(clipboardAdapter.writeText).toHaveBeenCalledWith(
'selected output'
)
)
fireEvent.click(screen.getByRole('button', { name: '粘贴' }))
await waitFor(() =>
expect(backend.adapter.write).toHaveBeenCalledWith({
sessionId,
data: 'Get-Location\r'
})
)
expect(clipboardAdapter.readText).toHaveBeenCalledOnce()
})
it('keeps the terminal panel and toolbar controls square', () => {
expect(terminalStylesheet).toMatch(
/\.terminal-panel\s*\{[^}]*border-radius:\s*0;/u
)
expect(terminalStylesheet).toMatch(
/\.terminal-panel__toolbar button\s*\{[^}]*border-radius:\s*0;/u
)
})
it('creates only after the first valid fitted size and forwards input', async () => {
const backend = adapterHarness()
const emulator = emulatorHarness()
+8 -2
View File
@@ -33,6 +33,8 @@ export type TerminalAdapter = Omit<DesktopApi['terminal'], 'onEvent'> & {
subscribe: DesktopApi['terminal']['onEvent']
}
export type TerminalClipboardAdapter = DesktopApi['clipboard']
type Disposable = { dispose: () => void }
export type TerminalEmulator = {
@@ -67,6 +69,7 @@ export type TerminalFactory = () => TerminalFactoryResult
export type TerminalPanelProps = {
adapter: TerminalAdapter
clipboardAdapter?: TerminalClipboardAdapter
target: TerminalTarget
sessionId?: TerminalSessionId
title?: string
@@ -149,6 +152,7 @@ function isValidSize(
export function TerminalPanel({
adapter,
clipboardAdapter,
target,
sessionId,
title,
@@ -484,7 +488,7 @@ export function TerminalPanel({
return
}
try {
await navigator.clipboard.writeText(value)
await (clipboardAdapter ?? window.goodbuddy.clipboard).writeText(value)
} catch (reason) {
setError(
failureMessage(
@@ -501,7 +505,9 @@ export function TerminalPanel({
return
}
try {
const data = await navigator.clipboard.readText()
const data = await (
clipboardAdapter ?? window.goodbuddy.clipboard
).readText()
if (data) {
await adapter.write({ sessionId: current.sessionId, data })
}
+15
View File
@@ -6,6 +6,8 @@ import {
waitFor,
within
} from '@testing-library/react'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import {
useState,
type ReactNode
@@ -29,6 +31,10 @@ const terminalOne: WorkbarTabInstance = {
title: '终端 · 本机 1',
targetRef: { type: 'local' }
}
const stylesheet = readFileSync(
join(process.cwd(), 'src', 'renderer', 'src', 'styles.css'),
'utf8'
)
function ControlledShell({
initialInstances = DEFAULT_WORKBAR_INSTANCES,
@@ -66,6 +72,15 @@ function ControlledShell({
}
describe('WorkbarShell', () => {
it('keeps tabs and their controls square', () => {
expect(stylesheet).toMatch(
/\.workbar-shell__tab-item\s*\{[^}]*border-radius:\s*0;/u
)
expect(stylesheet).toMatch(
/\.workbar-shell__tab-close,\s*\n\.workbar-shell__add\s*\{[^}]*border-radius:\s*0;/u
)
})
it('provides four default single-instance tabs and keeps add outside the tablist', () => {
render(<ControlledShell />)
+2 -2
View File
@@ -3858,7 +3858,7 @@ button > svg {
align-items: center;
border: 1px solid transparent;
border-bottom: 0;
border-radius: var(--radius-control) var(--radius-control) 0 0;
border-radius: 0;
background: transparent;
transition:
border-color var(--motion-fast) ease-out,
@@ -3936,7 +3936,7 @@ button > svg {
height: 28px;
padding: 0;
place-items: center;
border-radius: var(--radius-control);
border-radius: 0;
background: transparent;
color: var(--text-muted);
cursor: pointer;
+5 -1
View File
@@ -8,7 +8,7 @@
color: var(--text-primary);
background: var(--surface-raised);
border: 1px solid var(--border-default);
border-radius: var(--radius-card);
border-radius: 0;
}
.terminal-panel--loading {
@@ -47,6 +47,10 @@
cursor: pointer;
}
.terminal-panel__toolbar button {
border-radius: 0;
}
.terminal-panel__toolbar button:hover:not(:disabled),
.terminal-panel__inline-form button:hover:not(:disabled),
.terminal-panel__error button:hover:not(:disabled) {
+1
View File
@@ -1564,6 +1564,7 @@ export type DesktopApi = {
onOpenSettings: (listener: () => void) => () => void
}
clipboard: {
readText: () => Promise<string>
writeText: (text: string) => Promise<void>
}
agent: {
+1
View File
@@ -11,6 +11,7 @@ export const ipcChannels = {
appRendererPersistenceRequest: 'app:renderer-persistence-request',
appRendererPersistenceComplete: 'app:renderer-persistence-complete',
appClearLocalData: 'app:clear-local-data',
clipboardReadText: 'clipboard:read-text',
clipboardWriteText: 'clipboard:write-text',
conversationNew: 'conversation:new',
settingsOpen: 'settings:open',