From 501f2fac0edeedc000a4c923d5506764f6c518fd Mon Sep 17 00:00:00 2001 From: Alexandre Trendel Date: Tue, 26 Nov 2024 13:25:12 -0500 Subject: [PATCH] notify main app of the url to open --- Cargo.toml | 2 +- src/app/components/login/login.rs | 18 +++++- src/app/components/login/login_model.rs | 4 +- src/app/components/player_notifier.rs | 9 ++- src/app/mod.rs | 10 ++- src/app/state/login_state.rs | 27 +++++--- src/player/mod.rs | 19 ++++-- src/player/oauth2.rs | 82 +++++++++++++++---------- src/player/player.rs | 46 ++++++++++++-- 9 files changed, 155 insertions(+), 62 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 0235ff83..78e5a977 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -75,4 +75,4 @@ env_logger = "0.10.0" percent-encoding = "2.2.0" oauth2 = "4.4" url = "2.4.1" -open = "5.3.0" \ No newline at end of file +open = "5.3.0" diff --git a/src/app/components/login/login.rs b/src/app/components/login/login.rs index 6891eeb9..7e4a5519 100644 --- a/src/app/components/login/login.rs +++ b/src/app/components/login/login.rs @@ -2,10 +2,11 @@ use gtk::prelude::*; use gtk::subclass::prelude::*; use gtk::CompositeTemplate; use std::rc::Rc; +use url::Url; use crate::app::components::EventListener; -use crate::app::state::LoginEvent; -use crate::app::AppEvent; +use crate::app::state::{LoginEvent, LoginStartedEvent}; +use crate::app::{AppEvent, Worker}; use super::LoginModel; mod imp { @@ -92,10 +93,11 @@ pub struct Login { parent: gtk::Window, login_window: LoginWindow, model: Rc, + worker: Worker, } impl Login { - pub fn new(parent: gtk::Window, model: LoginModel) -> Self { + pub fn new(parent: gtk::Window, model: LoginModel, worker: Worker) -> Self { let model = Rc::new(model); let login_window = LoginWindow::new(); @@ -114,6 +116,7 @@ impl Login { parent, login_window, model, + worker, } } @@ -135,6 +138,12 @@ impl Login { self.show_self(); self.login_window.show_auth_error(true); } + + fn open_login_url(&self, url: Url) { + if let Err(_) = open::that(url.as_str()) { + warn!("Could not open login page"); + } + } } impl EventListener for Login { @@ -146,6 +155,9 @@ impl EventListener for Login { AppEvent::LoginEvent(LoginEvent::LoginFailed) => { self.reveal_error(); } + AppEvent::LoginEvent(LoginEvent::LoginStarted(LoginStartedEvent::OpenUrl(url))) => { + self.open_login_url(url.clone()); + } AppEvent::Started => { self.model.try_autologin(); } diff --git a/src/app/components/login/login_model.rs b/src/app/components/login/login_model.rs index f2e608f0..9f18f07b 100644 --- a/src/app/components/login/login_model.rs +++ b/src/app/components/login/login_model.rs @@ -12,11 +12,11 @@ impl LoginModel { pub fn try_autologin(&self) { self.dispatcher - .dispatch(LoginAction::TryLogin(TryLoginAction::Reconnect).into()); + .dispatch(LoginAction::TryLogin(TryLoginAction::Restore).into()); } pub fn login_with_spotify(&self) { self.dispatcher - .dispatch(LoginAction::TryLogin(TryLoginAction::NewLogin).into()) + .dispatch(LoginAction::TryLogin(TryLoginAction::InitLogin).into()) } } diff --git a/src/app/components/player_notifier.rs b/src/app/components/player_notifier.rs index 5b6dc047..e8cb907d 100644 --- a/src/app/components/player_notifier.rs +++ b/src/app/components/player_notifier.rs @@ -5,9 +5,7 @@ use futures::channel::mpsc::UnboundedSender; use librespot::core::spotify_id::{SpotifyId, SpotifyItemType}; use crate::app::components::EventListener; -use crate::app::state::{ - Device, LoginAction, LoginEvent, LoginStartedEvent, PlaybackEvent, SettingsEvent, -}; +use crate::app::state::{Device, LoginAction, LoginEvent, LoginStartedEvent, PlaybackEvent, SettingsEvent}; use crate::app::{ActionDispatcher, AppAction, AppEvent, AppModel, SongsSource}; use crate::connect::ConnectCommand; use crate::player::Command; @@ -85,8 +83,9 @@ impl PlayerNotifier { fn notify_login(&self, event: &LoginEvent) { info!("notify_login: {:?}", event); let command = match event { - LoginEvent::LoginStarted(LoginStartedEvent::Reconnect) => Some(Command::Reconnect), - LoginEvent::LoginStarted(LoginStartedEvent::NewLogin) => Some(Command::NewLogin), + LoginEvent::LoginStarted(LoginStartedEvent::Restore) => Some(Command::Restore), + LoginEvent::LoginStarted(LoginStartedEvent::InitLogin) => Some(Command::InitLogin), + LoginEvent::LoginStarted(LoginStartedEvent::CompleteLogin) => Some(Command::CompleteLogin), LoginEvent::FreshTokenRequested => Some(Command::RefreshToken), LoginEvent::LogoutCompleted => Some(Command::Logout), _ => None, diff --git a/src/app/mod.rs b/src/app/mod.rs index aa7f0293..dd864c8f 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -98,7 +98,7 @@ impl App { dispatcher.box_clone(), worker.clone(), ), - App::make_login(builder, dispatcher.box_clone()), + App::make_login(builder, dispatcher.box_clone(), worker.clone()), App::make_navigation( builder, Rc::clone(model), @@ -179,10 +179,14 @@ impl App { )) } - fn make_login(builder: >k::Builder, dispatcher: Box) -> Box { + fn make_login( + builder: >k::Builder, + dispatcher: Box, + worker: Worker, + ) -> Box { let parent: gtk::Window = builder.object("window").unwrap(); let model = LoginModel::new(dispatcher); - Box::new(Login::new(parent, model)) + Box::new(Login::new(parent, model, worker)) } fn make_selection_toolbar( diff --git a/src/app/state/login_state.rs b/src/app/state/login_state.rs index 78310acf..23c47df3 100644 --- a/src/app/state/login_state.rs +++ b/src/app/state/login_state.rs @@ -1,18 +1,21 @@ use gettextrs::*; use std::borrow::Cow; +use url::Url; use crate::app::models::PlaylistSummary; use crate::app::state::{AppAction, AppEvent, UpdatableState}; #[derive(Clone, Debug)] pub enum TryLoginAction { - Reconnect, - NewLogin, + Restore, + InitLogin, + CompleteLogin } #[derive(Clone, Debug)] pub enum LoginAction { ShowLogin, + OpenLoginUrl(Url), TryLogin(TryLoginAction), SetLoginSuccess(String), SetUserPlaylists(Vec), @@ -32,8 +35,10 @@ impl From for AppAction { #[derive(Clone, Debug)] pub enum LoginStartedEvent { - Reconnect, - NewLogin, + Restore, + InitLogin, + CompleteLogin, + OpenUrl(Url), } #[derive(Clone, Debug)] @@ -71,8 +76,14 @@ impl UpdatableState for LoginState { info!("update_with({:?})", action); match action.into_owned() { LoginAction::ShowLogin => vec![LoginEvent::LoginShown.into()], - LoginAction::TryLogin(TryLoginAction::Reconnect) => { - vec![LoginEvent::LoginStarted(LoginStartedEvent::Reconnect).into()] + LoginAction::OpenLoginUrl(url) => { + vec![LoginEvent::LoginStarted(LoginStartedEvent::OpenUrl(url)).into()] + } + LoginAction::TryLogin(TryLoginAction::Restore) => { + vec![LoginEvent::LoginStarted(LoginStartedEvent::Restore).into()] + } + LoginAction::TryLogin(TryLoginAction::CompleteLogin) => { + vec![LoginEvent::LoginStarted(LoginStartedEvent::CompleteLogin).into()] } LoginAction::SetLoginSuccess(username) => { self.user = Some(username); @@ -106,8 +117,8 @@ impl UpdatableState for LoginState { self.playlists = summaries; vec![LoginEvent::UserPlaylistsLoaded.into()] } - LoginAction::TryLogin(TryLoginAction::NewLogin) => { - vec![LoginEvent::LoginStarted(LoginStartedEvent::NewLogin).into()] + LoginAction::TryLogin(TryLoginAction::InitLogin) => { + vec![LoginEvent::LoginStarted(LoginStartedEvent::InitLogin).into()] } } } diff --git a/src/player/mod.rs b/src/player/mod.rs index 92b6d87e..b8e4492a 100644 --- a/src/player/mod.rs +++ b/src/player/mod.rs @@ -4,6 +4,7 @@ use std::cell::RefCell; use std::rc::Rc; use std::sync::Arc; use tokio::task; +use url::Url; use crate::app::state::{LoginAction, PlaybackAction}; use crate::app::AppAction; @@ -18,8 +19,9 @@ pub use token_store::*; #[derive(Debug, Clone)] pub enum Command { - Reconnect, - NewLogin, + Restore, + InitLogin, + CompleteLogin, RefreshToken, Logout, PlayerLoad { track: SpotifyId, resume: bool }, @@ -89,6 +91,13 @@ impl SpotifyPlayerDelegate for AppPlayerDelegate { .unbounded_send(PlaybackAction::Preload.into()) .unwrap(); } + + fn login_challenge_started(&self, url: Url) { + self.sender + .borrow_mut() + .unbounded_send(LoginAction::OpenLoginUrl(url).into()) + .unwrap(); + } } #[tokio::main] @@ -96,13 +105,14 @@ async fn player_main( player_settings: SpotifyPlayerSettings, appaction_sender: UnboundedSender, token_store: Arc, + sender: UnboundedSender, receiver: UnboundedReceiver, ) { task::LocalSet::new() .run_until(async move { task::spawn_local(async move { let delegate = Rc::new(AppPlayerDelegate::new(appaction_sender.clone())); - let player = SpotifyPlayer::new(player_settings, delegate, token_store); + let player = SpotifyPlayer::new(player_settings, delegate, token_store, sender); player.start(receiver).await.unwrap(); }) .await @@ -117,8 +127,9 @@ pub fn start_player_service( token_store: Arc, ) -> UnboundedSender { let (sender, receiver) = unbounded::(); + let sender_clone = sender.clone(); std::thread::spawn(move || { - player_main(player_settings, appaction_sender, token_store, receiver) + player_main(player_settings, appaction_sender, token_store, sender_clone, receiver) }); sender } diff --git a/src/player/oauth2.rs b/src/player/oauth2.rs index 12d52126..b3cb26cc 100644 --- a/src/player/oauth2.rs +++ b/src/player/oauth2.rs @@ -18,7 +18,7 @@ use oauth2::{ basic::BasicClient, AuthUrl, AuthorizationCode, ClientId, CsrfToken, PkceCodeChallenge, RedirectUrl, Scope, TokenResponse, TokenUrl, }; -use oauth2::{RefreshToken, RequestTokenError}; +use oauth2::{PkceCodeVerifier, RefreshToken, RequestTokenError}; use std::collections::HashMap; use std::io; use std::net::SocketAddr; @@ -26,7 +26,7 @@ use std::sync::Arc; use std::time::{Duration, SystemTime}; use thiserror::Error; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -use tokio::time; +use tokio::task::JoinHandle; use url::Url; use super::TokenStore; @@ -52,6 +52,12 @@ pub struct SpotOauthClient { token_store: Arc, } +pub struct AuthcodeChallenge { + pkce_verifier: PkceCodeVerifier, + pub auth_url: Url, + listener: JoinHandle>, +} + impl SpotOauthClient { pub fn new(token_store: Arc) -> Self { let auth_url = AuthUrl::new("https://accounts.spotify.com/authorize".to_string()) @@ -72,9 +78,10 @@ impl SpotOauthClient { } } - /// Obtain a Spotify access token using the authorization code with PKCE OAuth flow. - /// The redirect_uri must match what is registered to the client ID. - pub async fn get_token(&self) -> Result { + pub async fn spawn_authcode_listener( + &self, + notify_complete: impl FnOnce() -> () + 'static, + ) -> Result { let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); // Generate the full authorization URL. @@ -92,19 +99,32 @@ impl SpotOauthClient { .set_pkce_challenge(pkce_challenge) .url(); - if let Err(err) = open::that(auth_url.to_string()) { - error!("An error occurred when opening '{auth_url}': {err}") - } + Ok(AuthcodeChallenge { + pkce_verifier, + auth_url, + listener: tokio::task::spawn_local(async move { + let result = wait_for_authcode(csrf_token).await; + notify_complete(); + result + }), + }) + } - let res = wait_for_authcode().await?; - if *csrf_token.secret() != *res.csrf_token.secret() { - return Err(OAuthError::InvalidState); - } + /// Obtain a Spotify access token using the authorization code with PKCE OAuth flow. + /// The redirect_uri must match what is registered to the client ID. + pub async fn exchange_authcode( + &self, + challenge: AuthcodeChallenge, + ) -> Result { + let code = challenge + .listener + .await + .map_err(|_| OAuthError::AuthCodeListenerTerminated)??; let token = self .client - .exchange_code(res.code) - .set_pkce_verifier(pkce_verifier) + .exchange_code(code) + .set_pkce_verifier(challenge.pkce_verifier) .request_async(async_http_client) .await .map_err(|e| match e { @@ -204,7 +224,7 @@ impl SpotOauthClient { "Refreshing token in approx {}min", duration.as_secs().div_euclid(60) ); - time::sleep(duration.saturating_sub(Duration::from_secs(10))).await; + tokio::time::sleep(duration.saturating_sub(Duration::from_secs(10))).await; info!("Refreshing token..."); self.refresh_token(old_token).await @@ -244,13 +264,8 @@ pub enum OAuthError { InvalidState, } -struct OAuthResult { - csrf_token: CsrfToken, - code: AuthorizationCode, -} - /// Spawn HTTP server at provided socket address to accept OAuth callback and return auth code. -async fn wait_for_authcode() -> Result { +async fn wait_for_authcode(expected_state: CsrfToken) -> Result { let addr = get_socket_address(REDIRECT_URI).expect("Invalid redirect uri"); let listener = tokio::net::TcpListener::bind(addr) @@ -262,6 +277,18 @@ async fn wait_for_authcode() -> Result { .await .map_err(|_| OAuthError::AuthCodeListenerTerminated)?; + let mut request_line = String::new(); + let mut reader = BufReader::new(&mut stream); + reader + .read_line(&mut request_line) + .await + .map_err(|_| OAuthError::AuthCodeListenerParse)?; + + let (state, code) = parse_query(&request_line)?; + if *expected_state.secret() != *state.secret() { + return Err(OAuthError::InvalidState); + } + let message = include_str!("./login.html"); let response = format!( "HTTP/1.1 200 OK\r\ncontent-length: {}\r\n\r\n{}", @@ -273,17 +300,10 @@ async fn wait_for_authcode() -> Result { .await .map_err(|_| OAuthError::AuthCodeListenerWrite)?; - let mut request_line = String::new(); - let mut reader = BufReader::new(stream); - reader - .read_line(&mut request_line) - .await - .map_err(|_| OAuthError::AuthCodeListenerParse)?; - - parse_query(&request_line) + Ok(code) } -fn parse_query(request_line: &str) -> Result { +fn parse_query(request_line: &str) -> Result<(CsrfToken, AuthorizationCode), OAuthError> { let query = request_line .split_whitespace() .nth(1) @@ -305,7 +325,7 @@ fn parse_query(request_line: &str) -> Result { .map(AuthorizationCode::new) .ok_or(OAuthError::AuthCodeNotFound)?; - Ok(OAuthResult { csrf_token, code }) + Ok((csrf_token, code)) } // If the specified `redirect_uri` is HTTP, loopback, and contains a port, diff --git a/src/player/player.rs b/src/player/player.rs index e0b42560..9735d21b 100644 --- a/src/player/player.rs +++ b/src/player/player.rs @@ -1,6 +1,7 @@ -use futures::channel::mpsc::UnboundedReceiver; +use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender}; use futures::stream::StreamExt; +use futures::SinkExt; use librespot::core::authentication::Credentials; use librespot::core::cache::Cache; use librespot::core::config::SessionConfig; @@ -12,8 +13,9 @@ use librespot::playback::mixer::{Mixer, MixerConfig}; use librespot::playback::audio_backend; use librespot::playback::config::{AudioFormat, Bitrate, PlayerConfig, VolumeCtrl}; use librespot::playback::player::{Player, PlayerEvent, PlayerEventChannel}; +use url::Url; -use super::oauth2::SpotOauthClient; +use super::oauth2::{AuthcodeChallenge, SpotOauthClient}; use super::{Command, TokenStore}; use crate::app::credentials; use crate::player::oauth2::OAuthError; @@ -49,6 +51,7 @@ impl fmt::Display for SpotifyError { pub trait SpotifyPlayerDelegate { fn end_of_track_reached(&self); + fn login_challenge_started(&self, url: Url); fn token_login_successful(&self, username: String); fn refresh_successful(&self); fn report_error(&self, error: SpotifyError); @@ -87,7 +90,13 @@ pub struct SpotifyPlayer { player: Option>, mixer: Option>, session: Option, + + // Auth related stuff oauth_client: Arc, + auth_challenge: Option, + command_sender: UnboundedSender, + + // Receives feedback from commands or various events in the player delegate: Rc, } @@ -96,6 +105,7 @@ impl SpotifyPlayer { settings: SpotifyPlayerSettings, delegate: Rc, token_store: Arc, + command_sender: UnboundedSender, ) -> Self { Self { settings, @@ -103,6 +113,8 @@ impl SpotifyPlayer { player: None, session: None, oauth_client: Arc::new(SpotOauthClient::new(token_store)), + auth_challenge: None, + command_sender, delegate, } } @@ -177,7 +189,7 @@ impl SpotifyPlayer { let _ = self.player.take(); Ok(()) } - Command::Reconnect => { + Command::Restore => { let credentials = self.oauth_client .get_valid_token() @@ -190,10 +202,34 @@ impl SpotifyPlayer { info!("Restoring session"); self.initial_login(credentials).await } - Command::NewLogin => { + Command::InitLogin => { + let auth_url = match self.auth_challenge.as_ref() { + Some(challenge) => challenge.auth_url.clone(), + None => { + let cmd = self.command_sender.clone(); + let challenge = self + .oauth_client + .spawn_authcode_listener(move || { + cmd.unbounded_send(Command::CompleteLogin).unwrap(); + }) + .await + .map_err(|_| SpotifyError::LoginFailed)?; + let auth_url = challenge.auth_url.clone(); + self.auth_challenge = Some(challenge); + auth_url + } + }; + self.delegate.login_challenge_started(auth_url); + Ok(()) + } + Command::CompleteLogin => { + let Some(challenge) = self.auth_challenge.take() else { + return Err(SpotifyError::LoginFailed); + }; + let credentials = self .oauth_client - .get_token() + .exchange_authcode(challenge) .await .map_err(|_| SpotifyError::LoginFailed)?;