diff --git a/package-lock.json b/package-lock.json index b89f08d..38ade98 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7933,7 +7933,7 @@ }, "packages/server": { "name": "@johanleroy/git-arboretum", - "version": "3.5.0", + "version": "3.5.1", "license": "MIT", "dependencies": { "@fastify/cookie": "^11.0.0", diff --git a/packages/desktop/CHANGELOG.md b/packages/desktop/CHANGELOG.md index 089a72d..02b02a0 100644 --- a/packages/desktop/CHANGELOG.md +++ b/packages/desktop/CHANGELOG.md @@ -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. diff --git a/packages/desktop/package-lock.json b/packages/desktop/package-lock.json index a2b5f46..9cd7f84 100644 --- a/packages/desktop/package-lock.json +++ b/packages/desktop/package-lock.json @@ -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", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 95e84bb..9d52df7 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -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": { diff --git a/packages/desktop/src/main/clipboard.ts b/packages/desktop/src/main/clipboard.ts new file mode 100644 index 0000000..9193132 --- /dev/null +++ b/packages/desktop/src/main/clipboard.ts @@ -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; + }); +} diff --git a/packages/desktop/src/main/main.ts b/packages/desktop/src/main/main.ts index 3aa886f..32208f3 100644 --- a/packages/desktop/src/main/main.ts +++ b/packages/desktop/src/main/main.ts @@ -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 { 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 }); diff --git a/packages/desktop/src/preload/preload.ts b/packages/desktop/src/preload/preload.ts index 565cb8c..27bb43c 100644 --- a/packages/desktop/src/preload/preload.ts +++ b/packages/desktop/src/preload/preload.ts @@ -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 => ipcRenderer.invoke(CLIPBOARD_READ) as Promise, + writeText: (text: string): Promise => ipcRenderer.invoke(CLIPBOARD_WRITE, text) as Promise, + }, }); diff --git a/packages/desktop/src/shared/ipc.ts b/packages/desktop/src/shared/ipc.ts new file mode 100644 index 0000000..a225031 --- /dev/null +++ b/packages/desktop/src/shared/ipc.ts @@ -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'; diff --git a/packages/server/CHANGELOG.md b/packages/server/CHANGELOG.md index 425f568..51f78dd 100644 --- a/packages/server/CHANGELOG.md +++ b/packages/server/CHANGELOG.md @@ -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 diff --git a/packages/server/package.json b/packages/server/package.json index a8d6a0a..f02a62b 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -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", diff --git a/packages/web/src/components/TerminalView.vue b/packages/web/src/components/TerminalView.vue index 81f10d3..5b51cdf 100644 --- a/packages/web/src/components/TerminalView.vue +++ b/packages/web/src/components/TerminalView.vue @@ -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 => { 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 => { - 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; diff --git a/packages/web/src/lib/terminal-clipboard.ts b/packages/web/src/lib/terminal-clipboard.ts index adde73a..a0cbe8b 100644 --- a/packages/web/src/lib/terminal-clipboard.ts +++ b/packages/web/src/lib/terminal-clipboard.ts @@ -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://:7317 sans TLS, cas courant +// d'un accès LAN direct). + +interface DesktopClipboard { + readText?: () => Promise; + writeText?: (text: string) => Promise; +} + +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 { + 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 { + 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; + } +} diff --git a/packages/web/test/terminal-clipboard.test.ts b/packages/web/test/terminal-clipboard.test.ts index 45d1f15..4936743 100644 --- a/packages/web/test/terminal-clipboard.test.ts +++ b/packages/web/test/terminal-clipboard.test.ts @@ -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 { 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);