Initial scaffold for kestrove-desktop
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user