diff --git a/.env.example b/.env.example index b9bfcada0e..6429a49b2f 100644 --- a/.env.example +++ b/.env.example @@ -201,6 +201,14 @@ RUST_LOG=buzz_relay=debug,buzz_datastore=info,buzz_db=debug,buzz_auth=debug,buzz # app launch while keeping the current identity and relay data. # VITE_BUZZ_FORCE_FRESH_ONBOARDING=true +# ----------------------------------------------------------------------------- +# Google Authentication +# ----------------------------------------------------------------------------- +# Google OAuth Desktop Client Secret. Compile-time only. +# Leaving this unset disables Google SSO in the resulting binary. +# OSS/dev builds should leave it unset. NEVER put a real value here. +# BUZZ_BUILD_GOOGLE_CLIENT_SECRET= + # ── Subscription & filtering ───────────────────────────────────────────────── # Subscribe mode: "mentions" (default), "all", or "config" (rule-based). # BUZZ_ACP_SUBSCRIBE=mentions diff --git a/.github/workflows/linux-canary.yml b/.github/workflows/linux-canary.yml index d8b10032b2..4d24599bd0 100644 --- a/.github/workflows/linux-canary.yml +++ b/.github/workflows/linux-canary.yml @@ -189,10 +189,14 @@ jobs: cargo build --release -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli ./scripts/bundle-sidecars.sh + - name: Force recompile desktop crate (bust stale cache) + run: cd desktop/src-tauri && cargo clean -p buzz-desktop --release + - name: Build Linux Tauri app run: cd desktop && pnpm tauri build --ci --bundles deb,appimage --features mesh-llm --config src-tauri/tauri.canary.conf.json env: CMAKE_POLICY_VERSION_MINIMUM: "3.5" + BUZZ_BUILD_GOOGLE_CLIENT_SECRET: ${{ secrets.BUZZ_GOOGLE_CLIENT_SECRET }} - name: Fix AppImage (remove infra libs, symlink system GStreamer) # fix-appimage.sh checks for TAURI_SIGNING_PRIVATE_KEY and skips diff --git a/.github/workflows/macos-intel-canary.yml b/.github/workflows/macos-intel-canary.yml index 35b05313c9..ca2c293fcc 100644 --- a/.github/workflows/macos-intel-canary.yml +++ b/.github/workflows/macos-intel-canary.yml @@ -86,6 +86,9 @@ jobs: cargo build --release --target "$TARGET" -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli ./scripts/bundle-sidecars.sh "$TARGET" + - name: Force recompile desktop crate (bust stale cache) + run: cd desktop/src-tauri && cargo clean -p buzz-desktop --release --target "$TARGET" + - name: Build unsigned Intel DMG run: cd desktop && pnpm tauri build --verbose --no-sign --target "$TARGET" --bundles dmg --config src-tauri/tauri.canary.conf.json env: @@ -93,6 +96,7 @@ jobs: MACOSX_DEPLOYMENT_TARGET: "10.15" CMAKE_OSX_DEPLOYMENT_TARGET: "10.15" TAURI_BUNDLER_DMG_IGNORE_CI: "true" + BUZZ_BUILD_GOOGLE_CLIENT_SECRET: ${{ secrets.BUZZ_GOOGLE_CLIENT_SECRET }} - name: Locate fresh Intel DMG id: artifact diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9da067b74e..ba300bdb8e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -134,6 +134,9 @@ jobs: path: ${{ github.workspace }}/.cache/mesh-llama key: mesh-llama-${{ runner.os }}-metal-${{ steps.mesh_rev.outputs.rev }} + - name: Force recompile desktop crate (bust stale cache) + run: cd desktop/src-tauri && cargo clean -p buzz-desktop --release + - name: Build unsigned Tauri app run: cd desktop && pnpm tauri build --verbose --no-sign --features mesh-llm --config src-tauri/tauri.release.conf.json env: @@ -141,6 +144,7 @@ jobs: BUZZ_UPDATER_ENDPOINT: https://github.com/block/buzz/releases/download/buzz-desktop-latest/latest.json TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + BUZZ_BUILD_GOOGLE_CLIENT_SECRET: ${{ secrets.BUZZ_GOOGLE_CLIENT_SECRET }} CMAKE_POLICY_VERSION_MINIMUM: "3.5" MACOSX_DEPLOYMENT_TARGET: "10.15" CMAKE_OSX_DEPLOYMENT_TARGET: "10.15" @@ -311,6 +315,9 @@ jobs: cargo build --release --target "$TARGET" -p buzz-acp -p buzz-agent -p buzz-backend-kubernetes -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli ./scripts/bundle-sidecars.sh "$TARGET" + - name: Force recompile desktop crate (bust stale cache) + run: cd desktop/src-tauri && cargo clean -p buzz-desktop --release --target "$TARGET" + - name: Build unsigned Tauri app run: cd desktop && pnpm tauri build --verbose --no-sign --target "$TARGET" --config src-tauri/tauri.release.conf.json env: @@ -318,6 +325,7 @@ jobs: BUZZ_UPDATER_ENDPOINT: https://github.com/block/buzz/releases/download/buzz-desktop-latest/latest.json TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + BUZZ_BUILD_GOOGLE_CLIENT_SECRET: ${{ secrets.BUZZ_GOOGLE_CLIENT_SECRET }} CMAKE_POLICY_VERSION_MINIMUM: "3.5" MACOSX_DEPLOYMENT_TARGET: "10.15" CMAKE_OSX_DEPLOYMENT_TARGET: "10.15" @@ -572,11 +580,15 @@ jobs: BUZZ_UPDATER_PUBLIC_KEY: ${{ secrets.BUZZ_UPDATER_PUBLIC_KEY || secrets.SPROUT_UPDATER_PUBLIC_KEY }} BUZZ_UPDATER_ENDPOINT: https://github.com/block/buzz/releases/download/buzz-desktop-latest/latest.json + - name: Force recompile desktop crate (bust stale cache) + run: cd desktop/src-tauri && cargo clean -p buzz-desktop --release + - name: Build Linux Tauri app run: cd desktop && pnpm tauri build --verbose --ci --bundles deb,appimage --features mesh-llm --config src-tauri/tauri.release.conf.json env: BUZZ_UPDATER_PUBLIC_KEY: ${{ secrets.BUZZ_UPDATER_PUBLIC_KEY || secrets.SPROUT_UPDATER_PUBLIC_KEY }} BUZZ_UPDATER_ENDPOINT: https://github.com/block/buzz/releases/download/buzz-desktop-latest/latest.json + BUZZ_BUILD_GOOGLE_CLIENT_SECRET: ${{ secrets.BUZZ_GOOGLE_CLIENT_SECRET }} CMAKE_POLICY_VERSION_MINIMUM: "3.5" TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} @@ -715,6 +727,10 @@ jobs: cargo build --release --target "$TARGET" -p buzz-acp -p buzz-agent -p buzz-dev-mcp -p git-credential-nostr -p buzz-cli ./scripts/bundle-sidecars.sh "$TARGET" + - name: Force recompile desktop crate (bust stale cache) + shell: bash + run: cd desktop/src-tauri && cargo clean -p buzz-desktop --release --target "$TARGET" + - name: Build Windows NSIS installer (unsigned) shell: bash run: cd desktop && pnpm tauri build --verbose --target "$TARGET" --bundles nsis --config src-tauri/tauri.release.conf.json @@ -723,6 +739,7 @@ jobs: BUZZ_UPDATER_ENDPOINT: https://github.com/block/buzz/releases/download/buzz-desktop-latest/latest.json TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + BUZZ_BUILD_GOOGLE_CLIENT_SECRET: ${{ secrets.BUZZ_GOOGLE_CLIENT_SECRET }} CMAKE_POLICY_VERSION_MINIMUM: "3.5" - name: Locate Windows build artifacts diff --git a/.github/workflows/signed-macos-canary.yml b/.github/workflows/signed-macos-canary.yml index 5957f4785d..fcadbdda16 100644 --- a/.github/workflows/signed-macos-canary.yml +++ b/.github/workflows/signed-macos-canary.yml @@ -160,6 +160,9 @@ jobs: path: ${{ github.workspace }}/.cache/mesh-llama key: mesh-llama-${{ runner.os }}-metal-${{ steps.mesh_rev.outputs.rev }} + - name: Force recompile desktop crate (bust stale cache) + run: cd desktop/src-tauri && cargo clean -p buzz-desktop --release + - name: Build unsigned Tauri app run: cd desktop && pnpm tauri build --verbose --no-sign --features mesh-llm --config src-tauri/tauri.canary.conf.json env: @@ -168,6 +171,7 @@ jobs: CMAKE_OSX_DEPLOYMENT_TARGET: "10.15" LLAMA_STAGE_BACKEND: metal LLAMA_STAGE_BUILD_DIR: ${{ github.workspace }}/.cache/mesh-llama/build-stage-abi-metal + BUZZ_BUILD_GOOGLE_CLIENT_SECRET: ${{ secrets.BUZZ_GOOGLE_CLIENT_SECRET }} SKIPPY_LLAMA_AUTO_BUILD: "0" TAURI_BUNDLER_DMG_IGNORE_CI: "true" diff --git a/.github/workflows/windows-canary.yml b/.github/workflows/windows-canary.yml index 7093efd2dc..3ccef5ea04 100644 --- a/.github/workflows/windows-canary.yml +++ b/.github/workflows/windows-canary.yml @@ -16,7 +16,6 @@ permissions: jobs: build: name: Build Windows canary - if: github.repository == 'block/buzz' runs-on: windows-latest timeout-minutes: 60 permissions: @@ -24,15 +23,6 @@ jobs: env: TARGET: x86_64-pc-windows-msvc steps: - - name: Require main - shell: bash - env: - SOURCE_REF: ${{ github.ref }} - run: | - if [[ "$SOURCE_REF" != "refs/heads/main" ]]; then - echo "::error::Canary builds must run from main; got $SOURCE_REF" - exit 1 - fi - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: @@ -49,7 +39,6 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: 24.14.1 - package-manager-cache: false - uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 with: @@ -130,13 +119,19 @@ jobs: - name: Generate non-updating bundle config shell: bash run: | - cat > desktop/src-tauri/tauri.canary.conf.json <<'JSON' + TIMESTAMP=$(date -u +"%d%m-%H%M") + cat > desktop/src-tauri/tauri.canary.conf.json <-fork-ddmm-hhmm.exe` via the `productName` field in `tauri.canary.conf.json` (generated dynamically in `.github/workflows/windows-canary.yml`). This ensures every installed build is visually identifiable. + +## Coding Guidance (Agent Contract) + +Applies to every agent and human touching this repo. These are rules of construction, not a checklist of blessed values — never satisfy one by pasting a literal into source. + +### 1. Configuration & Secrets +* **No literals for anything environment-dependent.** Credentials, endpoints, ports, bucket names, model IDs, feature toggles: resolve from env at runtime, or via `option_env!` at compile time when the value must be baked into a shipped binary. Source holds the *name* of the variable, never the value. +* **Every new variable is declared in `.env.example`** in the same commit that reads it, with a comment on what it does and whether it is required. +* **Missing required config fails loudly at startup**, not lazily at first use. A binary that boots and then dies mid-OAuth is worse than one that refuses to boot. +* **Never disable, bypass, or annotate around a secret-scanning block.** A scanner hit means the value belongs in CI secrets, not that the scanner is wrong. If a value genuinely cannot be externalized, it is an accepted risk requiring a written entry under "Accepted Risks" below — not a silent bypass. +* **Never log, serialize, or return a secret**, including in error strings and `Debug` impls. Redact tokens, keys, and authorization codes at the boundary. + +### 2. Cryptographic & Identity Material +* **Key material must come from a CSPRNG**, or from a KDF whose input includes at least 128 bits of attacker-unknown entropy. A hash over a public identifier (email, OAuth `sub`, user ID, device ID) is not a secret regardless of how the salt is chosen. +* **Constants committed to the repo are public.** Never treat a hardcoded salt, pepper, or seed as a security boundary. +* **Private keys live in the OS keychain / secure enclave**, never in plaintext files, app state, or anything crossing the Tauri IPC boundary unless the user explicitly initiated an export. +* **Deriving identity from an SSO subject is not a substitute for key storage.** If cross-device recovery is needed, escrow an encrypted key to a k2alpha-controlled service; do not make the key recomputable from public inputs. + +### 3. OAuth & Authentication Flows +* Send and verify a random `state` parameter on every authorization request. PKCE protects code exchange; it does not authenticate the callback. +* Bind the loopback callback to `127.0.0.1` (never `0.0.0.0`), reject callbacks whose `state` does not match, and treat any unexpected request on the callback port as hostile. +* Validate every claim you depend on (`hd`, `email`, `email_verified`, `aud`, `exp`) explicitly. Do not rely on request-time hints like `hd=` in the auth URL — those are UI suggestions, not enforcement. +* Only skip JWT signature verification when the token was received directly from the issuer's token endpoint over TLS in the same function. If a token-parsing helper could ever be handed a token from another source, it verifies the signature or it does not exist. + +### 4. External Calls & Failure Handling +* **Every outbound call sets an explicit connect timeout and total timeout.** No unbounded waits, ever. Reuse a configured client; do not construct a default client per call site. +* **Retries are bounded, jittered, and only for idempotent or explicitly retry-safe operations.** Never retry a token exchange or any single-use code redemption. +* **Distinguish failure classes** — network, 4xx, 5xx, malformed payload — and surface them as typed errors. `Result<_, String>` is acceptable only at the Tauri command boundary, and only after the typed error has been logged. +* **Degrade, don't hang.** A dependency being down produces an actionable user-facing message within the timeout window. + +### 5. Resource Lifecycle +* Anything spawned, bound, or locked has exactly one guaranteed teardown path that runs on **all** exits — success, error, timeout, and early `?` return. Prefer RAII guards over manual cleanup calls placed after the happy path. +* No `unwrap()` / `expect()` on anything reachable from user input, IPC, or the network. Poisoned-lock recovery is explicit. +* Long-lived tasks are cancellable and observable; a leaked listener or task is a defect even when it is invisible. + +### 6. Observability +* Use `tracing` with structured fields, not `println!`. Instrument boundaries: IPC entry, external call, auth decision, error return. +* Log the decision *and* its inputs (redacted) at the point where the code takes a branch a support engineer would later need to explain. +* Error paths log at `warn`/`error` with enough context to diagnose without a reproduction. Silent `Err` returns are not acceptable. + +### 7. Fork Discipline (k2alpha ← block/sprout) +* **Keep k2alpha-specific deltas small, isolated, and clearly marked** so upstream merges stay mechanical. Prefer a dedicated module or config surface over edits scattered across upstream files. +* **Never restructure upstream code opportunistically.** Every diff against upstream is a future merge conflict; each one must be justified by a k2alpha requirement. +* **Forking is not a justification for a weaker practice.** If a control is hard to implement because of the fork, say so explicitly and record it under Accepted Risks — do not silently lower the bar. + +### 8. Change Discipline +* Touch only what the task requires; no drive-by refactors, reformatting, or "while I'm here" cleanup. +* Behavioral changes ship with a test that fails before the change and passes after. Deterministic logic gets unit tests; external dependencies are mocked. +* `cargo clippy`, `cargo fmt`, `npx biome check .`, and `tsc` pass before any release build. +* Repo root stays clean: no installers, binaries, scratch scripts, logs, or dumped JSON. Working files go under an ignored directory. + +### Accepted Risks (k2alpha, reviewed — not patterns to copy) +* **Hardcoded Google domain (`k2alpha.ai`)**: intentional. This fork serves one company; the domain is a product constraint, not configuration. *Not* an exception to §1 for any other value. + +Adding to this list requires a stated mitigation and a trigger condition for removing it. An entry without both is a bug, not an accepted risk. + +## Pending Tasks +* Await completion of Windows Canary build (Run #5, with cache bust + client_secret fix) to verify Google login works end-to-end. +* Rotate the exposed Google OAuth client secret and update the CI secret (`BUZZ_GOOGLE_CLIENT_SECRET`). diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index 2b997af891..a34be80ddb 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -14,12 +14,21 @@ fn main() { println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AGENT_ENV"); println!("cargo:rerun-if-env-changed=BUZZ_BUILD_RELAY_RECONNECT_CMD"); println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY"); + println!("cargo:rerun-if-env-changed=BUZZ_BUILD_GOOGLE_CLIENT_SECRET"); println!("cargo:rustc-check-cfg=cfg(buzz_updater_enabled)"); if let Ok(relay_url) = std::env::var("BUZZ_RELAY_URL") { println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_RELAY_URL={relay_url}"); } + if let Some(client_secret) = std::env::var("BUZZ_BUILD_GOOGLE_CLIENT_SECRET") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + { + println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_GOOGLE_CLIENT_SECRET={client_secret}"); + } + if let Ok(relay_http) = std::env::var("BUZZ_RELAY_HTTP") { println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_RELAY_HTTP={relay_http}"); } diff --git a/desktop/src-tauri/src/commands/google_auth.rs b/desktop/src-tauri/src/commands/google_auth.rs new file mode 100644 index 0000000000..5561c63bce --- /dev/null +++ b/desktop/src-tauri/src/commands/google_auth.rs @@ -0,0 +1,480 @@ +use axum::{ + extract::{Query, State as AxumState}, + response::{Html, IntoResponse, Response}, + routing::get, + Router, +}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use nostr::ToBech32; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{collections::HashMap, sync::Mutex, time::Duration}; +use tauri::Manager; +use tokio::{net::TcpListener, sync::oneshot}; + +use crate::{app_state::AppState, models::IdentityInfo}; + +const GOOGLE_CLIENT_ID: &str = + "928375928891-mjfo59obr65fldcehesvbq0cve94ease.apps.googleusercontent.com"; +const ALLOWED_DOMAIN: &str = "k2alpha.ai"; +const LOGIN_TIMEOUT: Duration = Duration::from_secs(5 * 60); + +fn is_secret_configured(secret: Option<&str>) -> bool { + secret.map(|s| !s.trim().is_empty()).unwrap_or(false) +} + +#[tauri::command] +pub fn google_sso_available() -> bool { + is_secret_configured(option_env!("BUZZ_DESKTOP_BUILD_GOOGLE_CLIENT_SECRET")) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GoogleAuthResult { + pub identity: IdentityInfo, + pub email: String, + pub name: Option, + pub is_fresh_key: bool, +} + +#[derive(Debug, Deserialize)] +struct TokenResponse { + id_token: Option, +} + +#[derive(Debug, Deserialize)] +struct IdTokenClaims { + email: Option, + hd: Option, + name: Option, + aud: String, + exp: u64, + email_verified: Option, + iss: Option, +} + +struct CallbackState { + sender: Mutex>>>, + state: String, +} + +struct ServerShutdownGuard { + tx: Option>, +} + +impl Drop for ServerShutdownGuard { + fn drop(&mut self) { + if let Some(tx) = self.tx.take() { + let _ = tx.send(()); + } + } +} + +async fn oauth_callback( + Query(query): Query>, + AxumState(state): AxumState>, +) -> Response { + let result = if query.get("state") != Some(&state.state) { + tracing::warn!("OAuth callback rejected: state mismatch or missing"); + Err("Invalid state parameter. Authentication aborted.".to_owned()) + } else { + match query.get("code").filter(|code| !code.is_empty()) { + Some(code) => Ok(code.clone()), + None => Err(query + .get("error_description") + .or_else(|| query.get("error")) + .cloned() + .unwrap_or_else(|| "Google authentication was cancelled or failed.".to_owned())), + } + }; + + if let Err(ref err) = result { + tracing::error!("OAuth callback failure: {err}"); + } + + if let Ok(mut lock) = state.sender.lock() { + if let Some(sender) = lock.take() { + let _ = sender.send(result.clone()); + } else { + tracing::warn!("OAuth callback received but sender already used"); + } + } else { + tracing::error!("OAuth callback sender mutex poisoned"); + } + + let html = render_oauth_callback_html(result.is_ok()); + + Html(html).into_response() +} + +fn render_oauth_callback_html(is_success: bool) -> &'static str { + if is_success { + r#" + +Authentication Complete + +
+
🐝
+

Authenticated with @k2alpha.ai

+

You can now close this tab and return to the Buzz desktop app.

+
+ +"# + } else { + r#" + +Authentication Failed + +
+
⚠️
+

Authentication Failed

+

Google authentication failed. Please return to the Buzz desktop app and try again.

+
+ +"# + } +} + +#[tauri::command] +pub async fn start_google_workspace_login( + app_handle: tauri::AppHandle, +) -> Result { + tracing::info!("Starting Google Workspace SSO login flow"); + + let listener = TcpListener::bind("127.0.0.1:0") + .await + .map_err(|e| { + tracing::error!("Failed to bind local callback port: {e}"); + format!("failed to bind local callback port: {e}") + })?; + let port = listener + .local_addr() + .map_err(|e| format!("failed to read local port: {e}"))? + .port(); + + let redirect_uri = format!("http://127.0.0.1:{port}/auth/callback"); + + let oauth_state = uuid::Uuid::new_v4().simple().to_string(); + + let (sender, receiver) = oneshot::channel(); + let callback_state = std::sync::Arc::new(CallbackState { + sender: Mutex::new(Some(sender)), + state: oauth_state.clone(), + }); + + let router = Router::new() + .route("/auth/callback", get(oauth_callback)) + .with_state(callback_state); + + let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); + let _guard = ServerShutdownGuard { + tx: Some(shutdown_tx), + }; + + tokio::spawn(async move { + let _ = axum::serve(listener, router) + .with_graceful_shutdown(async move { + let _ = shutdown_rx.await; + }) + .await; + }); + + let code_verifier = format!("{}{}", uuid::Uuid::new_v4().simple(), uuid::Uuid::new_v4().simple()); + + let mut hasher = Sha256::new(); + hasher.update(code_verifier.as_bytes()); + let code_challenge = URL_SAFE_NO_PAD.encode(hasher.finalize()); + + let mut auth_url = url::Url::parse("https://accounts.google.com/o/oauth2/v2/auth") + .map_err(|e| e.to_string())?; + auth_url + .query_pairs_mut() + .append_pair("client_id", GOOGLE_CLIENT_ID) + .append_pair("redirect_uri", &redirect_uri) + .append_pair("response_type", "code") + .append_pair("scope", "openid email profile") + .append_pair("hd", ALLOWED_DOMAIN) + .append_pair("prompt", "select_account") + .append_pair("state", &oauth_state) + .append_pair("code_challenge", &code_challenge) + .append_pair("code_challenge_method", "S256"); + + tauri_plugin_opener::OpenerExt::opener(&app_handle) + .open_url(auth_url.as_str(), None::<&str>) + .map_err(|e| format!("failed to open browser: {e}"))?; + + let code_result = tokio::time::timeout(LOGIN_TIMEOUT, receiver) + .await + .map_err(|_| { + tracing::warn!("OAuth authentication timed out"); + "Authentication timed out. Please try signing in again.".to_string() + })? + .map_err(|e| { + tracing::error!("OAuth callback channel closed: {e}"); + "Callback channel closed.".to_string() + })?; + + // Drop guard will handle the server shutdown automatically. + let code = code_result?; + + let client_secret = option_env!("BUZZ_DESKTOP_BUILD_GOOGLE_CLIENT_SECRET") + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .ok_or_else(|| "Google SSO is not available in this build (missing client secret).".to_string())?; + + let client = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(30)) + .build() + .map_err(|e| format!("failed to build HTTP client: {e}"))?; + let token_res = client + .post("https://oauth2.googleapis.com/token") + .form(&[ + ("code", code.as_str()), + ("client_id", GOOGLE_CLIENT_ID), + ("client_secret", client_secret), + ("grant_type", "authorization_code"), + ("redirect_uri", redirect_uri.as_str()), + ("code_verifier", code_verifier.as_str()), + ]) + .send() + .await + .map_err(|e| format!("failed token exchange request: {e}"))?; + + if !token_res.status().is_success() { + let status = token_res.status(); + let body = token_res.text().await.unwrap_or_default(); + tracing::error!("Google token exchange failed ({status}): {body}"); + return Err(format!("Google token exchange failed ({status}). Please try again.")); + } + + let token_resp: TokenResponse = token_res + .json() + .await + .map_err(|e| format!("failed parsing token response: {e}"))?; + + let id_token_str = token_resp + .id_token + .ok_or_else(|| "Google token response did not contain id_token".to_string())?; + + let claims = parse_id_token_claims(&id_token_str)?; + + let valid_issuers = ["https://accounts.google.com", "accounts.google.com"]; + let iss = claims.iss.as_deref().unwrap_or_default(); + if !valid_issuers.contains(&iss) { + tracing::error!("Token issuer mismatch or missing: {iss}"); + return Err("Invalid token issuer.".to_string()); + } + + if claims.aud != GOOGLE_CLIENT_ID { + tracing::error!("Token audience mismatch: {}", claims.aud); + return Err("Invalid token audience.".to_string()); + } + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + if claims.exp < now { + tracing::error!("Token has expired"); + return Err("Authentication token expired. Please try again.".to_string()); + } + + if claims.email_verified != Some(true) { + tracing::error!("Google account email is not verified"); + return Err("Email address is not verified by Google.".to_string()); + } + + let user_email = claims.email.unwrap_or_default().trim().to_lowercase(); + let user_domain = claims.hd.unwrap_or_default().trim().to_lowercase(); + + if !user_email.ends_with(&format!("@{ALLOWED_DOMAIN}")) && user_domain != ALLOWED_DOMAIN { + return Err(format!( + "Access Restricted: '{user_email}' is not a @{ALLOWED_DOMAIN} account." + )); + } + + let state = app_handle.state::(); + let keyring_locked = state.keyring_locked.load(std::sync::atomic::Ordering::Acquire); + let identity_lost = state.identity_lost.load(std::sync::atomic::Ordering::Acquire); + let storage = state.identity_storage(); + + let decision = resolve_login_key_decision(storage, keyring_locked, identity_lost)?; + + let (nsec, is_fresh_key) = match decision { + LoginKeyDecision::ReuseExisting => { + let existing_nsec = { + let keys = state.keys.lock().ok(); + keys.and_then(|k| k.secret_key().to_bech32().ok()) + }; + let nsec = existing_nsec.ok_or_else(|| "Failed to read existing identity key.".to_string())?; + (nsec, false) + } + LoginKeyDecision::GenerateFresh => { + let keys = nostr::Keys::generate(); + let nsec = keys + .secret_key() + .to_bech32() + .map_err(|e| format!("failed encoding generated nsec: {e}"))?; + (nsec, true) + } + }; + + let identity = crate::commands::identity::import_identity(nsec.clone(), None, app_handle).await?; + + tracing::info!("Google SSO flow completed successfully for {}", user_email); + + Ok(GoogleAuthResult { + identity, + email: user_email, + name: claims.name, + is_fresh_key, + }) +} + +fn parse_id_token_claims(id_token: &str) -> Result { + let parts: Vec<&str> = id_token.split('.').collect(); + if parts.len() < 2 { + return Err("Invalid ID token format".to_string()); + } + + let payload_b64 = parts[1]; + let decoded = base64::Engine::decode( + &base64::engine::general_purpose::URL_SAFE_NO_PAD, + payload_b64, + ) + .or_else(|_| { + base64::Engine::decode(&base64::engine::general_purpose::STANDARD, payload_b64) + }) + .map_err(|e| format!("failed decoding JWT payload: {e}"))?; + + serde_json::from_slice::(&decoded) + .map_err(|e| format!("failed parsing JWT claims: {e}")) +} + +#[derive(Debug, PartialEq, Eq)] +pub enum LoginKeyDecision { + ReuseExisting, + GenerateFresh, +} + +/// Resolves whether a new Google SSO login should generate a fresh Nostr identity or reuse the existing one. +/// +/// NOTE: This function takes NO `google_sub`. Identity derivation from SSO subjects is prohibited. +/// This signature acts as the regression barrier against reintroducing derivation. +fn resolve_login_key_decision( + storage: crate::app_state::IdentityStorage, + keyring_locked: bool, + identity_lost: bool, +) -> Result { + if keyring_locked { + return Err("Your secure storage is currently locked. Please unlock your keyring and retry.".into()); + } + if identity_lost { + return Err("Identity is lost. Please restore from backup and try again.".into()); + } + match storage { + crate::app_state::IdentityStorage::Ephemeral => Ok(LoginKeyDecision::GenerateFresh), + _ => Ok(LoginKeyDecision::ReuseExisting), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_secret_configured_check() { + assert!(!is_secret_configured(None)); + assert!(!is_secret_configured(Some(""))); + assert!(!is_secret_configured(Some(" "))); + assert!(is_secret_configured(Some("secret-value"))); + } + + #[test] + fn test_oauth_callback_html_rendering() { + let success_html = render_oauth_callback_html(true); + let failure_html = render_oauth_callback_html(false); + + assert_ne!(success_html, failure_html); + assert!(success_html.contains("Authenticated with @k2alpha.ai")); + assert!(failure_html.contains("Authentication Failed")); + assert!(!failure_html.contains("Authenticated with @k2alpha.ai")); + } + + #[test] + fn test_parse_id_token_claims_valid_payload() { + let payload = r#"{"sub":"12345","email":"alice@k2alpha.ai","hd":"k2alpha.ai","name":"Alice","aud":"test_client_id","exp":9999999999,"email_verified":true,"iss":"https://accounts.google.com"}"#; + let b64_payload = base64::Engine::encode( + &base64::engine::general_purpose::URL_SAFE_NO_PAD, + payload, + ); + let jwt = format!("header.{b64_payload}.signature"); + + let claims = parse_id_token_claims(&jwt).unwrap(); + assert_eq!(claims.email.as_deref(), Some("alice@k2alpha.ai")); + assert_eq!(claims.hd.as_deref(), Some("k2alpha.ai")); + assert_eq!(claims.name.as_deref(), Some("Alice")); + assert_eq!(claims.aud, "test_client_id"); + assert_eq!(claims.exp, 9999999999); + assert_eq!(claims.email_verified, Some(true)); + assert_eq!(claims.iss.as_deref(), Some("https://accounts.google.com")); + } + + #[test] + fn test_parse_id_token_claims_email_verified_and_iss() { + // Missing email_verified or false + let payload_unverified = r#"{"sub":"12345","email":"alice@k2alpha.ai","hd":"k2alpha.ai","aud":"test_client_id","exp":9999999999,"email_verified":false,"iss":"https://accounts.google.com"}"#; + let b64_unverified = base64::Engine::encode( + &base64::engine::general_purpose::URL_SAFE_NO_PAD, + payload_unverified, + ); + let jwt_unverified = format!("header.{b64_unverified}.sig"); + let claims_unverified = parse_id_token_claims(&jwt_unverified).unwrap(); + assert_eq!(claims_unverified.email_verified, Some(false)); + + let payload_no_verified = r#"{"sub":"12345","email":"alice@k2alpha.ai","hd":"k2alpha.ai","aud":"test_client_id","exp":9999999999,"iss":"accounts.google.com"}"#; + let b64_no_verified = base64::Engine::encode( + &base64::engine::general_purpose::URL_SAFE_NO_PAD, + payload_no_verified, + ); + let jwt_no_verified = format!("header.{b64_no_verified}.sig"); + let claims_no_verified = parse_id_token_claims(&jwt_no_verified).unwrap(); + assert_eq!(claims_no_verified.email_verified, None); + assert_eq!(claims_no_verified.iss.as_deref(), Some("accounts.google.com")); + } + + #[test] + fn test_login_key_decision_matrix() { + use crate::app_state::IdentityStorage; + + // Ephemeral, unlocked, not lost -> GenerateFresh + assert_eq!( + resolve_login_key_decision(IdentityStorage::Ephemeral, false, false).unwrap(), + LoginKeyDecision::GenerateFresh + ); + + // Keyring/file/Environment -> ReuseExisting + assert_eq!( + resolve_login_key_decision(IdentityStorage::SystemKeyring, false, false).unwrap(), + LoginKeyDecision::ReuseExisting + ); + assert_eq!( + resolve_login_key_decision(IdentityStorage::LocalFile, false, false).unwrap(), + LoginKeyDecision::ReuseExisting + ); + assert_eq!( + resolve_login_key_decision(IdentityStorage::Environment, false, false).unwrap(), + LoginKeyDecision::ReuseExisting + ); + + // keyring_locked = true -> Err + assert!(resolve_login_key_decision(IdentityStorage::SystemKeyring, true, false).is_err()); + assert!(resolve_login_key_decision(IdentityStorage::Ephemeral, true, false).is_err()); + + // identity_lost = true -> Err + assert!(resolve_login_key_decision(IdentityStorage::SystemKeyring, false, true).is_err()); + assert!(resolve_login_key_decision(IdentityStorage::Ephemeral, false, true).is_err()); + } +} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 237bc06e8d..bfc7e81f0c 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -19,6 +19,7 @@ mod dms; mod engrams; mod export_util; mod global_agent_config; +mod google_auth; mod identity; mod identity_archive; mod join_policy; @@ -80,6 +81,7 @@ pub use clipboard::*; pub use dms::*; pub use engrams::*; pub use global_agent_config::*; +pub use google_auth::*; pub use identity::*; pub use identity_archive::*; pub use join_policy::*; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index a7c191c43b..10f45b7eca 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -37,7 +37,7 @@ mod templates; mod terminal_runtime; #[cfg_attr(not(test), allow(dead_code))] mod terminal_transport; -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", target_os = "windows"))] mod tray_menu; mod util; #[cfg(target_os = "linux")] @@ -74,11 +74,11 @@ use mesh_llm_stubs::*; use shutdown::{hard_exit_after_mesh_shutdown, relaunch_after_mesh_shutdown}; use shutdown::{is_restart_request, shut_down_app}; use std::sync::{atomic::AtomicBool, atomic::Ordering, Arc}; -#[cfg(target_os = "macos")] -use tauri::Listener; -use tauri::{Emitter, Manager, RunEvent, WindowEvent}; +use tauri::{Emitter, Manager, RunEvent}; +#[cfg(any(target_os = "macos", target_os = "windows"))] +use tauri::{Listener, WindowEvent}; use tauri_plugin_window_state::StateFlags; -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", target_os = "windows"))] use tray_menu::show_main_window; #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -308,7 +308,8 @@ pub fn run() { .manage(terminal_runtime::TerminalSessions::default()) .setup(move |app| { let app_handle = app.handle().clone(); - #[cfg(target_os = "macos")] + + #[cfg(any(target_os = "macos", target_os = "windows"))] tray_menu::init(&app_handle)?; // ── Phase 2: boot-time sentinel wipe ────────────────────────────── @@ -632,6 +633,8 @@ pub fn run() { verify_ncryptsec_backup, save_ncryptsec_copy, import_identity, + start_google_workspace_login, + google_sso_available, persist_current_identity, get_profile, update_profile, @@ -893,13 +896,13 @@ pub fn run() { archive::read_unindexed_observer_rows, is_auto_update_supported, set_window_vibrancy, - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "windows"))] tray_menu::clear_tray_agent_activity, - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "windows"))] tray_menu::requeue_tray_actions, - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "windows"))] tray_menu::take_tray_actions, - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "windows"))] tray_menu::update_tray_agent_activity, ]) .build(tauri::generate_context!()) @@ -914,7 +917,7 @@ pub fn run() { app.run(move |app_handle, event| match event { #[cfg(target_os = "macos")] RunEvent::Reopen { .. } => show_main_window(app_handle), - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "windows"))] RunEvent::WindowEvent { label, event: WindowEvent::CloseRequested { api, .. }, diff --git a/desktop/src-tauri/src/models.rs b/desktop/src-tauri/src/models.rs index 3f04d3d7a1..5f12c33d78 100644 --- a/desktop/src-tauri/src/models.rs +++ b/desktop/src-tauri/src/models.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use serde::{Deserialize, Deserializer, Serialize}; -#[derive(Serialize)] +#[derive(Debug, Serialize)] pub struct IdentityInfo { pub pubkey: String, pub display_name: String, diff --git a/desktop/src-tauri/src/mouse_nav.rs b/desktop/src-tauri/src/mouse_nav.rs index cd729f7304..10c464e349 100644 --- a/desktop/src-tauri/src/mouse_nav.rs +++ b/desktop/src-tauri/src/mouse_nav.rs @@ -23,6 +23,7 @@ /// Maps an `otherMouseUp` button number to a navigation direction. /// Buttons 3 and 4 are X1 (back) and X2 (forward). +#[cfg(target_os = "macos")] fn direction_for_button(button: isize) -> Option<&'static str> { match button { 3 => Some("back"), @@ -35,6 +36,7 @@ fn direction_for_button(button: isize) -> Option<&'static str> { /// following the AppKit `swipeWithEvent:` convention: positive is back, /// negative is forward. A swipe arrives as a begin/end pair and only the /// end event carries the direction, so `deltaX == 0` maps to `None`. +#[cfg(target_os = "macos")] fn direction_for_swipe(delta_x: f64) -> Option<&'static str> { if delta_x > 0.0 { Some("back") @@ -45,6 +47,7 @@ fn direction_for_swipe(delta_x: f64) -> Option<&'static str> { } } +#[cfg(target_os = "macos")] pub fn init(app_handle: &tauri::AppHandle) { use block2::RcBlock; use objc2_app_kit::{NSEvent, NSEventMask, NSEventType}; @@ -102,7 +105,12 @@ pub fn init(app_handle: &tauri::AppHandle) { } } -#[cfg(test)] +#[cfg(not(target_os = "macos"))] +pub fn init(_app_handle: &tauri::AppHandle) { + // Non-macOS X1/X2 behavior is left to the underlying webview. +} + +#[cfg(all(test, target_os = "macos"))] mod tests { use super::*; diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index fdb4907180..1fdd1bbefb 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -440,6 +440,7 @@ export function AppShell() { getMessageReadAt, channels, huddleBackingChannelIds, + activeChannel?.id ?? null, ); const dueReminderBadge = useDueReminderBadgeCount( identityQuery.data?.pubkey, diff --git a/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs b/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs index fbaf1f5274..f3727598c0 100644 --- a/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs +++ b/desktop/src/features/agents/lib/personaCatalogRelay.test.mjs @@ -356,7 +356,7 @@ test("test_foreign_entry_with_no_local_copy_stays_unselected", () => { BOB, ); - assert.equal(personas[0].id, "catalog:" + ALICE + ":reviewer"); + assert.equal(personas[0].id, `catalog:${ALICE}:reviewer`); assert.equal(personas[0].isActive, false); }); @@ -377,7 +377,7 @@ test("test_catalog_source_match_is_scoped_to_the_publishing_owner", () => { ALICE, ); - assert.equal(personas[0].id, "catalog:" + BOB + ":reviewer"); + assert.equal(personas[0].id, `catalog:${BOB}:reviewer`); assert.equal(personas[0].isActive, false); }); diff --git a/desktop/src/features/notifications/hooks.ts b/desktop/src/features/notifications/hooks.ts index 72d1a03381..d7320252ab 100644 --- a/desktop/src/features/notifications/hooks.ts +++ b/desktop/src/features/notifications/hooks.ts @@ -379,6 +379,7 @@ export function useHomeFeedNotificationState( getMessageReadAt: (messageId: string) => number | null = () => null, channels: ReadonlyArray> = [], silentChannelIds?: ReadonlySet, + activeTargetId?: string | null, ) { useFeedDesktopNotifications( feed, @@ -390,6 +391,7 @@ export function useHomeFeedNotificationState( mutedChannelIds, channels, silentChannelIds, + activeTargetId, ); const normalizedPubkey = pubkey?.trim().toLowerCase() ?? ""; const [seenFeedIds, setSeenFeedIds] = React.useState(() => diff --git a/desktop/src/features/notifications/lib/conversationNotifications.ts b/desktop/src/features/notifications/lib/conversationNotifications.ts new file mode 100644 index 0000000000..320b8bdbea --- /dev/null +++ b/desktop/src/features/notifications/lib/conversationNotifications.ts @@ -0,0 +1,56 @@ +export type ConversationNotificationConfig = { + toastEnabled: boolean; + soundEnabled: boolean; +}; + +const STORAGE_KEY = "buzz-conversation-notification-config.v1"; + +export function getConversationNotificationConfig( + targetId: string | null | undefined, +): ConversationNotificationConfig { + if (!targetId || typeof window === "undefined") { + return { toastEnabled: true, soundEnabled: true }; + } + + try { + const raw = window.localStorage.getItem(`${STORAGE_KEY}:${targetId}`); + if (!raw) { + return { toastEnabled: true, soundEnabled: true }; + } + const parsed = JSON.parse(raw); + return { + toastEnabled: + typeof parsed.toastEnabled === "boolean" ? parsed.toastEnabled : true, + soundEnabled: + typeof parsed.soundEnabled === "boolean" ? parsed.soundEnabled : true, + }; + } catch { + return { toastEnabled: true, soundEnabled: true }; + } +} + +export function setConversationNotificationConfig( + targetId: string, + config: Partial, +): ConversationNotificationConfig { + if (!targetId || typeof window === "undefined") { + return { toastEnabled: true, soundEnabled: true }; + } + + const current = getConversationNotificationConfig(targetId); + const updated = { ...current, ...config }; + try { + window.localStorage.setItem( + `${STORAGE_KEY}:${targetId}`, + JSON.stringify(updated), + ); + window.dispatchEvent( + new CustomEvent("buzz:conversation-notification-config-changed", { + detail: { targetId, config: updated }, + }), + ); + } catch { + // Ignore storage write failures + } + return updated; +} diff --git a/desktop/src/features/notifications/lib/desktop.test.mjs b/desktop/src/features/notifications/lib/desktop.test.mjs new file mode 100644 index 0000000000..054a77ce50 --- /dev/null +++ b/desktop/src/features/notifications/lib/desktop.test.mjs @@ -0,0 +1,156 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + getDesktopNotificationPermissionState, + requestDesktopNotificationAccess, + sendDesktopNotification, +} from "./desktop.ts"; + +test("getDesktopNotificationPermissionState returns default in Tauri even if window.Notification.permission is denied", async () => { + const originalWindow = globalThis.window; + + try { + globalThis.isTauri = true; + globalThis.window = { + Notification: { + permission: "denied", + }, + __TAURI_INTERNALS__: { + invoke: async (cmd) => { + if (cmd === "plugin:notification|is_permission_granted") { + return false; + } + throw new Error(`Unexpected invoke command: ${cmd}`); + }, + }, + }; + + const state = await getDesktopNotificationPermissionState(); + assert.equal( + state, + "default", + "In Tauri mode, isPermissionGranted false must yield 'default', not WebView2 'denied'", + ); + } finally { + delete globalThis.isTauri; + globalThis.window = originalWindow; + } +}); + +test("getDesktopNotificationPermissionState returns granted in Tauri when isPermissionGranted is true", async () => { + const originalWindow = globalThis.window; + + try { + globalThis.isTauri = true; + globalThis.window = { + Notification: { + permission: "denied", + }, + __TAURI_INTERNALS__: { + invoke: async (cmd) => { + if (cmd === "plugin:notification|is_permission_granted") { + return true; + } + throw new Error(`Unexpected invoke command: ${cmd}`); + }, + }, + }; + + const state = await getDesktopNotificationPermissionState(); + assert.equal(state, "granted"); + } finally { + delete globalThis.isTauri; + globalThis.window = originalWindow; + } +}); + +test("getDesktopNotificationPermissionState falls back to window.Notification.permission when not in Tauri", async () => { + const originalWindow = globalThis.window; + + try { + globalThis.window = { + Notification: { + permission: "granted", + }, + }; + + const state = await getDesktopNotificationPermissionState(); + assert.equal(state, "granted"); + } finally { + globalThis.window = originalWindow; + } +}); + +test("requestDesktopNotificationAccess invokes request_permission plugin command in Tauri mode", async () => { + const originalWindow = globalThis.window; + let invokedCommand = null; + + try { + globalThis.isTauri = true; + globalThis.window = { + __TAURI_INTERNALS__: { + invoke: async (cmd) => { + invokedCommand = cmd; + if (cmd === "plugin:notification|request_permission") { + return "granted"; + } + throw new Error(`Unexpected invoke command: ${cmd}`); + }, + }, + }; + + const state = await requestDesktopNotificationAccess(); + assert.equal(invokedCommand, "plugin:notification|request_permission"); + assert.equal(state, "granted"); + } finally { + delete globalThis.isTauri; + globalThis.window = originalWindow; + } +}); + +test("sendDesktopNotification invokes notify plugin command in Tauri mode when granted", async () => { + const originalWindow = globalThis.window; + const invokedCalls = []; + + try { + globalThis.isTauri = true; + globalThis.window = { + __TAURI_INTERNALS__: { + invoke: async (cmd, args) => { + invokedCalls.push({ cmd, args }); + if (cmd === "plugin:notification|is_permission_granted") { + return true; + } + if (cmd === "plugin:notification|notify") { + return null; + } + throw new Error(`Unexpected invoke command: ${cmd}`); + }, + }, + }; + + const sent = await sendDesktopNotification({ + title: "Test Alert", + body: "Hello World", + }); + + assert.equal(sent, true); + assert.equal(invokedCalls.length, 2); + assert.equal( + invokedCalls[0].cmd, + "plugin:notification|is_permission_granted", + ); + assert.equal(invokedCalls[1].cmd, "plugin:notification|notify"); + assert.deepEqual(invokedCalls[1].args, { + options: { + title: "Test Alert", + body: "Hello World", + extra: undefined, + }, + }); + } finally { + delete globalThis.isTauri; + globalThis.window = originalWindow; + } +}); diff --git a/desktop/src/features/notifications/lib/desktop.ts b/desktop/src/features/notifications/lib/desktop.ts index 380521e0f2..421f529a12 100644 --- a/desktop/src/features/notifications/lib/desktop.ts +++ b/desktop/src/features/notifications/lib/desktop.ts @@ -1,11 +1,7 @@ import { invoke, isTauri } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { UserAttentionType, getCurrentWindow } from "@tauri-apps/api/window"; -import { - isPermissionGranted, - onAction, - requestPermission, -} from "@tauri-apps/plugin-notification"; +import { onAction } from "@tauri-apps/plugin-notification"; import { isLinuxPlatform, isMacPlatform } from "@/shared/lib/platform"; // Backend event emitted when the user clicks a native (Linux) notification. @@ -121,40 +117,59 @@ function dispatchDesktopNotificationTarget(target: DesktopNotificationTarget) { } export async function getDesktopNotificationPermissionState(): Promise { - if (!hasNotificationApi()) { - return "unsupported"; - } - - if (window.Notification.permission !== "default") { - return window.Notification.permission; + if (isTauri()) { + try { + const isGranted = await invoke( + "plugin:notification|is_permission_granted", + ); + return isGranted ? "granted" : "default"; + } catch { + return "default"; + } } - if (!isTauri()) { - return "default"; + if (!hasNotificationApi()) { + return "unsupported"; } - try { - return (await isPermissionGranted()) ? "granted" : "default"; - } catch { - return "default"; - } + return window.Notification.permission; } let pendingPermissionRequest: Promise | null = null; export async function requestDesktopNotificationAccess(): Promise { - if (!hasNotificationApi()) { - return "unsupported"; - } - if (pendingPermissionRequest) { return pendingPermissionRequest; } - pendingPermissionRequest = requestPermission().finally(() => { - pendingPermissionRequest = null; - }); + pendingPermissionRequest = + (async (): Promise => { + if (isTauri()) { + try { + const res = await invoke( + "plugin:notification|request_permission", + ); + return res === "granted" || res === "denied" || res === "default" + ? (res as DesktopNotificationPermissionState) + : "granted"; + } catch { + return "denied"; + } + } + + if (!hasNotificationApi()) { + return "unsupported"; + } + + try { + return await window.Notification.requestPermission(); + } catch { + return "denied"; + } + })().finally(() => { + pendingPermissionRequest = null; + }); return pendingPermissionRequest; } @@ -310,6 +325,25 @@ export async function sendDesktopNotification( } } + if (isTauri()) { + try { + await invoke("plugin:notification|notify", { + options: { + title: payload.title, + body: payload.body, + extra: notificationExtra(payload.target), + }, + }); + return true; + } catch { + return false; + } + } + + if (!hasNotificationApi()) { + return false; + } + const notification = new window.Notification(payload.title, { body: payload.body, silent: true, @@ -317,7 +351,7 @@ export async function sendDesktopNotification( } as DesktopNotificationOptions); const target = payload.target; - if (!isTauri() && target) { + if (target) { notification.onclick = () => { dispatchDesktopNotificationTarget(target); notification.close(); diff --git a/desktop/src/features/notifications/use-feed-desktop-notifications.ts b/desktop/src/features/notifications/use-feed-desktop-notifications.ts index b58e260ec2..a5a55d731a 100644 --- a/desktop/src/features/notifications/use-feed-desktop-notifications.ts +++ b/desktop/src/features/notifications/use-feed-desktop-notifications.ts @@ -26,6 +26,7 @@ import { slotForFeedKind, } from "./lib/sound"; import type { NotificationSettings } from "./hooks"; +import { getConversationNotificationConfig } from "./lib/conversationNotifications"; const HOME_FEED_SEEN_STORAGE_KEY = "buzz-home-feed-seen.v1"; const HOME_FEED_SEEN_MAX_ITEMS = 500; @@ -79,6 +80,7 @@ export function useFeedDesktopNotifications( mutedChannelIds?: ReadonlySet, channels: readonly NotificationChannel[] = [], silentChannelIds?: ReadonlySet, + activeTargetId?: string | null, ) { const normalizedPubkey = pubkey?.trim().toLowerCase() ?? ""; const seenItemIdsRef = React.useRef>( @@ -113,23 +115,39 @@ export function useFeedDesktopNotifications( const deliverFeedNotification = React.useEffectEvent( async (item: FeedItem, senderName?: string) => { const threadRootId = getThreadReference(item.tags).rootId ?? null; - const didSend = await sendDesktopNotification({ - body: notificationBody(item), - target: { - channelId: item.channelId, - channelName: item.channelName, - content: item.content, - createdAt: item.createdAt, - eventId: item.id, - kind: item.kind, - pubkey: item.pubkey, - threadRootId, - }, - title: notificationTitle(item, senderName), - }); + const targetId = item.channelId || threadRootId || item.pubkey; + const convConfig = getConversationNotificationConfig(targetId); + + const isWindowFocused = + typeof document !== "undefined" && document.hasFocus(); + const isViewingActiveTarget = + isWindowFocused && + !settings.notifyWhileViewing && + targetId !== null && + targetId !== undefined && + activeTargetId === targetId; + + let didSend = false; + if (!isViewingActiveTarget && convConfig.toastEnabled) { + didSend = await sendDesktopNotification({ + body: notificationBody(item), + target: { + channelId: item.channelId, + channelName: item.channelName, + content: item.content, + createdAt: item.createdAt, + eventId: item.id, + kind: item.kind, + pubkey: item.pubkey, + threadRootId, + }, + title: notificationTitle(item, senderName), + }); + } if ( - didSend && + convConfig.soundEnabled && + (didSend || !isViewingActiveTarget) && shouldPlayNotificationSound(item.channelId, silentChannelIds) ) { const slot = slotForFeedKind(item.kind, item.category); diff --git a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx index 693d1af058..3ec7e9826d 100644 --- a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx @@ -1,13 +1,16 @@ import * as React from "react"; -import type { QueryClient } from "@tanstack/react-query"; +import { useQuery, type QueryClient } from "@tanstack/react-query"; import { ArrowUp } from "lucide-react"; import { motion, useReducedMotion } from "motion/react"; import { - getIdentity, importIdentity, persistCurrentIdentity, } from "@/shared/api/tauriIdentity"; +import { + isGoogleSsoAvailable, + startGoogleWorkspaceLogin, +} from "@/shared/api/googleAuth"; import type { IdentityStorage } from "@/shared/api/types"; import { Button } from "@/shared/ui/button"; import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion"; @@ -100,6 +103,12 @@ export function MachineOnboardingFlow({ const backupSession = useEncryptedBackupSession(); const reduceMotion = useReducedMotion() ?? false; const isSecuritySubview = page === "backup" && backupSubview !== "created"; + + const { data: ssoAvailable } = useQuery({ + queryKey: ["google_sso_available"], + queryFn: isGoogleSsoAvailable, + staleTime: Infinity, + }); const handleReadyRuntimeIdsChange = React.useCallback( (runtimeIds: readonly string[]) => { setReadyRuntimeIds(Array.from(new Set(runtimeIds))); @@ -107,26 +116,32 @@ export function MachineOnboardingFlow({ [], ); - const loadFreshIdentity = React.useCallback(async () => { + const handleGoogleSignIn = React.useCallback(async () => { setIsPending(true); setError(null); try { - const identity = await getIdentity(); + const res = await startGoogleWorkspaceLogin(); + const identity = res.identity; queryClient.setQueryData(["identity"], identity); + continueWithIdentity(identity.pubkey); + setIdentityWasImported(true); setSelectedPubkey(identity.pubkey); - setIdentityStorage(identity.storage); - setBackupDirection("forward"); - setReturningFromSecurity(false); - setBackupSubview("created"); - setPage("backup"); + + if (res.isFreshKey) { + setIdentityStorage(identity.storage); + setBackupDirection("forward"); + setReturningFromSecurity(false); + setBackupSubview("created"); + setPage("backup"); + } else { + setPage("setup"); + } } catch (cause) { - setError( - cause instanceof Error ? cause.message : "Failed to load identity", - ); + setError(cause instanceof Error ? cause.message : String(cause)); } finally { setIsPending(false); } - }, [queryClient]); + }, [continueWithIdentity, queryClient]); const replaceLostIdentity = React.useCallback(async () => { const confirmed = window.confirm( @@ -226,19 +241,21 @@ export function MachineOnboardingFlow({ {error ? (

{error}

) : null} -
- +
+ {ssoAvailable === true && ( + + )}
diff --git a/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx b/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx index 59e5bfdb0b..81c866e0b1 100644 --- a/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx +++ b/desktop/src/features/onboarding/ui/NostrKeyImportForm.tsx @@ -55,6 +55,7 @@ export function NostrKeyImportForm({ const [isImporting, setIsImporting] = React.useState(false); const [importError, setImportError] = React.useState(null); const [isDragging, setIsDragging] = React.useState(false); + const [isRevealed, setIsRevealed] = React.useState(false); const inputRef = React.useRef(null); const passphraseInputRef = React.useRef(null); diff --git a/desktop/src/features/sidebar/ui/ChannelContextMenu.tsx b/desktop/src/features/sidebar/ui/ChannelContextMenu.tsx index 78f3929f19..e3cb5622e3 100644 --- a/desktop/src/features/sidebar/ui/ChannelContextMenu.tsx +++ b/desktop/src/features/sidebar/ui/ChannelContextMenu.tsx @@ -13,7 +13,14 @@ import { StarOff, Trash2, TriangleAlert, + Volume2, + VolumeX, } from "lucide-react"; +import { useState } from "react"; +import { + getConversationNotificationConfig, + setConversationNotificationConfig, +} from "@/features/notifications/lib/conversationNotifications"; import { useAppShell } from "@/app/AppShellContext"; import { @@ -38,6 +45,65 @@ import { ContextMenuSubTrigger, } from "@/shared/ui/context-menu"; +function NotificationPreferencesSubmenu({ targetId }: { targetId: string }) { + const [config, setConfig] = useState(() => + getConversationNotificationConfig(targetId), + ); + + const toggleToast = () => { + const updated = setConversationNotificationConfig(targetId, { + toastEnabled: !config.toastEnabled, + }); + setConfig(updated); + }; + + const toggleSound = () => { + const updated = setConversationNotificationConfig(targetId, { + soundEnabled: !config.soundEnabled, + }); + setConfig(updated); + }; + + return ( + + + + + + Notifications + + + { + e.preventDefault(); + toggleToast(); + }} + > + + {config.toastEnabled ? : null} + + OS Toast Alerts + + { + e.preventDefault(); + toggleSound(); + }} + > + + {config.soundEnabled ? ( + + ) : ( + + )} + + Sound Alerts + + + + ); +} + function MoveToSectionSubmenu({ channelId, sections, @@ -276,6 +342,7 @@ export function ChannelContextMenuItems({ ) : null} {showMuteToggle || showStar ? : null} + {showMuteToggle ? ( isMuted ? ( { + return invoke("start_google_workspace_login"); +} + +export async function isGoogleSsoAvailable(): Promise { + return invoke("google_sso_available"); +}