From a37c15904c87b68f6afa3cd566afaf9cf1e9ccaf Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:13:06 -0700 Subject: [PATCH] fix(web): a TUI copy the browser refuses offers a Copy button (#34135) Browser clipboards need a recent user gesture, and an OSC 52 copy from a TUI arrives after a host round trip that can outlive it. The web client now shows a replaceable toast whose Copy click writes the refused text. Desktop keeps its existing failure toast. Refs #33937 --- .../osc52-clipboard-toast.test.ts | 24 ++++++++++++++ .../terminal-pane/osc52-clipboard-toast.ts | 32 +++++++++++++++++++ .../terminal-pane-pane-created.ts | 10 +++++- src/renderer/src/i18n/locales/en.json | 5 ++- src/renderer/src/i18n/locales/es.json | 5 ++- src/renderer/src/i18n/locales/fr.json | 5 ++- src/renderer/src/i18n/locales/ja.json | 5 ++- src/renderer/src/i18n/locales/ko.json | 5 ++- src/renderer/src/i18n/locales/pt.json | 5 ++- src/renderer/src/i18n/locales/zh.json | 5 ++- src/shared/osc52-clipboard.test.ts | 18 ++++++++++- src/shared/osc52-clipboard.ts | 7 ++-- 12 files changed, 114 insertions(+), 12 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/osc52-clipboard-toast.test.ts b/src/renderer/src/components/terminal-pane/osc52-clipboard-toast.test.ts index 6c4dd13b16f..d11a6c73f24 100644 --- a/src/renderer/src/components/terminal-pane/osc52-clipboard-toast.test.ts +++ b/src/renderer/src/components/terminal-pane/osc52-clipboard-toast.test.ts @@ -142,3 +142,27 @@ describe('showOsc52ClipboardFailedToast', () => { expect(toastErrorMock).toHaveBeenCalledTimes(2) }) }) + +describe('showOsc52ClipboardCopyRetryToast', () => { + beforeEach(() => { + vi.resetModules() + toastErrorMock.mockReset() + }) + + // #33937: the Copy click is the browser gesture the original OSC 52 write lacked. + it('copies the refused text from the Copy click and replaces itself on every refusal', async () => { + const { showOsc52ClipboardCopyRetryToast } = await importToastModule() + const writeClipboardText = vi.fn<(text: string) => Promise>().mockResolvedValue() + + showOsc52ClipboardCopyRetryToast('first', writeClipboardText) + showOsc52ClipboardCopyRetryToast('copy me', writeClipboardText) + + expect(toastErrorMock).toHaveBeenCalledTimes(2) + const [, first] = toastErrorMock.mock.calls[0] + const [, latest] = toastErrorMock.mock.calls[1] + expect(latest.id).toBe(first.id) + expect(writeClipboardText).not.toHaveBeenCalled() + latest.action.onClick() + expect(writeClipboardText).toHaveBeenCalledWith('copy me') + }) +}) diff --git a/src/renderer/src/components/terminal-pane/osc52-clipboard-toast.ts b/src/renderer/src/components/terminal-pane/osc52-clipboard-toast.ts index d6dcad82c85..7f209a2cbac 100644 --- a/src/renderer/src/components/terminal-pane/osc52-clipboard-toast.ts +++ b/src/renderer/src/components/terminal-pane/osc52-clipboard-toast.ts @@ -66,3 +66,35 @@ export function showOsc52ClipboardFailedToast(): void { // Latch only after successful display, as for blocked writes. hasShownOsc52ClipboardFailedToast = true } + +const OSC52_CLIPBOARD_COPY_RETRY_TOAST_ID = 'osc52-clipboard-copy-retry' + +/** Browsers refuse clipboard writes without a recent gesture; the Copy click supplies one. Not latched: each refusal replaces the last. */ +export function showOsc52ClipboardCopyRetryToast( + text: string, + writeClipboardText: (text: string) => Promise +): void { + toast.error( + translate( + 'auto.components.terminal.pane.osc52.clipboard.failed.toast.de286dcfe1', + 'Terminal copy needs a click' + ), + { + id: OSC52_CLIPBOARD_COPY_RETRY_TOAST_ID, + description: translate( + 'auto.components.terminal.pane.osc52.clipboard.failed.toast.84c938ffa2', + 'The browser blocked a copy from the terminal app. Click Copy to put the text on your clipboard.' + ), + duration: 30_000, + action: { + label: translate( + 'auto.components.terminal.pane.osc52.clipboard.failed.toast.a73165c6c5', + 'Copy' + ), + onClick: () => { + void writeClipboardText(text).catch(showOsc52ClipboardFailedToast) + } + } + } + ) +} diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-pane-created.ts b/src/renderer/src/components/terminal-pane/terminal-pane-pane-created.ts index c33bc434de0..421308d751f 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-pane-created.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-pane-created.ts @@ -1,10 +1,12 @@ import type { ManagedPane } from '@/lib/pane-manager/pane-manager' import type { PaneSpawnHints } from '@/lib/pane-manager/pane-manager-types' import { useAppStore } from '@/store' +import { isWebClientLocation } from '@/lib/web-client-location' import { makePaneKey } from '../../../../shared/stable-pane-id' import { createOsc52OscHandler } from '../../../../shared/osc52-clipboard' import { showOsc52ClipboardBlockedToast, + showOsc52ClipboardCopyRetryToast, showOsc52ClipboardFailedToast } from './osc52-clipboard-toast' import { parseOsc7 } from './parse-osc7' @@ -81,7 +83,13 @@ export function createTerminalPaneCreatedHandler( getReplaying: () => isPaneReplaying(replayingPanesRef, pane.id), writeClipboardText: (text) => window.api.ui.writeTerminalClipboardText(text), showBlockedWriteToast: showOsc52ClipboardBlockedToast, - showWriteFailedToast: showOsc52ClipboardFailedToast + // Web clipboards need a recent gesture that a host round trip can outlive (#33937). + showWriteFailedToast: isWebClientLocation() + ? (text) => + showOsc52ClipboardCopyRetryToast(text, (retry) => + window.api.ui.writeTerminalClipboardText(retry) + ) + : showOsc52ClipboardFailedToast }) ) ) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index ece835068da..9cb29149900 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -3830,7 +3830,10 @@ "failed": { "toast": { "62a0af2cb4": "Terminal clipboard copy could not be confirmed", - "fdd3e7e977": "The terminal app requested a copy, but Orca could not confirm that it reached the system clipboard." + "fdd3e7e977": "The terminal app requested a copy, but Orca could not confirm that it reached the system clipboard.", + "de286dcfe1": "Terminal copy needs a click", + "84c938ffa2": "The browser blocked a copy from the terminal app. Click Copy to put the text on your clipboard.", + "a73165c6c5": "Copy" } } } diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 743ad506bf5..5f72d7bd4a5 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -3470,7 +3470,10 @@ "failed": { "toast": { "62a0af2cb4": "No se pudo confirmar la copia del portapapeles del terminal", - "fdd3e7e977": "La app del terminal solicitó una copia, pero Orca no pudo confirmar que llegara al portapapeles del sistema." + "fdd3e7e977": "La app del terminal solicitó una copia, pero Orca no pudo confirmar que llegara al portapapeles del sistema.", + "de286dcfe1": "La copia del terminal necesita un clic", + "84c938ffa2": "El navegador bloqueó una copia de la aplicación del terminal. Haz clic en Copiar para ponerla en tu portapapeles.", + "a73165c6c5": "Copiar" } } } diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index 913606b9e1d..0d94a71bd0b 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -3635,7 +3635,10 @@ "failed": { "toast": { "62a0af2cb4": "La copie du presse-papiers du terminal n'a pas pu être confirmée", - "fdd3e7e977": "L'application de terminal a demandé une copie, mais Orca n'a pas pu confirmer qu'elle a bien atteint le presse-papiers système." + "fdd3e7e977": "L'application de terminal a demandé une copie, mais Orca n'a pas pu confirmer qu'elle a bien atteint le presse-papiers système.", + "de286dcfe1": "La copie du terminal nécessite un clic", + "84c938ffa2": "Le navigateur a bloqué une copie depuis l'application du terminal. Cliquez sur Copier pour placer le texte dans votre presse-papiers.", + "a73165c6c5": "Copier" } } } diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 4a5e07b913c..d8d6af27750 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -3455,7 +3455,10 @@ "failed": { "toast": { "62a0af2cb4": "ターミナルのクリップボードコピーを確認できませんでした", - "fdd3e7e977": "ターミナルアプリがコピーを要求しましたが、システムクリップボードに反映されたことを Orca で確認できませんでした。" + "fdd3e7e977": "ターミナルアプリがコピーを要求しましたが、システムクリップボードに反映されたことを Orca で確認できませんでした。", + "de286dcfe1": "ターミナルのコピーにはクリックが必要です", + "84c938ffa2": "ブラウザがターミナルアプリからのコピーをブロックしました。「コピー」をクリックするとクリップボードに入ります。", + "a73165c6c5": "コピー" } } } diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 6fd46c55a6c..39849c56673 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -3455,7 +3455,10 @@ "failed": { "toast": { "62a0af2cb4": "터미널 클립보드 복사를 확인할 수 없습니다", - "fdd3e7e977": "터미널 앱이 복사를 요청했지만 Orca가 시스템 클립보드에 반영되었는지 확인하지 못했습니다." + "fdd3e7e977": "터미널 앱이 복사를 요청했지만 Orca가 시스템 클립보드에 반영되었는지 확인하지 못했습니다.", + "de286dcfe1": "터미널 복사에 클릭이 필요합니다", + "84c938ffa2": "브라우저가 터미널 앱의 복사를 차단했습니다. 복사를 클릭하면 클립보드에 넣습니다.", + "a73165c6c5": "복사" } } } diff --git a/src/renderer/src/i18n/locales/pt.json b/src/renderer/src/i18n/locales/pt.json index 394be9b0e3b..a5d77ada15e 100644 --- a/src/renderer/src/i18n/locales/pt.json +++ b/src/renderer/src/i18n/locales/pt.json @@ -15658,7 +15658,10 @@ "failed": { "toast": { "62a0af2cb4": "Não foi possível confirmar a cópia para a área de transferência do terminal", - "fdd3e7e977": "O aplicativo de terminal solicitou uma cópia, mas o Orca não conseguiu confirmar que ela chegou à área de transferência do sistema." + "fdd3e7e977": "O aplicativo de terminal solicitou uma cópia, mas o Orca não conseguiu confirmar que ela chegou à área de transferência do sistema.", + "de286dcfe1": "A cópia do terminal precisa de um clique", + "84c938ffa2": "O navegador bloqueou uma cópia do aplicativo do terminal. Clique em Copiar para colocar o texto na sua área de transferência.", + "a73165c6c5": "Copiar" } } } diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 6a3c8e06892..938d745deee 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -3455,7 +3455,10 @@ "failed": { "toast": { "62a0af2cb4": "无法确认终端剪贴板复制", - "fdd3e7e977": "终端应用请求了复制,但 Orca 无法确认内容已写入系统剪贴板。" + "fdd3e7e977": "终端应用请求了复制,但 Orca 无法确认内容已写入系统剪贴板。", + "de286dcfe1": "终端复制需要点击", + "84c938ffa2": "浏览器阻止了来自终端应用的复制。点击“复制”即可将文本放入剪贴板。", + "a73165c6c5": "复制" } } } diff --git a/src/shared/osc52-clipboard.test.ts b/src/shared/osc52-clipboard.test.ts index 9ad30535646..ef5dc05fafa 100644 --- a/src/shared/osc52-clipboard.test.ts +++ b/src/shared/osc52-clipboard.test.ts @@ -184,7 +184,7 @@ describe('createOsc52OscHandler', () => { settingEnabled?: boolean | null replaying?: boolean writeClipboardText?: ReturnType Promise>> - showWriteFailedToast?: ReturnType void>> + showWriteFailedToast?: ReturnType void>> } = {} ) { const settingEnabled = 'settingEnabled' in overrides ? overrides.settingEnabled : true @@ -310,6 +310,22 @@ describe('createOsc52OscHandler', () => { expect(showWriteFailedToast).toHaveBeenCalledTimes(1) }) + // #33937: a web client retries the refused text from a click, so the failure must carry it. + it('hands the refused text to the failed write toast', async () => { + const { handler, showWriteFailedToast } = setup({ + writeClipboardText: vi + .fn<(text: string) => Promise>() + .mockRejectedValue(new Error('NotAllowedError')) + }) + + expect(handler(`c;${b64('first')}`)).toBe(true) + expect(handler(`c;${b64('copy me')}`)).toBe(true) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(showWriteFailedToast).toHaveBeenCalledTimes(1) + expect(showWriteFailedToast).toHaveBeenCalledWith('copy me') + }) + it('surfaces a synchronous clipboard bridge throw as a failed write toast', async () => { const showWriteFailedToast = vi.fn(() => { throw new Error('toast unavailable') diff --git a/src/shared/osc52-clipboard.ts b/src/shared/osc52-clipboard.ts index b78f28344d0..30122bd5427 100644 --- a/src/shared/osc52-clipboard.ts +++ b/src/shared/osc52-clipboard.ts @@ -81,7 +81,8 @@ export function createOsc52OscHandler(deps: { getReplaying: () => boolean writeClipboardText: (text: string) => Promise showBlockedWriteToast?: () => void - showWriteFailedToast?: () => void + /** Receives the refused text so a web client can retry it from a fresh click. */ + showWriteFailedToast?: (text: string) => void }): (data: string) => boolean { // Hostile chunks can issue millions of writes. Coalesce to the last per microtask, roughly one native write per xterm parse yield. let pendingText: string | null = null @@ -98,10 +99,10 @@ export function createOsc52OscHandler(deps: { // Microtasks run outside the parser guard; catch synchronous missing/broken bridges as well as rejected writes and surface failures. try { void deps.writeClipboardText(next)?.catch(() => { - reportOsc52ClipboardWriteFailure(deps.showWriteFailedToast) + reportOsc52ClipboardWriteFailure(() => deps.showWriteFailedToast?.(next)) }) } catch { - reportOsc52ClipboardWriteFailure(deps.showWriteFailedToast) + reportOsc52ClipboardWriteFailure(() => deps.showWriteFailedToast?.(next)) } } })