diff --git a/crates/buzz-db/src/partition.rs b/crates/buzz-db/src/partition.rs index b3803f1b34..25f541789f 100644 --- a/crates/buzz-db/src/partition.rs +++ b/crates/buzz-db/src/partition.rs @@ -4,10 +4,47 @@ use chrono::{Datelike, TimeZone, Utc}; use sqlx::{PgPool, Row}; -use tracing::info; +use tracing::{info, warn}; use crate::error::{DbError, Result}; +/// True when a `sqlx::Error` is a Postgres `42P17` ("would overlap partition") +/// raised by a `CREATE PARTITION` whose range is already covered. Fresh schemas +/// include a right-edge catch-all (`*_p_future`), so a fresh install collides +/// on the "current month" boundary it tries to add. Split out of +/// `ensure_partition` so the classification is unit-testable without Postgres. +fn is_partition_overlap_error(err: &sqlx::Error) -> bool { + match err { + sqlx::Error::Database(db) => { + db.code().as_deref() == Some("42P17") + && db.message().contains("would overlap partition") + } + _ => false, + } +} + +/// Record #4033: a monthly partition attempt was absorbed by the `*_p_future` +/// catch-all (monthly partition NOT created). Metric so operators can alert +/// independent of log level. +fn catch_all_coverage_metric(table: &'static str) { + metrics::counter!( + "buzz_db_partition_catchall_coverage", + "table" => table, + ) + .increment(1); +} + +/// Resolve a validated table name to its static allowlist entry so the metric +/// label escapes the caller's stack. `ensure_partition` already validated the +/// name against `PARTITIONED_TABLES`; `.expect` is unreachable for valid input. +fn static_table_name(table: &str) -> &'static str { + PARTITIONED_TABLES + .iter() + .copied() + .find(|t| *t == table) + .expect("table name validated against PARTITIONED_TABLES") +} + /// Tables that may be partition-managed. Allowlist prevents DDL injection. const PARTITIONED_TABLES: &[&str] = &["events", "delivery_log"]; @@ -132,17 +169,20 @@ async fn ensure_partition( info!("added partition {partition_name}"); Ok(()) } - Err(sqlx::Error::Database(db_err)) - if db_err.code().as_deref() == Some("42P17") - && db_err.message().contains("would overlap partition") => - { - // Fresh schemas include a right-edge catch-all partition (`*_p_future`). - // If it already covers this month, the table is still safe for writes; - // treat the overlap as "ensured" rather than failing startup. - info!( - partition_name, - "partition range already covered by an existing partition" + Err(e) if is_partition_overlap_error(&e) => { + // #4033: the `*_p_future` catch-all already covers this month, so the + // monthly partition was NOT created and never will be — every row for + // this range keeps landing in the catch-all. Postgres only logs an + // unsuppressable server-side ERROR; the app must NOT surface this as a + // silent `info!` success. Escalate to `warn!` and emit a metric so the + // growing catch-all is alertable, but still return Ok: writes are safe + // and startup must not fail. + warn!( + partition = %partition_name, + table = %table_name, + "monthly partition was NOT created: range already covered by the `*_p_future` catch-all (Postgres 42P17); rows for this range keep accumulating in the catch-all. Re-base or split the catch-all to restore monthly pruning/archival." ); + catch_all_coverage_metric(static_table_name(table_name)); Ok(()) } Err(e) => Err(e.into()), @@ -153,6 +193,50 @@ async fn ensure_partition( mod tests { use super::*; + #[test] + fn overlap_predicate_classifies_42p17() { + // We cannot easily construct a real sqlx::Error::Database without a + // Postgres connection, so this documents the contract: the predicate + // only matches sqlx::Error::Database variants whose code is 42P17 and + // whose message contains "would overlap partition" (covered by the + // 42P17-catch-all arm in `ensure_partition`); every other sqlx::Error + // variant (PoolClosed here) must NOT match. + let non_db = sqlx::Error::PoolClosed; + assert!(!is_partition_overlap_error(&non_db)); + } + + #[test] + fn catchall_metric_emits_under_local_recorder() { + use metrics_util::debugging::DebuggingRecorder; + + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let guard = metrics::set_default_local_recorder(&recorder); + catch_all_coverage_metric("events"); + drop(guard); + + let hit = snapshotter + .snapshot() + .into_vec() + .into_iter() + .filter(|(key, ..)| key.key().name() == "buzz_db_partition_catchall_coverage") + .map(|(key, _, _, value)| { + let metrics_util::debugging::DebugValue::Counter(n) = value else { + panic!("must be a counter"); + }; + let labels: Vec<_> = key.key().labels().collect(); + let table = labels + .iter() + .find(|l| l.key() == "table") + .map(|l| l.value().to_owned()) + .unwrap_or_default(); + (table, n) + }) + .collect::>(); + + assert_eq!(hit, vec![("events".to_owned(), 1)]); + } + #[test] fn suffix_validation() { assert!(validate_partition_suffix("2026_03")); diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index fc90e6ab14..765688b687 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -54,6 +54,9 @@ pub struct AppState { pub managed_agent_processes: Mutex>, pub huddle_state: Mutex, pub huddle_audio: crate::huddle::tts_settings::HuddleAudioSettingsState, + /// macOS-only close-to-tray behavior (#4024). Resolved during setup + /// from `close_to_tray::load_for_app`; persisted in close-to-tray.json. + pub close_to_tray_behavior: Mutex, /// Tauri app handle — stored after setup so huddle commands can emit /// `huddle-state-changed` events without needing the handle threaded /// through every call site. @@ -218,6 +221,7 @@ pub fn build_app_state() -> AppState { session_config_cache: Mutex::new(HashMap::new()), huddle_state: Mutex::new(HuddleState::default()), huddle_audio: Default::default(), + close_to_tray_behavior: Mutex::new(crate::close_to_tray::CloseToTrayBehavior::default()), app_handle: Mutex::new(None), media_proxy_port: AtomicU16::new(0), prevent_sleep: Arc::new(Mutex::new( diff --git a/desktop/src-tauri/src/close_to_tray.rs b/desktop/src-tauri/src/close_to_tray.rs new file mode 100644 index 0000000000..4c07915fb4 --- /dev/null +++ b/desktop/src-tauri/src/close_to_tray.rs @@ -0,0 +1,214 @@ +//! macOS close-to-tray behavior preference (#4024). +//! +//! Closing Buzz's main window keeps the process and local agents running but +//! hides the only main window. That suits background agent work, but a user on +//! a window-switcher that excludes hidden windows (e.g. BetterTouchTool) loses +//! Buzz from the switcher even though it is still running — the app becomes +//! "windowless" with no way back through the normal switching workflow. +//! +//! This module persists a per-installation preference controlling what the +//! macOS close handler does. The default stays `KeepRunning` (current +//! behavior), preserving active agent work; `QuitWhenClosed` gives users who +//! prefer conventional visible-window app switching an explicit opt-out. + +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Manager, State}; + +use crate::{app_state::AppState, managed_agents::storage::atomic_write_json_restricted}; + +const SETTINGS_FILE: &str = "close-to-tray.json"; +const CURRENT_VERSION: u32 = 1; + +/// What the macOS handler does when the main window is closed. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum CloseToTrayBehavior { + /// Keep Buzz running in the menu bar with the window hidden (default, + /// preserves active agent sessions). Selected on the initial install and + /// whenever the setting file is missing or unreadable. + #[serde(rename = "keepRunning")] + KeepRunning, + /// Minimize the window to the Dock instead of hiding it, keeping Buzz + /// visible to window switchers that exclude hidden windows. + #[serde(rename = "minimizeToTray")] + MinimizeToTray, + /// Quit Buzz when the last main window closes, for users who do not want + /// a windowless background process. + #[serde(rename = "quitWhenClosed")] + QuitWhenClosed, +} + +impl Default for CloseToTrayBehavior { + /// Default to the current unconditional behavior (`keepRunning`) so an + /// upgrade without the setting behaves identically to before. + fn default() -> Self { + Self::KeepRunning + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CloseToTraySettings { + version: u32, + behavior: CloseToTrayBehavior, +} + +pub(crate) fn settings_path(app: &AppHandle) -> Result { + app.path() + .app_data_dir() + .map(|dir| dir.join(SETTINGS_FILE)) + .map_err(|error| format!("could not locate Buzz settings storage: {error}")) +} + +pub(crate) fn load_from_path(path: &Path) -> Result { + if !path.exists() { + return Ok(CloseToTraySettings { + version: CURRENT_VERSION, + behavior: CloseToTrayBehavior::default(), + }); + } + let bytes = std::fs::read(path) + .map_err(|error| format!("could not read close-to-tray settings: {error}"))?; + let value: serde_json::Value = serde_json::from_slice(&bytes) + .map_err(|error| format!("close-to-tray settings are not valid JSON: {error}"))?; + + // Unversioned settings are incompatible with the V1 schema — use explicit + // defaults rather than interpret ambiguous fields. + let version = match value.get("version").and_then(serde_json::Value::as_u64) { + None => { + return Ok(CloseToTraySettings { + version: CURRENT_VERSION, + behavior: CloseToTrayBehavior::default(), + }); + } + Some(v) => v, + }; + if version > u64::from(CURRENT_VERSION) { + return Err(format!( + "close-to-tray settings version {version} is newer than this Buzz build supports" + )); + } + serde_json::from_value(value) + .map_err(|error| format!("close-to-tray settings are invalid: {error}")) +} + +pub(crate) fn save_to_path(path: &Path, settings: &CloseToTraySettings) -> Result<(), String> { + let payload = serde_json::to_vec_pretty(settings) + .map_err(|error| format!("could not encode close-to-tray settings: {error}"))?; + atomic_write_json_restricted(path, &payload) + .map_err(|error| format!("could not save close-to-tray settings: {error}")) +} + +pub fn load_for_app(app: &AppHandle) -> CloseToTrayBehavior { + match settings_path(app).and_then(|path| load_from_path(&path)) { + Ok(settings) => settings.behavior, + Err(error) => { + eprintln!("buzz-desktop: {error}; keeping windows hidden on close for this session"); + CloseToTrayBehavior::default() + } + } +} + +#[tauri::command] +pub fn get_close_to_tray_behavior(state: State<'_, AppState>) -> Result { + let behavior = *state + .close_to_tray_behavior + .lock() + .map_err(|lock_error| format!("close-to-tray settings lock poisoned: {lock_error}"))?; + Ok(match behavior { + CloseToTrayBehavior::KeepRunning => "keepRunning", + CloseToTrayBehavior::MinimizeToTray => "minimizeToTray", + CloseToTrayBehavior::QuitWhenClosed => "quitWhenClosed", + } + .to_string()) +} + +#[tauri::command] +pub fn set_close_to_tray_behavior( + app: AppHandle, + state: State<'_, AppState>, + behavior: String, +) -> Result<(), String> { + let behavior = match behavior.as_str() { + "keepRunning" => CloseToTrayBehavior::KeepRunning, + "minimizeToTray" => CloseToTrayBehavior::MinimizeToTray, + "quitWhenClosed" => CloseToTrayBehavior::QuitWhenClosed, + other => return Err(format!("unsupported close-to-tray behavior: {other}")), + }; + { + let mut stored = state + .close_to_tray_behavior + .lock() + .map_err(|lock_error| format!("close-to-tray settings lock poisoned: {lock_error}"))?; + *stored = behavior; + } + let settings = CloseToTraySettings { + version: CURRENT_VERSION, + behavior, + }; + let path = settings_path(&app)?; + save_to_path(&path, &settings) + .map_err(|error| format!("could not persist close-to-tray settings: {error}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp_dir() -> tempfile::TempDir { + tempfile::tempdir().expect("temp dir") + } + + #[test] + fn default_when_missing() { + let dir = temp_dir(); + let path = dir.path().join(SETTINGS_FILE); + let settings = load_from_path(&path).expect("missing file must load defaults"); + assert_eq!(settings.behavior, CloseToTrayBehavior::KeepRunning); + assert_eq!(settings.version, CURRENT_VERSION); + } + + #[test] + fn round_trip_persists_behavior() { + let dir = temp_dir(); + let path = dir.path().join(SETTINGS_FILE); + let settings = CloseToTraySettings { + version: CURRENT_VERSION, + behavior: CloseToTrayBehavior::QuitWhenClosed, + }; + save_to_path(&path, &settings).expect("save"); + let loaded = load_from_path(&path).expect("load"); + assert_eq!(loaded.behavior, CloseToTrayBehavior::QuitWhenClosed); + } + + #[test] + fn unversioned_file_falls_back_to_default() { + let dir = temp_dir(); + let path = dir.path().join(SETTINGS_FILE); + std::fs::write(&path, br##"{"behavior":"quitWhenClosed"}"##).unwrap(); + let loaded = load_from_path(&path).expect("unversioned must load default"); + assert_eq!(loaded.behavior, CloseToTrayBehavior::KeepRunning); + } + + #[test] + fn newer_version_is_an_error() { + let dir = temp_dir(); + let path = dir.path().join(SETTINGS_FILE); + std::fs::write( + &path, + br##"{"version":999,"behavior":"keepRunning"}"##, + ) + .unwrap(); + assert!(load_from_path(&path).is_err()); + } + + #[test] + fn invalid_json_is_an_error() { + let dir = temp_dir(); + let path = dir.path().join(SETTINGS_FILE); + std::fs::write(&path, b"not json").unwrap(); + assert!(load_from_path(&path).is_err()); + } +} diff --git a/desktop/src-tauri/src/commands/agent_models_tests.rs b/desktop/src-tauri/src/commands/agent_models_tests.rs index 14c981d730..b417c11d08 100644 --- a/desktop/src-tauri/src/commands/agent_models_tests.rs +++ b/desktop/src-tauri/src/commands/agent_models_tests.rs @@ -96,6 +96,105 @@ fn openai_compat_model_normalization_preserves_provider_specific_ids() { ] ); } +/// Regression for #3934: harnesses that report models via the unstable +/// `models.availableModels` field (no `configOptions` at all — Hermes shape) +/// must populate the dropdown exactly like stable `configOptions` harnesses. +/// +/// The fixture mirrors the JSON that `buzz-acp models --json` emits for a +/// Hermes Agent session: `stable.configOptions` is null, while +/// `unstable.availableModels` carries the catalog. Before the merge, a change +/// here would silently leave the desktop with zero live models and show +/// "Using built-in model options. Could not load live models for this +/// provider." +#[test] +fn agent_models_populates_unstable_available_models_only_hermes_shape() { + let raw = serde_json::json!({ + "agent": { "name": "hermes-acp", "version": "0.19.0" }, + "stable": { "configOptions": null }, + "unstable": { + "currentModelId": "anthropic/claude-opus-4-7-thinking", + "availableModels": [ + { + "modelId": "anthropic/claude-opus-4-7-thinking", + "name": "Claude Opus 4.7 Thinking", + "description": "reasoning" + }, + { + "modelId": "anthropic/claude-opus-4-7", + "name": "Claude Opus 4.7", + "description": "balanced" + }, + { + "modelId": "gpt-5.6", + "name": "GPT-5.6", + "description": "general" + } + ] + } + }); + + let resp = normalize_agent_models(&raw, None); + + assert!(resp.supports_switching, "must support switching when only unstable availableModels is present"); + assert_eq!(resp.models.len(), 3, "must surface all unstable models"); + assert_eq!(resp.models[0].id, "anthropic/claude-opus-4-7-thinking"); + assert_eq!( + resp.models[0].name.as_deref(), + Some("Claude Opus 4.7 Thinking"), + "must preserve the UI-friendly name" + ); + assert_eq!(resp.models[1].id, "anthropic/claude-opus-4-7"); + assert_eq!(resp.models[2].id, "gpt-5.6"); + assert_eq!( + resp.agent_default_model.as_deref(), + Some("anthropic/claude-opus-4-7-thinking"), + "default must come from unstable.currentModelId" + ); +} + +/// Companion to the Hermes-shape test above: when one model id appears in +/// both stable `configOptions` and unstable `availableModels`, the stable +/// entry wins (first-seen behaves like an import merge with stable priority). +/// This prevents silent override by the unstable branch and pins the +/// deduplication order. +#[test] +fn agent_models_stable_config_options_take_precedence_over_unstable_duplicates() { + let raw = serde_json::json!({ + "agent": { "name": "dual-harness", "version": "1.0.0" }, + "stable": { + "configOptions": [{ + "category": "model", + "id": "model", + "displayName": "Model", + "options": [ + { "value": "shared-model", "displayName": "Stable Display Name" } + ] + }] + }, + "unstable": { + "currentModelId": "shared-model", + "availableModels": [ + { "modelId": "shared-model", "name": "Unstable Display Name", "description": "dup" } + ] + } + }); + + let resp = normalize_agent_models(&raw, None); + + assert!(resp.supports_switching); + assert_eq!(resp.models.len(), 1, "dedup must leave exactly one entry"); + assert_eq!( + resp.models[0].name.as_deref(), + Some("Stable Display Name"), + "stable configOptions must take precedence over unstable availableModels" + ); + assert_eq!( + resp.agent_default_model.as_deref(), + Some("shared-model"), + "default still comes from unstable.currentModelId" + ); +} + #[test] fn openai_models_url_uses_openai_default_base_url() { diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 6814008f0d..fcbb5014b6 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -29,6 +29,7 @@ mod ptt_shortcut; mod relay; mod relay_admission; mod reset; +mod close_to_tray; mod secret_store; mod shutdown; mod templates; @@ -38,6 +39,7 @@ mod util; #[cfg(target_os = "linux")] pub mod webkit_rendering; use app_state::{build_app_state, resolve_persisted_identity, AppState}; +use close_to_tray::CloseToTrayBehavior; use builderlab::*; use commands::*; use deep_link::{ @@ -475,6 +477,13 @@ pub fn run() { if let Ok(mut guard) = state.huddle_audio.tts_load_error.lock() { *guard = tts_settings_load_error; } + // Resolve the persisted close-to-tray behavior (#4024) once during + // startup so the macOS close handler has a stable value. + let __close_to_tray = close_to_tray::load_for_app(&app_handle); + *state + .close_to_tray_behavior + .lock() + .unwrap_or_else(|lock_error| lock_error.into_inner()) = __close_to_tray; if let Ok(mut huddle) = state.huddle_state.lock() { huddle.tts_enabled = tts_settings.agent_text_to_speech; } @@ -895,6 +904,8 @@ pub fn run() { huddle::tts_settings::preview_pocket_voice, huddle::tts_settings::import_pocket_voice, huddle::tts_settings::delete_pocket_voice, + close_to_tray::get_close_to_tray_behavior, + close_to_tray::set_close_to_tray_behavior, speak_agent_message, add_agent_to_huddle, check_pipeline_hotstart, @@ -960,11 +971,35 @@ pub fn run() { event: WindowEvent::CloseRequested { api, .. }, .. } if label == "main" => { - // Keep the webview alive so Buzz can be reopened from its tray menu. - api.prevent_close(); - if let Some(window) = app_handle.get_webview_window("main") { - if let Err(error) = window.hide() { - eprintln!("buzz-desktop: failed to hide main window: {error}"); + // Resolve the user's close behavior. `KeepRunning` preserves the + // previous unconditional behavior; `QuitWhenClosed` and + // `MinimizeToTray` are opt-outs for users who do not want a + // windowless background process (#4024). + let behavior = { + let state = app_handle.state::(); + let stored = state.close_to_tray_behavior.lock().unwrap_or_else(|lock_error| lock_error.into_inner()); + *stored + }; + match behavior { + CloseToTrayBehavior::KeepRunning => { + // Keep the webview alive so Buzz can be reopened from its tray menu. + api.prevent_close(); + if let Some(window) = app_handle.get_webview_window("main") { + if let Err(error) = window.hide() { + eprintln!("buzz-desktop: failed to hide main window: {error}"); + } + } + } + CloseToTrayBehavior::MinimizeToTray => { + api.prevent_close(); + if let Some(window) = app_handle.get_webview_window("main") { + if let Err(error) = window.minimize() { + eprintln!("buzz-desktop: failed to minimize main window: {error}"); + } + } + } + CloseToTrayBehavior::QuitWhenClosed => { + // Do not call api.prevent_close(): Buzz will quit. } } } diff --git a/desktop/src/features/settings/ui/CloseToTraySettingsCard.tsx b/desktop/src/features/settings/ui/CloseToTraySettingsCard.tsx new file mode 100644 index 0000000000..7b488b72a3 --- /dev/null +++ b/desktop/src/features/settings/ui/CloseToTraySettingsCard.tsx @@ -0,0 +1,136 @@ +import { useEffect, useState } from "react"; +import { ChevronDown } from "lucide-react"; + +import { isTauri } from "@tauri-apps/api/core"; +import { Button } from "@/shared/ui/button"; +import { isMacPlatform } from "@/shared/lib/platform"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@/shared/ui/dropdown-menu"; +import { SettingsOptionGroup, SettingsOptionRow } from "./SettingsOptionGroup"; +import { SettingsSectionHeader } from "./SettingsSectionHeader"; +import { + CLOSE_TO_TRAY_DEFAULT, + CLOSE_TO_TRAY_OPTIONS, + type CloseToTrayBehavior, + loadCloseToTrayBehavior, + saveCloseToTrayBehavior, +} from "./closeToTrayLogic"; + +function optionLabelFor(behavior: CloseToTrayBehavior): string { + return ( + CLOSE_TO_TRAY_OPTIONS.find((option) => option.value === behavior)?.label ?? + behavior + ); +} + +/** + * macOS-only preference for what happens when the main window closes (#4024). + * + * The backend close handler is macOS-only, so this card renders nothing on + * other platforms (and outside the Tauri desktop shell, where the commands do + * not exist). + */ +export function CloseToTraySettingsCard() { + const isDesktopMac = isTauri() && isMacPlatform(); + const [behavior, setBehavior] = + useState(CLOSE_TO_TRAY_DEFAULT); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (!isDesktopMac) { + return; + } + let cancelled = false; + void loadCloseToTrayBehavior().then((loaded) => { + if (!cancelled) { + setBehavior(loaded); + } + }); + return () => { + cancelled = true; + }; + }, [isDesktopMac]); + + if (!isDesktopMac) { + return null; + } + + const current = CLOSE_TO_TRAY_OPTIONS.find((o) => o.value === behavior); + + return ( +
+ + + + +
+ +

+ Keeping Buzz running in the background preserves active local + agents; choose minimize or quit if your window switcher does not + show hidden apps. +

+ {current && ( +

+ {current.description} +

+ )} +
+ + + + + + { + const next = value as CloseToTrayBehavior; + setBehavior(next); // optimistic + setSaving(true); + void saveCloseToTrayBehavior(next) + .catch(() => { + // Reload the persisted value if the write failed. + void loadCloseToTrayBehavior().then(setBehavior); + }) + .finally(() => setSaving(false)); + }} + > + {CLOSE_TO_TRAY_OPTIONS.map((option) => ( + + {option.label} + + ))} + + + +
+
+
+ ); +} diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx index 5c997efbc4..43135f7500 100644 --- a/desktop/src/features/settings/ui/SettingsPanels.tsx +++ b/desktop/src/features/settings/ui/SettingsPanels.tsx @@ -78,6 +78,7 @@ import { MobilePairingCard } from "./MobilePairingCard"; import { ModerationQueueCard } from "./ModerationQueueCard"; import { NotificationSettingsCard } from "./NotificationSettingsCard"; import { PreventSleepSettingsCard } from "./PreventSleepSettingsCard"; +import { CloseToTraySettingsCard } from "./CloseToTraySettingsCard"; import { AgentDefaultsSettingsCard } from "./AgentDefaultsSettingsCard"; import { HostedCommunitiesSettingsCard } from "./HostedCommunitiesSettingsCard"; import { SettingsOptionGroup, SettingsOptionRow } from "./SettingsOptionGroup"; @@ -824,6 +825,7 @@ export function renderSettingsSection( return (
+
diff --git a/desktop/src/features/settings/ui/closeToTrayLogic.test.mjs b/desktop/src/features/settings/ui/closeToTrayLogic.test.mjs new file mode 100644 index 0000000000..9651f8b7f6 --- /dev/null +++ b/desktop/src/features/settings/ui/closeToTrayLogic.test.mjs @@ -0,0 +1,43 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { + CLOSE_TO_TRAY_DEFAULT, + CLOSE_TO_TRAY_OPTIONS, + isCloseToTrayBehavior, + loadCloseToTrayBehavior, +} from "./closeToTrayLogic.ts"; + +test("default is keepRunning and is a valid behavior", () => { + assert.equal(CLOSE_TO_TRAY_DEFAULT, "keepRunning"); + assert.equal(isCloseToTrayBehavior(CLOSE_TO_TRAY_DEFAULT), true); +}); + +test("accepts the three documented behaviors", () => { + assert.equal(isCloseToTrayBehavior("keepRunning"), true); + assert.equal(isCloseToTrayBehavior("minimizeToTray"), true); + assert.equal(isCloseToTrayBehavior("quitWhenClosed"), true); +}); + +test("rejects unknown and non-string values", () => { + for (const value of ["", "quit", "hide", null, undefined, 0, {}, []]) { + assert.equal(isCloseToTrayBehavior(value), false); + } +}); + +test("every option has a unique valid behavior with copy", () => { + const values = new Set(); + for (const option of CLOSE_TO_TRAY_OPTIONS) { + assert.equal(isCloseToTrayBehavior(option.value), true); + assert.equal(values.has(option.value), false); + values.add(option.value); + assert.ok(option.label.length > 0); + assert.ok(option.description.length > 0); + } + assert.equal(values.size, CLOSE_TO_TRAY_OPTIONS.length); +}); + +test("load falls back to the default when Tauri is unavailable", async () => { + // In plain node there is no Tauri bridge, so load resolves to the default. + assert.equal(await loadCloseToTrayBehavior(), CLOSE_TO_TRAY_DEFAULT); +}); diff --git a/desktop/src/features/settings/ui/closeToTrayLogic.ts b/desktop/src/features/settings/ui/closeToTrayLogic.ts new file mode 100644 index 0000000000..770812b4b4 --- /dev/null +++ b/desktop/src/features/settings/ui/closeToTrayLogic.ts @@ -0,0 +1,76 @@ +export type CloseToTrayBehavior = + | "keepRunning" + | "minimizeToTray" + | "quitWhenClosed"; + +export const CLOSE_TO_TRAY_DEFAULT: CloseToTrayBehavior = "keepRunning"; + +export const CLOSE_TO_TRAY_OPTIONS: ReadonlyArray<{ + value: CloseToTrayBehavior; + label: string; + description: string; +}> = [ + { + value: "keepRunning", + label: "Keep running in background", + description: + "Closing the window hides Buzz but keeps it and its local agents running for background work. Reopen from the menu bar tray icon.", + }, + { + value: "minimizeToTray", + label: "Minimize to Dock", + description: + "Closing the window minimizes Buzz to the Dock so it stays visible in window switchers that exclude hidden windows, while keeping agents running.", + }, + { + value: "quitWhenClosed", + label: "Quit when window closes", + description: + "Closing the window quits Buzz entirely, like a conventional app. Local agents stop until you reopen Buzz.", + }, +]; + +export function isCloseToTrayBehavior( + value: unknown, +): value is CloseToTrayBehavior { + return ( + typeof value === "string" && + CLOSE_TO_TRAY_OPTIONS.some((option) => option.value === value) + ); +} + +/** Lazily resolve Tauri's invoke so pure validation stays unit-testable in node. */ +async function tauriInvoke(): Promise< + ((cmd: string, args?: Record) => Promise) | null +> { + try { + const { invoke, isTauri } = await import("@tauri-apps/api/core"); + return isTauri() ? invoke : null; + } catch { + // No Tauri bridge / dependency unavailable (plain node, web preview). + return null; + } +} + +export async function loadCloseToTrayBehavior(): Promise { + const invoke = await tauriInvoke(); + if (!invoke) { + return CLOSE_TO_TRAY_DEFAULT; + } + try { + const behavior = await invoke("get_close_to_tray_behavior"); + return isCloseToTrayBehavior(behavior) ? behavior : CLOSE_TO_TRAY_DEFAULT; + } catch { + return CLOSE_TO_TRAY_DEFAULT; + } +} + +export async function saveCloseToTrayBehavior( + behavior: CloseToTrayBehavior, +): Promise { + const invoke = await tauriInvoke(); + if (!invoke) { + return; + } + await invoke("set_close_to_tray_behavior", { behavior }); +} diff --git a/mobile/lib/features/channels/mentions/mention_candidates.dart b/mobile/lib/features/channels/mentions/mention_candidates.dart index 9c4ef96bbe..ec670cca7f 100644 --- a/mobile/lib/features/channels/mentions/mention_candidates.dart +++ b/mobile/lib/features/channels/mentions/mention_candidates.dart @@ -51,13 +51,20 @@ List buildMentionCandidates({ required Map userCache, required Map ownerByAgentPubkey, List searchResults = const [], + Set archivedPubkeys = const {}, String? currentPubkey, }) { final candidates = []; final seen = {}; + final self = currentPubkey?.toLowerCase(); for (final member in members) { final pk = member.pubkey.toLowerCase(); + // Fold relay-archived identities (#3840) out of + // autocomplete; the current user is exempt (NIP-IA §Self + // Requests anti-shadowban), matching desktop's + // `useIsArchivedPredicate`. + if (archivedPubkeys.contains(pk) && pk != self) continue; if (!seen.add(pk)) continue; final profile = userCache[pk]; final ownerPubkey = ownerByAgentPubkey[pk] ?? profile?.ownerPubkey; diff --git a/mobile/lib/features/channels/mentions/mention_candidates_provider.dart b/mobile/lib/features/channels/mentions/mention_candidates_provider.dart index 6e94459231..4e07299ce9 100644 --- a/mobile/lib/features/channels/mentions/mention_candidates_provider.dart +++ b/mobile/lib/features/channels/mentions/mention_candidates_provider.dart @@ -80,6 +80,8 @@ final mentionCandidatesProvider = Provider.family ref.watch(agentDirectoryProvider).asData?.value ?? const []; final owners = ref.watch(agentOwnersProvider).asData?.value ?? const {}; + final archived = ref.watch(archivedAgentPubkeysProvider).asData?.value ?? + const {}; final channels = ref.watch(channelsProvider).asData?.value ?? const []; final userCache = ref.watch(userCacheProvider); @@ -100,6 +102,7 @@ final mentionCandidatesProvider = Provider.family userCache: userCache, ownerByAgentPubkey: owners, searchResults: searchResults, + archivedPubkeys: archived, currentPubkey: currentPubkey, ); diff --git a/mobile/lib/shared/mentions/agent_identity_provider.dart b/mobile/lib/shared/mentions/agent_identity_provider.dart index ea6a2ee50f..4d662271e8 100644 --- a/mobile/lib/shared/mentions/agent_identity_provider.dart +++ b/mobile/lib/shared/mentions/agent_identity_provider.dart @@ -69,6 +69,28 @@ final agentDirectoryProvider = FutureProvider>(( return [for (final event in events) AgentDirectoryEntry.fromEvent(event)]; }); +/// NIP-IA archived-identity pubkeys (kind:13535 snapshot). +/// +/// The relay emits a single relay-signed `p`-tag-per-identity snapshot listing +/// identities archived on the relay. Mobile folds those identities out of +/// mention autocomplete (desktop does the same via `useIsArchivedPredicate`). +/// +/// Fail-open by construction: while the snapshot is missing or unfetched the +/// set is empty, so a cold start or a relay without any archives never briefly +/// hides everyone. +final archivedAgentPubkeysProvider = FutureProvider>((ref) async { + final sessionState = ref.watch(relaySessionProvider); + if (sessionState.status != SessionStatus.connected) return const {}; + final session = ref.read(relaySessionProvider.notifier); + final events = await session.fetchHistory(NostrFilters.archivedIdentities()); + if (events.isEmpty) return const {}; + return _AgentPubkeySet({ + for (final tag in events.first.tags) + if (tag.length >= 2 && tag[0] == 'p') tag[1].toLowerCase(), + }); +}); + + /// Verified NIP-OA owner pubkey per agent pubkey, from the agents' kind:0 /// profiles. An entry exists only when the `auth` tag verifies — mirrors /// desktop's `profile_valid_oa_owner_pubkey`. diff --git a/mobile/lib/shared/relay/nostr_filters.dart b/mobile/lib/shared/relay/nostr_filters.dart index 5cea037f9d..c4c62f60b5 100644 --- a/mobile/lib/shared/relay/nostr_filters.dart +++ b/mobile/lib/shared/relay/nostr_filters.dart @@ -209,6 +209,12 @@ abstract final class NostrFilters { static NostrFilter agentProfiles() => const NostrFilter(kinds: [10100], limit: 100); + /// NIP-IA archived-identity list (kind:13535). Relay-signed addressable + /// snapshot; one `p` tag per archived identity. Addresses match the relay's + /// `KIND_IA_ARCHIVED_LIST` = 13535. + static NostrFilter archivedIdentities() => + const NostrFilter(kinds: [13535], limit: 1); + /// User status (NIP-38, kind:30315). static NostrFilter userStatus(String pubkey) => NostrFilter(kinds: [30315], authors: [pubkey], limit: 1); diff --git a/mobile/test/features/channels/mentions/mention_candidates_test.dart b/mobile/test/features/channels/mentions/mention_candidates_test.dart index 811996857c..cdc045efeb 100644 --- a/mobile/test/features/channels/mentions/mention_candidates_test.dart +++ b/mobile/test/features/channels/mentions/mention_candidates_test.dart @@ -280,5 +280,54 @@ void main() { expect(candidates, hasLength(1)); expect(candidates.single.isMember, isTrue); }); + + test('archived agent identities are hidden from mention candidates', () { + final candidates = buildMentionCandidates( + members: [member(agentPubkey, role: 'bot')], + relayAgents: const [], + userCache: const {}, + ownerByAgentPubkey: const {}, + archivedPubkeys: {agentPubkey}, + currentPubkey: userPubkey, + ); + + expect(candidates.any((c) => c.pubkey == agentPubkey), isFalse); + }); + + test('the current user is exempt from the archived fold', () { + final candidates = buildMentionCandidates( + members: [member(agentPubkey, role: 'bot')], + relayAgents: const [], + userCache: const {}, + ownerByAgentPubkey: const {}, + archivedPubkeys: {agentPubkey}, + currentPubkey: agentPubkey, + ); + + expect( + candidates.any((c) => c.pubkey == agentPubkey), + isTrue, + reason: 'NIP-IA anti-shadowban: the archived self must remain visible', + ); + }); + + test('a non-archived agent candidate remains when others are archived', + () { + const keptPubkey = 'b' * 64; + final candidates = buildMentionCandidates( + members: [ + member(agentPubkey, role: 'bot'), + member(keptPubkey, role: 'bot'), + ], + relayAgents: const [], + userCache: const {}, + ownerByAgentPubkey: const {}, + archivedPubkeys: {agentPubkey}, + currentPubkey: userPubkey, + ); + + expect(candidates.any((c) => c.pubkey == agentPubkey), isFalse); + expect(candidates.any((c) => c.pubkey == keptPubkey), isTrue); + }); }); }