One-click backups for AT Protocol

feat: auto backups

Turtlepaw 6014b602 00f1a51a

+9
bun.lock
··· 22 22 "@tauri-apps/plugin-deep-link": "~2", 23 23 "@tauri-apps/plugin-fs": "~2", 24 24 "@tauri-apps/plugin-opener": "^2", 25 + "@tauri-apps/plugin-process": "~2", 26 + "@tauri-apps/plugin-shell": "~2", 25 27 "@tauri-apps/plugin-store": "~2", 26 28 "@tauri-apps/plugin-updater": "~2", 29 + "@tauri-apps/plugin-websocket": "~2", 27 30 "antd": "^5.26.4", 28 31 "class-variance-authority": "^0.7.1", 29 32 "clsx": "^2.1.1", ··· 639 642 640 643 "@tauri-apps/plugin-opener": ["@tauri-apps/plugin-opener@2.4.0", "", { "dependencies": { "@tauri-apps/api": "^2.6.0" } }, "sha512-43VyN8JJtvKWJY72WI/KNZszTpDpzHULFxQs0CJBIYUdCRowQ6Q1feWTDb979N7nldqSuDOaBupZ6wz2nvuWwQ=="], 641 644 645 + "@tauri-apps/plugin-process": ["@tauri-apps/plugin-process@2.3.0", "", { "dependencies": { "@tauri-apps/api": "^2.6.0" } }, "sha512-0DNj6u+9csODiV4seSxxRbnLpeGYdojlcctCuLOCgpH9X3+ckVZIEj6H7tRQ7zqWr7kSTEWnrxtAdBb0FbtrmQ=="], 646 + 647 + "@tauri-apps/plugin-shell": ["@tauri-apps/plugin-shell@2.3.0", "", { "dependencies": { "@tauri-apps/api": "^2.6.0" } }, "sha512-6GIRxO2z64uxPX4CCTuhQzefvCC0ew7HjdBhMALiGw74vFBDY95VWueAHOHgNOMV4UOUAFupyidN9YulTe5xlA=="], 648 + 642 649 "@tauri-apps/plugin-store": ["@tauri-apps/plugin-store@2.3.0", "", { "dependencies": { "@tauri-apps/api": "^2.6.0" } }, "sha512-mre8er0nXPhyEWQzWCpUd+UnEoBQYcoA5JYlwpwOV9wcxKqlXTGfminpKsE37ic8NUb2BIZqf0QQ9/U3ib2+/A=="], 643 650 644 651 "@tauri-apps/plugin-updater": ["@tauri-apps/plugin-updater@2.9.0", "", { "dependencies": { "@tauri-apps/api": "^2.6.0" } }, "sha512-j++sgY8XpeDvzImTrzWA08OqqGqgkNyxczLD7FjNJJx/uXxMZFz5nDcfkyoI/rCjYuj2101Tci/r/HFmOmoxCg=="], 652 + 653 + "@tauri-apps/plugin-websocket": ["@tauri-apps/plugin-websocket@2.4.0", "", { "dependencies": { "@tauri-apps/api": "^2.6.0" } }, "sha512-0TpWqPBb5G3I1qPE/rPnPUmMyAQTWsw2B75Ab6SQ6X4EcypPGyqTyvt3OXymG6PzJeZ+D9wE614Fnf0wXnj1mg=="], 645 654 646 655 "@theguild/remark-mermaid": ["@theguild/remark-mermaid@0.2.0", "", { "dependencies": { "mermaid": "^11.0.0", "unist-util-visit": "^5.0.0" }, "peerDependencies": { "react": "^18.2.0" } }, "sha512-o8n57TJy0OI4PCrNw8z6S+vpHtrwoQZzTA5Y3fL0U1NDRIoMg/78duWgEBFsCZcWM1G6zjE91yg1aKCsDwgE2Q=="], 647 656
+3
package.json
··· 28 28 "@tauri-apps/plugin-deep-link": "~2", 29 29 "@tauri-apps/plugin-fs": "~2", 30 30 "@tauri-apps/plugin-opener": "^2", 31 + "@tauri-apps/plugin-process": "~2", 32 + "@tauri-apps/plugin-shell": "~2", 31 33 "@tauri-apps/plugin-store": "~2", 32 34 "@tauri-apps/plugin-updater": "~2", 35 + "@tauri-apps/plugin-websocket": "~2", 33 36 "antd": "^5.26.4", 34 37 "class-variance-authority": "^0.7.1", 35 38 "clsx": "^2.1.1",
+173 -2
src-tauri/Cargo.lock
··· 237 237 name = "atproto-backup" 238 238 version = "0.1.0" 239 239 dependencies = [ 240 + "chrono", 240 241 "serde", 241 242 "serde_json", 242 243 "tauri", ··· 245 246 "tauri-plugin-deep-link", 246 247 "tauri-plugin-fs", 247 248 "tauri-plugin-opener", 249 + "tauri-plugin-process", 250 + "tauri-plugin-shell", 248 251 "tauri-plugin-single-instance", 249 252 "tauri-plugin-store", 250 253 "tauri-plugin-updater", 254 + "tauri-plugin-websocket", 255 + "tokio", 251 256 ] 252 257 253 258 [[package]] ··· 520 525 dependencies = [ 521 526 "android-tzdata", 522 527 "iana-time-zone", 528 + "js-sys", 523 529 "num-traits", 524 530 "serde", 531 + "wasm-bindgen", 525 532 "windows-link", 526 533 ] 527 534 ··· 742 749 ] 743 750 744 751 [[package]] 752 + name = "data-encoding" 753 + version = "2.9.0" 754 + source = "registry+https://github.com/rust-lang/crates.io-index" 755 + checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" 756 + 757 + [[package]] 745 758 name = "deranged" 746 759 version = "0.4.0" 747 760 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 940 953 version = "1.2.2" 941 954 source = "registry+https://github.com/rust-lang/crates.io-index" 942 955 checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" 956 + 957 + [[package]] 958 + name = "encoding_rs" 959 + version = "0.8.35" 960 + source = "registry+https://github.com/rust-lang/crates.io-index" 961 + checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" 962 + dependencies = [ 963 + "cfg-if", 964 + ] 943 965 944 966 [[package]] 945 967 name = "endi" ··· 1636 1658 "tokio", 1637 1659 "tokio-rustls", 1638 1660 "tower-service", 1639 - "webpki-roots", 1661 + "webpki-roots 1.0.1", 1640 1662 ] 1641 1663 1642 1664 [[package]] ··· 2562 2584 ] 2563 2585 2564 2586 [[package]] 2587 + name = "os_pipe" 2588 + version = "1.2.2" 2589 + source = "registry+https://github.com/rust-lang/crates.io-index" 2590 + checksum = "db335f4760b14ead6290116f2427bf33a14d4f0617d49f78a246de10c1831224" 2591 + dependencies = [ 2592 + "libc", 2593 + "windows-sys 0.59.0", 2594 + ] 2595 + 2596 + [[package]] 2565 2597 name = "osakit" 2566 2598 version = "0.3.1" 2567 2599 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3255 3287 "wasm-bindgen-futures", 3256 3288 "wasm-streams", 3257 3289 "web-sys", 3258 - "webpki-roots", 3290 + "webpki-roots 1.0.1", 3259 3291 ] 3260 3292 3261 3293 [[package]] ··· 3616 3648 ] 3617 3649 3618 3650 [[package]] 3651 + name = "sha1" 3652 + version = "0.10.6" 3653 + source = "registry+https://github.com/rust-lang/crates.io-index" 3654 + checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" 3655 + dependencies = [ 3656 + "cfg-if", 3657 + "cpufeatures", 3658 + "digest", 3659 + ] 3660 + 3661 + [[package]] 3619 3662 name = "sha2" 3620 3663 version = "0.10.9" 3621 3664 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3627 3670 ] 3628 3671 3629 3672 [[package]] 3673 + name = "shared_child" 3674 + version = "1.1.1" 3675 + source = "registry+https://github.com/rust-lang/crates.io-index" 3676 + checksum = "1e362d9935bc50f019969e2f9ecd66786612daae13e8f277be7bfb66e8bed3f7" 3677 + dependencies = [ 3678 + "libc", 3679 + "sigchld", 3680 + "windows-sys 0.60.2", 3681 + ] 3682 + 3683 + [[package]] 3630 3684 name = "shlex" 3631 3685 version = "1.3.0" 3632 3686 source = "registry+https://github.com/rust-lang/crates.io-index" 3633 3687 checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 3688 + 3689 + [[package]] 3690 + name = "sigchld" 3691 + version = "0.2.4" 3692 + source = "registry+https://github.com/rust-lang/crates.io-index" 3693 + checksum = "47106eded3c154e70176fc83df9737335c94ce22f821c32d17ed1db1f83badb1" 3694 + dependencies = [ 3695 + "libc", 3696 + "os_pipe", 3697 + "signal-hook", 3698 + ] 3699 + 3700 + [[package]] 3701 + name = "signal-hook" 3702 + version = "0.3.18" 3703 + source = "registry+https://github.com/rust-lang/crates.io-index" 3704 + checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" 3705 + dependencies = [ 3706 + "libc", 3707 + "signal-hook-registry", 3708 + ] 3634 3709 3635 3710 [[package]] 3636 3711 name = "signal-hook-registry" ··· 4120 4195 ] 4121 4196 4122 4197 [[package]] 4198 + name = "tauri-plugin-process" 4199 + version = "2.3.0" 4200 + source = "registry+https://github.com/rust-lang/crates.io-index" 4201 + checksum = "7461c622a5ea00eb9cd9f7a08dbd3bf79484499fd5c21aa2964677f64ca651ab" 4202 + dependencies = [ 4203 + "tauri", 4204 + "tauri-plugin", 4205 + ] 4206 + 4207 + [[package]] 4208 + name = "tauri-plugin-shell" 4209 + version = "2.3.0" 4210 + source = "registry+https://github.com/rust-lang/crates.io-index" 4211 + checksum = "2b9ffadec5c3523f11e8273465cacb3d86ea7652a28e6e2a2e9b5c182f791d25" 4212 + dependencies = [ 4213 + "encoding_rs", 4214 + "log", 4215 + "open", 4216 + "os_pipe", 4217 + "regex", 4218 + "schemars 0.8.22", 4219 + "serde", 4220 + "serde_json", 4221 + "shared_child", 4222 + "tauri", 4223 + "tauri-plugin", 4224 + "thiserror 2.0.12", 4225 + "tokio", 4226 + ] 4227 + 4228 + [[package]] 4123 4229 name = "tauri-plugin-single-instance" 4124 4230 version = "2.3.0" 4125 4231 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4181 4287 "url", 4182 4288 "windows-sys 0.60.2", 4183 4289 "zip", 4290 + ] 4291 + 4292 + [[package]] 4293 + name = "tauri-plugin-websocket" 4294 + version = "2.4.0" 4295 + source = "registry+https://github.com/rust-lang/crates.io-index" 4296 + checksum = "402eaba4a045e93f579637c364343d818de002c4b1abd9244f0fd6fcb9ba43dd" 4297 + dependencies = [ 4298 + "futures-util", 4299 + "http", 4300 + "log", 4301 + "rand 0.8.5", 4302 + "serde", 4303 + "serde_json", 4304 + "tauri", 4305 + "tauri-plugin", 4306 + "thiserror 2.0.12", 4307 + "tokio", 4308 + "tokio-tungstenite", 4184 4309 ] 4185 4310 4186 4311 [[package]] ··· 4421 4546 "io-uring", 4422 4547 "libc", 4423 4548 "mio", 4549 + "parking_lot", 4424 4550 "pin-project-lite", 4551 + "signal-hook-registry", 4425 4552 "slab", 4426 4553 "socket2", 4427 4554 "tokio-macros", ··· 4450 4577 ] 4451 4578 4452 4579 [[package]] 4580 + name = "tokio-tungstenite" 4581 + version = "0.27.0" 4582 + source = "registry+https://github.com/rust-lang/crates.io-index" 4583 + checksum = "489a59b6730eda1b0171fcfda8b121f4bee2b35cba8645ca35c5f7ba3eb736c1" 4584 + dependencies = [ 4585 + "futures-util", 4586 + "log", 4587 + "rustls", 4588 + "rustls-pki-types", 4589 + "tokio", 4590 + "tokio-rustls", 4591 + "tungstenite", 4592 + "webpki-roots 0.26.11", 4593 + ] 4594 + 4595 + [[package]] 4453 4596 name = "tokio-util" 4454 4597 version = "0.7.15" 4455 4598 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4667 4810 version = "0.2.5" 4668 4811 source = "registry+https://github.com/rust-lang/crates.io-index" 4669 4812 checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" 4813 + 4814 + [[package]] 4815 + name = "tungstenite" 4816 + version = "0.27.0" 4817 + source = "registry+https://github.com/rust-lang/crates.io-index" 4818 + checksum = "eadc29d668c91fcc564941132e17b28a7ceb2f3ebf0b9dae3e03fd7a6748eb0d" 4819 + dependencies = [ 4820 + "bytes", 4821 + "data-encoding", 4822 + "http", 4823 + "httparse", 4824 + "log", 4825 + "rand 0.9.1", 4826 + "rustls", 4827 + "rustls-pki-types", 4828 + "sha1", 4829 + "thiserror 2.0.12", 4830 + "utf-8", 4831 + ] 4670 4832 4671 4833 [[package]] 4672 4834 name = "typeid" ··· 5016 5178 "pkg-config", 5017 5179 "soup3-sys", 5018 5180 "system-deps", 5181 + ] 5182 + 5183 + [[package]] 5184 + name = "webpki-roots" 5185 + version = "0.26.11" 5186 + source = "registry+https://github.com/rust-lang/crates.io-index" 5187 + checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" 5188 + dependencies = [ 5189 + "webpki-roots 1.0.1", 5019 5190 ] 5020 5191 5021 5192 [[package]]
+7 -1
src-tauri/Cargo.toml
··· 16 16 17 17 [build-dependencies] 18 18 tauri-build = { version = "2", features = [] } 19 + tauri = { version = "2.0.0", features = [ "tray-icon" ] } 19 20 20 21 [dependencies] 21 - tauri = { version = "2", features = [] } 22 + tauri = { version = "2", features = ["tray-icon"] } 22 23 tauri-plugin-opener = "2" 23 24 serde = { version = "1", features = ["derive"] } 24 25 serde_json = "1" 25 26 tauri-plugin-deep-link = "2" 26 27 tauri-plugin-store = "2" 27 28 tauri-plugin-fs = "2" 29 + tauri-plugin-process = "2" 30 + tauri-plugin-shell = "2" 31 + tokio = { version = "1", features = ["full"] } 32 + chrono = { version = "0.4", features = ["serde"] } 33 + tauri-plugin-websocket = "2" 28 34 29 35 [target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\"))".dependencies] 30 36 tauri-plugin-single-instance = { version = "2.0.0", features = ["deep-link"] }
+2 -1
src-tauri/capabilities/default.json
··· 50 50 "fs:allow-document-write-recursive", 51 51 "autostart:allow-enable", 52 52 "autostart:allow-disable", 53 - "autostart:allow-is-enabled" 53 + "autostart:allow-is-enabled", 54 + "updater:default" 54 55 ] 55 56 }
+1
src-tauri/capabilities/desktop.json
··· 10 10 ], 11 11 "permissions": [ 12 12 "autostart:default", 13 + "updater:default", 13 14 "updater:default" 14 15 ] 15 16 }
+126
src-tauri/src/background.rs
··· 1 + use serde::{Deserialize, Serialize}; 2 + use serde_json::json; 3 + use std::sync::Arc; 4 + use std::time::{Duration, Instant}; 5 + use tauri::{AppHandle, Emitter, Manager, State}; 6 + use tauri_plugin_store::StoreExt; 7 + use tokio::sync::Mutex; 8 + use tokio::time::sleep; 9 + 10 + #[derive(Debug, Clone, Serialize, Deserialize)] 11 + pub struct BackupSettings { 12 + pub frequency: String, // "daily" or "weekly" 13 + pub last_backup_date: Option<String>, 14 + } 15 + 16 + pub struct BackgroundScheduler { 17 + app: AppHandle, 18 + is_running: Arc<Mutex<bool>>, 19 + } 20 + 21 + impl BackgroundScheduler { 22 + pub fn new(app: AppHandle) -> Self { 23 + Self { 24 + app, 25 + is_running: Arc::new(Mutex::new(false)), 26 + } 27 + } 28 + 29 + pub async fn start(&self) { 30 + let mut is_running = self.is_running.lock().await; 31 + if *is_running { 32 + return; 33 + } 34 + *is_running = true; 35 + drop(is_running); 36 + 37 + let is_running = self.is_running.clone(); 38 + let app = self.app.clone(); // <-- assuming it's Arc<App> 39 + 40 + tokio::spawn(async move { 41 + let mut last_check = Instant::now(); 42 + 43 + while *is_running.lock().await { 44 + if last_check.elapsed() >= Duration::from_secs(30 * 60) { 45 + last_check = Instant::now(); 46 + 47 + if let Err(e) = Self::check_and_perform_backup(app.clone()).await { 48 + eprintln!("Background backup check failed: {}", e); 49 + } 50 + } 51 + 52 + sleep(Duration::from_secs(5 * 60)).await; 53 + } 54 + }); 55 + } 56 + 57 + pub async fn stop(&self) { 58 + let mut is_running = self.is_running.lock().await; 59 + *is_running = false; 60 + } 61 + 62 + async fn check_and_perform_backup(app: AppHandle) -> Result<(), Box<dyn std::error::Error>> { 63 + // Get settings from store 64 + let store = app.store("settings.json")?; 65 + let raw_settings: Option<serde_json::Value> = store.get("settings"); 66 + 67 + let value = raw_settings.unwrap_or(json!({ 68 + "frequency": "daily", 69 + "last_backup_date": null 70 + })); 71 + 72 + let settings: BackupSettings = serde_json::from_value(value)?; 73 + 74 + // Check if backup is needed 75 + if Self::should_perform_backup(&settings).await? { 76 + println!("Background: Backup due, starting backup..."); 77 + 78 + // Emit event to frontend to perform backup 79 + app.emit("perform-backup", ())?; 80 + 81 + // Update last backup date 82 + let mut updated_settings = settings; 83 + updated_settings.last_backup_date = Some(chrono::Utc::now().to_rfc3339()); 84 + store.set("settings", json!(updated_settings)); 85 + store.save()?; 86 + 87 + println!("Background: Backup completed"); 88 + } 89 + 90 + Ok(()) 91 + } 92 + 93 + async fn should_perform_backup( 94 + settings: &BackupSettings, 95 + ) -> Result<bool, Box<dyn std::error::Error>> { 96 + if settings.last_backup_date.is_none() { 97 + return Ok(true); 98 + } 99 + 100 + let last_backup = 101 + chrono::DateTime::parse_from_rfc3339(&settings.last_backup_date.as_ref().unwrap())?; 102 + let now = chrono::Utc::now(); 103 + let time_diff = now.signed_duration_since(last_backup); 104 + 105 + let required_interval = match settings.frequency.as_str() { 106 + "daily" => chrono::Duration::days(1), 107 + "weekly" => chrono::Duration::weeks(1), 108 + _ => chrono::Duration::days(1), 109 + }; 110 + 111 + Ok(time_diff >= required_interval) 112 + } 113 + } 114 + 115 + #[tauri::command] 116 + pub async fn start_background_scheduler(app: AppHandle) { 117 + let scheduler = BackgroundScheduler::new(app); 118 + scheduler.start().await; 119 + } 120 + 121 + #[tauri::command] 122 + pub async fn stop_background_scheduler() -> Result<(), String> { 123 + // This would need to be implemented with a global scheduler reference 124 + // For now, we'll handle this differently 125 + Ok(()) 126 + }
+37 -1
src-tauri/src/lib.rs
··· 1 + use tauri::{Emitter, Manager}; 1 2 // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ 2 3 use tauri_plugin_deep_link::DeepLinkExt; 3 4 5 + mod background; 6 + mod tray; 7 + use background::{start_background_scheduler, stop_background_scheduler}; 8 + use tray::{create_system_tray}; 9 + 4 10 #[tauri::command] 5 11 fn greet(name: &str) -> String { 6 12 format!("Hello, {}! You've been greeted from Rust!", name) ··· 9 15 #[cfg_attr(mobile, tauri::mobile_entry_point)] 10 16 pub fn run() { 11 17 let mut builder = tauri::Builder::default() 18 + .plugin(tauri_plugin_websocket::init()) 19 + .plugin(tauri_plugin_shell::init()) 20 + .plugin(tauri_plugin_process::init()) 12 21 .plugin(tauri_plugin_updater::Builder::new().build()) 13 22 .plugin(tauri_plugin_autostart::init( 14 23 tauri_plugin_autostart::MacosLauncher::LaunchAgent, ··· 18 27 .plugin(tauri_plugin_store::Builder::new().build()) 19 28 .plugin(tauri_plugin_deep_link::init()) 20 29 .plugin(tauri_plugin_opener::init()) 21 - .invoke_handler(tauri::generate_handler![greet]) 30 + .invoke_handler(tauri::generate_handler![ 31 + greet, 32 + start_background_scheduler, 33 + stop_background_scheduler 34 + ]) 35 + .on_menu_event(|app, event| match event.id.as_ref() { 36 + "quit" => { 37 + std::process::exit(0); 38 + } 39 + "show" => { 40 + let window = app.get_webview_window("main").unwrap(); 41 + window.show().unwrap(); 42 + window.set_focus().unwrap(); 43 + } 44 + "hide" => { 45 + let window = app.get_webview_window("main").unwrap(); 46 + window.hide().unwrap(); 47 + } 48 + "backup_now" => { 49 + // Emit event to trigger backup 50 + app.emit("perform-backup", ()).unwrap(); 51 + } 52 + _ => { 53 + println!("menu item {:?} not handled", event.id); 54 + } 55 + }) 22 56 .setup(|app| { 23 57 #[cfg(any(windows, target_os = "linux"))] 24 58 { 25 59 app.deep_link().register_all()?; 26 60 } 61 + let tray = create_system_tray(app); 62 + 27 63 Ok(()) 28 64 }); 29 65
+24
src-tauri/src/tray.rs
··· 1 + use tauri::{ 2 + menu::{Menu, MenuItem}, 3 + tray::{TrayIcon, TrayIconBuilder}, 4 + }; 5 + 6 + pub fn create_system_tray(app: &tauri::App) -> Result<TrayIcon, tauri::Error> { 7 + let quit_i = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?; 8 + let show_i = MenuItem::with_id(app, "show", "Show", true, None::<&str>)?; 9 + let hide_i = MenuItem::with_id(app, "hide", "Hide", true, None::<&str>)?; 10 + let backup_now_i = MenuItem::with_id(app, "backup_now", "Backup Now", true, None::<&str>)?; 11 + 12 + let menu = Menu::with_items(app, &[ 13 + &quit_i, 14 + &show_i, 15 + &hide_i, 16 + &backup_now_i, 17 + ])?; 18 + 19 + TrayIconBuilder::new() 20 + .menu(&menu) 21 + .menu_on_left_click(true) 22 + .icon(app.default_window_icon().unwrap().clone()) 23 + .build(app) 24 + }
+12 -1
src-tauri/tauri.conf.json
··· 21 21 ], 22 22 "security": { 23 23 "csp": null 24 + }, 25 + "trayIcon": { 26 + "iconPath": "icons/icon.png", 27 + "iconAsTemplate": true 24 28 } 25 29 }, 26 30 "bundle": { ··· 32 36 "icons/128x128@2x.png", 33 37 "icons/icon.icns", 34 38 "icons/icon.ico" 35 - ] 39 + ], 40 + "createUpdaterArtifacts": true 36 41 }, 37 42 "plugins": { 38 43 "deep-link": { ··· 41 46 "atprotobackups" 42 47 ] 43 48 } 49 + }, 50 + "updater": { 51 + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDU2Njg2QTE3N0I2NTI0QUEKUldTcUpHVjdGMnBvVnFNWWE4TkdDQlp2eFo2RUZVVmp4b1IxbGdvS3JHOWd1dkRvQm1MdWsvZ1gK", 52 + "endpoints": [ 53 + "https://github.com/Turtlepaw/atproto-backup/releases/latest/download/latest.json" 54 + ] 44 55 } 45 56 } 46 57 }
+72 -4
src/App.tsx
··· 8 8 import { initializeLocalStorage } from "./localstorage_ployfill"; 9 9 import { Home } from "./routes/Home"; 10 10 import { ThemeProvider } from "./theme-provider"; 11 - import { Toaster } from "sonner"; 11 + import { toast, Toaster } from "sonner"; 12 12 import { ScrollArea } from "./components/ui/scroll-area"; 13 - import { BackupManager } from "./lib/backup"; 13 + import { BackupAgent } from "./lib/backup"; 14 14 import { settingsManager } from "./lib/settings"; 15 + import { check } from "@tauri-apps/plugin-updater"; 16 + import { relaunch } from "@tauri-apps/plugin-process"; 17 + import { 18 + BackgroundBackupService, 19 + handleBackgroundBackup, 20 + } from "./lib/backgroundBackup"; 15 21 16 22 function AppContent() { 17 23 const { isLoading, isAuthenticated, profile, client, login, logout, agent } = ··· 34 40 initStorage(); 35 41 }, []); 36 42 37 - // Auto-backup functionality 43 + // Background backup service initialization 44 + useEffect(() => { 45 + if (!isAuthenticated || !agent) return; 46 + 47 + const backgroundService = BackgroundBackupService.getInstance(); 48 + backgroundService.initialize(); 49 + 50 + // Listen for background backup requests 51 + const handleBackgroundBackupRequest = () => { 52 + handleBackgroundBackup(agent); 53 + }; 54 + 55 + window.addEventListener( 56 + "background-backup-requested", 57 + handleBackgroundBackupRequest 58 + ); 59 + 60 + return () => { 61 + window.removeEventListener( 62 + "background-backup-requested", 63 + handleBackgroundBackupRequest 64 + ); 65 + backgroundService.stop(); 66 + }; 67 + }, [isAuthenticated, agent]); 68 + 69 + // Auto-backup functionality (for when app is open) 38 70 useEffect(() => { 39 71 if (!isAuthenticated || !agent) return; 40 72 ··· 76 108 const performBackup = async () => { 77 109 try { 78 110 console.log("Automatic backup due, starting backup..."); 79 - const manager = new BackupManager(agent); 111 + const manager = new BackupAgent(agent); 80 112 await manager.startBackup(); 81 113 82 114 // Update the last backup date ··· 100 132 } 101 133 }; 102 134 }, [isAuthenticated, agent]); 135 + 136 + useEffect(() => { 137 + (async () => { 138 + const update = await check(); 139 + if (update) { 140 + console.log( 141 + `found update ${update.version} from ${update.date} with notes ${update.body}` 142 + ); 143 + toast("Downloading new update..."); 144 + let downloaded = 0; 145 + let contentLength = 0; 146 + // alternatively we could also call update.download() and update.install() separately 147 + await update.downloadAndInstall((event) => { 148 + switch (event.event) { 149 + case "Started": 150 + //@ts-expect-error 151 + contentLength = event.data.contentLength; 152 + console.log( 153 + `started downloading ${event.data.contentLength} bytes` 154 + ); 155 + break; 156 + case "Progress": 157 + downloaded += event.data.chunkLength; 158 + console.log(`downloaded ${downloaded} from ${contentLength}`); 159 + break; 160 + case "Finished": 161 + console.log("download finished"); 162 + break; 163 + } 164 + }); 165 + 166 + toast("Update ready, restarting..."); 167 + await relaunch(); 168 + } 169 + })(); 170 + }, []); 103 171 104 172 return ( 105 173 <main className="bg-background dark min-h-screen flex flex-col">
src/assets/ATTRIBUTIONS.md public/ATTRIBUTIONS.md
src/assets/cloudy_sunset.png public/cloudy_sunset.png
src/assets/milky_way.jpg public/milky_way.jpg
src/assets/night_sky.jpg public/night_sky.jpg
-1
src/assets/react.svg
··· 1 - <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
+88
src/components/BackgroundTest.tsx
··· 1 + import { useState } from "react"; 2 + import { Button } from "@/components/ui/button"; 3 + import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 4 + import { invoke } from "@tauri-apps/api/core"; 5 + import { listen } from "@tauri-apps/api/event"; 6 + import { toast } from "sonner"; 7 + 8 + export function BackgroundTest() { 9 + const [isListening, setIsListening] = useState(false); 10 + 11 + const startListening = async () => { 12 + try { 13 + await listen("perform-backup", () => { 14 + toast("🎯 Background backup event received!"); 15 + console.log("Background backup event received"); 16 + }); 17 + setIsListening(true); 18 + toast("🎯 Listening for background backup events"); 19 + } catch (error) { 20 + console.error("Failed to start listening:", error); 21 + toast("Failed to start listening"); 22 + } 23 + }; 24 + 25 + const testBackgroundScheduler = async () => { 26 + try { 27 + await invoke("start_background_scheduler"); 28 + toast("🎯 Background scheduler started"); 29 + } catch (error) { 30 + console.error("Failed to start background scheduler:", error); 31 + toast("Failed to start background scheduler"); 32 + } 33 + }; 34 + 35 + const testEmitEvent = async () => { 36 + try { 37 + // This will trigger the background backup 38 + await invoke("emit", { event: "perform-backup", payload: null }); 39 + toast("🎯 Test backup event emitted"); 40 + } catch (error) { 41 + console.error("Failed to emit event:", error); 42 + toast("Failed to emit event"); 43 + } 44 + }; 45 + 46 + return ( 47 + <Card className="bg-card border-white/10 mb-4"> 48 + <CardHeader> 49 + <CardTitle className="text-white">🎯 Background Backup Test</CardTitle> 50 + </CardHeader> 51 + <CardContent className="space-y-4"> 52 + <div className="flex gap-2"> 53 + <Button 54 + onClick={startListening} 55 + disabled={isListening} 56 + variant="outline" 57 + className="cursor-pointer" 58 + > 59 + {isListening ? "Listening..." : "Start Listening"} 60 + </Button> 61 + 62 + <Button 63 + onClick={testBackgroundScheduler} 64 + variant="outline" 65 + className="cursor-pointer" 66 + > 67 + Start Scheduler 68 + </Button> 69 + 70 + <Button 71 + onClick={testEmitEvent} 72 + variant="outline" 73 + className="cursor-pointer" 74 + > 75 + Test Event 76 + </Button> 77 + </div> 78 + 79 + <div className="text-xs text-white/60"> 80 + <p>• Click "Start Listening" to listen for backup events</p> 81 + <p>• Click "Start Scheduler" to start background scheduler</p> 82 + <p>• Click "Test Event" to manually trigger a backup event</p> 83 + <p>• Check browser console for detailed logs</p> 84 + </div> 85 + </CardContent> 86 + </Card> 87 + ); 88 + }
+115
src/components/TestAutoBackup.tsx
··· 1 + import { useState } from "react"; 2 + import { Button } from "@/components/ui/button"; 3 + import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 4 + import { TestAutoBackupScheduler } from "@/lib/testAutoBackup"; 5 + import { useAuth } from "@/Auth"; 6 + import { settingsManager } from "@/lib/settings"; 7 + import { toast } from "sonner"; 8 + 9 + export function TestAutoBackup() { 10 + const [isRunning, setIsRunning] = useState(false); 11 + const [scheduler, setScheduler] = useState<TestAutoBackupScheduler | null>( 12 + null 13 + ); 14 + const [lastBackupDate, setLastBackupDate] = useState<string | undefined>(); 15 + const [frequency, setFrequency] = useState<"daily" | "weekly">("daily"); 16 + const { agent } = useAuth(); 17 + 18 + const startTest = () => { 19 + if (!agent) { 20 + toast("No agent available"); 21 + return; 22 + } 23 + 24 + const testScheduler = new TestAutoBackupScheduler(agent); 25 + testScheduler.start(); 26 + setScheduler(testScheduler); 27 + setIsRunning(true); 28 + toast("🧪 Test auto-backup started! Check console for logs."); 29 + }; 30 + 31 + const stopTest = () => { 32 + if (scheduler) { 33 + scheduler.stop(); 34 + setScheduler(null); 35 + setIsRunning(false); 36 + toast("🧪 Test auto-backup stopped"); 37 + } 38 + }; 39 + 40 + const checkStatus = async () => { 41 + const lastBackup = await settingsManager.getLastBackupDate(); 42 + const freq = await settingsManager.getBackupFrequency(); 43 + setLastBackupDate(lastBackup); 44 + setFrequency(freq); 45 + }; 46 + 47 + const clearLastBackup = async () => { 48 + await settingsManager.updateSettings({ lastBackupDate: undefined }); 49 + await checkStatus(); 50 + toast("🧪 Cleared last backup date"); 51 + }; 52 + 53 + return ( 54 + <Card className="bg-card border-white/10 mb-4"> 55 + <CardHeader> 56 + <CardTitle className="text-white">🧪 Test Auto-Backup</CardTitle> 57 + </CardHeader> 58 + <CardContent className="space-y-4"> 59 + <div className="space-y-2"> 60 + <p className="text-white/80 text-sm"> 61 + Current frequency: <span className="font-bold">{frequency}</span> 62 + </p> 63 + <p className="text-white/80 text-sm"> 64 + Last backup:{" "} 65 + {lastBackupDate 66 + ? new Date(lastBackupDate).toLocaleString() 67 + : "None"} 68 + </p> 69 + </div> 70 + 71 + <div className="flex gap-2"> 72 + <Button 73 + onClick={startTest} 74 + disabled={isRunning} 75 + variant="outline" 76 + className="cursor-pointer" 77 + > 78 + Start Test (30s intervals) 79 + </Button> 80 + 81 + <Button 82 + onClick={stopTest} 83 + disabled={!isRunning} 84 + variant="outline" 85 + className="cursor-pointer" 86 + > 87 + Stop Test 88 + </Button> 89 + 90 + <Button 91 + onClick={checkStatus} 92 + variant="outline" 93 + className="cursor-pointer" 94 + > 95 + Check Status 96 + </Button> 97 + 98 + <Button 99 + onClick={clearLastBackup} 100 + variant="outline" 101 + className="cursor-pointer" 102 + > 103 + Clear Last Backup 104 + </Button> 105 + </div> 106 + 107 + <div className="text-xs text-white/60"> 108 + <p>• Test uses 1 minute for "daily" and 2 minutes for "weekly"</p> 109 + <p>• Check browser console for detailed logs</p> 110 + <p>• Change frequency in Settings to test different intervals</p> 111 + </div> 112 + </CardContent> 113 + </Card> 114 + ); 115 + }
+2 -2
src/lib/autoBackup.ts
··· 1 1 import { Agent } from "@atproto/api"; 2 - import { BackupManager } from "./backup"; 2 + import { BackupAgent } from "./backup"; 3 3 import { settingsManager } from "./settings"; 4 4 5 5 export class AutoBackupScheduler { ··· 78 78 79 79 private async performBackup(): Promise<void> { 80 80 try { 81 - const manager = new BackupManager(this.agent); 81 + const manager = new BackupAgent(this.agent); 82 82 await manager.startBackup(); 83 83 84 84 // Update the last backup date
+86
src/lib/backgroundBackup.ts
··· 1 + import { invoke } from "@tauri-apps/api/core"; 2 + import { listen } from "@tauri-apps/api/event"; 3 + import { BackupAgent } from "./backup"; 4 + import { settingsManager } from "./settings"; 5 + import { toast } from "sonner"; 6 + 7 + export class BackgroundBackupService { 8 + private static instance: BackgroundBackupService; 9 + private isInitialized = false; 10 + 11 + private constructor() {} 12 + 13 + static getInstance(): BackgroundBackupService { 14 + if (!BackgroundBackupService.instance) { 15 + BackgroundBackupService.instance = new BackgroundBackupService(); 16 + } 17 + return BackgroundBackupService.instance; 18 + } 19 + 20 + async initialize(): Promise<void> { 21 + if (this.isInitialized) return; 22 + 23 + // Start the background scheduler 24 + try { 25 + await invoke("start_background_scheduler"); 26 + console.log("Background backup scheduler started"); 27 + } catch (error) { 28 + console.error("Failed to start background scheduler:", error); 29 + } 30 + 31 + // Listen for backup events from the background scheduler 32 + await listen("perform-backup", async () => { 33 + console.log("Background backup event received"); 34 + await this.performBackgroundBackup(); 35 + }); 36 + 37 + this.isInitialized = true; 38 + } 39 + 40 + private async performBackgroundBackup(): Promise<void> { 41 + try { 42 + // Get the agent from the auth context 43 + // This is a bit tricky since we need to access the agent from the React context 44 + // For now, we'll emit an event that the main app can listen to 45 + window.dispatchEvent(new CustomEvent("background-backup-requested")); 46 + 47 + console.log("Background backup request dispatched"); 48 + } catch (error) { 49 + console.error("Background backup failed:", error); 50 + } 51 + } 52 + 53 + async stop(): Promise<void> { 54 + try { 55 + await invoke("stop_background_scheduler"); 56 + console.log("Background backup scheduler stopped"); 57 + } catch (error) { 58 + console.error("Failed to stop background scheduler:", error); 59 + } 60 + } 61 + } 62 + 63 + // Export a function to handle background backup requests 64 + export async function handleBackgroundBackup(agent: any): Promise<void> { 65 + try { 66 + console.log("Performing background backup..."); 67 + 68 + const manager = new BackupAgent(agent); 69 + await manager.startBackup(); 70 + 71 + // Update the last backup date 72 + await settingsManager.setLastBackupDate(new Date().toISOString()); 73 + 74 + console.log("Background backup completed successfully"); 75 + 76 + // Show a notification (if the app is minimized, this might not be visible) 77 + toast("Automatic backup completed", { 78 + description: "Your data has been backed up automatically", 79 + }); 80 + } catch (error) { 81 + console.error("Background backup failed:", error); 82 + toast("Automatic backup failed", { 83 + description: "Check the console for details", 84 + }); 85 + } 86 + }
+283 -11
src/lib/backup.ts
··· 15 15 timestamp: string; 16 16 backupType: string; 17 17 filePath: string; 18 + blobsPath?: string; 19 + blobCount?: number; 18 20 stats: CarStats; 19 21 } 20 22 21 - export class BackupManager { 23 + export interface BlobReference { 24 + cid: string; 25 + mimeType?: string; 26 + size?: number; 27 + } 28 + 29 + export type BackupStage = 30 + | "fetching" 31 + | "writing" 32 + | "blobs" 33 + | "cleanup" 34 + | "complete"; 35 + export interface ProgressInfo { 36 + stage: BackupStage; 37 + message: string; 38 + progress?: number; // 0-100 percentage 39 + current?: number; 40 + total?: number; 41 + } 42 + 43 + export type ProgressCallback = (progress: ProgressInfo) => void; 44 + 45 + export class BackupAgent { 22 46 private agent: Agent; 23 47 private maxBackups = 3; 48 + private downloadBlobs = true; 49 + private progressCallback?: ProgressCallback; 50 + private overwriteBackups = false; 24 51 25 - constructor(agent: Agent) { 52 + constructor( 53 + agent: Agent, 54 + options?: { 55 + downloadBlobs?: boolean; 56 + onProgress?: ProgressCallback; 57 + overwriteBackups?: boolean; 58 + } 59 + ) { 26 60 this.agent = agent; 61 + this.downloadBlobs = options?.downloadBlobs ?? true; 62 + this.progressCallback = options?.onProgress; 63 + this.overwriteBackups = options?.overwriteBackups ?? false; 64 + } 65 + 66 + private reportProgress(progress: ProgressInfo) { 67 + if (this.progressCallback) { 68 + this.progressCallback(progress); 69 + } 27 70 } 28 71 29 72 async startBackup(): Promise<Metadata> { 30 73 const did = this.agent.did; 31 74 if (did == null) throw Error("Unauthenticated"); 32 75 33 - // Get the repo data 34 - const data = await this.agent.com.atproto.sync.getRepo({ did: did }); 76 + try { 77 + // Stage 1: Fetching repo data 78 + this.reportProgress({ 79 + stage: "fetching", 80 + message: "Fetching repository data...", 81 + progress: 10, 82 + }); 83 + 84 + const data = await this.agent.com.atproto.sync.getRepo({ did: did }); 85 + 86 + // Stage 2: Writing backup file 87 + this.reportProgress({ 88 + stage: "writing", 89 + message: "Writing backup to file...", 90 + progress: 30, 91 + }); 92 + 93 + const metadata = await this.writeBackupToFile(data.data, did); 94 + 95 + // Stage 3: Download blobs if enabled 96 + if (this.downloadBlobs) { 97 + this.reportProgress({ 98 + stage: "blobs", 99 + message: "Preparing to download blobs...", 100 + progress: 40, 101 + }); 102 + 103 + await this.downloadBlobsForBackup(metadata); 104 + } else { 105 + this.reportProgress({ 106 + stage: "blobs", 107 + message: "Skipping blob download (disabled)", 108 + progress: 80, 109 + }); 110 + } 35 111 36 - // Write to backup location 37 - const metadata = await this.writeBackupToFile(data.data, did); 112 + // Clean up old backups or overwrite existing one 113 + if (this.overwriteBackups) { 114 + this.reportProgress({ 115 + stage: "cleanup", 116 + message: "Cleaning up previous backup...", 117 + progress: 90, 118 + }); 119 + await this.cleanupAllBackups(); 120 + } else { 121 + this.reportProgress({ 122 + stage: "cleanup", 123 + message: "Cleaning up old backups...", 124 + progress: 90, 125 + }); 126 + await this.cleanupOldBackups(); 127 + } 38 128 39 - // Clean up old backups after creating new one 40 - await this.cleanupOldBackups(); 129 + // Stage 5: Complete 130 + this.reportProgress({ 131 + stage: "complete", 132 + message: "Backup completed successfully!", 133 + progress: 100, 134 + }); 41 135 42 - return metadata; 136 + return metadata; 137 + } catch (error: any) { 138 + this.reportProgress({ 139 + stage: "complete", 140 + message: `Backup failed: ${error.message}`, 141 + progress: 0, 142 + }); 143 + throw error; 144 + } 43 145 } 44 146 45 147 private async writeBackupToFile( ··· 50 152 // Create backup directory structure 51 153 await createBackupDir(); 52 154 const backupDir = await getBackupDir(); 53 - const timestamp = new Date().toISOString().split("T")[0]; // YYYY-MM-DD 54 - const backupPath = await join(backupDir, `${timestamp}_backup`); 155 + 156 + let backupPath: string; 157 + if (this.overwriteBackups) { 158 + // Use a consistent name for overwriting 159 + backupPath = await join(backupDir, "current_backup"); 160 + 161 + // Remove existing backup if it exists 162 + try { 163 + await remove(backupPath, { recursive: true }); 164 + } catch (e) { 165 + // Directory might not exist, which is fine 166 + } 167 + } else { 168 + // Use timestamp-based naming, overwrite if exists for today 169 + const timestamp = new Date().toISOString().split("T")[0]; // YYYY-MM-DD 170 + backupPath = await join(backupDir, `${timestamp}_backup`); 171 + 172 + // Remove existing backup for today if it exists 173 + try { 174 + await remove(backupPath, { recursive: true }); 175 + console.log(`Overwriting existing backup for ${timestamp}`); 176 + } catch (e) { 177 + // Directory might not exist, which is fine 178 + } 179 + } 180 + 55 181 await mkdir(backupPath); 56 182 57 183 // Write the repo data as binary file ··· 81 207 } 82 208 } 83 209 210 + private async downloadBlobsForBackup(metadata: Metadata): Promise<void> { 211 + try { 212 + const backupDir = await resolve(metadata.filePath, ".."); 213 + const blobDir = await join(backupDir, "blobs"); 214 + await mkdir(blobDir); 215 + 216 + // Extract blob references from the CAR file 217 + this.reportProgress({ 218 + stage: "blobs", 219 + message: "Extracting blob references...", 220 + progress: 45, 221 + }); 222 + 223 + const blobRefs = await this.extractBlobReferences(); 224 + 225 + if (blobRefs.length === 0) { 226 + this.reportProgress({ 227 + stage: "blobs", 228 + message: "No blobs found in backup", 229 + progress: 80, 230 + }); 231 + console.log("No blobs found in backup"); 232 + return; 233 + } 234 + 235 + console.log(`Downloading ${blobRefs.length} blobs...`); 236 + let downloadedCount = 0; 237 + 238 + for (let i = 0; i < blobRefs.length; i++) { 239 + const blobRef = blobRefs[i]; 240 + const progress = 50 + Math.round((i / blobRefs.length) * 30); // 50-80% range 241 + 242 + this.reportProgress({ 243 + stage: "blobs", 244 + message: `Downloading blob ${i + 1} of ${blobRefs.length}...`, 245 + progress, 246 + current: i + 1, 247 + total: blobRefs.length, 248 + }); 249 + 250 + try { 251 + const blobData = await this.agent.com.atproto.sync.getBlob({ 252 + did: metadata.did, 253 + cid: blobRef, 254 + }); 255 + 256 + const blobPath = await join(blobDir, `${blobRef}.blob`); 257 + await writeFile(blobPath, blobData.data); 258 + downloadedCount++; 259 + 260 + // Optional: Save blob metadata 261 + const blobMetadata = { 262 + cid: blobRef, 263 + size: blobData.data.length, 264 + downloadedAt: new Date().toISOString(), 265 + }; 266 + 267 + const blobMetadataPath = await join(blobDir, `${blobRef}.json`); 268 + await writeFile( 269 + blobMetadataPath, 270 + new TextEncoder().encode(JSON.stringify(blobMetadata, null, 2)) 271 + ); 272 + } catch (error) { 273 + console.error(`Failed to download blob ${blobRef}:`, error); 274 + } 275 + } 276 + 277 + // Update main metadata with blob information 278 + const updatedMetadata = { 279 + ...metadata, 280 + blobsPath: blobDir, 281 + blobCount: downloadedCount, 282 + }; 283 + 284 + const metadataPath = await join(backupDir, "metadata.json"); 285 + await writeFile( 286 + metadataPath, 287 + new TextEncoder().encode(JSON.stringify(updatedMetadata, null, 2)) 288 + ); 289 + 290 + this.reportProgress({ 291 + stage: "blobs", 292 + message: `Downloaded ${downloadedCount}/${blobRefs.length} blobs`, 293 + progress: 80, 294 + }); 295 + 296 + console.log(`Downloaded ${downloadedCount}/${blobRefs.length} blobs`); 297 + } catch (error) { 298 + console.error("Failed to download blobs:", error); 299 + // Don't throw - blob download failure shouldn't fail the entire backup 300 + } 301 + } 302 + 303 + private async extractBlobReferences(): Promise<string[]> { 304 + let allBlobs = []; 305 + let cursor: string | undefined; 306 + while (true) { 307 + const blobs = await this.agent.com.atproto.sync.listBlobs({ 308 + did: this.agent.assertDid, 309 + limit: 500, 310 + cursor, 311 + }); 312 + allBlobs.push(...blobs.data.cids); 313 + if (blobs.data.cursor) cursor = blobs.data.cursor; 314 + else break; 315 + } 316 + 317 + return allBlobs; 318 + } 319 + 320 + private async cleanupAllBackups(): Promise<void> { 321 + try { 322 + const backups = await this.getBackups(); 323 + 324 + for (const backup of backups) { 325 + await this.deleteBackup(backup); 326 + } 327 + 328 + if (backups.length > 0) { 329 + console.log(`Deleted ${backups.length} existing backup(s)`); 330 + } 331 + } catch (error) { 332 + console.error("Failed to cleanup all backups:", error); 333 + // Don't throw here - we don't want backup creation to fail because of cleanup issues 334 + } 335 + } 336 + 84 337 private async cleanupOldBackups(): Promise<void> { 85 338 try { 86 339 const backups = await this.getBackups(); ··· 172 425 } 173 426 174 427 return data; 428 + } 429 + 430 + // Method to restore a specific blob 431 + async restoreBlob( 432 + backupMetadata: Metadata, 433 + blobCid: string 434 + ): Promise<Uint8Array | null> { 435 + if (!backupMetadata.blobsPath) { 436 + throw new Error("No blobs available for this backup"); 437 + } 438 + 439 + try { 440 + const blobPath = await join(backupMetadata.blobsPath, `${blobCid}.blob`); 441 + const blobData = await readTextFile(blobPath); 442 + return new TextEncoder().encode(blobData); 443 + } catch (error) { 444 + console.error(`Failed to restore blob ${blobCid}:`, error); 445 + return null; 446 + } 175 447 } 176 448 }
+104
src/lib/testAutoBackup.ts
··· 1 + import { Agent } from "@atproto/api"; 2 + import { BackupAgent } from "./backup"; 3 + import { settingsManager } from "./settings"; 4 + 5 + export class TestAutoBackupScheduler { 6 + private agent: Agent; 7 + private intervalId: NodeJS.Timeout | null = null; 8 + private isRunning = false; 9 + 10 + constructor(agent: Agent) { 11 + this.agent = agent; 12 + } 13 + 14 + start(): void { 15 + if (this.isRunning) return; 16 + 17 + this.isRunning = true; 18 + // Check every 30 seconds for testing (instead of 1 hour) 19 + this.intervalId = setInterval(() => { 20 + this.checkAndPerformBackup(); 21 + }, 30 * 1000); // 30 seconds 22 + 23 + // Also check immediately when starting 24 + this.checkAndPerformBackup(); 25 + } 26 + 27 + stop(): void { 28 + if (this.intervalId) { 29 + clearInterval(this.intervalId); 30 + this.intervalId = null; 31 + } 32 + this.isRunning = false; 33 + } 34 + 35 + private async checkAndPerformBackup(): Promise<void> { 36 + try { 37 + const shouldBackup = await this.shouldPerformBackup(); 38 + 39 + if (shouldBackup) { 40 + console.log("🧪 TEST: Automatic backup due, starting backup..."); 41 + await this.performBackup(); 42 + } else { 43 + console.log("🧪 TEST: No backup needed yet"); 44 + } 45 + } catch (error) { 46 + console.error("Error in automatic backup check:", error); 47 + } 48 + } 49 + 50 + private async shouldPerformBackup(): Promise<boolean> { 51 + try { 52 + const lastBackupDate = await settingsManager.getLastBackupDate(); 53 + const frequency = await settingsManager.getBackupFrequency(); 54 + 55 + if (!lastBackupDate) { 56 + console.log("🧪 TEST: No previous backup, should do one"); 57 + return true; 58 + } 59 + 60 + const lastBackup = new Date(lastBackupDate); 61 + const now = new Date(); 62 + const timeDiff = now.getTime() - lastBackup.getTime(); 63 + 64 + // For testing: use shorter intervals 65 + if (frequency === "daily") { 66 + // Test with 1 minute instead of 24 hours 67 + const oneMinute = 60 * 1000; 68 + const shouldBackup = timeDiff >= oneMinute; 69 + console.log( 70 + `🧪 TEST: Daily backup check - ${timeDiff}ms since last backup, need ${oneMinute}ms` 71 + ); 72 + return shouldBackup; 73 + } else if (frequency === "weekly") { 74 + // Test with 2 minutes instead of 7 days 75 + const twoMinutes = 2 * 60 * 1000; 76 + const shouldBackup = timeDiff >= twoMinutes; 77 + console.log( 78 + `🧪 TEST: Weekly backup check - ${timeDiff}ms since last backup, need ${twoMinutes}ms` 79 + ); 80 + return shouldBackup; 81 + } 82 + 83 + return false; 84 + } catch (error) { 85 + console.error("Error checking if backup is due:", error); 86 + return false; 87 + } 88 + } 89 + 90 + private async performBackup(): Promise<void> { 91 + try { 92 + console.log("🧪 TEST: Starting automatic backup..."); 93 + const manager = new BackupAgent(this.agent); 94 + await manager.startBackup(); 95 + 96 + // Update the last backup date 97 + await settingsManager.setLastBackupDate(new Date().toISOString()); 98 + 99 + console.log("🧪 TEST: Automatic backup completed successfully"); 100 + } catch (error) { 101 + console.error("🧪 TEST: Automatic backup failed:", error); 102 + } 103 + } 104 + }
+35
src/lib/testBackground.ts
··· 1 + import { invoke } from "@tauri-apps/api/core"; 2 + import { listen } from "@tauri-apps/api/event"; 3 + import { toast } from "sonner"; 4 + 5 + export class BackgroundTestService { 6 + static async testBackgroundBackup() { 7 + try { 8 + // Test the background scheduler 9 + await invoke("start_background_scheduler"); 10 + toast("Background scheduler started"); 11 + 12 + // Listen for backup events 13 + await listen("perform-backup", () => { 14 + toast("Background backup event received!"); 15 + console.log("Background backup event received"); 16 + }); 17 + 18 + console.log("Background test service initialized"); 19 + } catch (error) { 20 + console.error("Background test failed:", error); 21 + toast("Background test failed"); 22 + } 23 + } 24 + 25 + static async triggerTestBackup() { 26 + try { 27 + // Emit a test backup event 28 + await invoke("emit", { event: "perform-backup", payload: null }); 29 + toast("Test backup event triggered"); 30 + } catch (error) { 31 + console.error("Failed to trigger test backup:", error); 32 + toast("Failed to trigger test backup"); 33 + } 34 + } 35 + }
+47 -23
src/routes/Home.tsx
··· 1 + import { useAuth } from "@/Auth"; 2 + import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 3 + import { Badge } from "@/components/ui/badge"; 1 4 import { Button } from "@/components/ui/button"; 2 - import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 3 - import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 4 5 import { 5 6 DropdownMenu, 6 7 DropdownMenuContent, 7 8 DropdownMenuItem, 8 9 DropdownMenuTrigger, 9 10 } from "@/components/ui/dropdown-menu"; 10 - import { openPath } from "@tauri-apps/plugin-opener"; 11 + import { Progress } from "@/components/ui/progress"; 12 + import { BackupAgent, BackupStage, Metadata } from "@/lib/backup"; 11 13 import { createBackupDir, getBackupDir } from "@/lib/paths"; 12 - import { useEffect, useState, useRef } from "react"; 14 + import { settingsManager } from "@/lib/settings"; 15 + import { ProfileViewDetailed } from "@atproto/api/dist/client/types/app/bsky/actor/defs"; 16 + import { openPath } from "@tauri-apps/plugin-opener"; 13 17 import { 14 - History, 15 - LoaderCircleIcon, 16 18 ChevronDown, 17 - FolderOpen, 18 - Database, 19 19 FileText, 20 + FolderOpen, 20 21 HardDrive, 21 - Package, 22 22 Heart, 23 - Users, 24 - User, 23 + History, 24 + Images, 25 + LoaderCircleIcon, 26 + Package, 25 27 Settings as SettingsIcon, 28 + User, 29 + Users, 26 30 } from "lucide-react"; 27 - import { BackupManager, Metadata } from "@/lib/backup"; 28 - import { useAuth } from "@/Auth"; 31 + import { useEffect, useRef, useState } from "react"; 29 32 import { toast } from "sonner"; 30 - import { settingsManager } from "@/lib/settings"; 31 - import { Badge } from "@/components/ui/badge"; 32 - import { Progress } from "@/components/ui/progress"; 33 33 import Settings from "./Settings"; 34 34 35 35 export function Home({ ··· 84 84 variant="ghost" 85 85 size="sm" 86 86 onClick={() => setShowSettings(true)} 87 - className="text-white/80 hover:text-white" 87 + className="text-white/80 hover:text-white cursor-pointer" 88 88 > 89 89 <SettingsIcon className="w-4 h-4" /> 90 90 </Button> 91 91 </div> 92 92 93 + {/* For testing auto backup */} 94 + {/* <TestAutoBackup /> */} 95 + {/* <BackgroundTest /> */} 96 + 93 97 <div className="bg-card rounded-lg p-4 mb-4"> 94 98 <p className="mb-2 text-white">Backups</p> 95 99 <div className="flex gap-2"> ··· 117 121 </Button> 118 122 119 123 <StartBackup onBackupComplete={handleBackupComplete} /> 124 + 125 + {/* <Button 126 + variant="outline" 127 + className="cursor-pointer" 128 + onClick={async () => { 129 + await BackgroundTestService.testBackgroundBackup(); 130 + }} 131 + > 132 + Test Background 133 + </Button> */} 120 134 </div> 121 135 </div> 122 136 ··· 127 141 128 142 function StartBackup({ onBackupComplete }: { onBackupComplete: () => void }) { 129 143 const [isLoading, setIsLoading] = useState(false); 144 + const [stage, setStage] = useState<BackupStage | null>(null); 145 + const [_, setProgress] = useState<number | undefined>(); 130 146 const { agent } = useAuth(); 131 147 132 148 return ( ··· 140 156 toast("Agent not initialized, try to reload the app."); 141 157 return; 142 158 } 143 - const manager = new BackupManager(agent!!); 159 + const manager = new BackupAgent(agent!!, { 160 + onProgress: (progress) => { 161 + setStage(progress.stage); 162 + setProgress(progress.progress); 163 + }, 164 + }); 144 165 await manager.startBackup(); 145 166 await settingsManager.setLastBackupDate(new Date().toISOString()); 146 167 toast("Backup complete!"); ··· 155 176 disabled={isLoading} 156 177 > 157 178 {isLoading ? ( 158 - <LoaderCircleIcon className="animate-spin text-white/80" /> 179 + <> 180 + <LoaderCircleIcon className="animate-spin text-white/80" /> 181 + <span className="capitalize">{stage}</span> 182 + </> 159 183 ) : ( 160 184 <span>Backup now</span> 161 185 )} ··· 194 218 } 195 219 setIsLoading(true); 196 220 try { 197 - const manager = new BackupManager(agent); 221 + const manager = new BackupAgent(agent); 198 222 const backupsList = await manager.getBackups(); 199 223 // Sort backups by timestamp (newest first) 200 224 const sortedBackups = backupsList.sort( ··· 336 360 </div> 337 361 338 362 <div className="flex items-center gap-2 p-3 rounded-lg bg-white/5"> 339 - <Database className="w-5 h-5 text-purple-400" /> 363 + <Images className="w-5 h-5 text-purple-400" /> 340 364 <div> 341 - <p className="text-white/60 text-xs">Blocks</p> 365 + <p className="text-white/60 text-xs">Attachments</p> 342 366 <p className="text-white font-semibold"> 343 - {backup.stats?.totalBlocks?.toLocaleString() || "-"} 367 + {backup.blobCount?.toLocaleString() || "-"} 344 368 </p> 345 369 </div> 346 370 </div>
+1 -1
src/routes/Login.tsx
··· 100 100 <div 101 101 className="min-h-screen flex items-center justify-center bg-background px-4 relative" 102 102 style={{ 103 - backgroundImage: "url(/src/assets/milky_way.jpg)", 103 + backgroundImage: "url(/milky_way.jpg)", 104 104 backgroundSize: "300%", 105 105 backgroundPosition: "center", 106 106 backgroundRepeat: "no-repeat",
+1 -1
src/routes/Settings.tsx
··· 47 47 variant="ghost" 48 48 size="sm" 49 49 onClick={onBack} 50 - className="text-white/80 hover:text-white" 50 + className="text-white/80 hover:text-white cursor-pointer" 51 51 > 52 52 <ArrowLeft className="w-4 h-4 mr-2" /> 53 53 Back