Files
ObsiGate/frontend/js/desktop.js
T
bruno fb2d83e9e3
CI / lint (push) Successful in 1m40s
CI / security (push) Successful in 1m6s
CI / test (push) Failing after 1m52s
CI / build (push) Skipped
CI / e2e (push) Skipped
feat: guide d'utilisation - couverture complete, telechargement MD/PDF, section Architecture Mermaid, guide desktop elargi (#105, BUG-067)
2026-09-18 13:06:30 -04:00

269 lines
12 KiB
JavaScript

/* 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 async function dismissWizard() {
try { localStorage.setItem(WIZARD_DISMISSED_KEY, '1'); } catch (e) { /* ignore */ }
// Persist on the Rust side too, so clearing the webview's localStorage does
// not resurrect the wizard on the next launch.
await invoke('complete_wizard');
}
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 async function initDesktopWizard() {
if (typeof window === 'undefined' || typeof document === 'undefined') return;
// The authoritative "already done" signal lives in the desktop config
// (survives localStorage clears). Defaults to false when not on desktop.
const wizardDone = (await invoke('get_wizard_state')) === true;
if (!shouldShowWizard({ hasVaultPath: wizardDone })) 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;
// Desktop marker for CSS (wider reading layouts, e.g. the user guide #105).
try { document.body.classList.add('desktop-mode'); } catch (e) { /* ignore */ }
// 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.
await initDesktopWizard();
return true;
}
export default {
isTauriEnv,
invoke,
getSystemTheme,
hasExplicitThemeMode,
syncSystemTheme,
defineBackendCrashBanner,
pickVaultFolder,
pickAndAddVault,
shouldShowWizard,
dismissWizard,
getWizardDismissedKey,
initDesktopWizard,
initDesktopIntegration,
};