feat(desktop): tray + lancement au login + auto-update (C5)
tray.ts : icône de barre système (Ouvrir, bascule « Launch at login », Quitter). autostart.ts : login items (Windows/macOS) + fichier ~/.config/autostart/*.desktop (Linux). updater.ts : electron-updater (provider generic -> release Gitea), no-op en dev, actif Windows/Linux. main.ts : fermeture de fenêtre = réduction dans le tray, quit explicite via tray/menu, arrêt propre du daemon avant sortie. icon.png ajouté aux extraResources (tray en packagé). Vérifié : typecheck contre l'API Electron + electron-updater, bundle esbuild.
This commit is contained in:
@@ -19,6 +19,8 @@ extraResources:
|
|||||||
to: server
|
to: server
|
||||||
- from: build/node
|
- from: build/node
|
||||||
to: 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
|
# 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.
|
# (buildResources), pas besoin de .ico/.icns séparés.
|
||||||
|
|||||||
105
packages/desktop/package-lock.json
generated
105
packages/desktop/package-lock.json
generated
@@ -11,6 +11,7 @@
|
|||||||
"@types/node": "^22.10.0",
|
"@types/node": "^22.10.0",
|
||||||
"electron": "^33.0.0",
|
"electron": "^33.0.0",
|
||||||
"electron-builder": "^25.1.0",
|
"electron-builder": "^25.1.0",
|
||||||
|
"electron-updater": "^6.3.0",
|
||||||
"esbuild": "^0.21.0",
|
"esbuild": "^0.21.0",
|
||||||
"typescript": "^5.7.0"
|
"typescript": "^5.7.0"
|
||||||
}
|
}
|
||||||
@@ -2797,6 +2798,88 @@
|
|||||||
"node": ">= 10.0.0"
|
"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": {
|
"node_modules/electron/node_modules/@types/node": {
|
||||||
"version": "20.19.43",
|
"version": "20.19.43",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
|
||||||
@@ -3937,6 +4020,13 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true
|
"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": {
|
"node_modules/lodash.flatten": {
|
||||||
"version": "4.4.0",
|
"version": "4.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz",
|
||||||
@@ -3945,6 +4035,14 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true
|
"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": {
|
"node_modules/lodash.isplainobject": {
|
||||||
"version": "4.0.6",
|
"version": "4.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
||||||
@@ -5386,6 +5484,13 @@
|
|||||||
"node": ">= 10.0.0"
|
"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": {
|
"node_modules/tmp": {
|
||||||
"version": "0.2.7",
|
"version": "0.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"@types/node": "^22.10.0",
|
"@types/node": "^22.10.0",
|
||||||
"electron": "^33.0.0",
|
"electron": "^33.0.0",
|
||||||
"electron-builder": "^25.1.0",
|
"electron-builder": "^25.1.0",
|
||||||
|
"electron-updater": "^6.3.0",
|
||||||
"esbuild": "^0.21.0",
|
"esbuild": "^0.21.0",
|
||||||
"typescript": "^5.7.0"
|
"typescript": "^5.7.0"
|
||||||
}
|
}
|
||||||
|
|||||||
44
packages/desktop/src/main/autostart.ts
Normal file
44
packages/desktop/src/main/autostart.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 { join } from 'node:path';
|
||||||
import { startDaemon, type DaemonHandle } from './daemon';
|
import { startDaemon, type DaemonHandle } from './daemon';
|
||||||
import { seedSessionCookie } from './auth';
|
import { seedSessionCookie } from './auth';
|
||||||
import { loadWindowState, saveWindowState } from './window-state';
|
import { loadWindowState, saveWindowState } from './window-state';
|
||||||
|
import { createTray } from './tray';
|
||||||
|
import { initUpdater } from './updater';
|
||||||
|
|
||||||
const PARTITION = 'persist:arboretum';
|
const PARTITION = 'persist:arboretum';
|
||||||
const PORT = 7317;
|
const PORT = 7317;
|
||||||
|
|
||||||
let daemon: DaemonHandle | null = null;
|
let daemon: DaemonHandle | null = null;
|
||||||
let win: BrowserWindow | null = null;
|
let win: BrowserWindow | null = null;
|
||||||
|
let tray: Tray | null = null;
|
||||||
|
let isQuitting = false;
|
||||||
let shuttingDown = false;
|
let shuttingDown = false;
|
||||||
|
|
||||||
// Instance unique : deux instances = deux daemons/ports en conflit.
|
// Instance unique : deux instances = deux daemons/ports en conflit.
|
||||||
if (!app.requestSingleInstanceLock()) {
|
if (!app.requestSingleInstanceLock()) {
|
||||||
app.quit();
|
app.quit();
|
||||||
} else {
|
} else {
|
||||||
app.on('second-instance', () => {
|
app.on('second-instance', showWindow);
|
||||||
if (!win) return;
|
|
||||||
if (win.isMinimized()) win.restore();
|
|
||||||
win.focus();
|
|
||||||
});
|
|
||||||
app.whenReady().then(bootstrap).catch((err: unknown) => {
|
app.whenReady().then(bootstrap).catch((err: unknown) => {
|
||||||
console.error('[arboretum-desktop] bootstrap failed:', err);
|
console.error('[arboretum-desktop] bootstrap failed:', err);
|
||||||
app.quit();
|
app.quit();
|
||||||
@@ -31,6 +31,20 @@ async function bootstrap(): Promise<void> {
|
|||||||
daemon = await startDaemon({ dataDir, port: PORT, onLog: (l) => process.stdout.write(l) });
|
daemon = await startDaemon({ dataDir, port: PORT, onLog: (l) => process.stdout.write(l) });
|
||||||
await seedSessionCookie(PARTITION, daemon.url, daemon.token);
|
await seedSessionCookie(PARTITION, daemon.url, daemon.token);
|
||||||
createWindow(daemon.url);
|
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 {
|
function createWindow(url: string): void {
|
||||||
@@ -73,7 +87,16 @@ function createWindow(url: string): void {
|
|||||||
const b = win.getBounds();
|
const b = win.getBounds();
|
||||||
saveWindowState({ width: b.width, height: b.height, x: b.x, y: b.y, maximized: win.isMaximized() });
|
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.on('closed', () => {
|
||||||
win = null;
|
win = null;
|
||||||
});
|
});
|
||||||
@@ -81,9 +104,9 @@ function createWindow(url: string): void {
|
|||||||
void win.loadURL(`${url}/`);
|
void win.loadURL(`${url}/`);
|
||||||
}
|
}
|
||||||
|
|
||||||
app.on('window-all-closed', () => void shutdown());
|
|
||||||
app.on('before-quit', (e) => {
|
app.on('before-quit', (e) => {
|
||||||
// Ne pas quitter avant l'arrêt propre du daemon enfant.
|
// Ne pas quitter avant l'arrêt propre du daemon enfant.
|
||||||
|
isQuitting = true;
|
||||||
if (daemon && !shuttingDown) {
|
if (daemon && !shuttingDown) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
void shutdown();
|
void shutdown();
|
||||||
@@ -97,6 +120,8 @@ async function shutdown(): Promise<void> {
|
|||||||
await daemon?.stop();
|
await daemon?.stop();
|
||||||
} finally {
|
} finally {
|
||||||
daemon = null;
|
daemon = null;
|
||||||
|
tray?.destroy();
|
||||||
|
tray = null;
|
||||||
app.quit();
|
app.quit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
40
packages/desktop/src/main/tray.ts
Normal file
40
packages/desktop/src/main/tray.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
13
packages/desktop/src/main/updater.ts
Normal file
13
packages/desktop/src/main/updater.ts
Normal file
@@ -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 */
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user