mirror of
https://github.com/priyanshujain/margin.git
synced 2026-10-02 11:07:04 +00:00
changes
This commit is contained in:
1 parent
ecc94e6c31
commit
5cc083d670
16 files changed
+1634
-15
No files matched your search
@@ -26,6 +26,10 @@ dist-ssr
|
|||||||
# Tauri build output
|
# Tauri build output
|
||||||
src-tauri/target/
|
src-tauri/target/
|
||||||
|
|
||||||
|
# Google OAuth desktop client (real values, never commit)
|
||||||
|
/google-credentials.json
|
||||||
|
/client_secret_*.json
|
||||||
|
|
||||||
# Screenshots & Playwright MCP artifacts
|
# Screenshots & Playwright MCP artifacts
|
||||||
.playwright-mcp/
|
.playwright-mcp/
|
||||||
/*.png
|
/*.png
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
## Git Commit Rules
|
## Git Commit Rules
|
||||||
|
|
||||||
|
- Do not make branches, commit in main only
|
||||||
- Use conventional commit format: `feat|fix|refactor|docs|test|chore|ci(scope): message`
|
- 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.
|
- Never use `git add .` or `git add -A`. Always stage specific files by name.
|
||||||
- Don't batch multiple unrelated changes into one commit.
|
- 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.
|
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+133
-2
@@ -3089,8 +3089,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"wasi",
|
"wasi",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3754,6 +3756,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
|
"webpki-roots",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4632,6 +4635,12 @@ dependencies = [
|
|||||||
"hashbrown 0.17.1",
|
"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]]
|
[[package]]
|
||||||
name = "lzma-rs"
|
name = "lzma-rs"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
@@ -4709,8 +4718,11 @@ dependencies = [
|
|||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"fontdb",
|
"fontdb",
|
||||||
"harper-core",
|
"harper-core",
|
||||||
|
"rand 0.8.6",
|
||||||
|
"reqwest 0.12.28",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"spellbook",
|
"spellbook",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
@@ -4721,6 +4733,7 @@ dependencies = [
|
|||||||
"typst",
|
"typst",
|
||||||
"typst-as-lib",
|
"typst-as-lib",
|
||||||
"typst-pdf",
|
"typst-pdf",
|
||||||
|
"url",
|
||||||
"zip 2.4.2",
|
"zip 2.4.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -5967,6 +5980,61 @@ dependencies = [
|
|||||||
"memchr",
|
"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]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.45"
|
version = "1.0.45"
|
||||||
@@ -5994,6 +6062,8 @@ version = "0.8.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"rand_chacha 0.3.1",
|
||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
@@ -6034,6 +6104,7 @@ version = "0.6.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"getrandom 0.2.17",
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -6204,6 +6275,44 @@ version = "1.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
|
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]]
|
[[package]]
|
||||||
name = "reqwest"
|
name = "reqwest"
|
||||||
version = "0.13.4"
|
version = "0.13.4"
|
||||||
@@ -6426,6 +6535,7 @@ version = "1.14.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
|
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"web-time",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -6774,6 +6884,18 @@ dependencies = [
|
|||||||
"serde_core",
|
"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]]
|
[[package]]
|
||||||
name = "serde_with"
|
name = "serde_with"
|
||||||
version = "3.21.0"
|
version = "3.21.0"
|
||||||
@@ -7422,7 +7544,7 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"plist",
|
"plist",
|
||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
"reqwest",
|
"reqwest 0.13.4",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_repr",
|
"serde_repr",
|
||||||
@@ -7611,7 +7733,7 @@ dependencies = [
|
|||||||
"minisign-verify",
|
"minisign-verify",
|
||||||
"osakit",
|
"osakit",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"reqwest",
|
"reqwest 0.13.4",
|
||||||
"rustls",
|
"rustls",
|
||||||
"semver",
|
"semver",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -9150,6 +9272,15 @@ dependencies = [
|
|||||||
"rustls-pki-types",
|
"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]]
|
[[package]]
|
||||||
name = "webview2-com"
|
name = "webview2-com"
|
||||||
version = "0.38.2"
|
version = "0.38.2"
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ spellbook = "0.4"
|
|||||||
# bump could silently invalidate the patch ("unused") and pull the broken CUDA subtree back in,
|
# 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])
|
# so bump deliberately and re-audit the stubs. (see [patch.crates-io])
|
||||||
harper-core = { version = "=2.5.0", features = ["concurrent"] }
|
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).
|
# harper-core transitively declares an optional, disabled `burn-cuda` (CUDA backend).
|
||||||
# Cargo still version-resolves that dead subtree, which currently fails (broken tracel-llvm
|
# Cargo still version-resolves that dead subtree, which currently fails (broken tracel-llvm
|
||||||
|
|||||||
@@ -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<reqwest::Client> = 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<Credentials, String> {
|
||||||
|
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<Session>);
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Session {
|
||||||
|
refresh_token: Option<String>,
|
||||||
|
access_token: Option<String>,
|
||||||
|
access_expiry: u64,
|
||||||
|
email: Option<String>,
|
||||||
|
folder_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Default)]
|
||||||
|
struct BackupState {
|
||||||
|
#[serde(default)]
|
||||||
|
refresh_token: Option<String>,
|
||||||
|
email: Option<String>,
|
||||||
|
folder_id: Option<String>,
|
||||||
|
last_backup: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
files: HashMap<String, FileRecord>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
|
struct FileRecord {
|
||||||
|
hash: String,
|
||||||
|
drive_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct Status {
|
||||||
|
connected: bool,
|
||||||
|
email: Option<String>,
|
||||||
|
last_backup: Option<u64>,
|
||||||
|
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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now() -> u64 {
|
||||||
|
SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dictionary_path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||||
|
Ok(crate::library::app_data_dir(app)?.join(DICTIONARY_NAME))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state_path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||||
|
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!(
|
||||||
|
"<!doctype html><html><head><meta charset=\"utf-8\"><title>margin</title></head>\
|
||||||
|
<body style=\"font-family:system-ui,sans-serif;text-align:center;padding-top:80px;color:#222\">\
|
||||||
|
<h2>{message}</h2></body></html>"
|
||||||
|
);
|
||||||
|
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<String, String> {
|
||||||
|
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<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
expires_in: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct UserInfo {
|
||||||
|
#[serde(default)]
|
||||||
|
email: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct DriveFile {
|
||||||
|
id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct FileList {
|
||||||
|
#[serde(default)]
|
||||||
|
files: Vec<DriveFile>,
|
||||||
|
#[serde(default, rename = "nextPageToken")]
|
||||||
|
next_page_token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read_json<T: DeserializeOwned>(resp: reqwest::Response, context: &str) -> Result<T, String> {
|
||||||
|
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<TokenResponse, String> {
|
||||||
|
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<TokenResponse, String> {
|
||||||
|
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<String, String> {
|
||||||
|
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<String, String> {
|
||||||
|
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<Option<DriveFile>, 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<Vec<DriveFile>, String> {
|
||||||
|
let query = format!("'{}' in parents and trashed = false", drive_escape(folder_id));
|
||||||
|
let mut files = Vec::new();
|
||||||
|
let mut page_token: Option<String> = 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<String>,
|
||||||
|
) -> Result<DriveFile, String> {
|
||||||
|
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<u8> = 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<Vec<u8>, 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<String, String> {
|
||||||
|
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<String, String> {
|
||||||
|
{
|
||||||
|
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<Vec<(String, Vec<u8>)>, 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::<GDriveState>();
|
||||||
|
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::<GDriveState>();
|
||||||
|
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<String, String> {
|
||||||
|
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<Status, String> {
|
||||||
|
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<Status, String> {
|
||||||
|
Ok(status_inner(&app, &state))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn gdrive_backup(app: tauri::AppHandle, state: tauri::State<'_, GDriveState>) -> Result<BackupOutcome, String> {
|
||||||
|
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<RestoreResult, 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?;
|
||||||
|
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<Vec<RemoteBackup>, 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())
|
||||||
|
}
|
||||||
+13
-1
@@ -1,5 +1,6 @@
|
|||||||
mod epub;
|
mod epub;
|
||||||
mod fonts;
|
mod fonts;
|
||||||
|
mod gdrive;
|
||||||
mod library;
|
mod library;
|
||||||
mod pdf;
|
mod pdf;
|
||||||
mod project;
|
mod project;
|
||||||
@@ -72,6 +73,11 @@ pub fn run() {
|
|||||||
|
|
||||||
builder
|
builder
|
||||||
.manage(proofing::new_state())
|
.manage(proofing::new_state())
|
||||||
|
.manage(gdrive::GDriveState::default())
|
||||||
|
.setup(|app| {
|
||||||
|
gdrive::init_session(app.handle());
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
.menu(|handle| build_menu(handle))
|
.menu(|handle| build_menu(handle))
|
||||||
.on_menu_event(|app, event| {
|
.on_menu_event(|app, event| {
|
||||||
if matches!(
|
if matches!(
|
||||||
@@ -94,7 +100,13 @@ pub fn run() {
|
|||||||
library::save_book,
|
library::save_book,
|
||||||
library::delete_book,
|
library::delete_book,
|
||||||
proofing::proof_text,
|
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)
|
.run(context)
|
||||||
.expect("error while running margin");
|
.expect("error while running margin");
|
||||||
|
|||||||
@@ -19,12 +19,14 @@ fn corrupt_summary(stem: &str) -> BookSummary {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn library_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
pub(crate) fn app_data_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||||
let dir = app
|
let dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
||||||
.path()
|
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||||
.app_data_dir()
|
Ok(dir)
|
||||||
.map_err(|e| e.to_string())?
|
}
|
||||||
.join("library");
|
|
||||||
|
pub(crate) fn library_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||||
|
let dir = app_data_dir(app)?.join("library");
|
||||||
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||||
Ok(dir)
|
Ok(dir)
|
||||||
}
|
}
|
||||||
|
|||||||
+39
-3
@@ -3,7 +3,9 @@ import { listen } from "@tauri-apps/api/event";
|
|||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
import { Library } from "./components/Library";
|
import { Library } from "./components/Library";
|
||||||
import { EditorView } from "./components/EditorView";
|
import { EditorView } from "./components/EditorView";
|
||||||
|
import { BackupSettings } from "./components/BackupSettings";
|
||||||
import { useBook } from "./store/useBook";
|
import { useBook } from "./store/useBook";
|
||||||
|
import { useBackup } from "./store/useBackup";
|
||||||
import { isDesktop } from "./ipc";
|
import { isDesktop } from "./ipc";
|
||||||
import { createAndOpenBook, saveBook } from "./library";
|
import { createAndOpenBook, saveBook } from "./library";
|
||||||
import { runExport } from "./export/run";
|
import { runExport } from "./export/run";
|
||||||
@@ -37,9 +39,16 @@ function App() {
|
|||||||
const win = getCurrentWindow();
|
const win = getCurrentWindow();
|
||||||
const unlisten = win.onCloseRequested(async (event) => {
|
const unlisten = win.onCloseRequested(async (event) => {
|
||||||
const { book, dirty } = useBook.getState();
|
const { book, dirty } = useBook.getState();
|
||||||
if (!book || !dirty) return;
|
const { connected } = useBackup.getState();
|
||||||
|
if ((!book || !dirty) && !connected) return;
|
||||||
event.preventDefault();
|
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();
|
win.destroy();
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
@@ -47,7 +56,34 @@ function App() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return book ? <EditorView /> : <Library onOpen={openBook} />;
|
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 ? <EditorView /> : <Library onOpen={openBook} />}
|
||||||
|
{isDesktop && <BackupSettings />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
@@ -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<BackupStatus> {
|
||||||
|
if (!isDesktop) return OFFLINE;
|
||||||
|
return invoke<BackupStatus>("gdrive_status");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function gdriveConnect(): Promise<string> {
|
||||||
|
return invoke<string>("gdrive_connect");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function gdriveDisconnect(): Promise<BackupStatus> {
|
||||||
|
return invoke<BackupStatus>("gdrive_disconnect");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function gdriveBackup(): Promise<BackupOutcome> {
|
||||||
|
return invoke<BackupOutcome>("gdrive_backup");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function gdriveRestore(): Promise<RestoreResult> {
|
||||||
|
return invoke<RestoreResult>("gdrive_restore");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function gdriveListBackups(): Promise<RemoteBackup[]> {
|
||||||
|
if (!isDesktop) return [];
|
||||||
|
return invoke<RemoteBackup[]>("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()}`;
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<button className="icon-btn backup-btn" data-state={state} onClick={onClick} title={title}>
|
||||||
|
{busy ? (
|
||||||
|
<Icon size={18}>
|
||||||
|
<path d="M21 12a9 9 0 1 1-2.6-6.4M21 4v5h-5" />
|
||||||
|
</Icon>
|
||||||
|
) : (
|
||||||
|
<Icon size={18}>
|
||||||
|
<path d="M7 18a4 4 0 0 1 0-8 5 5 0 0 1 9.6-1.3A3.5 3.5 0 0 1 18 18H7z" />
|
||||||
|
{connected ? <path d="M12 16.5v-5M9.7 12.8l2.3-2.3 2.3 2.3" /> : null}
|
||||||
|
</Icon>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<number | null>(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 (
|
||||||
|
<>
|
||||||
|
<div className="overlay" onClick={close}>
|
||||||
|
<div className="panel" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="panel-head">
|
||||||
|
<h2>Backup & Sync</h2>
|
||||||
|
<button className="icon-btn" onClick={close} title="Close">
|
||||||
|
<Icon d="M6 6l12 12M18 6L6 18" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="panel-body">
|
||||||
|
{phase === "connecting" ? (
|
||||||
|
<div className="backup-connecting">
|
||||||
|
<div className="backup-waiting">
|
||||||
|
<Icon size={16}>
|
||||||
|
<path d="M21 12a9 9 0 1 1-2.6-6.4M21 4v5h-5" />
|
||||||
|
</Icon>
|
||||||
|
<span>Waiting for Google authorization…</span>
|
||||||
|
</div>
|
||||||
|
<p>Finish in the browser tab that opened. If it didn't open, reopen the link or copy it into a browser.</p>
|
||||||
|
<div className="backup-link-row">
|
||||||
|
<button className="backup-text-btn" onClick={cancelConnect}>
|
||||||
|
Stop linking
|
||||||
|
</button>
|
||||||
|
<div className="backup-links">
|
||||||
|
<button className="backup-text-btn" onClick={openAuthUrl}>
|
||||||
|
Open link again
|
||||||
|
</button>
|
||||||
|
<span className="backup-link-sep">·</span>
|
||||||
|
<button className="backup-text-btn" onClick={copyAuthUrl}>
|
||||||
|
Copy link
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : !connected ? (
|
||||||
|
<div className="backup-intro">
|
||||||
|
<p>
|
||||||
|
Connect Google Drive to keep a private backup of every book. margin only ever sees the files it
|
||||||
|
creates in a <strong>margin</strong> folder, never the rest of your Drive.
|
||||||
|
</p>
|
||||||
|
<button className="btn-primary" disabled={working} onClick={connect}>
|
||||||
|
{working ? "Connecting…" : "Connect Google Drive"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="backup-account">
|
||||||
|
<div className="backup-account-info">
|
||||||
|
<span className="field-label">Connected account</span>
|
||||||
|
<span className="backup-email">{email || "Google Drive"}</span>
|
||||||
|
</div>
|
||||||
|
<button className="btn-ghost" disabled={working} onClick={disconnect}>
|
||||||
|
Disconnect
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="backup-status-row">
|
||||||
|
<span className={`backup-dot ${pending ? "is-pending" : "is-synced"}`} />
|
||||||
|
<span>{pending ? "Changes not backed up" : formatBackupTime(lastBackup)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="backup-actions">
|
||||||
|
<button className="btn-primary" disabled={working} onClick={() => backup()}>
|
||||||
|
{working ? "Working…" : "Back up now"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-ghost"
|
||||||
|
disabled={working || remoteCount === 0}
|
||||||
|
onClick={() => setConfirmRestore(true)}
|
||||||
|
>
|
||||||
|
{remoteCount === null ? "Restore from Drive" : `Restore ${remoteCount} book${remoteCount === 1 ? "" : "s"}`}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="backup-note">
|
||||||
|
Backs up automatically when you close margin, and every 15 minutes while there are changes.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{confirmRestore && (
|
||||||
|
<ConfirmDialog
|
||||||
|
title="Restore from Google Drive"
|
||||||
|
message={
|
||||||
|
<>
|
||||||
|
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)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { Dock } from "./Dock";
|
|||||||
import { ResizeHandle } from "./ResizeHandle";
|
import { ResizeHandle } from "./ResizeHandle";
|
||||||
import { Icon } from "./Icon";
|
import { Icon } from "./Icon";
|
||||||
import { Settings } from "./Settings";
|
import { Settings } from "./Settings";
|
||||||
|
import { BackupButton } from "./BackupButton";
|
||||||
import { CoverView } from "./CoverView";
|
import { CoverView } from "./CoverView";
|
||||||
import { FindBar } from "./FindBar";
|
import { FindBar } from "./FindBar";
|
||||||
import { ProofPopover } from "./ProofPopover";
|
import { ProofPopover } from "./ProofPopover";
|
||||||
@@ -12,6 +13,7 @@ import { Editor } from "../editor/Editor";
|
|||||||
import { FloatingToolbar } from "../editor/FloatingToolbar";
|
import { FloatingToolbar } from "../editor/FloatingToolbar";
|
||||||
import type { ProofCoords, ProofIssue, ProofingStorage } from "../editor/proofing";
|
import type { ProofCoords, ProofIssue, ProofingStorage } from "../editor/proofing";
|
||||||
import { COVER_ID, useBook } from "../store/useBook";
|
import { COVER_ID, useBook } from "../store/useBook";
|
||||||
|
import { useBackup } from "../store/useBackup";
|
||||||
import { useProofing } from "../store/useProofing";
|
import { useProofing } from "../store/useProofing";
|
||||||
import { useTheme } from "../store/useTheme";
|
import { useTheme } from "../store/useTheme";
|
||||||
import { useWidth } from "../store/useWidth";
|
import { useWidth } from "../store/useWidth";
|
||||||
@@ -79,6 +81,7 @@ export function EditorView() {
|
|||||||
saveBook(current)
|
saveBook(current)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
if (useBook.getState().book === current) markSaved();
|
if (useBook.getState().book === current) markSaved();
|
||||||
|
useBackup.getState().refresh();
|
||||||
})
|
})
|
||||||
.catch((e) => setNotice(`Save failed: ${e}`));
|
.catch((e) => setNotice(`Save failed: ${e}`));
|
||||||
}, [markSaved, setNotice]);
|
}, [markSaved, setNotice]);
|
||||||
@@ -163,6 +166,7 @@ export function EditorView() {
|
|||||||
{dirty && <span className="dirty-dot" />}
|
{dirty && <span className="dirty-dot" />}
|
||||||
</button>
|
</button>
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
|
{isDesktop && <BackupButton />}
|
||||||
<button className="icon-btn" data-on={findOpen} onClick={() => (findOpen ? setFindOpen(false) : openFind(false))} title="Find (⌘F)">
|
<button className="icon-btn" data-on={findOpen} onClick={() => (findOpen ? setFindOpen(false) : openFind(false))} title="Find (⌘F)">
|
||||||
<Icon d="M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4" />
|
<Icon d="M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -7,13 +7,20 @@ import { isDesktop } from "../ipc";
|
|||||||
import { ConfirmDialog } from "./ConfirmDialog";
|
import { ConfirmDialog } from "./ConfirmDialog";
|
||||||
import { Icon } from "./Icon";
|
import { Icon } from "./Icon";
|
||||||
import { RowMenu } from "./RowMenu";
|
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 }) {
|
export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
|
||||||
const [books, setBooks] = useState<BookSummary[]>([]);
|
const [books, setBooks] = useState<BookSummary[]>([]);
|
||||||
const [loaded, setLoaded] = useState(false);
|
const [loaded, setLoaded] = useState(false);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [notice, setNotice] = useState<string | null>(null);
|
|
||||||
const [pendingDelete, setPendingDelete] = useState<BookSummary | null>(null);
|
const [pendingDelete, setPendingDelete] = useState<BookSummary | null>(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 = () =>
|
const refresh = () =>
|
||||||
listBooks()
|
listBooks()
|
||||||
@@ -23,6 +30,14 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refresh();
|
refresh();
|
||||||
}, []);
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
if (restoreNonce) refresh();
|
||||||
|
}, [restoreNonce]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!notice) return;
|
||||||
|
const timer = setTimeout(() => setNotice(null), 4000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [notice]);
|
||||||
|
|
||||||
const handleExample = async () => {
|
const handleExample = async () => {
|
||||||
const copy = exampleBook();
|
const copy = exampleBook();
|
||||||
@@ -64,7 +79,9 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="library">
|
<div className="library">
|
||||||
<header className="library-head" data-tauri-drag-region />
|
<header className="library-head" data-tauri-drag-region>
|
||||||
|
{isDesktop && <BackupButton />}
|
||||||
|
</header>
|
||||||
<div className="shelf">
|
<div className="shelf">
|
||||||
<button className="card card-action" onClick={() => createAndOpenBook(onOpen, setNotice)}>
|
<button className="card card-action" onClick={() => createAndOpenBook(onOpen, setNotice)}>
|
||||||
<Icon d="M12 5v14M5 12h14" size={20} />
|
<Icon d="M12 5v14M5 12h14" size={20} />
|
||||||
@@ -100,6 +117,14 @@ export function Library({ onOpen }: { onOpen: (book: Book) => void }) {
|
|||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{loaded && books.length === 0 && isDesktop && (
|
||||||
|
<div className="library-restore">
|
||||||
|
<button className="restore-link" onClick={restoreFromDrive} disabled={restoreWorking}>
|
||||||
|
<Icon d="M21 12a9 9 0 1 1-2.6-6.4M21 4v5h-5" size={15} />
|
||||||
|
{restoreWorking ? "Restoring…" : "Restore from Google Drive"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{notice && (
|
{notice && (
|
||||||
<div className="toast" onClick={() => setNotice(null)}>
|
<div className="toast" onClick={() => setNotice(null)}>
|
||||||
{notice}
|
{notice}
|
||||||
|
|||||||
@@ -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<void>;
|
||||||
|
connect: () => Promise<boolean>;
|
||||||
|
cancelConnect: () => void;
|
||||||
|
handleAuthEvent: (ok: boolean, error: string | null) => Promise<void>;
|
||||||
|
openAuthUrl: () => void;
|
||||||
|
copyAuthUrl: () => Promise<void>;
|
||||||
|
disconnect: () => Promise<void>;
|
||||||
|
backup: (silent?: boolean) => Promise<void>;
|
||||||
|
restore: () => Promise<void>;
|
||||||
|
restoreFromDrive: () => Promise<void>;
|
||||||
|
openSettings: () => void;
|
||||||
|
closeSettings: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function notify(message: string) {
|
||||||
|
useBook.getState().setNotice(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useBackup = create<BackupState>((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<boolean>((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 }),
|
||||||
|
}));
|
||||||
@@ -1257,6 +1257,10 @@ body.resizing {
|
|||||||
|
|
||||||
.library-head {
|
.library-head {
|
||||||
height: 44px;
|
height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 0 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shelf {
|
.shelf {
|
||||||
@@ -2283,3 +2287,170 @@ body.resizing {
|
|||||||
border: none;
|
border: none;
|
||||||
border-radius: 0;
|
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;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user