use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use rand::RngCore; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::collections::HashMap; use std::fs; use std::io::{Read, Write}; use std::net::{TcpListener, TcpStream}; use std::path::PathBuf; use std::sync::{LazyLock, Mutex}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use tauri::{Emitter, Manager}; const FOLDER_NAME: &str = "margin"; const DICTIONARY_NAME: &str = "custom-dictionary.txt"; const DRIVE_SCOPE: &str = "https://www.googleapis.com/auth/drive.file"; const SCOPES: &str = "openid email https://www.googleapis.com/auth/drive.file"; const AUTH_TIMEOUT_SECS: u64 = 120; const CREDENTIALS_JSON: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../google-credentials.json")); static HTTP: LazyLock = LazyLock::new(reqwest::Client::new); #[derive(Deserialize)] struct CredentialsFile { installed: Credentials, } #[derive(Deserialize)] struct Credentials { client_id: String, client_secret: String, auth_uri: String, token_uri: String, } fn load_credentials() -> Result { let runtime_path = concat!(env!("CARGO_MANIFEST_DIR"), "/../google-credentials.json"); let raw = fs::read_to_string(runtime_path).unwrap_or_else(|_| CREDENTIALS_JSON.to_string()); let parsed: CredentialsFile = serde_json::from_str(&raw) .map_err(|e| format!("invalid google-credentials.json: {e}"))?; let creds = parsed.installed; if creds.client_id.starts_with("YOUR_CLIENT_ID") || creds.client_secret.starts_with("YOUR_CLIENT_SECRET") { return Err("Google Drive is not set up yet. Add a real OAuth desktop client to google-credentials.json.".to_string()); } Ok(creds) } #[derive(Default)] pub struct GDriveState(pub Mutex); #[derive(Default)] pub struct Session { refresh_token: Option, access_token: Option, access_expiry: u64, email: Option, folder_id: Option, scopes: Option, } #[derive(Serialize, Deserialize, Default)] struct BackupState { #[serde(default)] refresh_token: Option, email: Option, folder_id: Option, last_backup: Option, #[serde(default)] scopes: Option, #[serde(default)] files: HashMap, } #[derive(Serialize, Deserialize, Clone)] struct FileRecord { hash: String, drive_id: String, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct Status { connected: bool, email: Option, last_backup: Option, pending: bool, needs_reauth: bool, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct RestoreResult { restored: usize, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct SyncOutcome { uploaded: usize, downloaded: usize, #[serde(flatten)] status: Status, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct BackupOutcome { uploaded: usize, #[serde(flatten)] status: Status, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct RemoteBackup { name: String, } #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] struct AuthEvent { ok: bool, error: Option, } fn now() -> u64 { SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs() } fn dictionary_path(app: &tauri::AppHandle) -> Result { Ok(crate::library::app_data_dir(app)?.join(DICTIONARY_NAME)) } fn state_path(app: &tauri::AppHandle) -> Result { Ok(crate::library::app_data_dir(app)?.join("backup.json")) } fn load_state(app: &tauri::AppHandle) -> BackupState { let path = match state_path(app) { Ok(p) => p, Err(_) => return BackupState::default(), }; match fs::read_to_string(&path) { Ok(contents) => serde_json::from_str(&contents).unwrap_or_default(), Err(_) => BackupState::default(), } } fn save_state(app: &tauri::AppHandle, state: &BackupState) -> Result<(), String> { let path = state_path(app)?; let contents = serde_json::to_string_pretty(state).map_err(|e| e.to_string())?; crate::project::atomic_write(&path, contents.as_bytes(), false) } fn random_b64(bytes: usize) -> String { let mut buf = vec![0u8; bytes]; rand::thread_rng().fill_bytes(&mut buf); URL_SAFE_NO_PAD.encode(buf) } fn pkce_challenge(verifier: &str) -> String { let mut hasher = Sha256::new(); hasher.update(verifier.as_bytes()); URL_SAFE_NO_PAD.encode(hasher.finalize()) } fn hash_bytes(bytes: &[u8]) -> String { let mut hasher = Sha256::new(); hasher.update(bytes); format!("{:x}", hasher.finalize()) } fn urlencode(s: &str) -> String { url::form_urlencoded::byte_serialize(s.as_bytes()).collect() } fn has_drive_scope(scope: &str) -> bool { scope.split_whitespace().any(|s| s == DRIVE_SCOPE) } const REAUTH_MESSAGE: &str = "margin needs the \"See, edit, create and delete only the specific Google Drive files you use with this app\" permission. Connect again and tick that box."; fn drive_escape(s: &str) -> String { s.replace('\\', "\\\\").replace('\'', "\\'") } fn safe_book_name(name: &str) -> bool { match name.strip_suffix(".margin") { Some(stem) => !stem.is_empty() && stem.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'), None => false, } } fn write_http_message(stream: &mut TcpStream, message: &str) { let body = format!( "margin\ \

{message}

" ); let response = format!( "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), body ); let _ = stream.write_all(response.as_bytes()); let _ = stream.flush(); } fn await_code(listener: TcpListener, expected_state: &str, deadline: Instant) -> Result { listener.set_nonblocking(true).map_err(|e| e.to_string())?; loop { if Instant::now() > deadline { return Err("Timed out waiting for Google authorization.".to_string()); } match listener.accept() { Ok((mut stream, _)) => { stream.set_nonblocking(false).ok(); stream.set_read_timeout(Some(Duration::from_secs(5))).ok(); let mut buf = [0u8; 8192]; let n = stream.read(&mut buf).unwrap_or(0); let request = String::from_utf8_lossy(&buf[..n]); let path = request.lines().next().and_then(|line| line.split_whitespace().nth(1)).unwrap_or(""); if path == "/favicon.ico" { write_http_message(&mut stream, "margin"); continue; } let full = format!("http://127.0.0.1{path}"); let parsed = match url::Url::parse(&full) { Ok(p) => p, Err(_) => { write_http_message(&mut stream, "Waiting for Google…"); continue; } }; let mut code = None; let mut state = None; let mut error = None; for (key, value) in parsed.query_pairs() { match key.as_ref() { "code" => code = Some(value.into_owned()), "state" => state = Some(value.into_owned()), "error" => error = Some(value.into_owned()), _ => {} } } if let Some(error) = error { write_http_message(&mut stream, "Authorization was cancelled. You can close this tab."); return Err(format!("Google authorization failed: {error}")); } match (code, state) { (Some(code), Some(state)) if state == expected_state => { write_http_message(&mut stream, "Connected to margin. You can close this tab."); return Ok(code); } (Some(_), _) => { write_http_message(&mut stream, "Could not verify the request. You can close this tab."); return Err("State mismatch during Google authorization.".to_string()); } _ => { write_http_message(&mut stream, "Waiting for Google…"); } } } Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => { std::thread::sleep(Duration::from_millis(150)); } Err(e) => return Err(e.to_string()), } } } #[derive(Deserialize)] struct TokenResponse { access_token: String, #[serde(default)] refresh_token: Option, #[serde(default)] expires_in: u64, #[serde(default)] scope: Option, } #[derive(Deserialize)] struct UserInfo { #[serde(default)] email: Option, } #[derive(Deserialize)] struct DriveFile { id: String, #[serde(default)] name: String, } #[derive(Deserialize)] struct FileList { #[serde(default)] files: Vec, #[serde(default, rename = "nextPageToken")] next_page_token: Option, } async fn read_json(resp: reqwest::Response, context: &str) -> Result { let status = resp.status(); let text = resp.text().await.map_err(|e| e.to_string())?; if !status.is_success() { return Err(format!("{context} failed ({status}): {text}")); } serde_json::from_str(&text).map_err(|e| format!("{context}: could not parse response: {e}")) } async fn exchange_code(creds: &Credentials, code: &str, redirect: &str, verifier: &str) -> Result { let resp = HTTP .post(&creds.token_uri) .form(&[ ("client_id", creds.client_id.as_str()), ("client_secret", creds.client_secret.as_str()), ("code", code), ("code_verifier", verifier), ("grant_type", "authorization_code"), ("redirect_uri", redirect), ]) .send() .await .map_err(|e| e.to_string())?; read_json(resp, "Google token exchange").await } async fn refresh_access_token(creds: &Credentials, refresh_token: &str) -> Result { let resp = HTTP .post(&creds.token_uri) .form(&[ ("client_id", creds.client_id.as_str()), ("client_secret", creds.client_secret.as_str()), ("refresh_token", refresh_token), ("grant_type", "refresh_token"), ]) .send() .await .map_err(|e| e.to_string())?; read_json(resp, "Google token refresh").await } async fn fetch_email(access_token: &str) -> Result { let resp = HTTP .get("https://www.googleapis.com/oauth2/v2/userinfo") .bearer_auth(access_token) .send() .await .map_err(|e| e.to_string())?; let info: UserInfo = read_json(resp, "Google account lookup").await?; Ok(info.email.unwrap_or_default()) } async fn ensure_folder(access_token: &str) -> Result { let query = format!( "name = '{}' and mimeType = 'application/vnd.google-apps.folder' and trashed = false", drive_escape(FOLDER_NAME) ); let resp = HTTP .get("https://www.googleapis.com/drive/v3/files") .bearer_auth(access_token) .query(&[ ("q", query.as_str()), ("fields", "files(id,name)"), ("spaces", "drive"), ("pageSize", "1"), ]) .send() .await .map_err(|e| e.to_string())?; let list: FileList = read_json(resp, "Drive folder lookup").await?; if let Some(folder) = list.files.into_iter().next() { return Ok(folder.id); } let body = serde_json::json!({ "name": FOLDER_NAME, "mimeType": "application/vnd.google-apps.folder", }); let resp = HTTP .post("https://www.googleapis.com/drive/v3/files") .bearer_auth(access_token) .query(&[("fields", "id")]) .json(&body) .send() .await .map_err(|e| e.to_string())?; let created: DriveFile = read_json(resp, "Drive folder creation").await?; Ok(created.id) } async fn find_file(access_token: &str, folder_id: &str, name: &str) -> Result, String> { let query = format!( "'{}' in parents and name = '{}' and trashed = false", drive_escape(folder_id), drive_escape(name) ); let resp = HTTP .get("https://www.googleapis.com/drive/v3/files") .bearer_auth(access_token) .query(&[ ("q", query.as_str()), ("fields", "files(id,name)"), ("spaces", "drive"), ("pageSize", "1"), ]) .send() .await .map_err(|e| e.to_string())?; let list: FileList = read_json(resp, "Drive file lookup").await?; Ok(list.files.into_iter().next()) } async fn list_in_folder(access_token: &str, folder_id: &str) -> Result, String> { let query = format!("'{}' in parents and trashed = false", drive_escape(folder_id)); let mut files = Vec::new(); let mut page_token: Option = None; loop { let mut request = HTTP .get("https://www.googleapis.com/drive/v3/files") .bearer_auth(access_token) .query(&[ ("q", query.as_str()), ("fields", "nextPageToken,files(id,name)"), ("spaces", "drive"), ("pageSize", "100"), ]); if let Some(token) = &page_token { request = request.query(&[("pageToken", token.as_str())]); } let resp = request.send().await.map_err(|e| e.to_string())?; let FileList { files: page, next_page_token } = read_json(resp, "Drive file list").await?; files.extend(page); match next_page_token { Some(token) => page_token = Some(token), None => break, } } Ok(files) } async fn upload_file( access_token: &str, folder_id: &str, name: &str, bytes: &[u8], existing_id: Option, ) -> Result { let boundary = "margin7f3e2a1b9c8d"; let metadata = match &existing_id { Some(_) => serde_json::json!({ "name": name }), None => serde_json::json!({ "name": name, "parents": [folder_id] }), }; let mut body: Vec = Vec::new(); body.extend_from_slice(format!("--{boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n").as_bytes()); body.extend_from_slice(serde_json::to_string(&metadata).unwrap_or_default().as_bytes()); body.extend_from_slice(format!("\r\n--{boundary}\r\nContent-Type: application/octet-stream\r\n\r\n").as_bytes()); body.extend_from_slice(bytes); body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes()); let request = match &existing_id { Some(id) => HTTP.patch(format!("https://www.googleapis.com/upload/drive/v3/files/{id}")), None => HTTP.post("https://www.googleapis.com/upload/drive/v3/files"), }; let resp = request .bearer_auth(access_token) .query(&[("uploadType", "multipart"), ("fields", "id")]) .header(reqwest::header::CONTENT_TYPE, format!("multipart/related; boundary={boundary}")) .body(body) .send() .await .map_err(|e| e.to_string())?; read_json(resp, &format!("Drive upload of {name}")).await } async fn download_file(access_token: &str, id: &str) -> Result, String> { let resp = HTTP .get(format!("https://www.googleapis.com/drive/v3/files/{id}")) .bearer_auth(access_token) .query(&[("alt", "media")]) .send() .await .map_err(|e| e.to_string())?; let status = resp.status(); if !status.is_success() { let text = resp.text().await.unwrap_or_default(); return Err(format!("Drive download failed ({status}): {text}")); } Ok(resp.bytes().await.map_err(|e| e.to_string())?.to_vec()) } async fn valid_access_token(app: &tauri::AppHandle, state: &GDriveState) -> Result { let (access, expiry, refresh) = { let session = state.0.lock().unwrap(); (session.access_token.clone(), session.access_expiry, session.refresh_token.clone()) }; if let Some(token) = access { if now() < expiry { return Ok(token); } } let refresh = refresh.ok_or("Not connected to Google Drive.")?; let creds = load_credentials()?; let tokens = match refresh_access_token(&creds, &refresh).await { Ok(tokens) => tokens, Err(e) => { if e.contains("invalid_grant") { forget_credentials(app, state); return Err( "Google Drive access has expired. Connect again to keep backing up.".to_string() ); } return Err(e); } }; if let Some(granted) = &tokens.scope { if !has_drive_scope(granted) { let mut session = state.0.lock().unwrap(); session.scopes = Some(granted.clone()); drop(session); let mut stored = load_state(app); stored.scopes = Some(granted.clone()); let _ = save_state(app, &stored); return Err(REAUTH_MESSAGE.to_string()); } } let access_token = tokens.access_token.clone(); { let mut session = state.0.lock().unwrap(); session.access_token = Some(tokens.access_token); session.access_expiry = now() + tokens.expires_in.saturating_sub(60); if let Some(rotated) = &tokens.refresh_token { session.refresh_token = Some(rotated.clone()); } if let Some(granted) = &tokens.scope { session.scopes = Some(granted.clone()); } } if tokens.refresh_token.is_some() || tokens.scope.is_some() { let mut stored = load_state(app); if let Some(rotated) = &tokens.refresh_token { stored.refresh_token = Some(rotated.clone()); } if let Some(granted) = &tokens.scope { stored.scopes = Some(granted.clone()); } let _ = save_state(app, &stored); } Ok(access_token) } async fn ensure_folder_id(app: &tauri::AppHandle, state: &GDriveState, access_token: &str) -> Result { { let session = state.0.lock().unwrap(); if let Some(folder) = &session.folder_id { return Ok(folder.clone()); } } let folder_id = ensure_folder(access_token).await?; { let mut session = state.0.lock().unwrap(); session.folder_id = Some(folder_id.clone()); } let mut stored = load_state(app); stored.folder_id = Some(folder_id.clone()); save_state(app, &stored)?; Ok(folder_id) } fn collect_local_files(app: &tauri::AppHandle) -> Result)>, String> { let mut files = Vec::new(); let library = crate::library::library_dir(app)?; for entry in fs::read_dir(&library).map_err(|e| e.to_string())? { let path = match entry { Ok(entry) => entry.path(), Err(_) => continue, }; if path.extension().and_then(|e| e.to_str()) != Some("margin") { continue; } let name = match path.file_name().and_then(|n| n.to_str()) { Some(name) if safe_book_name(name) => name.to_string(), _ => continue, }; let bytes = fs::read(&path).map_err(|e| e.to_string())?; files.push((name, bytes)); } let dictionary = dictionary_path(app)?; if dictionary.exists() { let bytes = fs::read(&dictionary).map_err(|e| e.to_string())?; files.push((DICTIONARY_NAME.to_string(), bytes)); } Ok(files) } fn file_pending(path: &std::path::Path, name: &str, stored: &BackupState, last_backup: u64) -> bool { let record = match stored.files.get(name) { Some(record) => record, None => return true, }; let touched = fs::metadata(path) .and_then(|meta| meta.modified()) .ok() .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) .map(|since| since.as_secs() > last_backup) .unwrap_or(true); if !touched { return false; } match fs::read(path) { Ok(bytes) => record.hash != hash_bytes(&bytes), Err(_) => false, } } fn compute_pending(app: &tauri::AppHandle, stored: &BackupState) -> bool { let last_backup = stored.last_backup.unwrap_or(0); let library = match crate::library::library_dir(app) { Ok(dir) => dir, Err(_) => return false, }; if let Ok(entries) = fs::read_dir(&library) { for entry in entries.flatten() { let path = entry.path(); if path.extension().and_then(|e| e.to_str()) != Some("margin") { continue; } if let Some(name) = path.file_name().and_then(|n| n.to_str()) { if safe_book_name(name) && file_pending(&path, name, stored, last_backup) { return true; } } } } match dictionary_path(app) { Ok(dict) if dict.exists() => file_pending(&dict, DICTIONARY_NAME, stored, last_backup), _ => false, } } fn status_inner(app: &tauri::AppHandle, state: &GDriveState) -> Status { let (connected, email, scopes) = { let session = state.0.lock().unwrap(); (session.refresh_token.is_some(), session.email.clone(), session.scopes.clone()) }; let stored = load_state(app); let pending = connected && compute_pending(app, &stored); let granted = scopes.or_else(|| stored.scopes.clone()); Status { connected, email: email.or(stored.email), last_backup: stored.last_backup, pending, needs_reauth: connected && granted.map(|s| !has_drive_scope(&s)).unwrap_or(false), } } pub fn init_session(app: &tauri::AppHandle) { let state = app.state::(); let stored = load_state(app); let mut session = state.0.lock().unwrap(); session.refresh_token = stored.refresh_token; session.email = stored.email; session.folder_id = stored.folder_id; session.scopes = stored.scopes; } fn forget_credentials(app: &tauri::AppHandle, state: &GDriveState) { { let mut session = state.0.lock().unwrap(); session.refresh_token = None; session.access_token = None; session.access_expiry = 0; session.scopes = None; } let mut stored = load_state(app); stored.refresh_token = None; stored.scopes = None; let _ = save_state(app, &stored); } async fn complete_auth( app: &tauri::AppHandle, listener: TcpListener, csrf: String, verifier: String, redirect: String, creds: Credentials, ) -> Result<(), String> { let code = tauri::async_runtime::spawn_blocking(move || { let deadline = Instant::now() + Duration::from_secs(AUTH_TIMEOUT_SECS); await_code(listener, &csrf, deadline) }) .await .map_err(|e| e.to_string())??; let tokens = exchange_code(&creds, &code, &redirect, &verifier).await?; let granted = tokens.scope.clone().unwrap_or_default(); if !has_drive_scope(&granted) { let _ = HTTP .post("https://oauth2.googleapis.com/revoke") .form(&[("token", tokens.access_token.as_str())]) .send() .await; return Err(REAUTH_MESSAGE.to_string()); } let email = fetch_email(&tokens.access_token).await?; let folder_id = ensure_folder(&tokens.access_token).await?; { let state = app.state::(); let mut session = state.0.lock().unwrap(); if let Some(refresh) = &tokens.refresh_token { session.refresh_token = Some(refresh.clone()); } session.access_token = Some(tokens.access_token.clone()); session.access_expiry = now() + tokens.expires_in.saturating_sub(60); session.email = Some(email.clone()); session.folder_id = Some(folder_id.clone()); session.scopes = Some(granted.clone()); } let mut stored = load_state(app); if let Some(refresh) = &tokens.refresh_token { stored.refresh_token = Some(refresh.clone()); } stored.email = Some(email); stored.folder_id = Some(folder_id); stored.scopes = Some(granted); save_state(app, &stored)?; Ok(()) } #[tauri::command] pub async fn gdrive_connect(app: tauri::AppHandle) -> Result { let creds = load_credentials()?; let verifier = random_b64(64); let challenge = pkce_challenge(&verifier); let csrf = random_b64(24); let listener = TcpListener::bind("127.0.0.1:0").map_err(|e| e.to_string())?; let port = listener.local_addr().map_err(|e| e.to_string())?.port(); let redirect = format!("http://127.0.0.1:{port}"); let auth_url = format!( "{}?client_id={}&redirect_uri={}&response_type=code&scope={}&code_challenge={}&code_challenge_method=S256&state={}&access_type=offline&prompt=consent", creds.auth_uri, urlencode(&creds.client_id), urlencode(&redirect), urlencode(SCOPES), challenge, urlencode(&csrf), ); use tauri_plugin_opener::OpenerExt; let _ = app.opener().open_url(auth_url.clone(), None::<&str>); let app_bg = app.clone(); tauri::async_runtime::spawn(async move { let event = match complete_auth(&app_bg, listener, csrf, verifier, redirect, creds).await { Ok(()) => AuthEvent { ok: true, error: None }, Err(error) => AuthEvent { ok: false, error: Some(error) }, }; let _ = app_bg.emit("gdrive-auth", event); }); Ok(auth_url) } #[tauri::command] pub async fn gdrive_disconnect(app: tauri::AppHandle, state: tauri::State<'_, GDriveState>) -> Result { let refresh = { let session = state.0.lock().unwrap(); session.refresh_token.clone() }; if let Some(refresh) = refresh { let _ = HTTP .post("https://oauth2.googleapis.com/revoke") .form(&[("token", refresh.as_str())]) .send() .await; } { let mut session = state.0.lock().unwrap(); session.refresh_token = None; session.access_token = None; session.access_expiry = 0; session.email = None; session.scopes = None; } let mut stored = load_state(&app); stored.refresh_token = None; stored.email = None; stored.scopes = None; save_state(&app, &stored)?; Ok(status_inner(&app, &state)) } #[tauri::command] pub async fn gdrive_status(app: tauri::AppHandle, state: tauri::State<'_, GDriveState>) -> Result { Ok(status_inner(&app, &state)) } async fn push_local_files( app: &tauri::AppHandle, access_token: &str, folder_id: &str, stored: &mut BackupState, ) -> Result { let files = collect_local_files(app)?; let mut uploaded = 0; for (name, bytes) in &files { let hash = hash_bytes(bytes); let existing = stored.files.get(name).cloned(); if let Some(record) = &existing { if record.hash == hash { continue; } } let drive_id = match existing.filter(|record| !record.drive_id.is_empty()) { Some(record) => Some(record.drive_id), None => find_file(access_token, folder_id, name).await?.map(|file| file.id), }; let result = upload_file(access_token, folder_id, name, bytes, drive_id).await?; stored.files.insert(name.clone(), FileRecord { hash, drive_id: result.id }); uploaded += 1; } Ok(uploaded) } #[tauri::command] pub async fn gdrive_backup(app: tauri::AppHandle, state: tauri::State<'_, GDriveState>) -> Result { let access_token = valid_access_token(&app, &state).await?; let folder_id = ensure_folder_id(&app, &state, &access_token).await?; let mut stored = load_state(&app); let uploaded = push_local_files(&app, &access_token, &folder_id, &mut stored).await?; if uploaded > 0 { stored.last_backup = Some(now()); } save_state(&app, &stored)?; Ok(BackupOutcome { uploaded, status: Status { connected: true, email: stored.email.clone(), last_backup: stored.last_backup, pending: false, needs_reauth: false, }, }) } #[tauri::command] pub async fn gdrive_sync(app: tauri::AppHandle, state: tauri::State<'_, GDriveState>) -> Result { let access_token = valid_access_token(&app, &state).await?; let folder_id = ensure_folder_id(&app, &state, &access_token).await?; let remote = list_in_folder(&access_token, &folder_id).await?; let mut stored = load_state(&app); let mut downloaded = 0; for file in &remote { let is_dictionary = file.name == DICTIONARY_NAME; if !is_dictionary && !safe_book_name(&file.name) { continue; } let destination = if is_dictionary { dictionary_path(&app)? } else { crate::library::library_dir(&app)?.join(&file.name) }; if destination.exists() { let record = stored .files .entry(file.name.clone()) .or_insert_with(|| FileRecord { hash: String::new(), drive_id: file.id.clone() }); record.drive_id = file.id.clone(); continue; } let bytes = download_file(&access_token, &file.id).await?; crate::project::atomic_write(&destination, &bytes, false)?; stored.files.insert( file.name.clone(), FileRecord { hash: hash_bytes(&bytes), drive_id: file.id.clone() }, ); downloaded += 1; } let uploaded = push_local_files(&app, &access_token, &folder_id, &mut stored).await?; if uploaded > 0 || downloaded > 0 { stored.last_backup = Some(now()); } save_state(&app, &stored)?; Ok(SyncOutcome { uploaded, downloaded, status: Status { connected: true, email: stored.email.clone(), last_backup: stored.last_backup, pending: false, needs_reauth: false, }, }) } #[tauri::command] pub async fn gdrive_restore(app: tauri::AppHandle, state: tauri::State<'_, GDriveState>) -> Result { let access_token = valid_access_token(&app, &state).await?; let folder_id = ensure_folder_id(&app, &state, &access_token).await?; let remote = list_in_folder(&access_token, &folder_id).await?; let mut stored = load_state(&app); let mut restored = 0; for file in &remote { let is_dictionary = file.name == DICTIONARY_NAME; let is_book = safe_book_name(&file.name); if !is_dictionary && !is_book { continue; } let bytes = download_file(&access_token, &file.id).await?; let destination = if is_dictionary { dictionary_path(&app)? } else { crate::library::library_dir(&app)?.join(&file.name) }; crate::project::atomic_write(&destination, &bytes, false)?; stored.files.insert( file.name.clone(), FileRecord { hash: hash_bytes(&bytes), drive_id: file.id.clone() }, ); restored += 1; } stored.last_backup = Some(now()); save_state(&app, &stored)?; Ok(RestoreResult { restored }) } #[tauri::command] pub async fn gdrive_list_backups(app: tauri::AppHandle, state: tauri::State<'_, GDriveState>) -> Result, String> { let access_token = valid_access_token(&app, &state).await?; let folder_id = ensure_folder_id(&app, &state, &access_token).await?; let remote = list_in_folder(&access_token, &folder_id).await?; Ok(remote .into_iter() .filter(|file| safe_book_name(&file.name)) .map(|file| RemoteBackup { name: file.name }) .collect()) }