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
+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"]
}
}