Initial scaffold for kestrove-desktop
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -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
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
src-tauri/target/
|
||||||
|
src-tauri/gen/
|
||||||
@@ -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
@@ -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>
|
||||||
@@ -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__"]
|
||||||
|
}
|
||||||
Generated
+1282
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
+106
@@ -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();
|
||||||
@@ -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; }
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"strict": true,
|
||||||
|
"lib": ["ES2022", "DOM"],
|
||||||
|
"noEmit": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
clearScreen: false,
|
||||||
|
server: { port: 1420, strictPort: true },
|
||||||
|
build: { target: "es2022", outDir: "dist" },
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user