feat: infrastructure Electron + persistence + electron-updater
- LocalStorageRepository avec migrations de données - Notifications Windows + ntfy mobile - WebAudioAlarm avec 4 sons - Migration electron-updater avec latest.yml + sha512 - Support ELECTRON_USER_DATA_DIR pour isolation E2E - Icône Windows native (.ico) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c640fbd416
commit
c71ad151e0
BIN
icon.ico
Executable file
BIN
icon.ico
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 207 KiB |
0
icon.png
Normal file → Executable file
0
icon.png
Normal file → Executable file
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.3 KiB |
422
main.js
422
main.js
@ -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; });
|
|
||||||
30
preload.js
30
preload.js
@ -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)),
|
|
||||||
});
|
|
||||||
74
src/infrastructure/alarm/WebAudioAlarm.ts
Normal file
74
src/infrastructure/alarm/WebAudioAlarm.ts
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
429
src/infrastructure/electron/main.ts
Normal file
429
src/infrastructure/electron/main.ts
Normal file
@ -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 ────────────────────────────<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'), '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; });
|
||||||
34
src/infrastructure/electron/preload.ts
Normal file
34
src/infrastructure/electron/preload.ts
Normal file
@ -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)),
|
||||||
|
});
|
||||||
17
src/infrastructure/notifications/ElectronNotification.ts
Normal file
17
src/infrastructure/notifications/ElectronNotification.ts
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
106
src/infrastructure/persistence/LocalStorageRepository.ts
Normal file
106
src/infrastructure/persistence/LocalStorageRepository.ts
Normal file
@ -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<string | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<AppState | null> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/infrastructure/update/GiteaUpdateAdapter.ts
Normal file
16
src/infrastructure/update/GiteaUpdateAdapter.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import type { UpdatePort, UpdateInfo } from '@domain/ports/UpdatePort';
|
||||||
|
|
||||||
|
export class GiteaUpdateAdapter implements UpdatePort {
|
||||||
|
async checkForUpdates(): Promise<UpdateInfo | null> {
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user