feat(wip): add tauri-plugin-oauth and enable Spotify oauth

This commit is contained in:
2025-10-15 12:53:51 -04:00
parent 8d773f8188
commit 7f719bec11
10 changed files with 870 additions and 3 deletions

View File

@@ -0,0 +1,436 @@
<script lang="ts">
import { onMount } from 'svelte';
import { spotifyAuth, loadSpotifyAuth, saveClientCredentials, saveTokens, saveUser, clearSpotifyAuth } from '$lib/stores/spotify';
import { spotifyAPI } from '$lib/services/spotify';
import { start, onUrl } from '@fabianlars/tauri-plugin-oauth';
import { openUrl } from '@tauri-apps/plugin-opener';
// Fixed port for OAuth callback - user must register this in Spotify Dashboard
const OAUTH_PORT = 8228;
const REDIRECT_URI = `http://127.0.0.1:${OAUTH_PORT}/callback`;
// Login form state
let clientIdInput = $state('');
let clientSecretInput = $state('');
let isAuthenticating = $state(false);
let loginError = $state('');
let loginSuccess = $state('');
// OAuth state
let isWaitingForCallback = $state(false);
onMount(async () => {
await loadSpotifyAuth();
// Check if we have client credentials stored
if ($spotifyAuth.clientId) {
clientIdInput = $spotifyAuth.clientId;
}
if ($spotifyAuth.clientSecret) {
clientSecretInput = $spotifyAuth.clientSecret;
}
});
async function handleAuthorize() {
if (!clientIdInput || !clientSecretInput) {
loginError = 'Please enter both Client ID and Client Secret';
return;
}
if (clientIdInput.trim().length === 0 || clientSecretInput.trim().length === 0) {
loginError = 'Client ID and Client Secret cannot be empty';
return;
}
isAuthenticating = true;
loginError = '';
loginSuccess = '';
try {
// Save credentials
await saveClientCredentials(clientIdInput.trim(), clientSecretInput.trim());
// Set up OAuth callback listener first
onUrl((callbackUrl) => {
handleOAuthCallback(callbackUrl);
});
// Start OAuth server on fixed port
const port = await start({ ports: [OAUTH_PORT] });
console.log(`[Spotify] OAuth server started on port ${port}`);
// Generate authorization URL
const { url, codeVerifier } = await spotifyAPI.getAuthorizationUrl(
clientIdInput.trim(),
REDIRECT_URI
);
// Store code verifier for callback
localStorage.setItem('spotify_code_verifier', codeVerifier);
isWaitingForCallback = true;
// Open Spotify authorization in default browser
await openUrl(url);
} catch (error) {
console.error('[Spotify] Authorization error:', error);
loginError = `Authorization error: ${error instanceof Error ? error.message : JSON.stringify(error)}`;
isAuthenticating = false;
isWaitingForCallback = false;
}
}
async function handleOAuthCallback(callbackUrl: string) {
try {
// Parse the callback URL
const url = new URL(callbackUrl);
const code = url.searchParams.get('code');
const error = url.searchParams.get('error');
if (error) {
throw new Error(`Authorization failed: ${error}`);
}
if (!code) {
throw new Error('No authorization code received');
}
// Retrieve code verifier from localStorage
const codeVerifier = localStorage.getItem('spotify_code_verifier');
if (!codeVerifier) {
throw new Error('OAuth state lost. Please try logging in again.');
}
// Exchange code for tokens
const tokenData = await spotifyAPI.exchangeCodeForToken(
code,
codeVerifier,
$spotifyAuth.clientId!,
REDIRECT_URI
);
// Save tokens
await saveTokens(tokenData.access_token, tokenData.refresh_token, tokenData.expires_in);
// Set tokens in API client
spotifyAPI.setClientCredentials($spotifyAuth.clientId!, $spotifyAuth.clientSecret!);
spotifyAPI.setTokens(
tokenData.access_token,
tokenData.refresh_token,
Date.now() + (tokenData.expires_in * 1000)
);
// Fetch user info
const user = await spotifyAPI.getCurrentUser();
await saveUser(user);
// Clean up
localStorage.removeItem('spotify_code_verifier');
} catch (error) {
loginError = `Authentication error: ${error instanceof Error ? error.message : 'Unknown error'}`;
localStorage.removeItem('spotify_code_verifier');
} finally {
isAuthenticating = false;
isWaitingForCallback = false;
}
}
async function handleLogout() {
await clearSpotifyAuth();
clientIdInput = '';
clientSecretInput = '';
loginSuccess = '';
loginError = '';
}
async function handleRefreshUser() {
if (!$spotifyAuth.accessToken || !$spotifyAuth.clientId || !$spotifyAuth.clientSecret) {
return;
}
try {
// Set credentials in API client
spotifyAPI.setClientCredentials($spotifyAuth.clientId, $spotifyAuth.clientSecret);
spotifyAPI.setTokens(
$spotifyAuth.accessToken,
$spotifyAuth.refreshToken!,
$spotifyAuth.expiresAt!
);
// Fetch updated user info
const user = await spotifyAPI.getCurrentUser();
await saveUser(user);
loginSuccess = 'User info refreshed successfully!';
setTimeout(() => {
loginSuccess = '';
}, 3000);
} catch (error) {
loginError = 'Error refreshing user info: ' + (error instanceof Error ? error.message : 'Unknown error');
}
}
</script>
<div class="spotify-wrapper">
<h2 style="padding: 8px">Spotify</h2>
{#if !$spotifyAuth.loggedIn}
<!-- Login Form -->
<section class="window login-section" style="max-width: 600px; margin: 8px;">
<div class="title-bar">
<div class="title-bar-text">Login to Spotify</div>
</div>
<div class="window-body">
<p>Enter your Spotify Developer credentials and authorize access:</p>
<div class="field-row-stacked">
<label for="client-id-input">Client ID</label>
<input
id="client-id-input"
type="text"
bind:value={clientIdInput}
placeholder="Your Spotify App Client ID"
disabled={isAuthenticating || isWaitingForCallback}
/>
</div>
<div class="field-row-stacked">
<label for="client-secret-input">Client Secret</label>
<input
id="client-secret-input"
type="password"
bind:value={clientSecretInput}
placeholder="Your Spotify App Client Secret"
disabled={isAuthenticating || isWaitingForCallback}
/>
</div>
{#if loginError}
<div class="error-message">
{loginError}
</div>
{/if}
{#if isWaitingForCallback}
<div class="info-message">
Waiting for authorization in your browser... Please complete the login process.
</div>
{/if}
<div class="button-row">
<button onclick={handleAuthorize} disabled={isAuthenticating || isWaitingForCallback}>
{isAuthenticating ? 'Authorizing...' : 'Authorize with Spotify'}
</button>
</div>
<p style="margin-top: 8px; font-size: 11px; opacity: 0.7;">
This will open Spotify's login page in your default browser.
</p>
<details class="instructions">
<summary>How to get your Spotify Developer credentials</summary>
<div class="instructions-content">
<ol>
<li>Go to <strong>developer.spotify.com/dashboard</strong></li>
<li>Log in with your Spotify account</li>
<li>Click <strong>"Create app"</strong></li>
<li>Fill in the app details:
<ul>
<li>App name: (any name you want, e.g., "Shark Music Player")</li>
<li>App description: (any description)</li>
<li>Redirect URI: <code>http://127.0.0.1:8228/callback</code></li>
<li>Check the Web API box</li>
</ul>
</li>
<li>Click <strong>"Save"</strong></li>
<li>Click <strong>"Settings"</strong> on your new app</li>
<li>Copy the <strong>Client ID</strong> (visible by default)</li>
<li>Click <strong>"View client secret"</strong> and copy the <strong>Client Secret</strong></li>
<li>Paste both values into the fields above</li>
</ol>
<p><strong>Note:</strong> The Client ID and Client Secret are used to authenticate your app with Spotify. Keep the Client Secret private and never share it publicly.</p>
<p><strong>Important:</strong> The Redirect URI must be exactly <code>http://127.0.0.1:8228/callback</code>. Port 8228 must be available when authorizing. If you get a port error, close any application using port 8228.</p>
<p><strong>Scopes used:</strong> This app requests access to your profile, email, saved library (tracks, albums), playlists (including private and collaborative), and followed artists.</p>
</div>
</details>
</div>
</section>
{:else}
<!-- Authenticated View -->
<section class="authenticated-content">
<div class="window" style="max-width: 600px; margin: 8px;">
<div class="title-bar">
<div class="title-bar-text">Connected to Spotify</div>
</div>
<div class="window-body">
{#if loginError}
<div class="error-message">
{loginError}
</div>
{/if}
<fieldset>
<legend>User Information</legend>
<div class="field-row">
<span class="field-label">Name:</span>
<span>{$spotifyAuth.user?.display_name || 'Unknown'}</span>
</div>
<div class="field-row">
<span class="field-label">Email:</span>
<span>{$spotifyAuth.user?.email || 'N/A'}</span>
</div>
<div class="field-row">
<span class="field-label">Country:</span>
<span>{$spotifyAuth.user?.country || 'N/A'}</span>
</div>
<div class="field-row">
<span class="field-label">Subscription:</span>
<span>{$spotifyAuth.user?.product ? $spotifyAuth.user.product.toUpperCase() : 'Unknown'}</span>
</div>
</fieldset>
<fieldset style="margin-top: 16px;">
<legend>Actions</legend>
<div class="button-row">
<button onclick={handleRefreshUser}>Refresh User Info</button>
<button onclick={handleLogout}>Logout</button>
</div>
</fieldset>
<div class="info-box">
<p><strong>Note:</strong> Spotify integration is for library sync only. This app does not support playback or downloads from Spotify.</p>
</div>
</div>
</div>
</section>
{/if}
</div>
<style>
.spotify-wrapper {
height: 100%;
display: flex;
flex-direction: column;
}
h2 {
margin: 0;
}
.login-section,
.authenticated-content {
margin-bottom: 16px;
}
.window-body {
padding: 12px;
}
.field-row-stacked {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 12px;
}
.field-row {
display: flex;
gap: 8px;
margin-bottom: 8px;
}
.field-label {
font-weight: bold;
min-width: 120px;
}
input[type="text"],
input[type="password"] {
width: 100%;
padding: 4px;
}
.button-row {
margin-top: 12px;
display: flex;
gap: 8px;
}
.error-message {
background-color: #ffcccc;
color: #cc0000;
padding: 8px;
margin: 8px 0;
border: 1px solid #cc0000;
}
.info-message {
background-color: #cce5ff;
color: #004085;
padding: 8px;
margin: 8px 0;
border: 1px solid #004085;
}
.instructions {
margin-top: 16px;
padding: 8px;
background-color: var(--button-shadow, #2a2a2a);
}
.instructions summary {
cursor: pointer;
font-weight: bold;
user-select: none;
}
.instructions-content {
margin-top: 8px;
padding-left: 4px;
}
.instructions-content ol {
margin: 8px 0;
padding-left: 20px;
}
.instructions-content ul {
margin: 4px 0;
padding-left: 20px;
}
.instructions-content li {
margin: 6px 0;
line-height: 1.4;
}
.instructions-content strong {
font-weight: bold;
}
.instructions-content code {
background-color: var(--button-highlight, #505050);
padding: 2px 4px;
border-radius: 2px;
font-family: monospace;
font-size: 0.9em;
}
.instructions-content p {
margin: 8px 0;
line-height: 1.5;
}
.info-box {
margin-top: 16px;
padding: 8px;
background-color: var(--button-shadow, #2a2a2a);
border: 1px solid var(--button-highlight, #606060);
}
.info-box p {
margin: 0;
line-height: 1.5;
}
</style>