Sandbox Electron, HTTPS ntfy, validation import structurelle, suppression executeJavaScript, nettoyage memory leaks, try/catch sur tous les appels electronAPI. 27 nouveaux tests de sécurité et validation. README mis à jour avec changelog et couverture. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
404 lines
16 KiB
TypeScript
404 lines
16 KiB
TypeScript
import type { CommandBus } from '@application/handlers/CommandBus';
|
|
import type { QueryBus } from '@application/handlers/QueryBus';
|
|
import type { SettingsResult } from '@application/queries/GetSettings';
|
|
import { esc } from '@presentation/helpers/format';
|
|
import { Toast } from './Toast';
|
|
import { ConfirmModal } from './ConfirmModal';
|
|
|
|
const NTFY_BASE = 'https://ntfy.mickael-pol.fr';
|
|
const NTFY_REDIRECT = 'https://ntfy-redirect.mickael-pol.fr';
|
|
|
|
const SOUND_OPTIONS: { value: string; label: string; icon: string }[] = [
|
|
{ value: 'arpege', label: 'Arpège', icon: 'music_note' },
|
|
{ value: 'pulse', label: 'Pulsation', icon: 'pulse_alert' },
|
|
{ value: 'fanfare', label: 'Fanfare', icon: 'celebration' },
|
|
{ value: 'cloche', label: 'Cloche', icon: 'notifications_active' },
|
|
];
|
|
|
|
export class ParametresView {
|
|
private el: HTMLElement | null = null;
|
|
private modal: HTMLElement | null = null;
|
|
|
|
constructor(
|
|
private commandBus: CommandBus,
|
|
private queryBus: QueryBus,
|
|
private playSound?: (name: string) => void,
|
|
) {}
|
|
|
|
render(container: HTMLElement): void {
|
|
this.el = document.createElement('div');
|
|
this.el.className = 'param-view';
|
|
container.appendChild(this.el);
|
|
this.updateDOM();
|
|
}
|
|
|
|
update(): void {}
|
|
|
|
destroy(): void {
|
|
this.modal?.remove();
|
|
this.modal = null;
|
|
this.el?.remove();
|
|
this.el = null;
|
|
}
|
|
|
|
private getSettings(): SettingsResult {
|
|
return this.queryBus.execute<SettingsResult>({ type: 'get-settings' });
|
|
}
|
|
|
|
private updateDOM(): void {
|
|
if (!this.el) return;
|
|
|
|
const { alarmSound, notifsEnabled, ntfyTopic } = this.getSettings();
|
|
|
|
let html = '';
|
|
|
|
// ── Hero ──────────────────────────────────────────────────────
|
|
html += `<div class="param-hero">
|
|
<div>
|
|
<h2 class="param-hero-title">Param\u00e8tres</h2>
|
|
<p class="param-hero-sub">Configuration de l'application et des notifications.</p>
|
|
</div>
|
|
</div>`;
|
|
|
|
// ── Son d'alarme ──────────────────────────────────────────────
|
|
html += `<div class="param-section">
|
|
<div class="param-section-header">
|
|
<span class="material-symbols-outlined param-section-icon">volume_up</span>
|
|
<h3 class="param-section-title">Son d'alarme</h3>
|
|
</div>
|
|
<p class="param-section-desc">Choisissez le son joué lorsqu'un enclos termine sa session.</p>
|
|
<div class="param-sound-grid">`;
|
|
|
|
for (const opt of SOUND_OPTIONS) {
|
|
const isActive = alarmSound === opt.value ? ' active' : '';
|
|
html += `<button class="param-sound-card${isActive}" data-sound="${esc(opt.value)}">
|
|
<span class="material-symbols-outlined param-sound-icon">${opt.icon}</span>
|
|
<span class="param-sound-label">${esc(opt.label)}</span>
|
|
</button>`;
|
|
}
|
|
|
|
html += `</div>
|
|
<button class="param-test-btn" id="param-test-sound">
|
|
<span class="material-symbols-outlined">play_circle</span>
|
|
Tester le son
|
|
</button>
|
|
</div>`;
|
|
|
|
// ── Notifications PC ──────────────────────────────────────────
|
|
html += `<div class="param-section">
|
|
<div class="param-section-header">
|
|
<span class="material-symbols-outlined param-section-icon">notifications</span>
|
|
<h3 class="param-section-title">Notifications PC</h3>
|
|
</div>
|
|
<p class="param-section-desc">Recevez une notification Windows quand un enclos est pr\u00eat.</p>
|
|
<div class="param-toggle-row">
|
|
<span class="param-toggle-label">${notifsEnabled ? 'Notifications activ\u00e9es' : 'Notifications d\u00e9sactiv\u00e9es'}</span>
|
|
<button class="param-toggle${notifsEnabled ? ' active' : ''}" id="param-notifs-toggle">
|
|
<span class="param-toggle-knob"></span>
|
|
</button>
|
|
</div>
|
|
</div>`;
|
|
|
|
// ── Notifications Mobile ──────────────────────────────────────
|
|
const mobileActive = ntfyTopic ? ' active' : '';
|
|
html += `<div class="param-section">
|
|
<div class="param-section-header">
|
|
<span class="material-symbols-outlined param-section-icon">phone_android</span>
|
|
<h3 class="param-section-title">Notifications Mobiles</h3>
|
|
</div>
|
|
<p class="param-section-desc">Recevez une alerte sur votre t\u00e9l\u00e9phone via ntfy, m\u00eame loin de votre PC.</p>
|
|
<div class="param-mobile-status">
|
|
<span class="param-mobile-dot${mobileActive}"></span>
|
|
<span class="param-mobile-text">${ntfyTopic ? 'Connect\u00e9 — notifications actives' : 'Non configur\u00e9'}</span>
|
|
</div>
|
|
<button class="param-mobile-btn${mobileActive}" id="param-ntfy-btn">
|
|
<span class="material-symbols-outlined">${ntfyTopic ? 'settings' : 'add_circle'}</span>
|
|
${ntfyTopic ? 'G\u00e9rer la configuration' : 'Activer les notifications mobiles'}
|
|
</button>
|
|
</div>`;
|
|
|
|
// ── Données ──────────────────────────────────────────────────
|
|
html += `<div class="param-section">
|
|
<div class="param-section-header">
|
|
<span class="material-symbols-outlined param-section-icon">database</span>
|
|
<h3 class="param-section-title">Donn\u00e9es</h3>
|
|
</div>
|
|
<p class="param-section-desc">Exportez ou importez toutes vos donn\u00e9es (enclos, dragodindes, statistiques, workflows).</p>
|
|
<div class="param-data-btns">
|
|
<button class="param-data-btn" id="param-export-data">
|
|
<span class="material-symbols-outlined">download</span>
|
|
Exporter les donn\u00e9es
|
|
</button>
|
|
<button class="param-data-btn param-data-btn-import" id="param-import-data">
|
|
<span class="material-symbols-outlined">upload</span>
|
|
Importer les donn\u00e9es
|
|
</button>
|
|
</div>
|
|
</div>`;
|
|
|
|
this.el.innerHTML = html;
|
|
this.bindEvents();
|
|
}
|
|
|
|
private bindEvents(): void {
|
|
if (!this.el) return;
|
|
|
|
// Sound cards
|
|
this.el.querySelectorAll<HTMLElement>('.param-sound-card').forEach(card => {
|
|
card.addEventListener('click', () => {
|
|
const sound = card.dataset['sound']!;
|
|
this.commandBus.execute({ type: 'update-settings', alarmSound: sound });
|
|
this.updateDOM();
|
|
});
|
|
});
|
|
|
|
// Test sound
|
|
this.el.querySelector('#param-test-sound')?.addEventListener('click', () => {
|
|
const { alarmSound } = this.getSettings();
|
|
this.playSound?.(alarmSound);
|
|
});
|
|
|
|
// PC notifications toggle
|
|
this.el.querySelector('#param-notifs-toggle')?.addEventListener('click', () => {
|
|
const { notifsEnabled } = this.getSettings();
|
|
this.commandBus.execute({ type: 'update-settings', notifsEnabled: !notifsEnabled });
|
|
this.updateDOM();
|
|
});
|
|
|
|
// Ntfy modal
|
|
this.el.querySelector('#param-ntfy-btn')?.addEventListener('click', () => {
|
|
this.openNtfyModal();
|
|
});
|
|
|
|
// Export data
|
|
this.el.querySelector('#param-export-data')?.addEventListener('click', () => this.exportData());
|
|
|
|
// Import data
|
|
this.el.querySelector('#param-import-data')?.addEventListener('click', () => this.importData());
|
|
}
|
|
|
|
/* ══ Modal ntfy ══ */
|
|
|
|
private openNtfyModal(): void {
|
|
if (this.modal) { this.modal.classList.remove('hidden'); this.renderNtfyModal(); return; }
|
|
|
|
this.modal = document.createElement('div');
|
|
this.modal.className = 'param-modal-overlay';
|
|
this.modal.innerHTML = `
|
|
<div class="param-modal-box">
|
|
<div class="param-modal-header">
|
|
<div class="param-modal-header-left">
|
|
<span class="material-symbols-outlined" style="color:var(--md-primary)">phone_android</span>
|
|
<h2 class="param-modal-title">Notifications mobiles</h2>
|
|
</div>
|
|
<button class="param-modal-close" id="ntfy-modal-close">
|
|
<span class="material-symbols-outlined">close</span>
|
|
</button>
|
|
</div>
|
|
<div class="param-modal-body" id="ntfy-modal-body"></div>
|
|
<div class="param-modal-footer">
|
|
<button class="param-modal-btn-ghost" id="ntfy-modal-footer-close">Fermer</button>
|
|
</div>
|
|
</div>`;
|
|
document.body.appendChild(this.modal);
|
|
|
|
this.modal.querySelector('#ntfy-modal-close')?.addEventListener('click', () => this.closeNtfyModal());
|
|
this.modal.querySelector('#ntfy-modal-footer-close')?.addEventListener('click', () => this.closeNtfyModal());
|
|
this.modal.addEventListener('click', (e) => { if (e.target === this.modal) this.closeNtfyModal(); });
|
|
|
|
this.renderNtfyModal();
|
|
}
|
|
|
|
private closeNtfyModal(): void {
|
|
this.modal?.classList.add('hidden');
|
|
}
|
|
|
|
private renderNtfyModal(): void {
|
|
const body = this.modal?.querySelector('#ntfy-modal-body');
|
|
if (!body) return;
|
|
|
|
const { ntfyTopic } = this.getSettings();
|
|
|
|
if (!ntfyTopic) {
|
|
body.innerHTML = `
|
|
<div class="param-ntfy-intro">
|
|
<div class="param-ntfy-intro-card">
|
|
<span class="material-symbols-outlined" style="font-size:36px;color:var(--md-primary)">notifications_active</span>
|
|
<p>Recevez une alerte sur votre t\u00e9l\u00e9phone quand un enclos est pr\u00eat, m\u00eame si votre PC est loin !</p>
|
|
</div>
|
|
<button class="param-ntfy-activate" id="ntfy-activate">
|
|
<span class="material-symbols-outlined">add_circle</span>
|
|
Activer les notifications mobiles
|
|
</button>
|
|
</div>`;
|
|
body.querySelector('#ntfy-activate')?.addEventListener('click', () => this.activateNtfy());
|
|
return;
|
|
}
|
|
|
|
// QR codes
|
|
const ntfyPlayStore = 'https://play.google.com/store/apps/details?id=io.heckel.ntfy';
|
|
const ntfyAppStore = 'https://apps.apple.com/app/ntfy/id1625396347';
|
|
const qrDownload = `https://api.qrserver.com/v1/create-qr-code/?size=160x160&data=${encodeURIComponent(ntfyPlayStore)}`;
|
|
const redirectUrl = `${NTFY_REDIRECT}/?t=${encodeURIComponent(ntfyTopic)}&s=${encodeURIComponent(NTFY_BASE.replace(/^https?:\/\//, ''))}&n=dd-timer`;
|
|
const qrSubscribe = `https://api.qrserver.com/v1/create-qr-code/?size=180x180&data=${encodeURIComponent(redirectUrl)}`;
|
|
|
|
body.innerHTML = `
|
|
<div class="param-ntfy-steps">
|
|
<div class="param-ntfy-step">
|
|
<div class="param-ntfy-step-badge">1</div>
|
|
<div class="param-ntfy-step-content">
|
|
<h4 class="param-ntfy-step-title">Installer l'app ntfy</h4>
|
|
<p class="param-ntfy-step-desc">Scannez ce QR code ou cherchez <strong>ntfy</strong> sur le
|
|
<a href="${ntfyPlayStore}" target="_blank">Play Store</a> /
|
|
<a href="${ntfyAppStore}" target="_blank">App Store</a></p>
|
|
<div class="param-ntfy-qr-wrap">
|
|
<img src="${qrDownload}" width="100" height="100" alt="T\u00e9l\u00e9charger ntfy">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="param-ntfy-step">
|
|
<div class="param-ntfy-step-badge">2</div>
|
|
<div class="param-ntfy-step-content">
|
|
<h4 class="param-ntfy-step-title">S'abonner aux notifications</h4>
|
|
<p class="param-ntfy-step-desc">Scannez ce QR code avec l'appareil photo de votre t\u00e9l\u00e9phone pour ajouter automatiquement les notifications.</p>
|
|
<div class="param-ntfy-qr-wrap param-ntfy-qr-main">
|
|
<img src="${qrSubscribe}" width="150" height="150" alt="S'abonner aux notifications">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="param-ntfy-actions">
|
|
<button class="param-ntfy-test-btn" id="ntfy-test">
|
|
<span class="material-symbols-outlined">send</span>
|
|
Tester
|
|
</button>
|
|
<button class="param-ntfy-deactivate-btn" id="ntfy-deactivate">
|
|
<span class="material-symbols-outlined">link_off</span>
|
|
D\u00e9sactiver
|
|
</button>
|
|
</div>`;
|
|
|
|
body.querySelector('#ntfy-test')?.addEventListener('click', () => this.testNtfy());
|
|
body.querySelector('#ntfy-deactivate')?.addEventListener('click', () => this.deactivateNtfy());
|
|
}
|
|
|
|
private activateNtfy(): void {
|
|
const topic = 'dd-' + Math.random().toString(36).slice(2, 8) + '-' + Date.now().toString(36).slice(-4);
|
|
this.commandBus.execute({ type: 'update-settings', ntfyTopic: topic });
|
|
this.updateDOM();
|
|
this.renderNtfyModal();
|
|
}
|
|
|
|
private deactivateNtfy(): void {
|
|
this.commandBus.execute({ type: 'update-settings', ntfyTopic: '' });
|
|
this.updateDOM();
|
|
this.renderNtfyModal();
|
|
}
|
|
|
|
private testNtfy(): void {
|
|
const { ntfyTopic } = this.getSettings();
|
|
if (!ntfyTopic) return;
|
|
const url = `${NTFY_BASE}/${ntfyTopic}`;
|
|
(window as any).electronAPI?.sendNtfy?.(url, 'Test alarme', 'Ceci est un test de la notification mobile Minuteur Dragodinde !');
|
|
}
|
|
|
|
/* ══ Backup / Restore ══ */
|
|
|
|
private async exportData(): Promise<void> {
|
|
const api = (window as any).electronAPI;
|
|
if (!api?.loadData || !api?.exportFile) return;
|
|
|
|
try {
|
|
const raw = await api.loadData();
|
|
if (!raw) {
|
|
Toast.show('error', 'Aucune donnée à exporter.');
|
|
return;
|
|
}
|
|
|
|
const version = await api.getVersion?.() ?? 'unknown';
|
|
const backup = {
|
|
app: 'minuteur-dragodinde',
|
|
version,
|
|
exportedAt: new Date().toISOString(),
|
|
data: JSON.parse(raw),
|
|
};
|
|
|
|
const date = new Date().toISOString().slice(0, 10);
|
|
const ok = await api.exportFile(JSON.stringify(backup, null, 2), `dd-timer-backup-${date}.json`);
|
|
if (ok) {
|
|
Toast.show('success', 'Données exportées avec succès.');
|
|
}
|
|
} catch {
|
|
Toast.show('error', 'Erreur lors de l\'export des données.');
|
|
}
|
|
}
|
|
|
|
private async importData(): Promise<void> {
|
|
const api = (window as any).electronAPI;
|
|
if (!api?.importFile || !api?.saveData) return;
|
|
|
|
try {
|
|
const raw = await api.importFile();
|
|
if (!raw) return; // dialogue annulé
|
|
|
|
let parsed: any;
|
|
try {
|
|
parsed = JSON.parse(raw);
|
|
} catch {
|
|
Toast.show('error', 'Le fichier sélectionné n\'est pas un JSON valide.');
|
|
return;
|
|
}
|
|
|
|
// Validation structurelle des données importées
|
|
const validateEnclosData = (data: any): boolean => {
|
|
if (!data || typeof data !== 'object' || data === null) return false;
|
|
if (!Array.isArray(data.enclos)) return false;
|
|
return data.enclos.every((enc: any) =>
|
|
enc && typeof enc === 'object' &&
|
|
typeof enc.id === 'number' &&
|
|
typeof enc.name === 'string' &&
|
|
Array.isArray(enc.dragodindes) &&
|
|
enc.gaugeLevels && typeof enc.gaugeLevels === 'object' &&
|
|
enc.timer && typeof enc.timer === 'object',
|
|
);
|
|
};
|
|
|
|
// Validation du format backup
|
|
if (parsed.app === 'minuteur-dragodinde' && parsed.data && typeof parsed.data === 'object' && parsed.data !== null) {
|
|
if (!validateEnclosData(parsed.data)) {
|
|
Toast.show('error', 'Le backup contient des données corrompues ou incomplètes.');
|
|
return;
|
|
}
|
|
const date = parsed.exportedAt ? new Date(parsed.exportedAt).toLocaleDateString('fr-FR') : 'inconnue';
|
|
const ok = await ConfirmModal.show(
|
|
'Importer les données',
|
|
`Ce backup date du ${date} (v${parsed.version ?? '?'}). Toutes vos données actuelles seront remplacées. Continuer ?`,
|
|
);
|
|
if (!ok) return;
|
|
api.saveData(JSON.stringify(parsed.data));
|
|
} else if (parsed.enclos && Array.isArray(parsed.enclos)) {
|
|
if (!validateEnclosData(parsed)) {
|
|
Toast.show('error', 'Le fichier contient des données corrompues ou incomplètes.');
|
|
return;
|
|
}
|
|
const ok = await ConfirmModal.show(
|
|
'Importer les données',
|
|
'Toutes vos données actuelles seront remplacées. Continuer ?',
|
|
);
|
|
if (!ok) return;
|
|
api.saveData(JSON.stringify(parsed));
|
|
} else {
|
|
Toast.show('error', 'Format de fichier non reconnu.');
|
|
return;
|
|
}
|
|
|
|
Toast.show('success', 'Données importées. Rechargement...');
|
|
setTimeout(() => window.location.reload(), 1000);
|
|
} catch {
|
|
Toast.show('error', 'Erreur lors de l\'import des données.');
|
|
}
|
|
}
|
|
}
|