Keep connections PR calendar-only

This commit is contained in:
klopez4212
2026-06-30 13:54:35 +01:00
parent dc50aeea1f
commit 6d4f7f796d
9 changed files with 0 additions and 501 deletions

View File

@@ -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)
# -----------------------------------------------------------------------------

View File

@@ -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::*;

View File

@@ -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<String>,
connected_at: i64,
expires_at: Option<i64>,
refresh_token: String,
scope: Option<String>,
token_type: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct SpotifyStatus {
configured: bool,
connected: bool,
connected_at: Option<i64>,
scopes: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct SpotifyTokenResponse {
access_token: Option<String>,
expires_in: Option<i64>,
refresh_token: Option<String>,
scope: Option<String>,
token_type: Option<String>,
}
fn spotify_client_id() -> Option<String> {
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::<u16>().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<Option<SpotifyCredential>, 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<String> {
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<Url, String> {
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!(
"<!doctype html><html><head><meta charset=\"utf-8\"><title>{title}</title></head>\
<body><main style=\"font-family: system-ui, sans-serif; max-width: 34rem; margin: 4rem auto;\">\
<h1>{title}</h1><p>{body}</p></main></body></html>"
);
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<String, String> {
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<SpotifyTokenResponse, String> {
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<SpotifyTokenResponse, String> {
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<SpotifyStatus, String> {
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<SpotifyStatus, String> {
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<SpotifyStatus, String> {
credential_store().delete(SPOTIFY_CREDENTIAL_KEY)?;
Ok(status_from_credential(None))
}

View File

@@ -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,

View File

@@ -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
<ExternalLink className="h-3.5 w-3.5" />
</a>
<SpotifySettingsCard />
</div>
</section>
);

View File

@@ -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<SpotifyStatus> {
return fromRawStatus(
await invokeTauri<RawSpotifyStatus>("get_spotify_status"),
);
}
export async function connectSpotify(): Promise<SpotifyStatus> {
return fromRawStatus(await invokeTauri<RawSpotifyStatus>("connect_spotify"));
}
export async function disconnectSpotify(): Promise<SpotifyStatus> {
return fromRawStatus(
await invokeTauri<RawSpotifyStatus>("disconnect_spotify"),
);
}

View File

@@ -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 };
}

View File

@@ -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 (
<div className="flex flex-col gap-4" data-testid="settings-spotify">
<SettingsOptionGroup>
<SettingsOptionRow>
<div className="min-w-0">
<span className="flex items-center gap-2 text-sm font-medium">
<Music2 className="h-4 w-4 text-muted-foreground" />
Spotify
</span>
</div>
{connected ? (
<Button
disabled={isBusy}
onClick={() => disconnect.mutate()}
size="sm"
type="button"
variant="outline"
>
{disconnect.isPending ? (
<Spinner className="h-4 w-4" />
) : (
<Unplug />
)}
Disconnect
</Button>
) : (
<Button
disabled={isLoading || isBusy || !status?.configured}
onClick={() => connect.mutate()}
size="sm"
type="button"
>
{isLoading || connect.isPending ? (
<Spinner className="h-4 w-4" />
) : (
<Plug />
)}
Connect
</Button>
)}
</SettingsOptionRow>
</SettingsOptionGroup>
{statusError || mutationError ? (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{mutationError ?? statusError}
</div>
) : null}
</div>
);
}

View File

@@ -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":