The Microsoft Store doesn't update EXE installs, so the app updates itself with Tauri's updater. It reads updater/latest.json from the Kestrove Gitea a minute after startup and once a day. A new version shows as a banner and in Settings > Updates, and installs only on "Update and restart". The vault locks before the installer takes over. The installer must be signed with the Kestrove updater key, with the signature bound to its version (requireSignedVersion), so a compromised download server can hold updates back but can't ship one or replay an old installer. Signing is a release step, after Authenticode: gitea-release.sh signs, uploads the .sig, and writes latest.json last. Plain builds need no key. Checks reveal only the IP address and app version, and can be turned off in Settings. endpoints is a list, so a mirror can be added. Linux packages update through their package manager. Co-Authored-By: Claude Opus 5.5 <[email protected]>
886 lines
28 KiB
Rust
886 lines
28 KiB
Rust
// Kestrove desktop (Windows/Linux; macOS uses kestrove-apple, but this builds and runs there for
|
|
// development). Thin Tauri shell over kv-ffi: the web UI only renders and never sees keys.
|
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
|
|
|
use std::path::PathBuf;
|
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
|
use std::sync::{Arc, Mutex};
|
|
use std::time::Duration;
|
|
use std::{io, thread};
|
|
|
|
use kestrove_desktop::autolock::{self, Activity, Reason};
|
|
use kestrove_desktop::broker::{Broker, PairingStore, PairingView, SaveView, Ui};
|
|
use kestrove_desktop::device_key::DeviceKey;
|
|
use kestrove_desktop::hello::{self, HelloError};
|
|
use kestrove_desktop::passkey::PasskeyView;
|
|
use kestrove_desktop::sync::{
|
|
self, AddDeviceView, DeviceView, JoinConfirmView, JoinPollView, JoinView, SetupView, SignIn, SyncService,
|
|
SyncStatus,
|
|
};
|
|
use kestrove_desktop::updates::{UpdateInfo, Updates};
|
|
use kestrove_desktop::{ipc, private_file};
|
|
use kv_ffi::{generate_password as core_generate_password, totp_code, CoreError, ItemDto, ItemKindDto, VaultCore};
|
|
use serde::{Deserialize, Serialize};
|
|
use tauri::menu::{Menu, MenuItem};
|
|
use tauri::tray::TrayIconBuilder;
|
|
use tauri::{AppHandle, Emitter, Manager, State, WindowEvent};
|
|
use zeroize::Zeroizing;
|
|
|
|
const CLIPBOARD_CLEAR: Duration = Duration::from_secs(90);
|
|
|
|
struct App {
|
|
core: Arc<VaultCore>,
|
|
sync: Arc<SyncService>,
|
|
broker: Arc<Broker>,
|
|
activity: Arc<Activity>,
|
|
data_dir: PathBuf,
|
|
ipc_error: Mutex<Option<String>>,
|
|
idle_minutes: Arc<AtomicU64>,
|
|
/// Windows Hello is set up on this computer (checked off the main thread; see `refresh_hello`).
|
|
hello_available: Arc<AtomicBool>,
|
|
updates: Arc<Updates>,
|
|
// Kept alive: on X11 the clipboard owner must outlive the copy.
|
|
clipboard: Mutex<Option<arboard::Clipboard>>,
|
|
}
|
|
|
|
impl App {
|
|
fn settings_path(&self) -> PathBuf {
|
|
self.data_dir.join("settings.json")
|
|
}
|
|
|
|
fn save_settings(&self) -> CmdResult<()> {
|
|
let s = Settings {
|
|
idle_minutes: self.idle_minutes.load(Ordering::Relaxed),
|
|
check_updates: self.updates.automatic(),
|
|
};
|
|
let bytes = serde_json::to_vec(&s).map_err(|e| e.to_string())?;
|
|
private_file::write(&self.settings_path(), &bytes).map_err(|e| e.to_string())
|
|
}
|
|
|
|
/// Re-checks Windows Hello in the background (it can be set up or removed while Kestrove runs).
|
|
fn refresh_hello(&self) {
|
|
let available = self.hello_available.clone();
|
|
let _ = thread::Builder::new().name("hello-check".into()).spawn(move || {
|
|
available.store(hello::is_supported(), Ordering::Relaxed);
|
|
});
|
|
}
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize)]
|
|
struct Settings {
|
|
idle_minutes: u64,
|
|
/// Check for a new version once a day (Windows; see `updates`).
|
|
#[serde(default = "yes")]
|
|
check_updates: bool,
|
|
}
|
|
|
|
fn yes() -> bool {
|
|
true
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct AppState {
|
|
has_account: bool,
|
|
locked: bool,
|
|
/// The Secret Key is kept on this device, so unlock only needs the master password.
|
|
remembers_secret_key: bool,
|
|
/// Browser integration could not start (e.g. another instance is running).
|
|
ipc_error: Option<String>,
|
|
idle_minutes: u64,
|
|
/// Unlock with Windows Hello: can be turned on, is on, is offered on the unlock screen now.
|
|
hello: hello::Status,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct Summary {
|
|
id: String,
|
|
title: String,
|
|
username: Option<String>,
|
|
urls: Vec<String>,
|
|
favorite: bool,
|
|
has_totp: bool,
|
|
tags: Vec<String>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct Detail {
|
|
id: String,
|
|
title: String,
|
|
username: Option<String>,
|
|
password: Option<String>,
|
|
urls: Vec<String>,
|
|
totp: Option<String>,
|
|
notes: Option<String>,
|
|
tags: Vec<String>,
|
|
/// "Google · [email protected]" when the account signs in with a provider.
|
|
sign_in_with: Option<SignInView>,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize)]
|
|
struct SignInView {
|
|
provider: String,
|
|
#[serde(default)]
|
|
provider_name: String,
|
|
account: Option<String>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct ProviderView {
|
|
id: String,
|
|
name: String,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct NewLogin {
|
|
title: String,
|
|
username: Option<String>,
|
|
password: Option<String>,
|
|
urls: Vec<String>,
|
|
totp: Option<String>,
|
|
#[serde(default)]
|
|
tags: Vec<String>,
|
|
#[serde(default)]
|
|
sign_in_with: Option<SignInView>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct Totp {
|
|
code: String,
|
|
seconds_remaining: u64,
|
|
period: u64,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct PairingSummary {
|
|
id: String,
|
|
name: String,
|
|
caller: String,
|
|
words: Vec<String>,
|
|
created_at: u64,
|
|
}
|
|
|
|
type CmdResult<T> = Result<T, String>;
|
|
|
|
fn err(e: CoreError) -> String {
|
|
e.to_string()
|
|
}
|
|
|
|
/// Tauri events to the UI; the window is brought forward so the user sees the prompt.
|
|
struct TauriUi {
|
|
handle: AppHandle,
|
|
activity: Arc<Activity>,
|
|
}
|
|
|
|
impl TauriUi {
|
|
fn surface(&self) {
|
|
if let Some(w) = self.handle.get_webview_window("main") {
|
|
let _ = w.show();
|
|
let _ = w.unminimize();
|
|
let _ = w.set_focus();
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Ui for TauriUi {
|
|
fn pairing_requested(&self, req: &PairingView) {
|
|
self.surface();
|
|
let _ = self.handle.emit("pairing-request", req);
|
|
}
|
|
fn save_requested(&self, req: &SaveView) {
|
|
self.surface();
|
|
let _ = self.handle.emit("save-request", req);
|
|
}
|
|
fn passkey_requested(&self, req: &PasskeyView) {
|
|
self.surface();
|
|
let _ = self.handle.emit("passkey-request", req);
|
|
}
|
|
fn passkey_settled(&self, id: &str) {
|
|
let _ = self.handle.emit("passkey-settled", id);
|
|
}
|
|
fn activity(&self) {
|
|
self.activity.touch();
|
|
}
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn state(app: State<'_, App>) -> AppState {
|
|
AppState {
|
|
has_account: app.core.has_account(),
|
|
locked: app.core.is_locked(),
|
|
remembers_secret_key: app.sync.stored_secret_key().is_some(),
|
|
ipc_error: app.ipc_error.lock().unwrap_or_else(|e| e.into_inner()).clone(),
|
|
idle_minutes: app.idle_minutes.load(Ordering::Relaxed),
|
|
hello: hello::status(&app.core, &app.data_dir, app.hello_available.load(Ordering::Relaxed)),
|
|
}
|
|
}
|
|
|
|
// Argon2id takes ~0.5 s, so these run as async commands off the main thread.
|
|
|
|
/// "Create a new account": a new vault here, registered with the server using its setup code or an invite.
|
|
#[tauri::command]
|
|
async fn create_account(
|
|
app: State<'_, App>,
|
|
server_url: String,
|
|
pins: String,
|
|
setup_code: String,
|
|
email: String,
|
|
master_password: String,
|
|
) -> CmdResult<String> {
|
|
let master_password = Zeroizing::new(master_password);
|
|
let sk = app.sync.create_account(&server_url, sync::parse_pins(&pins), &setup_code, &email, master_password).await;
|
|
app.activity.touch();
|
|
sk.map(|s| s.to_string())
|
|
}
|
|
|
|
/// `secret_key` may be omitted once this device remembers it.
|
|
#[tauri::command]
|
|
async fn unlock(app: State<'_, App>, master_password: String, secret_key: Option<String>) -> CmdResult<()> {
|
|
let master_password = Zeroizing::new(master_password);
|
|
let typed = secret_key.map(Zeroizing::new).filter(|s| !s.trim().is_empty());
|
|
let sk = typed.clone().or_else(|| app.sync.stored_secret_key()).ok_or("Enter your Secret Key.")?;
|
|
app.core.unlock(master_password.to_string(), sk.trim().to_string()).map_err(err)?;
|
|
if let Some(sk) = typed {
|
|
app.sync.remember_secret_key(&sk);
|
|
}
|
|
hello::password_unlocked(&app.data_dir);
|
|
app.activity.touch();
|
|
app.sync.request_sync();
|
|
Ok(())
|
|
}
|
|
|
|
/// Windows Hello calls block until the prompt closes, so they run off the main thread and the async runtime.
|
|
async fn off_thread<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> CmdResult<T> {
|
|
tauri::async_runtime::spawn_blocking(f).await.map_err(|e| e.to_string())
|
|
}
|
|
|
|
/// Unlock with Windows Hello. `false` when the prompt was cancelled (the password field stays).
|
|
#[tauri::command]
|
|
async fn hello_unlock(app: State<'_, App>) -> CmdResult<bool> {
|
|
let (core, dir) = (app.core.clone(), app.data_dir.clone());
|
|
match off_thread(move || hello::unlock(&core, &dir)).await? {
|
|
Ok(()) => {
|
|
app.activity.touch();
|
|
app.sync.request_sync();
|
|
Ok(true)
|
|
}
|
|
Err(HelloError::Cancelled) => Ok(false),
|
|
Err(e) => Err(e.to_string()),
|
|
}
|
|
}
|
|
|
|
/// Settings → "Unlock with Windows Hello". `false` when turning it on was cancelled.
|
|
#[tauri::command]
|
|
async fn set_hello(app: State<'_, App>, enabled: bool) -> CmdResult<bool> {
|
|
let (core, dir) = (app.core.clone(), app.data_dir.clone());
|
|
let r = off_thread(move || if enabled { hello::enable(&core, &dir) } else { hello::disable(&core, &dir) }).await?;
|
|
app.refresh_hello();
|
|
match r {
|
|
Ok(()) => Ok(true),
|
|
Err(HelloError::Cancelled) => Ok(false),
|
|
Err(e) => Err(e.to_string()),
|
|
}
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn lock(app: State<'_, App>) -> CmdResult<()> {
|
|
app.core.lock();
|
|
app.sync.on_lock().await;
|
|
Ok(())
|
|
}
|
|
|
|
/// UI input (throttled in JS): feeds the idle timer where the OS has no system idle time.
|
|
#[tauri::command]
|
|
fn touch(app: State<'_, App>) {
|
|
app.activity.touch();
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn set_idle_minutes(app: State<'_, App>, minutes: u64) -> CmdResult<()> {
|
|
let minutes = minutes.clamp(1, 240);
|
|
app.idle_minutes.store(minutes, Ordering::Relaxed);
|
|
app.save_settings()
|
|
}
|
|
|
|
// ---------------------------------------------------------------- updates (Windows; see `updates`)
|
|
|
|
#[tauri::command]
|
|
fn update_info(app: State<'_, App>, handle: AppHandle) -> UpdateInfo {
|
|
app.updates.info(&handle)
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn check_update(app: State<'_, App>, handle: AppHandle) -> CmdResult<UpdateInfo> {
|
|
Ok(app.updates.check(&handle).await)
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn set_update_checks(app: State<'_, App>, enabled: bool) -> CmdResult<()> {
|
|
app.updates.set_automatic(enabled);
|
|
app.save_settings()
|
|
}
|
|
|
|
/// "Update and restart": returns only if it failed; on success the installer replaces and restarts the app.
|
|
#[tauri::command]
|
|
async fn install_update(app: State<'_, App>, handle: AppHandle) -> CmdResult<()> {
|
|
let core = app.core.clone();
|
|
app.updates.install(&handle, move || core.lock()).await
|
|
}
|
|
|
|
#[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,
|
|
has_totp: s.has_totp,
|
|
tags: s.tags,
|
|
})
|
|
.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,
|
|
tags: d.tags,
|
|
sign_in_with: d.sign_in_with.map(|w| SignInView {
|
|
provider_name: kv_ffi::sign_in_provider_name(w.provider.clone()),
|
|
provider: w.provider,
|
|
account: w.account,
|
|
}),
|
|
})
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn sign_in_providers() -> Vec<ProviderView> {
|
|
kv_ffi::sign_in_providers().into_iter().map(|p| ProviderView { id: p.id, name: p.name }).collect()
|
|
}
|
|
|
|
/// Replaces an item's tags (the core normalises them); everything else about the item stays.
|
|
#[tauri::command]
|
|
fn set_tags(app: State<'_, App>, id: String, tags: Vec<String>) -> CmdResult<()> {
|
|
let mut item = app.core.get_item(id).map_err(err)?;
|
|
item.tags = tags;
|
|
app.core.save_item(item).map_err(err)?;
|
|
app.sync.request_sync();
|
|
Ok(())
|
|
}
|
|
|
|
#[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: item.totp.filter(|t| !t.trim().is_empty()),
|
|
notes: None,
|
|
favorite: false,
|
|
tags: item.tags,
|
|
sign_in_with: item.sign_in_with.map(|w| kv_ffi::SignInWithDto { provider: w.provider, account: w.account }),
|
|
modified_at: 0,
|
|
})
|
|
.map_err(err)
|
|
.inspect(|_| app.sync.request_sync())
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn delete_item(app: State<'_, App>, id: String) -> CmdResult<()> {
|
|
app.core.delete_item(id).map_err(err)?;
|
|
app.sync.request_sync();
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn totp(app: State<'_, App>, id: String) -> CmdResult<Option<Totp>> {
|
|
let Some(secret) = app.core.get_item(id).map_err(err)?.totp else { return Ok(None) };
|
|
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs());
|
|
let c = totp_code(secret, now).map_err(err)?;
|
|
Ok(Some(Totp { code: c.code, seconds_remaining: c.seconds_remaining, period: c.period }))
|
|
}
|
|
|
|
/// Copies from Rust so the secret never round-trips through JS, then clears it after 90 s.
|
|
#[tauri::command]
|
|
fn copy_secret(app: State<'_, App>, id: String, field: Option<String>) -> CmdResult<()> {
|
|
let item = app.core.get_item(id).map_err(err)?;
|
|
let secret = Zeroizing::new(match field.as_deref() {
|
|
Some("username") => item.username.unwrap_or_default(),
|
|
Some("totp") => {
|
|
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs());
|
|
item.totp.map(|t| totp_code(t, now)).transpose().map_err(err)?.map(|c| c.code).unwrap_or_default()
|
|
}
|
|
_ => item.password.unwrap_or_default(),
|
|
});
|
|
let mut guard = app.clipboard.lock().unwrap_or_else(|e| e.into_inner());
|
|
if guard.is_none() {
|
|
*guard = Some(arboard::Clipboard::new().map_err(|e| e.to_string())?);
|
|
}
|
|
let cb = guard.as_mut().expect("clipboard initialized above");
|
|
#[cfg(windows)]
|
|
let copied = {
|
|
// docs/04: keep secrets out of clipboard history, cloud sync and monitors.
|
|
use arboard::SetExtWindows;
|
|
cb.set().exclude_from_monitoring().exclude_from_cloud().exclude_from_history().text(secret.to_string())
|
|
};
|
|
#[cfg(not(windows))]
|
|
let copied = cb.set_text(secret.to_string());
|
|
copied.map_err(|e| e.to_string())?;
|
|
drop(guard);
|
|
thread::spawn(move || {
|
|
thread::sleep(CLIPBOARD_CLEAR);
|
|
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.clamp(8, 128), true)
|
|
}
|
|
|
|
// ---------------------------------------------------------------- browser pairing + saves
|
|
|
|
#[tauri::command]
|
|
fn pending_pairing(app: State<'_, App>) -> Option<PairingView> {
|
|
app.broker.pending_pairing()
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn resolve_pairing(app: State<'_, App>, id: String, accept: bool) -> CmdResult<()> {
|
|
app.broker.resolve_pairing(&id, accept)
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn list_pairings(app: State<'_, App>) -> Vec<PairingSummary> {
|
|
app.broker
|
|
.pairings()
|
|
.into_iter()
|
|
.map(|p| PairingSummary { id: p.id, name: p.name, caller: p.caller, words: p.words, created_at: p.created_at })
|
|
.collect()
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn revoke_pairing(app: State<'_, App>, id: String) -> CmdResult<()> {
|
|
app.broker.revoke_pairing(&id)
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn pending_saves(app: State<'_, App>) -> Vec<SaveView> {
|
|
app.broker.pending_saves()
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn resolve_save(app: State<'_, App>, id: String, accept: bool) -> CmdResult<()> {
|
|
if app.broker.resolve_save(&id, accept)?.is_some() {
|
|
app.sync.request_sync();
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// The colour theme and appearance saved for all the account's devices (docs/02 "App settings").
|
|
#[derive(Serialize)]
|
|
struct ThemeView {
|
|
theme: Option<String>,
|
|
appearance: Option<String>,
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn app_theme(app: State<'_, App>) -> CmdResult<ThemeView> {
|
|
let t = app.core.app_theme().map_err(err)?;
|
|
Ok(ThemeView { theme: t.theme, appearance: t.appearance })
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn set_app_theme(app: State<'_, App>, theme: Option<String>, appearance: Option<String>) -> CmdResult<()> {
|
|
app.core.set_app_theme(theme, appearance).map_err(err)?;
|
|
app.sync.request_sync();
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn pending_passkeys(app: State<'_, App>) -> Vec<PasskeyView> {
|
|
app.broker.pending_passkeys()
|
|
}
|
|
|
|
/// A page's passkey request (docs/05 "Passkeys in the browser"). Accepting needs the master password: the user
|
|
/// verification every passkey use requires, where the app has no biometrics to ask.
|
|
#[tauri::command]
|
|
fn resolve_passkey(
|
|
app: State<'_, App>,
|
|
id: String,
|
|
accept: bool,
|
|
credential_id: Option<String>,
|
|
master_password: Option<String>,
|
|
) -> CmdResult<()> {
|
|
if accept {
|
|
let pw = Zeroizing::new(master_password.unwrap_or_default());
|
|
app.core.confirm_master_password(pw.to_string()).map_err(|e| match e {
|
|
CoreError::BadCredentials => "Wrong master password.".to_string(),
|
|
e => err(e),
|
|
})?;
|
|
}
|
|
app.broker.resolve_passkey(&id, accept, credential_id.as_deref())?;
|
|
app.activity.touch();
|
|
if accept {
|
|
app.sync.request_sync();
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
// ---------------------------------------------------------------- sync server (docs/03)
|
|
|
|
#[tauri::command]
|
|
fn sync_status(app: State<'_, App>) -> SyncStatus {
|
|
app.sync.status()
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn sync_now(app: State<'_, App>) -> CmdResult<SyncStatus> {
|
|
app.sync.sync_now().await
|
|
}
|
|
|
|
/// Settings: server URL and optional pins for a vault created before sync (or to change them).
|
|
#[tauri::command]
|
|
fn configure_server(app: State<'_, App>, server_url: String, pins: String) -> CmdResult<()> {
|
|
app.sync.configure(&server_url, sync::parse_pins(&pins))
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn server_setup_open(app: State<'_, App>) -> CmdResult<bool> {
|
|
app.sync.setup_open().await
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn register_server(app: State<'_, App>, setup_code: String, email: String) -> CmdResult<()> {
|
|
app.sync.register(&setup_code, &email).await
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn list_devices(app: State<'_, App>) -> CmdResult<Vec<DeviceView>> {
|
|
app.sync.devices().await
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn approve_device(app: State<'_, App>, device_id: String) -> CmdResult<()> {
|
|
app.sync.approve_device(&device_id).await
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn revoke_device(app: State<'_, App>, device_id: String) -> CmdResult<()> {
|
|
app.sync.revoke_device(&device_id).await
|
|
}
|
|
|
|
// ---------------------------------------------------------------- onboarding (docs/10)
|
|
|
|
#[tauri::command]
|
|
async fn join_start(app: State<'_, App>, server_url: String) -> CmdResult<JoinView> {
|
|
app.sync.join_start(&server_url).await
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn join_poll(app: State<'_, App>) -> CmdResult<JoinPollView> {
|
|
app.sync.join_poll().await
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn join_cancel(app: State<'_, App>) {
|
|
app.sync.join_cancel();
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn setup_from_text(app: State<'_, App>, text: String) -> CmdResult<SetupView> {
|
|
app.sync.setup_from_text(&Zeroizing::new(text))
|
|
}
|
|
|
|
/// "Open Emergency Kit…": the file's bytes (raw IPC body), read by the web view's file picker.
|
|
#[tauri::command]
|
|
async fn setup_from_file(app: State<'_, App>, request: tauri::ipc::Request<'_>) -> CmdResult<SetupView> {
|
|
let tauri::ipc::InvokeBody::Raw(bytes) = request.body() else { return Err("Expected the file's bytes.".into()) };
|
|
app.sync.setup_from_file(bytes)
|
|
}
|
|
|
|
/// The theme's `bg` and `text` for the native title bar (Windows 11 only; see `title_bar`).
|
|
#[tauri::command]
|
|
fn set_title_bar_colors(window: tauri::WebviewWindow, bg: String, text: String) {
|
|
#[cfg(windows)]
|
|
kestrove_desktop::title_bar::paint(&window, &bg, &text);
|
|
#[cfg(not(windows))]
|
|
let _ = (window, bg, text);
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn clear_setup(app: State<'_, App>) {
|
|
app.sync.clear_setup();
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn finish_setup(app: State<'_, App>, master_password: String) -> CmdResult<SignIn> {
|
|
let r = app.sync.finish_setup(Zeroizing::new(master_password)).await;
|
|
app.activity.touch();
|
|
r
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn add_device(app: State<'_, App>) -> CmdResult<AddDeviceView> {
|
|
app.sync.add_device().await
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn cancel_add_device(app: State<'_, App>) -> CmdResult<()> {
|
|
app.sync.cancel_add_device().await;
|
|
Ok(())
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn approve_join(app: State<'_, App>, join_code: String) -> CmdResult<JoinConfirmView> {
|
|
app.sync.approve_join(&join_code).await
|
|
}
|
|
|
|
#[tauri::command]
|
|
async fn send_join(app: State<'_, App>) -> CmdResult<()> {
|
|
app.sync.send_join().await
|
|
}
|
|
|
|
#[tauri::command]
|
|
fn kit_qr(app: State<'_, App>) -> CmdResult<String> {
|
|
app.sync.kit_qr()
|
|
}
|
|
|
|
// ---------------------------------------------------------------- startup
|
|
|
|
fn start_ipc(broker: Arc<Broker>) -> io::Result<()> {
|
|
let listener = ipc::Listener::bind()?;
|
|
thread::Builder::new().name("ipc-accept".into()).spawn(move || loop {
|
|
match listener.accept() {
|
|
Ok(conn) => {
|
|
let b = broker.clone();
|
|
let _ = thread::Builder::new().name("ipc-conn".into()).spawn(move || {
|
|
let _ = b.serve(conn);
|
|
});
|
|
}
|
|
Err(e) => {
|
|
eprintln!("kestrove: IPC accept failed: {e}");
|
|
thread::sleep(Duration::from_secs(1));
|
|
}
|
|
}
|
|
})?;
|
|
Ok(())
|
|
}
|
|
|
|
fn lock_and_notify(handle: &AppHandle, sync: &Arc<SyncService>, reason: &str) {
|
|
sync.core().lock();
|
|
let sync = sync.clone();
|
|
tauri::async_runtime::spawn(async move { sync.on_lock().await });
|
|
let _ = handle.emit("locked", reason);
|
|
}
|
|
|
|
fn setup(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
|
// KESTROVE_DATA_DIR runs a second instance with its own vault (development and the two-device tests).
|
|
let data_dir = match std::env::var_os("KESTROVE_DATA_DIR") {
|
|
Some(d) => PathBuf::from(d),
|
|
None => app.path().app_data_dir()?,
|
|
};
|
|
std::fs::create_dir_all(&data_dir)?;
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
std::fs::set_permissions(&data_dir, std::fs::Permissions::from_mode(0o700))?;
|
|
}
|
|
let core = VaultCore::open(data_dir.join("vault.sqlite").to_string_lossy().into_owned())?;
|
|
let key = if std::env::var_os("KESTROVE_DEVICE_KEY").is_some_and(|v| v == "file") {
|
|
DeviceKey::load_or_create_file(&data_dir)
|
|
} else {
|
|
DeviceKey::load_or_create(&data_dir)
|
|
};
|
|
let sync = {
|
|
let handle = app.handle().clone();
|
|
SyncService::new(
|
|
core.clone(),
|
|
&data_dir,
|
|
key.map_err(|e| e.to_string()),
|
|
Box::new(move |s: &SyncStatus| {
|
|
let _ = handle.emit("sync-status", s);
|
|
}),
|
|
)
|
|
};
|
|
tauri::async_runtime::spawn(sync.clone().run());
|
|
let activity = Activity::new();
|
|
let ui = Arc::new(TauriUi { handle: app.handle().clone(), activity: activity.clone() });
|
|
let broker = Broker::new(core.clone(), ui, PairingStore::open(data_dir.join("pairings.json"))?);
|
|
|
|
let settings = private_file::read(&data_dir.join("settings.json"))
|
|
.ok()
|
|
.flatten()
|
|
.and_then(|b| serde_json::from_slice::<Settings>(&b).ok());
|
|
let idle_minutes = settings.as_ref().map_or(autolock::DEFAULT_IDLE.as_secs() / 60, |s| s.idle_minutes);
|
|
let idle_minutes = Arc::new(AtomicU64::new(idle_minutes));
|
|
let updates = Updates::new(settings.as_ref().is_none_or(|s| s.check_updates));
|
|
updates.clone().spawn(app.handle().clone());
|
|
|
|
let ipc_error = start_ipc(broker.clone()).err().map(|e| {
|
|
eprintln!("kestrove: browser integration disabled: {e}");
|
|
if e.kind() == io::ErrorKind::AddrInUse {
|
|
"Another Kestrove instance is serving the browser extension.".to_string()
|
|
} else {
|
|
format!("Browser integration could not start: {e}")
|
|
}
|
|
});
|
|
|
|
{
|
|
let (handle, sync, minutes) = (app.handle().clone(), sync.clone(), idle_minutes.clone());
|
|
let unlocked = core.clone();
|
|
autolock::spawn(
|
|
activity.clone(),
|
|
move || Duration::from_secs(minutes.load(Ordering::Relaxed) * 60),
|
|
move || !unlocked.is_locked(),
|
|
move |reason| {
|
|
let why = match reason {
|
|
Reason::Idle => "idle",
|
|
Reason::ScreenLocked => "screen-locked",
|
|
Reason::Sleep => "sleep",
|
|
};
|
|
lock_and_notify(&handle, &sync, why);
|
|
},
|
|
);
|
|
}
|
|
|
|
// Tray: the app keeps running (for the browser extension) when its window is closed.
|
|
let open = MenuItem::with_id(app, "open", "Open Kestrove", true, None::<&str>)?;
|
|
let lock_item = MenuItem::with_id(app, "lock", "Lock", true, None::<&str>)?;
|
|
let quit = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
|
|
let menu = Menu::with_items(app, &[&open, &lock_item, &quit])?;
|
|
let mut tray = TrayIconBuilder::with_id("main").menu(&menu).tooltip("Kestrove").on_menu_event({
|
|
let sync = sync.clone();
|
|
move |handle, event| match event.id().as_ref() {
|
|
"open" => {
|
|
if let Some(w) = handle.get_webview_window("main") {
|
|
let _ = w.show();
|
|
let _ = w.set_focus();
|
|
}
|
|
}
|
|
"lock" => lock_and_notify(handle, &sync, "manual"),
|
|
"quit" => {
|
|
sync.core().lock();
|
|
// Revoke an "Add a device" ticket still on screen (bounded: never hang Quit on the network).
|
|
let s = sync.clone();
|
|
let _ = tauri::async_runtime::block_on(async move {
|
|
tokio::time::timeout(Duration::from_secs(2), s.on_lock()).await
|
|
});
|
|
handle.exit(0);
|
|
}
|
|
_ => {}
|
|
}
|
|
});
|
|
if let Some(icon) = app.default_window_icon() {
|
|
tray = tray.icon(icon.clone());
|
|
}
|
|
tray.build(app)?;
|
|
|
|
app.manage(App {
|
|
core,
|
|
sync,
|
|
broker,
|
|
activity,
|
|
data_dir,
|
|
ipc_error: Mutex::new(ipc_error),
|
|
idle_minutes,
|
|
hello_available: Arc::new(AtomicBool::new(false)),
|
|
updates,
|
|
clipboard: Mutex::new(None),
|
|
});
|
|
app.state::<App>().refresh_hello();
|
|
Ok(())
|
|
}
|
|
|
|
fn main() {
|
|
let builder = tauri::Builder::default();
|
|
#[cfg(windows)]
|
|
let builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
|
|
builder
|
|
.setup(setup)
|
|
.on_window_event(|window, event| {
|
|
if let WindowEvent::CloseRequested { api, .. } = event {
|
|
// Keep serving the extension from the tray; Quit is in the tray menu.
|
|
api.prevent_close();
|
|
let _ = window.hide();
|
|
}
|
|
})
|
|
.invoke_handler(tauri::generate_handler![
|
|
state,
|
|
set_title_bar_colors,
|
|
create_account,
|
|
unlock,
|
|
hello_unlock,
|
|
set_hello,
|
|
lock,
|
|
touch,
|
|
set_idle_minutes,
|
|
list_items,
|
|
get_item,
|
|
save_item,
|
|
delete_item,
|
|
totp,
|
|
copy_secret,
|
|
generate_password,
|
|
pending_pairing,
|
|
resolve_pairing,
|
|
list_pairings,
|
|
revoke_pairing,
|
|
pending_saves,
|
|
pending_passkeys,
|
|
set_tags,
|
|
app_theme,
|
|
set_app_theme,
|
|
sign_in_providers,
|
|
resolve_passkey,
|
|
resolve_save,
|
|
sync_status,
|
|
sync_now,
|
|
configure_server,
|
|
server_setup_open,
|
|
register_server,
|
|
list_devices,
|
|
approve_device,
|
|
revoke_device,
|
|
join_start,
|
|
join_poll,
|
|
join_cancel,
|
|
setup_from_text,
|
|
setup_from_file,
|
|
clear_setup,
|
|
finish_setup,
|
|
add_device,
|
|
cancel_add_device,
|
|
approve_join,
|
|
send_join,
|
|
kit_qr,
|
|
update_info,
|
|
check_update,
|
|
set_update_checks,
|
|
install_update
|
|
])
|
|
.run(tauri::generate_context!())
|
|
.expect("error while running Kestrove");
|
|
}
|