feat(desktop): squelette Electron (C1) : daemon enfant + auth auto
Nouveau paquet packages/desktop (Electron, autonome, HORS des workspaces racine pour ne pas alourdir la CI du daemon). Process principal : lance le daemon en enfant (Node), lit le handshake token sur le fd 3, pose le cookie de session server-to-server (session.fetch) puis charge la SPA sur 127.0.0.1 (aucun écran de login). Fenêtre sûre (contextIsolation, sandbox, nodeIntegration off, preload minimal), instance unique, liens externes vers le navigateur, permissions limitées aux notifications, état de fenêtre persistant, arrêt propre du daemon (SIGTERM puis SIGKILL). Vérifié ici : tsc contre l'API Electron réelle + bundle esbuild (main/preload). Le run GUI et le packaging se font sur une machine avec affichage (C2+). Racine workspaces passée en liste explicite (5 paquets) pour exclure desktop du npm ci racine.
This commit is contained in:
Generated
+5
-1
@@ -8,7 +8,11 @@
|
|||||||
"name": "arboretum-monorepo",
|
"name": "arboretum-monorepo",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/*"
|
"packages/shared",
|
||||||
|
"packages/server",
|
||||||
|
"packages/web",
|
||||||
|
"packages/site",
|
||||||
|
"packages/vscode"
|
||||||
],
|
],
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.10.0",
|
"@types/node": "^22.10.0",
|
||||||
|
|||||||
+5
-1
@@ -4,7 +4,11 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/*"
|
"packages/shared",
|
||||||
|
"packages/server",
|
||||||
|
"packages/web",
|
||||||
|
"packages/site",
|
||||||
|
"packages/vscode"
|
||||||
],
|
],
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.16"
|
"node": ">=22.16"
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
release/
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// Bundle du process principal + preload en CJS (comme l'extension VS Code). `electron` reste
|
||||||
|
// externe (fourni par le runtime Electron) ; les modules node natifs sont externes par platform:node.
|
||||||
|
import esbuild from 'esbuild';
|
||||||
|
|
||||||
|
const watch = process.argv.includes('--watch');
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
entryPoints: { main: 'src/main/main.ts', preload: 'src/preload/preload.ts' },
|
||||||
|
outdir: 'dist',
|
||||||
|
bundle: true,
|
||||||
|
platform: 'node',
|
||||||
|
format: 'cjs',
|
||||||
|
target: 'node18',
|
||||||
|
sourcemap: true,
|
||||||
|
external: ['electron'],
|
||||||
|
logLevel: 'info',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (watch) {
|
||||||
|
const ctx = await esbuild.context(options);
|
||||||
|
await ctx.watch();
|
||||||
|
} else {
|
||||||
|
await esbuild.build(options);
|
||||||
|
}
|
||||||
Generated
+1328
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "@arboretum/desktop",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Arboretum desktop app: Electron shell that runs the daemon and shows its web UI",
|
||||||
|
"main": "dist/main.js",
|
||||||
|
"type": "commonjs",
|
||||||
|
"scripts": {
|
||||||
|
"typecheck": "tsc -p . --noEmit",
|
||||||
|
"build": "npm run typecheck && node esbuild.mjs",
|
||||||
|
"dev": "node esbuild.mjs && electron ."
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.10.0",
|
||||||
|
"electron": "^33.0.0",
|
||||||
|
"esbuild": "^0.21.0",
|
||||||
|
"typescript": "^5.7.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { session } from 'electron';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pose le cookie de session dans la partition du renderer, sans écran de login : login
|
||||||
|
* server-to-server depuis la session Electron cible (le Set-Cookie atterrit dans son jar), avec le
|
||||||
|
* token frais reçu au handshake. Le token ne transite jamais par le renderer.
|
||||||
|
*/
|
||||||
|
export async function seedSessionCookie(partition: string, url: string, token: string): Promise<void> {
|
||||||
|
const ses = session.fromPartition(partition);
|
||||||
|
const res = await ses.fetch(`${url}/api/v1/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ token }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`daemon login failed (HTTP ${res.status})`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { spawn, type ChildProcess } from 'node:child_process';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { resolveNodeBin, resolveServerEntry } from './paths';
|
||||||
|
import { buildChildEnv } from './env';
|
||||||
|
|
||||||
|
export interface DaemonHandle {
|
||||||
|
url: string;
|
||||||
|
token: string;
|
||||||
|
stop(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Handshake {
|
||||||
|
token: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lance le daemon en process enfant (Node bundlé) et attend son handshake sur le fd 3
|
||||||
|
* (ARBORETUM_EMIT_TOKEN_FD=3 côté serveur) : la réception du JSON {token,url} prouve que le
|
||||||
|
* serveur écoute (le handshake est écrit après app.listen). Arrêt propre : SIGTERM puis SIGKILL.
|
||||||
|
*/
|
||||||
|
export function startDaemon(opts: {
|
||||||
|
dataDir: string;
|
||||||
|
port: number;
|
||||||
|
onLog?: (line: string) => void;
|
||||||
|
onExit?: (code: number | null) => void;
|
||||||
|
}): Promise<DaemonHandle> {
|
||||||
|
const node = resolveNodeBin();
|
||||||
|
const entry = resolveServerEntry();
|
||||||
|
const dbPath = join(opts.dataDir, 'arboretum.db');
|
||||||
|
const env = buildChildEnv({ XDG_DATA_HOME: opts.dataDir, ARBORETUM_EMIT_TOKEN_FD: '3' });
|
||||||
|
|
||||||
|
const child: ChildProcess = spawn(node, [entry, '--port', String(opts.port), '--db', dbPath], {
|
||||||
|
env,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe', 'pipe'],
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stdout?.on('data', (d: Buffer) => opts.onLog?.(d.toString()));
|
||||||
|
child.stderr?.on('data', (d: Buffer) => opts.onLog?.(d.toString()));
|
||||||
|
|
||||||
|
let stopped = false;
|
||||||
|
const stop = (): Promise<void> =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
if (stopped || child.exitCode !== null) return resolve();
|
||||||
|
stopped = true;
|
||||||
|
const killTimer = setTimeout(() => child.kill('SIGKILL'), 3000);
|
||||||
|
child.once('exit', () => {
|
||||||
|
clearTimeout(killTimer);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Promise<DaemonHandle>((resolve, reject) => {
|
||||||
|
let buf = '';
|
||||||
|
let settled = false;
|
||||||
|
const fd3 = child.stdio[3] as NodeJS.ReadableStream | null;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
void stop();
|
||||||
|
reject(new Error('daemon handshake timeout'));
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
child.once('exit', (code) => {
|
||||||
|
opts.onExit?.(code);
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(new Error(`daemon exited before handshake (code ${code ?? 'null'})`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fd3?.on('data', (chunk: Buffer) => {
|
||||||
|
if (settled) return;
|
||||||
|
buf += chunk.toString();
|
||||||
|
const nl = buf.indexOf('\n');
|
||||||
|
if (nl < 0) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
try {
|
||||||
|
const hs = JSON.parse(buf.slice(0, nl)) as Handshake;
|
||||||
|
resolve({ url: hs.url, token: hs.token, stop });
|
||||||
|
} catch (err) {
|
||||||
|
void stop();
|
||||||
|
reject(err instanceof Error ? err : new Error(String(err)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { delimiter, join } from 'node:path';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
|
||||||
|
// Env de l'enfant daemon. Une app GUI démarre souvent avec un PATH minimal (sans ~/.local/bin,
|
||||||
|
// /usr/local/bin, /opt/homebrew/bin) : on l'enrichit pour que le daemon retrouve `git` et le CLI
|
||||||
|
// `claude`. Le réglage `claude_bin_path` (UI) reste le filet de secours.
|
||||||
|
export function buildChildEnv(extra: Record<string, string>): NodeJS.ProcessEnv {
|
||||||
|
const env: NodeJS.ProcessEnv = { ...process.env, ...extra };
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
const extras = ['/usr/local/bin', '/opt/homebrew/bin', join(homedir(), '.local', 'bin'), '/usr/bin', '/bin'];
|
||||||
|
const current = env.PATH ? env.PATH.split(delimiter) : [];
|
||||||
|
env.PATH = [...new Set([...extras, ...current])].join(delimiter);
|
||||||
|
}
|
||||||
|
return env;
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { app, BrowserWindow, session, shell, type BrowserWindowConstructorOptions } from 'electron';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { startDaemon, type DaemonHandle } from './daemon';
|
||||||
|
import { seedSessionCookie } from './auth';
|
||||||
|
import { loadWindowState, saveWindowState } from './window-state';
|
||||||
|
|
||||||
|
const PARTITION = 'persist:arboretum';
|
||||||
|
const PORT = 7317;
|
||||||
|
|
||||||
|
let daemon: DaemonHandle | null = null;
|
||||||
|
let win: BrowserWindow | null = null;
|
||||||
|
let shuttingDown = false;
|
||||||
|
|
||||||
|
// Instance unique : deux instances = deux daemons/ports en conflit.
|
||||||
|
if (!app.requestSingleInstanceLock()) {
|
||||||
|
app.quit();
|
||||||
|
} else {
|
||||||
|
app.on('second-instance', () => {
|
||||||
|
if (!win) return;
|
||||||
|
if (win.isMinimized()) win.restore();
|
||||||
|
win.focus();
|
||||||
|
});
|
||||||
|
app.whenReady().then(bootstrap).catch((err: unknown) => {
|
||||||
|
console.error('[arboretum-desktop] bootstrap failed:', err);
|
||||||
|
app.quit();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
createWindow(daemon.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWindow(url: string): void {
|
||||||
|
const state = loadWindowState();
|
||||||
|
const opts: BrowserWindowConstructorOptions = {
|
||||||
|
width: state.width,
|
||||||
|
height: state.height,
|
||||||
|
backgroundColor: '#09090b',
|
||||||
|
webPreferences: {
|
||||||
|
partition: PARTITION,
|
||||||
|
contextIsolation: true,
|
||||||
|
sandbox: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
preload: join(__dirname, 'preload.js'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (state.x !== undefined) opts.x = state.x;
|
||||||
|
if (state.y !== undefined) opts.y = state.y;
|
||||||
|
|
||||||
|
win = new BrowserWindow(opts);
|
||||||
|
if (state.maximized) win.maximize();
|
||||||
|
|
||||||
|
// Liens externes -> navigateur système ; toute navigation hors origine locale est déviée.
|
||||||
|
win.webContents.setWindowOpenHandler(({ url: u }) => {
|
||||||
|
void shell.openExternal(u);
|
||||||
|
return { action: 'deny' };
|
||||||
|
});
|
||||||
|
win.webContents.on('will-navigate', (e, u) => {
|
||||||
|
if (!u.startsWith(url)) {
|
||||||
|
e.preventDefault();
|
||||||
|
void shell.openExternal(u);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Permissions : n'autoriser que les notifications (cohérent avec la Permissions-Policy serveur).
|
||||||
|
session.fromPartition(PARTITION).setPermissionRequestHandler((_wc, permission, cb) => cb(permission === 'notifications'));
|
||||||
|
|
||||||
|
const persist = (): void => {
|
||||||
|
if (!win) return;
|
||||||
|
const b = win.getBounds();
|
||||||
|
saveWindowState({ width: b.width, height: b.height, x: b.x, y: b.y, maximized: win.isMaximized() });
|
||||||
|
};
|
||||||
|
win.on('close', persist);
|
||||||
|
win.on('closed', () => {
|
||||||
|
win = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
void win.loadURL(`${url}/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => void shutdown());
|
||||||
|
app.on('before-quit', (e) => {
|
||||||
|
// Ne pas quitter avant l'arrêt propre du daemon enfant.
|
||||||
|
if (daemon && !shuttingDown) {
|
||||||
|
e.preventDefault();
|
||||||
|
void shutdown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function shutdown(): Promise<void> {
|
||||||
|
if (shuttingDown) return;
|
||||||
|
shuttingDown = true;
|
||||||
|
try {
|
||||||
|
await daemon?.stop();
|
||||||
|
} finally {
|
||||||
|
daemon = null;
|
||||||
|
app.quit();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { app } from 'electron';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
// Résolution des chemins runtime : dev (depuis le repo) vs packagé (extraResources).
|
||||||
|
// __dirname pointe sur dist/ (bundle esbuild) une fois construit.
|
||||||
|
|
||||||
|
/** Entrée du serveur daemon (son dist/index.js). */
|
||||||
|
export function resolveServerEntry(): string {
|
||||||
|
if (app.isPackaged) {
|
||||||
|
// packagé : le tarball du daemon est extrait sous resources/server/package/
|
||||||
|
return join(process.resourcesPath, 'server', 'package', 'dist', 'index.js');
|
||||||
|
}
|
||||||
|
// dev : packages/desktop/dist/main.js -> packages/server/dist/index.js
|
||||||
|
return join(__dirname, '..', '..', 'server', 'dist', 'index.js');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Binaire Node qui exécute le daemon (>= 22.16 : node:sqlite + ABI node-pty maîtrisé). */
|
||||||
|
export function resolveNodeBin(): string {
|
||||||
|
if (app.isPackaged) {
|
||||||
|
return process.platform === 'win32'
|
||||||
|
? join(process.resourcesPath, 'node', 'node.exe')
|
||||||
|
: join(process.resourcesPath, 'node', 'bin', 'node');
|
||||||
|
}
|
||||||
|
// dev : node du PATH (mêmes prebuilds node-pty que le repo).
|
||||||
|
return process.platform === 'win32' ? 'node.exe' : 'node';
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { app } from 'electron';
|
||||||
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
export interface WindowState {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
x?: number;
|
||||||
|
y?: number;
|
||||||
|
maximized?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT: WindowState = { width: 1400, height: 900 };
|
||||||
|
|
||||||
|
function stateFile(): string {
|
||||||
|
return join(app.getPath('userData'), 'window-state.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadWindowState(): WindowState {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(readFileSync(stateFile(), 'utf8')) as Partial<WindowState>;
|
||||||
|
return { ...DEFAULT, ...parsed };
|
||||||
|
} catch {
|
||||||
|
return { ...DEFAULT };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveWindowState(state: WindowState): void {
|
||||||
|
try {
|
||||||
|
writeFileSync(stateFile(), JSON.stringify(state));
|
||||||
|
} catch {
|
||||||
|
/* best effort : quota / permissions */
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { contextBridge } from 'electron';
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
contextBridge.exposeInMainWorld('arboretumDesktop', {
|
||||||
|
isDesktop: true,
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "CommonJS",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"strict": true,
|
||||||
|
"exactOptionalPropertyTypes": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user