269 lines
12 KiB
JavaScript
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,
|
|
};
|