From 6d4f7f796dc6daf157f03c1245edd23019157a7c Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Tue, 30 Jun 2026 13:54:35 +0100 Subject: [PATCH] Keep connections PR calendar-only --- .env.example | 6 - desktop/src-tauri/src/commands/mod.rs | 2 - desktop/src-tauri/src/commands/spotify.rs | 317 ------------------ desktop/src-tauri/src/lib.rs | 3 - .../calendar/ui/CalendarSettingsCard.tsx | 3 - desktop/src/features/spotify/api.ts | 40 --- desktop/src/features/spotify/hooks.ts | 35 -- .../spotify/ui/SpotifySettingsCard.tsx | 80 ----- desktop/src/testing/e2eBridge.ts | 15 - 9 files changed, 501 deletions(-) delete mode 100644 desktop/src-tauri/src/commands/spotify.rs delete mode 100644 desktop/src/features/spotify/api.ts delete mode 100644 desktop/src/features/spotify/hooks.ts delete mode 100644 desktop/src/features/spotify/ui/SpotifySettingsCard.tsx diff --git a/.env.example b/.env.example index d62d9036b9..32ca137ed5 100644 --- a/.env.example +++ b/.env.example @@ -58,12 +58,6 @@ RELAY_URL=ws://localhost:3000 # 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 -# BUZZ_SPOTIFY_CLIENT_ID= -# BUZZ_SPOTIFY_REDIRECT_PORT=18202 - # ----------------------------------------------------------------------------- # Git (NIP-34 bare repositories) # ----------------------------------------------------------------------------- diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 338be14a43..4a73f8c4e8 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -28,7 +28,6 @@ mod profile; mod relay_members; mod relay_reconnect; mod social; -mod spotify; mod teams; mod workflows; mod workspace; @@ -61,7 +60,6 @@ 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 deleted file mode 100644 index 10b45c7df9..0000000000 --- a/desktop/src-tauri/src/commands/spotify.rs +++ /dev/null @@ -1,317 +0,0 @@ -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 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, Deserialize)] -struct SpotifyTokenResponse { - access_token: Option, - expires_in: Option, - refresh_token: Option, - scope: Option, - token_type: Option, -} - -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("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 authorization received", - "Return to Buzz to finish connecting Spotify.", - ) - } 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 -} - -#[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)) -} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index d6a98d21a7..e6db0b0a2c 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -449,9 +449,6 @@ pub fn run() { connect_google_calendar, disconnect_google_calendar, get_google_calendar_events, - get_spotify_status, - connect_spotify, - disconnect_spotify, fetch_link_preview_title, discover_acp_providers, install_acp_runtime, diff --git a/desktop/src/features/calendar/ui/CalendarSettingsCard.tsx b/desktop/src/features/calendar/ui/CalendarSettingsCard.tsx index 83acca4e28..aeee6d4504 100644 --- a/desktop/src/features/calendar/ui/CalendarSettingsCard.tsx +++ b/desktop/src/features/calendar/ui/CalendarSettingsCard.tsx @@ -4,7 +4,6 @@ import { useGoogleCalendarConnectionMutations, useGoogleCalendarStatusQuery, } from "@/features/calendar/hooks"; -import { SpotifySettingsCard } from "@/features/spotify/ui/SpotifySettingsCard"; import { Button } from "@/shared/ui/button"; import { SettingsOptionGroup, @@ -127,8 +126,6 @@ export function ConnectionsSettingsCard() { Google OAuth desktop app setup - - ); diff --git a/desktop/src/features/spotify/api.ts b/desktop/src/features/spotify/api.ts deleted file mode 100644 index 2c6468cd96..0000000000 --- a/desktop/src/features/spotify/api.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { invokeTauri } from "@/shared/api/tauri"; - -export type SpotifyStatus = { - configured: boolean; - connected: boolean; - connectedAt: number | null; - scopes: string[]; -}; - -type RawSpotifyStatus = { - configured: boolean; - connected: boolean; - connected_at: number | null; - scopes: string[]; -}; - -function fromRawStatus(raw: RawSpotifyStatus): SpotifyStatus { - return { - configured: raw.configured, - connected: raw.connected, - connectedAt: raw.connected_at, - scopes: raw.scopes, - }; -} - -export async function getSpotifyStatus(): Promise { - return fromRawStatus( - await invokeTauri("get_spotify_status"), - ); -} - -export async function connectSpotify(): Promise { - return fromRawStatus(await invokeTauri("connect_spotify")); -} - -export async function disconnectSpotify(): Promise { - return fromRawStatus( - await invokeTauri("disconnect_spotify"), - ); -} diff --git a/desktop/src/features/spotify/hooks.ts b/desktop/src/features/spotify/hooks.ts deleted file mode 100644 index aa701287f5..0000000000 --- a/desktop/src/features/spotify/hooks.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; - -import { - connectSpotify, - disconnectSpotify, - getSpotifyStatus, -} from "@/features/spotify/api"; - -export const spotifyStatusQueryKey = ["spotify-status"] as const; - -export function useSpotifyStatusQuery() { - return useQuery({ - queryKey: spotifyStatusQueryKey, - queryFn: getSpotifyStatus, - staleTime: 60_000, - }); -} - -export function useSpotifyConnectionMutations() { - const queryClient = useQueryClient(); - const invalidate = () => { - void queryClient.invalidateQueries({ queryKey: spotifyStatusQueryKey }); - }; - - const connect = useMutation({ - mutationFn: connectSpotify, - onSuccess: invalidate, - }); - const disconnect = useMutation({ - mutationFn: disconnectSpotify, - onSuccess: invalidate, - }); - - return { connect, disconnect }; -} diff --git a/desktop/src/features/spotify/ui/SpotifySettingsCard.tsx b/desktop/src/features/spotify/ui/SpotifySettingsCard.tsx deleted file mode 100644 index 92335be938..0000000000 --- a/desktop/src/features/spotify/ui/SpotifySettingsCard.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { Music2, Plug, Unplug } from "lucide-react"; - -import { - useSpotifyConnectionMutations, - useSpotifyStatusQuery, -} from "@/features/spotify/hooks"; -import { Button } from "@/shared/ui/button"; -import { - SettingsOptionGroup, - SettingsOptionRow, -} from "@/features/settings/ui/SettingsOptionGroup"; -import { Spinner } from "@/shared/ui/spinner"; - -function errorMessage(error: unknown): string | null { - if (!error) return null; - return error instanceof Error ? error.message : String(error); -} - -export function SpotifySettingsCard() { - const statusQuery = useSpotifyStatusQuery(); - const status = statusQuery.data; - const connected = Boolean(status?.connected); - const { connect, disconnect } = useSpotifyConnectionMutations(); - const isBusy = connect.isPending || disconnect.isPending; - const isLoading = statusQuery.isLoading; - const mutationError = - errorMessage(connect.error) ?? errorMessage(disconnect.error); - const statusError = errorMessage(statusQuery.error); - - return ( -
- - -
- - - Spotify - -
- {connected ? ( - - ) : ( - - )} -
-
- - {statusError || mutationError ? ( -
- {mutationError ?? statusError} -
- ) : null} -
- ); -} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 58f44e53d6..c40ace2c10 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -3449,15 +3449,6 @@ function mockGoogleCalendarStatus() { }; } -function mockSpotifyStatus() { - return { - configured: false, - connected: false, - connected_at: null, - scopes: [], - }; -} - // A random 64-hex event id, matching the shape of real Nostr event ids // (sha256 → 64 hex). Most mock events use the 32-hex `createMockEvent` default, // but kind:7 reactions need a real 64-hex id: the timeline's deletion path only @@ -7516,12 +7507,6 @@ export function maybeInstallE2eTauriMocks() { return mockGoogleCalendarStatus(); case "get_google_calendar_events": return []; - case "get_spotify_status": - return mockSpotifyStatus(); - case "connect_spotify": - return mockSpotifyStatus(); - case "disconnect_spotify": - return mockSpotifyStatus(); case "pick_and_upload_media": return await resolveMockUploadDescriptors(activeConfig); case "upload_media_bytes":