From b833a4cd59abe06b217ea1b89dec555304737049 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Tue, 30 Jun 2026 13:36:51 +0100 Subject: [PATCH 1/3] Add OAuth-backed connections --- .env.example | 14 + desktop/src-tauri/Cargo.toml | 2 +- desktop/src-tauri/src/commands/calendar.rs | 547 +++++++++++++ desktop/src-tauri/src/commands/mod.rs | 4 + desktop/src-tauri/src/commands/spotify.rs | 758 ++++++++++++++++++ desktop/src-tauri/src/lib.rs | 14 + desktop/src/features/calendar/api.ts | 88 ++ desktop/src/features/calendar/hooks.ts | 190 +++++ .../calendar/ui/CalendarSettingsCard.tsx | 137 ++++ .../features/profile/ui/ProfilePopover.tsx | 15 +- .../profile/ui/UserProfilePopover.tsx | 26 +- .../features/settings/ui/SettingsPanels.tsx | 11 + .../src/features/settings/ui/SettingsView.tsx | 1 + .../src/features/sidebar/ui/AppSidebar.tsx | 33 +- .../sidebar/ui/AppSidebarPinnedHeader.tsx | 109 ++- .../sidebar/ui/SidebarProfileCard.tsx | 30 +- .../features/sidebar/ui/SidebarSection.tsx | 19 + .../features/sidebar/useDmSidebarMetadata.ts | 42 +- desktop/src/features/spotify/api.ts | 186 +++++ desktop/src/features/spotify/hooks.ts | 123 +++ .../spotify/ui/SpotifySettingsCard.tsx | 80 ++ desktop/src/testing/e2eBridge.ts | 42 + 22 files changed, 2432 insertions(+), 39 deletions(-) create mode 100644 desktop/src-tauri/src/commands/calendar.rs create mode 100644 desktop/src-tauri/src/commands/spotify.rs create mode 100644 desktop/src/features/calendar/api.ts create mode 100644 desktop/src/features/calendar/hooks.ts create mode 100644 desktop/src/features/calendar/ui/CalendarSettingsCard.tsx create mode 100644 desktop/src/features/spotify/api.ts create mode 100644 desktop/src/features/spotify/hooks.ts create mode 100644 desktop/src/features/spotify/ui/SpotifySettingsCard.tsx diff --git a/.env.example b/.env.example index 696d3a061..0aebde02b 100644 --- a/.env.example +++ b/.env.example @@ -51,6 +51,20 @@ RELAY_URL=ws://localhost:3000 # (use `just web` for Vite HMR instead). # BUZZ_WEB_DIR=./web/dist +# ----------------------------------------------------------------------------- +# Desktop integrations (OAuth) +# ----------------------------------------------------------------------------- +# Optional Google Calendar desktop OAuth client. +# BUZZ_GOOGLE_CALENDAR_CLIENT_ID= +# BUZZ_GOOGLE_CALENDAR_CLIENT_SECRET= + +# Optional Spotify Web API app client ID. In the Spotify developer dashboard, +# register this redirect URI: +# http://127.0.0.1:18202/oauth/spotify/callback +# Spotify playback controls require Spotify Premium for each connected account. +# BUZZ_SPOTIFY_CLIENT_ID= +# BUZZ_SPOTIFY_REDIRECT_PORT=18202 + # ----------------------------------------------------------------------------- # Git (NIP-34 bare repositories) # ----------------------------------------------------------------------------- diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 6086233e8..ce2c4d8d7 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -78,7 +78,7 @@ serde_yaml = "0.9" toml = "0.8" nostr = { version = "0.44", features = ["nip44"] } zeroize = "1" -reqwest = { version = "0.13", features = ["json", "query", "stream"] } +reqwest = { version = "0.13", features = ["form", "json", "query", "stream"] } url = "2" buzz_core_pkg = { package = "buzz-core", path = "../../crates/buzz-core" } buzz_persona_pkg = { package = "buzz-persona", path = "../../crates/buzz-persona" } diff --git a/desktop/src-tauri/src/commands/calendar.rs b/desktop/src-tauri/src/commands/calendar.rs new file mode 100644 index 000000000..94c39f6f7 --- /dev/null +++ b/desktop/src-tauri/src/commands/calendar.rs @@ -0,0 +1,547 @@ +use std::time::Duration; + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use tauri::State; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; +use url::Url; +use uuid::Uuid; + +use crate::app_state::{AppState, KEYRING_SERVICE}; +use crate::secret_store::SecretStore; + +const GOOGLE_CALENDAR_CREDENTIAL_KEY: &str = "google-calendar-oauth"; +const GOOGLE_AUTH_URL: &str = "https://accounts.google.com/o/oauth2/v2/auth"; +const GOOGLE_TOKEN_URL: &str = "https://oauth2.googleapis.com/token"; +const GOOGLE_CALENDAR_EVENTS_READONLY_SCOPE: &str = + "https://www.googleapis.com/auth/calendar.events.readonly"; +const OAUTH_CALLBACK_PATH: &str = "/"; +const OAUTH_CALLBACK_TIMEOUT: Duration = Duration::from_secs(180); + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct GoogleCalendarCredential { + access_token: Option, + connected_at: i64, + expires_at: Option, + refresh_token: String, + scope: Option, + token_type: Option, +} + +#[derive(Debug, Serialize)] +pub struct GoogleCalendarStatus { + configured: bool, + connected: bool, + connected_at: Option, + scopes: Vec, +} + +#[derive(Debug, Serialize)] +pub struct GoogleCalendarEvent { + id: String, + title: String, + starts_at: String, + ends_at: String, + all_day: bool, + join_url: Option, + html_url: Option, + transparency: Option, +} + +#[derive(Debug, Deserialize)] +struct GoogleTokenResponse { + access_token: Option, + expires_in: Option, + refresh_token: Option, + scope: Option, + token_type: Option, +} + +#[derive(Debug, Deserialize)] +struct GoogleEventsResponse { + items: Option>, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct GoogleRawEvent { + id: Option, + summary: Option, + status: Option, + start: Option, + end: Option, + hangout_link: Option, + html_link: Option, + conference_data: Option, + transparency: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct GoogleRawEventTime { + date: Option, + date_time: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct GoogleConferenceData { + entry_points: Option>, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct GoogleConferenceEntryPoint { + entry_point_type: Option, + uri: Option, +} + +fn google_calendar_client_id() -> Option { + std::env::var("BUZZ_GOOGLE_CALENDAR_CLIENT_ID") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .or_else(|| { + option_env!("BUZZ_GOOGLE_CALENDAR_CLIENT_ID") + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + }) +} + +fn google_calendar_client_secret() -> Option { + std::env::var("BUZZ_GOOGLE_CALENDAR_CLIENT_SECRET") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .or_else(|| { + option_env!("BUZZ_GOOGLE_CALENDAR_CLIENT_SECRET") + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + }) +} + +fn credential_store() -> &'static SecretStore { + SecretStore::shared(KEYRING_SERVICE) +} + +fn now_ts() -> i64 { + Utc::now().timestamp() +} + +fn load_credential() -> Result, String> { + let Some(raw) = credential_store().load(GOOGLE_CALENDAR_CREDENTIAL_KEY)? else { + return Ok(None); + }; + serde_json::from_str(&raw).map_err(|e| format!("parse Google Calendar credential: {e}")) +} + +fn save_credential(credential: &GoogleCalendarCredential) -> Result<(), String> { + let raw = serde_json::to_string(credential) + .map_err(|e| format!("serialize Google Calendar credential: {e}"))?; + credential_store().store(GOOGLE_CALENDAR_CREDENTIAL_KEY, &raw) +} + +fn scopes(scope: Option<&str>) -> Vec { + scope + .unwrap_or("") + .split_whitespace() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .collect() +} + +fn status_from_credential(credential: Option<&GoogleCalendarCredential>) -> GoogleCalendarStatus { + GoogleCalendarStatus { + configured: google_calendar_client_id().is_some(), + connected: credential.is_some(), + connected_at: credential.map(|value| value.connected_at), + scopes: scopes(credential.and_then(|value| value.scope.as_deref())), + } +} + +fn pkce_verifier() -> String { + [ + Uuid::new_v4().simple().to_string(), + Uuid::new_v4().simple().to_string(), + Uuid::new_v4().simple().to_string(), + ] + .join("") +} + +fn pkce_challenge(verifier: &str) -> String { + URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())) +} + +fn oauth_state() -> String { + [ + Uuid::new_v4().simple().to_string(), + Uuid::new_v4().simple().to_string(), + ] + .join("") +} + +fn oauth_authorization_url( + client_id: &str, + redirect_uri: &str, + state: &str, + code_challenge: &str, +) -> Result { + let mut url = Url::parse(GOOGLE_AUTH_URL).map_err(|e| e.to_string())?; + url.query_pairs_mut() + .append_pair("client_id", client_id) + .append_pair("redirect_uri", redirect_uri) + .append_pair("response_type", "code") + .append_pair("scope", GOOGLE_CALENDAR_EVENTS_READONLY_SCOPE) + .append_pair("state", state) + .append_pair("code_challenge", code_challenge) + .append_pair("code_challenge_method", "S256") + .append_pair("prompt", "consent") + .append_pair("access_type", "offline"); + Ok(url) +} + +fn callback_response(title: &str, body: &str) -> String { + let html = format!( + "{title}\ +
\ +

{title}

{body}

" + ); + format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + html.len(), + html + ) +} + +async fn wait_for_oauth_callback( + listener: TcpListener, + expected_state: &str, +) -> Result { + let (mut stream, _) = tokio::time::timeout(OAUTH_CALLBACK_TIMEOUT, listener.accept()) + .await + .map_err(|_| "Timed out waiting for Google Calendar authorization.".to_string())? + .map_err(|e| format!("accept OAuth callback: {e}"))?; + + let mut buffer = [0_u8; 8192]; + let bytes_read = stream + .read(&mut buffer) + .await + .map_err(|e| format!("read OAuth callback: {e}"))?; + let request = String::from_utf8_lossy(&buffer[..bytes_read]); + let request_target = request + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .ok_or_else(|| "Invalid OAuth callback request.".to_string())?; + let callback_url = Url::parse(&format!("http://127.0.0.1{request_target}")) + .map_err(|e| format!("parse OAuth callback: {e}"))?; + + let mut code = None; + let mut state = None; + let mut error = None; + for (key, value) in callback_url.query_pairs() { + match key.as_ref() { + "code" => code = Some(value.into_owned()), + "state" => state = Some(value.into_owned()), + "error" => error = Some(value.into_owned()), + _ => {} + } + } + + let result = if callback_url.path() != OAUTH_CALLBACK_PATH { + Err("Google returned an unexpected OAuth callback path.".to_string()) + } else if let Some(error) = error { + Err(format!("Google Calendar authorization failed: {error}")) + } else if state.as_deref() != Some(expected_state) { + Err("Google Calendar authorization state did not match.".to_string()) + } else { + code.ok_or_else(|| "Google Calendar authorization returned no code.".to_string()) + }; + + let (title, body) = if result.is_ok() { + ( + "Google Calendar connected", + "You can close this browser tab and return to Buzz.", + ) + } else { + ( + "Google Calendar connection failed", + "Return to Buzz to try connecting again.", + ) + }; + let response = callback_response(title, body); + let _ = stream.write_all(response.as_bytes()).await; + let _ = stream.shutdown().await; + + result +} + +async fn exchange_code_for_token( + code: &str, + code_verifier: &str, + redirect_uri: &str, + client_id: &str, + state: &AppState, +) -> Result { + let mut form = vec![ + ("client_id", client_id.to_string()), + ("code", code.to_string()), + ("code_verifier", code_verifier.to_string()), + ("grant_type", "authorization_code".to_string()), + ("redirect_uri", redirect_uri.to_string()), + ]; + if let Some(client_secret) = google_calendar_client_secret() { + form.push(("client_secret", client_secret)); + } + + let response = state + .http_client + .post(GOOGLE_TOKEN_URL) + .form(&form) + .send() + .await + .map_err(|e| format!("Google OAuth token exchange failed: {e}"))?; + parse_google_token_response(response).await +} + +async fn refresh_access_token( + credential: &GoogleCalendarCredential, + client_id: &str, + state: &AppState, +) -> Result { + let mut form = vec![ + ("client_id", client_id.to_string()), + ("refresh_token", credential.refresh_token.clone()), + ("grant_type", "refresh_token".to_string()), + ]; + if let Some(client_secret) = google_calendar_client_secret() { + form.push(("client_secret", client_secret)); + } + + let response = state + .http_client + .post(GOOGLE_TOKEN_URL) + .form(&form) + .send() + .await + .map_err(|e| format!("Google Calendar token refresh failed: {e}"))?; + parse_google_token_response(response).await +} + +async fn parse_google_token_response( + response: reqwest::Response, +) -> Result { + let status = response.status(); + let body = response + .text() + .await + .map_err(|e| format!("read Google token response: {e}"))?; + if !status.is_success() { + return Err(format!("Google token request failed ({status}): {body}")); + } + serde_json::from_str(&body).map_err(|e| format!("parse Google token response: {e}")) +} + +async fn access_token(state: &AppState) -> Result { + let client_id = google_calendar_client_id().ok_or_else(|| { + "Set BUZZ_GOOGLE_CALENDAR_CLIENT_ID to enable Google Calendar.".to_string() + })?; + let mut credential = load_credential()? + .ok_or_else(|| "Connect Google Calendar before reading events.".to_string())?; + + if let (Some(token), Some(expires_at)) = (&credential.access_token, credential.expires_at) { + if expires_at > now_ts() + 60 { + return Ok(token.clone()); + } + } + + let token = refresh_access_token(&credential, &client_id, state).await?; + let access_token = token + .access_token + .ok_or_else(|| "Google token refresh returned no access token.".to_string())?; + credential.access_token = Some(access_token.clone()); + credential.expires_at = token.expires_in.map(|seconds| now_ts() + seconds); + credential.scope = token.scope.or(credential.scope); + credential.token_type = token.token_type.or(credential.token_type); + save_credential(&credential)?; + Ok(access_token) +} + +fn is_http_url(value: &str) -> bool { + Url::parse(value) + .map(|url| url.scheme() == "http" || url.scheme() == "https") + .unwrap_or(false) +} + +fn event_join_url(event: &GoogleRawEvent) -> Option { + event + .conference_data + .as_ref() + .and_then(|data| data.entry_points.as_ref()) + .and_then(|entry_points| { + entry_points + .iter() + .find(|entry| entry.entry_point_type.as_deref() == Some("video")) + .and_then(|entry| entry.uri.as_deref()) + .filter(|value| is_http_url(value)) + .map(ToOwned::to_owned) + .or_else(|| { + entry_points + .iter() + .filter_map(|entry| entry.uri.as_deref()) + .find(|value| is_http_url(value)) + .map(ToOwned::to_owned) + }) + }) + .or_else(|| { + event + .hangout_link + .as_deref() + .filter(|value| is_http_url(value)) + .map(ToOwned::to_owned) + }) +} + +fn convert_google_event(event: GoogleRawEvent) -> Option { + if event.status.as_deref() == Some("cancelled") { + return None; + } + + let join_url = event_join_url(&event); + let html_url = event + .html_link + .as_ref() + .filter(|value| is_http_url(value)) + .cloned(); + let start = event.start?; + let end = event.end?; + let starts_at = start.date_time.or(start.date)?; + let ends_at = end.date_time.or(end.date)?; + let all_day = !starts_at.contains('T') && !ends_at.contains('T'); + + Some(GoogleCalendarEvent { + id: event + .id + .unwrap_or_else(|| Uuid::new_v4().simple().to_string()), + title: event.summary.unwrap_or_else(|| "Busy".to_string()), + starts_at, + ends_at, + all_day, + join_url, + html_url, + transparency: event.transparency, + }) +} + +#[tauri::command] +pub fn get_google_calendar_status() -> Result { + if google_calendar_client_id().is_none() { + return Ok(status_from_credential(None)); + } + + Ok(status_from_credential(load_credential()?.as_ref())) +} + +#[tauri::command] +pub async fn connect_google_calendar( + state: State<'_, AppState>, +) -> Result { + let client_id = google_calendar_client_id().ok_or_else(|| { + "Set BUZZ_GOOGLE_CALENDAR_CLIENT_ID to enable Google Calendar.".to_string() + })?; + let listener = TcpListener::bind(("127.0.0.1", 0)) + .await + .map_err(|e| format!("bind Google Calendar OAuth callback: {e}"))?; + let port = listener + .local_addr() + .map_err(|e| format!("read OAuth callback address: {e}"))? + .port(); + let redirect_uri = format!("http://127.0.0.1:{port}"); + let code_verifier = pkce_verifier(); + let state_token = oauth_state(); + let auth_url = oauth_authorization_url( + &client_id, + &redirect_uri, + &state_token, + &pkce_challenge(&code_verifier), + )?; + + tauri_plugin_opener::open_url(auth_url.as_str(), None::<&str>) + .map_err(|e| format!("open Google authorization page: {e}"))?; + + let code = wait_for_oauth_callback(listener, &state_token).await?; + let token = + exchange_code_for_token(&code, &code_verifier, &redirect_uri, &client_id, &state).await?; + let refresh_token = token.refresh_token.ok_or_else(|| { + "Google did not return a refresh token. Disconnect and try again.".to_string() + })?; + let credential = GoogleCalendarCredential { + access_token: token.access_token, + connected_at: now_ts(), + expires_at: token.expires_in.map(|seconds| now_ts() + seconds), + refresh_token, + scope: token.scope, + token_type: token.token_type, + }; + save_credential(&credential)?; + + Ok(status_from_credential(Some(&credential))) +} + +#[tauri::command] +pub fn disconnect_google_calendar() -> Result { + credential_store().delete(GOOGLE_CALENDAR_CREDENTIAL_KEY)?; + Ok(status_from_credential(None)) +} + +#[tauri::command] +pub async fn get_google_calendar_events( + time_min: String, + time_max: String, + state: State<'_, AppState>, +) -> Result, String> { + let access_token = access_token(&state).await?; + let mut url = Url::parse("https://www.googleapis.com/calendar/v3/calendars/primary/events") + .map_err(|e| e.to_string())?; + url.query_pairs_mut() + .append_pair("timeMin", &time_min) + .append_pair("timeMax", &time_max) + .append_pair("singleEvents", "true") + .append_pair("orderBy", "startTime") + .append_pair("showDeleted", "false") + .append_pair("conferenceDataVersion", "1") + .append_pair("maxResults", "50"); + + let response = state + .http_client + .get(url) + .bearer_auth(access_token) + .send() + .await + .map_err(|e| format!("Google Calendar events request failed: {e}"))?; + let status = response.status(); + let body = response + .text() + .await + .map_err(|e| format!("read Google Calendar events response: {e}"))?; + if !status.is_success() { + return Err(format!( + "Google Calendar events request failed ({status}): {body}" + )); + } + + let raw: GoogleEventsResponse = + serde_json::from_str(&body).map_err(|e| format!("parse Google Calendar events: {e}"))?; + Ok(raw + .items + .unwrap_or_default() + .into_iter() + .filter_map(convert_google_event) + .collect()) +} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 445fe2956..338be14a4 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -3,6 +3,7 @@ mod agent_discovery; mod agent_models; mod agent_settings; mod agents; +mod calendar; mod canvas; mod channel_templates; mod channels; @@ -27,6 +28,7 @@ mod profile; mod relay_members; mod relay_reconnect; mod social; +mod spotify; mod teams; mod workflows; mod workspace; @@ -36,6 +38,7 @@ pub use agent_discovery::*; pub use agent_models::*; pub use agent_settings::*; pub use agents::*; +pub use calendar::*; pub use canvas::*; pub use channel_templates::*; pub use channels::*; @@ -58,6 +61,7 @@ pub use profile::*; pub use relay_members::*; pub use relay_reconnect::*; pub use social::*; +pub use spotify::*; pub use teams::*; pub use workflows::*; pub use workspace::*; diff --git a/desktop/src-tauri/src/commands/spotify.rs b/desktop/src-tauri/src/commands/spotify.rs new file mode 100644 index 000000000..f501970a6 --- /dev/null +++ b/desktop/src-tauri/src/commands/spotify.rs @@ -0,0 +1,758 @@ +use std::time::Duration; + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use tauri::State; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; +use url::Url; +use uuid::Uuid; + +use crate::app_state::{AppState, KEYRING_SERVICE}; +use crate::secret_store::SecretStore; + +const SPOTIFY_CREDENTIAL_KEY: &str = "spotify-oauth"; +const SPOTIFY_AUTH_URL: &str = "https://accounts.spotify.com/authorize"; +const SPOTIFY_TOKEN_URL: &str = "https://accounts.spotify.com/api/token"; +const SPOTIFY_API_BASE_URL: &str = "https://api.spotify.com/v1"; +const SPOTIFY_SCOPES: &str = + "user-read-playback-state user-read-currently-playing user-modify-playback-state"; +const OAUTH_CALLBACK_PATH: &str = "/oauth/spotify/callback"; +const OAUTH_CALLBACK_TIMEOUT: Duration = Duration::from_secs(180); +const DEFAULT_SPOTIFY_REDIRECT_PORT: u16 = 18202; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct SpotifyCredential { + access_token: Option, + connected_at: i64, + expires_at: Option, + refresh_token: String, + scope: Option, + token_type: Option, +} + +#[derive(Debug, Serialize)] +pub struct SpotifyStatus { + configured: bool, + connected: bool, + connected_at: Option, + scopes: Vec, +} + +#[derive(Debug, Serialize)] +pub struct SpotifyDevice { + id: Option, + name: String, + device_type: String, + is_active: bool, + is_restricted: bool, + volume_percent: Option, +} + +#[derive(Debug, Serialize)] +pub struct SpotifyPlaybackState { + context_uri: Option, + device: Option, + is_playing: bool, + item: Option, + progress_ms: Option, + timestamp: Option, +} + +#[derive(Debug, Serialize)] +pub struct SpotifyPlaybackItem { + artists: Vec, + duration_ms: Option, + image_url: Option, + item_type: Option, + name: String, + uri: String, +} + +#[derive(Debug, Deserialize)] +struct SpotifyTokenResponse { + access_token: Option, + expires_in: Option, + refresh_token: Option, + scope: Option, + token_type: Option, +} + +#[derive(Debug, Deserialize)] +struct SpotifyDevicesResponse { + devices: Vec, +} + +#[derive(Debug, Deserialize)] +struct SpotifyRawDevice { + id: Option, + #[serde(default)] + is_active: bool, + #[serde(default)] + is_restricted: bool, + #[serde(default)] + name: String, + #[serde(default, rename = "type")] + device_type: String, + volume_percent: Option, +} + +#[derive(Debug, Deserialize)] +struct SpotifyRawPlaybackState { + context: Option, + device: Option, + #[serde(default)] + is_playing: bool, + item: Option, + progress_ms: Option, + timestamp: Option, +} + +#[derive(Debug, Deserialize)] +struct SpotifyRawContext { + uri: Option, +} + +#[derive(Debug, Deserialize)] +struct SpotifyRawPlaybackItem { + album: Option, + artists: Option>, + duration_ms: Option, + name: Option, + #[serde(rename = "type")] + item_type: Option, + uri: Option, +} + +#[derive(Debug, Deserialize)] +struct SpotifyRawAlbum { + images: Option>, +} + +#[derive(Debug, Deserialize)] +struct SpotifyRawArtist { + name: Option, +} + +#[derive(Debug, Deserialize)] +struct SpotifyRawImage { + url: Option, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SpotifyPlaybackInput { + context_uri: Option, + device_id: Option, + position_ms: Option, + uris: Option>, +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SpotifyDeviceInput { + device_id: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SpotifySeekInput { + device_id: Option, + position_ms: u32, +} + +#[derive(Debug, Serialize)] +struct SpotifyPlaybackBody<'a> { + #[serde(skip_serializing_if = "Option::is_none")] + context_uri: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + position_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + uris: Option<&'a [String]>, +} + +fn spotify_client_id() -> Option { + std::env::var("BUZZ_SPOTIFY_CLIENT_ID") + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .or_else(|| { + option_env!("BUZZ_SPOTIFY_CLIENT_ID") + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + }) +} + +fn spotify_redirect_port() -> u16 { + std::env::var("BUZZ_SPOTIFY_REDIRECT_PORT") + .ok() + .and_then(|value| value.trim().parse::().ok()) + .filter(|port| *port != 0) + .unwrap_or(DEFAULT_SPOTIFY_REDIRECT_PORT) +} + +fn credential_store() -> &'static SecretStore { + SecretStore::shared(KEYRING_SERVICE) +} + +fn now_ts() -> i64 { + Utc::now().timestamp() +} + +fn load_credential() -> Result, String> { + let Some(raw) = credential_store().load(SPOTIFY_CREDENTIAL_KEY)? else { + return Ok(None); + }; + serde_json::from_str(&raw).map_err(|e| format!("parse Spotify credential: {e}")) +} + +fn save_credential(credential: &SpotifyCredential) -> Result<(), String> { + let raw = serde_json::to_string(credential) + .map_err(|e| format!("serialize Spotify credential: {e}"))?; + credential_store().store(SPOTIFY_CREDENTIAL_KEY, &raw) +} + +fn scopes(scope: Option<&str>) -> Vec { + scope + .unwrap_or("") + .split_whitespace() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .collect() +} + +fn status_from_credential(credential: Option<&SpotifyCredential>) -> SpotifyStatus { + SpotifyStatus { + configured: spotify_client_id().is_some(), + connected: credential.is_some(), + connected_at: credential.map(|value| value.connected_at), + scopes: scopes(credential.and_then(|value| value.scope.as_deref())), + } +} + +fn pkce_verifier() -> String { + [ + Uuid::new_v4().simple().to_string(), + Uuid::new_v4().simple().to_string(), + Uuid::new_v4().simple().to_string(), + ] + .join("") +} + +fn pkce_challenge(verifier: &str) -> String { + URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())) +} + +fn oauth_state() -> String { + [ + Uuid::new_v4().simple().to_string(), + Uuid::new_v4().simple().to_string(), + ] + .join("") +} + +fn oauth_authorization_url( + client_id: &str, + redirect_uri: &str, + state: &str, + code_challenge: &str, +) -> Result { + let mut url = Url::parse(SPOTIFY_AUTH_URL).map_err(|e| e.to_string())?; + url.query_pairs_mut() + .append_pair("client_id", client_id) + .append_pair("redirect_uri", redirect_uri) + .append_pair("response_type", "code") + .append_pair("scope", SPOTIFY_SCOPES) + .append_pair("state", state) + .append_pair("code_challenge", code_challenge) + .append_pair("code_challenge_method", "S256"); + Ok(url) +} + +fn callback_response(title: &str, body: &str) -> String { + let html = format!( + "{title}\ +
\ +

{title}

{body}

" + ); + format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + html.len(), + html + ) +} + +async fn wait_for_oauth_callback( + listener: TcpListener, + expected_state: &str, +) -> Result { + let (mut stream, _) = tokio::time::timeout(OAUTH_CALLBACK_TIMEOUT, listener.accept()) + .await + .map_err(|_| "Timed out waiting for Spotify authorization.".to_string())? + .map_err(|e| format!("accept OAuth callback: {e}"))?; + + let mut buffer = [0_u8; 8192]; + let bytes_read = stream + .read(&mut buffer) + .await + .map_err(|e| format!("read OAuth callback: {e}"))?; + let request = String::from_utf8_lossy(&buffer[..bytes_read]); + let request_target = request + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .ok_or_else(|| "Invalid OAuth callback request.".to_string())?; + let callback_url = Url::parse(&format!("http://127.0.0.1{request_target}")) + .map_err(|e| format!("parse OAuth callback: {e}"))?; + + let mut code = None; + let mut state = None; + let mut error = None; + for (key, value) in callback_url.query_pairs() { + match key.as_ref() { + "code" => code = Some(value.into_owned()), + "state" => state = Some(value.into_owned()), + "error" => error = Some(value.into_owned()), + _ => {} + } + } + + let result = if callback_url.path() != OAUTH_CALLBACK_PATH { + Err("Spotify returned an unexpected OAuth callback path.".to_string()) + } else if let Some(error) = error { + Err(format!("Spotify authorization couldn't complete: {error}")) + } else if state.as_deref() != Some(expected_state) { + Err("Spotify authorization state did not match.".to_string()) + } else { + code.ok_or_else(|| "Spotify authorization returned no code.".to_string()) + }; + + let (title, body) = if result.is_ok() { + ( + "Spotify connected", + "You can close this browser tab and return to Buzz.", + ) + } else { + ( + "Spotify connection incomplete", + "Return to Buzz to try again.", + ) + }; + let response = callback_response(title, body); + let _ = stream.write_all(response.as_bytes()).await; + let _ = stream.shutdown().await; + + result +} + +async fn parse_spotify_token_response( + response: reqwest::Response, +) -> Result { + let status = response.status(); + let body = response + .text() + .await + .map_err(|e| format!("read Spotify token response: {e}"))?; + if !status.is_success() { + return Err(format!("Spotify token request returned {status}: {body}")); + } + serde_json::from_str(&body).map_err(|e| format!("parse Spotify token response: {e}")) +} + +async fn exchange_code_for_token( + code: &str, + code_verifier: &str, + redirect_uri: &str, + client_id: &str, + state: &AppState, +) -> Result { + let form = vec![ + ("client_id", client_id.to_string()), + ("code", code.to_string()), + ("code_verifier", code_verifier.to_string()), + ("grant_type", "authorization_code".to_string()), + ("redirect_uri", redirect_uri.to_string()), + ]; + + let response = state + .http_client + .post(SPOTIFY_TOKEN_URL) + .form(&form) + .send() + .await + .map_err(|e| format!("Spotify OAuth token exchange couldn't complete: {e}"))?; + parse_spotify_token_response(response).await +} + +async fn refresh_access_token( + credential: &SpotifyCredential, + client_id: &str, + state: &AppState, +) -> Result { + let form = vec![ + ("client_id", client_id.to_string()), + ("refresh_token", credential.refresh_token.clone()), + ("grant_type", "refresh_token".to_string()), + ]; + + let response = state + .http_client + .post(SPOTIFY_TOKEN_URL) + .form(&form) + .send() + .await + .map_err(|e| format!("Spotify token refresh couldn't complete: {e}"))?; + parse_spotify_token_response(response).await +} + +async fn access_token(state: &AppState) -> Result { + let client_id = spotify_client_id() + .ok_or_else(|| "Set BUZZ_SPOTIFY_CLIENT_ID to enable Spotify.".to_string())?; + let mut credential = load_credential()? + .ok_or_else(|| "Connect Spotify before controlling playback.".to_string())?; + + if let (Some(token), Some(expires_at)) = (&credential.access_token, credential.expires_at) { + if expires_at > now_ts() + 60 { + return Ok(token.clone()); + } + } + + let token = refresh_access_token(&credential, &client_id, state).await?; + let access_token = token + .access_token + .ok_or_else(|| "Spotify token refresh returned no access token.".to_string())?; + credential.access_token = Some(access_token.clone()); + credential.expires_at = token.expires_in.map(|seconds| now_ts() + seconds); + if let Some(refresh_token) = token.refresh_token { + credential.refresh_token = refresh_token; + } + credential.scope = token.scope.or(credential.scope); + credential.token_type = token.token_type.or(credential.token_type); + save_credential(&credential)?; + Ok(access_token) +} + +fn convert_device(device: SpotifyRawDevice) -> SpotifyDevice { + SpotifyDevice { + id: device.id, + name: device.name, + device_type: device.device_type, + is_active: device.is_active, + is_restricted: device.is_restricted, + volume_percent: device.volume_percent, + } +} + +fn convert_playback_item(item: SpotifyRawPlaybackItem) -> Option { + let uri = item.uri?; + let name = item.name.unwrap_or_else(|| "Untitled".to_string()); + let artists = item + .artists + .unwrap_or_default() + .into_iter() + .filter_map(|artist| artist.name) + .collect(); + let image_url = item + .album + .and_then(|album| album.images) + .and_then(|images| images.into_iter().find_map(|image| image.url)); + + Some(SpotifyPlaybackItem { + artists, + duration_ms: item.duration_ms, + image_url, + item_type: item.item_type, + name, + uri, + }) +} + +fn convert_playback_state(state: SpotifyRawPlaybackState) -> SpotifyPlaybackState { + SpotifyPlaybackState { + context_uri: state.context.and_then(|context| context.uri), + device: state.device.map(convert_device), + is_playing: state.is_playing, + item: state.item.and_then(convert_playback_item), + progress_ms: state.progress_ms, + timestamp: state.timestamp, + } +} + +async fn spotify_json Deserialize<'de>>( + response: reqwest::Response, + context: &str, +) -> Result { + let status = response.status(); + let body = response + .text() + .await + .map_err(|e| format!("read Spotify {context} response: {e}"))?; + if !status.is_success() { + return Err(format!( + "Spotify {context} request returned {status}: {body}" + )); + } + serde_json::from_str(&body).map_err(|e| format!("parse Spotify {context} response: {e}")) +} + +async fn spotify_empty(response: reqwest::Response, context: &str) -> Result<(), String> { + let status = response.status(); + let body = response + .text() + .await + .map_err(|e| format!("read Spotify {context} response: {e}"))?; + if !status.is_success() { + return Err(format!( + "Spotify {context} request returned {status}: {body}" + )); + } + Ok(()) +} + +#[tauri::command] +pub fn get_spotify_status() -> Result { + if spotify_client_id().is_none() { + return Ok(status_from_credential(None)); + } + + Ok(status_from_credential(load_credential()?.as_ref())) +} + +#[tauri::command] +pub async fn connect_spotify(state: State<'_, AppState>) -> Result { + let client_id = spotify_client_id() + .ok_or_else(|| "Set BUZZ_SPOTIFY_CLIENT_ID to enable Spotify.".to_string())?; + let redirect_port = spotify_redirect_port(); + let listener = TcpListener::bind(("127.0.0.1", redirect_port)) + .await + .map_err(|e| format!("bind Spotify OAuth callback on port {redirect_port}: {e}"))?; + let redirect_uri = format!("http://127.0.0.1:{redirect_port}{OAUTH_CALLBACK_PATH}"); + let code_verifier = pkce_verifier(); + let state_token = oauth_state(); + let auth_url = oauth_authorization_url( + &client_id, + &redirect_uri, + &state_token, + &pkce_challenge(&code_verifier), + )?; + + tauri_plugin_opener::open_url(auth_url.as_str(), None::<&str>) + .map_err(|e| format!("open Spotify authorization page: {e}"))?; + + let code = wait_for_oauth_callback(listener, &state_token).await?; + let token = + exchange_code_for_token(&code, &code_verifier, &redirect_uri, &client_id, &state).await?; + let refresh_token = token.refresh_token.ok_or_else(|| { + "Spotify did not return a refresh token. Disconnect and try again.".to_string() + })?; + let credential = SpotifyCredential { + access_token: token.access_token, + connected_at: now_ts(), + expires_at: token.expires_in.map(|seconds| now_ts() + seconds), + refresh_token, + scope: token.scope, + token_type: token.token_type, + }; + save_credential(&credential)?; + + Ok(status_from_credential(Some(&credential))) +} + +#[tauri::command] +pub fn disconnect_spotify() -> Result { + credential_store().delete(SPOTIFY_CREDENTIAL_KEY)?; + Ok(status_from_credential(None)) +} + +#[tauri::command] +pub async fn get_spotify_devices(state: State<'_, AppState>) -> Result, String> { + let access_token = access_token(&state).await?; + let url = format!("{SPOTIFY_API_BASE_URL}/me/player/devices"); + let response = state + .http_client + .get(url) + .bearer_auth(access_token) + .send() + .await + .map_err(|e| format!("Spotify devices request couldn't complete: {e}"))?; + let raw: SpotifyDevicesResponse = spotify_json(response, "devices").await?; + Ok(raw.devices.into_iter().map(convert_device).collect()) +} + +#[tauri::command] +pub async fn get_spotify_playback_state( + state: State<'_, AppState>, +) -> Result, String> { + let access_token = access_token(&state).await?; + let url = format!("{SPOTIFY_API_BASE_URL}/me/player"); + let response = state + .http_client + .get(url) + .bearer_auth(access_token) + .send() + .await + .map_err(|e| format!("Spotify playback state request couldn't complete: {e}"))?; + + if response.status().as_u16() == 204 { + return Ok(None); + } + + let raw: SpotifyRawPlaybackState = spotify_json(response, "playback state").await?; + Ok(Some(convert_playback_state(raw))) +} + +#[tauri::command] +pub async fn start_spotify_playback( + input: Option, + state: State<'_, AppState>, +) -> Result<(), String> { + let access_token = access_token(&state).await?; + let input = input.unwrap_or_default(); + let mut url = + Url::parse(&format!("{SPOTIFY_API_BASE_URL}/me/player/play")).map_err(|e| e.to_string())?; + if let Some(device_id) = input.device_id.as_deref().filter(|value| !value.is_empty()) { + url.query_pairs_mut().append_pair("device_id", device_id); + } + + let uris = input.uris.as_ref().filter(|values| !values.is_empty()); + let body = SpotifyPlaybackBody { + context_uri: input + .context_uri + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()), + position_ms: input.position_ms, + uris: uris.map(Vec::as_slice), + }; + let has_body = body.context_uri.is_some() || body.position_ms.is_some() || body.uris.is_some(); + let request = state.http_client.put(url).bearer_auth(access_token); + let response = if has_body { + request.json(&body).send().await + } else { + request.send().await + } + .map_err(|e| format!("Spotify playback request couldn't complete: {e}"))?; + + let status = response.status(); + let body = response + .text() + .await + .map_err(|e| format!("read Spotify playback response: {e}"))?; + if !status.is_success() { + return Err(format!( + "Spotify playback request returned {status}: {body}" + )); + } + Ok(()) +} + +#[tauri::command] +pub async fn pause_spotify_playback( + input: Option, + state: State<'_, AppState>, +) -> Result<(), String> { + let access_token = access_token(&state).await?; + let mut url = Url::parse(&format!("{SPOTIFY_API_BASE_URL}/me/player/pause")) + .map_err(|e| e.to_string())?; + if let Some(device_id) = input + .and_then(|input| input.device_id) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + { + url.query_pairs_mut().append_pair("device_id", &device_id); + } + + let response = state + .http_client + .put(url) + .bearer_auth(access_token) + .send() + .await + .map_err(|e| format!("Spotify pause request couldn't complete: {e}"))?; + spotify_empty(response, "pause").await +} + +#[tauri::command] +pub async fn skip_spotify_next( + input: Option, + state: State<'_, AppState>, +) -> Result<(), String> { + let access_token = access_token(&state).await?; + let mut url = + Url::parse(&format!("{SPOTIFY_API_BASE_URL}/me/player/next")).map_err(|e| e.to_string())?; + if let Some(device_id) = input + .and_then(|input| input.device_id) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + { + url.query_pairs_mut().append_pair("device_id", &device_id); + } + + let response = state + .http_client + .post(url) + .bearer_auth(access_token) + .send() + .await + .map_err(|e| format!("Spotify next track request couldn't complete: {e}"))?; + spotify_empty(response, "next track").await +} + +#[tauri::command] +pub async fn skip_spotify_previous( + input: Option, + state: State<'_, AppState>, +) -> Result<(), String> { + let access_token = access_token(&state).await?; + let mut url = Url::parse(&format!("{SPOTIFY_API_BASE_URL}/me/player/previous")) + .map_err(|e| e.to_string())?; + if let Some(device_id) = input + .and_then(|input| input.device_id) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + { + url.query_pairs_mut().append_pair("device_id", &device_id); + } + + let response = state + .http_client + .post(url) + .bearer_auth(access_token) + .send() + .await + .map_err(|e| format!("Spotify previous track request couldn't complete: {e}"))?; + spotify_empty(response, "previous track").await +} + +#[tauri::command] +pub async fn seek_spotify_playback( + input: SpotifySeekInput, + state: State<'_, AppState>, +) -> Result<(), String> { + let access_token = access_token(&state).await?; + let mut url = + Url::parse(&format!("{SPOTIFY_API_BASE_URL}/me/player/seek")).map_err(|e| e.to_string())?; + url.query_pairs_mut() + .append_pair("position_ms", &input.position_ms.to_string()); + if let Some(device_id) = input + .device_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + url.query_pairs_mut().append_pair("device_id", device_id); + } + + let response = state + .http_client + .put(url) + .bearer_auth(access_token) + .send() + .await + .map_err(|e| format!("Spotify seek request couldn't complete: {e}"))?; + spotify_empty(response, "seek").await +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 369046a9d..c2f9a86a2 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -445,6 +445,20 @@ pub fn run() { get_relay_ws_url, get_relay_http_url, get_media_proxy_port, + get_google_calendar_status, + connect_google_calendar, + disconnect_google_calendar, + get_google_calendar_events, + get_spotify_status, + connect_spotify, + disconnect_spotify, + get_spotify_devices, + get_spotify_playback_state, + start_spotify_playback, + pause_spotify_playback, + skip_spotify_next, + skip_spotify_previous, + seek_spotify_playback, fetch_link_preview_title, discover_acp_providers, install_acp_runtime, diff --git a/desktop/src/features/calendar/api.ts b/desktop/src/features/calendar/api.ts new file mode 100644 index 000000000..e905b7b54 --- /dev/null +++ b/desktop/src/features/calendar/api.ts @@ -0,0 +1,88 @@ +import { invokeTauri } from "@/shared/api/tauri"; + +export type GoogleCalendarStatus = { + configured: boolean; + connected: boolean; + connectedAt: number | null; + scopes: string[]; +}; + +export type GoogleCalendarEvent = { + id: string; + title: string; + startsAt: string; + endsAt: string; + allDay: boolean; + joinUrl: string | null; + htmlUrl: string | null; + transparency: string | null; +}; + +type RawGoogleCalendarStatus = { + configured: boolean; + connected: boolean; + connected_at: number | null; + scopes: string[]; +}; + +type RawGoogleCalendarEvent = { + id: string; + title: string; + starts_at: string; + ends_at: string; + all_day: boolean; + join_url: string | null; + html_url: string | null; + transparency: string | null; +}; + +function fromRawStatus(raw: RawGoogleCalendarStatus): GoogleCalendarStatus { + return { + configured: raw.configured, + connected: raw.connected, + connectedAt: raw.connected_at, + scopes: raw.scopes, + }; +} + +function fromRawEvent(raw: RawGoogleCalendarEvent): GoogleCalendarEvent { + return { + id: raw.id, + title: raw.title, + startsAt: raw.starts_at, + endsAt: raw.ends_at, + allDay: raw.all_day, + joinUrl: raw.join_url, + htmlUrl: raw.html_url, + transparency: raw.transparency, + }; +} + +export async function getGoogleCalendarStatus(): Promise { + return fromRawStatus( + await invokeTauri("get_google_calendar_status"), + ); +} + +export async function connectGoogleCalendar(): Promise { + return fromRawStatus( + await invokeTauri("connect_google_calendar"), + ); +} + +export async function disconnectGoogleCalendar(): Promise { + return fromRawStatus( + await invokeTauri("disconnect_google_calendar"), + ); +} + +export async function getGoogleCalendarEvents(input: { + timeMax: string; + timeMin: string; +}): Promise { + const raw = await invokeTauri( + "get_google_calendar_events", + input, + ); + return raw.map(fromRawEvent); +} diff --git a/desktop/src/features/calendar/hooks.ts b/desktop/src/features/calendar/hooks.ts new file mode 100644 index 000000000..e8ad788cd --- /dev/null +++ b/desktop/src/features/calendar/hooks.ts @@ -0,0 +1,190 @@ +import * as React from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { + connectGoogleCalendar, + disconnectGoogleCalendar, + getGoogleCalendarEvents, + getGoogleCalendarStatus, + type GoogleCalendarEvent, +} from "@/features/calendar/api"; +import { useNow } from "@/shared/lib/useNow"; + +export const googleCalendarStatusQueryKey = ["google-calendar-status"] as const; + +export const googleCalendarEventsQueryKey = ( + timeMin: string, + timeMax: string, +) => ["google-calendar-events", timeMin, timeMax] as const; + +const calendarBusyTimeFormatter = new Intl.DateTimeFormat(undefined, { + hour: "numeric", + minute: "2-digit", +}); + +export function useGoogleCalendarStatusQuery({ + enabled = true, +}: { + enabled?: boolean; +} = {}) { + return useQuery({ + enabled, + queryKey: googleCalendarStatusQueryKey, + queryFn: getGoogleCalendarStatus, + staleTime: 60_000, + }); +} + +export function useGoogleCalendarEventsQuery({ + enabled, + timeMax, + timeMin, +}: { + enabled: boolean; + timeMax: string; + timeMin: string; +}) { + return useQuery({ + enabled, + queryKey: googleCalendarEventsQueryKey(timeMin, timeMax), + queryFn: () => getGoogleCalendarEvents({ timeMax, timeMin }), + refetchInterval: enabled ? 60_000 : false, + staleTime: 45_000, + }); +} + +export function useGoogleCalendarConnectionMutations() { + const queryClient = useQueryClient(); + const invalidate = () => { + void queryClient.invalidateQueries({ + queryKey: googleCalendarStatusQueryKey, + }); + void queryClient.invalidateQueries({ + predicate: (query) => query.queryKey[0] === "google-calendar-events", + }); + }; + + const connect = useMutation({ + mutationFn: connectGoogleCalendar, + onSuccess: invalidate, + }); + const disconnect = useMutation({ + mutationFn: disconnectGoogleCalendar, + onSuccess: invalidate, + }); + + return { connect, disconnect }; +} + +export function eventStartDate(event: GoogleCalendarEvent): Date { + return parseCalendarDate(event.startsAt, event.allDay); +} + +export function eventEndDate(event: GoogleCalendarEvent): Date { + return parseCalendarDate(event.endsAt, event.allDay); +} + +export function isBusyCalendarEvent(event: GoogleCalendarEvent): boolean { + return !event.allDay && event.transparency !== "transparent"; +} + +export function isOngoingCalendarEvent( + event: GoogleCalendarEvent, + nowMs: number, +): boolean { + return ( + isBusyCalendarEvent(event) && + eventStartDate(event).getTime() <= nowMs && + eventEndDate(event).getTime() > nowMs + ); +} + +export function isUpcomingCalendarEvent( + event: GoogleCalendarEvent, + nowMs: number, +): boolean { + return isBusyCalendarEvent(event) && eventStartDate(event).getTime() > nowMs; +} + +export function formatCalendarBusyLabel(event: GoogleCalendarEvent): string { + return `In meeting until ${calendarBusyTimeFormatter.format( + eventEndDate(event), + )}`; +} + +export function parseCalendarDate(value: string, allDay: boolean): Date { + if (allDay && /^\d{4}-\d{2}-\d{2}$/.test(value)) { + return new Date(`${value}T00:00:00`); + } + return new Date(value); +} + +export function todayCalendarWindow(nowMs = Date.now()): { + timeMax: string; + timeMin: string; +} { + const start = new Date(nowMs); + start.setHours(0, 0, 0, 0); + const end = new Date(start); + end.setDate(end.getDate() + 1); + return { + timeMin: start.toISOString(), + timeMax: end.toISOString(), + }; +} + +export function currentCalendarWindow(nowMs = Date.now()): { + timeMax: string; + timeMin: string; +} { + const start = new Date(nowMs - 60 * 60 * 1_000); + const end = new Date(nowMs + 8 * 60 * 60 * 1_000); + return { + timeMin: start.toISOString(), + timeMax: end.toISOString(), + }; +} + +export function useCurrentGoogleCalendarEvent({ + enabled = true, +}: { + enabled?: boolean; +} = {}) { + const now = useNow(60_000); + const statusQuery = useGoogleCalendarStatusQuery({ enabled }); + const window = React.useMemo(() => todayCalendarWindow(now), [now]); + const eventsQuery = useGoogleCalendarEventsQuery({ + enabled: Boolean( + enabled && statusQuery.data?.configured && statusQuery.data.connected, + ), + timeMax: window.timeMax, + timeMin: window.timeMin, + }); + const events = eventsQuery.data ?? []; + const sortedEvents = React.useMemo( + () => + [...events].sort( + (a, b) => eventStartDate(a).getTime() - eventStartDate(b).getTime(), + ), + [events], + ); + const currentEvent = React.useMemo( + () => + sortedEvents.find((event) => isOngoingCalendarEvent(event, now)) ?? null, + [sortedEvents, now], + ); + const nextEvent = React.useMemo( + () => + sortedEvents.find((event) => isUpcomingCalendarEvent(event, now)) ?? null, + [sortedEvents, now], + ); + + return { + currentEvent, + eventsQuery, + isConnected: Boolean(statusQuery.data?.connected), + isConfigured: Boolean(statusQuery.data?.configured), + nextEvent, + statusQuery, + }; +} diff --git a/desktop/src/features/calendar/ui/CalendarSettingsCard.tsx b/desktop/src/features/calendar/ui/CalendarSettingsCard.tsx new file mode 100644 index 000000000..83acca4e2 --- /dev/null +++ b/desktop/src/features/calendar/ui/CalendarSettingsCard.tsx @@ -0,0 +1,137 @@ +import { CalendarDays, Check, ExternalLink, Plug, Unplug } from "lucide-react"; + +import { + useGoogleCalendarConnectionMutations, + useGoogleCalendarStatusQuery, +} from "@/features/calendar/hooks"; +import { SpotifySettingsCard } from "@/features/spotify/ui/SpotifySettingsCard"; +import { Button } from "@/shared/ui/button"; +import { + SettingsOptionGroup, + SettingsOptionRow, +} from "@/features/settings/ui/SettingsOptionGroup"; +import { SettingsSectionHeader } from "@/features/settings/ui/SettingsSectionHeader"; + +function errorMessage(error: unknown): string | null { + if (!error) return null; + return error instanceof Error ? error.message : String(error); +} + +export function ConnectionsSettingsCard() { + const statusQuery = useGoogleCalendarStatusQuery(); + const { connect, disconnect } = useGoogleCalendarConnectionMutations(); + const status = statusQuery.data; + const isBusy = connect.isPending || disconnect.isPending; + const isLoading = statusQuery.isLoading; + const mutationError = + errorMessage(connect.error) ?? errorMessage(disconnect.error); + const statusError = errorMessage(statusQuery.error); + const description = isLoading + ? "Checking Google Calendar connection status." + : status?.connected + ? "Connected with read-only event access. Meeting details stay on this device." + : status?.configured + ? "Connect to read upcoming events and video meeting links." + : "Google Calendar is unavailable until this build includes a Google OAuth client ID."; + + return ( +
+ + +
+ + +
+ + + Google Calendar + +

+ {description} +

+
+ {status?.connected ? ( + + ) : ( + + )} +
+
+ + {status?.connected ? ( +
+ + + Google Calendar is ready. The sidebar will show current and + upcoming meetings. + +
+ ) : null} + + {!isLoading && !status?.configured ? ( +
+ Connect is unavailable because this build is missing its Google + OAuth client ID. End users do not create this themselves; local and + staging builds need{" "} + + BUZZ_GOOGLE_CALENDAR_CLIENT_ID + + . If the OAuth app has a client secret, also provide{" "} + + BUZZ_GOOGLE_CALENDAR_CLIENT_SECRET + + . +
+ ) : null} + + {!status?.connected && status?.configured ? ( +
+ Connect opens Google OAuth in your browser. After you approve + read-only calendar access, Buzz stores the refresh token in the + system keychain. +
+ ) : null} + + {statusError || mutationError ? ( +
+ {mutationError ?? statusError} +
+ ) : null} + + + Google OAuth desktop app setup + + + + +
+
+ ); +} + +export const CalendarSettingsCard = ConnectionsSettingsCard; diff --git a/desktop/src/features/profile/ui/ProfilePopover.tsx b/desktop/src/features/profile/ui/ProfilePopover.tsx index 7b6478767..b06f08377 100644 --- a/desktop/src/features/profile/ui/ProfilePopover.tsx +++ b/desktop/src/features/profile/ui/ProfilePopover.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { ChevronRight, Smile } from "lucide-react"; +import { CalendarDays, ChevronRight, Smile } from "lucide-react"; import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; @@ -18,6 +18,7 @@ interface ProfilePopoverProps { avatarDataUrl?: string | null; currentStatus: PresenceStatus; isStatusPending?: boolean; + calendarBusyLabel?: string | null; userStatusText?: string; userStatusEmoji?: string; onSetStatus: (status: PresenceStatus) => void; @@ -48,6 +49,7 @@ export function ProfilePopover({ avatarDataUrl, currentStatus, isStatusPending, + calendarBusyLabel, userStatusText, userStatusEmoji, onSetStatus, @@ -62,6 +64,7 @@ export function ProfilePopover({ const [presenceMenuOpen, setPresenceMenuOpen] = React.useState(false); const presenceHoverTimer = React.useRef(null); const hasUserStatus = Boolean(userStatusText || userStatusEmoji); + const hasCalendarBusy = Boolean(calendarBusyLabel); const settingsShortcutLabel = isMacPlatform() ? "⌘," : "Ctrl+,"; function clearPresenceHoverTimer() { @@ -167,7 +170,11 @@ export function ProfilePopover({ role="menuitem" type="button" > - + {hasCalendarBusy && !hasUserStatus ? ( + + ) : ( + + )} {hasUserStatus ? ( {userStatusEmoji ? ( @@ -178,6 +185,10 @@ export function ProfilePopover({ ) : null} {userStatusText} + ) : hasCalendarBusy ? ( + + {calendarBusyLabel} + ) : ( Update your status diff --git a/desktop/src/features/profile/ui/UserProfilePopover.tsx b/desktop/src/features/profile/ui/UserProfilePopover.tsx index be6b1f6c3..ca27216b3 100644 --- a/desktop/src/features/profile/ui/UserProfilePopover.tsx +++ b/desktop/src/features/profile/ui/UserProfilePopover.tsx @@ -1,6 +1,11 @@ import * as React from "react"; import { useQueryClient } from "@tanstack/react-query"; -import { Activity, Headphones, MessageSquare } from "lucide-react"; +import { + Activity, + CalendarDays, + Headphones, + MessageSquare, +} from "lucide-react"; import { toast } from "sonner"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; @@ -42,6 +47,10 @@ import { BotIdenticon } from "@/features/messages/ui/BotIdenticon"; import { useNow } from "@/shared/lib/useNow"; import { Button } from "@/shared/ui/button"; import { Spinner } from "@/shared/ui/spinner"; +import { + formatCalendarBusyLabel, + useCurrentGoogleCalendarEvent, +} from "@/features/calendar/hooks"; type UserProfilePopoverProps = { children: React.ReactNode; @@ -216,6 +225,13 @@ export function UserProfilePopover({ const isSelf = currentPubkey !== undefined && currentPubkey.toLowerCase() === pubkey.toLowerCase(); + const { currentEvent: selfCalendarEvent } = useCurrentGoogleCalendarEvent({ + enabled: open && isSelf, + }); + const selfCalendarBusyLabel = selfCalendarEvent + ? formatCalendarBusyLabel(selfCalendarEvent) + : null; + const hasSelfCalendarBusy = Boolean(selfCalendarBusyLabel); const showProfileActions = currentPubkey !== undefined && !isSelf; const selfProfileQuery = useProfileQuery(open && showProfileActions); const isCurrentUserOwner = @@ -573,7 +589,7 @@ export function UserProfilePopover({ ) : null} - {hasUserStatus || showProfileActions ? ( + {hasUserStatus || hasSelfCalendarBusy || showProfileActions ? ( <>