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>
450 lines
17 KiB
TypeScript
Executable File
450 lines
17 KiB
TypeScript
Executable File
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; });
|