fix(desktop, web): pont presse-papier Electron, sans quoi copier ne faisait rien dans l'app
CI / No em/en dashes (push) Successful in 3s
CI / Build & test (Node 24) (push) Successful in 10m48s
Release / Publish to Gitea npm registry (push) Successful in 10m52s
CI / Build & test (Node 22) (push) Successful in 11m6s
Desktop Release / Build Linux (AppImage + deb) (push) Successful in 13m38s
Desktop Release / Publish floating desktop-latest release (push) Successful in 11s
CI / Pack & boot smoke (Node 22) (push) Successful in 9m56s
Desktop Release / Build Windows (NSIS + portable) (push) Canceled after 0s
CI / No em/en dashes (push) Successful in 3s
CI / Build & test (Node 24) (push) Successful in 10m48s
Release / Publish to Gitea npm registry (push) Successful in 10m52s
CI / Build & test (Node 22) (push) Successful in 11m6s
Desktop Release / Build Linux (AppImage + deb) (push) Successful in 13m38s
Desktop Release / Publish floating desktop-latest release (push) Successful in 11s
CI / Pack & boot smoke (Node 22) (push) Successful in 9m56s
Desktop Release / Build Windows (NSIS + portable) (push) Canceled after 0s
git-arboretum 3.5.1, desktop 0.2.2.
Le copier/coller livre en 3.5.0 ne fonctionnait que dans un navigateur : verifie dans l'app packagee,
`navigator.clipboard` y rejette en NotAllowedError A LA LECTURE COMME A L'ECRITURE. La copie echouait
donc silencieusement dans le contexte meme ou le probleme avait ete signale.
- feat(desktop): `arboretumDesktop.clipboard` expose par le preload, relaye en IPC vers le module
`clipboard` d'Electron (lecture + ecriture, ecriture bornee a 1 M de caracteres). Le module
`clipboard` n'etant pas accessible a un preload sandboxe, le passage par le main est obligatoire.
Constantes de canaux isolees dans src/shared/ipc.ts pour que le bundle du preload ne tire pas
`ipcMain` avec lui.
- fix(web): acces au presse-papier en cascade, du plus fiable au plus degrade : pont de l'app de
bureau, puis `navigator.clipboard`, puis `document.execCommand('copy')` pour l'ecriture. Ce dernier
recours couvre aussi l'acces LAN en clair (http://<ip>:7317), ou l'API Clipboard est indisponible.
- test: quatre cas de plus sur l'ordre de la cascade et le retour a null quand tout est refuse
(501 tests). Pont IPC verifie dans un vrai Electron : writeText/readText repondent OK, alors que
`navigator.clipboard` y refuse. Le chemin complet frappe -> copie -> collage -> SIGINT reste
couvert par verify-clipboard.mjs (Chromium CDP, ALL GREEN).
This commit is contained in:
@@ -27,7 +27,7 @@ import '@xterm/xterm/css/xterm.css';
|
||||
import { wsClient, type Attachment } from '../lib/ws-client';
|
||||
import { terminalTheme, TERMINAL_FONT_FAMILY } from '../lib/terminal-theme';
|
||||
import { resolvedTheme } from '../lib/theme';
|
||||
import { clipboardIntent, isMacPlatform } from '../lib/terminal-clipboard';
|
||||
import { clipboardIntent, isMacPlatform, readClipboard, writeClipboard } from '../lib/terminal-clipboard';
|
||||
|
||||
const props = withDefaults(defineProps<{ sessionId: string; mode?: 'interactive' | 'observer' }>(), {
|
||||
mode: 'interactive',
|
||||
@@ -155,20 +155,11 @@ onMounted(async () => {
|
||||
// est invisible au DOM, on y injecte donc nous-mêmes le texte sélectionné.
|
||||
const copySelection = async (): Promise<void> => {
|
||||
const text = activeTerm.getSelection();
|
||||
if (!text) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch {
|
||||
/* presse-papier refusé (contexte non sécurisé) : le menu Édition natif reste disponible */
|
||||
}
|
||||
if (text) await writeClipboard(text);
|
||||
};
|
||||
const pasteClipboard = async (): Promise<void> => {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
if (text) attachment?.sendStdin(text);
|
||||
} catch {
|
||||
/* lecture refusée : xterm reçoit de toute façon les collages natifs via son textarea */
|
||||
}
|
||||
const text = await readClipboard();
|
||||
if (text) attachment?.sendStdin(text);
|
||||
};
|
||||
activeTerm.attachCustomKeyEventHandler((e) => {
|
||||
if (e.type !== 'keydown') return true;
|
||||
|
||||
@@ -54,3 +54,79 @@ export function isMacPlatform(nav: { platform?: string; userAgent?: string } = n
|
||||
const p = `${nav.platform ?? ''} ${nav.userAgent ?? ''}`;
|
||||
return /Mac|iPhone|iPad|iPod/i.test(p);
|
||||
}
|
||||
|
||||
// Accès au presse-papier, par ordre de fiabilité décroissante.
|
||||
//
|
||||
// 1. Le pont de l'app de bureau (`window.arboretumDesktop.clipboard`, IPC vers le module `clipboard`
|
||||
// d'Electron). INDISPENSABLE : dans le renderer Electron, `navigator.clipboard` rejette en
|
||||
// `NotAllowedError`, en lecture comme en écriture. Sans ce pont, copier depuis un terminal était
|
||||
// impossible dans l'app alors que la même page y arrive dans un navigateur.
|
||||
// 2. `navigator.clipboard`, le chemin normal des navigateurs (contexte sécurisé requis).
|
||||
// 3. Pour l'écriture seulement, `document.execCommand('copy')` sur un textarea hors écran : déprécié
|
||||
// mais il reste le seul recours en contexte non sécurisé (http://<ip>:7317 sans TLS, cas courant
|
||||
// d'un accès LAN direct).
|
||||
|
||||
interface DesktopClipboard {
|
||||
readText?: () => Promise<string>;
|
||||
writeText?: (text: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
function desktopClipboard(): DesktopClipboard | null {
|
||||
const bridge = (globalThis as { arboretumDesktop?: { clipboard?: DesktopClipboard } }).arboretumDesktop;
|
||||
return bridge?.clipboard ?? null;
|
||||
}
|
||||
|
||||
/** Copie via textarea hors écran : dernier recours quand aucune API presse-papier n'est permise. */
|
||||
function copyViaExecCommand(text: string): boolean {
|
||||
if (typeof document === 'undefined') return false;
|
||||
const area = document.createElement('textarea');
|
||||
area.value = text;
|
||||
// hors écran mais focusable : `display:none` ou `hidden` empêcheraient la sélection
|
||||
area.setAttribute('aria-hidden', 'true');
|
||||
area.style.cssText = 'position:fixed;top:-1000px;left:-1000px;opacity:0;';
|
||||
document.body.appendChild(area);
|
||||
try {
|
||||
area.select();
|
||||
return document.execCommand('copy');
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
area.remove();
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeClipboard(text: string): Promise<boolean> {
|
||||
if (!text) return false;
|
||||
const bridge = desktopClipboard();
|
||||
if (bridge?.writeText) {
|
||||
try {
|
||||
if (await bridge.writeText(text)) return true;
|
||||
} catch {
|
||||
/* pont indisponible : on tente les voies navigateur */
|
||||
}
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
return copyViaExecCommand(text);
|
||||
}
|
||||
}
|
||||
|
||||
export async function readClipboard(): Promise<string | null> {
|
||||
const bridge = desktopClipboard();
|
||||
if (bridge?.readText) {
|
||||
try {
|
||||
return await bridge.readText();
|
||||
} catch {
|
||||
/* pont indisponible : on tente la voie navigateur */
|
||||
}
|
||||
}
|
||||
try {
|
||||
return await navigator.clipboard.readText();
|
||||
} catch {
|
||||
// Lecture refusée (Electron sans pont, ou permission navigateur) : le collage natif du système
|
||||
// (Ctrl+V / Cmd+V) reste opérationnel, xterm le reçoit via son textarea.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { clipboardIntent, isMacPlatform, type ClipboardKey } from '../src/lib/terminal-clipboard';
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import {
|
||||
clipboardIntent,
|
||||
isMacPlatform,
|
||||
readClipboard,
|
||||
writeClipboard,
|
||||
type ClipboardKey,
|
||||
} from '../src/lib/terminal-clipboard';
|
||||
|
||||
function key(k: string, mods: Partial<ClipboardKey> = {}): ClipboardKey {
|
||||
return { key: k, ctrlKey: false, shiftKey: false, metaKey: false, altKey: false, ...mods };
|
||||
@@ -57,6 +63,51 @@ describe('clipboardIntent (macOS)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Le pont de l'app de bureau doit primer : dans Electron, navigator.clipboard rejette en
|
||||
// NotAllowedError (lecture ET écriture), ce qui rendait la copie impossible depuis un terminal.
|
||||
describe('writeClipboard / readClipboard', () => {
|
||||
// `globalThis.navigator` est un getter en Node : seul stubGlobal sait le remplacer proprement.
|
||||
const stub = (name: string, value: unknown): void => vi.stubGlobal(name, value);
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('écrit par le pont desktop quand il est présent', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(true);
|
||||
stub('arboretumDesktop', { clipboard: { writeText } });
|
||||
stub('navigator', { clipboard: { writeText: vi.fn().mockRejectedValue(new Error('NotAllowedError')) } });
|
||||
expect(await writeClipboard('bonjour')).toBe(true);
|
||||
expect(writeText).toHaveBeenCalledWith('bonjour');
|
||||
});
|
||||
|
||||
it('retombe sur navigator.clipboard si le pont échoue', async () => {
|
||||
stub('arboretumDesktop', { clipboard: { writeText: vi.fn().mockRejectedValue(new Error('ipc down')) } });
|
||||
const navWrite = vi.fn().mockResolvedValue(undefined);
|
||||
stub('navigator', { clipboard: { writeText: navWrite } });
|
||||
expect(await writeClipboard('secours')).toBe(true);
|
||||
expect(navWrite).toHaveBeenCalledWith('secours');
|
||||
});
|
||||
|
||||
it('ne tente rien pour un texte vide', async () => {
|
||||
const writeText = vi.fn();
|
||||
stub('arboretumDesktop', { clipboard: { writeText } });
|
||||
expect(await writeClipboard('')).toBe(false);
|
||||
expect(writeText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lit par le pont desktop, sinon par navigator, sinon null', async () => {
|
||||
stub('arboretumDesktop', { clipboard: { readText: vi.fn().mockResolvedValue('du pont') } });
|
||||
expect(await readClipboard()).toBe('du pont');
|
||||
|
||||
stub('arboretumDesktop', undefined);
|
||||
stub('navigator', { clipboard: { readText: vi.fn().mockResolvedValue('du navigateur') } });
|
||||
expect(await readClipboard()).toBe('du navigateur');
|
||||
|
||||
stub('navigator', { clipboard: { readText: vi.fn().mockRejectedValue(new Error('NotAllowedError')) } });
|
||||
expect(await readClipboard()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isMacPlatform', () => {
|
||||
it('reconnaît macOS et iPadOS, pas Linux ni Windows', () => {
|
||||
expect(isMacPlatform({ platform: 'MacIntel', userAgent: 'Mozilla/5.0 (Macintosh)' })).toBe(true);
|
||||
|
||||
Reference in New Issue
Block a user