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

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:
2026-08-04 21:11:46 +02:00
parent bde5358ea8
commit 114fbc8ba0
13 changed files with 197 additions and 23 deletions
+7
View File
@@ -4,6 +4,13 @@ Notable changes to the Arboretum desktop app (`packages/desktop`). The daemon an
extension keep their own changelogs in `packages/server/CHANGELOG.md` and
`packages/vscode/CHANGELOG.md`.
## 0.2.2
- **Clipboard bridge.** The renderer cannot use `navigator.clipboard` (Electron rejects it with
`NotAllowedError`), so copying a terminal selection did nothing in the app even after 0.2.1. The
preload now exposes `arboretumDesktop.clipboard`, relaying to Electron's `clipboard` module over IPC
(read and write, writes capped at 1M chars). Ships the daemon 3.5.1, whose SPA uses that bridge first.
## 0.2.1
Ships the daemon 3.5.0, which fixes the black window seen after updating the app.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@arboretum/desktop",
"version": "0.2.1",
"version": "0.2.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@arboretum/desktop",
"version": "0.2.1",
"version": "0.2.2",
"license": "MIT",
"devDependencies": {
"@types/node": "^22.10.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@arboretum/desktop",
"private": true,
"version": "0.2.1",
"version": "0.2.2",
"description": "Self-hosted multi-project AI IDE for git worktrees and Claude Code sessions",
"homepage": "https://git-arboretum.com",
"repository": {
+23
View File
@@ -0,0 +1,23 @@
import { clipboard, ipcMain } from 'electron';
import { CLIPBOARD_READ, CLIPBOARD_WRITE } from '../shared/ipc';
// Pont presse-papier pour le renderer.
//
// Pourquoi il est nécessaire : dans Electron, `navigator.clipboard.writeText` ET `readText`
// échouent en `NotAllowedError` (vérifié dans l'app packagée). La SPA ne pouvait donc PAS copier
// la sélection d'un terminal, alors que la même page y arrive dans un navigateur. Le module
// `clipboard` n'étant pas exposé aux preloads sandboxés, on passe par IPC.
//
// Portée : l'app charge exclusivement sa propre SPA locale servie par son daemon, et un terminal
// web est déjà de l'exécution de code par conception : le presse-papier n'élargit pas la surface.
// On borne quand même la taille écrite pour qu'une boucle accidentelle ne remplisse pas la mémoire.
const MAX_WRITE_CHARS = 1_000_000;
export function registerClipboardBridge(): void {
ipcMain.handle(CLIPBOARD_READ, () => clipboard.readText());
ipcMain.handle(CLIPBOARD_WRITE, (_event, text: unknown) => {
if (typeof text !== 'string' || text.length === 0) return false;
clipboard.writeText(text.slice(0, MAX_WRITE_CHARS));
return true;
});
}
+2
View File
@@ -5,6 +5,7 @@ import { seedSessionCookie } from './auth';
import { loadWindowState, saveWindowState } from './window-state';
import { createTray } from './tray';
import { installAppMenu } from './app-menu';
import { registerClipboardBridge } from './clipboard';
import { initUpdater } from './updater';
import { resolveIconPath } from './paths';
@@ -39,6 +40,7 @@ async function bootstrap(): Promise<void> {
const dataDir = join(app.getPath('userData'), 'daemon');
daemon = await startDaemon({ dataDir, port: PORT, onLog: (l) => process.stdout.write(l) });
await seedSessionCookie(PARTITION, daemon.url, daemon.token);
registerClipboardBridge();
createWindow(daemon.url);
installAppMenu({ url: daemon.url, onQuit: quitApp });
tray = createTray({ show: showWindow, quit: quitApp });
+12 -3
View File
@@ -1,7 +1,16 @@
import { contextBridge } from 'electron';
import { contextBridge, ipcRenderer } from 'electron';
import { CLIPBOARD_READ, CLIPBOARD_WRITE } from '../shared/ipc';
// Preload minimal (sandbox activé) : expose seulement un marqueur permettant à la SPA de détecter
// qu'elle tourne dans l'app de bureau. Aucun accès Node/fs exposé au renderer.
// Preload minimal (sandbox activé) : un marqueur permettant à la SPA de détecter qu'elle tourne dans
// l'app de bureau, plus un pont presse-papier. Aucun accès Node/fs exposé au renderer.
//
// Le pont existe parce que `navigator.clipboard` est refusé (NotAllowedError) dans le renderer
// Electron : sans lui, impossible de copier la sélection d'un terminal depuis l'app. Le module
// `clipboard` n'étant pas disponible dans un preload sandboxé, on relaie par IPC vers le main.
contextBridge.exposeInMainWorld('arboretumDesktop', {
isDesktop: true,
clipboard: {
readText: (): Promise<string> => ipcRenderer.invoke(CLIPBOARD_READ) as Promise<string>,
writeText: (text: string): Promise<boolean> => ipcRenderer.invoke(CLIPBOARD_WRITE, text) as Promise<boolean>,
},
});
+4
View File
@@ -0,0 +1,4 @@
// Noms des canaux IPC, partagés entre le process principal et le preload. Isolés ici pour que le
// bundle du preload n'ait pas à importer un module du main (qui tire `ipcMain` avec lui).
export const CLIPBOARD_READ = 'arboretum:clipboard-read';
export const CLIPBOARD_WRITE = 'arboretum:clipboard-write';
+11
View File
@@ -3,6 +3,17 @@
Notable changes to `@johanleroy/git-arboretum` (the Arboretum daemon). The VS Code
extension keeps its own changelog in `packages/vscode/CHANGELOG.md`.
## 3.5.1
Completes the terminal copy & paste of 3.5.0, which only worked in a browser.
- **Copy & paste inside the desktop app.** In the Electron renderer, `navigator.clipboard` rejects with
`NotAllowedError` for reads AND writes, so 3.5.0's copy silently did nothing there, exactly where the
problem had been reported. Clipboard access now goes through a cascade: the desktop app's own bridge
first (IPC to Electron's `clipboard` module, exposed by the preload), then `navigator.clipboard`, then
`document.execCommand('copy')` for writes, which also covers plain-HTTP access over a LAN where the
Clipboard API is unavailable.
## 3.5.0
Fixes a black screen after every update, gives the web terminal a working copy & paste, and lets you
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@johanleroy/git-arboretum",
"version": "3.5.0",
"version": "3.5.1",
"description": "Self-hosted multi-project AI IDE for git worktrees and the Claude Code sessions running on them",
"license": "MIT",
"type": "module",
+4 -13
View File
@@ -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;
}
}
+53 -2
View File
@@ -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);