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
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:
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user