Sync with the server and scan-to-join onboarding
ci / build (ubuntu-22.04) (push) Canceled after 0s
ci / build (windows-2025) (push) Canceled after 0s
ci / deny (push) Canceled after 0s

- Device key (DeviceSigner): Windows TPM via CNG with a DPAPI-sealed software
  fallback, Linux Secret Service with a 0600 file fallback (warned in Settings),
  macOS keychain for dev runs. The store used is recorded; no silent switching.
- SyncService (Tauri-free): server URL and pins, create account with the
  server's setup code, sync after unlock/save/browser save, every 15 min while
  unlocked and on demand, status events, integrity alarm, device list with
  fingerprint approval and revoke.
- Onboarding: "Use my phone" (join_start/poll, QR rendered locally, 6-digit
  code), Emergency Kit from PDF/PNG/JPEG (hayro + rqrr), paste setup code, new
  account. Then only the master password.
- From a signed-in desktop: "Add a phone or tablet" QR with countdown (cancel,
  lock and quit revoke the ticket) and "Approve a computer" via pasted join code.
- KESTROVE_DATA_DIR / KESTROVE_DEVICE_NAME for a second instance; ignored
  live test drives three instances against a real server.
- deny: ignore three unmaintained notices for zbus 5 sub-crates (Linux keyring).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-18 07:51:06 +09:00
co-authored by Claude Opus 5.5
parent 7701b65087
commit e19370f261
13 changed files with 3532 additions and 116 deletions
+600 -32
View File
@@ -8,9 +8,21 @@ interface ItemDto { id: string; title: string; username: string | null; password
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")!;
function el<K extends keyof HTMLElementTagNameMap>(tag: K, props: Partial<HTMLElementTagNameMap[K]> & Record<string, unknown> = {}, ...children: (Node | string)[]): HTMLElementTagNameMap[K] {
const e: HTMLElementTagNameMap[K] = Object.assign(document.createElement(tag), props);
@@ -19,43 +31,296 @@ function el<K extends keyof HTMLElementTagNameMap>(tag: K, props: Partial<HTMLEl
}
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();
function countdown(expiresAt: number, onExpire: () => void): HTMLElement {
const span = el("span", { className: "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();
}
/// 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<AppState>("state");
app.replaceChildren();
if (!state.has_account) renderCreate();
if (!state.has_account) await renderOnboarding();
else if (state.locked) renderUnlock(state);
else await renderVault(state);
await renderPrompts(state);
}
// ------------------------------------------------------------------ account
// ------------------------------------------------------------------ onboarding (docs/10)
function renderCreate() {
const pw = el("input", { type: "password", placeholder: "Master password (10+ chars)" });
type Tab = "phone" | "kit" | "paste" | "create";
async function renderOnboarding(tab: Tab = "phone") {
const status = await invoke<SyncStatus>("sync_status");
const body = el("div", { className: "grid" });
const tabs: [Tab, string][] = [["phone", "Use my phone"], ["kit", "Emergency Kit"], ["paste", "Paste setup code"], ["create", "New account"]];
const nav = el("div", { className: "tabs" }, ...tabs.map(([t, label]) => el("button", {
textContent: label,
className: t === tab ? "tab sel" : "tab",
onclick: async () => {
await invoke("join_cancel");
app.replaceChildren();
screen++;
await renderOnboarding(t);
},
})));
const card = el("div", { className: "card wide" }, el("h2", { textContent: "Set up Kestrove on this computer" }), nav, body);
if (status.key_warning) card.append(el("p", { className: "warn", textContent: status.key_warning }));
app.append(el("div", { className: "center" }, 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 el("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 = el("div", { className: "err" });
const btn = el("button", { textContent: "Create vault", className: "primary" });
const card = el("div", { className: "card" }, el("h2", { textContent: "Create your Kestrove vault" }), pw, btn, err);
btn.onclick = async () => {
btn.disabled = true;
const start = el("button", { textContent: "Show QR code", className: "primary" });
body.append(
el("p", { className: "muted", textContent: "Your phone sends this computer everything it needs. You'll only type your master password." }),
el("label", { className: "grid" }, "Your Kestrove server", url),
start, err,
);
start.onclick = async () => {
start.disabled = true;
err.textContent = "";
try {
const secretKey = await invoke<string>("create_account", { masterPassword: pw.value });
pw.value = "";
card.replaceChildren(
el("h2", { textContent: "Your Secret Key" }),
el("p", { className: "mono", textContent: secretKey }),
el("p", { className: "muted", textContent: "Write this down in your Emergency Kit. You need it to add devices. This device remembers it." }),
el("button", { textContent: "Continue", className: "primary", onclick: render }),
);
const join = await invoke<JoinView>("join_start", { serverUrl: url.value });
showJoin(body, join);
} catch (e) {
err.textContent = errText(e);
start.disabled = false;
}
};
url.onkeydown = (e) => e.key === "Enter" && start.click();
if (!url.value) url.focus();
}
function showJoin(body: HTMLElement, join: JoinView) {
const mine = screen;
const codeBox = el("div", { className: "code muted", textContent: "Waiting for your phone…" });
const err = el("div", { className: "err" });
const text = el("textarea", { readOnly: true, className: "mono small", rows: 3, value: join.qr_text });
const copy = el("button", { textContent: "Copy", onclick: () => copyText(text) });
const cancel = el("button", {
textContent: "Cancel",
onclick: async () => {
await invoke("join_cancel");
await render();
},
});
body.replaceChildren(
el("ol", { className: "steps" },
el("li", { textContent: "On your phone, open Kestrove → Settings → Add a computer." }),
el("li", { textContent: "Scan this code." }),
el("li", { textContent: "Check that both screens show the same 6 digits, then approve on the phone." })),
el("img", { src: join.qr_svg, alt: "QR code for your phone", className: "qr" }),
el("div", { className: "row" }, countdown(join.expires_at, () => {
if (mine === screen) err.textContent = "The code expired. Cancel and start again.";
})),
codeBox,
el("details", {},
el("summary", { textContent: "No phone? Approve from another computer" }),
el("p", { className: "muted", textContent: "On a computer that is already signed in, open Settings → Devices → Approve a computer, and paste this join code:" }),
text, el("div", { className: "row" }, copy)),
err,
el("div", { className: "row" }, cancel),
);
void (async () => {
while (mine === screen) {
await sleep(2000);
if (mine !== screen) return;
try {
const p = await invoke<JoinPollView>("join_poll");
if (p.code) {
codeBox.className = "code";
codeBox.replaceChildren(el("div", { className: "muted", textContent: "Your phone should show:" }), el("div", { className: "digits", textContent: p.code }));
}
if (p.setup) {
if (mine === screen) finishSetup(body, p.setup);
return;
}
} catch (e) {
err.textContent = errText(e);
return;
}
}
})();
}
function kitPanel(body: HTMLElement) {
const file = el("input", { type: "file", accept: ".png,.jpg,.jpeg,.pdf,image/png,image/jpeg,application/pdf" });
const err = el("div", { className: "err" });
const open = el("button", { textContent: "Open Emergency Kit…", className: "primary", onclick: () => file.click() });
file.hidden = true;
file.onchange = async () => {
const f = file.files?.[0];
if (!f) return;
err.textContent = "";
open.disabled = true;
open.textContent = "Reading…";
try {
const bytes = new Uint8Array(await f.arrayBuffer());
const view = await invoke<SetupView>("setup_from_file", bytes);
finishSetup(body, view);
} catch (e) {
err.textContent = errText(e);
} finally {
open.disabled = false;
open.textContent = "Open Emergency Kit…";
file.value = "";
}
};
body.append(
el("p", { className: "muted", textContent: "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, open, err,
);
}
function pastePanel(body: HTMLElement) {
const text = el("textarea", { rows: 4, className: "mono small", placeholder: "kestrove://setup/v1?…", spellcheck: false });
const err = el("div", { className: "err" });
const next = el("button", { textContent: "Continue", className: "primary" });
next.onclick = async () => {
err.textContent = "";
try {
const view = await invoke<SetupView>("setup_from_text", { text: text.value });
text.value = "";
finishSetup(body, view);
} catch (e) {
err.textContent = errText(e);
}
};
body.append(el("p", { className: "muted", textContent: "Paste the setup code from your Emergency Kit or from “Add a device” on another device." }), text, next, err);
text.focus();
}
/// The last step of every way in: only the master password.
function finishSetup(body: HTMLElement, view: SetupView) {
const pw = el("input", { type: "password", placeholder: "Master password", autocomplete: "current-password" });
const err = el("div", { className: "err" });
const btn = el("button", { textContent: "Sign in", className: "primary" });
const waiting = el("div");
const submit = async () => {
btn.disabled = true;
err.textContent = "";
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<SyncStatus>("sync_status");
waiting.replaceChildren(el("div", { className: "notice" },
el("p", { textContent: "Almost done: approve this computer on a device that is already signed in (Settings → Devices). Check that it shows this fingerprint:" }),
el("p", { className: "mono", textContent: groups(status.device_fingerprint ?? "") }),
el("p", { className: "muted", textContent: "Then come back and choose “Check again”." })));
btn.textContent = "Check again";
} catch (e) {
err.textContent = errText(e);
btn.textContent = "Sign in";
}
btn.disabled = false;
};
btn.onclick = submit;
pw.onkeydown = (e) => e.key === "Enter" && void submit();
body.replaceChildren(
el("p", {}, "Signing in to ", el("strong", { textContent: view.server_host }), "."),
el("p", { className: "muted", textContent: view.preapproved ? "This computer is pre-approved." : "Another device will need to approve this computer." }),
pw, btn, waiting, err,
el("button", { textContent: "Start over", className: "link", onclick: async () => { await invoke("clear_setup"); await render(); } }),
);
pw.focus();
}
function createPanel(body: HTMLElement, status: SyncStatus) {
const url = serverInput(status);
const code = el("input", { placeholder: "Setup code (KV_SETUP_CODE in the server's .env)", spellcheck: false });
const pins = el("input", { placeholder: "SPKI pins, base64, comma-separated (optional)", className: "mono small", spellcheck: false });
const pw = el("input", { type: "password", placeholder: "Master password (10+ characters)", autocomplete: "new-password" });
const pw2 = el("input", { type: "password", placeholder: "Repeat master password", autocomplete: "new-password" });
const err = el("div", { className: "err" });
const btn = el("button", { textContent: "Create account", className: "primary" });
btn.onclick = async () => {
err.textContent = "";
if (pw.value !== pw2.value) {
err.textContent = "The passwords don't match.";
return;
}
btn.disabled = true;
btn.textContent = "Creating…";
try {
const secretKey = await invoke<string>("create_account", { serverUrl: url.value, pins: pins.value, setupCode: code.value, masterPassword: pw.value });
pw.value = pw2.value = "";
await showSecretKey(secretKey);
} catch (e) {
err.textContent = errText(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<AppState>("state")).has_account) {
body.append(el("button", { textContent: "Continue to the vault", onclick: render }));
}
}
};
app.append(el("div", { className: "center" }, card));
pw.focus();
body.append(
el("p", { className: "muted", textContent: "For the first device on a new server. You'll get a Secret Key: keep it with your Emergency Kit." }),
el("label", { className: "grid" }, "Server", url),
el("label", { className: "grid" }, "Setup code", code),
el("details", {}, el("summary", { textContent: "Certificate pins (advanced)" }), pins),
pw, pw2, btn, err,
);
}
async function showSecretKey(secretKey: string) {
const kit = await invoke<string>("kit_qr").catch(() => null);
const card = el("div", { className: "card wide" },
el("h2", { textContent: "Your Secret Key" }),
el("p", { className: "mono big", textContent: secretKey }),
el("p", { className: "muted", textContent: "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(
el("p", { textContent: "Setup code for your Emergency Kit (server, account and Secret Key; keep it as safe as the Secret Key):" }),
el("img", { src: kit, alt: "Emergency Kit setup code", className: "qr" }));
}
card.append(el("button", { textContent: "Continue", className: "primary", onclick: render }));
app.replaceChildren(el("div", { className: "center" }, card));
}
// ------------------------------------------------------------------ unlock
function renderUnlock(state: AppState) {
const pw = el("input", { type: "password", placeholder: "Master password" });
const sk = el("input", { placeholder: "Secret Key (K1-…)", className: "mono" });
@@ -84,26 +349,86 @@ function renderUnlock(state: AppState) {
// ------------------------------------------------------------------ vault
let refreshList: (() => Promise<void>) | null = null;
let syncBox: HTMLElement | null = null;
async function renderVault(state: AppState) {
const items = await invoke<ItemSummary[]>("list_items");
const detail = el("section");
const list = el("ul");
for (const it of items) {
const li = el("li", {}, el("div", { textContent: it.title }), el("div", { className: "muted", textContent: it.username ?? "" }));
li.onclick = async () => {
list.querySelectorAll("li").forEach((n) => n.classList.remove("sel"));
li.classList.add("sel");
await renderDetail(detail, it.id);
};
list.append(li);
}
if (!items.length) list.append(el("li", { className: "muted", textContent: "No items yet." }));
const fill = async () => {
const items = await invoke<ItemSummary[]>("list_items");
const selected = list.querySelector("li.sel")?.getAttribute("data-id");
list.replaceChildren();
for (const it of items) {
const li = el("li", {}, el("div", { textContent: it.title }), el("div", { className: "muted", textContent: it.username ?? "" }));
li.dataset.id = it.id;
if (it.id === selected) li.classList.add("sel");
li.onclick = async () => {
list.querySelectorAll("li").forEach((n) => n.classList.remove("sel"));
li.classList.add("sel");
await renderDetail(detail, it.id);
};
list.append(li);
}
if (!items.length) list.append(el("li", { className: "muted", textContent: "No items yet." }));
};
await fill();
refreshList = fill;
const add = el("button", { textContent: "+ New login", onclick: () => renderEditor(detail) });
const settings = el("button", { textContent: "Settings", onclick: () => renderSettings(detail, state) });
const lock = el("button", { textContent: "Lock", onclick: async () => { await invoke("lock"); await render(); } });
const aside = el("aside", {}, el("div", { className: "toolbar" }, add, settings, lock), list);
if (state.ipc_error) aside.append(el("p", { className: "err pad", textContent: state.ipc_error }));
syncBox = el("div", { className: "sync" });
const aside = el("aside", {}, el("div", { className: "toolbar" }, add, settings, lock), list, syncBox);
if (state.ipc_error) aside.insertBefore(el("p", { className: "err pad", textContent: state.ipc_error }), list);
app.append(aside, detail);
const status = await invoke<SyncStatus>("sync_status");
lastChanges = status.changes;
showSync(status, detail, state);
}
/// Sync footer and banners in the sidebar; 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("main section") as HTMLElement | null);
if (d && state) void renderSettings(d, state);
else if (d) void invoke<AppState>("state").then((st) => renderSettings(d, st));
};
const parts: Node[] = [];
if (s.integrity_alarm) {
parts.push(el("div", { className: "alarm" },
el("strong", { textContent: "Sync stopped: integrity alarm" }),
el("p", { textContent: s.integrity_alarm }),
el("p", { textContent: "The server (or someone controlling it) sent data that doesn't match what your devices signed. Don't trust this server until you've checked it." })));
}
if (s.pending_devices > 0) {
parts.push(el("div", { className: "notice" }, `${s.pending_devices} device${s.pending_devices > 1 ? "s" : ""} waiting for approval. `,
el("button", { textContent: "Review", className: "link", onclick: openSettings })));
}
if (s.pending_approval) {
parts.push(el("div", { className: "notice", textContent: `This computer waits for approval on another device. Fingerprint: ${groups(s.device_fingerprint ?? "")}` }));
}
if (s.restored_items > 0) {
parts.push(el("div", { className: "notice", textContent: `${s.restored_items} item${s.restored_items > 1 ? "s" : ""} deleted on another device came back because they were edited here.` }));
}
let line: string;
if (!s.server_url) line = "Not connected to a sync server";
else if (s.syncing) line = "Syncing…";
else if (s.last_error) line = s.last_error;
else if (s.last_sync_at) line = `Synced ${ago(s.last_sync_at)}`;
else line = "Not synced yet";
const now = el("button", {
textContent: "Sync now",
disabled: s.syncing || !s.server_url,
onclick: async () => {
now.disabled = true;
await invoke("sync_now").catch(() => undefined);
},
});
parts.push(el("div", { className: "row between" },
el("span", { className: s.last_error && !s.syncing ? "err small" : "muted small", textContent: line, title: s.server_url ?? "" }),
s.server_url ? now : el("button", { textContent: "Set up", onclick: openSettings })));
syncBox.replaceChildren(...parts);
}
async function renderDetail(target: HTMLElement, id: string) {
@@ -166,6 +491,8 @@ function renderEditor(target: HTMLElement) {
target.replaceChildren(el("div", { className: "card" }, el("h2", { textContent: "New login" }), title, user, el("div", { className: "row" }, pass, gen), url, totp, save, err));
}
// ------------------------------------------------------------------ settings
async function renderSettings(target: HTMLElement, state: AppState) {
const pairings = await invoke<Pairing[]>("list_pairings");
const idle = el("select", {}, ...[1, 5, 10, 15, 30, 60, 120].map((m) => el("option", { value: String(m), textContent: `${m} min`, selected: m === state.idle_minutes })));
@@ -187,13 +514,234 @@ async function renderSettings(target: HTMLElement, state: AppState) {
);
}
if (!pairings.length) list.append(el("li", { className: "muted", textContent: "No browsers paired. Open the Kestrove extension in your browser and choose “Pair”." }));
const syncSection = el("div");
const devicesSection = el("div");
target.replaceChildren(
el("h2", { textContent: "Settings" }),
el("label", { className: "row" }, "Lock after idle for ", idle),
el("p", { className: "muted", textContent: "Kestrove also locks on screen lock and sleep." }),
el("h3", { textContent: "Sync" }),
syncSection,
el("h3", { textContent: "Devices" }),
devicesSection,
el("h3", { textContent: "Emergency Kit" }),
kitSection(),
el("h3", { textContent: "Paired browsers" }),
list,
);
const status = await invoke<SyncStatus>("sync_status");
renderSyncSettings(syncSection, status, () => renderSettings(target, state));
if (status.server_url && status.logged_in) await renderDevices(devicesSection);
else devicesSection.append(el("p", { className: "muted", textContent: "Connect to a sync server first." }));
}
function renderSyncSettings(box: HTMLElement, s: SyncStatus, reload: () => Promise<void>) {
const facts = el("dl", { className: "facts" },
el("dt", { textContent: "Server" }), el("dd", { className: "mono", textContent: s.server_url ?? "—" }),
el("dt", { textContent: "Pins" }), el("dd", { className: "mono small", textContent: s.pins.length ? s.pins.join(", ") : "none (system certificates)" }),
el("dt", { textContent: "Last sync" }), el("dd", { textContent: s.last_sync_at ? ago(s.last_sync_at) : "never" }),
el("dt", { textContent: "This device" }), el("dd", { className: "mono", textContent: groups(s.device_fingerprint ?? "—") }),
el("dt", { textContent: "Device key" }), el("dd", { textContent: s.key_storage ?? "unavailable" }));
box.replaceChildren(facts);
if (s.key_warning) box.append(el("p", { className: "warn", textContent: s.key_warning }));
if (s.last_error) box.append(el("p", { className: "err", textContent: s.last_error }));
if (s.server_url && s.logged_in) return;
// A vault from before sync (or whose registration failed): connect it.
const url = el("input", { type: "url", placeholder: "https://kestrove.example.com", value: s.server_url ?? "" });
const pins = el("input", { placeholder: "SPKI pins (optional)", className: "mono small", value: s.pins.join(", ") });
const code = el("input", { placeholder: "Setup code (only for a new server)" });
const err = el("div", { className: "err" });
const btn = el("button", { textContent: "Connect", className: "primary" });
btn.onclick = async () => {
btn.disabled = true;
err.textContent = "";
try {
await invoke("configure_server", { serverUrl: url.value, pins: pins.value });
if (await invoke<boolean>("server_setup_open")) {
if (!code.value.trim()) throw new Error("This server has no account yet: enter its setup code to register this vault.");
await invoke("register_server", { setupCode: code.value });
} else {
await invoke("sync_now");
}
await reload();
} catch (e) {
err.textContent = errText(e);
btn.disabled = false;
}
};
box.append(el("div", { className: "card" }, url, pins, code, btn, err));
}
async function renderDevices(box: HTMLElement) {
const err = el("div", { className: "err" });
const list = el("ul", { className: "plain" });
const reload = () => renderDevices(box);
let devices: DeviceView[] = [];
try {
devices = await invoke<DeviceView[]>("list_devices");
} catch (e) {
err.textContent = errText(e);
}
for (const d of devices.filter((d) => !d.revoked)) {
const badges = [d.current ? "this device" : "", d.approved ? "" : "waiting for approval"].filter(Boolean).join(" · ");
const li = el("li", {},
el("div", {}, el("strong", { textContent: d.name }), ` ${d.platform}`, badges ? el("span", { className: d.approved ? "muted" : "warn", textContent: ` · ${badges}` }) : ""),
el("div", { className: "muted mono small", textContent: groups(d.key_fingerprint) }),
el("div", { className: "muted small", textContent: `Added ${new Date(d.created_at).toLocaleString()}` }));
const actions = el("div", { className: "row" });
if (!d.approved) {
actions.append(el("button", {
textContent: "Approve…",
className: "primary",
onclick: () => confirmModal(
`Approve “${d.name}”?`,
[el("p", { textContent: "Only approve it if the new device shows exactly this fingerprint:" }), el("p", { className: "mono big", textContent: groups(d.key_fingerprint) })],
"The fingerprints match: approve",
async () => { await invoke("approve_device", { deviceId: d.device_id }); await reload(); }),
}));
}
if (!d.current) {
actions.append(el("button", {
textContent: d.approved ? "Remove" : "Reject",
className: "danger",
onclick: () => confirmModal(`Remove “${d.name}”?`, [el("p", { textContent: "It will be signed out and can't sync again. Its local copy stays on it." })], "Remove device",
async () => { await invoke("revoke_device", { deviceId: d.device_id }); await reload(); }),
}));
}
li.append(actions);
list.append(li);
}
box.replaceChildren(
el("div", { className: "row" },
el("button", { textContent: "Add a phone or tablet…", onclick: addDeviceModal }),
el("button", { textContent: "Approve a computer…", onclick: approveComputerModal })),
list, err);
}
function kitSection(): HTMLElement {
const box = el("div");
const show = el("button", {
textContent: "Show Emergency Kit setup code",
onclick: async () => {
try {
const qr = await invoke<string>("kit_qr");
box.replaceChildren(
el("p", { className: "warn", textContent: "This code contains your Secret Key. Keep it as safe as your Emergency Kit." }),
el("img", { src: qr, alt: "Emergency Kit setup code", className: "qr" }),
el("button", { textContent: "Hide", onclick: () => box.replaceChildren(show) }));
} catch (e) {
box.append(el("p", { className: "err", textContent: errText(e) }));
}
},
});
box.append(show);
return box;
}
// ------------------------------------------------------------------ dialogs
function closeModal() {
modal.replaceChildren();
modal.hidden = true;
}
function openModal(...children: Node[]) {
modal.replaceChildren(el("div", { className: "prompt" }, ...children));
modal.hidden = false;
}
function confirmModal(title: string, body: Node[], yes: string, action: () => Promise<void>) {
const err = el("div", { className: "err" });
const ok = el("button", { textContent: yes, className: "primary" });
ok.onclick = async () => {
ok.disabled = true;
try {
await action();
closeModal();
} catch (e) {
err.textContent = errText(e);
ok.disabled = false;
}
};
openModal(el("h3", { textContent: title }), ...body, el("div", { className: "row" }, ok, el("button", { textContent: "Cancel", onclick: closeModal })), err);
}
/// "Add a device": a single-use, 10-minute QR. Cancel or Done revokes the ticket if it wasn't used.
async function addDeviceModal() {
let view: AddDeviceView;
try {
view = await invoke<AddDeviceView>("add_device");
} catch (e) {
openModal(el("h3", { textContent: "Add a device" }), el("p", { className: "err", textContent: errText(e) }), el("button", { textContent: "Close", onclick: closeModal }));
return;
}
const done = async () => {
closeModal();
await invoke("cancel_add_device");
};
openModal(
el("h3", { textContent: "Add a phone or tablet" }),
el("p", { textContent: "In Kestrove on the new device, choose “Scan setup code” and scan this. Then it only asks for your master password." }),
el("img", { src: view.qr_svg, alt: "Setup code for a new device", className: "qr" }),
el("p", { className: "warn small", textContent: "It contains your Secret Key and works once. Don't show it to anyone else." }),
el("div", { className: "row between" }, countdown(view.expires_at, () => { if (!modal.hidden) void done(); }), el("button", { textContent: "Done", className: "primary", onclick: done })),
);
}
/// Desktops usually have no camera: approve a new computer from its pasted join code.
function approveComputerModal() {
const text = el("textarea", { rows: 4, className: "mono small", placeholder: "kestrove://join/v1?…", spellcheck: false });
const err = el("div", { className: "err" });
const next = el("button", { textContent: "Continue", className: "primary" });
const cancel = async () => {
await invoke("join_cancel");
closeModal();
};
next.onclick = async () => {
next.disabled = true;
err.textContent = "";
try {
const c = await invoke<JoinConfirmView>("approve_join", { joinCode: text.value });
const approve = el("button", { textContent: "The codes match: approve", className: "primary" });
const err2 = el("div", { className: "err" });
approve.onclick = async () => {
approve.disabled = true;
try {
await invoke("send_join");
openModal(el("h3", { textContent: "Approved" }), el("p", { textContent: `${c.device_name} will finish setting up and ask for your master password.` }), el("button", { textContent: "Close", className: "primary", onclick: closeModal }));
} catch (e) {
err2.textContent = errText(e);
approve.disabled = false;
}
};
openModal(
el("h3", { textContent: `Add “${c.device_name}” (${c.platform})?` }),
el("p", { textContent: "Check that the new computer shows exactly:" }),
el("div", { className: "digits", textContent: c.code }),
el("p", { className: "muted small", textContent: `Device key ${groups(c.key_fingerprint)}` }),
el("div", { className: "row" }, approve, el("button", { textContent: "Cancel", onclick: cancel })),
err2,
);
} catch (e) {
err.textContent = errText(e);
next.disabled = false;
}
};
openModal(
el("h3", { textContent: "Approve a computer" }),
el("p", { textContent: "On the new computer choose “Use my phone”, open “No phone? Approve from another computer” and copy the join code. Paste it here:" }),
text,
el("div", { className: "row" }, next, el("button", { textContent: "Cancel", onclick: cancel })),
err,
);
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)
@@ -260,7 +808,27 @@ function saveCard(s: SaveView): HTMLElement {
void listen("pairing-request", () => render());
void listen("save-request", () => render());
void listen("locked", () => render());
void listen("locked", () => {
closeModal();
void render();
});
let alarmShown = false;
void listen<SyncStatus>("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(el("h3", { textContent: "Sync stopped: integrity alarm" }), el("p", { textContent: s.integrity_alarm }),
el("p", { textContent: "Your local data is unchanged and stays usable. Don't trust the server until you've checked it." }),
el("button", { textContent: "OK", className: "primary", onclick: closeModal }));
}
});
// Keep "Synced 3 min ago" fresh.
setInterval(() => {
if (syncBox?.isConnected) void invoke<SyncStatus>("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;