dd-timer/src/infrastructure/electron/main.ts
POL Mickaël 3555242c84 fix: statistiques vides en production — normalise les dates migrées
Les données migrées depuis "Minuteur Dragodinde" stockaient les dates
comme timestamps numériques, causant un crash sur `.slice()`.
Ajout de normalizeDate(), gardes défensives, migration robuste et
fallback d'erreur visible dans l'UI. Bump v1.1.7.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 12:36:36 +02:00

450 lines
17 KiB
TypeScript
Executable File
Raw Blame History

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 ────────────────────────────<E29480><E29480>─────────────────────────────
// 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; });