From f3981dbfefc09e6a888ada489badb7dafdf122cf Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 24 Jul 2026 14:19:31 -0700 Subject: [PATCH 001/393] fix(discovery): spawn PowerShell install commands natively on Windows (#2750) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem On Windows, `install_shell_command` wraps every install command in Git Bash `-l -c`, including the Windows-specific `powershell.exe … irm https://chatgpt.com/codex/install.ps1 | iex` command. A Git Bash login shell prepends its POSIX dirs (`C:\Program Files\Git\usr\bin`) to PATH, so when Codex's `install.ps1` shells out to bare `tar -xzf C:\…`, it resolves Git's GNU tar (`/usr/bin/tar`) instead of Windows bundled bsdtar. GNU tar parses `C:` as a remote host: ``` tar (child): Cannot connect to C: resolve failed gzip: stdin: unexpected end of file /usr/bin/tar: Child returned status 128 /usr/bin/tar: Error is not recoverable: exiting now Downloaded Codex package archive did not contain the expected package layout. ``` Claude's installer doesn't hit the same failure because it doesn't shell out to `tar`. ## Solution On Windows, detect `powershell.exe` install commands and spawn them natively (`Command::new("powershell.exe")`) instead of routing them through Git Bash. The discriminator is a case-insensitive prefix check on the first whitespace-delimited token — minimal and precise. The native spawn preserves everything `install_shell_command` provides that applies: - `NPM_CONFIG_*` / `COREPACK` env strip + managed npm prefix env - PATH composed from managed Buzz dirs + inherited process PATH (no POSIX login-shell dirs) - `CREATE_NO_WINDOW` so no console flash - stdin null, piped-drain in `run_install_command` - The retry/backoff/annotate logic is fully shared The `-Command` body is split correctly at the boundary (case-insensitive) and passed as a single argument to preserve pipes and spaces inside the installer script call. Non-PowerShell commands (e.g. `npm install -g …` adapter steps) continue through the existing Git Bash path unchanged. ## Tests 6 unit tests: 1. `is_powershell_command` detection (positive + negative) 2. Routing: PowerShell → native spawn on Windows, non-PowerShell → Git Bash 3. Unix: non-Windows path returns the shell command unchanged (compile-time cfg) 4. `-Command` body preservation (no bash args in native spawn; body is single arg) Full `just desktop-tauri-test` suite: 1627/1627 passing. Windows CI will validate end-to-end. --------- Signed-off-by: Will Pfleger Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 --- desktop/scripts/check-file-sizes.mjs | 6 +- .../src-tauri/src/commands/agent_discovery.rs | 333 ++++++++++++++++-- 2 files changed, 316 insertions(+), 23 deletions(-) diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index a901d5010d..2651318bc3 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -494,7 +494,11 @@ const overrides = new Map([ // +53: pass 2 — three cfg(windows) install shell tests (resolve succeeds with // Git, error hint content, install_shell_command succeeds). // +8: install_shell_from pure seam extracted for deterministic testing. - ["src-tauri/src/commands/agent_discovery.rs", 1523], + // +287: is_powershell_command + install_powershell_command + build_install_command + // route PowerShell CLI installs natively on Windows (bypasses Git Bash PATH + // poisoning that resolved GNU tar instead of bsdtar → Codex install failure). + // Includes unit tests for detection, routing, and -Command body preservation. + ["src-tauri/src/commands/agent_discovery.rs", 1810], // draft-persistence predicate: submit-time `loadDraft` check + inline comment // + deps-array entry in submitMessage closes the never-persisted-boundary // defect (Thufir Pass-3 finding). Load-bearing correctness fix; queued to diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index 40c4333a11..f080272914 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -552,21 +552,8 @@ fn install_shell_command(command: &str) -> Result let mut cmd = std::process::Command::new(&shell); cmd.args(["-l", "-c", command]); - // Strip hermit env vars so npm/node use the user's normal registry rather - // than the project-local hermit-managed paths, then give npm defaults for - // Buzz-owned app data. Adapter install commands also pass --prefix - // explicitly; these env vars keep subprocesses/cache/corepack aligned. - cmd.env_remove("NPM_CONFIG_PREFIX"); - cmd.env_remove("NPM_CONFIG_CACHE"); - cmd.env_remove("COREPACK_HOME"); - - if let Some(prefix) = crate::managed_agents::buzz_managed_npm_prefix() { - cmd.env("NPM_CONFIG_PREFIX", &prefix); - cmd.env("npm_config_prefix", &prefix); - cmd.env("COREPACK_HOME", prefix.join("corepack")); - cmd.env("NPM_CONFIG_CACHE", prefix.join("cache")); - cmd.env("npm_config_cache", prefix.join("cache")); - } + // Strip hermit vars and set managed npm paths (see apply_npm_env). + apply_npm_env(&mut cmd); // Compose the PATH for the install shell using the same kernel as the // runtime/probe path so the two can never drift. managed entries first @@ -615,12 +602,7 @@ fn install_shell_command(command: &str) -> Result } // Suppress the console window on Windows. - #[cfg(windows)] - { - use std::os::windows::process::CommandExt; - const CREATE_NO_WINDOW: u32 = 0x0800_0000; - cmd.creation_flags(CREATE_NO_WINDOW); - } + apply_no_window(&mut cmd); Ok(cmd) } @@ -721,8 +703,161 @@ fn annotate_retry_attempts(mut result: InstallStepResult, attempts: u32) -> Inst result } +/// Returns `true` when `command` is a Windows-native PowerShell invocation +/// (i.e. begins with `powershell.exe`). These commands must NOT be routed +/// through Git Bash: the Bash login shell prepends POSIX dirs to PATH, so +/// bare `tar` inside the PowerShell script resolves to GNU tar +/// (`/usr/bin/tar`) instead of Windows bsdtar. GNU tar parses the drive +/// letter in `C:\…` as a remote host and fails with "Cannot connect to C: +/// resolve failed", which is the exact failure Will observed in the Codex +/// PowerShell installer. Non-PowerShell commands (e.g. `npm install -g …` +/// adapter steps) are unaffected. +/// +/// The check is case-insensitive because Windows file-system conventions do +/// not mandate casing, and the install command constants could change. +#[cfg(windows)] +fn is_powershell_command(command: &str) -> bool { + command + .split_ascii_whitespace() + .next() + .is_some_and(|tok| tok.eq_ignore_ascii_case("powershell.exe")) +} + +/// Apply the shared npm env cleanup and managed-prefix setup to an install child. +/// Strips hermit-managed vars and establishes the Buzz-managed npm prefix so adapters +/// installed via either path (shell or native PowerShell) land in the same location. +fn apply_npm_env(cmd: &mut std::process::Command) { + cmd.env_remove("NPM_CONFIG_PREFIX"); + cmd.env_remove("NPM_CONFIG_CACHE"); + cmd.env_remove("COREPACK_HOME"); + + if let Some(prefix) = crate::managed_agents::buzz_managed_npm_prefix() { + cmd.env("NPM_CONFIG_PREFIX", &prefix); + cmd.env("npm_config_prefix", &prefix); + cmd.env("COREPACK_HOME", prefix.join("corepack")); + cmd.env("NPM_CONFIG_CACHE", prefix.join("cache")); + cmd.env("npm_config_cache", prefix.join("cache")); + } +} + +/// Suppress the console window for an install child on Windows (no-op elsewhere). +fn apply_no_window(_cmd: &mut std::process::Command) { + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + _cmd.creation_flags(CREATE_NO_WINDOW); + } +} + +/// Build a native `powershell.exe` [`Command`][std::process::Command] for the given command +/// string, bypassing Git Bash so POSIX PATH entries never leak into the child. +/// +/// Finds the first `-Command` token (case-insensitive, token-boundary match), passes preceding +/// tokens as individual flags, and passes the rest as a single body arg. One outer double-quote +/// pair is stripped from the body — the catalog strings (`discovery.rs:107`, `:138`) wrap it for +/// Bash serialization; stripping here delivers the bare pipeline to PowerShell. Tokens with no +/// `-Command` are forwarded individually. Only called from [`build_install_command`] on Windows. +#[cfg(windows)] +fn install_powershell_command(command: &str) -> std::process::Command { + // Strip the leading `powershell.exe` token. + let after_exe = command + .split_once(|c: char| c.is_ascii_whitespace()) + .map(|(_, rest)| rest.trim()) + .unwrap_or(""); + + let mut cmd = std::process::Command::new("powershell.exe"); + + // Walk tokens to find -Command on a token boundary (not as a substring of + // a preceding argument). The comparison is case-insensitive because + // PowerShell itself is case-insensitive for parameters. + let mut rest = after_exe; + let mut found_command_flag = false; + loop { + let trimmed = rest.trim_start(); + if trimmed.is_empty() { + break; + } + // Find the end of the current token. + let tok_end = trimmed + .find(|c: char| c.is_ascii_whitespace()) + .unwrap_or(trimmed.len()); + let tok = &trimmed[..tok_end]; + if tok.eq_ignore_ascii_case("-command") { + // Everything after this token (trimmed) is the body. + let body_raw = trimmed[tok_end..].trim(); + // Strip one matching outer double-quote pair inserted by the + // Bash-layer catalog serialization. PowerShell does not need them. + let body = + if body_raw.starts_with('"') && body_raw.ends_with('"') && body_raw.len() >= 2 { + &body_raw[1..body_raw.len() - 1] + } else { + body_raw + }; + cmd.arg("-Command"); + if !body.is_empty() { + cmd.arg(body); + } + found_command_flag = true; + break; + } + cmd.arg(tok); + rest = &trimmed[tok_end..]; + } + + if !found_command_flag { + // No -Command boundary — forward remaining tokens individually. + for arg in rest.split_ascii_whitespace() { + cmd.arg(arg); + } + } + + apply_npm_env(&mut cmd); + + // Compose PATH: managed Buzz dirs first, then inherited process PATH. + // No login-shell path: login_shell_path() always returns None on Windows, + // and we deliberately skip it here to avoid POSIX-shaped entries. + let managed: Vec = [ + crate::managed_agents::buzz_managed_node_bin_dir(), + crate::managed_agents::buzz_managed_npm_bin_dir(), + ] + .into_iter() + .flatten() + .collect(); + let inherited: Vec = std::env::var_os("PATH") + .map(|p| std::env::split_paths(&p).collect()) + .unwrap_or_default(); + // No login path → should_use_inherited returns true so inherited appended. + let path_parts = crate::managed_agents::compose_path_entries(managed, vec![], inherited, true); + if !path_parts.is_empty() { + if let Ok(path) = std::env::join_paths(path_parts) { + cmd.env("PATH", path); + } + } + + apply_no_window(&mut cmd); + cmd +} + +/// Select the right [`Command`][std::process::Command] builder for `command`. +/// +/// On Windows, PowerShell-prefixed commands are spawned natively (via +/// [`install_powershell_command`]) to avoid the Git Bash PATH poisoning that +/// causes GNU `tar` to be resolved instead of Windows bsdtar. All other +/// commands — including `npm install -g …` adapter steps — keep the existing +/// Git Bash path via [`install_shell_command`]. +/// +/// On non-Windows this is always `install_shell_command`. +fn build_install_command(command: &str) -> Result { + #[cfg(windows)] + if is_powershell_command(command) { + return Ok(install_powershell_command(command)); + } + install_shell_command(command) +} + fn run_install_command(step: &str, command: &str) -> InstallStepResult { - let mut cmd = match install_shell_command(command) { + let mut cmd = match build_install_command(command) { Ok(cmd) => cmd, Err(hint) => { return InstallStepResult { @@ -1386,6 +1521,160 @@ mod tests { ); } + // ── PowerShell routing ──────────────────────────────────────────────────── + + /// Commands beginning with `powershell.exe` (any casing) must be identified + /// as PowerShell commands; all others must not. + #[cfg(windows)] + #[test] + fn test_is_powershell_command_detects_powershell_commands() { + assert!( + super::is_powershell_command( + r#"powershell.exe -NoProfile -NonInteractive -Command "irm https://chatgpt.com/codex/install.ps1 | iex""# + ), + "canonical codex install command must be detected as PowerShell" + ); + assert!( + super::is_powershell_command("POWERSHELL.EXE -Command foo"), + "is_powershell_command must be case-insensitive" + ); + assert!( + !super::is_powershell_command("npm install -g @agentclientprotocol/claude-agent-acp"), + "npm commands must NOT be detected as PowerShell" + ); + assert!( + !super::is_powershell_command(r"curl -fsSL https://example.com | bash"), + "bash pipe commands must NOT be detected as PowerShell" + ); + assert!( + !super::is_powershell_command(""), + "empty string must not be detected as PowerShell" + ); + } + + /// On Windows, `build_install_command` must return a `Command` whose + /// program is `powershell.exe` (not `bash.exe`) for PowerShell commands. + #[cfg(windows)] + #[test] + fn test_build_install_command_uses_powershell_natively_on_windows() { + let ps_command = r#"powershell.exe -NoProfile -NonInteractive -Command "irm https://chatgpt.com/codex/install.ps1 | iex""#; + let result = super::build_install_command(ps_command); + assert!( + result.is_ok(), + "build_install_command must succeed for a PowerShell command; got: {:?}", + result.err() + ); + let cmd = result.unwrap(); + let program = cmd.get_program().to_string_lossy().to_lowercase(); + assert!( + program.contains("powershell"), + "PowerShell install command must use powershell.exe, not bash; got: {program}" + ); + assert!( + !program.contains("bash"), + "PowerShell install command must NOT go through bash; got: {program}" + ); + } + + /// On Windows, `build_install_command` must route non-PowerShell commands + /// through Git Bash (program must be bash.exe). + #[cfg(windows)] + #[test] + fn test_build_install_command_uses_git_bash_for_non_powershell_on_windows() { + let npm_command = "npm install -g @agentclientprotocol/claude-agent-acp"; + let result = super::build_install_command(npm_command); + assert!( + result.is_ok(), + "build_install_command must succeed for an npm command on Windows with Git; got: {:?}", + result.err() + ); + let cmd = result.unwrap(); + let program = cmd.get_program().to_string_lossy().to_lowercase(); + assert!( + program.contains("bash"), + "non-PowerShell install command must still use bash.exe on Windows; got: {program}" + ); + } + + /// On non-Windows, `build_install_command` must always use the Unix shell + /// (zsh or bash), never powershell.exe. + #[cfg(not(windows))] + #[test] + fn test_build_install_command_uses_unix_shell_on_non_windows() { + let command = r"curl -fsSL https://example.com/install.sh | bash"; + let result = super::build_install_command(command); + assert!( + result.is_ok(), + "build_install_command must succeed on Unix; got: {:?}", + result.err() + ); + let cmd = result.unwrap(); + let program = cmd.get_program().to_string_lossy(); + assert!( + program.contains("bash") || program.contains("zsh"), + "Unix install command must use bash or zsh, got: {program}" + ); + } + + /// On Windows, `install_powershell_command` must build an exact argv: + /// flags before `-Command` forwarded, body unquoted (outer catalog quotes stripped), + /// no bash flags, and `-Command` found on token boundary not as substring. + #[cfg(windows)] + #[test] + fn test_powershell_command_argv_exact() { + // Catalog format: body wrapped in one outer double-quote pair (Bash-layer serialization). + let body = "irm https://chatgpt.com/codex/install.ps1 | iex"; + let cmd = super::install_powershell_command(&format!( + r#"powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "{body}""# + )); + assert_eq!( + cmd.get_args() + .map(|a| a.to_string_lossy().into_owned()) + .collect::>(), + vec!["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", body], + "argv must be exact with outer quotes stripped" + ); + } + + /// Token that merely contains `-command` as a substring must not be treated + /// as the `-Command` boundary; only an exact token match (case-insensitive) counts. + #[cfg(windows)] + #[test] + fn test_powershell_command_token_boundary_not_substring() { + let cmd = super::install_powershell_command( + r#"powershell.exe -x-command-y -Command "echo hello""#, + ); + assert_eq!( + cmd.get_args() + .map(|a| a.to_string_lossy().into_owned()) + .collect::>(), + vec!["-x-command-y", "-Command", "echo hello"], + "substring must not consume -Command boundary early" + ); + } + + /// Claude Code catalog command (discovery.rs:107) must dequote to the bare pipeline. + #[cfg(windows)] + #[test] + fn test_powershell_command_claude_catalog_dequoted() { + let cmd = super::install_powershell_command( + r#"powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "irm https://claude.ai/install.ps1 | iex""#, + ); + assert_eq!( + cmd.get_args() + .map(|a| a.to_string_lossy().into_owned()) + .collect::>(), + vec![ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + "irm https://claude.ai/install.ps1 | iex", + ], + "Claude catalog command must be dequoted correctly" + ); + } + // ── install retry ───────────────────────────────────────────────────────── /// Build an `InstallStepResult` with just the fields the retry loop reads. From 0a9c26ee8cf490819e9e92736468358bdd11c368 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 24 Jul 2026 14:20:24 -0700 Subject: [PATCH 002/393] fix(acp): dead-letter auth errors immediately with re-auth hint (#2751) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Auth-class errors (expired OAuth token, HTTP 401) are non-retryable: the token won't self-repair between attempts. Today, `PromptOutcome::Error` for an application-class error falls into the generic `queue.requeue()` path, burning up to 10 retry slots over a long backoff window before dead-lettering. Will's canary run observed the 401 message being retried repeatedly. ## Solution Add `is_auth_error()` that classifies `AcpError::AgentError` messages matching two narrow patterns observed in the field: - `"Re-authenticate"` — emitted by the Claude CLI for expired OAuth tokens - `"API Error: 401"` — present in Claude/Codex HTTP-401 responses Conservative matching is intentional: a false positive (misclassifying a transient error as non-retryable) silently drops a user message, which is worse than a false negative (extra retries on an auth error). In `handle_prompt_result`, a new branch intercepts the failing batch before `queue.requeue()` for auth-class errors and dead-letters immediately, posting a user-visible notice to re-authenticate the CLI (e.g. `claude /login` or `codex login`). The transport/application split in `PromptOutcome::Error` is untouched — this only changes batch fate after an application-class auth error. ## Tests 6 new tests in `error_outcome_emission_tests`: 1. `is_auth_error` matches `Re-authenticate` message 2. `is_auth_error` matches `API Error: 401` message 3. `is_auth_error` rejects other `AgentError` messages (usage credits, etc.) 4. `is_auth_error` rejects transport errors (I/O, WriteTimeout) 5. Auth error dead-letters immediately — 0 pending channels after `handle_prompt_result` 6. Non-auth application error still requeued — 1 pending channel after `handle_prompt_result` Full `cargo test -p buzz-acp`: 598/598 passing. Signed-off-by: Will Pfleger Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 --- crates/buzz-acp/src/lib.rs | 270 +++++++++++++++++++++++++++++++++++++ 1 file changed, 270 insertions(+) diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 03b75a4211..0230ea0875 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -2978,6 +2978,35 @@ fn dispatch_pending( dispatched_channels } +/// Returns `true` when `error` is a non-retryable authentication failure. +/// +/// Retrying auth errors is harmful: the token won't self-repair between +/// attempts, so each retry wastes an attempt slot, delays the visible failure, +/// and burns the user's context window. Dead-letter immediately and surface a +/// re-authentication hint instead. +/// +/// # Classification rationale +/// +/// Auth failures arrive as [`acp::AcpError::AgentError`] with a message +/// surfaced from the upstream CLI. Two narrow patterns reliably identify +/// non-transient auth failures observed in the field: +/// +/// - `"Re-authenticate"` — emitted by the Claude CLI when an OAuth token has +/// expired ("OAuth access token has expired. Re-authenticate to continue."). +/// Specific to the auth-expiry flow; does not appear in unrelated errors. +/// - `"API Error: 401"` — present in Claude/Codex HTTP-401 responses; 401 is +/// the standard auth-failure status and does not arise from network blips. +/// +/// False positives (misclassifying a transient error as non-retryable) silently +/// drop a user message, which is worse than a false negative (extra retries on +/// an auth error). Both patterns are therefore chosen for high precision. +fn is_auth_error(error: &acp::AcpError) -> bool { + let acp::AcpError::AgentError { message, .. } = error else { + return false; + }; + message.contains("Re-authenticate") || message.contains("API Error: 401") +} + /// Spawn a task that posts a user-visible failure notice to the relay. /// /// Shared by the hard-cap immediate dead-letter path and the retries-exhausted @@ -3098,6 +3127,21 @@ fn handle_prompt_result( } else { hard_timeout_fate_suffix = Some(" — requeued for retry (recently active)"); } + } else if matches!(&result.outcome, PromptOutcome::Error(e) if is_auth_error(e)) { + // Auth errors are non-retryable: the token won't self-repair + // between retries, so requeueing only wastes attempt slots and + // delays the visible failure. Dead-letter immediately and tell + // the user to re-authenticate the CLI. + tracing::warn!( + channel_id = %batch.channel_id, + events = batch.events.len(), + "dead-lettering batch immediately — non-retryable auth error" + ); + let content = "⚠️ I couldn't process the last request: authentication failed. \ + Please re-authenticate the CLI (e.g. run `claude /login` or `codex login`) \ + and then re-send." + .to_string(); + spawn_failure_notice(rest_client, &batch, content); } else if let Some(dead) = queue.requeue(batch) { let reason = match &result.outcome { PromptOutcome::Timeout(TimeoutKind::Idle) => "the turn timed out".to_string(), @@ -6048,6 +6092,232 @@ mod error_outcome_emission_tests { let app = AcpError::IdleTimeout(std::time::Duration::from_secs(1)); assert_eq!(turn_errors_emitted_for(PromptOutcome::Error(app)).await, 1); } + + // ── is_auth_error classification ─────────────────────────────────────── + + #[test] + fn is_auth_error_matches_reauthenticate_message() { + let e = acp::AcpError::AgentError { + code: -32000, + message: "API Error: OAuth access token has expired. Re-authenticate to continue." + .to_string(), + }; + assert!( + is_auth_error(&e), + "Re-authenticate variant must be classified as auth error" + ); + } + + #[test] + fn is_auth_error_matches_401_message() { + let e = acp::AcpError::AgentError { + code: -32000, + message: "Internal error: API Error: 401 OAuth access token has expired.".to_string(), + }; + assert!( + is_auth_error(&e), + "API Error: 401 variant must be classified as auth error" + ); + } + + #[test] + fn is_auth_error_rejects_other_agent_error_message() { + let e = acp::AcpError::AgentError { + code: -32601, + message: "Usage credits required for 1M context — turn on usage credits".to_string(), + }; + assert!( + !is_auth_error(&e), + "usage-credit error must NOT be classified as auth error" + ); + } + + #[test] + fn is_auth_error_rejects_transport_errors() { + let io = acp::AcpError::Io(std::io::Error::other("pipe broke")); + assert!( + !is_auth_error(&io), + "I/O error must not be classified as auth error" + ); + let timeout = acp::AcpError::WriteTimeout(std::time::Duration::from_secs(5)); + assert!( + !is_auth_error(&timeout), + "WriteTimeout must not be classified as auth error" + ); + } + + // ── auth error dead-letter behavior ──────────────────────────────────── + + /// An auth-class `PromptOutcome::Error` must dead-letter immediately + /// (the batch is never requeued) so the user sees a re-auth hint at once + /// rather than after 10 futile retries. + #[tokio::test] + async fn auth_error_dead_letters_immediately_without_requeueing() { + let keys = nostr::Keys::generate(); + let event = nostr::EventBuilder::new(nostr::Kind::Custom(9), "test") + .sign_with_keys(&keys) + .unwrap(); + let channel_id = uuid::Uuid::new_v4(); + let batch = FlushBatch { + channel_id, + events: vec![BatchEvent { + event, + prompt_tag: "test".into(), + received_at: std::time::Instant::now(), + }], + cancelled_events: vec![], + cancel_reason: None, + }; + + let auth_error = acp::AcpError::AgentError { + code: -32000, + message: "API Error: 401 OAuth access token has expired. Re-authenticate to continue." + .to_string(), + }; + + let agent = dummy_agent(0).await; + let mut pool = AgentPool::from_slots(vec![None]); + let task_id = pool.join_set.spawn(async {}).id(); + pool.task_map_mut().insert( + task_id, + crate::pool::TaskMeta { + agent_index: 0, + channel_id: None, + turn_id: "test-turn-id".to_string(), + recoverable_batch: None, + control_tx: None, + steer_tx: None, + }, + ); + let mut queue = EventQueue::new(config::DedupMode::Queue); + let config = test_config(); + let mut heartbeat_in_flight = false; + let removed_channels = std::collections::HashSet::new(); + let mut crash_history = vec![SlotCircuit { + crash_times: Vec::new(), + open_until: None, + respawn_in_flight: false, + }]; + let (respawn_tx, _respawn_rx) = mpsc::channel(8); + let mut respawn_tasks = tokio::task::JoinSet::new(); + let result = PromptResult { + agent, + source: PromptSource::Channel(channel_id), + turn_id: "test-turn-id".to_string(), + outcome: PromptOutcome::Error(auth_error), + batch: Some(batch), + }; + handle_prompt_result( + &mut pool, + &mut queue, + &config, + result, + &mut heartbeat_in_flight, + &removed_channels, + &mut crash_history, + &respawn_tx, + &mut respawn_tasks, + None, + None, + ); + + // The batch must not be requeued: pending_channels returns 0. + assert_eq!( + queue.pending_channels(), + 0, + "auth error must dead-letter immediately — batch must not be requeued" + ); + assert_eq!( + queue.queued_event_count(&channel_id), + 0, + "auth error must dead-letter immediately — no events should be pending" + ); + } + + /// A non-auth application error (e.g. usage credits) must still follow the + /// standard requeue path so today's behavior is unchanged. + #[tokio::test] + async fn non_auth_application_error_is_requeued() { + let keys = nostr::Keys::generate(); + let event = nostr::EventBuilder::new(nostr::Kind::Custom(9), "test") + .sign_with_keys(&keys) + .unwrap(); + let channel_id = uuid::Uuid::new_v4(); + let batch = FlushBatch { + channel_id, + events: vec![BatchEvent { + event, + prompt_tag: "test".into(), + received_at: std::time::Instant::now(), + }], + cancelled_events: vec![], + cancel_reason: None, + }; + + // Usage-credits error — AgentError but NOT an auth error. + let usage_error = acp::AcpError::AgentError { + code: -32000, + message: "Usage credits required for 1M context".to_string(), + }; + + let agent = dummy_agent(0).await; + let mut pool = AgentPool::from_slots(vec![None]); + let task_id = pool.join_set.spawn(async {}).id(); + pool.task_map_mut().insert( + task_id, + crate::pool::TaskMeta { + agent_index: 0, + channel_id: None, + turn_id: "test-turn-id".to_string(), + recoverable_batch: None, + control_tx: None, + steer_tx: None, + }, + ); + let mut queue = EventQueue::new(config::DedupMode::Queue); + let config = test_config(); + let mut heartbeat_in_flight = false; + let removed_channels = std::collections::HashSet::new(); + let mut crash_history = vec![SlotCircuit { + crash_times: Vec::new(), + open_until: None, + respawn_in_flight: false, + }]; + let (respawn_tx, _respawn_rx) = mpsc::channel(8); + let mut respawn_tasks = tokio::task::JoinSet::new(); + let result = PromptResult { + agent, + source: PromptSource::Channel(channel_id), + turn_id: "test-turn-id".to_string(), + outcome: PromptOutcome::Error(usage_error), + batch: Some(batch), + }; + handle_prompt_result( + &mut pool, + &mut queue, + &config, + result, + &mut heartbeat_in_flight, + &removed_channels, + &mut crash_history, + &respawn_tx, + &mut respawn_tasks, + None, + None, + ); + + // Non-auth application error: batch IS requeued (first attempt, retry budget > 0). + assert_eq!( + queue.pending_channels(), + 1, + "non-auth application error must requeue the batch for retry" + ); + assert_eq!( + queue.queued_event_count(&channel_id), + 1, + "non-auth application error must preserve the event for retry" + ); + } } #[cfg(test)] From e8105d1446834f522c75d628bf1d9e77c9c077a4 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 24 Jul 2026 16:03:31 -0700 Subject: [PATCH 003/393] chore(release): release Buzz Desktop version 0.4.25 (#2776) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Buzz Desktop release v0.4.25 ### Changes since v0.4.24: - fix(discovery): spawn PowerShell install commands natively on Windows ([#2750](https://github.com/block/buzz/pull/2750)) ([`f3981dbfe`](https://github.com/block/buzz/commit/f3981dbfefc09e6a888ada489badb7dafdf122cf)) - fix(desktop): use augmented PATH for model discovery subprocess ([#2753](https://github.com/block/buzz/pull/2753)) ([`3bd3a014c`](https://github.com/block/buzz/commit/3bd3a014c6ed3f8c8c6c6ea359fee9a7e98dd670)) - Improve huddle audio failure handling ([#2578](https://github.com/block/buzz/pull/2578)) ([`fb4a801ad`](https://github.com/block/buzz/commit/fb4a801adf82677b242399f10b1d95b554bb8ad0)) - fix(onboarding): show real install errors and fix concurrent install state ([#2658](https://github.com/block/buzz/pull/2658)) ([`9731cd818`](https://github.com/block/buzz/commit/9731cd818b21c7a3e1f56a319272eac6dada0555)) - feat(node): add Windows managed Node.js fallback (win-x64 + win-arm64) ([#2661](https://github.com/block/buzz/pull/2661)) ([`596386ee5`](https://github.com/block/buzz/commit/596386ee55d1516bc3d88922c44b9067a113a28e)) - fix(desktop): parse runtime team instructions section ([#2645](https://github.com/block/buzz/pull/2645)) ([`269ef357f`](https://github.com/block/buzz/commit/269ef357f75a0cbcc71973db1b891d6ff87fac67)) - Match create-channel template selector styling ([#2654](https://github.com/block/buzz/pull/2654)) ([`72bbaece4`](https://github.com/block/buzz/commit/72bbaece4ba9b3b8a5c2b8561ef82150b22b1cc4)) - feat(desktop): make pull request reviews actionable ([#2510](https://github.com/block/buzz/pull/2510)) ([`9081ab0ec`](https://github.com/block/buzz/commit/9081ab0ec9c5d91548c7f5ff52eba6cca4788dd0)) - fix(desktop): shared-compute usability — share toggle, usage indicator, model resync ([#2448](https://github.com/block/buzz/pull/2448)) ([`9cc9652c7`](https://github.com/block/buzz/commit/9cc9652c7dec9145b0bf0ce2c4b46c8191d215f8)) - fix(desktop): refine focused thread dismissal targets ([#2644](https://github.com/block/buzz/pull/2644)) ([`c86c4f59c`](https://github.com/block/buzz/commit/c86c4f59c4d800f170a36761da2fa2f0d12ddbb2)) - Clarify agent harness defaults in create flow ([#2601](https://github.com/block/buzz/pull/2601)) ([`76aeae703`](https://github.com/block/buzz/commit/76aeae703664a6a6741b82771df67c546886aafd)) - fix: expose community icon control on open relays ([#2640](https://github.com/block/buzz/pull/2640)) ([`e341b09cb`](https://github.com/block/buzz/commit/e341b09cb9ed0f9dd626b74b9440e4180c15b435)) **To release:** merge this PR. The tag and build will happen automatically. --- CHANGELOG.md | 16 ++++++++++++++++ desktop/package.json | 2 +- desktop/src-tauri/Cargo.lock | 2 +- desktop/src-tauri/Cargo.toml | 2 +- desktop/src-tauri/tauri.conf.json | 2 +- 5 files changed, 20 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d42a6c2203..70075b6887 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## v0.4.25 + +- fix(discovery): spawn PowerShell install commands natively on Windows ([#2750](https://github.com/block/buzz/pull/2750)) ([`f3981dbfe`](https://github.com/block/buzz/commit/f3981dbfefc09e6a888ada489badb7dafdf122cf)) +- fix(desktop): use augmented PATH for model discovery subprocess ([#2753](https://github.com/block/buzz/pull/2753)) ([`3bd3a014c`](https://github.com/block/buzz/commit/3bd3a014c6ed3f8c8c6c6ea359fee9a7e98dd670)) +- Improve huddle audio failure handling ([#2578](https://github.com/block/buzz/pull/2578)) ([`fb4a801ad`](https://github.com/block/buzz/commit/fb4a801adf82677b242399f10b1d95b554bb8ad0)) +- fix(onboarding): show real install errors and fix concurrent install state ([#2658](https://github.com/block/buzz/pull/2658)) ([`9731cd818`](https://github.com/block/buzz/commit/9731cd818b21c7a3e1f56a319272eac6dada0555)) +- feat(node): add Windows managed Node.js fallback (win-x64 + win-arm64) ([#2661](https://github.com/block/buzz/pull/2661)) ([`596386ee5`](https://github.com/block/buzz/commit/596386ee55d1516bc3d88922c44b9067a113a28e)) +- fix(desktop): parse runtime team instructions section ([#2645](https://github.com/block/buzz/pull/2645)) ([`269ef357f`](https://github.com/block/buzz/commit/269ef357f75a0cbcc71973db1b891d6ff87fac67)) +- Match create-channel template selector styling ([#2654](https://github.com/block/buzz/pull/2654)) ([`72bbaece4`](https://github.com/block/buzz/commit/72bbaece4ba9b3b8a5c2b8561ef82150b22b1cc4)) +- feat(desktop): make pull request reviews actionable ([#2510](https://github.com/block/buzz/pull/2510)) ([`9081ab0ec`](https://github.com/block/buzz/commit/9081ab0ec9c5d91548c7f5ff52eba6cca4788dd0)) +- fix(desktop): shared-compute usability — share toggle, usage indicator, model resync ([#2448](https://github.com/block/buzz/pull/2448)) ([`9cc9652c7`](https://github.com/block/buzz/commit/9cc9652c7dec9145b0bf0ce2c4b46c8191d215f8)) +- fix(desktop): refine focused thread dismissal targets ([#2644](https://github.com/block/buzz/pull/2644)) ([`c86c4f59c`](https://github.com/block/buzz/commit/c86c4f59c4d800f170a36761da2fa2f0d12ddbb2)) +- Clarify agent harness defaults in create flow ([#2601](https://github.com/block/buzz/pull/2601)) ([`76aeae703`](https://github.com/block/buzz/commit/76aeae703664a6a6741b82771df67c546886aafd)) +- fix: expose community icon control on open relays ([#2640](https://github.com/block/buzz/pull/2640)) ([`e341b09cb`](https://github.com/block/buzz/commit/e341b09cb9ed0f9dd626b74b9440e4180c15b435)) + + ## v0.4.24 - fix(desktop): suppress Windows console flashes and reject WSL bash alias ([#2587](https://github.com/block/buzz/pull/2587)) ([`5afa16157`](https://github.com/block/buzz/commit/5afa16157a63c71f2cd8a80aa7276de28ce1c54c)) diff --git a/desktop/package.json b/desktop/package.json index 1efebcf7a3..5da501c984 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "buzz", "private": true, - "version": "0.4.24", + "version": "0.4.25", "type": "module", "scripts": { "dev": "vite", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index e97e86c7d8..5835f8b39b 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1008,7 +1008,7 @@ dependencies = [ [[package]] name = "buzz-desktop" -version = "0.4.24" +version = "0.4.25" dependencies = [ "anyhow", "arboard", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index baae670863..9db907db3f 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "buzz-desktop" -version = "0.4.24" +version = "0.4.25" description = "Buzz desktop app" authors = ["you"] edition = "2021" diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 2fe2fdf959..83046c01a3 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Buzz", - "version": "0.4.24", + "version": "0.4.25", "identifier": "xyz.block.buzz.app", "build": { "beforeDevCommand": { From bb445d3cf13c37431f8afd754d6d575bb6909cdf Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sat, 25 Jul 2026 00:22:43 +0100 Subject: [PATCH 004/393] Add adaptive community QR scanner (#2739) ## Summary - Expand the pairing scanner from the Dynamic Island on supported iPhones - Reveal the camera behind the pairing UI on Android and standard iPhones - Preserve tap-to-dismiss and reduced-motion behavior ## Testing - `just mobile-check` - `just mobile-test` - iOS `RunnerTests` --- mobile/ios/Runner/AppDelegate.swift | 58 ++++ mobile/ios/RunnerTests/RunnerTests.swift | 22 ++ mobile/lib/features/pairing/pairing_page.dart | 122 +++----- .../features/pairing/pairing_qr_scanner.dart | 175 +++++++++++ .../dynamic_island_portal.dart | 177 +++++++++++ .../pairing_qr_scanner/fallback_scanner.dart | 282 ++++++++++++++++++ .../pairing_qr_scanner/scanner_camera.dart | 53 ++++ .../pairing/pairing_qr_scanner_test.dart | 277 +++++++++++++++++ 8 files changed, 1081 insertions(+), 85 deletions(-) create mode 100644 mobile/lib/features/pairing/pairing_qr_scanner.dart create mode 100644 mobile/lib/features/pairing/pairing_qr_scanner/dynamic_island_portal.dart create mode 100644 mobile/lib/features/pairing/pairing_qr_scanner/fallback_scanner.dart create mode 100644 mobile/lib/features/pairing/pairing_qr_scanner/scanner_camera.dart create mode 100644 mobile/test/features/pairing/pairing_qr_scanner_test.dart diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index c6ae676c6e..5edb4b6aaf 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -6,6 +6,7 @@ import UserNotifications @main @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { private var mediaUploadChannel: FlutterMethodChannel? + private var qrScannerChannel: FlutterMethodChannel? override func application( _ application: UIApplication, @@ -24,6 +25,63 @@ import UserNotifications mediaUploadChannel?.setMethodCallHandler { [weak self] call, result in self?.handleMediaUploadMethodCall(call, result: result) } + qrScannerChannel = FlutterMethodChannel( + name: "buzz/qr_scanner", + binaryMessenger: engineBridge.applicationRegistrar.messenger() + ) + qrScannerChannel?.setMethodCallHandler { call, result in + Self.handleQrScannerMethodCall(call, result: result) + } + } + + private static func handleQrScannerMethodCall( + _ call: FlutterMethodCall, + result: @escaping FlutterResult + ) { + switch call.method { + case "usesDynamicIslandQrScannerPortal": + result( + UIDevice.current.userInterfaceIdiom == .phone + && usesDynamicIslandQrScannerPortal( + safeAreaTopInset: activeWindowSafeAreaTopInset() + ) + ) + case "setDynamicIslandScannerStatusBarHidden": + guard let hidden = call.arguments as? Bool else { + result( + FlutterError( + code: "invalid_arguments", + message: "Expected a Bool status-bar visibility value.", + details: nil + ) + ) + return + } + UIApplication.shared.setStatusBarHidden(hidden, with: .fade) + result(nil) + case "performDynamicIslandQrScanSuccessHaptic": + let generator = UINotificationFeedbackGenerator() + generator.prepare() + generator.notificationOccurred(.success) + result(nil) + default: + result(FlutterMethodNotImplemented) + } + } + + static func usesDynamicIslandQrScannerPortal( + safeAreaTopInset: CGFloat + ) -> Bool { + safeAreaTopInset > 50 + } + + private static func activeWindowSafeAreaTopInset() -> CGFloat { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .filter { $0.activationState == .foregroundActive } + .flatMap(\.windows) + .first(where: \.isKeyWindow)? + .safeAreaInsets.top ?? 0 } private func handleMediaUploadMethodCall( diff --git a/mobile/ios/RunnerTests/RunnerTests.swift b/mobile/ios/RunnerTests/RunnerTests.swift index ecaf590fd7..a174c78684 100644 --- a/mobile/ios/RunnerTests/RunnerTests.swift +++ b/mobile/ios/RunnerTests/RunnerTests.swift @@ -6,6 +6,28 @@ import XCTest class RunnerTests: XCTestCase { + func testDynamicIslandQrScannerRecognizesTallSafeAreas() { + for safeAreaTopInset in [51, 59, 62] { + XCTAssertTrue( + AppDelegate.usesDynamicIslandQrScannerPortal( + safeAreaTopInset: CGFloat(safeAreaTopInset) + ), + "\(safeAreaTopInset)" + ) + } + } + + func testDynamicIslandQrScannerRejectsStandardSafeAreas() { + for safeAreaTopInset in [0, 44, 47, 50] { + XCTAssertFalse( + AppDelegate.usesDynamicIslandQrScannerPortal( + safeAreaTopInset: CGFloat(safeAreaTopInset) + ), + "\(safeAreaTopInset)" + ) + } + } + func testClipboardImageDataPrefersOriginalPngBytes() throws { let pasteboard = try XCTUnwrap( UIPasteboard(name: UIPasteboard.Name(UUID().uuidString), create: true) diff --git a/mobile/lib/features/pairing/pairing_page.dart b/mobile/lib/features/pairing/pairing_page.dart index 223936017e..70514f274d 100644 --- a/mobile/lib/features/pairing/pairing_page.dart +++ b/mobile/lib/features/pairing/pairing_page.dart @@ -1,11 +1,13 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; -import 'package:mobile_scanner/mobile_scanner.dart'; import '../../shared/theme/theme.dart'; import 'pairing_provider.dart'; +import 'pairing_qr_scanner.dart'; class PairingPage extends HookConsumerWidget { /// When true, the pairing page is being used to add a new community @@ -18,6 +20,7 @@ class PairingPage extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final pairingState = ref.watch(pairingProvider); final codeController = useTextEditingController(); + final fallbackScannerVisible = useState(false); final isBusy = pairingState.status == PairingStatus.connecting || pairingState.status == PairingStatus.transferring || @@ -33,7 +36,28 @@ class PairingPage extends HookConsumerWidget { }); } - return PopScope( + Future handleScannerResult(String? code) async { + if (code != null && context.mounted) { + await ref.read(pairingProvider.notifier).pair(code); + } + } + + Future openScanner() async { + final usesDynamicIslandPortal = await usesDynamicIslandQrScannerPortal(); + if (!context.mounted) { + return; + } + + if (!usesDynamicIslandPortal) { + fallbackScannerVisible.value = true; + return; + } + + final code = await showDynamicIslandPairingQrScanner(context); + await handleScannerResult(code); + } + + final appSurface = PopScope( onPopInvokedWithResult: (didPop, _) { if (didPop) { ref.read(pairingProvider.notifier).reset(); @@ -84,9 +108,7 @@ class PairingPage extends HookConsumerWidget { // Scan QR button FilledButton.icon( - onPressed: isBusy - ? null - : () => _openScanner(context, ref), + onPressed: isBusy ? null : openScanner, icon: const Icon(LucideIcons.scanLine), label: const Text('Scan QR Code'), ), @@ -198,18 +220,17 @@ class PairingPage extends HookConsumerWidget { ), ), ); - } - void _openScanner(BuildContext context, WidgetRef ref) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => _ScannerPage( - onScanned: (code) { - Navigator.of(context).pop(); - ref.read(pairingProvider.notifier).pair(code); - }, - ), - ), + if (!fallbackScannerVisible.value) { + return appSurface; + } + + return FallbackPairingQrScanner( + appSurface: appSurface, + onClosed: (code) { + fallbackScannerVisible.value = false; + unawaited(handleScannerResult(code)); + }, ); } } @@ -336,72 +357,3 @@ class _SasVerificationView extends StatelessWidget { ); } } - -class _ScannerPage extends HookWidget { - final void Function(String code) onScanned; - - const _ScannerPage({required this.onScanned}); - - @override - Widget build(BuildContext context) { - final handled = useState(false); - final controller = useMemoized(() => MobileScannerController()); - - useEffect(() => controller.dispose, const []); - - return Scaffold( - appBar: AppBar( - title: const Text('Scan QR Code'), - leading: IconButton( - icon: const Icon(LucideIcons.arrowLeft), - onPressed: () => Navigator.of(context).pop(), - ), - ), - body: MobileScanner( - controller: controller, - errorBuilder: (context, error) { - final message = switch (error.errorCode) { - MobileScannerErrorCode.permissionDenied => - 'Camera permission is required to scan QR codes.\n\nPlease grant camera access in your device settings.', - _ => - 'Could not start camera: ${error.errorDetails?.message ?? 'unknown error'}', - }; - return Center( - child: Padding( - padding: const EdgeInsets.all(Grid.sm), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - LucideIcons.cameraOff, - size: 48, - color: context.colors.onSurfaceVariant, - ), - const SizedBox(height: Grid.xs), - Text( - message, - textAlign: TextAlign.center, - style: context.textTheme.bodyMedium?.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), - ], - ), - ), - ); - }, - onDetect: (capture) { - if (handled.value) return; - final barcodes = capture.barcodes; - if (barcodes.isNotEmpty) { - final value = barcodes.first.rawValue; - if (value != null && value.isNotEmpty) { - handled.value = true; - onScanned(value); - } - } - }, - ), - ); - } -} diff --git a/mobile/lib/features/pairing/pairing_qr_scanner.dart b/mobile/lib/features/pairing/pairing_qr_scanner.dart new file mode 100644 index 0000000000..648ca451cd --- /dev/null +++ b/mobile/lib/features/pairing/pairing_qr_scanner.dart @@ -0,0 +1,175 @@ +import 'dart:async'; +import 'dart:math' as math; +import 'dart:ui' show lerpDouble; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; + +import '../../shared/theme/theme.dart'; + +part 'pairing_qr_scanner/dynamic_island_portal.dart'; +part 'pairing_qr_scanner/fallback_scanner.dart'; +part 'pairing_qr_scanner/scanner_camera.dart'; + +const _qrScannerPlatformChannel = MethodChannel('buzz/qr_scanner'); + +/// Returns whether the current iPhone should use the Dynamic Island scanner. +/// +/// Android, iPad, and iPhones without a Dynamic Island always use the standard +/// full-screen scanner. +Future usesDynamicIslandQrScannerPortal() async { + if (defaultTargetPlatform != TargetPlatform.iOS) { + return false; + } + + try { + return await _qrScannerPlatformChannel.invokeMethod( + 'usesDynamicIslandQrScannerPortal', + ) ?? + false; + } on PlatformException { + return false; + } on MissingPluginException { + return false; + } +} + +/// Opens the Dynamic Island QR scanner portal. +/// +/// Callers determine device support with +/// [usesDynamicIslandQrScannerPortal]. Standard devices reveal the camera +/// behind their existing app surface with [FallbackPairingQrScanner]. +Future showDynamicIslandPairingQrScanner(BuildContext context) { + return Navigator.of(context).push( + PageRouteBuilder( + opaque: false, + barrierColor: Colors.transparent, + barrierDismissible: false, + transitionDuration: Duration.zero, + reverseTransitionDuration: Duration.zero, + pageBuilder: (_, _, _) => const _DynamicIslandQrScannerPortal(), + ), + ); +} + +Future _setDynamicIslandScannerStatusBarHidden(bool hidden) async { + try { + await _qrScannerPlatformChannel.invokeMethod( + 'setDynamicIslandScannerStatusBarHidden', + hidden, + ); + } on PlatformException { + // The scanner still works if the native status-bar bridge is unavailable. + } on MissingPluginException { + // Widget tests and non-iOS embedders do not register the bridge. + } +} + +Future _performDynamicIslandQrScanSuccessHaptic() async { + try { + await _qrScannerPlatformChannel.invokeMethod( + 'performDynamicIslandQrScanSuccessHaptic', + ); + } on PlatformException { + // Scanning should still complete if haptics are unavailable. + } on MissingPluginException { + // Widget tests and non-iOS embedders do not register the bridge. + } +} + +String? _firstScannedValue(BarcodeCapture capture) { + for (final barcode in capture.barcodes) { + final value = barcode.rawValue; + if (value != null && value.isNotEmpty) { + return value; + } + } + return null; +} + +/// Geometry for the iPhone Dynamic Island scanner portal. +/// +/// The collapsed and expanded frames share one top edge. This makes the camera +/// grow down from the hardware cutout instead of scaling around the center of +/// its final square. +@visibleForTesting +class DynamicIslandQrScannerGeometry { + /// Creates geometry for a viewport and its top safe-area inset. + const DynamicIslandQrScannerGeometry({ + required this.viewport, + required this.safeAreaTop, + }); + + /// The full logical-pixel size available to the portal route. + final Size viewport; + + /// The top safe-area inset reported by iOS. + final double safeAreaTop; + + static const _collapsedWidth = 120.0; + static const _collapsedHeight = 36.0; + static const _fallbackTop = 11.0; + static const _referenceSafeAreaTop = 59.0; + static const _edgeMargin = 15.0; + static const _expandedCornerRadius = 40.0; + static const _scannerFadeStart = 0.18; + static const _introLabelFadeEnd = 0.42; + + /// The physical-island-aligned top edge used throughout the morph. + double get top => + _fallbackTop + math.max(0, safeAreaTop - _referenceSafeAreaTop); + + /// The portal frame before the opening animation starts. + Rect get collapsedFrame => Rect.fromLTWH( + (viewport.width - _collapsedWidth) / 2, + top, + _collapsedWidth, + _collapsedHeight, + ); + + /// The square camera frame after the opening animation completes. + Rect get expandedFrame { + final maxHeight = math.max( + _collapsedHeight, + viewport.height - top - _edgeMargin, + ); + final size = math.min(viewport.width - (_edgeMargin * 2), maxHeight); + return Rect.fromLTWH(_edgeMargin, top, size, size); + } + + /// Returns the top-anchored portal frame at [progress]. + Rect frameAt(double progress) { + final t = progress.clamp(0.0, 1.0); + final start = collapsedFrame; + final end = expandedFrame; + return Rect.fromLTRB( + lerpDouble(start.left, end.left, t)!, + top, + lerpDouble(start.right, end.right, t)!, + lerpDouble(start.bottom, end.bottom, t)!, + ); + } + + /// Returns the portal corner radius at [progress]. + double cornerRadiusAt(double progress) { + final t = progress.clamp(0.0, 1.0); + return lerpDouble(_collapsedHeight / 2, _expandedCornerRadius, t)!; + } + + /// Returns the camera opacity after its delayed entrance. + double scannerOpacityAt(double progress) { + return ((progress - _scannerFadeStart) / (1 - _scannerFadeStart)).clamp( + 0.0, + 1.0, + ); + } + + /// Returns the opacity of the compact prompt inside the island pill. + double introLabelOpacityAt(double progress) { + return (1 - (progress / _introLabelFadeEnd)).clamp(0.0, 1.0); + } +} diff --git a/mobile/lib/features/pairing/pairing_qr_scanner/dynamic_island_portal.dart b/mobile/lib/features/pairing/pairing_qr_scanner/dynamic_island_portal.dart new file mode 100644 index 0000000000..11d7061be2 --- /dev/null +++ b/mobile/lib/features/pairing/pairing_qr_scanner/dynamic_island_portal.dart @@ -0,0 +1,177 @@ +part of '../pairing_qr_scanner.dart'; + +const _dynamicIslandOpenDuration = Duration(milliseconds: 460); +const _dynamicIslandCloseDuration = Duration(milliseconds: 340); +const _dynamicIslandEaseOut = Cubic(0.16, 1, 0.3, 1); + +class _DynamicIslandQrScannerPortal extends HookWidget { + const _DynamicIslandQrScannerPortal(); + + @override + Widget build(BuildContext context) { + final controller = useMemoized(MobileScannerController.new); + final animation = useAnimationController( + duration: _dynamicIslandOpenDuration, + reverseDuration: _dynamicIslandCloseDuration, + ); + final isClosing = useState(false); + final canPop = useState(false); + final hasHandledResult = useRef(false); + final reduceMotion = MediaQuery.disableAnimationsOf(context); + + useEffect(() { + unawaited(_setDynamicIslandScannerStatusBarHidden(true)); + if (reduceMotion) { + animation.value = 1; + } else { + unawaited(animation.forward()); + } + + return () { + unawaited(_setDynamicIslandScannerStatusBarHidden(false)); + unawaited(controller.dispose()); + }; + }, [animation, controller, reduceMotion]); + + Future finish(String? result) async { + canPop.value = true; + await WidgetsBinding.instance.endOfFrame; + if (context.mounted) { + Navigator.of(context).pop(result); + } + } + + Future closePortal() async { + if (isClosing.value || hasHandledResult.value) { + return; + } + hasHandledResult.value = true; + isClosing.value = true; + canPop.value = true; + + if (reduceMotion) { + animation.value = 0; + await WidgetsBinding.instance.endOfFrame; + } else { + await animation.reverse(); + } + if (context.mounted) { + Navigator.of(context).pop(); + } + } + + void handleDetection(BarcodeCapture capture) { + if (isClosing.value || hasHandledResult.value) { + return; + } + final value = _firstScannedValue(capture); + if (value == null) { + return; + } + + hasHandledResult.value = true; + unawaited(_performDynamicIslandQrScanSuccessHaptic()); + unawaited(finish(value)); + } + + return PopScope( + canPop: canPop.value, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) { + unawaited(closePortal()); + } + }, + child: Material( + type: MaterialType.transparency, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: closePortal, + child: LayoutBuilder( + builder: (context, constraints) { + final geometry = DynamicIslandQrScannerGeometry( + viewport: constraints.biggest, + safeAreaTop: MediaQuery.viewPaddingOf(context).top, + ); + + return Stack( + children: [ + Positioned.fill( + child: AnimatedBuilder( + animation: animation, + builder: (context, _) => ColoredBox( + color: Colors.black.withValues( + alpha: 0.12 * animation.value, + ), + ), + ), + ), + AnimatedBuilder( + animation: animation, + builder: (context, _) { + final progress = _dynamicIslandEaseOut.transform( + animation.value.clamp(0.0, 1.0), + ); + final frame = geometry.frameAt(progress); + final scannerOpacity = geometry.scannerOpacityAt( + progress, + ); + final introLabelOpacity = isClosing.value + ? 0.0 + : geometry.introLabelOpacityAt(progress); + final promptOpacity = isClosing.value + ? 0.0 + : math.max(introLabelOpacity, scannerOpacity); + + return Positioned.fromRect( + rect: frame, + child: ClipRRect( + key: const ValueKey( + 'dynamic-island-qr-scanner-portal', + ), + borderRadius: BorderRadius.circular( + geometry.cornerRadiusAt(progress), + ), + child: ColoredBox( + color: Colors.black, + child: Stack( + fit: StackFit.expand, + children: [ + if (!isClosing.value && scannerOpacity > 0) + Opacity( + opacity: scannerOpacity, + child: _QrScannerCamera( + controller: controller, + onDetect: handleDetection, + ), + ), + IgnorePointer( + child: Opacity( + opacity: promptOpacity, + child: Center( + child: Text( + 'Scan a QR code', + style: context.textTheme.bodyMedium + ?.copyWith( + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ), + ], + ), + ), + ), + ); + }, + ), + ], + ); + }, + ), + ), + ), + ); + } +} diff --git a/mobile/lib/features/pairing/pairing_qr_scanner/fallback_scanner.dart b/mobile/lib/features/pairing/pairing_qr_scanner/fallback_scanner.dart new file mode 100644 index 0000000000..52fd263ca5 --- /dev/null +++ b/mobile/lib/features/pairing/pairing_qr_scanner/fallback_scanner.dart @@ -0,0 +1,282 @@ +part of '../pairing_qr_scanner.dart'; + +const _fallbackScannerOpenDuration = Duration(milliseconds: 420); +const _fallbackScannerCloseDuration = Duration(milliseconds: 320); +const _fallbackScannerSheetPeekHeight = 112.0; +const _fallbackScannerSheetCornerRadius = 32.0; +const _fallbackScannerDrawerCurve = Cubic(0.32, 0.72, 0, 1); + +/// Reveals the scanner behind the current app surface on standard devices. +/// +/// The app surface moves down as a rounded sheet instead of navigating to a +/// separate scanner page. This is used by Android and iPhones without a +/// Dynamic Island. +class FallbackPairingQrScanner extends HookWidget { + const FallbackPairingQrScanner({ + required this.appSurface, + required this.onClosed, + super.key, + }); + + /// The current Buzz screen that moves down to reveal the camera. + final Widget appSurface; + + /// Called after the app surface has returned, with a scanned value if any. + final ValueChanged onClosed; + + @override + Widget build(BuildContext context) { + final controller = useMemoized(MobileScannerController.new); + final animation = useAnimationController( + duration: _fallbackScannerOpenDuration, + reverseDuration: _fallbackScannerCloseDuration, + ); + final isClosing = useRef(false); + final reduceMotion = MediaQuery.disableAnimationsOf(context); + + useEffect( + () => () { + unawaited(controller.dispose()); + }, + [controller], + ); + + useEffect(() { + if (reduceMotion) { + animation.value = 1; + } else { + unawaited(animation.forward()); + } + return null; + }, [animation, reduceMotion]); + + Future closeScanner([String? result]) async { + if (isClosing.value) { + return; + } + isClosing.value = true; + + if (reduceMotion) { + animation.value = 0; + await WidgetsBinding.instance.endOfFrame; + } else { + await animation.reverse(); + } + + if (context.mounted) { + onClosed(result); + } + } + + void handleDetection(BarcodeCapture capture) { + if (isClosing.value) { + return; + } + final value = _firstScannedValue(capture); + if (value == null) { + return; + } + + unawaited(closeScanner(value)); + } + + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) { + unawaited(closeScanner()); + } + }, + child: AnnotatedRegion( + value: SystemUiOverlayStyle.light, + child: Material( + key: const ValueKey('fallback-qr-scanner-reveal'), + color: Colors.black, + child: LayoutBuilder( + builder: (context, constraints) { + final viewport = constraints.biggest; + final sheetTravel = math.max( + 0.0, + viewport.height - _fallbackScannerSheetPeekHeight, + ); + + return Stack( + clipBehavior: Clip.hardEdge, + children: [ + Positioned.fill( + child: Semantics( + button: true, + label: 'Close QR scanner', + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: closeScanner, + child: Stack( + fit: StackFit.expand, + children: [ + _QrScannerCamera( + controller: controller, + onDetect: handleDetection, + ), + const IgnorePointer( + child: _FallbackQrScannerViewfinder(), + ), + ], + ), + ), + ), + ), + AnimatedBuilder( + animation: animation, + child: appSurface, + builder: (context, child) { + final progress = _fallbackScannerDrawerCurve.transform( + animation.value.clamp(0.0, 1.0), + ); + final offset = sheetTravel * progress; + final radius = + _fallbackScannerSheetCornerRadius * progress; + + return Positioned( + top: offset, + left: 0, + right: 0, + height: viewport.height, + child: Semantics( + button: true, + label: 'Close QR scanner', + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: closeScanner, + child: ClipRRect( + key: const ValueKey( + 'fallback-qr-scanner-app-sheet', + ), + borderRadius: BorderRadius.vertical( + top: Radius.circular(radius), + ), + child: Stack( + fit: StackFit.expand, + children: [ + AbsorbPointer(child: child), + if (progress > 0) + Align( + alignment: Alignment.topCenter, + child: Padding( + padding: const EdgeInsets.only( + top: Grid.twelve, + ), + child: Opacity( + opacity: progress, + child: Container( + width: Grid.md, + height: Grid.half, + decoration: BoxDecoration( + color: context + .colors + .onSurfaceVariant + .withValues(alpha: 0.45), + borderRadius: + BorderRadius.circular( + Grid.half, + ), + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + }, + ), + ], + ); + }, + ), + ), + ), + ); + } +} + +class _FallbackQrScannerViewfinder extends StatelessWidget { + const _FallbackQrScannerViewfinder(); + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final size = math.min( + constraints.maxWidth - (Grid.xl * 2), + constraints.maxHeight - 240, + ); + + return Stack( + fit: StackFit.expand, + children: [ + CustomPaint( + painter: _FallbackQrScannerViewfinderPainter( + viewfinderSize: math.max(0, size), + ), + ), + Align( + alignment: Alignment.center, + child: Transform.translate( + offset: Offset(0, (size / 2) + Grid.md), + child: Text( + 'Scan a QR code', + style: context.textTheme.bodyMedium?.copyWith( + color: Colors.white, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ); + }, + ); + } +} + +class _FallbackQrScannerViewfinderPainter extends CustomPainter { + const _FallbackQrScannerViewfinderPainter({required this.viewfinderSize}); + + final double viewfinderSize; + + @override + void paint(Canvas canvas, Size size) { + final rect = Rect.fromCenter( + center: size.center(Offset.zero), + width: viewfinderSize, + height: viewfinderSize, + ); + final roundedRect = RRect.fromRectAndRadius( + rect, + const Radius.circular(40), + ); + + canvas.saveLayer(Offset.zero & size, Paint()); + canvas.drawRect( + Offset.zero & size, + Paint()..color = Colors.black.withValues(alpha: 0.6), + ); + canvas.drawRRect(roundedRect, Paint()..blendMode = BlendMode.clear); + canvas.restore(); + canvas.drawRRect( + roundedRect, + Paint() + ..color = Colors.white + ..style = PaintingStyle.stroke + ..strokeWidth = 3, + ); + } + + @override + bool shouldRepaint(_FallbackQrScannerViewfinderPainter oldDelegate) { + return oldDelegate.viewfinderSize != viewfinderSize; + } +} diff --git a/mobile/lib/features/pairing/pairing_qr_scanner/scanner_camera.dart b/mobile/lib/features/pairing/pairing_qr_scanner/scanner_camera.dart new file mode 100644 index 0000000000..d02a820a05 --- /dev/null +++ b/mobile/lib/features/pairing/pairing_qr_scanner/scanner_camera.dart @@ -0,0 +1,53 @@ +part of '../pairing_qr_scanner.dart'; + +class _QrScannerCamera extends StatelessWidget { + const _QrScannerCamera({required this.controller, required this.onDetect}); + + final MobileScannerController controller; + final ValueChanged onDetect; + + @override + Widget build(BuildContext context) { + return MobileScanner( + controller: controller, + fit: BoxFit.cover, + errorBuilder: (context, error) { + final message = switch (error.errorCode) { + MobileScannerErrorCode.permissionDenied => + 'Camera permission is required to scan QR codes.\n\n' + 'Please grant camera access in your device settings.', + _ => + 'Could not start camera: ' + "${error.errorDetails?.message ?? 'unknown error'}", + }; + return ColoredBox( + color: Colors.black, + child: Center( + child: Padding( + padding: const EdgeInsets.all(Grid.sm), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + LucideIcons.cameraOff, + size: 48, + color: Colors.white.withValues(alpha: 0.72), + ), + const SizedBox(height: Grid.xs), + Text( + message, + textAlign: TextAlign.center, + style: context.textTheme.bodyMedium?.copyWith( + color: Colors.white.withValues(alpha: 0.72), + ), + ), + ], + ), + ), + ), + ); + }, + onDetect: onDetect, + ); + } +} diff --git a/mobile/test/features/pairing/pairing_qr_scanner_test.dart b/mobile/test/features/pairing/pairing_qr_scanner_test.dart new file mode 100644 index 0000000000..79aa81ab48 --- /dev/null +++ b/mobile/test/features/pairing/pairing_qr_scanner_test.dart @@ -0,0 +1,277 @@ +import 'dart:async'; + +import 'package:buzz/features/pairing/pairing_qr_scanner.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; + +const _qrScannerPlatformChannel = MethodChannel('buzz/qr_scanner'); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final defaultScannerPlatform = MobileScannerPlatform.instance; + late _FakeMobileScannerPlatform fakeScannerPlatform; + + setUp(() { + fakeScannerPlatform = _FakeMobileScannerPlatform(); + MobileScannerPlatform.instance = fakeScannerPlatform; + }); + + tearDown(() async { + debugDefaultTargetPlatformOverride = null; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_qrScannerPlatformChannel, null); + await fakeScannerPlatform.dispose(); + MobileScannerPlatform.instance = defaultScannerPlatform; + }); + + group('DynamicIslandQrScannerGeometry', () { + const viewport = Size(393, 852); + const geometry = DynamicIslandQrScannerGeometry( + viewport: viewport, + safeAreaTop: 59, + ); + + test('starts at the physical Dynamic Island frame', () { + expect(geometry.collapsedFrame, const Rect.fromLTWH(136.5, 11, 120, 36)); + }); + + test('keeps the top edge fixed while the camera grows down', () { + final start = geometry.frameAt(0); + final middle = geometry.frameAt(0.5); + final end = geometry.frameAt(1); + + expect(middle.top, start.top); + expect(end.top, start.top); + expect(middle.bottom, greaterThan(start.bottom)); + expect(end.bottom, greaterThan(middle.bottom)); + expect(end, const Rect.fromLTWH(15, 11, 363, 363)); + }); + + test('delays the camera until the portal has left the island', () { + expect(geometry.scannerOpacityAt(0.18), 0); + expect(geometry.scannerOpacityAt(0.5), greaterThan(0)); + expect(geometry.scannerOpacityAt(1), 1); + }); + }); + + group('usesDynamicIslandQrScannerPortal', () { + test('asks the native bridge on iOS', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_qrScannerPlatformChannel, (call) async { + expect(call.method, 'usesDynamicIslandQrScannerPortal'); + return true; + }); + + expect(await usesDynamicIslandQrScannerPortal(), isTrue); + }); + + test('uses the fallback without asking native code on Android', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + var nativeCalls = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_qrScannerPlatformChannel, (call) async { + nativeCalls += 1; + return true; + }); + + expect(await usesDynamicIslandQrScannerPortal(), isFalse); + expect(nativeCalls, 0); + }); + }); + + testWidgets('fallback reveals the camera behind the current app surface', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(375, 667)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + var scannerClosed = false; + await tester.pumpWidget( + MaterialApp( + home: FallbackPairingQrScanner( + appSurface: const ColoredBox( + color: Colors.white, + child: Center(child: Text('Current app surface')), + ), + onClosed: (_) { + scannerClosed = true; + }, + ), + ), + ); + + final sheet = find.byKey(const ValueKey('fallback-qr-scanner-app-sheet')); + expect(tester.getRect(sheet).top, 0); + expect(find.text('Current app surface'), findsOneWidget); + + await tester.pump(const Duration(milliseconds: 420)); + expect(tester.getRect(sheet).top, closeTo(555, 0.1)); + + await tester.tapAt(const Offset(187.5, 100)); + await tester.pump(); + expect(scannerClosed, isFalse); + + await tester.pump(const Duration(milliseconds: 320)); + await tester.pumpAndSettle(); + expect(scannerClosed, isTrue); + expect(tester.getRect(sheet).top, 0); + }); + + testWidgets( + 'portal route opens from the island and an outside tap reverses it closed', + (tester) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_qrScannerPlatformChannel, (call) async { + return switch (call.method) { + 'usesDynamicIslandQrScannerPortal' => true, + 'setDynamicIslandScannerStatusBarHidden' => null, + _ => null, + }; + }); + await tester.binding.setSurfaceSize(const Size(393, 852)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + var scannerClosed = false; + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: const MediaQueryData( + size: Size(393, 852), + viewPadding: EdgeInsets.only(top: 59), + ), + child: Builder( + builder: (context) => TextButton( + onPressed: () async { + await showDynamicIslandPairingQrScanner(context); + scannerClosed = true; + }, + child: const Text('Open scanner'), + ), + ), + ), + ), + ); + + await tester.tap(find.text('Open scanner')); + await tester.pump(); + + final portal = find.byKey( + const ValueKey('dynamic-island-qr-scanner-portal'), + ); + expect(tester.getRect(portal), const Rect.fromLTWH(136.5, 11, 120, 36)); + + await tester.pump(const Duration(milliseconds: 460)); + expect(tester.getRect(portal).top, 11); + expect( + find.byKey(const ValueKey('dynamic-island-qr-scanner-close')), + findsNothing, + ); + + await tester.tapAt(const Offset(196.5, 700)); + await tester.pump(); + expect(scannerClosed, isFalse); + + await tester.pump(const Duration(milliseconds: 340)); + await tester.pumpAndSettle(); + expect(scannerClosed, isTrue); + expect(portal, findsNothing); + debugDefaultTargetPlatformOverride = null; + }, + ); + + testWidgets('tapping the scanner closes it without a separate control', ( + tester, + ) async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(_qrScannerPlatformChannel, (call) async { + return switch (call.method) { + 'usesDynamicIslandQrScannerPortal' => true, + 'setDynamicIslandScannerStatusBarHidden' => null, + _ => null, + }; + }); + await tester.binding.setSurfaceSize(const Size(393, 852)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + var scannerClosed = false; + await tester.pumpWidget( + MaterialApp( + builder: (context, child) => MediaQuery( + data: MediaQuery.of(context).copyWith(disableAnimations: true), + child: child!, + ), + home: Builder( + builder: (context) => TextButton( + onPressed: () async { + await showDynamicIslandPairingQrScanner(context); + scannerClosed = true; + }, + child: const Text('Open scanner'), + ), + ), + ), + ); + + await tester.tap(find.text('Open scanner')); + await tester.pump(); + + final portal = find.byKey( + const ValueKey('dynamic-island-qr-scanner-portal'), + ); + expect(tester.getRect(portal), const Rect.fromLTWH(15, 11, 363, 363)); + + await tester.tapAt(tester.getCenter(portal)); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(scannerClosed, isTrue); + expect(portal, findsNothing); + debugDefaultTargetPlatformOverride = null; + }); +} + +class _FakeMobileScannerPlatform extends MobileScannerPlatform { + final _barcodes = StreamController.broadcast(); + var _isDisposed = false; + + @override + Stream get barcodesStream => _barcodes.stream; + + @override + Stream get torchStateStream => + Stream.value(TorchState.unavailable); + + @override + Stream get zoomScaleStateStream => Stream.value(1); + + @override + Future start(StartOptions startOptions) async { + return const MobileScannerViewAttributes( + cameraDirection: CameraFacing.back, + currentTorchMode: TorchState.unavailable, + size: Size(200, 200), + numberOfCameras: 1, + ); + } + + @override + Widget buildCameraView() => const SizedBox.expand(); + + @override + Future stop() async {} + + @override + Future dispose() async { + if (_isDisposed) { + return; + } + _isDisposed = true; + await _barcodes.close(); + } +} From 264a56a2260ac87350bfe1f5d3ec3d89615eb47c Mon Sep 17 00:00:00 2001 From: Shani Singh Date: Sat, 25 Jul 2026 04:58:39 +0530 Subject: [PATCH 005/393] fix(audit): hash created_at at the precision Postgres stores (#2638) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #2637 — full analysis and reproduction there. ## Problem Audit entries are stamped and hashed with `Utc::now()` (nanoseconds), then stored in a `TIMESTAMPTZ` column (microseconds). `compute_hash` covers `created_at.to_rfc3339()`, and chrono emits 0/3/6/**9** fractional digits depending on the value — so the digest written at `service.rs:103` is computed over `…T12:00:00.123456789+00:00` while `verify_chain` recomputes over the `…T12:00:00.123456+00:00` that Postgres hands back. Every hash chain backed by a real database therefore fails verification at its first entry, on untampered data. That is not just a broken feature — it means a genuinely forged row is indistinguishable from the permanent baseline failure, so `HashMismatch` carries no signal. It is invisible in CI because all six chain tests are `#[ignore = "requires Postgres"]`, and the in-process `hash.rs` tests use a fixture timestamp of `2026-01-01T00:00:00Z` — zero sub-seconds, the one value where the bug cannot appear. ## Solution Reduce `created_at` to the stored precision *before* hashing, so the in-memory entry and the row are byte-identical: ```rust pub fn to_storage_precision(created_at: DateTime) -> DateTime { created_at.trunc_subsecs(6) } ``` `log_inner` is the only place that assigns `created_at` — every caller goes through `NewAuditEntry`, which carries no timestamp — so this is a single choke point. It is wrapped in a `log_timestamp()` helper purely so the invariant is assertable without a database. I chose truncation at the write path over the alternative (hashing a precision-independent encoding such as `timestamp_micros().to_be_bytes()`). Both fix the mismatch, but truncating keeps the existing hash preimage format and gives the stronger invariant: the `AuditEntry` returned from `log()` is now exactly what a later read returns. Truncation matches what actually happens on the wire — sqlx encodes `DateTime` as microseconds since the Postgres epoch, truncating — so the value hashed is the value stored. ## Validation Toolchain note: built on Windows with the `x86_64-pc-windows-gnu` toolchain (no MSVC linker locally). **Before**, against Postgres 17 with `migrations/*` applied: ``` $ cargo test -p buzz-audit --lib -- --ignored --test-threads=1 test service::tests::chain_links_within_one_community ... FAILED test service::tests::chains_are_independent_per_community ... FAILED test service::tests::community_chain_starts_at_seq_1_with_null_prev ... ok test service::tests::cross_community_row_does_not_verify ... ok test service::tests::verify_detects_tampering_within_a_community ... FAILED test service::tests::verify_empty_range_is_false ... ok test result: FAILED. 3 passed; 3 failed ``` with `HashMismatch { seq: 2 }` / `HashMismatch { seq: 1 }` on untampered chains. **After**, same database: ``` test result: ok. 6 passed; 0 failed ``` `verify_detects_tampering_within_a_community` is the one to look at: it asserts `HashMismatch` lands on the *tampered* entry's `seq`. It was failing because verification already blew up on an earlier untampered row — so the assertion proving tamper detection works had never actually been exercised. It passes now. Also: - `cargo test -p buzz-audit --lib` (no Postgres) — 12 passed, 0 failed. - `cargo clippy -p buzz-audit --all-targets -- -D warnings` — clean. - `cargo fmt -p buzz-audit -- --check` — clean. ## New tests Three in `hash.rs`, none needing Postgres: - `storage_precision_drops_sub_microsecond_digits` — the helper's contract, and that it is idempotent so a re-read value is unchanged. - `nanosecond_timestamps_cannot_survive_a_database_round_trip` — asserts the digests **differ**. This is the trap itself, written down so the next person changing the hash preimage sees why the precision reduction is load-bearing. - `storage_precision_timestamps_survive_a_database_round_trip` — the invariant the write path must hold. Plus `log_timestamp_carries_no_sub_microsecond_digits` in `service.rs`, deliberately **not** `#[ignore]`d, so a regression on the write path is caught by `just test-unit` instead of only by Postgres-gated tests that normally never run. ## Compatibility Rows written before this stay unverifiable — they always were — so there is no migration. An operator relying on an existing chain has to re-anchor. ## Relationship to #2620 #2620 proposes a shared `verify_entries` walk (anchoring, seq contiguity, tail-truncation detection) plus a `buzz-admin audit verify` command. Its Postgres-free unit tests build entries in memory and would pass regardless, but its `#[ignore]` Postgres tests and the operator command itself would fail on every real chain until this lands. Worth taking this first so that work has a verifiable baseline — the two changes don't overlap in code. --------- Signed-off-by: Shani Singh --- crates/buzz-audit/src/hash.rs | 97 +++++++++++++++++++++++++++++++- crates/buzz-audit/src/service.rs | 27 ++++++++- 2 files changed, 121 insertions(+), 3 deletions(-) diff --git a/crates/buzz-audit/src/hash.rs b/crates/buzz-audit/src/hash.rs index a272d4a024..8d6091a00c 100644 --- a/crates/buzz-audit/src/hash.rs +++ b/crates/buzz-audit/src/hash.rs @@ -1,3 +1,4 @@ +use chrono::{DateTime, SubsecRound, Utc}; use sha2::{Digest, Sha256}; use crate::entry::AuditEntry; @@ -7,12 +8,34 @@ use crate::error::AuditError; /// entry. Stored as `prev_hash = NULL`; hashed as all-zero bytes. pub const GENESIS_HASH: [u8; 32] = [0u8; 32]; +/// Reduce a timestamp to the precision the audit store round-trips. +/// +/// `audit_log.created_at` is `TIMESTAMPTZ`, which Postgres keeps at microsecond +/// resolution. [`compute_hash`] covers `created_at.to_rfc3339()`, and that +/// string's sub-second digit count follows the value (chrono emits 0, 3, 6 or 9 +/// digits), so a timestamp carrying nanoseconds hashes to a digest that can +/// never be recomputed from the stored row — the entry is written with one +/// preimage and verified against another. +/// +/// Every `created_at` must therefore pass through here *before* it is hashed +/// and stored, so the in-memory entry and the row are byte-identical. +pub fn to_storage_precision(created_at: DateTime) -> DateTime { + created_at.trunc_subsecs(6) +} + /// SHA-256 over the entry's identity, chain, and context fields. /// /// Field order is fixed — changing it invalidates all existing chains. The /// `community_id` is hashed first so chain identity carries the tenant: an entry /// cannot be lifted out of one community's chain and re-verified inside another. /// +/// `created_at` is normalized through [`to_storage_precision`] here rather than +/// hashed as given. Write paths truncate before storing so the row matches the +/// in-memory entry, but normalizing again at the single point that consumes the +/// value means no future caller can reintroduce the write/read preimage split +/// by forgetting to. Values already at storage precision are unaffected — +/// truncation is idempotent — so this does not change any digest. +/// /// `detail` is serialized via [`canonical_json`] (sorted keys) so the hash is /// stable across machines and Rust versions. A serialization failure is a hard /// error, never silently hashed as empty. @@ -21,7 +44,11 @@ pub fn compute_hash(entry: &AuditEntry) -> Result<[u8; 32], AuditError> { // Tenant binding: community_id leads the hash. hasher.update(entry.community_id.as_bytes()); hasher.update(entry.seq.to_be_bytes()); - hasher.update(entry.created_at.to_rfc3339().as_bytes()); + hasher.update( + to_storage_precision(entry.created_at) + .to_rfc3339() + .as_bytes(), + ); hasher.update(entry.action.as_str().as_bytes()); match &entry.actor_pubkey { Some(pk) => { @@ -111,6 +138,17 @@ mod tests { } } + /// A wall-clock instant carrying sub-microsecond digits, like `Utc::now()` + /// returns on Linux (`clock_gettime`, nanosecond resolution). + fn nanosecond_instant() -> chrono::DateTime { + chrono::DateTime::from_timestamp_nanos(1_700_000_000_123_456_789) + } + + /// What Postgres hands back for a `TIMESTAMPTZ`: microsecond resolution. + fn after_database_round_trip(ts: chrono::DateTime) -> chrono::DateTime { + ts.trunc_subsecs(6) + } + #[test] fn deterministic() { let entry = sample_entry(); @@ -118,6 +156,63 @@ mod tests { assert_eq!(compute_hash(&entry).unwrap().len(), 32); } + #[test] + fn storage_precision_drops_sub_microsecond_digits() { + let stored = to_storage_precision(nanosecond_instant()); + assert_eq!(stored.timestamp_subsec_nanos(), 123_456_000); + // Idempotent, so a stored value re-read from Postgres is unchanged. + assert_eq!(stored, after_database_round_trip(stored)); + } + + #[test] + fn rfc3339_sub_second_width_follows_the_value() { + // The underlying trap, pinned on the preimage rather than the digest: + // chrono emits 0/3/6/9 fractional digits depending on the value, so a + // nanosecond timestamp and its microsecond truncation are *different + // strings*. Hashing the untruncated value therefore produces a digest + // that cannot be recomputed from the stored row — which is what made + // every entry fail `verify_chain` with `HashMismatch`. + let ns = nanosecond_instant(); + assert_eq!(ns.to_rfc3339(), "2023-11-14T22:13:20.123456789+00:00"); + assert_eq!( + after_database_round_trip(ns).to_rfc3339(), + "2023-11-14T22:13:20.123456+00:00" + ); + assert_ne!(ns.to_rfc3339(), after_database_round_trip(ns).to_rfc3339()); + } + + #[test] + fn compute_hash_normalizes_sub_microsecond_timestamps() { + // The enforcement point: even handed an untruncated `created_at`, + // `compute_hash` digests the storage-precision value, so a write path + // that forgot to truncate cannot split the write/read preimage. + let ns = nanosecond_instant(); + let mut written = sample_entry(); + written.created_at = ns; + let mut read_back = sample_entry(); + read_back.created_at = after_database_round_trip(ns); + + assert_eq!( + compute_hash(&written).unwrap(), + compute_hash(&read_back).unwrap() + ); + } + + #[test] + fn storage_precision_timestamps_survive_a_database_round_trip() { + // The invariant the write path must hold: hash what will be stored, so + // recomputing from the row reproduces the digest. + let mut written = sample_entry(); + written.created_at = to_storage_precision(nanosecond_instant()); + let mut read_back = written.clone(); + read_back.created_at = after_database_round_trip(read_back.created_at); + + assert_eq!( + compute_hash(&written).unwrap(), + compute_hash(&read_back).unwrap() + ); + } + #[test] fn community_id_is_part_of_identity() { // The whole point: the same logical entry in two communities hashes diff --git a/crates/buzz-audit/src/service.rs b/crates/buzz-audit/src/service.rs index 913131a591..fa0e0fb443 100644 --- a/crates/buzz-audit/src/service.rs +++ b/crates/buzz-audit/src/service.rs @@ -10,9 +10,18 @@ use crate::{ action::AuditAction, entry::{AuditEntry, NewAuditEntry}, error::AuditError, - hash::compute_hash, + hash::{compute_hash, to_storage_precision}, }; +/// The `created_at` stamped on a new entry. +/// +/// Reduced to the precision Postgres round-trips before it is hashed — see +/// [`to_storage_precision`]. Split out from [`AuditService::log_inner`] so the +/// invariant is testable without a database. +fn log_timestamp() -> DateTime { + to_storage_precision(Utc::now()) +} + /// Per-community advisory lock key. Derived in Postgres from the community UUID /// so two communities never serialize each other's audit writes (which would be /// both a throughput bottleneck and a cross-tenant timing oracle). The lock is @@ -100,7 +109,7 @@ impl AuditService { }; let seq = prev_seq + 1; - let created_at: DateTime = Utc::now(); + let created_at: DateTime = log_timestamp(); let mut audit_entry = AuditEntry { community_id, @@ -251,6 +260,7 @@ mod tests { use super::*; use crate::action::AuditAction; use crate::entry::NewAuditEntry; + use chrono::SubsecRound; use std::sync::OnceLock; use tokio::sync::Mutex; use uuid::Uuid; @@ -268,6 +278,19 @@ mod tests { PgPool::connect(&url).await.ok() } + /// Runs without Postgres, so a regression here is caught by `just + /// test-unit` rather than only by the `#[ignore]` chain tests below. + #[test] + fn log_timestamp_carries_no_sub_microsecond_digits() { + let ts = log_timestamp(); + assert_eq!( + ts, + ts.trunc_subsecs(6), + "created_at is hashed and then stored in a TIMESTAMPTZ column; \ + sub-microsecond digits make every entry fail verify_chain" + ); + } + /// A `community_id` known to exist in `communities` (FK target). Inserts a /// throwaway community row with a unique host and returns its id. async fn make_community(pool: &PgPool) -> Uuid { From 8398468ec9347567467d2296246866868ea8c035 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sat, 25 Jul 2026 00:32:22 +0100 Subject: [PATCH 006/393] Refine the mobile message composer (#2730) ## Summary - replace the mobile composer sheet with a compact expanding capsule - add vertically stacked attachment actions and an inline camera preview - align suggestion, formatting, and send treatments with desktop ## Validation - `just mobile-check` - `just mobile-test` (541 passed, 1 skipped) --------- Signed-off-by: npub102wg7q285p64ch2fjvstmf2ntn2sz3c4u5hmwatalc76mhsuauysftjtfj <7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz> Co-authored-by: npub102wg7q285p64ch2fjvstmf2ntn2sz3c4u5hmwatalc76mhsuauysftjtfj <7a9c8f0147a0755c5d499320bda5535cd5014715e52fb7757dfe3dadde1cef09@buzz.block.builderlab.xyz> --- mobile/ios/Podfile.lock | 18 + mobile/ios/Runner/Info.plist | 2 +- .../channels/camera_capture_cleanup.dart | 21 + mobile/lib/features/channels/compose_bar.dart | 521 ++++++++++++------ .../channels/compose_bar/attachments.dart | 220 +++++++- .../channels/compose_bar/camera_preview.dart | 312 +++++++++++ .../compose_bar/formatting_toolbar.dart | 78 ++- .../markdown_editing_controller.dart | 249 +++++++++ .../channels/compose_bar/send_button.dart | 6 +- .../channels/compose_bar/suggestions.dart | 95 ++-- .../features/channels/message_content.dart | 72 ++- mobile/lib/shared/relay/media_auth.dart | 12 +- mobile/lib/shared/relay/media_upload.dart | 89 ++- mobile/pubspec.lock | 94 +++- mobile/pubspec.yaml | 4 + .../channels/camera_capture_cleanup_test.dart | 35 ++ .../channels/channel_detail_page_test.dart | 12 +- .../features/channels/compose_bar_test.dart | 276 +++++++++- .../channels/message_content_test.dart | 40 ++ .../test/shared/relay/media_upload_test.dart | 72 +++ 20 files changed, 1932 insertions(+), 296 deletions(-) create mode 100644 mobile/lib/features/channels/camera_capture_cleanup.dart create mode 100644 mobile/lib/features/channels/compose_bar/camera_preview.dart create mode 100644 mobile/lib/features/channels/compose_bar/markdown_editing_controller.dart create mode 100644 mobile/test/features/channels/camera_capture_cleanup_test.dart diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index ea62f66562..ba8c828d25 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -3,8 +3,12 @@ PODS: - Flutter - app_links (6.4.1): - Flutter + - camera_avfoundation (0.0.1): + - Flutter - connectivity_plus (0.0.1): - Flutter + - file_selector_ios (0.0.1): + - Flutter - Flutter (1.0.0) - flutter_secure_storage_darwin (10.0.0): - Flutter @@ -14,6 +18,8 @@ PODS: - mobile_scanner (7.0.0): - Flutter - FlutterMacOS + - open_filex (0.0.2): + - Flutter - package_info_plus (0.4.5): - Flutter - shared_preferences_foundation (0.0.1): @@ -28,11 +34,14 @@ PODS: DEPENDENCIES: - app_badge_plus (from `.symlinks/plugins/app_badge_plus/ios`) - app_links (from `.symlinks/plugins/app_links/ios`) + - camera_avfoundation (from `.symlinks/plugins/camera_avfoundation/ios`) - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) + - file_selector_ios (from `.symlinks/plugins/file_selector_ios/ios`) - Flutter (from `Flutter`) - flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - mobile_scanner (from `.symlinks/plugins/mobile_scanner/darwin`) + - open_filex (from `.symlinks/plugins/open_filex/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) @@ -43,8 +52,12 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/app_badge_plus/ios" app_links: :path: ".symlinks/plugins/app_links/ios" + camera_avfoundation: + :path: ".symlinks/plugins/camera_avfoundation/ios" connectivity_plus: :path: ".symlinks/plugins/connectivity_plus/ios" + file_selector_ios: + :path: ".symlinks/plugins/file_selector_ios/ios" Flutter: :path: Flutter flutter_secure_storage_darwin: @@ -53,6 +66,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/image_picker_ios/ios" mobile_scanner: :path: ".symlinks/plugins/mobile_scanner/darwin" + open_filex: + :path: ".symlinks/plugins/open_filex/ios" package_info_plus: :path: ".symlinks/plugins/package_info_plus/ios" shared_preferences_foundation: @@ -65,11 +80,14 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: app_badge_plus: 09939f19a075cc742cc155d8ed85e6d8601f0104 app_links: 3dbc685f76b1693c66a6d9dd1e9ab6f73d97dc0a + camera_avfoundation: 968a9a5323c79a99c166ad9d7866bfd2047b5a9b connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd + file_selector_ios: ec57ec07954363dd730b642e765e58f199bb621a Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23 image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326 mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93 + open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1 package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index 9e4f42cb89..4aa9fcb7d5 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -44,7 +44,7 @@ LSRequiresIPhoneOS NSCameraUsageDescription - Buzz needs camera access to scan QR codes for device pairing. + Buzz needs camera access so you can take photos to attach to messages and scan QR codes for device pairing. NSPhotoLibraryUsageDescription Buzz needs photo library access so you can attach images to messages. UIApplicationSceneManifest diff --git a/mobile/lib/features/channels/camera_capture_cleanup.dart b/mobile/lib/features/channels/camera_capture_cleanup.dart new file mode 100644 index 0000000000..6937218c9e --- /dev/null +++ b/mobile/lib/features/channels/camera_capture_cleanup.dart @@ -0,0 +1,21 @@ +import 'dart:io'; + +import 'package:image_picker/image_picker.dart'; + +Future processCapturedImage( + XFile image, + Future Function(XFile image) onCapture, +) async { + try { + await onCapture(image); + } finally { + final path = image.path; + if (path.isNotEmpty) { + try { + await File(path).delete(); + } on FileSystemException { + // The camera plugin may already have removed its temporary file. + } + } + } +} diff --git a/mobile/lib/features/channels/compose_bar.dart b/mobile/lib/features/channels/compose_bar.dart index 0285cda560..8711ff7b3a 100644 --- a/mobile/lib/features/channels/compose_bar.dart +++ b/mobile/lib/features/channels/compose_bar.dart @@ -1,7 +1,10 @@ +import 'dart:async'; import 'dart:collection'; +import 'package:camera/camera.dart' as camera; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/physics.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:image_picker/image_picker.dart'; @@ -16,6 +19,7 @@ import '../profile/user_cache_provider.dart'; import '../profile/user_profile.dart'; import '../custom_emoji/custom_emoji.dart'; import '../custom_emoji/custom_emoji_provider.dart'; +import 'camera_capture_cleanup.dart'; import 'channel.dart'; import 'channel_management_provider.dart'; import 'channels_provider.dart'; @@ -25,9 +29,11 @@ import 'mentions/mention_candidates_provider.dart'; import 'mentions/mention_ranking.dart'; part 'compose_bar/helpers.dart'; +part 'compose_bar/markdown_editing_controller.dart'; part 'compose_bar/suggestions.dart'; part 'compose_bar/formatting_toolbar.dart'; part 'compose_bar/attachments.dart'; +part 'compose_bar/camera_preview.dart'; part 'compose_bar/send_button.dart'; const _pastedImageMimeTypes = [ @@ -37,9 +43,9 @@ const _pastedImageMimeTypes = [ 'image/webp', ]; -/// Rich compose bar with @mention autocomplete, emoji picker, and a markdown -/// formatting toolbar. Used in both channel and thread views — the caller -/// provides an [onSend] callback that handles actual message submission. +/// Rich compose bar with @mention autocomplete and a markdown formatting +/// toolbar. Used in both channel and thread views — the caller provides an +/// [onSend] callback that handles actual message submission. typedef ComposeBarOnSend = Future Function( String content, @@ -69,8 +75,12 @@ class ComposeBar extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final controller = useTextEditingController(); + final controller = useMemoized(_MarkdownEditingController.new); + useEffect(() => controller.dispose, [controller]); final focusNode = useFocusNode(); + final isComposerExpanded = useState(false); + final showAttachments = useState(false); + final showCamera = useState(false); final isSending = useState(false); final showFormatting = useState(false); final attachments = useState>([]); @@ -80,11 +90,41 @@ class ComposeBar extends HookConsumerWidget { final hasAttachments = attachments.value.isNotEmpty; final hasPendingUploads = uploadingCount.value > 0; final customEmoji = ref.watch(customEmojiListProvider); + final reducedMotion = MediaQuery.disableAnimationsOf(context); + final composerExpansionController = useAnimationController( + initialValue: 0, + upperBound: 1.05, + ); + final composerExpansionValue = useAnimation(composerExpansionController); + final composerExpansionProgress = composerExpansionValue + .clamp(0.0, 1.0) + .toDouble(); final resolvedHint = hintText ?? (channelName.isNotEmpty ? 'Message #$channelName' : 'Message\u2026'); + useEffect(() { + final target = isComposerExpanded.value ? 1.0 : 0.0; + if (reducedMotion) { + composerExpansionController.value = target; + } else if ((composerExpansionController.value - target).abs() > 0.001) { + composerExpansionController.animateWith( + SpringSimulation( + SpringDescription.withDurationAndBounce( + duration: const Duration(milliseconds: 280), + bounce: 0.16, + ), + composerExpansionController.value, + target, + 0, + snapToEnd: true, + ), + ); + } + return null; + }, [isComposerExpanded.value, reducedMotion]); + useEffect(() { if (defaultTargetPlatform != TargetPlatform.iOS) return null; @@ -288,12 +328,28 @@ class ComposeBar extends HookConsumerWidget { // Insert `#` at the cursor to manually trigger channel mode. void triggerChannel() => _insertTriggerAtCursor(controller, focusNode, '#'); + // Insert a selected emoji at the cursor without replacing the draft. + void insertEmoji(String emoji) { + final text = controller.text; + final selection = controller.selection; + final cursor = selection.isValid + ? selection.baseOffset.clamp(0, text.length) + : text.length; + controller.value = TextEditingValue( + text: text.replaceRange(cursor, cursor, emoji), + selection: TextSelection.collapsed(offset: cursor + emoji.length), + ); + focusNode.requestFocus(); + } + void clearComposer() { controller.clear(); attachments.value = []; mentionMap.value.clear(); mentionQuery.value = null; channelQuery.value = null; + showAttachments.value = false; + showCamera.value = false; showFormatting.value = false; uploadError.value = null; focusNode.requestFocus(); @@ -490,21 +546,6 @@ class ComposeBar extends HookConsumerWidget { ); } - // Insert an emoji at the cursor. - void insertEmoji(String emoji) { - final text = controller.text; - final cursor = controller.selection.isValid - ? controller.selection.baseOffset - : text.length; - final before = text.substring(0, cursor); - final after = text.substring(cursor); - controller.text = '$before$emoji$after'; - controller.selection = TextSelection.collapsed( - offset: cursor + emoji.length, - ); - focusNode.requestFocus(); - } - // Wrap (or insert) markdown formatting around the current selection. void applyFormat(String prefix, [String? suffix]) { suffix ??= prefix; @@ -539,61 +580,150 @@ class ComposeBar extends HookConsumerWidget { // ----- Widget tree ---------------------------------------------------- - final hasSuggestions = - suggestions.isNotEmpty || channelSuggestions.isNotEmpty; + void chooseAttachment(Future Function() pick) { + showAttachments.value = false; + showCamera.value = false; + pickAndUpload(pick); + } - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Channel suggestions (above the compose chrome). - if (channelSuggestions.isNotEmpty) - _ChannelSuggestions( - suggestions: channelSuggestions, - onSelect: insertChannel, - ), + void toggleAttachments() { + if (showCamera.value) { + showCamera.value = false; + showAttachments.value = false; + return; + } + showCamera.value = false; + showAttachments.value = !showAttachments.value; + } - // Mention suggestions (above the compose chrome). - if (suggestions.isNotEmpty) - _MentionSuggestions( - suggestions: suggestions, - userCache: userCache, - currentPubkey: currentPubkey, - isDmChannel: isDmChannel, - onSelect: insertMention, - ), + void openCamera() { + focusNode.unfocus(); + showAttachments.value = false; + showCamera.value = true; + } - // Compose chrome — bottom-sheet style container. - Container( + final motionDuration = reducedMotion + ? Duration.zero + : const Duration(milliseconds: 180); + final suggestionOverlayController = useMemoized( + OverlayPortalController.new, + ); + + useEffect(() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) suggestionOverlayController.show(); + }); + return null; + }, [suggestionOverlayController]); + + void expandComposer() { + if (isComposerExpanded.value) return; + showAttachments.value = false; + showCamera.value = false; + isComposerExpanded.value = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.mounted) focusNode.requestFocus(); + }); + } + + final suggestionPanel = channelSuggestions.isNotEmpty + ? KeyedSubtree( + key: const ValueKey('channel-suggestions'), + child: _ChannelSuggestions( + suggestions: channelSuggestions, + onSelect: insertChannel, + ), + ) + : suggestions.isNotEmpty + ? KeyedSubtree( + key: const ValueKey('mention-suggestions'), + child: _MentionSuggestions( + suggestions: suggestions, + userCache: userCache, + currentPubkey: currentPubkey, + isDmChannel: isDmChannel, + onSelect: insertMention, + ), + ) + : const SizedBox.shrink(key: ValueKey('no-suggestions')); + final overlayPanel = showCamera.value + ? KeyedSubtree( + key: const ValueKey('camera-preview'), + child: _InlineCameraPreview( + onClose: () => showCamera.value = false, + onCapture: (image) async { + showCamera.value = false; + await pickAndUpload( + () => ref.read(mediaUploadServiceProvider).uploadImage(image), + ); + }, + ), + ) + : showAttachments.value + ? KeyedSubtree( + key: const ValueKey('attachment-menu'), + child: Align( + alignment: Alignment.bottomLeft, + heightFactor: 1, + child: _AttachmentMenu( + onCamera: openCamera, + onPhotos: () => chooseAttachment( + ref.read(mediaUploadServiceProvider).pickAndUploadImage, + ), + onVideo: () => chooseAttachment( + ref.read(mediaUploadServiceProvider).pickAndUploadVideo, + ), + onFiles: () => chooseAttachment( + ref.read(mediaUploadServiceProvider).pickAndUploadFile, + ), + ), + ), + ) + : suggestionPanel; + + // Suggestions and attachments live in the overlay so showing them cannot + // reflow the composer. Both stay anchored just above the capsule. + return Padding( + padding: EdgeInsets.only( + left: Grid.twelve, + right: Grid.twelve, + bottom: MediaQuery.viewPaddingOf(context).bottom + Grid.xxs, + ), + child: OverlayPortal.overlayChildLayoutBuilder( + controller: suggestionOverlayController, + overlayChildBuilder: (context, layoutInfo) { + final composerOrigin = MatrixUtils.transformPoint( + layoutInfo.childPaintTransform, + Offset.zero, + ); + return Positioned( + left: composerOrigin.dx, + bottom: layoutInfo.overlaySize.height - composerOrigin.dy, + width: layoutInfo.childSize.width, + child: ClipRect( + child: Padding( + padding: const EdgeInsets.only(bottom: Grid.xxs), + child: _SuggestionPanelMotion( + duration: motionDuration, + child: overlayPanel, + ), + ), + ), + ); + }, + child: Container( decoration: BoxDecoration( color: context.colors.surfaceContainerHighest, - borderRadius: !hasSuggestions - ? const BorderRadius.vertical( - top: Radius.circular(Radii.dialog), - ) - : BorderRadius.zero, - boxShadow: !hasSuggestions - ? [ - BoxShadow( - color: context.colors.shadow.withValues(alpha: 0.08), - blurRadius: 8, - offset: const Offset(0, -2), - ), - ] - : null, - ), - padding: EdgeInsets.only( - left: Grid.gutter, - right: Grid.gutter, - top: Grid.xs, - bottom: MediaQuery.viewPaddingOf(context).bottom + Grid.twelve, + borderRadius: BorderRadius.circular(Radii.dialog), + border: Border.all( + color: Colors.black.withValues(alpha: 0.04), + width: 1, + ), ), + padding: const EdgeInsets.all(Grid.xxs), child: Column( mainAxisSize: MainAxisSize.min, children: [ - // Formatting toolbar (toggled via Aa button). - if (showFormatting.value) - _FormattingToolbar(onFormat: applyFormat), - if (hasAttachments || hasPendingUploads) ...[ _AttachmentStrip( attachments: attachments.value, @@ -616,110 +746,185 @@ class ComposeBar extends HookConsumerWidget { const SizedBox(height: Grid.xxs), ], - // Row 1 — text input (full width, grows). - TextField( - controller: controller, - focusNode: focusNode, - textInputAction: TextInputAction.send, - contextMenuBuilder: buildContextMenu, - contentInsertionConfiguration: ContentInsertionConfiguration( - allowedMimeTypes: _pastedImageMimeTypes, - onContentInserted: uploadPastedImage, - ), - onSubmitted: (_) => send(), - minLines: 1, - maxLines: 5, - style: context.textTheme.bodyMedium, - decoration: InputDecoration( - hintText: resolvedHint, - hintStyle: context.textTheme.bodyMedium?.copyWith( - color: context.colors.onSurfaceVariant, + // Keep the default state out of the focus system entirely so + // restored native focus cannot expand a newly opened channel. + if (isComposerExpanded.value) + TextField( + controller: controller, + focusNode: focusNode, + textInputAction: TextInputAction.send, + contextMenuBuilder: buildContextMenu, + contentInsertionConfiguration: ContentInsertionConfiguration( + allowedMimeTypes: _pastedImageMimeTypes, + onContentInserted: uploadPastedImage, ), - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - contentPadding: const EdgeInsets.symmetric( - horizontal: Grid.half, - vertical: Grid.half, + onSubmitted: (_) => send(), + minLines: 1, + maxLines: 5, + style: context.textTheme.bodyLarge, + decoration: InputDecoration( + hintText: resolvedHint, + hintStyle: context.textTheme.bodyLarge?.copyWith( + color: context.colors.onSurfaceVariant, + ), + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + contentPadding: const EdgeInsets.symmetric( + horizontal: Grid.half, + vertical: Grid.half, + ), + isDense: true, ), - isDense: true, - ), - ), - - const SizedBox(height: Grid.xxs), - - // Row 2 — action buttons [paperclip, emoji, @, Aa] ... [send]. - Row( - children: [ - _ComposeAction( - icon: LucideIcons.paperclip, - onTap: () { - showModalBottomSheet( - context: context, - showDragHandle: true, - builder: (sheetContext) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: const Icon(LucideIcons.image), - title: const Text('Photo'), - onTap: () { - Navigator.of(sheetContext).pop(); - pickAndUpload( - ref - .read(mediaUploadServiceProvider) - .pickAndUploadImage, - ); - }, + ) + else + Row( + children: [ + _AttachmentTrigger( + open: showAttachments.value || showCamera.value, + onTap: toggleAttachments, + ), + const SizedBox(width: Grid.xxs), + Expanded( + child: Semantics( + button: true, + label: resolvedHint, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: expandComposer, + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: Grid.half, + ), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + resolvedHint, + style: context.textTheme.bodyLarge?.copyWith( + color: context.colors.onSurfaceVariant, + ), ), - ListTile( - leading: const Icon(LucideIcons.video), - title: const Text('Video'), - onTap: () { - Navigator.of(sheetContext).pop(); - pickAndUpload( - ref - .read(mediaUploadServiceProvider) - .pickAndUploadVideo, - ); - }, - ), - ], + ), ), ), - ); - }, - ), - _ComposeAction( - icon: LucideIcons.smilePlus, - onTap: () => showEmojiPicker( - context: context, - onSelect: insertEmoji, + ), + ), + ], + ), + + ClipRect( + child: Align( + alignment: Alignment.topCenter, + heightFactor: composerExpansionValue, + child: IgnorePointer( + ignoring: composerExpansionValue < 0.98, + child: Opacity( + opacity: composerExpansionProgress, + child: Transform.translate( + offset: Offset( + 0, + Grid.xxs * (1 - composerExpansionProgress), + ), + child: Column( + children: [ + const SizedBox(height: Grid.xxs), + Row( + children: [ + _AttachmentTrigger( + open: + showAttachments.value || + showCamera.value || + showFormatting.value, + onTap: () { + if (showFormatting.value) { + showFormatting.value = false; + } else { + toggleAttachments(); + } + }, + ), + const SizedBox(width: Grid.half), + Expanded( + child: AnimatedSwitcher( + duration: motionDuration, + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + layoutBuilder: + (currentChild, previousChildren) => + Stack( + alignment: Alignment.centerLeft, + children: [ + ...previousChildren, + ?currentChild, + ], + ), + child: showFormatting.value + ? _FormattingToolbar( + onFormat: applyFormat, + ) + : Row( + key: const ValueKey( + 'standard-actions', + ), + children: [ + _ComposeAction( + icon: LucideIcons.atSign, + onTap: () { + showAttachments.value = false; + showCamera.value = false; + triggerMention(); + }, + ), + _ComposeAction( + icon: LucideIcons.hash, + onTap: () { + showAttachments.value = false; + showCamera.value = false; + triggerChannel(); + }, + ), + _ComposeAction( + icon: LucideIcons.smilePlus, + onTap: () { + showAttachments.value = false; + showCamera.value = false; + showEmojiPicker( + context: context, + onSelect: insertEmoji, + ); + }, + ), + _ComposeAction( + icon: LucideIcons.aLargeSmall, + onTap: () { + showAttachments.value = false; + showCamera.value = false; + showFormatting.value = true; + }, + ), + const Spacer(), + _SendButton( + isDisabled: hasPendingUploads, + isSending: isSending.value, + onTap: send, + ), + ], + ), + ), + ), + ], + ), + ], + ), + ), ), ), - _ComposeAction( - icon: LucideIcons.atSign, - onTap: triggerMention, - ), - _ComposeAction(icon: LucideIcons.hash, onTap: triggerChannel), - _ComposeAction( - icon: LucideIcons.aLargeSmall, - active: showFormatting.value, - onTap: () => showFormatting.value = !showFormatting.value, - ), - const Spacer(), - _SendButton( - isDisabled: hasPendingUploads, - isSending: isSending.value, - onTap: send, - ), - ], + ), ), ], ), ), - ], + ), ); } } diff --git a/mobile/lib/features/channels/compose_bar/attachments.dart b/mobile/lib/features/channels/compose_bar/attachments.dart index a3313ba64c..1d342cd15c 100644 --- a/mobile/lib/features/channels/compose_bar/attachments.dart +++ b/mobile/lib/features/channels/compose_bar/attachments.dart @@ -23,6 +23,146 @@ class _ComposeDraftPayload { } } +class _AttachmentTrigger extends StatelessWidget { + final bool open; + final VoidCallback onTap; + + const _AttachmentTrigger({required this.open, required this.onTap}); + + @override + Widget build(BuildContext context) { + final duration = MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 180); + + return SizedBox.square( + dimension: 36, + child: DecoratedBox( + decoration: BoxDecoration( + color: context.colors.surface, + shape: BoxShape.circle, + border: Border.all( + color: Colors.black.withValues(alpha: 0.04), + width: 1, + ), + ), + child: IconButton( + tooltip: open ? 'Close attachments' : 'Add attachment', + onPressed: onTap, + padding: EdgeInsets.zero, + visualDensity: VisualDensity.compact, + icon: AnimatedRotation( + duration: duration, + curve: Curves.easeInOutCubic, + turns: open ? 0.125 : 0, + child: Icon( + LucideIcons.plus, + size: 20, + color: context.colors.onSurfaceVariant, + ), + ), + ), + ), + ); + } +} + +class _AttachmentMenu extends StatelessWidget { + final VoidCallback onCamera; + final VoidCallback onPhotos; + final VoidCallback onVideo; + final VoidCallback onFiles; + + const _AttachmentMenu({ + required this.onCamera, + required this.onPhotos, + required this.onVideo, + required this.onFiles, + }); + + @override + Widget build(BuildContext context) { + return Container( + width: 176, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: context.colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(Radii.dialog), + border: Border.all( + color: Colors.black.withValues(alpha: 0.04), + width: 1, + ), + ), + padding: const EdgeInsets.symmetric(vertical: Grid.xxs), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _AttachmentMenuItem( + icon: LucideIcons.camera, + label: 'Camera', + onTap: onCamera, + ), + _AttachmentMenuItem( + icon: LucideIcons.images, + label: 'Photos', + onTap: onPhotos, + ), + _AttachmentMenuItem( + icon: LucideIcons.video, + label: 'Video', + onTap: onVideo, + ), + _AttachmentMenuItem( + icon: LucideIcons.file, + label: 'Files', + onTap: onFiles, + ), + ], + ), + ); + } +} + +class _AttachmentMenuItem extends StatelessWidget { + final IconData icon; + final String label; + final VoidCallback onTap; + + const _AttachmentMenuItem({ + required this.icon, + required this.label, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 48, + child: Tooltip( + message: label, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: Grid.twelve), + child: Row( + children: [ + Icon(icon, size: 20, color: context.colors.onSurfaceVariant), + const SizedBox(width: Grid.xxs), + Text( + label, + style: context.textTheme.bodyLarge?.copyWith( + color: context.colors.onSurface, + ), + ), + ], + ), + ), + ), + ), + ); + } +} + List _withoutAttachment( List attachments, String url, @@ -53,25 +193,55 @@ class _AttachmentStrip extends StatelessWidget { height: thumbHeight, child: ListView.separated( scrollDirection: Axis.horizontal, - itemCount: attachments.length + uploadingCount, + itemCount: attachments.length + (uploadingCount > 0 ? 1 : 0), separatorBuilder: (_, _) => const SizedBox(width: Grid.half), itemBuilder: (context, index) { - if (index >= attachments.length) { - return Container( - width: thumbWidth, - decoration: BoxDecoration( - color: context.colors.surface, - borderRadius: BorderRadius.circular(Radii.md), - border: Border.all(color: context.colors.outlineVariant), - ), - child: const Center( - child: CircularProgressIndicator(strokeWidth: 2), + if (index == attachments.length) { + final label = uploadingCount == 1 + ? 'Uploading attachment…' + : 'Uploading $uploadingCount attachments…'; + return Semantics( + liveRegion: true, + label: label, + child: Container( + key: const ValueKey('compose-upload-progress'), + width: 128, + decoration: BoxDecoration( + color: context.colors.surface, + borderRadius: BorderRadius.circular(Radii.md), + border: Border.all(color: context.colors.outlineVariant), + ), + padding: const EdgeInsets.symmetric(horizontal: Grid.xxs), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox.square( + dimension: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: context.colors.primary, + ), + ), + const SizedBox(width: Grid.half), + Flexible( + child: Text( + label, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ), + ], + ), ), ); } final attachment = attachments[index]; final isVideo = attachment.type.startsWith('video/'); + final isImage = attachment.type.startsWith('image/'); final previewUrl = attachment.thumb ?? attachment.url; return Container( key: ValueKey('compose-attachment:${attachment.url}'), @@ -96,7 +266,8 @@ class _AttachmentStrip extends StatelessWidget { ), ), ) - : MediaImage( + : isImage + ? MediaImage( url: previewUrl, fit: BoxFit.cover, errorBuilder: (_, _, _) => ColoredBox( @@ -106,6 +277,31 @@ class _AttachmentStrip extends StatelessWidget { color: context.colors.onSurfaceVariant, ), ), + ) + : ColoredBox( + color: context.colors.surface, + child: Padding( + padding: const EdgeInsets.all(Grid.xxs), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + LucideIcons.file, + color: context.colors.onSurfaceVariant, + ), + const SizedBox(height: Grid.quarter), + Text( + attachment.filename ?? 'File', + maxLines: 2, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ], + ), + ), ), ), Positioned( diff --git a/mobile/lib/features/channels/compose_bar/camera_preview.dart b/mobile/lib/features/channels/compose_bar/camera_preview.dart new file mode 100644 index 0000000000..df16827ba5 --- /dev/null +++ b/mobile/lib/features/channels/compose_bar/camera_preview.dart @@ -0,0 +1,312 @@ +part of '../compose_bar.dart'; + +class _InlineCameraPreview extends HookConsumerWidget { + final Future Function(XFile image) onCapture; + final VoidCallback onClose; + + const _InlineCameraPreview({required this.onCapture, required this.onClose}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final controller = useState(null); + final controllerRef = useRef(null); + final isInitializing = useState(true); + final isCapturing = useState(false); + final error = useState(null); + + useEffect(() { + var disposed = false; + var generation = 0; + + Future disposeCurrent() async { + generation += 1; + final current = controllerRef.value; + controllerRef.value = null; + if (!disposed) controller.value = null; + await current?.dispose(); + } + + Future initialize() async { + final currentGeneration = ++generation; + if (!disposed) { + isInitializing.value = true; + error.value = null; + } + + camera.CameraController? next; + try { + final available = await camera.availableCameras(); + if (disposed || currentGeneration != generation) return; + if (available.isEmpty) { + throw camera.CameraException( + 'no-cameras', + 'No cameras are available on this device.', + ); + } + + final description = available.firstWhere( + (candidate) => + candidate.lensDirection == camera.CameraLensDirection.back, + orElse: () => available.first, + ); + next = camera.CameraController( + description, + camera.ResolutionPreset.high, + enableAudio: false, + ); + await next.initialize(); + + if (disposed || currentGeneration != generation) { + await next.dispose(); + return; + } + controllerRef.value = next; + controller.value = next; + isInitializing.value = false; + } catch (cameraError) { + await next?.dispose(); + if (!disposed && currentGeneration == generation) { + error.value = _cameraErrorMessage(cameraError); + isInitializing.value = false; + } + } + } + + final lifecycleListener = AppLifecycleListener( + onInactive: () => unawaited(disposeCurrent()), + onResume: () => unawaited(initialize()), + ); + unawaited(initialize()); + + return () { + disposed = true; + lifecycleListener.dispose(); + generation += 1; + final current = controllerRef.value; + controllerRef.value = null; + unawaited(current?.dispose() ?? Future.value()); + }; + }, const []); + + Future capture() async { + final activeController = controller.value; + if (activeController == null || + isCapturing.value || + activeController.value.isTakingPicture) { + return; + } + + isCapturing.value = true; + error.value = null; + try { + final image = await activeController.takePicture(); + if (context.mounted) { + await processCapturedImage(image, onCapture); + } else { + await processCapturedImage(image, (_) async {}); + } + } catch (captureError) { + if (context.mounted) { + error.value = _cameraErrorMessage(captureError); + } + } finally { + if (context.mounted) isCapturing.value = false; + } + } + + final activeController = controller.value; + return Container( + width: double.infinity, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(Radii.dialog), + ), + foregroundDecoration: BoxDecoration( + borderRadius: BorderRadius.circular(Radii.dialog), + border: Border.all( + color: Colors.black.withValues(alpha: 0.04), + width: 1, + ), + ), + child: AspectRatio( + aspectRatio: 4 / 3, + child: Stack( + fit: StackFit.expand, + children: [ + if (activeController case final initialized?) + _CameraFeed(controller: initialized) + else + _CameraPlaceholder( + isInitializing: isInitializing.value, + message: error.value, + ), + if (activeController != null) + Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: const EdgeInsets.all(Grid.twelve), + child: _CameraCaptureButton( + isPressed: isCapturing.value, + onTap: capture, + ), + ), + ), + Positioned( + top: Grid.xxs, + right: Grid.xxs, + child: _CameraCloseButton(onTap: onClose), + ), + ], + ), + ), + ); + } +} + +class _CameraFeed extends StatelessWidget { + final camera.CameraController controller; + + const _CameraFeed({required this.controller}); + + @override + Widget build(BuildContext context) { + final previewSize = controller.value.previewSize; + if (previewSize == null) return const ColoredBox(color: Colors.black); + + return ClipRect( + child: FittedBox( + fit: BoxFit.cover, + child: SizedBox( + width: previewSize.height, + height: previewSize.width, + child: camera.CameraPreview(controller), + ), + ), + ); + } +} + +class _CameraPlaceholder extends StatelessWidget { + final bool isInitializing; + final String? message; + + const _CameraPlaceholder({ + required this.isInitializing, + required this.message, + }); + + @override + Widget build(BuildContext context) { + return ColoredBox( + color: Colors.black, + child: Center( + child: Padding( + padding: const EdgeInsets.all(Grid.sm), + child: isInitializing + ? const CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ) + : Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + LucideIcons.cameraOff, + color: Colors.white, + size: 28, + ), + const SizedBox(height: Grid.xxs), + Text( + message ?? 'Camera isn’t available here.', + textAlign: TextAlign.center, + style: context.textTheme.bodyMedium?.copyWith( + color: Colors.white, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _CameraCaptureButton extends StatelessWidget { + final bool isPressed; + final VoidCallback onTap; + + const _CameraCaptureButton({required this.isPressed, required this.onTap}); + + @override + Widget build(BuildContext context) { + final duration = MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 100); + + return Semantics( + button: true, + label: 'Take photo', + child: GestureDetector( + onTap: isPressed ? null : onTap, + child: AnimatedScale( + scale: isPressed ? 0.92 : 1, + duration: duration, + curve: Curves.easeOutCubic, + child: Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.24), + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 3), + ), + padding: const EdgeInsets.all(Grid.half), + child: const DecoratedBox( + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + ), + ), + ), + ), + ); + } +} + +class _CameraCloseButton extends StatelessWidget { + final VoidCallback onTap; + + const _CameraCloseButton({required this.onTap}); + + @override + Widget build(BuildContext context) { + return SizedBox.square( + dimension: 36, + child: IconButton( + onPressed: onTap, + tooltip: 'Close camera', + padding: EdgeInsets.zero, + style: IconButton.styleFrom( + backgroundColor: Colors.black.withValues(alpha: 0.56), + foregroundColor: Colors.white, + ), + icon: const Icon(LucideIcons.x, size: 18), + ), + ); + } +} + +String _cameraErrorMessage(Object error) { + if (error is camera.CameraException) { + return switch (error.code) { + 'CameraAccessDenied' || + 'CameraAccessDeniedWithoutPrompt' || + 'CameraAccessRestricted' => 'Camera access is turned off for Buzz.', + 'no-cameras' => 'Camera isn’t available on this device.', + _ => 'Camera couldn’t start. Try again.', + }; + } + return 'Camera couldn’t start. Try again.'; +} diff --git a/mobile/lib/features/channels/compose_bar/formatting_toolbar.dart b/mobile/lib/features/channels/compose_bar/formatting_toolbar.dart index f08d947f1e..c9089a99cb 100644 --- a/mobile/lib/features/channels/compose_bar/formatting_toolbar.dart +++ b/mobile/lib/features/channels/compose_bar/formatting_toolbar.dart @@ -7,37 +7,36 @@ class _FormattingToolbar extends StatelessWidget { @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(bottom: Grid.half), - child: Row( - children: [ - _FormatButton( - icon: LucideIcons.bold, - tooltip: 'Bold', - onTap: () => onFormat('**'), - ), - _FormatButton( - icon: LucideIcons.italic, - tooltip: 'Italic', - onTap: () => onFormat('_'), - ), - _FormatButton( - icon: LucideIcons.strikethrough, - tooltip: 'Strikethrough', - onTap: () => onFormat('~~'), - ), - _FormatButton( - icon: LucideIcons.code, - tooltip: 'Code', - onTap: () => onFormat('`'), - ), - _FormatButton( - icon: LucideIcons.squareCode, - tooltip: 'Code block', - onTap: () => onFormat('```\n', '\n```'), - ), - ], - ), + return Row( + key: const ValueKey('formatting-actions'), + mainAxisSize: MainAxisSize.min, + children: [ + _FormatButton( + icon: LucideIcons.bold, + tooltip: 'Bold', + onTap: () => onFormat('**'), + ), + _FormatButton( + icon: LucideIcons.italic, + tooltip: 'Italic', + onTap: () => onFormat('_'), + ), + _FormatButton( + icon: LucideIcons.strikethrough, + tooltip: 'Strikethrough', + onTap: () => onFormat('~~'), + ), + _FormatButton( + icon: LucideIcons.code, + tooltip: 'Code', + onTap: () => onFormat('`'), + ), + _FormatButton( + icon: LucideIcons.squareCode, + tooltip: 'Code block', + onTap: () => onFormat('```\n', '\n```'), + ), + ], ); } } @@ -62,7 +61,7 @@ class _FormatButton extends StatelessWidget { onTap: onTap, child: Padding( padding: const EdgeInsets.all(Grid.xxs), - child: Icon(icon, size: 18, color: context.colors.onSurfaceVariant), + child: Icon(icon, size: 18, color: context.colors.primary), ), ), ); @@ -71,14 +70,9 @@ class _FormatButton extends StatelessWidget { class _ComposeAction extends StatelessWidget { final IconData icon; - final bool active; final VoidCallback onTap; - const _ComposeAction({ - required this.icon, - this.active = false, - required this.onTap, - }); + const _ComposeAction({required this.icon, required this.onTap}); @override Widget build(BuildContext context) { @@ -87,13 +81,7 @@ class _ComposeAction extends StatelessWidget { height: 36, child: IconButton( onPressed: onTap, - icon: Icon( - icon, - size: 20, - color: active - ? context.colors.primary - : context.colors.onSurfaceVariant, - ), + icon: Icon(icon, size: 20, color: context.colors.onSurfaceVariant), padding: EdgeInsets.zero, visualDensity: VisualDensity.compact, ), diff --git a/mobile/lib/features/channels/compose_bar/markdown_editing_controller.dart b/mobile/lib/features/channels/compose_bar/markdown_editing_controller.dart new file mode 100644 index 0000000000..29e22a8ca9 --- /dev/null +++ b/mobile/lib/features/channels/compose_bar/markdown_editing_controller.dart @@ -0,0 +1,249 @@ +part of '../compose_bar.dart'; + +enum _MarkdownStyle { codeBlock, inlineCode, bold, italic, strikethrough } + +class _MarkdownRule { + final RegExp pattern; + final _MarkdownStyle style; + final bool parsesNestedFormatting; + + _MarkdownRule( + String pattern, + this.style, { + this.parsesNestedFormatting = true, + }) : pattern = RegExp(pattern); +} + +class _MarkdownEditingController extends TextEditingController { + static final _rules = [ + _MarkdownRule( + r'```(?:\r?\n)?([\s\S]*?)(?:\r?\n)?```', + _MarkdownStyle.codeBlock, + parsesNestedFormatting: false, + ), + _MarkdownRule( + r'`([^`\n]*?)`', + _MarkdownStyle.inlineCode, + parsesNestedFormatting: false, + ), + _MarkdownRule(r'\*\*([^\n]*?)\*\*', _MarkdownStyle.bold), + _MarkdownRule(r'~~([^\n]*?)~~', _MarkdownStyle.strikethrough), + _MarkdownRule(r'_([^_\n]*?)_', _MarkdownStyle.italic), + ]; + + @override + TextSpan buildTextSpan({ + required BuildContext context, + TextStyle? style, + required bool withComposing, + }) { + final baseStyle = style ?? const TextStyle(); + final composingRange = + withComposing && value.composing.isValid && !value.composing.isCollapsed + ? value.composing + : TextRange.empty; + return TextSpan( + style: baseStyle, + children: _buildMarkdownSpans( + context, + text, + baseStyle, + sourceOffset: 0, + composingRange: composingRange, + ), + ); + } + + List _buildMarkdownSpans( + BuildContext context, + String source, + TextStyle inheritedStyle, { + required int sourceOffset, + required TextRange composingRange, + }) { + final spans = []; + var offset = 0; + + while (offset < source.length) { + final tail = source.substring(offset); + _MarkdownRule? nextRule; + RegExpMatch? nextMatch; + + for (final rule in _rules) { + final match = rule.pattern.firstMatch(tail); + if (match == null) continue; + if (nextMatch == null || match.start < nextMatch.start) { + nextRule = rule; + nextMatch = match; + } + } + + if (nextRule == null || nextMatch == null) { + spans.addAll( + _buildTextSpans( + source.substring(offset), + inheritedStyle, + sourceOffset + offset, + composingRange, + ), + ); + break; + } + + if (nextMatch.start > 0) { + spans.addAll( + _buildTextSpans( + tail.substring(0, nextMatch.start), + inheritedStyle, + sourceOffset + offset, + composingRange, + ), + ); + } + + final fullMatch = nextMatch.group(0)!; + final content = nextMatch.group(1)!; + final (contentStart, contentEnd) = _contentBounds( + fullMatch, + nextRule.style, + ); + final contentStyle = _styleFor(context, inheritedStyle, nextRule.style); + final matchOffset = sourceOffset + offset + nextMatch.start; + + spans.add( + TextSpan( + text: fullMatch.substring(0, contentStart), + style: _hiddenSyntaxStyle(inheritedStyle), + ), + ); + if (nextRule.parsesNestedFormatting) { + spans.addAll( + _buildMarkdownSpans( + context, + content, + contentStyle, + sourceOffset: matchOffset + contentStart, + composingRange: composingRange, + ), + ); + } else { + spans.addAll( + _buildTextSpans( + content, + contentStyle, + matchOffset + contentStart, + composingRange, + ), + ); + } + spans.add( + TextSpan( + text: fullMatch.substring(contentEnd), + style: _hiddenSyntaxStyle(inheritedStyle), + ), + ); + + offset += nextMatch.end; + } + + return spans; + } + + List _buildTextSpans( + String source, + TextStyle style, + int sourceOffset, + TextRange composingRange, + ) { + if (source.isEmpty) return const []; + final localStart = (composingRange.start - sourceOffset) + .clamp(0, source.length) + .toInt(); + final localEnd = (composingRange.end - sourceOffset) + .clamp(0, source.length) + .toInt(); + if (!composingRange.isValid || + composingRange.isCollapsed || + localStart >= localEnd) { + return [TextSpan(text: source, style: style)]; + } + + final composingDecorations = [ + if (style.decoration != null && style.decoration != TextDecoration.none) + style.decoration!, + TextDecoration.underline, + ]; + final composingStyle = style.copyWith( + decoration: TextDecoration.combine(composingDecorations), + ); + return [ + if (localStart > 0) + TextSpan(text: source.substring(0, localStart), style: style), + TextSpan( + text: source.substring(localStart, localEnd), + style: composingStyle, + ), + if (localEnd < source.length) + TextSpan(text: source.substring(localEnd), style: style), + ]; + } + + (int, int) _contentBounds(String fullMatch, _MarkdownStyle markdownStyle) { + final delimiterLength = switch (markdownStyle) { + _MarkdownStyle.bold || _MarkdownStyle.strikethrough => 2, + _MarkdownStyle.italic || _MarkdownStyle.inlineCode => 1, + _MarkdownStyle.codeBlock => 3, + }; + if (markdownStyle != _MarkdownStyle.codeBlock) { + return (delimiterLength, fullMatch.length - delimiterLength); + } + + final openingLength = fullMatch.startsWith('```\r\n') + ? 5 + : fullMatch.startsWith('```\n') + ? 4 + : delimiterLength; + final closingLength = fullMatch.endsWith('\r\n```') + ? 5 + : fullMatch.endsWith('\n```') + ? 4 + : delimiterLength; + return (openingLength, fullMatch.length - closingLength); + } + + TextStyle _styleFor( + BuildContext context, + TextStyle inheritedStyle, + _MarkdownStyle markdownStyle, + ) { + return switch (markdownStyle) { + _MarkdownStyle.bold => inheritedStyle.merge( + const TextStyle(fontWeight: FontWeight.w700), + ), + _MarkdownStyle.italic => inheritedStyle.merge( + const TextStyle(fontStyle: FontStyle.italic), + ), + _MarkdownStyle.strikethrough => inheritedStyle.merge( + const TextStyle(decoration: TextDecoration.lineThrough), + ), + _MarkdownStyle.inlineCode || + _MarkdownStyle.codeBlock => inheritedStyle.merge( + TextStyle( + fontFamily: 'GeistMono', + color: context.colors.onSurface, + backgroundColor: context.colors.surface, + ), + ), + }; + } + + TextStyle _hiddenSyntaxStyle(TextStyle inheritedStyle) { + return inheritedStyle.copyWith( + color: Colors.transparent, + fontSize: 0.01, + height: 0.01, + letterSpacing: 0, + decoration: TextDecoration.none, + ); + } +} diff --git a/mobile/lib/features/channels/compose_bar/send_button.dart b/mobile/lib/features/channels/compose_bar/send_button.dart index cc32894d63..45bd1df9c2 100644 --- a/mobile/lib/features/channels/compose_bar/send_button.dart +++ b/mobile/lib/features/channels/compose_bar/send_button.dart @@ -23,9 +23,7 @@ class _SendButton extends StatelessWidget { disabledBackgroundColor: context.colors.primary.withValues( alpha: 0.5, ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(Radii.md), - ), + shape: const CircleBorder(), ), padding: EdgeInsets.zero, icon: isSending @@ -38,7 +36,7 @@ class _SendButton extends StatelessWidget { ), ) : Icon( - LucideIcons.sendHorizontal, + LucideIcons.arrowUp, size: 18, color: context.colors.onPrimary, ), diff --git a/mobile/lib/features/channels/compose_bar/suggestions.dart b/mobile/lib/features/channels/compose_bar/suggestions.dart index b7958138a1..f1deefcdaf 100644 --- a/mobile/lib/features/channels/compose_bar/suggestions.dart +++ b/mobile/lib/features/channels/compose_bar/suggestions.dart @@ -1,5 +1,48 @@ part of '../compose_bar.dart'; +class _SuggestionPanelMotion extends StatelessWidget { + final Duration duration; + final Widget child; + + const _SuggestionPanelMotion({required this.duration, required this.child}); + + @override + Widget build(BuildContext context) { + return AnimatedSwitcher( + duration: duration, + reverseDuration: duration, + layoutBuilder: (currentChild, previousChildren) => Stack( + alignment: Alignment.bottomLeft, + clipBehavior: Clip.none, + children: [...previousChildren, ?currentChild], + ), + transitionBuilder: (child, animation) { + final curvedAnimation = CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + ); + + return AnimatedBuilder( + animation: curvedAnimation, + child: child, + builder: (context, child) => IgnorePointer( + ignoring: animation.status == AnimationStatus.reverse, + child: Opacity( + opacity: curvedAnimation.value, + child: Transform.translate( + offset: Offset(0, Grid.xs * (1 - curvedAnimation.value)), + child: child, + ), + ), + ), + ); + }, + child: child, + ); + } +} + class _MentionSuggestions extends StatelessWidget { final List suggestions; final Map userCache; @@ -22,16 +65,11 @@ class _MentionSuggestions extends StatelessWidget { clipBehavior: Clip.hardEdge, decoration: BoxDecoration( color: context.colors.surfaceContainerHighest, - borderRadius: const BorderRadius.vertical( - top: Radius.circular(Radii.dialog), + borderRadius: BorderRadius.circular(Radii.dialog), + border: Border.all( + color: Colors.black.withValues(alpha: 0.04), + width: 1, ), - boxShadow: [ - BoxShadow( - color: context.colors.shadow.withValues(alpha: 0.08), - blurRadius: 8, - offset: const Offset(0, -2), - ), - ], ), child: ListView.separated( shrinkWrap: true, @@ -49,16 +87,17 @@ class _MentionSuggestions extends StatelessWidget { visualDensity: VisualDensity.compact, leading: AvatarImage( imageUrl: avatarUrl, - radius: 14, + radius: 18, backgroundColor: context.colors.primaryContainer, fallback: Text( name[0].toUpperCase(), - style: context.textTheme.labelSmall?.copyWith( + style: context.textTheme.labelMedium?.copyWith( color: context.colors.onPrimaryContainer, + fontWeight: FontWeight.w600, ), ), ), - title: Text(name, style: context.textTheme.bodyMedium), + title: Text(name, style: context.textTheme.titleSmall), subtitle: _MentionSuggestionInfo.build( context, candidate: candidate, @@ -176,16 +215,11 @@ class _ChannelSuggestions extends StatelessWidget { clipBehavior: Clip.hardEdge, decoration: BoxDecoration( color: context.colors.surfaceContainerHighest, - borderRadius: const BorderRadius.vertical( - top: Radius.circular(Radii.dialog), + borderRadius: BorderRadius.circular(Radii.dialog), + border: Border.all( + color: Colors.black.withValues(alpha: 0.04), + width: 1, ), - boxShadow: [ - BoxShadow( - color: context.colors.shadow.withValues(alpha: 0.08), - blurRadius: 8, - offset: const Offset(0, -2), - ), - ], ), child: ListView.separated( shrinkWrap: true, @@ -197,21 +231,16 @@ class _ChannelSuggestions extends StatelessWidget { return ListTile( dense: true, visualDensity: VisualDensity.compact, - leading: Icon( - channel.isForum ? LucideIcons.messageSquare : LucideIcons.hash, - size: 18, - color: context.colors.onSurfaceVariant, - ), - title: Text( - '#${channel.name}', - style: context.textTheme.bodyMedium, - ), - trailing: Text( - channel.channelType, - style: context.textTheme.labelSmall?.copyWith( + horizontalTitleGap: 0, + leading: SizedBox.square( + dimension: 36, + child: Icon( + LucideIcons.hash, + size: 20, color: context.colors.onSurfaceVariant, ), ), + title: Text(channel.name, style: context.textTheme.bodyLarge), onTap: () => onSelect(channel), ); }, diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index c55494d67f..1dc892acfd 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -1,3 +1,4 @@ +import 'dart:io'; import 'dart:math' as math; import 'package:flutter/material.dart'; @@ -6,6 +7,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:gpt_markdown/gpt_markdown.dart'; import 'package:gpt_markdown/custom_widgets/markdown_config.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:open_filex/open_filex.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:url_launcher/url_launcher.dart'; import '../../shared/clipboard_utils.dart'; @@ -21,6 +24,47 @@ import 'message_media.dart'; const _messageMediaMaxInlineWidth = 320.0; const _messageMediaMaxImageHeight = 240.0; +typedef OpenDownloadedFile = + Future Function( + String url, + Map headers, + String filename, + ); + +final openDownloadedFileProvider = Provider((ref) { + final client = ref.watch(mediaHttpClientProvider); + return (url, headers, filename) async { + final response = await client.get(Uri.parse(url), headers: headers); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw HttpException( + 'Attachment download failed (${response.statusCode})', + uri: Uri.parse(url), + ); + } + + final directory = await getTemporaryDirectory(); + final safeName = _safeDownloadedFilename(filename); + final file = File( + '${directory.path}${Platform.pathSeparator}' + '${DateTime.now().microsecondsSinceEpoch}-$safeName', + ); + await file.writeAsBytes(response.bodyBytes, flush: true); + final result = await OpenFilex.open(file.path); + if (result.type != ResultType.done) { + throw FileSystemException(result.message, file.path); + } + }; +}); + +String _safeDownloadedFilename(String filename) { + final safe = filename + .split(RegExp(r'[/\\]')) + .last + .replaceAll(RegExp(r'[\x00-\x1F\x7F]'), '') + .trim(); + return safe.isEmpty ? 'attachment' : safe; +} + /// Renders message content with markdown formatting, @mentions, #channel links, /// and media-aware markdown images/videos. class MessageContent extends HookConsumerWidget { @@ -151,7 +195,7 @@ class MessageContent extends HookConsumerWidget { codeBuilder: (context, name, code, closed) => _MessageCodeBlock(name: name, code: code), linkBuilder: (context, linkText, url, linkStyle) => - _buildLink(context, linkText, url, linkStyle, style), + _buildLink(context, ref, linkText, url, linkStyle, style), imageBuilder: (context, imageUrl) => _buildMedia(context, imageUrl, imetaByUrl[imageUrl]), maxLines: maxLines, @@ -182,6 +226,7 @@ class MessageContent extends HookConsumerWidget { Widget _buildLink( BuildContext context, + WidgetRef ref, InlineSpan linkText, String url, TextStyle linkStyle, @@ -198,10 +243,29 @@ class MessageContent extends HookConsumerWidget { final baseStyle = fallbackStyle ?? linkStyle; return GestureDetector( - onTap: () { + onTap: () async { final uri = Uri.tryParse(url); - if (uri != null && (uri.scheme == 'http' || uri.scheme == 'https')) { - launchUrl(uri, mode: LaunchMode.externalApplication); + if (uri == null || (uri.scheme != 'http' && uri.scheme != 'https')) { + return; + } + + final auth = ref.read(mediaGetAuthServiceProvider); + if (!auth.isRelayMediaUrl(url)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + return; + } + + try { + await ref.read(openDownloadedFileProvider)( + url, + auth.headersFor(url), + text, + ); + } catch (_) { + if (!context.mounted) return; + ScaffoldMessenger.maybeOf(context)?.showSnackBar( + const SnackBar(content: Text('Could not open attachment')), + ); } }, child: Text( diff --git a/mobile/lib/shared/relay/media_auth.dart b/mobile/lib/shared/relay/media_auth.dart index 3982fb48d1..b21eeca36d 100644 --- a/mobile/lib/shared/relay/media_auth.dart +++ b/mobile/lib/shared/relay/media_auth.dart @@ -40,13 +40,17 @@ class MediaGetAuthService { _nsec = nsec, _now = now ?? DateTime.now; + bool isRelayMediaUrl(String url) { + final uri = Uri.tryParse(url); + final relayUri = Uri.tryParse(_baseUrl); + if (uri == null || relayUri == null) return false; + return _isRelayMediaUrl(uri, relayUri); + } + Map headersFor(String url) { final nsec = _nsec; if (nsec == null || nsec.isEmpty) return const {}; - final uri = Uri.tryParse(url); - final relayUri = Uri.tryParse(_baseUrl); - if (uri == null || relayUri == null) return const {}; - if (!_isRelayMediaUrl(uri, relayUri)) return const {}; + if (!isRelayMediaUrl(url)) return const {}; final cached = _cachedHeaders; final refreshAt = _refreshAt; diff --git a/mobile/lib/shared/relay/media_upload.dart b/mobile/lib/shared/relay/media_upload.dart index b868fc2845..93c53e9743 100644 --- a/mobile/lib/shared/relay/media_upload.dart +++ b/mobile/lib/shared/relay/media_upload.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:io'; +import 'package:file_selector/file_selector.dart' as file_selector; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -46,10 +47,12 @@ const _allowedImageMimeTypes = { }; const _allowedVideoMimeTypes = {'video/mp4'}; const _maxVideoSizeBytes = 100 * 1024 * 1024; // 100MB +const _maxFileSizeBytes = 100 * 1024 * 1024; // 100MB const _mediaPolicyUploadMessage = "We couldn't prepare this image for upload."; typedef PickGalleryImage = Future Function(); typedef PickGalleryVideo = Future Function(); +typedef PickAttachmentFile = Future Function(); typedef SanitizeImageBytes = Future Function(Uint8List bytes, String mimeType); typedef TranscodeImageToJpeg = Future Function(Uint8List bytes); @@ -83,6 +86,7 @@ class BlobDescriptor { final String? thumb; final double? duration; final String? image; + final String? filename; const BlobDescriptor({ required this.url, @@ -95,6 +99,7 @@ class BlobDescriptor { this.thumb, this.duration, this.image, + this.filename, }); factory BlobDescriptor.fromJson(Map json) => BlobDescriptor( @@ -108,6 +113,21 @@ class BlobDescriptor { thumb: json['thumb'] as String?, duration: (json['duration'] as num?)?.toDouble(), image: json['image'] as String?, + filename: json['filename'] as String?, + ); + + BlobDescriptor withFilename(String value) => BlobDescriptor( + url: url, + sha256: sha256, + size: size, + type: type, + uploaded: uploaded, + dim: dim, + blurhash: blurhash, + thumb: thumb, + duration: duration, + image: image, + filename: value, ); List toImetaTag() => [ @@ -121,10 +141,18 @@ class BlobDescriptor { if (thumb != null) 'thumb $thumb', if (duration != null) 'duration $duration', if (image != null) 'image $image', + if (filename != null) 'filename $filename', ]; - String toMarkdownImage() => - type.startsWith('video/') ? '![video]($url)' : '![image]($url)'; + String toMarkdownImage() { + if (type.startsWith('video/')) return '![video]($url)'; + if (type.startsWith('image/')) return '![image]($url)'; + final label = (filename ?? 'file').replaceAllMapped( + RegExp(r'[\\\[\]]'), + (match) => '\\${match[0]}', + ); + return '[$label]($url)'; + } } class MediaUploadService { @@ -132,6 +160,7 @@ class MediaUploadService { final String? _nsec; final PickGalleryImage _pickGalleryImage; final PickGalleryVideo _pickGalleryVideo; + final PickAttachmentFile? _pickAttachmentFile; final SanitizeImageBytes _sanitizeImageBytes; final TranscodeImageToJpeg _transcodeImageToJpeg; final TranscodeVideoToMp4 _transcodeVideoToMp4; @@ -145,6 +174,7 @@ class MediaUploadService { required String? nsec, required PickGalleryImage pickGalleryImage, required PickGalleryVideo pickGalleryVideo, + PickAttachmentFile? pickAttachmentFile, SanitizeImageBytes? sanitizeImageBytes, TranscodeImageToJpeg? transcodeImageToJpeg, TranscodeVideoToMp4? transcodeVideoToMp4, @@ -155,6 +185,7 @@ class MediaUploadService { _nsec = nsec, _pickGalleryImage = pickGalleryImage, _pickGalleryVideo = pickGalleryVideo, + _pickAttachmentFile = pickAttachmentFile, _sanitizeImageBytes = sanitizeImageBytes ?? _sanitizePickedImageBytes, _transcodeImageToJpeg = transcodeImageToJpeg ?? _transcodePickedImageToJpeg, @@ -234,6 +265,32 @@ class MediaUploadService { } } + Future pickAndUploadFile() async { + final pickAttachmentFile = _pickAttachmentFile; + if (pickAttachmentFile == null) { + throw Exception("File attachments aren't available on this device."); + } + final pickedFile = await pickAttachmentFile(); + if (pickedFile == null) return null; + + final length = await pickedFile.length(); + if (length == 0) { + throw Exception('File is empty.'); + } + if (length > _maxFileSizeBytes) { + throw Exception( + 'File is too large (${(length / 1024 / 1024).toStringAsFixed(0)}MB). Maximum is 100MB.', + ); + } + final bytes = await pickedFile.readAsBytes(); + final descriptor = await _uploadPreparedBytes( + bytes, + mimeType: 'application/octet-stream', + allowGenericFile: true, + ); + return descriptor.withFilename(_safeAttachmentFilename(pickedFile.name)); + } + Future uploadBytes( Uint8List bytes, { required String mimeType, @@ -253,8 +310,10 @@ class MediaUploadService { Future _uploadPreparedBytes( Uint8List bytes, { required String mimeType, + bool allowGenericFile = false, }) async { - if (!_allowedImageMimeTypes.contains(mimeType) && + if (!allowGenericFile && + !_allowedImageMimeTypes.contains(mimeType) && !_allowedVideoMimeTypes.contains(mimeType)) { throw Exception('unsupported file type: $mimeType'); } @@ -421,6 +480,29 @@ class MediaUploadService { } } +String _safeAttachmentFilename(String filename) { + final segments = filename.split(RegExp(r'[/\\]')); + final basename = segments.isEmpty ? '' : segments.last; + final sanitized = StringBuffer(); + var byteLength = 0; + + for (final rune in basename.runes) { + if ((rune >= 0 && rune <= 0x1f) || (rune >= 0x7f && rune <= 0x9f)) { + continue; + } + + final character = String.fromCharCode(rune); + final characterByteLength = utf8.encode(character).length; + if (byteLength + characterByteLength > 255) break; + + sanitized.write(character); + byteLength += characterByteLength; + } + + final safeBasename = sanitized.toString().trim(); + return safeBasename.isEmpty ? 'file' : safeBasename; +} + String _sha256Hex(Uint8List bytes) { final digest = SHA256Digest().process(bytes); return digest.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(); @@ -687,6 +769,7 @@ final mediaUploadServiceProvider = Provider((ref) { requestFullMetadata: false, ), pickGalleryVideo: () => picker.pickVideo(source: ImageSource.gallery), + pickAttachmentFile: file_selector.openFile, ); ref.onDispose(service.dispose); return service; diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index dc062b8628..55185fc9c6 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -129,6 +129,46 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.5" + camera: + dependency: "direct main" + description: + name: camera + sha256: "558230d6ce6ccea856b32d390db7e7b557adf4d9320aa614481bd3f2f608953f" + url: "https://pub.dev" + source: hosted + version: "0.12.0+2" + camera_android_camerax: + dependency: transitive + description: + name: camera_android_camerax + sha256: b5064cf25a2787d122d0bf12e77c7b1033a2b983d0730e3091f770ee376efde5 + url: "https://pub.dev" + source: hosted + version: "0.7.2" + camera_avfoundation: + dependency: transitive + description: + name: camera_avfoundation + sha256: "866e9cd8370f8055d005c0413937a52dc1d7a472687f0ee3ce02392955aababa" + url: "https://pub.dev" + source: hosted + version: "0.10.2" + camera_platform_interface: + dependency: transitive + description: + name: camera_platform_interface + sha256: "4524ca6eb4176b066864036ad4fe02c3e4863e63b77eadc21a5bf56824f43498" + url: "https://pub.dev" + source: hosted + version: "2.13.1" + camera_web: + dependency: transitive + description: + name: camera_web + sha256: "1245a480a113437f8d46d19c0fb90cea9db921436d9cf2ba5fb11854a1312693" + url: "https://pub.dev" + source: hosted + version: "0.3.5+4" characters: dependency: transitive description: @@ -329,6 +369,30 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_selector: + dependency: "direct main" + description: + name: file_selector + sha256: bd15e43e9268db636b53eeaca9f56324d1622af30e5c34d6e267649758c84d9a + url: "https://pub.dev" + source: hosted + version: "1.1.0" + file_selector_android: + dependency: transitive + description: + name: file_selector_android + sha256: "89243030ea4b3463fb402b44d5eeacc4ccb1c46a88870cb2a5080d693200c1ed" + url: "https://pub.dev" + source: hosted + version: "0.5.2+6" + file_selector_ios: + dependency: transitive + description: + name: file_selector_ios + sha256: e2ecf2885c121691ce13b60db3508f53c01f869fb6e8dc5c1cfa771e4c46aeca + url: "https://pub.dev" + source: hosted + version: "0.5.3+5" file_selector_linux: dependency: transitive description: @@ -353,6 +417,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.7.0" + file_selector_web: + dependency: transitive + description: + name: file_selector_web + sha256: "73181fbc5257776d8ecaa6a94ab3c8e920ad143b9132a6d984a9271dfc6928d3" + url: "https://pub.dev" + source: hosted + version: "0.9.5" file_selector_windows: dependency: transitive description: @@ -824,6 +896,14 @@ packages: url: "https://pub.dev" source: hosted version: "9.3.0" + open_filex: + dependency: "direct main" + description: + name: open_filex + sha256: "9976da61b6a72302cf3b1efbce259200cd40232643a467aac7370addf94d6900" + url: "https://pub.dev" + source: hosted + version: "4.7.0" package_config: dependency: transitive description: @@ -865,13 +945,13 @@ packages: source: hosted version: "1.1.0" path_provider: - dependency: transitive + dependency: "direct main" description: name: path_provider - sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 url: "https://pub.dev" source: hosted - version: "2.1.5" + version: "2.1.6" path_provider_android: dependency: transitive description: @@ -1165,6 +1245,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.dev" + source: hosted + version: "2.1.1" string_scanner: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index d6f25eb004..b90f50c25a 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -26,12 +26,16 @@ dependencies: highlight: ^0.7.0 intl: ^0.20.2 uuid: ^4.5.1 + file_selector: ^1.1.0 + camera: ^0.12.0+2 image_picker: ^1.1.2 video_player: ^2.10.1 package_info_plus: ^10.0.0 app_badge_plus: ^1.2.10 app_links: ^6.4.0 scrollable_positioned_list: ^0.3.8 + open_filex: ^4.7.0 + path_provider: ^2.1.6 dev_dependencies: flutter_test: diff --git a/mobile/test/features/channels/camera_capture_cleanup_test.dart b/mobile/test/features/channels/camera_capture_cleanup_test.dart new file mode 100644 index 0000000000..2b5360035d --- /dev/null +++ b/mobile/test/features/channels/camera_capture_cleanup_test.dart @@ -0,0 +1,35 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:buzz/features/channels/camera_capture_cleanup.dart'; + +void main() { + test('deletes the inline camera file after upload succeeds', () async { + final file = File( + '${Directory.systemTemp.path}/buzz-camera-${DateTime.now().microsecondsSinceEpoch}.jpg', + ); + await file.writeAsBytes([1, 2, 3]); + + await processCapturedImage(XFile(file.path), (_) async {}); + + expect(await file.exists(), isFalse); + }); + + test('deletes the inline camera file when upload fails', () async { + final file = File( + '${Directory.systemTemp.path}/buzz-camera-${DateTime.now().microsecondsSinceEpoch}.jpg', + ); + await file.writeAsBytes([1, 2, 3]); + + await expectLater( + processCapturedImage( + XFile(file.path), + (_) async => throw Exception('upload failed'), + ), + throwsException, + ); + + expect(await file.exists(), isFalse); + }); +} diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index c1236c2a67..01fae2b799 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -1272,12 +1272,20 @@ void main() { }); group('Compose bar', () { - testWidgets('shows text field and send button', (tester) async { + testWidgets('expands from the channel hint into the composer controls', ( + tester, + ) async { await tester.pumpWidget(_buildTestable(messages: [])); await tester.pumpAndSettle(); + expect(find.byType(TextField), findsNothing); + expect(find.byIcon(LucideIcons.arrowUp).hitTestable(), findsNothing); + + await tester.tap(find.text('Message #general')); + await tester.pumpAndSettle(); + expect(find.byType(TextField), findsOneWidget); - expect(find.byIcon(LucideIcons.sendHorizontal), findsOneWidget); + expect(find.byIcon(LucideIcons.arrowUp).hitTestable(), findsOneWidget); }); testWidgets('shows hint text', (tester) async { diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index dd629dcf88..a4953401ae 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -18,6 +18,8 @@ import 'package:buzz/features/channels/compose_bar.dart'; import 'package:buzz/features/channels/channels_provider.dart'; import 'package:buzz/features/channels/mentions/mention_candidates.dart'; import 'package:buzz/features/channels/mentions/mention_candidates_provider.dart'; +import 'package:buzz/features/custom_emoji/custom_emoji.dart'; +import 'package:buzz/features/custom_emoji/custom_emoji_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme.dart'; @@ -141,9 +143,11 @@ Widget _buildComposeBar({ List channels = const [], String? currentPubkey, bool? supportsShowingSystemContextMenu, + List customEmoji = const [], }) { return ProviderScope( overrides: [ + customEmojiListProvider.overrideWithValue(customEmoji), mediaUploadServiceProvider.overrideWithValue(uploadService), currentPubkeyProvider.overrideWith((ref) => currentPubkey), channelMembersProvider( @@ -170,7 +174,10 @@ Widget _buildComposeBar({ ), home: Scaffold( body: SafeArea( - child: ComposeBar(channelId: 'channel-1', onSend: onSend), + child: Align( + alignment: Alignment.bottomCenter, + child: ComposeBar(channelId: 'channel-1', onSend: onSend), + ), ), ), ), @@ -255,6 +262,42 @@ void main() { }); group('ComposeBar', () { + testWidgets('inserts a community emoji at the cursor from the action row', ( + tester, + ) async { + await tester.pumpWidget( + _buildComposeBar( + uploadService: _testUploadService(nostr.Keys.generate().nsec), + customEmoji: const [ + CustomEmoji(shortcode: 'meow', url: 'https://example.com/meow.png'), + ], + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + + await _expandComposer(tester); + await tester.enterText(find.byType(TextField), 'hello world'); + final textField = tester.widget(find.byType(TextField)); + textField.controller!.selection = const TextSelection.collapsed( + offset: 6, + ); + + await tester.tap(find.byIcon(LucideIcons.smilePlus)); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(LucideIcons.sparkles)); + await tester.pump(); + await tester.tap(find.byTooltip(':meow:')); + await tester.pumpAndSettle(); + + expect(textField.controller!.text, 'hello :meow:world'); + expect(textField.controller!.selection.baseOffset, 12); + }); + testWidgets('uploads an image and sends markdown plus imeta tags', ( tester, ) async { @@ -299,14 +342,14 @@ void main() { ), ); - await tester.tap(find.byIcon(LucideIcons.paperclip)); - await tester.pumpAndSettle(); - await tester.tap(find.text('Photo')); + await _openAttachmentMenu(tester); + await tester.tap(find.text('Photos')); await tester.pumpAndSettle(); expect(find.byTooltip('Remove attachment'), findsOneWidget); - await tester.tap(find.byIcon(LucideIcons.sendHorizontal)); + await _expandComposer(tester); + await tester.tap(find.byIcon(LucideIcons.arrowUp)); await tester.pump(); await tester.pumpAndSettle(); @@ -320,6 +363,150 @@ void main() { expect(find.byTooltip('Remove attachment'), findsNothing); }); + testWidgets('keeps upload progress visible after the picker closes', ( + tester, + ) async { + final pickedImage = Completer(); + final uploadService = MediaUploadService( + baseUrl: 'https://relay.example', + nsec: nostr.Keys.generate().nsec, + pickGalleryVideo: () async => null, + pickGalleryImage: () => pickedImage.future, + ); + + await tester.pumpWidget( + _buildComposeBar( + uploadService: uploadService, + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + + await _openAttachmentMenu(tester); + await tester.tap(find.text('Photos')); + await tester.pump(); + + expect( + find.byKey(const ValueKey('compose-upload-progress')), + findsOneWidget, + ); + expect(find.text('Uploading attachment…'), findsOneWidget); + + pickedImage.complete(null); + await tester.pumpAndSettle(); + + expect( + find.byKey(const ValueKey('compose-upload-progress')), + findsNothing, + ); + }); + + testWidgets('renders markdown formatting without visible delimiters', ( + tester, + ) async { + await tester.pumpWidget( + _buildComposeBar( + uploadService: _testUploadService(nostr.Keys.generate().nsec), + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + + await _expandComposer(tester); + await tester.enterText( + find.byType(TextField), + '**bold** _italic_ ~~strike~~ `code`', + ); + + final textField = tester.widget(find.byType(TextField)); + final textSpan = textField.controller!.buildTextSpan( + context: tester.element(find.byType(TextField)), + style: tester.element(find.byType(TextField)).textTheme.bodyLarge, + withComposing: false, + ); + final spans = _flattenStyledTextSpans(textSpan); + + expect(textSpan.toPlainText(), '**bold** _italic_ ~~strike~~ `code`'); + expect( + spans.singleWhere((span) => span.text == 'bold').style.fontWeight, + FontWeight.w700, + ); + expect( + spans.singleWhere((span) => span.text == 'italic').style.fontStyle, + FontStyle.italic, + ); + expect( + spans.singleWhere((span) => span.text == 'strike').style.decoration, + TextDecoration.lineThrough, + ); + expect( + spans.singleWhere((span) => span.text == 'code').style.fontFamily, + 'GeistMono', + ); + expect( + spans + .where((span) => {'**', '_', '~~', '`'}.contains(span.text)) + .every((span) => (span.style.fontSize ?? 1) < 1), + isTrue, + ); + + textField.controller!.value = textField.controller!.value.copyWith( + composing: const TextRange(start: 2, end: 6), + ); + final composingSpan = textField.controller!.buildTextSpan( + context: tester.element(find.byType(TextField)), + style: tester.element(find.byType(TextField)).textTheme.bodyLarge, + withComposing: true, + ); + final composingSpans = _flattenStyledTextSpans(composingSpan); + expect( + composingSpans + .where((span) => span.text == '**') + .every((span) => (span.style.fontSize ?? 1) < 1), + isTrue, + ); + expect( + composingSpans + .singleWhere((span) => span.text == 'bold') + .style + .decoration, + TextDecoration.underline, + ); + }); + + testWidgets('uses the primary color for formatting actions', ( + tester, + ) async { + await tester.pumpWidget( + _buildComposeBar( + uploadService: _testUploadService(nostr.Keys.generate().nsec), + onSend: + ( + content, + mentionPubkeys, { + mediaTags = const >[], + }) async {}, + ), + ); + + await _expandComposer(tester); + await tester.tap(find.byIcon(LucideIcons.aLargeSmall)); + await tester.pumpAndSettle(); + + final boldFinder = find.byIcon(LucideIcons.bold); + final boldIcon = tester.widget(boldFinder); + final colors = tester.element(boldFinder).colors; + expect(boldIcon.color, colors.primary); + }); + testWidgets('pasted image follows the attachment preview and send path', ( tester, ) async { @@ -370,6 +557,7 @@ void main() { ), ); + await _expandComposer(tester); final textField = tester.widget(find.byType(TextField)); final insertionConfiguration = textField.contentInsertionConfiguration; expect(insertionConfiguration, isNotNull); @@ -400,7 +588,7 @@ void main() { ); expect(find.byTooltip('Remove attachment'), findsOneWidget); - await tester.tap(find.byIcon(LucideIcons.sendHorizontal)); + await tester.tap(find.byIcon(LucideIcons.arrowUp)); await tester.pumpAndSettle(); expect(sentContent, '\n![image](https://relay.example/media/pasted.png)'); @@ -450,6 +638,7 @@ void main() { ), ); + await _expandComposer(tester); final textField = tester.widget(find.byType(TextField)); final editableTextState = tester.state( find.byType(EditableText), @@ -513,6 +702,7 @@ void main() { ); await tester.pump(); + await _expandComposer(tester); final textField = tester.widget(find.byType(TextField)); final editableTextState = tester.state( find.byType(EditableText), @@ -581,6 +771,7 @@ void main() { ), ); + await _expandComposer(tester); final textField = tester.widget(find.byType(TextField)); final editableTextState = tester.state( find.byType(EditableText), @@ -631,6 +822,7 @@ void main() { ), ); + await _expandComposer(tester); final textField = tester.widget(find.byType(TextField)); textField.contentInsertionConfiguration!.onContentInserted( const KeyboardInsertedContent( @@ -669,6 +861,7 @@ void main() { ), ); + await _expandComposer(tester); final textField = tester.widget(find.byType(TextField)); final editableTextState = tester.state( find.byType(EditableText), @@ -712,6 +905,7 @@ void main() { ), ); + await _expandComposer(tester); final textField = tester.widget(find.byType(TextField)); final editableTextState = tester.state( find.byType(EditableText), @@ -767,9 +961,8 @@ void main() { ), ); - await tester.tap(find.byIcon(LucideIcons.paperclip)); - await tester.pumpAndSettle(); - await tester.tap(find.text('Photo')); + await _openAttachmentMenu(tester); + await tester.tap(find.text('Photos')); await tester.pumpAndSettle(); final attachmentFinder = find.byKey( @@ -824,9 +1017,8 @@ void main() { ), ); - await tester.tap(find.byIcon(LucideIcons.paperclip)); - await tester.pumpAndSettle(); - await tester.tap(find.text('Photo')); + await _openAttachmentMenu(tester); + await tester.tap(find.text('Photos')); await tester.pumpAndSettle(); expect(find.textContaining('upload failed'), findsOneWidget); @@ -866,9 +1058,8 @@ void main() { ), ); - await tester.tap(find.byIcon(LucideIcons.paperclip)); - await tester.pumpAndSettle(); - await tester.tap(find.text('Photo')); + await _openAttachmentMenu(tester); + await tester.tap(find.text('Photos')); await tester.pumpAndSettle(); expect( @@ -916,9 +1107,8 @@ void main() { ), ); - await tester.tap(find.byIcon(LucideIcons.paperclip)); - await tester.pumpAndSettle(); - await tester.tap(find.text('Photo')); + await _openAttachmentMenu(tester); + await tester.tap(find.text('Photos')); await tester.pumpAndSettle(); expect( @@ -981,12 +1171,13 @@ void main() { ); session.debugAttachSocketForTest(socket); + await _expandComposer(tester); await tester.enterText(find.byType(TextField), '@hel'); await tester.pumpAndSettle(); await tester.tap(find.text('Helper Bot')); await tester.pumpAndSettle(); await tester.enterText(find.byType(TextField), 'hello @Helper Bot'); - await tester.tap(find.byIcon(LucideIcons.sendHorizontal)); + await tester.tap(find.byIcon(LucideIcons.arrowUp)); await tester.pumpAndSettle(); expect(sentContent, 'hello @Helper Bot'); @@ -1082,12 +1273,13 @@ void main() { ); session.debugAttachSocketForTest(socket); + await _expandComposer(tester); await tester.enterText(find.byType(TextField), '@hel'); await tester.pumpAndSettle(); await tester.tap(find.text('Helper Bot')); await tester.pumpAndSettle(); await tester.enterText(find.byType(TextField), 'hello @Helper Bot'); - await tester.tap(find.byIcon(LucideIcons.sendHorizontal)); + await tester.tap(find.byIcon(LucideIcons.arrowUp)); await tester.pump(); expect(didSend, isFalse); @@ -1142,9 +1334,8 @@ void main() { ), ); - await tester.tap(find.byIcon(LucideIcons.paperclip)); - await tester.pumpAndSettle(); - await tester.tap(find.text('Photo')); + await _openAttachmentMenu(tester); + await tester.tap(find.text('Photos')); await tester.pumpAndSettle(); expect( @@ -1216,8 +1407,7 @@ void main() { ), ); - await tester.tap(find.byIcon(LucideIcons.paperclip)); - await tester.pumpAndSettle(); + await _openAttachmentMenu(tester); await tester.tap(find.text('Video')); // Pump enough frames for the async file read + upload to complete. // Can't use pumpAndSettle here — the upload spinner's animation @@ -1229,7 +1419,7 @@ void main() { // Video attachment should show a video icon (not a broken image). expect(find.byIcon(LucideIcons.video), findsOneWidget); - await tester.tap(find.byIcon(LucideIcons.sendHorizontal)); + await tester.tap(find.byIcon(LucideIcons.arrowUp)); await tester.pump(); await tester.pumpAndSettle(); @@ -1454,16 +1644,48 @@ AgentDirectoryEntry _testAgent(String pubkey) { ); } +Future _expandComposer(WidgetTester tester) async { + if (find.byType(TextField).evaluate().isNotEmpty) return; + await tester.tap(find.text('Message\u2026')); + await tester.pumpAndSettle(); +} + +Future _openAttachmentMenu(WidgetTester tester) async { + await tester.tap(find.byTooltip('Add attachment').hitTestable()); + await tester.pumpAndSettle(); +} + Future _selectAndSendAgentMention(WidgetTester tester) async { + await _expandComposer(tester); await tester.enterText(find.byType(TextField), '@hel'); await tester.pumpAndSettle(); await tester.tap(find.text('Helper Bot')); await tester.pumpAndSettle(); await tester.enterText(find.byType(TextField), 'hello @Helper Bot'); - await tester.tap(find.byIcon(LucideIcons.sendHorizontal)); + await tester.tap(find.byIcon(LucideIcons.arrowUp)); await tester.pumpAndSettle(); } +List<({String text, TextStyle style})> _flattenStyledTextSpans( + InlineSpan root, +) { + final result = <({String text, TextStyle style})>[]; + + void visit(InlineSpan span, TextStyle inheritedStyle) { + if (span is! TextSpan) return; + final effectiveStyle = inheritedStyle.merge(span.style); + if (span.text case final text?) { + result.add((text: text, style: effectiveStyle)); + } + for (final child in span.children ?? const []) { + visit(child, effectiveStyle); + } + } + + visit(root, const TextStyle()); + return result; +} + Channel _makeCurrentChannel({String channelType = 'stream'}) { return Channel( id: 'channel-1', diff --git a/mobile/test/features/channels/message_content_test.dart b/mobile/test/features/channels/message_content_test.dart index 02821157a8..bcb46f0c37 100644 --- a/mobile/test/features/channels/message_content_test.dart +++ b/mobile/test/features/channels/message_content_test.dart @@ -3,8 +3,10 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/misc.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:nostr/nostr.dart' as nostr; import 'package:buzz/features/channels/message_content.dart'; import 'package:buzz/features/channels/media_viewer_page.dart'; +import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme.dart'; Widget _testable(Widget child, {List overrides = const []}) { @@ -135,6 +137,44 @@ bool _spanHasStyle( void main() { group('MessageContent', () { + testWidgets('opens local file links through an authenticated download', ( + tester, + ) async { + const url = 'https://relay.example/media/report.pdf'; + String? openedUrl; + Map? openedHeaders; + String? openedFilename; + final auth = MediaGetAuthService( + baseUrl: 'https://relay.example', + nsec: nostr.Keys.generate().nsec, + ); + + await tester.pumpWidget( + _testable( + const MessageContent(content: '[report.pdf]($url)'), + overrides: [ + mediaGetAuthServiceProvider.overrideWithValue(auth), + openDownloadedFileProvider.overrideWithValue(( + url, + headers, + filename, + ) async { + openedUrl = url; + openedHeaders = headers; + openedFilename = filename; + }), + ], + ), + ); + + await tester.tap(find.text('report.pdf')); + await tester.pump(); + + expect(openedUrl, url); + expect(openedFilename, 'report.pdf'); + expect(openedHeaders?['Authorization'], startsWith('Nostr ')); + }); + test('buildImageViewerRoute uses modal-style page route builder', () { final route = buildImageViewerRoute( imageUrl: 'https://example.com/media/image.png', diff --git a/mobile/test/shared/relay/media_upload_test.dart b/mobile/test/shared/relay/media_upload_test.dart index dc901eba38..dcd04359b2 100644 --- a/mobile/test/shared/relay/media_upload_test.dart +++ b/mobile/test/shared/relay/media_upload_test.dart @@ -68,6 +68,15 @@ final _heicBytes = Uint8List.fromList([ 0x63, ]); +class _NamedXFile extends XFile { + final String _name; + + _NamedXFile(super.bytes, this._name) : super.fromData(); + + @override + String get name => _name; +} + final _gifBytes = Uint8List.fromList([ ...ascii.encode('GIF89a'), 0x02, @@ -1147,6 +1156,69 @@ void main() { ), ); }); + + test('rejects empty generic file attachments before upload', () async { + var uploadRequested = false; + final service = MediaUploadService( + baseUrl: 'https://relay.example', + nsec: nostr.Keys.generate().nsec, + httpClient: http_testing.MockClient((request) async { + uploadRequested = true; + return http.Response('', HttpStatus.ok); + }), + pickGalleryVideo: () async => null, + pickGalleryImage: () async => null, + pickAttachmentFile: () async => + XFile.fromData(Uint8List(0), name: 'empty.txt'), + ); + + await expectLater( + service.pickAndUploadFile(), + throwsA( + isA().having( + (error) => error.toString(), + 'message', + contains('File is empty'), + ), + ), + ); + expect(uploadRequested, isFalse); + }); + + test('sanitizes generic filenames to relay imeta constraints', () async { + final service = MediaUploadService( + baseUrl: 'https://relay.example', + nsec: nostr.Keys.generate().nsec, + httpClient: http_testing.MockClient((request) async { + return http.Response( + jsonEncode({ + 'url': 'https://relay.example/media/test.bin', + 'sha256': + '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + 'size': request.bodyBytes.length, + 'type': 'application/octet-stream', + 'uploaded': 1, + }), + HttpStatus.ok, + ); + }), + pickGalleryVideo: () async => null, + pickGalleryImage: () async => null, + pickAttachmentFile: () async => _NamedXFile( + Uint8List.fromList([1]), + 'folder\\draft\u0000${List.filled(200, 'é').join()}.txt', + ), + ); + + final descriptor = await service.pickAndUploadFile(); + final filename = descriptor!.filename!; + + expect(filename, startsWith('draft')); + expect(filename, isNot(contains('\u0000'))); + expect(filename, isNot(contains('/'))); + expect(filename, isNot(contains('\\'))); + expect(utf8.encode(filename).length, lessThanOrEqualTo(255)); + }); }); group('pickAndUploadVideo', () { From b8510ede1b52ebe87ed3cf18cf0b2590a86b2245 Mon Sep 17 00:00:00 2001 From: Atish Patel Date: Fri, 24 Jul 2026 19:21:25 -0500 Subject: [PATCH 007/393] fix(desktop): clarify CLI runtime setup (#2680) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why Installing the Codex, Claude, or Goose desktop app does not install the command-line harness Buzz needs. The current UI makes that distinction unclear, links some missing-CLI states to adapter documentation, and can report a successful install from the installer exit code even when runtime discovery still fails. On Windows, Buzz also invokes Goose's Bash installer, which writes the executable somewhere Buzz does not discover. ## What - distinguish missing vendor CLIs from missing or outdated ACP adapters in runtime metadata and UI guidance - link Codex, Claude Code, and Goose missing-CLI states to their official CLI installation documentation - explain in Settings, onboarding, and agent configuration that the desktop app alone is not sufficient - use Goose's official PowerShell installer on Windows - refresh PATH and rediscover the requested runtime after installation, keeping the control retryable if the runtime is still unavailable - add Rust and Playwright regression coverage for Windows installer selection, CLI/adapter guidance, false-success prevention, verified installs, and onboarding copy ## Risk Assessment Medium. This changes desktop onboarding and runtime installation behavior. Successful installs now require the runtime catalog to verify availability; previously hidden discovery failures will surface as actionable errors instead of a false success state. ## References - [Codex CLI installation](https://developers.openai.com/codex/cli/) - [Claude Code installation](https://code.claude.com/docs/en/getting-started) - [Goose installation](https://goose-docs.ai/docs/getting-started/installation/) - Follow-up to #2563 and #2587 ## Validation - `just desktop-typecheck` - `just desktop-test` — 3,455 passed - focused Rust post-install verification tests - focused Playwright Doctor/onboarding coverage (in progress; CI and local sequential rerun will provide final results) Generated with Codex --------- Signed-off-by: Atish Patel Co-authored-by: Codex Co-authored-by: Goose --- desktop/scripts/check-file-sizes.mjs | 4 +- .../src-tauri/src/commands/agent_config.rs | 9 +- .../src-tauri/src/commands/agent_discovery.rs | 44 +++- .../post_install_verification.rs | 74 ++++++ .../config_bridge/reader_tests.rs | 6 +- .../src-tauri/src/managed_agents/discovery.rs | 241 ++++++++++-------- .../discovery/runtime_metadata.rs | 48 +++- .../src/managed_agents/discovery/tests.rs | 15 +- .../src-tauri/src/managed_agents/readiness.rs | 6 +- desktop/src-tauri/src/managed_agents/types.rs | 2 + desktop/src/features/agents/AGENTS.md | 4 +- .../agents/ui/AgentDefinitionDialog.tsx | 4 +- .../src/features/onboarding/ui/SetupStep.tsx | 15 +- .../settings/ui/DoctorSettingsPanel.tsx | 48 ++-- desktop/src/shared/api/tauri.ts | 3 + desktop/src/shared/api/types.ts | 2 + desktop/src/testing/e2eBridge.ts | 4 + desktop/tests/e2e/doctor-states.spec.ts | 137 +++++++--- .../e2e/onboarding-agent-defaults.spec.ts | 34 +++ 19 files changed, 502 insertions(+), 198 deletions(-) create mode 100644 desktop/src-tauri/src/commands/agent_discovery/post_install_verification.rs diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 2651318bc3..96f97126e0 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -498,7 +498,9 @@ const overrides = new Map([ // route PowerShell CLI installs natively on Windows (bypasses Git Bash PATH // poisoning that resolved GNU tar instead of bsdtar → Codex install failure). // Includes unit tests for detection, routing, and -Command body preservation. - ["src-tauri/src/commands/agent_discovery.rs", 1810], + // +16: test_powershell_command_goose_catalog_dequoted proves the \$→$ escape + // fix for the Goose Windows installer (PR #2680 interaction with #2750). + ["src-tauri/src/commands/agent_discovery.rs", 1826], // draft-persistence predicate: submit-time `loadDraft` check + inline comment // + deps-array entry in submitMessage closes the never-persisted-boundary // defect (Thufir Pass-3 finding). Load-bearing correctness fix; queued to diff --git a/desktop/src-tauri/src/commands/agent_config.rs b/desktop/src-tauri/src/commands/agent_config.rs index 05fb179125..f6094ed529 100644 --- a/desktop/src-tauri/src/commands/agent_config.rs +++ b/desktop/src-tauri/src/commands/agent_config.rs @@ -596,8 +596,6 @@ fn parse_models(raw: Option<&serde_json::Value>) -> (Vec, Option< #[cfg(test)] mod tests { - use std::collections::BTreeMap; - use super::*; use crate::managed_agents::{BackendKind, RespondTo}; @@ -614,7 +612,8 @@ mod tests { cli_install_commands: &[], cli_install_commands_windows: &[], adapter_install_commands: &[], - install_instructions_url: "", + cli_install_instructions_url: "", + adapter_install_instructions_url: "", cli_install_hint: "", adapter_install_hint: "", skill_dir: None, @@ -654,7 +653,7 @@ mod tests { parallelism: 1, system_prompt: None, model: None, - env_vars: BTreeMap::new(), + env_vars: Default::default(), start_on_app_launch: false, auto_restart_on_config_change: true, runtime_pid: None, @@ -705,7 +704,7 @@ mod tests { is_active: true, source_team: None, source_team_persona_slug: None, - env_vars: BTreeMap::new(), + env_vars: Default::default(), respond_to: None, respond_to_allowlist: Vec::new(), parallelism: None, diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index f080272914..7b1979d654 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -12,6 +12,8 @@ use crate::{ relay::query_relay, }; +mod post_install_verification; + fn active_installs() -> &'static std::sync::Mutex> { use std::collections::HashSet; use std::sync::{Mutex, OnceLock}; @@ -229,11 +231,10 @@ fn install_acp_runtime_blocking(runtime_id: &str) -> Result>(), + vec![ + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + "$env:CONFIGURE='false'; irm https://raw.githubusercontent.com/aaif-goose/goose/main/download_cli.ps1 | iex", + ], + "Goose catalog command must dequote with bare $env: (no backslash before $)" + ); + } + // ── install retry ───────────────────────────────────────────────────────── /// Build an `InstallStepResult` with just the fields the retry loop reads. diff --git a/desktop/src-tauri/src/commands/agent_discovery/post_install_verification.rs b/desktop/src-tauri/src/commands/agent_discovery/post_install_verification.rs new file mode 100644 index 0000000000..3155104b56 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_discovery/post_install_verification.rs @@ -0,0 +1,74 @@ +use crate::managed_agents::{AcpAvailabilityStatus, InstallStepResult}; + +pub(super) fn run(runtime_id: &str, steps: &mut Vec) { + // Observe PATH changes and binaries added after Buzz launched. + crate::managed_agents::refresh_login_shell_path(); + crate::managed_agents::clear_resolve_cache(); + + let availability = crate::managed_agents::discover_acp_runtime_availability(runtime_id); + if let Some(failure) = failure(runtime_id, availability) { + steps.push(failure); + } +} + +fn failure( + runtime_id: &str, + availability: Option, +) -> Option { + if availability == Some(AcpAvailabilityStatus::Available) { + return None; + } + + let observed = availability + .map(availability_label) + .unwrap_or_else(|| "missing from the runtime catalog".to_string()); + Some(InstallStepResult { + step: "verify".to_string(), + command: format!("discover {runtime_id}"), + success: false, + stdout: String::new(), + stderr: format!( + "The installer finished, but Buzz still could not use {runtime_id} (observed: {observed})." + ), + exit_code: None, + hint: Some( + "Buzz requires the vendor CLI executable, not only its desktop app. If the CLI was installed while Buzz was open, restart Buzz and check again." + .to_string(), + ), + }) +} + +fn availability_label(status: AcpAvailabilityStatus) -> String { + match status { + AcpAvailabilityStatus::Available => "available", + AcpAvailabilityStatus::AdapterMissing => "ACP adapter missing", + AcpAvailabilityStatus::AdapterOutdated => "ACP adapter outdated", + AcpAvailabilityStatus::CliMissing => "CLI missing", + AcpAvailabilityStatus::NotInstalled => "not installed", + } + .to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_available_runtime() { + assert!(failure("goose", Some(AcpAvailabilityStatus::Available)).is_none()); + } + + #[test] + fn rejects_unresolved_runtime() { + let failure = failure("goose", Some(AcpAvailabilityStatus::NotInstalled)) + .expect("not-installed runtime must fail verification"); + + assert_eq!(failure.step, "verify"); + assert!(!failure.success); + assert!(failure.stderr.contains("observed: not installed")); + assert!(failure + .hint + .as_deref() + .is_some_and(|hint| hint.contains("desktop app"))); + } +} diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs index f5fc7c3899..4c11cd6c49 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs @@ -40,7 +40,8 @@ fn test_runtime() -> &'static KnownAcpRuntime { cli_install_commands: &[], cli_install_commands_windows: &[], adapter_install_commands: &[], - install_instructions_url: "", + cli_install_instructions_url: "", + adapter_install_instructions_url: "", cli_install_hint: "", adapter_install_hint: "", skill_dir: None, @@ -615,7 +616,8 @@ fn buzz_agent_runtime() -> &'static KnownAcpRuntime { cli_install_commands: &[], cli_install_commands_windows: &[], adapter_install_commands: &[], - install_instructions_url: "", + cli_install_instructions_url: "", + adapter_install_instructions_url: "", cli_install_hint: "", adapter_install_hint: "", skill_dir: None, diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index f40eed5a13..ab76965259 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -73,10 +73,13 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ mcp_hooks: false, underlying_cli: Some("goose"), cli_install_commands: &["curl -fsSL https://github.com/aaif-goose/goose/releases/download/stable/download_cli.sh | CONFIGURE=false bash"], - cli_install_commands_windows: &[], // goose install script is already Windows-aware + // Goose's stable release currently publishes only the Unix installer; + // its official Windows instructions intentionally point at this main-branch script. + cli_install_commands_windows: &["powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"$env:CONFIGURE='false'; irm https://raw.githubusercontent.com/aaif-goose/goose/main/download_cli.ps1 | iex\""], adapter_install_commands: &[], - install_instructions_url: "https://block.github.io/goose/", - cli_install_hint: "Install Goose via the official install script.", + cli_install_instructions_url: "https://goose-docs.ai/docs/getting-started/installation/", + adapter_install_instructions_url: "", + cli_install_hint: "Buzz requires the Goose CLI; the desktop app alone is not enough.", adapter_install_hint: "", skill_dir: Some(".goose/skills"), supports_acp_model_switching: false, @@ -106,8 +109,9 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ cli_install_commands: &["curl -fsSL https://claude.ai/install.sh | bash"], cli_install_commands_windows: &["powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"irm https://claude.ai/install.ps1 | iex\""], adapter_install_commands: &["npm install -g @agentclientprotocol/claude-agent-acp"], - install_instructions_url: "https://github.com/agentclientprotocol/claude-agent-acp", - cli_install_hint: "Install the Claude Code CLI via the official install script.", + cli_install_instructions_url: "https://code.claude.com/docs/en/getting-started", + adapter_install_instructions_url: "https://github.com/agentclientprotocol/claude-agent-acp", + cli_install_hint: "Buzz requires the Claude Code CLI; the desktop app alone is not enough.", adapter_install_hint: "Install the Claude Code ACP adapter via npm.", skill_dir: Some(".claude/skills"), supports_acp_model_switching: false, @@ -137,8 +141,9 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ cli_install_commands: &["curl -fsSL https://chatgpt.com/codex/install.sh | sh"], cli_install_commands_windows: &["powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"irm https://chatgpt.com/codex/install.ps1 | iex\""], adapter_install_commands: &["npm install -g @agentclientprotocol/codex-acp"], - install_instructions_url: "https://github.com/agentclientprotocol/codex-acp", - cli_install_hint: "Install the Codex CLI via the official install script.", + cli_install_instructions_url: "https://developers.openai.com/codex/cli/", + adapter_install_instructions_url: "https://github.com/agentclientprotocol/codex-acp", + cli_install_hint: "Buzz requires the Codex CLI; the desktop app alone is not enough.", adapter_install_hint: "Install the Codex ACP adapter via npm.", skill_dir: Some(".codex/skills"), supports_acp_model_switching: false, @@ -169,7 +174,8 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ cli_install_commands: &[], cli_install_commands_windows: &[], adapter_install_commands: &[], - install_instructions_url: "https://github.com/block/buzz", + cli_install_instructions_url: "https://github.com/block/buzz", + adapter_install_instructions_url: "https://github.com/block/buzz", cli_install_hint: "Ships with the Buzz desktop app.", adapter_install_hint: "", skill_dir: None, @@ -1158,109 +1164,130 @@ struct PartialEntry { entry: AcpRuntimeCatalogEntry, } +fn discover_acp_runtime_phase1(runtime: &'static KnownAcpRuntime) -> PartialEntry { + let adapter_result = runtime + .commands + .iter() + .find_map(|command| find_command(command).map(|path| (*command, path))); + + let underlying_cli_found = runtime + .underlying_cli + .map(|cli| find_command(cli).is_some()) + .unwrap_or(false); + let (mut availability, command, binary_path) = + classify_runtime(adapter_result, runtime.underlying_cli, underlying_cli_found); + + // For codex-acp: when the adapter resolves as Available, probe the + // version. An adapter with major version < 1 is treated as outdated — + // the CODEX_CONFIG spawn contract requires 1.x. + if runtime.id == "codex" + && availability == AcpAvailabilityStatus::Available + && command.as_deref() == Some("codex-acp") + { + if let Some(path_str) = &binary_path { + availability = codex_adapter_availability(&PathBuf::from(path_str)); + } + } + + // Warm the adapter-availability cache for the badge fallback. + // The cache is scoped to the codex runtime; other runtimes leave it + // unchanged. Invalidated by `clear_resolve_cache`. + if runtime.id == "codex" { + cache_adapter_availability(availability.clone()); + } + + let underlying_cli_path = runtime + .underlying_cli + .and_then(find_command) + .map(|p| p.display().to_string()); + + let default_args = command + .as_deref() + .map(|cmd| normalize_agent_args(cmd, Vec::new())) + .unwrap_or_default(); + + let can_auto_install = !runtime.cli_install_commands_for_os().is_empty() + || !runtime.adapter_install_commands.is_empty(); + + let cli_hint = runtime.cli_install_hint; + let adapter_hint = runtime.adapter_install_hint; + let install_hint = match availability { + AcpAvailabilityStatus::Available => cli_hint.to_string(), + AcpAvailabilityStatus::CliMissing => cli_hint.to_string(), + AcpAvailabilityStatus::AdapterMissing => adapter_hint.to_string(), + AcpAvailabilityStatus::AdapterOutdated => adapter_hint.to_string(), + AcpAvailabilityStatus::NotInstalled => { + if !cli_hint.is_empty() && !adapter_hint.is_empty() { + format!("{cli_hint} {adapter_hint}") + } else if !cli_hint.is_empty() { + cli_hint.to_string() + } else { + adapter_hint.to_string() + } + } + }; + let install_instructions_url = match availability { + AcpAvailabilityStatus::AdapterMissing | AcpAvailabilityStatus::AdapterOutdated => { + runtime.adapter_install_instructions_url + } + AcpAvailabilityStatus::Available + | AcpAvailabilityStatus::CliMissing + | AcpAvailabilityStatus::NotInstalled => runtime.cli_install_instructions_url, + }; + + // node_required now means Buzz cannot provide npm for this platform. + // On supported desktop platforms, Buzz downloads a private Node/npm + // runtime into app data before running npm-backed adapter installs. + let node_required = matches!( + availability, + AcpAvailabilityStatus::AdapterMissing | AcpAvailabilityStatus::NotInstalled + ) && runtime_needs_npm(runtime) + && buzz_managed_node_bin_dir().is_none() + && resolve_command("npm").is_none() + && resolve_command("node").is_none(); + + PartialEntry { + runtime, + entry: AcpRuntimeCatalogEntry { + id: runtime.id.to_string(), + label: runtime.label.to_string(), + avatar_url: runtime.avatar_url.to_string(), + availability, + command, + binary_path, + default_args, + mcp_command: runtime.mcp_command.map(str::to_string), + model_env_var: runtime.model_env_var.map(str::to_string), + provider_env_var: runtime.provider_env_var.map(str::to_string), + thinking_env_var: runtime.thinking_env_var.map(str::to_string), + install_hint, + install_instructions_url: install_instructions_url.to_string(), + can_auto_install, + requires_external_cli: runtime.underlying_cli.is_some(), + underlying_cli_path, + node_required, + // Filled in by the auth-probe phase in full catalog discovery. + auth_status: AuthStatus::Unknown, + login_hint: None, + }, + } +} + +/// Discover one runtime's filesystem availability without running any auth probes. +/// +/// Post-install verification only needs to know whether the requested runtime +/// resolves, so it should not pay the cost of authenticating every catalog entry. +pub(crate) fn discover_acp_runtime_availability(runtime_id: &str) -> Option { + known_acp_runtime_exact(runtime_id) + .map(discover_acp_runtime_phase1) + .map(|partial| partial.entry.availability) +} + pub fn discover_acp_runtimes() -> Vec { // Phase 1: build all entries (fast — no probes yet). let mut partials: Vec = KNOWN_ACP_RUNTIMES .iter() - .map(|runtime| { - let adapter_result = runtime - .commands - .iter() - .find_map(|command| find_command(command).map(|path| (*command, path))); - - let underlying_cli_found = runtime - .underlying_cli - .map(|cli| find_command(cli).is_some()) - .unwrap_or(false); - let (mut availability, command, binary_path) = - classify_runtime(adapter_result, runtime.underlying_cli, underlying_cli_found); - - // For codex-acp: when the adapter resolves as Available, probe the - // version. An adapter with major version < 1 is treated as outdated — - // the CODEX_CONFIG spawn contract requires 1.x. - if runtime.id == "codex" - && availability == AcpAvailabilityStatus::Available - && command.as_deref() == Some("codex-acp") - { - if let Some(path_str) = &binary_path { - availability = codex_adapter_availability(&PathBuf::from(path_str)); - } - } - - // Warm the adapter-availability cache for the badge fallback. - // The cache is scoped to the codex runtime; other runtimes leave it - // unchanged. Invalidated by `clear_resolve_cache`. - if runtime.id == "codex" { - cache_adapter_availability(availability.clone()); - } - - let underlying_cli_path = runtime - .underlying_cli - .and_then(find_command) - .map(|p| p.display().to_string()); - - let default_args = command - .as_deref() - .map(|cmd| normalize_agent_args(cmd, Vec::new())) - .unwrap_or_default(); - - let can_auto_install = !runtime.cli_install_commands_for_os().is_empty() - || !runtime.adapter_install_commands.is_empty(); - - let cli_hint = runtime.cli_install_hint; - let adapter_hint = runtime.adapter_install_hint; - let install_hint = match availability { - AcpAvailabilityStatus::Available => cli_hint.to_string(), - AcpAvailabilityStatus::CliMissing => cli_hint.to_string(), - AcpAvailabilityStatus::AdapterMissing => adapter_hint.to_string(), - AcpAvailabilityStatus::AdapterOutdated => adapter_hint.to_string(), - AcpAvailabilityStatus::NotInstalled => { - if !cli_hint.is_empty() && !adapter_hint.is_empty() { - format!("{cli_hint} {adapter_hint}") - } else if !cli_hint.is_empty() { - cli_hint.to_string() - } else { - adapter_hint.to_string() - } - } - }; - - // node_required now means Buzz cannot provide npm for this platform. - // On supported desktop platforms, Buzz downloads a private Node/npm - // runtime into app data before running npm-backed adapter installs. - let node_required = matches!( - availability, - AcpAvailabilityStatus::AdapterMissing | AcpAvailabilityStatus::NotInstalled - ) && runtime_needs_npm(runtime) - && buzz_managed_node_bin_dir().is_none() - && resolve_command("npm").is_none() - && resolve_command("node").is_none(); - - PartialEntry { - runtime, - entry: AcpRuntimeCatalogEntry { - id: runtime.id.to_string(), - label: runtime.label.to_string(), - avatar_url: runtime.avatar_url.to_string(), - availability, - command, - binary_path, - default_args, - mcp_command: runtime.mcp_command.map(str::to_string), - model_env_var: runtime.model_env_var.map(str::to_string), - provider_env_var: runtime.provider_env_var.map(str::to_string), - thinking_env_var: runtime.thinking_env_var.map(str::to_string), - install_hint, - install_instructions_url: runtime.install_instructions_url.to_string(), - can_auto_install, - underlying_cli_path, - node_required, - // Filled in by the probe phase below. - auth_status: AuthStatus::Unknown, - login_hint: None, - }, - } - }) + .map(discover_acp_runtime_phase1) .collect(); // Phase 2: run auth probes in parallel for entries that need them. diff --git a/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs b/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs index 4dfcad4256..2fb6a471d4 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs @@ -20,8 +20,10 @@ pub(crate) struct KnownAcpRuntime { pub cli_install_commands_windows: &'static [&'static str], /// Shell commands to install the ACP adapter (run sequentially, after CLI). pub adapter_install_commands: &'static [&'static str], - /// Link to docs/repo for manual instructions. - pub install_instructions_url: &'static str, + /// Official CLI installation documentation. + pub cli_install_instructions_url: &'static str, + /// ACP adapter installation documentation. + pub adapter_install_instructions_url: &'static str, /// Human-readable hint about installing the CLI binary. pub cli_install_hint: &'static str, /// Human-readable hint about installing the ACP adapter. @@ -78,3 +80,45 @@ impl KnownAcpRuntime { self.cli_install_commands } } + +#[cfg(test)] +mod tests { + use super::super::known_acp_runtime_exact; + + #[test] + fn vendor_metadata_distinguishes_cli_and_adapter_guidance() { + let goose = known_acp_runtime_exact("goose").unwrap(); + assert_eq!( + goose.cli_install_instructions_url, + "https://goose-docs.ai/docs/getting-started/installation/" + ); + assert!(goose.adapter_install_instructions_url.is_empty()); + assert!(goose.cli_install_hint.contains("desktop app alone")); + assert!(goose + .cli_install_commands_windows + .iter() + .any(|command| command.contains("raw.githubusercontent.com/aaif-goose/goose/main"))); + assert!(goose + .cli_install_commands_windows + .iter() + .any(|command| command.contains("$env:CONFIGURE='false'"))); + + let claude = known_acp_runtime_exact("claude").unwrap(); + assert_eq!( + claude.cli_install_instructions_url, + "https://code.claude.com/docs/en/getting-started" + ); + assert!(claude + .adapter_install_instructions_url + .contains("claude-agent-acp")); + assert!(claude.cli_install_hint.contains("desktop app alone")); + + let codex = known_acp_runtime_exact("codex").unwrap(); + assert_eq!( + codex.cli_install_instructions_url, + "https://developers.openai.com/codex/cli/" + ); + assert!(codex.adapter_install_instructions_url.contains("codex-acp")); + assert!(codex.cli_install_hint.contains("desktop app alone")); + } +} diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index 0ed4fe0f6a..364caa452b 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -1104,16 +1104,18 @@ fn test_cli_install_commands_for_os_non_empty_for_claude_codex() { ); } -/// On Windows, Claude and Codex select the PowerShell install commands. +/// On Windows, every vendor CLI selects its official PowerShell installer. #[cfg(windows)] #[test] fn test_cli_install_commands_for_os_selects_powershell_on_windows() { let claude = super::known_acp_runtime_exact("claude").unwrap(); let codex = super::known_acp_runtime_exact("codex").unwrap(); + let goose = super::known_acp_runtime_exact("goose").unwrap(); // Windows must select the PowerShell commands, not the curl|bash ones. let claude_cmds = claude.cli_install_commands_for_os(); let codex_cmds = codex.cli_install_commands_for_os(); + let goose_cmds = goose.cli_install_commands_for_os(); assert_ne!( claude_cmds, claude.cli_install_commands, @@ -1123,6 +1125,7 @@ fn test_cli_install_commands_for_os_selects_powershell_on_windows() { codex_cmds, codex.cli_install_commands, "Windows must NOT use the default curl|bash commands for codex" ); + assert_eq!(goose_cmds, goose.cli_install_commands_windows); // Verify they are the PowerShell installers. assert!( @@ -1133,13 +1136,9 @@ fn test_cli_install_commands_for_os_selects_powershell_on_windows() { codex_cmds.iter().any(|c| c.contains("powershell")), "codex Windows install must use powershell; got: {codex_cmds:?}" ); - - // Goose and buzz-agent must NOT use Windows-specific commands. - let goose = super::known_acp_runtime_exact("goose").unwrap(); - assert_eq!( - goose.cli_install_commands_for_os(), - goose.cli_install_commands, - "goose must use the same commands on all platforms" + assert!( + goose_cmds.iter().any(|c| c.contains("download_cli.ps1")), + "Goose Windows install must use download_cli.ps1; got: {goose_cmds:?}" ); } diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index 87ee6241ee..c04dfa88a4 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -869,7 +869,8 @@ mod tests { cli_install_commands: &[], cli_install_commands_windows: &[], adapter_install_commands: &[], - install_instructions_url: "", + cli_install_instructions_url: "", + adapter_install_instructions_url: "", cli_install_hint: "", adapter_install_hint: "", skill_dir: None, @@ -1063,7 +1064,8 @@ mod tests { cli_install_commands: &[], cli_install_commands_windows: &[], adapter_install_commands: &[], - install_instructions_url: "", + cli_install_instructions_url: "", + adapter_install_instructions_url: "", cli_install_hint: "", adapter_install_hint: "", skill_dir: None, diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 90da759af0..af4908911b 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -585,6 +585,8 @@ pub struct AcpRuntimeCatalogEntry { pub install_instructions_url: String, /// true when at least one automated install step is available pub can_auto_install: bool, + /// true when this runtime depends on a separately installed vendor CLI. + pub requires_external_cli: bool, pub underlying_cli_path: Option, /// true when an npm adapter step is pending but Node.js / npm is absent. /// The UI hides the Install button and shows a Node.js install callout. diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index dc61984487..2af9ddb98c 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -15,7 +15,9 @@ Plan of record: `Buzz/Harness-Provider-Model.md` in Morgan's Obsidian vault declares each harness's model/provider/effort env keys and capabilities. Spawn applies them; `AcpRuntimeCatalogEntry` exposes them over IPC; and `lib/agentConfigCore.ts` projects them into field descriptors. The frontend -never maintains a rival copy of this table. +never maintains a rival copy of this table. Setup guidance follows the same +rule: `requires_external_cli` is derived from `KnownAcpRuntime` and projected +to the UI rather than inferred from a runtime ID in a component. If you need a new capability fact (a new env key, a native option, a "supports X" flag): add it to `KnownAcpRuntime` first, expose it on diff --git a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx index 7556a79ba2..5263920854 100644 --- a/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx +++ b/desktop/src/features/agents/ui/AgentDefinitionDialog.tsx @@ -627,8 +627,8 @@ export function AgentDefinitionDialog({ ? `${selectedRuntime.label} CLI is installed but the ACP adapter is missing.` : selectedRuntime.availability === "adapter_outdated" ? `${selectedRuntime.label} ACP adapter is outdated — reinstall to continue.` - : selectedRuntime.availability === "cli_missing" - ? `${selectedRuntime.label} ACP adapter is installed but the CLI is missing.` + : selectedRuntime.requiresExternalCli + ? `${selectedRuntime.label} CLI is missing. ${selectedRuntime.installHint}` : `${selectedRuntime.label} is not installed.`}{" "} Visit Settings > Agents to set it up.

diff --git a/desktop/src/features/onboarding/ui/SetupStep.tsx b/desktop/src/features/onboarding/ui/SetupStep.tsx index aa02486706..288ca30259 100644 --- a/desktop/src/features/onboarding/ui/SetupStep.tsx +++ b/desktop/src/features/onboarding/ui/SetupStep.tsx @@ -384,8 +384,11 @@ function runtimeDetailText(runtime: AcpRuntimeCatalogEntry): string { if (runtime.availability === "adapter_outdated") { return "ACP adapter detected but outdated — reinstall required."; } - if (runtime.availability === "cli_missing") { - return "ACP adapter detected; CLI missing."; + if ( + runtime.availability === "cli_missing" || + runtime.availability === "not_installed" + ) { + return "CLI not detected; the desktop app alone isn’t enough."; } return ""; } @@ -605,8 +608,8 @@ function RuntimeProvidersSection({ Set up your agent harnesses

- Buzz detected the harnesses available on this machine. Install or sign - in to at least one to continue. + Buzz checks for command-line harnesses on this machine. Install the + CLI or sign in to at least one to continue.

@@ -629,8 +632,8 @@ function RuntimeProvidersSection({ className="max-w-[560px] rounded-2xl bg-white/70 px-6 py-6 text-sm text-muted-foreground" data-testid="onboarding-acp-empty" > - No supported agent harnesses were detected yet. Install Claude Code - or Codex, then check again. + No supported command-line harnesses were detected yet. Install a + supported CLI, then check again.

)} diff --git a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx index 7a0186a7ba..894d88bd22 100644 --- a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx +++ b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx @@ -53,6 +53,13 @@ const RUNTIME_SORT_PRIORITY: Record = { goose: 1, }; +function runtimeInstallGuideLabel(runtime: AcpRuntimeCatalogEntry) { + return runtime.availability === "adapter_missing" || + runtime.availability === "adapter_outdated" + ? "Adapter install guide" + : "CLI setup guide"; +} + function RuntimeLogo({ runtime }: { runtime: AcpRuntimeCatalogEntry }) { const avatarUrl = RUNTIME_LOGO_URLS[runtime.id] ?? runtime.avatarUrl; @@ -131,7 +138,7 @@ function RuntimeOverflowMenu({ onSelect={() => void openUrl(runtime.installInstructionsUrl)} > - Instructions + {runtimeInstallGuideLabel(runtime)} ) : null} @@ -142,7 +149,6 @@ function RuntimeOverflowMenu({ function RuntimeActions({ authMethods, connectingMethodId, - installSuccess, isConnecting, isInstalling, onConnect, @@ -151,7 +157,6 @@ function RuntimeActions({ }: { authMethods: AcpAuthMethod[]; connectingMethodId: string | null; - installSuccess: boolean; isConnecting: boolean; isInstalling: boolean; onConnect: (method: AcpAuthMethod) => void; @@ -160,7 +165,6 @@ function RuntimeActions({ }) { const isAvailable = runtime.availability === "available"; const canInstall = runtime.canAutoInstall && !runtime.nodeRequired; - const isOn = isAvailable || installSuccess; const isWorking = isInstalling || isConnecting; return ( @@ -183,10 +187,10 @@ function RuntimeActions({ ) : ( { if (checked) { onInstall(); @@ -206,7 +210,8 @@ function RuntimeStatusChip({ runtime }: { runtime: AcpRuntimeCatalogEntry }) { ? "Adapter needed" : runtime.availability === "adapter_outdated" ? "Update needed" - : runtime.availability === "cli_missing" + : runtime.availability === "cli_missing" || + runtime.availability === "not_installed" ? "CLI needed" : null; @@ -239,7 +244,6 @@ function RuntimeStatusChip({ runtime }: { runtime: AcpRuntimeCatalogEntry }) { function RuntimeHeader({ authMethods, connectingMethodId, - installSuccess, isConnecting, isInstalling, onConnect, @@ -248,7 +252,6 @@ function RuntimeHeader({ }: { authMethods: AcpAuthMethod[]; connectingMethodId: string | null; - installSuccess: boolean; isConnecting: boolean; isInstalling: boolean; onConnect: (method: AcpAuthMethod) => void; @@ -267,7 +270,6 @@ function RuntimeHeader({ { @@ -391,6 +391,25 @@ function RuntimeRow({ runtime={runtime} /> + {runtime.availability !== "available" ? ( +
+

{runtime.installHint}

+ {runtime.installInstructionsUrl.trim().length > 0 ? ( + + ) : null} +
+ ) : null} + {runtime.authStatus.status === "config_invalid" ? (

) : null} - {installSuccess && runtime.availability !== "available" ? ( -

- {runtime.label} installed. Checking for sign-in options... -

- ) : null} {installError ? (

{ ), ), ); - expect(new Set(rowHeights).size).toBe(1); + expect(rowHeights[2]).toBeGreaterThan(rowHeights[0]); const [gooseColors, codexColors] = await Promise.all( ["goose", "codex"].map((runtimeId) => page.getByTestId(`doctor-runtime-${runtimeId}`).evaluate((element) => { @@ -194,11 +196,11 @@ test.describe("Doctor panel state screenshots", () => { await expect(toggle.locator("span")).toHaveClass(/shadow-none/); } await expect( - page.getByRole("menuitem", { name: "Instructions" }), + page.getByRole("menuitem", { name: "CLI setup guide" }), ).toHaveCount(0); await page.getByTestId("doctor-runtime-menu-codex").click(); await expect( - page.getByRole("menuitem", { name: "Instructions" }), + page.getByRole("menuitem", { name: "CLI setup guide" }), ).toBeVisible(); await waitForAnimations(page); await page.screenshot({ @@ -212,6 +214,17 @@ test.describe("Doctor panel state screenshots", () => { await expect(page.getByTestId("doctor-runtime-codex")).not.toContainText( "Not installed", ); + await expect(page.getByTestId("doctor-runtime-status-codex")).toHaveText( + "CLI needed", + ); + await expect( + page.getByTestId("doctor-runtime-guidance-codex"), + ).toContainText("desktop app alone is not enough"); + await expect( + page + .getByTestId("doctor-runtime-guidance-codex") + .getByRole("button", { name: "CLI setup guide" }), + ).toBeVisible(); await runtimeList.scrollIntoViewIfNeeded(); await waitForAnimations(page); @@ -287,7 +300,7 @@ test.describe("Doctor panel state screenshots", () => { ); await page.getByTestId("doctor-runtime-menu-codex").click(); await expect( - page.getByRole("menuitem", { name: "Instructions" }), + page.getByRole("menuitem", { name: "CLI setup guide" }), ).toBeVisible(); await page.keyboard.press("Escape"); @@ -331,7 +344,7 @@ test.describe("Doctor panel state screenshots", () => { ); await page.getByTestId("doctor-runtime-menu-claude").click(); await expect( - page.getByRole("menuitem", { name: "Instructions" }), + page.getByRole("menuitem", { name: "CLI setup guide" }), ).toBeVisible(); await page.keyboard.press("Escape"); @@ -354,8 +367,9 @@ test.describe("Doctor panel state screenshots", () => { availability: "adapter_missing", underlying_cli_path: "/usr/local/bin/codex", node_required: true, - install_hint: - "Install the Codex ACP adapter: npm install -g @zed-industries/codex-acp", + install_hint: "Install the Codex ACP adapter via npm.", + install_instructions_url: + "https://github.com/agentclientprotocol/codex-acp", }, BUZZ_AGENT_AVAILABLE, ], @@ -373,16 +387,20 @@ test.describe("Doctor panel state screenshots", () => { "Adapter needed", ); await expect(row).not.toContainText("Node.js is required"); - await expect(row).toHaveCSS( - "height", + expect( + await row.evaluate((element) => element.getBoundingClientRect().height), + ).toBeGreaterThan( await page .getByTestId("doctor-runtime-goose") - .evaluate((element) => getComputedStyle(element).height), + .evaluate((element) => element.getBoundingClientRect().height), ); await page.getByTestId("doctor-runtime-menu-codex").click(); await expect( page.getByRole("menuitem", { name: "Install Node.js" }), ).toBeVisible(); + await expect( + page.getByRole("menuitem", { name: "Adapter install guide" }), + ).toBeVisible(); await page.keyboard.press("Escape"); await row.scrollIntoViewIfNeeded(); @@ -395,9 +413,10 @@ test.describe("Doctor panel state screenshots", () => { * * The mock is configured with a two-call sequence: * call 1 → failure (E404) - * call 2 → success + * call 2 → installer exit 0, but post-install discovery still cannot find + * the runtime * This exercises the full retry path: fail state → toggle on again → - * success banner. + * verified failure without a false installed state. */ test("05-retry-after-failure", async ({ page }) => { await installMockBridge(page, { @@ -427,16 +446,26 @@ test.describe("Doctor panel state screenshots", () => { ], }, { - success: true, + success: false, steps: [ { - step: "adapter", - command: "npm install -g @zed-industries/codex-acp", + step: "cli", + command: "powershell.exe install codex", success: true, - stdout: "added 1 package", + stdout: "installed", stderr: "", exit_code: 0, }, + { + step: "verify", + command: "discover codex", + success: false, + stdout: "", + stderr: + "The installer finished, but Buzz still could not use codex (observed: NotInstalled).", + exit_code: null, + hint: "Buzz requires the vendor CLI executable, not only its desktop app. If the CLI was installed while Buzz was open, restart Buzz and check again.", + }, ], }, ], @@ -470,25 +499,71 @@ test.describe("Doctor panel state screenshots", () => { await waitForAnimations(page); await row.screenshot({ path: `${SHOTS}/05-retry-after-failure.png` }); - // Toggle on again — the mock returns success on the second call. + // Toggle on again — the install command exits 0, but verification fails. await toggle.click(); await expect(loading).toBeVisible(); await expect(toggle).toHaveCount(0); - // The error disappears, then the success banner and on state render. + // The runtime remains retryable and never renders a false success state. await expect(loading).toHaveCount(0, { timeout: 5_000 }); - await expect(row).not.toContainText("failed", { timeout: 5_000 }); - await expect( - row.getByText("Codex installed. Checking for sign-in options..."), - ).toBeVisible({ - timeout: 10_000, - }); - await expect(toggle).toBeChecked(); - await expect(toggle).toBeDisabled(); + await expect(row).toContainText("desktop app", { timeout: 5_000 }); + await expect(row).toContainText('Step "verify" failed'); + await expect(row.getByText(/installed\. Checking/)).toHaveCount(0); + await expect(toggle).not.toBeChecked(); + await expect(toggle).toBeEnabled(); await row.scrollIntoViewIfNeeded(); await waitForAnimations(page); - await row.screenshot({ path: `${SHOTS}/05-retry-success.png` }); + await row.screenshot({ path: `${SHOTS}/05-verification-failure.png` }); + }); + + test("05b-verified-install-enables-runtime", async ({ page }) => { + await installMockBridge(page, { + acpRuntimesCatalog: [ + GOOSE_AVAILABLE, + CLAUDE_AVAILABLE_LOGGED_IN, + CODEX_NOT_INSTALLED, + BUZZ_AGENT_AVAILABLE, + ], + acpRuntimesCatalogAfterInstall: [ + GOOSE_AVAILABLE, + CLAUDE_AVAILABLE_LOGGED_IN, + { + ...CODEX_NOT_INSTALLED, + availability: "available", + command: "codex-acp", + binary_path: "/usr/local/bin/codex-acp", + underlying_cli_path: "/usr/local/bin/codex", + auth_status: { status: "logged_in" }, + }, + BUZZ_AGENT_AVAILABLE, + ], + installAcpRuntimeResult: { + success: true, + steps: [ + { + step: "adapter", + command: "npm install -g @agentclientprotocol/codex-acp", + success: true, + stdout: "added 1 package", + stderr: "", + exit_code: 0, + }, + ], + }, + }); + + await page.goto("/", { waitUntil: "domcontentloaded" }); + await openSettings(page, "agents"); + + const toggle = page.getByTestId("doctor-runtime-toggle-codex"); + await expect(toggle).toBeEnabled(); + await toggle.click(); + await expect(toggle).toBeChecked({ timeout: 5_000 }); + await expect(toggle).toBeDisabled(); + await expect(page.getByTestId("doctor-runtime-guidance-codex")).toHaveCount( + 0, + ); }); /** @@ -590,7 +665,7 @@ test.describe("Doctor panel state screenshots", () => { ); await page.getByTestId("doctor-runtime-menu-claude").click(); await expect( - page.getByRole("menuitem", { name: "Instructions" }), + page.getByRole("menuitem", { name: "CLI setup guide" }), ).toBeVisible(); await expect( page.getByRole("menuitem", { name: "Sign in with ChatGPT" }), diff --git a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts index e1d8b11b6f..d007bd6118 100644 --- a/desktop/tests/e2e/onboarding-agent-defaults.spec.ts +++ b/desktop/tests/e2e/onboarding-agent-defaults.spec.ts @@ -84,6 +84,40 @@ test("setup shows only Claude Code and Codex as detected harnesses", async ({ await expect(page.getByRole("checkbox")).toHaveCount(0); }); +test("setup distinguishes a missing CLI from an installed desktop app", async ({ + page, +}) => { + await installMockBridge( + page, + { + acpRuntimesCatalog: [ + runtime( + "codex", + "not_installed", + { status: "unknown" }, + { + install_hint: + "Buzz requires the Codex CLI; the desktop app alone is not enough.", + install_instructions_url: + "https://developers.openai.com/codex/cli/", + }, + ), + ], + }, + { skipCommunitySeed: true, skipOnboardingSeed: true }, + ); + await page.goto("/"); + await navigateToSetupPage(page); + + const card = page.getByTestId("onboarding-runtime-codex"); + await expect(card).toContainText( + "CLI not detected; the desktop app alone isn’t enough.", + ); + await expect(card.getByTestId("onboarding-runtime-install-codex")).toHaveText( + "INSTALL", + ); +}); + test("ready state is detected and enables Next without persisting a default", async ({ page, }) => { From e527d74f069de5d706714d63d99a494389e824af Mon Sep 17 00:00:00 2001 From: joel Date: Sat, 25 Jul 2026 11:54:31 +1000 Subject: [PATCH 008/393] fix(desktop): explain macOS local network access (#2263) ## Summary - add a macOS local-network purpose string - explain that LAN access is for optional Share Compute and local relay connections - clarify that messaging through a remote relay does not need it ## Context macOS currently falls back to its generic Local Network prompt because Buzz's `Info.plist` has no `NSLocalNetworkUsageDescription`. The alert says Buzz can find and collect data from devices without explaining which Buzz features use that capability, which makes an optional feature feel invasive. Buzz can intentionally make local-network connections for Share Compute peers and user-configured local relays. Ordinary messaging through a remote relay does not require that access. Apple recommends that any app using the local network directly or indirectly include `NSLocalNetworkUsageDescription`: https://developer.apple.com/documentation/bundleresources/information-property-list/nslocalnetworkusagedescription This PR improves the disclosure only. It does not change when macOS asks. The exact unexpected trigger should be handled separately once it can be reproduced and attributed. ## Verification - `plutil -lint desktop/src-tauri/Info.plist` - built a debug macOS `.app` bundle and confirmed its final `Info.plist` contains the exact purpose string - `just ci` Signed-off-by: joelbrilliant --- desktop/src-tauri/Info.plist | 2 ++ 1 file changed, 2 insertions(+) diff --git a/desktop/src-tauri/Info.plist b/desktop/src-tauri/Info.plist index c1b7331ebc..cddadcc6e3 100644 --- a/desktop/src-tauri/Info.plist +++ b/desktop/src-tauri/Info.plist @@ -10,5 +10,7 @@ Buzz needs microphone access for voice huddles. NSCameraUsageDescription Buzz needs camera access to record animated avatars. + NSLocalNetworkUsageDescription + Buzz uses your local network for optional Share Compute and local relay connections. Remote messaging does not require this access. From 121033f9daab96501883d0ba8901539cfa970df2 Mon Sep 17 00:00:00 2001 From: Taksh Kothari Date: Sat, 25 Jul 2026 07:40:48 +0530 Subject: [PATCH 009/393] docs: point readme at deploy compose bundle (#2363) ## Summary - production Compose already lives under `deploy/compose/`; root README only described the local-dev stack - add a one-liner so VPS / single-node setups find the right bundle (follow-up to #2323) ## Test plan - [ ] README Quick start links to `deploy/compose/README.md` and clarifies root `docker-compose.yml` is for local dev Made with [Cursor](https://cursor.com) Signed-off-by: Taksh --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 6aa3eeff0b..89a131e73e 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,8 @@ Relay on `ws://localhost:3000`. Desktop app pops up. You're in. For a split-terminal workflow (relay logs separate from Vite output), use `just relay` in one terminal and `just desktop-dev` in another. +Want a single-node / VPS relay instead of the local-dev stack? Use the production Compose bundle in [`deploy/compose/`](deploy/compose/README.md) (`docker compose` + Postgres, Redis, MinIO, optional Caddy/TLS). The root [`docker-compose.yml`](docker-compose.yml) is for day-to-day development only. + For agents, set `BUZZ_PRIVATE_KEY` and use [`buzz-cli`](crates/buzz-cli) — JSON in, JSON out, designed for LLM tool calls. --- From bcca885ba92b74df77e36b8e6c45a54dafc291f9 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 24 Jul 2026 19:11:54 -0700 Subject: [PATCH 010/393] docs: replace VPN-vendor references with generic wording (#2805) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Replace all references to a specific corporate VPN product name with generic "VPN" / "corporate VPN" / "VPN tunnel" / "VPN CLI" language across 15 files (21 lines) - Comment-only and doc-only changes — zero functional impact - Keeps the OSS repo free of vendor-specific assumptions `rg -i warp` returns zero hits after this change (excluding `node_modules`, lockfiles). --- desktop/src-tauri/src/commands/media_download.rs | 2 +- desktop/src-tauri/src/commands/reconnect_hook_config.rs | 2 +- desktop/src-tauri/src/commands/relay_reconnect.rs | 6 +++--- desktop/src-tauri/src/lib.rs | 2 +- desktop/src-tauri/src/media_proxy.rs | 6 +++--- desktop/src/features/messages/ui/MessageReactions.tsx | 2 +- desktop/src/features/profile/lib/selfProfileStorage.ts | 2 +- desktop/src/features/user-status/ui/StatusEmoji.tsx | 2 +- desktop/src/shared/api/relayClientShared.ts | 2 +- desktop/src/shared/api/relayStallWatchdog.test.mjs | 2 +- desktop/src/shared/api/tauriMedia.ts | 2 +- desktop/src/shared/lib/mediaUrl.ts | 4 ++-- desktop/src/shared/lib/relayError.ts | 4 ++-- desktop/tests/e2e/custom-emoji.spec.ts | 2 +- docs/admin/README.md | 2 +- 15 files changed, 21 insertions(+), 21 deletions(-) diff --git a/desktop/src-tauri/src/commands/media_download.rs b/desktop/src-tauri/src/commands/media_download.rs index c182986cba..016865878e 100644 --- a/desktop/src-tauri/src/commands/media_download.rs +++ b/desktop/src-tauri/src/commands/media_download.rs @@ -281,7 +281,7 @@ async fn fetch_blob_bytes_with_cap( state: &State<'_, AppState>, cap: u64, ) -> Result, String> { - // Fetch bytes via the no-redirect media client (goes through WARP tunnel). + // Fetch bytes via the no-redirect media client (goes through the VPN tunnel). // A no-redirect client keeps the minted media auth token from being // forwarded across origins by a relay-issued 3xx (redirect-hop SSRF); a // 3xx is returned verbatim and rejected by the `is_success` check below. diff --git a/desktop/src-tauri/src/commands/reconnect_hook_config.rs b/desktop/src-tauri/src/commands/reconnect_hook_config.rs index e02aafcaed..0fef6ae159 100644 --- a/desktop/src-tauri/src/commands/reconnect_hook_config.rs +++ b/desktop/src-tauri/src/commands/reconnect_hook_config.rs @@ -18,7 +18,7 @@ pub struct ReconnectHookConfig { pub ready_probe: Vec, /// RAW SUBSTRING matched in the probe's stdout to consider the transport /// ready — NOT a parsed field. Pick a token unlikely to collide with other - /// substrings in the probe output (e.g. `warp-cli -j status` JSON, where + /// substrings in the probe output (e.g. a VPN CLI's JSON status output, where /// "Connected" can collide with "Connecting"/"Disconnected"). pub ready_match: String, /// Per-process wall-clock cap (ms) for each step and the readiness probe diff --git a/desktop/src-tauri/src/commands/relay_reconnect.rs b/desktop/src-tauri/src/commands/relay_reconnect.rs index 57c99b20da..69b730db78 100644 --- a/desktop/src-tauri/src/commands/relay_reconnect.rs +++ b/desktop/src-tauri/src/commands/relay_reconnect.rs @@ -4,7 +4,7 @@ //! builds), this command runs an ordered sequence of subprocess steps followed by //! a readiness poll before the frontend fires the relay WebSocket reconnect. //! -//! OSS builds (env var unset) get a pure no-op — zero WARP knowledge compiled in. +//! OSS builds (env var unset) get a pure no-op — zero VPN-specific knowledge compiled in. // Single source of truth for the config schema, shared with build.rs via // `include!`. See reconnect_hook_config.rs for why this is shared, not a module. @@ -43,14 +43,14 @@ pub async fn relay_reconnect_hook() -> Result<(), String> { /// Run a fixed-argv command (`argv[0]` + `argv[1..]`) with a wall-clock cap. /// /// `std::process::Command::output()` blocks until the child exits — a wedged -/// `warp-cli` (the exact degraded-transport case this hook targets) would hang +/// VPN CLI (the exact degraded-transport case this hook targets) would hang /// forever, pinning the blocking-pool thread and leaving the frontend `invoke` /// unresolved. So we spawn, poll `try_wait()` every 500ms, and kill+reap on the /// deadline. Modeled on `media_transcode.rs` `run_ffmpeg_with_timeout`. /// /// stdout/stderr are piped and read only after the child exits. The pipe-buffer /// deadlock noted there (a child blocking on write() when the ~64 KiB OS pipe -/// fills) does not apply: `warp-cli` emits a few lines, far below the buffer. +/// fills) does not apply: these CLIs emit a few lines, far below the buffer. fn run_with_timeout( argv: &[String], timeout: std::time::Duration, diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 3b47866ac4..c5b71987ce 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -461,7 +461,7 @@ pub fn run() { } // Start the localhost media streaming proxy. Uses the shared HTTP - // client so WARP tunnelling applies. The port is stored in AppState + // client so VPN tunnelling applies. The port is stored in AppState // and exposed to the frontend via the `get_media_proxy_port` command. let proxy_client = state.http_client.clone(); let proxy_handle = app_handle.clone(); diff --git a/desktop/src-tauri/src/media_proxy.rs b/desktop/src-tauri/src/media_proxy.rs index 760111cdea..21692ce523 100644 --- a/desktop/src-tauri/src/media_proxy.rs +++ b/desktop/src-tauri/src/media_proxy.rs @@ -150,11 +150,11 @@ pub async fn spawn_media_proxy(http_client: reqwest::Client, app_handle: tauri:: port } -/// Proxy media requests through the Rust backend so they traverse the WARP tunnel. +/// Proxy media requests through the Rust backend so they traverse the VPN tunnel. /// -/// WKWebView's networking stack bypasses WARP, causing 403s from Cloudflare Access. +/// WKWebView's networking stack bypasses the VPN tunnel, causing 403s from Cloudflare Access. /// This handler routes `buzz-media://localhost/{path}` through reqwest, which -/// runs in the Tauri process and goes through WARP. +/// runs in the Tauri process and goes through the VPN. pub async fn handle_buzz_media( app: &tauri::AppHandle, request: &http::Request>, diff --git a/desktop/src/features/messages/ui/MessageReactions.tsx b/desktop/src/features/messages/ui/MessageReactions.tsx index a15bcfa430..2250b3931f 100644 --- a/desktop/src/features/messages/ui/MessageReactions.tsx +++ b/desktop/src/features/messages/ui/MessageReactions.tsx @@ -61,7 +61,7 @@ function isSameBadgeBurstRect( * Render a reaction's emoji: a custom (image) emoji when `emojiUrl` is set, * otherwise the unicode/text glyph. `className` sizes the image to match the * surrounding text. The relay URL is rewritten through the localhost media - * proxy (like every other relay-hosted ) — WKWebView bypasses WARP, so a + * proxy (like every other relay-hosted ) — WKWebView bypasses the VPN tunnel, so a * direct relay URL gets a Cloudflare Access 403 and renders as a broken image. */ function EmojiGlyph({ diff --git a/desktop/src/features/profile/lib/selfProfileStorage.ts b/desktop/src/features/profile/lib/selfProfileStorage.ts index d0a5a4e383..dbc4f88760 100644 --- a/desktop/src/features/profile/lib/selfProfileStorage.ts +++ b/desktop/src/features/profile/lib/selfProfileStorage.ts @@ -1,7 +1,7 @@ /** * Cached self-profile store for relay-unreachable scenarios. * - * When the relay is unreachable (e.g. WARP VPN needs reauth), the app cannot + * When the relay is unreachable (e.g. corporate VPN needs reauth), the app cannot * fetch the user's kind-0 profile. This module persists the last successfully * fetched profile — including a base64 avatar snapshot — so the UI can render * the user's identity even when offline. diff --git a/desktop/src/features/user-status/ui/StatusEmoji.tsx b/desktop/src/features/user-status/ui/StatusEmoji.tsx index 7632cfb267..950143f2b2 100644 --- a/desktop/src/features/user-status/ui/StatusEmoji.tsx +++ b/desktop/src/features/user-status/ui/StatusEmoji.tsx @@ -13,7 +13,7 @@ import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; * Every place that shows a status emoji renders this, so the shortcode→image * resolution can't drift across the (five) display sites — the same reason the * picker is unified. The relay URL is rewritten through the localhost media - * proxy, matching reactions' `EmojiGlyph` (WKWebView bypasses WARP, so a direct + * proxy, matching reactions' `EmojiGlyph` (WKWebView bypasses the VPN tunnel, so a direct * relay URL 403s and renders broken). */ type StatusEmojiProps = { diff --git a/desktop/src/shared/api/relayClientShared.ts b/desktop/src/shared/api/relayClientShared.ts index 76b7448c06..596bdde34e 100644 --- a/desktop/src/shared/api/relayClientShared.ts +++ b/desktop/src/shared/api/relayClientShared.ts @@ -8,7 +8,7 @@ import type { RelayEvent } from "@/shared/api/types"; * - `connected` — socket open and AUTH'd. * - `reconnecting` — socket dropped, waiting for the backoff timer. * - `stalled` — socket is *open* per the WS layer but no inbound frames - * for a long time (half-open / Warp split-brain). We + * for a long time (half-open socket / VPN split-brain). We * surface this so the UI can warn even though tungstenite * hasn't reported anything wrong yet. * - `disconnected` — final/terminal disconnect (auth rejected, community diff --git a/desktop/src/shared/api/relayStallWatchdog.test.mjs b/desktop/src/shared/api/relayStallWatchdog.test.mjs index b160a9435c..94097ca26d 100644 --- a/desktop/src/shared/api/relayStallWatchdog.test.mjs +++ b/desktop/src/shared/api/relayStallWatchdog.test.mjs @@ -44,7 +44,7 @@ test("does not send probes while watching for stalls", async () => { await sleep(45); wd.stop(); // The passive watchdog has no send callback by construction. This test is a - // regression guard for the WARP bug: liveness checks must not write to a + // regression guard for the half-open-socket bug: liveness checks must not write to a // socket already suspected of being half-open. assert.equal(typeof wd.recordInbound, "function"); }); diff --git a/desktop/src/shared/api/tauriMedia.ts b/desktop/src/shared/api/tauriMedia.ts index e56c611b6c..1dab9b7728 100644 --- a/desktop/src/shared/api/tauriMedia.ts +++ b/desktop/src/shared/api/tauriMedia.ts @@ -11,7 +11,7 @@ export async function pickAndUploadImage(): Promise { } /** - * Fetch relay media bytes over IPC (Rust reqwest, WARP-tunneled). + * Fetch relay media bytes over IPC (Rust reqwest, VPN-tunneled). * * Used by the composer image editor: wrapping the bytes in a same-origin * `blob:` URL gives the canvas pixel access without CORS, so the media diff --git a/desktop/src/shared/lib/mediaUrl.ts b/desktop/src/shared/lib/mediaUrl.ts index db6602dc5a..8875920ef2 100644 --- a/desktop/src/shared/lib/mediaUrl.ts +++ b/desktop/src/shared/lib/mediaUrl.ts @@ -1,9 +1,9 @@ /** * Rewrite relay media URLs to use the localhost streaming proxy. * - * WKWebView's networking stack bypasses WARP, so direct requests + * WKWebView's networking stack bypasses the VPN tunnel, so direct requests * to the relay get 403'd by Cloudflare Access. The localhost proxy routes - * fetches through the Rust backend (via reqwest), which goes through WARP. + * fetches through the Rust backend (via reqwest), which goes through the VPN. * * For video, the proxy streams via axum — no buffering the entire file. * Images and other media also benefit from this path. diff --git a/desktop/src/shared/lib/relayError.ts b/desktop/src/shared/lib/relayError.ts index c13b42c6b2..ca16aa41e2 100644 --- a/desktop/src/shared/lib/relayError.ts +++ b/desktop/src/shared/lib/relayError.ts @@ -3,7 +3,7 @@ * * The Rust backend (`desktop/src-tauri/src/relay.rs`) prefixes every * "relay unreachable" error message with this literal string so that the - * frontend can distinguish a transient connectivity failure (e.g. WARP VPN + * frontend can distinguish a transient connectivity failure (e.g. corporate VPN * needs reauth, Cloudflare Access 403) from an application-level error. * * Contract: the Rust layer MUST emit errors starting with exactly this prefix @@ -18,7 +18,7 @@ export const RELAY_UNREACHABLE_MESSAGE = /** * Returns true when `error` carries the stable Rust-layer prefix indicating - * the relay is unreachable (network failure, WARP VPN reauth needed, etc.). + * the relay is unreachable (network failure, VPN reauth needed, etc.). * * Accepts both `Error` instances and raw strings so callers can pass whatever * the Tauri IPC or WebSocket layer hands them without pre-normalizing. diff --git a/desktop/tests/e2e/custom-emoji.spec.ts b/desktop/tests/e2e/custom-emoji.spec.ts index e78ac5737a..aa345570f9 100644 --- a/desktop/tests/e2e/custom-emoji.spec.ts +++ b/desktop/tests/e2e/custom-emoji.spec.ts @@ -167,7 +167,7 @@ test("native emoji-only messages leave space below the author metadata", async ( // // The bug (shipped in the custom-emoji launch, PR #816): the reaction renderer // put the relay emoji URL straight into without going through -// rewriteRelayUrl(). WKWebView bypasses WARP, so the direct relay URL gets a +// rewriteRelayUrl(). WKWebView bypasses the VPN tunnel, so the direct relay URL gets a // Cloudflare Access 403 and shows a broken image — even though the same emoji // rendered fine inline in chat (that path rewrites). The chat path was covered // by the tests above; the reaction path was not, which is why it slipped. diff --git a/docs/admin/README.md b/docs/admin/README.md index a66a53fc9e..e51566fb29 100644 --- a/docs/admin/README.md +++ b/docs/admin/README.md @@ -62,7 +62,7 @@ content retains attachment disposition. Successful reads produce a structured trace containing feedback ID, community ID, and attachment hash, but no feedback body or attachment URL. -The human trust boundary remains the private admin ingress. WARP/source-IP +The human trust boundary remains the private admin ingress. VPN/source-IP admission is not per-operator identity. Anyone admitted to the dashboard can read attachments for feedback records they can access. Per-person attribution or revocation requires authenticated operator identity at ingress/application From 60a171b19efd515d9213b535d52a2bcbec3ff2fe Mon Sep 17 00:00:00 2001 From: Alex Rosenzweig <64241648+shellz-n-stuff@users.noreply.github.com> Date: Fri, 24 Jul 2026 19:24:47 -0700 Subject: [PATCH 011/393] fix(workflow): bypass system proxies for webhooks (#2800) ## Summary Disable automatic system-proxy discovery for workflow webhook requests. ## Why Webhook destinations are resolved, validated, and pinned before the request to prevent DNS-rebinding SSRF. If reqwest uses a system proxy, the proxy can resolve the original hostname itself instead of connecting to the validated address, bypassing that pinning guarantee. Calling `no_proxy()` keeps these security-sensitive requests on the directly validated connection path. Redirects remain disabled. ## Test plan - `cargo fmt --all -- --check` - `cargo test -p buzz-workflow --features reqwest` (149 passed) - `cargo clippy -p buzz-workflow --all-targets --features reqwest -- -D warnings` - `git diff --check` Co-authored-by: Amp --- crates/buzz-workflow/src/executor.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/buzz-workflow/src/executor.rs b/crates/buzz-workflow/src/executor.rs index a029b44622..e30541377e 100644 --- a/crates/buzz-workflow/src/executor.rs +++ b/crates/buzz-workflow/src/executor.rs @@ -805,6 +805,9 @@ async fn call_webhook_impl( // different address than the one validated above (DNS rebinding TOCTOU). let client = Client::builder() .timeout(Duration::from_secs(10)) + // A system proxy would resolve the original hostname itself, bypassing + // the validated and pinned address above. + .no_proxy() // Disable redirects — a redirect to an internal host bypasses the SSRF check. .redirect(reqwest::redirect::Policy::none()) .resolve(host, std::net::SocketAddr::new(safe_ip, port)) From 384c72dee6336234beae3c1a0fec305044815245 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sat, 25 Jul 2026 03:30:33 +0100 Subject: [PATCH 012/393] Refine community management flows (#2738) ## Summary - simplify Add community into clear create and join paths - align community actions and icon editing across the rail, profile menu, and settings - consolidate invites around the member list and a reusable link dialog ## Testing - `pnpm check` - `pnpm test` (3,483 passed) - `pnpm run build:e2e` - focused Playwright coverage (34 passed) ## Snapshots | Add a community | Join an existing community | | --- | --- | | ![Add a community](https://raw.githubusercontent.com/block/buzz/fae692faa77186df7702fb025ca3082c7cc93a8c/pr-2738--01-add-community.png) | ![Join an existing community](https://raw.githubusercontent.com/block/buzz/fae692faa77186df7702fb025ca3082c7cc93a8c/pr-2738--02-join-community.png) | | Create a new community | Hosted community icon | | --- | --- | | ![Create a new community](https://raw.githubusercontent.com/block/buzz/fae692faa77186df7702fb025ca3082c7cc93a8c/pr-2738--03-create-community.png) | ![Hosted community icon](https://raw.githubusercontent.com/block/buzz/fae692faa77186df7702fb025ca3082c7cc93a8c/pr-2738--04-community-icon.png) | | Invites | Invite link | | --- | --- | | ![Invites](https://raw.githubusercontent.com/block/buzz/fae692faa77186df7702fb025ca3082c7cc93a8c/pr-2738--05-invites.png) | ![Invite link](https://raw.githubusercontent.com/block/buzz/fae692faa77186df7702fb025ca3082c7cc93a8c/pr-2738--06-invite-link.png) | --------- Signed-off-by: Wes Co-authored-by: Wes Co-authored-by: Carl --- desktop/playwright.config.ts | 5 +- .../agents/ui/AgentCreationPreview.tsx | 292 ++++++++-- .../agents/ui/AgentCreationPreview.utils.ts | 16 + .../features/communities/relayProbe.test.mjs | 7 +- .../src/features/communities/relayProbe.ts | 13 + .../communities/ui/AddCommunityDialog.tsx | 496 +++++------------ .../ui/CommunityIconSettingsCard.tsx | 194 ++++--- .../communities/ui/CommunitySwitcher.tsx | 77 ++- .../communities/ui/EditCommunityDialog.tsx | 22 + .../ui/HostedCommunityCreateFlow.tsx | 457 ++++++++++++++++ .../ui/CommunityInviteDialog.tsx | 44 ++ .../ui/CommunityMembersSettingsCard.tsx | 507 ++++++------------ .../ui/InviteLinkSection.tsx | 284 ++++------ .../onboarding/ui/InviteRedeemForm.tsx | 205 +++++-- .../profile/ui/MaskedAvatarBadgeFrame.tsx | 129 ++++- .../profile/ui/ProfileAvatarEditor.utils.ts | 9 +- .../features/profile/ui/ProfilePopover.tsx | 61 ++- .../src/features/profile/useAvatarUpload.ts | 40 +- .../ui/HostedCommunitiesSettingsCard.tsx | 34 +- .../features/settings/ui/SettingsPanels.tsx | 6 +- .../src/features/settings/ui/SettingsView.tsx | 16 +- .../src/features/sidebar/ui/AppSidebar.tsx | 3 +- .../src/features/sidebar/ui/CommunityRail.tsx | 33 +- .../sidebar/ui/SidebarProfileCard.tsx | 17 +- desktop/src/shared/api/relayMembers.test.mjs | 20 +- desktop/src/shared/api/relayMembers.ts | 6 +- desktop/src/testing/e2eBridge.ts | 5 + .../e2e/add-community-screenshots.spec.ts | 69 +++ desktop/tests/e2e/community-rail.spec.ts | 184 +++++++ desktop/tests/e2e/deep-link-invite.spec.ts | 33 +- ...d-communities-settings-screenshots.spec.ts | 96 ++++ ...nload.spec.ts => invite-link-copy.spec.ts} | 29 +- .../e2e/invites-settings-screenshots.spec.ts | 100 ++++ desktop/tests/e2e/onboarding.spec.ts | 12 + desktop/tests/e2e/sidebar.spec.ts | 140 ++++- desktop/tests/helpers/settings.ts | 1 + 36 files changed, 2425 insertions(+), 1237 deletions(-) create mode 100644 desktop/src/features/agents/ui/AgentCreationPreview.utils.ts create mode 100644 desktop/src/features/communities/ui/HostedCommunityCreateFlow.tsx create mode 100644 desktop/src/features/community-members/ui/CommunityInviteDialog.tsx create mode 100644 desktop/tests/e2e/add-community-screenshots.spec.ts create mode 100644 desktop/tests/e2e/hosted-communities-settings-screenshots.spec.ts rename desktop/tests/e2e/{invite-qr-download.spec.ts => invite-link-copy.spec.ts} (57%) create mode 100644 desktop/tests/e2e/invites-settings-screenshots.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 5046b29688..aaa1486953 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -30,6 +30,9 @@ export default defineConfig({ "**/badge.spec.ts", "**/channel-browser.spec.ts", "**/channel-add-screenshots.spec.ts", + "**/add-community-screenshots.spec.ts", + "**/hosted-communities-settings-screenshots.spec.ts", + "**/invites-settings-screenshots.spec.ts", "**/messaging.spec.ts", "**/custom-emoji.spec.ts", "**/profile-custom-emoji-status.spec.ts", @@ -102,7 +105,7 @@ export default defineConfig({ "**/channel-sort.spec.ts", "**/identity-lost.spec.ts", "**/deep-link-invite.spec.ts", - "**/invite-qr-download.spec.ts", + "**/invite-link-copy.spec.ts", "**/global-agent-config-screenshots.spec.ts", "**/doctor-states.spec.ts", "**/onboarding-avatar-skip.spec.ts", diff --git a/desktop/src/features/agents/ui/AgentCreationPreview.tsx b/desktop/src/features/agents/ui/AgentCreationPreview.tsx index 6c73d14adc..1bdd93673b 100644 --- a/desktop/src/features/agents/ui/AgentCreationPreview.tsx +++ b/desktop/src/features/agents/ui/AgentCreationPreview.tsx @@ -38,31 +38,29 @@ import { } from "@/shared/ui/popover"; import { Spinner } from "@/shared/ui/spinner"; import { Tabs, TabsList, TabsTrigger } from "@/shared/ui/tabs"; - -function isAvatarFileDrag(event: React.DragEvent) { - return Array.from(event.dataTransfer.types).includes("Files"); -} - -const AVATAR_APPLY_MOTION_TRANSITION = { - duration: 0.14, - ease: [0.23, 1, 0.32, 1], -} as const; - -type AvatarTab = "image" | "emoji"; - -type EmojiMartEmoji = { - native?: string; -}; +import { + AVATAR_APPLY_MOTION_TRANSITION, + type AvatarTab, + type EmojiMartEmoji, + isAvatarFileDrag, +} from "./AgentCreationPreview.utils"; export function AgentCreationPreview({ + assetLabel = "avatar", avatarUrl, disabled = false, hideEditControl = false, label, onClearAvatar, + onCommitAvatar, onUploadPendingChange, onSelectAvatar, + processImage, + shape = "circle", + testIdPrefix = "agent-avatar", + variant = "default", }: { + assetLabel?: string; avatarUrl: string | null; disabled?: boolean; /** When true, omit all upload/edit controls and render the avatar as a @@ -71,8 +69,13 @@ export function AgentCreationPreview({ hideEditControl?: boolean; label: string; onClearAvatar?: () => void; + onCommitAvatar?: (avatarUrl: string) => void; onUploadPendingChange?: (isPending: boolean) => void; onSelectAvatar: (avatarUrl: string) => void; + processImage?: (file: File) => Promise; + shape?: "circle" | "rounded-square"; + testIdPrefix?: string; + variant?: "compact" | "default"; }) { const [isDragOverAvatar, setIsDragOverAvatar] = React.useState(false); const [isAvatarMenuOpen, setIsAvatarMenuOpen] = React.useState(false); @@ -102,6 +105,11 @@ export function AgentCreationPreview({ const emojiPickerContainerRef = React.useRef(null); const emojiMartThemeVars = useEmojiMartThemeVars(); const { burstEmoji } = useEmojiBurst(); + const assetLabelTitle = + assetLabel.charAt(0).toUpperCase() + assetLabel.slice(1); + const isRoundedSquare = shape === "rounded-square"; + const isCompact = variant === "compact"; + const emojiShape = isRoundedSquare ? "rounded-square" : "circle"; const { inputRef: avatarUploadInputRef, isUploading, @@ -111,10 +119,13 @@ export function AgentCreationPreview({ uploadFile: uploadAvatarFile, handleFileChange: handleAvatarUploadFileChange, } = useAvatarUpload({ + fallbackErrorMessage: `Could not use that ${assetLabel}.`, onUploadSuccess: (url) => { onSelectAvatar(url); + onCommitAvatar?.(url); setIsAvatarMenuOpen(false); }, + processImage, }); useEmojiMartStyles( @@ -164,7 +175,11 @@ export function AgentCreationPreview({ if (!isCustomColorPickerOpen || !selectedEmoji) { return; } - const nextAvatarUrl = emojiAvatarDataUrl(selectedEmoji, customColorDraft); + const nextAvatarUrl = emojiAvatarDataUrl( + selectedEmoji, + customColorDraft, + emojiShape, + ); if (avatarUrl === nextAvatarUrl) { return; } @@ -175,6 +190,7 @@ export function AgentCreationPreview({ isCustomColorPickerOpen, onSelectAvatar, selectedEmoji, + emojiShape, ]); function applyAvatarUrl() { @@ -184,14 +200,22 @@ export function AgentCreationPreview({ } clearUploadError(); onSelectAvatar(nextUrl); + onCommitAvatar?.(nextUrl); setIsAvatarMenuOpen(false); } function applyEmojiAvatar(emoji: string, color = selectedColor) { - onSelectAvatar(emojiAvatarDataUrl(emoji, color)); + const nextAvatarUrl = emojiAvatarDataUrl(emoji, color, emojiShape); + onSelectAvatar(nextAvatarUrl); + onCommitAvatar?.(nextAvatarUrl); setSquishKey((key) => key + 1); } + function clearAvatar() { + onClearAvatar?.(); + onCommitAvatar?.(""); + } + function handleColorSelect(swatch: AvatarColorSwatch) { if (disabled) { return; @@ -385,7 +409,7 @@ export function AgentCreationPreview({ > {/* Single drop zone covering the entire popover */}

{ - onClearAvatar(); + clearAvatar(); setIsAvatarMenuOpen(false); }} type="button" > - Remove avatar + Remove {assetLabel} ) : null} @@ -624,7 +648,7 @@ export function AgentCreationPreview({ setCustomValue(nextValue); }} saturation={customSaturation} - testIdPrefix="agent-avatar" + testIdPrefix={testIdPrefix} value={customValue} visible={isCustomColorPickerVisible} /> @@ -634,13 +658,13 @@ export function AgentCreationPreview({ className="flex min-h-8 w-full items-center justify-center rounded-lg text-xs text-destructive outline-hidden transition-colors duration-150 ease-out hover:bg-destructive/10 focus-visible:bg-destructive/10 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50" disabled={disabled} onClick={() => { - onClearAvatar(); + clearAvatar(); setSelectedEmoji(null); setIsAvatarMenuOpen(false); }} type="button" > - Remove avatar + Remove {assetLabel} ) : null} @@ -653,24 +677,60 @@ export function AgentCreationPreview({ // Used when the caller provides its own edit affordance. if (hideEditControl) { return ( -
-
-
+
+
+
{emojiAvatarPreview ? (
- + {emojiAvatarPreview.emoji}
+ ) : isRoundedSquare && avatarUrl ? ( + {`${label} ) : ( )} @@ -681,11 +741,17 @@ export function AgentCreationPreview({ } return ( -
+
-
- {hasAvatar ? ( +
+ {hasAvatar && isRoundedSquare ? ( } - badgeBox={{ bottom: 0, height: 42, right: 0, width: 42 }} - className="h-36 w-36" - cutout={{ cx: 123, cy: 123, r: 24 }} - size={144} + badgeBox={ + isCompact + ? { bottom: 0, height: 28, right: 0, width: 28 } + : { bottom: 0, height: 42, right: 0, width: 42 } + } + className={isCompact ? "h-16 w-16" : "h-36 w-36"} + clipTestId={`${testIdPrefix}-mask`} + cornerRadius={isCompact ? 16 : 32} + cutout={ + isCompact + ? { cx: 58, cy: 58, r: 16.5 } + : { cx: 123, cy: 123, r: 24 } + } + maskMode={isCompact ? "radial" : "clip-path"} + size={isCompact ? 64 : 144} > {emojiAvatarPreview ? (
+ 0 && "buzz-avatar-squish", + )} + key={squishKey} + style={ + { + "--buzz-avatar-emoji-offset-x": "0px", + "--buzz-avatar-emoji-offset-y": "0px", + } as React.CSSProperties + } + > + {emojiAvatarPreview.emoji} + +
+ ) : ( + {`${label} + )} +
+ ) : hasAvatar ? ( + + + + } + badgeBox={ + isCompact + ? { bottom: 0, height: 28, right: 0, width: 28 } + : { bottom: 0, height: 42, right: 0, width: 42 } + } + className={isCompact ? "h-16 w-16" : "h-36 w-36"} + clipTestId={`${testIdPrefix}-mask`} + cutout={ + isCompact + ? { cx: 58, cy: 58, r: 16.5 } + : { cx: 123, cy: 123, r: 24 } + } + maskMode={isCompact ? "radial" : "clip-path"} + size={isCompact ? 64 : 144} + > + {emojiAvatarPreview ? ( +
0 && "buzz-avatar-squish", )} key={squishKey} @@ -762,7 +938,8 @@ export function AgentCreationPreview({ diff --git a/desktop/src/features/agents/ui/AgentCreationPreview.utils.ts b/desktop/src/features/agents/ui/AgentCreationPreview.utils.ts new file mode 100644 index 0000000000..a6ba650557 --- /dev/null +++ b/desktop/src/features/agents/ui/AgentCreationPreview.utils.ts @@ -0,0 +1,16 @@ +import type * as React from "react"; + +export function isAvatarFileDrag(event: React.DragEvent) { + return Array.from(event.dataTransfer.types).includes("Files"); +} + +export const AVATAR_APPLY_MOTION_TRANSITION = { + duration: 0.14, + ease: [0.23, 1, 0.32, 1], +} as const; + +export type AvatarTab = "image" | "emoji"; + +export type EmojiMartEmoji = { + native?: string; +}; diff --git a/desktop/src/features/communities/relayProbe.test.mjs b/desktop/src/features/communities/relayProbe.test.mjs index 0184278f58..6eb0b3b135 100644 --- a/desktop/src/features/communities/relayProbe.test.mjs +++ b/desktop/src/features/communities/relayProbe.test.mjs @@ -76,8 +76,11 @@ test("normalizeRelayUrl_empty_returns_null", () => { assert.equal(normalizeRelayUrl(" "), null); }); -test("normalizeRelayUrl_bare_hostname_returns_null", () => { - assert.equal(normalizeRelayUrl("relay.example.com"), null); +test("normalizeRelayUrl_bare_hostname_adds_secure_scheme", () => { + assert.equal( + normalizeRelayUrl("relay.example.com"), + "wss://relay.example.com", + ); }); test("normalizeRelayUrl_ftp_scheme_returns_null", () => { diff --git a/desktop/src/features/communities/relayProbe.ts b/desktop/src/features/communities/relayProbe.ts index 7e9c2fe69a..efa5bdf281 100644 --- a/desktop/src/features/communities/relayProbe.ts +++ b/desktop/src/features/communities/relayProbe.ts @@ -37,6 +37,19 @@ export function normalizeRelayUrl(input: string): string | null { } } + // Match the legacy add/edit community forms: a scheme-less host is assumed + // to be a secure relay. Validation still rejects whitespace and malformed + // values instead of blindly persisting the prefixed string. + if (!trimmed.includes("://")) { + const wsUrl = `wss://${trimmed}`; + try { + new URL(wsUrl); + return wsUrl; + } catch { + return null; + } + } + return null; } diff --git a/desktop/src/features/communities/ui/AddCommunityDialog.tsx b/desktop/src/features/communities/ui/AddCommunityDialog.tsx index 227804e7b4..e4f105471a 100644 --- a/desktop/src/features/communities/ui/AddCommunityDialog.tsx +++ b/desktop/src/features/communities/ui/AddCommunityDialog.tsx @@ -1,22 +1,10 @@ import * as React from "react"; -import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { ArrowLeft, ChevronRight, Link2, Plus } from "lucide-react"; import type { AddCommunityPrefillRequest } from "@/features/communities/addCommunityPrefill"; -import { - deriveCommunityName, - expandTilde, - normalizeRelayUrl, -} from "@/features/communities/communityStorage"; +import { HostedCommunityCreateFlow } from "@/features/communities/ui/HostedCommunityCreateFlow"; import { useCommunityOnboarding } from "@/features/onboarding/communityOnboarding"; -import { inviteErrorMessage } from "@/shared/api/inviteHelpers"; -import { - acceptJoinPolicy, - getJoinPolicy, - isJoinPolicyDiscoveryCandidate, - type JoinPolicy, -} from "@/shared/api/invites"; -import { validateReposDir } from "@/shared/api/tauri"; -import { Button } from "@/shared/ui/button"; +import { InviteRedeemForm } from "@/features/onboarding/ui/InviteRedeemForm"; import { Dialog, DialogContent, @@ -24,11 +12,6 @@ import { DialogHeader, DialogTitle, } from "@/shared/ui/dialog"; -import { Input } from "@/shared/ui/input"; -import { JoinPolicyNotice } from "@/features/onboarding/ui/JoinPolicyNotice"; - -const POLICY_DISCOVERY_DELAY_MS = 250; -const POLICY_REVEAL_EASE = [0.23, 1, 0.32, 1] as const; type AddCommunityDialogProps = { prefill?: AddCommunityPrefillRequest | null; @@ -39,162 +22,79 @@ type AddCommunityDialogProps = { onOpenChange: (open: boolean) => void; }; +type AddCommunityMode = "choose" | "create" | "join"; + +const OPTION_CLASS = + "flex w-full items-center gap-3 rounded-xl border border-border/70 bg-muted/30 px-4 py-4 text-left transition-colors duration-150 ease-out hover:bg-muted/60 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"; + export function AddCommunityDialog({ prefill, open, onOpenChange, }: AddCommunityDialogProps) { - const [name, setName] = React.useState(""); - const [relayUrl, setRelayUrl] = React.useState(""); - const [token, setToken] = React.useState(""); - const [inviteCode, setInviteCode] = React.useState(""); - const [inviteError, setInviteError] = React.useState(null); - const [joinPolicy, setJoinPolicy] = React.useState(null); - const [ageConfirmed, setAgeConfirmed] = React.useState(false); - const [agreementConfirmed, setAgreementConfirmed] = React.useState(false); - const [reposDir, setReposDir] = React.useState(""); const communityOnboarding = useCommunityOnboarding(); - const [reposDirError, setReposDirError] = React.useState(null); - const shouldReduceMotion = useReducedMotion(); + const [mode, setMode] = React.useState("choose"); + const [joinError, setJoinError] = React.useState(null); const appliedPrefillId = React.useRef(null); React.useEffect(() => { if (!prefill || appliedPrefillId.current === prefill.requestId) return; appliedPrefillId.current = prefill.requestId; - setName(prefill.name ?? deriveCommunityName(prefill.relayUrl)); - setRelayUrl(prefill.relayUrl); - setToken(""); - setInviteCode(""); - setReposDir(""); - setReposDirError(null); + setJoinError(null); + setMode("join"); }, [prefill]); - React.useEffect(() => { - if (!open || !relayUrl.trim()) return; - - const normalizedUrl = normalizeRelayUrl(relayUrl.trim()); - if (!isJoinPolicyDiscoveryCandidate(normalizedUrl)) return; - - let cancelled = false; - const timeoutId = window.setTimeout(() => { - void getJoinPolicy(normalizedUrl) - .then((policy) => { - if (cancelled || !policy) return; - setJoinPolicy(policy); - setAgeConfirmed(false); - setAgreementConfirmed(false); - setInviteError(null); - }) - .catch(() => { - // Background discovery is best-effort. A deliberate submit retries - // the request and surfaces any relay error to the user. - }); - }, POLICY_DISCOVERY_DELAY_MS); - - return () => { - cancelled = true; - window.clearTimeout(timeoutId); - }; - }, [open, relayUrl]); - const handleClose = React.useCallback(() => { onOpenChange(false); - setName(""); - setRelayUrl(""); - setToken(""); - setInviteCode(""); - setInviteError(null); - setJoinPolicy(null); - setAgeConfirmed(false); - setAgreementConfirmed(false); - setReposDir(""); - setReposDirError(null); + setMode("choose"); + setJoinError(null); }, [onOpenChange]); - const handleSubmit = React.useCallback( - async (e: React.FormEvent) => { - e.preventDefault(); - if (!relayUrl.trim()) { - return; - } - - // Expand `~` before save — the backend rejects tilde paths. Empty input - // resolves to `undefined` so REPOS keeps its default location. Validate - // the expanded value (the bytes the backend canonicalizes) before save - // so a bad path is caught here instead of bricking a later boot. - const expandedReposDir = await expandTilde(reposDir); - try { - await validateReposDir(expandedReposDir ?? ""); - } catch (error) { - setReposDirError(String(error)); - return; - } - - const normalizedRelayUrl = normalizeRelayUrl(relayUrl.trim()); - let policyReceipt: string | undefined; - try { - const policy = await getJoinPolicy(normalizedRelayUrl); - if (policy && (!joinPolicy || joinPolicy.version !== policy.version)) { - setJoinPolicy(policy); - setAgeConfirmed(false); - setAgreementConfirmed(false); - setInviteError(null); - return; - } - if (policy?.ageAttestationRequired && !ageConfirmed) { - setInviteError("Confirm that you are at least 18 years old."); - return; - } - if ( - policy && - (policy.termsMarkdown || policy.privacyMarkdown) && - !agreementConfirmed - ) { - setInviteError("Agree to the Terms of Service and Privacy Policy."); - return; - } - - // Receipts are bound to an invite code, so one is only minted when a - // code is present. The claim itself runs on the onboarding - // transaction (useClaimInvite), which forwards this receipt. - if (policy && inviteCode.trim()) { - policyReceipt = await acceptJoinPolicy( - normalizedRelayUrl, - inviteCode.trim(), - policy.version, - ageConfirmed, - ); - } - } catch (error) { - setInviteError(`Community rejected: ${inviteErrorMessage(error)}`); - return; - } - - communityOnboarding.start({ + const startConnection = React.useCallback( + ({ + relayUrl, + inviteCode, + policyReceipt, + token, + }: { + relayUrl: string; + inviteCode?: string; + policyReceipt?: string; + token?: string; + }) => { + const started = communityOnboarding.start({ source: "add-community", - relayUrl: normalizedRelayUrl, - inviteCode: inviteCode.trim() || undefined, - communityName: name.trim() || deriveCommunityName(normalizedRelayUrl), - token: token.trim() || undefined, - reposDir: expandedReposDir, + relayUrl, + inviteCode, + communityName: prefill?.name, policyReceipt, + token, }); + if (!started) { + setJoinError( + "Finish connecting the community already in progress, then try again.", + ); + return; + } handleClose(); }, - [ - name, - relayUrl, - token, - inviteCode, - reposDir, - joinPolicy, - ageConfirmed, - agreementConfirmed, - communityOnboarding, - handleClose, - ], + [communityOnboarding, handleClose, prefill?.name], ); + const title = + mode === "create" + ? "Create a new community" + : mode === "join" + ? "Join an existing community" + : "Add community"; + + const description = + mode === "create" + ? "Opens Builderlab in your browser." + : mode === "join" + ? "Use the community URL or invite link you received." + : "Create a new community or join one you already have."; + return ( { @@ -203,212 +103,100 @@ export function AddCommunityDialog({ }} open={open} > - - - Add Community - - Connect to another Buzz relay. Each community has its own channels, - messages, and identity. + + +
+ {mode !== "choose" ? ( + + ) : null} + {title} +
+ + {description}
-
void handleSubmit(e)} - > -
- - { - setRelayUrl(e.target.value); - setInviteError(null); - setJoinPolicy(null); - setAgeConfirmed(false); - setAgreementConfirmed(false); - }} - placeholder="wss://relay.example.com" - type="text" - value={relayUrl} - /> -
-
- - setName(e.target.value)} - placeholder="My Community" - type="text" - value={name} - /> -
-
- - setToken(e.target.value)} - placeholder="buzz_..." - type="password" - value={token} - /> -
-
- - { - setInviteCode(e.target.value); - setInviteError(null); - setJoinPolicy(null); - setAgeConfirmed(false); - setAgreementConfirmed(false); - }} - placeholder="Paste an invite code for a members-only relay" - type="text" - value={inviteCode} - /> -
-
- - { - setReposDir(e.target.value); - setReposDirError(null); - }} - placeholder="~/Development" - type="text" - value={reposDir} - /> - {reposDirError ? ( -

{reposDirError}

- ) : null} -

- Point the agent's REPOS directory at an existing - folder so agents work in your local checkouts. Leave blank to use - the default location. -

-
-

- Communities share your active identity. To use a different key, - import it on the profile step (or in settings). -

- {inviteError ? ( -

{inviteError}

- ) : null} - - {joinPolicy && relayUrl.trim() ? ( - + {mode === "choose" ? ( +
+ - + + +
+ ) : mode === "join" ? ( + { + setJoinError(null); + setMode("choose"); + }} + onConnect={(relayUrl, token) => + startConnection({ relayUrl, token }) } - type="submit" - > - Add Community - -
- + onRedeem={(relayUrl, inviteCode, policyReceipt) => + startConnection({ relayUrl, inviteCode, policyReceipt }) + } + variant="add-community" + /> + ) : ( + + )} +
); diff --git a/desktop/src/features/communities/ui/CommunityIconSettingsCard.tsx b/desktop/src/features/communities/ui/CommunityIconSettingsCard.tsx index 4f557fd8a3..a9fed1648e 100644 --- a/desktop/src/features/communities/ui/CommunityIconSettingsCard.tsx +++ b/desktop/src/features/communities/ui/CommunityIconSettingsCard.tsx @@ -1,135 +1,121 @@ -import { ImagePlus, Trash2 } from "lucide-react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import * as React from "react"; import { toast } from "sonner"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { AgentCreationPreview } from "@/features/agents/ui/AgentCreationPreview"; import { downscaleIconToDataUrl } from "@/features/communities/lib/downscaleIcon"; import { - useActiveCommunityIcon, communityIconQueryKey, + useActiveCommunityIcon, } from "@/features/communities/useCommunityIcons"; import { useCommunities } from "@/features/communities/useCommunities"; -import { getInitials } from "@/shared/lib/initials"; import { setCommunityIcon } from "@/shared/api/communityProfile"; -import { Button } from "@/shared/ui/button"; - -const ICON_IMAGE_TYPES = ["image/gif", "image/jpeg", "image/png", "image/webp"]; /** - * Admin-only community icon editor, rendered inside the Community Access - * settings section. Publishes a kind:9033 command; the relay stores - * the icon per community and serves it in NIP-11, which every member's - * rail reads. + * Community icon editor using the same image/emoji picker as agent creation. + * Images stay as small inline data URLs so inactive-community rails can render + * them without crossing another relay's authenticated media boundary. */ -export function CommunityIconSettingsCard() { +export function CommunityIconSettingsCard({ + compact = false, +}: { + compact?: boolean; +}) { const { activeCommunity } = useCommunities(); const relayUrl = activeCommunity?.relayUrl; const iconQuery = useActiveCommunityIcon(relayUrl); const queryClient = useQueryClient(); - const inputRef = React.useRef(null); + const persistedIcon = iconQuery.data ?? ""; + const [draftIcon, setDraftIcon] = React.useState(persistedIcon); + const queuedIconRef = React.useRef(undefined); + const isSavingRef = React.useRef(false); + const hasLocalEditRef = React.useRef(false); + + React.useEffect(() => { + if (hasLocalEditRef.current) return; + setDraftIcon(persistedIcon); + }, [persistedIcon]); const mutation = useMutation({ mutationFn: (icon: string) => setCommunityIcon(icon), onSuccess: async (_data, icon) => { - if (relayUrl) { - queryClient.setQueryData(communityIconQueryKey(relayUrl), icon || null); - await queryClient.invalidateQueries({ - queryKey: communityIconQueryKey(relayUrl), - }); - } + if (!relayUrl) return; + queryClient.setQueryData(communityIconQueryKey(relayUrl), icon || null); + await queryClient.invalidateQueries({ + queryKey: communityIconQueryKey(relayUrl), + }); }, }); - async function handleFile(file: File) { - if (!ICON_IMAGE_TYPES.includes(file.type)) { - toast.error("Choose a PNG, JPG, GIF, or WebP image."); - return; - } - try { - const dataUrl = await downscaleIconToDataUrl(file); - await mutation.mutateAsync(dataUrl); - toast.success("Community icon updated"); - } catch (error) { - toast.error( - error instanceof Error - ? error.message - : "Failed to update the community icon.", - ); - } + const mutateIcon = mutation.mutateAsync; + + const persistIcon = React.useCallback( + (icon: string) => { + hasLocalEditRef.current = true; + queuedIconRef.current = icon; + setDraftIcon(icon); + if (isSavingRef.current) return; + + isSavingRef.current = true; + const drainQueue = async () => { + let confirmedIcon = persistedIcon; + let lastAttemptFailed = false; + while (queuedIconRef.current !== undefined) { + const nextIcon = queuedIconRef.current; + queuedIconRef.current = undefined; + try { + await mutateIcon(nextIcon); + confirmedIcon = nextIcon; + lastAttemptFailed = false; + } catch (error) { + lastAttemptFailed = true; + toast.error( + error instanceof Error + ? error.message + : "Couldn’t update the community icon.", + ); + } + } + if (lastAttemptFailed) setDraftIcon(confirmedIcon); + hasLocalEditRef.current = false; + isSavingRef.current = false; + }; + void drainQueue(); + }, + [mutateIcon, persistedIcon], + ); + + function previewIcon(icon: string) { + hasLocalEditRef.current = true; + setDraftIcon(icon); } - async function handleClear() { - try { - await mutation.mutateAsync(""); - toast.success("Community icon removed"); - } catch (error) { - toast.error( - error instanceof Error - ? error.message - : "Failed to remove the community icon.", - ); - } + function clearIconPreview() { + hasLocalEditRef.current = true; + setDraftIcon(""); } - const icon = iconQuery.data ?? null; - const initials = activeCommunity ? getInitials(activeCommunity.name) : ""; - return ( -
- Community icon -
- - {icon ? ( - Community icon - ) : ( - initials || "🐝" - )} - -
- - {icon ? ( - - ) : null} -
- { - const file = event.target.files?.[0]; - event.target.value = ""; - if (file) void handleFile(file); - }} - ref={inputRef} - type="file" - /> -
-

- Shown to every member in the community rail and switcher. Square images - work best. -

+
+
); } diff --git a/desktop/src/features/communities/ui/CommunitySwitcher.tsx b/desktop/src/features/communities/ui/CommunitySwitcher.tsx index 020a2e8838..985d28fa8e 100644 --- a/desktop/src/features/communities/ui/CommunitySwitcher.tsx +++ b/desktop/src/features/communities/ui/CommunitySwitcher.tsx @@ -2,8 +2,11 @@ import { Check, ChevronDown, ChevronRight, + Link2, MoreHorizontal, Plus, + Settings2, + Ticket, WifiOff, } from "lucide-react"; import * as React from "react"; @@ -29,6 +32,7 @@ import { isRelayConnectionDegraded, useRelayConnection, } from "@/shared/api/useRelayConnection"; +import { writeTextToClipboard } from "@/shared/lib/clipboard"; import { useActiveCommunityIcon } from "@/features/communities/useCommunityIcons"; import { EditCommunityDialog } from "./EditCommunityDialog"; @@ -45,6 +49,8 @@ type CommunitySwitcherProps = { activeCommunity: Community | null; communities: Community[]; variant?: "sidebar" | "profile" | "profile-menu"; + canInvite?: boolean; + onInvite?: () => void; onSwitchCommunity: (id: string) => void; onAddCommunity: () => void; onUpdateCommunity: ( @@ -87,6 +93,8 @@ export function CommunitySwitcher({ activeCommunity, communities, variant = "sidebar", + canInvite = false, + onInvite, onSwitchCommunity, onAddCommunity, onUpdateCommunity, @@ -204,7 +212,7 @@ export function CommunitySwitcher({ aria-label={ degraded ? `${activeCommunity?.name ?? "Community"} — ${connectionLabel}` - : "Switch community" + : "Community actions" } className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm text-popover-foreground outline-hidden transition-colors hover:bg-muted/50 focus:bg-muted/50 focus:outline-none focus-visible:bg-muted/50 focus-visible:outline-none data-[state=open]:bg-muted/50 data-[state=open]:text-popover-foreground" data-testid="community-switcher" @@ -221,50 +229,60 @@ export function CommunitySwitcher({ className="w-60 p-1" onMouseEnter={() => scheduleProfileMenu(true)} onMouseLeave={() => scheduleProfileMenu(false)} + onOpenAutoFocus={(event) => event.preventDefault()} side="right" sideOffset={0} > -
- {communities.map((community) => ( -
+
+ {activeCommunity ? ( + <> + {canInvite && onInvite ? ( + + ) : null} -
- ))} -
+
+ + ) : null}
@@ -354,7 +372,7 @@ export function CommunitySwitcher({ - Add Community + Add a community @@ -381,6 +399,7 @@ export function CommunitySwitcher({ onSave={onUpdateCommunity} open={editingCommunity !== null} community={editingCommunity} + showIconEditor={editingCommunity?.id === activeCommunity?.id} /> ); diff --git a/desktop/src/features/communities/ui/EditCommunityDialog.tsx b/desktop/src/features/communities/ui/EditCommunityDialog.tsx index 7176019143..3b963979bc 100644 --- a/desktop/src/features/communities/ui/EditCommunityDialog.tsx +++ b/desktop/src/features/communities/ui/EditCommunityDialog.tsx @@ -1,5 +1,7 @@ import * as React from "react"; +import { CommunityIconSettingsCard } from "@/features/communities/ui/CommunityIconSettingsCard"; +import { useMyRelayMembershipLookupQuery } from "@/features/community-members/hooks"; import type { Community } from "@/features/communities/types"; import { expandTilde, @@ -28,6 +30,7 @@ type EditCommunityDialogProps = { ) => void; onRemove?: (id: string) => void; canRemove?: boolean; + showIconEditor?: boolean; }; export function EditCommunityDialog({ @@ -37,12 +40,20 @@ export function EditCommunityDialog({ onSave, onRemove, canRemove, + showIconEditor = false, }: EditCommunityDialogProps) { const [name, setName] = React.useState(""); const [relayUrl, setRelayUrl] = React.useState(""); const [token, setToken] = React.useState(""); const [reposDir, setReposDir] = React.useState(""); const [reposDirError, setReposDirError] = React.useState(null); + const membershipQuery = useMyRelayMembershipLookupQuery(); + const activeRole = membershipQuery.data?.membership?.role; + const canEditIcon = + showIconEditor && + (membershipQuery.data?.membershipRequired === false || + activeRole === "owner" || + activeRole === "admin"); // Sync form state when the dialog opens with a community React.useEffect(() => { @@ -135,6 +146,17 @@ export function EditCommunityDialog({ className="flex flex-col gap-4" onSubmit={(e) => void handleSubmit(e)} > + {canEditIcon ? ( +
+
+

Community icon

+

+ Shown in the community rail and switcher. +

+
+ +
+ ) : null}
diff --git a/desktop/src/features/agents/ui/RelayDirectorySection.tsx b/desktop/src/features/agents/ui/RelayDirectorySection.tsx deleted file mode 100644 index ca4fe58030..0000000000 --- a/desktop/src/features/agents/ui/RelayDirectorySection.tsx +++ /dev/null @@ -1,159 +0,0 @@ -import * as React from "react"; -import { ChevronDown, ChevronRight, Search } from "lucide-react"; - -import type { RelayAgent } from "@/shared/api/types"; -import { PresenceBadge } from "@/features/presence/ui/PresenceBadge"; -import { Card } from "@/shared/ui/card"; -import { Input } from "@/shared/ui/input"; -import { truncatePubkey } from "@/shared/lib/pubkey"; - -export function RelayDirectorySection({ - error, - isLoading, - managedPubkeys, - relayAgents, -}: { - error: Error | null; - isLoading: boolean; - managedPubkeys: Set; - relayAgents: RelayAgent[]; -}) { - const [isExpanded, setIsExpanded] = React.useState(false); - const [searchQuery, setSearchQuery] = React.useState(""); - - // Only show agents that are NOT managed locally — those are already in the - // managed agents section above. - const otherAgents = React.useMemo( - () => relayAgents.filter((agent) => !managedPubkeys.has(agent.pubkey)), - [relayAgents, managedPubkeys], - ); - - const filteredAgents = React.useMemo(() => { - if (!searchQuery.trim()) return otherAgents; - const query = searchQuery.toLowerCase(); - return otherAgents.filter( - (agent) => - agent.name.toLowerCase().includes(query) || - agent.agentType.toLowerCase().includes(query) || - agent.channels.some((ch) => ch.toLowerCase().includes(query)), - ); - }, [otherAgents, searchQuery]); - - const sortedAgents = React.useMemo( - () => - [...filteredAgents].sort((left, right) => - left.name.localeCompare(right.name), - ), - [filteredAgents], - ); - - if (isLoading || otherAgents.length === 0) return null; - - return ( -
-
- -

- View agents other members have shared in this community. -

-
- - {isExpanded ? ( - <> -
- - setSearchQuery(event.target.value)} - placeholder="Search by name, type, or channel..." - value={searchQuery} - /> -
- - {sortedAgents.length === 0 ? ( -

- {searchQuery.trim() - ? "No agents match your search." - : "No other agents in this community."} -

- ) : ( - -
- - - - - - - - - - - {sortedAgents.map((agent) => ( - - - - - - - ))} - -
AgentStatusTypeChannels
-
-

- {agent.name} -

-

- {truncatePubkey(agent.pubkey)} -

-
-
- - - {agent.agentType || "Unknown"} - - - {agent.channels.length > 0 - ? agent.channels.join(", ") - : "No visible channel memberships"} - -
-
-
- )} - - ) : null} - - {error ? ( -

- {error.message} -

- ) : null} -
- ); -} From cc6c4d3471629fad018bcf645f9471a01b9ffe2f Mon Sep 17 00:00:00 2001 From: Daniel Cadenas Date: Sat, 25 Jul 2026 23:49:30 -0300 Subject: [PATCH 035/393] fix(desktop): make Linux AppImage GStreamer work on non-Debian distros (#2176) --- .github/workflows/release.yml | 2 +- desktop/scripts/fix-appimage.sh | 103 +++++++++++++----- .../src-tauri/src/managed_agents/runtime.rs | 9 +- .../src/managed_agents/runtime/tests.rs | 7 ++ 4 files changed, 92 insertions(+), 29 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d3e20c393d..c613924e57 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -629,7 +629,7 @@ jobs: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - - name: Fix AppImage (remove infra libs, symlink system GStreamer) + - name: Fix AppImage (remove infra libs, shim host GStreamer) run: | mapfile -t APPIMAGES < <(find desktop/src-tauri/target/release/bundle/appimage -name '*.AppImage' -type f) if [[ ${#APPIMAGES[@]} -eq 0 ]]; then diff --git a/desktop/scripts/fix-appimage.sh b/desktop/scripts/fix-appimage.sh index 45f537bb72..c51b85723a 100755 --- a/desktop/scripts/fix-appimage.sh +++ b/desktop/scripts/fix-appimage.sh @@ -19,12 +19,19 @@ # skew causes eglGetDisplay to return EGL_BAD_PARAMETER under Wayland, which # WebKitWebProcess treats as fatal and aborts before the window ever appears. # -# 2. GStreamer crash: linuxdeploy also bundles libgst*.so* (GStreamer core libs). -# AppRun unconditionally sets GST_PLUGIN_SYSTEM_PATH_1_0 to a dir inside the -# AppImage that the bundler never populates (bundleMediaFramework is false by -# default), so GStreamer's plugin discovery yields an empty registry. The -# "GStreamer element appsink not found" error kills the render process; as a -# side effect the broken run poisons ~/.cache/gstreamer-1.0/registry.x86_64.bin. +# 2. GStreamer crash: linuxdeploy's compiled AppRun.wrapped force-sets +# GST_PLUGIN_SYSTEM_PATH_1_0 to $APPDIR/usr/lib/gstreamer-1.0 -- a dir the +# bundler never populates (bundleMediaFramework is off, and we strip the +# bundled libgst* core below to use the host's). Crucially, once that variable +# is set it *replaces* GStreamer's compiled-in default search path rather than +# adding to it, so the app finds ZERO plugins on every distro: +# "GStreamer element appsink not found" kills the WebKitWebProcess and the +# window never paints. An earlier revision of this script hid the failure on +# Debian only by symlinking usr/lib/gstreamer-1.0 to the Debian multiarch dir +# (/usr/lib/x86_64-linux-gnu/gstreamer-1.0); that symlink dangles on Arch and +# Fedora, and the "safe fallback to default discovery" it assumed does not +# exist -- a set GST_PLUGIN_SYSTEM_PATH_1_0 disables the default. A broken run +# also poisons ~/.cache/gstreamer-1.0/registry.x86_64.bin. # # 3. WebKit helper mismatch (latent): the bundled WebKit helpers # (WebKitNetworkProcess/WebKitWebProcess) have RUNPATH=$ORIGIN only, and @@ -35,11 +42,17 @@ # wrong -- spawning nothing, dying on unresolved bundled libs, or spawning # the system helpers -- and the window never appears. # -# Fix: remove the offending libs so the app uses the system copies (which are -# newer and ABI-compatible on any distro shipping glib >= 2.72 / Ubuntu 22.04+), -# and symlink the system GStreamer plugin directory so discovery works correctly. -# No tauri.conf.json knob can do this — bundle.linux.appimage only exposes -# bundleMediaFramework, files (copy-only, no remove/symlink), and bundleXdgOpen. +# Fix: (a) remove the offending libs so the app uses the system copies (newer and +# ABI-compatible on any distro shipping glib >= 2.72 / Ubuntu 22.04+), and +# (b) install a launcher shim in front of the app binary that strips the +# bundle-pointing GST_PLUGIN_* overrides AppRun.wrapped injects, letting the host +# GStreamer resolve plugins via its own default path (correct on Debian, Arch, and +# Fedora alike). The shim has to run *after* AppRun.wrapped: the wrapper rewrites +# the variable last -- after every apprun-hook -- so any value set before it is +# discarded (verified empirically; a runtime GST_PLUGIN_SYSTEM_PATH_1_0 passed +# into the AppImage does not survive). No tauri.conf.json knob can do this -- +# bundle.linux.appimage only exposes bundleMediaFramework, files (copy-only, no +# remove/symlink), and bundleXdgOpen. set -euo pipefail @@ -61,16 +74,6 @@ APPIMAGE_NAME="$(basename "$APPIMAGE_ABS")" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" DESKTOP_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -# Detect multiarch triplet for GStreamer plugin path. -case "$(uname -m)" in - x86_64) MULTIARCH="x86_64-linux-gnu" ;; - aarch64) MULTIARCH="aarch64-linux-gnu" ;; - *) - echo "Error: unsupported architecture: $(uname -m)" >&2 - exit 1 - ;; -esac - WORKDIR="$(mktemp -d)" trap 'rm -rf "$WORKDIR"' EXIT @@ -107,12 +110,58 @@ rm -f \ "$LIBDIR"/libelf.so* \ "$LIBDIR"/libffi.so* -echo "==> Symlinking system GStreamer plugin directory" -# On distros without the Debian multiarch layout (e.g. Arch), this symlink -# dangles — GStreamer then falls back to its default plugin discovery, which -# is a safe degradation (unlike the original empty in-bundle dir). -rm -rf "$LIBDIR/gstreamer-1.0" -ln -s "/usr/lib/$MULTIARCH/gstreamer-1.0" "$LIBDIR/gstreamer-1.0" +echo "==> Installing GStreamer launcher shim on the app binary" +# AppRun.wrapped force-sets GST_PLUGIN_SYSTEM_PATH_1_0 (and the 0.10-era +# GST_PLUGIN_SYSTEM_PATH) to $APPDIR/usr/lib/gstreamer-1.0 — a dir we bundle no +# plugins into. Because a set path *replaces* GStreamer's default instead of +# extending it, the app finds zero plugins on any distro and WebKit aborts. The +# wrapper rewrites the variable after every apprun-hook, so the only place to undo +# it is a shim between AppRun.wrapped and the real binary. First confirm the +# wrapper still injects the override; if a tauri/linuxdeploy bump drops it, the +# shim becomes a harmless no-op, but we want a human to re-verify rather than +# silently ship — so fail loudly (mirrors the libwayland guard above). +APPRUN_WRAPPED="$WORKDIR/squashfs-root/AppRun.wrapped" +if ! grep -aq "GST_PLUGIN_SYSTEM_PATH_1_0" "$APPRUN_WRAPPED"; then + echo "Error: AppRun.wrapped is missing or no longer references GST_PLUGIN_SYSTEM_PATH_1_0 — GStreamer path injection changed; re-verify fix-appimage.sh" >&2 + exit 1 +fi + +APP_BIN="$WORKDIR/squashfs-root/usr/bin/buzz-desktop" +if [[ ! -f "$APP_BIN" ]]; then + echo "Error: app binary usr/bin/buzz-desktop not found — bundler layout changed; update fix-appimage.sh" >&2 + exit 1 +fi +if [[ -e "$APP_BIN.bin" ]]; then + echo "Error: usr/bin/buzz-desktop.bin already exists — shim already installed?" >&2 + exit 1 +fi + +# The real binary moves aside; buzz-desktop becomes a shim AppRun.wrapped execs. +mv "$APP_BIN" "$APP_BIN.bin" +cat > "$APP_BIN" <<'SHIM' +#!/usr/bin/env bash +# GStreamer shim installed by desktop/scripts/fix-appimage.sh. +# +# linuxdeploy's AppRun.wrapped force-sets GST_PLUGIN_SYSTEM_PATH_1_0 to an empty +# in-bundle dir ($APPDIR/usr/lib/gstreamer-1.0). A set path *replaces* the host's +# default GStreamer search path, so the app finds zero plugins and WebKit aborts +# (blank window). Drop the bundle-pointing GST_PLUGIN_* overrides so the system +# GStreamer — which we use, having removed the bundled core libs — resolves +# plugins via its own default path on any distro. Values that don't point into +# this AppImage are the user's own and are preserved. +here="$(dirname "$(readlink -f "$0")")" +appdir="$(readlink -f "$here/../..")" +for var in GST_PLUGIN_SYSTEM_PATH_1_0 GST_PLUGIN_SYSTEM_PATH \ + GST_PLUGIN_PATH_1_0 GST_PLUGIN_PATH \ + GST_PLUGIN_SCANNER GST_PLUGIN_SCANNER_1_0; do + val="${!var-}" + if [[ -n "$val" && "$val" == *"$appdir/"* ]]; then + unset "$var" + fi +done +exec -a "buzz-desktop" "$here/buzz-desktop.bin" "$@" +SHIM +chmod +x "$APP_BIN" echo "==> Repacking AppImage" # Pass a pinned type2 runtime when provided (CI sets APPIMAGETOOL_RUNTIME_FILE); diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 6687cbbcf2..44cee49aeb 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -855,7 +855,14 @@ pub(crate) fn collect_same_instance_orphans( /// Binary names for the Buzz desktop/Tauri process. Used by dead-instance /// detection to confirm the owning desktop is still alive. -const DESKTOP_BINARY_NAMES: &[&str] = &["Buzz", "buzz-desktop", "buzz_desktop"]; +const DESKTOP_BINARY_NAMES: &[&str] = &[ + "Buzz", + "buzz-desktop", + "buzz_desktop", + // Linux limits /proc//comm to 15 visible bytes, truncating the + // AppImage shim's real executable name, `buzz-desktop.bin`. + "buzz-desktop.bi", +]; /// Check if a process name matches a known Buzz desktop binary. fn is_desktop_binary(name: &str) -> bool { diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index fd758047c3..019bfe61d3 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -1,5 +1,12 @@ use crate::managed_agents::known_acp_runtime; +// ── desktop binary name tests ─────────────────────────────────────────── + +#[test] +fn appimage_binary_matches_truncated_linux_comm_name() { + assert!(super::is_desktop_binary("buzz-desktop.bi")); +} + // ── buffer_contains_identifier tests ──────────────────────────────────── #[test] From c2a4ee711e481bb427d6cf8cd08b2c7329d1508c Mon Sep 17 00:00:00 2001 From: Matthew Sweeney Date: Sat, 25 Jul 2026 19:51:09 -0700 Subject: [PATCH 036/393] Fix formatting in README.md diagram (#2284) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 89a131e73e..72af92ce13 100644 --- a/README.md +++ b/README.md @@ -188,7 +188,7 @@ If you'd rather point buzz at a different bash-compatible shell, set `BUZZ_SHELL ┌─────────────────────────────────────────────────────────────────────────┐ │ buzz-relay │ │ NIP-01 · NIP-42 auth · channel/DM/media/workflow/git REST · audit log │ -└───┬──────────────────────────┬──────────────────────────┬──────────────┘ +└───┬──────────────────────────┬──────────────────────────┬───────────────┘ │ │ │ ┌──▼───────────┐ ┌──────▼──────┐ ┌───────▼─────┐ │ Postgres │ │ Redis │ │ S3/MinIO │ From dd222a509b156ba52ed3219e895d7bf1cf322c92 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sun, 26 Jul 2026 08:57:31 +0100 Subject: [PATCH 037/393] Refine mobile settings and themes (#2844) ## Summary - reorganize mobile settings around profile, appearance, and connection cards - add System/Light/Dark theme pairing, accent selection, and the Buzz gradient theme - align avatar badges, status editing, and supporting mobile chrome ## Test plan - `just mobile-check` - `just mobile-test` --- mobile/lib/app.dart | 35 +- .../channels/channel_management_provider.dart | 4 +- .../lib/features/channels/channels_page.dart | 36 +- .../channels/channels_page/channel_tile.dart | 41 +- mobile/lib/features/channels/compose_bar.dart | 4 +- .../lib/features/channels/emoji_picker.dart | 6 +- .../features/channels/message_actions.dart | 4 +- .../features/channels/message_content.dart | 6 +- .../lib/features/channels/reaction_row.dart | 2 +- .../features/channels/timeline_message.dart | 2 +- mobile/lib/features/forum/forum_provider.dart | 4 +- mobile/lib/features/home/home_page.dart | 10 +- .../lib/features/profile/profile_avatar.dart | 71 ++-- .../features/profile/set_status_sheet.dart | 281 +++++--------- .../profile/settings_profile_header.dart | 157 ++++++++ mobile/lib/features/pulse/note_card.dart | 2 +- mobile/lib/features/pulse/pulse_actions.dart | 4 +- .../features/settings/accent_picker_page.dart | 74 ++++ .../lib/features/settings/settings_page.dart | 309 ++------------- .../settings_page/appearance_section.dart | 138 +++++++ .../settings_page/connection_section.dart | 103 +++++ .../features/settings/theme_picker_page.dart | 280 +++++++------- .../custom_emoji/custom_emoji.dart | 2 +- .../custom_emoji/custom_emoji_provider.dart | 2 +- .../custom_emoji/custom_emoji_render.dart | 2 +- mobile/lib/shared/theme/app_colors.dart | 29 +- mobile/lib/shared/theme/app_theme.dart | 13 +- mobile/lib/shared/theme/buzz_theme.dart | 49 +++ mobile/lib/shared/theme/theme.dart | 2 + mobile/lib/shared/theme/theme_catalog.dart | 20 + mobile/lib/shared/theme/theme_pairs.dart | 115 ++++++ mobile/lib/shared/theme/theme_provider.dart | 203 ++++++++-- mobile/lib/shared/widgets/app_list.dart | 112 +++--- mobile/lib/shared/widgets/app_list_card.dart | 85 +++++ mobile/lib/shared/widgets/app_list_inset.dart | 23 ++ .../lib/shared/widgets/frosted_app_bar.dart | 13 +- .../shared/widgets/masked_avatar_badge.dart | 356 ++++++++++++++++++ .../features/channels/channels_page_test.dart | 24 +- .../features/channels/compose_bar_test.dart | 4 +- mobile/test/features/home/home_page_test.dart | 17 +- .../features/profile/profile_avatar_test.dart | 104 +++++ .../profile/set_status_sheet_test.dart | 72 ++++ .../profile/settings_profile_header_test.dart | 63 ++++ .../settings/theme_picker_page_test.dart | 203 ++++++++++ .../custom_emoji/custom_emoji_test.dart | 2 +- .../test/shared/theme/accent_colors_test.dart | 4 +- mobile/test/shared/theme/buzz_theme_test.dart | 196 ++++++++++ .../test/shared/theme/theme_pairs_test.dart | 254 +++++++++++++ mobile/test/shared/widgets/app_list_test.dart | 124 ++++++ .../widgets/masked_avatar_badge_test.dart | 147 ++++++++ 50 files changed, 3037 insertions(+), 776 deletions(-) create mode 100644 mobile/lib/features/profile/settings_profile_header.dart create mode 100644 mobile/lib/features/settings/accent_picker_page.dart create mode 100644 mobile/lib/features/settings/settings_page/appearance_section.dart create mode 100644 mobile/lib/features/settings/settings_page/connection_section.dart rename mobile/lib/{features => shared}/custom_emoji/custom_emoji.dart (99%) rename mobile/lib/{features => shared}/custom_emoji/custom_emoji_provider.dart (98%) rename mobile/lib/{features => shared}/custom_emoji/custom_emoji_render.dart (98%) create mode 100644 mobile/lib/shared/theme/buzz_theme.dart create mode 100644 mobile/lib/shared/theme/theme_pairs.dart create mode 100644 mobile/lib/shared/widgets/app_list_card.dart create mode 100644 mobile/lib/shared/widgets/app_list_inset.dart create mode 100644 mobile/lib/shared/widgets/masked_avatar_badge.dart create mode 100644 mobile/test/features/profile/profile_avatar_test.dart create mode 100644 mobile/test/features/profile/set_status_sheet_test.dart create mode 100644 mobile/test/features/profile/settings_profile_header_test.dart create mode 100644 mobile/test/features/settings/theme_picker_page_test.dart rename mobile/test/{features => shared}/custom_emoji/custom_emoji_test.dart (98%) create mode 100644 mobile/test/shared/theme/buzz_theme_test.dart create mode 100644 mobile/test/shared/theme/theme_pairs_test.dart create mode 100644 mobile/test/shared/widgets/app_list_test.dart create mode 100644 mobile/test/shared/widgets/masked_avatar_badge_test.dart diff --git a/mobile/lib/app.dart b/mobile/lib/app.dart index 38a7e60aee..3d6a562a92 100644 --- a/mobile/lib/app.dart +++ b/mobile/lib/app.dart @@ -10,6 +10,8 @@ import 'features/pairing/pairing_page.dart'; import 'features/channels/agent_activity/observer_subscription.dart'; import 'features/channels/deep_link_dispatcher.dart'; import 'features/profile/user_status_cache_provider.dart'; +import 'features/profile/settings_profile_header.dart'; +import 'features/settings/settings_page.dart'; import 'shared/auth/auth.dart'; import 'shared/deeplink/pending_deep_link_provider.dart'; import 'shared/relay/relay.dart'; @@ -25,13 +27,25 @@ class App extends HookConsumerWidget { final schemeName = ref.watch(schemeProvider); final authState = ref.watch(authProvider); - final resolved = resolveSchemes(schemeName); + final resolved = resolveSchemes(schemeName, themeMode); final lightScheme = applyAccent(resolved.light, accentIndex); final darkScheme = applyAccent(resolved.dark, accentIndex); - // Default and named schemes can force light or dark mode; otherwise - // respect the user's ThemeMode preference. + // Light/Dark modes pin the brightness; System leaves it null so Flutter + // follows the OS across the selected theme and its pair. final effectiveMode = resolved.forcedMode ?? themeMode; + // Derive the gradient from the themes that produced each color scheme. + // This keeps fallbacks and pinned brightness changes aligned with the + // rendered palette rather than the raw persisted selection. + final buzzLightGradient = buzzTopSectionGradient( + resolved.lightTheme?.name ?? '', + lightScheme.brightness, + ); + final buzzDarkGradient = buzzTopSectionGradient( + resolved.darkTheme?.name ?? '', + darkScheme.brightness, + ); + // Eagerly initialize websocket session and lifecycle observer when // authenticated. These providers connect and manage the websocket. if (authState.value?.status == AuthStatus.authenticated) { @@ -65,15 +79,21 @@ class App extends HookConsumerWidget { return MaterialApp( title: 'Buzz', - theme: AppTheme.light(colorScheme: lightScheme), - darkTheme: AppTheme.dark(colorScheme: darkScheme), + theme: AppTheme.light( + colorScheme: lightScheme, + topSectionGradient: buzzLightGradient, + ), + darkTheme: AppTheme.dark( + colorScheme: darkScheme, + topSectionGradient: buzzDarkGradient, + ), themeMode: effectiveMode, home: authState.when( loading: () => const _SplashScreen(), error: (_, _) => const PairingPage(), data: (state) => switch (state.status) { AuthStatus.authenticated => const DeepLinkDispatcher( - child: HomePage(), + child: HomePage(settingsPageBuilder: _buildSettingsPage), ), _ => const DeepLinkDispatcher( dispatchMessageLinks: false, @@ -85,6 +105,9 @@ class App extends HookConsumerWidget { } } +Widget _buildSettingsPage(BuildContext context) => + const SettingsPage(profileHeader: SettingsProfileHeader()); + class _SplashScreen extends StatelessWidget { const _SplashScreen(); diff --git a/mobile/lib/features/channels/channel_management_provider.dart b/mobile/lib/features/channels/channel_management_provider.dart index d88de261c3..9a72054a2b 100644 --- a/mobile/lib/features/channels/channel_management_provider.dart +++ b/mobile/lib/features/channels/channel_management_provider.dart @@ -5,10 +5,10 @@ import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/auth/auth.dart'; +import '../../shared/custom_emoji/custom_emoji.dart'; +import '../../shared/custom_emoji/custom_emoji_provider.dart'; import '../../shared/relay/relay.dart'; import '../profile/profile_provider.dart'; -import '../custom_emoji/custom_emoji.dart'; -import '../custom_emoji/custom_emoji_provider.dart'; import 'channel.dart'; import 'channels_provider.dart'; diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index 674a498af8..df92a51193 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -15,12 +15,11 @@ import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; -import '../custom_emoji/custom_emoji.dart'; -import '../custom_emoji/custom_emoji_provider.dart'; -import '../custom_emoji/custom_emoji_render.dart'; +import '../../shared/custom_emoji/custom_emoji.dart'; +import '../../shared/custom_emoji/custom_emoji_provider.dart'; +import '../../shared/custom_emoji/custom_emoji_render.dart'; import '../profile/profile_avatar.dart'; import '../profile/profile_provider.dart'; -import '../settings/settings_page.dart'; import '../profile/presence_cache_provider.dart'; import '../profile/user_cache_provider.dart'; import '../pairing/pairing_page.dart'; @@ -62,6 +61,19 @@ const double _kChannelLabelGap = Grid.xxs; const double _kChannelRowVerticalPadding = Grid.xxs + Grid.quarter; const double _kChannelLabelInset = _kChannelSectionInset + _kChannelLeadingWidth + _kChannelLabelGap; + +/// DM avatars are circles, so they fill their box edge to edge where a channel +/// glyph leaves 4dp of slack inside the same 22dp leading column. Sizing them to +/// the glyph's ink width keeps the icon-to-label distance identical across both +/// sections while the labels stay on [_kChannelLabelInset]. +const double _kDmAvatarSize = _kChannelIconSize; + +/// The top section's avatars are 32dp circles, which fill their box edge to +/// edge; the channel rows below lead with an 18dp glyph left-aligned in a 22dp +/// box at [_kChannelSectionInset]. Edge-aligning the two leaves the circles +/// looking pushed outward, so the bar is pulled in to sit the avatar's centre +/// on the channel-icon column (12 + 16 = 28dp against the glyph's ~29dp). +const double _kTopSectionInset = Grid.twelve; const Duration _kSectionExpandDuration = Duration(milliseconds: 220); const Duration _kSectionCollapseDuration = Duration(milliseconds: 170); const Curve _kSectionExpandCurve = Cubic(0.23, 1, 0.32, 1); @@ -129,7 +141,9 @@ _UnreadChannelState _computeUnreadChannelState({ } class ChannelsPage extends HookConsumerWidget { - const ChannelsPage({super.key}); + const ChannelsPage({required this.settingsPageBuilder, super.key}); + + final WidgetBuilder settingsPageBuilder; @override Widget build(BuildContext context, WidgetRef ref) { @@ -208,7 +222,11 @@ class ChannelsPage extends HookConsumerWidget { return FrostedScaffold( appBar: FrostedAppBar( - horizontalInset: _kChannelSectionInset, + horizontalInset: _kTopSectionInset, + // Under a Buzz theme the community + account avatar strip carries the + // branded gradient, the way desktop paints it across the sidebar. Null + // under every other theme, leaving the default frosted fill. + gradient: context.appColors.topSectionGradient, leading: _CommunityIndicator( onTap: () => showModalBottomSheet( context: context, @@ -219,9 +237,9 @@ class ChannelsPage extends HookConsumerWidget { title: const SizedBox.shrink(), actions: [ ProfileAvatar( - onTap: () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const SettingsPage()), - ), + onTap: () => Navigator.of( + context, + ).push(MaterialPageRoute(builder: settingsPageBuilder)), ), ], ), diff --git a/mobile/lib/features/channels/channels_page/channel_tile.dart b/mobile/lib/features/channels/channels_page/channel_tile.dart index 2d730a34d2..fc3f340b9d 100644 --- a/mobile/lib/features/channels/channels_page/channel_tile.dart +++ b/mobile/lib/features/channels/channels_page/channel_tile.dart @@ -41,20 +41,19 @@ class _ChannelTile extends ConsumerWidget { ), child: Row( children: [ - if (channel.isDm) - _DmAvatar(channel: channel, currentPubkey: currentPubkey) - else - SizedBox( - width: _kChannelLeadingWidth, - child: Align( - alignment: Alignment.centerLeft, - child: Icon( - channelIcon(channel), - size: _kChannelIconSize, - color: context.colors.onSurface, - ), - ), + SizedBox( + width: _kChannelLeadingWidth, + child: Align( + alignment: Alignment.centerLeft, + child: channel.isDm + ? _DmAvatar(channel: channel, currentPubkey: currentPubkey) + : Icon( + channelIcon(channel), + size: _kChannelIconSize, + color: context.colors.onSurface, + ), ), + ), const SizedBox(width: _kChannelLabelGap), Expanded( child: Column( @@ -343,8 +342,8 @@ class _DmAvatar extends ConsumerWidget { if (visiblePubkeys.length > 1) { return Container( - width: 22, - height: 22, + width: _kDmAvatarSize, + height: _kDmAvatarSize, alignment: Alignment.center, decoration: BoxDecoration( color: context.colors.surfaceContainerHighest, @@ -354,7 +353,7 @@ class _DmAvatar extends ConsumerWidget { child: Text( '${visiblePubkeys.length}', style: context.textTheme.labelSmall?.copyWith( - fontSize: 10, + fontSize: 9, color: context.colors.onSurface, fontWeight: FontWeight.w600, height: 1, @@ -385,14 +384,14 @@ class _DmAvatar extends ConsumerWidget { : 'offline'; return SizedBox( - width: 22, - height: 22, + width: _kDmAvatarSize, + height: _kDmAvatarSize, child: Stack( clipBehavior: Clip.none, children: [ AvatarImage( imageUrl: avatarUrl, - radius: 10, + radius: _kDmAvatarSize / 2, backgroundColor: context.colors.primaryContainer, fallback: Text( initial, @@ -407,8 +406,8 @@ class _DmAvatar extends ConsumerWidget { right: -1, bottom: -1, child: Container( - width: 9, - height: 9, + width: 8, + height: 8, decoration: BoxDecoration( color: _presenceColor(context, presence), shape: BoxShape.circle, diff --git a/mobile/lib/features/channels/compose_bar.dart b/mobile/lib/features/channels/compose_bar.dart index 8711ff7b3a..d0190f513a 100644 --- a/mobile/lib/features/channels/compose_bar.dart +++ b/mobile/lib/features/channels/compose_bar.dart @@ -17,8 +17,8 @@ import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; import '../profile/user_cache_provider.dart'; import '../profile/user_profile.dart'; -import '../custom_emoji/custom_emoji.dart'; -import '../custom_emoji/custom_emoji_provider.dart'; +import '../../shared/custom_emoji/custom_emoji.dart'; +import '../../shared/custom_emoji/custom_emoji_provider.dart'; import 'camera_capture_cleanup.dart'; import 'channel.dart'; import 'channel_management_provider.dart'; diff --git a/mobile/lib/features/channels/emoji_picker.dart b/mobile/lib/features/channels/emoji_picker.dart index b43a57b410..c961966531 100644 --- a/mobile/lib/features/channels/emoji_picker.dart +++ b/mobile/lib/features/channels/emoji_picker.dart @@ -3,9 +3,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/theme/theme.dart'; -import '../custom_emoji/custom_emoji.dart'; -import '../custom_emoji/custom_emoji_provider.dart'; -import '../custom_emoji/custom_emoji_render.dart'; +import '../../shared/custom_emoji/custom_emoji.dart'; +import '../../shared/custom_emoji/custom_emoji_provider.dart'; +import '../../shared/custom_emoji/custom_emoji_render.dart'; /// Opens the full emoji picker as a modal bottom sheet. void showEmojiPicker({ diff --git a/mobile/lib/features/channels/message_actions.dart b/mobile/lib/features/channels/message_actions.dart index fe03bcb65e..c1b52203e6 100644 --- a/mobile/lib/features/channels/message_actions.dart +++ b/mobile/lib/features/channels/message_actions.dart @@ -4,8 +4,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/theme/theme.dart'; -import '../custom_emoji/custom_emoji.dart'; -import '../custom_emoji/custom_emoji_provider.dart'; +import '../../shared/custom_emoji/custom_emoji.dart'; +import '../../shared/custom_emoji/custom_emoji_provider.dart'; import 'channel_management_provider.dart'; import 'emoji_picker.dart'; import 'thread_detail_page.dart'; diff --git a/mobile/lib/features/channels/message_content.dart b/mobile/lib/features/channels/message_content.dart index 1dc892acfd..cb1af75c68 100644 --- a/mobile/lib/features/channels/message_content.dart +++ b/mobile/lib/features/channels/message_content.dart @@ -15,9 +15,9 @@ import '../../shared/clipboard_utils.dart'; import '../../shared/relay/relay.dart'; import '../../shared/syntax_highlight.dart'; import '../../shared/theme/theme.dart'; -import '../custom_emoji/custom_emoji.dart'; -import '../custom_emoji/custom_emoji_provider.dart'; -import '../custom_emoji/custom_emoji_render.dart'; +import '../../shared/custom_emoji/custom_emoji.dart'; +import '../../shared/custom_emoji/custom_emoji_provider.dart'; +import '../../shared/custom_emoji/custom_emoji_render.dart'; import 'media_viewer_page.dart'; import 'message_media.dart'; diff --git a/mobile/lib/features/channels/reaction_row.dart b/mobile/lib/features/channels/reaction_row.dart index a721ef1988..8814d698fb 100644 --- a/mobile/lib/features/channels/reaction_row.dart +++ b/mobile/lib/features/channels/reaction_row.dart @@ -4,7 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; -import '../custom_emoji/custom_emoji_render.dart'; +import '../../shared/custom_emoji/custom_emoji_render.dart'; import '../profile/user_cache_provider.dart'; import '../profile/user_profile.dart'; import 'channel_management_provider.dart'; diff --git a/mobile/lib/features/channels/timeline_message.dart b/mobile/lib/features/channels/timeline_message.dart index 496060e50b..203be2dbbd 100644 --- a/mobile/lib/features/channels/timeline_message.dart +++ b/mobile/lib/features/channels/timeline_message.dart @@ -3,7 +3,7 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import '../../shared/relay/relay.dart'; -import '../custom_emoji/custom_emoji.dart'; +import '../../shared/custom_emoji/custom_emoji.dart'; import 'channel_window.dart'; enum SystemEventType { diff --git a/mobile/lib/features/forum/forum_provider.dart b/mobile/lib/features/forum/forum_provider.dart index 8ff2c335c4..710e16aa34 100644 --- a/mobile/lib/features/forum/forum_provider.dart +++ b/mobile/lib/features/forum/forum_provider.dart @@ -2,8 +2,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/relay/relay.dart'; import '../channels/channel_management_provider.dart'; -import '../custom_emoji/custom_emoji.dart'; -import '../custom_emoji/custom_emoji_provider.dart'; +import '../../shared/custom_emoji/custom_emoji.dart'; +import '../../shared/custom_emoji/custom_emoji_provider.dart'; import 'forum_models.dart'; /// Fetches forum posts (kind:45001) for a channel from the relay. diff --git a/mobile/lib/features/home/home_page.dart b/mobile/lib/features/home/home_page.dart index e76d739ab2..b4fa08649d 100644 --- a/mobile/lib/features/home/home_page.dart +++ b/mobile/lib/features/home/home_page.dart @@ -13,7 +13,9 @@ import '../channels/channels_page.dart'; import '../search/search_page.dart'; class HomePage extends HookConsumerWidget { - const HomePage({super.key}); + const HomePage({required this.settingsPageBuilder, super.key}); + + final WidgetBuilder settingsPageBuilder; static const double _tabBarHeight = 56; static const double _tabBarRadius = _tabBarHeight / 2; @@ -54,7 +56,11 @@ class HomePage extends HookConsumerWidget { _destinations.length, ); - const pages = [ChannelsPage(), ActivityPage(), SearchPage()]; + final pages = [ + ChannelsPage(settingsPageBuilder: settingsPageBuilder), + const ActivityPage(), + const SearchPage(), + ]; return Scaffold( extendBody: true, diff --git a/mobile/lib/features/profile/profile_avatar.dart b/mobile/lib/features/profile/profile_avatar.dart index 38cc3e6138..fb60fae125 100644 --- a/mobile/lib/features/profile/profile_avatar.dart +++ b/mobile/lib/features/profile/profile_avatar.dart @@ -3,10 +3,23 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; +import '../../shared/widgets/masked_avatar_badge.dart'; import 'profile_provider.dart'; import 'user_profile.dart'; +/// Matches desktop's sidebar profile card, whose avatar is 32px. +const _avatarSize = 32.0; + +/// The visible dot is smaller than the notch it sits in, so a ring of +/// background separates it from the avatar. Desktop's `h-2 w-2` dot inside a +/// `h-3.5 w-3.5` badge frame. +const _presenceDotRatio = 8 / 14; + /// User avatar with a presence dot indicator, for use in the app bar. +/// +/// The dot sits in a notch masked out of the avatar rather than overlapping it, +/// the same treatment desktop's `SidebarProfileCard` uses — so it needs no +/// background-coloured ring, and reads correctly over the themed top section. class ProfileAvatar extends ConsumerWidget { final VoidCallback? onTap; final bool showPresence; @@ -32,7 +45,7 @@ class ProfileAvatar extends ConsumerWidget { Widget _buildPlaceholder(BuildContext context) { return CircleAvatar( - radius: 16, + radius: _avatarSize / 2, backgroundColor: context.colors.primaryContainer, ); } @@ -44,37 +57,37 @@ class ProfileAvatar extends ConsumerWidget { ) { return GestureDetector( onTap: onTap, - child: Stack( - children: [ - AvatarImage( - imageUrl: profile?.avatarUrl, - radius: 16, - backgroundColor: context.colors.primaryContainer, - fallback: Text( - profile?.initial ?? '?', - style: context.textTheme.labelMedium?.copyWith( - color: context.colors.onPrimaryContainer, - ), - ), - ), - if (showPresence) - Positioned( - right: 0, - bottom: 0, - child: Container( - width: 10, - height: 10, - decoration: BoxDecoration( - color: _presenceColor(context, presence), - shape: BoxShape.circle, - border: Border.all( - color: context.theme.scaffoldBackgroundColor, - width: 1.5, - ), + child: MaskedAvatarBadge( + size: _avatarSize, + geometry: AvatarBadgeMaskGeometry.presenceDot, + avatar: ClipOval( + child: ColoredBox( + color: context.colors.primaryContainer, + child: AvatarImageContent( + imageUrl: profile?.avatarUrl, + fallback: Text( + profile?.initial ?? '?', + style: context.textTheme.labelMedium?.copyWith( + color: context.colors.onPrimaryContainer, ), ), ), - ], + ), + ), + badge: showPresence + ? Center( + child: FractionallySizedBox( + widthFactor: _presenceDotRatio, + heightFactor: _presenceDotRatio, + child: DecoratedBox( + decoration: BoxDecoration( + color: _presenceColor(context, presence), + shape: BoxShape.circle, + ), + ), + ), + ) + : null, ), ); } diff --git a/mobile/lib/features/profile/set_status_sheet.dart b/mobile/lib/features/profile/set_status_sheet.dart index 2249a0d6f8..2e03e4c110 100644 --- a/mobile/lib/features/profile/set_status_sheet.dart +++ b/mobile/lib/features/profile/set_status_sheet.dart @@ -1,33 +1,24 @@ +import 'dart:math' as math; + import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../../shared/custom_emoji/custom_emoji.dart'; +import '../../shared/custom_emoji/custom_emoji_provider.dart'; +import '../../shared/custom_emoji/custom_emoji_render.dart'; import '../../shared/theme/theme.dart'; -import '../custom_emoji/custom_emoji.dart'; -import '../custom_emoji/custom_emoji_provider.dart'; -import '../custom_emoji/custom_emoji_render.dart'; import '../channels/emoji_picker.dart'; import 'user_status.dart'; import 'user_status_provider.dart'; -const _emojiOptions = [ - (emoji: '\u{1F5E3}\u{FE0F}', label: 'In a meeting'), - (emoji: '\u{1F68C}', label: 'Commuting'), - (emoji: '\u{1F912}', label: 'Out sick'), - (emoji: '\u{1F3D6}\u{FE0F}', label: 'Vacationing'), - (emoji: '\u{1F3E0}', label: 'Working remotely'), - (emoji: '\u{1F354}', label: 'Lunch'), - (emoji: '\u{1F3AF}', label: 'Focus'), - (emoji: '\u{1F4AA}', label: 'Exercising'), -]; - -const _presets = [ - (text: 'In a meeting', emoji: '\u{1F5E3}\u{FE0F}'), - (text: 'Commuting', emoji: '\u{1F68C}'), - (text: 'Out sick', emoji: '\u{1F912}'), - (text: 'Vacationing', emoji: '\u{1F3D6}\u{FE0F}'), - (text: 'Working remotely', emoji: '\u{1F3E0}'), -]; +/// The emoji well and the text field are sized to be the two things you reach +/// for, so they carry no borders — the sheet has no other controls to compete +/// with them. +const _emojiWellSize = 56.0; +const _emojiGlyphSize = 32.0; +const _saveButtonHeight = 52.0; void showSetStatusSheet(BuildContext context, {UserStatus? currentStatus}) { showModalBottomSheet( @@ -51,7 +42,6 @@ class _SetStatusSheet extends HookConsumerWidget { final emoji = useState(currentStatus?.emoji ?? ''); final text = useState(currentStatus?.text ?? ''); final isSaving = useState(false); - final customEmoji = ref.watch(customEmojiListProvider); useEffect(() { void listener() => text.value = textController.text; @@ -91,7 +81,13 @@ class _SetStatusSheet extends HookConsumerWidget { Grid.gutter, 0, Grid.gutter, - MediaQuery.viewInsetsOf(context).bottom, + // The sheet ends in the Save button, so it owns its own breathing room + // above whichever is taller: the keyboard, or the home indicator. + Grid.gutter + + math.max( + MediaQuery.viewInsetsOf(context).bottom, + MediaQuery.viewPaddingOf(context).bottom, + ), ), child: Column( mainAxisSize: MainAxisSize.min, @@ -107,31 +103,77 @@ class _SetStatusSheet extends HookConsumerWidget { ), const SizedBox(height: Grid.twelve), - // Text input with emoji preview + // The emoji well doubles as the picker's entry point, which is why + // there is no separate row of emoji suggestions below. Row( children: [ - Container( - width: 40, - height: 40, - alignment: Alignment.center, - decoration: BoxDecoration( - border: Border.all(color: context.colors.outlineVariant), - borderRadius: BorderRadius.circular(Radii.md), - ), - child: _StatusEmojiPreview(emoji: emoji.value), + Stack( + clipBehavior: Clip.none, + children: [ + Semantics( + button: true, + label: 'Choose a status emoji', + child: InkWell( + borderRadius: BorderRadius.circular(Radii.lg), + onTap: () => showEmojiPicker( + context: context, + onSelect: (value) => emoji.value = value, + ), + child: SizedBox.square( + dimension: _emojiWellSize, + child: Center( + child: _StatusEmojiPreview(emoji: emoji.value), + ), + ), + ), + ), + if (emoji.value.isNotEmpty) + Positioned( + top: -Grid.quarter, + right: -Grid.quarter, + child: SizedBox.square( + dimension: Grid.sm, + child: IconButton( + onPressed: isSaving.value + ? null + : () => emoji.value = '', + tooltip: 'Remove status emoji', + visualDensity: VisualDensity.compact, + style: IconButton.styleFrom( + backgroundColor: context.colors.surface, + minimumSize: const Size.square(Grid.sm), + maximumSize: const Size.square(Grid.sm), + padding: EdgeInsets.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + icon: Icon( + LucideIcons.x, + size: 14, + color: context.colors.onSurface, + ), + ), + ), + ), + ], ), - const SizedBox(width: Grid.xxs), + const SizedBox(width: Grid.half), Expanded( child: TextField( controller: textController, autofocus: true, - decoration: const InputDecoration( + style: context.textTheme.titleMedium, + decoration: InputDecoration( hintText: 'What\u2019s your status?', - border: OutlineInputBorder(), - contentPadding: EdgeInsets.symmetric( - horizontal: Grid.twelve, - vertical: Grid.xxs, + hintStyle: context.textTheme.titleMedium?.copyWith( + color: context.colors.onSurfaceVariant, ), + // The theme outlines inputs; this one is the sheet's whole + // content, so every border state is cleared explicitly. + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + isDense: false, + contentPadding: EdgeInsets.zero, ), textInputAction: TextInputAction.done, onSubmitted: (_) { @@ -141,74 +183,28 @@ class _SetStatusSheet extends HookConsumerWidget { ), ], ), - const SizedBox(height: Grid.twelve), + const SizedBox(height: Grid.gutter), - Wrap( - spacing: Grid.half, - runSpacing: Grid.half, - children: [ - for (final option in _emojiOptions) - _EmojiButton( - emoji: option.emoji, - label: option.label, - selected: emoji.value == option.emoji, - onTap: () { - emoji.value = emoji.value == option.emoji - ? '' - : option.emoji; - }, - ), - if (customEmoji.isNotEmpty) - _PickCustomEmojiButton( - selected: emoji.value.startsWith(':'), - onTap: () => showEmojiPicker( - context: context, - onSelect: (value) => emoji.value = value, - ), - ), - ], + SizedBox( + width: double.infinity, + height: _saveButtonHeight, + child: FilledButton( + onPressed: hasContent && !isSaving.value ? handleSave : null, + child: const Text('Save'), + ), ), - const SizedBox(height: Grid.twelve), - - // Presets - Wrap( - spacing: Grid.half, - runSpacing: Grid.half, - children: [ - for (final preset in _presets) - ActionChip( - label: Text('${preset.emoji} ${preset.text}'), - labelStyle: context.textTheme.labelSmall, - onPressed: () { - textController.text = preset.text; - emoji.value = preset.emoji; - }, - ), - ], - ), - const SizedBox(height: Grid.xs), - - Row( - children: [ - if (hasExistingStatus) - TextButton( + // No Cancel \u2014 the sheet dismisses by swiping down. + if (hasExistingStatus) + Padding( + padding: const EdgeInsets.only(top: Grid.half), + child: SizedBox( + width: double.infinity, + child: TextButton( onPressed: isSaving.value ? null : handleClear, child: const Text('Clear status'), ), - const Spacer(), - TextButton( - onPressed: isSaving.value - ? null - : () => Navigator.of(context).pop(), - child: const Text('Cancel'), ), - const SizedBox(width: Grid.xxs), - FilledButton( - onPressed: hasContent && !isSaving.value ? handleSave : null, - child: const Text('Save'), - ), - ], - ), + ), ], ), ); @@ -223,7 +219,11 @@ class _StatusEmojiPreview extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { if (emoji.isEmpty) { - return const Text('\u{1F4AC}', style: TextStyle(fontSize: 18)); + return Icon( + LucideIcons.smilePlus, + size: _emojiGlyphSize, + color: context.colors.onSurfaceVariant, + ); } final palette = ref.watch(customEmojiListProvider); final shortcode = normalizeShortcode(emoji); @@ -233,84 +233,11 @@ class _StatusEmojiPreview extends ConsumerWidget { return CustomEmojiImage( shortcode: shortcode, url: entry.url, - size: 22, + size: _emojiGlyphSize, ); } } } - return Text(emoji, style: const TextStyle(fontSize: 18)); - } -} - -class _PickCustomEmojiButton extends StatelessWidget { - final bool selected; - final VoidCallback onTap; - - const _PickCustomEmojiButton({required this.selected, required this.onTap}); - - @override - Widget build(BuildContext context) { - return Tooltip( - message: 'Custom emoji', - child: InkWell( - borderRadius: BorderRadius.circular(Radii.md), - onTap: onTap, - child: Container( - width: 36, - height: 36, - alignment: Alignment.center, - decoration: BoxDecoration( - color: selected - ? context.colors.secondaryContainer - : Colors.transparent, - borderRadius: BorderRadius.circular(Radii.md), - border: selected ? Border.all(color: context.colors.outline) : null, - ), - child: Icon( - Icons.add_reaction_outlined, - size: 18, - color: context.colors.onSurfaceVariant, - ), - ), - ), - ); - } -} - -class _EmojiButton extends StatelessWidget { - final String emoji; - final String label; - final bool selected; - final VoidCallback onTap; - - const _EmojiButton({ - required this.emoji, - required this.label, - required this.selected, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - return Tooltip( - message: label, - child: InkWell( - borderRadius: BorderRadius.circular(Radii.md), - onTap: onTap, - child: Container( - width: 36, - height: 36, - alignment: Alignment.center, - decoration: BoxDecoration( - color: selected - ? context.colors.secondaryContainer - : Colors.transparent, - borderRadius: BorderRadius.circular(Radii.md), - border: selected ? Border.all(color: context.colors.outline) : null, - ), - child: Text(emoji, style: const TextStyle(fontSize: 18)), - ), - ), - ); + return Text(emoji, style: const TextStyle(fontSize: _emojiGlyphSize)); } } diff --git a/mobile/lib/features/profile/settings_profile_header.dart b/mobile/lib/features/profile/settings_profile_header.dart new file mode 100644 index 0000000000..23816ccaa9 --- /dev/null +++ b/mobile/lib/features/profile/settings_profile_header.dart @@ -0,0 +1,157 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../shared/custom_emoji/custom_emoji_provider.dart'; +import '../../shared/custom_emoji/custom_emoji_render.dart'; +import '../../shared/theme/theme.dart'; +import '../../shared/widgets/avatar_image.dart'; +import '../../shared/widgets/masked_avatar_badge.dart'; +import 'profile_provider.dart'; +import 'set_status_sheet.dart'; +import 'user_status_provider.dart'; + +/// Desktop's settings-avatar treatment (`ProfileSettingsCard`): a large centred +/// avatar with a circular badge notched out of its bottom-right corner. Desktop +/// puts an edit-photo pencil in that badge; here it carries the status glyph and +/// opens the status sheet instead. The notch shape — including the fillets where +/// it meets the avatar's edge — comes from [AvatarBadgeMaskGeometry.badge]. +class SettingsProfileHeader extends ConsumerWidget { + const SettingsProfileHeader({super.key}); + + static const _avatarSize = 128.0; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final profile = ref.watch(profileProvider).asData?.value; + final status = ref.watch(userStatusProvider).asData?.value; + final hasStatus = status != null && !status.isEmpty; + + void openStatusSheet() => + showSetStatusSheet(context, currentStatus: status); + + return Padding( + padding: const EdgeInsets.only(top: Grid.xxs, bottom: Grid.sm), + child: Column( + children: [ + MaskedAvatarBadge( + size: _avatarSize, + avatar: ColoredBox( + color: context.colors.primaryContainer, + child: AvatarImageContent( + imageUrl: profile?.avatarUrl, + fallback: Text( + profile?.initial ?? '?', + style: context.textTheme.displaySmall?.copyWith( + color: context.colors.onPrimaryContainer, + ), + ), + ), + ), + badge: _StatusBadge( + emoji: status?.emoji ?? '', + onTap: openStatusSheet, + ), + ), + const SizedBox(height: Grid.twelve), + Text( + profile?.label ?? 'Your profile', + style: context.textTheme.titleMedium, + textAlign: TextAlign.center, + ), + // No placeholder copy — the badge is the affordance, so this line + // appears only once there is an actual status to show. + if (hasStatus) + GestureDetector( + onTap: openStatusSheet, + child: Padding( + padding: const EdgeInsets.only( + top: Grid.quarter, + left: Grid.gutter, + right: Grid.gutter, + bottom: Grid.half, + ), + child: Text( + status.text.isNotEmpty ? status.text : status.emoji, + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ], + ), + ); + } +} + +/// Fills the notch left by [MaskedAvatarBadge], so its size comes from the mask +/// geometry rather than being set here. +class _StatusBadge extends StatelessWidget { + const _StatusBadge({required this.emoji, required this.onTap}); + + final String emoji; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Semantics( + button: true, + label: 'Set a status', + child: GestureDetector( + onTap: onTap, + child: DecoratedBox( + decoration: BoxDecoration( + color: context.colors.surfaceContainerHighest, + shape: BoxShape.circle, + ), + child: Center(child: _StatusGlyph(emoji: emoji)), + ), + ), + ); + } +} + +/// The status emoji, resolving `:shortcode:` values against the community's +/// custom emoji. Falls back to the add-status icon when no status is set. +class _StatusGlyph extends ConsumerWidget { + const _StatusGlyph({required this.emoji}); + + final String emoji; + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (emoji.isEmpty) { + return Icon( + LucideIcons.smilePlus, + size: 20, + color: context.colors.onSurfaceVariant, + ); + } + + final shortcode = emoji.startsWith(':') && emoji.endsWith(':') + ? emoji.substring(1, emoji.length - 1).toLowerCase() + : null; + if (shortcode != null) { + for (final entry in ref.watch(customEmojiListProvider)) { + if (entry.shortcode == shortcode) { + return CustomEmojiImage( + shortcode: shortcode, + url: entry.url, + size: 22, + ); + } + } + return Icon( + LucideIcons.smile, + size: 20, + color: context.colors.onSurfaceVariant, + ); + } + + return Text(emoji, style: const TextStyle(fontSize: 20)); + } +} diff --git a/mobile/lib/features/pulse/note_card.dart b/mobile/lib/features/pulse/note_card.dart index c843de7010..6caf1dad6a 100644 --- a/mobile/lib/features/pulse/note_card.dart +++ b/mobile/lib/features/pulse/note_card.dart @@ -149,7 +149,7 @@ class NoteCard extends HookConsumerWidget { _ActionButton( icon: effectiveUpvoted ? Icons.favorite - : Icons.favorite_border, + : LucideIcons.heart, label: effectiveCount > 0 ? '$effectiveCount' : null, color: effectiveUpvoted ? Colors.redAccent : null, onTap: () async { diff --git a/mobile/lib/features/pulse/pulse_actions.dart b/mobile/lib/features/pulse/pulse_actions.dart index f1657abd13..13b5dee667 100644 --- a/mobile/lib/features/pulse/pulse_actions.dart +++ b/mobile/lib/features/pulse/pulse_actions.dart @@ -2,8 +2,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/relay/relay.dart'; import '../channels/channel_management_provider.dart'; -import '../custom_emoji/custom_emoji.dart'; -import '../custom_emoji/custom_emoji_provider.dart'; +import '../../shared/custom_emoji/custom_emoji.dart'; +import '../../shared/custom_emoji/custom_emoji_provider.dart'; import 'pulse_models.dart'; import 'pulse_provider.dart'; diff --git a/mobile/lib/features/settings/accent_picker_page.dart b/mobile/lib/features/settings/accent_picker_page.dart new file mode 100644 index 0000000000..6f174df741 --- /dev/null +++ b/mobile/lib/features/settings/accent_picker_page.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../shared/theme/theme.dart'; +import '../../shared/widgets/frosted_app_bar.dart'; +import '../../shared/widgets/frosted_scaffold.dart'; + +/// Accent colors as a list of rows, matching the theme picker's shape. The +/// swatches used to live inline on the settings page as a wrap of tappable +/// circles; a row per color keeps the settings page to plain rows and gives each +/// accent a readable name. +class AccentPickerPage extends ConsumerWidget { + const AccentPickerPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final selected = ref.watch(accentProvider); + final colorScheme = context.colors; + + return FrostedScaffold( + appBar: const FrostedAppBar(title: Text('Accent Color')), + body: ListView( + padding: EdgeInsets.only( + top: frostedAppBarHeight(context), + bottom: Grid.xs, + ), + children: [ + for (var i = 0; i < accentColors.length; i++) + _AccentRow( + color: accentColorForScheme(colorScheme, i), + label: accentColors[i].name, + selected: selected == i, + onTap: () => ref.read(accentProvider.notifier).setAccent(i), + ), + ], + ), + ); + } +} + +class _AccentRow extends StatelessWidget { + const _AccentRow({ + required this.color, + required this.label, + required this.selected, + required this.onTap, + }); + + final Color color; + final String label; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return ListTile( + leading: Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: Border.all(color: context.colors.outlineVariant), + ), + ), + title: Text(label), + trailing: selected + ? Icon(LucideIcons.check, size: 18, color: context.colors.primary) + : null, + onTap: onTap, + ); + } +} diff --git a/mobile/lib/features/settings/settings_page.dart b/mobile/lib/features/settings/settings_page.dart index 214360f9eb..f089f63915 100644 --- a/mobile/lib/features/settings/settings_page.dart +++ b/mobile/lib/features/settings/settings_page.dart @@ -10,23 +10,22 @@ import '../../shared/clipboard_utils.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/app_list.dart'; +import '../../shared/widgets/app_list_card.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; -import '../profile/set_status_sheet.dart'; -import '../profile/user_status_provider.dart'; -import '../custom_emoji/custom_emoji_provider.dart'; -import '../custom_emoji/custom_emoji_render.dart'; +import 'accent_picker_page.dart'; import 'theme_picker_page.dart'; +part 'settings_page/appearance_section.dart'; +part 'settings_page/connection_section.dart'; + class SettingsPage extends HookConsumerWidget { - const SettingsPage({super.key}); + const SettingsPage({super.key, required this.profileHeader}); + + final Widget profileHeader; @override Widget build(BuildContext context, WidgetRef ref) { - final config = ref.watch(relayConfigProvider); - final selectedAccent = ref.watch(accentProvider); - final selectedScheme = ref.watch(schemeProvider); - final colorScheme = context.colors; final packageInfoFuture = useMemoized(() => PackageInfo.fromPlatform()); final packageInfo = useFuture(packageInfoFuture); @@ -41,283 +40,55 @@ class SettingsPage extends HookConsumerWidget { bottom: Grid.xs, ), children: [ - const SizedBox(height: Grid.xxs), - - // Status — flush header row, like Slack's profile/status block. - _StatusRow(), - - // Appearance - AppListSection( - label: 'Appearance', - children: [ - AppListRow( - icon: LucideIcons.palette, - title: 'Color Scheme', - subtitle: selectedScheme == null - ? 'Default ($defaultSchemeDisplayName)' - : findTheme(selectedScheme)?.displayName ?? - selectedScheme, - trailing: Icon( - LucideIcons.chevronRight, - size: 18, - color: context.colors.onSurfaceVariant, - ), - onTap: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const ThemePickerPage(), - ), - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB( - Grid.gutter, - Grid.xxs, - Grid.gutter, - Grid.twelve, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Accent Color', - style: context.textTheme.bodyMedium?.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), - const SizedBox(height: Grid.twelve), - Wrap( - spacing: Grid.xxs, - runSpacing: Grid.xxs, - children: [ - for (var i = 0; i < accentColors.length; i++) - _AccentSwatch( - color: accentColorForScheme(colorScheme, i), - label: accentColors[i].name, - selected: selectedAccent == i, - onTap: () => ref - .read(accentProvider.notifier) - .setAccent(i), - ), - ], - ), - ], - ), - ), - ], - ), - - // Connection - AppListSection( - label: 'Connection', - children: [ - AppListRow( - icon: LucideIcons.server, - title: 'Connected to', - subtitle: config.baseUrl, - ), - if (config.nsec != null && config.nsec!.isNotEmpty) - Builder( - builder: (context) { - final privHex = nostr.Nip19.decode( - payload: config.nsec!, - ).data; - final pubkey = privHex.isNotEmpty - ? nostr.Keys(privHex).public - : 'unknown'; - return AppListRow( - icon: LucideIcons.key, - title: 'Identity (pubkey)', - subtitle: pubkey, - subtitleStyle: context.textTheme.bodySmall - ?.copyWith( - color: context.colors.onSurfaceVariant, - fontFamily: 'GeistMono', - fontSize: 11, - ), - subtitleMaxLines: 2, - trailing: IconButton( - icon: const Icon(LucideIcons.copy, size: 16), - onPressed: () async { - await copyToClipboard( - context, - pubkey, - message: 'Pubkey copied', - ); - }, - ), - ); - }, - ), - Padding( - padding: const EdgeInsets.symmetric(vertical: Grid.xxs), - child: Center( - child: TextButton.icon( - onPressed: () => _confirmSignOut(context, ref), - icon: const Icon(LucideIcons.logOut, size: 18), - label: const Text('Remove Community'), - style: TextButton.styleFrom( - foregroundColor: context.colors.error, - ), - ), - ), - ), - ], - ), + profileHeader, + const _AppearanceSection(), + const _ConnectionSection(), + const _RemoveCommunitySection(), ], ), ), if (packageInfo.hasData) - SafeArea( - top: false, - child: Padding( - padding: const EdgeInsets.only(bottom: Grid.xs, top: Grid.xxs), - child: Center( - child: Text( - 'v${packageInfo.data!.version}', - style: context.textTheme.bodySmall?.copyWith( - color: context.colors.onSurfaceVariant.withValues( - alpha: 0.6, - ), - ), - ), - ), - ), - ), - ], - ), - ); - } - - void _confirmSignOut(BuildContext context, WidgetRef ref) { - showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('Remove Community'), - content: const Text( - 'This will disconnect this community. You will need ' - 'to scan a new pairing code to reconnect.', - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(ctx).pop(), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: () { - Navigator.of(ctx).pop(); // close dialog - // Pop all pushed routes back to root so MaterialApp.home - // rebuilds to PairingPage when auth state changes. - Navigator.of(context).popUntil((route) => route.isFirst); - ref.read(authProvider.notifier).signOut(); - }, - style: FilledButton.styleFrom(backgroundColor: ctx.colors.error), - child: const Text('Remove'), - ), + _VersionFooter(version: packageInfo.data!.version), ], ), ); } } -class _StatusRow extends ConsumerWidget { - @override - Widget build(BuildContext context, WidgetRef ref) { - final statusAsync = ref.watch(userStatusProvider); - final status = statusAsync.asData?.value; - final hasStatus = status != null && !status.isEmpty; +class _VersionFooter extends StatelessWidget { + const _VersionFooter({required this.version}); - return AppListRowRaw( - leading: _StatusEmojiIcon(emoji: status?.emoji ?? ''), - title: Text( - hasStatus - ? (status.text.isNotEmpty ? status.text : status.emoji) - : 'Set a status', - style: hasStatus - ? context.textTheme.bodyLarge - : context.textTheme.bodyLarge?.copyWith( - color: context.colors.onSurfaceVariant, - ), - ), - subtitle: hasStatus - ? Text( - 'Tap to update', - style: context.textTheme.bodySmall?.copyWith( - color: context.colors.onSurfaceVariant, - ), - ) - : null, - onTap: () => showSetStatusSheet(context, currentStatus: status), - ); - } -} - -class _StatusEmojiIcon extends ConsumerWidget { - final String emoji; - - const _StatusEmojiIcon({required this.emoji}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - if (emoji.isEmpty) { - return const Text('\u{1F4AC}', style: TextStyle(fontSize: 20)); - } - final shortcode = emoji.startsWith(':') && emoji.endsWith(':') - ? emoji.substring(1, emoji.length - 1).toLowerCase() - : null; - if (shortcode != null) { - for (final entry in ref.watch(customEmojiListProvider)) { - if (entry.shortcode == shortcode) { - return CustomEmojiImage( - shortcode: shortcode, - url: entry.url, - size: 24, - ); - } - } - } - return Text(emoji, style: const TextStyle(fontSize: 20)); - } -} - -class _AccentSwatch extends StatelessWidget { - const _AccentSwatch({ - required this.color, - required this.label, - required this.selected, - required this.onTap, - }); - - final Color color; - final String label; - final bool selected; - final VoidCallback onTap; + final String version; @override Widget build(BuildContext context) { - return Tooltip( - message: label, - child: GestureDetector( - onTap: onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - width: 36, - height: 36, - decoration: BoxDecoration( - color: color, - borderRadius: BorderRadius.circular(Radii.md), - border: selected - ? Border.all(color: context.colors.onSurface, width: 2.5) - : Border.all(color: color.withValues(alpha: 0.4), width: 1), + return SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.only(bottom: Grid.xs, top: Grid.xxs), + child: Center( + child: Text( + 'v$version', + style: context.textTheme.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant.withValues(alpha: 0.6), + ), ), - child: selected - ? Icon( - LucideIcons.check, - size: 16, - color: contrastForeground(color), - ) - : null, ), ), ); } } + +/// Trailing affordance shared by the rows that push a picker page. +class _RowChevron extends StatelessWidget { + const _RowChevron(); + + @override + Widget build(BuildContext context) { + return Icon( + LucideIcons.chevronRight, + size: 18, + color: context.colors.onSurfaceVariant, + ); + } +} diff --git a/mobile/lib/features/settings/settings_page/appearance_section.dart b/mobile/lib/features/settings/settings_page/appearance_section.dart new file mode 100644 index 0000000000..26c9d20fe1 --- /dev/null +++ b/mobile/lib/features/settings/settings_page/appearance_section.dart @@ -0,0 +1,138 @@ +part of '../settings_page.dart'; + +/// System / Light / Dark, mirroring desktop's appearance-mode selector. +const _modeOptions = <({ThemeMode mode, String label, IconData icon})>[ + (mode: ThemeMode.system, label: 'System', icon: LucideIcons.sunMoon), + (mode: ThemeMode.light, label: 'Light', icon: LucideIcons.sun), + (mode: ThemeMode.dark, label: 'Dark', icon: LucideIcons.moon), +]; + +String _modeLabel(ThemeMode mode) => + _modeOptions.firstWhere((option) => option.mode == mode).label; + +class _AppearanceSection extends ConsumerWidget { + const _AppearanceSection(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final mode = ref.watch(themeProvider); + final schemeName = ref.watch(schemeProvider); + final accentIndex = ref.watch(accentProvider); + + return AppListCard( + label: 'Style', + children: [ + AppListRow( + icon: LucideIcons.sunMoon, + title: 'Appearance', + value: _modeLabel(mode), + trailing: const _RowChevron(), + onTap: () => _showAppearanceModeSheet(context), + ), + AppListRow( + icon: LucideIcons.palette, + title: 'Theme', + value: themeSelectionLabel(schemeName, mode), + trailing: const _RowChevron(), + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const ThemePickerPage()), + ), + ), + AppListRow( + icon: LucideIcons.droplet, + title: 'Accent color', + // The swatch *is* the value — naming the color as well would say the + // same thing twice, so it takes the chevron's place. + trailing: _AccentSwatch(accentIndex: accentIndex), + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const AccentPickerPage()), + ), + ), + ], + ); + } +} + +void _showAppearanceModeSheet(BuildContext context) { + showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (_) => const _AppearanceModeSheet(), + ); +} + +/// Three choices is too few to warrant a page, so the appearance row opens a +/// sheet rather than pushing a route. +class _AppearanceModeSheet extends ConsumerWidget { + const _AppearanceModeSheet(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final mode = ref.watch(themeProvider); + + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB( + Grid.gutter, + 0, + Grid.gutter, + Grid.xxs, + ), + child: Text('Appearance', style: context.textTheme.titleMedium), + ), + for (final option in _modeOptions) + AppListRow( + icon: option.icon, + title: option.label, + trailing: option.mode == mode + ? Icon( + LucideIcons.check, + size: 18, + color: context.colors.primary, + ) + : null, + onTap: () { + final schemeName = ref.read(schemeProvider); + final compatibleScheme = schemeForAppearanceMode( + schemeName, + option.mode, + ); + if (compatibleScheme != schemeName) { + ref.read(schemeProvider.notifier).setScheme(compatibleScheme); + } + ref.read(themeProvider.notifier).setMode(option.mode); + Navigator.of(context).pop(); + }, + ), + const SizedBox(height: Grid.xxs), + ], + ), + ); + } +} + +/// The selected accent, shown where a row would otherwise carry a chevron. +class _AccentSwatch extends StatelessWidget { + const _AccentSwatch({required this.accentIndex}); + + static const _size = 22.0; + + final int accentIndex; + + @override + Widget build(BuildContext context) { + return Container( + width: _size, + height: _size, + decoration: BoxDecoration( + color: accentColorForScheme(context.colors, accentIndex), + shape: BoxShape.circle, + border: Border.all(color: context.colors.outlineVariant), + ), + ); + } +} diff --git a/mobile/lib/features/settings/settings_page/connection_section.dart b/mobile/lib/features/settings/settings_page/connection_section.dart new file mode 100644 index 0000000000..f34e88f26e --- /dev/null +++ b/mobile/lib/features/settings/settings_page/connection_section.dart @@ -0,0 +1,103 @@ +part of '../settings_page.dart'; + +class _ConnectionSection extends ConsumerWidget { + const _ConnectionSection(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final config = ref.watch(relayConfigProvider); + final nsec = config.nsec; + + return AppListCard( + label: 'Connection', + children: [ + AppListRow( + icon: LucideIcons.server, + title: 'Connected to', + subtitle: config.baseUrl, + ), + if (nsec != null && nsec.isNotEmpty) _IdentityRow(nsec: nsec), + ], + ); + } +} + +/// Destructive, so it gets a container of its own rather than sitting at the +/// bottom of the connection group. +class _RemoveCommunitySection extends ConsumerWidget { + const _RemoveCommunitySection(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + return AppListCard( + children: [ + AppListRow( + icon: LucideIcons.logOut, + title: 'Remove community', + titleColor: context.colors.error, + onTap: () => _confirmRemoveCommunity(context, ref), + ), + ], + ); + } +} + +class _IdentityRow extends StatelessWidget { + const _IdentityRow({required this.nsec}); + + final String nsec; + + @override + Widget build(BuildContext context) { + final privHex = nostr.Nip19.decode(payload: nsec).data; + final pubkey = privHex.isNotEmpty ? nostr.Keys(privHex).public : 'unknown'; + + return AppListRow( + icon: LucideIcons.key, + title: 'Identity (pubkey)', + subtitle: pubkey, + subtitleStyle: context.textTheme.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + fontFamily: 'GeistMono', + fontSize: 11, + ), + subtitleMaxLines: 2, + trailing: IconButton( + icon: const Icon(LucideIcons.copy, size: 16), + onPressed: () async { + await copyToClipboard(context, pubkey, message: 'Pubkey copied'); + }, + ), + ); + } +} + +void _confirmRemoveCommunity(BuildContext context, WidgetRef ref) { + showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Remove Community'), + content: const Text( + 'This will disconnect this community. You will need ' + 'to scan a new pairing code to reconnect.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () { + Navigator.of(ctx).pop(); // close dialog + // Pop all pushed routes back to root so MaterialApp.home rebuilds + // to PairingPage when auth state changes. + Navigator.of(context).popUntil((route) => route.isFirst); + ref.read(authProvider.notifier).signOut(); + }, + style: FilledButton.styleFrom(backgroundColor: ctx.colors.error), + child: const Text('Remove'), + ), + ], + ), + ); +} diff --git a/mobile/lib/features/settings/theme_picker_page.dart b/mobile/lib/features/settings/theme_picker_page.dart index f9bbc8ea1d..aba6579148 100644 --- a/mobile/lib/features/settings/theme_picker_page.dart +++ b/mobile/lib/features/settings/theme_picker_page.dart @@ -7,6 +7,9 @@ import '../../shared/theme/theme.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; +/// Themes for the current appearance mode, organised the way desktop does it: +/// System offers the light/dark pairs as single entries, while Light and Dark +/// each list every theme of that brightness. class ThemePickerPage extends HookConsumerWidget { const ThemePickerPage({super.key}); @@ -15,41 +18,47 @@ class ThemePickerPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final mode = ref.watch(themeProvider); final selectedScheme = ref.watch(schemeProvider); final searchQuery = useState(''); final searchController = useTextEditingController(); final scrollController = useScrollController(); - // Flat alphabetical list by display name - final sorted = List.from(themeCatalog) - ..sort((a, b) => a.displayName.compareTo(b.displayName)); + final groups = themeGroups(); + final entries = switch (mode) { + ThemeMode.system => groups.paired, + ThemeMode.light => groups.light, + ThemeMode.dark => groups.dark, + }; + final isSystem = mode == ThemeMode.system; + + String labelFor(ThemeColors theme) => + isSystem ? pairedThemeLabel(theme.name) : theme.displayName; final query = searchQuery.value.toLowerCase(); - final defaultLabel = 'Default ($defaultSchemeDisplayName)'; final filtered = query.isEmpty - ? sorted - : sorted - .where((t) => t.displayName.toLowerCase().contains(query)) + ? entries + : entries + .where((t) => labelFor(t).toLowerCase().contains(query)) .toList(); - final showDefault = - query.isEmpty || - 'default'.contains(query) || - defaultLabel.toLowerCase().contains(query); - // Default entry colors. - final defaultTheme = findTheme(defaultSchemeName); - final defaultBg = defaultTheme?.bg ?? lightColorScheme.surface; - final defaultFg = defaultTheme?.fg ?? lightColorScheme.onSurface; - final defaultComment = - defaultTheme?.comment ?? lightColorScheme.onSurfaceVariant; + // In System mode either half of a pair counts as the selection; in the + // pinned modes the effective theme already accounts for coercion, so the + // checkmark always names what is actually rendered. + final active = effectiveTheme(selectedScheme, mode); + bool isSelected(ThemeColors theme) { + if (active == null) return false; + if (!isSystem) return active.name == theme.name; + return active.name == theme.name || + themePairFor(theme.name) == active.name; + } - // Auto-scroll to the selected theme on first build (no search active) + // Auto-scroll to the selected theme on first build (no search active). useEffect(() { - if (selectedScheme == null || query.isNotEmpty) return null; - final idx = sorted.indexWhere((t) => t.name == selectedScheme); + if (query.isNotEmpty) return null; + final idx = filtered.indexWhere(isSelected); if (idx < 0) return null; - // +1 to account for the Default entry at the top - final offset = (idx + 1) * _itemHeight; + final offset = idx * _itemHeight; WidgetsBinding.instance.addPostFrameCallback((_) { if (scrollController.hasClients) { scrollController.animateTo( @@ -63,98 +72,22 @@ class ThemePickerPage extends HookConsumerWidget { }, const []); return FrostedScaffold( - appBar: const FrostedAppBar(title: Text('Color Scheme')), + appBar: const FrostedAppBar(title: Text('Theme')), body: Column( children: [ SizedBox(height: frostedAppBarHeight(context)), - // Always-visible search bar - Padding( - padding: const EdgeInsets.symmetric( - horizontal: Grid.gutter, - vertical: Grid.xxs, - ), - child: Container( - height: 36, - padding: const EdgeInsets.symmetric(horizontal: Grid.twelve), - decoration: BoxDecoration( - color: context.colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(Radii.lg), - border: Border.all(color: context.colors.outlineVariant), - ), - child: Row( - children: [ - Icon( - LucideIcons.search, - size: 16, - color: context.colors.onSurfaceVariant, - ), - const SizedBox(width: Grid.xxs), - Expanded( - child: TextField( - controller: searchController, - decoration: InputDecoration( - hintText: 'Search themes...', - hintStyle: context.textTheme.bodyMedium?.copyWith( - color: context.colors.onSurfaceVariant, - ), - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - isDense: true, - contentPadding: EdgeInsets.zero, - ), - style: context.textTheme.bodyMedium, - onChanged: (v) => searchQuery.value = v, - ), - ), - if (searchQuery.value.isNotEmpty) - GestureDetector( - onTap: () { - searchController.clear(); - searchQuery.value = ''; - }, - child: Icon( - LucideIcons.x, - size: 16, - color: context.colors.onSurfaceVariant, - ), - ), - ], - ), - ), + _SearchField( + controller: searchController, + query: searchQuery.value, + onChanged: (v) => searchQuery.value = v, + onClear: () { + searchController.clear(); + searchQuery.value = ''; + }, ), - - // Theme list Expanded( - child: ListView( - controller: scrollController, - children: [ - // Default entry - if (showDefault) - _ThemeRow( - bg: defaultBg, - fg: defaultFg, - comment: defaultComment, - label: defaultLabel, - selected: selectedScheme == null, - onTap: () => - ref.read(schemeProvider.notifier).setScheme(null), - ), - - // All themes, flat alphabetical - for (final theme in filtered) - _ThemeRow( - bg: theme.bg, - fg: theme.fg, - comment: theme.comment, - label: theme.displayName, - selected: selectedScheme == theme.name, - onTap: () => - ref.read(schemeProvider.notifier).setScheme(theme.name), - ), - - if (!showDefault && filtered.isEmpty) - Padding( + child: filtered.isEmpty + ? Padding( padding: const EdgeInsets.all(Grid.sm), child: Center( child: Text( @@ -164,9 +97,26 @@ class ThemePickerPage extends HookConsumerWidget { ), ), ), + ) + : ListView.builder( + controller: scrollController, + itemCount: filtered.length, + itemBuilder: (_, i) { + final theme = filtered[i]; + final pairName = isSystem + ? themePairFor(theme.name) + : null; + return _ThemeRow( + theme: theme, + pair: pairName == null ? null : findTheme(pairName), + label: labelFor(theme), + selected: isSelected(theme), + onTap: () => ref + .read(schemeProvider.notifier) + .setScheme(theme.name), + ); + }, ), - ], - ), ), ], ), @@ -174,50 +124,118 @@ class ThemePickerPage extends HookConsumerWidget { } } -/// A row showing a 3-stripe color bar (bg/fg/comment) + theme name + checkmark. +class _SearchField extends StatelessWidget { + const _SearchField({ + required this.controller, + required this.query, + required this.onChanged, + required this.onClear, + }); + + final TextEditingController controller; + final String query; + final ValueChanged onChanged; + final VoidCallback onClear; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: Grid.gutter, + vertical: Grid.xxs, + ), + child: Container( + height: 36, + padding: const EdgeInsets.symmetric(horizontal: Grid.twelve), + decoration: BoxDecoration( + color: context.colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(Radii.lg), + border: Border.all(color: context.colors.outlineVariant), + ), + child: Row( + children: [ + Icon( + LucideIcons.search, + size: 16, + color: context.colors.onSurfaceVariant, + ), + const SizedBox(width: Grid.xxs), + Expanded( + child: TextField( + controller: controller, + decoration: InputDecoration( + hintText: 'Search themes...', + hintStyle: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.zero, + ), + style: context.textTheme.bodyMedium, + onChanged: onChanged, + ), + ), + if (query.isNotEmpty) + GestureDetector( + onTap: onClear, + child: Icon( + LucideIcons.x, + size: 16, + color: context.colors.onSurfaceVariant, + ), + ), + ], + ), + ), + ); + } +} + +/// A theme row with a colour bar + name + checkmark. When [pair] is supplied the +/// bar is split down the middle — light stripes on the left, dark on the right — +/// standing in for desktop's split light/dark preview tile. class _ThemeRow extends StatelessWidget { const _ThemeRow({ - required this.bg, - required this.fg, - required this.comment, + required this.theme, + required this.pair, required this.label, required this.selected, required this.onTap, }); - final Color bg; - final Color fg; - final Color comment; + final ThemeColors theme; + final ThemeColors? pair; final String label; final bool selected; final VoidCallback onTap; @override Widget build(BuildContext context) { + final swatch = pair == null ? [theme] : [theme, pair!]; + return ListTile( leading: Container( width: 56, height: 28, decoration: BoxDecoration( borderRadius: BorderRadius.circular(Radii.sm), - border: Border.all(color: context.colors.outlineVariant, width: 1), + border: Border.all(color: context.colors.outlineVariant), ), child: ClipRRect( borderRadius: BorderRadius.circular(Radii.sm - 1), child: Row( children: [ - Expanded( - child: ColoredBox(color: bg, child: const SizedBox.expand()), - ), - Expanded( - child: ColoredBox(color: fg, child: const SizedBox.expand()), - ), - Expanded( - child: ColoredBox( - color: comment, - child: const SizedBox.expand(), - ), - ), + for (final t in swatch) + for (final color in [t.bg, t.fg, t.comment]) + Expanded( + child: ColoredBox( + color: color, + child: const SizedBox.expand(), + ), + ), ], ), ), diff --git a/mobile/lib/features/custom_emoji/custom_emoji.dart b/mobile/lib/shared/custom_emoji/custom_emoji.dart similarity index 99% rename from mobile/lib/features/custom_emoji/custom_emoji.dart rename to mobile/lib/shared/custom_emoji/custom_emoji.dart index 5fe51151af..da2e2d2909 100644 --- a/mobile/lib/features/custom_emoji/custom_emoji.dart +++ b/mobile/lib/shared/custom_emoji/custom_emoji.dart @@ -1,6 +1,6 @@ import 'package:flutter/foundation.dart'; -import '../../shared/relay/nostr_models.dart'; +import '../relay/nostr_models.dart'; /// A community custom emoji: a `:shortcode:` mapped to an image URL. /// diff --git a/mobile/lib/features/custom_emoji/custom_emoji_provider.dart b/mobile/lib/shared/custom_emoji/custom_emoji_provider.dart similarity index 98% rename from mobile/lib/features/custom_emoji/custom_emoji_provider.dart rename to mobile/lib/shared/custom_emoji/custom_emoji_provider.dart index be866e17c4..1779678a0d 100644 --- a/mobile/lib/features/custom_emoji/custom_emoji_provider.dart +++ b/mobile/lib/shared/custom_emoji/custom_emoji_provider.dart @@ -1,6 +1,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../../shared/relay/relay.dart'; +import '../relay/relay.dart'; import 'custom_emoji.dart'; /// Community custom-emoji palette (NIP-30, per-user kind:30030 sets unioned). diff --git a/mobile/lib/features/custom_emoji/custom_emoji_render.dart b/mobile/lib/shared/custom_emoji/custom_emoji_render.dart similarity index 98% rename from mobile/lib/features/custom_emoji/custom_emoji_render.dart rename to mobile/lib/shared/custom_emoji/custom_emoji_render.dart index 549babe608..e4af0cc43f 100644 --- a/mobile/lib/features/custom_emoji/custom_emoji_render.dart +++ b/mobile/lib/shared/custom_emoji/custom_emoji_render.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:gpt_markdown/gpt_markdown.dart'; import 'package:gpt_markdown/custom_widgets/markdown_config.dart'; -import '../../shared/relay/relay.dart'; +import '../relay/relay.dart'; import 'custom_emoji.dart'; diff --git a/mobile/lib/shared/theme/app_colors.dart b/mobile/lib/shared/theme/app_colors.dart index 21c47eb0d1..2a937ec6b6 100644 --- a/mobile/lib/shared/theme/app_colors.dart +++ b/mobile/lib/shared/theme/app_colors.dart @@ -6,19 +6,31 @@ class AppColors extends ThemeExtension { final Color warning; final Color accent; + /// Gradient for the app's top section, non-null only under the Buzz themes. + /// Carried on the theme rather than read from a provider so any surface can + /// opt in via `context.appColors.topSectionGradient` — see + /// `buzzTopSectionGradient`. + final Gradient? topSectionGradient; + const AppColors({ required this.success, required this.warning, required this.accent, + this.topSectionGradient, }); @override - AppColors copyWith({Color? success, Color? warning, Color? accent}) => - AppColors( - success: success ?? this.success, - warning: warning ?? this.warning, - accent: accent ?? this.accent, - ); + AppColors copyWith({ + Color? success, + Color? warning, + Color? accent, + Gradient? topSectionGradient, + }) => AppColors( + success: success ?? this.success, + warning: warning ?? this.warning, + accent: accent ?? this.accent, + topSectionGradient: topSectionGradient ?? this.topSectionGradient, + ); @override AppColors lerp(ThemeExtension? other, double t) { @@ -27,6 +39,11 @@ class AppColors extends ThemeExtension { success: Color.lerp(success, other.success, t)!, warning: Color.lerp(warning, other.warning, t)!, accent: Color.lerp(accent, other.accent, t)!, + topSectionGradient: Gradient.lerp( + topSectionGradient, + other.topSectionGradient, + t, + ), ); } } diff --git a/mobile/lib/shared/theme/app_theme.dart b/mobile/lib/shared/theme/app_theme.dart index f05c579a32..c9ace49eee 100644 --- a/mobile/lib/shared/theme/app_theme.dart +++ b/mobile/lib/shared/theme/app_theme.dart @@ -13,16 +13,21 @@ class Radii { static const double lg = 10.0; static const double md = 8.0; static const double sm = 6.0; + static const double card = 12.0; // grouped settings cards static const double dialog = 24.0; // desktop uses rounded-3xl for dialogs } class AppTheme { - static ThemeData light({ColorScheme? colorScheme}) { + static ThemeData light({ + ColorScheme? colorScheme, + Gradient? topSectionGradient, + }) { final scheme = colorScheme ?? lightColorScheme; final appColors = AppColors( success: const Color(0xFF40A02B), // Catppuccin Latte Green — universal warning: const Color(0xFFDF8E1D), // Latte Yellow accent: scheme.tertiary, + topSectionGradient: topSectionGradient, ); return _buildTheme( @@ -34,7 +39,10 @@ class AppTheme { ); } - static ThemeData dark({ColorScheme? colorScheme}) { + static ThemeData dark({ + ColorScheme? colorScheme, + Gradient? topSectionGradient, + }) { final scheme = colorScheme ?? darkColorScheme; final appColors = AppColors( success: const Color( @@ -42,6 +50,7 @@ class AppTheme { ), // Catppuccin Macchiato Green — universal warning: const Color(0xFFEED49F), // Macchiato Yellow accent: scheme.tertiary, + topSectionGradient: topSectionGradient, ); return _buildTheme( diff --git a/mobile/lib/shared/theme/buzz_theme.dart b/mobile/lib/shared/theme/buzz_theme.dart new file mode 100644 index 0000000000..1214b97790 --- /dev/null +++ b/mobile/lib/shared/theme/buzz_theme.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; + +/// Name of the first-party Buzz theme. Buzz reuses the GitHub Light palette for +/// every base color; the one thing that sets it apart is a branded gradient +/// painted across the app's top section. Mirrors desktop, where the same +/// gradient fills the sidebar canvas — see `data-buzz-sidebar` in +/// `desktop/src/shared/styles/globals/theme.css`. +const buzzThemeName = 'buzz'; + +/// Name of the dark counterpart, which reuses the GitHub Dark palette and the +/// dark-tuned gradient stops. Paired with [buzzThemeName] in `themePairs`, so +/// the two behave as a single "Buzz" choice under System mode. +const buzzDarkThemeName = 'buzz-dark'; + +/// Whether [themeName] is either half of the Buzz pair. Both halves enable the +/// gradient so System mode keeps it on across an OS light/dark switch. +bool isBuzzTheme(String themeName) => + themeName == buzzThemeName || themeName == buzzDarkThemeName; + +/// Gradient stops, matching desktop's `--buzz-gradient-*` custom properties. +const _lightTop = Color(0xFFE6E6B6); +const _lightBottom = Color(0xFFC4D0DA); +const _darkTop = Color(0xFF4A4616); +const _darkBottom = Color(0xFF0A1423); + +/// The Buzz gradient for the app's top section, or null when [themeName] is not +/// a Buzz theme — in which case the section keeps its default frosted fill. +/// +/// The stops are fully opaque: under Buzz the color replaces the frosted +/// treatment rather than tinting it, matching desktop's solid sidebar canvas. +/// +/// [brightness] comes from the applied color scheme rather than the theme name, +/// so System mode picks the right stops as the OS switches. +LinearGradient? buzzTopSectionGradient( + String themeName, + Brightness brightness, +) { + if (!isBuzzTheme(themeName)) return null; + + final isDark = brightness == Brightness.dark; + return LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + isDark ? _darkTop : _lightTop, + isDark ? _darkBottom : _lightBottom, + ], + ); +} diff --git a/mobile/lib/shared/theme/theme.dart b/mobile/lib/shared/theme/theme.dart index e8a956661c..f1aae3609b 100644 --- a/mobile/lib/shared/theme/theme.dart +++ b/mobile/lib/shared/theme/theme.dart @@ -2,8 +2,10 @@ export 'accent_colors.dart'; export 'adaptive_theme.dart'; export 'app_colors.dart'; export 'app_theme.dart'; +export 'buzz_theme.dart'; export 'color_scheme.dart'; export 'grid.dart'; export 'theme_catalog.dart'; export 'theme_extensions.dart'; +export 'theme_pairs.dart'; export 'theme_provider.dart'; diff --git a/mobile/lib/shared/theme/theme_catalog.dart b/mobile/lib/shared/theme/theme_catalog.dart index 3a9780f201..9e00cfc811 100644 --- a/mobile/lib/shared/theme/theme_catalog.dart +++ b/mobile/lib/shared/theme/theme_catalog.dart @@ -33,6 +33,7 @@ class ThemeColors { /// Known light theme names — used to show sun/moon icons before loading. const lightThemeNames = { + 'buzz', 'catppuccin-latte', 'everforest-light', 'github-light', @@ -79,6 +80,25 @@ const themeCatalog = [ added: Color(0xFF70BF56), deleted: Color(0xFFF26D78), ), + // Buzz and Buzz Dark are first-party: they borrow the GitHub Light / GitHub + // Dark palettes wholesale and are distinguished only by the branded gradient + // painted across the app's top section (see buzz_theme.dart). + ThemeColors( + name: 'buzz', + bg: Color(0xFFFFFFFF), + fg: Color(0xFF24292E), + comment: Color(0xFF6A737D), + added: Color(0xFF28A745), + deleted: Color(0xFFD73A49), + ), + ThemeColors( + name: 'buzz-dark', + bg: Color(0xFF24292E), + fg: Color(0xFFE1E4E8), + comment: Color(0xFF6A737D), + added: Color(0xFF34D058), + deleted: Color(0xFFEA4A5A), + ), ThemeColors( name: 'catppuccin-frappe', bg: Color(0xFF303446), diff --git a/mobile/lib/shared/theme/theme_pairs.dart b/mobile/lib/shared/theme/theme_pairs.dart new file mode 100644 index 0000000000..cfbf6a53a8 --- /dev/null +++ b/mobile/lib/shared/theme/theme_pairs.dart @@ -0,0 +1,115 @@ +import 'theme_catalog.dart'; + +/// Light → dark theme counterparts, ported from the desktop app's `THEME_PAIRS` +/// (`desktop/src/shared/theme/theme-loader.ts`) so both clients offer the same +/// System-mode pairings. +/// +/// Buzz leads the map the way it leads desktop's, so the first-party pair sorts +/// ahead of the borrowed syntax themes wherever insertion order is preserved. +const themePairs = { + 'buzz': 'buzz-dark', + 'catppuccin-latte': 'catppuccin-mocha', + 'everforest-light': 'everforest-dark', + 'github-light': 'github-dark', + 'github-light-default': 'github-dark-default', + 'github-light-high-contrast': 'github-dark-high-contrast', + 'gruvbox-light-hard': 'gruvbox-dark-hard', + 'gruvbox-light-medium': 'gruvbox-dark-medium', + 'gruvbox-light-soft': 'gruvbox-dark-soft', + 'kanagawa-lotus': 'kanagawa-wave', + 'light-plus': 'dark-plus', + 'material-theme-lighter': 'material-theme', + 'min-light': 'min-dark', + 'one-light': 'one-dark-pro', + 'rose-pine-dawn': 'rose-pine', + 'slack-ochin': 'slack-dark', + 'solarized-light': 'solarized-dark', + 'vitesse-light': 'vitesse-dark', +}; + +final Map _darkToLight = { + for (final entry in themePairs.entries) entry.value: entry.key, +}; + +/// The counterpart of [name] in the opposite brightness, or null when the theme +/// has no pair. Resolves in both directions. +String? themePairFor(String name) => themePairs[name] ?? _darkToLight[name]; + +/// Whether [name] participates in a light/dark pair, and so can follow the OS. +bool isPairedTheme(String name) => themePairFor(name) != null; + +/// [themeCatalog] partitioned the way the appearance picker offers it. +class ThemeGroups { + /// Light members of light/dark pairs — the System-mode options. Each stands + /// in for both halves of its pair. + final List paired; + + /// Every light theme, paired or not — the Light-mode options. + final List light; + + /// Every dark theme, paired or not — the Dark-mode options. + final List dark; + + const ThemeGroups({ + required this.paired, + required this.light, + required this.dark, + }); +} + +ThemeGroups? _groups; + +/// [themeCatalog] grouped for the appearance picker, each list sorted by +/// display name. Computed once — the catalog is a compile-time constant. +ThemeGroups themeGroups() { + final cached = _groups; + if (cached != null) return cached; + + final paired = []; + final light = []; + final dark = []; + + for (final theme in themeCatalog) { + if (theme.isDark) { + dark.add(theme); + continue; + } + light.add(theme); + if (themePairs.containsKey(theme.name)) paired.add(theme); + } + + int byDisplayName(ThemeColors a, ThemeColors b) => + a.displayName.compareTo(b.displayName); + paired.sort(byDisplayName); + light.sort(byDisplayName); + dark.sort(byDisplayName); + + return _groups = ThemeGroups(paired: paired, light: light, dark: dark); +} + +/// Tokens that only describe a theme's brightness, stripped from paired labels. +const _modeTokens = { + 'light', + 'latte', + 'dawn', + 'lotus', + 'ochin', + 'lighter', + 'plus', +}; + +/// Display label for a pair, derived from its light member: strips the +/// mode-specific tokens so `github-light` reads as "Github" and stands for both +/// halves. Mirrors desktop's `pairedThemeLabel`. +String pairedThemeLabel(String lightName) { + final stripped = lightName + .split('-') + .where((token) => !_modeTokens.contains(token)) + .toList(); + // Stripping can remove everything (e.g. "light-plus") — fall back to the raw + // name so the row is never blank. + final parts = stripped.isEmpty ? lightName.split('-') : stripped; + return parts + .map((w) => w.isEmpty ? w : '${w[0].toUpperCase()}${w.substring(1)}') + .join(' '); +} diff --git a/mobile/lib/shared/theme/theme_provider.dart b/mobile/lib/shared/theme/theme_provider.dart index 79f47dcab4..908134ebf7 100644 --- a/mobile/lib/shared/theme/theme_provider.dart +++ b/mobile/lib/shared/theme/theme_provider.dart @@ -4,21 +4,26 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'accent_colors.dart'; import 'adaptive_theme.dart'; +import 'buzz_theme.dart'; import 'color_scheme.dart'; import 'theme_catalog.dart'; +import 'theme_pairs.dart'; const _themeModeKey = 'buzz_theme_mode'; const _accentKey = 'buzz_accent_color'; const _schemeKey = 'buzz_color_scheme'; -const defaultSchemeName = 'github-light'; -const defaultSchemeDisplayName = 'GitHub Light'; +/// Buzz ships as the default: the first-party pair, so a fresh install gets the +/// branded top-section gradient without picking a theme first. +const defaultSchemeName = buzzThemeName; +const defaultSchemeDisplayName = 'Buzz'; /// Pre-loaded SharedPreferences instance, overridden in main(). final savedPrefsProvider = Provider( (_) => throw UnimplementedError('Must be overridden'), ); +/// Tracks the appearance mode: follow the OS, or pin light/dark. class ThemeNotifier extends Notifier { @override ThemeMode build() { @@ -30,6 +35,11 @@ class ThemeNotifier extends Notifier { } return ThemeMode.system; } + + void setMode(ThemeMode mode) { + state = mode; + ref.read(savedPrefsProvider).setString(_themeModeKey, mode.name); + } } final themeProvider = NotifierProvider( @@ -68,19 +78,21 @@ class SchemeNotifier extends Notifier { @override String? build() { final prefs = ref.read(savedPrefsProvider); - final scheme = prefs.getString(_schemeKey); + final stored = prefs.getString(_schemeKey); + final compatible = schemeForAppearanceMode( + stored, + ref.watch(themeProvider), + ); - // One-time migration: if no scheme has been chosen and the user had a - // non-system themeMode persisted (from the old Light/System/Dark toggle), - // reset it because default schemes now control their own brightness. - if (scheme == null) { - final storedMode = prefs.getString(_themeModeKey); - if (storedMode != null && storedMode != ThemeMode.system.name) { - prefs.setString(_themeModeKey, ThemeMode.system.name); + if (compatible != stored) { + if (compatible == null) { + prefs.remove(_schemeKey); + } else { + prefs.setString(_schemeKey, compatible); } } - return scheme; + return compatible; } void setScheme(String? name) { @@ -98,25 +110,160 @@ final schemeProvider = NotifierProvider( SchemeNotifier.new, ); -/// Resolves the current scheme selection into light and dark [ColorScheme]s. -/// When a named scheme is selected, generates it via the adaptive engine. -/// When null (default), resolves to [defaultSchemeName]. -({ColorScheme light, ColorScheme dark, ThemeMode? forcedMode}) resolveSchemes( - String? schemeName, -) { - final theme = +/// Returns a scheme that can honor [mode] without silently pinning brightness. +/// +/// System mode only offers paired themes because they have both light and dark +/// variants. Switching to it from an unpaired or unknown selection therefore +/// falls back to the first paired theme, matching the picker ordering. +String? schemeForAppearanceMode(String? schemeName, ThemeMode mode) { + if (mode != ThemeMode.system) return schemeName; + + final selected = findTheme(schemeName ?? defaultSchemeName); + if (selected != null && isPairedTheme(selected.name)) return schemeName; + + return themeGroups().paired.firstOrNull?.name ?? schemeName; +} + +/// Resolves the selected scheme and appearance [mode] into light and dark +/// [ColorScheme]s, mirroring desktop's System / Light / Dark behaviour. +/// +/// In [ThemeMode.system] a paired theme is rendered with its pair +/// ([themePairFor]) so Flutter swaps them with the OS. An unpaired theme is +/// pinned to its own brightness. In [ThemeMode.light] and [ThemeMode.dark] the +/// theme is coerced to that brightness and the mode is forced, so the OS setting +/// is ignored. +({ + ColorScheme light, + ColorScheme dark, + ThemeColors? lightTheme, + ThemeColors? darkTheme, + ThemeMode? forcedMode, +}) +resolveSchemes(String? schemeName, ThemeMode mode) { + final selected = findTheme(schemeName ?? defaultSchemeName) ?? findTheme(defaultSchemeName); - if (theme == null) { - return (light: lightColorScheme, dark: darkColorScheme, forcedMode: null); + if (selected == null) { + return ( + light: lightColorScheme, + dark: darkColorScheme, + lightTheme: null, + darkTheme: null, + forcedMode: null, + ); } - final scheme = generateColorScheme(theme); - - // A named scheme is inherently light or dark — force the mode. - return ( - light: scheme, - dark: scheme, - forcedMode: theme.isDark ? ThemeMode.dark : ThemeMode.light, - ); + switch (mode) { + case ThemeMode.system: + final pairName = themePairFor(selected.name); + if (pairName == null) { + final scheme = generateColorScheme(selected); + return ( + light: scheme, + dark: scheme, + lightTheme: selected, + darkTheme: selected, + forcedMode: selected.isDark ? ThemeMode.dark : ThemeMode.light, + ); + } + final counterpart = findTheme(pairName) ?? selected; + final lightTheme = selected.isDark ? counterpart : selected; + final darkTheme = selected.isDark ? selected : counterpart; + return ( + light: generateColorScheme(lightTheme), + dark: generateColorScheme(darkTheme), + lightTheme: lightTheme, + darkTheme: darkTheme, + forcedMode: null, + ); + case ThemeMode.light: + final theme = _themeForBrightness(selected, wantDark: false); + final scheme = generateColorScheme(theme); + return ( + light: scheme, + dark: scheme, + lightTheme: theme, + darkTheme: theme, + forcedMode: ThemeMode.light, + ); + case ThemeMode.dark: + final theme = _themeForBrightness(selected, wantDark: true); + final scheme = generateColorScheme(theme); + return ( + light: scheme, + dark: scheme, + lightTheme: theme, + darkTheme: theme, + forcedMode: ThemeMode.dark, + ); + } +} + +/// The theme whose colors are actually applied for [schemeName] under [mode]. +/// +/// In [ThemeMode.system] that is the selected theme itself — its pair covers the +/// other brightness. In the pinned modes it is the theme coerced to the pinned +/// brightness, which is what the picker highlights so the checkmark always +/// tracks what is on screen. +ThemeColors? effectiveTheme(String? schemeName, ThemeMode mode) { + final selected = + findTheme(schemeName ?? defaultSchemeName) ?? + findTheme(defaultSchemeName); + if (selected == null) return null; + + return switch (mode) { + ThemeMode.system => selected, + ThemeMode.light => _themeForBrightness(selected, wantDark: false), + ThemeMode.dark => _themeForBrightness(selected, wantDark: true), + }; +} + +/// Label for the current theme selection. System mode names the pair as a whole +/// ("Github" rather than "Github Light"), since both halves are in play. +String themeSelectionLabel(String? schemeName, ThemeMode mode) { + final theme = effectiveTheme(schemeName, mode); + if (theme == null) return defaultSchemeDisplayName; + if (mode != ThemeMode.system) return theme.displayName; + + final lightMember = themePairs.containsKey(theme.name) + ? theme.name + : themePairFor(theme.name); + return lightMember == null + ? theme.displayName + : pairedThemeLabel(lightMember); +} + +/// Coerces [selected] to the requested brightness: itself when it already +/// matches, otherwise its pair, otherwise the first catalog theme of that +/// brightness. Keeps a stored light theme usable after switching to Dark mode +/// without rewriting the user's saved selection. +ThemeColors _themeForBrightness( + ThemeColors selected, { + required bool wantDark, +}) { + if (selected.isDark == wantDark) return selected; + + final pairName = themePairFor(selected.name); + final paired = pairName == null ? null : findTheme(pairName); + if (paired != null && paired.isDark == wantDark) return paired; + + // Match desktop's paired-first picker ordering: an unpaired selection falls + // back through the first-party default pair, not whichever borrowed theme + // sorts first in the full brightness group. + final defaultTheme = findTheme(defaultSchemeName); + if (defaultTheme != null) { + if (defaultTheme.isDark == wantDark) return defaultTheme; + + final defaultPairName = themePairFor(defaultTheme.name); + final defaultPair = defaultPairName == null + ? null + : findTheme(defaultPairName); + if (defaultPair != null && defaultPair.isDark == wantDark) { + return defaultPair; + } + } + + final groups = themeGroups(); + final fallback = wantDark ? groups.dark : groups.light; + return fallback.isEmpty ? selected : fallback.first; } diff --git a/mobile/lib/shared/widgets/app_list.dart b/mobile/lib/shared/widgets/app_list.dart index 53557925a0..393b1d70ba 100644 --- a/mobile/lib/shared/widgets/app_list.dart +++ b/mobile/lib/shared/widgets/app_list.dart @@ -1,10 +1,19 @@ import 'package:flutter/material.dart'; import '../theme/theme.dart'; +import 'app_list_inset.dart'; + +/// Widest an inline [AppListRow.value] may grow before it ellipsises, leaving +/// room for a reasonable title beside it. +const double _maxValueWidth = 180.0; + +/// Row height comes entirely from this padding — rows carry a single line of +/// text most of the time, so it sets how airy a card reads. +const double _rowVerticalPadding = Grid.xs; /// A flush, borderless settings/list row: leading icon, title, optional -/// subtitle and trailing widget. No card, no background — groups are -/// separated by [AppListSection] dividers instead. +/// subtitle and trailing widget. Its own background comes from whatever +/// contains it — a grouped card, or the page itself. class AppListRow extends StatelessWidget { const AppListRow({ super.key, @@ -13,6 +22,7 @@ class AppListRow extends StatelessWidget { this.subtitle, this.subtitleStyle, this.subtitleMaxLines, + this.value, this.trailing, this.titleColor, this.onTap, @@ -23,6 +33,11 @@ class AppListRow extends StatelessWidget { final String? subtitle; final TextStyle? subtitleStyle; final int? subtitleMaxLines; + + /// The row's current setting, shown muted on the trailing side next to + /// [trailing] — for rows whose value is short enough to sit inline. + final String? value; + final Widget? trailing; final Color? titleColor; final VoidCallback? onTap; @@ -30,21 +45,20 @@ class AppListRow extends StatelessWidget { @override Widget build(BuildContext context) { final row = Padding( - padding: const EdgeInsets.symmetric( - horizontal: Grid.gutter, - vertical: Grid.twelve, + padding: EdgeInsets.symmetric( + horizontal: AppListInset.of(context), + vertical: _rowVerticalPadding, ), child: Row( - crossAxisAlignment: CrossAxisAlignment.start, + // Centred rather than baseline-aligned to the title: on a two-line row + // the icon and trailing control read as belonging to the row, not to + // its first line. children: [ if (icon != null) ...[ - Padding( - padding: const EdgeInsets.only(top: 1), - child: Icon( - icon, - size: 22, - color: titleColor ?? context.colors.onSurfaceVariant, - ), + Icon( + icon, + size: 22, + color: titleColor ?? context.colors.onSurfaceVariant, ), const SizedBox(width: Grid.xs), ], @@ -77,6 +91,24 @@ class AppListRow extends StatelessWidget { ], ), ), + if (value != null) ...[ + const SizedBox(width: Grid.xxs), + // Inflexible, so the title's Expanded absorbs the slack and the + // value stays flush against the trailing edge; capped instead of + // flexed so a long value ellipsises rather than overflowing. + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: _maxValueWidth), + child: Text( + value!, + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.right, + ), + ), + ], if (trailing != null) ...[const SizedBox(width: Grid.xxs), trailing!], ], ), @@ -108,9 +140,9 @@ class AppListRowRaw extends StatelessWidget { @override Widget build(BuildContext context) { final row = Padding( - padding: const EdgeInsets.symmetric( - horizontal: Grid.gutter, - vertical: Grid.twelve, + padding: EdgeInsets.symmetric( + horizontal: AppListInset.of(context), + vertical: _rowVerticalPadding, ), child: Row( children: [ @@ -138,51 +170,3 @@ class AppListRowRaw extends StatelessWidget { return InkWell(onTap: onTap, child: row); } } - -/// A group of list rows separated from the previous group by a hairline -/// divider with breathing room, Slack-style. An optional [label] renders a -/// small muted header above the rows. -class AppListSection extends StatelessWidget { - const AppListSection({ - super.key, - this.label, - required this.children, - this.showDivider = true, - }); - - final String? label; - final List children; - final bool showDivider; - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (showDivider) - Padding( - padding: const EdgeInsets.symmetric(vertical: Grid.xxs), - child: Divider(height: 1, color: context.colors.outlineVariant), - ), - if (label != null) - Padding( - padding: const EdgeInsets.fromLTRB( - Grid.gutter, - Grid.xxs, - Grid.gutter, - Grid.quarter, - ), - child: Text( - label!.toUpperCase(), - style: context.textTheme.labelMedium?.copyWith( - color: context.colors.onSurfaceVariant, - fontWeight: FontWeight.w600, - letterSpacing: 0.6, - ), - ), - ), - ...children, - ], - ); - } -} diff --git a/mobile/lib/shared/widgets/app_list_card.dart b/mobile/lib/shared/widgets/app_list_card.dart new file mode 100644 index 0000000000..20a569e2e3 --- /dev/null +++ b/mobile/lib/shared/widgets/app_list_card.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; + +import '../theme/theme.dart'; +import 'app_list_inset.dart'; + +/// A group of list rows in a rounded container, with an optional [label] above +/// it. Rows inside are hairline-separated and inset to the card rather than the +/// page, via [AppListInset]. +class AppListCard extends StatelessWidget { + const AppListCard({super.key, this.label, required this.children}); + + /// Rendered above the card in sentence case, as written — no uppercasing. + final String? label; + + final List children; + + static const _inset = Grid.xs; + + /// Separators start at the label column, clearing the leading icon. + static const _dividerIndent = _inset + _iconColumnWidth; + static const _iconColumnWidth = 22.0 + Grid.xs; + + @override + Widget build(BuildContext context) { + final separated = []; + for (var index = 0; index < children.length; index++) { + if (index > 0) { + separated.add( + Divider( + height: 1, + thickness: 1, + indent: _dividerIndent, + endIndent: _inset, + // The scheme's own border tokens are derived from the page surface, + // which lands them within a few levels of the card fill — invisible. + // Tinting with the text color instead keeps the hairline readable on + // the card in both brightnesses. + color: context.colors.onSurface.withValues(alpha: 0.12), + ), + ); + } + separated.add(children[index]); + } + + return Padding( + padding: const EdgeInsets.fromLTRB( + Grid.gutter, + Grid.xxs, + Grid.gutter, + Grid.xxs, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (label != null) + Padding( + padding: const EdgeInsets.only(left: Grid.half, bottom: Grid.xxs), + child: Text( + label!, + style: context.textTheme.labelMedium?.copyWith( + color: context.colors.onSurfaceVariant, + fontWeight: FontWeight.w600, + ), + ), + ), + Material( + // The softest step on the elevation ramp — a rung below the home tab + // bar's active pill (primaryContainer, see PR #2810), since a + // full-width card at the pill's contrast reads heavier than the pill + // does. The dividers carry the group structure, so the fill only has + // to separate the card from the page. + color: context.colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(Radii.card), + // Keeps row ripples inside the rounded corners. + clipBehavior: Clip.antiAlias, + child: AppListInset( + horizontal: _inset, + child: Column(children: separated), + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/shared/widgets/app_list_inset.dart b/mobile/lib/shared/widgets/app_list_inset.dart new file mode 100644 index 0000000000..122894e4f6 --- /dev/null +++ b/mobile/lib/shared/widgets/app_list_inset.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; + +import '../theme/theme.dart'; + +/// The horizontal padding list rows use, allowing grouped containers to replace +/// the page-level inset without changing each row. +class AppListInset extends InheritedWidget { + const AppListInset({ + super.key, + required this.horizontal, + required super.child, + }); + + final double horizontal; + + static double of(BuildContext context) => + context.dependOnInheritedWidgetOfExactType()?.horizontal ?? + Grid.gutter; + + @override + bool updateShouldNotify(AppListInset oldWidget) => + oldWidget.horizontal != horizontal; +} diff --git a/mobile/lib/shared/widgets/frosted_app_bar.dart b/mobile/lib/shared/widgets/frosted_app_bar.dart index 34a2da8970..9fa76781bd 100644 --- a/mobile/lib/shared/widgets/frosted_app_bar.dart +++ b/mobile/lib/shared/widgets/frosted_app_bar.dart @@ -36,6 +36,11 @@ class FrostedAppBar extends StatelessWidget { /// Color applied to icons in the app bar. final Color? iconColor; + /// Paints over the frosted fill instead of the default translucent surface. + /// Used by the Buzz themes to carry their branded gradient across the app's + /// top section — see [buzzTopSectionGradient]. + final Gradient? gradient; + const FrostedAppBar({ super.key, this.leading, @@ -43,6 +48,7 @@ class FrostedAppBar extends StatelessWidget { this.actions = const [], this.horizontalInset = Grid.quarter, this.iconColor, + this.gradient, }); @override @@ -75,7 +81,12 @@ class FrostedAppBar extends StatelessWidget { child: Container( padding: EdgeInsets.only(top: topPadding), decoration: BoxDecoration( - color: context.colors.surface.withValues(alpha: 0.5), + // A gradient and a color cannot both paint, so the gradient + // replaces the frosted surface fill when one is supplied. + color: gradient == null + ? context.colors.surface.withValues(alpha: 0.5) + : null, + gradient: gradient, border: Border( bottom: BorderSide( color: context.colors.outlineVariant.withValues(alpha: 0.3), diff --git a/mobile/lib/shared/widgets/masked_avatar_badge.dart b/mobile/lib/shared/widgets/masked_avatar_badge.dart new file mode 100644 index 0000000000..4d476abfd4 --- /dev/null +++ b/mobile/lib/shared/widgets/masked_avatar_badge.dart @@ -0,0 +1,356 @@ +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; + +/// Avatar-with-badge geometry ported from desktop's `MaskedAvatarBadgeFrame` +/// (`desktop/src/features/profile/ui/MaskedAvatarBadgeFrame.tsx`). +/// +/// A badge (presence dot, status glyph) sits in a notch cut out of the avatar +/// rather than on top of it. The notch is not a plain circular subtraction: the +/// two edges are joined by cubic fillets so the avatar's outline flows into the +/// cutout instead of meeting it at a cusp. That fillet is the whole visual +/// signature, which is why the curve constants are ported verbatim. +@immutable +class AvatarBadgeCurve { + const AvatarBadgeCurve({ + this.avatarRoundingAngle = 0.075, + this.cutoutRoundingLength = 5.5, + this.cutoutRoundingMinAngle = 0.22, + this.cutoutRoundingMaxAngle = 0.38, + this.handleDistanceRatio = 0.42, + this.handleLengthRatio = 0.14, + }); + + /// Radians the avatar's edge pulls back from the intersection before the + /// fillet starts. + final double avatarRoundingAngle; + + /// Fillet length along the cutout, converted to an angle by dividing by the + /// cutout radius and clamped between the two angle bounds below. + final double cutoutRoundingLength; + final double cutoutRoundingMinAngle; + final double cutoutRoundingMaxAngle; + + /// Bezier handle length, as a fraction of the gap between the two fillet + /// endpoints and of the cutout radius — whichever is shorter wins. + final double handleDistanceRatio; + final double handleLengthRatio; + + /// Desktop's `DEFAULT_AVATAR_BADGE_CURVE`, used for large badges. + static const standard = AvatarBadgeCurve(); + + /// Desktop's `STATUS_DOT_MASK_CURVE` — a rounder, softer notch that reads + /// correctly at presence-dot sizes. + static const statusDot = AvatarBadgeCurve( + avatarRoundingAngle: 0.11, + cutoutRoundingLength: 6.5, + cutoutRoundingMinAngle: 0.28, + cutoutRoundingMaxAngle: 0.44, + handleDistanceRatio: 0.5, + handleLengthRatio: 0.2, + ); + + @override + bool operator ==(Object other) => + other is AvatarBadgeCurve && + other.avatarRoundingAngle == avatarRoundingAngle && + other.cutoutRoundingLength == cutoutRoundingLength && + other.cutoutRoundingMinAngle == cutoutRoundingMinAngle && + other.cutoutRoundingMaxAngle == cutoutRoundingMaxAngle && + other.handleDistanceRatio == handleDistanceRatio && + other.handleLengthRatio == handleLengthRatio; + + @override + int get hashCode => Object.hash( + avatarRoundingAngle, + cutoutRoundingLength, + cutoutRoundingMinAngle, + cutoutRoundingMaxAngle, + handleDistanceRatio, + handleLengthRatio, + ); +} + +/// Where the notch sits and how big the badge in it is, as fractions of the +/// avatar box so one constant covers every avatar size. +@immutable +class AvatarBadgeMaskGeometry { + const AvatarBadgeMaskGeometry({ + required this.cutoutCenterRatio, + required this.cutoutRadiusRatio, + required this.badgeSizeRatio, + required this.curve, + }); + + /// Distance of the notch centre from the top-left, on both axes. + final double cutoutCenterRatio; + final double cutoutRadiusRatio; + + /// The badge is centred on the notch and is deliberately smaller than it, so + /// a ring of background shows between badge and avatar. + final double badgeSizeRatio; + + final AvatarBadgeCurve curve; + + /// Desktop's settings-card treatment: a 192px avatar with a 54px badge notched + /// in at (165, 165) with radius 30. + static const badge = AvatarBadgeMaskGeometry( + cutoutCenterRatio: 165 / 192, + cutoutRadiusRatio: 30 / 192, + badgeSizeRatio: 54 / 192, + curve: AvatarBadgeCurve.standard, + ); + + /// Desktop's sidebar presence treatment: a 32px avatar with a 14px dot frame + /// notched in at (28, 28) with radius 7.5. + static const presenceDot = AvatarBadgeMaskGeometry( + cutoutCenterRatio: 28 / 32, + cutoutRadiusRatio: 7.5 / 32, + badgeSizeRatio: 14 / 32, + curve: AvatarBadgeCurve.statusDot, + ); + + Offset cutoutCenter(double size) => + Offset(size * cutoutCenterRatio, size * cutoutCenterRatio); + + double cutoutRadius(double size) => size * cutoutRadiusRatio; + + double badgeSize(double size) => size * badgeSizeRatio; + + @override + bool operator ==(Object other) => + other is AvatarBadgeMaskGeometry && + other.cutoutCenterRatio == cutoutCenterRatio && + other.cutoutRadiusRatio == cutoutRadiusRatio && + other.badgeSizeRatio == badgeSizeRatio && + other.curve == curve; + + @override + int get hashCode => + Object.hash(cutoutCenterRatio, cutoutRadiusRatio, badgeSizeRatio, curve); +} + +double _angleTo(Offset center, Offset point) => + math.atan2(point.dy - center.dy, point.dx - center.dx); + +Offset _pointOn(Offset center, double radius, double angle) => + center + Offset(math.cos(angle), math.sin(angle)) * radius; + +/// Unit tangent at [angle]; [clockwise] picks which way around the circle. +Offset _tangent(double angle, {required bool clockwise}) => clockwise + ? Offset(-math.sin(angle), math.cos(angle)) + : Offset(math.sin(angle), -math.cos(angle)); + +/// Signed sweep from [startAngle] to [endAngle], ported from desktop's +/// `getArcSweep`. Flutter shares SVG's angle convention (y grows downward, so a +/// positive sweep runs clockwise on screen), which is why the port is direct. +double _arcSweep( + double startAngle, + double endAngle, { + required bool clockwise, + required bool largeArc, +}) { + const fullTurn = math.pi * 2; + var sweep = clockwise ? endAngle - startAngle : startAngle - endAngle; + while (sweep < 0) { + sweep += fullTurn; + } + if (largeArc && sweep < math.pi) sweep += fullTurn; + if (!largeArc && sweep > math.pi) sweep -= fullTurn; + return clockwise ? sweep : -sweep; +} + +/// The avatar outline with [geometry]'s notch cut out of its bottom-right. +/// +/// Falls back to a plain circle when the notch does not actually cross the +/// avatar's edge — there is nothing to fillet in that case. +Path avatarBadgeMaskPath({ + required double size, + required AvatarBadgeMaskGeometry geometry, +}) { + final avatarRadius = size / 2; + final avatarCenter = Offset(avatarRadius, avatarRadius); + final avatarRect = Rect.fromCircle( + center: avatarCenter, + radius: avatarRadius, + ); + final cutoutCenter = geometry.cutoutCenter(size); + final cutoutRadius = geometry.cutoutRadius(size); + final curve = geometry.curve; + + final span = cutoutCenter - avatarCenter; + final distance = span.distance; + if (distance >= avatarRadius + cutoutRadius || + distance <= (avatarRadius - cutoutRadius).abs()) { + return Path()..addOval(avatarRect); + } + + // Chord where the two circles cross, split into the upper and lower crossing. + final distanceToMidpoint = + (avatarRadius * avatarRadius - + cutoutRadius * cutoutRadius + + distance * distance) / + (2 * distance); + final halfChord = math.sqrt( + math.max( + 0, + avatarRadius * avatarRadius - distanceToMidpoint * distanceToMidpoint, + ), + ); + final unit = span / distance; + final midpoint = avatarCenter + unit * distanceToMidpoint; + final perpendicular = Offset(-unit.dy, unit.dx); + final first = midpoint + perpendicular * halfChord; + final second = midpoint - perpendicular * halfChord; + final upperCrossing = first.dy < second.dy ? first : second; + final lowerCrossing = first.dy < second.dy ? second : first; + + final cutoutRoundingAngle = math.min( + curve.cutoutRoundingMaxAngle, + math.max( + curve.cutoutRoundingMinAngle, + curve.cutoutRoundingLength / cutoutRadius, + ), + ); + + // Pull each edge back from its crossing; the gap left behind is the fillet. + final avatarUpperAngle = + _angleTo(avatarCenter, upperCrossing) - curve.avatarRoundingAngle; + final avatarLowerAngle = + _angleTo(avatarCenter, lowerCrossing) + curve.avatarRoundingAngle; + final cutoutUpperAngle = + _angleTo(cutoutCenter, upperCrossing) - cutoutRoundingAngle; + final cutoutLowerAngle = + _angleTo(cutoutCenter, lowerCrossing) + cutoutRoundingAngle; + + final avatarUpper = _pointOn(avatarCenter, avatarRadius, avatarUpperAngle); + final avatarLower = _pointOn(avatarCenter, avatarRadius, avatarLowerAngle); + final cutoutUpper = _pointOn(cutoutCenter, cutoutRadius, cutoutUpperAngle); + final cutoutLower = _pointOn(cutoutCenter, cutoutRadius, cutoutLowerAngle); + + final upperHandle = math.min( + cutoutRadius * curve.handleLengthRatio, + (cutoutUpper - avatarUpper).distance * curve.handleDistanceRatio, + ); + final lowerHandle = math.min( + cutoutRadius * curve.handleLengthRatio, + (avatarLower - cutoutLower).distance * curve.handleDistanceRatio, + ); + + final upperFromCutout = + cutoutUpper + _tangent(cutoutUpperAngle, clockwise: true) * upperHandle; + final upperIntoAvatar = + avatarUpper - _tangent(avatarUpperAngle, clockwise: false) * upperHandle; + final lowerFromAvatar = + avatarLower + _tangent(avatarLowerAngle, clockwise: false) * lowerHandle; + final lowerIntoCutout = + cutoutLower - _tangent(cutoutLowerAngle, clockwise: true) * lowerHandle; + + return Path() + ..moveTo(cutoutUpper.dx, cutoutUpper.dy) + ..cubicTo( + upperFromCutout.dx, + upperFromCutout.dy, + upperIntoAvatar.dx, + upperIntoAvatar.dy, + avatarUpper.dx, + avatarUpper.dy, + ) + // The long way round the avatar, back to the other side of the notch. + ..arcTo( + avatarRect, + avatarUpperAngle, + _arcSweep( + avatarUpperAngle, + avatarLowerAngle, + clockwise: false, + largeArc: true, + ), + false, + ) + ..cubicTo( + lowerFromAvatar.dx, + lowerFromAvatar.dy, + lowerIntoCutout.dx, + lowerIntoCutout.dy, + cutoutLower.dx, + cutoutLower.dy, + ) + // Back across the notch itself, which bites into the avatar. + ..arcTo( + Rect.fromCircle(center: cutoutCenter, radius: cutoutRadius), + cutoutLowerAngle, + _arcSweep( + cutoutLowerAngle, + cutoutUpperAngle, + clockwise: true, + largeArc: false, + ), + false, + ) + ..close(); +} + +/// Clips an avatar to the rounded badge-notch path described by [geometry]. +class AvatarBadgeMaskClipper extends CustomClipper { + const AvatarBadgeMaskClipper({required this.geometry}); + + final AvatarBadgeMaskGeometry geometry; + + @override + Path getClip(Size size) => + avatarBadgeMaskPath(size: size.shortestSide, geometry: geometry); + + @override + bool shouldReclip(covariant AvatarBadgeMaskClipper oldClipper) => + oldClipper.geometry != geometry; +} + +/// A square [avatar] with [badge] seated in a notch cut out of its bottom-right +/// corner. With no [badge] the avatar is left whole — there is nothing to make +/// room for. +class MaskedAvatarBadge extends StatelessWidget { + const MaskedAvatarBadge({ + super.key, + required this.size, + required this.avatar, + this.geometry = AvatarBadgeMaskGeometry.badge, + this.badge, + }); + + final double size; + final Widget avatar; + final AvatarBadgeMaskGeometry geometry; + final Widget? badge; + + @override + Widget build(BuildContext context) { + if (badge == null) { + return SizedBox.square(dimension: size, child: avatar); + } + + final badgeSize = geometry.badgeSize(size); + final center = geometry.cutoutCenter(size); + + return SizedBox.square( + dimension: size, + child: Stack( + // The notch reaches past the avatar box, so the badge does too. + clipBehavior: Clip.none, + children: [ + ClipPath( + clipper: AvatarBadgeMaskClipper(geometry: geometry), + child: SizedBox.square(dimension: size, child: avatar), + ), + Positioned( + left: center.dx - badgeSize / 2, + top: center.dy - badgeSize / 2, + width: badgeSize, + height: badgeSize, + child: badge!, + ), + ], + ), + ); + } +} diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index 0cb3383f3a..b51133ede2 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -11,6 +11,7 @@ import 'package:buzz/features/channels/channels_page.dart'; import 'package:buzz/features/channels/channels_provider.dart'; import 'package:buzz/features/channels/read_state/read_state_provider.dart'; import 'package:buzz/features/channels/unread_badge/observed_unread_event.dart'; +import 'package:buzz/features/profile/profile_avatar.dart'; import 'package:buzz/features/profile/profile_provider.dart'; import 'package:buzz/features/profile/user_profile.dart'; import 'package:buzz/shared/theme/theme.dart'; @@ -42,7 +43,7 @@ void main() { ), home: const Stack( children: [ - ChannelsPage(), + ChannelsPage(settingsPageBuilder: _buildSettingsPage), Positioned.fill( child: ChannelQuickActionsLauncher( visible: true, @@ -117,6 +118,24 @@ void main() { expect(find.byTooltip('Create or start conversation'), findsOneWidget); }); + testWidgets('opens the settings page supplied by the app layer', ( + tester, + ) async { + await tester.pumpWidget( + buildTestable( + overrides: [ + channelsProvider.overrideWith(() => _FakeNotifier(testChannels)), + ], + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byType(ProfileAvatar)); + await tester.pumpAndSettle(); + + expect(find.text('Injected settings'), findsOneWidget); + }); + testWidgets('quick actions slide behind navigation when leaving home', ( tester, ) async { @@ -885,6 +904,9 @@ void main() { }); } +Widget _buildSettingsPage(BuildContext context) => + const Scaffold(body: Text('Injected settings')); + class _FakeNotifier extends ChannelsNotifier { final List _channels; final Map> _observedEventsByChannel; diff --git a/mobile/test/features/channels/compose_bar_test.dart b/mobile/test/features/channels/compose_bar_test.dart index a4953401ae..4464749382 100644 --- a/mobile/test/features/channels/compose_bar_test.dart +++ b/mobile/test/features/channels/compose_bar_test.dart @@ -18,8 +18,8 @@ import 'package:buzz/features/channels/compose_bar.dart'; import 'package:buzz/features/channels/channels_provider.dart'; import 'package:buzz/features/channels/mentions/mention_candidates.dart'; import 'package:buzz/features/channels/mentions/mention_candidates_provider.dart'; -import 'package:buzz/features/custom_emoji/custom_emoji.dart'; -import 'package:buzz/features/custom_emoji/custom_emoji_provider.dart'; +import 'package:buzz/shared/custom_emoji/custom_emoji.dart'; +import 'package:buzz/shared/custom_emoji/custom_emoji_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; import 'package:buzz/shared/theme/theme.dart'; diff --git a/mobile/test/features/home/home_page_test.dart b/mobile/test/features/home/home_page_test.dart index a738c3dce4..15e13b5822 100644 --- a/mobile/test/features/home/home_page_test.dart +++ b/mobile/test/features/home/home_page_test.dart @@ -12,7 +12,10 @@ void main() { ) async { await tester.pumpWidget( ProviderScope( - child: MaterialApp(theme: AppTheme.light(), home: const HomePage()), + child: MaterialApp( + theme: AppTheme.light(), + home: const HomePage(settingsPageBuilder: _buildSettingsPage), + ), ), ); await tester.pump(); @@ -67,7 +70,10 @@ void main() { await tester.pumpWidget( ProviderScope( - child: MaterialApp(theme: AppTheme.light(), home: const HomePage()), + child: MaterialApp( + theme: AppTheme.light(), + home: const HomePage(settingsPageBuilder: _buildSettingsPage), + ), ), ); await tester.pump(); @@ -95,7 +101,10 @@ void main() { ) async { await tester.pumpWidget( ProviderScope( - child: MaterialApp(theme: AppTheme.light(), home: const HomePage()), + child: MaterialApp( + theme: AppTheme.light(), + home: const HomePage(settingsPageBuilder: _buildSettingsPage), + ), ), ); await tester.pump(); @@ -135,3 +144,5 @@ void main() { expect(opacity(), closeTo(1, 0.001)); }); } + +Widget _buildSettingsPage(BuildContext context) => const SizedBox.shrink(); diff --git a/mobile/test/features/profile/profile_avatar_test.dart b/mobile/test/features/profile/profile_avatar_test.dart new file mode 100644 index 0000000000..4562e4166c --- /dev/null +++ b/mobile/test/features/profile/profile_avatar_test.dart @@ -0,0 +1,104 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:buzz/features/profile/profile_avatar.dart'; +import 'package:buzz/features/profile/profile_provider.dart'; +import 'package:buzz/features/profile/user_profile.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:buzz/shared/widgets/masked_avatar_badge.dart'; + +void main() { + Widget harness({bool showPresence = true, String presence = 'online'}) { + return ProviderScope( + overrides: [ + profileProvider.overrideWith(_FakeProfileNotifier.new), + presenceProvider.overrideWith(() => _FakePresenceNotifier(presence)), + ], + child: MaterialApp( + theme: AppTheme.light(), + home: Center(child: ProfileAvatar(showPresence: showPresence)), + ), + ); + } + + testWidgets('seats the presence dot in a notch instead of over the avatar', ( + tester, + ) async { + await tester.pumpWidget(harness()); + await tester.pumpAndSettle(); + + final badge = tester.widget( + find.byType(MaskedAvatarBadge), + ); + expect(badge.geometry, AvatarBadgeMaskGeometry.presenceDot); + expect( + tester.widget(find.byType(ClipPath).last).clipper, + isA(), + ); + + // The notch supplies the separation, so the dot carries no border ring. + final dot = tester + .widgetList(find.byType(DecoratedBox)) + .map((box) => box.decoration) + .whereType() + .firstWhere((decoration) => decoration.shape == BoxShape.circle); + expect(dot.border, isNull); + }); + + Color dotColor(WidgetTester tester) => tester + .widgetList(find.byType(DecoratedBox)) + .map((box) => box.decoration) + .whereType() + .firstWhere((decoration) => decoration.shape == BoxShape.circle) + .color!; + + final theme = AppTheme.light(); + + testWidgets('paints the dot with the presence color', (tester) async { + await tester.pumpWidget(harness()); + await tester.pumpAndSettle(); + + expect(dotColor(tester), theme.extension()!.success); + }); + + testWidgets('mutes the dot when offline', (tester) async { + await tester.pumpWidget(harness(presence: 'offline')); + await tester.pumpAndSettle(); + + expect(dotColor(tester), theme.colorScheme.outline); + }); + + testWidgets('leaves the avatar unmasked when presence is hidden', ( + tester, + ) async { + await tester.pumpWidget(harness(showPresence: false)); + await tester.pumpAndSettle(); + + final badge = tester.widget( + find.byType(MaskedAvatarBadge), + ); + expect(badge.badge, isNull); + expect( + find.descendant( + of: find.byType(MaskedAvatarBadge), + matching: find.byType(ClipPath), + ), + findsNothing, + ); + }); +} + +class _FakeProfileNotifier extends ProfileNotifier { + @override + Future build() async => + const UserProfile(pubkey: 'aabb', displayName: 'Test'); +} + +class _FakePresenceNotifier extends PresenceNotifier { + _FakePresenceNotifier(this._presence); + + final String _presence; + + @override + Future build() async => _presence; +} diff --git a/mobile/test/features/profile/set_status_sheet_test.dart b/mobile/test/features/profile/set_status_sheet_test.dart new file mode 100644 index 0000000000..53563758d9 --- /dev/null +++ b/mobile/test/features/profile/set_status_sheet_test.dart @@ -0,0 +1,72 @@ +import 'package:buzz/features/profile/set_status_sheet.dart'; +import 'package:buzz/features/profile/user_status.dart'; +import 'package:buzz/features/profile/user_status_provider.dart'; +import 'package:buzz/shared/custom_emoji/custom_emoji_provider.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../helpers/widget_helpers.dart'; + +void main() { + testWidgets('removes only the emoji from an existing status', (tester) async { + final statusNotifier = _RecordingUserStatusNotifier(); + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + customEmojiListProvider.overrideWithValue(const []), + userStatusProvider.overrideWith(() => statusNotifier), + ], + child: Builder( + builder: (context) => FilledButton( + onPressed: () => showSetStatusSheet( + context, + currentStatus: const UserStatus( + text: 'Focusing', + emoji: '\u{1F3AF}', + updatedAt: 1, + ), + ), + child: const Text('Open status editor'), + ), + ), + ), + ); + + await tester.tap(find.text('Open status editor')); + await tester.pumpAndSettle(); + + expect(find.text('\u{1F3AF}'), findsOneWidget); + expect(find.byTooltip('Remove status emoji'), findsOneWidget); + + await tester.tap(find.byTooltip('Remove status emoji')); + await tester.pump(); + + expect(find.text('\u{1F3AF}'), findsNothing); + expect(find.byIcon(LucideIcons.smilePlus), findsOneWidget); + expect( + tester.widget(find.byType(TextField)).controller?.text, + 'Focusing', + ); + + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect(statusNotifier.savedText, 'Focusing'); + expect(statusNotifier.savedEmoji, isEmpty); + }); +} + +class _RecordingUserStatusNotifier extends UserStatusNotifier { + String? savedText; + String? savedEmoji; + + @override + Future build() async => null; + + @override + Future setStatus(String text, String emoji) async { + savedText = text; + savedEmoji = emoji; + } +} diff --git a/mobile/test/features/profile/settings_profile_header_test.dart b/mobile/test/features/profile/settings_profile_header_test.dart new file mode 100644 index 0000000000..dbf27b5c81 --- /dev/null +++ b/mobile/test/features/profile/settings_profile_header_test.dart @@ -0,0 +1,63 @@ +import 'package:buzz/features/profile/profile_provider.dart'; +import 'package:buzz/features/profile/settings_profile_header.dart'; +import 'package:buzz/features/profile/user_profile.dart'; +import 'package:buzz/features/profile/user_status.dart'; +import 'package:buzz/features/profile/user_status_provider.dart'; +import 'package:buzz/shared/custom_emoji/custom_emoji_provider.dart'; +import 'package:buzz/shared/widgets/masked_avatar_badge.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../helpers/widget_helpers.dart'; + +void main() { + testWidgets('uses a bounded icon for an unresolved status shortcode', ( + tester, + ) async { + const missingShortcode = ':very_long_missing_custom_emoji:'; + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + profileProvider.overrideWith(_FakeProfileNotifier.new), + userStatusProvider.overrideWith( + () => _FakeUserStatusNotifier( + const UserStatus( + text: 'Focusing', + emoji: missingShortcode, + updatedAt: 1, + ), + ), + ), + customEmojiListProvider.overrideWithValue(const []), + ], + child: const SettingsProfileHeader(), + ), + ); + await tester.pumpAndSettle(); + + final badge = find.byType(MaskedAvatarBadge); + expect( + find.descendant(of: badge, matching: find.text(missingShortcode)), + findsNothing, + ); + expect( + find.descendant(of: badge, matching: find.byIcon(LucideIcons.smile)), + findsOneWidget, + ); + }); +} + +class _FakeProfileNotifier extends ProfileNotifier { + @override + Future build() async => + const UserProfile(pubkey: 'aabb', displayName: 'Test'); +} + +class _FakeUserStatusNotifier extends UserStatusNotifier { + _FakeUserStatusNotifier(this._status); + + final UserStatus _status; + + @override + Future build() async => _status; +} diff --git a/mobile/test/features/settings/theme_picker_page_test.dart b/mobile/test/features/settings/theme_picker_page_test.dart new file mode 100644 index 0000000000..6f8591f285 --- /dev/null +++ b/mobile/test/features/settings/theme_picker_page_test.dart @@ -0,0 +1,203 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:buzz/features/settings/accent_picker_page.dart'; +import 'package:buzz/features/settings/theme_picker_page.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../helpers/widget_helpers.dart'; + +Future _prefs(Map initial) async { + SharedPreferences.setMockInitialValues(initial); + return SharedPreferences.getInstance(); +} + +Future _pumpPicker( + WidgetTester tester, + Widget page, { + Map prefs = const {}, +}) async { + final instance = await _prefs(prefs); + await tester.pumpWidget( + WidgetHelpers.testable( + child: page, + overrides: [savedPrefsProvider.overrideWithValue(instance)], + ), + ); + await tester.pumpAndSettle(); +} + +Future _search(WidgetTester tester, String query) async { + await tester.enterText(find.byType(TextField), query); + await tester.pumpAndSettle(); +} + +void main() { + group('ThemePickerPage', () { + testWidgets('system mode offers pairs under their stripped label', ( + tester, + ) async { + await _pumpPicker(tester, const ThemePickerPage()); + + await _search(tester, 'github'); + + // Paired labels drop the brightness token — one row stands for both halves. + expect(find.text('Github'), findsOneWidget); + expect(find.text('Github Default'), findsOneWidget); + expect(find.text('Github Light'), findsNothing); + expect(find.text('Github Dark'), findsNothing); + }); + + testWidgets('system mode hides themes with no counterpart', (tester) async { + await _pumpPicker(tester, const ThemePickerPage()); + + // 'snazzy-light' is light but unpaired, so it cannot follow the OS. + await _search(tester, 'snazzy'); + + expect(find.text('No themes found'), findsOneWidget); + }); + + testWidgets('system mode normalizes a stored unpaired theme', ( + tester, + ) async { + final instance = await _prefs({'buzz_color_scheme': 'snazzy-light'}); + await tester.pumpWidget( + WidgetHelpers.testable( + child: const ThemePickerPage(), + overrides: [savedPrefsProvider.overrideWithValue(instance)], + ), + ); + await tester.pumpAndSettle(); + + expect( + instance.getString('buzz_color_scheme'), + themeGroups().paired.first.name, + ); + }); + + testWidgets('light mode lists light themes by their full name', ( + tester, + ) async { + await _pumpPicker( + tester, + const ThemePickerPage(), + prefs: {'buzz_theme_mode': 'light'}, + ); + + await _search(tester, 'github'); + + expect(find.text('Github Light'), findsOneWidget); + expect(find.text('Github Light Default'), findsOneWidget); + expect(find.text('Github Dark'), findsNothing); + }); + + testWidgets('light mode includes unpaired light themes', (tester) async { + await _pumpPicker( + tester, + const ThemePickerPage(), + prefs: {'buzz_theme_mode': 'light'}, + ); + + await _search(tester, 'snazzy'); + + expect(find.text('Snazzy Light'), findsOneWidget); + }); + + testWidgets('dark mode lists dark themes only', (tester) async { + await _pumpPicker( + tester, + const ThemePickerPage(), + prefs: {'buzz_theme_mode': 'dark'}, + ); + + await _search(tester, 'github'); + + expect(find.text('Github Dark'), findsOneWidget); + expect(find.text('Github Light'), findsNothing); + }); + + testWidgets('checks the row matching the stored selection', (tester) async { + await _pumpPicker( + tester, + const ThemePickerPage(), + prefs: {'buzz_theme_mode': 'light', 'buzz_color_scheme': 'nord'}, + ); + + await _search(tester, 'nord'); + + // 'nord' is dark, so Light mode renders its light fallback instead — the + // checkmark must follow what is applied, not the raw stored value. + expect(find.byIcon(LucideIcons.check), findsNothing); + }); + + testWidgets('either half of a pair checks the same system row', ( + tester, + ) async { + await _pumpPicker( + tester, + const ThemePickerPage(), + prefs: {'buzz_color_scheme': 'github-dark'}, + ); + + await _search(tester, 'github'); + + final checked = tester + .widgetList(find.byType(ListTile)) + .where((tile) => tile.trailing != null); + expect(checked, hasLength(1)); + expect(find.text('Github'), findsOneWidget); + }); + + testWidgets('tapping a theme persists the selection', (tester) async { + final instance = await _prefs({'buzz_theme_mode': 'dark'}); + await tester.pumpWidget( + WidgetHelpers.testable( + child: const ThemePickerPage(), + overrides: [savedPrefsProvider.overrideWithValue(instance)], + ), + ); + await tester.pumpAndSettle(); + + await _search(tester, 'nord'); + await tester.tap(find.text('Nord')); + await tester.pumpAndSettle(); + + expect(instance.getString('buzz_color_scheme'), 'nord'); + }); + }); + + group('AccentPickerPage', () { + testWidgets('lists every accent and checks the stored one', (tester) async { + await _pumpPicker( + tester, + const AccentPickerPage(), + prefs: {'buzz_accent_color': 2}, + ); + + for (final accent in accentColors) { + expect(find.text(accent.name), findsOneWidget); + } + expect(find.byIcon(LucideIcons.check), findsOneWidget); + }); + + testWidgets('tapping an accent persists the index', (tester) async { + final instance = await _prefs(const {}); + await tester.pumpWidget( + WidgetHelpers.testable( + child: const AccentPickerPage(), + overrides: [savedPrefsProvider.overrideWithValue(instance)], + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Green')); + await tester.pumpAndSettle(); + + expect( + instance.getInt('buzz_accent_color'), + accentColors.indexWhere((a) => a.name == 'Green'), + ); + }); + }); +} diff --git a/mobile/test/features/custom_emoji/custom_emoji_test.dart b/mobile/test/shared/custom_emoji/custom_emoji_test.dart similarity index 98% rename from mobile/test/features/custom_emoji/custom_emoji_test.dart rename to mobile/test/shared/custom_emoji/custom_emoji_test.dart index 5b20f8276d..145bf63ea8 100644 --- a/mobile/test/features/custom_emoji/custom_emoji_test.dart +++ b/mobile/test/shared/custom_emoji/custom_emoji_test.dart @@ -1,5 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:buzz/features/custom_emoji/custom_emoji.dart'; +import 'package:buzz/shared/custom_emoji/custom_emoji.dart'; import 'package:buzz/shared/relay/nostr_models.dart'; NostrEvent _event( diff --git a/mobile/test/shared/theme/accent_colors_test.dart b/mobile/test/shared/theme/accent_colors_test.dart index d1f777c598..8d31ae6cee 100644 --- a/mobile/test/shared/theme/accent_colors_test.dart +++ b/mobile/test/shared/theme/accent_colors_test.dart @@ -5,7 +5,7 @@ import 'package:buzz/shared/theme/theme.dart'; void main() { group('default accent', () { test('uses black on the default light scheme', () { - final resolved = resolveSchemes(null); + final resolved = resolveSchemes(null, ThemeMode.light); final accented = applyAccent(resolved.light, defaultAccentIndex); @@ -13,7 +13,7 @@ void main() { }); test('uses the theme foreground on forced dark schemes', () { - final resolved = resolveSchemes('github-dark'); + final resolved = resolveSchemes('github-dark', ThemeMode.dark); final base = resolved.dark; final accented = applyAccent(base, defaultAccentIndex); diff --git a/mobile/test/shared/theme/buzz_theme_test.dart b/mobile/test/shared/theme/buzz_theme_test.dart new file mode 100644 index 0000000000..613fec1467 --- /dev/null +++ b/mobile/test/shared/theme/buzz_theme_test.dart @@ -0,0 +1,196 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:buzz/shared/widgets/frosted_app_bar.dart'; + +void main() { + group('Buzz theme catalog entries', () { + test('both halves are in the catalog', () { + expect(findTheme(buzzThemeName), isNotNull); + expect(findTheme(buzzDarkThemeName), isNotNull); + }); + + test('borrow the GitHub palettes', () { + final buzz = findTheme(buzzThemeName)!; + final github = findTheme('github-light')!; + expect(buzz.bg, github.bg); + expect(buzz.fg, github.fg); + expect(buzz.comment, github.comment); + + final buzzDark = findTheme(buzzDarkThemeName)!; + final githubDark = findTheme('github-dark')!; + expect(buzzDark.bg, githubDark.bg); + expect(buzzDark.fg, githubDark.fg); + expect(buzzDark.comment, githubDark.comment); + }); + + test('are a light/dark pair', () { + expect(findTheme(buzzThemeName)!.isDark, isFalse); + expect(findTheme(buzzDarkThemeName)!.isDark, isTrue); + expect(themePairFor(buzzThemeName), buzzDarkThemeName); + expect(themePairFor(buzzDarkThemeName), buzzThemeName); + }); + + test('appear as a single System-mode option labelled "Buzz"', () { + final paired = themeGroups().paired.map((t) => t.name); + expect(paired, contains(buzzThemeName)); + expect(paired, isNot(contains(buzzDarkThemeName))); + expect(pairedThemeLabel(buzzThemeName), 'Buzz'); + expect(themeSelectionLabel(buzzThemeName, ThemeMode.system), 'Buzz'); + expect(themeSelectionLabel(buzzDarkThemeName, ThemeMode.system), 'Buzz'); + }); + + test('resolve across brightnesses like any other pair', () { + final resolved = resolveSchemes(buzzThemeName, ThemeMode.system); + expect(resolved.forcedMode, isNull); + expect(resolved.light.brightness, Brightness.light); + expect(resolved.dark.brightness, Brightness.dark); + expect(resolved.lightTheme?.name, buzzThemeName); + expect(resolved.darkTheme?.name, buzzDarkThemeName); + + expect( + effectiveTheme(buzzThemeName, ThemeMode.dark)?.name, + buzzDarkThemeName, + ); + expect( + effectiveTheme(buzzDarkThemeName, ThemeMode.light)?.name, + buzzThemeName, + ); + }); + + test( + 'fallbacks expose the effective Buzz theme for gradient selection', + () { + final coerced = resolveSchemes('nord', ThemeMode.light); + expect(coerced.lightTheme?.name, buzzThemeName); + expect( + buzzTopSectionGradient( + coerced.lightTheme!.name, + coerced.light.brightness, + ), + isNotNull, + ); + + final unknown = resolveSchemes('not-a-theme', ThemeMode.light); + expect(unknown.lightTheme?.name, buzzThemeName); + expect( + buzzTopSectionGradient( + unknown.lightTheme!.name, + unknown.light.brightness, + ), + isNotNull, + ); + }, + ); + }); + + group('buzzTopSectionGradient', () { + test('is null for non-Buzz themes', () { + expect(buzzTopSectionGradient('github-light', Brightness.light), isNull); + expect(buzzTopSectionGradient('nord', Brightness.dark), isNull); + }); + + test('paints top to bottom for both halves of the pair', () { + for (final name in [buzzThemeName, buzzDarkThemeName]) { + final gradient = buzzTopSectionGradient(name, Brightness.light); + expect(gradient, isNotNull, reason: '$name should be gradient-backed'); + expect(gradient!.begin, Alignment.topCenter); + expect(gradient.end, Alignment.bottomCenter); + expect(gradient.colors, hasLength(2)); + } + }); + + test('brightness selects the stops, not the theme name', () { + // Both halves enable the gradient, so System mode keeps it on across an + // OS switch — the applied brightness alone decides which stops are used. + final light = buzzTopSectionGradient(buzzThemeName, Brightness.light)!; + final dark = buzzTopSectionGradient(buzzThemeName, Brightness.dark)!; + + expect(light.colors, isNot(dark.colors)); + expect( + buzzTopSectionGradient(buzzDarkThemeName, Brightness.dark)!.colors, + dark.colors, + ); + expect( + buzzTopSectionGradient(buzzDarkThemeName, Brightness.light)!.colors, + light.colors, + ); + }); + + test('is opaque so the color replaces the frosted fill', () { + for (final brightness in Brightness.values) { + final gradient = buzzTopSectionGradient(buzzThemeName, brightness)!; + for (final color in gradient.colors) { + expect(color.a, 1.0); + } + } + }); + }); + + group('theme threading', () { + BoxDecoration barDecoration(WidgetTester tester) { + final container = tester + .widgetList( + find.descendant( + of: find.byType(FrostedAppBar), + matching: find.byType(Container), + ), + ) + .first; + return container.decoration! as BoxDecoration; + } + + Widget harness(ThemeData theme) => MaterialApp( + theme: theme, + home: Builder( + builder: (context) => Stack( + children: [ + FrostedAppBar( + gradient: context.appColors.topSectionGradient, + title: const Text('Home'), + ), + ], + ), + ), + ); + + testWidgets('AppTheme carries the gradient to the top section', ( + tester, + ) async { + await tester.pumpWidget( + harness( + AppTheme.light( + topSectionGradient: buzzTopSectionGradient( + buzzThemeName, + Brightness.light, + ), + ), + ), + ); + + final decoration = barDecoration(tester); + expect(decoration.gradient, isNotNull); + // A BoxDecoration cannot paint a color and a gradient at once. + expect(decoration.color, isNull); + }); + + testWidgets('non-Buzz themes keep the frosted surface fill', ( + tester, + ) async { + await tester.pumpWidget(harness(AppTheme.light())); + + final decoration = barDecoration(tester); + expect(decoration.gradient, isNull); + expect(decoration.color, isNotNull); + }); + }); + + group('isBuzzTheme', () { + test('matches only the Buzz pair', () { + expect(isBuzzTheme(buzzThemeName), isTrue); + expect(isBuzzTheme(buzzDarkThemeName), isTrue); + expect(isBuzzTheme('github-light'), isFalse); + expect(isBuzzTheme(''), isFalse); + }); + }); +} diff --git a/mobile/test/shared/theme/theme_pairs_test.dart b/mobile/test/shared/theme/theme_pairs_test.dart new file mode 100644 index 0000000000..428ad5bc40 --- /dev/null +++ b/mobile/test/shared/theme/theme_pairs_test.dart @@ -0,0 +1,254 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:buzz/shared/theme/theme.dart'; + +void main() { + group('themePairs', () { + test('every pair member exists in the catalog', () { + for (final entry in themePairs.entries) { + expect( + findTheme(entry.key), + isNotNull, + reason: '${entry.key} is not in themeCatalog', + ); + expect( + findTheme(entry.value), + isNotNull, + reason: '${entry.value} is not in themeCatalog', + ); + } + }); + + test('pairs map a light theme to a dark one', () { + for (final entry in themePairs.entries) { + expect( + findTheme(entry.key)!.isDark, + isFalse, + reason: '${entry.key} should be light', + ); + expect( + findTheme(entry.value)!.isDark, + isTrue, + reason: '${entry.value} should be dark', + ); + } + }); + + test('resolves in both directions', () { + expect(themePairFor('github-light'), 'github-dark'); + expect(themePairFor('github-dark'), 'github-light'); + expect(themePairFor('andromeeda'), isNull); + expect(isPairedTheme('catppuccin-latte'), isTrue); + expect(isPairedTheme('andromeeda'), isFalse); + }); + }); + + group('themeGroups', () { + test('light and dark partition the whole catalog', () { + final groups = themeGroups(); + + expect(groups.light.length + groups.dark.length, themeCatalog.length); + expect(groups.light.every((t) => !t.isDark), isTrue); + expect(groups.dark.every((t) => t.isDark), isTrue); + }); + + test('paired holds only light members of pairs', () { + final groups = themeGroups(); + + expect(groups.paired.length, themePairs.length); + for (final theme in groups.paired) { + expect(theme.isDark, isFalse); + expect(themePairs.containsKey(theme.name), isTrue); + } + }); + + test('groups are sorted by display name', () { + final groups = themeGroups(); + + for (final list in [groups.paired, groups.light, groups.dark]) { + final names = list.map((t) => t.displayName).toList(); + expect(names, orderedEquals(List.of(names)..sort())); + } + }); + }); + + group('pairedThemeLabel', () { + test('strips brightness tokens', () { + expect(pairedThemeLabel('github-light'), 'Github'); + expect(pairedThemeLabel('github-light-default'), 'Github Default'); + expect(pairedThemeLabel('gruvbox-light-soft'), 'Gruvbox Soft'); + expect(pairedThemeLabel('material-theme-lighter'), 'Material Theme'); + expect(pairedThemeLabel('catppuccin-latte'), 'Catppuccin'); + }); + + test('falls back to the raw name when stripping empties it', () { + expect(pairedThemeLabel('light-plus'), 'Light Plus'); + }); + + test('never produces a blank label for a real pair', () { + for (final lightName in themePairs.keys) { + expect(pairedThemeLabel(lightName), isNotEmpty); + } + }); + }); + + group('resolveSchemes', () { + test('system mode pairs the selection across brightnesses', () { + final resolved = resolveSchemes('github-light', ThemeMode.system); + + // Null forcedMode leaves MaterialApp following the OS. + expect(resolved.forcedMode, isNull); + expect(resolved.light.brightness, Brightness.light); + expect(resolved.dark.brightness, Brightness.dark); + }); + + test('system mode works from the dark half of a pair too', () { + final resolved = resolveSchemes('github-dark', ThemeMode.system); + + expect(resolved.forcedMode, isNull); + expect(resolved.light.brightness, Brightness.light); + expect(resolved.dark.brightness, Brightness.dark); + }); + + test('system mode pins an unpaired theme to its own brightness', () { + final resolved = resolveSchemes('andromeeda', ThemeMode.system); + + // No counterpart exists, so both sides render the selected theme rather + // than jumping to an unrelated one. + expect(resolved.forcedMode, ThemeMode.dark); + expect(resolved.light, resolved.dark); + expect(resolved.dark.brightness, Brightness.dark); + }); + + test('system mode pins an unpaired light theme to light mode', () { + final resolved = resolveSchemes('snazzy-light', ThemeMode.system); + + expect(resolved.forcedMode, ThemeMode.light); + expect(resolved.light, resolved.dark); + expect(resolved.light.brightness, Brightness.light); + }); + + test('light mode forces light and swaps a dark selection for its pair', () { + final resolved = resolveSchemes('github-dark', ThemeMode.light); + + expect(resolved.forcedMode, ThemeMode.light); + expect(resolved.light, resolved.dark); + expect(resolved.light.brightness, Brightness.light); + expect(resolved.light, generateColorScheme(findTheme('github-light')!)); + }); + + test('dark mode forces dark and swaps a light selection for its pair', () { + final resolved = resolveSchemes('github-light', ThemeMode.dark); + + expect(resolved.forcedMode, ThemeMode.dark); + expect(resolved.dark.brightness, Brightness.dark); + expect(resolved.dark, generateColorScheme(findTheme('github-dark')!)); + }); + + test('dark mode falls back to the default pair when pick is unpaired', () { + // 'snazzy-light' is light and has no dark counterpart. + final resolved = resolveSchemes('snazzy-light', ThemeMode.dark); + + expect(resolved.forcedMode, ThemeMode.dark); + expect(resolved.dark.brightness, Brightness.dark); + expect(resolved.darkTheme?.name, buzzDarkThemeName); + expect(resolved.dark, generateColorScheme(findTheme(buzzDarkThemeName)!)); + }); + + test('light mode falls back to the default pair when pick is unpaired', () { + final resolved = resolveSchemes('andromeeda', ThemeMode.light); + + expect(resolved.forcedMode, ThemeMode.light); + expect(resolved.light.brightness, Brightness.light); + expect(resolved.lightTheme?.name, buzzThemeName); + expect(resolved.light, generateColorScheme(findTheme(buzzThemeName)!)); + }); + + test('an unknown scheme name falls back to the default theme', () { + final resolved = resolveSchemes('not-a-theme', ThemeMode.light); + + expect( + resolved.light, + generateColorScheme(findTheme(defaultSchemeName)!), + ); + }); + }); + + group('schemeForAppearanceMode', () { + test('system mode replaces an unpaired selection with a paired theme', () { + expect( + schemeForAppearanceMode('snazzy-light', ThemeMode.system), + themeGroups().paired.first.name, + ); + expect( + schemeForAppearanceMode('nord', ThemeMode.system), + themeGroups().paired.first.name, + ); + }); + + test('system mode preserves either half of a paired selection', () { + expect( + schemeForAppearanceMode('github-light', ThemeMode.system), + 'github-light', + ); + expect( + schemeForAppearanceMode('github-dark', ThemeMode.system), + 'github-dark', + ); + }); + + test('pinned modes preserve an unpaired selection', () { + expect( + schemeForAppearanceMode('snazzy-light', ThemeMode.light), + 'snazzy-light', + ); + expect(schemeForAppearanceMode('nord', ThemeMode.dark), 'nord'); + }); + }); + + group('effectiveTheme', () { + test('system mode keeps the stored selection', () { + expect( + effectiveTheme('github-dark', ThemeMode.system)?.name, + 'github-dark', + ); + }); + + test('pinned modes report the coerced theme', () { + expect( + effectiveTheme('github-dark', ThemeMode.light)?.name, + 'github-light', + ); + expect( + effectiveTheme('github-light', ThemeMode.dark)?.name, + 'github-dark', + ); + }); + + test('a null selection resolves to the default theme', () { + expect(effectiveTheme(null, ThemeMode.light)?.name, defaultSchemeName); + }); + }); + + group('themeSelectionLabel', () { + test('names the pair as a whole in system mode', () { + expect(themeSelectionLabel('github-light', ThemeMode.system), 'Github'); + expect(themeSelectionLabel('github-dark', ThemeMode.system), 'Github'); + }); + + test('names the concrete theme in pinned modes', () { + expect( + themeSelectionLabel('github-light', ThemeMode.light), + 'Github Light', + ); + expect( + themeSelectionLabel('github-light', ThemeMode.dark), + 'Github Dark', + ); + }); + + test('falls back to the theme display name when unpaired', () { + expect(themeSelectionLabel('andromeeda', ThemeMode.system), 'Andromeeda'); + }); + }); +} diff --git a/mobile/test/shared/widgets/app_list_test.dart b/mobile/test/shared/widgets/app_list_test.dart new file mode 100644 index 0000000000..db493a3f9b --- /dev/null +++ b/mobile/test/shared/widgets/app_list_test.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:buzz/shared/widgets/app_list.dart'; +import 'package:buzz/shared/widgets/app_list_card.dart'; + +Widget _host(Widget child) => MaterialApp( + theme: AppTheme.light(), + home: Scaffold(body: child), +); + +void main() { + group('AppListRow', () { + testWidgets('centres the leading icon against a two-line row', ( + tester, + ) async { + await tester.pumpWidget( + _host( + const AppListRow( + icon: Icons.dns, + title: 'Connected to', + subtitle: 'https://relay.example.com', + ), + ), + ); + + final row = tester.getRect(find.byType(AppListRow)); + final icon = tester.getRect(find.byType(Icon)); + expect(icon.center.dy, closeTo(row.center.dy, 0.5)); + }); + + testWidgets('centres the trailing control against a two-line row', ( + tester, + ) async { + await tester.pumpWidget( + _host( + const AppListRow( + icon: Icons.key, + title: 'Identity', + subtitle: 'deadbeef', + trailing: Icon(Icons.copy, key: Key('trailing')), + ), + ), + ); + + final row = tester.getRect(find.byType(AppListRow)); + final trailing = tester.getRect(find.byKey(const Key('trailing'))); + expect(trailing.center.dy, closeTo(row.center.dy, 0.5)); + }); + + testWidgets('keeps the value flush against the trailing edge', ( + tester, + ) async { + await tester.pumpWidget( + _host( + const AppListRow( + icon: Icons.palette, + title: 'Theme', + value: 'Buzz', + trailing: Icon(Icons.chevron_right, key: Key('chevron')), + ), + ), + ); + + final value = tester.getRect(find.text('Buzz')); + final chevron = tester.getRect(find.byKey(const Key('chevron'))); + // Only the row's own inter-widget gap sits between them — no flex slack. + expect(chevron.left - value.right, closeTo(Grid.xxs, 0.5)); + }); + }); + + group('AppListCard', () { + testWidgets('separates rows with dividers that clear the icon column', ( + tester, + ) async { + await tester.pumpWidget( + _host( + const AppListCard( + label: 'Style', + children: [ + AppListRow(icon: Icons.sunny, title: 'Appearance'), + AppListRow(icon: Icons.palette, title: 'Theme'), + AppListRow(icon: Icons.water_drop, title: 'Accent color'), + ], + ), + ), + ); + + final dividers = find.byType(Divider); + expect(dividers, findsNWidgets(2)); + + final divider = tester.widget(dividers.first); + final scheme = AppTheme.light().colorScheme; + // Derived from the text color, not the scheme's border tokens — those sit + // within a few levels of the card fill and vanish. + expect(divider.color, scheme.onSurface.withValues(alpha: 0.12)); + + // The indent is painted inside the divider's box, so the line starts at + // the box's left edge plus the indent — level with the row titles. + final titleLeft = tester.getRect(find.text('Appearance')).left; + final lineLeft = tester.getRect(dividers.first).left + divider.indent!; + expect(lineLeft, closeTo(titleLeft, 0.5)); + }); + + testWidgets('fills with the softest container step', (tester) async { + await tester.pumpWidget( + _host( + const AppListCard(children: [AppListRow(title: 'Remove community')]), + ), + ); + + final material = tester.widget( + find.descendant( + of: find.byType(AppListCard), + matching: find.byType(Material), + ), + ); + expect( + material.color, + AppTheme.light().colorScheme.surfaceContainerHighest, + ); + }); + }); +} diff --git a/mobile/test/shared/widgets/masked_avatar_badge_test.dart b/mobile/test/shared/widgets/masked_avatar_badge_test.dart new file mode 100644 index 0000000000..f2794d7132 --- /dev/null +++ b/mobile/test/shared/widgets/masked_avatar_badge_test.dart @@ -0,0 +1,147 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:buzz/shared/widgets/masked_avatar_badge.dart'; + +void main() { + group('avatarBadgeMaskPath', () { + const size = 128.0; + const geometry = AvatarBadgeMaskGeometry.badge; + + test('keeps the avatar but cuts the notch out of it', () { + final path = avatarBadgeMaskPath(size: size, geometry: geometry); + + expect(path.contains(const Offset(size / 2, size / 2)), isTrue); + expect(path.contains(geometry.cutoutCenter(size)), isFalse); + // The far edge is untouched — the notch only bites the bottom-right. + expect(path.contains(const Offset(4, size / 2)), isTrue); + }); + + test('stays inside the avatar circle on the unnotched sides', () { + // Checked with contains() rather than getBounds(), which reports + // control-point bounds and so overshoots the near-full-circle arc. + final path = avatarBadgeMaskPath(size: size, geometry: geometry); + + expect(path.contains(const Offset(-1, size / 2)), isFalse); + expect(path.contains(const Offset(size / 2, -1)), isFalse); + expect(path.contains(const Offset(2, 2)), isFalse); + }); + + test('fillets the notch rather than subtracting a bare circle', () { + // A plain difference meets the cutout at two cusps. The fillets round + // those off, which necessarily removes extra material just outside the + // cutout circle — so the masked path must be a strict subset there. + final masked = avatarBadgeMaskPath(size: size, geometry: geometry); + final cutoutCenter = geometry.cutoutCenter(size); + final cutoutRadius = geometry.cutoutRadius(size); + final bare = Path.combine( + PathOperation.difference, + Path()..addOval( + Rect.fromCircle( + center: const Offset(size / 2, size / 2), + radius: size / 2, + ), + ), + Path()..addOval( + Rect.fromCircle(center: cutoutCenter, radius: cutoutRadius), + ), + ); + + // Sample the ring just outside the cutout, where the fillets live. + var filleted = 0; + for (var step = 0; step < 360; step++) { + final angle = step * math.pi / 180; + final point = + cutoutCenter + + Offset(math.cos(angle), math.sin(angle)) * (cutoutRadius + 2); + if (bare.contains(point) && !masked.contains(point)) filleted++; + // The fillet only adds material to the cut, never puts it back. + expect( + masked.contains(point) && !bare.contains(point), + isFalse, + reason: 'mask should not extend beyond the plain difference', + ); + } + expect(filleted, greaterThan(0)); + }); + + test('falls back to a plain circle when the notch misses the avatar', () { + // A notch tucked at the centre never crosses the avatar's edge, so there + // is no crossing to fillet and nothing should be cut. + const detached = AvatarBadgeMaskGeometry( + cutoutCenterRatio: 0.5, + cutoutRadiusRatio: 0.05, + badgeSizeRatio: 0.08, + curve: AvatarBadgeCurve.standard, + ); + final path = avatarBadgeMaskPath(size: size, geometry: detached); + + expect(path.contains(detached.cutoutCenter(size)), isTrue); + expect(path.getBounds().size, const Size(size, size)); + }); + + test( + 'scales geometry with the avatar, matching desktop at its own size', + () { + // Desktop's sidebar avatar is 32px with the cutout at (28, 28) r 7.5. + const presence = AvatarBadgeMaskGeometry.presenceDot; + + expect(presence.cutoutCenter(32), const Offset(28, 28)); + expect(presence.cutoutRadius(32), 7.5); + expect(presence.badgeSize(32), 14); + // Twice the avatar, twice the notch. + expect(presence.cutoutCenter(64), const Offset(56, 56)); + expect(presence.cutoutRadius(64), 15); + }, + ); + }); + + group('MaskedAvatarBadge', () { + Widget harness({Widget? badge}) => MaterialApp( + theme: AppTheme.light(), + home: Center( + child: MaskedAvatarBadge( + size: 128, + geometry: AvatarBadgeMaskGeometry.badge, + avatar: const ColoredBox(color: Color(0xFF123456)), + badge: badge, + ), + ), + ); + + testWidgets('masks the avatar and centres the badge in the notch', ( + tester, + ) async { + await tester.pumpWidget( + harness(badge: const ColoredBox(color: Color(0xFFABCDEF))), + ); + + final clip = tester.widget(find.byType(ClipPath)); + expect(clip.clipper, isA()); + + const geometry = AvatarBadgeMaskGeometry.badge; + final frame = tester.getRect(find.byType(MaskedAvatarBadge)); + final badgeRect = tester.getRect(find.byType(ColoredBox).last); + + expect(badgeRect.width, closeTo(geometry.badgeSize(128), 0.01)); + expect( + badgeRect.center - frame.topLeft, + within(distance: 0.01, from: geometry.cutoutCenter(128)), + ); + }); + + testWidgets('leaves the avatar whole when there is no badge', ( + tester, + ) async { + await tester.pumpWidget(harness()); + + expect(find.byType(ClipPath), findsNothing); + expect( + tester.getSize(find.byType(MaskedAvatarBadge)), + const Size(128, 128), + ); + }); + }); +} From 74b63e1846212af6e6751a62cfc631f74b1dfe07 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Sun, 26 Jul 2026 16:34:00 +0100 Subject: [PATCH 038/393] Refactor managed-agent runtime into cohesive modules (#2974) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - split the managed-agent runtime warehouse into cohesive modules for process ownership/termination, orphan sweeping, dead-instance reaping, lifecycle synchronization, and runtime metadata - preserve the existing `runtime` API through narrow re-exports; helper bodies and platform `cfg` branches are unchanged apart from module-qualified visibility - reduce `runtime.rs` from 2,220 lines on `main` to 908 lines and remove its temporary file-size override, restoring the standard 1,000-line ceiling ## Why `main` failed after stale successful PR checks allowed independent growth to combine above `runtime.rs`'s 2,216-line override. The earlier fix in #2974 extracted only 55 lines and left the monolith on a special ratchet. This replacement includes that extraction but establishes responsibility boundaries and removes the exception entirely. ## Module boundaries - `process.rs` — process identity, ownership markers, receipt validation, and termination primitives - `orphan_sweep.rs` — same-instance orphan discovery and cleanup - `instance_reaper.rs` — foreign/dead desktop instance detection and agent reaping - `lifecycle.rs` — tracked runtime synchronization and stale record cleanup - `metadata.rs` — model/provider metadata resolution - `runtime.rs` — summary/config/spawn orchestration and composition ## Validation At `a824fda31eff6ecc0d39ca1b8ea5602a108897e6`: - pre-push `desktop-check` - pre-push `desktop-test` - pre-push full `desktop-tauri-test`: 1,637 passed, 0 failed, 14 ignored; integration + doc tests passed - `cargo check --manifest-path desktop/src-tauri/Cargo.toml --lib` - `cargo fmt --manifest-path desktop/src-tauri/Cargo.toml --all -- --check` - `node desktop/scripts/check-file-sizes.mjs` Supersedes #2974 and #2930. Signed-off-by: Wes Co-authored-by: Wes Co-authored-by: Princess Donut <68157ebd23b3897c1991015c3038658ea916200c67d3a54620b0754d1b92f6e0@buzz.block.builderlab.xyz> --- desktop/scripts/check-file-sizes.mjs | 13 - .../src-tauri/src/managed_agents/runtime.rs | 1367 +---------------- .../managed_agents/runtime/instance_reaper.rs | 359 +++++ .../src/managed_agents/runtime/lifecycle.rs | 105 ++ .../src/managed_agents/runtime/metadata.rs | 55 + .../managed_agents/runtime/orphan_sweep.rs | 371 +++++ .../src/managed_agents/runtime/process.rs | 447 ++++++ 7 files changed, 1366 insertions(+), 1351 deletions(-) create mode 100644 desktop/src-tauri/src/managed_agents/runtime/instance_reaper.rs create mode 100644 desktop/src-tauri/src/managed_agents/runtime/lifecycle.rs create mode 100644 desktop/src-tauri/src/managed_agents/runtime/metadata.rs create mode 100644 desktop/src-tauri/src/managed_agents/runtime/orphan_sweep.rs create mode 100644 desktop/src-tauri/src/managed_agents/runtime/process.rs diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index f2d3883794..65928a29db 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -119,19 +119,6 @@ const overrides = new Map([ // helpers) replace the pubkey-keyed PID file, plus the hashed pair-scoped // runtime log path. Load-bearing crash-recovery surface; queued to split. ["src-tauri/src/managed_agents/storage.rs", 1383], - // harness-persona-sync: persona-runtime resolution threaded into the spawn - // path here. Load-bearing feature growth; queued to split in the resolver - // unify refactor followup. +26 for resolve_effective_prompt_model_provider - // re-introduced after 826d735fe removal (config-bridge caller still needs it). - // PGID resolution helper + PID-recycling safety guard added for orphan sweep. - // activity-feed threads avatar_url into build_managed_agent_summary for the - // assistant-bubble pinned snapshot. - // +1 for agent_pubkey field in setup payload (config-nudge card wire). - // persona-blank-fallback: resolve_effective_prompt_model_provider gains a - // record_provider param + applies persona_field_with_record_fallback. +5 lines. - // global-agent-config: spawn_agent_child loads global config and merges as - // lowest env layer (+8 lines). Queued to split. - ["src-tauri/src/managed_agents/runtime.rs", 2216], // config-bridge setup-payload env-boundary fix adds readiness wiring in // spawn_agent_child; load-bearing security fix, queued to split. ["src-tauri/src/managed_agents/config_bridge/reader.rs", 1016], diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 44cee49aeb..b9c7ec65ea 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -20,6 +20,9 @@ pub(crate) use path::compose_path_entries; pub(crate) use path::should_skip_claude_executable; pub(crate) use path::should_use_inherited; +mod metadata; +pub(crate) use metadata::{resolve_effective_prompt_model_provider, runtime_metadata_env_vars}; + mod stop; pub(crate) use stop::managed_agent_runtime_keys; pub use stop::{stop_managed_agent_process, stop_managed_agent_workspace_pair}; @@ -29,1294 +32,38 @@ pub(crate) use sweep::sweep_untracked_bundle_harnesses; type RespondToEnv = (Vec<(&'static str, String)>, Vec<&'static str>); -/// Binary name fragments for all known agent/harness processes that Buzz -/// may spawn. Used by `process_belongs_to_us()` and the orphan sweep to -/// identify processes we should clean up. Both hyphenated and underscored -/// variants are listed because macOS `proc_name()` and Linux `/proc/comm` -/// may report either form depending on how the binary was built. -pub(crate) const KNOWN_AGENT_BINARIES: &[&str] = &[ - "buzz-acp", - "buzz_acp", - "buzz-agent", - "buzz_agent", - "claude-agent-acp", - "claude_agent_acp", - "claude-code-acp", - "claude_code_acp", - "codex-acp", - "codex_acp", - "goose", - // buzz-dev-mcp's multicall personalities (rg, tree, buzz, - // git-credential-nostr, git-sign-nostr) are short-lived per-tool-call - // invocations — not listed here. - "buzz-dev-mcp", - "buzz_dev_mcp", -]; - -/// Script interpreters that may host managed agent wrappers (e.g. npm shims). -/// A process whose name matches here is NOT immediately claimed — it must also -/// carry `BUZZ_MANAGED_AGENT` in its environment (checked by the caller via -/// `process_has_buzz_marker()`). This avoids sweeping unrelated node processes. -pub(crate) const KNOWN_SCRIPT_INTERPRETERS: &[&str] = &["node"]; - -/// Check if a process name matches any of our known agent binaries. -/// Uses exact match or prefix-with-separator to avoid false positives -/// (e.g. `"goose"` must not match `"mongoose"`). -fn name_matches_known_binary(name: &str) -> bool { - KNOWN_AGENT_BINARIES.iter().any(|&binary| { - name == binary || { - name.starts_with(binary) && { - let rest = &name[binary.len()..]; - rest.starts_with('-') || rest.starts_with('_') || rest.starts_with('.') - } - } - }) -} - -/// Check if a process name is a known script interpreter that may be hosting -/// a managed agent wrapper (e.g. `node` running an npm shim for `codex-acp`). -/// Callers must additionally verify `BUZZ_MANAGED_AGENT` ownership. -fn name_matches_interpreter(name: &str) -> bool { - KNOWN_SCRIPT_INTERPRETERS.contains(&name) -} +mod process; +#[cfg(test)] +use process::{ + buzz_marker_entry, name_matches_interpreter, name_matches_known_binary, + terminate_runtime_receipt_with, +}; +pub(crate) use process::{ + current_instance_id, process_belongs_to_us, process_has_buzz_marker, process_is_running, + terminate_process, terminate_untracked_pair_runtime, valid_agent_runtime_receipt, +}; +mod orphan_sweep; +#[cfg(target_os = "macos")] +use orphan_sweep::proc_pidinfo; +pub(crate) use orphan_sweep::{ + sweep_orphaned_agent_processes, sweep_system_agent_processes, + sweep_system_agent_processes_with_grace, +}; +#[cfg(target_os = "macos")] +use orphan_sweep::{BSDInfo, PROC_PIDTBSDINFO}; #[cfg(unix)] -pub(crate) fn process_is_running(pid: u32) -> bool { - // Use libc::kill with signal 0 instead of forking a subprocess. - // Returns true only if the process exists AND we can signal it. - // Returns false for non-existent PIDs (ESRCH) and PIDs owned by - // other users (EPERM) — callers should not interact with those. - unsafe { libc::kill(pid as i32, 0) == 0 } -} +use process::resolve_pgids_and_kill; -#[cfg(not(unix))] -pub(crate) fn process_is_running(_pid: u32) -> bool { - false -} - -/// Check if a PID belongs to a known agent process we spawned. -/// Returns false for recycled PIDs that now belong to other processes. -#[cfg(target_os = "macos")] -pub(crate) fn process_belongs_to_us(pid: u32) -> bool { - // Use proc_name() from libproc to get the process name without spawning - // a subprocess. - extern "C" { - fn proc_name(pid: libc::c_int, buffer: *mut libc::c_void, buffersize: u32) -> libc::c_int; - } - let mut buf = [0u8; 1024]; - let len = unsafe { - proc_name( - pid as i32, - buf.as_mut_ptr() as *mut libc::c_void, - buf.len() as u32, - ) - }; - if len <= 0 { - return false; - } - let name = String::from_utf8_lossy(&buf[..len as usize]); - // Fall through for script interpreters (e.g. `node` hosting an npm shim): - // the caller's `process_has_buzz_marker()` check decides true ownership. - name_matches_known_binary(&name) || name_matches_interpreter(&name) -} - -#[cfg(all(unix, not(target_os = "macos")))] -pub(crate) fn process_belongs_to_us(pid: u32) -> bool { - // First try /proc//comm. Note: comm is truncated to 15 bytes on Linux, - // so binaries with names longer than 15 chars (e.g. "claude-agent-acp") - // will never match here. - if let Ok(name) = std::fs::read_to_string(format!("/proc/{pid}/comm")) { - if name_matches_known_binary(name.trim()) { - return true; - } - // Interpreter check: `node` is 4 bytes, never truncated. - if name_matches_interpreter(name.trim()) { - return true; - } - } - - // Fallback: read /proc//exe which is a symlink to the full binary path. - // This is not subject to the 15-byte truncation limit. - if let Ok(exe_path) = std::fs::read_link(format!("/proc/{pid}/exe")) { - if let Some(basename) = exe_path.file_name().and_then(|n| n.to_str()) { - // Fall through for script interpreters — caller checks the marker. - return name_matches_known_binary(basename) || name_matches_interpreter(basename); - } - } - - false -} - -#[cfg(not(unix))] -pub(crate) fn process_belongs_to_us(_pid: u32) -> bool { - false -} - -/// The value stamped into the `BUZZ_MANAGED_AGENT` env var of every agent we -/// spawn, identifying *which* desktop instance owns it. We use the app's bundle -/// identifier (`xyz.block.buzz.app` for release, `xyz.block.buzz.app.dev` -/// for `just dev`) because it is stable across restarts — a relaunched dev -/// instance still recognizes its own previously-spawned agents as reclaimable, -/// while never matching another instance's (e.g. a dev build never reaps a DMG -/// build's agents, and vice versa). This is what lets two Buzzs coexist on -/// one machine without one's cleanup nuking the other's agents. -pub(crate) fn current_instance_id(app: &AppHandle) -> String { - app.config().identifier.clone() -} - -/// Build the full `BUZZ_MANAGED_AGENT=` env entry we match -/// against when scanning processes. Kept here so the spawn stamp and the sweep -/// matcher can never drift apart. -fn buzz_marker_entry(instance_id: &str) -> Vec { - format!("BUZZ_MANAGED_AGENT={instance_id}").into_bytes() -} - -/// Check if a running process is one of *our* managed agents: it must carry -/// `BUZZ_MANAGED_AGENT=` in its environment, where `instance_id` -/// is this desktop instance's id. A process stamped with a *different* instance -/// id belongs to another live Buzz app and must never be reaped here. -#[cfg(target_os = "macos")] -pub(crate) fn process_has_buzz_marker(pid: u32, instance_id: &str) -> bool { - let marker = buzz_marker_entry(instance_id); - let Some(buf) = sweep::procargs2_buffer(pid) else { - return false; - }; - - // Buffer layout: [i32 argc][exec_path\0][null padding][argv\0...][env\0...] - if buf.len() < std::mem::size_of::() { - return false; - } - let mut n_args: libc::c_int = 0; - unsafe { - std::ptr::copy_nonoverlapping( - buf.as_ptr(), - &mut n_args as *mut libc::c_int as *mut u8, - std::mem::size_of::(), - ); - } - let mut pos = std::mem::size_of::(); - - // Skip exec path (scan to first null). - while pos < buf.len() && buf[pos] != 0 { - pos += 1; - } - // Skip null padding between exec path and argv[0]. - while pos < buf.len() && buf[pos] == 0 { - pos += 1; - } - // Skip argc argument strings. - let mut args_remaining = n_args; - while args_remaining > 0 && pos < buf.len() { - while pos < buf.len() && buf[pos] != 0 { - pos += 1; - } - while pos < buf.len() && buf[pos] == 0 { - pos += 1; - } - args_remaining -= 1; - } - // Remaining bytes are null-delimited environment strings. - buf[pos..].split(|&b| b == 0).any(|entry| entry == marker) -} - -#[cfg(all(unix, not(target_os = "macos")))] -pub(crate) fn process_has_buzz_marker(pid: u32, instance_id: &str) -> bool { - let marker = buzz_marker_entry(instance_id); - let Ok(data) = std::fs::read(format!("/proc/{pid}/environ")) else { - return false; - }; - data.split(|&b| b == 0).any(|entry| entry == marker) -} - -#[cfg(not(unix))] -pub(crate) fn process_has_buzz_marker(_pid: u32, _instance_id: &str) -> bool { - false -} - -#[cfg(unix)] -fn signal_process_group_or_leader(pid: u32, signal: i32, action: &str) -> Result<(), String> { - let pgid = -(pid as i32); - - if unsafe { libc::kill(pgid, signal) } == 0 { - return Ok(()); - } - - let group_err = std::io::Error::last_os_error(); - if !process_is_running(pid) { - return Ok(()); - } - - // Some local agent trees can no longer be signalled as a process group - // (for example if the leader changed groups, or macOS returns EPERM for one - // descendant). Fall back to the leader PID so stop/delete can still recover. - if matches!( - group_err.raw_os_error(), - Some(libc::EPERM) | Some(libc::ESRCH) - ) { - if unsafe { libc::kill(pid as i32, signal) } == 0 { - return Ok(()); - } - - let leader_err = std::io::Error::last_os_error(); - if leader_err.raw_os_error() == Some(libc::ESRCH) || !process_is_running(pid) { - return Ok(()); - } - - return Err(format!("failed to {action} process {pid}: {leader_err}")); - } - - Err(format!( - "failed to {action} process group {pid}: {group_err}" - )) -} - -#[cfg(unix)] -pub(crate) fn terminate_process(pid: u32) -> Result<(), String> { - // Try graceful shutdown first (SIGTERM to the group). - signal_process_group_or_leader(pid, libc::SIGTERM, "terminate")?; - - // Wait up to 1s for graceful exit. - for _ in 0..10 { - if !process_is_running(pid) { - return Ok(()); - } - std::thread::sleep(std::time::Duration::from_millis(100)); - } - - // Escalate to SIGKILL on the entire group. - signal_process_group_or_leader(pid, libc::SIGKILL, "kill")?; - - Ok(()) -} - -#[cfg(windows)] -pub(crate) fn terminate_process(pid: u32) -> Result<(), String> { - // No job handle is available on this path (e.g. after an app restart, when - // we only recovered the PID from the record), so fall back to taskkill on - // the whole tree. - super::process_lifecycle::taskkill_tree(pid) -} - -#[cfg(not(any(unix, windows)))] -pub(crate) fn terminate_process(_pid: u32) -> Result<(), String> { - Err("managed agent shutdown after app restart is not supported on this platform".to_string()) -} - -/// Send SIGTERM to all given PIDs (as process groups), wait, then SIGKILL -/// any survivors. Uses `-pid` to kill the entire process group — if an -/// orphaned agent called `setsid()`, it IS the group leader, so this -/// reaches its children too. -#[cfg(unix)] -fn sigterm_then_sigkill(pids: &[i32]) { - // Send SIGTERM to each process group. Track whether any signal was - // actually delivered so we can skip the sleep when everything is - // already gone. - let mut any_signalled = false; - for &pid in pids { - if unsafe { libc::kill(-pid, libc::SIGTERM) } == 0 { - any_signalled = true; - } - } - - if !any_signalled { - return; - } - - std::thread::sleep(std::time::Duration::from_millis(200)); - - for &pid in pids { - // Check if the group has any living members, not just the leader. - // kill(-pid, 0) returns 0 if ANY member of the group is signalable. - if unsafe { libc::kill(-pid, 0) } == 0 { - unsafe { - libc::kill(-pid, libc::SIGKILL); - } - } - } -} - -/// Resolve orphan candidate PIDs to their actual process group IDs, dedupe, -/// and signal the groups. An orphaned grandchild (e.g. `goose` or `buzz-dev-mcp`) -/// whose harness has exited retains the harness's PGID — signaling that PGID -/// kills the entire orphaned subtree. Falls back to the candidate PID itself -/// when PGID resolution fails (process may have exited between detection and -/// kill). -#[cfg(target_os = "macos")] -fn resolve_pgids_and_kill(candidate_pids: &[i32]) { - let candidate_set: std::collections::HashSet = candidate_pids.iter().copied().collect(); - let mut pgids = std::collections::HashSet::new(); - for &pid in candidate_pids { - let pgid = unsafe { libc::getpgid(pid) }; - if pgid > 0 { - pgids.insert(pgid); - } else { - // Process may have exited; try signaling it directly as a group. - pgids.insert(pid); - } - } - // PID-recycling guard: if a resolved PGID is alive but isn't one of our - // orphan candidates, the old harness PID was recycled by a new process - // that called setsid() — skip it to avoid killing an unrelated group. - let candidate_groups = pgids.len(); - pgids.retain(|&pgid| { - if candidate_set.contains(&pgid) { - return true; - } - let alive = unsafe { libc::kill(pgid, 0) } == 0; - !alive - }); - if pgids.is_empty() && candidate_groups > 0 { - eprintln!( - "buzz-desktop: orphan sweep: skipped all {candidate_groups} candidate group(s) (live foreign group leader or candidate already exited); nothing signalled" - ); - } - let unique: Vec = pgids.into_iter().collect(); - sigterm_then_sigkill(&unique); -} - -/// Resolve orphan candidate PIDs to their actual process group IDs, dedupe, -/// and signal the groups. Linux variant reads PGID from /proc//stat. -#[cfg(all(unix, not(target_os = "macos")))] -fn resolve_pgids_and_kill(candidate_pids: &[i32]) { - let candidate_set: std::collections::HashSet = candidate_pids.iter().copied().collect(); - let mut pgids = std::collections::HashSet::new(); - for &pid in candidate_pids { - if let Some(pgid) = read_pgid_linux(pid as u32) { - pgids.insert(pgid as i32); - } else { - // Process may have exited; try signaling it directly as a group. - pgids.insert(pid); - } - } - // PID-recycling guard: if a resolved PGID is alive but isn't one of our - // orphan candidates, the old harness PID was recycled by a new process - // that called setsid() — skip it to avoid killing an unrelated group. - let candidate_groups = pgids.len(); - pgids.retain(|&pgid| { - if candidate_set.contains(&pgid) { - return true; - } - let alive = unsafe { libc::kill(pgid, 0) } == 0; - !alive - }); - if pgids.is_empty() && candidate_groups > 0 { - eprintln!( - "buzz-desktop: orphan sweep: skipped all {candidate_groups} candidate group(s) (live foreign group leader or candidate already exited); nothing signalled" - ); - } - let unique: Vec = pgids.into_iter().collect(); - sigterm_then_sigkill(&unique); -} - -pub(crate) fn valid_agent_runtime_receipt( - path: &std::path::Path, - receipt: &super::ManagedAgentRuntimeReceipt, - instance_id: &str, -) -> bool { - let Ok(canonical) = - ManagedAgentRuntimeKey::new(receipt.key.pubkey.clone(), &receipt.key.relay_url) - else { - return false; - }; - canonical == receipt.key - && path.file_name().and_then(|name| name.to_str()) - == Some(&format!("{}.json", receipt.key.runtime_id())) - && receipt.desktop_instance_id == instance_id - && process_is_running(receipt.pid) - && process_belongs_to_us(receipt.pid) - && process_has_buzz_marker(receipt.pid, &receipt.desktop_instance_id) -} - -fn terminate_runtime_receipt_with( - path: &std::path::Path, - receipt: &super::ManagedAgentRuntimeReceipt, - terminate: impl FnOnce(u32) -> Result<(), String>, - mut is_running: impl FnMut(u32) -> bool, - remove: impl FnOnce(&std::path::Path), -) -> Result<(), String> { - terminate(receipt.pid)?; - for _ in 0..20 { - if !is_running(receipt.pid) { - remove(path); - return Ok(()); - } - std::thread::sleep(std::time::Duration::from_millis(100)); - } - Err(format!( - "prior runtime {} for pair {} on {} did not exit", - receipt.pid, receipt.key.pubkey, receipt.key.relay_url - )) -} - -/// Replace a valid prior-session process before registering a new child for -/// the same pair. The caller must hold the runtime transition lock so receipt -/// inspection, termination, spawn, and registration cannot race shutdown or -/// another start. -pub(crate) fn terminate_untracked_pair_runtime( - app: &AppHandle, - key: &ManagedAgentRuntimeKey, -) -> Result<(), String> { - let instance_id = current_instance_id(app); - let Some((path, receipt)) = super::read_all_agent_runtime_receipts(app) - .into_iter() - .find(|(path, receipt)| { - receipt.key == *key && valid_agent_runtime_receipt(path, receipt, &instance_id) - }) - else { - return Ok(()); - }; - - terminate_runtime_receipt_with( - &path, - &receipt, - terminate_process, - process_is_running, - super::remove_agent_runtime_receipt_path, - ) -} - -/// Kill orphaned agent processes using PID file receipts. Reads all files from -/// `agent-pids/`, verifies each PID still belongs to a known agent binary, -/// then resolves each candidate's actual PGID and signals the process group. -/// Deletes the PID file after killing. -/// -/// `skip_pids` are PIDs already handled by the tracked-agent path. -#[cfg(unix)] -pub(crate) fn sweep_orphaned_agent_processes(app: &AppHandle, skip_pids: &[u32]) { - let legacy_entries = super::read_all_agent_pid_files(app); - let instance_id = current_instance_id(app); - let receipt_entries: Vec<_> = super::read_all_agent_runtime_receipts(app) - .into_iter() - .filter_map(|(path, receipt)| { - if valid_agent_runtime_receipt(&path, &receipt, &instance_id) { - Some((path, receipt)) - } else { - super::remove_agent_runtime_receipt_path(&path); - None - } - }) - .collect(); - // Collect live orphans AND dead-leader groups into a single kill batch. - // Dead leaders: PGID may have been recycled, but the window is narrow - // (PID files are from this session) and the cost of missing surviving - // group members outweighs the recycling risk. - let targets: Vec = legacy_entries - .iter() - .map(|(_, pid)| *pid) - .chain(receipt_entries.iter().map(|(_, receipt)| receipt.pid)) - .filter(|pid| { - if skip_pids.contains(pid) { - return false; - } - (process_is_running(*pid) && process_belongs_to_us(*pid)) || !process_is_running(*pid) - }) - .map(|pid| pid as i32) - .collect(); - - if !targets.is_empty() { - resolve_pgids_and_kill(&targets); - } - - // Clean up PID files for processes we just killed or that are already gone. - for (pubkey, pid) in &legacy_entries { - if skip_pids.contains(pid) { - continue; - } - if !process_is_running(*pid) || !process_belongs_to_us(*pid) { - super::remove_agent_pid_file(app, pubkey); - } - } - for (_, receipt) in &receipt_entries { - if skip_pids.contains(&receipt.pid) { - continue; - } - if !process_is_running(receipt.pid) || !process_belongs_to_us(receipt.pid) { - super::remove_agent_runtime_receipt(app, &receipt.key); - } - } -} - -#[cfg(not(unix))] -pub(crate) fn sweep_orphaned_agent_processes(app: &AppHandle, _skip_pids: &[u32]) { - let _ = app; -} - -// ── macOS process-info FFI (shared by all sweep/reap functions) ────────── -// -// `proc_listallpids` lives in `sweep.rs` (which owns `collect_all_pids`). -// All callers in this file reach it through `sweep::collect_all_pids()`. -// `proc_pidinfo` and `BSDInfo` are declared here as `pub(super)` so that -// `sweep.rs` can call `super::proc_pidinfo` / use `super::BSDInfo` without -// redefining the struct layout in two places. - -#[cfg(target_os = "macos")] -extern "C" { - pub(super) fn proc_pidinfo( - pid: libc::c_int, - flavor: libc::c_int, - arg: u64, - buffer: *mut libc::c_void, - buffersize: libc::c_int, - ) -> libc::c_int; -} - -/// Subset of `struct proc_bsdinfo` from ``. Layout verified -/// against the macOS SDK — total size 136 bytes. -#[cfg(target_os = "macos")] -#[repr(C)] -pub(super) struct BSDInfo { - _flags_status_xstatus: [u8; 12], // pbi_flags + pbi_status + pbi_xstatus - pub(super) pbi_pid: u32, // offset 12 - pub(super) pbi_ppid: u32, // offset 16 - pub(super) pbi_uid: u32, // offset 20 - _rest: [u8; 112], -} - -#[cfg(target_os = "macos")] -const _: () = assert!(std::mem::size_of::() == 136); - -#[cfg(target_os = "macos")] -pub(super) const PROC_PIDTBSDINFO: libc::c_int = 3; - -/// Enumerate all processes on the system owned by the current user and kill any -/// agent binary stamped with *this* instance's `BUZZ_MANAGED_AGENT` marker -/// (`instance_id`) that isn't in `skip_pids`. This catches orphans that escaped -/// PID-file-based cleanup (e.g. agent workers spawned with their own process -/// group whose parent harness already exited and had its PID file removed), -/// while leaving another live Buzz instance's agents untouched. -#[cfg(target_os = "macos")] -pub(crate) fn sweep_system_agent_processes(instance_id: &str, skip_pids: &[u32]) { - let my_uid = unsafe { libc::getuid() }; - let pids = sweep::collect_all_pids(); - if pids.is_empty() { - return; - } - let my_pid = std::process::id() as i32; - let mut orphans: Vec = Vec::new(); - - for &pid in &pids { - if pid <= 0 { - continue; - } - let upid = pid as u32; - if skip_pids.contains(&upid) || pid == my_pid { - continue; - } - // Check binary name first (cheap proc_name call) before UID lookup. - if !process_belongs_to_us(upid) { - continue; - } - // Verify UID and PPID via proc_pidinfo. - let mut info = std::mem::MaybeUninit::::zeroed(); - let ret = unsafe { - proc_pidinfo( - pid, - PROC_PIDTBSDINFO, - 0, - info.as_mut_ptr() as *mut libc::c_void, - std::mem::size_of::() as libc::c_int, - ) - }; - if ret <= 0 { - continue; - } - let info = unsafe { info.assume_init() }; - if info.pbi_uid != my_uid { - continue; - } - if !process_has_buzz_marker(upid, instance_id) { - continue; - } - // Live descendants of a tracked harness are exempt — see sweep::is_live_descendant_*. - if sweep::is_live_descendant_macos(upid, info.pbi_ppid, skip_pids) { - continue; - } - orphans.push(pid); - } - - if !orphans.is_empty() { - eprintln!( - "buzz-desktop: system sweep found {} orphaned agent process(es), cleaning up", - orphans.len() - ); - resolve_pgids_and_kill(&orphans); - } -} - -/// Read the process group ID from /proc//stat by delegating to the shared -/// stat parser in `sweep`. Keeps a single parse site for the `/proc//stat` -/// field layout. -#[cfg(all(unix, not(target_os = "macos")))] -fn read_pgid_linux(pid: u32) -> Option { - sweep::proc_stat_ppid_pgid_linux(pid).map(|(_, pgid)| pgid) -} - -#[cfg(all(unix, not(target_os = "macos")))] -pub(crate) fn sweep_system_agent_processes(instance_id: &str, skip_pids: &[u32]) { - let my_uid = unsafe { libc::getuid() }; - let mut orphans: Vec = Vec::new(); - let my_pid = std::process::id() as i32; - - let Ok(entries) = std::fs::read_dir("/proc") else { - return; - }; - for entry in entries.flatten() { - let name = entry.file_name(); - let Some(name_str) = name.to_str() else { - continue; - }; - let Ok(pid) = name_str.parse::() else { - continue; - }; - if pid <= 0 || pid == my_pid { - continue; - } - let upid = pid as u32; - if skip_pids.contains(&upid) { - continue; - } - // Check ownership via /proc/ metadata. - let Ok(meta) = entry.metadata() else { - continue; - }; - use std::os::unix::fs::MetadataExt; - if meta.uid() != my_uid { - continue; - } - if !process_belongs_to_us(upid) || !process_has_buzz_marker(upid, instance_id) { - continue; - } - // Live descendants of a tracked harness are exempt — see sweep::is_live_descendant_*. - if sweep::is_live_descendant_linux(upid, skip_pids) { - continue; - } - orphans.push(pid); - } - - if !orphans.is_empty() { - eprintln!( - "buzz-desktop: system sweep found {} orphaned agent process(es), cleaning up", - orphans.len() - ); - resolve_pgids_and_kill(&orphans); - } -} - -#[cfg(not(unix))] -pub(crate) fn sweep_system_agent_processes(_instance_id: &str, _skip_pids: &[u32]) {} - -/// Periodic-sweep variant with two-tick grace: only reaps same-instance orphans -/// that were also seen orphaned on the previous tick. This prevents killing a -/// legitimately-starting agent that spawned between the skip-list snapshot and -/// the process scan. Returns the current orphan set for use as `prev_orphans` -/// on the next tick. -#[cfg(unix)] -pub(crate) fn sweep_system_agent_processes_with_grace( - instance_id: &str, - skip_pids: &[u32], - prev_orphans: &std::collections::HashSet, -) -> std::collections::HashSet { - let current = collect_same_instance_orphans(instance_id, skip_pids); - // Only reap PIDs seen orphaned on two consecutive ticks. - let confirmed: Vec = current - .iter() - .filter(|pid| prev_orphans.contains(pid)) - .map(|&pid| pid as i32) - .collect(); - if !confirmed.is_empty() { - eprintln!( - "buzz-desktop: periodic sweep confirmed {} orphaned agent process(es), cleaning up", - confirmed.len() - ); - resolve_pgids_and_kill(&confirmed); - } - current -} - -#[cfg(not(unix))] -pub(crate) fn sweep_system_agent_processes_with_grace( - _instance_id: &str, - _skip_pids: &[u32], - _prev_orphans: &std::collections::HashSet, -) -> std::collections::HashSet { - std::collections::HashSet::new() -} - -/// Collect PIDs of same-instance agent processes that appear orphaned (not in -/// `skip_pids`). Returns the set for use in two-tick grace logic — does NOT -/// kill anything. -#[cfg(target_os = "macos")] -pub(crate) fn collect_same_instance_orphans( - instance_id: &str, - skip_pids: &[u32], -) -> std::collections::HashSet { - let my_uid = unsafe { libc::getuid() }; - let my_pid = std::process::id() as i32; - let mut orphans = std::collections::HashSet::new(); - - let pids = sweep::collect_all_pids(); - if pids.is_empty() { - return orphans; - } - - for &pid in &pids { - if pid <= 0 || pid == my_pid { - continue; - } - let upid = pid as u32; - if skip_pids.contains(&upid) { - continue; - } - if !process_belongs_to_us(upid) { - continue; - } - let mut info = std::mem::MaybeUninit::::zeroed(); - let ret = unsafe { - proc_pidinfo( - pid, - PROC_PIDTBSDINFO, - 0, - info.as_mut_ptr() as *mut libc::c_void, - std::mem::size_of::() as libc::c_int, - ) - }; - if ret <= 0 { - continue; - } - let info = unsafe { info.assume_init() }; - if info.pbi_uid != my_uid { - continue; - } - if !process_has_buzz_marker(upid, instance_id) { - continue; - } - // Live descendants of a tracked harness are exempt — see sweep::is_live_descendant_*. - if sweep::is_live_descendant_macos(upid, info.pbi_ppid, skip_pids) { - continue; - } - orphans.insert(upid); - } - orphans -} - -#[cfg(all(unix, not(target_os = "macos")))] -pub(crate) fn collect_same_instance_orphans( - instance_id: &str, - skip_pids: &[u32], -) -> std::collections::HashSet { - let my_uid = unsafe { libc::getuid() }; - let my_pid = std::process::id() as i32; - let mut orphans = std::collections::HashSet::new(); - - let Ok(entries) = std::fs::read_dir("/proc") else { - return orphans; - }; - for entry in entries.flatten() { - let name = entry.file_name(); - let Some(name_str) = name.to_str() else { - continue; - }; - let Ok(pid) = name_str.parse::() else { - continue; - }; - if pid <= 0 || pid == my_pid { - continue; - } - let upid = pid as u32; - if skip_pids.contains(&upid) { - continue; - } - let Ok(meta) = entry.metadata() else { - continue; - }; - use std::os::unix::fs::MetadataExt; - if meta.uid() != my_uid { - continue; - } - if !process_belongs_to_us(upid) || !process_has_buzz_marker(upid, instance_id) { - continue; - } - // Live descendants of a tracked harness are exempt — see sweep::is_live_descendant_*. - if sweep::is_live_descendant_linux(upid, skip_pids) { - continue; - } - orphans.insert(upid); - } - orphans -} - -#[cfg(not(unix))] -pub(crate) fn collect_same_instance_orphans( - _instance_id: &str, - _skip_pids: &[u32], -) -> std::collections::HashSet { - std::collections::HashSet::new() -} - -/// Binary names for the Buzz desktop/Tauri process. Used by dead-instance -/// detection to confirm the owning desktop is still alive. -const DESKTOP_BINARY_NAMES: &[&str] = &[ - "Buzz", - "buzz-desktop", - "buzz_desktop", - // Linux limits /proc//comm to 15 visible bytes, truncating the - // AppImage shim's real executable name, `buzz-desktop.bin`. - "buzz-desktop.bi", -]; - -/// Check if a process name matches a known Buzz desktop binary. -fn is_desktop_binary(name: &str) -> bool { - DESKTOP_BINARY_NAMES.contains(&name) -} - -/// Check whether `buf` contains `id` as a complete identifier — not as a -/// prefix of a longer dotted name. The identifier appears in the Tauri config -/// JSON as `"identifier":"xyz.block.buzz.app.dev"` and in environment entries -/// as `KEY=...app.dev\0`, so a valid match is followed by a non-identifier byte -/// (not `[A-Za-z0-9._-]`) or sits at the end of the buffer. This prevents -/// `xyz.block.buzz.app` from matching inside `xyz.block.buzz.app.dev`. -fn buffer_contains_identifier(buf: &[u8], id: &[u8]) -> bool { - if id.is_empty() { - return false; - } - buf.windows(id.len()).enumerate().any(|(i, w)| { - if w != id { - return false; - } - // Boundary check on the byte immediately after the match: end-of-buffer - // or any byte that can't continue a dotted reverse-DNS identifier. - match buf.get(i + id.len()) { - None => true, - Some(&next) => { - !next.is_ascii_alphanumeric() && next != b'.' && next != b'_' && next != b'-' - } - } - }) -} - -/// Extract the `BUZZ_MANAGED_AGENT` value from a process's environment. -/// Returns `None` if the process doesn't have the marker or can't be read. -#[cfg(target_os = "macos")] -fn extract_buzz_marker_value(pid: u32) -> Option { - let prefix = b"BUZZ_MANAGED_AGENT="; - let buf = sweep::procargs2_buffer(pid)?; - - if buf.len() < std::mem::size_of::() { - return None; - } - let mut n_args: libc::c_int = 0; - unsafe { - std::ptr::copy_nonoverlapping( - buf.as_ptr(), - &mut n_args as *mut libc::c_int as *mut u8, - std::mem::size_of::(), - ); - } - let mut pos = std::mem::size_of::(); - - // Skip exec path. - while pos < buf.len() && buf[pos] != 0 { - pos += 1; - } - while pos < buf.len() && buf[pos] == 0 { - pos += 1; - } - // Skip argc argument strings. - let mut args_remaining = n_args; - while args_remaining > 0 && pos < buf.len() { - while pos < buf.len() && buf[pos] != 0 { - pos += 1; - } - while pos < buf.len() && buf[pos] == 0 { - pos += 1; - } - args_remaining -= 1; - } - // Search environment entries for our marker. - for entry in buf[pos..].split(|&b| b == 0) { - if entry.starts_with(prefix) { - return String::from_utf8(entry[prefix.len()..].to_vec()).ok(); - } - } - None -} - -#[cfg(all(unix, not(target_os = "macos")))] -fn extract_buzz_marker_value(pid: u32) -> Option { - let prefix = b"BUZZ_MANAGED_AGENT="; - let data = std::fs::read(format!("/proc/{pid}/environ")).ok()?; - for entry in data.split(|&b| b == 0) { - if entry.starts_with(prefix) { - return String::from_utf8(entry[prefix.len()..].to_vec()).ok(); - } - } - None -} - -#[cfg(not(unix))] -fn extract_buzz_marker_value(_pid: u32) -> Option { - None -} - -/// Check if a Buzz desktop process is still alive for the given instance ID. -/// Scans all user-owned processes named "Buzz" or "buzz-desktop" and checks -/// whether any has the identifier in its command-line args (KERN_PROCARGS2 buffer -/// includes both argv and environ — the `--config` JSON from `tauri dev` contains -/// the identifier string). -#[cfg(target_os = "macos")] -fn desktop_is_alive_for_instance(instance_id: &str) -> bool { - extern "C" { - fn proc_name(pid: libc::c_int, buffer: *mut libc::c_void, buffersize: u32) -> libc::c_int; - } - - let my_uid = unsafe { libc::getuid() }; - let identifier_bytes = instance_id.as_bytes(); - - let pids = sweep::collect_all_pids(); - if pids.is_empty() { - return false; - } - - for &pid in &pids { - if pid <= 0 { - continue; - } - // Check binary name — only look at desktop binaries. - let mut name_buf = [0u8; 1024]; - let len = unsafe { - proc_name( - pid, - name_buf.as_mut_ptr() as *mut libc::c_void, - name_buf.len() as u32, - ) - }; - if len <= 0 { - continue; - } - let name = String::from_utf8_lossy(&name_buf[..len as usize]); - if !is_desktop_binary(&name) { - continue; - } - // Verify UID. - let mut info = std::mem::MaybeUninit::::zeroed(); - let ret = unsafe { - proc_pidinfo( - pid, - PROC_PIDTBSDINFO, - 0, - info.as_mut_ptr() as *mut libc::c_void, - std::mem::size_of::() as libc::c_int, - ) - }; - if ret <= 0 { - continue; - } - let info = unsafe { info.assume_init() }; - if info.pbi_uid != my_uid { - continue; - } - // Check if this desktop process's args/env contain the identifier. - // The KERN_PROCARGS2 buffer holds argv + environ as null-delimited strings. - let Some(args_buf) = sweep::procargs2_buffer(pid as u32) else { - continue; - }; - // Boundary-anchored search: the identifier in the config JSON is - // followed by a non-identifier char (typically `"`). A raw substring - // match would let `...app` match inside `...app.dev`. - if buffer_contains_identifier(&args_buf, identifier_bytes) { - return true; - } - } - false -} - -#[cfg(all(unix, not(target_os = "macos")))] -fn desktop_is_alive_for_instance(instance_id: &str) -> bool { - let my_uid = unsafe { libc::getuid() }; - let Ok(entries) = std::fs::read_dir("/proc") else { - return false; - }; - for entry in entries.flatten() { - let name = entry.file_name(); - let Some(name_str) = name.to_str() else { - continue; - }; - let Ok(pid) = name_str.parse::() else { - continue; - }; - // Check ownership. - let Ok(meta) = entry.metadata() else { - continue; - }; - use std::os::unix::fs::MetadataExt; - if meta.uid() != my_uid { - continue; - } - // Check binary name via /proc//comm. - let Ok(comm) = std::fs::read_to_string(format!("/proc/{pid}/comm")) else { - continue; - }; - if !is_desktop_binary(comm.trim()) { - continue; - } - // Check cmdline for the identifier with boundary anchoring. - let Ok(cmdline) = std::fs::read(format!("/proc/{pid}/cmdline")) else { - continue; - }; - if buffer_contains_identifier(&cmdline, instance_id.as_bytes()) { - return true; - } - } - false -} - -#[cfg(not(unix))] -fn desktop_is_alive_for_instance(_instance_id: &str) -> bool { - false -} - -/// Reap agent processes belonging to dead Buzz desktop instances. -/// -/// Scans all user processes for `BUZZ_MANAGED_AGENT=*`, groups them by -/// instance ID, and for each foreign instance (≠ `our_instance_id`) checks -/// whether a Buzz desktop binary is still alive for that instance. If not, -/// all agents from that dead instance are reaped. -#[cfg(target_os = "macos")] -pub(crate) fn reap_dead_instance_agents(our_instance_id: &str, skip_pids: &[u32]) { - let my_uid = unsafe { libc::getuid() }; - let my_pid = std::process::id() as i32; - - let pids = sweep::collect_all_pids(); - if pids.is_empty() { - return; - } - - // Collect (pid, instance_id) for all foreign agent processes. - let mut foreign_agents: HashMap> = HashMap::new(); - - for &pid in &pids { - if pid <= 0 || pid == my_pid { - continue; - } - let upid = pid as u32; - if skip_pids.contains(&upid) { - continue; - } - if !process_belongs_to_us(upid) { - continue; - } - // Verify UID. - let mut info = std::mem::MaybeUninit::::zeroed(); - let ret = unsafe { - proc_pidinfo( - pid, - PROC_PIDTBSDINFO, - 0, - info.as_mut_ptr() as *mut libc::c_void, - std::mem::size_of::() as libc::c_int, - ) - }; - if ret <= 0 { - continue; - } - let info = unsafe { info.assume_init() }; - if info.pbi_uid != my_uid { - continue; - } - // Extract the instance ID from this agent's env. - let Some(agent_instance_id) = extract_buzz_marker_value(upid) else { - continue; - }; - // Skip agents belonging to our own instance (handled by sweep_system_agent_processes). - if agent_instance_id == our_instance_id { - continue; - } - foreign_agents - .entry(agent_instance_id) - .or_default() - .push(pid); - } - - // For each foreign instance, check if its desktop is still alive. - for (instance_id, agent_pids) in &foreign_agents { - if desktop_is_alive_for_instance(instance_id) { - continue; - } - eprintln!( - "buzz-desktop: reaping {} orphaned agent(s) from dead instance '{instance_id}'", - agent_pids.len() - ); - resolve_pgids_and_kill(agent_pids); - } -} - -#[cfg(all(unix, not(target_os = "macos")))] -pub(crate) fn reap_dead_instance_agents(our_instance_id: &str, skip_pids: &[u32]) { - let my_uid = unsafe { libc::getuid() }; - let my_pid = std::process::id() as i32; - let mut foreign_agents: HashMap> = HashMap::new(); - - let Ok(entries) = std::fs::read_dir("/proc") else { - return; - }; - for entry in entries.flatten() { - let name = entry.file_name(); - let Some(name_str) = name.to_str() else { - continue; - }; - let Ok(pid) = name_str.parse::() else { - continue; - }; - if pid <= 0 || pid == my_pid { - continue; - } - let upid = pid as u32; - if skip_pids.contains(&upid) { - continue; - } - let Ok(meta) = entry.metadata() else { - continue; - }; - use std::os::unix::fs::MetadataExt; - if meta.uid() != my_uid { - continue; - } - if !process_belongs_to_us(upid) { - continue; - } - let Some(agent_instance_id) = extract_buzz_marker_value(upid) else { - continue; - }; - if agent_instance_id == our_instance_id { - continue; - } - foreign_agents - .entry(agent_instance_id) - .or_default() - .push(pid); - } - - for (instance_id, agent_pids) in &foreign_agents { - if desktop_is_alive_for_instance(instance_id) { - continue; - } - eprintln!( - "buzz-desktop: reaping {} orphaned agent(s) from dead instance '{instance_id}'", - agent_pids.len() - ); - resolve_pgids_and_kill(agent_pids); - } -} - -#[cfg(not(unix))] -pub(crate) fn reap_dead_instance_agents(_our_instance_id: &str, _skip_pids: &[u32]) {} +mod instance_reaper; +pub(crate) use instance_reaper::reap_dead_instance_agents; +#[cfg(test)] +use instance_reaper::{buffer_contains_identifier, is_desktop_binary}; // Exact-path harness sweep lives in runtime/sweep.rs (re-exported above). -/// Kill stale agent processes from a previous session whose PID is still alive -/// but not tracked in the current `runtimes` map. Updates the record fields and -/// returns `true` if any records were modified. -pub fn kill_stale_tracked_processes( - records: &mut [ManagedAgentRecord], - runtimes: &HashMap, - instance_id: &str, -) -> bool { - use crate::managed_agents::BackendKind; - - let mut changed = false; - for record in records.iter_mut() { - if record.backend != BackendKind::Local { - continue; - } - let Some(pid) = record.runtime_pid else { - continue; - }; - if !runtimes.keys().any(|key| key.pubkey == record.pubkey) { - if process_belongs_to_us(pid) && process_has_buzz_marker(pid, instance_id) { - let _ = terminate_process(pid); - } - record.runtime_pid = None; - record.last_stopped_at = Some(crate::util::now_iso()); - record.updated_at = crate::util::now_iso(); - changed = true; - } - } - changed -} - -pub fn sync_managed_agent_processes( - records: &mut [ManagedAgentRecord], - runtimes: &mut HashMap, - _instance_id: &str, -) -> (bool, Vec) { - let mut changed = false; - let mut exited = Vec::new(); - - for (key, runtime) in runtimes.iter_mut() { - let status = match runtime.child.try_wait() { - Ok(status) => status, - Err(error) => { - if let Some(record) = records - .iter_mut() - .find(|record| record.pubkey == key.pubkey) - { - record.updated_at = now_iso(); - record.last_error = Some(format!("failed to inspect process state: {error}")); - record.last_error_code = None; - } - changed = true; - exited.push(key.clone()); - continue; - } - }; - - let Some(status) = status else { - continue; - }; - - if let Some(record) = records - .iter_mut() - .find(|record| record.pubkey == key.pubkey) - { - record.updated_at = now_iso(); - record.last_stopped_at = Some(now_iso()); - record.last_exit_code = status.code(); - let log_err = if status.success() { - None - } else { - Some( - super::meaningful_agent_error_from_log(&runtime.log_path).unwrap_or_else( - || super::storage::AgentLogError { - message: format!("harness exited with status {status}"), - code: None, - }, - ), - ) - }; - record.last_error = log_err.as_ref().map(|e| e.message.clone()); - record.last_error_code = log_err.as_ref().and_then(|e| e.code); - } - - changed = true; - exited.push(key.clone()); - } - - let exited_pubkeys: Vec = exited.iter().map(|key| key.pubkey.clone()).collect(); - for key in exited { - runtimes.remove(&key); - } - - // `runtime_pid` is legacy bookkeeping. Pair runtimes and receipts are the - // authoritative lifecycle source; migration cleanup is handled separately. - for record in records.iter_mut() { - if record.runtime_pid.take().is_some() { - record.updated_at = now_iso(); - changed = true; - } - } - - (changed, exited_pubkeys) -} +mod lifecycle; +pub use lifecycle::{kill_stale_tracked_processes, sync_managed_agent_processes}; /// Classify an agent's persona against the live catalog for the Agents-menu /// drift indicator. Returns `(out_of_date, orphaned)`. @@ -2159,61 +906,5 @@ pub fn start_managed_agent_process( Ok(()) } -/// Returns the (key, value) env var pairs that should be forwarded to the -/// agent process for model and provider selection. -/// -/// Model injection is unconditional — even agents that support ACP model -/// switching need the initial bootstrap value. Provider injection is skipped -/// when `provider_locked` is true (e.g. Claude runtimes that only work with -/// Anthropic). -pub(crate) fn runtime_metadata_env_vars<'a>( - model_env_var: Option<&'a str>, - provider_env_var: Option<&'a str>, - provider_locked: bool, - effective_model: Option<&'a str>, - effective_provider: Option<&'a str>, -) -> Vec<(&'a str, &'a str)> { - let mut vars = Vec::new(); - if let (Some(env_key), Some(model)) = (model_env_var, effective_model) { - vars.push((env_key, model)); - } - if !provider_locked { - if let (Some(env_key), Some(provider)) = (provider_env_var, effective_provider) { - vars.push((env_key, provider)); - } - } - vars -} - -/// Resolve the effective (prompt, model, provider) triple for a persona-linked agent. -/// -/// Given a persona_id, finds the persona in the list and returns its system_prompt, -/// model, and provider as the authoritative values. When the persona leaves `model` -/// or `provider` blank (None or whitespace-only), falls back to the record's own -/// field using the same precedence rule as `persona_snapshot_with_agent_config_fallback` -/// so the display surface matches spawn behavior. Falls back to the record's own -/// prompt/model/provider when no persona is linked or found. -/// -/// Used by `agent_config.rs` to inject persona defaults into the config surface -/// before running the reader, so BuzzExplicit-tagged fields can be re-tagged to -/// PersonaDefault for fields the record did not independently set. -pub(crate) fn resolve_effective_prompt_model_provider( - persona_id: Option<&str>, - personas: &[crate::managed_agents::types::AgentDefinition], - record_prompt: Option, - record_model: Option, - record_provider: Option, -) -> (Option, Option, Option) { - let fallback = crate::managed_agents::persona_events::persona_field_with_record_fallback; - match persona_id.and_then(|pid| personas.iter().find(|p| p.id == pid)) { - Some(p) => ( - Some(p.system_prompt.clone()), - fallback(p.model.as_deref(), record_model.as_deref()), // fallback: record.model - fallback(p.provider.as_deref(), record_provider.as_deref()), // fallback: record.provider - ), - None => (record_prompt, record_model, record_provider), - } -} - #[cfg(test)] mod tests; diff --git a/desktop/src-tauri/src/managed_agents/runtime/instance_reaper.rs b/desktop/src-tauri/src/managed_agents/runtime/instance_reaper.rs new file mode 100644 index 0000000000..27fefd0303 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime/instance_reaper.rs @@ -0,0 +1,359 @@ +use super::*; + +/// Binary names for the Buzz desktop/Tauri process. Used by dead-instance +/// detection to confirm the owning desktop is still alive. +const DESKTOP_BINARY_NAMES: &[&str] = &[ + "Buzz", + "buzz-desktop", + "buzz_desktop", + // Linux limits /proc//comm to 15 visible bytes, truncating the + // AppImage shim's real executable name, `buzz-desktop.bin`. + "buzz-desktop.bi", +]; + +/// Check if a process name matches a known Buzz desktop binary. +pub(super) fn is_desktop_binary(name: &str) -> bool { + DESKTOP_BINARY_NAMES.contains(&name) +} + +/// Check whether `buf` contains `id` as a complete identifier — not as a +/// prefix of a longer dotted name. The identifier appears in the Tauri config +/// JSON as `"identifier":"xyz.block.buzz.app.dev"` and in environment entries +/// as `KEY=...app.dev\0`, so a valid match is followed by a non-identifier byte +/// (not `[A-Za-z0-9._-]`) or sits at the end of the buffer. This prevents +/// `xyz.block.buzz.app` from matching inside `xyz.block.buzz.app.dev`. +pub(super) fn buffer_contains_identifier(buf: &[u8], id: &[u8]) -> bool { + if id.is_empty() { + return false; + } + buf.windows(id.len()).enumerate().any(|(i, w)| { + if w != id { + return false; + } + // Boundary check on the byte immediately after the match: end-of-buffer + // or any byte that can't continue a dotted reverse-DNS identifier. + match buf.get(i + id.len()) { + None => true, + Some(&next) => { + !next.is_ascii_alphanumeric() && next != b'.' && next != b'_' && next != b'-' + } + } + }) +} + +/// Extract the `BUZZ_MANAGED_AGENT` value from a process's environment. +/// Returns `None` if the process doesn't have the marker or can't be read. +#[cfg(target_os = "macos")] +fn extract_buzz_marker_value(pid: u32) -> Option { + let prefix = b"BUZZ_MANAGED_AGENT="; + let buf = sweep::procargs2_buffer(pid)?; + + if buf.len() < std::mem::size_of::() { + return None; + } + let mut n_args: libc::c_int = 0; + unsafe { + std::ptr::copy_nonoverlapping( + buf.as_ptr(), + &mut n_args as *mut libc::c_int as *mut u8, + std::mem::size_of::(), + ); + } + let mut pos = std::mem::size_of::(); + + // Skip exec path. + while pos < buf.len() && buf[pos] != 0 { + pos += 1; + } + while pos < buf.len() && buf[pos] == 0 { + pos += 1; + } + // Skip argc argument strings. + let mut args_remaining = n_args; + while args_remaining > 0 && pos < buf.len() { + while pos < buf.len() && buf[pos] != 0 { + pos += 1; + } + while pos < buf.len() && buf[pos] == 0 { + pos += 1; + } + args_remaining -= 1; + } + // Search environment entries for our marker. + for entry in buf[pos..].split(|&b| b == 0) { + if entry.starts_with(prefix) { + return String::from_utf8(entry[prefix.len()..].to_vec()).ok(); + } + } + None +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn extract_buzz_marker_value(pid: u32) -> Option { + let prefix = b"BUZZ_MANAGED_AGENT="; + let data = std::fs::read(format!("/proc/{pid}/environ")).ok()?; + for entry in data.split(|&b| b == 0) { + if entry.starts_with(prefix) { + return String::from_utf8(entry[prefix.len()..].to_vec()).ok(); + } + } + None +} + +#[cfg(not(unix))] +fn extract_buzz_marker_value(_pid: u32) -> Option { + None +} + +/// Check if a Buzz desktop process is still alive for the given instance ID. +/// Scans all user-owned processes named "Buzz" or "buzz-desktop" and checks +/// whether any has the identifier in its command-line args (KERN_PROCARGS2 buffer +/// includes both argv and environ — the `--config` JSON from `tauri dev` contains +/// the identifier string). +#[cfg(target_os = "macos")] +fn desktop_is_alive_for_instance(instance_id: &str) -> bool { + extern "C" { + fn proc_name(pid: libc::c_int, buffer: *mut libc::c_void, buffersize: u32) -> libc::c_int; + } + + let my_uid = unsafe { libc::getuid() }; + let identifier_bytes = instance_id.as_bytes(); + + let pids = sweep::collect_all_pids(); + if pids.is_empty() { + return false; + } + + for &pid in &pids { + if pid <= 0 { + continue; + } + // Check binary name — only look at desktop binaries. + let mut name_buf = [0u8; 1024]; + let len = unsafe { + proc_name( + pid, + name_buf.as_mut_ptr() as *mut libc::c_void, + name_buf.len() as u32, + ) + }; + if len <= 0 { + continue; + } + let name = String::from_utf8_lossy(&name_buf[..len as usize]); + if !is_desktop_binary(&name) { + continue; + } + // Verify UID. + let mut info = std::mem::MaybeUninit::::zeroed(); + let ret = unsafe { + proc_pidinfo( + pid, + PROC_PIDTBSDINFO, + 0, + info.as_mut_ptr() as *mut libc::c_void, + std::mem::size_of::() as libc::c_int, + ) + }; + if ret <= 0 { + continue; + } + let info = unsafe { info.assume_init() }; + if info.pbi_uid != my_uid { + continue; + } + // Check if this desktop process's args/env contain the identifier. + // The KERN_PROCARGS2 buffer holds argv + environ as null-delimited strings. + let Some(args_buf) = sweep::procargs2_buffer(pid as u32) else { + continue; + }; + // Boundary-anchored search: the identifier in the config JSON is + // followed by a non-identifier char (typically `"`). A raw substring + // match would let `...app` match inside `...app.dev`. + if buffer_contains_identifier(&args_buf, identifier_bytes) { + return true; + } + } + false +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn desktop_is_alive_for_instance(instance_id: &str) -> bool { + let my_uid = unsafe { libc::getuid() }; + let Ok(entries) = std::fs::read_dir("/proc") else { + return false; + }; + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(name_str) = name.to_str() else { + continue; + }; + let Ok(pid) = name_str.parse::() else { + continue; + }; + // Check ownership. + let Ok(meta) = entry.metadata() else { + continue; + }; + use std::os::unix::fs::MetadataExt; + if meta.uid() != my_uid { + continue; + } + // Check binary name via /proc//comm. + let Ok(comm) = std::fs::read_to_string(format!("/proc/{pid}/comm")) else { + continue; + }; + if !is_desktop_binary(comm.trim()) { + continue; + } + // Check cmdline for the identifier with boundary anchoring. + let Ok(cmdline) = std::fs::read(format!("/proc/{pid}/cmdline")) else { + continue; + }; + if buffer_contains_identifier(&cmdline, instance_id.as_bytes()) { + return true; + } + } + false +} + +#[cfg(not(unix))] +fn desktop_is_alive_for_instance(_instance_id: &str) -> bool { + false +} + +/// Reap agent processes belonging to dead Buzz desktop instances. +/// +/// Scans all user processes for `BUZZ_MANAGED_AGENT=*`, groups them by +/// instance ID, and for each foreign instance (≠ `our_instance_id`) checks +/// whether a Buzz desktop binary is still alive for that instance. If not, +/// all agents from that dead instance are reaped. +#[cfg(target_os = "macos")] +pub(crate) fn reap_dead_instance_agents(our_instance_id: &str, skip_pids: &[u32]) { + let my_uid = unsafe { libc::getuid() }; + let my_pid = std::process::id() as i32; + + let pids = sweep::collect_all_pids(); + if pids.is_empty() { + return; + } + + // Collect (pid, instance_id) for all foreign agent processes. + let mut foreign_agents: HashMap> = HashMap::new(); + + for &pid in &pids { + if pid <= 0 || pid == my_pid { + continue; + } + let upid = pid as u32; + if skip_pids.contains(&upid) { + continue; + } + if !process_belongs_to_us(upid) { + continue; + } + // Verify UID. + let mut info = std::mem::MaybeUninit::::zeroed(); + let ret = unsafe { + proc_pidinfo( + pid, + PROC_PIDTBSDINFO, + 0, + info.as_mut_ptr() as *mut libc::c_void, + std::mem::size_of::() as libc::c_int, + ) + }; + if ret <= 0 { + continue; + } + let info = unsafe { info.assume_init() }; + if info.pbi_uid != my_uid { + continue; + } + // Extract the instance ID from this agent's env. + let Some(agent_instance_id) = extract_buzz_marker_value(upid) else { + continue; + }; + // Skip agents belonging to our own instance (handled by sweep_system_agent_processes). + if agent_instance_id == our_instance_id { + continue; + } + foreign_agents + .entry(agent_instance_id) + .or_default() + .push(pid); + } + + // For each foreign instance, check if its desktop is still alive. + for (instance_id, agent_pids) in &foreign_agents { + if desktop_is_alive_for_instance(instance_id) { + continue; + } + eprintln!( + "buzz-desktop: reaping {} orphaned agent(s) from dead instance '{instance_id}'", + agent_pids.len() + ); + resolve_pgids_and_kill(agent_pids); + } +} + +#[cfg(all(unix, not(target_os = "macos")))] +pub(crate) fn reap_dead_instance_agents(our_instance_id: &str, skip_pids: &[u32]) { + let my_uid = unsafe { libc::getuid() }; + let my_pid = std::process::id() as i32; + let mut foreign_agents: HashMap> = HashMap::new(); + + let Ok(entries) = std::fs::read_dir("/proc") else { + return; + }; + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(name_str) = name.to_str() else { + continue; + }; + let Ok(pid) = name_str.parse::() else { + continue; + }; + if pid <= 0 || pid == my_pid { + continue; + } + let upid = pid as u32; + if skip_pids.contains(&upid) { + continue; + } + let Ok(meta) = entry.metadata() else { + continue; + }; + use std::os::unix::fs::MetadataExt; + if meta.uid() != my_uid { + continue; + } + if !process_belongs_to_us(upid) { + continue; + } + let Some(agent_instance_id) = extract_buzz_marker_value(upid) else { + continue; + }; + if agent_instance_id == our_instance_id { + continue; + } + foreign_agents + .entry(agent_instance_id) + .or_default() + .push(pid); + } + + for (instance_id, agent_pids) in &foreign_agents { + if desktop_is_alive_for_instance(instance_id) { + continue; + } + eprintln!( + "buzz-desktop: reaping {} orphaned agent(s) from dead instance '{instance_id}'", + agent_pids.len() + ); + resolve_pgids_and_kill(agent_pids); + } +} + +#[cfg(not(unix))] +pub(crate) fn reap_dead_instance_agents(_our_instance_id: &str, _skip_pids: &[u32]) {} diff --git a/desktop/src-tauri/src/managed_agents/runtime/lifecycle.rs b/desktop/src-tauri/src/managed_agents/runtime/lifecycle.rs new file mode 100644 index 0000000000..ec64433af0 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime/lifecycle.rs @@ -0,0 +1,105 @@ +use super::*; + +/// Kill stale agent processes from a previous session whose PID is still alive +/// but not tracked in the current `runtimes` map. Updates the record fields and +/// returns `true` if any records were modified. +pub fn kill_stale_tracked_processes( + records: &mut [ManagedAgentRecord], + runtimes: &HashMap, + instance_id: &str, +) -> bool { + use crate::managed_agents::BackendKind; + + let mut changed = false; + for record in records.iter_mut() { + if record.backend != BackendKind::Local { + continue; + } + let Some(pid) = record.runtime_pid else { + continue; + }; + if !runtimes.keys().any(|key| key.pubkey == record.pubkey) { + if process_belongs_to_us(pid) && process_has_buzz_marker(pid, instance_id) { + let _ = terminate_process(pid); + } + record.runtime_pid = None; + record.last_stopped_at = Some(crate::util::now_iso()); + record.updated_at = crate::util::now_iso(); + changed = true; + } + } + changed +} + +pub fn sync_managed_agent_processes( + records: &mut [ManagedAgentRecord], + runtimes: &mut HashMap, + _instance_id: &str, +) -> (bool, Vec) { + let mut changed = false; + let mut exited = Vec::new(); + + for (key, runtime) in runtimes.iter_mut() { + let status = match runtime.child.try_wait() { + Ok(status) => status, + Err(error) => { + if let Some(record) = records + .iter_mut() + .find(|record| record.pubkey == key.pubkey) + { + record.updated_at = now_iso(); + record.last_error = Some(format!("failed to inspect process state: {error}")); + record.last_error_code = None; + } + changed = true; + exited.push(key.clone()); + continue; + } + }; + + let Some(status) = status else { + continue; + }; + + if let Some(record) = records + .iter_mut() + .find(|record| record.pubkey == key.pubkey) + { + record.updated_at = now_iso(); + record.last_stopped_at = Some(now_iso()); + record.last_exit_code = status.code(); + let log_err = if status.success() { + None + } else { + Some( + super::super::meaningful_agent_error_from_log(&runtime.log_path) + .unwrap_or_else(|| super::super::storage::AgentLogError { + message: format!("harness exited with status {status}"), + code: None, + }), + ) + }; + record.last_error = log_err.as_ref().map(|e| e.message.clone()); + record.last_error_code = log_err.as_ref().and_then(|e| e.code); + } + + changed = true; + exited.push(key.clone()); + } + + let exited_pubkeys: Vec = exited.iter().map(|key| key.pubkey.clone()).collect(); + for key in exited { + runtimes.remove(&key); + } + + // `runtime_pid` is legacy bookkeeping. Pair runtimes and receipts are the + // authoritative lifecycle source; migration cleanup is handled separately. + for record in records.iter_mut() { + if record.runtime_pid.take().is_some() { + record.updated_at = now_iso(); + changed = true; + } + } + + (changed, exited_pubkeys) +} diff --git a/desktop/src-tauri/src/managed_agents/runtime/metadata.rs b/desktop/src-tauri/src/managed_agents/runtime/metadata.rs new file mode 100644 index 0000000000..56d2e68353 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime/metadata.rs @@ -0,0 +1,55 @@ +/// Returns the (key, value) env var pairs that should be forwarded to the +/// agent process for model and provider selection. +/// +/// Model injection is unconditional — even agents that support ACP model +/// switching need the initial bootstrap value. Provider injection is skipped +/// when `provider_locked` is true (e.g. Claude runtimes that only work with +/// Anthropic). +pub(crate) fn runtime_metadata_env_vars<'a>( + model_env_var: Option<&'a str>, + provider_env_var: Option<&'a str>, + provider_locked: bool, + effective_model: Option<&'a str>, + effective_provider: Option<&'a str>, +) -> Vec<(&'a str, &'a str)> { + let mut vars = Vec::new(); + if let (Some(env_key), Some(model)) = (model_env_var, effective_model) { + vars.push((env_key, model)); + } + if !provider_locked { + if let (Some(env_key), Some(provider)) = (provider_env_var, effective_provider) { + vars.push((env_key, provider)); + } + } + vars +} + +/// Resolve the effective (prompt, model, provider) triple for a persona-linked agent. +/// +/// Given a persona_id, finds the persona in the list and returns its system_prompt, +/// model, and provider as the authoritative values. When the persona leaves `model` +/// or `provider` blank (None or whitespace-only), falls back to the record's own +/// field using the same precedence rule as `persona_snapshot_with_agent_config_fallback` +/// so the display surface matches spawn behavior. Falls back to the record's own +/// prompt/model/provider when no persona is linked or found. +/// +/// Used by `agent_config.rs` to inject persona defaults into the config surface +/// before running the reader, so BuzzExplicit-tagged fields can be re-tagged to +/// PersonaDefault for fields the record did not independently set. +pub(crate) fn resolve_effective_prompt_model_provider( + persona_id: Option<&str>, + personas: &[crate::managed_agents::types::AgentDefinition], + record_prompt: Option, + record_model: Option, + record_provider: Option, +) -> (Option, Option, Option) { + let fallback = crate::managed_agents::persona_events::persona_field_with_record_fallback; + match persona_id.and_then(|pid| personas.iter().find(|p| p.id == pid)) { + Some(p) => ( + Some(p.system_prompt.clone()), + fallback(p.model.as_deref(), record_model.as_deref()), // fallback: record.model + fallback(p.provider.as_deref(), record_provider.as_deref()), // fallback: record.provider + ), + None => (record_prompt, record_model, record_provider), + } +} diff --git a/desktop/src-tauri/src/managed_agents/runtime/orphan_sweep.rs b/desktop/src-tauri/src/managed_agents/runtime/orphan_sweep.rs new file mode 100644 index 0000000000..899f47b2c6 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime/orphan_sweep.rs @@ -0,0 +1,371 @@ +use super::*; + +/// Kill orphaned agent processes using PID file receipts. Reads all files from +/// `agent-pids/`, verifies each PID still belongs to a known agent binary, +/// then resolves each candidate's actual PGID and signals the process group. +/// Deletes the PID file after killing. +/// +/// `skip_pids` are PIDs already handled by the tracked-agent path. +#[cfg(unix)] +pub(crate) fn sweep_orphaned_agent_processes(app: &AppHandle, skip_pids: &[u32]) { + let legacy_entries = super::super::read_all_agent_pid_files(app); + let instance_id = current_instance_id(app); + let receipt_entries: Vec<_> = super::super::read_all_agent_runtime_receipts(app) + .into_iter() + .filter_map(|(path, receipt)| { + if valid_agent_runtime_receipt(&path, &receipt, &instance_id) { + Some((path, receipt)) + } else { + super::super::remove_agent_runtime_receipt_path(&path); + None + } + }) + .collect(); + // Collect live orphans AND dead-leader groups into a single kill batch. + // Dead leaders: PGID may have been recycled, but the window is narrow + // (PID files are from this session) and the cost of missing surviving + // group members outweighs the recycling risk. + let targets: Vec = legacy_entries + .iter() + .map(|(_, pid)| *pid) + .chain(receipt_entries.iter().map(|(_, receipt)| receipt.pid)) + .filter(|pid| { + if skip_pids.contains(pid) { + return false; + } + (process_is_running(*pid) && process_belongs_to_us(*pid)) || !process_is_running(*pid) + }) + .map(|pid| pid as i32) + .collect(); + + if !targets.is_empty() { + resolve_pgids_and_kill(&targets); + } + + // Clean up PID files for processes we just killed or that are already gone. + for (pubkey, pid) in &legacy_entries { + if skip_pids.contains(pid) { + continue; + } + if !process_is_running(*pid) || !process_belongs_to_us(*pid) { + super::super::remove_agent_pid_file(app, pubkey); + } + } + for (_, receipt) in &receipt_entries { + if skip_pids.contains(&receipt.pid) { + continue; + } + if !process_is_running(receipt.pid) || !process_belongs_to_us(receipt.pid) { + super::super::remove_agent_runtime_receipt(app, &receipt.key); + } + } +} + +#[cfg(not(unix))] +pub(crate) fn sweep_orphaned_agent_processes(app: &AppHandle, _skip_pids: &[u32]) { + let _ = app; +} + +// ── macOS process-info FFI (shared by all sweep/reap functions) ────────── +// +// `proc_listallpids` lives in `sweep.rs` (which owns `collect_all_pids`). +// All callers in this file reach it through `sweep::collect_all_pids()`. +// `proc_pidinfo` and `BSDInfo` are declared here as `pub(super)` so that +// `sweep.rs` can call `super::proc_pidinfo` / use `super::BSDInfo` without +// redefining the struct layout in two places. + +#[cfg(target_os = "macos")] +extern "C" { + pub(super) fn proc_pidinfo( + pid: libc::c_int, + flavor: libc::c_int, + arg: u64, + buffer: *mut libc::c_void, + buffersize: libc::c_int, + ) -> libc::c_int; +} + +/// Subset of `struct proc_bsdinfo` from ``. Layout verified +/// against the macOS SDK — total size 136 bytes. +#[cfg(target_os = "macos")] +#[repr(C)] +pub(super) struct BSDInfo { + _flags_status_xstatus: [u8; 12], // pbi_flags + pbi_status + pbi_xstatus + pub(super) pbi_pid: u32, // offset 12 + pub(super) pbi_ppid: u32, // offset 16 + pub(super) pbi_uid: u32, // offset 20 + _rest: [u8; 112], +} + +#[cfg(target_os = "macos")] +const _: () = assert!(std::mem::size_of::() == 136); + +#[cfg(target_os = "macos")] +pub(super) const PROC_PIDTBSDINFO: libc::c_int = 3; + +/// Enumerate all processes on the system owned by the current user and kill any +/// agent binary stamped with *this* instance's `BUZZ_MANAGED_AGENT` marker +/// (`instance_id`) that isn't in `skip_pids`. This catches orphans that escaped +/// PID-file-based cleanup (e.g. agent workers spawned with their own process +/// group whose parent harness already exited and had its PID file removed), +/// while leaving another live Buzz instance's agents untouched. +#[cfg(target_os = "macos")] +pub(crate) fn sweep_system_agent_processes(instance_id: &str, skip_pids: &[u32]) { + let my_uid = unsafe { libc::getuid() }; + let pids = sweep::collect_all_pids(); + if pids.is_empty() { + return; + } + let my_pid = std::process::id() as i32; + let mut orphans: Vec = Vec::new(); + + for &pid in &pids { + if pid <= 0 { + continue; + } + let upid = pid as u32; + if skip_pids.contains(&upid) || pid == my_pid { + continue; + } + // Check binary name first (cheap proc_name call) before UID lookup. + if !process_belongs_to_us(upid) { + continue; + } + // Verify UID and PPID via proc_pidinfo. + let mut info = std::mem::MaybeUninit::::zeroed(); + let ret = unsafe { + proc_pidinfo( + pid, + PROC_PIDTBSDINFO, + 0, + info.as_mut_ptr() as *mut libc::c_void, + std::mem::size_of::() as libc::c_int, + ) + }; + if ret <= 0 { + continue; + } + let info = unsafe { info.assume_init() }; + if info.pbi_uid != my_uid { + continue; + } + if !process_has_buzz_marker(upid, instance_id) { + continue; + } + // Live descendants of a tracked harness are exempt — see sweep::is_live_descendant_*. + if sweep::is_live_descendant_macos(upid, info.pbi_ppid, skip_pids) { + continue; + } + orphans.push(pid); + } + + if !orphans.is_empty() { + eprintln!( + "buzz-desktop: system sweep found {} orphaned agent process(es), cleaning up", + orphans.len() + ); + resolve_pgids_and_kill(&orphans); + } +} + +#[cfg(all(unix, not(target_os = "macos")))] +pub(crate) fn sweep_system_agent_processes(instance_id: &str, skip_pids: &[u32]) { + let my_uid = unsafe { libc::getuid() }; + let mut orphans: Vec = Vec::new(); + let my_pid = std::process::id() as i32; + + let Ok(entries) = std::fs::read_dir("/proc") else { + return; + }; + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(name_str) = name.to_str() else { + continue; + }; + let Ok(pid) = name_str.parse::() else { + continue; + }; + if pid <= 0 || pid == my_pid { + continue; + } + let upid = pid as u32; + if skip_pids.contains(&upid) { + continue; + } + // Check ownership via /proc/ metadata. + let Ok(meta) = entry.metadata() else { + continue; + }; + use std::os::unix::fs::MetadataExt; + if meta.uid() != my_uid { + continue; + } + if !process_belongs_to_us(upid) || !process_has_buzz_marker(upid, instance_id) { + continue; + } + // Live descendants of a tracked harness are exempt — see sweep::is_live_descendant_*. + if sweep::is_live_descendant_linux(upid, skip_pids) { + continue; + } + orphans.push(pid); + } + + if !orphans.is_empty() { + eprintln!( + "buzz-desktop: system sweep found {} orphaned agent process(es), cleaning up", + orphans.len() + ); + resolve_pgids_and_kill(&orphans); + } +} + +#[cfg(not(unix))] +pub(crate) fn sweep_system_agent_processes(_instance_id: &str, _skip_pids: &[u32]) {} + +/// Periodic-sweep variant with two-tick grace: only reaps same-instance orphans +/// that were also seen orphaned on the previous tick. This prevents killing a +/// legitimately-starting agent that spawned between the skip-list snapshot and +/// the process scan. Returns the current orphan set for use as `prev_orphans` +/// on the next tick. +#[cfg(unix)] +pub(crate) fn sweep_system_agent_processes_with_grace( + instance_id: &str, + skip_pids: &[u32], + prev_orphans: &std::collections::HashSet, +) -> std::collections::HashSet { + let current = collect_same_instance_orphans(instance_id, skip_pids); + // Only reap PIDs seen orphaned on two consecutive ticks. + let confirmed: Vec = current + .iter() + .filter(|pid| prev_orphans.contains(pid)) + .map(|&pid| pid as i32) + .collect(); + if !confirmed.is_empty() { + eprintln!( + "buzz-desktop: periodic sweep confirmed {} orphaned agent process(es), cleaning up", + confirmed.len() + ); + resolve_pgids_and_kill(&confirmed); + } + current +} + +#[cfg(not(unix))] +pub(crate) fn sweep_system_agent_processes_with_grace( + _instance_id: &str, + _skip_pids: &[u32], + _prev_orphans: &std::collections::HashSet, +) -> std::collections::HashSet { + std::collections::HashSet::new() +} + +/// Collect PIDs of same-instance agent processes that appear orphaned (not in +/// `skip_pids`). Returns the set for use in two-tick grace logic — does NOT +/// kill anything. +#[cfg(target_os = "macos")] +pub(crate) fn collect_same_instance_orphans( + instance_id: &str, + skip_pids: &[u32], +) -> std::collections::HashSet { + let my_uid = unsafe { libc::getuid() }; + let my_pid = std::process::id() as i32; + let mut orphans = std::collections::HashSet::new(); + + let pids = sweep::collect_all_pids(); + if pids.is_empty() { + return orphans; + } + + for &pid in &pids { + if pid <= 0 || pid == my_pid { + continue; + } + let upid = pid as u32; + if skip_pids.contains(&upid) { + continue; + } + if !process_belongs_to_us(upid) { + continue; + } + let mut info = std::mem::MaybeUninit::::zeroed(); + let ret = unsafe { + proc_pidinfo( + pid, + PROC_PIDTBSDINFO, + 0, + info.as_mut_ptr() as *mut libc::c_void, + std::mem::size_of::() as libc::c_int, + ) + }; + if ret <= 0 { + continue; + } + let info = unsafe { info.assume_init() }; + if info.pbi_uid != my_uid { + continue; + } + if !process_has_buzz_marker(upid, instance_id) { + continue; + } + // Live descendants of a tracked harness are exempt — see sweep::is_live_descendant_*. + if sweep::is_live_descendant_macos(upid, info.pbi_ppid, skip_pids) { + continue; + } + orphans.insert(upid); + } + orphans +} + +#[cfg(all(unix, not(target_os = "macos")))] +pub(crate) fn collect_same_instance_orphans( + instance_id: &str, + skip_pids: &[u32], +) -> std::collections::HashSet { + let my_uid = unsafe { libc::getuid() }; + let my_pid = std::process::id() as i32; + let mut orphans = std::collections::HashSet::new(); + + let Ok(entries) = std::fs::read_dir("/proc") else { + return orphans; + }; + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(name_str) = name.to_str() else { + continue; + }; + let Ok(pid) = name_str.parse::() else { + continue; + }; + if pid <= 0 || pid == my_pid { + continue; + } + let upid = pid as u32; + if skip_pids.contains(&upid) { + continue; + } + let Ok(meta) = entry.metadata() else { + continue; + }; + use std::os::unix::fs::MetadataExt; + if meta.uid() != my_uid { + continue; + } + if !process_belongs_to_us(upid) || !process_has_buzz_marker(upid, instance_id) { + continue; + } + // Live descendants of a tracked harness are exempt — see sweep::is_live_descendant_*. + if sweep::is_live_descendant_linux(upid, skip_pids) { + continue; + } + orphans.insert(upid); + } + orphans +} + +#[cfg(not(unix))] +pub(crate) fn collect_same_instance_orphans( + _instance_id: &str, + _skip_pids: &[u32], +) -> std::collections::HashSet { + std::collections::HashSet::new() +} diff --git a/desktop/src-tauri/src/managed_agents/runtime/process.rs b/desktop/src-tauri/src/managed_agents/runtime/process.rs new file mode 100644 index 0000000000..335080a3cb --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime/process.rs @@ -0,0 +1,447 @@ +use super::*; + +/// Binary name fragments for all known agent/harness processes that Buzz +/// may spawn. Used by `process_belongs_to_us()` and the orphan sweep to +/// identify processes we should clean up. Both hyphenated and underscored +/// variants are listed because macOS `proc_name()` and Linux `/proc/comm` +/// may report either form depending on how the binary was built. +pub(crate) const KNOWN_AGENT_BINARIES: &[&str] = &[ + "buzz-acp", + "buzz_acp", + "buzz-agent", + "buzz_agent", + "claude-agent-acp", + "claude_agent_acp", + "claude-code-acp", + "claude_code_acp", + "codex-acp", + "codex_acp", + "goose", + // buzz-dev-mcp's multicall personalities (rg, tree, buzz, + // git-credential-nostr, git-sign-nostr) are short-lived per-tool-call + // invocations — not listed here. + "buzz-dev-mcp", + "buzz_dev_mcp", +]; + +/// Script interpreters that may host managed agent wrappers (e.g. npm shims). +/// A process whose name matches here is NOT immediately claimed — it must also +/// carry `BUZZ_MANAGED_AGENT` in its environment (checked by the caller via +/// `process_has_buzz_marker()`). This avoids sweeping unrelated node processes. +pub(crate) const KNOWN_SCRIPT_INTERPRETERS: &[&str] = &["node"]; + +/// Check if a process name matches any of our known agent binaries. +/// Uses exact match or prefix-with-separator to avoid false positives +/// (e.g. `"goose"` must not match `"mongoose"`). +pub(super) fn name_matches_known_binary(name: &str) -> bool { + KNOWN_AGENT_BINARIES.iter().any(|&binary| { + name == binary || { + name.starts_with(binary) && { + let rest = &name[binary.len()..]; + rest.starts_with('-') || rest.starts_with('_') || rest.starts_with('.') + } + } + }) +} + +/// Check if a process name is a known script interpreter that may be hosting +/// a managed agent wrapper (e.g. `node` running an npm shim for `codex-acp`). +/// Callers must additionally verify `BUZZ_MANAGED_AGENT` ownership. +pub(super) fn name_matches_interpreter(name: &str) -> bool { + KNOWN_SCRIPT_INTERPRETERS.contains(&name) +} + +#[cfg(unix)] +pub(crate) fn process_is_running(pid: u32) -> bool { + // Use libc::kill with signal 0 instead of forking a subprocess. + // Returns true only if the process exists AND we can signal it. + // Returns false for non-existent PIDs (ESRCH) and PIDs owned by + // other users (EPERM) — callers should not interact with those. + unsafe { libc::kill(pid as i32, 0) == 0 } +} + +#[cfg(not(unix))] +pub(crate) fn process_is_running(_pid: u32) -> bool { + false +} + +/// Check if a PID belongs to a known agent process we spawned. +/// Returns false for recycled PIDs that now belong to other processes. +#[cfg(target_os = "macos")] +pub(crate) fn process_belongs_to_us(pid: u32) -> bool { + // Use proc_name() from libproc to get the process name without spawning + // a subprocess. + extern "C" { + fn proc_name(pid: libc::c_int, buffer: *mut libc::c_void, buffersize: u32) -> libc::c_int; + } + let mut buf = [0u8; 1024]; + let len = unsafe { + proc_name( + pid as i32, + buf.as_mut_ptr() as *mut libc::c_void, + buf.len() as u32, + ) + }; + if len <= 0 { + return false; + } + let name = String::from_utf8_lossy(&buf[..len as usize]); + // Fall through for script interpreters (e.g. `node` hosting an npm shim): + // the caller's `process_has_buzz_marker()` check decides true ownership. + name_matches_known_binary(&name) || name_matches_interpreter(&name) +} + +#[cfg(all(unix, not(target_os = "macos")))] +pub(crate) fn process_belongs_to_us(pid: u32) -> bool { + // First try /proc//comm. Note: comm is truncated to 15 bytes on Linux, + // so binaries with names longer than 15 chars (e.g. "claude-agent-acp") + // will never match here. + if let Ok(name) = std::fs::read_to_string(format!("/proc/{pid}/comm")) { + if name_matches_known_binary(name.trim()) { + return true; + } + // Interpreter check: `node` is 4 bytes, never truncated. + if name_matches_interpreter(name.trim()) { + return true; + } + } + + // Fallback: read /proc//exe which is a symlink to the full binary path. + // This is not subject to the 15-byte truncation limit. + if let Ok(exe_path) = std::fs::read_link(format!("/proc/{pid}/exe")) { + if let Some(basename) = exe_path.file_name().and_then(|n| n.to_str()) { + // Fall through for script interpreters — caller checks the marker. + return name_matches_known_binary(basename) || name_matches_interpreter(basename); + } + } + + false +} + +#[cfg(not(unix))] +pub(crate) fn process_belongs_to_us(_pid: u32) -> bool { + false +} + +/// The value stamped into the `BUZZ_MANAGED_AGENT` env var of every agent we +/// spawn, identifying *which* desktop instance owns it. We use the app's bundle +/// identifier (`xyz.block.buzz.app` for release, `xyz.block.buzz.app.dev` +/// for `just dev`) because it is stable across restarts — a relaunched dev +/// instance still recognizes its own previously-spawned agents as reclaimable, +/// while never matching another instance's (e.g. a dev build never reaps a DMG +/// build's agents, and vice versa). This is what lets two Buzzs coexist on +/// one machine without one's cleanup nuking the other's agents. +pub(crate) fn current_instance_id(app: &AppHandle) -> String { + app.config().identifier.clone() +} + +/// Build the full `BUZZ_MANAGED_AGENT=` env entry we match +/// against when scanning processes. Kept here so the spawn stamp and the sweep +/// matcher can never drift apart. +pub(super) fn buzz_marker_entry(instance_id: &str) -> Vec { + format!("BUZZ_MANAGED_AGENT={instance_id}").into_bytes() +} + +/// Check if a running process is one of *our* managed agents: it must carry +/// `BUZZ_MANAGED_AGENT=` in its environment, where `instance_id` +/// is this desktop instance's id. A process stamped with a *different* instance +/// id belongs to another live Buzz app and must never be reaped here. +#[cfg(target_os = "macos")] +pub(crate) fn process_has_buzz_marker(pid: u32, instance_id: &str) -> bool { + let marker = buzz_marker_entry(instance_id); + let Some(buf) = sweep::procargs2_buffer(pid) else { + return false; + }; + + // Buffer layout: [i32 argc][exec_path\0][null padding][argv\0...][env\0...] + if buf.len() < std::mem::size_of::() { + return false; + } + let mut n_args: libc::c_int = 0; + unsafe { + std::ptr::copy_nonoverlapping( + buf.as_ptr(), + &mut n_args as *mut libc::c_int as *mut u8, + std::mem::size_of::(), + ); + } + let mut pos = std::mem::size_of::(); + + // Skip exec path (scan to first null). + while pos < buf.len() && buf[pos] != 0 { + pos += 1; + } + // Skip null padding between exec path and argv[0]. + while pos < buf.len() && buf[pos] == 0 { + pos += 1; + } + // Skip argc argument strings. + let mut args_remaining = n_args; + while args_remaining > 0 && pos < buf.len() { + while pos < buf.len() && buf[pos] != 0 { + pos += 1; + } + while pos < buf.len() && buf[pos] == 0 { + pos += 1; + } + args_remaining -= 1; + } + // Remaining bytes are null-delimited environment strings. + buf[pos..].split(|&b| b == 0).any(|entry| entry == marker) +} + +#[cfg(all(unix, not(target_os = "macos")))] +pub(crate) fn process_has_buzz_marker(pid: u32, instance_id: &str) -> bool { + let marker = buzz_marker_entry(instance_id); + let Ok(data) = std::fs::read(format!("/proc/{pid}/environ")) else { + return false; + }; + data.split(|&b| b == 0).any(|entry| entry == marker) +} + +#[cfg(not(unix))] +pub(crate) fn process_has_buzz_marker(_pid: u32, _instance_id: &str) -> bool { + false +} + +#[cfg(unix)] +fn signal_process_group_or_leader(pid: u32, signal: i32, action: &str) -> Result<(), String> { + let pgid = -(pid as i32); + + if unsafe { libc::kill(pgid, signal) } == 0 { + return Ok(()); + } + + let group_err = std::io::Error::last_os_error(); + if !process_is_running(pid) { + return Ok(()); + } + + // Some local agent trees can no longer be signalled as a process group + // (for example if the leader changed groups, or macOS returns EPERM for one + // descendant). Fall back to the leader PID so stop/delete can still recover. + if matches!( + group_err.raw_os_error(), + Some(libc::EPERM) | Some(libc::ESRCH) + ) { + if unsafe { libc::kill(pid as i32, signal) } == 0 { + return Ok(()); + } + + let leader_err = std::io::Error::last_os_error(); + if leader_err.raw_os_error() == Some(libc::ESRCH) || !process_is_running(pid) { + return Ok(()); + } + + return Err(format!("failed to {action} process {pid}: {leader_err}")); + } + + Err(format!( + "failed to {action} process group {pid}: {group_err}" + )) +} + +#[cfg(unix)] +pub(crate) fn terminate_process(pid: u32) -> Result<(), String> { + // Try graceful shutdown first (SIGTERM to the group). + signal_process_group_or_leader(pid, libc::SIGTERM, "terminate")?; + + // Wait up to 1s for graceful exit. + for _ in 0..10 { + if !process_is_running(pid) { + return Ok(()); + } + std::thread::sleep(std::time::Duration::from_millis(100)); + } + + // Escalate to SIGKILL on the entire group. + signal_process_group_or_leader(pid, libc::SIGKILL, "kill")?; + + Ok(()) +} + +#[cfg(windows)] +pub(crate) fn terminate_process(pid: u32) -> Result<(), String> { + // No job handle is available on this path (e.g. after an app restart, when + // we only recovered the PID from the record), so fall back to taskkill on + // the whole tree. + super::super::process_lifecycle::taskkill_tree(pid) +} + +#[cfg(not(any(unix, windows)))] +pub(crate) fn terminate_process(_pid: u32) -> Result<(), String> { + Err("managed agent shutdown after app restart is not supported on this platform".to_string()) +} + +/// Send SIGTERM to all given PIDs (as process groups), wait, then SIGKILL +/// any survivors. Uses `-pid` to kill the entire process group — if an +/// orphaned agent called `setsid()`, it IS the group leader, so this +/// reaches its children too. +#[cfg(unix)] +fn sigterm_then_sigkill(pids: &[i32]) { + // Send SIGTERM to each process group. Track whether any signal was + // actually delivered so we can skip the sleep when everything is + // already gone. + let mut any_signalled = false; + for &pid in pids { + if unsafe { libc::kill(-pid, libc::SIGTERM) } == 0 { + any_signalled = true; + } + } + + if !any_signalled { + return; + } + + std::thread::sleep(std::time::Duration::from_millis(200)); + + for &pid in pids { + // Check if the group has any living members, not just the leader. + // kill(-pid, 0) returns 0 if ANY member of the group is signalable. + if unsafe { libc::kill(-pid, 0) } == 0 { + unsafe { + libc::kill(-pid, libc::SIGKILL); + } + } + } +} + +/// Resolve orphan candidate PIDs to their actual process group IDs, dedupe, +/// and signal the groups. An orphaned grandchild (e.g. `goose` or `buzz-dev-mcp`) +/// whose harness has exited retains the harness's PGID — signaling that PGID +/// kills the entire orphaned subtree. Falls back to the candidate PID itself +/// when PGID resolution fails (process may have exited between detection and +/// kill). +#[cfg(target_os = "macos")] +pub(super) fn resolve_pgids_and_kill(candidate_pids: &[i32]) { + let candidate_set: std::collections::HashSet = candidate_pids.iter().copied().collect(); + let mut pgids = std::collections::HashSet::new(); + for &pid in candidate_pids { + let pgid = unsafe { libc::getpgid(pid) }; + if pgid > 0 { + pgids.insert(pgid); + } else { + // Process may have exited; try signaling it directly as a group. + pgids.insert(pid); + } + } + // PID-recycling guard: if a resolved PGID is alive but isn't one of our + // orphan candidates, the old harness PID was recycled by a new process + // that called setsid() — skip it to avoid killing an unrelated group. + let candidate_groups = pgids.len(); + pgids.retain(|&pgid| { + if candidate_set.contains(&pgid) { + return true; + } + let alive = unsafe { libc::kill(pgid, 0) } == 0; + !alive + }); + if pgids.is_empty() && candidate_groups > 0 { + eprintln!( + "buzz-desktop: orphan sweep: skipped all {candidate_groups} candidate group(s) (live foreign group leader or candidate already exited); nothing signalled" + ); + } + let unique: Vec = pgids.into_iter().collect(); + sigterm_then_sigkill(&unique); +} + +/// Resolve orphan candidate PIDs to their actual process group IDs, dedupe, +/// and signal the groups. Linux variant reads PGID from /proc//stat. +#[cfg(all(unix, not(target_os = "macos")))] +pub(super) fn resolve_pgids_and_kill(candidate_pids: &[i32]) { + let candidate_set: std::collections::HashSet = candidate_pids.iter().copied().collect(); + let mut pgids = std::collections::HashSet::new(); + for &pid in candidate_pids { + if let Some((_, pgid)) = sweep::proc_stat_ppid_pgid_linux(pid as u32) { + pgids.insert(pgid as i32); + } else { + // Process may have exited; try signaling it directly as a group. + pgids.insert(pid); + } + } + // PID-recycling guard: if a resolved PGID is alive but isn't one of our + // orphan candidates, the old harness PID was recycled by a new process + // that called setsid() — skip it to avoid killing an unrelated group. + let candidate_groups = pgids.len(); + pgids.retain(|&pgid| { + if candidate_set.contains(&pgid) { + return true; + } + let alive = unsafe { libc::kill(pgid, 0) } == 0; + !alive + }); + if pgids.is_empty() && candidate_groups > 0 { + eprintln!( + "buzz-desktop: orphan sweep: skipped all {candidate_groups} candidate group(s) (live foreign group leader or candidate already exited); nothing signalled" + ); + } + let unique: Vec = pgids.into_iter().collect(); + sigterm_then_sigkill(&unique); +} + +pub(crate) fn valid_agent_runtime_receipt( + path: &std::path::Path, + receipt: &super::super::ManagedAgentRuntimeReceipt, + instance_id: &str, +) -> bool { + let Ok(canonical) = + ManagedAgentRuntimeKey::new(receipt.key.pubkey.clone(), &receipt.key.relay_url) + else { + return false; + }; + canonical == receipt.key + && path.file_name().and_then(|name| name.to_str()) + == Some(&format!("{}.json", receipt.key.runtime_id())) + && receipt.desktop_instance_id == instance_id + && process_is_running(receipt.pid) + && process_belongs_to_us(receipt.pid) + && process_has_buzz_marker(receipt.pid, &receipt.desktop_instance_id) +} + +pub(super) fn terminate_runtime_receipt_with( + path: &std::path::Path, + receipt: &super::super::ManagedAgentRuntimeReceipt, + terminate: impl FnOnce(u32) -> Result<(), String>, + mut is_running: impl FnMut(u32) -> bool, + remove: impl FnOnce(&std::path::Path), +) -> Result<(), String> { + terminate(receipt.pid)?; + for _ in 0..20 { + if !is_running(receipt.pid) { + remove(path); + return Ok(()); + } + std::thread::sleep(std::time::Duration::from_millis(100)); + } + Err(format!( + "prior runtime {} for pair {} on {} did not exit", + receipt.pid, receipt.key.pubkey, receipt.key.relay_url + )) +} + +/// Replace a valid prior-session process before registering a new child for +/// the same pair. The caller must hold the runtime transition lock so receipt +/// inspection, termination, spawn, and registration cannot race shutdown or +/// another start. +pub(crate) fn terminate_untracked_pair_runtime( + app: &AppHandle, + key: &ManagedAgentRuntimeKey, +) -> Result<(), String> { + let instance_id = current_instance_id(app); + let Some((path, receipt)) = super::super::read_all_agent_runtime_receipts(app) + .into_iter() + .find(|(path, receipt)| { + receipt.key == *key && valid_agent_runtime_receipt(path, receipt, &instance_id) + }) + else { + return Ok(()); + }; + + terminate_runtime_receipt_with( + &path, + &receipt, + terminate_process, + process_is_running, + super::super::remove_agent_runtime_receipt_path, + ) +} From c9a73726be6e6edd4e5d0c9c44abdf07d550ecd0 Mon Sep 17 00:00:00 2001 From: Wes Date: Sun, 26 Jul 2026 09:16:36 -0700 Subject: [PATCH 039/393] fix(mobile): validate invite relay destinations (#2986) ## Summary - require invite relay destinations to be secure public origins in production - reject non-public and ambiguous IP literals before confirmation and again before the claim request - disable redirects for invite claims so a validated relay cannot redirect the request elsewhere - preserve explicit debug-only localhost support ## Validation - pre-commit `dart format` and `flutter analyze` - pre-push full mobile test suite: 666 passed, 1 skipped - independent source reviews from Princess Donut and Mongo found no remaining blockers ## Scope and residual risk This fixes the mobile invite trust boundary without changing NIP-98 or NIP-42. Hostnames are not resolved and pinned by this patch, so DNS rebinding remains a networking-layer residual risk requiring connect-time resolution/pinning. Signed-off-by: Wes Co-authored-by: Carl --- .../invites/invite_join_provider.dart | 33 ++-- mobile/lib/shared/deeplink/deep_link.dart | 22 ++- mobile/lib/shared/relay/relay_validation.dart | 164 ++++++++++++++++++ .../invites/invite_join_provider_test.dart | 37 +++- .../test/shared/deeplink/deep_link_test.dart | 29 ++++ .../shared/relay/relay_validation_test.dart | 161 +++++++++++++++++ 6 files changed, 429 insertions(+), 17 deletions(-) create mode 100644 mobile/lib/shared/relay/relay_validation.dart create mode 100644 mobile/test/shared/relay/relay_validation_test.dart diff --git a/mobile/lib/features/invites/invite_join_provider.dart b/mobile/lib/features/invites/invite_join_provider.dart index 0cd58d47a7..0e5547aca5 100644 --- a/mobile/lib/features/invites/invite_join_provider.dart +++ b/mobile/lib/features/invites/invite_join_provider.dart @@ -7,6 +7,7 @@ import 'package:nostr/nostr.dart' as nostr; import '../../shared/auth/auth.dart'; import '../../shared/deeplink/deep_link.dart'; import '../../shared/relay/relay_session.dart'; +import '../../shared/relay/relay_validation.dart'; final inviteJoinHttpClientProvider = Provider((ref) { final client = http.Client(); @@ -68,6 +69,7 @@ class InviteJoinNotifier extends Notifier { InviteJoinState build() => const InviteJoinState(); Future prepare(InviteDeepLink invite) async { + validateInviteRelayUri(Uri.parse(invite.relayUrl)); final communities = await ref.read(communityListProvider.future); final existing = _existingCommunity(communities, invite.relayUrl); if (existing != null) { @@ -121,22 +123,25 @@ class InviteJoinNotifier extends Notifier { if (invite.policyReceipt != null) 'policy_receipt': invite.policyReceipt, }); + final relayUri = Uri.parse(invite.relayUrl); + validateInviteRelayUri(relayUri); final url = _claimUrlFromRelay(invite.relayUrl); - final response = await ref + final request = http.Request('POST', Uri.parse(url)) + ..followRedirects = false + ..headers.addAll({ + 'Authorization': buildNip98AuthHeader( + method: 'POST', + url: url, + bodyBytes: utf8.encode(body), + nsec: keys.nsec, + ), + 'Content-Type': 'application/json', + }) + ..body = body; + final streamedResponse = await ref .read(inviteJoinHttpClientProvider) - .post( - Uri.parse(url), - headers: { - 'Authorization': buildNip98AuthHeader( - method: 'POST', - url: url, - bodyBytes: utf8.encode(body), - nsec: keys.nsec, - ), - 'Content-Type': 'application/json', - }, - body: body, - ); + .send(request); + final response = await http.Response.fromStream(streamedResponse); final decoded = jsonDecode(response.body.isEmpty ? '{}' : response.body); if (response.statusCode < 200 || response.statusCode >= 300) { final message = decoded is Map && decoded['error'] is String diff --git a/mobile/lib/shared/deeplink/deep_link.dart b/mobile/lib/shared/deeplink/deep_link.dart index b2e57b8b00..a208048dbd 100644 --- a/mobile/lib/shared/deeplink/deep_link.dart +++ b/mobile/lib/shared/deeplink/deep_link.dart @@ -7,6 +7,8 @@ /// half-formed target. library; +import '../relay/relay_validation.dart'; + /// A parsed deep link supported by the app. sealed class BuzzDeepLink { const BuzzDeepLink(); @@ -107,8 +109,9 @@ MessageDeepLink? parseMessageDeepLink(Uri uri) { /// /// Accepted forms: /// - `https:///invite/` -> `wss://` + code -/// - `http:///invite/` -> `ws://` + code -/// - `buzz://join?relay=&code=` -> relay + code +/// - `http://localhost/invite/` -> `ws://localhost` + code in debug builds +/// - `buzz://join?relay=&code=` -> relay + code +/// - `buzz://join?relay=&code=` -> local relay in debug /// /// Rejects credentials, fragments, missing params, nested relay credentials, and /// non-invite paths so scanners do not accidentally treat arbitrary URLs as @@ -131,6 +134,11 @@ InviteDeepLink? parseInviteDeepLink(Uri uri) { relayUri.hasFragment) { return null; } + try { + validateInviteRelayUri(relayUri); + } on FormatException { + return null; + } final normalizedRelay = Uri( scheme: relayUri.scheme, host: relayUri.host, @@ -155,6 +163,16 @@ InviteDeepLink? parseInviteDeepLink(Uri uri) { return null; } final relayScheme = uri.scheme == 'https' ? 'wss' : 'ws'; + final relayUri = Uri( + scheme: relayScheme, + host: uri.host, + port: uri.hasPort ? uri.port : null, + ); + try { + validateInviteRelayUri(relayUri); + } on FormatException { + return null; + } final relay = Uri( scheme: relayScheme, host: uri.host, diff --git a/mobile/lib/shared/relay/relay_validation.dart b/mobile/lib/shared/relay/relay_validation.dart new file mode 100644 index 0000000000..eccbadf974 --- /dev/null +++ b/mobile/lib/shared/relay/relay_validation.dart @@ -0,0 +1,164 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; + +/// Validates an untrusted relay origin before the mobile app connects to it. +/// +/// Production relay URLs must use TLS and may not contain local or otherwise +/// non-public IP literals. Debug builds retain plaintext localhost support for +/// local development, but other non-public destinations remain blocked. +void validateInviteRelayUri( + Uri uri, { + bool allowInsecureLocalhost = kDebugMode, +}) { + if (uri.host.isEmpty || + uri.userInfo.isNotEmpty || + uri.hasFragment || + (uri.path.isNotEmpty && uri.path != '/') || + uri.hasQuery) { + throw const FormatException('Relay URL must be an origin'); + } + + if (uri.scheme != 'ws' && uri.scheme != 'wss') { + throw FormatException('Invalid relay URL scheme: ${uri.scheme}'); + } + + final host = uri.host.toLowerCase(); + final isLocalhost = host == 'localhost' || host.endsWith('.localhost'); + if (uri.scheme != 'wss' && !(allowInsecureLocalhost && isLocalhost)) { + throw const FormatException('Relay URL must use WSS'); + } + if (isLocalhost) { + if (!allowInsecureLocalhost) { + throw const FormatException('Relay URL cannot target localhost'); + } + return; + } + + final address = InternetAddress.tryParse(host); + if (_looksLikeNumericAddress(host)) { + // Only canonical dotted-decimal IPv4 is accepted. Ambiguous legacy forms + // such as a single integer, hexadecimal, shortened components, or leading + // zeroes have varied interpretation across URI/network stacks. + final ipv4Parts = host.split('.'); + final hasLeadingZero = ipv4Parts.any( + (part) => part.length > 1 && part.startsWith('0'), + ); + if (address == null || + address.type != InternetAddressType.IPv4 || + address.address != host || + ipv4Parts.length != 4 || + hasLeadingZero) { + throw const FormatException('Relay URL contains an invalid IP address'); + } + } + if (address != null) { + if (!_isPublicAddress(address)) { + throw const FormatException( + 'Relay URL cannot target non-public network addresses', + ); + } + return; + } + + // DNS hostnames are allowed here. Preventing a hostname from resolving or + // rebinding to a non-public address requires connect-time resolution and + // address pinning in the networking layer. +} + +bool _looksLikeNumericAddress(String host) { + if (RegExp(r'^\d+$').hasMatch(host) || + RegExp(r'^0x[0-9a-f]+$', caseSensitive: false).hasMatch(host)) { + return true; + } + final parts = host.split('.'); + return parts.length > 1 && + parts.every( + (part) => + RegExp(r'^\d+$').hasMatch(part) || + RegExp(r'^0x[0-9a-f]+$', caseSensitive: false).hasMatch(part), + ); +} + +bool _isPublicAddress(InternetAddress address) { + final bytes = address.rawAddress; + if (address.type == InternetAddressType.IPv4) { + return !_isNonPublicIpv4(bytes); + } + if (address.type != InternetAddressType.IPv6 || bytes.length != 16) { + return false; + } + + // IPv4-compatible and IPv4-mapped IPv6 addresses inherit the embedded + // IPv4 address's classification. + final firstTenZero = bytes.take(10).every((byte) => byte == 0); + if (firstTenZero && + ((bytes[10] == 0 && bytes[11] == 0) || + (bytes[10] == 0xff && bytes[11] == 0xff))) { + return !_isNonPublicIpv4(bytes.sublist(12)); + } + + // Accept only globally reachable IPv6 literals. Global unicast space is + // 2000::/3, with special-purpose exclusions and narrow globally reachable + // exceptions tracked by IANA (reconciled 2026-07-26): + // https://www.iana.org/assignments/iana-ipv6-special-registry/ + if (!_hasPrefix(bytes, [0x20], 3)) return false; + + // 2001::/23 is reserved for IETF protocol assignments. Only these entries + // are currently classified by IANA as globally reachable. + if (_hasPrefix(bytes, [0x20, 0x01, 0x00], 23) && + !_isGloballyReachableIetfAssignment(bytes)) { + return false; + } + + // Documentation and transition prefixes are not public relay destinations. + if (_hasPrefix(bytes, [0x20, 0x01, 0x0d, 0xb8], 32)) return false; + if (_hasPrefix(bytes, [0x20, 0x02], 16)) return false; + if (_hasPrefix(bytes, [0x3f, 0xff, 0x00], 20)) return false; + return true; +} + +bool _isGloballyReachableIetfAssignment(List bytes) { + final isGlobalAnycast = + _hasPrefix(bytes, [0x20, 0x01, 0x00, 0x01, 0, 0, 0, 0], 64) && + bytes.sublist(8, 15).every((byte) => byte == 0) && + bytes[15] >= 1 && + bytes[15] <= 3; + return isGlobalAnycast || + _hasPrefix(bytes, [0x20, 0x01, 0x00, 0x03], 32) || + _hasPrefix(bytes, [0x20, 0x01, 0x00, 0x04, 0x01, 0x12], 48) || + _hasPrefix(bytes, [0x20, 0x01, 0x00, 0x20], 28) || + _hasPrefix(bytes, [0x20, 0x01, 0x00, 0x30], 28); +} + +bool _hasPrefix(List address, List prefix, int prefixLength) { + final wholeBytes = prefixLength ~/ 8; + for (var i = 0; i < wholeBytes; i++) { + if (address[i] != prefix[i]) return false; + } + final remainingBits = prefixLength % 8; + if (remainingBits == 0) return true; + final mask = (0xff << (8 - remainingBits)) & 0xff; + return (address[wholeBytes] & mask) == (prefix[wholeBytes] & mask); +} + +bool _isNonPublicIpv4(List bytes) { + if (bytes.length != 4) return true; + final a = bytes[0]; + final b = bytes[1]; + final c = bytes[2]; + + return a == 0 || + a == 10 || + a == 127 || + (a == 100 && b >= 64 && b <= 127) || + (a == 169 && b == 254) || + (a == 172 && b >= 16 && b <= 31) || + (a == 192 && b == 0 && c == 0) || + (a == 192 && b == 0 && c == 2) || + (a == 192 && b == 168) || + (a == 198 && (b == 18 || b == 19)) || + (a == 198 && b == 51 && c == 100) || + (a == 203 && b == 0 && c == 113) || + a >= 224; +} diff --git a/mobile/test/features/invites/invite_join_provider_test.dart b/mobile/test/features/invites/invite_join_provider_test.dart index 29158f7910..e02134bb5f 100644 --- a/mobile/test/features/invites/invite_join_provider_test.dart +++ b/mobile/test/features/invites/invite_join_provider_test.dart @@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart' as http_testing; import 'package:nostr/nostr.dart' as nostr; +import 'package:pointycastle/digests/sha256.dart'; import 'package:buzz/features/invites/invite_join_provider.dart'; import 'package:buzz/shared/auth/auth.dart'; @@ -128,7 +129,26 @@ void main() { 'https://relay.example.com/api/invites/claim', ); expect(capturedRequest!.body, jsonEncode({'code': 'code'})); - expect(capturedRequest!.headers['Authorization'], startsWith('Nostr ')); + final authHeader = capturedRequest!.headers['Authorization']; + expect(authHeader, startsWith('Nostr ')); + expect(capturedRequest!.followRedirects, isFalse); + final encoded = authHeader!.substring('Nostr '.length); + final authEvent = + jsonDecode( + utf8.decode(base64Url.decode(base64Url.normalize(encoded))), + ) + as Map; + final tags = (authEvent['tags'] as List) + .map((tag) => (tag as List).cast()) + .toList(); + final payloadHash = SHA256Digest() + .process(capturedRequest!.bodyBytes) + .map((byte) => byte.toRadixString(16).padLeft(2, '0')) + .join(); + expect(authEvent['kind'], 27235); + expect(tags, contains(equals(['u', capturedRequest!.url.toString()]))); + expect(tags, contains(equals(['method', 'POST']))); + expect(tags, contains(equals(['payload', payloadHash]))); expect(auth.authenticatedCommunities, hasLength(1)); expect( auth.authenticatedCommunities.single.relayUrl, @@ -139,6 +159,21 @@ void main() { }, ); + test('prepare rejects an unsafe relay before showing confirmation', () async { + final container = ProviderContainer(); + addTearDown(container.dispose); + + await expectLater( + container + .read(inviteJoinProvider.notifier) + .prepare( + const InviteDeepLink(relayUrl: 'wss://127.0.0.1', code: 'code'), + ), + throwsFormatException, + ); + expect(container.read(inviteJoinProvider).status, InviteJoinStatus.idle); + }); + test('join_policy_required requires a fresh link and cannot retry', () async { final keys = nostr.Keys.generate(); var attempts = 0; diff --git a/mobile/test/shared/deeplink/deep_link_test.dart b/mobile/test/shared/deeplink/deep_link_test.dart index a60056c3bc..64e15f0513 100644 --- a/mobile/test/shared/deeplink/deep_link_test.dart +++ b/mobile/test/shared/deeplink/deep_link_test.dart @@ -104,6 +104,23 @@ void _inviteTests() { ); }); + test('normalizes trailing slash in buzz join handoff', () { + final link = parseInviteDeepLink( + Uri.parse( + 'buzz://join?relay=wss%3A%2F%2Frelay.example.com%2F&code=abc123', + ), + ); + expect(link?.relayUrl, 'wss://relay.example.com'); + }); + + test('rejects plaintext public buzz join handoff', () { + final relay = Uri.encodeQueryComponent('ws://relay.example.com'); + expect( + parseInviteDeepLink(Uri.parse('buzz://join?relay=$relay&code=abc')), + isNull, + ); + }); + test('preserves policy receipt in buzz join handoff', () { final link = parseInviteDeepLink( Uri.parse( @@ -177,6 +194,18 @@ void _inviteTests() { ); }); + test('rejects non-public invite relay destinations', () { + for (final url in [ + 'https://127.0.0.1/invite/abc', + 'https://169.254.169.254/invite/abc', + 'https://192.168.1.1/invite/abc', + 'https://[::1]/invite/abc', + 'https://[::ffff:127.0.0.1]/invite/abc', + ]) { + expect(parseInviteDeepLink(Uri.parse(url)), isNull, reason: url); + } + }); + test('rejects buzz join with dangerous relay schemes', () { // The `relay=` param is an allowlist — only `ws` / `wss` are safe to // hand to a Nostr relay session. Anything else must be dropped by the diff --git a/mobile/test/shared/relay/relay_validation_test.dart b/mobile/test/shared/relay/relay_validation_test.dart new file mode 100644 index 0000000000..e564767ea4 --- /dev/null +++ b/mobile/test/shared/relay/relay_validation_test.dart @@ -0,0 +1,161 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:buzz/shared/relay/relay_validation.dart'; + +void main() { + group('validateInviteRelayUri', () { + test('accepts public secure relay origins', () { + for (final url in [ + 'wss://relay.example.com', + 'wss://relay.example.com/', + 'wss://relay.example.com:8443', + 'wss://8.8.8.8', + 'wss://[2001:4860:4860::8888]', + 'wss://[2001:1::1]', + 'wss://[2001:1::2]', + 'wss://[2001:1::3]', + 'wss://[2001:3::1]', + 'wss://[2001:4:112::1]', + 'wss://[2001:20::1]', + 'wss://[2001:30::1]', + 'wss://[2001:200::1]', + 'wss://[2620:4f:8000::1]', + 'wss://[3fff:1000::1]', + 'wss://[2606:4700:4700::1111]', + ]) { + expect( + () => validateInviteRelayUri( + Uri.parse(url), + allowInsecureLocalhost: false, + ), + returnsNormally, + reason: url, + ); + } + }); + + test('allows plaintext localhost only when explicitly enabled', () { + for (final url in ['ws://localhost:3000', 'ws://relay.localhost:3000']) { + expect( + () => validateInviteRelayUri( + Uri.parse(url), + allowInsecureLocalhost: true, + ), + returnsNormally, + reason: url, + ); + expect( + () => validateInviteRelayUri( + Uri.parse(url), + allowInsecureLocalhost: false, + ), + throwsFormatException, + reason: url, + ); + } + }); + + test('rejects plaintext public relays', () { + expect( + () => validateInviteRelayUri( + Uri.parse('ws://relay.example.com'), + allowInsecureLocalhost: false, + ), + throwsFormatException, + ); + }); + + test('rejects non-public IPv4 literals', () { + for (final host in [ + '0.0.0.0', + '10.0.0.1', + '100.64.0.1', + '127.0.0.1', + '169.254.169.254', + '172.16.0.1', + '192.168.0.1', + '198.18.0.1', + '224.0.0.1', + ]) { + expect( + () => validateInviteRelayUri( + Uri.parse('wss://$host'), + allowInsecureLocalhost: false, + ), + throwsFormatException, + reason: host, + ); + } + }); + + test('rejects non-public IPv6 literals and mapped IPv4', () { + for (final host in [ + '[::]', + '[::1]', + '[::ffff:127.0.0.1]', + '[::ffff:169.254.169.254]', + '[::ffff:a9fe:a9fe]', + '[64:ff9b::a9fe:a9fe]', + '[100::1]', + '[100:0:0:1::1]', + '[2001::1]', + '[2001:1::4]', + '[2001:2::1]', + '[2001:4:111::1]', + '[2001:10::1]', + '[2001:40::1]', + '[2001:db8::1]', + '[2002:a9fe:a9fe::1]', + '[3fff::1]', + '[3fff:fff::1]', + '[5f00::1]', + '[fc00::1]', + '[fd00::1]', + '[fe80::1]', + '[fec0::1]', + '[ff02::1]', + ]) { + expect( + () => validateInviteRelayUri( + Uri.parse('wss://$host'), + allowInsecureLocalhost: false, + ), + throwsFormatException, + reason: host, + ); + } + }); + + test('rejects legacy numeric IPv4 spellings', () { + for (final host in ['2130706433', '0x7f000001', '0177.0.0.1', '127.1']) { + expect( + () => validateInviteRelayUri( + Uri.parse('wss://$host'), + allowInsecureLocalhost: false, + ), + throwsFormatException, + reason: host, + ); + } + }); + + test('rejects non-origin URLs', () { + for (final url in [ + 'https://relay.example.com', + 'wss://user@relay.example.com', + 'wss://relay.example.com/path', + 'wss://relay.example.com?query=x', + 'wss://relay.example.com#fragment', + ]) { + expect( + () => validateInviteRelayUri( + Uri.parse(url), + allowInsecureLocalhost: false, + ), + throwsFormatException, + reason: url, + ); + } + }); + }); +} From 166c6655e8bca87d83ad60c087fb70a32a026baf Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Sun, 26 Jul 2026 09:28:30 -0700 Subject: [PATCH 040/393] fix(desktop): surface install failures hidden by curl-pipe exit codes (#2892) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ubuntu Doctor reports `Install failed at verify: The installer finished, but Buzz still could not use claude-code (observed: CLI missing)` while the `cli` step shows success. The `cli` step is lying. ## The masking Every CLI install command is a pipe — `curl -fsSL https://claude.ai/install.sh | bash` (`managed_agents/discovery.rs:109`), `… | sh` for Codex (`:141`), `… | CONFIGURE=false bash` for Goose (`:75`). `install_shell_command` ran them through `bash -l -c` with no `pipefail`, so the pipeline's exit status was the **right-hand** side's. A `curl` that fails — or that isn't on the child's PATH at all — feeds `bash` an empty stdin, and `bash` with nothing to run exits 0: ``` $ /bin/bash -l -c 'curl -fsSL https://nonexistent.invalid/x.sh | bash'; echo $? curl: (6) Could not resolve host: nonexistent.invalid 0 $ PATH=/tmp/empty /bin/bash -l -c 'curl -fsSL https://claude.ai/install.sh | bash'; echo $? bash: line 1: curl: command not found 0 ``` `run_install_command` records exit 0 as `success: true`, the adapter step then installs fine (it uses Buzz's own bundled Node, no system PATH needed), and `post_install_verification` correctly reports the CLI is absent. The user is handed a `verify` riddle instead of curl's error, which is why diagnosing this required three rounds of guessing. Install commands now run under `set -o pipefail`, so the left-hand side's failure is the step's failure and `InstallStepResult.stderr` carries the vendor's own message. `SHELLOPTS` is not exported by either shell, so the piped-to vendor script still runs with its default options. The Windows PowerShell install path (`install_powershell_command`) bypasses this shell and is untouched. ## The PATH collapse it was hiding `install_shell_command` composes the child's PATH and calls `cmd.env("PATH", …)`, which **replaces** rather than extends. `should_use_inherited` was `is_windows && !had_shell_path && has_local_context`, so on Unix the inherited process PATH was never appended. When `login_shell_path()` returns `None` — a login shell that exits non-zero or prints nothing, which a GUI-launched process can easily hit via `~/.profile` — the child's entire PATH becomes Buzz's two managed Node dirs. There is no `curl`, `sh`, `sha256sum`, or `tar` in either, so every curl-pipe install fails, and before this PR it failed invisibly. The `is_windows` requirement is dropped: the inherited PATH is the floor whenever no login-shell PATH was obtained, on every OS. Both existing suppressions are kept — a login-shell PATH present still suppresses it (no doubling), and no home/exe context still suppresses it (never manufacture a PATH from ambient state alone). Inherited entries stay **last**, so managed dirs keep precedence. The other caller, `build_augmented_path` (`runtime/path.rs:148`, feeding agent spawns and CLI probes), reads correctly under the new rule for the same reason: it only gains the inherited PATH in the case where it would otherwise hand a child a PATH with no native entries. When a login-shell PATH exists — the normal case on macOS and Linux — its output is unchanged, which `unix_shell_path_suppresses_inherited_fallback` pins. ## Scope This fixes the reporting defect and the PATH floor. The specific environment failure on the affected Ubuntu box is still being diagnosed and is deliberately not addressed here; the point of this change is that the next attempt produces the real error instead of a `verify` riddle. One interaction worth noting: `install_failure_is_retryable` retries any failure that carries an exit code, so a pipefail-surfaced curl failure now gets 3 attempts with backoff — correct for transient network blips, and harmless for hard failures. `desktop/scripts/check-file-sizes.mjs` ratchets the `agent_discovery.rs` ceiling 1836 → 1895 for the added tests. --------- Signed-off-by: Will Pfleger Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 --- desktop/scripts/check-file-sizes.mjs | 10 +- .../src-tauri/src/commands/agent_discovery.rs | 206 +++++++++++++++++- .../src/managed_agents/runtime/path.rs | 176 +++++++++------ 3 files changed, 319 insertions(+), 73 deletions(-) diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 65928a29db..e593a8aa4e 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -492,7 +492,15 @@ const overrides = new Map([ // fix for the Goose Windows installer (PR #2680 interaction with #2750). // +10: pass an explicit PATH through Codex adapter install planning so unit // tests avoid the process-global login-shell PATH cache. - ["src-tauri/src/commands/agent_discovery.rs", 1836], + // +59: run install commands under `pipefail` so a failing `curl` in a + // `curl … | bash` install fails the `cli` step instead of being masked by + // `bash`'s exit 0, plus tests for the arg shape and the real pipeline status. + // +81: install_shell_args re-exports the composed PATH inside the command + // body so login startup files can't clear or reorder it, plus an isolated + // hostile-profile regression the pure composition tests structurally miss. + // +42: gate that re-export off Windows, where join_paths is `;`-separated and + // bash would collapse it into one entry, plus a platform-shape test. + ["src-tauri/src/commands/agent_discovery.rs", 2022], // draft-persistence predicate: submit-time `loadDraft` check + inline comment // + deps-array entry in submitMessage closes the never-persisted-boundary // defect (Thufir Pass-3 finding). Load-bearing correctness fix; queued to diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index e3d9b22189..d97e26001d 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -547,6 +547,62 @@ fn persist_last_error_on_install( save_managed_agents(app, &records) } +/// Build the `-l -c` argument list for the install shell. +/// +/// The body runs under `pipefail`: every CLI install command is a `curl … | +/// bash` / `| sh` pipe, and without it the pipeline's status is the right-hand +/// side's — `bash`/`sh` fed an empty stdin exits 0 — so a `curl` that fails (or +/// isn't on PATH at all) was recorded as a successful `cli` step, leaving the +/// user an unactionable `verify` error instead of curl's own stderr. Every +/// install shell supports it; the Windows PowerShell path bypasses this shell. +/// `SHELLOPTS` is not exported, so the piped-to vendor script keeps its own +/// defaults. +/// +/// Off Windows, `composed_path` is passed as a positional and re-exported +/// *inside* the body, because `-l` sources the user's login startup files after +/// the process environment is installed: a profile assigning PATH overwrites +/// `cmd.env("PATH", …)` before the vendor command runs. `export PATH=` empties +/// it outright; macOS `/etc/zprofile` runs `path_helper`, which reorders it and +/// costs Buzz's managed Node/npm dirs their precedence. A positional rather than +/// an interpolated body keeps entries containing spaces or quotes intact. +/// +/// The prelude is omitted where it would do harm: +/// - `composed_path` is `None` — `export PATH="$1"` with `$1` unset sets an +/// *empty* PATH, worse than the ambient one. +/// - `is_windows` — `join_paths` uses the platform separator, so the positional +/// would be `;`-joined while bash splits PATH on `:`, collapsing every entry +/// into one nonsense path; and Windows is where the inherited fallback always +/// fires (`login_shell_path()` is unconditionally `None` there), so this would +/// be the steady state. `cmd.env("PATH", …)` already delivers the native form +/// Git Bash translates on entry, and Windows has no login startup files doing +/// the clobbering this prelude defends against. +/// +/// `is_windows` is a parameter rather than a `#[cfg]` so the Windows shape stays +/// asserted on Unix CI — the same reason `should_skip_claude_executable` takes +/// one. Extracted from `install_shell_command` for that testability, not because +/// it has more than one caller. +fn install_shell_args( + command: &str, + composed_path: Option<&std::ffi::OsStr>, + is_windows: bool, +) -> Vec { + let Some(path) = composed_path.filter(|_| !is_windows) else { + return vec![ + "-l".into(), + "-c".into(), + format!("set -o pipefail; {command}").into(), + ]; + }; + vec![ + "-l".into(), + "-c".into(), + format!("export PATH=\"$1\"; set -o pipefail; {command}").into(), + // `$0` is the shell-name slot, so the PATH must be the second positional. + "buzz-install".into(), + path.to_os_string(), + ] +} + /// Build a login-shell `Command` for `command` with hermit env vars stripped, /// Buzz-managed npm locations set, and the user's PATH set. This is the /// single source of truth for @@ -561,7 +617,6 @@ fn install_shell_command(command: &str) -> Result let shell: std::path::PathBuf = resolve_install_shell()?; let mut cmd = std::process::Command::new(&shell); - cmd.args(["-l", "-c", command]); // Strip hermit vars and set managed npm paths (see apply_npm_env). apply_npm_env(&mut cmd); @@ -569,10 +624,18 @@ fn install_shell_command(command: &str) -> Result // Compose the PATH for the install shell using the same kernel as the // runtime/probe path so the two can never drift. managed entries first // (Node/npm bins keep precedence); login-shell entries next; inherited - // process PATH appended last on Windows when no login-shell PATH exists - // (login_shell_path() always returns None on Windows — Git Bash paths are - // POSIX-shaped and poison native children; cmd.env("PATH", …) replaces - // rather than extends, so without inherited the install shell loses npm). + // process PATH appended last when no login-shell PATH exists — the case + // where the composed PATH would otherwise be Buzz's managed Node dirs + // alone, with no `curl`/`sh`/`tar` for the vendor install pipes + // (cmd.env("PATH", …) replaces rather than extends). On Windows that case + // is the steady state: login_shell_path() always returns None there + // because Git Bash paths are POSIX-shaped and poison native children. + // + // The composed PATH is set twice on purpose off Windows: `cmd.env` so the + // login startup files themselves run with a usable PATH, and the `$1` + // export in `install_shell_args` so their own PATH assignments cannot undo + // it. Neither is redundant — see `install_shell_args`, which also explains + // why the export is suppressed on Windows. let login_path = crate::managed_agents::login_shell_path(); let had_login = login_path.is_some(); let managed: Vec = [ @@ -589,14 +652,20 @@ fn install_shell_command(command: &str) -> Result let inherited: Vec = std::env::var_os("PATH") .map(|p| std::env::split_paths(&p).collect()) .unwrap_or_default(); - let use_inherited = crate::managed_agents::should_use_inherited(had_login, true, cfg!(windows)); + let use_inherited = crate::managed_agents::should_use_inherited(had_login, true); let path_parts = crate::managed_agents::compose_path_entries(managed, login, inherited, use_inherited); - if !path_parts.is_empty() { - if let Ok(path) = std::env::join_paths(path_parts) { - cmd.env("PATH", path); - } + let composed_path = (!path_parts.is_empty()) + .then(|| std::env::join_paths(path_parts).ok()) + .flatten(); + if let Some(path) = composed_path.as_deref() { + cmd.env("PATH", path); } + cmd.args(install_shell_args( + command, + composed_path.as_deref(), + cfg!(windows), + )); // Detach from the controlling terminal so install scripts that read from // /dev/tty (e.g. Codex's "Start Codex now? [y/N]") fall back to stdin @@ -1408,6 +1477,123 @@ mod tests { assert!(result.is_ok(), "install_shell_command must succeed on Unix"); } + // ── pipefail: install pipes must not mask a failing left-hand side ──────── + + /// The command handed to the install shell must run under `set -o pipefail;` + /// with the vendor command preserved verbatim, so `curl … | bash` fails when + /// `curl` does. Platform-agnostic: only the PATH prelude differs by OS, and + /// `test_install_shell_args_shape_per_platform` pins that. + #[test] + fn test_install_shell_command_enables_pipefail() { + let cmd = super::install_shell_command("curl -fsSL https://example.test/i.sh | bash") + .expect("install shell must resolve on a test host"); + let args: Vec = cmd + .get_args() + .map(|a| a.to_string_lossy().into_owned()) + .collect(); + let body = &args[2]; + assert!( + body.contains("set -o pipefail; "), + "the install body must set pipefail; got: {body}" + ); + assert!( + body.ends_with("curl -fsSL https://example.test/i.sh | bash"), + "the vendor command must be preserved verbatim; got: {body}" + ); + } + + /// The PATH prelude is emitted only where it helps, and the exact argument + /// vector is the contract: a stray trailing positional with no `$1` reader, + /// or an export whose `$1` the shell cannot split, both corrupt PATH. + /// Windows is excluded because `join_paths` is `;`-separated there while bash + /// splits PATH on `:` — and it is the platform where the inherited fallback + /// always fires. See `install_shell_args` for the full reasoning. + #[test] + fn test_install_shell_args_shape_per_platform() { + let composed = std::ffi::OsString::from("/buzz/node/bin:/usr/bin"); + let windows_composed = std::ffi::OsString::from(r"C:\buzz\node;C:\Windows\system32"); + let bare = ["-l", "-c", "set -o pipefail; echo hi"].map(std::ffi::OsString::from); + + assert_eq!( + super::install_shell_args("echo hi", Some(&composed), false), + [ + "-l", + "-c", + "export PATH=\"$1\"; set -o pipefail; echo hi", + "buzz-install", + "/buzz/node/bin:/usr/bin", + ] + .map(std::ffi::OsString::from), + "Unix must re-export the composed PATH after login init" + ); + assert_eq!( + super::install_shell_args("echo hi", Some(&windows_composed), true), + bare, + "Windows must not re-export a `;`-joined PATH inside bash" + ); + assert_eq!( + super::install_shell_args("echo hi", None, false), + bare, + "no composed PATH must yield the bare pipefail body and no positionals" + ); + } + + /// Regression for the login-startup-file overwrite: `cmd.env("PATH", …)` is + /// installed *before* `-l` sources the user's profile, so a profile that + /// assigns PATH silently discards the composed one. Uses `/bin/bash` + /// explicitly — the planted profile is bash-specific, so resolving the host + /// shell (which prefers zsh) would make this vacuous. + #[cfg(unix)] + #[test] + fn test_composed_path_survives_a_profile_that_clears_it() { + let home = tempfile::tempdir().expect("temp HOME"); + std::fs::write(home.path().join(".bash_profile"), "export PATH=\n") + .expect("plant a hostile login profile"); + let composed = std::ffi::OsString::from("/buzz/sentinel/bin:/usr/bin:/bin"); + + // `echo` is a shell builtin, so the child needs no PATH to report one. + let out = std::process::Command::new("/bin/bash") + .args(super::install_shell_args( + "echo \"$PATH\"", + Some(&composed), + false, + )) + .env("HOME", home.path()) + .env("PATH", &composed) + .stdin(std::process::Stdio::null()) + .output() + .expect("bash must spawn"); + + let path = String::from_utf8_lossy(&out.stdout); + assert!( + path.contains("/buzz/sentinel/bin"), + "the composed PATH must survive login init; got: {path:?}" + ); + } + + /// End-to-end on the real resolved install shell (no network): a pipeline + /// whose left-hand side fails must exit non-zero, while a fully successful + /// pipeline must still succeed. Without `pipefail` the status is the + /// right-hand side's and the left-hand failure is invisible. + #[cfg(unix)] + #[test] + fn test_install_shell_pipeline_status_follows_left_side() { + for (command, expect_success) in [("false | true", false), ("echo ok | cat", true)] { + let status = super::install_shell_command(command) + .expect("Unix must always resolve an install shell") + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .expect("install shell must spawn"); + assert_eq!( + status.success(), + expect_success, + "`{command}` must report success={expect_success}; got {status:?}" + ); + } + } + // ── Phase A: Windows install shell selection ─────────────────────────────── /// On Windows (CI runner has Git pre-installed), resolve_install_shell succeeds. diff --git a/desktop/src-tauri/src/managed_agents/runtime/path.rs b/desktop/src-tauri/src/managed_agents/runtime/path.rs index cf6950f577..efec0c903e 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/path.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/path.rs @@ -30,30 +30,25 @@ pub(crate) fn should_skip_claude_executable(path: &std::path::Path, is_windows: /// Decide whether the inherited process PATH should be appended to the /// composed PATH. /// -/// On Windows, `login_shell_path()` always returns `None` because Git Bash -/// returns POSIX colon-delimited paths that poison native children. -/// `Command::env("PATH", …)` replaces rather than extends, so without the -/// inherited PATH every child loses node/npm/git. -/// -/// This pure function takes an explicit `is_windows` flag so it can be -/// unit-tested cross-host (macOS CI can pass `true` to exercise the Windows -/// policy without needing the `cfg!(windows)` target). +/// `Command::env("PATH", …)` replaces rather than extends, so a child whose +/// composed PATH carries no native entries loses every system binary. On +/// Windows that is the steady state — `login_shell_path()` always returns +/// `None` because Git Bash returns POSIX colon-delimited paths that poison +/// native children. On Unix it is the failure mode: a login shell that exits +/// non-zero or prints nothing also yields `None`, and the child is then left +/// with only Buzz's managed Node dirs — no `curl`, `sh`, or `tar`, which +/// silently breaks every `curl … | bash` install. The inherited PATH is the +/// floor under both cases, appended last so managed dirs keep precedence. /// /// Rules: -/// - Only append when `is_windows` — on Unix the login-shell PATH always covers -/// the needed runtimes. /// - Suppress when `had_shell_path` is `true` — if a login-shell PATH was /// supplied it already carries the user's native entries; appending the /// process PATH would double them. /// - Suppress when `has_local_context` is `false` — callers that pass no home /// or exe-parent context must not receive a PATH manufactured from ambient /// process state alone. -pub(crate) fn should_use_inherited( - had_shell_path: bool, - has_local_context: bool, - is_windows: bool, -) -> bool { - is_windows && !had_shell_path && has_local_context +pub(crate) fn should_use_inherited(had_shell_path: bool, has_local_context: bool) -> bool { + !had_shell_path && has_local_context } /// Pure PATH composition kernel shared by the install shell and the runtime/probe paths. @@ -91,10 +86,14 @@ pub(crate) fn compose_path_entries( /// 4. `nvm_bin` — nvm's default Node.js bin dir (if the user uses nvm) /// 5. exe parent dir — DMG sidecars under `Contents/MacOS/` /// 6. user's login-shell `PATH` — runtimes like node/python from other managers -/// 7. Windows only: the current process `PATH` (appended when no login-shell -/// PATH exists, because callers use `Command::env("PATH", …)` which -/// *replaces* the child's PATH — without this, the child loses node/npm/git -/// and every npm `.cmd` shim fails with `'node' is not recognized`) +/// 7. the current process `PATH` — appended on every platform when no +/// login-shell PATH exists, because callers use `Command::env("PATH", …)` +/// which *replaces* the child's PATH. This is the steady state on Windows, +/// where `login_shell_path()` always returns `None` and without it the +/// child loses node/npm/git and every npm `.cmd` shim fails with +/// `'node' is not recognized`; on Unix it is the login-shell-probe failure +/// fallback, which keeps `curl`/`sh`/`tar` reachable. See +/// [`should_use_inherited`] for the suppression rules. /// /// `shell_path` is the raw colon-delimited string from a login shell, so it is /// split into individual entries before joining. Pushing it as a single segment @@ -145,7 +144,7 @@ pub(in crate::managed_agents) fn build_augmented_path( let inherited: Vec = std::env::var_os("PATH") .map(|p| std::env::split_paths(&p).collect()) .unwrap_or_default(); - let use_inherited = should_use_inherited(had_shell_path, has_local_context, cfg!(windows)); + let use_inherited = should_use_inherited(had_shell_path, has_local_context); let parts = compose_path_entries(managed, login, inherited, use_inherited); if parts.is_empty() { @@ -223,29 +222,86 @@ mod tests { #[cfg(unix)] #[test] fn nvm_bin_none_does_not_add_segment() { + let _guard = crate::managed_agents::lock_path_mutex(); + let previous = std::env::var_os("PATH"); + // With no shell_path the inherited process PATH is appended last, so + // pin it to a sentinel to keep the assertion deterministic. + std::env::set_var("PATH", "/sentinel/inherited"); + let result = build_augmented_path( Some(PathBuf::from("/home/user")), Some(PathBuf::from("/usr/local/bin")), None, None, ); + + match previous { + Some(value) => std::env::set_var("PATH", value), + None => std::env::remove_var("PATH"), + } + let result = result.expect("path"); assert!(result.starts_with("/home/user/.local/bin:"), "{result}"); - assert!(result.ends_with(":/usr/local/bin"), "{result}"); + assert!(!result.contains(".nvm"), "no nvm segment: {result}"); + assert!( + result.contains(":/usr/local/bin:"), + "exe parent must precede the inherited PATH: {result}" + ); + assert!( + result.ends_with(":/sentinel/inherited"), + "inherited PATH must be appended last when no shell_path: {result}" + ); } - /// On Unix, supplying a `shell_path` must NOT trigger the Windows process-PATH - /// fallback — the output must be byte-identical to what it was before this - /// fix. + /// On Unix with no login-shell PATH, `build_augmented_path` must fall back to + /// the inherited process PATH — otherwise the child gets only Buzz-managed + /// dirs and loses every system binary (`curl`, `sh`, `tar`). #[cfg(unix)] #[test] - fn unix_shell_path_output_unchanged_by_windows_fallback_logic() { + fn unix_appends_process_path_when_no_shell_path() { + let _guard = crate::managed_agents::lock_path_mutex(); + let previous = std::env::var_os("PATH"); + std::env::set_var("PATH", "/usr/bin:/bin"); + + let result = build_augmented_path(Some(PathBuf::from("/home/user")), None, None, None); + + match previous { + Some(value) => std::env::set_var("PATH", value), + None => std::env::remove_var("PATH"), + } + + let result = result.expect("path must not be None with a home dir"); + assert!( + result.starts_with("/home/user/.local/bin:"), + "home/.local/bin must be first: {result}" + ); + assert!( + result.ends_with(":/usr/bin:/bin"), + "process PATH must be last: {result}" + ); + } + + /// On Unix, supplying a `shell_path` must NOT also append the inherited + /// process PATH — the login-shell PATH already carries the native entries. + #[cfg(unix)] + #[test] + fn unix_shell_path_suppresses_inherited_fallback() { + let _guard = crate::managed_agents::lock_path_mutex(); + let previous = std::env::var_os("PATH"); + std::env::set_var("PATH", "/should/not/appear"); + let result = build_augmented_path( Some(PathBuf::from("/home/user")), None, Some("/usr/local/bin:/usr/bin:/bin".to_string()), None, ); + + match previous { + Some(value) => std::env::set_var("PATH", value), + None => std::env::remove_var("PATH"), + } + let result = result.expect("path"); assert!( result.ends_with(":/usr/local/bin:/usr/bin:/bin"), @@ -334,8 +390,8 @@ mod tests { // ── Pure policy and composition tests — run on every host ──────────────────── // // These test `should_use_inherited` and `compose_path_entries` with explicit -// inputs, so they run on macOS/Linux CI and validate the Windows policy -// behavior without touching process state or requiring a Windows target. +// inputs, so they run on macOS/Linux CI and validate the cross-platform +// fallback policy without touching process state or requiring a Windows target. #[cfg(test)] mod compose_tests { use super::{compose_path_entries, is_batch_shim, should_use_inherited}; @@ -347,43 +403,37 @@ mod compose_tests { // ── should_use_inherited policy matrix ──────────────────────────────────── - /// Windows + no shell path + has local context → must use inherited. + /// No shell path + has local context → must use inherited, on every OS. + /// This is the steady state on Windows (login_shell_path() is always None) + /// and the failure mode on Unix (login shell exited non-zero or printed + /// nothing); in both the child would otherwise get no native PATH entries. #[test] - fn policy_windows_no_shell_with_context_uses_inherited() { + fn policy_no_shell_with_context_uses_inherited() { assert!( - should_use_inherited(false, true, true), - "Windows, no shell path, has context → must append inherited" + should_use_inherited(false, true), + "no shell path, has context → must append inherited" ); } - /// Windows + shell path present → must NOT use inherited (login path covers it). + /// Shell path present → must NOT use inherited (login path covers it). #[test] - fn policy_windows_shell_path_present_suppresses_inherited() { + fn policy_shell_path_present_suppresses_inherited() { assert!( - !should_use_inherited(true, true, true), - "Windows, shell path present → must not append inherited" + !should_use_inherited(true, true), + "shell path present → must not append inherited" ); } - /// Windows + no local context → must NOT use inherited (no ambient state). + /// No local context → must NOT use inherited (no ambient state). #[test] - fn policy_windows_no_local_context_suppresses_inherited() { + fn policy_no_local_context_suppresses_inherited() { assert!( - !should_use_inherited(false, false, true), - "Windows, no local context → must not append inherited" - ); - } - - /// Non-Windows → never use inherited, regardless of other flags. - #[test] - fn policy_non_windows_never_uses_inherited() { - assert!( - !should_use_inherited(false, true, false), - "non-Windows must never append inherited PATH" + !should_use_inherited(false, false), + "no local context → must not append inherited" ); assert!( - !should_use_inherited(false, false, false), - "non-Windows + no context must never append inherited PATH" + !should_use_inherited(true, false), + "no local context → must not append inherited even with a shell path" ); } @@ -498,23 +548,25 @@ mod compose_tests { // compute the same `should_use_inherited` decision for equivalent inputs. // Tests the policy function directly to confirm the wrappers can't drift. - /// Exhaustive truth-table for `should_use_inherited` — all four input - /// combinations that affect real callers. Confirms the policy is correct - /// before either wrapper binds to it. + /// Exhaustive truth-table for `should_use_inherited` — every input + /// combination. Confirms the policy is correct before either wrapper binds + /// to it. The rule is OS-independent: the inherited PATH is the floor + /// whenever no login-shell PATH was obtained, because the alternative is a + /// child with no native binaries at all. #[test] fn should_use_inherited_policy_truth_table() { - // (had_shell, has_context, is_windows) → expected + // (had_shell, has_context) → expected let cases = [ - (false, true, true, true), // Windows, no shell, context → USE - (true, true, true, false), // Windows, shell present → NO - (false, false, true, false), // Windows, no context → NO - (false, true, false, false), // non-Windows → NO + (false, true, true), // no shell PATH, context → USE (the floor) + (true, true, false), // shell PATH present → NO (already covered) + (false, false, false), // no context → NO (no ambient-only PATH) + (true, false, false), // no context → NO, shell PATH irrelevant ]; - for (had_shell, has_ctx, is_win, expected) in cases { - let result = should_use_inherited(had_shell, has_ctx, is_win); + for (had_shell, has_ctx, expected) in cases { + let result = should_use_inherited(had_shell, has_ctx); assert_eq!( result, expected, - "policy mismatch: had_shell={had_shell} has_ctx={has_ctx} is_win={is_win}" + "policy mismatch: had_shell={had_shell} has_ctx={has_ctx}" ); } } From 8e67cf399d0291bcdbc69cd0402983ca030f05bb Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Sun, 26 Jul 2026 09:30:13 -0700 Subject: [PATCH 041/393] chore(desktop): delete dead persona catalog UI cluster (#2886) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Deletes 6 orphaned files in `desktop/src/features/agents/ui/` (556 lines) that formed a closed cluster with zero imports from the reachable module graph — orphaned by the 1B dialog consolidation (`PersonaCatalogSurface`, `PersonaCatalogSection`, `PersonaCatalogDetailsSheet`, `PersonaCatalogSelectionBadge`, `PersonaIdentity`, `PersonaLibraryEntryPoints`). - Removes a stale `check-file-sizes.mjs` override entry for the no-longer-existent `PersonaDialog.tsx`. Verified dead two ways: import-graph reachability walk from all entry points puts all six outside the reachable set, and `tsc --noEmit` passes clean after deletion. Their `data-testid`s have zero references outside the cluster. `PersonaCatalogDialog.tsx` is alive (`AgentsView` imports it) and stays. Independent of #1968 — pure dead code removal, no behavioral change. --- desktop/scripts/check-file-sizes.mjs | 14 -- .../agents/ui/PersonaCatalogDetailsSheet.tsx | 163 ----------------- .../agents/ui/PersonaCatalogSection.tsx | 166 ------------------ .../ui/PersonaCatalogSelectionBadge.tsx | 29 --- .../agents/ui/PersonaCatalogSurface.tsx | 79 --------- .../features/agents/ui/PersonaIdentity.tsx | 62 ------- .../agents/ui/PersonaLibraryEntryPoints.tsx | 57 ------ 7 files changed, 570 deletions(-) delete mode 100644 desktop/src/features/agents/ui/PersonaCatalogDetailsSheet.tsx delete mode 100644 desktop/src/features/agents/ui/PersonaCatalogSection.tsx delete mode 100644 desktop/src/features/agents/ui/PersonaCatalogSelectionBadge.tsx delete mode 100644 desktop/src/features/agents/ui/PersonaCatalogSurface.tsx delete mode 100644 desktop/src/features/agents/ui/PersonaIdentity.tsx delete mode 100644 desktop/src/features/agents/ui/PersonaLibraryEntryPoints.tsx diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index e593a8aa4e..b74e05ae7b 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -219,20 +219,6 @@ const overrides = new Map([ // (+3) — the new team-id spawn link and the runtime-layered instructions // field. ["src/shared/api/types.ts", 1047], - // readiness-gate: PersonaDialog.tsx threads computeLocalModeGate + - // requiredCredentialEnvKeys + RequiredFieldLabel so the "New agent" dialog - // shows required markers and credential amber rows (parity with - // CreateAgentDialog). +23 lines of gate wiring. Queued to split. - // config-bridge-aware requirements: useRuntimeFileConfigQuery wiring adds - // ~16 lines. Queued to split. - // baked-env-required-badge: useBakedBuildEnvKeysQuery + bakedEnvKeys wiring - // + correct exclusion-semantics for requiredEnvKeys adds ~14 lines. - // +2 lines: filter managed provider key from requiredEnvKeys (suppress dead-input locked row). - // global-agent-config parity: wire useGlobalAgentConfig into PersonaDialog - // (Gap A: global-aware computeLocalModeGate + drop bare requiredCredentialEnvKeys; - // Gap B: hasAutoOpenedAdvancedRef auto-expand effect) + effective-provider - // save gate + Inherit/Select-a-provider label. Queued to split. - ["src/features/agents/ui/PersonaDialog.tsx", 1080], // harness-persona-sync feature growth, queued to split in the resolver-unify // refactor followup. discovery.rs is dominated by the new test module // (the effective_agent_command / divergent / create-time override matrix); diff --git a/desktop/src/features/agents/ui/PersonaCatalogDetailsSheet.tsx b/desktop/src/features/agents/ui/PersonaCatalogDetailsSheet.tsx deleted file mode 100644 index 6aab3096cb..0000000000 --- a/desktop/src/features/agents/ui/PersonaCatalogDetailsSheet.tsx +++ /dev/null @@ -1,163 +0,0 @@ -import { isCatalogPersonaSelected } from "@/features/agents/lib/catalog"; -import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; -import type { AgentPersona } from "@/shared/api/types"; -import { cn } from "@/shared/lib/cn"; -import { promptPreview } from "@/shared/lib/promptPreview"; -import { - Sheet, - SheetContent, - SheetDescription, - SheetHeader, - SheetTitle, -} from "@/shared/ui/sheet"; - -import { PersonaCatalogSelectionBadge } from "./PersonaCatalogSelectionBadge"; -import { - getPersonaCatalogDetailSelectionCopy, - getPersonaCatalogSelectionAriaLabel, -} from "./personaLibraryCopy"; - -type PersonaCatalogDetailsSheetProps = { - feedbackErrorMessage: string | null; - feedbackNoticeMessage: string | null; - isPending: boolean; - onOpenChange: (open: boolean) => void; - onTogglePersona: (persona: AgentPersona) => void; - open: boolean; - persona: AgentPersona | null; -}; - -export function PersonaCatalogDetailsSheet({ - feedbackErrorMessage, - feedbackNoticeMessage, - isPending, - onOpenChange, - onTogglePersona, - open, - persona, -}: PersonaCatalogDetailsSheetProps) { - const preview = persona ? promptPreview(persona.systemPrompt) : ""; - const isSelected = persona ? isCatalogPersonaSelected(persona) : false; - const selectionCopy = getPersonaCatalogDetailSelectionCopy(isSelected); - - return ( - - - {persona ? ( - <> - -
- -
-
- - {persona.displayName} - - -
- - {preview || "No summary available."} - -
-
-
-
- - - {feedbackNoticeMessage ? ( -

- {feedbackNoticeMessage} -

- ) : null} - - {feedbackErrorMessage ? ( -

- {feedbackErrorMessage} -

- ) : null} - -
-
-

- Type -

-

Built-in agent

-
-
-

- Preferred model -

-

- {persona.model ?? "Use app default"} -

-
-
-

- Preferred runtime -

-

- {persona.runtime ?? "Use app default"} -

-
-
- -
-

- System prompt -

-
-                  {persona.systemPrompt}
-                
-
-
- - ) : null} -
-
- ); -} diff --git a/desktop/src/features/agents/ui/PersonaCatalogSection.tsx b/desktop/src/features/agents/ui/PersonaCatalogSection.tsx deleted file mode 100644 index 25cc9e4751..0000000000 --- a/desktop/src/features/agents/ui/PersonaCatalogSection.tsx +++ /dev/null @@ -1,166 +0,0 @@ -import { isCatalogPersonaSelected } from "@/features/agents/lib/catalog"; -import type { AgentPersona } from "@/shared/api/types"; -import { promptPreview } from "@/shared/lib/promptPreview"; -import { cn } from "@/shared/lib/cn"; -import { Button } from "@/shared/ui/button"; -import { Card } from "@/shared/ui/card"; -import { Skeleton } from "@/shared/ui/skeleton"; -import { PersonaCatalogSelectionBadge } from "./PersonaCatalogSelectionBadge"; -import { PersonaIdentity } from "./PersonaIdentity"; -import { - getPersonaCatalogSelectionActionCopy, - getPersonaCatalogSelectionAriaLabel, - personaCatalogCopy, -} from "./personaLibraryCopy"; - -type PersonaCatalogSectionProps = { - emptyDescription?: string; - emptyTitle?: string; - error: Error | null; - isLoading: boolean; - isPending: boolean; - onTogglePersona: (persona: AgentPersona) => void; - onViewDetails: (persona: AgentPersona) => void; - personas: AgentPersona[]; - showHeader?: boolean; -}; - -export function PersonaCatalogSection({ - emptyDescription = personaCatalogCopy.emptyCatalogDescription, - emptyTitle = personaCatalogCopy.emptyCatalogTitle, - error, - isLoading, - isPending, - onTogglePersona, - onViewDetails, - personas, - showHeader = true, -}: PersonaCatalogSectionProps) { - return ( -
- {showHeader ? ( -
-

- {personaCatalogCopy.title} -

-

- {personaCatalogCopy.description} -

-
- ) : null} - - {isLoading ? ( -
- {["first", "second", "third", "fourth"].map((key) => ( - -
- -
- - -
-
-
- ))} -
- ) : null} - - {!isLoading && personas.length > 0 ? ( -
- {personas.map((persona) => { - const preview = promptPreview(persona.systemPrompt); - const isSelected = isCatalogPersonaSelected(persona); - - return ( -
- - - - {getPersonaCatalogSelectionActionCopy(isSelected)} - -
-
-
- ); - })} -
- ) : null} - - {!isLoading && personas.length === 0 ? ( -
-

{emptyTitle}

-

- {emptyDescription} -

-
- ) : null} - - {error ? ( -

- {error.message} -

- ) : null} - - ); -} diff --git a/desktop/src/features/agents/ui/PersonaCatalogSelectionBadge.tsx b/desktop/src/features/agents/ui/PersonaCatalogSelectionBadge.tsx deleted file mode 100644 index 75837d0f94..0000000000 --- a/desktop/src/features/agents/ui/PersonaCatalogSelectionBadge.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { Check } from "lucide-react"; - -import { cn } from "@/shared/lib/cn"; - -import { personaCatalogCopy } from "./personaLibraryCopy"; - -type PersonaCatalogSelectionBadgeProps = { - isActive: boolean; -}; - -export function PersonaCatalogSelectionBadge({ - isActive, -}: PersonaCatalogSelectionBadgeProps) { - return ( - - {isActive ? : null} - {isActive - ? personaCatalogCopy.selectedState - : personaCatalogCopy.availableState} - - ); -} diff --git a/desktop/src/features/agents/ui/PersonaCatalogSurface.tsx b/desktop/src/features/agents/ui/PersonaCatalogSurface.tsx deleted file mode 100644 index 159025f7b9..0000000000 --- a/desktop/src/features/agents/ui/PersonaCatalogSurface.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import * as React from "react"; - -import { isCatalogPersonaSelected } from "@/features/agents/lib/catalog"; -import type { AgentPersona } from "@/shared/api/types"; -import { useFeedbackToasts } from "@/shared/hooks/useToastEffect"; - -import { PersonaCatalogDetailsSheet } from "./PersonaCatalogDetailsSheet"; -import { PersonaCatalogSection } from "./PersonaCatalogSection"; - -type PersonaCatalogSurfaceProps = { - error: Error | null; - feedbackErrorMessage: string | null; - feedbackNoticeMessage: string | null; - isLoading: boolean; - isPending: boolean; - onClearFeedback: () => void; - onSelectPersona: (persona: AgentPersona, active: boolean) => void; - personas: AgentPersona[]; - showHeader?: boolean; -}; - -export function PersonaCatalogSurface({ - error, - feedbackErrorMessage, - feedbackNoticeMessage, - isLoading, - isPending, - onClearFeedback, - onSelectPersona, - personas, - showHeader = true, -}: PersonaCatalogSurfaceProps) { - const [detailPersonaId, setDetailPersonaId] = React.useState( - null, - ); - const detailPersona = React.useMemo( - () => - detailPersonaId - ? (personas.find((persona) => persona.id === detailPersonaId) ?? null) - : null, - [detailPersonaId, personas], - ); - const handleTogglePersona = (persona: AgentPersona) => { - onSelectPersona(persona, !isCatalogPersonaSelected(persona)); - }; - - useFeedbackToasts(feedbackNoticeMessage, feedbackErrorMessage); - - return ( - <> - { - onClearFeedback(); - setDetailPersonaId(persona.id); - }} - personas={personas} - showHeader={showHeader} - /> - - { - if (!open) { - setDetailPersonaId(null); - } - }} - onTogglePersona={handleTogglePersona} - open={detailPersonaId !== null} - persona={detailPersona} - /> - - ); -} diff --git a/desktop/src/features/agents/ui/PersonaIdentity.tsx b/desktop/src/features/agents/ui/PersonaIdentity.tsx deleted file mode 100644 index eb2c5c0253..0000000000 --- a/desktop/src/features/agents/ui/PersonaIdentity.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { Info } from "lucide-react"; - -import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; -import type { AgentPersona } from "@/shared/api/types"; -import { cn } from "@/shared/lib/cn"; -import { promptPreview } from "@/shared/lib/promptPreview"; -import { Badge } from "@/shared/ui/badge"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; - -type PersonaIdentityProps = { - className?: string; - persona: AgentPersona; - showBuiltInBadge?: boolean; - showPromptTooltip?: boolean; -}; - -export function PersonaIdentity({ - className, - persona, - showBuiltInBadge = persona.isBuiltIn, - showPromptTooltip = true, -}: PersonaIdentityProps) { - const preview = promptPreview(persona.systemPrompt); - - return ( -
-
- -
-
-

- {persona.displayName} -

- {showBuiltInBadge ? ( - Built-in - ) : null} - {showPromptTooltip && preview ? ( - - - - - -

{preview}

-
-
- ) : null} -
-
-
-
- ); -} diff --git a/desktop/src/features/agents/ui/PersonaLibraryEntryPoints.tsx b/desktop/src/features/agents/ui/PersonaLibraryEntryPoints.tsx deleted file mode 100644 index 113d38b898..0000000000 --- a/desktop/src/features/agents/ui/PersonaLibraryEntryPoints.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { Upload } from "lucide-react"; - -import { Button } from "@/shared/ui/button"; - -import { CreateNewButton } from "./CreateNewButton"; -import { personaLibraryCopy } from "./personaLibraryCopy"; - -type PersonaLibraryEntryPointsProps = { - canChooseCatalog?: boolean; - isPending: boolean; - layout: "header" | "empty"; - onCreate: () => void; - onChooseCatalog?: () => void; - onImport?: () => void; -}; - -export function PersonaLibraryEntryPoints({ - canChooseCatalog = false, - isPending, - layout, - onCreate, - onChooseCatalog, - onImport, -}: PersonaLibraryEntryPointsProps) { - const isHeader = layout === "header"; - const chooseVariant = isHeader ? "outline" : "default"; - const createVariant = isHeader ? "default" : "outline"; - - return ( - <> - {canChooseCatalog && onChooseCatalog ? ( - - ) : null} - - {!isHeader && onImport ? ( - - ) : null} - - ); -} From 8c0e8cb1656b04ad269bce3c2deeda2a943ae78a Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Sun, 26 Jul 2026 09:30:33 -0700 Subject: [PATCH 042/393] fix(desktop): make agent definition authoritative for model/provider/prompt (#1968) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Introduces a single effective-config resolver that makes agent definitions authoritative for model, provider, system prompt, and relay-mesh routing on linked instances. Stale materialized record bytes are never consulted at spawn, deploy, readiness, hash, card summary, or mesh preflight. **Resolution semantics (field-specific):** - **model/provider (linked):** definition → global. `None` = inherit global default. Record value never consulted. - **model/provider (definition-less):** instance → global. - **system_prompt (linked):** strictly from definition; blank = no prompt. No global tier for prompt. - **system_prompt (definition-less):** from the instance. - **relay-mesh preflight (both):** driven by the same `resolve_effective_config` resolution spawn's mesh env consults. For a linked instance the record's own `provider`/`model`/`relay_mesh` bytes are never consulted; for a definition-less instance with no provider of its own, the legacy marker and env preset are the last fallback (see below). - **Orphaned (linked record, definition missing):** spawn, deploy, and mesh preflight blocked with actionable user-facing error. **Changes:** - New `effective_config` resolver module with `ConfigSource` metadata (`definition`, `global`, `instance_legacy`) - All consumers routed through the single resolver: local spawn, deploy, readiness, spawn hash, card summary, relay-mesh preflight (interactive start and restore-on-launch) - `apply_persona_snapshot` no longer preserves stale record model/provider when definition is blank - Backend `update_managed_agent` blocks model/provider/prompt writes for linked records - Frontend omits model/provider/systemPrompt submission for linked instances; system prompt override hidden - `model_source` field added to `ManagedAgentSummary` so card labels distinguish inherited (`Default model (X)`) from explicit - Spawn hash now digests resolved model/provider (not raw record fields) so global default changes trip the restart badge even for runtimes without `model_env_var` - Orphan spawn/deploy/mesh-preflight blocked with jargon-free error ("This agent's configuration is missing — it may still be syncing or was deleted on another device") - `EffectiveAgentConfig::relay_mesh_model_id()` and `resolve_effective_relay_mesh_model_id()` added; both mesh preflights (`start_local_agent_with_preflight`, `restore_managed_agents_on_launch`) call this instead of the deleted `relay_mesh_config`/`relay_mesh_model_id` record-byte sniffs - Legacy relay-mesh records keep their mesh routing. Two shipped generations predate `provider: "relay-mesh"` and are never rewritten on load: the typed `relay_mesh` marker (added when `ManagedAgentRecord` had no `provider` field), and before it the mesh preset written directly into `env_vars`. `resolve_definition_less` falls back to the marker, then to the env preset, so these records still resolve to mesh instead of silently misrouting to an unrelated provider while their stale env bytes reach the child. The fallback is skipped when the record carries an explicit `provider` — that states current intent, including a switch away from mesh — and `resolve_linked` has no legacy fallback at all - The env discriminator accepts both spellings of the two sentinels renamed in the Jun-11 window without a record migration: the provider env key (`BUZZ_AGENT_PROVIDER`, previously `SPROUT_AGENT_PROVIDER`) and the api-key value (`buzz-mesh-local`, previously `sprout-mesh-local`). Each is independent, since a record can straddle the window; the current provider-key spelling wins when both are present - Dead `persona_field_with_record_fallback` and wrapper `persona_snapshot_with_agent_config_fallback` deleted; callers use `persona_snapshot` directly - New resolver/deploy/hash/write-guard/mesh-preflight tests, including switch-away and global-inheritance regressions for both mesh preflight call sites, per-class legacy-mesh resolution (typed marker and env preset, in every rename-window spelling combination), and the paired assertions that a linked instance's legacy mesh bytes stay inert --------- Signed-off-by: Will Pfleger Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 --- desktop/scripts/check-file-sizes.mjs | 38 +- .../src-tauri/src/commands/agent_config.rs | 98 +- .../src-tauri/src/commands/agent_models.rs | 73 +- .../src/commands/agent_models_tests.rs | 175 +++- desktop/src-tauri/src/commands/agents.rs | 47 +- .../src-tauri/src/commands/agents_deploy.rs | 79 +- .../src-tauri/src/commands/agents_tests.rs | 87 +- desktop/src-tauri/src/commands/mesh_llm.rs | 14 +- .../managed_agents/effective_config/mod.rs | 318 +++++++ .../managed_agents/effective_config/tests.rs | 872 ++++++++++++++++++ .../src-tauri/src/managed_agents/env_vars.rs | 8 +- .../src/managed_agents/global_config/mod.rs | 69 +- .../src/managed_agents/global_config/tests.rs | 56 +- desktop/src-tauri/src/managed_agents/mod.rs | 1 + .../src/managed_agents/persona_events.rs | 115 +-- .../managed_agents/persona_events/tests.rs | 302 +++--- .../src-tauri/src/managed_agents/readiness.rs | 15 +- .../src/managed_agents/relay_mesh.rs | 229 ----- .../src-tauri/src/managed_agents/restore.rs | 28 +- .../src-tauri/src/managed_agents/runtime.rs | 142 ++- .../src/managed_agents/runtime/metadata.rs | 28 +- .../src/managed_agents/runtime/tests.rs | 74 +- .../src/managed_agents/spawn_hash.rs | 42 +- .../src/managed_agents/spawn_hash/tests.rs | 129 ++- desktop/src-tauri/src/managed_agents/types.rs | 45 +- .../agents/lib/agentCardModelLabel.test.mjs | 58 ++ .../agents/lib/agentCardModelLabel.ts | 44 + .../agents/ui/AgentInstanceEditDialog.tsx | 36 +- .../agents/ui/EditAgentAdvancedFields.tsx | 47 +- .../features/agents/ui/ManagedAgentRow.tsx | 14 +- .../agents/ui/UnifiedAgentsSection.tsx | 47 +- .../features/agents/ui/personaRuntimeModel.ts | 43 +- desktop/src/shared/api/tauri.ts | 3 + desktop/src/shared/api/types.ts | 1 + 34 files changed, 2548 insertions(+), 829 deletions(-) create mode 100644 desktop/src-tauri/src/managed_agents/effective_config/mod.rs create mode 100644 desktop/src-tauri/src/managed_agents/effective_config/tests.rs create mode 100644 desktop/src/features/agents/lib/agentCardModelLabel.test.mjs create mode 100644 desktop/src/features/agents/lib/agentCardModelLabel.ts diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index b74e05ae7b..9649e9bbfb 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -80,7 +80,15 @@ const overrides = new Map([ // ratcheting 1443 -> 1295. Queued to split further in the A2 fold. // global-agent-config: resolve_deploy_model_provider + visibility exports // add ~40 lines on top of the 1A.1 ratchet. Queued to split. - ["src-tauri/src/commands/agents.rs", 1340], + // +22 (1340 -> 1360): agent-config-resolver — start_local_agent_with_preflight + // uses resolve_effective_relay_mesh_model_id at both preflight call sites; + // preview_prospective_persona_snapshot helper extracted; orphan guard threaded + // through restore path. Load-bearing feature changes; queued to split. + // +9 (1360 -> 1369): start_local_agent_pairs_with_preflight — added + // personas/global load + resolve_effective_relay_mesh_model_id call to + // replace stale record-byte preflight. Same resolver pattern as + // start_local_agent_with_preflight. Load-bearing; queued to split. + ["src-tauri/src/commands/agents.rs", 1369], // agent-lifecycle-fixes: cascade-delete in delete_persona restructured into // 3-phase (stage/stop/commit) + commit_cascade_agents injectable helper for // retry-safety. Load-bearing reviewer-required change; queued to split. @@ -168,7 +176,9 @@ const overrides = new Map([ // Windows PATH-correctness fix: 3 #[cfg(windows)] test functions covering // .cmd shim rejection, .bat shim rejection, and .exe acceptance for // configure_runtime_cli (fix #2397). Test-only growth; queued to split. - ["src-tauri/src/managed_agents/runtime/tests.rs", 1041], + // +7 (1041 -> 1048): rebase onto main — this PR's resolver tests land on top + // of main's #2397 Windows shim tests. Test-only; queued to split. + ["src-tauri/src/managed_agents/runtime/tests.rs", 1055], // applyWorkspace reposDir parameter plus the validateReposDir binding, // threaded through Tauri invokes for configurable repos_dir, plus the // harness-persona-sync `harnessOverride` create-input bit — load-bearing @@ -203,7 +213,8 @@ const overrides = new Map([ // mapper. This is the existing API boundary; split remains queued. // team-instructions-first-class: createManagedAgent Tauri bridge threads the // new teamId input through to the backend (+1 line). - ["src/shared/api/tauri.ts", 1305], + // +2 for model_source field in RawManagedAgent + fromRawManagedAgent mapping. + ["src/shared/api/tauri.ts", 1307], // doctor-npm-eacces-preflight: hint field added to InstallStepResult (+1 line). // codex-acp-package-swap: "adapter_outdated" variant added to AcpAvailabilityStatus (+1 line). // doctor-install-reliability: AuthStatus tagged union + nodeRequired/authStatus/ @@ -438,7 +449,15 @@ const overrides = new Map([ // (if let Some(provider_update) = input.provider { record.provider = provider_update; }). // +8: harness_override thread-through in update_managed_agent so a deliberate // Custom pin routes to update_time_agent_command_override (comment + call). - ["src-tauri/src/commands/agent_models.rs", 1079], + // +22 (1079 -> 1101): Finding 2 — model discovery now resolves through + // resolve_effective_model_provider instead of raw record bytes + // (saved_agent_model_discovery_config takes personas/global and the + // get_agent_models call site loads global config), plus + // apply_model_provider_prompt_update's linked-instance write-guard + // extraction and its regression tests. + // +4 (1101 -> 1105): rebase onto agents-everywhere — agents.rs function + // signatures updated for ManagedAgentRuntimeKey-keyed runtimes map. + ["src-tauri/src/commands/agent_models.rs", 1113], // global-agent-config: get_agent_config_surface / write_agent_config_field / // put_agent_session_config commands + GlobalAgentConfig serde types. New file // in this PR; queued to split with the command module refactor. @@ -454,7 +473,12 @@ const overrides = new Map([ // relay from the harness-attached payload relayUrl (with effective-relay // fallback for older harnesses) instead of a required arg the frontend // wrapper never passed, which silently broke the session-config cache. - ["src-tauri/src/commands/agent_config.rs", 1050], + // +60 (1050 -> 1110): agent-config-resolver — resolve_config_surface now + // clears a linked instance's own system_prompt/model/provider before + // computing had_* so stale materialized snapshot bytes can never be tagged + // BuzzExplicit and shadow the definition/global fallthrough; the dead + // persona-model re-tag branch replaced; two new regression tests added. + ["src-tauri/src/commands/agent_config.rs", 1110], // codex-install-auto-restart review-fixes: should_restart_after_install // takes pid_alive:bool (pure predicate, no OS-dependent call); 3 racy // cache tests replaced with 6 pure availability_drift predicate tests; @@ -532,7 +556,9 @@ const overrides = new Map([ // for Databricks v1 gate; prospectiveRuntimeId default fallback for builtins. // PR-B moves default/API-key derivation into shared hooks; the explicit // hidden-key projection keeps the top-level secret out of Advanced rows. - ["src/features/agents/ui/AgentInstanceEditDialog.tsx", 1195], + // +6 (1195 -> 1201): rebase onto main — this PR's model-source label wiring + // lands on top of main's dialog growth. Queued to split. + ["src/features/agents/ui/AgentInstanceEditDialog.tsx", 1201], // AgentDefinitionDialog grew past 1000 with the following load-bearing fixes: // isRuntimeAutoSeededRef tracking for edit-mode seeding (Fizz shows models); // runtimeSupportsLlmProviderSelection guard on discovery provider (codex fix); diff --git a/desktop/src-tauri/src/commands/agent_config.rs b/desktop/src-tauri/src/commands/agent_config.rs index f6094ed529..461fed5dbd 100644 --- a/desktop/src-tauri/src/commands/agent_config.rs +++ b/desktop/src-tauri/src/commands/agent_config.rs @@ -39,6 +39,13 @@ pub struct RuntimeFileConfigSubset { /// Resolve the config surface with persona and global default values applied. /// +/// Linked instances are definition-authoritative: the record's own +/// system_prompt/model/provider are cleared before applying, so a stale +/// materialized snapshot can never shadow the persona's current values or a +/// blank-definition fallthrough to global defaults (mirrors +/// `effective_config::resolve_linked`). Definition-less instances keep their +/// own explicit values. +/// /// The pipeline: resolve the linked persona's prompt/model/provider, inject /// each into the record only where the record lacks its own value, let /// `read_config_surface` tag those injected fields `BuzzExplicit`, then re-tag @@ -59,6 +66,22 @@ fn resolve_config_surface( session_cache: Option<&SessionConfigCache>, global: &GlobalAgentConfig, ) -> RuntimeConfigSurface { + // Linked instances are definition-authoritative (mirrors + // `effective_config::resolve_linked`): the record's own + // system_prompt/model/provider fields are, at best, a stale materialized + // snapshot from the last `apply_persona_snapshot` — never a legitimate + // live override, since `update_managed_agent` blocks writing these three + // fields for linked instances. Clear them before computing `had_*` below + // so a stale byte can never masquerade as BuzzExplicit and suppress + // definition/global injection. Env var overrides (set via the advanced + // env-vars editor) are untouched — those remain a legitimate + // per-instance override regardless of link status. + if record.persona_id.is_some() { + record.system_prompt = None; + record.model = None; + record.provider = None; + } + let had_prompt = record.system_prompt.is_some() || record.env_vars.contains_key("BUZZ_ACP_SYSTEM_PROMPT"); let had_model = record.model.is_some(); @@ -172,22 +195,6 @@ fn resolve_config_surface( retag_global_default(&mut surface.normalized.provider); } - // Re-tag persona-snapshotted model from BuzzExplicit to PersonaDefault. - // Persona-created agents have record.model set at create time from the - // persona snapshot — had_model is true, but the model came from the persona, - // not an explicit user choice. Re-tag when the record model matches the - // persona model and no live override is active. Only applies when a persona - // is actually linked — non-persona agents with an explicit model keep BuzzExplicit. - if had_model && !model_overridden && record.persona_id.is_some() { - if let (Some(ref record_model), Some(ref persona_model_val)) = - (&record.model, &persona_model) - { - if record_model == persona_model_val { - retag_persona_default(&mut surface.normalized.model); - } - } - } - surface } @@ -728,11 +735,64 @@ mod tests { } } - /// A model the user set explicitly in Buzz must never be re-tagged to - /// `PersonaDefault`, even when the linked persona also has a model. + /// Definition-authoritative: a stale materialized `record.model` on a + /// linked instance must never outrank (or even be consulted against) the + /// linked persona's model. `update_managed_agent` already blocks writing + /// model/provider/prompt for linked instances, so a non-`None` value here + /// can only be leftover snapshot bytes from before a persona edit — the + /// panel must report the persona's current model, tagged `PersonaDefault`, + /// not the stale byte as `BuzzExplicit`. #[test] - fn explicit_record_model_outranks_persona_and_keeps_buzz_explicit_origin() { + fn linked_stale_record_model_never_outranks_persona_model() { let mut record = agent_record(); + record.model = Some("stale-explicit-model".to_string()); + let personas = vec![persona_with_model("persona-model")]; + + let surface = resolve_config_surface( + record, + &personas, + Some(goose_runtime()), + None, + &Default::default(), + ); + + let model = surface.normalized.model.as_ref().expect("model resolved"); + assert_eq!(model.value.as_deref(), Some("persona-model")); + assert_eq!(model.origin, ConfigOrigin::PersonaDefault); + } + + /// Definition-authoritative, blank-definition case: a linked instance + /// whose persona has no model of its own must fall through to the global + /// default, tagged `GlobalDefault` — mirroring + /// `effective_config::resolve_linked`'s `None => global` arm. A stale + /// materialized record model must not shadow this fallthrough either. + #[test] + fn linked_blank_definition_model_falls_through_to_global_default() { + let mut record = agent_record(); + record.model = Some("stale-explicit-model".to_string()); + let mut persona = persona_with_model("unused"); + persona.model = None; + let personas = vec![persona]; + let global = crate::managed_agents::GlobalAgentConfig { + model: Some("global-model".to_string()), + ..Default::default() + }; + + let surface = + resolve_config_surface(record, &personas, Some(goose_runtime()), None, &global); + + let model = surface.normalized.model.as_ref().expect("model resolved"); + assert_eq!(model.value.as_deref(), Some("global-model")); + assert_eq!(model.origin, ConfigOrigin::GlobalDefault); + } + + /// A definition-less (no `persona_id`) instance's own explicit model IS + /// authoritative — the stale-record clearing above is scoped to linked + /// instances only. + #[test] + fn definition_less_explicit_record_model_keeps_buzz_explicit_origin() { + let mut record = agent_record(); + record.persona_id = None; record.model = Some("explicit-model".to_string()); let personas = vec![persona_with_model("persona-model")]; diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index 112f297fa7..2e423ed6dd 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -87,9 +87,13 @@ pub async fn get_agent_models( // ModelPicker can persist a selected model but not rewrite the saved // provider/env snapshot, and runtime spawn reads that same snapshot. - // Discover models against the record snapshot so an out-of-date persona - // cannot offer models for a provider this agent will not launch with. - let discovery = saved_agent_model_discovery_config(record, &effective_command); + // Discover models against the resolver's effective model/provider — + // definition-authoritative for linked instances — so discovery can + // never query a stale provider this agent will not actually launch + // with. + let global = crate::managed_agents::load_global_agent_config(&app).unwrap_or_default(); + let discovery = + saved_agent_model_discovery_config(record, &effective_command, &personas, &global); ( resolved, @@ -161,27 +165,38 @@ struct SavedAgentModelDiscoveryConfig { env: BTreeMap, } +/// Resolve the model/provider discovery config from the same authoritative +/// source spawn uses (`resolve_effective_model_provider`) — linked instances +/// read their definition, never a stale materialized `record.model`/ +/// `record.provider`, so model discovery cannot query a provider this agent +/// will not actually launch with. Definition-less instances keep their own +/// record values, matching spawn's `resolve_definition_less` arm. fn saved_agent_model_discovery_config( record: &crate::managed_agents::ManagedAgentRecord, agent_command: &str, + personas: &[crate::managed_agents::AgentDefinition], + global: &crate::managed_agents::GlobalAgentConfig, ) -> SavedAgentModelDiscoveryConfig { let runtime_meta = known_acp_runtime(agent_command); + let (effective_model, effective_provider) = + crate::managed_agents::resolve_effective_model_provider(record, personas, global); + let mut derived_env = BTreeMap::new(); if let Some(meta) = runtime_meta { for (key, value) in crate::managed_agents::runtime_metadata_env_vars( meta.model_env_var, meta.provider_env_var, meta.provider_locked, - record.model.as_deref(), - record.provider.as_deref(), + effective_model.as_deref(), + effective_provider.as_deref(), ) { derived_env.insert(key.to_string(), value.to_string()); } } SavedAgentModelDiscoveryConfig { - model: record.model.clone(), - provider: record.provider.clone(), + model: effective_model, + provider: effective_provider, provider_env_var: runtime_meta.and_then(|meta| meta.provider_env_var), env: crate::managed_agents::merged_user_env(&derived_env, &record.env_vars), } @@ -775,6 +790,35 @@ async fn discover_databricks_models( })) } +/// Apply an `UpdateManagedAgentRequest`'s model/provider/system_prompt patch +/// to `record`, enforcing the linked-instance write guard: a definition-linked +/// record's model/provider/prompt are definition-authoritative (see +/// `effective_config::resolve_linked`), so writes to these three fields are +/// silently dropped for a linked instance rather than persisting a byte the +/// resolver will never read. Definition-less instances accept the patch +/// as-is. Extracted so the guard is exercised by both `update_managed_agent` +/// and its regression tests — a test that reimplements this check instead of +/// calling it can go green after the real guard is deleted. +fn apply_model_provider_prompt_update( + record: &mut crate::managed_agents::ManagedAgentRecord, + model: Option>, + provider: Option>, + system_prompt: Option>, +) { + if record.persona_id.is_some() { + return; + } + if let Some(model_update) = model { + record.model = model_update; + } + if let Some(provider_update) = provider { + record.provider = provider_update; + } + if let Some(prompt_update) = system_prompt { + record.system_prompt = prompt_update; + } +} + /// Update mutable fields on an existing managed agent record. /// /// Does NOT auto-restart the agent. Runtime config changes (system prompt, @@ -814,15 +858,12 @@ pub async fn update_managed_agent( name_changed = true; } } - if let Some(model_update) = input.model { - record.model = model_update; - } - if let Some(provider_update) = input.provider { - record.provider = provider_update; - } - if let Some(prompt_update) = input.system_prompt { - record.system_prompt = prompt_update; - } + apply_model_provider_prompt_update( + record, + input.model, + input.provider, + input.system_prompt, + ); if let Some(parallelism) = input.parallelism { record.parallelism = parallelism; } diff --git a/desktop/src-tauri/src/commands/agent_models_tests.rs b/desktop/src-tauri/src/commands/agent_models_tests.rs index 0d6449e781..d962545bdc 100644 --- a/desktop/src-tauri/src/commands/agent_models_tests.rs +++ b/desktop/src-tauri/src/commands/agent_models_tests.rs @@ -164,7 +164,7 @@ fn redaction_env_records_value_used_for_request() { } #[test] -fn saved_agent_model_discovery_uses_record_snapshot() { +fn saved_agent_model_discovery_uses_record_snapshot_for_definition_less_agent() { let record: crate::managed_agents::ManagedAgentRecord = serde_json::from_str( r#"{ "pubkey": "abcd1234", @@ -193,7 +193,12 @@ fn saved_agent_model_discovery_uses_record_snapshot() { ) .expect("sample managed agent record"); - let config = saved_agent_model_discovery_config(&record, "goose"); + let config = saved_agent_model_discovery_config( + &record, + "goose", + &[], + &crate::managed_agents::GlobalAgentConfig::default(), + ); assert_eq!(config.model.as_deref(), Some("record-model")); assert_eq!(config.provider.as_deref(), Some("databricks")); @@ -341,6 +346,74 @@ fn effective_discovery_provider_reads_the_runtimes_own_env_var() { ); } +/// Definition-authoritative: a linked agent's stale materialized +/// `record.model`/`record.provider` must never drive model discovery — the +/// linked definition's current model/provider wins, mirroring spawn's +/// `resolve_effective_model_provider`. +#[test] +fn saved_agent_model_discovery_ignores_stale_record_for_linked_agent() { + let record: crate::managed_agents::ManagedAgentRecord = serde_json::from_str( + r#"{ + "pubkey": "abcd1234", + "name": "test-agent", + "persona_id": "persona-1", + "private_key_nsec": "nsec1fake", + "relay_url": "wss://localhost:3000", + "acp_command": "buzz-acp", + "agent_command": "goose", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 320, + "system_prompt": null, + "model": "stale-record-model", + "provider": "stale-record-provider", + "env_vars": {}, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + "last_started_at": null, + "last_stopped_at": null, + "last_exit_code": null, + "last_error": null + }"#, + ) + .expect("sample managed agent record"); + + let persona = crate::managed_agents::AgentDefinition { + id: "persona-1".to_string(), + display_name: "Persona".to_string(), + avatar_url: None, + system_prompt: "You are a persona.".to_string(), + runtime: None, + model: Some("persona-model".to_string()), + provider: Some("anthropic".to_string()), + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: None, + env_vars: BTreeMap::new(), + respond_to: None, + respond_to_allowlist: Vec::new(), + parallelism: None, + created_at: "".to_string(), + updated_at: "".to_string(), + }; + + let config = saved_agent_model_discovery_config( + &record, + "goose", + &[persona], + &crate::managed_agents::GlobalAgentConfig::default(), + ); + + assert_eq!(config.model.as_deref(), Some("persona-model")); + assert_eq!(config.provider.as_deref(), Some("anthropic")); + assert_eq!( + config.env.get("GOOSE_MODEL").map(String::as_str), + Some("persona-model") + ); +} + // --------------------------------------------------------------------------- // Databricks provider detection // --------------------------------------------------------------------------- @@ -381,6 +454,104 @@ fn update_request_turn_timeout_parses_for_wire_compat() { assert_eq!(req.turn_timeout_seconds, Some(9999)); } +// --------------------------------------------------------------------------- +// Linked-instance write guard (model/provider/prompt) +// --------------------------------------------------------------------------- + +#[test] +fn linked_instance_ignores_model_provider_prompt_writes() { + let mut record: crate::managed_agents::ManagedAgentRecord = serde_json::from_str( + r#"{ + "pubkey": "linked1", + "name": "linked-agent", + "persona_id": "p1", + "private_key_nsec": "nsec1fake", + "relay_url": "wss://localhost:3000", + "acp_command": "buzz-acp", + "agent_command": "goose", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 320, + "system_prompt": null, + "model": null, + "provider": null, + "env_vars": {}, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + "last_started_at": null, + "last_stopped_at": null, + "last_exit_code": null, + "last_error": null + }"#, + ) + .expect("linked agent record"); + + let is_linked = record.persona_id.is_some(); + assert!(is_linked, "test setup: record must be linked"); + + crate::commands::agent_models::apply_model_provider_prompt_update( + &mut record, + Some(Some("explicit-model".to_string())), + Some(Some("explicit-prov".to_string())), + Some(Some("explicit-prompt".to_string())), + ); + + assert!( + record.model.is_none(), + "linked record model must not be updated" + ); + assert!( + record.provider.is_none(), + "linked record provider must not be updated" + ); + assert!( + record.system_prompt.is_none(), + "linked record system_prompt must not be updated" + ); +} + +#[test] +fn definition_less_instance_accepts_model_provider_prompt_writes() { + let mut record: crate::managed_agents::ManagedAgentRecord = serde_json::from_str( + r#"{ + "pubkey": "standalone1", + "name": "standalone-agent", + "private_key_nsec": "nsec1fake", + "relay_url": "wss://localhost:3000", + "acp_command": "buzz-acp", + "agent_command": "goose", + "agent_args": [], + "mcp_command": "", + "turn_timeout_seconds": 320, + "system_prompt": null, + "model": null, + "provider": null, + "env_vars": {}, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + "last_started_at": null, + "last_stopped_at": null, + "last_exit_code": null, + "last_error": null + }"#, + ) + .expect("standalone agent record"); + + let is_linked = record.persona_id.is_some(); + assert!(!is_linked, "test setup: record must not be linked"); + + crate::commands::agent_models::apply_model_provider_prompt_update( + &mut record, + Some(Some("new-model".to_string())), + Some(Some("new-prov".to_string())), + Some(Some("new-prompt".to_string())), + ); + + assert_eq!(record.model.as_deref(), Some("new-model")); + assert_eq!(record.provider.as_deref(), Some("new-prov")); + assert_eq!(record.system_prompt.as_deref(), Some("new-prompt")); +} + #[test] fn is_databricks_provider_matches_both_variants() { assert!(is_databricks_provider(Some("databricks"))); diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index c65900b5de..61e4034b9f 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -293,16 +293,16 @@ fn resolve_created_avatar_url( #[cfg(feature = "mesh-llm")] async fn ensure_relay_mesh_for_record( app: &AppHandle, - record: &ManagedAgentRecord, + model_id: Option<&str>, allow_fresh_create_start: bool, ) -> Result<(), String> { - crate::commands::ensure_relay_mesh_for_record(app, record, allow_fresh_create_start).await + crate::commands::ensure_relay_mesh_for_record(app, model_id, allow_fresh_create_start).await } #[cfg(not(feature = "mesh-llm"))] async fn ensure_relay_mesh_for_record( _app: &AppHandle, - _record: &ManagedAgentRecord, + _model_id: Option<&str>, _allow_fresh_create_start: bool, ) -> Result<(), String> { Ok(()) @@ -327,7 +327,16 @@ pub(super) async fn start_local_agent_pairs_with_preflight( if record_snapshot.backend != BackendKind::Local { return Err(format!("agent {pubkey} is not a local agent")); } - ensure_relay_mesh_for_record(app, &record_snapshot, false).await?; + let personas_for_preflight = load_personas(app).unwrap_or_default(); + let global_for_preflight = + crate::managed_agents::load_global_agent_config(app).unwrap_or_default(); + let mesh_model_id = + crate::managed_agents::effective_config::resolve_effective_relay_mesh_model_id( + &record_snapshot, + &personas_for_preflight, + &global_for_preflight, + ); + ensure_relay_mesh_for_record(app, mesh_model_id.as_deref(), false).await?; { let _store_guard = state @@ -407,7 +416,22 @@ pub(super) async fn start_local_agent_with_preflight( return Err(format!("agent {pubkey} is not a local agent")); } - ensure_relay_mesh_for_record(app, &record_snapshot, allow_fresh_create_start).await?; + // Preflight against the same resolution spawn uses — `resolve_effective_config` + // (definition → global fallback). A linked instance's own `provider`/`model`/ + // `relay_mesh` bytes never contribute: this reads the CURRENT definition + // directly, so a definition edit that flips `provider` to/from relay-mesh + // between saves is reflected here without needing a prospective re-snapshot; + // for a global-inherited blank definition, it also folds in the global + // default, which record-byte sniffing could never see. + let personas = load_personas(app).unwrap_or_default(); + let global = crate::managed_agents::load_global_agent_config(app).unwrap_or_default(); + let mesh_model_id = + crate::managed_agents::effective_config::resolve_effective_relay_mesh_model_id( + &record_snapshot, + &personas, + &global, + ); + ensure_relay_mesh_for_record(app, mesh_model_id.as_deref(), allow_fresh_create_start).await?; let _store_guard = state .managed_agents_store_lock @@ -431,9 +455,16 @@ pub(super) async fn start_local_agent_with_preflight( // at the end — avoids a second disk read for the same file in the same call. let personas = load_personas(app).unwrap_or_default(); if let Some(persona_id) = record.persona_id.clone() { - if let Some(persona) = personas.iter().find(|p| p.id == persona_id) { - crate::managed_agents::persona_events::apply_persona_snapshot(record, persona); - record.updated_at = crate::util::now_iso(); + match personas.iter().find(|p| p.id == persona_id) { + Some(persona) => { + crate::managed_agents::persona_events::apply_persona_snapshot(record, persona); + record.updated_at = crate::util::now_iso(); + } + None => { + return Err( + crate::managed_agents::effective_config::ORPHANED_INSTANCE_ERROR.to_string(), + ); + } } } start_managed_agent_process(app, record, &mut runtimes, Some(owner_hex))?; diff --git a/desktop/src-tauri/src/commands/agents_deploy.rs b/desktop/src-tauri/src/commands/agents_deploy.rs index be132225ea..af785711d5 100644 --- a/desktop/src-tauri/src/commands/agents_deploy.rs +++ b/desktop/src-tauri/src/commands/agents_deploy.rs @@ -4,42 +4,36 @@ use tauri::AppHandle; +#[cfg(test)] +use crate::managed_agents::AgentDefinition; use crate::{ app_state::AppState, - managed_agents::{load_personas, AgentDefinition, ManagedAgentRecord}, + managed_agents::{load_personas, ManagedAgentRecord}, relay::relay_ws_url_with_override, }; /// Resolve the deploy-specific structured model/provider for a managed agent. /// -/// Deploy uses **live-persona-first** precedence so remote agents receive -/// current config after a persona update, without requiring delete+recreate. -/// Unlike local spawn (which re-snapshots the persona onto `record` at the -/// start of every spawn), provider start does not re-snapshot — so the -/// record may hold a stale snapshot while the linked persona has moved on. +/// Delegates to the single effective-config resolver which enforces +/// definition-authoritative semantics for linked instances: +/// - **Linked:** definition → global. Stale record bytes are never consulted. +/// - **Definition-less:** instance → global. +/// - **Orphaned:** returns `(None, None)` — spawn is blocked elsewhere. /// -/// Precedence: live-persona → record (snapshot fallback) → global. -/// Symmetric for both model and provider. +/// Both local spawn and deploy now use the same resolver, so they can never +/// disagree on what model/provider an agent runs with. /// /// Exported `pub(crate)` for unit testing. -pub(crate) fn resolve_deploy_model_provider<'a>( - record: &'a ManagedAgentRecord, - personas: &'a [AgentDefinition], - global: &'a crate::managed_agents::GlobalAgentConfig, -) -> (Option<&'a str>, Option<&'a str>) { - let live_persona = record - .persona_id - .as_deref() - .and_then(|pid| personas.iter().find(|p| p.id == pid)); - let model = live_persona - .and_then(|p| p.model.as_deref()) - .or(record.model.as_deref()) - .or(global.model.as_deref()); - let provider = live_persona - .and_then(|p| p.provider.as_deref()) - .or(record.provider.as_deref()) - .or(global.provider.as_deref()); - (model, provider) +#[cfg(test)] +pub(crate) fn resolve_deploy_model_provider( + record: &ManagedAgentRecord, + personas: &[AgentDefinition], + global: &crate::managed_agents::GlobalAgentConfig, +) -> (Option, Option) { + crate::managed_agents::effective_config::resolve_effective_model_provider_pair( + record, personas, global, + ) + .unwrap_or((None, None)) } /// Build the standard agent JSON payload for provider deploy calls. @@ -84,15 +78,16 @@ pub(super) fn build_deploy_payload( let merged_env = crate::managed_agents::merged_user_env(&global_persona_merged, &record.env_vars); - // Resolve the deploy-specific structured provider/model. Uses the deploy - // resolver with live-persona → record → global precedence. let personas = load_personas(app).unwrap_or_default(); - let (effective_model, effective_provider) = - resolve_deploy_model_provider(record, &personas, &global_config); - let (effective_model, effective_provider) = ( - effective_model.map(str::to_string), - effective_provider.map(str::to_string), - ); + let cfg = crate::managed_agents::effective_config::resolve_effective_config( + record, + &personas, + &global_config, + ) + .require_resolved()?; + let effective_model = cfg.model.value; + let effective_provider = cfg.provider.value; + let effective_prompt = cfg.system_prompt.value; Ok(deploy_payload_json( record, @@ -102,6 +97,7 @@ pub(super) fn build_deploy_payload( ), effective_model, effective_provider, + effective_prompt, merged_env, )) } @@ -114,36 +110,25 @@ pub(super) fn deploy_payload_json( relay_url: String, effective_model: Option, effective_provider: Option, + effective_prompt: Option, merged_env: std::collections::BTreeMap, ) -> serde_json::Value { serde_json::json!({ "name": &record.name, - // Resolve the per-agent pin against the active workspace relay here: - // this payload crosses the host boundary to a remote provider harness - // that has no notion of the desktop's workspace, so the blank→workspace - // fallback (otherwise applied at read-time in `effective_agent_relay_url`) - // must be materialized into a concrete URL before serializing. "relay_url": relay_url, "private_key_nsec": &record.private_key_nsec, "auth_tag": &record.auth_tag, "agent_command": &record.agent_command, "agent_args": &record.agent_args, - "system_prompt": &record.system_prompt, + "system_prompt": effective_prompt, "model": effective_model, - // Structured provider from the persona record. Providers that don't - // yet read this field will fall back to env_vars or their own default - // — no protocol break. "provider": effective_provider, "turn_timeout_seconds": record.turn_timeout_seconds, "idle_timeout_seconds": record.idle_timeout_seconds, "max_turn_duration_seconds": record.max_turn_duration_seconds, "parallelism": record.parallelism, - // Inbound author gate. Providers that don't yet read these fall back - // to the harness default (`owner-only`) — no protocol break. "respond_to": record.respond_to, "respond_to_allowlist": &record.respond_to_allowlist, - // Merged persona + agent env vars. Providers that don't read this - // field will simply ignore it — no protocol break. "env_vars": merged_env, }) } diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index 1b2e0ed179..e32fc1cfe4 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -126,57 +126,56 @@ fn build_agent_archive_request_attaches_owner_auth_and_retired_reason() { })); } -/// Deploy-path regression for Fix 1 of Thufir pass-2: a persona-linked -/// provider agent with a stale record snapshot must use the live persona -/// model/provider in the deploy payload, not the stale record values. -/// -/// Scenario: agent was created with persona at model="old-model"/provider="old-prov". -/// The persona was subsequently updated to "new-model"/"new-prov" but the record -/// was NOT re-snapshotted (provider start skips re-snapshot; local spawn does it). -/// The deploy resolver must use the current persona values. -/// -/// Fails against `resolve_effective_model_provider` (record-first precedence), -/// which would return "old-model"/"old-prov" from the stale record. +/// Deploy resolver uses definition model/provider, ignoring stale record. #[test] -fn deploy_resolver_uses_live_persona_over_stale_record_snapshot() { - // Record holds the stale snapshot (created when persona had old values). +fn deploy_resolver_uses_definition_over_stale_record() { let record = bare_agent_record(Some("p1"), Some("old-model"), Some("old-prov")); - // Live persona has been updated since the record was snapshotted. let personas = vec![persona_record("p1", Some("new-model"), Some("new-prov"))]; let global = crate::managed_agents::GlobalAgentConfig::default(); let (model, provider) = resolve_deploy_model_provider(&record, &personas, &global); assert_eq!( - model, + model.as_deref(), Some("new-model"), - "deploy must use live persona model, not stale record snapshot" + "deploy must use definition model, not stale record snapshot" ); assert_eq!( - provider, + provider.as_deref(), Some("new-prov"), - "deploy must use live persona provider, not stale record snapshot" + "deploy must use definition provider, not stale record snapshot" ); } -/// Deploy resolver falls back to record when persona has no model/provider -/// (persona without structured model — fallback to record snapshot). +/// When a linked definition has blank model/provider (inherit), the deploy +/// resolver must fall through to global — stale record bytes are inert. #[test] -fn deploy_resolver_falls_back_to_record_when_persona_has_none() { - let record = bare_agent_record(Some("p1"), Some("record-model"), Some("record-prov")); - // Persona exists but has no model/provider. +fn deploy_resolver_inherits_global_when_definition_blank() { + let record = bare_agent_record(Some("p1"), Some("stale-model"), Some("stale-prov")); let personas = vec![persona_record("p1", None, None)]; - let global = crate::managed_agents::GlobalAgentConfig::default(); + let global = crate::managed_agents::GlobalAgentConfig { + model: Some("global-model".to_string()), + provider: Some("global-prov".to_string()), + ..Default::default() + }; let (model, provider) = resolve_deploy_model_provider(&record, &personas, &global); - assert_eq!(model, Some("record-model")); - assert_eq!(provider, Some("record-prov")); + assert_eq!( + model.as_deref(), + Some("global-model"), + "definition blank → global; stale record ignored" + ); + assert_eq!( + provider.as_deref(), + Some("global-prov"), + "definition blank → global; stale record ignored" + ); } -/// Deploy resolver falls back to global when both persona and record have none. +/// Deploy resolver falls back to global when both definition and record have none. #[test] -fn deploy_resolver_falls_back_to_global_when_persona_and_record_have_none() { +fn deploy_resolver_falls_back_to_global_when_definition_and_record_have_none() { let record = bare_agent_record(Some("p1"), None, None); let personas = vec![persona_record("p1", None, None)]; let global = crate::managed_agents::GlobalAgentConfig { @@ -187,8 +186,35 @@ fn deploy_resolver_falls_back_to_global_when_persona_and_record_have_none() { let (model, provider) = resolve_deploy_model_provider(&record, &personas, &global); - assert_eq!(model, Some("global-model")); - assert_eq!(provider, Some("global-prov")); + assert_eq!(model.as_deref(), Some("global-model")); + assert_eq!(provider.as_deref(), Some("global-prov")); +} + +/// Orphan: linked record with missing definition → the pure model/provider +/// pair resolver returns `(None, None)`. This is NOT the deploy refusal +/// boundary — `build_deploy_payload` refuses an orphan outright via +/// `.require_resolved()?` before this pair is ever computed. This test pins +/// the resolver's own orphan behavior, which readiness/hash also depend on. +#[test] +fn deploy_resolver_returns_none_for_orphaned_instance() { + let record = bare_agent_record(Some("missing-def"), Some("stale-model"), Some("stale-prov")); + let personas: Vec = vec![]; + let global = crate::managed_agents::GlobalAgentConfig { + model: Some("global-model".to_string()), + provider: Some("global-prov".to_string()), + ..Default::default() + }; + + let (model, provider) = resolve_deploy_model_provider(&record, &personas, &global); + + assert!( + model.is_none(), + "orphaned instance must not resolve to any model" + ); + assert!( + provider.is_none(), + "orphaned instance must not resolve to any provider" + ); } #[test] @@ -404,6 +430,7 @@ fn deploy_payload_carries_the_full_behavioral_quad() { "wss://relay.example".to_string(), Some("gpt-x".to_string()), Some("openai".to_string()), + None, std::collections::BTreeMap::new(), ); diff --git a/desktop/src-tauri/src/commands/mesh_llm.rs b/desktop/src-tauri/src/commands/mesh_llm.rs index adcdeb7ec1..87af9fa08f 100644 --- a/desktop/src-tauri/src/commands/mesh_llm.rs +++ b/desktop/src-tauri/src/commands/mesh_llm.rs @@ -481,11 +481,11 @@ fn pick_serve_target_for_model( /// Non relay-mesh records are a no-op. pub(crate) async fn ensure_relay_mesh_for_record( app: &AppHandle, - record: &crate::managed_agents::ManagedAgentRecord, + model_id: Option<&str>, _allow_fresh_create_start: bool, ) -> Result<(), String> { let state = app.state::(); - let Some(model_id) = crate::managed_agents::relay_mesh_model_id(record) else { + let Some(model_id) = model_id else { return Ok(()); }; // A local serve/client runtime already owns the OpenAI ingress and its @@ -505,7 +505,7 @@ pub(crate) async fn ensure_relay_mesh_for_record( .await { mesh_llm::MeshRuntimeRecovery::Live => { - return wait_for_mesh_inference(&model_id).await; + return wait_for_mesh_inference(model_id).await; } mesh_llm::MeshRuntimeRecovery::Evicted | mesh_llm::MeshRuntimeRecovery::Absent => {} mesh_llm::MeshRuntimeRecovery::Debouncing => { @@ -521,7 +521,7 @@ pub(crate) async fn ensure_relay_mesh_for_record( ); } mesh_llm::MeshRuntimeRecovery::Replaced => { - return wait_for_mesh_inference(&model_id).await; + return wait_for_mesh_inference(model_id).await; } mesh_llm::MeshRuntimeRecovery::RestartRequired => { app.request_restart(); @@ -532,7 +532,7 @@ pub(crate) async fn ensure_relay_mesh_for_record( } } } - let target = match resolve_mesh_bootstrap_target(&state, &model_id).await { + let target = match resolve_mesh_bootstrap_target(&state, model_id).await { Ok(Some(target)) => target, Ok(None) => { return Err( @@ -556,8 +556,8 @@ pub(crate) async fn ensure_relay_mesh_for_record( // (the router resolves per-request), so it only cold-starts a Client when // there is genuinely no runtime. Falling back to Client if a serve node // crashed under local pressure is a desirable fail-safe, not a regression. - ensure_client_node_for_model(&state, &model_id, Some(target.endpoint_addr)).await?; - wait_for_mesh_inference(&model_id).await + ensure_client_node_for_model(&state, model_id, Some(target.endpoint_addr)).await?; + wait_for_mesh_inference(model_id).await } #[tauri::command] diff --git a/desktop/src-tauri/src/managed_agents/effective_config/mod.rs b/desktop/src-tauri/src/managed_agents/effective_config/mod.rs new file mode 100644 index 0000000000..e079c76a1d --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/effective_config/mod.rs @@ -0,0 +1,318 @@ +use std::collections::BTreeMap; + +use serde::Serialize; + +use super::global_config::GlobalAgentConfig; +use super::relay_mesh::{ + RELAY_MESH_API_BASE_URL, RELAY_MESH_API_KEY_PLACEHOLDER, RELAY_MESH_AUTO_MODEL_ID, + RELAY_MESH_PROVIDER_ID, +}; +use super::types::{AgentDefinition, ManagedAgentRecord}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ConfigSource { + Definition, + Global, + InstanceLegacy, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedField { + pub value: Option, + pub source: ConfigSource, +} + +#[derive(Debug, Clone)] +pub struct EffectiveAgentConfig { + pub model: ResolvedField, + pub provider: ResolvedField, + pub system_prompt: ResolvedField, +} + +impl EffectiveAgentConfig { + /// The relay-mesh model id this config resolves to, or `None` when the + /// effective provider isn't relay-mesh. + /// + /// This is the single authoritative mesh decision for this config. Both + /// the mesh preflight (interactive start, restore-on-launch) AND spawn's + /// `apply_relay_mesh_env` block MUST derive their mesh gate from this + /// method — never from a separate provider comparison — so the two paths + /// are guaranteed to agree even when the stored provider string has leading + /// or trailing whitespace. The provider is trimmed before matching; + /// a blank effective model falls back to "auto", mirroring + /// `apply_relay_mesh_env`'s own rule. + pub fn relay_mesh_model_id(&self) -> Option { + if self.provider.value.as_deref().map(str::trim) != Some(RELAY_MESH_PROVIDER_ID) { + return None; + } + Some( + self.model + .value + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(RELAY_MESH_AUTO_MODEL_ID) + .to_string(), + ) + } +} + +#[derive(Debug, Clone)] +pub enum EffectiveConfigResult { + Resolved(EffectiveAgentConfig), + OrphanedInstance { + record_pubkey: String, + missing_persona_id: String, + }, +} + +fn non_blank(v: Option<&str>) -> Option<&str> { + v.filter(|s| !s.trim().is_empty()) +} + +fn resolve_linked( + definition: &AgentDefinition, + global: &GlobalAgentConfig, +) -> EffectiveAgentConfig { + let model = match non_blank(definition.model.as_deref()) { + Some(m) => ResolvedField { + value: Some(m.to_owned()), + source: ConfigSource::Definition, + }, + None => ResolvedField { + value: global.model.clone(), + source: ConfigSource::Global, + }, + }; + + let provider = match non_blank(definition.provider.as_deref()) { + Some(p) => ResolvedField { + value: Some(p.to_owned()), + source: ConfigSource::Definition, + }, + None => ResolvedField { + value: global.provider.clone(), + source: ConfigSource::Global, + }, + }; + + let system_prompt = ResolvedField { + value: non_blank(Some(definition.system_prompt.as_str())).map(str::to_owned), + source: ConfigSource::Definition, + }; + + EffectiveAgentConfig { + model, + provider, + system_prompt, + } +} + +/// The API key value the relay-mesh preset wrote before the Jun-11 rename +/// window (#960, `8f580f308`) changed `RELAY_MESH_API_KEY_PLACEHOLDER` from +/// `"sprout-mesh-local"` to its current value. The old string is persisted as a +/// *value* in `env_vars` on records created before that, and no migration ever +/// rewrote it. +const LEGACY_MESH_API_KEY_PLACEHOLDER: &str = "sprout-mesh-local"; + +/// The provider key the preset wrote before #971 (`8c8312932`) renamed it to +/// `BUZZ_AGENT_PROVIDER`. That commit changed source literals only — persisted +/// `env_vars` keys were never migrated. +const LEGACY_MESH_PROVIDER_ENV_KEY: &str = "SPROUT_AGENT_PROVIDER"; + +/// The legacy env discriminator: recognizes the relay-mesh preset purely from +/// the env vars a pre-typed-field record carries, returning its served model id. +/// +/// All three sentinels must match — the local base URL alone is not enough, +/// since a user may point their own OpenAI-compatible provider at the same +/// port. The placeholder API key is what makes this Buzz's own preset. +/// +/// Two of those sentinels were renamed in the same Jun-11 window, in separate +/// commits, with neither migrating persisted records: the provider env *key* +/// (#971) and the API key *value* (#960). Each is therefore accepted under +/// either spelling, independently — a record straddling the window carries one +/// old and one new. In both cases the current spelling is authoritative when +/// present, so a record that has since been rewritten with a non-mesh value is +/// not resurrected by the stale leftover beside it. +/// +/// `OPENAI_COMPAT_BASE_URL` and `OPENAI_COMPAT_MODEL` were never renamed. +/// Nothing beyond these two either/ors is loosened: every sentinel dropped +/// widens the false-positive surface for a user's own openai-compatible agent. +fn mesh_preset_env_model_id(env_vars: &BTreeMap) -> Option { + let base_url = env_vars.get("OPENAI_COMPAT_BASE_URL")?.trim(); + if base_url.trim_end_matches('/') != RELAY_MESH_API_BASE_URL { + return None; + } + let provider = env_vars + .get("BUZZ_AGENT_PROVIDER") + .or_else(|| env_vars.get(LEGACY_MESH_PROVIDER_ENV_KEY))? + .trim(); + if provider != "openai" { + return None; + } + let api_key = env_vars.get("OPENAI_COMPAT_API_KEY")?.trim(); + if api_key != RELAY_MESH_API_KEY_PLACEHOLDER && api_key != LEGACY_MESH_API_KEY_PLACEHOLDER { + return None; + } + non_blank(env_vars.get("OPENAI_COMPAT_MODEL").map(String::as_str)).map(str::to_owned) +} + +/// The mesh model id a definition-less record carries in legacy form, or `None` +/// when it is not a legacy mesh record. +/// +/// Two shipped record generations predate `provider: "relay-mesh"` and are +/// never rewritten on load, so they are still on disk: +/// +/// - the typed `relay_mesh` marker, added before the record had a `provider` +/// field at all; +/// - before that, the mesh preset written straight into `env_vars`. +/// +/// Consulted only by [`resolve_definition_less`]: a record with no definition +/// has nothing authoritative to be overridden by, so its own bytes are the +/// highest-precedence signal it has. A linked instance never reaches here. +fn legacy_record_mesh_model_id(record: &ManagedAgentRecord) -> Option { + match &record.relay_mesh { + // The marker itself is the mesh signal; a blank `model_ref` still means + // mesh, resolved to the auto model exactly as `apply_relay_mesh_env` + // and `relay_mesh_model_id` treat a blank model. + Some(config) => Some( + non_blank(Some(config.model_ref.as_str())) + .unwrap_or(RELAY_MESH_AUTO_MODEL_ID) + .to_owned(), + ), + None => mesh_preset_env_model_id(&record.env_vars), + } +} + +fn resolve_definition_less( + record: &ManagedAgentRecord, + global: &GlobalAgentConfig, +) -> EffectiveAgentConfig { + let model = match non_blank(record.model.as_deref()) { + Some(m) => ResolvedField { + value: Some(m.to_owned()), + source: ConfigSource::InstanceLegacy, + }, + None => ResolvedField { + value: global.model.clone(), + source: ConfigSource::Global, + }, + }; + + let provider = match non_blank(record.provider.as_deref()) { + Some(p) => ResolvedField { + value: Some(p.to_owned()), + source: ConfigSource::InstanceLegacy, + }, + None => ResolvedField { + value: global.provider.clone(), + source: ConfigSource::Global, + }, + }; + + let system_prompt = ResolvedField { + value: non_blank(record.system_prompt.as_deref()).map(str::to_owned), + source: ConfigSource::InstanceLegacy, + }; + + let mut config = EffectiveAgentConfig { + model, + provider, + system_prompt, + }; + + // Legacy mesh compatibility. A record with an explicit `provider` has + // already stated its intent — including switching AWAY from mesh, which + // leaves the old marker and env bytes behind — so its legacy bytes are + // never consulted. Only a record that never carried a provider at all + // falls back, and both fields move together so the single mesh gate + // (`relay_mesh_model_id`) and the spawned model agree. + if non_blank(record.provider.as_deref()).is_none() { + if let Some(model_ref) = legacy_record_mesh_model_id(record) { + config.provider = ResolvedField { + value: Some(RELAY_MESH_PROVIDER_ID.to_owned()), + source: ConfigSource::InstanceLegacy, + }; + config.model = ResolvedField { + value: Some(model_ref), + source: ConfigSource::InstanceLegacy, + }; + } + } + + config +} + +pub fn resolve_effective_config( + record: &ManagedAgentRecord, + definitions: &[AgentDefinition], + global: &GlobalAgentConfig, +) -> EffectiveConfigResult { + match &record.persona_id { + Some(pid) => match definitions.iter().find(|d| d.id == *pid) { + Some(def) => EffectiveConfigResult::Resolved(resolve_linked(def, global)), + None => EffectiveConfigResult::OrphanedInstance { + record_pubkey: record.pubkey.clone(), + missing_persona_id: pid.clone(), + }, + }, + None => EffectiveConfigResult::Resolved(resolve_definition_less(record, global)), + } +} + +pub fn resolve_effective_model_provider_pair( + record: &ManagedAgentRecord, + definitions: &[AgentDefinition], + global: &GlobalAgentConfig, +) -> Option<(Option, Option)> { + match resolve_effective_config(record, definitions, global) { + EffectiveConfigResult::Resolved(cfg) => Some((cfg.model.value, cfg.provider.value)), + EffectiveConfigResult::OrphanedInstance { .. } => None, + } +} + +/// The relay-mesh preflight decision for `record`, resolved the same way +/// spawn resolves its mesh env: through `resolve_effective_config` (which +/// folds in the definition → global fallback). A linked instance's own +/// `provider`/`model`/`relay_mesh` bytes never contribute; a definition-less +/// legacy record may fall back to them via `legacy_record_mesh_model_id`, +/// which is confined to `resolve_definition_less`. +/// +/// `None` covers both "not a mesh agent" and "orphaned instance" — an orphan +/// never spawns (see `require_resolved`), so it never needs a mesh preflight +/// either; the caller's own orphan handling downstream is unaffected, this +/// just avoids tripping mesh bootstrap for a start that will be refused. +pub fn resolve_effective_relay_mesh_model_id( + record: &ManagedAgentRecord, + definitions: &[AgentDefinition], + global: &GlobalAgentConfig, +) -> Option { + match resolve_effective_config(record, definitions, global) { + EffectiveConfigResult::Resolved(cfg) => cfg.relay_mesh_model_id(), + EffectiveConfigResult::OrphanedInstance { .. } => None, + } +} + +/// The single user-facing message for a linked instance whose definition no +/// longer exists. Shared by every path that must refuse to act on an orphan: +/// the spawn boundary (`spawn_agent_child`), the interactive start command, +/// and provider deploy. +pub const ORPHANED_INSTANCE_ERROR: &str = + "This agent's configuration is missing — it may still be \ + syncing or was deleted on another device."; + +impl EffectiveConfigResult { + /// Unwrap into the resolved config, or the shared orphan-refusal error. + pub fn require_resolved(self) -> Result { + match self { + EffectiveConfigResult::Resolved(cfg) => Ok(cfg), + EffectiveConfigResult::OrphanedInstance { .. } => { + Err(ORPHANED_INSTANCE_ERROR.to_string()) + } + } + } +} + +#[cfg(test)] +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/effective_config/tests.rs b/desktop/src-tauri/src/managed_agents/effective_config/tests.rs new file mode 100644 index 0000000000..81c2611d5c --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/effective_config/tests.rs @@ -0,0 +1,872 @@ +use super::*; +use std::collections::BTreeMap; + +fn definition( + id: &str, + model: Option<&str>, + provider: Option<&str>, + prompt: &str, +) -> AgentDefinition { + AgentDefinition { + id: id.to_string(), + display_name: "Test Definition".to_string(), + avatar_url: None, + system_prompt: prompt.to_string(), + runtime: None, + model: model.map(str::to_string), + provider: provider.map(str::to_string), + name_pool: vec![], + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: None, + env_vars: BTreeMap::new(), + respond_to: None, + respond_to_allowlist: vec![], + parallelism: None, + created_at: "".to_string(), + updated_at: "".to_string(), + } +} + +fn record( + persona_id: Option<&str>, + model: Option<&str>, + provider: Option<&str>, + prompt: Option<&str>, +) -> ManagedAgentRecord { + use crate::managed_agents::{BackendKind, RespondTo}; + ManagedAgentRecord { + pubkey: "agent-pk".to_string(), + name: "Agent".to_string(), + persona_id: persona_id.map(str::to_string), + private_key_nsec: "".to_string(), + auth_tag: None, + relay_url: "ws://localhost:3000".to_string(), + avatar_url: None, + acp_command: "buzz-acp".to_string(), + agent_command: "goose".to_string(), + agent_command_override: None, + agent_args: vec![], + mcp_command: "".to_string(), + turn_timeout_seconds: 300, + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + parallelism: 1, + system_prompt: prompt.map(str::to_string), + model: model.map(str::to_string), + provider: provider.map(str::to_string), + persona_source_version: None, + env_vars: BTreeMap::new(), + start_on_app_launch: false, + runtime_pid: None, + backend: BackendKind::Local, + backend_agent_id: None, + provider_binary_path: None, + team_id: None, + persona_team_dir: None, + persona_name_in_team: None, + created_at: "".to_string(), + updated_at: "".to_string(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + last_error_code: None, + respond_to: RespondTo::OwnerOnly, + respond_to_allowlist: vec![], + display_name: None, + slug: None, + runtime: None, + name_pool: vec![], + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: None, + relay_mesh: None, + auto_restart_on_config_change: false, + definition_respond_to: None, + definition_respond_to_allowlist: vec![], + definition_parallelism: None, + } +} + +fn global(model: Option<&str>, provider: Option<&str>) -> GlobalAgentConfig { + GlobalAgentConfig { + model: model.map(str::to_string), + provider: provider.map(str::to_string), + ..Default::default() + } +} + +// ── Linked instance: definition → global, record ignored ── + +#[test] +fn linked_definition_model_wins_over_stale_record() { + let rec = record( + Some("d1"), + Some("stale-model"), + Some("stale-prov"), + Some("stale prompt"), + ); + let defs = vec![definition( + "d1", + Some("def-model"), + Some("def-prov"), + "def prompt", + )]; + let g = global(Some("global-model"), Some("global-prov")); + + let result = resolve_effective_config(&rec, &defs, &g); + let cfg = match result { + EffectiveConfigResult::Resolved(c) => c, + other => panic!("expected Resolved, got {:?}", other), + }; + + assert_eq!(cfg.model.value.as_deref(), Some("def-model")); + assert_eq!(cfg.model.source, ConfigSource::Definition); + assert_eq!(cfg.provider.value.as_deref(), Some("def-prov")); + assert_eq!(cfg.provider.source, ConfigSource::Definition); + assert_eq!(cfg.system_prompt.value.as_deref(), Some("def prompt")); + assert_eq!(cfg.system_prompt.source, ConfigSource::Definition); +} + +#[test] +fn linked_inherit_global_when_definition_blank() { + let rec = record( + Some("d1"), + Some("stale-model"), + Some("stale-prov"), + Some("stale prompt"), + ); + let defs = vec![definition("d1", None, None, "")]; + let g = global(Some("global-model"), Some("global-prov")); + + let result = resolve_effective_config(&rec, &defs, &g); + let cfg = match result { + EffectiveConfigResult::Resolved(c) => c, + other => panic!("expected Resolved, got {:?}", other), + }; + + assert_eq!(cfg.model.value.as_deref(), Some("global-model")); + assert_eq!(cfg.model.source, ConfigSource::Global); + assert_eq!(cfg.provider.value.as_deref(), Some("global-prov")); + assert_eq!(cfg.provider.source, ConfigSource::Global); + assert_eq!(cfg.system_prompt.value, None); + assert_eq!(cfg.system_prompt.source, ConfigSource::Definition); +} + +#[test] +fn linked_stale_record_model_is_inert() { + let rec = record(Some("d1"), Some("stale-model"), Some("stale-prov"), None); + let defs = vec![definition("d1", None, None, "")]; + let g = global(None, None); + + let result = resolve_effective_config(&rec, &defs, &g); + let cfg = match result { + EffectiveConfigResult::Resolved(c) => c, + other => panic!("expected Resolved, got {:?}", other), + }; + + assert_eq!(cfg.model.value, None); + assert_eq!(cfg.model.source, ConfigSource::Global); + assert_eq!(cfg.provider.value, None); + assert_eq!(cfg.provider.source, ConfigSource::Global); +} + +#[test] +fn linked_definition_model_set_provider_inherits() { + let rec = record(Some("d1"), None, None, None); + let defs = vec![definition("d1", Some("def-model"), None, "prompt")]; + let g = global(None, Some("global-prov")); + + let result = resolve_effective_config(&rec, &defs, &g); + let cfg = match result { + EffectiveConfigResult::Resolved(c) => c, + other => panic!("expected Resolved, got {:?}", other), + }; + + assert_eq!(cfg.model.value.as_deref(), Some("def-model")); + assert_eq!(cfg.model.source, ConfigSource::Definition); + assert_eq!(cfg.provider.value.as_deref(), Some("global-prov")); + assert_eq!(cfg.provider.source, ConfigSource::Global); +} + +#[test] +fn linked_blank_prompt_means_no_prompt() { + let rec = record(Some("d1"), None, None, Some("stale prompt on record")); + let defs = vec![definition("d1", None, None, "")]; + let g = global(None, None); + + let result = resolve_effective_config(&rec, &defs, &g); + let cfg = match result { + EffectiveConfigResult::Resolved(c) => c, + other => panic!("expected Resolved, got {:?}", other), + }; + + assert_eq!(cfg.system_prompt.value, None); + assert_eq!(cfg.system_prompt.source, ConfigSource::Definition); +} + +#[test] +fn linked_whitespace_only_definition_model_inherits_global() { + let rec = record(Some("d1"), Some("stale"), None, None); + let defs = vec![definition("d1", Some(" "), Some(" \t"), "")]; + let g = global(Some("global-model"), Some("global-prov")); + + let result = resolve_effective_config(&rec, &defs, &g); + let cfg = match result { + EffectiveConfigResult::Resolved(c) => c, + other => panic!("expected Resolved, got {:?}", other), + }; + + assert_eq!(cfg.model.value.as_deref(), Some("global-model")); + assert_eq!(cfg.model.source, ConfigSource::Global); + assert_eq!(cfg.provider.value.as_deref(), Some("global-prov")); + assert_eq!(cfg.provider.source, ConfigSource::Global); +} + +// ── Orphaned instance ── + +#[test] +fn orphaned_linked_instance_returns_error() { + let rec = record(Some("missing-def"), None, None, None); + let defs = vec![]; + let g = global(Some("global-model"), None); + + let result = resolve_effective_config(&rec, &defs, &g); + match result { + EffectiveConfigResult::OrphanedInstance { + record_pubkey, + missing_persona_id, + } => { + assert_eq!(record_pubkey, "agent-pk"); + assert_eq!(missing_persona_id, "missing-def"); + } + other => panic!("expected OrphanedInstance, got {:?}", other), + } +} + +// ── Definition-less instance: instance → global ── + +#[test] +fn definition_less_uses_own_fields() { + let rec = record(None, Some("my-model"), Some("my-prov"), Some("my prompt")); + let defs = vec![]; + let g = global(Some("global-model"), Some("global-prov")); + + let result = resolve_effective_config(&rec, &defs, &g); + let cfg = match result { + EffectiveConfigResult::Resolved(c) => c, + other => panic!("expected Resolved, got {:?}", other), + }; + + assert_eq!(cfg.model.value.as_deref(), Some("my-model")); + assert_eq!(cfg.model.source, ConfigSource::InstanceLegacy); + assert_eq!(cfg.provider.value.as_deref(), Some("my-prov")); + assert_eq!(cfg.provider.source, ConfigSource::InstanceLegacy); + assert_eq!(cfg.system_prompt.value.as_deref(), Some("my prompt")); + assert_eq!(cfg.system_prompt.source, ConfigSource::InstanceLegacy); +} + +#[test] +fn definition_less_falls_back_to_global() { + let rec = record(None, None, None, None); + let defs = vec![]; + let g = global(Some("global-model"), Some("global-prov")); + + let result = resolve_effective_config(&rec, &defs, &g); + let cfg = match result { + EffectiveConfigResult::Resolved(c) => c, + other => panic!("expected Resolved, got {:?}", other), + }; + + assert_eq!(cfg.model.value.as_deref(), Some("global-model")); + assert_eq!(cfg.model.source, ConfigSource::Global); + assert_eq!(cfg.provider.value.as_deref(), Some("global-prov")); + assert_eq!(cfg.provider.source, ConfigSource::Global); +} + +#[test] +fn definition_less_blank_record_fields_fall_through() { + let rec = record(None, Some(" "), Some(""), Some(" ")); + let defs = vec![]; + let g = global(Some("g-model"), None); + + let result = resolve_effective_config(&rec, &defs, &g); + let cfg = match result { + EffectiveConfigResult::Resolved(c) => c, + other => panic!("expected Resolved, got {:?}", other), + }; + + assert_eq!(cfg.model.value.as_deref(), Some("g-model")); + assert_eq!(cfg.model.source, ConfigSource::Global); + assert_eq!(cfg.provider.value, None); + assert_eq!(cfg.provider.source, ConfigSource::Global); + assert_eq!(cfg.system_prompt.value, None); +} + +// ── Convenience helper ── + +#[test] +fn model_provider_pair_returns_none_for_orphan() { + let rec = record(Some("missing"), None, None, None); + assert_eq!( + resolve_effective_model_provider_pair(&rec, &[], &global(None, None)), + None + ); +} + +#[test] +fn model_provider_pair_returns_resolved_values() { + let rec = record(Some("d1"), None, None, None); + let defs = vec![definition("d1", Some("m"), Some("p"), "")]; + let g = global(None, None); + + let pair = resolve_effective_model_provider_pair(&rec, &defs, &g); + assert_eq!(pair, Some((Some("m".to_string()), Some("p".to_string())))); +} + +// ── require_resolved: the shared orphan-refusal contract used by +// build_deploy_payload and spawn_agent_child ── + +#[test] +fn require_resolved_returns_shared_error_for_orphan() { + let rec = record(Some("missing"), None, None, None); + let error = resolve_effective_config(&rec, &[], &global(None, None)) + .require_resolved() + .expect_err("orphan must not resolve"); + assert_eq!(error, ORPHANED_INSTANCE_ERROR); +} + +#[test] +fn require_resolved_returns_config_for_resolved() { + let rec = record(Some("d1"), None, None, None); + let defs = vec![definition("d1", Some("m"), Some("p"), "prompt")]; + let cfg = resolve_effective_config(&rec, &defs, &global(None, None)) + .require_resolved() + .expect("linked instance with a live definition must resolve"); + assert_eq!(cfg.model.value.as_deref(), Some("m")); +} + +#[test] +fn require_resolved_refuses_orphan_only() { + let orphan = record(Some("missing"), None, None, None); + assert_eq!( + resolve_effective_config(&orphan, &[], &global(None, None)) + .require_resolved() + .unwrap_err(), + ORPHANED_INSTANCE_ERROR, + ); + + let linked = record(Some("d1"), None, None, None); + let defs = vec![definition("d1", Some("m"), None, "")]; + assert!( + resolve_effective_config(&linked, &defs, &global(None, None)) + .require_resolved() + .is_ok() + ); + + // Definition-less instances are never orphaned regardless of how bare + // their own fields are — orphan status only applies to a dangling link. + let bare = record(None, None, None, None); + assert!(resolve_effective_config(&bare, &[], &global(None, None)) + .require_resolved() + .is_ok()); +} + +// ── Morgan's exact regression sequence ── + +#[test] +fn morgans_sequence_inherit_explicit_inherit() { + let g = global(Some("claude-opus-4-6"), Some("anthropic")); + + // Step 1: fresh agent with inherited model → resolves global + let rec_step1 = record(Some("d1"), None, None, None); + let defs = vec![definition("d1", None, None, "agent prompt")]; + let cfg1 = match resolve_effective_config(&rec_step1, &defs, &g) { + EffectiveConfigResult::Resolved(c) => c, + other => panic!("step 1: {:?}", other), + }; + assert_eq!(cfg1.model.value.as_deref(), Some("claude-opus-4-6")); + assert_eq!(cfg1.model.source, ConfigSource::Global); + + // Step 2: set explicit model on definition + let defs_explicit = vec![definition( + "d1", + Some("goose-gpt-5-6-sol"), + Some("databricks"), + "agent prompt", + )]; + let cfg2 = match resolve_effective_config(&rec_step1, &defs_explicit, &g) { + EffectiveConfigResult::Resolved(c) => c, + other => panic!("step 2: {:?}", other), + }; + assert_eq!(cfg2.model.value.as_deref(), Some("goose-gpt-5-6-sol")); + assert_eq!(cfg2.model.source, ConfigSource::Definition); + + // Step 3: switch back to inherit — even with stale record bytes + let rec_stale = record( + Some("d1"), + Some("goose-gpt-5-6-sol"), + Some("databricks"), + None, + ); + let defs_inherit = vec![definition("d1", None, None, "agent prompt")]; + let cfg3 = match resolve_effective_config(&rec_stale, &defs_inherit, &g) { + EffectiveConfigResult::Resolved(c) => c, + other => panic!("step 3: {:?}", other), + }; + assert_eq!(cfg3.model.value.as_deref(), Some("claude-opus-4-6")); + assert_eq!(cfg3.model.source, ConfigSource::Global); + assert_eq!(cfg3.provider.value.as_deref(), Some("anthropic")); + assert_eq!(cfg3.provider.source, ConfigSource::Global); +} + +// ── relay-mesh preflight resolution (Wes review 2 on #1968) ── +// +// Both regressions below reproduce the exact defects: the preflight must +// key off `resolve_effective_config`'s resolution — the same one spawn's +// mesh env consults — not the record's own `provider`/`model` bytes. + +#[test] +fn relay_mesh_model_id_none_for_non_mesh_config() { + let rec = record(Some("d1"), None, None, None); + let defs = vec![definition("d1", Some("m"), Some("anthropic"), "")]; + let g = global(None, None); + + assert_eq!(resolve_effective_relay_mesh_model_id(&rec, &defs, &g), None); +} + +#[test] +fn relay_mesh_model_id_defaults_to_auto_when_model_blank() { + let rec = record(Some("d1"), None, None, None); + let defs = vec![definition("d1", None, Some(RELAY_MESH_PROVIDER_ID), "")]; + let g = global(None, None); + + assert_eq!( + resolve_effective_relay_mesh_model_id(&rec, &defs, &g).as_deref(), + Some(RELAY_MESH_AUTO_MODEL_ID) + ); +} + +/// Switch-away regression (Wes finding 1): a linked definition that used to +/// be relay-mesh but was edited to another provider must NOT trigger the mesh +/// preflight — even though the record's own stale bytes still say +/// `provider: relay-mesh`. The old `relay_mesh_config(record)` sniff read +/// those stale record bytes directly and returned Some; the resolver-driven +/// decision must read the definition's CURRENT provider and return None. +#[test] +fn switch_away_from_relay_mesh_clears_preflight_despite_stale_record_bytes() { + let rec = record(Some("d1"), Some("auto"), Some(RELAY_MESH_PROVIDER_ID), None); + let defs = vec![definition( + "d1", + Some("claude-opus-4-6"), + Some("anthropic"), + "", + )]; + let g = global(None, None); + + assert_eq!( + resolve_effective_relay_mesh_model_id(&rec, &defs, &g), + None, + "definition switched away from relay-mesh — no mesh preflight should fire" + ); +} + +/// Global-inheritance regression (Wes finding 2): a blank linked definition +/// falling through to a relay-mesh GLOBAL default must trigger the mesh +/// preflight, even though the record carries `provider: None` and no legacy +/// env — all three of the old `relay_mesh_config` branches would miss here. +#[test] +fn global_relay_mesh_default_triggers_preflight_for_blank_definition() { + let rec = record(Some("d1"), None, None, None); + let defs = vec![definition("d1", None, None, "")]; + let g = global(Some("Qwen3"), Some(RELAY_MESH_PROVIDER_ID)); + + assert_eq!( + resolve_effective_relay_mesh_model_id(&rec, &defs, &g).as_deref(), + Some("Qwen3"), + "global relay-mesh default must trigger the mesh preflight for a blank definition" + ); +} + +/// Symmetric case: a definition-less (legacy) instance inheriting the global +/// relay-mesh default must also trigger the preflight. +#[test] +fn global_relay_mesh_default_triggers_preflight_for_definition_less_instance() { + let rec = record(None, None, None, None); + let defs = vec![]; + let g = global(None, Some(RELAY_MESH_PROVIDER_ID)); + + assert_eq!( + resolve_effective_relay_mesh_model_id(&rec, &defs, &g).as_deref(), + Some(RELAY_MESH_AUTO_MODEL_ID) + ); +} + +/// Orphaned instance: no mesh preflight regardless of stale record bytes — +/// the caller's own orphan refusal is what matters, not a mesh bootstrap for +/// a start that will never happen. +#[test] +fn orphaned_instance_never_triggers_mesh_preflight() { + let rec = record( + Some("missing-def"), + Some("auto"), + Some(RELAY_MESH_PROVIDER_ID), + None, + ); + let g = global(None, None); + + assert_eq!(resolve_effective_relay_mesh_model_id(&rec, &[], &g), None); +} + +// ── Whitespace-provider parity (trim-mismatch regression, T1) ── +// +// The old spawn gate was `effective_provider.as_deref() == Some(RELAY_MESH_PROVIDER_ID)` +// — an exact compare — while `relay_mesh_model_id()` trims before matching. +// A stored provider of " relay-mesh " would make preflight fire while spawn +// skipped the mesh-env block. After the fix spawn derives its gate from +// `relay_mesh_model_id()` too, so the trim semantics are identical. +// +// These tests verify `relay_mesh_model_id()` returns Some for padded providers +// on every resolution path, confirming the helper that both callers now use +// handles whitespace correctly. + +/// Definition provider with leading/trailing whitespace — resolver preserves +/// the raw string, but `relay_mesh_model_id()` trims before matching, so the +/// mesh preflight fires correctly. +#[test] +fn whitespace_provider_in_definition_triggers_mesh_decision() { + let rec = record(Some("d1"), None, None, None); + let defs = vec![definition("d1", Some("qwen3"), Some(" relay-mesh "), "")]; + let g = global(None, None); + + let mesh_id = resolve_effective_relay_mesh_model_id(&rec, &defs, &g); + assert_eq!( + mesh_id.as_deref(), + Some("qwen3"), + "padded definition provider must be treated as relay-mesh by the shared helper" + ); +} + +/// Global provider with leading/trailing whitespace — same assertion for the +/// global-inherit path (blank definition falls through to padded global). +#[test] +fn whitespace_provider_in_global_triggers_mesh_decision() { + let rec = record(Some("d1"), None, None, None); + let defs = vec![definition("d1", None, None, "")]; + let g = global(Some("qwen3"), Some("\trelay-mesh\t")); + + let mesh_id = resolve_effective_relay_mesh_model_id(&rec, &defs, &g); + assert_eq!( + mesh_id.as_deref(), + Some("qwen3"), + "padded global provider must be treated as relay-mesh by the shared helper" + ); +} + +// ── Legacy relay-mesh record compatibility (Wes review 3 on #1968) ── +// +// Two record generations shipped before `provider: "relay-mesh"` existed and +// are never rewritten on load, so they are still on disk: +// +// Class A (#798) — the four mesh preset env vars in `env_vars`, no typed +// field, no `provider` (the record had no `provider` field yet). +// Class B (#879) — the typed `relay_mesh` marker, still no `provider` field. +// +// Without the fallback both classes resolve to a non-mesh provider while their +// own stale env bytes still reach the child: a silent misroute, not a clean +// failure. The fallback lives in `resolve_definition_less` only — a linked +// instance's definition stays authoritative. + +/// Class A: env-only legacy record resolves mesh via the four-env preset. +#[test] +fn legacy_record_falls_back_to_env_sniff() { + let mut rec = record(None, None, None, None); + rec.env_vars = BTreeMap::from([ + ("BUZZ_AGENT_PROVIDER".to_string(), "openai".to_string()), + ( + "OPENAI_COMPAT_BASE_URL".to_string(), + "http://127.0.0.1:9337/v1/".to_string(), + ), + ("OPENAI_COMPAT_MODEL".to_string(), "Qwen3".to_string()), + ( + "OPENAI_COMPAT_API_KEY".to_string(), + RELAY_MESH_API_KEY_PLACEHOLDER.to_string(), + ), + ]); + + assert_eq!( + resolve_effective_relay_mesh_model_id(&rec, &[], &global(None, None)).as_deref(), + Some("Qwen3"), + "env-only legacy mesh record must still resolve to its served model" + ); +} + +/// Class A, oldest shipped record: both renamed sentinels in their original +/// spelling. The preset first wrote `SPROUT_AGENT_PROVIDER` (#798) and the +/// api-key value `sprout-mesh-local`; #971 and #960 renamed each in the same +/// Jun-11 window without migrating persisted `env_vars`, so this exact shape is +/// still on disk and must resolve mesh. +#[test] +fn legacy_record_falls_back_to_env_sniff_with_pre_rename_sentinels() { + let mut rec = record(None, None, None, None); + rec.env_vars = BTreeMap::from([ + ("SPROUT_AGENT_PROVIDER".to_string(), "openai".to_string()), + ( + "OPENAI_COMPAT_BASE_URL".to_string(), + "http://127.0.0.1:9337/v1".to_string(), + ), + ("OPENAI_COMPAT_MODEL".to_string(), "Qwen3".to_string()), + ( + "OPENAI_COMPAT_API_KEY".to_string(), + "sprout-mesh-local".to_string(), + ), + ]); + + assert_eq!( + resolve_effective_relay_mesh_model_id(&rec, &[], &global(None, None)).as_deref(), + Some("Qwen3"), + "the oldest shipped mesh record carries both pre-rename sentinels and was never migrated" + ); +} + +/// Class A straddling the rename window: old provider key with the new api-key +/// value. Proves the two either/ors are independent rather than one either/or on +/// a bundled old/new pair. +#[test] +fn legacy_record_falls_back_to_env_sniff_with_mixed_rename_sentinels() { + let mut rec = record(None, None, None, None); + rec.env_vars = BTreeMap::from([ + ("SPROUT_AGENT_PROVIDER".to_string(), "openai".to_string()), + ( + "OPENAI_COMPAT_BASE_URL".to_string(), + "http://127.0.0.1:9337/v1".to_string(), + ), + ("OPENAI_COMPAT_MODEL".to_string(), "Qwen3".to_string()), + ( + "OPENAI_COMPAT_API_KEY".to_string(), + RELAY_MESH_API_KEY_PLACEHOLDER.to_string(), + ), + ]); + + assert_eq!( + resolve_effective_relay_mesh_model_id(&rec, &[], &global(None, None)).as_deref(), + Some("Qwen3"), + "old provider key with new api key must resolve — the sentinels renamed independently" + ); +} + +/// The mirrored straddle: new provider key with the old api-key value. +#[test] +fn legacy_record_falls_back_to_env_sniff_with_new_provider_and_old_api_key() { + let mut rec = record(None, None, None, None); + rec.env_vars = BTreeMap::from([ + ("BUZZ_AGENT_PROVIDER".to_string(), "openai".to_string()), + ( + "OPENAI_COMPAT_BASE_URL".to_string(), + "http://127.0.0.1:9337/v1".to_string(), + ), + ("OPENAI_COMPAT_MODEL".to_string(), "Qwen3".to_string()), + ( + "OPENAI_COMPAT_API_KEY".to_string(), + "sprout-mesh-local".to_string(), + ), + ]); + + assert_eq!( + resolve_effective_relay_mesh_model_id(&rec, &[], &global(None, None)).as_deref(), + Some("Qwen3"), + "new provider key with old api key must resolve too" + ); +} + +/// Both spellings present and disagreeing: the renamed key is authoritative, +/// so a record whose current provider env is non-mesh is not resurrected as +/// mesh by the stale `SPROUT_` leftover. +#[test] +fn legacy_env_sniff_prefers_renamed_provider_key_over_stale_old_key() { + let mut rec = record(None, None, None, None); + rec.env_vars = BTreeMap::from([ + ("BUZZ_AGENT_PROVIDER".to_string(), "anthropic".to_string()), + ("SPROUT_AGENT_PROVIDER".to_string(), "openai".to_string()), + ( + "OPENAI_COMPAT_BASE_URL".to_string(), + "http://127.0.0.1:9337/v1".to_string(), + ), + ("OPENAI_COMPAT_MODEL".to_string(), "Qwen3".to_string()), + ( + "OPENAI_COMPAT_API_KEY".to_string(), + RELAY_MESH_API_KEY_PLACEHOLDER.to_string(), + ), + ]); + + assert_eq!( + resolve_effective_relay_mesh_model_id(&rec, &[], &global(None, None)), + None, + "the renamed key states current intent — a stale SPROUT_ key must not override it" + ); +} + +/// Class A negative: a user's own OpenAI-compatible provider on the same local +/// port is not Buzz's preset — the placeholder API key is the discriminator. +#[test] +fn legacy_env_sniff_ignores_user_openai_on_same_local_port() { + let mut rec = record(None, None, None, None); + rec.env_vars = BTreeMap::from([ + ("BUZZ_AGENT_PROVIDER".to_string(), "openai".to_string()), + ( + "OPENAI_COMPAT_BASE_URL".to_string(), + "http://127.0.0.1:9337/v1".to_string(), + ), + ("OPENAI_COMPAT_MODEL".to_string(), "Qwen3".to_string()), + ("OPENAI_COMPAT_API_KEY".to_string(), "real-key".to_string()), + ]); + + assert_eq!( + resolve_effective_relay_mesh_model_id(&rec, &[], &global(None, None)), + None, + "a real user key on the mesh port must not be mistaken for Buzz's preset" + ); +} + +/// Class B: typed marker with no `provider` resolves mesh, and its `model_ref` +/// wins over an unrelated global model so preflight and spawn agree. +#[test] +fn legacy_record_falls_back_to_typed_marker_without_provider() { + let mut rec = record(None, None, None, None); + rec.relay_mesh = Some(crate::managed_agents::RelayMeshConfig { + model_ref: "Qwen3".to_string(), + }); + + let cfg = match resolve_effective_config(&rec, &[], &global(Some("gpt-5"), Some("openai"))) { + EffectiveConfigResult::Resolved(cfg) => cfg, + EffectiveConfigResult::OrphanedInstance { .. } => { + panic!("definition-less is never orphaned") + } + }; + + assert_eq!(cfg.provider.value.as_deref(), Some(RELAY_MESH_PROVIDER_ID)); + assert_eq!( + cfg.relay_mesh_model_id().as_deref(), + Some("Qwen3"), + "typed marker must resolve mesh even with no provider on the record" + ); +} + +/// Class B with a blank `model_ref`: the marker is still the mesh signal, and +/// the model resolves to auto — same rule `apply_relay_mesh_env` applies. +#[test] +fn legacy_typed_marker_with_blank_model_resolves_auto() { + let mut rec = record(None, None, None, None); + rec.relay_mesh = Some(crate::managed_agents::RelayMeshConfig { + model_ref: " ".to_string(), + }); + + assert_eq!( + resolve_effective_relay_mesh_model_id(&rec, &[], &global(None, None)).as_deref(), + Some(RELAY_MESH_AUTO_MODEL_ID) + ); +} + +/// A definition-less record that was switched AWAY from mesh keeps its stale +/// marker and env bytes on disk. Its explicit `provider` states current intent, +/// so the legacy fallback must not resurrect mesh. +#[test] +fn definition_less_explicit_provider_wins_over_stale_legacy_mesh_bytes() { + let mut rec = record(None, Some("claude-opus-4-6"), Some("anthropic"), None); + rec.relay_mesh = Some(crate::managed_agents::RelayMeshConfig { + model_ref: "Qwen3".to_string(), + }); + rec.env_vars = BTreeMap::from([ + ("BUZZ_AGENT_PROVIDER".to_string(), "openai".to_string()), + ( + "OPENAI_COMPAT_BASE_URL".to_string(), + "http://127.0.0.1:9337/v1".to_string(), + ), + ("OPENAI_COMPAT_MODEL".to_string(), "Qwen3".to_string()), + ( + "OPENAI_COMPAT_API_KEY".to_string(), + RELAY_MESH_API_KEY_PLACEHOLDER.to_string(), + ), + ]); + + let cfg = match resolve_effective_config(&rec, &[], &global(None, None)) { + EffectiveConfigResult::Resolved(cfg) => cfg, + EffectiveConfigResult::OrphanedInstance { .. } => { + panic!("definition-less is never orphaned") + } + }; + + assert_eq!(cfg.provider.value.as_deref(), Some("anthropic")); + assert_eq!(cfg.model.value.as_deref(), Some("claude-opus-4-6")); + assert_eq!( + cfg.relay_mesh_model_id(), + None, + "a switched-away legacy record must not be dragged back onto mesh" + ); +} + +/// The test that proves the fallback did not reintroduce the bug this PR +/// deletes: a LINKED record carrying both legacy mesh signals resolves purely +/// from its definition and never trips mesh. +#[test] +fn linked_record_ignores_legacy_mesh_marker_and_env() { + let mut rec = record(Some("d1"), Some("auto"), None, None); + rec.relay_mesh = Some(crate::managed_agents::RelayMeshConfig { + model_ref: "Qwen3".to_string(), + }); + rec.env_vars = BTreeMap::from([ + ("BUZZ_AGENT_PROVIDER".to_string(), "openai".to_string()), + ( + "OPENAI_COMPAT_BASE_URL".to_string(), + "http://127.0.0.1:9337/v1".to_string(), + ), + ("OPENAI_COMPAT_MODEL".to_string(), "Qwen3".to_string()), + ( + "OPENAI_COMPAT_API_KEY".to_string(), + RELAY_MESH_API_KEY_PLACEHOLDER.to_string(), + ), + ]); + let defs = vec![definition( + "d1", + Some("claude-opus-4-6"), + Some("anthropic"), + "", + )]; + + let cfg = match resolve_effective_config(&rec, &defs, &global(None, None)) { + EffectiveConfigResult::Resolved(cfg) => cfg, + EffectiveConfigResult::OrphanedInstance { .. } => panic!("definition exists"), + }; + + assert_eq!(cfg.provider.value.as_deref(), Some("anthropic")); + assert_eq!(cfg.model.value.as_deref(), Some("claude-opus-4-6")); + assert_eq!( + cfg.relay_mesh_model_id(), + None, + "legacy record bytes must never influence a linked instance" + ); +} + +/// A linked instance whose definition inherits a blank provider from a blank +/// global must NOT pick up the record's legacy mesh bytes either — the linked +/// path has no legacy fallback at all, by construction. +#[test] +fn linked_record_with_legacy_bytes_inherits_global_not_mesh() { + let mut rec = record(Some("d1"), None, None, None); + rec.relay_mesh = Some(crate::managed_agents::RelayMeshConfig { + model_ref: "Qwen3".to_string(), + }); + let defs = vec![definition("d1", None, None, "")]; + + let cfg = match resolve_effective_config(&rec, &defs, &global(Some("gpt-5"), Some("openai"))) { + EffectiveConfigResult::Resolved(cfg) => cfg, + EffectiveConfigResult::OrphanedInstance { .. } => panic!("definition exists"), + }; + + assert_eq!(cfg.provider.value.as_deref(), Some("openai")); + assert_eq!(cfg.model.value.as_deref(), Some("gpt-5")); + assert_eq!(cfg.relay_mesh_model_id(), None); +} diff --git a/desktop/src-tauri/src/managed_agents/env_vars.rs b/desktop/src-tauri/src/managed_agents/env_vars.rs index 6318a5c838..c652dae427 100644 --- a/desktop/src-tauri/src/managed_agents/env_vars.rs +++ b/desktop/src-tauri/src/managed_agents/env_vars.rs @@ -285,8 +285,12 @@ pub(crate) fn merged_user_env( /// Look up the live env map of `persona_id` within an already-loaded persona /// slice. Returns an empty map for standalone agents (`None`) and for links -/// to personas that no longer exist (an orphaned agent spawns from its own -/// overrides alone — same fallback the prompt/model resolution uses). +/// to personas that no longer exist. The latter is an orphaned instance, +/// which `spawn_agent_child` refuses before this is ever read at spawn time +/// (see `effective_config::resolve_effective_config`'s `OrphanedInstance` +/// arm via `require_resolved`) — an empty map here only matters for +/// non-spawn callers like readiness/hash that must still resolve +/// a slice for a record whose orphan status hasn't been checked yet. pub(crate) fn live_persona_env( personas: &[super::types::AgentDefinition], persona_id: Option<&str>, diff --git a/desktop/src-tauri/src/managed_agents/global_config/mod.rs b/desktop/src-tauri/src/managed_agents/global_config/mod.rs index 57993fc040..162f447981 100644 --- a/desktop/src-tauri/src/managed_agents/global_config/mod.rs +++ b/desktop/src-tauri/src/managed_agents/global_config/mod.rs @@ -1,13 +1,14 @@ //! Global agent configuration defaults. //! //! A single `global-agent-config.json` record that applies to ALL managed -//! agents. Per-agent config always wins; global provides the lowest -//! user-settable layer below persona. +//! agents. For a linked instance, the definition wins over global; for a +//! definition-less (legacy) instance, the record's own fields win over +//! global. Global is always the lowest user-settable layer. //! //! # Precedence (low → high) //! //! ```text -//! baked build env < GLOBAL < persona < per-agent < Buzz-identity +//! baked build env < GLOBAL < definition (linked) / instance (legacy) < Buzz-identity //! ``` //! //! # Semantics @@ -38,8 +39,10 @@ use crate::managed_agents::types::{AgentDefinition, ManagedAgentRecord}; /// so the config vocabulary is consistent across all three tiers. /// /// `env_vars` is the lowest user-settable env layer — global < persona < agent. -/// `provider` / `model` are fallback defaults: effective provider/model = -/// `agent → persona → global → None`. +/// `provider` / `model` are fallback defaults, resolved via +/// `effective_config::resolve_effective_config`: for a linked instance, +/// definition → global (the record's own `provider`/`model` bytes are never +/// consulted); for a definition-less instance, instance → global. #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] pub struct GlobalAgentConfig { /// Global env vars injected into ALL agents unconditionally. @@ -204,44 +207,28 @@ pub fn save_global_agent_config(app: &AppHandle, config: &GlobalAgentConfig) -> atomic_write_json_restricted(&path, &payload) } -/// Resolve the effective model and provider for an agent using the -/// precedence chain: `agent record → linked persona → global defaults → None`. +/// Resolve the effective model and provider for an agent. /// -/// This is the single source of truth used by readiness evaluation, spawn, -/// and deploy-payload construction. All three paths must use this function so -/// they agree on what model/provider the agent will actually run with. +/// Delegates to `effective_config::resolve_effective_config` which enforces +/// definition-authoritative semantics for linked instances: +/// - **Linked:** definition → global. Record bytes are never consulted. +/// - **Definition-less:** instance → global. +/// - **Orphaned:** returns `(None, None)`. This function is a display/ +/// readiness/hash convenience, not the spawn gate — an orphan must never +/// reach a real spawn regardless of what this returns, because +/// `spawn_agent_child` refuses orphans itself via +/// `effective_config::resolve_effective_config(...).require_resolved()` +/// before resolving model/provider/prompt for the process env. /// -/// # Arguments -/// * `record` — the `ManagedAgentRecord` (may have `None` for model/provider) -/// * `personas` — all current persona records (looked up by `record.persona_id`) -/// * `global` — global agent config defaults -/// -/// # Returns -/// `(effective_model, effective_provider)` — both `Option<&str>`. -pub(crate) fn resolve_effective_model_provider<'a>( - record: &'a ManagedAgentRecord, - personas: &'a [AgentDefinition], - global: &'a GlobalAgentConfig, -) -> (Option<&'a str>, Option<&'a str>) { - let (persona_model, persona_provider) = record - .persona_id - .as_deref() - .and_then(|pid| personas.iter().find(|p| p.id == pid)) - .map(|p| (p.model.as_deref(), p.provider.as_deref())) - .unwrap_or((None, None)); - - let effective_model = record - .model - .as_deref() - .or(persona_model) - .or(global.model.as_deref()); - let effective_provider = record - .provider - .as_deref() - .or(persona_provider) - .or(global.provider.as_deref()); - - (effective_model, effective_provider) +/// Returns owned `String`s because the effective value may come from a +/// tier that outlives none of the input borrows (e.g. global config). +pub(crate) fn resolve_effective_model_provider( + record: &ManagedAgentRecord, + personas: &[AgentDefinition], + global: &GlobalAgentConfig, +) -> (Option, Option) { + super::effective_config::resolve_effective_model_provider_pair(record, personas, global) + .unwrap_or((None, None)) } #[cfg(test)] diff --git a/desktop/src-tauri/src/managed_agents/global_config/tests.rs b/desktop/src-tauri/src/managed_agents/global_config/tests.rs index 6eb4338034..33b93d8a52 100644 --- a/desktop/src-tauri/src/managed_agents/global_config/tests.rs +++ b/desktop/src-tauri/src/managed_agents/global_config/tests.rs @@ -376,15 +376,14 @@ fn persona(id: &str, model: Option<&str>, provider: Option<&str>) -> AgentDefini } } -/// Tier 1 — agent record wins: record has explicit model/provider; they must -/// outrank both the linked persona and the global defaults. Fails against any -/// implementation that prefers global or persona over the record. +/// Linked instance: definition (persona) wins — stale record bytes are +/// ignored. This is the core model-inheritance fix. #[test] -fn resolve_agent_record_wins_over_persona_and_global() { +fn resolve_definition_wins_over_stale_record_for_linked_instance() { let mut record = bare_record(); record.persona_id = Some("p1".to_string()); - record.model = Some("record-model".to_string()); - record.provider = Some("record-provider".to_string()); + record.model = Some("stale-record-model".to_string()); + record.provider = Some("stale-record-provider".to_string()); let personas = vec![persona( "p1", Some("persona-model"), @@ -398,11 +397,15 @@ fn resolve_agent_record_wins_over_persona_and_global() { let (model, provider) = resolve_effective_model_provider(&record, &personas, &global); - assert_eq!(model, Some("record-model"), "record model must win"); assert_eq!( - provider, - Some("record-provider"), - "record provider must win" + model.as_deref(), + Some("persona-model"), + "definition model must win over stale record" + ); + assert_eq!( + provider.as_deref(), + Some("persona-provider"), + "definition provider must win over stale record" ); } @@ -428,12 +431,12 @@ fn resolve_persona_fallback_when_record_has_none() { let (model, provider) = resolve_effective_model_provider(&record, &personas, &global); assert_eq!( - model, + model.as_deref(), Some("persona-model"), "persona model must be used when record has none" ); assert_eq!( - provider, + provider.as_deref(), Some("persona-provider"), "persona provider must be used when record has none" ); @@ -458,12 +461,12 @@ fn resolve_global_fallback_when_record_and_persona_have_none() { let (model, provider) = resolve_effective_model_provider(&record, &personas, &global); assert_eq!( - model, + model.as_deref(), Some("global-model"), "global model must be used when record and persona have none" ); assert_eq!( - provider, + provider.as_deref(), Some("global-provider"), "global provider must be used when record and persona have none" ); @@ -519,8 +522,8 @@ fn resolve_global_fallback_when_no_persona_linked() { let (model, provider) = resolve_effective_model_provider(&record, &personas, &global); - assert_eq!(model, Some("global-model")); - assert_eq!(provider, Some("global-provider")); + assert_eq!(model.as_deref(), Some("global-model")); + assert_eq!(provider.as_deref(), Some("global-provider")); } /// All-None: no source provides model/provider → both must be None. @@ -543,18 +546,15 @@ fn resolve_all_none_when_no_source_provides_values() { ); } -/// Partial tier — record has model but not provider; persona has provider but -/// not model; global has both. Each field resolves independently through the -/// three-tier chain. +/// Each field resolves independently: definition model=None → global fills +/// model; definition has provider → definition wins for provider. Stale +/// record bytes are ignored for linked instances. #[test] fn resolve_each_field_resolves_independently_through_tiers() { let mut record = bare_record(); record.persona_id = Some("p1".to_string()); - record.model = Some("record-model".to_string()); - // record.provider = None → falls through to persona + record.model = Some("stale-record-model".to_string()); let personas = vec![persona("p1", None, Some("persona-provider"))]; - // persona.model = None → global fills model if record also had none, but - // record has model here so global is not needed for model. let global = GlobalAgentConfig { model: Some("global-model".to_string()), provider: Some("global-provider".to_string()), @@ -563,11 +563,15 @@ fn resolve_each_field_resolves_independently_through_tiers() { let (model, provider) = resolve_effective_model_provider(&record, &personas, &global); - assert_eq!(model, Some("record-model"), "record wins for model"); assert_eq!( - provider, + model.as_deref(), + Some("global-model"), + "definition model=None → global fills model; stale record ignored" + ); + assert_eq!( + provider.as_deref(), Some("persona-provider"), - "persona wins for provider when record has none" + "definition provider wins" ); } diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index 66f3d882b9..d213142352 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -8,6 +8,7 @@ pub(crate) use agent_env::{ mod backend; pub(crate) mod config_bridge; mod discovery; +pub(crate) mod effective_config; mod env_vars; pub(crate) mod git_bash; pub(crate) mod global_config; diff --git a/desktop/src-tauri/src/managed_agents/persona_events.rs b/desktop/src-tauri/src/managed_agents/persona_events.rs index 2033003bc8..5b62615a8c 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events.rs @@ -359,10 +359,14 @@ pub fn persona_event_content(record: &AgentDefinition) -> PersonaEventContent { } /// A persona's spawn-relevant config, pinned onto a `ManagedAgentRecord` at -/// create time. After the snapshot, spawn and deploy read these fields off the -/// record and never the live persona, so an agent stays pinned to the config -/// it was created with — restart reuses the snapshot, delete+respawn rewrites -/// it. +/// create time for display/backward-compat purposes. For a linked instance, +/// spawn and deploy do NOT read these snapshotted fields — they resolve +/// model/provider/prompt live from the definition on every spawn (see +/// `effective_config::resolve_effective_config`), so a definition edit +/// propagates on the next restart without delete+respawn. The snapshot still +/// matters for `runtime` (materialized per B5, no live-read path yet) and for +/// `persona_source_version`, the drift basis the Agents menu compares against +/// the definition's current content hash. pub struct PersonaSnapshot { pub system_prompt: Option, pub model: Option, @@ -377,24 +381,6 @@ pub struct PersonaSnapshot { pub source_version: String, } -/// Apply persona-wins-when-set precedence for a single optional string field. -/// -/// Returns the persona's value when it is non-`None` and non-whitespace-only; -/// otherwise falls back to the record's value with the same blank filter applied. -/// Returns `None` only when both are blank — a genuinely unconfigured field stays -/// unconfigured. -/// -/// This is the single source of truth for the precedence rule used by -/// `persona_snapshot_with_agent_config_fallback`, `build_deploy_payload`, and -/// `resolve_effective_prompt_model_provider` so the three paths cannot drift. -pub fn persona_field_with_record_fallback( - persona_value: Option<&str>, - record_value: Option<&str>, -) -> Option { - let non_blank = |v: Option<&str>| v.filter(|s| !s.trim().is_empty()).map(str::to_owned); - non_blank(persona_value).or_else(|| non_blank(record_value)) -} - /// Build the pinned snapshot for an agent created from `persona`. /// /// The persona's `system_prompt` is always present, so it is wrapped in @@ -411,51 +397,14 @@ pub fn persona_snapshot(persona: &AgentDefinition) -> PersonaSnapshot { } } -/// Build the pinned snapshot for an **existing** agent record being re-snapshotted -/// from its linked persona (on spawn or app-launch restore). +/// Re-pin `record` to `persona`: build a snapshot via [`persona_snapshot`] +/// and mirror it onto the record — the definition quad +/// (`system_prompt`/`model`/`provider`/`runtime`), the env-override +/// self-heal, and the `persona_source_version` drift basis. /// -/// Precedence rule: when the persona sets `model` or `provider` (non-`None`, non-empty), -/// the persona wins — this is the expected inheritance. When the persona leaves -/// these fields blank (`None` or empty string), the agent record's own values are -/// preserved instead. This prevents a persona with no configured model/provider from -/// clobbering a value the user already set on the agent, which would trap the agent -/// in a permanent "needs configuration" loop that users cannot escape. -/// -/// `source_version` is always updated to the current persona content hash so the -/// drift badge clears correctly even when model/provider are not touched. -/// -/// Env vars are not part of the snapshot: `record.env_vars` (agent overrides) -/// is left untouched and the live persona env is merged underneath at read time. -/// -/// The two fields (`model`, `provider`) are independent: a persona that sets only -/// `model` wins on `model` while the agent's `provider` is preserved, and vice versa. -pub fn persona_snapshot_with_agent_config_fallback( - persona: &AgentDefinition, - current_agent_model: Option<&str>, - current_agent_provider: Option<&str>, -) -> PersonaSnapshot { - // Delegate system_prompt and source_version to persona_snapshot so future - // PersonaSnapshot field additions stay automatically consistent. - let base = persona_snapshot(persona); - - // Apply the shared precedence rule: persona wins when non-blank, else - // the agent record's value is preserved so a configured agent stays configured. - let model = persona_field_with_record_fallback(base.model.as_deref(), current_agent_model); - let provider = - persona_field_with_record_fallback(base.provider.as_deref(), current_agent_provider); - - PersonaSnapshot { - model, - provider, - ..base - } -} - -/// Re-pin `record` to `persona`: build the snapshot (via -/// [`persona_snapshot_with_agent_config_fallback`], so blank persona -/// `model`/`provider` preserve the record's own values) and mirror it onto the -/// record — the definition quad (`system_prompt`/`model`/`provider`/`runtime`), -/// the env-override self-heal, and the `persona_source_version` drift basis. +/// Definition-authoritative: blank definition model/provider produce `None` +/// on the record. The effective-config resolver falls through to the global +/// default at read time; stale materialized record bytes are never preserved. /// /// This is the single apply used by every snapshot-apply site: the spawn /// re-pin (`start_local_agent_with_preflight`), the launch backfill and @@ -467,11 +416,7 @@ pub fn persona_snapshot_with_agent_config_fallback( /// caller's concern, and `spawn_config_hash` (which applies this to a clone) /// must stay pure. pub fn apply_persona_snapshot(record: &mut ManagedAgentRecord, persona: &AgentDefinition) { - let snapshot = persona_snapshot_with_agent_config_fallback( - persona, - record.model.as_deref(), // fallback: record.model - record.provider.as_deref(), // fallback: record.provider - ); + let snapshot = persona_snapshot(persona); if let Some(prompt) = snapshot.system_prompt { record.system_prompt = Some(prompt); } @@ -507,5 +452,33 @@ pub fn apply_persona_snapshot(record: &mut ManagedAgentRecord, persona: &AgentDe .retain(|k, v| persona.env_vars.get(k) != Some(v)); record.persona_source_version = Some(snapshot.source_version); } + +/// Preview what `record` would look like immediately after the start/restore +/// paths re-pin it to its linked persona, without mutating `record` itself. +/// +/// Every decision made ahead of the real re-pin — the relay-mesh preflight in +/// `start_local_agent_with_preflight`, the restart-badge hash in +/// `spawn_config_hash` — needs to reason about spawn-time state, not +/// pre-snapshot bytes, so a persona edit that flips a field (e.g. `provider` +/// to/from relay-mesh) between saves is reflected in the decision instead of +/// the stale value the real [`apply_persona_snapshot`] is about to overwrite +/// anyway. Idempotent: applying it to an already-current record is a no-op, +/// so the spawn-time stamp and later recomputes agree when nothing changed. +/// +/// Orphaned records (persona deleted) pass through unchanged: the caller's +/// own orphan handling — refusing to spawn, hashing as `(None, None, None)` +/// — runs on the real record downstream, not on this preview. +pub fn preview_prospective_persona_snapshot( + record: &ManagedAgentRecord, + personas: &[AgentDefinition], +) -> ManagedAgentRecord { + let mut preview = record.clone(); + if let Some(persona_id) = preview.persona_id.clone() { + if let Some(persona) = personas.iter().find(|p| p.id == persona_id) { + apply_persona_snapshot(&mut preview, persona); + } + } + preview +} #[cfg(test)] mod tests; diff --git a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs index d62cc5b4bb..27d3b0ce06 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events/tests.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events/tests.rs @@ -1,4 +1,141 @@ use super::*; +use crate::managed_agents::{BackendKind, ManagedAgentRecord, RespondTo}; + +/// A linked instance record with no persona-derived fields set yet — the +/// state right after creation, before any snapshot apply. +fn sample_record() -> ManagedAgentRecord { + ManagedAgentRecord { + pubkey: "p".repeat(64), + name: "agent".into(), + persona_id: Some("test-persona".into()), + private_key_nsec: "nsec1fake".into(), + auth_tag: None, + relay_url: "ws://localhost:3000".into(), + avatar_url: None, + acp_command: "buzz-acp".into(), + agent_command: "goose".into(), + agent_command_override: None, + agent_args: vec![], + mcp_command: String::new(), + turn_timeout_seconds: 320, + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + parallelism: 1, + system_prompt: None, + model: None, + provider: None, + persona_source_version: None, + env_vars: BTreeMap::new(), + start_on_app_launch: false, + auto_restart_on_config_change: true, + runtime_pid: None, + backend: BackendKind::Local, + backend_agent_id: None, + provider_binary_path: None, + team_id: None, + persona_team_dir: None, + persona_name_in_team: None, + created_at: "now".into(), + updated_at: "now".into(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + last_error_code: None, + respond_to: RespondTo::OwnerOnly, + respond_to_allowlist: vec![], + display_name: None, + slug: None, + runtime: None, + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + source_team: None, + source_team_persona_slug: None, + definition_respond_to: None, + definition_respond_to_allowlist: Vec::new(), + definition_parallelism: None, + relay_mesh: None, + } +} + +// ── preview_prospective_persona_snapshot (Finding 4: relay-mesh preflight +// ordering) ─────────────────────────────────────────────────────────────── + +/// Regression for the relay-mesh preflight-ordering defect: the preflight +/// used to check `ensure_relay_mesh_for_record` against the record's stale +/// pre-snapshot bytes, so a persona edit that flips `provider` to +/// `relay-mesh` between saves would not trigger the mesh preflight on the +/// very next start — only on the restart after that, once the real +/// `apply_persona_snapshot` had already landed. The preview must reflect the +/// same prospective re-snapshot the real spawn path applies. +#[test] +fn preview_reflects_persona_edit_flipping_provider_into_relay_mesh() { + let record = sample_record(); // provider: None (not relay-mesh) + let mut persona = sample_persona(); + persona.id = "test-persona".into(); + persona.provider = Some("relay-mesh".into()); + persona.model = Some("auto".into()); + + let preview = preview_prospective_persona_snapshot(&record, &[persona]); + + assert_eq!( + preview.provider.as_deref(), + Some("relay-mesh"), + "preview must carry the persona's current provider, not the record's stale None" + ); + assert_eq!(preview.model.as_deref(), Some("auto")); +} + +/// Preview reflects a persona edit flipping AWAY from relay-mesh too — +/// symmetric with the into-mesh case above. +#[test] +fn preview_reflects_persona_edit_flipping_provider_out_of_relay_mesh() { + let mut record = sample_record(); + record.provider = Some("relay-mesh".into()); + record.model = Some("auto".into()); + let mut persona = sample_persona(); + persona.id = "test-persona".into(); + persona.provider = Some("anthropic".into()); + persona.model = Some("claude-opus-4".into()); + + let preview = preview_prospective_persona_snapshot(&record, &[persona]); + + assert_eq!(preview.provider.as_deref(), Some("anthropic")); + assert_eq!(preview.model.as_deref(), Some("claude-opus-4")); +} + +/// The preview must not mutate the record passed in — callers (the mesh +/// preflight) need the original bytes intact for the real snapshot apply +/// that follows. +#[test] +fn preview_does_not_mutate_input_record() { + let record = sample_record(); + let original = record.clone(); + let mut persona = sample_persona(); + persona.id = "test-persona".into(); + persona.provider = Some("relay-mesh".into()); + + let _ = preview_prospective_persona_snapshot(&record, &[persona]); + + assert_eq!(record.provider, original.provider); + assert_eq!(record.model, original.model); +} + +/// Orphaned instance (persona deleted): the preview must pass the record +/// through unchanged so downstream orphan handling (refuse to spawn) sees +/// the same bytes it always would. +#[test] +fn preview_passes_through_unchanged_when_persona_missing() { + let mut record = sample_record(); + record.provider = Some("anthropic".into()); + record.persona_id = Some("deleted-persona".into()); + + let preview = preview_prospective_persona_snapshot(&record, &[]); + + assert_eq!(preview.provider.as_deref(), Some("anthropic")); + assert_eq!(preview.persona_id.as_deref(), Some("deleted-persona")); +} fn sample_persona() -> AgentDefinition { AgentDefinition { @@ -437,43 +574,6 @@ fn persona_content_hash_changes_on_edit() { ); } -// ── persona_field_with_record_fallback ──────────────────────────────────── - -#[test] -fn field_fallback_persona_present_wins() { - assert_eq!( - persona_field_with_record_fallback(Some("persona-model"), Some("record-model")), - Some("persona-model".to_owned()), - ); -} - -#[test] -fn field_fallback_persona_blank_uses_record() { - assert_eq!( - persona_field_with_record_fallback(None, Some("record-model")), - Some("record-model".to_owned()), - ); - assert_eq!( - persona_field_with_record_fallback(Some(" "), Some("record-model")), - Some("record-model".to_owned()), - ); -} - -#[test] -fn field_fallback_both_blank_is_none() { - assert_eq!(persona_field_with_record_fallback(None, None), None); - assert_eq!(persona_field_with_record_fallback(Some(""), Some("")), None); -} - -#[test] -fn field_fallback_record_blank_is_none() { - assert_eq!( - persona_field_with_record_fallback(None, Some(" ")), - None, - "whitespace-only record value must also be treated as blank" - ); -} - // ── PersonaSnapshot.runtime ─────────────────────────────────────────────── /// (b) The snapshot carries the persona's runtime VERBATIM — including None, @@ -484,7 +584,7 @@ fn field_fallback_record_blank_is_none() { #[test] fn snapshot_runtime_verbatim_from_persona() { let persona = sample_persona(); // runtime = Some("goose") - let snap = persona_snapshot_with_agent_config_fallback(&persona, Some("gpt-4"), Some("openai")); + let snap = persona_snapshot(&persona); assert_eq!( snap.runtime.as_deref(), Some("goose"), @@ -493,15 +593,14 @@ fn snapshot_runtime_verbatim_from_persona() { let mut no_runtime = sample_persona(); no_runtime.runtime = None; - let snap = - persona_snapshot_with_agent_config_fallback(&no_runtime, Some("gpt-4"), Some("openai")); + let snap = persona_snapshot(&no_runtime); assert_eq!( snap.runtime, None, "persona runtime None must produce None snapshot (clears stale materialized value)" ); } -// ── persona_snapshot_with_agent_config_fallback ──────────────────────────── +// ── persona_snapshot (definition-authoritative) ────────────────────────── /// Helper: a persona with no model/provider configured. fn blank_model_persona() -> AgentDefinition { @@ -512,25 +611,22 @@ fn blank_model_persona() -> AgentDefinition { } } -/// (a) Persona leaves model/provider blank, agent record has values → -/// record values preserved AND source_version still updated to current hash. +/// Definition-authoritative: blank definition model/provider → snapshot is +/// None. The effective-config resolver handles global fallback at read time. #[test] -fn fallback_preserves_record_values_when_persona_blank() { +fn blank_definition_clears_model_provider() { let persona = blank_model_persona(); let expected_version = persona_content_hash(&persona_event_content(&persona)); - let snapshot = - persona_snapshot_with_agent_config_fallback(&persona, Some("gpt-4o"), Some("openai")); + let snapshot = persona_snapshot(&persona); - assert_eq!( - snapshot.model.as_deref(), - Some("gpt-4o"), - "blank persona model must fall back to agent record value" + assert!( + snapshot.model.is_none(), + "blank definition model must produce None" ); - assert_eq!( - snapshot.provider.as_deref(), - Some("openai"), - "blank persona provider must fall back to agent record value" + assert!( + snapshot.provider.is_none(), + "blank definition provider must produce None" ); assert_eq!( snapshot.source_version, expected_version, @@ -538,124 +634,102 @@ fn fallback_preserves_record_values_when_persona_blank() { ); } -/// (b) Persona has model/provider set → persona wins over agent record. +/// Persona has model/provider set → snapshot carries them verbatim. #[test] -fn fallback_persona_wins_when_set() { +fn snapshot_carries_persona_model_provider() { let persona = sample_persona(); // has model=Some("claude-opus-4"), provider=Some("anthropic") - let snapshot = persona_snapshot_with_agent_config_fallback( - &persona, - Some("gpt-4o"), // agent had a different model - Some("openai"), // agent had a different provider - ); + let snapshot = persona_snapshot(&persona); assert_eq!( snapshot.model.as_deref(), Some("claude-opus-4"), - "persona model must win when persona has a value" + "persona model must be carried verbatim" ); assert_eq!( snapshot.provider.as_deref(), Some("anthropic"), - "persona provider must win when persona has a value" + "persona provider must be carried verbatim" ); } -/// (c) Both blank → snapshot keeps None; a genuinely unconfigured agent +/// Both blank → snapshot keeps None; a genuinely unconfigured agent /// stays unconfigured (no fabricated values). #[test] -fn fallback_both_blank_stays_none() { +fn both_blank_stays_none() { let persona = blank_model_persona(); - let snapshot = persona_snapshot_with_agent_config_fallback( - &persona, None, // agent also has no model - None, // agent also has no provider - ); + let snapshot = persona_snapshot(&persona); assert!( snapshot.model.is_none(), - "neither persona nor agent has model — snapshot must be None" + "blank persona model → snapshot None" ); assert!( snapshot.provider.is_none(), - "neither persona nor agent has provider — snapshot must be None" + "blank persona provider → snapshot None" ); } -/// Whitespace-only values on the persona are treated as blank; agent -/// fallback applies. +/// Whitespace-only values on the definition are preserved verbatim in the +/// snapshot. The effective-config resolver treats whitespace as blank. #[test] -fn fallback_treats_whitespace_only_persona_value_as_blank() { +fn whitespace_only_definition_preserved_verbatim() { let mut persona = sample_persona(); persona.model = Some(" ".to_string()); persona.provider = Some("\t".to_string()); - let snapshot = persona_snapshot_with_agent_config_fallback( - &persona, - Some("claude-opus-4"), - Some("anthropic"), - ); + let snapshot = persona_snapshot(&persona); assert_eq!( snapshot.model.as_deref(), - Some("claude-opus-4"), - "whitespace-only persona model must be treated as blank" + Some(" "), + "whitespace-only model is preserved verbatim in snapshot" ); assert_eq!( snapshot.provider.as_deref(), - Some("anthropic"), - "whitespace-only persona provider must be treated as blank" + Some("\t"), + "whitespace-only provider is preserved verbatim in snapshot" ); } -/// Cross-field independence: persona sets model but not provider → model -/// comes from persona, provider falls back to the record. This is the -/// practically common case (model-only personas). +/// Cross-field independence: definition sets model but not provider → model +/// comes from definition, provider is None (global fallback at read time). #[test] -fn fallback_persona_model_set_provider_blank_uses_record_provider() { - let mut persona = sample_persona(); // model=Some("claude-opus-4"), provider=Some("anthropic") - persona.provider = None; // blank provider on persona +fn definition_model_set_provider_blank_produces_none_provider() { + let mut persona = sample_persona(); + persona.provider = None; - let snapshot = persona_snapshot_with_agent_config_fallback( - &persona, - Some("gpt-4o"), // record model (should be overridden by persona) - Some("openai"), // record provider (should be preserved) - ); + let snapshot = persona_snapshot(&persona); assert_eq!( snapshot.model.as_deref(), Some("claude-opus-4"), - "persona model must win when persona has a value" + "definition model must be used" ); - assert_eq!( - snapshot.provider.as_deref(), - Some("openai"), - "record provider must be used when persona provider is blank" + assert!( + snapshot.provider.is_none(), + "definition provider=None → snapshot None" ); } -/// Inverse: persona sets provider but not model → provider comes from -/// persona, model falls back to the record. +/// Definition provider set, model blank → model=None (global fallback at +/// read time), provider from definition. #[test] -fn fallback_persona_provider_set_model_blank_uses_record_model() { - let mut persona = sample_persona(); // model=Some("claude-opus-4"), provider=Some("anthropic") - persona.model = None; // blank model on persona +fn definition_provider_set_model_blank_produces_none_model() { + let mut persona = sample_persona(); + persona.model = None; - let snapshot = persona_snapshot_with_agent_config_fallback( - &persona, - Some("gpt-4o"), // record model (should be preserved) - Some("openai"), // record provider (should be overridden by persona) - ); + let snapshot = persona_snapshot(&persona); - assert_eq!( - snapshot.model.as_deref(), - Some("gpt-4o"), - "record model must be used when persona model is blank" + assert!( + snapshot.model.is_none(), + "definition model=None → snapshot None" ); assert_eq!( snapshot.provider.as_deref(), Some("anthropic"), - "persona provider must win when persona has a value" + "definition provider must be used" ); } diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index c04dfa88a4..fb45cc9a4e 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -98,11 +98,6 @@ pub(crate) fn resolve_effective_agent_env( // Layer 1: baked build defaults (floor — internal builds only; OSS = empty). let mut env = baked_build_env(); - // Layer 2: runtime metadata env vars (model / provider keys derived from - // the record's structured fields, with global as fallback). - // - // Uses the shared resolver to guarantee readiness and spawn agree on the - // effective model/provider: agent → persona → global → None. let (effective_model, effective_provider) = super::global_config::resolve_effective_model_provider(record, personas, global); @@ -111,8 +106,8 @@ pub(crate) fn resolve_effective_agent_env( rt.model_env_var, rt.provider_env_var, rt.provider_locked, - effective_model, - effective_provider, + effective_model.as_deref(), + effective_provider.as_deref(), ) { env.insert(key.to_string(), value.to_string()); } @@ -138,7 +133,11 @@ pub(crate) fn resolve_effective_agent_env( // Buzz shared compute is a native Buzz provider. Translate it to buzz-agent's // OpenAI-compatible transport only in the effective runtime environment. #[cfg(feature = "mesh-llm")] - super::apply_relay_mesh_env(&mut env, effective_provider, effective_model); + super::apply_relay_mesh_env( + &mut env, + effective_provider.as_deref(), + effective_model.as_deref(), + ); EffectiveAgentEnv { env, diff --git a/desktop/src-tauri/src/managed_agents/relay_mesh.rs b/desktop/src-tauri/src/managed_agents/relay_mesh.rs index a47122df80..934155e881 100644 --- a/desktop/src-tauri/src/managed_agents/relay_mesh.rs +++ b/desktop/src-tauri/src/managed_agents/relay_mesh.rs @@ -1,6 +1,3 @@ -#[cfg(feature = "mesh-llm")] -use super::{ManagedAgentRecord, RelayMeshConfig}; - pub const RELAY_MESH_API_BASE_URL: &str = "http://127.0.0.1:9337/v1"; pub const RELAY_MESH_API_KEY_PLACEHOLDER: &str = "buzz-mesh-local"; pub const RELAY_MESH_PROVIDER_ID: &str = "relay-mesh"; @@ -46,146 +43,11 @@ pub fn apply_relay_mesh_env( env.insert("BUZZ_AGENT_THINKING_EFFORT".to_string(), "none".to_string()); } -/// Resolve a record's relay-mesh config, typed field first. -/// -/// Source of truth is the typed `record.relay_mesh` field. For records saved -/// before that field existed, fall back to detecting the relay-mesh preset -/// from `env_vars` (the legacy discriminator). New records carry the typed -/// field and need no env-var sniffing at all. -#[cfg(feature = "mesh-llm")] -pub fn relay_mesh_config(record: &ManagedAgentRecord) -> Option { - if record.provider.as_deref() == Some(RELAY_MESH_PROVIDER_ID) { - let model_ref = record - .model - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or(RELAY_MESH_AUTO_MODEL_ID) - .to_string(); - return Some(RelayMeshConfig { model_ref }); - } - if let Some(config) = &record.relay_mesh { - return Some(config.clone()); - } - relay_mesh_model_id_from_env(record).map(|model_ref| RelayMeshConfig { model_ref }) -} - -/// Returns the relay-mesh model id for agents whose provider env points at the -/// local mesh client endpoint created by Buzz's relay-mesh preset. -/// -/// Prefer [`relay_mesh_config`]; this remains as a convenience for call sites -/// that only need the model id. -#[cfg(feature = "mesh-llm")] -pub fn relay_mesh_model_id(record: &ManagedAgentRecord) -> Option { - relay_mesh_config(record).map(|config| config.model_ref) -} - -/// Legacy env-var discriminator: detects the relay-mesh preset purely from the -/// four preset env vars. Used as a fallback for records saved before the typed -/// `relay_mesh` field existed. -#[cfg(feature = "mesh-llm")] -fn relay_mesh_model_id_from_env(record: &ManagedAgentRecord) -> Option { - let base_url = record.env_vars.get("OPENAI_COMPAT_BASE_URL")?.trim(); - if base_url.trim_end_matches('/') != RELAY_MESH_API_BASE_URL { - return None; - } - let provider = record.env_vars.get("BUZZ_AGENT_PROVIDER")?.trim(); - if provider != "openai" { - return None; - } - let api_key = record.env_vars.get("OPENAI_COMPAT_API_KEY")?.trim(); - if api_key != RELAY_MESH_API_KEY_PLACEHOLDER { - return None; - } - record - .env_vars - .get("OPENAI_COMPAT_MODEL") - .map(|value| value.trim()) - .filter(|value| !value.is_empty()) - .map(str::to_string) -} - #[cfg(all(test, feature = "mesh-llm"))] mod tests { use std::collections::BTreeMap; use super::*; - use crate::managed_agents::{BackendKind, ManagedAgentRecord, RespondTo}; - - fn fixture() -> ManagedAgentRecord { - ManagedAgentRecord { - pubkey: "p".into(), - name: "n".into(), - persona_id: None, - private_key_nsec: "nsec1fake".into(), - auth_tag: Some("tag".into()), - relay_url: "ws://localhost:3000".into(), - avatar_url: None, - acp_command: "buzz-acp".into(), - agent_command: "goose".into(), - agent_command_override: None, - agent_args: vec![], - mcp_command: String::new(), - turn_timeout_seconds: 320, - idle_timeout_seconds: None, - max_turn_duration_seconds: None, - parallelism: 1, - system_prompt: None, - model: None, - env_vars: BTreeMap::new(), - start_on_app_launch: false, - auto_restart_on_config_change: true, - runtime_pid: None, - backend: BackendKind::Local, - backend_agent_id: None, - provider_binary_path: None, - team_id: None, - persona_team_dir: None, - persona_name_in_team: None, - persona_source_version: None, - provider: None, - created_at: "now".into(), - updated_at: "now".into(), - last_started_at: None, - last_stopped_at: None, - last_exit_code: None, - last_error: None, - last_error_code: None, - respond_to: RespondTo::OwnerOnly, - respond_to_allowlist: vec![], - display_name: None, - slug: None, - runtime: None, - name_pool: Vec::new(), - is_builtin: false, - is_active: true, - source_team: None, - source_team_persona_slug: None, - definition_respond_to: None, - definition_respond_to_allowlist: Vec::new(), - definition_parallelism: None, - relay_mesh: None, - } - } - - #[test] - fn relay_mesh_model_id_detects_mesh_preset_env() { - let mut rec = fixture(); - rec.env_vars = BTreeMap::from([ - ("BUZZ_AGENT_PROVIDER".to_string(), "openai".to_string()), - ( - "OPENAI_COMPAT_BASE_URL".to_string(), - "http://127.0.0.1:9337/v1/".to_string(), - ), - ("OPENAI_COMPAT_MODEL".to_string(), "Qwen3".to_string()), - ( - "OPENAI_COMPAT_API_KEY".to_string(), - RELAY_MESH_API_KEY_PLACEHOLDER.to_string(), - ), - ]); - - assert_eq!(relay_mesh_model_id(&rec).as_deref(), Some("Qwen3")); - } #[test] fn native_provider_uses_context_safe_non_reasoning_budget() { @@ -205,95 +67,4 @@ mod tests { Some("none") ); } - - #[test] - fn relay_mesh_model_id_ignores_non_mesh_openai_env() { - let mut rec = fixture(); - rec.env_vars = BTreeMap::from([ - ("BUZZ_AGENT_PROVIDER".to_string(), "openai".to_string()), - ( - "OPENAI_COMPAT_BASE_URL".to_string(), - "https://api.openai.com/v1".to_string(), - ), - ("OPENAI_COMPAT_MODEL".to_string(), "gpt-5".to_string()), - ]); - - assert_eq!(relay_mesh_model_id(&rec), None); - } - - #[test] - fn relay_mesh_model_id_ignores_user_openai_on_same_local_port() { - let mut rec = fixture(); - rec.env_vars = BTreeMap::from([ - ("BUZZ_AGENT_PROVIDER".to_string(), "openai".to_string()), - ( - "OPENAI_COMPAT_BASE_URL".to_string(), - "http://127.0.0.1:9337/v1".to_string(), - ), - ("OPENAI_COMPAT_MODEL".to_string(), "Qwen3".to_string()), - ("OPENAI_COMPAT_API_KEY".to_string(), "real-key".to_string()), - ]); - - assert_eq!(relay_mesh_model_id(&rec), None); - } - - #[test] - fn native_provider_fields_are_authoritative() { - // The whole point: a typed record needs no env-var sniffing to be - // recognized as a relay-mesh agent. - let mut rec = fixture(); - rec.provider = Some(RELAY_MESH_PROVIDER_ID.to_string()); - rec.model = Some("Qwen3".to_string()); - assert!(rec.env_vars.is_empty()); - assert_eq!( - relay_mesh_config(&rec), - Some(RelayMeshConfig { - model_ref: "Qwen3".to_string() - }) - ); - assert_eq!(relay_mesh_model_id(&rec).as_deref(), Some("Qwen3")); - } - - #[test] - fn native_provider_fields_win_over_legacy_config() { - let mut rec = fixture(); - rec.provider = Some(RELAY_MESH_PROVIDER_ID.to_string()); - rec.model = Some("native-model".to_string()); - rec.relay_mesh = Some(RelayMeshConfig { - model_ref: "typed-model".to_string(), - }); - rec.env_vars = BTreeMap::from([ - ("BUZZ_AGENT_PROVIDER".to_string(), "openai".to_string()), - ( - "OPENAI_COMPAT_BASE_URL".to_string(), - "http://127.0.0.1:9337/v1".to_string(), - ), - ("OPENAI_COMPAT_MODEL".to_string(), "env-model".to_string()), - ( - "OPENAI_COMPAT_API_KEY".to_string(), - RELAY_MESH_API_KEY_PLACEHOLDER.to_string(), - ), - ]); - assert_eq!(relay_mesh_model_id(&rec).as_deref(), Some("native-model")); - } - - #[test] - fn legacy_record_falls_back_to_env_sniff() { - // Records saved before the typed field still resolve via env vars. - let mut rec = fixture(); - rec.relay_mesh = None; - rec.env_vars = BTreeMap::from([ - ("BUZZ_AGENT_PROVIDER".to_string(), "openai".to_string()), - ( - "OPENAI_COMPAT_BASE_URL".to_string(), - "http://127.0.0.1:9337/v1".to_string(), - ), - ("OPENAI_COMPAT_MODEL".to_string(), "Qwen3".to_string()), - ( - "OPENAI_COMPAT_API_KEY".to_string(), - RELAY_MESH_API_KEY_PLACEHOLDER.to_string(), - ), - ]); - assert_eq!(relay_mesh_model_id(&rec).as_deref(), Some("Qwen3")); - } } diff --git a/desktop/src-tauri/src/managed_agents/restore.rs b/desktop/src-tauri/src/managed_agents/restore.rs index fab481e12b..1910620159 100644 --- a/desktop/src-tauri/src/managed_agents/restore.rs +++ b/desktop/src-tauri/src/managed_agents/restore.rs @@ -1,5 +1,3 @@ -#[cfg(feature = "mesh-llm")] -use super::relay_mesh_model_id; use super::{ find_managed_agent_mut, kill_stale_tracked_processes, load_managed_agents, load_personas, save_managed_agents, spawn_agent_child, sync_managed_agent_processes, BackendKind, @@ -36,10 +34,9 @@ type AgentSpawnResult = (String, SpawnOutcome); /// `model`/`provider` were clobbered by the old unconditional snapshot code before /// this fix — are skipped here; they self-heal on the next manual start via the /// start-path re-snapshot in `start_local_agent_with_preflight`. -/// If the linked persona is gone, we log loudly and leave the snapshot empty — -/// the record's own `system_prompt`/`model` (possibly empty for persona-created -/// agents) is then all the config that remains, which is the same fallback an -/// orphaned agent already gets. +/// If the linked persona is gone, we log loudly and leave the record untouched — +/// it stays orphaned and `spawn_agent_child` refuses to start it (see +/// `effective_config::resolve_effective_config`'s `OrphanedInstance` arm). pub fn backfill_persona_snapshots(app: &tauri::AppHandle) -> Result<(), String> { let state = app.state::(); let _store_guard = state @@ -66,7 +63,7 @@ pub fn backfill_persona_snapshots(app: &tauri::AppHandle) -> Result<(), String> } let Some(persona) = personas.iter().find(|p| p.id == persona_id) else { eprintln!( - "buzz-desktop: persona-snapshot backfill: agent {} links persona {persona_id} which no longer exists; leaving snapshot empty — it will spawn from its record fields", + "buzz-desktop: persona-snapshot backfill: agent {} links persona {persona_id} which no longer exists; leaving it orphaned — spawn will refuse it", record.pubkey ); continue; @@ -206,6 +203,9 @@ pub async fn restore_managed_agents_on_launch( continue; }; let Some(persona) = personas_for_snapshot.iter().find(|p| p.id == persona_id) else { + // Orphaned: no current persona to re-snapshot from. Leave the + // record as-is — `spawn_agent_child` (Phase B below) refuses to + // spawn it and Phase C persists the refusal to `last_error`. continue; }; super::persona_events::apply_persona_snapshot(record, persona); @@ -240,16 +240,26 @@ pub async fn restore_managed_agents_on_launch( #[cfg(feature = "mesh-llm")] let agents_to_start = { + // Preflight against the same resolution spawn uses — `resolve_effective_config` + // (definition → global fallback). A linked instance's own `provider`/`model`/ + // `relay_mesh` bytes never contribute. See `start_local_agent_with_preflight` + // in `commands/agents.rs` for the identical rationale on the interactive path. + let personas = load_personas(app).unwrap_or_default(); + let global = super::load_global_agent_config(app).unwrap_or_default(); let mut mesh_preflight_failures = std::collections::HashSet::new(); for record in &agents_to_start { - if relay_mesh_model_id(record).is_none() { + let mesh_model_id = super::effective_config::resolve_effective_relay_mesh_model_id( + record, &personas, &global, + ); + if mesh_model_id.is_none() { continue; } // Auto-start after relaunch: re-resolve a live bootstrap target and // dial it. Skip (with an actionable error) only when no live target // serves this model right now. if let Err(error) = - crate::commands::ensure_relay_mesh_for_record(app, record, false).await + crate::commands::ensure_relay_mesh_for_record(app, mesh_model_id.as_deref(), false) + .await { persist_restore_error(app, &state, &record.pubkey, error)?; mesh_preflight_failures.insert(record.pubkey.clone()); diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index b9c7ec65ea..79ee8df1ac 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -189,12 +189,37 @@ pub fn build_managed_agent_summary( } }; - // Display contract: show the pinned record snapshot — what the agent - // actually runs — not the live persona. The drift flags below signal when - // the snapshot has fallen behind an edited persona; showing live values - // next to an "out of date" badge would contradict it. let (persona_out_of_date, persona_orphaned) = persona_drift_state(record, personas); + let global_for_summary = + crate::managed_agents::load_global_agent_config(app).unwrap_or_default(); + let effective_cfg = crate::managed_agents::effective_config::resolve_effective_config( + record, + personas, + &global_for_summary, + ); + let (effective_model, effective_provider, effective_prompt, model_source) = match effective_cfg + { + crate::managed_agents::effective_config::EffectiveConfigResult::Resolved(cfg) => { + let source = cfg.model.source.clone(); + ( + cfg.model.value, + cfg.provider.value, + cfg.system_prompt.value, + Some(source), + ) + } + crate::managed_agents::effective_config::EffectiveConfigResult::OrphanedInstance { + record_pubkey, + missing_persona_id, + } => { + eprintln!( + "orphaned agent instance: pubkey={record_pubkey}, missing_persona_id={missing_persona_id}" + ); + (None, None, None, None) + } + }; + // Restart badge: the running process stamped its effective spawn config // at launch; recompute from current disk state and flag drift. Only the // tracked live pair for THIS workspace can drift — stopped agents spawn @@ -227,7 +252,13 @@ pub fn build_managed_agent_summary( runtime.adapter_availability.as_ref(), super::adapter_availability_cached(), ); - hash_drift || availability_drift + // An orphan can never be restarted successfully — + // `spawn_agent_child` refuses it before any process side effect — + // so `needs_restart` must never fire for one regardless of hash or + // availability drift. Surfacing "Restart required" here would offer + // an action guaranteed to fail; the UI shows `persona_orphaned` + // instead (see `ManagedAgentSummary::persona_orphaned`). + restart_eligible(persona_orphaned, hash_drift, availability_drift) }); // Resolve the effective harness the same way, then derive args/mcp from it, @@ -254,10 +285,11 @@ pub fn build_managed_agent_summary( idle_timeout_seconds: record.idle_timeout_seconds, max_turn_duration_seconds: record.max_turn_duration_seconds, parallelism: record.parallelism, - system_prompt: record.system_prompt.clone(), + system_prompt: effective_prompt, avatar_url: record.avatar_url.clone(), - model: record.model.clone(), - provider: record.provider.clone(), + model: effective_model, + model_source, + provider: effective_provider, persona_out_of_date, persona_orphaned, needs_restart, @@ -281,6 +313,19 @@ pub fn build_managed_agent_summary( }) } +/// Pure predicate: should the "Restart required" badge fire? +/// +/// An orphaned linked instance (its persona/definition no longer exists) +/// can never be restarted successfully — `spawn_agent_child` refuses to +/// spawn it before any process side effect. Surfacing "Restart required" +/// for one would offer an action guaranteed to fail, so this always +/// returns `false` for an orphan regardless of drift. Extracted for unit +/// testing without `AppHandle`/global state, following the +/// `availability_drift` pattern in `discovery.rs`. +fn restart_eligible(persona_orphaned: bool, hash_drift: bool, availability_drift: bool) -> bool { + !persona_orphaned && (hash_drift || availability_drift) +} + pub fn find_managed_agent_mut<'a>( records: &'a mut [ManagedAgentRecord], pubkey: &str, @@ -389,6 +434,33 @@ pub fn spawn_agent_child( return Err(error); } let runtime_key = ManagedAgentRuntimeKey::new(record.pubkey.clone(), relay_url)?; + // Resolve the effective harness (agent command) from the linked persona, so + // persona harness edits propagate on the next spawn; an explicit per-agent + // override wins. `agent_args` and `mcp_command` are pure derivations of the + // command, so we recompute them from the effective value rather than the + // frozen record snapshot. Mirrors the model resolution below. + let personas = super::load_personas(app).unwrap_or_default(); + let teams = super::load_teams(app).unwrap_or_default(); + // Load global config once; used for runtime_metadata_env_vars (model/provider fallback) + // and for the env-var merge at spawn time. + let global = crate::managed_agents::load_global_agent_config(app).unwrap_or_default(); + + // Resolve model/provider/prompt ONCE, here, at the shared spawn boundary — + // the single source both the env writes below and `spawn_config_hash` + // read from. Previously prompt was read from the record's own (possibly + // stale, Phase-A-snapshot) bytes while model/provider were resolved live + // from `personas`; a definition edit landing between a caller's snapshot + // apply and this spawn could hand a fresh model/provider to a stale + // prompt. This also folds in orphan refusal via `require_resolved`: every + // caller (interactive start, launch restore, `start_managed_agent_process`) + // inherits it — no caller can bypass this by reaching `spawn_agent_child` + // directly. Checked before any side effect (log marker, log file, process + // spawn) so a refused spawn leaves no trace. + let effective_cfg = crate::managed_agents::effective_config::resolve_effective_config( + record, &personas, &global, + ) + .require_resolved()?; + let log_path = super::managed_agent_runtime_log_path(app, &runtime_key)?; append_log_marker( &log_path, @@ -404,16 +476,6 @@ pub fn spawn_agent_child( let stderr = stdout .try_clone() .map_err(|error| format!("failed to clone log handle: {error}"))?; - // Resolve the effective harness (agent command) from the linked persona, so - // persona harness edits propagate on the next spawn; an explicit per-agent - // override wins. `agent_args` and `mcp_command` are pure derivations of the - // command, so we recompute them from the effective value rather than the - // frozen record snapshot. Mirrors the model resolution below. - let personas = super::load_personas(app).unwrap_or_default(); - let teams = super::load_teams(app).unwrap_or_default(); - // Load global config once; used for runtime_metadata_env_vars (model/provider fallback) - // and for the env-var merge at spawn time. - let global = crate::managed_agents::load_global_agent_config(app).unwrap_or_default(); let effective_command = super::record_agent_command(record, &personas); let agent_args = normalize_agent_args(&effective_command, record.agent_args.clone()); let resolved_acp_command = resolve_command(&record.acp_command) @@ -628,37 +690,40 @@ pub fn spawn_agent_child( command.env_remove("BUZZ_ACP_TEAM_INSTRUCTIONS"); } - // System prompt via the shared spawn-effective filter — the SAME function the - // config hash digests, so env write and badge cannot disagree (see - // `effective_spawn_prompt` for the Some("")/None collapse). Model and provider - // use the shared - // resolver: agent → persona → global → None, so a global-default-only agent - // spawns with the correct provider/model env. - let effective_prompt = super::spawn_hash::effective_spawn_prompt(record); - let (effective_model, effective_provider) = - crate::managed_agents::resolve_effective_model_provider(record, &personas, &global); + // Prompt, model, and provider all come from the single `effective_cfg` + // resolved at the top of this function — the SAME resolve `spawn_config_hash` + // performs below, so env write and restart badge cannot disagree. Linked + // instances never consult the record's own model/provider/prompt bytes; + // definition-less instances fall back to their own fields, then global. + // + // Derive the mesh decision BEFORE moving fields out — `relay_mesh_model_id` + // is the single authoritative gate; the mesh-llm block below MUST use it + // rather than re-deriving from `effective_provider` to keep preflight and + // spawn semantics in lock-step (see `EffectiveAgentConfig::relay_mesh_model_id`). + #[cfg(feature = "mesh-llm")] + let mesh_model_id = effective_cfg.relay_mesh_model_id(); + let effective_prompt = effective_cfg.system_prompt.value; + let effective_model = effective_cfg.model.value; + let effective_provider = effective_cfg.provider.value; if let Some(prompt) = &effective_prompt { command.env("BUZZ_ACP_SYSTEM_PROMPT", prompt); } else { command.env_remove("BUZZ_ACP_SYSTEM_PROMPT"); } - if let Some(model) = effective_model { + if let Some(model) = effective_model.as_deref() { command.env("BUZZ_ACP_MODEL", model); } else { command.env_remove("BUZZ_ACP_MODEL"); } - // Baked-in provider defaults for internal builds (buzz-releases sets - // BUZZ_BUILD_BUZZ_AGENT_* at compile time; OSS builds bake nothing). - // Written FIRST so that record/persona metadata env vars below override them. build_buzz_agent_provider_defaults(&mut command); if let Some(meta) = runtime_meta { for (key, value) in runtime_metadata_env_vars( meta.model_env_var, meta.provider_env_var, meta.provider_locked, - effective_model, - effective_provider, + effective_model.as_deref(), + effective_provider.as_deref(), ) { command.env(key, value); } @@ -749,10 +814,17 @@ pub fn spawn_agent_child( // Buzz shared compute is stored as a native provider; derive the OpenAI-compatible // transport at spawn time and scrub any unrelated ambient OpenAI key. + // Gate on `mesh_model_id` (derived from `effective_cfg.relay_mesh_model_id()` + // above) — not on `effective_provider` directly — so the mesh gate here + // uses the same trim semantics as the preflight callers. #[cfg(feature = "mesh-llm")] - if effective_provider == Some(super::RELAY_MESH_PROVIDER_ID) { + if let Some(ref mesh_model_id) = mesh_model_id { let mut mesh_env = std::collections::BTreeMap::new(); - super::apply_relay_mesh_env(&mut mesh_env, effective_provider, effective_model); + super::apply_relay_mesh_env( + &mut mesh_env, + Some(super::RELAY_MESH_PROVIDER_ID), + Some(mesh_model_id.as_str()), + ); command.env_remove("OPENAI_API_KEY"); for (key, value) in mesh_env { command.env(key, value); diff --git a/desktop/src-tauri/src/managed_agents/runtime/metadata.rs b/desktop/src-tauri/src/managed_agents/runtime/metadata.rs index 56d2e68353..ac9e3a0a3f 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/metadata.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/metadata.rs @@ -24,18 +24,11 @@ pub(crate) fn runtime_metadata_env_vars<'a>( vars } -/// Resolve the effective (prompt, model, provider) triple for a persona-linked agent. -/// -/// Given a persona_id, finds the persona in the list and returns its system_prompt, -/// model, and provider as the authoritative values. When the persona leaves `model` -/// or `provider` blank (None or whitespace-only), falls back to the record's own -/// field using the same precedence rule as `persona_snapshot_with_agent_config_fallback` -/// so the display surface matches spawn behavior. Falls back to the record's own -/// prompt/model/provider when no persona is linked or found. +/// Resolve effective prompt/model/provider using definition-authoritative +/// semantics for linked instances. /// /// Used by `agent_config.rs` to inject persona defaults into the config surface -/// before running the reader, so BuzzExplicit-tagged fields can be re-tagged to -/// PersonaDefault for fields the record did not independently set. +/// before running the reader. pub(crate) fn resolve_effective_prompt_model_provider( persona_id: Option<&str>, personas: &[crate::managed_agents::types::AgentDefinition], @@ -43,13 +36,16 @@ pub(crate) fn resolve_effective_prompt_model_provider( record_model: Option, record_provider: Option, ) -> (Option, Option, Option) { - let fallback = crate::managed_agents::persona_events::persona_field_with_record_fallback; match persona_id.and_then(|pid| personas.iter().find(|p| p.id == pid)) { - Some(p) => ( - Some(p.system_prompt.clone()), - fallback(p.model.as_deref(), record_model.as_deref()), // fallback: record.model - fallback(p.provider.as_deref(), record_provider.as_deref()), // fallback: record.provider - ), + Some(p) => { + fn non_blank(v: Option<&str>) -> Option { + v.filter(|s| !s.trim().is_empty()).map(str::to_owned) + } + let prompt = non_blank(Some(&p.system_prompt)); + let model = non_blank(p.model.as_deref()); + let provider = non_blank(p.provider.as_deref()); + (prompt, model, provider) + } None => (record_prompt, record_model, record_provider), } } diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index 019bfe61d3..55fa8efba7 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -315,20 +315,16 @@ fn persona_with_provider( // that the old create-time env baking silently blocked. use crate::managed_agents::env_vars::{live_persona_env, merged_user_env}; -use crate::managed_agents::persona_events::persona_snapshot; use std::collections::BTreeMap; /// Apply a persona snapshot onto a record, mirroring `create_managed_agent`: -/// snapshotted prompt/model/provider/source_version are pinned, with the -/// system_prompt unwrapped (the persona always carries one). `env_vars` is -/// deliberately NOT touched — it stays agent overrides only. +/// links the record to `persona.id`, then delegates the actual snapshot +/// (prompt/model/provider/runtime/source_version) to the real production +/// `apply_persona_snapshot` — so a change to that function's behavior is +/// exercised by these tests instead of silently diverging from it. fn pin_persona(record: &mut ManagedAgentRecord, persona: &crate::managed_agents::AgentDefinition) { - let snapshot = persona_snapshot(persona); record.persona_id = Some(persona.id.clone()); - record.system_prompt = snapshot.system_prompt; - record.model = snapshot.model; - record.provider = snapshot.provider; - record.persona_source_version = Some(snapshot.source_version); + crate::managed_agents::persona_events::apply_persona_snapshot(record, persona); } fn persona_v( @@ -428,19 +424,31 @@ fn agent_env_overrides_win_over_persona_env_at_spawn() { } #[test] -fn orphaned_agent_spawns_from_its_own_overrides() { - // Persona deleted: the live merge degrades to the record's own overrides. +fn orphaned_agent_refused_at_spawn_boundary() { + // Persona deleted: `spawn_agent_child` must refuse before any process + // side effect, not silently degrade to the record's stale overrides. + // `require_resolved` on the shared resolver is the pure predicate + // `spawn_agent_child` checks first — this pins the contract without + // needing a real `AppHandle`. let persona = persona_v("p", "prompt", &[("ANTHROPIC_API_KEY", "persona-key")]); let mut record = fixture(RespondTo::Anyone, vec![], Some("tag".into())); record.env_vars = BTreeMap::from([("EXTRA".to_string(), "agent-value".to_string())]); pin_persona(&mut record, &persona); - let env = spawn_user_env(&record, &[]); - assert_eq!(env.get("EXTRA").map(String::as_str), Some("agent-value")); + // The persona is absent from the live catalog — same shape restore/start + // see when a persona was deleted on another device. + let no_personas: &[crate::managed_agents::AgentDefinition] = &[]; + let error = crate::managed_agents::effective_config::resolve_effective_config( + &record, + no_personas, + &Default::default(), + ) + .require_resolved() + .unwrap_err(); assert_eq!( - env.get("ANTHROPIC_API_KEY"), - None, - "a deleted persona's env must not linger" + error, + crate::managed_agents::effective_config::ORPHANED_INSTANCE_ERROR.to_string(), + "an orphaned linked instance must be refused, not spawned from its own overrides" ); } @@ -1010,3 +1018,37 @@ fn invalid_pubkey_resolves_no_pair_key() { // the summary must fall back to the stopped/legacy-pid path, not panic. assert!(super::resolve_workspace_pair_key("not-a-key", "", "wss://one.example").is_none()); } + +// ── restart_eligible tests ────────────────────────────────────────────── + +#[test] +fn restart_eligible_true_when_non_orphan_has_hash_drift() { + assert!(super::restart_eligible(false, true, false)); +} + +#[test] +fn restart_eligible_true_when_non_orphan_has_availability_drift() { + assert!(super::restart_eligible(false, false, true)); +} + +#[test] +fn restart_eligible_false_when_orphan_has_hash_drift() { + // An orphan can never be restarted successfully — spawn refuses it — + // so hash drift alone must not surface "Restart required". + assert!(!super::restart_eligible(true, true, false)); +} + +#[test] +fn restart_eligible_false_when_orphan_has_availability_drift() { + assert!(!super::restart_eligible(true, false, true)); +} + +#[test] +fn restart_eligible_false_when_orphan_has_no_drift() { + assert!(!super::restart_eligible(true, false, false)); +} + +#[test] +fn restart_eligible_false_when_non_orphan_has_no_drift() { + assert!(!super::restart_eligible(false, false, false)); +} diff --git a/desktop/src-tauri/src/managed_agents/spawn_hash.rs b/desktop/src-tauri/src/managed_agents/spawn_hash.rs index 4d80549288..8cf0014e36 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_hash.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_hash.rs @@ -28,24 +28,14 @@ use std::hash::{DefaultHasher, Hash, Hasher}; use super::{ + effective_config::{resolve_effective_config, EffectiveConfigResult}, known_acp_runtime, normalize_agent_args, - persona_events::apply_persona_snapshot, + persona_events::preview_prospective_persona_snapshot, resolve_effective_agent_env, types::{AgentDefinition, ManagedAgentRecord, TeamRecord}, GlobalAgentConfig, }; -/// The prompt a spawn would actually deliver: `Some("")` collapses to `None` -/// because an empty `BUZZ_ACP_SYSTEM_PROMPT` is no prompt. -/// -/// The single source of truth for the spawn env write AND the config hash. -pub(crate) fn effective_spawn_prompt(record: &ManagedAgentRecord) -> Option { - record - .system_prompt - .clone() - .filter(|prompt| !prompt.is_empty()) -} - /// Resolve the current instructions for this instance's deployment-time team binding. /// A deleted team deliberately degrades to no team section. pub(crate) fn effective_team_instructions( @@ -78,12 +68,7 @@ pub(crate) fn spawn_config_hash( // when nothing changed. The persona env itself reaches the hash through // `resolve_effective_agent_env` below; `persona_source_version` is set on // the clone but is not a hash input. - let mut record = record.clone(); - if let Some(persona_id) = record.persona_id.clone() { - if let Some(persona) = personas.iter().find(|p| p.id == persona_id) { - apply_persona_snapshot(&mut record, persona); - } - } + let record = preview_prospective_persona_snapshot(record, personas); let record = &record; let effective_command = crate::managed_agents::record_agent_command(record, personas); @@ -109,11 +94,24 @@ pub(crate) fn spawn_config_hash( // resolved: every record spawns on the workspace relay (legacy pins // ignored), so a workspace relay change must trip the badge. crate::relay::effective_agent_relay_url(&record.relay_url, workspace_relay).hash(&mut hasher); - // Prompt and runtime-layered team instructions use the same resolver as spawn. - effective_spawn_prompt(record).hash(&mut hasher); + // Team instructions use the same resolver as spawn. effective_team_instructions(record, teams).hash(&mut hasher); - record.model.hash(&mut hasher); - record.provider.hash(&mut hasher); + // Prompt, model, and provider all come from ONE `resolve_effective_config` + // call — the SAME resolve `spawn_agent_child` performs for the env write, + // so env write and this badge cannot disagree. An orphaned link (missing + // definition) hashes as if all three were absent: `spawn_agent_child` + // refuses to spawn an orphan regardless, so this is a display-only + // convenience, not the spawn gate. + let (resolved_prompt, resolved_model, resolved_provider) = + match resolve_effective_config(record, personas, global) { + EffectiveConfigResult::Resolved(cfg) => { + (cfg.system_prompt.value, cfg.model.value, cfg.provider.value) + } + EffectiveConfigResult::OrphanedInstance { .. } => (None, None, None), + }; + resolved_prompt.hash(&mut hasher); + resolved_model.hash(&mut hasher); + resolved_provider.hash(&mut hasher); record.auth_tag.hash(&mut hasher); record.respond_to.as_str().hash(&mut hasher); // The allowlist is hashed as the env receives it: spawn sets diff --git a/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs b/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs index 6438b3b37d..44ad7f4bbc 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_hash/tests.rs @@ -441,18 +441,123 @@ fn missing_definition_leaves_materialized_runtime_in_hash() { ); } +// ── Global default trips hash for linked inherited agents ───────────────── + #[test] -fn effective_spawn_prompt_matches_hash_semantics() { - // The env write and the hash share effective_spawn_prompt — this row - // pins the helper's own semantics so a refactor of either caller cannot - // silently diverge from the contract. - let mut r = record(); - r.system_prompt = Some(String::new()); - assert_eq!( - effective_spawn_prompt(&r), - None, - "empty collapses to absent" +fn global_model_change_trips_hash_for_linked_inherited_agent() { + let mut rec = record(); + rec.persona_id = Some("p1".into()); + rec.model = Some("stale-record-model".into()); + + let personas = vec![persona("p1", Some("goose"), "prompt")]; + + let global_a = GlobalAgentConfig { + model: Some("model-a".to_string()), + provider: Some("prov-a".to_string()), + ..Default::default() + }; + let global_b = GlobalAgentConfig { + model: Some("model-b".to_string()), + provider: Some("prov-b".to_string()), + ..Default::default() + }; + + let hash_a = spawn_config_hash(&rec, &personas, &[], "wss://ws.example", &global_a); + let hash_b = spawn_config_hash(&rec, &personas, &[], "wss://ws.example", &global_b); + + assert_ne!( + hash_a, hash_b, + "changing the global default must trip the hash for a linked inherited agent" + ); +} + +#[test] +fn global_model_change_trips_hash_without_model_env_var() { + let mut rec = record(); + rec.persona_id = Some("p1".into()); + rec.agent_command = "some-harness-without-model-env".into(); + + let personas = vec![{ + let mut p = persona("p1", None, "prompt"); + p.model = None; + p.provider = None; + p + }]; + + let global_a = GlobalAgentConfig { + model: Some("model-a".to_string()), + ..Default::default() + }; + let global_b = GlobalAgentConfig { + model: Some("model-b".to_string()), + ..Default::default() + }; + + let hash_a = spawn_config_hash(&rec, &personas, &[], "wss://ws.example", &global_a); + let hash_b = spawn_config_hash(&rec, &personas, &[], "wss://ws.example", &global_b); + + assert_ne!( + hash_a, hash_b, + "global model change must trip hash even without a model_env_var runtime" + ); +} + +#[test] +fn linked_instance_stale_prompt_bytes_are_inert_at_hash_time() { + // Regression for the split-resolve defect: prompt used to be read from + // the record's own (possibly Phase-A-snapshot-stale) bytes while + // model/provider were resolved live from the definition. A definition + // edit landing between a caller's snapshot apply and spawn could hand a + // fresh model/provider to a stale prompt, and the hash (which already + // resolved model/provider live) would silently agree with a spawn that + // wrote the stale prompt. Now both come from one `resolve_effective_config` + // call, so a record whose own `system_prompt` bytes disagree with the + // live definition must hash exactly as if the record carried the + // definition's prompt verbatim — the record's prompt bytes are inert for + // a linked instance. + let mut rec = record(); + rec.persona_id = Some("p1".into()); + rec.system_prompt = Some("stale prompt on record".into()); + + let mut matching_bytes = rec.clone(); + matching_bytes.system_prompt = Some("live prompt".into()); + + let personas = [persona("p1", Some("goose"), "live prompt")]; + + assert_eq!( + spawn_config_hash( + &rec, + &personas, + &[], + "wss://ws.example", + &Default::default() + ), + spawn_config_hash( + &matching_bytes, + &personas, + &[], + "wss://ws.example", + &Default::default() + ), + "record's own system_prompt bytes must not affect the hash of a linked instance" + ); +} + +#[test] +fn linked_instance_prompt_model_provider_resolve_from_one_call() { + // The prompt for a linked instance must track the definition, exactly + // like model/provider — a definition prompt edit trips the hash even + // though the record's own (stale) system_prompt bytes are unchanged. + let mut rec = record(); + rec.persona_id = Some("p1".into()); + rec.system_prompt = Some("stale".into()); + + let before = [persona("p1", Some("goose"), "old definition prompt")]; + let after = [persona("p1", Some("goose"), "new definition prompt")]; + + assert_ne!( + spawn_config_hash(&rec, &before, &[], "wss://ws.example", &Default::default()), + spawn_config_hash(&rec, &after, &[], "wss://ws.example", &Default::default()), + "linked instance prompt must resolve from the live definition, not stale record bytes" ); - r.system_prompt = Some("real".into()); - assert_eq!(effective_spawn_prompt(&r).as_deref(), Some("real")); } diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index af4908911b..cce2cd4039 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -259,13 +259,19 @@ pub struct ManagedAgentRecord { /// Desired LLM model ID. Matches AgentModelInfo.id from discovery. /// The harness re-discovers the correct ACP switching metadata at session /// creation by matching this ID against the fresh session/new response. + /// For a linked instance this is a legacy/display snapshot only — spawn + /// and deploy resolve the effective model from the definition, never + /// from this field (see `effective_config::resolve_effective_config`). + /// For a definition-less instance this field is authoritative. #[serde(default)] pub model: Option, - /// LLM inference provider snapshotted from the persona at create time - /// (e.g. 'databricks', 'anthropic'). Spawn and deploy read this, never the - /// live persona — so the agent stays pinned to the provider it was created - /// with across restarts. `#[serde(default)]` so pre-existing records - /// deserialize as `None` and get backfilled on first load. + /// LLM inference provider. For a linked instance this is a legacy/display + /// snapshot only — spawn and deploy resolve the effective provider from + /// the definition, never from this field (see + /// `effective_config::resolve_effective_config`). For a definition-less + /// instance this field is authoritative. `#[serde(default)]` so + /// pre-existing records deserialize as `None` and get backfilled on + /// first load. #[serde(default)] pub provider: Option, /// Content hash of the persona at the time this agent was created — the @@ -390,12 +396,15 @@ pub struct ManagedAgentRecord { /// inference through Buzz's relay-mesh local endpoint; the `model_ref` is /// the served model id to route to. `None` is a normal agent. /// - /// This is the source of truth for "is this a mesh agent + which model" — - /// replacing the old practice of sniffing it back out of `env_vars` - /// (`relay_mesh_config`). Spawn-time env vars are *derived from* this, not - /// the other way around. `#[serde(default)]` so pre-existing saved records - /// deserialize as `None` and are resolved via the env-var fallback until - /// they are rewritten with this field. + /// Not the source of truth. `provider == "relay-mesh"` is, resolved through + /// `effective_config::resolve_effective_config`; spawn-time env vars are + /// derived from that resolution. This field is retained solely as a + /// backward-compatibility signal for records written before the record had + /// a `provider` field, and is consulted only for definition-less records + /// that carry no provider — after which the env-var preset is the last + /// fallback. A linked instance's marker is never read: its definition is + /// authoritative. `#[serde(default)]` so records predating the field + /// deserialize as `None`. #[serde(default, skip_serializing_if = "Option::is_none")] pub relay_mesh: Option, } @@ -478,7 +487,11 @@ pub struct ManagedAgentSummary { pub system_prompt: Option, pub avatar_url: Option, pub model: Option, - /// LLM inference provider, from the agent's pinned record snapshot. + #[serde(skip_serializing_if = "Option::is_none")] + pub model_source: Option, + /// LLM inference provider, resolved the same way as `model`/`model_source` + /// (definition → global for linked instances; instance → global for + /// definition-less instances). `None` for an orphaned instance. pub provider: Option, /// `true` when the linked persona has been edited since this agent was /// created — the running agent uses the older pinned snapshot. The UI @@ -487,9 +500,11 @@ pub struct ManagedAgentSummary { /// persona is gone, so there is nothing newer to drift toward). pub persona_out_of_date: bool, /// `true` when the agent was created from a persona that no longer exists. - /// Distinct from out-of-date: there is no current persona to respawn into, - /// so the UI should not prompt a respawn — the pinned snapshot is all the - /// config that remains. + /// Distinct from out-of-date: there is no current persona to respawn into. + /// An orphaned agent also cannot be (re)started — `spawn_agent_child` + /// refuses it (see `effective_config::resolve_effective_config`'s + /// `OrphanedInstance` arm via `require_resolved`) — so the UI + /// should surface that it's stuck, not merely stale. pub persona_orphaned: bool, /// `true` when the running process was spawned with a config that no /// longer matches what a spawn would use today — a plain restart would diff --git a/desktop/src/features/agents/lib/agentCardModelLabel.test.mjs b/desktop/src/features/agents/lib/agentCardModelLabel.test.mjs new file mode 100644 index 0000000000..696a055d20 --- /dev/null +++ b/desktop/src/features/agents/lib/agentCardModelLabel.test.mjs @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { resolveAgentCardModelLabel } from "./agentCardModelLabel.ts"; + +test("resolveAgentCardModelLabel — unspawned definition with explicit model renders the model, not inherited", () => { + const label = resolveAgentCardModelLabel({ + agent: undefined, + personaModel: "gpt-5", + defaultModel: "claude-sonnet", + }); + assert.equal(label, "gpt-5"); +}); + +test("resolveAgentCardModelLabel — unspawned definition with no model renders the default", () => { + const label = resolveAgentCardModelLabel({ + agent: undefined, + personaModel: null, + defaultModel: "claude-sonnet", + }); + assert.equal(label, "Default model (claude-sonnet)"); +}); + +test("resolveAgentCardModelLabel — linked instance inheriting the global default ignores stale persona.model", () => { + const label = resolveAgentCardModelLabel({ + agent: { modelSource: "global", model: "stale-model" }, + personaModel: "gpt-5", + defaultModel: "claude-sonnet", + }); + assert.equal(label, "Default model (claude-sonnet)"); +}); + +test("resolveAgentCardModelLabel — linked instance with no modelSource (legacy/unset) is treated as inherited", () => { + const label = resolveAgentCardModelLabel({ + agent: { modelSource: null, model: "stale-model" }, + personaModel: "gpt-5", + defaultModel: "claude-sonnet", + }); + assert.equal(label, "Default model (claude-sonnet)"); +}); + +test("resolveAgentCardModelLabel — linked instance with an explicit resolved model renders that model", () => { + const label = resolveAgentCardModelLabel({ + agent: { modelSource: "definition", model: "gpt-5" }, + personaModel: "should-not-be-used", + defaultModel: "claude-sonnet", + }); + assert.equal(label, "gpt-5"); +}); + +test("resolveAgentCardModelLabel — non-inherited agent with a blank resolved model falls back to the default", () => { + const label = resolveAgentCardModelLabel({ + agent: { modelSource: "instance_legacy", model: " " }, + personaModel: null, + defaultModel: "claude-sonnet", + }); + assert.equal(label, "Default model (claude-sonnet)"); +}); diff --git a/desktop/src/features/agents/lib/agentCardModelLabel.ts b/desktop/src/features/agents/lib/agentCardModelLabel.ts new file mode 100644 index 0000000000..4ec06f10c5 --- /dev/null +++ b/desktop/src/features/agents/lib/agentCardModelLabel.ts @@ -0,0 +1,44 @@ +import { formatAgentModelLabel } from "./formatAgentModelLabel"; +import type { ManagedAgent } from "@/shared/api/types"; + +/** + * Model label for the Agents-page card, shared by the definition-grouped + * card (`AgentPersonaCard`) and the standalone/legacy card + * (`StandaloneAgentCard`). + * + * A materialized `agent` is authoritative once it exists: its `modelSource` + * says whether the *effective* config (from `resolve_effective_config` on + * the backend) came from the global default or from an explicit + * definition/instance value, so the card never has to re-derive that from + * raw model bytes. Absent or `"global"` renders the default-model label; + * anything else renders the agent's own resolved model. + * + * With NO materialized instance yet (a definition that has never been + * started), there is no `modelSource` to read — the definition itself is + * authoritative, so an explicit `personaModel` must render directly rather + * than falling through to "inherited" for lack of an instance. + */ +export function resolveAgentCardModelLabel(input: { + agent: Pick | undefined; + personaModel: string | null | undefined; + defaultModel: string; +}): string { + if (input.agent) { + const isInherited = + !input.agent.modelSource || input.agent.modelSource === "global"; + if (isInherited) { + return formatDefaultModelLabel(input.defaultModel); + } + return input.agent.model?.trim() + ? formatAgentModelLabel(input.agent.model) + : formatDefaultModelLabel(input.defaultModel); + } + return input.personaModel?.trim() + ? formatAgentModelLabel(input.personaModel) + : formatDefaultModelLabel(input.defaultModel); +} + +export function formatDefaultModelLabel(defaultModel: string) { + const model = defaultModel.trim(); + return model ? `Default model (${model})` : "Default model"; +} diff --git a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx index 86a70c3db6..601d57f95d 100644 --- a/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx +++ b/desktop/src/features/agents/ui/AgentInstanceEditDialog.tsx @@ -655,30 +655,36 @@ export function AgentInstanceEditDialog({ parsedParallelism > 0 && parsedParallelism !== agent.parallelism ? parsedParallelism : undefined, - // Use tri-state: send null to clear, value to set, omit if unchanged. + // Linked instances defer model/provider/systemPrompt to the definition. systemPrompt: - (systemPrompt.trim() || null) !== agent.systemPrompt - ? systemPrompt.trim() || null - : undefined, + linkedPersona != null + ? undefined + : (systemPrompt.trim() || null) !== agent.systemPrompt + ? systemPrompt.trim() || null + : undefined, model: - normalizedModel !== (agent.model ?? null) - ? normalizedModel - : undefined, + linkedPersona != null + ? undefined + : normalizedModel !== (agent.model ?? null) + ? normalizedModel + : undefined, // Tri-state provider persistence keyed on providerRuntimeCapability: // "capable" → persist: value if changed, omit if unchanged. // "locked" → clear: send null if provider was set, else omit. // "unknown" → omit always (never send null for a transient state). // llmProviderFieldVisible is for UX visibility only; not used here. provider: - providerRuntimeCapability === "capable" - ? normalizedSubmitProvider !== (agent.provider ?? null) - ? normalizedSubmitProvider - : undefined - : providerRuntimeCapability === "locked" - ? (agent.provider ?? null) !== null - ? null + linkedPersona != null + ? undefined + : providerRuntimeCapability === "capable" + ? normalizedSubmitProvider !== (agent.provider ?? null) + ? normalizedSubmitProvider : undefined - : undefined, // "unknown" → omit always + : providerRuntimeCapability === "locked" + ? (agent.provider ?? null) !== null + ? null + : undefined + : undefined, // "unknown" → omit always envVars: envVarsEqual(submitEnvVars, agent.envVars) ? undefined : submitEnvVars, diff --git a/desktop/src/features/agents/ui/EditAgentAdvancedFields.tsx b/desktop/src/features/agents/ui/EditAgentAdvancedFields.tsx index 35de0d983d..8412792a2c 100644 --- a/desktop/src/features/agents/ui/EditAgentAdvancedFields.tsx +++ b/desktop/src/features/agents/ui/EditAgentAdvancedFields.tsx @@ -211,29 +211,32 @@ export function EditAgentAdvancedFields({
- {/* System prompt override */} -
- -
-