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
#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.
229 lines
8.0 KiB
JavaScript
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 };
|