/* 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: (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 name = lastPathSegment(path) || 'Vault'; await invoke('add_vault', { name: name, path: path }); await invoke('restart_backend'); return path; } // ── Vault & root-directory management (#159) ──────────────────────────── // The Tauri shell injects vaults (VAULT_N_*) and root directories (DIR_N_*) // into the backend as environment variables. These helpers unregister them // from the desktop config, restart the backend and reload the page. // Pure: classify a root name against the desktop config lists. // Returns 'vault' | 'dir' | null. export function resolveRootKind(vaults, dirs, name) { if ((vaults || []).some((v) => v.name === name)) return 'vault'; if ((dirs || []).some((d) => d.name === name)) return 'dir'; return null; } // Pure: last path segment, Windows or POSIX separators, trailing // separators ignored ('' for root-ish inputs). export function lastPathSegment(path) { const segments = String(path).replace(/[\\/]+$/, '').split(/[\\/]/); return segments[segments.length - 1] || ''; } // Remove a vault or a root directory from the desktop config. `confirmMsg` // (already translated) gates the destructive step; nothing on disk is // deleted. Returns 'vault' | 'dir', or undefined when not on desktop, // cancelled, unknown root, or a command failed. export async function removeRoot(name, confirmMsg) { if (!isTauriEnv() || !name) return undefined; if (confirmMsg && !window.confirm(confirmMsg)) return undefined; const vaults = (await invoke('list_vaults')) || []; const dirs = (await invoke('list_dirs')) || []; const kind = resolveRootKind(vaults, dirs, name); if (!kind) return undefined; const res = await invoke(kind === 'vault' ? 'remove_vault' : 'remove_dir', { name: name }); if (res === undefined) return undefined; // command failed (see invoke wrapper) await invoke('restart_backend'); window.location.reload(); return kind; } // Add a root directory: side-effect-free native folder picker, register it, // restart the backend and reload. Mirrors pickAndAddVault. export async function pickAndAddDir() { if (!isTauriEnv()) return undefined; const path = await invoke('pick_folder'); if (typeof path !== 'string' || !path) return undefined; const name = lastPathSegment(path) || 'Dossier'; await invoke('add_dir', { name: name, path: path }); await invoke('restart_backend'); window.location.reload(); 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, };