Files
ObsiGate/frontend/js/push.js
T
bruno ac16fc1f0e
CI / lint (push) Successful in 52s
CI / security (push) Successful in 36s
CI / test (push) Successful in 1m6s
CI / build (push) Successful in 1m15s
CI / e2e (push) Failing after 12m40s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s
feat: #78 Excalidraw finitions + #67 push + #68 health-detailed + #77 desktop jumplist
#78 Excalidraw — finitions B5/C8/F2/F3
- backend/indexer.py: port Python pur de lz-string decompressFromBase64 (bitsPerChar=6, resetValue=32) validé contre 4 fixtures JS truth (texte accentué, edge cases) — remplace le stub base64 non-fonctionnel
- B5 indexation: .excalidraw.md (format plugin Obsidian) maintenant décompressé côté Python → texte indexé pour recherche TF-IDF
- 7 nouveaux tests B5 dans tests/test_excalidraw.py (13/13 total)
- F2: excalidraw-viewer.test.mjs enregistré dans CI (lint job)
- F3: tests/e2e/excalidraw.spec.js (9 specs — ouverture .excalidraw/.excalidraw.md, création via modale/menu contextuel, toolbar, thème, pop-out, recherche)
- Fixtures test_vault/diagram.excalidraw + diagram.excalidraw.md

#67 Push notifications (Web Push API + VAPID)
- backend/push.py: router + VAPID keys + send_push_notification (pywebpush>=2.3.0)
- frontend/js/push.js: subscription UI + service worker integration
- tests/test_push.py: 10 tests (subscribe/list/unsubscribe/vapid key)
- requirements.txt + sw.js + locales push strings

#68 Health check enrichi
- backend/main.py: GET /api/health/detailed (admin) — memory/cpu/disk/backups/index/SSE connections
- backend/indexer.py: _last_full_index_ts tracking
- tests/conftest.py: admin_client fixture
- tests/test_api_main.py: 3 nouveaux tests health detailed

#77 Desktop jumplist
- desktop/src/jumplist.rs: Windows jumplist integration
- Cargo.toml/lock + capabilities + main.rs
- frontend/js/desktop.js: Tauri bridge (isTauriEnv, invoke, getSystemTheme, syncSystemTheme, shouldShowWizard, crash banner)
- tests/frontend/desktop.test.mjs: 21 tests JSDOM (détection, invoke degradation, theme gating, wizard, crash banner)
- tests/frontend/unit.test.mjs: desktop.js whitelisted (standalone global reader)

# Frontend & CI
- frontend/sw.js: réécriture complète (precache + push event handlers + offline)
- frontend/index.html: section push dans settings + about repositionné
- frontend/js/app.js: initDesktopIntegration + initPush
- CI .gitea/workflows/ci.yml: excalidraw-viewer.test.mjs ajouté au lint job

All checks: ruff clean, 552 backend tests pass, 9 frontend unit, 5 excalidraw-viewer, 21 desktop, 9 pane-manager, validate-imports 33 modules.
2026-09-09 23:18:29 -04:00

229 lines
8.0 KiB
JavaScript

/**
* push.js — Push Notifications client for ObsiGate
* ROADMAP #67
*
* Handles:
* - Subscribing to Web Push via VAPID
* - Unsubscribing
* - UI rendering in the config modal
*/
import { t } from './i18n.js';
let _vapidPublicKey = null;
// ── Helpers ──────────────────────────────────────────────────────────────────
function _api(method, path, body) {
const opts = {
method,
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
};
if (body !== undefined) opts.body = JSON.stringify(body);
return fetch(path, opts).then((r) => {
if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
return r.json();
});
}
function _urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
// ── VAPID key ────────────────────────────────────────────────────────────────
async function getVapidPublicKey() {
if (_vapidPublicKey) return _vapidPublicKey;
const data = await _api('GET', '/api/push/vapid-public-key');
_vapidPublicKey = data.public_key;
return _vapidPublicKey;
}
// ── Subscription management ──────────────────────────────────────────────────
async function subscribePush(vault) {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
throw new Error('Push notifications not supported by this browser');
}
const reg = await navigator.serviceWorker.ready;
const vapidKey = await getVapidPublicKey();
// Check existing subscription
let subscription = await reg.pushManager.getSubscription();
if (!subscription) {
// Subscribe to push
subscription = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: _urlBase64ToUint8Array(vapidKey),
});
}
// Register subscription with backend
const subJSON = subscription.toJSON();
await _api('POST', '/api/push/subscribe', {
subscription: {
endpoint: subJSON.endpoint,
keys: subJSON.keys,
},
vault,
});
return { success: true };
}
async function unsubscribePush() {
if (!('serviceWorker' in navigator)) return;
const reg = await navigator.serviceWorker.ready;
const subscription = await reg.pushManager.getSubscription();
if (!subscription) return;
// Unsubscribe from browser
await subscription.unsubscribe();
// Unsubscribe from backend
const endpoint = subscription.endpoint;
try {
await _api('DELETE', `/api/push/subscribe?endpoint=${encodeURIComponent(endpoint)}`);
} catch {
// Best-effort: endpoint may already be expired
}
}
async function isSubscribed() {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) return false;
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.getSubscription();
return !!sub;
}
async function getSubscriptions() {
return _api('GET', '/api/push/subscriptions');
}
// ── Config UI ────────────────────────────────────────────────────────────────
export async function renderPushSection() {
const container = document.getElementById('push-settings');
if (!container) return;
const supported = 'serviceWorker' in navigator && 'PushManager' in window;
const subscribed = await isSubscribed();
if (!supported) {
container.innerHTML = `
<div class="mfa-status-row">
<span class="mfa-status-label">Notifications push</span>
<span class="mfa-badge mfa-badge-off">Non supportées par ce navigateur</span>
</div>`;
return;
}
container.innerHTML = `
<div class="mfa-status-row">
<span class="mfa-status-label">Notifications push</span>
<span id="push-status-badge" class="mfa-badge ${subscribed ? 'mfa-badge-on' : 'mfa-badge-off'}">${subscribed ? 'Activées' : 'Désactivées'}</span>
</div>
<div class="config-row" style="margin-top:12px">
<label class="config-label" for="push-vault-select">Vault cible</label>
<select id="push-vault-select" class="config-input" style="max-width:240px"></select>
</div>
<div class="config-row" style="margin-top:8px">
<button id="push-toggle-btn" class="config-save-btn" style="${subscribed ? 'background:var(--danger-bg);color:var(--danger);border-color:var(--danger)' : ''}">
${subscribed ? 'Désactiver les notifications' : 'Activer les notifications'}
</button>
</div>
<div id="push-sub-count" style="margin-top:8px;font-size:12px;color:var(--text-muted)"></div>`;
// Populate vault select
const vaultSelect = document.getElementById('push-vault-select');
try {
const vaultData = await _api('GET', '/api/vaults');
const vaults = vaultData.vaults || vaultData || [];
(Array.isArray(vaults) ? vaults : []).forEach((v) => {
const name = typeof v === 'string' ? v : v.name;
const opt = document.createElement('option');
opt.value = name;
opt.textContent = name;
vaultSelect.appendChild(opt);
});
} catch {
const opt = document.createElement('option');
opt.value = 'default';
opt.textContent = 'default';
vaultSelect.appendChild(opt);
}
// Toggle button
document.getElementById('push-toggle-btn')?.addEventListener('click', async () => {
const btn = document.getElementById('push-toggle-btn');
const badge = document.getElementById('push-status-badge');
try {
btn.disabled = true;
btn.textContent = '…';
if (subscribed) {
await unsubscribePush();
badge.textContent = 'Désactivées';
badge.className = 'mfa-badge mfa-badge-off';
btn.textContent = 'Activer les notifications';
btn.style.background = '';
btn.style.color = '';
btn.style.borderColor = '';
} else {
const vault = vaultSelect.value || 'default';
await subscribePush(vault);
badge.textContent = 'Activées';
badge.className = 'mfa-badge mfa-badge-on';
btn.textContent = 'Désactiver les notifications';
btn.style.background = 'var(--danger-bg)';
btn.style.color = 'var(--danger)';
btn.style.borderColor = 'var(--danger)';
}
} catch (err) {
console.error('[Push] Toggle error:', err);
alert('Erreur: ' + err.message);
} finally {
btn.disabled = false;
}
});
// Show subscription count
try {
const data = await getSubscriptions();
const count = (data.subscriptions || []).length;
document.getElementById('push-sub-count').textContent =
count > 0 ? `${count} abonnement(s) actif(s)` : '';
} catch {
// Ignore
}
}
// ── Init ─────────────────────────────────────────────────────────────────────
export function initPush() {
// Render push section when config modal opens
document.addEventListener('DOMContentLoaded', () => {
// Observe the config modal becoming visible
const observer = new MutationObserver(() => {
const section = document.getElementById('cfg-push');
if (section && section.offsetParent !== null) {
renderPushSection();
}
});
const modal = document.getElementById('config-modal');
if (modal) observer.observe(modal, { attributes: true, attributeFilter: ['class'] });
});
}
export default { initPush, subscribePush, unsubscribePush, isSubscribed, renderPushSection };