Local vaults without a server; Snap package; version 1.2.2
ci / build (ubuntu-22.04) (push) Waiting to run
ci / build (windows-2025) (push) Waiting to run
ci / deny (push) Waiting to run

The New vault tab can now skip the server, as Apple and Android already do. The snap
writes its browser manifests on start, since a strict snap can't install them itself.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-31 08:24:59 +04:00
co-authored by Claude Opus 5.5
parent 608d85c297
commit a818a83a9c
15 changed files with 227 additions and 11 deletions
+1 -1
View File
@@ -3064,7 +3064,7 @@ dependencies = [
[[package]]
name = "kestrove-desktop"
version = "1.2.1"
version = "1.2.2"
dependencies = [
"arboard",
"base64 0.22.1",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "kestrove-desktop"
version = "1.2.1"
version = "1.2.2"
edition = "2021"
rust-version = "1.82"
license = "Apache-2.0"
+1
View File
@@ -7,6 +7,7 @@ pub mod autolock;
pub mod device_key;
pub mod hello;
pub mod qr;
pub mod snap;
pub mod sync;
pub mod title_bar;
pub mod updates;
+10
View File
@@ -232,6 +232,14 @@ async fn create_account(
sk.map(|s| s.to_string())
}
/// A new vault on this computer only.
#[tauri::command]
async fn create_local_account(app: State<'_, App>, master_password: String) -> CmdResult<String> {
let sk = app.sync.create_local_account(Zeroizing::new(master_password));
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<()> {
@@ -814,6 +822,7 @@ fn setup(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
}
fn main() {
kestrove_desktop::snap::register_native_host();
let builder = tauri::Builder::default();
#[cfg(windows)]
let builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
@@ -830,6 +839,7 @@ fn main() {
state,
set_title_bar_colors,
create_account,
create_local_account,
unlock,
hello_unlock,
set_hello,
+79
View File
@@ -0,0 +1,79 @@
//! Browser registration for the Snap Store build. A snap can't ship files into the user's home, so the app writes
//! the native messaging manifests itself on startup, through the `browser-native-hosts` plug
//! (snap/snapcraft.yaml). Outside a snap this does nothing: the .deb and .rpm install them system-wide.
use std::fs;
use std::path::Path;
const HOST: &str = "app.kestrove.nmh";
/// The helper's snap command. A browser, or the portal for snap Firefox, starts it inside the Kestrove snap, where
/// it shares the app's runtime directory and so its socket.
const NMH: &str = "/snap/bin/kestrove.nmh";
const FIREFOX_IDS: &[&str] = &["[email protected]"];
/// The Chrome Web Store build, then the unpacked development build.
const CHROME_ORIGINS: &[&str] =
&["chrome-extension://pmhfoafjoogobhfadaicklhkldgnafmk/", "chrome-extension://kogapfibnnpeapfcjkkpgjddbhnjfein/"];
const FIREFOX_DIR: &str = ".mozilla/native-messaging-hosts";
const CHROME_DIRS: &[&str] = &[
".config/google-chrome/NativeMessagingHosts",
".config/chromium/NativeMessagingHosts",
".config/microsoft-edge/NativeMessagingHosts",
".config/BraveSoftware/Brave-Browser/NativeMessagingHosts",
];
/// Writes the manifests when running as a snap. A browser that isn't installed, or a plug that isn't connected, only
/// means no browser integration for that browser, so failures are logged and the app carries on.
pub fn register_native_host() {
if std::env::var_os("SNAP").is_none() {
return;
}
let Some(home) = std::env::var_os("SNAP_REAL_HOME") else {
eprintln!("kestrove: SNAP_REAL_HOME is not set; browser integration not registered");
return;
};
for (dir, res) in write_manifests(Path::new(&home)) {
if let Err(e) = res {
eprintln!("kestrove: can't register the browser helper in {dir}: {e}");
}
}
}
fn write_manifests(home: &Path) -> Vec<(&'static str, std::io::Result<()>)> {
let manifest = |key: &str, ids: &[&str]| {
let doc = serde_json::json!({
"name": HOST, "description": "Kestrove native messaging host", "path": NMH, "type": "stdio", key: ids,
});
doc.to_string()
};
let firefox = manifest("allowed_extensions", FIREFOX_IDS);
let chrome = manifest("allowed_origins", CHROME_ORIGINS);
let targets = std::iter::once((FIREFOX_DIR, &firefox)).chain(CHROME_DIRS.iter().map(|d| (*d, &chrome)));
targets.map(|(dir, body)| (dir, write(&home.join(dir), body))).collect()
}
fn write(dir: &Path, body: &str) -> std::io::Result<()> {
fs::create_dir_all(dir)?;
fs::write(dir.join(format!("{HOST}.json")), body)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn writes_a_manifest_for_each_browser() {
let home = tempfile::tempdir().unwrap();
assert!(write_manifests(home.path()).iter().all(|(_, r)| r.is_ok()));
let read = |dir: &str| -> serde_json::Value {
let body = fs::read_to_string(home.path().join(dir).join(format!("{HOST}.json"))).unwrap();
serde_json::from_str(&body).unwrap()
};
let firefox = read(FIREFOX_DIR);
assert_eq!(firefox["path"], NMH);
assert_eq!(firefox["allowed_extensions"][0], FIREFOX_IDS[0]);
for dir in CHROME_DIRS {
assert_eq!(read(dir)["allowed_origins"].as_array().unwrap().len(), CHROME_ORIGINS.len());
}
}
}
+24
View File
@@ -403,6 +403,17 @@ impl SyncService {
Ok(sk)
}
/// A vault on this computer only. Settings → Sync can connect it to a server later. Returns the Secret Key for
/// the Emergency Kit.
pub fn create_local_account(&self, master_password: Zeroizing<String>) -> Result<Zeroizing<String>, String> {
if self.core.has_account() {
return Err("This computer already has a vault.".into());
}
let sk = Zeroizing::new(self.core.create_account(master_password.to_string()).map_err(|e| describe(&e))?);
self.remember_secret_key(&sk);
Ok(sk)
}
/// For a vault created before sync was set up: registers it with the server (setup code, invite, or nothing
/// with open sign-up).
pub async fn register(&self, setup_code: &str, email: &str) -> Result<(), String> {
@@ -668,6 +679,19 @@ mod tests {
assert!(!device_name().is_empty());
}
#[tokio::test]
async fn a_local_vault_needs_no_server() {
let dir = tempfile::tempdir().unwrap();
let core = VaultCore::new_in_memory();
let key = DeviceKey::load_or_create_file(dir.path()).map_err(|e| e.to_string());
let svc = SyncService::new(core.clone(), dir.path(), key, Box::new(|_| {}));
let sk = svc.create_local_account(Zeroizing::new("correct horse battery".into())).unwrap();
assert!(core.has_account());
assert_eq!(svc.status().server_url, None);
assert_eq!(svc.stored_secret_key().as_deref().map(String::as_str), Some(sk.as_str()));
assert!(svc.create_local_account(Zeroizing::new("correct horse battery".into())).is_err());
}
#[tokio::test]
async fn settings_persist_and_secrets_stay_out_of_views() {
let dir = tempfile::tempdir().unwrap();
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Kestrove",
"version": "1.2.1",
"version": "1.2.2",
"identifier": "app.kestrove.desktop",
"build": {
"beforeDevCommand": "npm run dev",