Files
buzz/desktop/src-tauri/src/commands/workspace.rs

167 lines
6.6 KiB
Rust

use nostr::Keys;
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter, State};
use crate::app_state::AppState;
use crate::managed_agents::{
effective_repos_dir, ensure_repos_symlink, nest_dir, try_regenerate_nest,
write_persisted_repos_dir,
};
use crate::relay;
#[derive(Deserialize)]
struct RelayInfoIcon {
#[serde(default)]
icon: Option<String>,
}
/// Fetch a relay's workspace icon from its NIP-11 relay information document.
///
/// Works for any workspace (active or not) with a plain unauthenticated HTTP
/// GET — no WebSocket session needed. Returns `None` when the relay has no
/// icon set, is unreachable, or serves a malformed document: the rail falls
/// back to initials in all three cases.
#[tauri::command]
pub async fn fetch_workspace_icon(
relay_url: String,
state: State<'_, AppState>,
) -> Result<Option<String>, String> {
let http_url = relay::relay_http_base_url(&relay_url);
let Ok(response) = state
.http_client
.get(&http_url)
.header("Accept", "application/nostr+json")
.send()
.await
else {
return Ok(None);
};
if !response.status().is_success() {
return Ok(None);
}
let doc = response
.json::<RelayInfoIcon>()
.await
.unwrap_or(RelayInfoIcon { icon: None });
Ok(doc.icon.filter(|icon| !icon.is_empty()))
}
#[derive(Serialize)]
pub struct ActiveWorkspaceInfo {
relay_url: String,
pubkey: String,
}
/// Returns the current active workspace info (relay URL + pubkey).
#[tauri::command]
pub fn get_active_workspace(state: State<'_, AppState>) -> Result<ActiveWorkspaceInfo, String> {
let keys = state.keys.lock().map_err(|e| e.to_string())?;
let relay_url = relay::relay_ws_url_with_override(&state);
Ok(ActiveWorkspaceInfo {
relay_url,
pubkey: keys.public_key().to_hex(),
})
}
/// Validate a candidate `repos_dir` without mutating the filesystem.
///
/// The Add/Edit workspace dialogs call this on submit to block Save on a bad
/// path, so a typo never reaches `apply_workspace`. Reuses the same
/// `validate_repos_dir` the boot/apply path uses — one source of truth for
/// "what's a valid repos dir". An empty/whitespace value clears the override
/// and is valid. `Err` carries the human-readable reason for inline display.
#[tauri::command]
pub fn validate_repos_dir(dir: String) -> Result<(), String> {
let trimmed = dir.trim();
if trimmed.is_empty() {
return Ok(());
}
let nest = nest_dir().ok_or("cannot resolve home directory for nest")?;
crate::managed_agents::validate_repos_dir(&nest, trimmed).map(|_| ())
}
/// Apply a workspace's configuration to the backend session.
///
/// Called by the frontend on app init (after reload) to configure the
/// Tauri backend with the selected workspace's relay URL, keys, and repos
/// directory.
///
/// A bad `repos_dir` is non-fatal: relay/keys always apply (the relay is the
/// active workspace's own choice — orthogonal to the filesystem repos dir),
/// the bad value is NOT persisted (so the next boot starts clean), the
/// `REPOS` symlink is skipped (REPOS stays a real dir), a `repos-dir-error`
/// event surfaces the reason, and the command returns `Ok`. The dialogs
/// already block a bad path at Save (`validate_repos_dir`); this fallback only
/// catches a value that went bad after save (deleted dir, unmounted volume).
#[tauri::command]
pub fn apply_workspace(
relay_url: String,
nsec: Option<String>,
repos_dir: Option<String>,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<(), String> {
// ── Validate before mutating ──────────────────────────────────────────
let parsed_keys = match nsec.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
Some(nsec_trimmed) => {
Some(Keys::parse(nsec_trimmed).map_err(|e| format!("invalid nsec: {e}"))?)
}
None => None,
};
// Decide the effective repos_dir from the candidate. A bad path does NOT
// reject — it is treated as if no override were set: relay/keys still
// apply, the bad value is not persisted, and a `repos-dir-error` surfaces
// the reason. Persisting a bad path would make every later boot read it,
// fail to resolve the symlink, and silently skip agent restore. One
// validate (inside `effective_repos_dir`) drives both the emit and the
// persisted value. `nest` is resolved softly: when absent there is nothing
// to persist or symlink, and relay/keys must still apply unconditionally.
let nest = nest_dir();
let effective_repos_dir = match nest.as_deref() {
Some(nest) => match effective_repos_dir(nest, repos_dir.as_deref()) {
Ok(value) => value,
Err(error) => {
let _ = app.emit("repos-dir-error", error);
None
}
},
None => None,
};
// ── Apply all state changes (nothing below can fail) ──────────────────
{
let mut override_guard = state.relay_url_override.lock().map_err(|e| e.to_string())?;
*override_guard = Some(relay_url);
}
if let Some(keys) = parsed_keys {
let mut keys_guard = state.keys.lock().map_err(|e| e.to_string())?;
*keys_guard = keys;
}
// ── Filesystem side-effect (non-fatal) ────────────────────────────────
// Persist the *effective* repos_dir (None when the candidate failed
// validation) for the backend to read at boot, then re-point REPOS to
// match. Persisting first makes the dotfile authoritative even if the
// symlink apply fails here (e.g. a non-empty real REPOS): the next boot
// reads the persisted value and resolves the symlink before any agent can
// clone into REPOS. A bad candidate persists `None`, so the next boot is
// clean and agent restore proceeds. Failure of either must NOT fail the
// command — relay/keys are already applied. Surface symlink errors via
// `repos-dir-error`.
if let Some(nest) = nest.as_deref() {
if let Err(error) = write_persisted_repos_dir(nest, effective_repos_dir.as_deref()) {
eprintln!("buzz-desktop: persist repos dir failed: {error}");
}
if let Err(error) = ensure_repos_symlink(nest, effective_repos_dir.as_deref()) {
eprintln!("buzz-desktop: repos dir setup failed: {error}");
let _ = app.emit("repos-dir-error", error);
}
}
try_regenerate_nest(&app);
Ok(())
}