feat: add backup/restore for NIP-4e encryption key #22

Merged
reya merged 4 commits from nip4e-adv into master 2026-03-17 07:42:26 +00:00
3 changed files with 87 additions and 14 deletions
Showing only changes of commit 84229330e2 - Show all commits

View File

@@ -7,6 +7,13 @@ pub fn home_dir() -> &'static PathBuf {
HOME_DIR.get_or_init(|| dirs::home_dir().expect("failed to determine home directory"))
}
/// Returns the path to the user's download directory.
pub fn download_dir() -> &'static PathBuf {
static DOWNLOAD_DIR: OnceLock<PathBuf> = OnceLock::new();
DOWNLOAD_DIR
.get_or_init(|| dirs::download_dir().expect("failed to determine download directory"))
}
/// Returns the path to the configuration directory used by Coop.
pub fn config_dir() -> &'static PathBuf {
static CONFIG_DIR: OnceLock<PathBuf> = OnceLock::new();
@@ -56,9 +63,3 @@ pub fn support_dir() -> &'static PathBuf {
config_dir().clone()
})
}
/// Returns the path to the `nostr` file.
pub fn nostr_file() -> &'static PathBuf {
static NOSTR_FILE: OnceLock<PathBuf> = OnceLock::new();
NOSTR_FILE.get_or_init(|| support_dir().join("nostr-db"))
}

View File

@@ -4,6 +4,7 @@ use std::sync::Arc;
use ::settings::AppSettings;
use chat::{ChatEvent, ChatRegistry};
use common::download_dir;
use device::{DeviceEvent, DeviceRegistry};
use gpui::prelude::FluentBuilder;
use gpui::{
@@ -47,9 +48,11 @@ enum Command {
ToggleTheme,
ToggleAccount,
RefreshEncryption,
RefreshRelayList,
RefreshMessagingRelays,
BackupEncryption,
ImportEncryption,
RefreshEncryption,
ResetEncryption,
ShowRelayList,
@@ -428,6 +431,42 @@ impl Workspace {
Command::ToggleAccount => {
self.account_selector(window, cx);
}
Command::BackupEncryption => {
let device = DeviceRegistry::global(cx).downgrade();
let save_dialog = cx.prompt_for_new_path(download_dir(), Some("encryption.txt"));
cx.spawn_in(window, async move |_this, cx| {
// Get the output path from the save dialog
let output_path = match save_dialog.await {
Ok(Ok(Some(path))) => path,
Ok(Ok(None)) | Err(_) => return Ok(()),
Ok(Err(error)) => {
cx.update(|window, cx| {
let message = format!("Failed to pick save location: {error:#}");
let note = Notification::error(message).autohide(false);
window.push_notification(note, cx);
})?;
return Ok(());
}
};
// Get the backup task
let backup =
device.read_with(cx, |this, cx| this.backup(output_path.clone(), cx))?;
// Run the backup task
backup.await?;
// Open the backup file with the system's default application
cx.update(|_window, cx| {
cx.open_with_system(output_path.as_path());
})?;
Ok::<_, anyhow::Error>(())
})
.detach();
}
_ => {}
}
}
@@ -444,7 +483,7 @@ impl Workspace {
.child(
div()
.italic()
.text_color(cx.theme().warning_active)
.text_color(cx.theme().text_danger)
.child(SharedString::from(ENC_WARN)),
),
)
@@ -708,6 +747,7 @@ impl Workspace {
let requesting = device.read(cx).requesting;
this.min_w(px(260.))
.label("Encryption Key")
.when(requesting, |this| {
this.item(PopupMenuItem::element(move |_window, cx| {
h_flex()
@@ -730,6 +770,9 @@ impl Workspace {
.w_full()
.gap_2()
.text_sm()
.when(!subscribing, |this| {
this.text_color(cx.theme().text_muted)
})
.child(div().size_1p5().rounded_full().map(|this| {
if subscribing {
this.bg(cx.theme().icon_accent)
@@ -739,13 +782,24 @@ impl Workspace {
}))
.map(|this| {
if subscribing {
this.child(SharedString::from("Getting messages..."))
this.child("Listening for messages")
} else {
this.child(SharedString::from("Not getting messages"))
this.child("Idle")
}
})
}))
.separator()
.menu_with_icon(
"Backup",
IconName::Shield,
Box::new(Command::BackupEncryption),
)
.menu_with_icon(
"Restore from secret key",
IconName::Usb,
Box::new(Command::ImportEncryption),
)
.separator()
.menu_with_icon(
"Reload",
IconName::Refresh,
@@ -766,7 +820,7 @@ impl Workspace {
.loading(!inbox_connected)
.disabled(!inbox_connected)
.when(!inbox_connected, |this| {
this.tooltip("Connecting to user's messaging relays...")
this.tooltip("Connecting to the user's messaging relays...")
})
.when(inbox_connected, |this| this.indicator())
.dropdown_menu(move |this, _window, cx| {
@@ -838,7 +892,7 @@ impl Workspace {
.loading(!relay_connected)
.disabled(!relay_connected)
.when(!relay_connected, |this| {
this.tooltip("Connecting to user's relay list...")
this.tooltip("Connecting to the user's relay list...")
})
.when(relay_connected, |this| this.indicator())
.dropdown_menu(move |this, _window, _cx| {

View File

@@ -1,5 +1,6 @@
use std::cell::Cell;
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::rc::Rc;
use std::time::Duration;
@@ -34,8 +35,8 @@ impl Global for GlobalDeviceRegistry {}
pub enum DeviceEvent {
/// A new encryption signer has been set
Set,
/// The encryption key has been reset
Reset,
/// The device is requesting an encryption key
Requesting,
/// Encryption key is not set
NotSet { reason: SharedString },
/// An event to notify that Coop isn't subscribed to gift wrap events
@@ -288,6 +289,21 @@ impl DeviceRegistry {
})
}
/// Backup the encryption's secret key to a file
pub fn backup(&self, path: PathBuf, cx: &App) -> Task<Result<(), Error>> {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
cx.background_spawn(async move {
let keys = get_keys(&client).await?;
let content = keys.secret_key().to_bech32()?;
smol::fs::write(path, &content).await?;
Ok(())
})
}
/// Get device announcement for current user
pub fn get_announcement(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
@@ -507,6 +523,8 @@ impl DeviceRegistry {
this.update(cx, |this, cx| {
this.set_requesting(true, cx);
this.wait_for_approval(cx);
cx.emit(DeviceEvent::Requesting);
})?;
}
Err(e) => {