// Desktop UI. All vault logic runs in Rust (src-tauri → kv-ffi); this layer only renders, with the // Kestrove components in ./ui (the same ones the browser extension uses). import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { banner, button, codeText, copyButton, dots, field, h, icon, iconButton, input, integrityBanner, itemRow, lockup, pill, ring, secret, tile, type IconName, } from "./ui/kv"; interface AppState { has_account: boolean; locked: boolean; remembers_secret_key: boolean; ipc_error: string | null; idle_minutes: number } interface ItemSummary { id: string; title: string; username: string | null; urls: string[]; favorite: boolean; has_totp: boolean } interface ItemDto { id: string; title: string; username: string | null; password: string | null; urls: string[]; totp: string | null; notes: string | null } interface PairingView { id: string; name: string; caller: string; words: string[] } interface Pairing { id: string; name: string; caller: string; words: string[]; created_at: number } interface SaveView { id: string; origin: string; username: string; update: boolean } interface SyncStatus { server_url: string | null; pins: string[]; logged_in: boolean; syncing: boolean; last_sync_at: number | null; last_error: string | null; pending_approval: boolean; pending_devices: number; integrity_alarm: string | null; device_fingerprint: string | null; key_storage: string | null; key_warning: string | null; restored_items: number; changes: number; } interface DeviceView { device_id: string; name: string; platform: string; approved: boolean; revoked: boolean; current: boolean; created_at: string; key_fingerprint: string } interface SetupView { server_url: string; server_host: string; account_id: string; preapproved: boolean } interface JoinView { qr_svg: string; qr_text: string; expires_at: number } interface JoinPollView { code: string | null; setup: SetupView | null } interface AddDeviceView { qr_svg: string; expires_at: number } interface JoinConfirmView { code: string; device_name: string; platform: string; key_fingerprint: string } const app = document.getElementById("app")!; const overlay = document.getElementById("overlay")!; const modal = document.getElementById("modal")!; const isMac = navigator.userAgent.includes("Macintosh"); document.documentElement.classList.toggle("mac", isMac); const errText = (e: unknown) => (typeof e === "string" ? e : e instanceof Error ? e.message : String(e)); const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); /// "ab12cd34…" → "ab12 cd34 …" so two screens are easy to compare. const groups = (hex: string) => hex.replace(/(.{4})/g, "$1 ").trim(); const errBox = () => h("div", "kv-error"); const setErr = (box: HTMLElement, e: unknown) => box.replaceChildren(icon("octagon", "sm"), errText(e)); const p = (cls: string, ...children: (Node | string)[]) => h("p", cls, ...children); const stack = (cls = "", ...children: (Node | string | null | false)[]) => h("div", `stack ${cls}`.trim(), ...children); function countdown(expiresAt: number, onExpire: () => void): HTMLElement { const span = h("span", "kv-caption kv-muted"); const tick = () => { if (!span.isConnected && span.dataset.started) return; span.dataset.started = "1"; const left = Math.max(0, Math.round(expiresAt - Date.now() / 1000)); span.textContent = `Expires in ${Math.floor(left / 60)}:${String(left % 60).padStart(2, "0")}`; if (left === 0) onExpire(); else setTimeout(tick, 1000); }; queueMicrotask(tick); return span; } function ago(unix: number): string { const s = Math.max(0, Math.round(Date.now() / 1000 - unix)); if (s < 60) return "just now"; if (s < 3600) return `${Math.floor(s / 60)} min ago`; if (s < 86400) return `${Math.floor(s / 3600)} h ago`; return new Date(unix * 1000).toLocaleString(); } // ------------------------------------------------------------------ theme (Light, Dark, System) type Theme = "system" | "light" | "dark"; function storedTheme(): Theme { try { const t = localStorage.getItem("theme"); return t === "light" || t === "dark" ? t : "system"; } catch { return "system"; } } function applyTheme(t: Theme) { if (t === "system") delete document.documentElement.dataset.theme; else document.documentElement.dataset.theme = t; // The native title bar and window controls follow too. void getCurrentWindow().setTheme(t === "system" ? null : t).catch(() => undefined); paintTitleBar(); } /// Windows and Linux keep the native title bar (Tauri overlays it only on macOS); on Windows 11 its caption /// takes the theme's `bg` and `text`, so it and the header below read as one custom title bar. function paintTitleBar() { if (isMac) return; const css = getComputedStyle(document.documentElement); const bg = css.getPropertyValue("--bg").trim(); const text = css.getPropertyValue("--text").trim(); void invoke("set_title_bar_colors", { bg, text }).catch(() => undefined); } // "System" follows the OS setting while the app runs. matchMedia("(prefers-color-scheme: dark)").addEventListener("change", paintTitleBar); function setTheme(t: Theme) { try { localStorage.setItem("theme", t); } catch { // Not persisted; still applied for this run. } applyTheme(t); } applyTheme(storedTheme()); // ------------------------------------------------------------------ chrome /// The custom title bar in `bg`: native window controls (macOS traffic lights sit over its left edge). function titleBar(...trailing: Node[]): HTMLElement { const bar = h("header", "titlebar", lockup(20, "kv-word sm"), h("span", "grow"), ...trailing); bar.dataset.tauriDragRegion = ""; bar.querySelectorAll("span").forEach((s) => (s.dataset.tauriDragRegion = "")); return bar; } /// Centered single-card screens: onboarding, unlock, the Secret Key. function centered(card: HTMLElement) { app.className = "auth"; app.replaceChildren(titleBar(), h("div", "center", card)); } /// Bumped on every full render so polling loops of a screen that is gone stop. let screen = 0; let lastChanges = -1; async function render() { screen++; const state = await invoke("state"); app.replaceChildren(); if (!state.has_account) await renderOnboarding(); else if (state.locked) renderUnlock(state); else await renderVault(state); await renderPrompts(state); } // ------------------------------------------------------------------ onboarding (docs/10) type Tab = "phone" | "kit" | "paste" | "create"; async function renderOnboarding(tab: Tab = "phone") { const status = await invoke("sync_status"); const body = stack(); const tabs: [Tab, string][] = [["phone", "Use my phone"], ["kit", "Emergency Kit"], ["paste", "Paste setup code"], ["create", "New account"]]; const nav = h("div", "seg", ...tabs.map(([t, label]) => { const b = button(label, "quiet", async () => { await invoke("join_cancel"); app.replaceChildren(); screen++; await renderOnboarding(t); }); b.setAttribute("aria-pressed", String(t === tab)); return b; })); nav.setAttribute("role", "group"); nav.setAttribute("aria-label", "How to set up"); const card = h("div", "kv-card auth-card wide", h("h1", "kv-title-1", "Set up Kestrove on this computer"), nav, body); if (status.key_warning) card.append(banner("warn", "Your device key is stored in a file", status.key_warning)); centered(card); if (tab === "phone") phonePanel(body, status); else if (tab === "kit") kitPanel(body); else if (tab === "paste") pastePanel(body); else createPanel(body, status); } function serverInput(status: SyncStatus): HTMLInputElement { return input({ type: "url", placeholder: "https://kestrove.example.com", value: status.server_url ?? "", spellcheck: false }); } function phonePanel(body: HTMLElement, status: SyncStatus) { const url = serverInput(status); const err = errBox(); const start = button("Show QR code", "primary"); body.append( p("kv-muted", "Your phone sends this computer everything it needs. You'll only type your master password."), field("Your Kestrove server", url, "The address you open in a browser."), h("div", "actions", start), err, ); start.onclick = async () => { start.disabled = true; err.replaceChildren(); try { const join = await invoke("join_start", { serverUrl: url.value }); showJoin(body, join); } catch (e) { setErr(err, e); start.disabled = false; } }; // Block body on purpose: a DOM `on…` handler that returns `false` cancels the event, which would block typing. url.onkeydown = (e) => { if (e.key === "Enter") start.click(); }; if (!url.value) url.focus(); } function qrCard(src: string, alt: string): HTMLElement { const img = h("img"); img.src = src; img.alt = alt; return h("div", "kv-qr", img); } function steps(...items: (string | Node)[]): HTMLElement { return h("ol", "steps kv-small kv-muted", ...items.map((i) => h("li", "", i))); } function showJoin(body: HTMLElement, join: JoinView) { const mine = screen; const codeBox = h("div", "confirm", h("span", "kv-hint", "Waiting for your phone…")); const err = errBox(); const text = h("textarea", "kv-field mono kv-small"); text.readOnly = true; text.rows = 3; text.value = join.qr_text; const cancel = button("Cancel", "quiet", async () => { await invoke("join_cancel"); await render(); }); body.replaceChildren(h("div", "qr-sheet", steps("Open Kestrove on your phone.", h("span", "", "Go to ", h("b", "", "Settings → Devices"), "."), h("span", "", "Choose ", h("b", "", "Scan a computer's code"), ".")), qrCard(join.qr_svg, "QR code for your phone"), countdown(join.expires_at, () => { if (mine === screen) setErr(err, "The code expired. Cancel and start again."); }), codeBox, cancel, err), h("details", "", h("summary", "kv-small", "No phone? Approve from another computer"), p("kv-small kv-muted", "On a computer that is already signed in, open Settings → Devices → Approve a computer, and paste this join code:"), text, h("div", "actions", button("Copy join code", "secondary", () => copyText(text)))), ); void (async () => { while (mine === screen) { await sleep(2000); if (mine !== screen) return; try { const poll = await invoke("join_poll"); if (poll.code) codeBox.replaceChildren(h("span", "kv-hint", "Your phone should show:"), codeText(poll.code, true)); if (poll.setup) { if (mine === screen) finishSetup(body, poll.setup); return; } } catch (e) { setErr(err, e); return; } } })(); } function kitPanel(body: HTMLElement) { const file = h("input"); file.type = "file"; file.accept = ".png,.jpg,.jpeg,.pdf,image/png,image/jpeg,application/pdf"; const err = errBox(); const open = button("Open Emergency Kit…", "primary", () => file.click()); file.hidden = true; file.onchange = async () => { const f = file.files?.[0]; if (!f) return; err.replaceChildren(); open.disabled = true; open.textContent = "Reading…"; try { const bytes = new Uint8Array(await f.arrayBuffer()); const view = await invoke("setup_from_file", bytes); finishSetup(body, view); } catch (e) { setErr(err, e); } finally { open.disabled = false; open.textContent = "Open Emergency Kit…"; file.value = ""; } }; body.append( p("kv-muted", "Choose your Emergency Kit PDF, or a photo or screenshot of its QR code (PNG or JPEG). Another device will need to approve this computer."), file, h("div", "actions", open), err, ); } function pastePanel(body: HTMLElement) { const text = h("textarea", "kv-field mono kv-small"); text.rows = 4; text.placeholder = "kestrove://setup/v1?…"; text.spellcheck = false; const err = errBox(); const next = button("Continue", "primary"); next.onclick = async () => { err.replaceChildren(); try { const view = await invoke("setup_from_text", { text: text.value }); text.value = ""; finishSetup(body, view); } catch (e) { setErr(err, e); } }; body.append(field("Setup code", text, "From your Emergency Kit, or from “Add a device” on another device."), h("div", "actions", next), err); text.focus(); } /// The last step of every way in: only the master password. function finishSetup(body: HTMLElement, view: SetupView) { const pw = input({ type: "password", autocomplete: "current-password" }); const err = errBox(); const btn = button("Sign in", "primary"); const waiting = h("div"); const submit = async () => { btn.disabled = true; err.replaceChildren(); btn.textContent = "Signing in…"; try { const r = await invoke<"ok" | "pending_approval">("finish_setup", { masterPassword: pw.value }); if (r === "ok") { pw.value = ""; await render(); return; } const status = await invoke("sync_status"); waiting.replaceChildren(banner("warn", "Almost done: approve this computer", h("div", "", p("", "On a device that is already signed in, open Settings → Devices and check that it shows this fingerprint:"), p("fingerprint", groups(status.device_fingerprint ?? "")), p("kv-muted", "Then come back and choose “Check again”.")))); btn.textContent = "Check again"; } catch (e) { setErr(err, e); btn.textContent = "Sign in"; } btn.disabled = false; }; btn.onclick = submit; pw.onkeydown = (e) => { if (e.key === "Enter") void submit(); }; body.replaceChildren( p("", "Signing in to ", h("strong", "", view.server_host), "."), p("kv-muted", view.preapproved ? "This computer is pre-approved." : "Another device will need to approve this computer."), field("Master password", pw), h("div", "actions", btn), waiting, err, button("Start over", "text", async () => { await invoke("clear_setup"); await render(); }), ); pw.focus(); } function createPanel(body: HTMLElement, status: SyncStatus) { const url = serverInput(status); const email = input({ type: "email", autocomplete: "email", spellcheck: false }); const code = input({ spellcheck: false }); const pins = input({ placeholder: "base64, comma-separated", spellcheck: false }, true); const pw = input({ type: "password", autocomplete: "new-password" }); const pw2 = input({ type: "password", autocomplete: "new-password" }); const err = errBox(); const btn = button("Create account", "primary"); btn.onclick = async () => { err.replaceChildren(); if (pw.value !== pw2.value) { setErr(err, "The passwords don't match."); pw2.classList.add("invalid"); return; } pw2.classList.remove("invalid"); btn.disabled = true; btn.textContent = "Creating…"; try { const secretKey = await invoke("create_account", { serverUrl: url.value, pins: pins.value, setupCode: code.value, email: email.value, masterPassword: pw.value }); pw.value = pw2.value = ""; await showSecretKey(secretKey); } catch (e) { setErr(err, e); btn.disabled = false; btn.textContent = "Create account"; // A vault created here whose server registration failed: continue to the vault (Settings → Sync retries). if ((await invoke("state")).has_account) { body.append(button("Continue to the vault", "secondary", render)); } } }; body.append( p("kv-muted", "A new account on your Kestrove server. You'll get a Secret Key: keep it with your Emergency Kit."), field("Your Kestrove server", url), field("Email", email, "You sign in with it on new devices."), field("Invite or setup code", code, "The invite from your server's admin, or KV_SETUP_CODE from its .env for the first account. Empty with open sign-up."), h("details", "", h("summary", "kv-small", "Certificate pins (advanced)"), field("SPKI pins", pins)), field("Master password", pw, "10 or more characters."), field("Repeat master password", pw2), h("div", "actions", btn), err, ); } async function showSecretKey(secretKey: string) { const kit = await invoke("kit_qr").catch(() => null); const card = h("div", "kv-card auth-card wide", h("h1", "kv-title-1", "Your Secret Key"), h("div", "secret-key", secret(secretKey)), p("kv-muted", "Write it down, or print this screen, and keep it with your Emergency Kit. Every new device needs it with your master password. This computer remembers it.")); if (kit) { card.append( p("", "Setup code for your Emergency Kit (server, account and Secret Key; keep it as safe as the Secret Key):"), h("div", "center-row", qrCard(kit, "Emergency Kit setup code"))); } card.append(h("div", "actions", button("Continue", "primary", render))); centered(card); } // ------------------------------------------------------------------ unlock function renderUnlock(state: AppState) { const pw = input({ type: "password", autocomplete: "current-password" }); const sk = input({ placeholder: "K1-XXXXXX-XXXXX-…", spellcheck: false }, true); const err = errBox(); const btn = button("Unlock", "primary", undefined, "block"); const submit = async () => { btn.disabled = true; err.replaceChildren(); pw.classList.remove("invalid"); try { await invoke("unlock", { masterPassword: pw.value, secretKey: sk.value || null }); pw.value = ""; sk.value = ""; await render(); } catch (e) { setErr(err, e); pw.classList.add("invalid"); btn.disabled = false; } }; btn.onclick = submit; pw.onkeydown = (e) => { if (e.key === "Enter") void submit(); }; const card = h("div", "kv-card auth-card", h("div", "unlock-mark", lockup(32)), h("h1", "kv-title-1", "Unlock your vault"), field("Master password", pw)); if (!state.remembers_secret_key) card.append(field("Secret Key", sk, "It's on your Emergency Kit. Dashes are optional.")); card.append(err, btn); centered(card); pw.focus(); } // ------------------------------------------------------------------ vault let refreshList: (() => Promise) | null = null; let syncBox: HTMLElement | null = null; let syncPill: HTMLElement | null = null; let alarmBox: HTMLElement | null = null; type Category = "all" | "favorites"; let category: Category = "all"; let selectedId: string | null = null; async function renderVault(state: AppState) { const detail = h("section", "detail"); const list = h("div", "kv-list"); list.setAttribute("role", "listbox"); list.setAttribute("aria-label", "Items"); const search = input({ type: "search", placeholder: "Search", spellcheck: false }); search.setAttribute("aria-label", "Search items"); let items: ItemSummary[] = []; const draw = () => { const q = search.value.trim().toLowerCase(); const shown = items.filter((it) => (category === "all" || it.favorite) && (!q || it.title.toLowerCase().includes(q) || (it.username ?? "").toLowerCase().includes(q))); list.replaceChildren(); for (const it of shown) { const row = itemRow(it.title, it.username ?? "", { favorite: it.favorite, selected: it.id === selectedId }); row.setAttribute("role", "option"); row.setAttribute("aria-selected", String(it.id === selectedId)); row.onclick = async () => { selectedId = it.id; list.querySelectorAll(".kv-item").forEach((n) => { n.classList.remove("sel"); n.setAttribute("aria-selected", "false"); }); row.classList.add("sel"); row.setAttribute("aria-selected", "true"); await renderDetail(detail, it.id); }; list.append(row); } if (!shown.length) { list.append(h("div", "empty kv-small kv-muted", items.length ? "Nothing matches." : "No items yet. Choose “New item” to add a login.")); } }; const fill = async () => { items = await invoke("list_items"); if (selectedId && !items.some((i) => i.id === selectedId)) selectedId = null; draw(); }; search.oninput = draw; await fill(); refreshList = fill; const cat = (c: Category, label: string, ic: IconName) => { const b = h("button", "rail-item", icon(ic, "sm"), h("span", "grow", label)); b.type = "button"; if (c === category) b.setAttribute("aria-current", "page"); b.onclick = () => { category = c; rail.querySelectorAll(".rail-item").forEach((n) => n.removeAttribute("aria-current")); b.setAttribute("aria-current", "page"); draw(); }; return b; }; const settings = h("button", "rail-item", icon("settings", "sm"), h("span", "grow", "Settings")); settings.type = "button"; settings.onclick = () => { rail.querySelectorAll(".rail-item").forEach((n) => n.removeAttribute("aria-current")); settings.setAttribute("aria-current", "page"); void renderSettings(detail, state); }; syncBox = h("div", "rail-foot"); const rail = h("nav", "rail", h("div", "rail-h kv-label", "Vault"), cat("all", "All items", "key"), cat("favorites", "Favourites", "star"), h("div", "rail-h kv-label", "Manage"), settings, h("span", "grow"), syncBox); rail.setAttribute("aria-label", "Categories"); if (state.ipc_error) rail.insertBefore(banner("bad", "Browser connection unavailable", state.ipc_error), syncBox); const add = button("New item", "quiet", () => { selectedId = null; draw(); renderEditor(detail); }); add.prepend(icon("plus", "sm")); const listPane = h("div", "listpane", h("div", "list-head", h("div", "search", icon("search", "sm"), search), add), list); syncPill = h("span"); const lock = iconButton("lock", "Lock", async () => { await invoke("lock"); await render(); }); alarmBox = h("div", "alarm-slot"); app.className = "shell"; app.replaceChildren(titleBar(syncPill, lock), alarmBox, h("div", "cols", rail, listPane, detail)); if (selectedId) await renderDetail(detail, selectedId); else detail.append(emptyDetail()); const status = await invoke("sync_status"); lastChanges = status.changes; showSync(status, detail, state); } function emptyDetail(): HTMLElement { return h("div", "empty-detail", lockup(32), p("kv-muted", "Choose an item to see it here.")); } function syncLine(s: SyncStatus): [ReturnType, string] { if (s.integrity_alarm) return [pill("bad", "Sync stopped"), "Kestrove stopped syncing this vault."]; if (!s.server_url) return [pill("off", "Not syncing"), "Not connected to your server."]; if (s.pending_approval) return [pill("wait", "Waiting for approval"), "Approve this computer on another device."]; if (s.syncing) return [pill("ok", "Syncing…"), "Syncing…"]; if (s.last_error) return [pill("off", "Offline"), s.last_error]; if (s.last_sync_at) return [pill("ok", "Synced"), `Synced ${ago(s.last_sync_at)}`]; return [pill("off", "Not synced yet"), "Not synced yet"]; } /// Sync status: the title-bar pill, the rail footer and the banners; updated on every `sync-status` event. function showSync(s: SyncStatus, detail?: HTMLElement, state?: AppState) { if (!syncBox?.isConnected) return; const openSettings = () => { const d = detail ?? (document.querySelector("section.detail") as HTMLElement | null); if (d && state) void renderSettings(d, state); else if (d) void invoke("state").then((st) => renderSettings(d, st)); }; const [statusPill, line] = syncLine(s); syncPill?.replaceChildren(statusPill); alarmBox?.replaceChildren(...(s.integrity_alarm ? [integrityBanner()] : [])); const parts: Node[] = []; if (s.pending_devices > 0) { parts.push(banner("warn", `${s.pending_devices} device${s.pending_devices > 1 ? "s" : ""} waiting for approval`, undefined, button("Review", "text", openSettings))); } if (s.pending_approval) { parts.push(banner("info", "This computer is waiting for approval", h("span", "", "Fingerprint ", h("span", "fingerprint", groups(s.device_fingerprint ?? ""))))); } if (s.restored_items > 0) { parts.push(banner("info", `${s.restored_items} item${s.restored_items > 1 ? "s" : ""} came back`, "They were deleted on another device but edited here.")); } const now = iconButton("sync", "Sync now", async () => { now.disabled = true; await invoke("sync_now").catch(() => undefined); }); now.disabled = s.syncing || !s.server_url; const lineEl = h("span", `kv-caption grow ${s.last_error && !s.syncing ? "kv-error-text" : "kv-muted"}`, line); lineEl.title = s.server_url ?? ""; parts.push(h("div", "sync-line", lineEl, s.server_url ? now : button("Set up", "text", openSettings))); syncBox.replaceChildren(...parts); } let remask: ReturnType | undefined; function fieldRow(label: string, value: Node, ...actions: Node[]): HTMLElement { return h("div", "kv-frow", h("div", "val", h("span", "kv-label", label), value), ...actions); } async function renderDetail(target: HTMLElement, id: string) { const item = await invoke("get_item", { id }); const rows: HTMLElement[] = []; if (item.username) { rows.push(fieldRow("Username", h("span", "", item.username), copyButton("Copy username", () => invoke("copy_secret", { id, field: "username" })))); } if (item.password) { const value = h("span", "", dots("Password")); let shown = false; const reveal = iconButton("eye", "Reveal password"); const mask = () => { shown = false; clearTimeout(remask); value.replaceChildren(dots("Password")); reveal.replaceChildren(icon("eye")); reveal.setAttribute("aria-label", "Reveal password"); }; reveal.onclick = () => { if (shown) return mask(); shown = true; value.replaceChildren(secret(item.password ?? "")); reveal.replaceChildren(icon("eyeOff")); reveal.setAttribute("aria-label", "Hide password"); // Re-mask after 30 seconds. clearTimeout(remask); remask = setTimeout(() => { if (value.isConnected) mask(); }, 30_000); }; rows.push(fieldRow("Password", value, reveal, copyButton("Copy password", () => invoke("copy_secret", { id, field: "password" })))); } if (item.totp) { let current: HTMLElement = h("span", "kv-code", "––– –––"); const value = h("span", "", current); const secs = h("span", "kv-hint"); const r = ring(); const draw = async () => { if (!current.isConnected) return; const t = await invoke<{ code: string; seconds_remaining: number; period: number } | null>("totp", { id }).catch(() => null); if (t) { const next = codeText(t.code); current.replaceWith(next); current = next; secs.textContent = `${t.seconds_remaining}s`; r.update(t.seconds_remaining, t.period || 30); } setTimeout(draw, 1000); }; rows.push(fieldRow("One-time code", value, secs, r.el, copyButton("Copy one-time code", () => invoke("copy_secret", { id, field: "totp" })))); queueMicrotask(draw); } for (const u of item.urls) rows.push(fieldRow("Website", h("span", "kv-link", u))); if (item.notes) rows.push(fieldRow("Notes", h("span", "notes", item.notes))); const del = button("Delete", "danger", () => confirmModal(`Delete “${item.title}”?`, [p("kv-muted", "It's removed from every device that syncs with your server.")], "Delete", async () => { await invoke("delete_item", { id }); selectedId = null; await render(); }, true)); target.replaceChildren( h("div", "detail-head", tile(item.title), h("h2", "kv-title-2 grow", item.title), del), h("div", "kv-card fields", ...rows), ); } function renderEditor(target: HTMLElement) { const title = input({ autocomplete: "off" }); const user = input({ autocomplete: "off", spellcheck: false }); const pass = input({ autocomplete: "off" }, true); const url = input({ type: "url", placeholder: "https://example.com", spellcheck: false }); const totp = input({ placeholder: "otpauth://totp/… or base32 secret" }, true); const gen = button("Generate", "quiet", async () => { pass.value = await invoke("generate_password", { length: 24 }); }); const err = errBox(); const save = button("Save", "primary"); save.onclick = async () => { try { await invoke("save_item", { item: { title: title.value, username: user.value || null, password: pass.value || null, urls: url.value ? [url.value] : [], totp: totp.value || null }, }); await render(); } catch (e) { setErr(err, e); } }; target.replaceChildren(h("div", "editor stack", h("h2", "kv-title-2", "New login"), field("Title", title), field("Username", user), h("label", "kv-fieldset", h("span", "kv-label", "Password"), h("div", "row", pass, gen)), field("Website", url), field("One-time code", totp, "Optional."), err, h("div", "actions", save, button("Cancel", "secondary", () => target.replaceChildren(emptyDetail()))))); title.focus(); } // ------------------------------------------------------------------ settings function section(title: string, ...children: (Node | string | null)[]): HTMLElement { return h("section", "settings-section", h("h3", "kv-title-3", title), ...children); } async function renderSettings(target: HTMLElement, state: AppState) { const pairings = await invoke("list_pairings"); const idle = h("select", "kv-field", ...[1, 5, 10, 15, 30, 60, 120].map((m) => Object.assign(h("option", "", `${m} min`), { value: String(m), selected: m === state.idle_minutes }))); idle.onchange = () => invoke("set_idle_minutes", { minutes: Number(idle.value) }); idle.setAttribute("aria-label", "Lock after idle for"); const theme = h("div", "seg", ...(["system", "light", "dark"] as Theme[]).map((t) => { const b = button(t === "system" ? "System" : t === "light" ? "Light" : "Dark", "quiet"); b.setAttribute("aria-pressed", String(storedTheme() === t)); b.onclick = () => { setTheme(t); theme.querySelectorAll("button").forEach((x) => x.setAttribute("aria-pressed", String(x === b))); }; return b; })); theme.setAttribute("role", "group"); theme.setAttribute("aria-label", "Appearance"); const list = h("div", "kv-card rows"); for (const pr of pairings) { list.append(h("div", "kv-frow", h("div", "val", h("strong", "", `${pr.name}${pr.caller ? ` · ${pr.caller}` : ""}`), h("span", "kv-small kv-muted words-line", pr.words.join(" ")), h("span", "kv-hint", `Paired ${new Date(pr.created_at * 1000).toLocaleString()}`)), button("Unpair", "danger", async () => { await invoke("revoke_pairing", { id: pr.id }); await renderSettings(target, state); }))); } if (!pairings.length) list.append(h("div", "kv-frow", p("kv-muted", "No browsers paired. Open the Kestrove extension in your browser and choose “Pair”."))); const syncSection = h("div", "stack"); const devicesSection = h("div", "stack"); target.replaceChildren(h("div", "settings stack", h("h2", "kv-title-2", "Settings"), section("General", h("div", "kv-card rows", h("div", "kv-frow", h("div", "val", h("span", "", "Appearance")), theme), h("div", "kv-frow", h("div", "val", h("span", "", "Lock after idle for"), h("span", "kv-hint", "Kestrove also locks on screen lock and sleep.")), idle))), section("Sync", syncSection), section("Devices", devicesSection), section("Emergency Kit", kitSection()), section("Paired browsers", list), )); const status = await invoke("sync_status"); renderSyncSettings(syncSection, status, () => renderSettings(target, state)); if (status.server_url && status.logged_in) await renderDevices(devicesSection); else devicesSection.append(p("kv-muted", "Connect to your server first.")); } function renderSyncSettings(box: HTMLElement, s: SyncStatus, reload: () => Promise) { const fact = (k: string, v: Node | string) => h("div", "kv-frow", h("div", "val", h("span", "kv-label", k), v)); const facts = h("div", "kv-card rows", fact("Server", h("span", "", s.server_url ?? "—")), fact("Certificate pins", h("span", "kv-mono kv-small", s.pins.length ? s.pins.join(", ") : "None (system certificates)")), fact("Last sync", s.last_sync_at ? ago(s.last_sync_at) : "Never"), fact("This device", h("span", "fingerprint", groups(s.device_fingerprint ?? "—"))), fact("Device key", s.key_storage ?? "Unavailable")); box.replaceChildren(facts); if (s.key_warning) box.append(banner("warn", "Your device key is stored in a file", s.key_warning)); if (s.last_error) box.append(banner("bad", "Couldn't sync", s.last_error)); if (s.server_url && s.logged_in) return; // A vault from before sync (or whose registration failed): connect it. const url = input({ type: "url", placeholder: "https://kestrove.example.com", value: s.server_url ?? "" }); const pins = input({ value: s.pins.join(", ") }, true); const email = input({ type: "email", autocomplete: "email", spellcheck: false }); const code = input({}); const err = errBox(); const btn = button("Connect", "primary"); btn.onclick = async () => { btn.disabled = true; err.replaceChildren(); try { await invoke("configure_server", { serverUrl: url.value, pins: pins.value }); const setupOpen = await invoke("server_setup_open"); if (setupOpen && !code.value.trim()) throw new Error("This server has no account yet: enter its setup code to register this vault."); if (setupOpen || email.value.trim()) { await invoke("register_server", { setupCode: code.value, email: email.value }); } else { await invoke("sync_now"); } await reload(); } catch (e) { setErr(err, e); btn.disabled = false; } }; box.append(h("div", "kv-card pad stack", field("Your Kestrove server", url), field("SPKI pins", pins, "Optional."), field("Email", email, "To register this vault as a new account."), field("Invite or setup code", code, "An admin's invite, or the setup code of a new server."), err, h("div", "actions", btn))); } async function renderDevices(box: HTMLElement) { const err = errBox(); const list = h("div", "kv-card rows"); const reload = () => renderDevices(box); let devices: DeviceView[] = []; try { devices = await invoke("list_devices"); } catch (e) { setErr(err, e); } for (const d of devices.filter((d) => !d.revoked)) { const badges = h("span", "badges", d.current ? pill("off", "This device") : null, d.approved ? null : pill("wait", "Waiting for approval")); const actions = h("div", "row"); if (!d.approved) { actions.append(button("Approve…", "primary", () => confirmModal( `Approve “${d.name}”?`, [p("", "Only approve it if the new device shows exactly this fingerprint:"), p("fingerprint lg", groups(d.key_fingerprint))], "The fingerprints match: approve", async () => { await invoke("approve_device", { deviceId: d.device_id }); await reload(); }))); } if (!d.current) { actions.append(button(d.approved ? "Remove" : "Reject", "danger", () => confirmModal(`Remove “${d.name}”?`, [p("kv-muted", "It will be signed out and can't sync again. Its local copy stays on it. To add it back later, set it up again as a new device.")], "Remove device", async () => { await invoke("revoke_device", { deviceId: d.device_id }); await reload(); }, true))); } list.append(h("div", "kv-frow", h("span", "device-ico", icon(/ios|android|phone|tablet/i.test(d.platform) ? "key" : "laptop")), h("div", "val", h("span", "row", h("strong", "", d.name), h("span", "kv-small kv-muted", d.platform), badges), h("span", "fingerprint kv-small kv-muted", groups(d.key_fingerprint)), h("span", "kv-hint", `Added ${new Date(d.created_at).toLocaleString()}`)), actions)); } box.replaceChildren( h("div", "row", button("Add a device…", "secondary", addDeviceModal), button("Approve a computer…", "secondary", approveComputerModal)), list, err); } function kitSection(): HTMLElement { const box = h("div", "stack"); const show = button("Show Emergency Kit setup code", "secondary", async () => { try { const qr = await invoke("kit_qr"); box.replaceChildren( banner("warn", "This code contains your Secret Key", "Keep it as safe as your Emergency Kit."), h("div", "center-row", qrCard(qr, "Emergency Kit setup code")), h("div", "actions", button("Hide", "quiet", () => box.replaceChildren(show)))); } catch (e) { const err = errBox(); setErr(err, e); box.append(err); } }); box.append(h("div", "actions", show)); return box; } // ------------------------------------------------------------------ dialogs function closeModal() { modal.replaceChildren(); modal.hidden = true; } function openModal(...children: Node[]) { const sheet = h("div", "kv-sheet prompt", ...children); sheet.setAttribute("role", "dialog"); sheet.setAttribute("aria-modal", "true"); modal.replaceChildren(sheet); modal.hidden = false; sheet.querySelector("button.primary, textarea, input")?.focus(); } function confirmModal(title: string, body: Node[], yes: string, action: () => Promise, destructive = false) { const err = errBox(); const ok = button(yes, destructive ? "danger-fill" : "primary"); ok.onclick = async () => { ok.disabled = true; try { await action(); closeModal(); } catch (e) { setErr(err, e); ok.disabled = false; } }; openModal(h("h2", "kv-title-2", title), ...body, err, h("div", "actions end", button("Cancel", "secondary", closeModal), ok)); } /// "Add a device": a single-use, 10-minute QR. Cancel revokes the ticket if it wasn't used. async function addDeviceModal() { let view: AddDeviceView; try { view = await invoke("add_device"); } catch (e) { const err = errBox(); setErr(err, e); openModal(h("h2", "kv-title-2", "Add a device"), err, h("div", "actions end", button("Close", "secondary", closeModal))); return; } const done = async () => { closeModal(); await invoke("cancel_add_device"); }; openModal(h("div", "qr-sheet", h("h2", "kv-title-2", "Add a device"), steps("Open Kestrove on the new device.", h("span", "", "Choose ", h("b", "", "Scan setup QR"), "."), "Type your master password there."), qrCard(view.qr_svg, "Setup code for a new device"), countdown(view.expires_at, () => { if (!modal.hidden) void done(); }), p("kv-hint", "It contains your Secret Key and works once. Don't show it to anyone else."), button("Cancel", "quiet", done))); } /// Desktops usually have no camera: approve a new computer from its pasted join code. function approveComputerModal() { const text = h("textarea", "kv-field mono kv-small"); text.rows = 4; text.placeholder = "kestrove://join/v1?…"; text.spellcheck = false; const err = errBox(); const next = button("Continue", "primary"); const cancel = async () => { await invoke("join_cancel"); closeModal(); }; next.onclick = async () => { next.disabled = true; err.replaceChildren(); try { const c = await invoke("approve_join", { joinCode: text.value }); const approve = button("Approve", "primary", undefined, "block"); const err2 = errBox(); approve.onclick = async () => { approve.disabled = true; try { await invoke("send_join"); openModal(h("h2", "kv-title-2", "Approved"), p("", `${c.device_name} will finish setting up and ask for your master password.`), h("div", "actions end", button("Close", "primary", closeModal))); } catch (e) { setErr(err2, e); approve.disabled = false; } }; openModal(h("div", "qr-sheet", h("h2", "kv-title-2", "Approve this computer?"), p("kv-small kv-muted", `${c.device_name} · ${c.platform}`, h("br"), "Check that the computer shows the same code."), h("span", "kv-hint", "The computer should show:"), codeText(c.code, true), p("kv-hint", `Device key ${groups(c.key_fingerprint)}`), err2, approve, button("The codes don't match", "secondary", cancel, "block"))); } catch (e) { setErr(err, e); next.disabled = false; } }; openModal( h("h2", "kv-title-2", "Approve a computer"), p("kv-muted", "On the new computer choose “Use my phone”, open “No phone? Approve from another computer” and copy the join code. Paste it here:"), field("Join code", text), err, h("div", "actions end", button("Cancel", "secondary", cancel), next), ); text.focus(); } function copyText(area: HTMLTextAreaElement) { area.select(); if (navigator.clipboard) void navigator.clipboard.writeText(area.value).catch(() => document.execCommand("copy")); else document.execCommand("copy"); } // ------------------------------------------------------------------ browser prompts (pairing, save) async function renderPrompts(state: AppState) { overlay.replaceChildren(); const pairing = await invoke("pending_pairing"); if (pairing) overlay.append(pairingCard(pairing, state)); if (state.has_account && !state.locked) { for (const s of await invoke("pending_saves")) overlay.append(saveCard(s)); } else if (state.has_account && (await invoke("pending_saves")).length) { overlay.append(h("div", "kv-sheet prompt", banner("info", "Your browser asked to save a login", "Unlock Kestrove to review it."))); } overlay.hidden = !overlay.childElementCount; } function words(ws: string[]): HTMLElement { return h("div", "words", ...ws.map((w) => h("span", "", w))); } function pairingCard(pr: PairingView, state: AppState): HTMLElement { const err = errBox(); const accept = button("The words match: pair", "primary"); const reject = button("Don't pair", "secondary"); const resolve = async (yes: boolean) => { try { await invoke("resolve_pairing", { id: pr.id, accept: yes }); await render(); } catch (e) { setErr(err, e); } }; accept.onclick = () => resolve(true); reject.onclick = () => resolve(false); accept.disabled = state.locked; return h("div", "kv-sheet prompt", h("h2", "kv-title-2", "Pair a browser?"), p("", `${pr.name}${pr.caller ? ` (${pr.caller})` : ""} wants to use Kestrove. Check that the extension shows exactly these words:`), words(pr.words), state.locked ? p("kv-muted", "Unlock Kestrove first, then confirm.") : null, err, h("div", "actions end", reject, accept)); } function saveCard(s: SaveView): HTMLElement { const err = errBox(); const yes = button(s.update ? "Update password" : "Save", "primary"); const no = button("Not now", "secondary"); const resolve = async (accept: boolean) => { try { await invoke("resolve_save", { id: s.id, accept }); await render(); } catch (e) { setErr(err, e); } }; yes.onclick = () => resolve(true); no.onclick = () => resolve(false); return h("div", "kv-sheet prompt", h("h2", "kv-title-2", s.update ? "Update saved password?" : "Save this login?"), h("div", "kv-card rows", h("div", "kv-frow", tile(s.origin.replace(/^https?:\/\/(www\.)?/, "")), h("div", "val", h("strong", "", s.username || "No username"), h("span", "kv-small kv-muted", s.origin)))), err, h("div", "actions end", no, yes)); } // ------------------------------------------------------------------ wiring void listen("pairing-request", () => render()); void listen("save-request", () => render()); void listen("locked", () => { closeModal(); void render(); }); let alarmShown = false; void listen("sync-status", (e) => { const s = e.payload; showSync(s); if (lastChanges !== -1 && s.changes !== lastChanges && refreshList && syncBox?.isConnected) void refreshList(); lastChanges = s.changes; if (s.integrity_alarm && !alarmShown) { alarmShown = true; openModal(integrityBanner(), h("div", "actions end", button("OK", "primary", closeModal))); } }); // Keep "Synced 3 min ago" fresh. setInterval(() => { if (syncBox?.isConnected) void invoke("sync_status").then((s) => showSync(s)); }, 30_000); // Activity for the idle timer (Linux has no system idle time without extra deps). let lastTouch = 0; for (const ev of ["keydown", "mousedown", "mousemove"]) { document.addEventListener(ev, () => { const now = Date.now(); if (now - lastTouch > 30_000) { lastTouch = now; void invoke("touch"); } }, { passive: true }); } void render();