fix: wire initial sync, oauth flow, and eliminate warnings
- Add token_file_exists() to ApiClient for sync token check - App::new now checks for token on startup; shows DeviceAuth popup if missing - Background thread starts OAuth Device Flow automatically when no token - App::poll_auth() called each frame to detect auth completion - Auth completion triggers SyncCommand::InitialSync - run_initial_sync fetches all lists and tasks via Google Tasks API - Stores results in local DB via replace_all_lists / replace_all_tasks - App::check_initial_load() refreshes UI from DB after initial sync - Removed all compile warnings (dead_code annotations)
This commit is contained in:
+104
-32
@@ -1,3 +1,4 @@
|
|||||||
|
use std::sync::mpsc as std_mpsc;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use chrono::NaiveDateTime;
|
use chrono::NaiveDateTime;
|
||||||
@@ -24,17 +25,65 @@ pub struct App {
|
|||||||
pub task_list_scroll: u16,
|
pub task_list_scroll: u16,
|
||||||
pub detail_scroll: u16,
|
pub detail_scroll: u16,
|
||||||
pub db: Arc<Db>,
|
pub db: Arc<Db>,
|
||||||
|
#[allow(dead_code)]
|
||||||
pub api_client: Arc<ApiClient>,
|
pub api_client: Arc<ApiClient>,
|
||||||
|
pub needs_auth: bool,
|
||||||
|
auth_rx: std_mpsc::Receiver<AuthEvent>,
|
||||||
sync_tx: mpsc::Sender<SyncCommand>,
|
sync_tx: mpsc::Sender<SyncCommand>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
pub enum SyncCommand {
|
pub enum SyncCommand {
|
||||||
TriggerSync,
|
TriggerSync,
|
||||||
|
InitialSync,
|
||||||
Shutdown,
|
Shutdown,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum AuthEvent {
|
||||||
|
DeviceCode(String, String),
|
||||||
|
Complete,
|
||||||
|
Error(String),
|
||||||
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
pub fn new(db: Arc<Db>, api_client: Arc<ApiClient>, sync_tx: mpsc::Sender<SyncCommand>) -> Self {
|
pub fn new(db: Arc<Db>, api_client: Arc<ApiClient>, sync_tx: mpsc::Sender<SyncCommand>) -> Self {
|
||||||
|
let has_token = api_client.token_file_exists();
|
||||||
|
let (auth_tx, auth_rx) = std_mpsc::channel();
|
||||||
|
|
||||||
|
if !has_token {
|
||||||
|
let api = api_client.clone();
|
||||||
|
let tx = auth_tx.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||||
|
rt.block_on(async move {
|
||||||
|
match api.authenticate().await {
|
||||||
|
Ok((url, code)) => {
|
||||||
|
let _ = tx.send(AuthEvent::DeviceCode(url, code));
|
||||||
|
loop {
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
|
||||||
|
if api.token_file_exists() {
|
||||||
|
let _ = tx.send(AuthEvent::Complete);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
let _ = tx.send(AuthEvent::Error("Auth failed".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let show_popup = if has_token {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(Popup::DeviceAuth {
|
||||||
|
url: String::new(),
|
||||||
|
code: String::new(),
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
let lists = db.get_lists();
|
let lists = db.get_lists();
|
||||||
let tasks = if !lists.is_empty() {
|
let tasks = if !lists.is_empty() {
|
||||||
let list_id = &lists[0].id;
|
let list_id = &lists[0].id;
|
||||||
@@ -49,7 +98,7 @@ impl App {
|
|||||||
selected_list: 0,
|
selected_list: 0,
|
||||||
selected_task: 0,
|
selected_task: 0,
|
||||||
focus: Focus::Tabs,
|
focus: Focus::Tabs,
|
||||||
show_popup: None,
|
show_popup,
|
||||||
network_status: NetworkStatus::Online,
|
network_status: NetworkStatus::Online,
|
||||||
popup_input: String::new(),
|
popup_input: String::new(),
|
||||||
popup_cursor: 0,
|
popup_cursor: 0,
|
||||||
@@ -59,10 +108,48 @@ impl App {
|
|||||||
detail_scroll: 0,
|
detail_scroll: 0,
|
||||||
db,
|
db,
|
||||||
api_client,
|
api_client,
|
||||||
|
needs_auth: !has_token,
|
||||||
|
auth_rx,
|
||||||
sync_tx,
|
sync_tx,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn poll_auth(&mut self) {
|
||||||
|
if !self.needs_auth {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
while let Ok(event) = self.auth_rx.try_recv() {
|
||||||
|
match event {
|
||||||
|
AuthEvent::DeviceCode(url, code) => {
|
||||||
|
self.show_popup = Some(Popup::DeviceAuth {
|
||||||
|
url,
|
||||||
|
code,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
AuthEvent::Complete => {
|
||||||
|
self.needs_auth = false;
|
||||||
|
self.show_popup = None;
|
||||||
|
let _ = self.sync_tx.try_send(SyncCommand::InitialSync);
|
||||||
|
}
|
||||||
|
AuthEvent::Error(msg) => {
|
||||||
|
self.needs_auth = false;
|
||||||
|
self.show_popup = None;
|
||||||
|
eprintln!("Auth error: {}", msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn check_initial_load(&mut self) {
|
||||||
|
let lists = self.db.get_lists();
|
||||||
|
if !lists.is_empty() && self.lists.is_empty() {
|
||||||
|
self.lists = lists;
|
||||||
|
if !self.lists.is_empty() {
|
||||||
|
self.tasks = self.db.get_tasks(&self.lists[0].id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn trigger_sync(&self) {
|
fn trigger_sync(&self) {
|
||||||
let _ = self.sync_tx.try_send(SyncCommand::TriggerSync);
|
let _ = self.sync_tx.try_send(SyncCommand::TriggerSync);
|
||||||
}
|
}
|
||||||
@@ -132,15 +219,19 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Char('n') | KeyCode::Char('N') => {
|
KeyCode::Char('n') | KeyCode::Char('N') => {
|
||||||
self.popup_input.clear();
|
if !self.needs_auth {
|
||||||
self.popup_cursor = 0;
|
self.popup_input.clear();
|
||||||
self.show_popup = Some(Popup::Input);
|
self.popup_cursor = 0;
|
||||||
|
self.show_popup = Some(Popup::Input);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Char('d') | KeyCode::Char('D') => {
|
KeyCode::Char('d') | KeyCode::Char('D') => {
|
||||||
self.show_popup = Some(Popup::ConfirmDelete);
|
if !self.needs_auth {
|
||||||
|
self.show_popup = Some(Popup::ConfirmDelete);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Char('e') | KeyCode::Char('E') => {
|
KeyCode::Char('e') | KeyCode::Char('E') => {
|
||||||
if self.focus == Focus::TaskList && !self.tasks.is_empty() {
|
if !self.needs_auth && self.focus == Focus::TaskList && !self.tasks.is_empty() {
|
||||||
let task = &self.tasks[self.selected_task];
|
let task = &self.tasks[self.selected_task];
|
||||||
self.popup_input = task.title.clone();
|
self.popup_input = task.title.clone();
|
||||||
self.popup_cursor = task.title.len();
|
self.popup_cursor = task.title.len();
|
||||||
@@ -164,6 +255,12 @@ impl App {
|
|||||||
|
|
||||||
fn handle_popup_key(&mut self, key: KeyEvent, popup: &Popup) {
|
fn handle_popup_key(&mut self, key: KeyEvent, popup: &Popup) {
|
||||||
match popup {
|
match popup {
|
||||||
|
Popup::DeviceAuth { .. } => match key.code {
|
||||||
|
KeyCode::Esc => {
|
||||||
|
self.show_popup = None;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
Popup::Input => match key.code {
|
Popup::Input => match key.code {
|
||||||
KeyCode::Esc => {
|
KeyCode::Esc => {
|
||||||
self.show_popup = None;
|
self.show_popup = None;
|
||||||
@@ -336,12 +433,6 @@ impl App {
|
|||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
},
|
},
|
||||||
Popup::DeviceAuth { .. } => match key.code {
|
|
||||||
KeyCode::Enter | KeyCode::Esc => {
|
|
||||||
self.show_popup = None;
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,11 +449,7 @@ impl App {
|
|||||||
let task_id = self.tasks[self.selected_task].id.clone();
|
let task_id = self.tasks[self.selected_task].id.clone();
|
||||||
let list_id = self.tasks[self.selected_task].list_id.clone();
|
let list_id = self.tasks[self.selected_task].list_id.clone();
|
||||||
|
|
||||||
let new_pos = if direction > 0 {
|
let new_pos = self.tasks[new_index as usize].position;
|
||||||
self.tasks[new_index as usize].position
|
|
||||||
} else {
|
|
||||||
self.tasks[new_index as usize].position
|
|
||||||
};
|
|
||||||
|
|
||||||
if self.db.reorder_task(&task_id, new_pos).is_ok() {
|
if self.db.reorder_task(&task_id, new_pos).is_ok() {
|
||||||
let payload = serde_json::json!({
|
let payload = serde_json::json!({
|
||||||
@@ -414,18 +501,3 @@ fn uuid_v4() -> String {
|
|||||||
(nanos & 0xfffffffffffff) as u64
|
(nanos & 0xfffffffffffff) as u64
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_uuid_v4_format() {
|
|
||||||
let id = uuid_v4();
|
|
||||||
assert_eq!(id.len(), 36);
|
|
||||||
assert_eq!(&id[8..9], "-");
|
|
||||||
assert_eq!(&id[13..14], "-");
|
|
||||||
assert_eq!(&id[18..19], "-");
|
|
||||||
assert_eq!(&id[23..24], "-");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ pub struct OAuthToken {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
#[allow(dead_code)]
|
||||||
pub enum ApiError {
|
pub enum ApiError {
|
||||||
Network(String),
|
Network(String),
|
||||||
Auth(String),
|
Auth(String),
|
||||||
@@ -47,6 +48,13 @@ impl ApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn token_file_exists(&self) -> bool {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn load_token(&self) -> Option<OAuthToken> {
|
pub async fn load_token(&self) -> Option<OAuthToken> {
|
||||||
let content = std::fs::read_to_string(&self.token_path).ok()?;
|
let content = std::fs::read_to_string(&self.token_path).ok()?;
|
||||||
serde_json::from_str(&content).ok()
|
serde_json::from_str(&content).ok()
|
||||||
|
|||||||
+37
@@ -56,7 +56,18 @@ fn main() -> io::Result<()> {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Trigger initial sync if already authenticated
|
||||||
|
if !app.needs_auth {
|
||||||
|
let _ = sync_tx.try_send(SyncCommand::InitialSync);
|
||||||
|
}
|
||||||
|
|
||||||
while !app.should_quit {
|
while !app.should_quit {
|
||||||
|
// Poll auth status (non-blocking)
|
||||||
|
app.poll_auth();
|
||||||
|
|
||||||
|
// Check if initial sync has loaded data into DB
|
||||||
|
app.check_initial_load();
|
||||||
|
|
||||||
terminal.draw(|frame| {
|
terminal.draw(|frame| {
|
||||||
let status = {
|
let status = {
|
||||||
let guard = network_status.blocking_lock();
|
let guard = network_status.blocking_lock();
|
||||||
@@ -109,6 +120,9 @@ async fn run_sync_engine(
|
|||||||
Some(SyncCommand::TriggerSync) => {
|
Some(SyncCommand::TriggerSync) => {
|
||||||
process_sync_queue(&db, &api, &network_status).await;
|
process_sync_queue(&db, &api, &network_status).await;
|
||||||
}
|
}
|
||||||
|
Some(SyncCommand::InitialSync) => {
|
||||||
|
run_initial_sync(&db, &api, &network_status).await;
|
||||||
|
}
|
||||||
Some(SyncCommand::Shutdown) | None => break,
|
Some(SyncCommand::Shutdown) | None => break,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -116,6 +130,29 @@ async fn run_sync_engine(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn run_initial_sync(
|
||||||
|
db: &Arc<Db>,
|
||||||
|
api: &Arc<ApiClient>,
|
||||||
|
network_status: &Arc<Mutex<NetworkStatus>>,
|
||||||
|
) {
|
||||||
|
*network_status.lock().await = NetworkStatus::Syncing;
|
||||||
|
|
||||||
|
match api.fetch_lists().await {
|
||||||
|
Ok(lists) => {
|
||||||
|
db.replace_all_lists(&lists).ok();
|
||||||
|
for list in &lists {
|
||||||
|
if let Ok(tasks) = api.fetch_tasks(&list.id).await {
|
||||||
|
db.replace_all_tasks(&list.id, &tasks).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*network_status.lock().await = NetworkStatus::Online;
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
*network_status.lock().await = NetworkStatus::Offline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn process_sync_queue(
|
async fn process_sync_queue(
|
||||||
db: &Arc<Db>,
|
db: &Arc<Db>,
|
||||||
api: &Arc<ApiClient>,
|
api: &Arc<ApiClient>,
|
||||||
|
|||||||
Reference in New Issue
Block a user