feat: #78 Excalidraw finitions + #67 push + #68 health-detailed + #77 desktop jumplist
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.
This commit is contained in:
2026-09-09 23:18:29 -04:00
parent e4aca3b31e
commit ac16fc1f0e
30 changed files with 2477 additions and 166 deletions
+8 -1
View File
@@ -20,6 +20,8 @@ import { initMobileToolbar } from './mobile-toolbar.js';
import { initDragDrop } from './dragdrop.js';
import { initMermaid, setupFocusMode } from './mermaid-viewer.js';
import PaneManager from './pane-manager.js';
import { initDesktopIntegration, isTauriEnv } from './desktop.js';
import { initPush } from './push.js';
// =========================================================================
// Initialization — mirrors the original app.js init() ordering
@@ -30,6 +32,10 @@ async function init() {
// LocalStorage has correct language if user set it previously
await initI18n();
// 1b. Desktop bridge — crash banner + OS theme sync (before theme engine).
// No-op on web (Docker); on desktop it also readies the vault picker.
await initDesktopIntegration();
// 2. Init auth — now t() works inside showApp()/renderUserMenu()
const authOk = await Auth.AuthManager.initAuth();
@@ -62,7 +68,8 @@ async function init() {
UI.TabManager.init();
PaneManager.init();
initCommandPalette();
initMobileToolbar();
initMobileToolbar();
initPush();
if (authOk) {
Legacy.initSyncStatus();
+258
View File
@@ -0,0 +1,258 @@
/* ObsiGate — Desktop (Tauri) bridge.
* Bridges the vanilla-JS frontend to the Rust/Tauri shell.
*
* On desktop, the frontend is served by the Python backend over
* http://127.0.0.1:<port> (the webview redirects there after boot).
* Tauri exposes its API globally via `window.__TAURI__` (withGlobalTauri).
* Remote IPC access for that origin is granted in capabilities/default.json.
*
* On web (Docker), `window.__TAURI__` is absent — every function degrades
* to a no-op so the same bundle runs unchanged in the browser.
*
* Usage: import { initDesktopIntegration } from './desktop.js';
*/
// ── Detection ──────────────────────────────────────────────────────────────
// window.__TAURI__ is injected by Tauri when app.withGlobalTauri === true.
// window.__TAURI_INTERNALS__ is a secondary signal used by some Tauri builds.
export function isTauriEnv() {
if (typeof window === 'undefined') return false;
return !!(window.__TAURI__ || window.__TAURI_INTERNALS__);
}
// ── invoke wrapper ─────────────────────────────────────────────────────────
// Safely invokes a Tauri command. Resolves to undefined on web or on any
// IPC error, so callers never crash the app.
export async function invoke(cmd, args) {
if (!isTauriEnv()) return undefined;
try {
const core = window.__TAURI__ && window.__TAURI__.core;
if (!core || typeof core.invoke !== 'function') return undefined;
return await core.invoke(cmd, args);
} catch (e) {
console.warn('[desktop] invoke(' + cmd + ') failed:', e);
return undefined;
}
}
// ── System theme ───────────────────────────────────────────────────────────
// Returns 'dark' or 'light' from the OS. Falls back to the CSS media query
// (which the system WebView also honours) if the Tauri command is unavailable.
export async function getSystemTheme() {
const tauriTheme = await invoke('get_system_theme');
if (tauriTheme === 'dark' || tauriTheme === 'light') return tauriTheme;
try {
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark';
}
} catch (e) { /* ignore */ }
return 'light';
}
// ── Theme preference detection ─────────────────────────────────────────────
// The real theme engine is themes.js: it persists the *mode* under
// 'obsigate-theme-mode' (dark/light/high-contrast/sepia). Absence of that key
// means "no explicit user choice yet" — the desktop app should then follow
// the OS theme instead of defaulting to dark.
export function hasExplicitThemeMode() {
try {
return localStorage.getItem('obsigate-theme-mode') !== null;
} catch (e) {
return false;
}
}
// Apply the OS theme mode, but ONLY if the user hasn't chosen one explicitly.
export async function syncSystemTheme() {
if (!isTauriEnv()) return;
if (hasExplicitThemeMode()) return;
const sys = await getSystemTheme();
// Map OS light/dark onto the theme engine's mode axis (never override
// high-contrast/sepia, which are explicit choices stored in the same key).
const current = (function () {
try { return localStorage.getItem('obsigate-theme-mode'); } catch (e) { return null; }
})();
if (current === 'high-contrast' || current === 'sepia') return;
try { localStorage.setItem('obsigate-theme-mode', sys); } catch (e) { /* ignore */ }
}
// ── Crash banner ───────────────────────────────────────────────────────────
// The Rust shell calls `showBackendCrashBanner()` via window.eval when the
// backend process dies. Define it globally (idempotent) so the call always
// resolves. On web this is never triggered, but defining it is harmless.
export function defineBackendCrashBanner() {
if (typeof window === 'undefined') return;
if (window.showBackendCrashBanner) return;
window.showBackendCrashBanner = function () {
// Avoid stacking duplicates
if (document.getElementById('backend-crash-banner')) return;
const banner = document.createElement('div');
banner.id = 'backend-crash-banner';
banner.setAttribute('role', 'alert');
banner.style.cssText = [
'position:fixed', 'top:0', 'left:0', 'right:0', 'z-index:99999',
'background:#7f1d1d', 'color:#fef2f2', 'padding:10px 16px',
'font-size:13px', 'display:flex', 'align-items:center', 'justify-content:space-between',
'box-shadow:0 2px 8px rgba(0,0,0,0.4)',
].join(';');
const msg = document.createElement('span');
msg.textContent = '⚠ Le backend ObsiGate s\u2019est arrêté. Redémarrage en cours…';
const btn = document.createElement('button');
btn.textContent = 'Recharger';
btn.style.cssText = 'background:#fff;color:#7f1d1d;border:none;border-radius:4px;padding:4px 12px;cursor:pointer;font-weight:600;';
btn.addEventListener('click', function () { window.location.reload(); });
banner.appendChild(msg);
banner.appendChild(btn);
document.body.appendChild(banner);
};
}
// ── Vault picker ───────────────────────────────────────────────────────────
// Opens the native folder picker and registers the chosen folder as a vault,
// then restarts the backend so the new VAULT_N_* env vars take effect.
export async function pickVaultFolder() {
if (!isTauriEnv()) return undefined;
const path = await invoke('pick_vault_folder');
return typeof path === 'string' ? path : undefined;
}
// ── First-run wizard ───────────────────────────────────────────────────────
// Decides whether the first-run "choose your vault" wizard should be shown.
// Pure function (no DOM) so it is unit-testable.
//
// Returns true when: desktop env, the wizard has never been dismissed, and
// the user has no explicitly-picked vault folder yet.
const WIZARD_DISMISSED_KEY = 'obsigate-desktop-wizard-dismissed';
export function shouldShowWizard(opts) {
opts = opts || {};
const isDesktop = opts.isDesktop !== undefined ? opts.isDesktop : isTauriEnv();
const dismissed = opts.dismissed !== undefined ? opts.dismissed : (function () {
try { return localStorage.getItem(WIZARD_DISMISSED_KEY) === '1'; } catch (e) { return false; }
})();
const hasVaultPath = !!opts.hasVaultPath;
return isDesktop && !dismissed && !hasVaultPath;
}
export function dismissWizard() {
try { localStorage.setItem(WIZARD_DISMISSED_KEY, '1'); } catch (e) { /* ignore */ }
}
export function getWizardDismissedKey() { return WIZARD_DISMISSED_KEY; }
// ── Vault management (native) ──────────────────────────────────────────────
// pickAndAddVault opens the native folder picker, registers the chosen folder
// as a vault in the desktop config, restarts the backend (so VAULT_N_* env
// vars are re-injected) and reloads. Returns the chosen path, or undefined
// if cancelled / not on desktop.
export async function pickAndAddVault() {
if (!isTauriEnv()) return undefined;
const path = await pickVaultFolder();
if (!path) return undefined;
// Derive a display name from the final path segment.
const segments = path.replace(/[\\/]+$/, '').split(/[\\/]/);
const name = segments[segments.length - 1] || 'Vault';
await invoke('add_vault', { name: name, path: path });
await invoke('restart_backend');
return path;
}
// ── First-run wizard (DOM) ─────────────────────────────────────────────────
// Shows a dismissible welcome banner on first desktop launch offering to pick
// the user's Obsidian vault folder. Non-blocking; the default vault created
// by the Rust shell remains available if the user dismisses it.
export function initDesktopWizard() {
if (typeof window === 'undefined' || typeof document === 'undefined') return;
if (!shouldShowWizard({})) return;
// Defer so the app shell is fully rendered before injecting the banner.
setTimeout(function () {
if (document.getElementById('obsigate-desktop-wizard')) return;
const banner = document.createElement('div');
banner.id = 'obsigate-desktop-wizard';
banner.setAttribute('role', 'dialog');
banner.style.cssText = [
'position:fixed', 'bottom:16px', 'left:16px', 'right:16px', 'z-index:99998',
'background:var(--surface2,#161b22)', 'border:1px solid var(--border,#21262d)',
'border-radius:10px', 'padding:14px 16px', 'display:flex',
'align-items:center', 'gap:12px', 'flex-wrap:wrap',
'box-shadow:0 8px 24px rgba(0,0,0,0.35)',
].join(';');
const text = document.createElement('span');
text.style.cssText = 'flex:1;min-width:220px;font-size:13px;color:var(--text,#e6edf3);';
text.textContent = 'Bienvenue dans ObsiGate Desktop. Choisissez le dossier de vos vaults Obsidian pour commencer.';
const pickBtn = document.createElement('button');
pickBtn.textContent = 'Choisir mon dossier';
pickBtn.style.cssText = 'background:#7c3aed;color:#fff;border:none;border-radius:6px;padding:8px 14px;cursor:pointer;font-weight:600;font-size:13px;';
pickBtn.addEventListener('click', async function () {
const p = await pickAndAddVault();
if (p) { dismissWizard(); window.location.reload(); }
});
const laterBtn = document.createElement('button');
laterBtn.textContent = 'Plus tard';
laterBtn.style.cssText = 'background:transparent;color:var(--text-2,#8b949e);border:1px solid var(--border,#21262d);border-radius:6px;padding:8px 14px;cursor:pointer;font-size:13px;';
laterBtn.addEventListener('click', function () {
dismissWizard();
banner.remove();
});
banner.appendChild(text);
banner.appendChild(pickBtn);
banner.appendChild(laterBtn);
document.body.appendChild(banner);
}, 800);
}
// ── Integration entry point ────────────────────────────────────────────────
export async function initDesktopIntegration() {
defineBackendCrashBanner();
if (!isTauriEnv()) return false;
// Follow the OS theme on first run, before the theme engine renders.
await syncSystemTheme();
// Refresh the OS theme when the system preference changes at runtime.
try {
const mq = window.matchMedia('(prefers-color-scheme: dark)');
if (mq && mq.addEventListener) {
mq.addEventListener('change', function () { syncSystemTheme(); });
}
} catch (e) { /* ignore */ }
// First-run vault wizard.
initDesktopWizard();
return true;
}
export default {
isTauriEnv,
invoke,
getSystemTheme,
hasExplicitThemeMode,
syncSystemTheme,
defineBackendCrashBanner,
pickVaultFolder,
pickAndAddVault,
shouldShowWizard,
dismissWizard,
getWizardDismissedKey,
initDesktopWizard,
initDesktopIntegration,
};
+228
View File
@@ -0,0 +1,228 @@
/**
* 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 };
+13 -4
View File
@@ -755,10 +755,10 @@ const FileOperations = {
});
},
showCreateFileModal(vault, parentPath) {
const overlay = this._createModalOverlay();
const modal = document.createElement('div');
modal.className = 'obsigate-modal';
showCreateFileModal(vault, parentPath, preselectExt = null) {
const overlay = this._createModalOverlay();
const modal = document.createElement('div');
modal.className = 'obsigate-modal';
modal.innerHTML = `
<div class="obsigate-modal-header">
<h3 class="obsigate-modal-title">${t('modal.create_file')}</h3>
@@ -804,6 +804,10 @@ const FileOperations = {
input.focus();
if (preselectExt && [...extSelect.options].some(o => o.value === preselectExt)) {
extSelect.value = preselectExt;
}
const validateName = (name) => {
if (!name.trim()) return 'Le nom ne peut pas être vide';
if (/[/\\:*?"<>|]/.test(name)) return 'Caractères interdits: / \\ : * ? " < > |';
@@ -1695,6 +1699,7 @@ export const ContextMenuManager = {
} else if (type === 'directory') {
this._addItem('folder-plus', 'Nouveau sous-dossier', () => this._createDirectory(), isReadonly);
this._addItem('file-plus', 'Nouveau fichier ici', () => this._createFile(), isReadonly);
this._addItem('pen-tool', 'Nouveau diagramme Excalidraw', () => this._createExcalidraw(), isReadonly);
this._addSeparator();
this._addItem('bookmark-plus', 'Ajouter aux recherches sauvegardees', () => this._saveDirectorySearch(), false);
this._addSeparator();
@@ -1775,6 +1780,10 @@ export const ContextMenuManager = {
FileOperations.showCreateFileModal(this._targetVault, this._targetPath);
},
_createExcalidraw() {
FileOperations.showCreateFileModal(this._targetVault, this._targetPath, '.excalidraw');
},
_renameItem() {
FileOperations.startInlineRename(this._targetVault, this._targetPath, this._targetType);
},