import { app, BrowserWindow, Tray, Menu, nativeImage, ipcMain, Notification, dialog, } from 'electron'; import path from 'path'; import https from 'https'; import fs from 'fs'; import { autoUpdater } from 'electron-updater'; // ─── NOM DE L'APPLICATION ───────────────────────────────────────────────────── app.setName('Obsidienne'); // Windows utilise l'AppUserModelId pour le nom affiché dans les notifications if (process.platform === 'win32') { app.setAppUserModelId('Obsidienne'); } // ─── MODE DEV / E2E ────────────────────────────��───────────────────────────── // En E2E (Playwright), utiliser un dossier userData dédié if (process.env.ELECTRON_USER_DATA_DIR) { app.setPath('userData', process.env.ELECTRON_USER_DATA_DIR); } else if (!app.isPackaged) { // En dev (npm start), les données sont isolées de l'app installée app.setPath('userData', path.join(app.getPath('appData'), 'Obsidienne-DEV')); } // ─── MIGRATION DONNÉES (ancien nom → Obsidienne) ──────────────────────────── // Les utilisateurs de "Minuteur Dragodinde" conservent leurs données après le renommage if (app.isPackaged) { const oldDataFile = path.join(app.getPath('appData'), 'Minuteur Dragodinde', 'dd-timer-data.json'); const newDataFile = path.join(app.getPath('userData'), 'dd-timer-data.json'); const shouldMigrate = (() => { if (!fs.existsSync(oldDataFile)) return false; if (!fs.existsSync(newDataFile)) return true; // Si le nouveau fichier est quasi-vide (<200 octets = état par défaut), on remigre try { const newSize = fs.statSync(newDataFile).size; const oldSize = fs.statSync(oldDataFile).size; return newSize < 200 && oldSize > newSize; } catch { return false; } })(); if (shouldMigrate) { try { fs.mkdirSync(path.dirname(newDataFile), { recursive: true }); fs.copyFileSync(oldDataFile, newDataFile); console.log('Migration données: Minuteur Dragodinde → Obsidienne OK'); } catch (e: unknown) { console.error('Migration données échouée:', (e as Error).message); } } } // ─── CONFIG GITEA ───────────────────────────────────────────────────────────── const GITEA_HOST = 'gitea.mickael-pol.fr'; // ton instance Gitea const GITEA_USER = 'mickael'; // ton user Gitea const GITEA_REPO = 'dd-timer'; // ton repo const CURRENT_VERSION: string = app.getVersion(); // lu depuis package.json interface UpdateInfo { version: string; } let mainWindow: BrowserWindow | null = null; let tray: Tray | null = null; let isQuitting = false; let updateInfo: UpdateInfo | null = null; let updateCheckInProgress = false; let updateDownloaded = false; // ─── ICÔNE ─────────────────────────────────────────────────────────────────── function getAppIcon(): Electron.NativeImage { const iconPath = app.isPackaged ? path.join(process.resourcesPath, 'icon.ico') : path.join(__dirname, '../icon.ico'); if (fs.existsSync(iconPath)) return nativeImage.createFromPath(iconPath); return nativeImage.createEmpty(); } // ─── FENÊTRE ───────────────────────────────────────────────────────────────── function createWindow(): void { mainWindow = new BrowserWindow({ width: 1280, height: 900, minWidth: 960, minHeight: 650, title: 'Obsidienne - Dofus 3', icon: getAppIcon(), backgroundColor: '#0b0b14', webPreferences: { nodeIntegration: false, contextIsolation: true, sandbox: true, backgroundThrottling: false, preload: path.join(__dirname, 'preload.js'), }, }); // Dev: Vite dev server; Prod: built renderer in dist-vite/ if (process.env['VITE_DEV_SERVER_URL']) { mainWindow.loadURL(process.env['VITE_DEV_SERVER_URL']); } else { mainWindow.loadFile(path.join(__dirname, '../dist-vite/index.html')); } mainWindow.on('close', (e) => { if (!isQuitting) { e.preventDefault(); const choice = dialog.showMessageBoxSync(mainWindow!, { type: 'question', buttons: ['Minimiser', 'Quitter'], defaultId: 0, cancelId: 0, title: 'Obsidienne', message: 'Que souhaites-tu faire ?', detail: 'Minimiser garde l\'app en arriere-plan.\nLes alarmes continueront de sonner.', }); if (choice === 1) { if (tray) { tray.destroy(); tray = null; } process.exit(0); } else { mainWindow!.hide(); } } }); // Zoom clavier : Ctrl+Plus, Ctrl+Minus, Ctrl+0 mainWindow.webContents.on('before-input-event', (_e, input) => { if (!input.control || input.type !== 'keyDown') return; const wc = mainWindow!.webContents; if (input.key === '+' || input.key === '=') { wc.setZoomLevel(wc.getZoomLevel() + 0.5); } else if (input.key === '-') { wc.setZoomLevel(wc.getZoomLevel() - 0.5); } else if (input.key === '0') { wc.setZoomLevel(0); } }); // Envoyer les infos de version au renderer une fois chargé mainWindow.webContents.on('did-finish-load', () => { mainWindow!.webContents.send('app-version', CURRENT_VERSION); if (updateInfo) { mainWindow!.webContents.send('update-available', updateInfo); } // Badge DEV visible dans l'interface (via IPC, pas executeJavaScript) if (!app.isPackaged) { mainWindow!.webContents.send('dev-mode'); } }); } // ─── TRAY ───────────────────────────────────────────────────────────────────── function createTray(): void { tray = new Tray(getAppIcon()); tray.setToolTip(`Obsidienne v${CURRENT_VERSION}`); rebuildTrayMenu(); tray.on('double-click', () => { mainWindow!.show(); mainWindow!.focus(); }); } function rebuildTrayMenu(): void { if (!tray) return; const items: Electron.MenuItemConstructorOptions[] = [ { label: `Obsidienne v${CURRENT_VERSION}`, enabled: false }, { type: 'separator' }, { label: 'Ouvrir', click: () => { mainWindow!.show(); mainWindow!.focus(); } }, ]; if (updateInfo) { items.push({ type: 'separator' }); if (updateDownloaded) { items.push({ label: `⬆ Installer v${updateInfo.version} maintenant`, click: () => autoUpdater.quitAndInstall(true, true), }); } else { items.push({ label: `⏳ Téléchargement v${updateInfo.version}...`, enabled: false, }); } } items.push({ type: 'separator' }); items.push({ label: 'Quitter', click: () => { isQuitting = true; app.quit(); } }); tray.setContextMenu(Menu.buildFromTemplate(items)); } // ─── NOTIFICATIONS ──────────────────────────────────────────────────────────── function fireNotification(title: string, body: string): void { if (!Notification.isSupported()) return; const n = new Notification({ title, body, timeoutType: 'never' }); n.on('click', () => { mainWindow!.show(); mainWindow!.focus(); }); n.show(); } // ─── IPC ────────────────────────────────────────────────────────────────────── ipcMain.on('trigger-alarm', (_event, { enclosName }: { enclosName: string }) => { fireNotification('Dragodindes pretes !', enclosName + ' - Toutes les cibles ont ete atteintes !'); if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('play-alarm-sound'); } }); ipcMain.on('show-notification', (_event, { title, body }: { title: string; body: string }) => { fireNotification(title, body); }); // Dialogue de confirmation natif (remplace confirm() du renderer qui casse les inputs) ipcMain.handle('show-confirm', (_event, { title, message, detail }: { title: string; message: string; detail: string }) => { const choice = dialog.showMessageBoxSync(mainWindow!, { type: 'question', buttons: ['Annuler', 'Confirmer'], defaultId: 0, cancelId: 0, title: title || 'Confirmation', message: message || '', detail: detail || '', }); return choice === 1; }); // ─── SAUVEGARDE FICHIER (persistante entre mises à jour) ───────────────── const dataFile: string = path.join(app.getPath('userData'), 'dd-timer-data.json'); ipcMain.handle('load-data', () => { try { if (fs.existsSync(dataFile)) return fs.readFileSync(dataFile, 'utf-8'); } catch (e: unknown) { console.error('load-data error:', (e as Error).message); } return null; }); ipcMain.on('save-data', (_event, json: string) => { try { fs.writeFileSync(dataFile, json, 'utf-8'); } catch (e: unknown) { console.error('save-data error:', (e as Error).message); } }); // ─── NTFY (notifications mobiles) ───────────────────────────────────────── ipcMain.on('send-ntfy', (_event, { url, title, message }: { url: string; title: string; message: string }) => { if (!url) return; try { const parsed = new URL(url.trim()); if (parsed.protocol !== 'https:') { console.warn('ntfy: HTTPS requis, requête ignorée'); return; } // API JSON ntfy : supporte nativement l'UTF-8 (accents, emojis) const topic = parsed.pathname.replace(/^\//, ''); const jsonBody = JSON.stringify({ topic, title, message, priority: 4, tags: ['dragon'], }); const options: https.RequestOptions = { hostname: parsed.hostname, port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80), path: '/', method: 'POST', headers: { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(jsonBody, 'utf-8'), }, }; const req = https.request(options, (res) => { res.on('data', () => {}); // drain res.on('end', () => {}); }); req.on('error', (e: Error) => console.warn('ntfy send error:', e.message)); req.write(jsonBody, 'utf-8'); req.end(); } catch (e: unknown) { console.warn('ntfy error:', (e as Error).message); } }); ipcMain.on('focus-window', () => { mainWindow!.show(); mainWindow!.focus(); }); // Renderer demande à installer la mise à jour ipcMain.on('install-update', () => { if (updateDownloaded) { autoUpdater.quitAndInstall(true, true); } }); // Renderer demande la version ipcMain.handle('get-version', () => CURRENT_VERSION); // ─── EXPORT / IMPORT FICHIER ──────────────────────────────────────────────── ipcMain.handle('export-file', async (_event, { data, defaultName }: { data: string; defaultName: string }) => { if (!mainWindow) return false; const result = await dialog.showSaveDialog(mainWindow, { title: 'Exporter les plans', defaultPath: path.join(app.getPath('documents'), defaultName), filters: [{ name: 'JSON', extensions: ['json'] }], }); if (result.canceled || !result.filePath) return false; try { fs.writeFileSync(result.filePath, data, 'utf-8'); return true; } catch (e: unknown) { console.error('export-file error:', (e as Error).message); return false; } }); ipcMain.handle('import-file', async () => { if (!mainWindow) return null; const result = await dialog.showOpenDialog(mainWindow, { title: 'Importer des plans', defaultPath: app.getPath('documents'), filters: [{ name: 'JSON', extensions: ['json'] }], properties: ['openFile'], }); if (result.canceled || result.filePaths.length === 0) return null; try { return fs.readFileSync(result.filePaths[0], 'utf-8'); } catch (e: unknown) { console.error('import-file error:', (e as Error).message); return null; } }); // ─── COMPARAISON DE VERSIONS ──────────────────────────────────────────────── function compareVersions(a: string, b: string): number { const pa = a.replace(/^v/, '').split('.').map(Number); const pb = b.replace(/^v/, '').split('.').map(Number); for (let i = 0; i < 3; i++) { if ((pb[i] || 0) > (pa[i] || 0)) return 1; if ((pb[i] || 0) < (pa[i] || 0)) return -1; } return 0; } // ─── ELECTRON-UPDATER : CONFIGURATION ─────────────────────────────────────── autoUpdater.autoDownload = true; autoUpdater.autoInstallOnAppQuit = false; // ─── ELECTRON-UPDATER : EVENTS ────────────────────────────────────────────── autoUpdater.on('update-available', (info) => { updateInfo = { version: info.version }; if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('update-available', updateInfo); mainWindow.webContents.send('update-downloading', { version: info.version }); } fireNotification( `Mise a jour v${info.version} disponible !`, 'Téléchargement en cours...' ); rebuildTrayMenu(); }); autoUpdater.on('download-progress', (progress) => { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('update-progress', { percent: Math.round(progress.percent) }); } }); autoUpdater.on('update-downloaded', () => { updateDownloaded = true; if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('update-ready'); } rebuildTrayMenu(); }); autoUpdater.on('error', (err) => { console.error('Update error:', err.message); updateCheckInProgress = false; if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('update-error', { message: err.message }); } }); autoUpdater.on('update-not-available', () => { console.log('electron-updater: no update found in latest.yml'); updateCheckInProgress = false; }); // ─── VÉRIFICATION VIA API GITEA ───────────────────────────────────────────── function checkForUpdates(silent = false): void { if (updateCheckInProgress) return; updateCheckInProgress = true; const options: https.RequestOptions = { hostname: GITEA_HOST, port: 443, path: `/api/v1/repos/${GITEA_USER}/${GITEA_REPO}/releases?limit=1`, method: 'GET', headers: { 'User-Agent': `Obsidienne/${CURRENT_VERSION}`, 'Accept': 'application/json', }, }; const req = https.request(options, (res) => { let data = ''; res.on('data', (chunk: string) => { data += chunk; }); res.on('end', () => { try { const releases = JSON.parse(data); if (!Array.isArray(releases) || releases.length === 0) { updateCheckInProgress = false; return; } const release = releases[0]; const latestVersion = release.tag_name; if (!latestVersion || compareVersions(CURRENT_VERSION, latestVersion) <= 0) { updateCheckInProgress = false; if (!silent && mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('update-not-available'); } return; } // Pointer electron-updater vers le tag de la release const tag = latestVersion.startsWith('v') ? latestVersion : `v${latestVersion}`; autoUpdater.setFeedURL({ provider: 'generic', url: `https://${GITEA_HOST}/${GITEA_USER}/${GITEA_REPO}/releases/download/${tag}`, }); // electron-updater prend le relais : lit latest.yml, télécharge, vérifie sha512 autoUpdater.checkForUpdates(); } catch (e: unknown) { updateCheckInProgress = false; console.error('Update check parse error:', (e as Error).message); } }); }); req.on('error', (e: Error) => { updateCheckInProgress = false; console.error('Update check error:', e.message); }); req.end(); } // ─── CYCLE DE VIE ──────────────────────────────────────────────────────────── app.whenReady().then(() => { createWindow(); createTray(); // Vérifier les mises à jour uniquement au démarrage (silencieux) setTimeout(() => checkForUpdates(true), 3000); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); }); }); app.on('window-all-closed', () => { if (process.platform === 'darwin') app.quit(); }); app.on('before-quit', () => { isQuitting = true; });