diff --git a/.gitignore b/.gitignore index 4a18478..7095c04 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,10 @@ dist-ssr # Tauri build output src-tauri/target/ +# Google OAuth desktop client (real values, never commit) +/google-credentials.json +/client_secret_*.json + # Screenshots & Playwright MCP artifacts .playwright-mcp/ /*.png diff --git a/CLAUDE.md b/CLAUDE.md index a7856c4..eb28f4a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,7 +10,7 @@ ## Git Commit Rules + - Do not make branches, commit in main only - Use conventional commit format: `feat|fix|refactor|docs|test|chore|ci(scope): message` - Never use `git add .` or `git add -A`. Always stage specific files by name. - Don't batch multiple unrelated changes into one commit. - - Commit early and often. A working 5-line change is better than a pending 200-line change. diff --git a/google-credentials.example.json b/google-credentials.example.json new file mode 100644 index 0000000..8792e79 --- /dev/null +++ b/google-credentials.example.json @@ -0,0 +1,11 @@ +{ + "installed": { + "client_id": "YOUR_CLIENT_ID.apps.googleusercontent.com", + "project_id": "your-project-id", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_secret": "YOUR_CLIENT_SECRET", + "redirect_uris": ["http://127.0.0.1"] + } +} diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 75c683b..75747e0 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3089,8 +3089,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -3754,6 +3756,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", + "webpki-roots", ] [[package]] @@ -4632,6 +4635,12 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "lzma-rs" version = "0.3.0" @@ -4709,8 +4718,11 @@ dependencies = [ "base64 0.22.1", "fontdb", "harper-core", + "rand 0.8.6", + "reqwest 0.12.28", "serde", "serde_json", + "sha2", "spellbook", "tauri", "tauri-build", @@ -4721,6 +4733,7 @@ dependencies = [ "typst", "typst-as-lib", "typst-pdf", + "url", "zip 2.4.2", ] @@ -5967,6 +5980,61 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.2", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash 2.1.2", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.45" @@ -5994,6 +6062,8 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ + "libc", + "rand_chacha 0.3.1", "rand_core 0.6.4", "serde", ] @@ -6034,6 +6104,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ + "getrandom 0.2.17", "serde", ] @@ -6204,6 +6275,44 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + [[package]] name = "reqwest" version = "0.13.4" @@ -6426,6 +6535,7 @@ version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ + "web-time", "zeroize", ] @@ -6774,6 +6884,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_with" version = "3.21.0" @@ -7422,7 +7544,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest", + "reqwest 0.13.4", "serde", "serde_json", "serde_repr", @@ -7611,7 +7733,7 @@ dependencies = [ "minisign-verify", "osakit", "percent-encoding", - "reqwest", + "reqwest 0.13.4", "rustls", "semver", "serde", @@ -9150,6 +9272,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 7a3fe6d..b5e5a89 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -36,6 +36,10 @@ spellbook = "0.4" # bump could silently invalidate the patch ("unused") and pull the broken CUDA subtree back in, # so bump deliberately and re-audit the stubs. (see [patch.crates-io]) harper-core = { version = "=2.5.0", features = ["concurrent"] } +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } +sha2 = "0.10" +rand = "0.8" +url = "2" # harper-core transitively declares an optional, disabled `burn-cuda` (CUDA backend). # Cargo still version-resolves that dead subtree, which currently fails (broken tracel-llvm diff --git a/src-tauri/src/gdrive.rs b/src-tauri/src/gdrive.rs new file mode 100644 index 0000000..1bce948 --- /dev/null +++ b/src-tauri/src/gdrive.rs @@ -0,0 +1,806 @@ +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 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, +} + +#[derive(Serialize, Deserialize, Default)] +struct BackupState { + #[serde(default)] + refresh_token: Option, + email: Option, + folder_id: Option, + last_backup: 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, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RestoreResult { + restored: usize, +} + +#[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 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, +} + +#[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 = refresh_access_token(&creds, &refresh).await?; + 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(rotated) = &tokens.refresh_token { + let mut stored = load_state(app); + stored.refresh_token = Some(rotated.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) = { + let session = state.0.lock().unwrap(); + (session.refresh_token.is_some(), session.email.clone()) + }; + let stored = load_state(app); + let pending = connected && compute_pending(app, &stored); + Status { + connected, + email: email.or(stored.email), + last_backup: stored.last_backup, + pending, + } +} + +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; +} + +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 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()); + } + + 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); + 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; + } + let mut stored = load_state(&app); + stored.refresh_token = None; + stored.email = 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)) +} + +#[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 files = collect_local_files(&app)?; + let mut stored = load_state(&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 { + Some(record) => Some(record.drive_id.clone()), + 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; + } + 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, + }, + }) +} + +#[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()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 907ee4d..56645e7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,5 +1,6 @@ mod epub; mod fonts; +mod gdrive; mod library; mod pdf; mod project; @@ -72,6 +73,11 @@ pub fn run() { builder .manage(proofing::new_state()) + .manage(gdrive::GDriveState::default()) + .setup(|app| { + gdrive::init_session(app.handle()); + Ok(()) + }) .menu(|handle| build_menu(handle)) .on_menu_event(|app, event| { if matches!( @@ -94,7 +100,13 @@ pub fn run() { library::save_book, library::delete_book, proofing::proof_text, - proofing::remember_word + proofing::remember_word, + gdrive::gdrive_connect, + gdrive::gdrive_disconnect, + gdrive::gdrive_status, + gdrive::gdrive_backup, + gdrive::gdrive_restore, + gdrive::gdrive_list_backups ]) .run(context) .expect("error while running margin"); diff --git a/src-tauri/src/library.rs b/src-tauri/src/library.rs index 1999390..e44602c 100644 --- a/src-tauri/src/library.rs +++ b/src-tauri/src/library.rs @@ -19,12 +19,14 @@ fn corrupt_summary(stem: &str) -> BookSummary { } } -fn library_dir(app: &tauri::AppHandle) -> Result { - let dir = app - .path() - .app_data_dir() - .map_err(|e| e.to_string())? - .join("library"); +pub(crate) fn app_data_dir(app: &tauri::AppHandle) -> Result { + let dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + Ok(dir) +} + +pub(crate) fn library_dir(app: &tauri::AppHandle) -> Result { + let dir = app_data_dir(app)?.join("library"); fs::create_dir_all(&dir).map_err(|e| e.to_string())?; Ok(dir) } diff --git a/src/App.tsx b/src/App.tsx index f29b1f0..9b22904 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,7 +3,9 @@ import { listen } from "@tauri-apps/api/event"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { Library } from "./components/Library"; import { EditorView } from "./components/EditorView"; +import { BackupSettings } from "./components/BackupSettings"; import { useBook } from "./store/useBook"; +import { useBackup } from "./store/useBackup"; import { isDesktop } from "./ipc"; import { createAndOpenBook, saveBook } from "./library"; import { runExport } from "./export/run"; @@ -37,9 +39,16 @@ function App() { const win = getCurrentWindow(); const unlisten = win.onCloseRequested(async (event) => { const { book, dirty } = useBook.getState(); - if (!book || !dirty) return; + const { connected } = useBackup.getState(); + if ((!book || !dirty) && !connected) return; event.preventDefault(); - await saveBook(book).catch(() => {}); + if (book && dirty) await saveBook(book).catch(() => {}); + if (connected) { + await Promise.race([ + useBackup.getState().backup(true), + new Promise((resolve) => setTimeout(resolve, 8000)), + ]); + } win.destroy(); }); return () => { @@ -47,7 +56,34 @@ function App() { }; }, []); - return book ? : ; + useEffect(() => { + if (!isDesktop) return; + const unlisten = listen<{ ok: boolean; error: string | null }>("gdrive-auth", (event) => { + useBackup.getState().handleAuthEvent(event.payload.ok, event.payload.error); + }); + return () => { + unlisten.then((stop) => stop()); + }; + }, []); + + useEffect(() => { + if (!isDesktop) return; + const tick = async () => { + await useBackup.getState().refresh(); + const state = useBackup.getState(); + if (state.connected && state.pending) state.backup(true); + }; + tick(); + const id = setInterval(tick, 15 * 60 * 1000); + return () => clearInterval(id); + }, []); + + return ( + <> + {book ? : } + {isDesktop && } + + ); } export default App; diff --git a/src/backup.ts b/src/backup.ts new file mode 100644 index 0000000..aec496c --- /dev/null +++ b/src/backup.ts @@ -0,0 +1,63 @@ +import { invoke } from "@tauri-apps/api/core"; +import { isDesktop } from "./ipc"; + +export interface BackupStatus { + connected: boolean; + email: string | null; + lastBackup: number | null; + pending: boolean; +} + +export interface RestoreResult { + restored: number; +} + +export interface BackupOutcome extends BackupStatus { + uploaded: number; +} + +export interface RemoteBackup { + name: string; +} + +const OFFLINE: BackupStatus = { connected: false, email: null, lastBackup: null, pending: false }; + +export async function gdriveStatus(): Promise { + if (!isDesktop) return OFFLINE; + return invoke("gdrive_status"); +} + +export async function gdriveConnect(): Promise { + return invoke("gdrive_connect"); +} + +export async function gdriveDisconnect(): Promise { + return invoke("gdrive_disconnect"); +} + +export async function gdriveBackup(): Promise { + return invoke("gdrive_backup"); +} + +export async function gdriveRestore(): Promise { + return invoke("gdrive_restore"); +} + +export async function gdriveListBackups(): Promise { + if (!isDesktop) return []; + return invoke("gdrive_list_backups"); +} + +export function formatBackupTime(ts: number | null): string { + if (!ts) return "Not backed up yet"; + const diff = Date.now() - ts * 1000; + const min = Math.floor(diff / 60000); + if (min < 1) return "Backed up just now"; + if (min < 60) return `Backed up ${min}m ago`; + const hr = Math.floor(min / 60); + if (hr < 24) return `Backed up ${hr}h ago`; + const day = Math.floor(hr / 24); + if (day === 1) return "Backed up yesterday"; + if (day < 7) return `Backed up ${day}d ago`; + return `Backed up ${new Date(ts * 1000).toLocaleDateString()}`; +} diff --git a/src/components/BackupButton.tsx b/src/components/BackupButton.tsx new file mode 100644 index 0000000..fca84a3 --- /dev/null +++ b/src/components/BackupButton.tsx @@ -0,0 +1,48 @@ +import { Icon } from "./Icon"; +import { useBackup } from "../store/useBackup"; +import { formatBackupTime } from "../backup"; + +export function BackupButton() { + const connected = useBackup((s) => s.connected); + const pending = useBackup((s) => s.pending); + const phase = useBackup((s) => s.phase); + const lastBackup = useBackup((s) => s.lastBackup); + const openSettings = useBackup((s) => s.openSettings); + + const busy = phase === "working" || phase === "connecting"; + const error = phase === "error"; + + const state = busy ? "working" : error ? "error" : !connected ? "off" : pending ? "pending" : "synced"; + + const title = + phase === "connecting" + ? "Waiting for Google authorization…" + : state === "working" + ? "Backing up…" + : state === "error" + ? "Backup needs attention" + : state === "off" + ? "Connect Google Drive" + : state === "pending" + ? "Changes not backed up" + : formatBackupTime(lastBackup); + + const onClick = () => { + openSettings(); + }; + + return ( + + ); +} diff --git a/src/components/BackupSettings.tsx b/src/components/BackupSettings.tsx new file mode 100644 index 0000000..0d48a2c --- /dev/null +++ b/src/components/BackupSettings.tsx @@ -0,0 +1,146 @@ +import { useEffect, useState } from "react"; +import { Icon } from "./Icon"; +import { ConfirmDialog } from "./ConfirmDialog"; +import { useBackup } from "../store/useBackup"; +import { formatBackupTime, gdriveListBackups } from "../backup"; + +export function BackupSettings() { + const open = useBackup((s) => s.settingsOpen); + const close = useBackup((s) => s.closeSettings); + const connected = useBackup((s) => s.connected); + const email = useBackup((s) => s.email); + const lastBackup = useBackup((s) => s.lastBackup); + const pending = useBackup((s) => s.pending); + const phase = useBackup((s) => s.phase); + const connect = useBackup((s) => s.connect); + const disconnect = useBackup((s) => s.disconnect); + const backup = useBackup((s) => s.backup); + const restore = useBackup((s) => s.restore); + const cancelConnect = useBackup((s) => s.cancelConnect); + const openAuthUrl = useBackup((s) => s.openAuthUrl); + const copyAuthUrl = useBackup((s) => s.copyAuthUrl); + + const [remoteCount, setRemoteCount] = useState(null); + const [confirmRestore, setConfirmRestore] = useState(false); + const working = phase === "working"; + + useEffect(() => { + if (!open || !connected) { + setRemoteCount(null); + return; + } + let active = true; + gdriveListBackups() + .then((list) => active && setRemoteCount(list.length)) + .catch(() => active && setRemoteCount(null)); + return () => { + active = false; + }; + }, [open, connected, lastBackup]); + + if (!open) return null; + + return ( + <> +
+
e.stopPropagation()}> +
+

Backup & Sync

+ +
+
+ {phase === "connecting" ? ( +
+
+ + + + Waiting for Google authorization… +
+

Finish in the browser tab that opened. If it didn't open, reopen the link or copy it into a browser.

+
+ +
+ + · + +
+
+
+ ) : !connected ? ( +
+

+ Connect Google Drive to keep a private backup of every book. margin only ever sees the files it + creates in a margin folder, never the rest of your Drive. +

+ +
+ ) : ( + <> +
+
+ Connected account + {email || "Google Drive"} +
+ +
+ +
+ + {pending ? "Changes not backed up" : formatBackupTime(lastBackup)} +
+ +
+ + +
+ +

+ Backs up automatically when you close margin, and every 15 minutes while there are changes. +

+ + )} +
+
+
+ + {confirmRestore && ( + + This replaces your local books with the copies in Google Drive. Any local changes that haven't been backed + up will be lost. + + } + confirmLabel="Restore" + onConfirm={() => { + setConfirmRestore(false); + restore(); + }} + onClose={() => setConfirmRestore(false)} + /> + )} + + ); +} diff --git a/src/components/EditorView.tsx b/src/components/EditorView.tsx index ec03970..b9ea252 100644 --- a/src/components/EditorView.tsx +++ b/src/components/EditorView.tsx @@ -5,6 +5,7 @@ import { Dock } from "./Dock"; import { ResizeHandle } from "./ResizeHandle"; import { Icon } from "./Icon"; import { Settings } from "./Settings"; +import { BackupButton } from "./BackupButton"; import { CoverView } from "./CoverView"; import { FindBar } from "./FindBar"; import { ProofPopover } from "./ProofPopover"; @@ -12,6 +13,7 @@ import { Editor } from "../editor/Editor"; import { FloatingToolbar } from "../editor/FloatingToolbar"; import type { ProofCoords, ProofIssue, ProofingStorage } from "../editor/proofing"; import { COVER_ID, useBook } from "../store/useBook"; +import { useBackup } from "../store/useBackup"; import { useProofing } from "../store/useProofing"; import { useTheme } from "../store/useTheme"; import { useWidth } from "../store/useWidth"; @@ -79,6 +81,7 @@ export function EditorView() { saveBook(current) .then(() => { if (useBook.getState().book === current) markSaved(); + useBackup.getState().refresh(); }) .catch((e) => setNotice(`Save failed: ${e}`)); }, [markSaved, setNotice]); @@ -163,6 +166,7 @@ export function EditorView() { {dirty && }
+ {isDesktop && } diff --git a/src/components/Library.tsx b/src/components/Library.tsx index 7c2d914..66b5f8d 100644 --- a/src/components/Library.tsx +++ b/src/components/Library.tsx @@ -7,13 +7,20 @@ import { isDesktop } from "../ipc"; import { ConfirmDialog } from "./ConfirmDialog"; import { Icon } from "./Icon"; import { RowMenu } from "./RowMenu"; +import { BackupButton } from "./BackupButton"; +import { useBackup } from "../store/useBackup"; +import { useBook } from "../store/useBook"; export function Library({ onOpen }: { onOpen: (book: Book) => void }) { const [books, setBooks] = useState([]); const [loaded, setLoaded] = useState(false); const [busy, setBusy] = useState(false); - const [notice, setNotice] = useState(null); const [pendingDelete, setPendingDelete] = useState(null); + const notice = useBook((s) => s.notice); + const setNotice = useBook((s) => s.setNotice); + const restoreNonce = useBackup((s) => s.restoreNonce); + const restoreWorking = useBackup((s) => s.phase === "working"); + const restoreFromDrive = useBackup((s) => s.restoreFromDrive); const refresh = () => listBooks() @@ -23,6 +30,14 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) { useEffect(() => { refresh(); }, []); + useEffect(() => { + if (restoreNonce) refresh(); + }, [restoreNonce]); + useEffect(() => { + if (!notice) return; + const timer = setTimeout(() => setNotice(null), 4000); + return () => clearTimeout(timer); + }, [notice]); const handleExample = async () => { const copy = exampleBook(); @@ -64,7 +79,9 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) { return (
-
+
+ {isDesktop && } +
+ {loaded && books.length === 0 && isDesktop && ( +
+ +
+ )} {notice && (
setNotice(null)}> {notice} diff --git a/src/store/useBackup.ts b/src/store/useBackup.ts new file mode 100644 index 0000000..4f36424 --- /dev/null +++ b/src/store/useBackup.ts @@ -0,0 +1,156 @@ +import { create } from "zustand"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import { + type BackupStatus, + gdriveBackup, + gdriveConnect, + gdriveDisconnect, + gdriveRestore, + gdriveStatus, +} from "../backup"; +import { isDesktop } from "../ipc"; +import { useBook } from "./useBook"; + +type Phase = "idle" | "connecting" | "working" | "error"; + +interface BackupState { + connected: boolean; + email: string | null; + lastBackup: number | null; + pending: boolean; + phase: Phase; + error: string | null; + authUrl: string | null; + settingsOpen: boolean; + restoreNonce: number; + resolveConnect: ((ok: boolean) => void) | null; + apply: (status: BackupStatus) => void; + refresh: () => Promise; + connect: () => Promise; + cancelConnect: () => void; + handleAuthEvent: (ok: boolean, error: string | null) => Promise; + openAuthUrl: () => void; + copyAuthUrl: () => Promise; + disconnect: () => Promise; + backup: (silent?: boolean) => Promise; + restore: () => Promise; + restoreFromDrive: () => Promise; + openSettings: () => void; + closeSettings: () => void; +} + +function notify(message: string) { + useBook.getState().setNotice(message); +} + +export const useBackup = create((set, get) => ({ + connected: false, + email: null, + lastBackup: null, + pending: false, + phase: "idle", + error: null, + authUrl: null, + settingsOpen: false, + restoreNonce: 0, + resolveConnect: null, + apply: (status) => + set({ + connected: status.connected, + email: status.email, + lastBackup: status.lastBackup, + pending: status.pending, + }), + refresh: async () => { + if (!isDesktop) return; + try { + get().apply(await gdriveStatus()); + } catch {} + }, + connect: () => + new Promise((resolve) => { + get().resolveConnect?.(false); + set({ phase: "connecting", error: null, authUrl: null, resolveConnect: resolve }); + gdriveConnect() + .then((url) => set({ authUrl: url })) + .catch((e) => { + set({ phase: "error", error: String(e), resolveConnect: null }); + notify(`Could not connect: ${e}`); + resolve(false); + }); + }), + cancelConnect: () => { + const resolve = get().resolveConnect; + set({ phase: "idle", authUrl: null, resolveConnect: null }); + resolve?.(false); + }, + handleAuthEvent: async (ok, error) => { + if (get().phase !== "connecting") return; + const resolve = get().resolveConnect; + if (ok) { + await get().refresh(); + set({ phase: "idle", authUrl: null, error: null, resolveConnect: null }); + notify("Connected to Google Drive"); + } else { + set({ phase: "error", authUrl: null, error: error ?? "authorization failed", resolveConnect: null }); + notify(`Could not connect: ${error ?? "authorization failed"}`); + } + resolve?.(ok); + }, + openAuthUrl: () => { + const url = get().authUrl; + if (url) openUrl(url).catch(() => {}); + }, + copyAuthUrl: async () => { + const url = get().authUrl; + if (!url) return; + try { + await navigator.clipboard.writeText(url); + notify("Authorization link copied"); + } catch { + notify("Could not copy the link"); + } + }, + disconnect: async () => { + set({ phase: "working", error: null }); + try { + get().apply(await gdriveDisconnect()); + set({ phase: "idle" }); + notify("Disconnected from Google Drive"); + } catch (e) { + set({ phase: "error", error: String(e) }); + notify(`Could not disconnect: ${e}`); + } + }, + backup: async (silent = false) => { + if (!get().connected || get().phase === "working") return; + set({ phase: "working", error: null }); + try { + const outcome = await gdriveBackup(); + get().apply(outcome); + set({ phase: "idle" }); + if (!silent) notify(outcome.uploaded > 0 ? "Backed up to Google Drive" : "Nothing new to back up"); + } catch (e) { + set({ phase: "error", error: String(e) }); + if (!silent) notify(`Backup failed: ${e}`); + } + }, + restore: async () => { + set({ phase: "working", error: null }); + try { + const result = await gdriveRestore(); + await get().refresh(); + set((s) => ({ phase: "idle", restoreNonce: s.restoreNonce + 1 })); + notify(result.restored > 0 ? `Restored ${result.restored} file${result.restored === 1 ? "" : "s"}` : "No backups found in Google Drive"); + } catch (e) { + set({ phase: "error", error: String(e) }); + notify(`Restore failed: ${e}`); + } + }, + restoreFromDrive: async () => { + const ok = get().connected || (await get().connect()); + if (ok) await get().restore(); + }, + openSettings: () => set({ settingsOpen: true }), + closeSettings: () => set({ settingsOpen: false }), +})); diff --git a/src/styles/app.css b/src/styles/app.css index 77a681e..573b918 100644 --- a/src/styles/app.css +++ b/src/styles/app.css @@ -1257,6 +1257,10 @@ body.resizing { .library-head { height: 44px; + display: flex; + align-items: center; + justify-content: flex-end; + padding: 0 14px; } .shelf { @@ -2283,3 +2287,170 @@ body.resizing { border: none; border-radius: 0; } + +/* Google Drive backup */ +.backup-btn[data-state="pending"] { + color: var(--accent); +} + +.backup-btn[data-state="error"] { + color: var(--danger); +} + +.backup-btn[data-state="working"] svg { + animation: backup-spin 0.9s linear infinite; +} + +@keyframes backup-spin { + to { + transform: rotate(360deg); + } +} + +.library-restore { + display: flex; + justify-content: center; + padding: 0 44px 60px; +} + +.restore-link { + display: inline-flex; + align-items: center; + gap: 7px; + color: var(--ink-faint); + font-family: var(--font-ui); + font-size: var(--t-2); + transition: color 120ms var(--ease); +} + +.restore-link:hover:not(:disabled) { + color: var(--accent); +} + +.restore-link:disabled { + opacity: 0.6; + cursor: default; +} + +.backup-intro, +.backup-connecting { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 16px; +} + +.backup-intro p, +.backup-connecting p { + margin: 0; + color: var(--ink-soft); + font-family: var(--font-ui); + font-size: var(--t-3); + line-height: 1.55; +} + +.backup-text-btn { + color: var(--ink-faint); + font-family: var(--font-ui); + font-size: var(--t-2); + transition: color 120ms var(--ease); +} + +.backup-text-btn:hover { + color: var(--accent); +} + +.backup-waiting { + display: flex; + align-items: center; + gap: 9px; + font-family: var(--font-ui); + font-size: var(--t-3); + color: var(--ink); +} + +.backup-waiting svg { + color: var(--ink-soft); + animation: backup-spin 0.9s linear infinite; +} + +.backup-links { + display: flex; + align-items: center; + gap: 10px; +} + +.backup-link-sep { + color: var(--line-strong); +} + +.backup-link-row { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + margin-top: 2px; +} + +.backup-account { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding-bottom: 16px; + border-bottom: 1px solid var(--line); +} + +.backup-account-info { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.backup-email { + font-family: var(--font-ui); + font-size: var(--t-3); + color: var(--ink); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.backup-status-row { + display: flex; + align-items: center; + gap: 9px; + margin: 18px 0; + font-family: var(--font-ui); + font-size: var(--t-3); + color: var(--ink-soft); +} + +.backup-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex: none; +} + +.backup-dot.is-synced { + background: var(--ink-faint); +} + +.backup-dot.is-pending { + background: var(--accent); +} + +.backup-actions { + display: flex; + gap: 10px; +} + +.backup-note { + margin: 18px 0 0; + color: var(--ink-faint); + font-family: var(--font-ui); + font-size: var(--t-2); + line-height: 1.5; +}