diff --git a/packages/desktop/electron-builder.yml b/packages/desktop/electron-builder.yml index 13eba6d..f037921 100644 --- a/packages/desktop/electron-builder.yml +++ b/packages/desktop/electron-builder.yml @@ -19,6 +19,8 @@ extraResources: to: server - from: build/node to: node + - from: resources/icon.png + to: icon.png # Icône : electron-builder dérive toutes les tailles/formats par OS depuis resources/icon.png # (buildResources), pas besoin de .ico/.icns séparés. diff --git a/packages/desktop/package-lock.json b/packages/desktop/package-lock.json index 0bd3039..33567f9 100644 --- a/packages/desktop/package-lock.json +++ b/packages/desktop/package-lock.json @@ -11,6 +11,7 @@ "@types/node": "^22.10.0", "electron": "^33.0.0", "electron-builder": "^25.1.0", + "electron-updater": "^6.3.0", "esbuild": "^0.21.0", "typescript": "^5.7.0" } @@ -2797,6 +2798,88 @@ "node": ">= 10.0.0" } }, + "node_modules/electron-updater": { + "version": "6.8.9", + "resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.8.9.tgz", + "integrity": "sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==", + "dev": true, + "license": "MIT", + "dependencies": { + "builder-util-runtime": "9.7.0", + "fs-extra": "^10.1.0", + "js-yaml": "^4.1.0", + "lazy-val": "^1.0.5", + "lodash.escaperegexp": "^4.1.2", + "lodash.isequal": "^4.5.0", + "semver": "~7.7.3", + "tiny-typed-emitter": "^2.1.0" + } + }, + "node_modules/electron-updater/node_modules/builder-util-runtime": { + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.7.0.tgz", + "integrity": "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "sax": "^1.2.4" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/electron-updater/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/electron-updater/node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/electron-updater/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/electron-updater/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/electron/node_modules/@types/node": { "version": "20.19.43", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", @@ -3937,6 +4020,13 @@ "license": "MIT", "peer": true }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.flatten": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", @@ -3945,6 +4035,14 @@ "license": "MIT", "peer": true }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.isplainobject": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", @@ -5386,6 +5484,13 @@ "node": ">= 10.0.0" } }, + "node_modules/tiny-typed-emitter": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz", + "integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==", + "dev": true, + "license": "MIT" + }, "node_modules/tmp": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 1710fb7..ab7e3e6 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -19,6 +19,7 @@ "@types/node": "^22.10.0", "electron": "^33.0.0", "electron-builder": "^25.1.0", + "electron-updater": "^6.3.0", "esbuild": "^0.21.0", "typescript": "^5.7.0" } diff --git a/packages/desktop/src/main/autostart.ts b/packages/desktop/src/main/autostart.ts new file mode 100644 index 0000000..2a86226 --- /dev/null +++ b/packages/desktop/src/main/autostart.ts @@ -0,0 +1,44 @@ +import { app } from 'electron'; +import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { homedir } from 'node:os'; + +// Lancement au login. Windows/macOS : API Electron (login items). Linux : fichier .desktop dans +// ~/.config/autostart (pas d'API Electron fiable pour l'autostart Linux). + +function desktopFile(): string { + return join(homedir(), '.config', 'autostart', 'arboretum.desktop'); +} + +function desktopEntry(): string { + const exec = process.env.APPIMAGE ?? app.getPath('exe'); + return `[Desktop Entry] +Type=Application +Name=Arboretum +Exec=${exec} +Terminal=false +X-GNOME-Autostart-enabled=true +`; +} + +export function isAutoStartEnabled(): boolean { + if (process.platform === 'linux') return existsSync(desktopFile()); + return app.getLoginItemSettings().openAtLogin; +} + +export function setAutoStart(enabled: boolean): void { + if (process.platform === 'linux') { + if (enabled) { + mkdirSync(dirname(desktopFile()), { recursive: true }); + writeFileSync(desktopFile(), desktopEntry()); + } else { + try { + unlinkSync(desktopFile()); + } catch { + /* déjà absent */ + } + } + return; + } + app.setLoginItemSettings({ openAtLogin: enabled }); +} diff --git a/packages/desktop/src/main/main.ts b/packages/desktop/src/main/main.ts index 8948bf9..559dec6 100644 --- a/packages/desktop/src/main/main.ts +++ b/packages/desktop/src/main/main.ts @@ -1,25 +1,25 @@ -import { app, BrowserWindow, session, shell, type BrowserWindowConstructorOptions } from 'electron'; +import { app, BrowserWindow, session, shell, type BrowserWindowConstructorOptions, type Tray } from 'electron'; import { join } from 'node:path'; import { startDaemon, type DaemonHandle } from './daemon'; import { seedSessionCookie } from './auth'; import { loadWindowState, saveWindowState } from './window-state'; +import { createTray } from './tray'; +import { initUpdater } from './updater'; const PARTITION = 'persist:arboretum'; const PORT = 7317; let daemon: DaemonHandle | null = null; let win: BrowserWindow | null = null; +let tray: Tray | null = null; +let isQuitting = false; 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.on('second-instance', showWindow); app.whenReady().then(bootstrap).catch((err: unknown) => { console.error('[arboretum-desktop] bootstrap failed:', err); app.quit(); @@ -31,6 +31,20 @@ async function bootstrap(): Promise { daemon = await startDaemon({ dataDir, port: PORT, onLog: (l) => process.stdout.write(l) }); await seedSessionCookie(PARTITION, daemon.url, daemon.token); createWindow(daemon.url); + tray = createTray({ show: showWindow, quit: quitApp }); + initUpdater(); +} + +function showWindow(): void { + if (!win) return; + if (win.isMinimized()) win.restore(); + win.show(); + win.focus(); +} + +function quitApp(): void { + isQuitting = true; + app.quit(); } function createWindow(url: string): void { @@ -73,7 +87,16 @@ function createWindow(url: string): void { const b = win.getBounds(); saveWindowState({ width: b.width, height: b.height, x: b.x, y: b.y, maximized: win.isMaximized() }); }; - win.on('close', persist); + + // Fermer la fenêtre = réduire dans le tray (l'app continue en arrière-plan) ; quitter réellement + // se fait via le menu du tray ou le menu applicatif. + win.on('close', (e) => { + persist(); + if (!isQuitting) { + e.preventDefault(); + win?.hide(); + } + }); win.on('closed', () => { win = null; }); @@ -81,9 +104,9 @@ function createWindow(url: string): void { 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. + isQuitting = true; if (daemon && !shuttingDown) { e.preventDefault(); void shutdown(); @@ -97,6 +120,8 @@ async function shutdown(): Promise { await daemon?.stop(); } finally { daemon = null; + tray?.destroy(); + tray = null; app.quit(); } } diff --git a/packages/desktop/src/main/tray.ts b/packages/desktop/src/main/tray.ts new file mode 100644 index 0000000..9e0988c --- /dev/null +++ b/packages/desktop/src/main/tray.ts @@ -0,0 +1,40 @@ +import { app, Menu, Tray, nativeImage } from 'electron'; +import { join } from 'node:path'; +import { isAutoStartEnabled, setAutoStart } from './autostart'; + +function iconPath(): string { + return app.isPackaged + ? join(process.resourcesPath, 'icon.png') + : join(__dirname, '..', 'resources', 'icon.png'); +} + +/** Icône de barre système : ouvrir la fenêtre, basculer le lancement au login, quitter. */ +export function createTray(opts: { show: () => void; quit: () => void }): Tray { + const image = nativeImage.createFromPath(iconPath()); + const tray = new Tray(image.isEmpty() ? nativeImage.createEmpty() : image.resize({ width: 18, height: 18 })); + tray.setToolTip('Arboretum'); + + const buildMenu = (): void => { + tray.setContextMenu( + Menu.buildFromTemplate([ + { label: 'Open Arboretum', click: opts.show }, + { type: 'separator' }, + { + label: 'Launch at login', + type: 'checkbox', + checked: isAutoStartEnabled(), + click: (item) => { + setAutoStart(item.checked); + buildMenu(); + }, + }, + { type: 'separator' }, + { label: 'Quit', click: opts.quit }, + ]), + ); + }; + + buildMenu(); + tray.on('click', opts.show); + return tray; +} diff --git a/packages/desktop/src/main/updater.ts b/packages/desktop/src/main/updater.ts new file mode 100644 index 0000000..07c211b --- /dev/null +++ b/packages/desktop/src/main/updater.ts @@ -0,0 +1,13 @@ +import { app } from 'electron'; +import { autoUpdater } from 'electron-updater'; + +// Vérifie les mises à jour (provider generic -> assets de release Gitea, cf. electron-builder.yml). +// No-op en dev (pas de app-update.yml). Auto-update effectif : Windows (nsis) + Linux (AppImage) ; +// macOS reste manuel tant que l'app n'est pas signée. +export function initUpdater(): void { + if (!app.isPackaged) return; + autoUpdater.autoDownload = true; + void autoUpdater.checkForUpdatesAndNotify().catch(() => { + /* hors ligne / pas de release publiée : silencieux */ + }); +}