diff --git a/build.bat b/build.bat old mode 100644 new mode 100755 diff --git a/build.ps1 b/build.ps1 old mode 100644 new mode 100755 diff --git a/icon.ico b/icon.ico new file mode 100755 index 0000000..8fbacdf Binary files /dev/null and b/icon.ico differ diff --git a/icon.png b/icon.png old mode 100644 new mode 100755 diff --git a/main.js b/main.js deleted file mode 100644 index 1d5e417..0000000 --- a/main.js +++ /dev/null @@ -1,422 +0,0 @@ -const { app, BrowserWindow, Tray, Menu, nativeImage, ipcMain, Notification, dialog, shell } = require('electron'); -const path = require('path'); -const https = require('https'); -const fs = require('fs'); -const os = require('os'); - -// ─── NOM DE L'APPLICATION ───────────────────────────────────────────────────── -app.setName('Minuteur Dragodinde'); -// Windows utilise l'AppUserModelId pour le nom affiché dans les notifications -if (process.platform === 'win32') { - app.setAppUserModelId('Minuteur Dragodinde'); -} - -// ─── MODE DEV ──────────────────────────────────────────────────────────────── -// En dev (npm start), les données sont isolées de l'app installée -if (!app.isPackaged) { - app.setPath('userData', path.join(app.getPath('appData'), 'MinuteurDragodinde-DEV')); -} - -// ─── 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 = app.getVersion(); // lu depuis package.json - -let mainWindow; -let tray; -let isQuitting = false; -let updateInfo = null; // { version, downloadUrl } si mise à jour dispo -let downloading = false; - -// ─── ICÔNE ─────────────────────────────────────────────────────────────────── -function getTrayIcon() { - const fs = require('fs'); - const iconPath = path.join(__dirname, 'icon.png'); - if (fs.existsSync(iconPath)) return nativeImage.createFromPath(iconPath); - return nativeImage.createEmpty(); -} - -// ─── FENÊTRE ───────────────────────────────────────────────────────────────── -function createWindow() { - mainWindow = new BrowserWindow({ - width: 1280, height: 900, minWidth: 960, minHeight: 650, - title: 'Minuteur Dragodinde - Dofus 3', - backgroundColor: '#0b0b14', - webPreferences: { - nodeIntegration: false, - contextIsolation: true, - backgroundThrottling: false, - preload: path.join(__dirname, 'preload.js'), - }, - }); - mainWindow.loadFile(path.join(__dirname, 'src', '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: 'Minuteur Dragodinde', - 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(); - } - } - }); - - // 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 - if (!app.isPackaged) { - mainWindow.webContents.executeJavaScript(` - const p = document.querySelector('header p'); - if (p && !document.getElementById('dev-badge')) { - const b = document.createElement('span'); - b.id = 'dev-badge'; - b.textContent = 'DEV'; - b.style.cssText = 'background:#ff9820;color:#000;padding:2px 10px;border-radius:8px;font-size:0.72rem;font-weight:800;margin-left:8px;vertical-align:middle'; - p.appendChild(b); - } - `).catch(()=>{}); - } - }); -} - -// ─── TRAY ───────────────────────────────────────────────────────────────────── -function createTray() { - tray = new Tray(getTrayIcon()); - tray.setToolTip(`Minuteur Dragodinde v${CURRENT_VERSION}`); - rebuildTrayMenu(); - tray.on('double-click', () => { mainWindow.show(); mainWindow.focus(); }); -} - -function rebuildTrayMenu() { - if (!tray) return; - const items = [ - { label: `Minuteur Dragodinde v${CURRENT_VERSION}`, enabled: false }, - { type: 'separator' }, - { label: 'Ouvrir', click: () => { mainWindow.show(); mainWindow.focus(); } }, - ]; - if (updateInfo) { - items.push({ type: 'separator' }); - items.push({ - label: `⬆ Mise a jour v${updateInfo.version} disponible !`, - click: () => startDownload(), - }); - } - items.push({ type: 'separator' }); - items.push({ label: 'Quitter', click: () => { isQuitting = true; app.quit(); } }); - tray.setContextMenu(Menu.buildFromTemplate(items)); -} - -// ─── NOTIFICATIONS ──────────────────────────────────────────────────────────── -function fireNotification(title, body) { - 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 }) => { - 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 }) => { - fireNotification(title, body); -}); - -// Dialogue de confirmation natif (remplace confirm() du renderer qui casse les inputs) -ipcMain.handle('show-confirm', (event, { title, message, detail }) => { - 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 = 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) { console.error('load-data error:', e.message); } - return null; -}); - -ipcMain.on('save-data', (event, json) => { - try { - fs.writeFileSync(dataFile, json, 'utf-8'); - } catch (e) { console.error('save-data error:', e.message); } -}); - -// ─── NTFY (notifications mobiles) ───────────────────────────────────────── -ipcMain.on('send-ntfy', (event, { url, title, message }) => { - if (!url) return; - try { - const parsed = new URL(url.trim()); - const mod = parsed.protocol === 'https:' ? https : require('http'); - const postData = message; - const options = { - hostname: parsed.hostname, - port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80), - path: parsed.pathname + parsed.search, - method: 'POST', - headers: { - 'Content-Type': 'text/plain; charset=utf-8', - 'Content-Length': Buffer.byteLength(postData, 'utf-8'), - 'Title': title, - 'Priority': 'high', - 'Tags': 'hatching_chick', - }, - }; - const req = mod.request(options, (res) => { - res.on('data', () => {}); // drain - res.on('end', () => {}); - }); - req.on('error', (e) => console.warn('ntfy send error:', e.message)); - req.write(postData, 'utf-8'); - req.end(); - } catch (e) { - console.warn('ntfy error:', e.message); - } -}); - -ipcMain.on('focus-window', () => { mainWindow.show(); mainWindow.focus(); }); - -// Renderer demande à installer la mise à jour -ipcMain.on('install-update', () => startDownload()); - -// Renderer demande la version -ipcMain.handle('get-version', () => CURRENT_VERSION); - -// ─── VÉRIFICATION DE MISE À JOUR ───────────────────────────────────────────── -function compareVersions(a, b) { - // Retourne > 0 si b > a (b est plus récent) - 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; -} - -function checkForUpdates(silent = false) { - // API Gitea : GET /api/v1/repos/{user}/{repo}/releases?limit=1 - // Retourne un tableau — le premier élément est la release la plus récente - const options = { - hostname: GITEA_HOST, - port: 443, - path: `/api/v1/repos/${GITEA_USER}/${GITEA_REPO}/releases?limit=1`, - method: 'GET', - headers: { - 'User-Agent': `MinuteurDragodinde/${CURRENT_VERSION}`, - 'Accept': 'application/json', - }, - }; - - const req = https.request(options, (res) => { - let data = ''; - res.on('data', chunk => { data += chunk; }); - res.on('end', () => { - try { - const releases = JSON.parse(data); - - // Gitea renvoie un tableau, on prend le premier (le plus récent) - if (!Array.isArray(releases) || releases.length === 0) return; - const release = releases[0]; - - const latestVersion = release.tag_name; - if (!latestVersion) return; - - if (compareVersions(CURRENT_VERSION, latestVersion) > 0) { - // Chercher l'asset installeur (.exe contenant "Setup") - const asset = release.assets && release.assets.find(a => - a.name.includes('Setup') && a.name.endsWith('.exe') - ); - if (!asset) return; - - updateInfo = { - version: latestVersion, - downloadUrl: asset.browser_download_url, - assetName: asset.name, - releaseNotes: release.body || '', - }; - - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('update-available', updateInfo); - } - - fireNotification( - `Mise a jour v${latestVersion} disponible !`, - 'Cliquez pour mettre a jour Minuteur Dragodinde.' - ); - - rebuildTrayMenu(); - - } else if (!silent) { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('update-not-available'); - } - } - } catch (e) { - console.error('Update check parse error:', e.message); - } - }); - }); - - req.on('error', (e) => console.error('Update check error:', e.message)); - req.end(); -} - -// ─── TÉLÉCHARGEMENT ET REMPLACEMENT ────────────────────────────────────────── -function startDownload() { - if (!updateInfo || downloading) return; - downloading = true; - - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('update-downloading', { version: updateInfo.version }); - } - - const tmpDir = os.tmpdir(); - const tmpExe = path.join(tmpDir, updateInfo.assetName); - const file = fs.createWriteStream(tmpExe); - const currentExe = process.execPath; - - // Suivre les redirections manuellement (GitHub assets redirigent) - function download(url, redirectCount = 0) { - if (redirectCount > 5) { - sendUpdateError('Trop de redirections.'); - return; - } - const urlObj = new URL(url); - const mod = urlObj.protocol === 'https:' ? https : require('http'); - const opts = { - hostname: urlObj.hostname, - path: urlObj.pathname + urlObj.search, - method: 'GET', - headers: { 'User-Agent': `MinuteurDragodinde/${CURRENT_VERSION}` }, - }; - - mod.request(opts, (res) => { - if (res.statusCode === 302 || res.statusCode === 301) { - download(res.headers.location, redirectCount + 1); - return; - } - if (res.statusCode !== 200) { - sendUpdateError(`Erreur HTTP ${res.statusCode}`); - return; - } - - const total = parseInt(res.headers['content-length'] || '0', 10); - let received = 0; - - res.on('data', chunk => { - received += chunk.length; - file.write(chunk); - if (total > 0 && mainWindow && !mainWindow.isDestroyed()) { - const pct = Math.round((received / total) * 100); - mainWindow.webContents.send('update-progress', { percent: pct }); - } - }); - - res.on('end', () => { - file.end(); - file.on('finish', () => { - // Lancer le script de remplacement - launchUpdater(tmpExe, currentExe); - }); - }); - - res.on('error', e => sendUpdateError(e.message)); - }).on('error', e => sendUpdateError(e.message)).end(); - } - - download(updateInfo.downloadUrl); -} - -function sendUpdateError(msg) { - downloading = false; - console.error('Update error:', msg); - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('update-error', { message: msg }); - } -} - -function launchUpdater(newExe, currentExe) { - if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.webContents.send('update-ready'); - } - - const { spawn } = require('child_process'); - - // Script batch qui survit à la fermeture de l'app : - // 1. Attend que l'app se ferme - // 2. Lance l'installeur en silencieux - // 3. Relance l'app - // 4. Se supprime lui-même - const batPath = path.join(os.tmpdir(), 'dd-timer-update.cmd'); - const batContent = [ - '@echo off', - 'timeout /t 3 /nobreak >nul', - `start /wait "" "${newExe}" /S`, - 'timeout /t 5 /nobreak >nul', - `start "" "${currentExe}"`, - 'del "%~f0"', - ].join('\r\n'); - - fs.writeFileSync(batPath, batContent, 'utf-8'); - - setTimeout(() => { - spawn('cmd.exe', ['/c', batPath], { - detached: true, - stdio: 'ignore', - windowsHide: true, - }).unref(); - - isQuitting = true; - app.quit(); - }, 1500); -} - -// ─── 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; }); diff --git a/preload.js b/preload.js deleted file mode 100644 index 73636ad..0000000 --- a/preload.js +++ /dev/null @@ -1,30 +0,0 @@ -const { contextBridge, ipcRenderer } = require('electron'); - -contextBridge.exposeInMainWorld('electronAPI', { - isElectron: true, - - // Sauvegarde persistante (fichier JSON dans userData) - saveData: (json) => ipcRenderer.send('save-data', json), - loadData: () => ipcRenderer.invoke('load-data'), - - // Alarme - triggerAlarm: (enclosName) => ipcRenderer.send('trigger-alarm', { enclosName }), - showNotification: (title, body) => ipcRenderer.send('show-notification', { title, body }), - sendNtfy: (url, title, message) => ipcRenderer.send('send-ntfy', { url, title, message }), - focusWindow: () => ipcRenderer.send('focus-window'), - showConfirm: (title, message, detail) => ipcRenderer.invoke('show-confirm', { title, message, detail }), - onPlayAlarmSound: (cb) => ipcRenderer.on('play-alarm-sound', () => cb()), - - // Version - getVersion: () => ipcRenderer.invoke('get-version'), - onAppVersion: (cb) => ipcRenderer.on('app-version', (e, v) => cb(v)), - - // Mises à jour - installUpdate: () => ipcRenderer.send('install-update'), - onUpdateAvailable: (cb) => ipcRenderer.on('update-available', (e, info) => cb(info)), - onUpdateNotAvailable: (cb) => ipcRenderer.on('update-not-available', () => cb()), - onUpdateDownloading: (cb) => ipcRenderer.on('update-downloading', (e, info) => cb(info)), - onUpdateProgress: (cb) => ipcRenderer.on('update-progress', (e, info) => cb(info)), - onUpdateReady: (cb) => ipcRenderer.on('update-ready', () => cb()), - onUpdateError: (cb) => ipcRenderer.on('update-error', (e, info) => cb(info)), -}); diff --git a/src/infrastructure/alarm/WebAudioAlarm.ts b/src/infrastructure/alarm/WebAudioAlarm.ts new file mode 100644 index 0000000..ba40380 --- /dev/null +++ b/src/infrastructure/alarm/WebAudioAlarm.ts @@ -0,0 +1,74 @@ +import type { AlarmPort } from '@domain/ports/AlarmPort'; + +export class WebAudioAlarm implements AlarmPort { + private audioCtx: AudioContext | null = null; + + private ensureContext(): AudioContext { + if (!this.audioCtx) { + this.audioCtx = new AudioContext(); + } + if (this.audioCtx.state === 'suspended') { + this.audioCtx.resume(); + } + return this.audioCtx; + } + + play(soundName: string): void { + const ctx = this.ensureContext(); + const doPlay = () => { + if (soundName === 'arpege') { + [440, 554, 659, 880].forEach((f, i) => setTimeout(() => { + const o = ctx.createOscillator(), g = ctx.createGain(); + o.connect(g); g.connect(ctx.destination); + o.frequency.value = f; o.type = 'sine'; + g.gain.setValueAtTime(0.35, ctx.currentTime); + g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.55); + o.start(); o.stop(ctx.currentTime + 0.55); + }, i * 140)); + } else if (soundName === 'pulse') { + [0, 200, 400, 600, 800].forEach(ms => setTimeout(() => { + const o = ctx.createOscillator(), g = ctx.createGain(); + o.connect(g); g.connect(ctx.destination); + o.frequency.value = 880; o.type = 'square'; + g.gain.setValueAtTime(0.2, ctx.currentTime); + g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.15); + o.start(); o.stop(ctx.currentTime + 0.15); + }, ms)); + } else if (soundName === 'fanfare') { + ([[523, 0], [659, 150], [784, 300], [1047, 500], [784, 700], [1047, 900]] as [number, number][]).forEach(([f, ms]) => setTimeout(() => { + const o = ctx.createOscillator(), g = ctx.createGain(); + o.connect(g); g.connect(ctx.destination); + o.frequency.value = f; o.type = 'triangle'; + g.gain.setValueAtTime(0.3, ctx.currentTime); + g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.18); + o.start(); o.stop(ctx.currentTime + 0.18); + }, ms)); + } else if (soundName === 'cloche') { + const t = ctx.currentTime; + ([[440, 1], [880, 0.6], [1320, 0.4], [1760, 0.25]] as [number, number][]).forEach(([f, v], i) => { + const o = ctx.createOscillator(), g = ctx.createGain(); + o.connect(g); g.connect(ctx.destination); + o.frequency.value = f; o.type = 'sine'; + g.gain.setValueAtTime(v * 0.3, t); + g.gain.exponentialRampToValueAtTime(0.001, t + 2.5); + o.start(t + i * 0.01); o.stop(t + 2.5); + }); + } else { + // Fallback : arpege + this.play('arpege'); + } + }; + if (ctx.state === 'suspended') { + ctx.resume().then(doPlay).catch(() => {}); + } else { + doPlay(); + } + } + + stop(): void { + if (this.audioCtx) { + this.audioCtx.close(); + this.audioCtx = null; + } + } +} diff --git a/src/infrastructure/electron/main.ts b/src/infrastructure/electron/main.ts new file mode 100644 index 0000000..c085942 --- /dev/null +++ b/src/infrastructure/electron/main.ts @@ -0,0 +1,429 @@ +import { + app, + BrowserWindow, + Tray, + Menu, + nativeImage, + ipcMain, + Notification, + dialog, +} from 'electron'; +import path from 'path'; +import https from 'https'; +import http from 'http'; +import fs from 'fs'; +import { autoUpdater } from 'electron-updater'; + +// ─── NOM DE L'APPLICATION ───────────────────────────────────────────────────── +app.setName('Minuteur Dragodinde'); +// Windows utilise l'AppUserModelId pour le nom affiché dans les notifications +if (process.platform === 'win32') { + app.setAppUserModelId('Minuteur Dragodinde'); +} + +// ─── 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'), 'MinuteurDragodinde-DEV')); +} + +// ─── 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: 'Minuteur Dragodinde - Dofus 3', + icon: getAppIcon(), + backgroundColor: '#0b0b14', + webPreferences: { + nodeIntegration: false, + contextIsolation: 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: 'Minuteur Dragodinde', + 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 + if (!app.isPackaged) { + mainWindow!.webContents.executeJavaScript(` + const p = document.querySelector('header p'); + if (p && !document.getElementById('dev-badge')) { + const b = document.createElement('span'); + b.id = 'dev-badge'; + b.textContent = 'DEV'; + b.style.cssText = 'background:#ff9820;color:#000;padding:2px 10px;border-radius:8px;font-size:0.72rem;font-weight:800;margin-left:8px;vertical-align:middle'; + p.appendChild(b); + } + `).catch(() => {}); + } + }); +} + +// ─── TRAY ───────────────────────────────────────────────────────────────────── +function createTray(): void { + tray = new Tray(getAppIcon()); + tray.setToolTip(`Minuteur Dragodinde v${CURRENT_VERSION}`); + rebuildTrayMenu(); + tray.on('double-click', () => { mainWindow!.show(); mainWindow!.focus(); }); +} + +function rebuildTrayMenu(): void { + if (!tray) return; + const items: Electron.MenuItemConstructorOptions[] = [ + { label: `Minuteur Dragodinde 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()); + const mod = parsed.protocol === 'https:' ? https : http; + // 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 = mod.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': `MinuteurDragodinde/${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; }); diff --git a/src/infrastructure/electron/preload.ts b/src/infrastructure/electron/preload.ts new file mode 100644 index 0000000..12afa7a --- /dev/null +++ b/src/infrastructure/electron/preload.ts @@ -0,0 +1,34 @@ +import { contextBridge, ipcRenderer } from 'electron'; + +contextBridge.exposeInMainWorld('electronAPI', { + isElectron: true, + + // Sauvegarde persistante (fichier JSON dans userData) + saveData: (json: string) => ipcRenderer.send('save-data', json), + loadData: () => ipcRenderer.invoke('load-data'), + + // Alarme + triggerAlarm: (enclosName: string) => ipcRenderer.send('trigger-alarm', { enclosName }), + showNotification: (title: string, body: string) => ipcRenderer.send('show-notification', { title, body }), + sendNtfy: (url: string, title: string, message: string) => ipcRenderer.send('send-ntfy', { url, title, message }), + focusWindow: () => ipcRenderer.send('focus-window'), + showConfirm: (title: string, message: string, detail: string) => ipcRenderer.invoke('show-confirm', { title, message, detail }), + onPlayAlarmSound: (cb: () => void) => ipcRenderer.on('play-alarm-sound', () => cb()), + + // Export / Import fichier + exportFile: (data: string, defaultName: string) => ipcRenderer.invoke('export-file', { data, defaultName }), + importFile: () => ipcRenderer.invoke('import-file'), + + // Version + getVersion: () => ipcRenderer.invoke('get-version'), + onAppVersion: (cb: (v: string) => void) => ipcRenderer.on('app-version', (_e, v) => cb(v)), + + // Mises à jour + installUpdate: () => ipcRenderer.send('install-update'), + onUpdateAvailable: (cb: (info: any) => void) => ipcRenderer.on('update-available', (_e, info) => cb(info)), + onUpdateNotAvailable: (cb: () => void) => ipcRenderer.on('update-not-available', () => cb()), + onUpdateDownloading: (cb: (info: any) => void) => ipcRenderer.on('update-downloading', (_e, info) => cb(info)), + onUpdateProgress: (cb: (info: any) => void) => ipcRenderer.on('update-progress', (_e, info) => cb(info)), + onUpdateReady: (cb: () => void) => ipcRenderer.on('update-ready', () => cb()), + onUpdateError: (cb: (info: any) => void) => ipcRenderer.on('update-error', (_e, info) => cb(info)), +}); diff --git a/src/infrastructure/notifications/ElectronNotification.ts b/src/infrastructure/notifications/ElectronNotification.ts new file mode 100644 index 0000000..a943b6d --- /dev/null +++ b/src/infrastructure/notifications/ElectronNotification.ts @@ -0,0 +1,17 @@ +import type { NotificationPort } from '@domain/ports/NotificationPort'; + +export class ElectronNotification implements NotificationPort { + showNotification(title: string, body: string): void { + const api = (window as any).electronAPI; + if (api?.showNotification) { + api.showNotification(title, body); + } + } + + sendMobileNotification(url: string, title: string, message: string): void { + const api = (window as any).electronAPI; + if (api?.sendNtfy) { + api.sendNtfy(url, title, message); + } + } +} diff --git a/src/infrastructure/persistence/LocalStorageRepository.ts b/src/infrastructure/persistence/LocalStorageRepository.ts new file mode 100644 index 0000000..5f639ab --- /dev/null +++ b/src/infrastructure/persistence/LocalStorageRepository.ts @@ -0,0 +1,106 @@ +import type { AppState, StateRepository } from '@domain/ports/StateRepository'; +import { DEFAULT_TARGETS } from '@domain/value-objects/GaugeType'; + +interface ElectronAPI { + saveData: (json: string) => void; + loadData: () => Promise; +} + +function getElectronAPI(): ElectronAPI | null { + if (typeof window !== 'undefined' && (window as any).electronAPI) { + return (window as any).electronAPI; + } + return null; +} + +export class LocalStorageRepository implements StateRepository { + private readonly storageKey = 'dd3v3'; + + async load(): Promise { + try { + let raw: string | null = null; + const api = getElectronAPI(); + if (api) raw = await api.loadData(); + if (!raw && typeof localStorage !== 'undefined') { + raw = localStorage.getItem(this.storageKey) || localStorage.getItem('dd3v2'); + } + if (!raw) return null; + return this.deserialize(raw); + } catch { + return null; + } + } + + save(state: AppState): void { + try { + const d = JSON.parse(JSON.stringify(state)); + // Reset runtime state before persisting + d.enclos.forEach((e: any) => { + e.timer.running = false; + e.alerted = {}; + }); + const json = JSON.stringify(d); + const api = getElectronAPI(); + if (api) api.saveData(json); + if (typeof localStorage !== 'undefined') { + localStorage.setItem(this.storageKey, json); + } + } catch { + // Silently fail + } + } + + private deserialize(raw: string): AppState { + const d = JSON.parse(raw); + const state: AppState = { + enclos: d.enclos || [], + activeId: d.activeId ?? null, + nextEnclosId: d.nextEnclosId || 1, + alarmSound: d.alarmSound || 'arpege', + notifsEnabled: d.notifsEnabled !== undefined ? d.notifsEnabled : true, + ntfyTopic: d.ntfyTopic || '', + archivedStats: d.archivedStats || [], + inventaire: d.inventaire || {}, + workflows: d.workflows || [], + accouplements: d.accouplements || [], + }; + + // Migration: old ntfyUrl format → ntfyTopic + if (!state.ntfyTopic && d.ntfyUrl) { + const m = d.ntfyUrl.match(/\/([^\/]+)$/); + if (m) state.ntfyTopic = m[1]; + } + + // Migrate enclos data + state.enclos.forEach((enc: any) => { + enc.timer = enc.timer || { running: false, startTime: null, pausedAt: null, pausedMs: 0, snapGauges: {}, snapStats: {} }; + enc.timer.running = false; + enc.alerted = {}; + if (enc.gaugeLevels.mangeoire === undefined) enc.gaugeLevels.mangeoire = 0; + enc.dragodindes.forEach((dd: any) => { + if (dd.stats.xp === undefined) dd.stats.xp = 1; + // Migration: old serenite target → gauge-based targets + if (dd.targets.serenite !== undefined && dd.targets.baffeur === undefined) { + const old = { ...dd.targets }; + dd.targets = { + baffeur: old.serenite ?? -5000, + caresseur: Math.max(0, old.serenite ?? 40), + foudroyeur: old.endurance ?? 20000, + abreuvoir: old.maturite ?? 20000, + dragofesse: old.amour ?? 20000, + mangeoire: 100, + }; + } + Object.keys(DEFAULT_TARGETS).forEach(k => { + if (dd.targets[k] === undefined) dd.targets[k] = (DEFAULT_TARGETS as any)[k]; + }); + }); + }); + + if (!state.activeId && state.enclos.length) { + state.activeId = state.enclos[0].id; + } + + return state; + } +} diff --git a/src/infrastructure/update/GiteaUpdateAdapter.ts b/src/infrastructure/update/GiteaUpdateAdapter.ts new file mode 100644 index 0000000..2742fb0 --- /dev/null +++ b/src/infrastructure/update/GiteaUpdateAdapter.ts @@ -0,0 +1,16 @@ +import type { UpdatePort, UpdateInfo } from '@domain/ports/UpdatePort'; + +export class GiteaUpdateAdapter implements UpdatePort { + async checkForUpdates(): Promise { + // Delegated to main process via IPC in the actual Electron app + // This is a placeholder — the real check happens in main.ts + return null; + } + + downloadAndInstall(info: UpdateInfo): void { + const api = (window as any).electronAPI; + if (api?.installUpdate) { + api.installUpdate(); + } + } +}