Rename the app to Keynectr

- Crate/binary: nostr-manager-backend -> keynectr
- Data directory: nost-feed-manager -> keynectr, migrated automatically
  on first data_dir() call (existing vaults, settings and backups move)
- Electron extraResources/spawn path, executableName, productName,
  window title and CLI usage strings updated to match
- Deliberately unchanged: crypto.rs KDF verifier string, so previously
  encrypted vault backups remain decryptable

Verified live: existing vault with two profiles migrated to
~/.local/share/keynectr and loads correctly.
This commit is contained in:
Avi 2026-08-23 10:22:25 -05:00
commit 7c6a085bf1
13 changed files with 108 additions and 264 deletions

38
Cargo.lock generated
View file

@ -683,6 +683,25 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "keynectr"
version = "0.1.0"
dependencies = [
"aes-gcm",
"argon2",
"base64",
"getrandom 0.2.17",
"hex",
"nostr",
"nostr-sdk",
"rpassword",
"serde",
"serde_json",
"tokio",
"uuid",
"zeroize",
]
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.189" version = "0.2.189"
@ -781,25 +800,6 @@ dependencies = [
"tokio", "tokio",
] ]
[[package]]
name = "nostr-manager-backend"
version = "0.1.0"
dependencies = [
"aes-gcm",
"argon2",
"base64",
"getrandom 0.2.17",
"hex",
"nostr",
"nostr-sdk",
"rpassword",
"serde",
"serde_json",
"tokio",
"uuid",
"zeroize",
]
[[package]] [[package]]
name = "nostr-relay-pool" name = "nostr-relay-pool"
version = "0.40.1" version = "0.40.1"

View file

@ -1,5 +1,5 @@
[package] [package]
name = "nostr-manager-backend" name = "keynectr"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"

View file

@ -76,10 +76,10 @@ let nextId = 1;
function resolveBackendPath(): string { function resolveBackendPath(): string {
if (app.isPackaged) { if (app.isPackaged) {
return path.join(process.resourcesPath, 'nostr-manager-backend'); return path.join(process.resourcesPath, 'keynectr');
} }
// Development: the crate builds to <project>/target/release. // Development: the crate builds to <project>/target/release.
return path.join(app.getAppPath(), '..', 'target', 'release', 'nostr-manager-backend'); return path.join(app.getAppPath(), '..', 'target', 'release', 'keynectr');
} }
function startBackend(): void { function startBackend(): void {

View file

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Nostr Feed Manager</title> <title>Keynectr</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View file

@ -1,11 +1,11 @@
{ {
"name": "nost-feed-manager", "name": "keynectr",
"version": "0.1.0", "version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "nost-feed-manager", "name": "keynectr",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"react": "^18.3.1", "react": "^18.3.1",

View file

@ -1,5 +1,6 @@
{ {
"name": "nost-feed-manager", "name": "keynectr",
"productName": "Keynectr",
"productName": "Nostr Feed Manager", "productName": "Nostr Feed Manager",
"version": "0.1.0", "version": "0.1.0",
"description": "A friendly Linux desktop app for managing Nostr profiles and publishing text notes.", "description": "A friendly Linux desktop app for managing Nostr profiles and publishing text notes.",
@ -58,8 +59,8 @@
], ],
"extraResources": [ "extraResources": [
{ {
"from": "../target/release/nostr-manager-backend", "from": "../target/release/keynectr",
"to": "nostr-manager-backend" "to": "keynectr"
} }
], ],
"linux": { "linux": {
@ -67,7 +68,7 @@
"dir" "dir"
], ],
"category": "Network", "category": "Network",
"executableName": "nost-feed-manager" "executableName": "keynectr"
} }
} }
} }

View file

@ -11,7 +11,7 @@ describe('SettingsScreen', () => {
renderWithApp(<SettingsScreen />); renderWithApp(<SettingsScreen />);
expect( expect(
await screen.findByText('/home/user/.local/share/nost-feed-manager/profiles_vault.json'), await screen.findByText('/home/user/.local/share/keynectr/profiles_vault.json'),
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.getByText('Storage is not encrypted')).toBeInTheDocument(); expect(screen.getByText('Storage is not encrypted')).toBeInTheDocument();
}); });

View file

@ -35,8 +35,8 @@ export function makeState(overrides?: Partial<AppState>): AppState {
}; };
return { return {
version: '0.1.0', version: '0.1.0',
vault_path: '/home/user/.local/share/nost-feed-manager/profiles_vault.json', vault_path: '/home/user/.local/share/keynectr/profiles_vault.json',
settings_path: '/home/user/.local/share/nost-feed-manager/settings.json', settings_path: '/home/user/.local/share/keynectr/settings.json',
encrypted_storage: false, encrypted_storage: false,
vault_locked: false, vault_locked: false,
migrated_from: null, migrated_from: null,
@ -188,7 +188,7 @@ export function createApiMock(initial: AppState = makeState()): ApiMock {
}, },
), ),
backupNow: vi.fn(async () => ({ backupNow: vi.fn(async () => ({
backup_path: '/home/user/.local/share/nost-feed-manager/profiles_vault.json.backup-1', backup_path: '/home/user/.local/share/keynectr/profiles_vault.json.backup-1',
})), })),
setVaultPassword: vi.fn(async () => ({ setVaultPassword: vi.fn(async () => ({
...state, ...state,

View file

@ -151,7 +151,7 @@ pub struct ReplyEnvelope {
/// Run the JSON-lines IPC server on stdin/stdout. /// Run the JSON-lines IPC server on stdin/stdout.
/// ///
/// The Electron main process spawns `nostr-manager-backend serve` and /// The Electron main process spawns `keynectr serve` and
/// exchanges one JSON object per line. Requests are processed sequentially so /// exchanges one JSON object per line. Requests are processed sequentially so
/// the shared state never sees concurrent mutations. /// the shared state never sees concurrent mutations.
pub async fn serve() -> Result<(), AppError> { pub async fn serve() -> Result<(), AppError> {

View file

@ -14,4 +14,7 @@ pub mod vault;
pub use errors::AppError; pub use errors::AppError;
/// Stable application-data directory name. /// Stable application-data directory name.
pub const APP_DIR_NAME: &str = "nost-feed-manager"; pub const APP_DIR_NAME: &str = "keynectr";
/// Previous application-data directory name, migrated automatically on first use.
pub const LEGACY_APP_DIR_NAME: &str = "nost-feed-manager";

View file

@ -1,18 +1,18 @@
use std::process::ExitCode; use std::process::ExitCode;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use nostr_manager_backend::app::App; use keynectr::app::App;
use nostr_manager_backend::errors::{AppError, ErrorKind}; use keynectr::errors::{AppError, ErrorKind};
use nostr_manager_backend::ipc; use keynectr::ipc;
use nostr_manager_backend::profiles::{self, ProfileSummary}; use keynectr::profiles::{self, ProfileSummary};
use nostr_manager_backend::publish; use keynectr::publish;
use nostr_manager_backend::relays; use keynectr::relays;
use nostr_manager_backend::settings::Theme; use keynectr::settings::Theme;
use nostr_manager_backend::signer::Signer; use keynectr::signer::Signer;
use nostr_manager_backend::vault::{self, StoredProfile, Vault}; use keynectr::vault::{self, StoredProfile, Vault};
const USAGE: &str = "\ const USAGE: &str = "\
nostr-manager-backend <command> [args...] keynectr <command> [args...]
Commands: Commands:
create <label> Create a new profile create <label> Create a new profile
@ -133,7 +133,7 @@ fn cli_list() -> Result<String, AppError> {
fn cli_switch(args: &[String]) -> Result<String, AppError> { fn cli_switch(args: &[String]) -> Result<String, AppError> {
let npub = args let npub = args
.get(2) .get(2)
.ok_or_else(|| AppError::config("Usage: nostr-manager-backend switch <npub>"))?; .ok_or_else(|| AppError::config("Usage: keynectr switch <npub>"))?;
let mut app = App::load()?; let mut app = App::load()?;
profiles::set_active(&mut app.vault, npub)?; profiles::set_active(&mut app.vault, npub)?;
@ -143,9 +143,7 @@ fn cli_switch(args: &[String]) -> Result<String, AppError> {
async fn cli_publish(args: &[String]) -> Result<String, AppError> { async fn cli_publish(args: &[String]) -> Result<String, AppError> {
if args.len() < 4 { if args.len() < 4 {
return Err(AppError::config( return Err(AppError::config("Usage: keynectr publish <npub> <content>"));
"Usage: nostr-manager-backend publish <npub> <content>",
));
} }
let npub = &args[2]; let npub = &args[2];
@ -169,9 +167,7 @@ async fn cli_publish(args: &[String]) -> Result<String, AppError> {
fn cli_set_picture(args: &[String]) -> Result<String, AppError> { fn cli_set_picture(args: &[String]) -> Result<String, AppError> {
let [_, _, npub, url] = args else { let [_, _, npub, url] = args else {
return Err(AppError::config( return Err(AppError::config("Usage: keynectr set-picture <npub> <url>"));
"Usage: nostr-manager-backend set-picture <npub> <url>",
));
}; };
let mut app = load_app_with_unlock()?; let mut app = load_app_with_unlock()?;
@ -194,7 +190,7 @@ fn cli_set_picture(args: &[String]) -> Result<String, AppError> {
fn cli_publish_name(args: &[String]) -> Result<String, AppError> { fn cli_publish_name(args: &[String]) -> Result<String, AppError> {
let npub = args let npub = args
.get(2) .get(2)
.ok_or_else(|| AppError::config("Usage: nostr-manager-backend publish-name <npub>"))?; .ok_or_else(|| AppError::config("Usage: keynectr publish-name <npub>"))?;
let app = load_app_with_unlock()?; let app = load_app_with_unlock()?;
let key = app.vault_key().copied(); let key = app.vault_key().copied();
@ -227,7 +223,7 @@ async fn cli_feed(args: &[String]) -> Result<String, AppError> {
let limit = rest let limit = rest
.first() .first()
.and_then(|raw| raw.parse::<usize>().ok().filter(|n| *n > 0)) .and_then(|raw| raw.parse::<usize>().ok().filter(|n| *n > 0))
.unwrap_or(nostr_manager_backend::feed::DEFAULT_LIMIT); .unwrap_or(keynectr::feed::DEFAULT_LIMIT);
let app = App::load()?; let app = App::load()?;
let items = if contacts { let items = if contacts {
@ -236,10 +232,10 @@ async fn cli_feed(args: &[String]) -> Result<String, AppError> {
.active_profile .active_profile
.as_deref() .as_deref()
.ok_or_else(AppError::no_active_profile)?; .ok_or_else(AppError::no_active_profile)?;
let pubkey = nostr_manager_backend::feed::owner_pubkey(npub)?; let pubkey = keynectr::feed::owner_pubkey(npub)?;
nostr_manager_backend::feed::contact_feed(&app.settings, limit, &pubkey.to_hex()).await? keynectr::feed::contact_feed(&app.settings, limit, &pubkey.to_hex()).await?
} else { } else {
nostr_manager_backend::feed::aggregate_feed(&app.settings, limit).await? keynectr::feed::aggregate_feed(&app.settings, limit).await?
}; };
if items.is_empty() { if items.is_empty() {
return Ok(if contacts { return Ok(if contacts {
@ -274,9 +270,7 @@ fn shorten_note(content: &str) -> String {
async fn cli_relays(args: &[String]) -> Result<String, AppError> { async fn cli_relays(args: &[String]) -> Result<String, AppError> {
let sub = args.get(2).ok_or_else(|| { let sub = args.get(2).ok_or_else(|| {
AppError::config( AppError::config("Usage: keynectr relays <list|add|remove|enable|disable|test> [...]")
"Usage: nostr-manager-backend relays <list|add|remove|enable|disable|test> [...]",
)
})?; })?;
let mut app = App::load()?; let mut app = App::load()?;
@ -332,7 +326,7 @@ async fn cli_relays(args: &[String]) -> Result<String, AppError> {
fn cli_settings(args: &[String]) -> Result<String, AppError> { fn cli_settings(args: &[String]) -> Result<String, AppError> {
let sub = args let sub = args
.get(2) .get(2)
.ok_or_else(|| AppError::config("Usage: nostr-manager-backend settings <get|set>"))?; .ok_or_else(|| AppError::config("Usage: keynectr settings <get|set>"))?;
let mut app = App::load()?; let mut app = App::load()?;
@ -447,7 +441,7 @@ fn cli_unlock() -> Result<String, AppError> {
fn cli_show_secret(args: &[String]) -> Result<String, AppError> { fn cli_show_secret(args: &[String]) -> Result<String, AppError> {
let npub = args let npub = args
.get(2) .get(2)
.ok_or_else(|| AppError::config("Usage: nostr-manager-backend show-secret <npub>"))?; .ok_or_else(|| AppError::config("Usage: keynectr show-secret <npub>"))?;
let app = load_app_with_unlock()?; let app = load_app_with_unlock()?;
let revealed = profiles::reveal_secret_key(&app.vault, npub, app.vault_key())?; let revealed = profiles::reveal_secret_key(&app.vault, npub, app.vault_key())?;
@ -462,7 +456,7 @@ fn cli_show_secret(args: &[String]) -> Result<String, AppError> {
async fn cli_signer(args: &[String]) -> Result<String, AppError> { async fn cli_signer(args: &[String]) -> Result<String, AppError> {
let sub = args let sub = args
.get(2) .get(2)
.ok_or_else(|| AppError::config("Usage: nostr-manager-backend signer <status|connect>"))?; .ok_or_else(|| AppError::config("Usage: keynectr signer <status|connect>"))?;
match sub.as_str() { match sub.as_str() {
"status" => { "status" => {
@ -483,7 +477,7 @@ async fn cli_signer(args: &[String]) -> Result<String, AppError> {
), ),
"In the GUI, the signer listens as long as the app is running. From here, run:" "In the GUI, the signer listens as long as the app is running. From here, run:"
.to_string(), .to_string(),
" nostr-manager-backend signer connect <nostrconnect://…>".to_string(), " keynectr signer connect <nostrconnect://…>".to_string(),
"Sign/decrypt requests must be approved in the GUI signer screen.".to_string(), "Sign/decrypt requests must be approved in the GUI signer screen.".to_string(),
] ]
.join("\n")) .join("\n"))

View file

@ -1,194 +0,0 @@
use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use std::env;
use std::fs;
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Serialize, Deserialize, Clone)]
struct Profile {
label: String,
public_key: String,
secret_key: String, // Stored as a plaintext hex string
created_at: u64,
}
const VAULT_FILE: &str = "profiles_vault.json";
fn load_vault() -> Result<Vec<Profile>, String> {
if !Path::new(VAULT_FILE).exists() {
return Ok(Vec::new());
}
let content = fs::read_to_string(VAULT_FILE)
.map_err(|e| format!("Failed to read {VAULT_FILE}: {e}"))?;
if content.trim().is_empty() {
return Ok(Vec::new());
}
serde_json::from_str(&content)
.map_err(|e| format!("Failed to parse {VAULT_FILE}: {e}"))
}
fn save_vault(profiles: &[Profile]) -> Result<(), String> {
let content = serde_json::to_string_pretty(profiles)
.map_err(|e| format!("Failed to serialize profiles: {e}"))?;
fs::write(VAULT_FILE, content)
.map_err(|e| format!("Failed to write {VAULT_FILE}: {e}"))
}
fn unix_timestamp() -> Result<u64, String> {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.map_err(|e| format!("System clock error: {e}"))
}
#[tokio::main]
async fn main() {
if let Err(error) = run().await {
eprintln!("Error: {error}");
std::process::exit(1);
}
}
async fn run() -> Result<(), String> {
let args: Vec<String> = env::args().collect();
let command = args.get(1).ok_or_else(|| {
[
"Usage: nostr-manager-backend <command> [args...]",
"Commands:",
" create <label>",
" list",
" switch <npub>",
" publish <npub> <content>",
]
.join("\n")
})?;
let result = match command.as_str() {
"create" => {
let label = args
.get(2)
.cloned()
.unwrap_or_else(|| "New Profile".to_string());
let keys = Keys::generate();
// Explicit ::hex prevents conflict with nostr_sdk::prelude::*.
let secret_hex =
::hex::encode(keys.secret_key().to_secret_bytes());
let public_key = keys
.public_key()
.to_bech32()
.map_err(|e| format!("Failed to encode public key: {e}"))?;
let profile = Profile {
label,
public_key: public_key.clone(),
secret_key: secret_hex,
created_at: unix_timestamp()?,
};
let mut profiles = load_vault()?;
profiles.push(profile);
save_vault(&profiles)?;
format!("Created profile: {public_key}")
}
"list" => {
let profiles = load_vault()?;
serde_json::to_string_pretty(&profiles)
.map_err(|e| format!("Failed to serialize profiles: {e}"))?
}
"switch" => {
let npub = args
.get(2)
.ok_or("Usage: nostr-manager-backend switch <npub>")?;
let profiles = load_vault()?;
if !profiles.iter().any(|profile| profile.public_key == *npub) {
return Err(format!("No stored profile found for {npub}"));
}
format!("Switched context to: {npub}")
}
"publish" => {
if args.len() < 4 {
return Err(
"Usage: nostr-manager-backend publish <npub> <content>"
.to_string(),
);
}
let npub = &args[2];
let content = args[3..].join(" ");
let profiles = load_vault()?;
let secret_hex = profiles
.iter()
.find(|profile| profile.public_key == *npub)
.map(|profile| profile.secret_key.clone())
.ok_or_else(|| format!("No stored profile found for {npub}"))?;
let secret_bytes = ::hex::decode(&secret_hex)
.map_err(|e| format!("Stored secret key is not valid hex: {e}"))?;
let secret_key = SecretKey::from_slice(&secret_bytes)
.map_err(|e| format!("Stored secret key is invalid: {e}"))?;
// Keys::from(secret_key) is incorrect for this nostr-sdk API.
let keys = Keys::new(secret_key);
let client = Client::new(keys.clone());
client
.add_relay("wss://relay.damus.io")
.await
.map_err(|e| format!("Failed to add Damus relay: {e}"))?;
client
.add_relay("wss://relay.nostr.band")
.await
.map_err(|e| format!("Failed to add nostr.band relay: {e}"))?;
client.connect().await;
let builder = EventBuilder::new(Kind::TextNote, content);
let event = builder
.sign(&keys)
.await
.map_err(|e| format!("Failed to sign event: {e}"))?;
client
.send_event(&event)
.await
.map_err(|e| format!("Failed to publish event: {e}"))?;
let event_id = event
.id
.to_bech32()
.map_err(|e| format!("Failed to encode event ID: {e}"))?;
format!("Published: {event_id}")
}
_ => {
return Err(format!("Unknown command: {command}"));
}
};
println!("{result}");
Ok(())
}

View file

@ -3,6 +3,7 @@ use std::fs;
use std::io::Write; use std::io::Write;
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Once;
use std::time::{SystemTime, UNIX_EPOCH}; use std::time::{SystemTime, UNIX_EPOCH};
use base64::engine::general_purpose::STANDARD as B64; use base64::engine::general_purpose::STANDARD as B64;
@ -10,7 +11,6 @@ use base64::Engine;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::errors::AppError; use crate::errors::AppError;
use crate::APP_DIR_NAME;
/// Current vault schema version. /// Current vault schema version.
pub const VAULT_VERSION: u32 = 2; pub const VAULT_VERSION: u32 = 2;
@ -114,17 +114,57 @@ pub fn unix_timestamp() -> Result<u64, AppError> {
/// Stable application-data directory for this app. /// Stable application-data directory for this app.
/// ///
/// Uses `$XDG_DATA_HOME` when set, otherwise `~/.local/share`. /// Uses `$XDG_DATA_HOME` when set, otherwise `~/.local/share`.
///
/// The first call also migrates a previous-named data directory (from before
/// the app was renamed) by moving it to the new location, so existing vaults,
/// settings and backups are preserved without any user action.
pub fn data_dir() -> PathBuf { pub fn data_dir() -> PathBuf {
if let Ok(dir) = env::var("XDG_DATA_HOME") { let dir = if let Ok(dir) = env::var("XDG_DATA_HOME") {
if !dir.trim().is_empty() { if !dir.trim().is_empty() {
return PathBuf::from(dir).join(APP_DIR_NAME); PathBuf::from(dir).join(crate::APP_DIR_NAME)
} else {
default_app_dir()
} }
} } else {
default_app_dir()
};
static MIGRATED: Once = Once::new();
MIGRATED.call_once(|| migrate_legacy_app_dir(&dir));
dir
}
fn default_app_dir() -> PathBuf {
let home = env::var("HOME").unwrap_or_else(|_| ".".to_string()); let home = env::var("HOME").unwrap_or_else(|_| ".".to_string());
PathBuf::from(home) PathBuf::from(home)
.join(".local") .join(".local")
.join("share") .join("share")
.join(APP_DIR_NAME) .join(crate::APP_DIR_NAME)
}
/// Move the legacy application-data directory to the new name, once.
///
/// No-op when the target already exists or there is nothing to move; a failed
/// move is reported rather than silently losing the data.
fn migrate_legacy_app_dir(target: &Path) {
if target.exists() {
return;
}
let legacy = match &target.parent() {
Some(parent) => parent.join(crate::LEGACY_APP_DIR_NAME),
None => return,
};
if !legacy.exists() {
return;
}
if let Err(e) = fs::rename(&legacy, target) {
// Leave both in place: the app starts fresh at `target` and the old
// data stays untouched for manual recovery.
eprintln!(
"Warning: could not move {} to {}: {e}",
legacy.display(),
target.display()
);
}
} }
pub fn vault_path() -> PathBuf { pub fn vault_path() -> PathBuf {
@ -402,7 +442,7 @@ mod tests {
fn temp_vault_path() -> PathBuf { fn temp_vault_path() -> PathBuf {
let dir = env::temp_dir().join(format!( let dir = env::temp_dir().join(format!(
"nost-feed-manager-test-{}-{}", "keynectr-test-{}-{}",
std::process::id(), std::process::id(),
COUNTER.fetch_add(1, Ordering::SeqCst) COUNTER.fetch_add(1, Ordering::SeqCst)
)); ));