Initial scaffold for kestrove-desktop

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-15 18:30:14 +09:00
co-authored by Claude Opus 5.5
commit d2cbd56ff7
19 changed files with 1802 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
name: ci
on: [push, pull_request]
jobs:
build:
strategy:
matrix: { os: [ubuntu-22.04, windows-latest] }
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
with: { path: kestrove-desktop }
- uses: actions/checkout@v4
with: { repository: ${{ github.repository_owner }}/kestrove-core, path: kestrove-core, token: ${{ secrets.CORE_READ_TOKEN }} }
- if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libxdo-dev
- uses: dtolnay/rust-toolchain@stable
- uses: actions/setup-node@v4
with: { node-version: 22 }
- run: npm ci && npm run build
working-directory: kestrove-desktop
- run: cargo check --all-targets
working-directory: kestrove-desktop/src-tauri
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
src-tauri/target/
src-tauri/gen/
+23
View File
@@ -0,0 +1,23 @@
# kestrove-desktop
**Windows and Linux** app built on Tauri 2. The Rust side links `kestrove-core`'s `kv-ffi`
directly. The web UI only renders and never touches keys. macOS uses the native `kestrove-apple` app.
Also ships `kestrove-nmh`, the **native-messaging host** that bridges `kestrove-browser` to the app.
```bash
# prerequisites: rustup, Node 20+, and Tauri's OS deps (https://tauri.app/start/prerequisites/)
npm install
npm run tauri icon path/to/logo.png # generates src-tauri/icons (required before building)
npm run tauri dev
npm run tauri build # MSI/NSIS on Windows, AppImage/deb on Linux
```
## Roadmap (M3, kestrove-spec/docs/07)
- [ ] Persistent vault + sync (kv-store / kv-sync)
- [ ] Unlock with Windows Hello (KeyCredentialManager, TPM) and libsecret on Linux
- [ ] Auto-lock on idle, screen lock and suspend
- [ ] Local IPC between `kestrove-nmh` and the app, plus the pairing AEAD channel
- [ ] **Quick Access** global hotkey (Ctrl+Shift+Space): search, then copy or auto-type (Windows `SendInput`, X11 XTest; Wayland gets copy only)
- [ ] Installer registers native-messaging manifests (`native-messaging/`)
- [ ] Windows 11 passkey plugin provider
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Kestrove</title>
<link rel="stylesheet" href="/src/style.css" />
</head>
<body>
<main id="app"></main>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+13
View File
@@ -0,0 +1,13 @@
# Native messaging manifests
The installer fills in the placeholders and registers the manifests:
| Browser | Linux | Windows |
|---|---|---|
| Chrome | `~/.config/google-chrome/NativeMessagingHosts/app.kestrove.nmh.json` | `HKCU\Software\Google\Chrome\NativeMessagingHosts\app.kestrove.nmh` → path to json |
| Edge | `~/.config/microsoft-edge/NativeMessagingHosts/` | `HKCU\Software\Microsoft\Edge\NativeMessagingHosts\app.kestrove.nmh` |
| Firefox | `~/.mozilla/native-messaging-hosts/app.kestrove.nmh.json` | `HKCU\Software\Mozilla\NativeMessagingHosts\app.kestrove.nmh` |
| Brave | `~/.config/BraveSoftware/Brave-Browser/NativeMessagingHosts/` | same key as Chrome |
`allowed_origins` / `allowed_extensions` pin the host to **your** extension id.
That is the first line of defense against other extensions (docs/04 A8).
@@ -0,0 +1,7 @@
{
"name": "app.kestrove.nmh",
"description": "Kestrove native messaging host",
"path": "__INSTALL_DIR__/kestrove-nmh",
"type": "stdio",
"allowed_origins": ["chrome-extension://__CHROME_EXTENSION_ID__/"]
}
@@ -0,0 +1,7 @@
{
"name": "app.kestrove.nmh",
"description": "Kestrove native messaging host",
"path": "__INSTALL_DIR__/kestrove-nmh",
"type": "stdio",
"allowed_extensions": ["__FIREFOX_EXTENSION_ID__"]
}
+1282
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
{
"name": "kestrove-desktop",
"version": "0.1.0",
"private": true,
"license": "Apache-2.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"tauri": "tauri"
},
"dependencies": {
"@tauri-apps/api": "2.1.1"
},
"devDependencies": {
"@tauri-apps/cli": "2.1.0",
"typescript": "5.7.2",
"vite": "6.0.3"
}
}
+33
View File
@@ -0,0 +1,33 @@
[package]
name = "kestrove-desktop"
version = "0.1.0"
edition = "2021"
rust-version = "1.82"
license = "Apache-2.0"
publish = false
[[bin]]
name = "kestrove-desktop"
path = "src/main.rs"
# Native messaging host: started by the browser, relays to the running app (docs/05).
[[bin]]
name = "kestrove-nmh"
path = "src/bin/kestrove-nmh.rs"
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
# Repos are checked out side by side (see ../../README.md).
kv-ffi = { path = "../../kestrove-core/crates/kv-ffi" }
tauri = { version = "2", features = [] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
arboard = "3"
[profile.release]
lto = true
codegen-units = 1
panic = "abort"
strip = true
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+7
View File
@@ -0,0 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Main window: core IPC only. No fs, shell or http plugins.",
"windows": ["main"],
"permissions": ["core:default"]
}
+54
View File
@@ -0,0 +1,54 @@
//! Native messaging host for Chrome/Edge/Firefox (kestrove-spec/docs/05).
//!
//! The browser starts this process and talks over stdio: each message is a u32 length
//! (native byte order) followed by UTF-8 JSON, at most 1 MiB to the browser.
//! This process holds **no** secrets. It relays each request to the running desktop app over
//! a local IPC channel (Windows named pipe `\\.\pipe\kestrove-nmh-<user>`, Unix socket
//! `$XDG_RUNTIME_DIR/kestrove/nmh.sock` with 0600 perms). The app re-checks origins and asks for
//! approval or unlock as needed.
use std::io::{self, Read, Write};
use serde_json::{json, Value};
const MAX_IN: usize = 4 * 1024 * 1024;
fn read_message(stdin: &mut impl Read) -> io::Result<Option<Value>> {
let mut len = [0u8; 4];
match stdin.read_exact(&mut len) {
Ok(()) => {}
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(None), // browser closed the port
Err(e) => return Err(e),
}
let len = u32::from_ne_bytes(len) as usize;
if len > MAX_IN {
return Err(io::Error::new(io::ErrorKind::InvalidData, "message too large"));
}
let mut buf = vec![0u8; len];
stdin.read_exact(&mut buf)?;
serde_json::from_slice(&buf).map(Some).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
}
fn write_message(stdout: &mut impl Write, v: &Value) -> io::Result<()> {
let bytes = serde_json::to_vec(v)?;
stdout.write_all(&(bytes.len() as u32).to_ne_bytes())?;
stdout.write_all(&bytes)?;
stdout.flush()
}
/// TODO(M3): forward to the desktop app over local IPC, inside the paired AEAD channel.
fn relay(req: &Value) -> Value {
match req.get("type").and_then(Value::as_str) {
Some("hello") => json!({ "type": "hello", "version": env!("CARGO_PKG_VERSION"), "paired": false }),
Some(_) => json!({ "type": "error", "code": "unavailable" }),
None => json!({ "type": "error", "code": "bad_request" }),
}
}
fn main() -> io::Result<()> {
let (mut stdin, mut stdout) = (io::stdin().lock(), io::stdout().lock());
while let Some(req) = read_message(&mut stdin)? {
write_message(&mut stdout, &relay(&req))?;
}
Ok(())
}
+151
View File
@@ -0,0 +1,151 @@
// Kestrove desktop (Windows/Linux; macOS uses kestrove-apple). Thin Tauri shell over kv-ffi.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use std::{sync::Arc, thread, time::Duration};
use kv_ffi::{generate_password as core_generate_password, CoreError, ItemDto, ItemKindDto, VaultCore};
use serde::{Deserialize, Serialize};
use tauri::State;
struct App {
// TODO(M3): persistent store + sync (kv-store/kv-sync) instead of in-memory.
core: Arc<VaultCore>,
}
#[derive(Serialize)]
struct AppState {
has_account: bool,
locked: bool,
}
#[derive(Serialize)]
struct Summary {
id: String,
title: String,
username: Option<String>,
urls: Vec<String>,
favorite: bool,
}
#[derive(Serialize)]
struct Detail {
id: String,
title: String,
username: Option<String>,
password: Option<String>,
urls: Vec<String>,
totp: Option<String>,
notes: Option<String>,
}
#[derive(Deserialize)]
struct NewLogin {
title: String,
username: Option<String>,
password: Option<String>,
urls: Vec<String>,
}
type CmdResult<T> = Result<T, String>;
fn err(e: CoreError) -> String {
e.to_string()
}
#[tauri::command]
fn state(app: State<'_, App>) -> AppState {
AppState { has_account: app.core.has_account(), locked: app.core.is_locked() }
}
// Argon2id takes ~0.5 s, so these run as async commands off the main thread.
#[tauri::command]
async fn create_account(app: State<'_, App>, master_password: String) -> CmdResult<String> {
app.core.create_account(master_password).map_err(err)
}
#[tauri::command]
async fn unlock(app: State<'_, App>, master_password: String, secret_key: String) -> CmdResult<()> {
app.core.unlock(master_password, secret_key).map_err(err)
}
#[tauri::command]
fn lock(app: State<'_, App>) {
app.core.lock();
}
#[tauri::command]
fn list_items(app: State<'_, App>) -> CmdResult<Vec<Summary>> {
Ok(app
.core
.list_items()
.map_err(err)?
.into_iter()
.map(|s| Summary { id: s.id, title: s.title, username: s.username, urls: s.urls, favorite: s.favorite })
.collect())
}
#[tauri::command]
fn get_item(app: State<'_, App>, id: String) -> CmdResult<Detail> {
let d = app.core.get_item(id).map_err(err)?;
Ok(Detail { id: d.id, title: d.title, username: d.username, password: d.password, urls: d.urls, totp: d.totp, notes: d.notes })
}
#[tauri::command]
fn save_item(app: State<'_, App>, item: NewLogin) -> CmdResult<String> {
app.core
.save_item(ItemDto {
id: String::new(),
kind: ItemKindDto::Login,
title: item.title,
username: item.username,
password: item.password,
urls: item.urls,
totp: None,
notes: None,
favorite: false,
tags: vec![],
modified_at: 0,
})
.map_err(err)
}
/// Copies the password from Rust so it never needs to round-trip through JS, then clears it after 90 s.
/// TODO(M3): on Windows also set ExcludeClipboardContentFromMonitorProcessing (docs/04).
#[tauri::command]
fn copy_secret(app: State<'_, App>, id: String) -> CmdResult<()> {
let secret = app.core.get_item(id).map_err(err)?.password.unwrap_or_default();
let mut cb = arboard::Clipboard::new().map_err(|e| e.to_string())?;
cb.set_text(secret.clone()).map_err(|e| e.to_string())?;
thread::spawn(move || {
thread::sleep(Duration::from_secs(90));
if let Ok(mut cb) = arboard::Clipboard::new() {
if cb.get_text().ok().as_deref() == Some(secret.as_str()) {
let _ = cb.clear();
}
}
});
Ok(())
}
#[tauri::command]
fn generate_password(length: u32) -> String {
core_generate_password(length, true)
}
fn main() {
tauri::Builder::default()
.manage(App { core: VaultCore::new_in_memory() })
.invoke_handler(tauri::generate_handler![
state,
create_account,
unlock,
lock,
list_items,
get_item,
save_item,
copy_secret,
generate_password
])
.run(tauri::generate_context!())
.expect("error while running Kestrove");
}
+23
View File
@@ -0,0 +1,23 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Kestrove",
"version": "0.1.0",
"identifier": "app.kestrove.desktop",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"windows": [{ "title": "Kestrove", "width": 960, "height": 640, "minWidth": 720, "minHeight": 480 }],
"security": {
"csp": "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src ipc: http://ipc.localhost"
}
},
"bundle": {
"active": true,
"targets": ["msi", "nsis", "appimage", "deb"],
"icon": ["icons/32x32.png", "icons/128x128.png", "icons/icon.ico", "icons/icon.png"]
}
}
+106
View File
@@ -0,0 +1,106 @@
// Minimal desktop UI. All vault logic runs in Rust (src-tauri → kv-ffi); this layer only renders.
import { invoke } from "@tauri-apps/api/core";
interface ItemSummary { id: string; title: string; username: string | null; urls: string[]; favorite: boolean }
interface ItemDto { id: string; title: string; username: string | null; password: string | null; urls: string[]; totp: string | null; notes: string | null }
const app = document.getElementById("app")!;
function el<K extends keyof HTMLElementTagNameMap>(tag: K, props: Record<string, unknown> = {}, ...children: (Node | string)[]): HTMLElementTagNameMap[K] {
const e: HTMLElementTagNameMap[K] = Object.assign(document.createElement(tag), props);
e.append(...children);
return e;
}
async function render() {
const state = await invoke<{ has_account: boolean; locked: boolean }>("state");
app.replaceChildren();
if (!state.has_account) return renderCreate();
if (state.locked) return renderUnlock();
return renderVault();
}
function renderCreate() {
const pw = el("input", { type: "password", placeholder: "Master password (10+ chars)" });
const err = el("div", { className: "err" });
const btn = el("button", { textContent: "Create vault" });
const card = el("div", { className: "card" }, el("h2", { textContent: "Create your Kestrove vault" }), pw, btn, err);
btn.onclick = async () => {
try {
const secretKey = await invoke<string>("create_account", { masterPassword: 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." }),
el("button", { textContent: "Continue", onclick: render }),
);
} catch (e) {
err.textContent = String(e);
}
};
app.append(el("div", { className: "center" }, card));
}
function renderUnlock() {
const pw = el("input", { type: "password", placeholder: "Master password" });
const sk = el("input", { placeholder: "Secret Key (K1-…)", className: "mono" });
const err = el("div", { className: "err" });
const btn = el("button", { textContent: "Unlock" });
btn.onclick = async () => {
try {
await invoke("unlock", { masterPassword: pw.value, secretKey: sk.value });
pw.value = "";
await render();
} catch (e) {
err.textContent = String(e);
}
};
app.append(el("div", { className: "center" }, el("div", { className: "card" }, el("h2", { textContent: "Kestrove is locked" }), pw, sk, btn, err)));
}
async function renderVault() {
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");
const item = await invoke<ItemDto>("get_item", { id: it.id });
const pwSpan = el("span", { className: "mono", textContent: "••••••••" });
detail.replaceChildren(
el("h2", { textContent: item.title }),
el("p", {}, "Username: ", el("span", { className: "mono", textContent: item.username ?? "" })),
el("p", {}, "Password: ", pwSpan, " ",
el("button", { textContent: "Reveal", onclick: () => (pwSpan.textContent = item.password ?? "") }),
" ",
el("button", { textContent: "Copy", onclick: () => invoke("copy_secret", { id: item.id }) })),
el("p", { className: "muted", textContent: item.urls.join(", ") }),
);
};
list.append(li);
}
const add = el("button", { textContent: "+ New login" });
add.onclick = () => renderEditor(detail);
const lock = el("button", { textContent: "Lock", onclick: async () => { await invoke("lock"); await render(); } });
app.append(el("aside", {}, el("div", { className: "toolbar" }, add, lock), list), detail);
}
function renderEditor(target: HTMLElement) {
const title = el("input", { placeholder: "Title" });
const user = el("input", { placeholder: "Username" });
const pass = el("input", { placeholder: "Password", className: "mono" });
const url = el("input", { placeholder: "https://example.com" });
const gen = el("button", { textContent: "Generate", onclick: async () => (pass.value = await invoke<string>("generate_password", { length: 24 })) });
const save = el("button", { textContent: "Save" });
save.onclick = async () => {
await invoke("save_item", {
item: { title: title.value, username: user.value || null, password: pass.value || null, urls: url.value ? [url.value] : [] },
});
await render();
};
target.replaceChildren(el("div", { className: "card" }, el("h2", { textContent: "New login" }), title, user, pass, gen, url, save));
}
void render();
+16
View File
@@ -0,0 +1,16 @@
:root { color-scheme: light dark; font: 14px system-ui, sans-serif; }
body { margin: 0; }
main { display: grid; grid-template-columns: 300px 1fr; height: 100vh; }
.center { display: grid; place-items: center; height: 100vh; grid-column: 1 / -1; }
.card { display: grid; gap: 8px; width: 340px; }
input, button { font: inherit; padding: 8px; border-radius: 6px; border: 1px solid #8886; }
button { cursor: pointer; }
aside { border-right: 1px solid #8884; overflow: auto; }
aside li { list-style: none; padding: 8px 12px; cursor: pointer; }
aside li:hover, aside li.sel { background: #8882; }
aside ul { margin: 0; padding: 0; }
section { padding: 16px; }
.muted { opacity: .7; }
.err { color: #d33; }
.mono { font-family: ui-monospace, monospace; }
.toolbar { display: flex; gap: 8px; padding: 8px; }
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"lib": ["ES2022", "DOM"],
"noEmit": true,
"skipLibCheck": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from "vite";
export default defineConfig({
clearScreen: false,
server: { port: 1420, strictPort: true },
build: { target: "es2022", outDir: "dist" },
});