mirror of
https://github.com/block/buzz.git
synced 2026-11-07 01:41:06 +00:00
Keep connections PR calendar-only
This commit is contained in:
@@ -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)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user