diff --git a/bun.lock b/bun.lock index eca5ea4..582fa89 100644 --- a/bun.lock +++ b/bun.lock @@ -4,6 +4,7 @@ "": { "name": "shark", "dependencies": { + "@fabianlars/tauri-plugin-oauth": "2", "@noble/ciphers": "^2.0.1", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "~2", @@ -84,6 +85,8 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.10", "", { "os": "win32", "cpu": "x64" }, "sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw=="], + "@fabianlars/tauri-plugin-oauth": ["@fabianlars/tauri-plugin-oauth@2.0.0", "", { "dependencies": { "@tauri-apps/api": "^2.0.3" } }, "sha512-I1s08ZXrsFuYfNWusAcpLyiCfr5TCvaBrRuKfTG+XQrcaqnAcwjdWH0U5J9QWuMDLwCUMnVxdobtMJzPR8raxQ=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], diff --git a/package.json b/package.json index c8b339f..85a6181 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "license": "UNLICENSED", "private": true, "dependencies": { + "@fabianlars/tauri-plugin-oauth": "2", "@noble/ciphers": "^2.0.1", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "~2", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 161a46c..8d3a771 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -20,6 +20,7 @@ dependencies = [ "tauri-plugin-dialog", "tauri-plugin-fs", "tauri-plugin-http", + "tauri-plugin-oauth", "tauri-plugin-opener", "tauri-plugin-os", "tauri-plugin-process", @@ -4931,6 +4932,21 @@ dependencies = [ "urlpattern", ] +[[package]] +name = "tauri-plugin-oauth" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eda564acdb23185caf700f89dd6e5d4540225d6a991516b2cad0cbcf27e4dcd3" +dependencies = [ + "httparse", + "log", + "serde", + "tauri", + "tauri-plugin", + "thiserror 1.0.69", + "url", +] + [[package]] name = "tauri-plugin-opener" version = "2.5.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d07d246..8fee94d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -39,4 +39,5 @@ futures-util = "0.3.31" tauri-plugin-os = "2" walkdir = "2.5.0" unicode-normalization = "0.1.24" +tauri-plugin-oauth = "2.0.0" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 81799d9..9904843 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -72,12 +72,28 @@ }, { "url": "https://lrclib.net/**" + }, + { + "url": "https://accounts.spotify.com/**" + }, + { + "url": "https://api.spotify.com/**" } ] }, "sql:default", "sql:allow-execute", "process:default", - "os:default" + "os:default", + "oauth:allow-start", + "oauth:allow-cancel", + { + "identifier": "opener:allow-open-url", + "allow": [ + { + "url": "https://accounts.spotify.com/*" + } + ] + } ] } \ No newline at end of file diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f8c4b67..31bd175 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -300,6 +300,7 @@ pub fn run() { }]; tauri::Builder::default() + .plugin(tauri_plugin_oauth::init()) .plugin(tauri_plugin_os::init()) .plugin(tauri_plugin_process::init()) .plugin( diff --git a/src/lib/services/spotify.ts b/src/lib/services/spotify.ts new file mode 100644 index 0000000..f250254 --- /dev/null +++ b/src/lib/services/spotify.ts @@ -0,0 +1,260 @@ +import { fetch } from '@tauri-apps/plugin-http'; +import type { SpotifyUser } from '$lib/stores/spotify'; +import { isTokenExpired } from '$lib/stores/spotify'; + +const SPOTIFY_AUTH_URL = 'https://accounts.spotify.com/authorize'; +const SPOTIFY_TOKEN_URL = 'https://accounts.spotify.com/api/token'; +const SPOTIFY_API_BASE = 'https://api.spotify.com/v1'; + +// Required scopes for the app +const REQUIRED_SCOPES = [ + 'user-read-private', + 'user-read-email', + 'user-library-read', + 'playlist-read-private', + 'playlist-read-collaborative', + 'user-follow-read' +]; + +/** + * Spotify API client with OAuth 2.0 PKCE flow + */ +export class SpotifyAPI { + private clientId: string | null = null; + private clientSecret: string | null = null; + private accessToken: string | null = null; + private refreshToken: string | null = null; + private expiresAt: number | null = null; + + /** + * Set client credentials (developer app credentials) + */ + setClientCredentials(clientId: string, clientSecret: string): void { + this.clientId = clientId; + this.clientSecret = clientSecret; + } + + /** + * Set OAuth tokens + */ + setTokens(accessToken: string, refreshToken: string, expiresAt: number): void { + this.accessToken = accessToken; + this.refreshToken = refreshToken; + this.expiresAt = expiresAt; + } + + /** + * Generate a random code verifier for PKCE + */ + generateCodeVerifier(): string { + const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + const values = crypto.getRandomValues(new Uint8Array(64)); + return Array.from(values) + .map(x => possible[x % possible.length]) + .join(''); + } + + /** + * Generate code challenge from verifier using SHA256 + */ + async generateCodeChallenge(verifier: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(verifier); + const hashed = await crypto.subtle.digest('SHA-256', data); + + // Base64 URL encode + const base64 = btoa(String.fromCharCode(...new Uint8Array(hashed))) + .replace(/=/g, '') + .replace(/\+/g, '-') + .replace(/\//g, '_'); + + return base64; + } + + /** + * Get the authorization URL for user to authenticate + * Returns the URL and the code verifier (must be stored for later) + */ + async getAuthorizationUrl(clientId: string, redirectUri: string): Promise<{ url: string; codeVerifier: string }> { + const codeVerifier = this.generateCodeVerifier(); + const codeChallenge = await this.generateCodeChallenge(codeVerifier); + + const params = new URLSearchParams({ + client_id: clientId, + response_type: 'code', + redirect_uri: redirectUri, + code_challenge_method: 'S256', + code_challenge: codeChallenge, + scope: REQUIRED_SCOPES.join(' ') + }); + + const url = `${SPOTIFY_AUTH_URL}?${params.toString()}`; + + return { url, codeVerifier }; + } + + /** + * Exchange authorization code for access token + */ + async exchangeCodeForToken( + code: string, + codeVerifier: string, + clientId: string, + redirectUri: string + ): Promise<{ access_token: string; refresh_token: string; expires_in: number }> { + const params = new URLSearchParams({ + client_id: clientId, + grant_type: 'authorization_code', + code: code, + redirect_uri: redirectUri, + code_verifier: codeVerifier + }); + + const response = await fetch(SPOTIFY_TOKEN_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: params.toString() + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error('Token exchange error:', errorText); + throw new Error(`Token exchange failed: ${response.statusText}`); + } + + const data = await response.json(); + + // Store tokens + this.accessToken = data.access_token; + this.refreshToken = data.refresh_token; + this.expiresAt = Date.now() + (data.expires_in * 1000); + + return { + access_token: data.access_token, + refresh_token: data.refresh_token, + expires_in: data.expires_in + }; + } + + /** + * Refresh the access token using the refresh token + */ + async refreshAccessToken(): Promise<{ access_token: string; expires_in: number }> { + if (!this.refreshToken || !this.clientId || !this.clientSecret) { + throw new Error('Missing refresh token or client credentials'); + } + + const credentials = btoa(`${this.clientId}:${this.clientSecret}`); + + const params = new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: this.refreshToken + }); + + const response = await fetch(SPOTIFY_TOKEN_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'Authorization': `Basic ${credentials}` + }, + body: params.toString() + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error('Token refresh error:', errorText); + throw new Error(`Token refresh failed: ${response.statusText}`); + } + + const data = await response.json(); + + // Update tokens + this.accessToken = data.access_token; + this.expiresAt = Date.now() + (data.expires_in * 1000); + + // Note: Spotify may or may not return a new refresh token + if (data.refresh_token) { + this.refreshToken = data.refresh_token; + } + + return { + access_token: data.access_token, + expires_in: data.expires_in + }; + } + + /** + * Make an authenticated API call to Spotify + * Automatically refreshes token if expired + */ + private async apiCall(endpoint: string, options: RequestInit = {}): Promise { + // Check if token needs refresh + if (isTokenExpired(this.expiresAt)) { + console.log('[Spotify] Token expired, refreshing...'); + await this.refreshAccessToken(); + } + + if (!this.accessToken) { + throw new Error('No access token available'); + } + + const url = `${SPOTIFY_API_BASE}${endpoint}`; + + const response = await fetch(url, { + ...options, + headers: { + ...options.headers, + 'Authorization': `Bearer ${this.accessToken}` + } + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error(`Spotify API error for ${endpoint}:`, errorText); + throw new Error(`API call failed: ${response.statusText}`); + } + + return response.json() as Promise; + } + + /** + * Get current user's profile + */ + async getCurrentUser(): Promise { + return this.apiCall('/me'); + } + + /** + * Get user's playlists + */ + async getUserPlaylists(limit: number = 50, offset: number = 0): Promise { + return this.apiCall(`/me/playlists?limit=${limit}&offset=${offset}`); + } + + /** + * Get user's saved tracks + */ + async getUserTracks(limit: number = 50, offset: number = 0): Promise { + return this.apiCall(`/me/tracks?limit=${limit}&offset=${offset}`); + } + + /** + * Get user's saved albums + */ + async getUserAlbums(limit: number = 50, offset: number = 0): Promise { + return this.apiCall(`/me/albums?limit=${limit}&offset=${offset}`); + } + + /** + * Get user's followed artists + */ + async getUserArtists(limit: number = 50, after?: string): Promise { + const afterParam = after ? `&after=${after}` : ''; + return this.apiCall(`/me/following?type=artist&limit=${limit}${afterParam}`); + } +} + +// Export singleton instance +export const spotifyAPI = new SpotifyAPI(); diff --git a/src/lib/stores/spotify.ts b/src/lib/stores/spotify.ts new file mode 100644 index 0000000..e4b1992 --- /dev/null +++ b/src/lib/stores/spotify.ts @@ -0,0 +1,133 @@ +import { LazyStore } from '@tauri-apps/plugin-store'; +import { writable, type Writable } from 'svelte/store'; + +// Spotify User interface +export interface SpotifyUser { + id: string; + display_name: string; + email?: string; + country?: string; + product?: string; // premium, free, etc. + images?: Array<{ url: string }>; +} + +// Spotify auth state +export interface SpotifyAuthState { + // Developer credentials + clientId: string | null; + clientSecret: string | null; + // OAuth tokens + accessToken: string | null; + refreshToken: string | null; + expiresAt: number | null; // Unix timestamp in milliseconds + // User data + user: SpotifyUser | null; + loggedIn: boolean; +} + +// Initialize the store with spotify.json +const store = new LazyStore('spotify.json'); + +// Default state +const defaultState: SpotifyAuthState = { + clientId: null, + clientSecret: null, + accessToken: null, + refreshToken: null, + expiresAt: null, + user: null, + loggedIn: false +}; + +// Create a writable store for reactive UI updates +export const spotifyAuth: Writable = writable(defaultState); + +// Load Spotify auth state from store +export async function loadSpotifyAuth(): Promise { + const clientId = await store.get('clientId'); + const clientSecret = await store.get('clientSecret'); + const accessToken = await store.get('accessToken'); + const refreshToken = await store.get('refreshToken'); + const expiresAt = await store.get('expiresAt'); + const user = await store.get('user'); + + spotifyAuth.set({ + clientId: clientId ?? null, + clientSecret: clientSecret ?? null, + accessToken: accessToken ?? null, + refreshToken: refreshToken ?? null, + expiresAt: expiresAt ?? null, + user: user ?? null, + loggedIn: !!(accessToken && user) + }); +} + +// Save client credentials (developer app credentials) +export async function saveClientCredentials(clientId: string, clientSecret: string): Promise { + await store.set('clientId', clientId); + await store.set('clientSecret', clientSecret); + await store.save(); + + spotifyAuth.update(s => ({ + ...s, + clientId, + clientSecret + })); +} + +// Save OAuth tokens +export async function saveTokens(accessToken: string, refreshToken: string, expiresIn: number): Promise { + const expiresAt = Date.now() + (expiresIn * 1000); + + await store.set('accessToken', accessToken); + await store.set('refreshToken', refreshToken); + await store.set('expiresAt', expiresAt); + await store.save(); + + spotifyAuth.update(s => ({ + ...s, + accessToken, + refreshToken, + expiresAt + })); +} + +// Save user data +export async function saveUser(user: SpotifyUser): Promise { + await store.set('user', user); + await store.save(); + + spotifyAuth.update(s => ({ + ...s, + user, + loggedIn: true + })); +} + +// Clear auth (logout) +export async function clearSpotifyAuth(): Promise { + await store.delete('accessToken'); + await store.delete('refreshToken'); + await store.delete('expiresAt'); + await store.delete('user'); + await store.save(); + + spotifyAuth.update(s => ({ + ...s, + accessToken: null, + refreshToken: null, + expiresAt: null, + user: null, + loggedIn: false + })); +} + +// Check if token is expired or about to expire (within 5 minutes) +export function isTokenExpired(expiresAt: number | null): boolean { + if (!expiresAt) return true; + const bufferTime = 5 * 60 * 1000; // 5 minutes in milliseconds + return Date.now() >= (expiresAt - bufferTime); +} + +// Initialize on module load +loadSpotifyAuth(); diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index ff6cf72..e3b7d51 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -132,10 +132,10 @@ Services