Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
114fbc8ba0 |
Generated
+1
-1
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
Generated
+2
-2
@@ -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,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": {
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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>,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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