2026-06-20 20:55:08 +01:00
use std ::io ::{ BufRead , BufReader , Write };
use std ::net ::TcpListener ;
2026-06-20 19:41:47 +01:00
use std ::path ::PathBuf ;
use std ::sync ::Arc ;
use chrono ::{ DateTime , Utc };
use reqwest ::Client ;
use serde ::{ Deserialize , Serialize };
use tokio ::sync ::Mutex ;
2026-06-20 20:55:08 +01:00
use url ::Url ;
2026-06-20 19:41:47 +01:00
use crate ::domain ::models ::* ;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OAuthToken {
pub access_token : String ,
pub refresh_token : Option < String > ,
pub expires_at : Option < DateTime < Utc >> ,
}
#[derive(Debug)]
2026-06-20 19:51:10 +01:00
#[allow(dead_code)]
2026-06-20 19:41:47 +01:00
pub enum ApiError {
Network ( String ),
Auth ( String ),
Api ( String ),
}
pub struct ApiClient {
client : Client ,
client_id : String ,
client_secret : String ,
token : Arc < Mutex < Option < OAuthToken >>> ,
token_path : PathBuf ,
}
2026-06-20 20:55:08 +01:00
const SCOPES : & str = "https://www.googleapis.com/auth/tasks" ;
2026-06-20 19:41:47 +01:00
impl ApiClient {
pub fn new ( client_id : String , client_secret : String ) -> Self {
let token_path = dirs ::config_dir ()
. unwrap_or_else ( || PathBuf ::from ( "." ))
. join ( "task_app" )
. join ( "token.json" );
Self {
client : Client ::new (),
client_id ,
client_secret ,
token : Arc ::new ( Mutex ::new ( None )),
token_path ,
}
}
2026-06-20 19:51:10 +01:00
pub fn token_file_exists ( & self ) -> bool {
2026-06-20 20:55:08 +01:00
self . token_path . exists ()
&& std ::fs ::read_to_string ( & self . token_path )
. ok ()
. and_then ( | s | serde_json ::from_str ::< OAuthToken > ( & s ). ok ())
. is_some ()
2026-06-20 19:51:10 +01:00
}
2026-06-20 19:41:47 +01:00
pub async fn load_token ( & self ) -> Option < OAuthToken > {
let content = std ::fs ::read_to_string ( & self . token_path ). ok () ? ;
serde_json ::from_str ( & content ). ok ()
}
pub async fn save_token ( & self , token : & OAuthToken ) {
if let Some ( parent ) = self . token_path . parent () {
std ::fs ::create_dir_all ( parent ). ok ();
}
if let Ok ( content ) = serde_json ::to_string_pretty ( token ) {
std ::fs ::write ( & self . token_path , content ). ok ();
}
}
2026-06-20 20:55:08 +01:00
/// Starts the Loopback IP Redirect OAuth flow (RFC 8252).
/// Returns (auth_url, callback_port) so the app can tell the user
/// to open the URL or open it automatically.
pub async fn start_auth_flow ( & self ) -> Result < ( String , u16 ), ApiError > {
2026-06-20 19:56:41 +01:00
if self . client_id . is_empty () {
return Err ( ApiError ::Auth (
2026-06-20 20:55:08 +01:00
"GOOGLE_CLIENT_ID not set" . to_string (),
2026-06-20 20:30:26 +01:00
));
}
2026-06-20 19:41:47 +01:00
2026-06-20 20:55:08 +01:00
// Find a free port
let listener = TcpListener ::bind ( "127.0.0.1:0" )
. map_err ( | e | ApiError ::Network ( format! ( "Failed to bind port: {} " , e ))) ? ;
let port = listener . local_addr (). unwrap (). port ();
let redirect_uri = format! ( "http://127.0.0.1: {} /" , port );
// Build Google auth URL
let auth_url = format! (
"https://accounts.google.com/o/oauth2/v2/auth? \
response_type=code& \
client_id= {} & \
redirect_uri= {} & \
scope= {} & \
access_type=offline& \
prompt=consent" ,
urlencoding ( & self . client_id ),
urlencoding ( & redirect_uri ),
urlencoding ( SCOPES ),
);
2026-06-20 19:56:41 +01:00
2026-06-20 20:55:08 +01:00
// Spawn a thread that accepts one connection and parses the code
let client_id = self . client_id . clone ();
let client_secret = self . client_secret . clone ();
let token = self . token . clone ();
let token_path = self . token_path . clone ();
std ::thread ::spawn ( move || {
if let Err ( e ) = handle_oauth_callback (
listener ,
& client_id ,
& client_secret ,
& token ,
& token_path ,
) {
eprintln! ( "OAuth callback error: {} " , e );
2026-06-20 20:30:26 +01:00
}
2026-06-20 20:55:08 +01:00
});
2026-06-20 20:30:26 +01:00
2026-06-20 20:55:08 +01:00
Ok (( auth_url , port ))
}
2026-06-20 19:41:47 +01:00
2026-06-20 20:55:08 +01:00
/// Opens the browser or returns the URL for manual opening
pub fn open_browser ( auth_url : & str ) -> bool {
webbrowser ::open ( auth_url ). is_ok ()
}
2026-06-20 19:41:47 +01:00
2026-06-20 20:55:08 +01:00
/// Polls the in-memory token to see if auth completed
pub async fn token_is_ready ( & self ) -> bool {
self . token . lock (). await . is_some ()
2026-06-20 19:41:47 +01:00
}
pub async fn refresh_access_token ( & self , refresh_token : & str ) -> Result < (), ApiError > {
let params = serde_json ::json! ({
"client_id" : self . client_id ,
"client_secret" : self . client_secret ,
"refresh_token" : refresh_token ,
"grant_type" : "refresh_token" ,
});
let resp = self
. client
. post ( "https://oauth2.googleapis.com/token" )
. json ( & params )
. send ()
. await
2026-06-20 20:55:08 +01:00
. map_err ( | e | ApiError ::Network ( format! ( "HTTP request failed: {} " , e ))) ? ;
2026-06-20 19:41:47 +01:00
2026-06-20 20:55:08 +01:00
let status = resp . status ();
2026-06-20 19:41:47 +01:00
let data : serde_json ::Value = resp
. json ()
. await
2026-06-20 20:55:08 +01:00
. map_err ( | e | ApiError ::Api ( format! ( "Invalid response (status {} ): {} " , status , e ))) ? ;
if ! status . is_success () {
return Err ( ApiError ::Api ( format! (
"Token refresh failed ( {} ): {:?} " ,
status , data
)));
}
2026-06-20 19:41:47 +01:00
if let Some ( access_token ) = data [ "access_token" ]. as_str () {
let expires_in = data [ "expires_in" ]. as_i64 (). unwrap_or ( 3600 );
let token = OAuthToken {
access_token : access_token . to_string (),
refresh_token : Some ( refresh_token . to_string ()),
expires_at : Some ( Utc ::now () + chrono ::Duration ::seconds ( expires_in )),
};
self . save_token ( & token ). await ;
let mut t = self . token . lock (). await ;
* t = Some ( token );
Ok (())
} else {
Err ( ApiError ::Auth ( "Failed to refresh token" . to_string ()))
}
}
pub async fn ensure_token ( & self ) -> Result < String , ApiError > {
let mut token = self . token . lock (). await ;
if let Some ( ref t ) = * token {
if let Some ( expires_at ) = t . expires_at {
if Utc ::now () < expires_at {
return Ok ( t . access_token . clone ());
}
}
if let Some ( ref refresh ) = t . refresh_token {
let refresh_token = refresh . clone ();
drop ( token );
self . refresh_access_token ( & refresh_token ). await ? ;
let t2 = self . token . lock (). await ;
if let Some ( ref t ) = * t2 {
return Ok ( t . access_token . clone ());
}
}
2026-06-20 20:55:08 +01:00
Err ( ApiError ::Auth (
"Token expired and no refresh token" . to_string (),
))
2026-06-20 19:41:47 +01:00
} else if let Some ( saved ) = self . load_token (). await {
* token = Some ( saved );
if let Some ( ref t ) = * token {
Ok ( t . access_token . clone ())
} else {
Err ( ApiError ::Auth ( "No token available" . to_string ()))
}
} else {
Err ( ApiError ::Auth ( "Not authenticated" . to_string ()))
}
}
pub async fn fetch_lists ( & self ) -> Result < Vec < TaskList > , ApiError > {
let token = self . ensure_token (). await ? ;
let resp = self
. client
. get ( "https://tasks.googleapis.com/tasks/v1/users/@me/lists" )
. bearer_auth ( & token )
. send ()
. await
. map_err ( | e | ApiError ::Network ( e . to_string ())) ? ;
let data : serde_json ::Value = resp
. json ()
. await
. map_err ( | e | ApiError ::Api ( e . to_string ())) ? ;
let empty = vec! [];
let items = data [ "items" ]. as_array (). unwrap_or ( & empty );
let lists = items
. iter ()
. map ( | item | TaskList {
id : item [ "id" ]. as_str (). unwrap_or ( "" ). to_string (),
title : item [ "title" ]. as_str (). unwrap_or ( "" ). to_string (),
})
. collect ();
Ok ( lists )
}
pub async fn fetch_tasks ( & self , list_id : & str ) -> Result < Vec < Task > , ApiError > {
let token = self . ensure_token (). await ? ;
let url = format! (
"https://tasks.googleapis.com/tasks/v1/lists/ {} /tasks?showCompleted=true&showHidden=true" ,
list_id
);
let resp = self
. client
. get ( & url )
. bearer_auth ( & token )
. send ()
. await
. map_err ( | e | ApiError ::Network ( e . to_string ())) ? ;
let data : serde_json ::Value = resp
. json ()
. await
. map_err ( | e | ApiError ::Api ( e . to_string ())) ? ;
let empty = vec! [];
let items = data [ "items" ]. as_array (). unwrap_or ( & empty );
let tasks = items
. iter ()
. enumerate ()
. map ( | ( i , item ) | {
let due_str = item [ "due" ]. as_str (). and_then ( | s | {
chrono ::NaiveDateTime ::parse_from_str (
2026-06-20 20:55:08 +01:00
& s . replace ( "T" , " " )
. replace ( "Z" , "" )
. chars ()
. take ( 16 )
. collect ::< String > (),
2026-06-20 19:41:47 +01:00
"%Y-%m-%d %H:%M" ,
)
. ok ()
});
Task {
id : item [ "id" ]. as_str (). unwrap_or ( "" ). to_string (),
list_id : list_id . to_string (),
title : item [ "title" ]. as_str (). unwrap_or ( "" ). to_string (),
notes : item [ "notes" ]. as_str (). map ( | s | s . to_string ()),
status : if item [ "status" ]. as_str () == Some ( "completed" ) {
TaskStatus ::Completed
} else {
TaskStatus ::NeedsAction
},
due : due_str ,
position : i as i64 ,
}
})
. collect ();
Ok ( tasks )
}
pub async fn create_task ( & self , list_id : & str , task : & Task ) -> Result < (), ApiError > {
let token = self . ensure_token (). await ? ;
let mut body = serde_json ::json! ({
"title" : task . title ,
});
if let Some ( ref notes ) = task . notes {
body [ "notes" ] = serde_json ::Value ::String ( notes . clone ());
}
if let Some ( due ) = task . due {
2026-06-20 20:55:08 +01:00
body [ "due" ] =
serde_json ::Value ::String ( due . format ( "%Y-%m-%dT%H:%M:00.000Z" ). to_string ());
2026-06-20 19:41:47 +01:00
}
if task . status == TaskStatus ::Completed {
body [ "status" ] = serde_json ::Value ::String ( "completed" . to_string ());
}
let url = format! (
"https://tasks.googleapis.com/tasks/v1/lists/ {} /tasks" ,
list_id
);
let resp = self
. client
. post ( & url )
. bearer_auth ( & token )
. json ( & body )
. send ()
. await
. map_err ( | e | ApiError ::Network ( e . to_string ())) ? ;
if ! resp . status (). is_success () {
2026-06-20 20:55:08 +01:00
return Err ( ApiError ::Api ( format! (
"Create failed: {} " ,
resp . status ()
)));
2026-06-20 19:41:47 +01:00
}
Ok (())
}
pub async fn update_task ( & self , list_id : & str , task : & Task ) -> Result < (), ApiError > {
let token = self . ensure_token (). await ? ;
let mut body = serde_json ::json! ({
"title" : task . title ,
});
if let Some ( ref notes ) = task . notes {
body [ "notes" ] = serde_json ::Value ::String ( notes . clone ());
}
if let Some ( due ) = task . due {
2026-06-20 20:55:08 +01:00
body [ "due" ] =
serde_json ::Value ::String ( due . format ( "%Y-%m-%dT%H:%M:00.000Z" ). to_string ());
2026-06-20 19:41:47 +01:00
}
body [ "status" ] = serde_json ::Value ::String ( match task . status {
TaskStatus ::Completed => "completed" . to_string (),
TaskStatus ::NeedsAction => "needsAction" . to_string (),
});
let url = format! (
"https://tasks.googleapis.com/tasks/v1/lists/ {} /tasks/ {} " ,
list_id , task . id
);
let resp = self
. client
. patch ( & url )
. bearer_auth ( & token )
. json ( & body )
. send ()
. await
. map_err ( | e | ApiError ::Network ( e . to_string ())) ? ;
if ! resp . status (). is_success () {
2026-06-20 20:55:08 +01:00
return Err ( ApiError ::Api ( format! (
"Update failed: {} " ,
resp . status ()
)));
2026-06-20 19:41:47 +01:00
}
Ok (())
}
pub async fn delete_task ( & self , list_id : & str , task_id : & str ) -> Result < (), ApiError > {
let token = self . ensure_token (). await ? ;
let url = format! (
"https://tasks.googleapis.com/tasks/v1/lists/ {} /tasks/ {} " ,
list_id , task_id
);
let resp = self
. client
. delete ( & url )
. bearer_auth ( & token )
. send ()
. await
. map_err ( | e | ApiError ::Network ( e . to_string ())) ? ;
if ! resp . status (). is_success () {
2026-06-20 20:55:08 +01:00
return Err ( ApiError ::Api ( format! (
"Delete failed: {} " ,
resp . status ()
)));
2026-06-20 19:41:47 +01:00
}
Ok (())
}
pub async fn move_task (
& self ,
list_id : & str ,
task_id : & str ,
prev : Option <& str > ,
sibling : Option <& str > ,
) -> Result < (), ApiError > {
let token = self . ensure_token (). await ? ;
let mut url = format! (
"https://tasks.googleapis.com/tasks/v1/lists/ {} /tasks/ {} /move" ,
list_id , task_id
);
if let Some ( p ) = prev {
url . push_str ( & format! ( "&previous= {} " , p ));
}
if let Some ( s ) = sibling {
url . push_str ( & format! ( "&destinationTaskList= {} " , s ));
}
let resp = self
. client
. post ( & url )
. bearer_auth ( & token )
. send ()
. await
. map_err ( | e | ApiError ::Network ( e . to_string ())) ? ;
if ! resp . status (). is_success () {
return Err ( ApiError ::Api ( format! ( "Move failed: {} " , resp . status ())));
}
Ok (())
}
}
2026-06-20 20:55:08 +01:00
fn urlencoding ( s : & str ) -> String {
s . chars ()
. map ( | c | match c {
'A' ..= 'Z' | 'a' ..= 'z' | '0' ..= '9' | '-' | '_' | '.' | '~' => c . to_string (),
_ => format! ( "% {:02X} " , c as u8 ),
})
. collect ()
}
fn handle_oauth_callback (
listener : TcpListener ,
client_id : & str ,
client_secret : & str ,
token_storage : & Arc < Mutex < Option < OAuthToken >>> ,
token_path : & PathBuf ,
) -> Result < (), Box < dyn std ::error ::Error >> {
let ( stream , _ ) = listener . accept () ? ;
let mut reader = BufReader ::new ( & stream );
let mut request_line = String ::new ();
reader . read_line ( & mut request_line ) ? ;
// Parse the GET request to extract the code
let code = request_line
. split_whitespace ()
. nth ( 1 )
. and_then ( | path | {
let parsed = Url ::parse ( & format! ( "http://localhost {} " , path )). ok () ? ;
parsed . query_pairs (). find ( | ( k , _ ) | k == "code" ) ? . 1. to_string (). into ()
});
let reply = if let Some ( ref _code ) = code {
// Send success response to browser
"HTTP/1.1 200 OK \r\n Content-Type: text/html \r\n\r\n <html><body><h1>Authorized!</h1><p>You can close this tab and return to the terminal.</p></body></html>"
} else {
"HTTP/1.1 400 Bad Request \r\n Content-Type: text/html \r\n\r\n <html><body><h1>Authorization failed</h1><p>No code received.</p></body></html>"
};
let mut response = stream . try_clone () ? ;
response . write_all ( reply . as_bytes ()) ? ;
response . flush () ? ;
if let Some ( auth_code ) = code {
// Exchange code for token
let rt = tokio ::runtime ::Runtime ::new () ? ;
rt . block_on ( async move {
let client = Client ::new ();
let params = serde_json ::json! ({
"client_id" : client_id ,
"client_secret" : client_secret ,
"code" : auth_code ,
"redirect_uri" : format ! ( "http://127.0.0.1:{}/" , listener . local_addr (). unwrap (). port ()),
"grant_type" : "authorization_code" ,
});
if let Ok ( resp ) = client
. post ( "https://oauth2.googleapis.com/token" )
. json ( & params )
. send ()
. await
{
if let Ok ( data ) = resp . json ::< serde_json ::Value > (). await {
if let Some ( access_token ) = data [ "access_token" ]. as_str () {
let expires_in = data [ "expires_in" ]. as_i64 (). unwrap_or ( 3600 );
let oauth_token = OAuthToken {
access_token : access_token . to_string (),
refresh_token : data [ "refresh_token" ]. as_str (). map ( | s | s . to_string ()),
expires_at : Some ( Utc ::now () + chrono ::Duration ::seconds ( expires_in )),
};
if let Ok ( content ) = serde_json ::to_string_pretty ( & oauth_token ) {
std ::fs ::write ( token_path , content ). ok ();
}
let mut t = token_storage . lock (). await ;
* t = Some ( oauth_token );
}
}
}
});
}
Ok (())
}