[READ-ONLY] Mirror of https://github.com/probablykasper/time-machine-inspector. Time Machine backup size inspector app
backup macos tauri time-machine
0

Configure Feed

Select the types of activity you want to include in your feed.

Load files from backup

Kasper (Jan 6, 2022, 1:59 AM +0100) f55c42e2 3e089ff4

+628 -110
public/Karla-VariableFont_wght.ttf

This is a binary file and will not be displayed.

public/OpenSans-VariableFont_wdth,wght.ttf

This is a binary file and will not be displayed.

+69 -1
src-tauri/Cargo.lock
··· 99 99 checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" 100 100 101 101 [[package]] 102 + name = "base64" 103 + version = "0.13.0" 104 + source = "registry+https://github.com/rust-lang/crates.io-index" 105 + checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd" 106 + 107 + [[package]] 102 108 name = "bincode" 103 109 version = "1.3.3" 104 110 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1069 1075 ] 1070 1076 1071 1077 [[package]] 1078 + name = "hashbrown" 1079 + version = "0.11.2" 1080 + source = "registry+https://github.com/rust-lang/crates.io-index" 1081 + checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e" 1082 + 1083 + [[package]] 1072 1084 name = "heck" 1073 1085 version = "0.3.3" 1074 1086 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1169 1181 ] 1170 1182 1171 1183 [[package]] 1184 + name = "indexmap" 1185 + version = "1.7.0" 1186 + source = "registry+https://github.com/rust-lang/crates.io-index" 1187 + checksum = "bc633605454125dec4b66843673f01c7df2b89479b32e0ed634e43a91cff62a5" 1188 + dependencies = [ 1189 + "autocfg", 1190 + "hashbrown", 1191 + ] 1192 + 1193 + [[package]] 1172 1194 name = "infer" 1173 1195 version = "0.4.0" 1174 1196 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1286 1308 version = "0.2.112" 1287 1309 source = "registry+https://github.com/rust-lang/crates.io-index" 1288 1310 checksum = "1b03d17f364a3a042d5e5d46b053bbbf82c92c9430c592dd4c064dc6ee997125" 1311 + 1312 + [[package]] 1313 + name = "line-wrap" 1314 + version = "0.1.1" 1315 + source = "registry+https://github.com/rust-lang/crates.io-index" 1316 + checksum = "f30344350a2a51da54c1d53be93fade8a237e545dbcc4bdbe635413f2117cab9" 1317 + dependencies = [ 1318 + "safemem", 1319 + ] 1289 1320 1290 1321 [[package]] 1291 1322 name = "lock_api" ··· 1808 1839 checksum = "58893f751c9b0412871a09abd62ecd2a00298c6c83befa223ef98c52aef40cbe" 1809 1840 1810 1841 [[package]] 1842 + name = "plist" 1843 + version = "1.3.1" 1844 + source = "registry+https://github.com/rust-lang/crates.io-index" 1845 + checksum = "bd39bc6cdc9355ad1dc5eeedefee696bb35c34caf21768741e81826c0bbd7225" 1846 + dependencies = [ 1847 + "base64", 1848 + "indexmap", 1849 + "line-wrap", 1850 + "serde", 1851 + "time 0.3.5", 1852 + "xml-rs", 1853 + ] 1854 + 1855 + [[package]] 1811 1856 name = "png" 1812 1857 version = "0.11.0" 1813 1858 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2151 2196 version = "1.0.9" 2152 2197 source = "registry+https://github.com/rust-lang/crates.io-index" 2153 2198 checksum = "73b4b750c782965c211b42f022f59af1fbceabdd026623714f104152f1ec149f" 2199 + 2200 + [[package]] 2201 + name = "safemem" 2202 + version = "0.3.3" 2203 + source = "registry+https://github.com/rust-lang/crates.io-index" 2204 + checksum = "ef703b7cb59335eae2eb93ceb664c0eb7ea6bf567079d843e09420219668e072" 2154 2205 2155 2206 [[package]] 2156 2207 name = "same-file" ··· 2750 2801 ] 2751 2802 2752 2803 [[package]] 2804 + name = "time" 2805 + version = "0.3.5" 2806 + source = "registry+https://github.com/rust-lang/crates.io-index" 2807 + checksum = "41effe7cfa8af36f439fac33861b66b049edc6f9a32331e2312660529c1c24ad" 2808 + dependencies = [ 2809 + "itoa 0.4.8", 2810 + "libc", 2811 + ] 2812 + 2813 + [[package]] 2753 2814 name = "time-machine-inspector" 2754 2815 version = "0.1.0" 2755 2816 dependencies = [ 2817 + "plist", 2756 2818 "serde", 2757 2819 "serde_json", 2758 2820 "tauri", ··· 3313 3375 ] 3314 3376 3315 3377 [[package]] 3378 + name = "xml-rs" 3379 + version = "0.8.4" 3380 + source = "registry+https://github.com/rust-lang/crates.io-index" 3381 + checksum = "d2d7d3948613f75c98fd9328cfdcc45acc4d360655289d0a7d4ec931392200a3" 3382 + 3383 + [[package]] 3316 3384 name = "zip" 3317 3385 version = "0.5.13" 3318 3386 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 3323 3391 "crc32fast", 3324 3392 "flate2", 3325 3393 "thiserror", 3326 - "time", 3394 + "time 0.1.43", 3327 3395 ] 3328 3396 3329 3397 [[package]]
+1
src-tauri/Cargo.toml
··· 19 19 serde = { version = "1.0", features = ["derive"] } 20 20 tauri = { git = "https://github.com/tauri-apps/tauri", branch = "next", features = ["dialog-open", "dialog-save", "shell-open"] } 21 21 tauri-plugin-vibrancy = { git = "https://github.com/tauri-apps/tauri-plugin-vibrancy", features = ["tauri-impl"] } 22 + plist = { version = "1.3.1", features = ["serde"] } 22 23 23 24 [patch."https://github.com/tauri-apps/tauri"] 24 25 tauri = { git = "https://github.com/probablykasper/tauri", rev = "4c1be45" }
+87 -17
src-tauri/src/cmd.rs
··· 1 - use crate::throw; 1 + use crate::dir_map::DirMap; 2 + use crate::{compare, dir_map, throw}; 2 3 use std::fs::File; 3 - use std::process::Command; 4 + use std::io::{BufRead, BufReader}; 5 + use std::process::{Command, ExitStatus, Stdio}; 6 + use std::time::Instant; 4 7 use tauri::api::{dialog, shell}; 5 8 use tauri::{command, Window}; 6 9 ··· 18 21 } 19 22 } 20 23 21 - #[command] 22 - pub async fn load_backups(w: Window) -> Result<Option<String>, String> { 24 + fn check_cmd_success(status: &ExitStatus, stderr: Vec<u8>) -> Result<(), String> { 25 + if !status.success() { 26 + let stderr = parse_output(stderr)?; 27 + throw!("tmutil error {}:\n{}", code_to_str(status.code()), stderr); 28 + } 29 + Ok(()) 30 + } 31 + 32 + pub async fn full_disk_access(dialog_window: Window) -> Result<(), String> { 23 33 match File::open("/Library/Preferences/com.apple.TimeMachine.plist") { 24 34 Ok(_file) => {} 25 35 Err(e) => match e.kind() { 26 36 std::io::ErrorKind::PermissionDenied => { 27 37 dialog::message( 28 - Some(&w), 38 + Some(&dialog_window), 29 39 "Full Disk Access", 30 40 "Time Machine Utility requires full disk access to interact with Time Machine.\n\ 31 41 To grant access:\n\ ··· 37 47 let link = "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles"; 38 48 shell::open(link.to_string(), None).unwrap(); 39 49 40 - return Ok(None); 50 + return Ok(()); 41 51 } 42 52 _ => eprintln!("Unable to open Time Machine preferences: {}", e), 43 53 }, 44 54 }; 55 + Ok(()) 56 + } 45 57 46 - let cmd = Command::new("tmutil") 58 + #[command] 59 + pub async fn load_backups(w: Window) -> Result<Option<String>, String> { 60 + full_disk_access(w).await?; 61 + 62 + // return Ok(Some( 63 + // "/Volumes/Time Machine Backups/Backups.backupdb/my-mac/2021-12-21-133750\n\ 64 + // /Volumes/Time Machine Backups/Backups.backupdb/my-mac/2021-12-22-162608\n\ 65 + // /Volumes/Time Machine Backups/Backups.backupdb/my-mac/2021-12-23-180250\n\ 66 + // /Volumes/Time Machine Backups/Backups.backupdb/my-mac/2021-12-24-175020\n\ 67 + // /Volumes/Time Machine Backups/Backups.backupdb/my-mac/2021-12-25-164417\n\ 68 + // /Volumes/Time Machine Backups/Backups.backupdb/my-mac/2021-12-26-161709\n\ 69 + // /Volumes/Time Machine Backups/Backups.backupdb/my-mac/2021-12-27-193733\n" 70 + // .to_string(), 71 + // )); 72 + 73 + let output = Command::new("tmutil") 47 74 .arg("listbackups") 48 75 .output() 49 - .expect("Error getting backups"); 76 + .expect("Error calling command"); 77 + check_cmd_success(&output.status, output.stderr.clone())?; 50 78 51 - if !cmd.status.success() { 52 - let stderr = parse_output(cmd.stderr)?; 53 - eprintln!( 54 - "listbackups exited with error code {}. stderr:\n{}", 55 - code_to_str(cmd.status.code()), 56 - stderr, 57 - ); 58 - throw!("{}", stderr); 79 + Ok(Some(parse_output(output.stdout)?)) 80 + } 81 + 82 + #[command] 83 + pub async fn compare_backups(old: String, new: String, w: Window) -> Result<DirMap, String> { 84 + full_disk_access(w).await?; 85 + 86 + let mut anchor = Instant::now(); 87 + 88 + let mut cmd = Command::new("tmutil") 89 + .arg("compare") 90 + .arg("-X") 91 + .arg("-s") 92 + .arg(&old) 93 + .arg(&new) 94 + .stdout(Stdio::piped()) 95 + .stderr(Stdio::piped()) 96 + .spawn() 97 + .expect("Error calling command"); 98 + 99 + println!("\u{23f1} {:.3}ms running tmutil", reset_dur(&mut anchor)); 100 + 101 + let mut child_out = BufReader::new(cmd.stdout.as_mut().unwrap()); 102 + let mut lines = Vec::new(); 103 + 104 + loop { 105 + match child_out.read_until(b'\n', &mut lines) { 106 + Ok(0) => break, 107 + _ => {} 108 + }; 59 109 } 60 110 61 - Ok(Some(parse_output(cmd.stdout)?)) 111 + let output = cmd.wait_with_output().expect("Failed ot wait on command"); 112 + check_cmd_success(&output.status, output.stderr)?; 113 + 114 + println!("\u{23f1} {:.3}ms reading output", reset_dur(&mut anchor)); 115 + 116 + let comparison = compare::parse_xml(&lines)?; 117 + println!("{:#?}", comparison.totals); 118 + 119 + println!("\u{23f1} {:.3}ms parse xml", reset_dur(&mut anchor)); 120 + 121 + let dir_map = dir_map::make_map(comparison)?; 122 + 123 + println!("\u{23f1} {:.3}ms constructing map", reset_dur(&mut anchor)); 124 + 125 + Ok(dir_map) 126 + } 127 + 128 + fn reset_dur(since: &mut Instant) -> f32 { 129 + let dur = Instant::now().duration_since(*since).as_nanos() as f32; 130 + *since = Instant::now(); 131 + dur / 1000.0 / 1000.0 62 132 }
+119
src-tauri/src/compare.rs
··· 1 + use crate::throw; 2 + use plist::Value; 3 + use serde::de::DeserializeOwned; 4 + use serde::{Deserialize, Serialize}; 5 + use std::io::{BufWriter, Cursor}; 6 + 7 + #[derive(Serialize, Debug)] 8 + pub struct Comparison { 9 + pub changes: Vec<Change>, 10 + pub totals: Totals, 11 + } 12 + 13 + #[derive(Deserialize, Debug)] 14 + #[serde(deny_unknown_fields)] 15 + struct ComparisonXml { 16 + #[serde(rename = "Changes")] 17 + // changes: Vec<Change>, 18 + changes: Vec<Value>, 19 + #[serde(rename = "Totals")] 20 + totals: Totals, 21 + } 22 + 23 + #[derive(Serialize, Deserialize, Debug)] 24 + #[serde(untagged)] 25 + pub enum Change { 26 + Add(Add), 27 + Update(Update), 28 + Delete(Delete), 29 + } 30 + 31 + #[derive(Serialize, Deserialize, Debug)] 32 + #[serde(deny_unknown_fields)] 33 + pub struct Add { 34 + #[serde(alias = "AddedItem")] 35 + pub added_item: Item, 36 + } 37 + 38 + #[derive(Serialize, Deserialize, Debug)] 39 + #[serde(deny_unknown_fields)] 40 + pub struct Update { 41 + #[serde(alias = "Differences")] 42 + pub differences: Vec<String>, 43 + #[serde(alias = "NewerItem")] 44 + pub newer_item: Item, 45 + #[serde(alias = "OlderItem")] 46 + pub older_item: Item, 47 + } 48 + 49 + #[derive(Serialize, Deserialize, Debug)] 50 + #[serde(deny_unknown_fields)] 51 + pub struct Delete { 52 + #[serde(alias = "RemovedItem")] 53 + pub removed_item: Item, 54 + } 55 + 56 + #[derive(Serialize, Deserialize, Debug)] 57 + #[serde(deny_unknown_fields)] 58 + pub struct Item { 59 + #[serde(rename = "Path")] 60 + pub path: String, 61 + #[serde(rename = "Size")] 62 + pub size: u64, 63 + } 64 + 65 + #[derive(Serialize, Deserialize, Debug)] 66 + #[serde(deny_unknown_fields)] 67 + pub struct Totals { 68 + #[serde(alias = "AddedSize")] 69 + pub added_size: u64, 70 + #[serde(alias = "ChangedSize")] 71 + pub changed_size: u64, 72 + #[serde(alias = "RemovedSize")] 73 + pub removed_size: u64, 74 + } 75 + 76 + pub fn parse_xml(lines: &[u8]) -> Result<Comparison, String> { 77 + let comparison: ComparisonXml = match plist::from_bytes(lines) { 78 + Ok(v) => v, 79 + Err(e) => throw!("Unable to parse response: {}", e), 80 + }; 81 + 82 + let mut changes = Vec::with_capacity(comparison.changes.len()); 83 + for change_dict in &comparison.changes { 84 + let change: Change = match deserialize_value(&change_dict) { 85 + Ok(v) => v, 86 + Err(e) => { 87 + throw!( 88 + "Unable to read change: {}\nReceived value: {:#?}", 89 + e, 90 + change_dict 91 + ); 92 + } 93 + }; 94 + changes.push(change); 95 + } 96 + 97 + Ok(Comparison { 98 + changes, 99 + totals: comparison.totals, 100 + }) 101 + } 102 + 103 + fn deserialize_value<T: DeserializeOwned>(value: &Value) -> Result<T, String> { 104 + let mut buf_writer = BufWriter::new(Vec::new()); 105 + match value.to_writer_binary(&mut buf_writer) { 106 + Ok(_) => {} 107 + Err(_) => throw!("Failed serializing change"), 108 + }; 109 + let bytes = match buf_writer.into_inner() { 110 + Ok(v) => v, 111 + Err(_) => throw!("Error change_buf_writer.into_inner"), 112 + }; 113 + let cursor = Cursor::new(bytes); 114 + let change: T = match plist::from_reader(cursor) { 115 + Ok(v) => v, 116 + Err(e) => throw!("Unable to read item: {}", e.to_string()), 117 + }; 118 + Ok(change) 119 + }
+45
src-tauri/src/dir_map.rs
··· 1 + use crate::compare; 2 + use std::collections::HashMap; 3 + use std::path::PathBuf; 4 + 5 + pub type DirItem = u64; 6 + pub type DirContent = HashMap<String, DirItem>; 7 + pub type DirMap = HashMap<String, DirContent>; 8 + 9 + pub fn make_map(comparison: compare::Comparison) -> Result<DirMap, String> { 10 + let mut dir_map: DirMap = HashMap::new(); 11 + dir_map.entry("/".into()).or_default(); 12 + 13 + for change in comparison.changes { 14 + let new_item = match change { 15 + compare::Change::Add(add) => add.added_item, 16 + compare::Change::Update(update) => update.newer_item, 17 + compare::Change::Delete(_) => continue, 18 + }; 19 + let path = PathBuf::from(new_item.path); 20 + 21 + let mut base = path.file_name().expect("path base"); 22 + let mut parent = path.parent().expect("path parent"); 23 + loop { 24 + let dir_content = dir_map 25 + .entry(parent.to_string_lossy().to_string()) 26 + .or_insert(HashMap::new()); 27 + 28 + let dir_item = dir_content 29 + .entry(base.to_string_lossy().to_string()) 30 + .or_insert(0); 31 + *dir_item += new_item.size; 32 + 33 + base = match parent.file_name() { 34 + Some(v) => v, 35 + None => break, 36 + }; 37 + parent = match parent.parent() { 38 + Some(v) => v, 39 + None => break, 40 + }; 41 + } 42 + } 43 + 44 + Ok(dir_map) 45 + }
+9 -3
src-tauri/src/main.rs
··· 11 11 }; 12 12 13 13 mod cmd; 14 + mod compare; 15 + mod dir_map; 14 16 15 17 #[command] 16 18 fn error_popup(msg: String, win: Window) { ··· 31 33 let ctx = tauri::generate_context!(); 32 34 33 35 tauri::Builder::default() 34 - .invoke_handler(tauri::generate_handler![error_popup, cmd::load_backups]) 36 + .invoke_handler(tauri::generate_handler![ 37 + error_popup, 38 + cmd::load_backups, 39 + cmd::compare_backups, 40 + ]) 35 41 .create_window("main", WindowUrl::default(), |win, webview| { 36 42 let win = win 37 43 .title("Time Machine Inspector") ··· 40 46 .decorations(true) 41 47 // .transparent(true) 42 48 .always_on_top(false) 43 - .inner_size(800.0, 550.0) 44 - .min_inner_size(400.0, 200.0) 49 + .inner_size(900.0, 600.0) 50 + .min_inner_size(600.0, 250.0) 45 51 .skip_taskbar(false) 46 52 .fullscreen(false); 47 53 return (win, webview);
+46 -84
src/App.svelte
··· 1 1 <script lang="ts"> 2 - import { runCmd } from './general' 3 2 import Item, { ItemClickEvent } from './Item.svelte' 4 - import { page } from './page' 5 - 6 - let rootPath = '/' 7 - let backups: DirMap | null = null 8 - 9 - type DirMap = { 10 - '/': string[] 11 - [name: string]: string[] 12 - } 13 - 14 - function parseStdout(stdout: string) { 15 - const paths = stdout.split('\n').filter((path) => path !== '') 16 - const dirmap: DirMap = { '/': [] } 17 - 18 - paths.forEach((path) => { 19 - let base = path.substring(path.lastIndexOf('/') + 1) 20 - let parent = path.substring(0, path.lastIndexOf('/')) 21 - do { 22 - if (parent === '') { 23 - dirmap['/'].push(base) 24 - break 25 - } else if (dirmap[parent] === undefined) { 26 - dirmap[parent] = [base] 27 - } else { 28 - dirmap[parent].push(base) 29 - break 30 - } 31 - base = parent.substring(parent.lastIndexOf('/') + 1) 32 - parent = parent.substring(0, parent.lastIndexOf('/')) 33 - } while (dirmap[parent] === undefined) 34 - }) 35 - 36 - console.log(dirmap) 37 - return dirmap 38 - } 39 - 40 - let stdout: string | null = null 41 - $: stdoutUpdate(stdout) 42 - function stdoutUpdate(stdout: string | null) { 43 - if (stdout === null) { 44 - backups = null 45 - } else { 46 - backups = parseStdout(stdout) 47 - if (backups['/'].length === 1 && backups['/'][0] === 'Volumes') { 48 - rootPath = '/Volumes' 49 - } else { 50 - rootPath = '/' 51 - } 52 - } 53 - } 3 + import { page, backups, close as closePage, loadBackups } from './page' 4 + import Page from './Page.svelte' 54 5 55 6 let loading = false 56 - async function loadBackups() { 7 + async function refreshBackups() { 57 8 if (loading) { 58 9 return 59 10 } 60 11 loading = true 61 - stdout = (await runCmd('load_backups')) as string | null 62 - await new Promise((resolve) => setTimeout(resolve, 1000)) 12 + closePage() 13 + loadBackups() 63 14 loading = false 64 15 } 65 16 ··· 73 24 } 74 25 } 75 26 } 27 + refreshBackups() 76 28 </script> 77 29 78 30 <div class="sidebar"> 79 - <button on:click={loadBackups} class:disabled={loading} tabindex="0"> 31 + <button on:click={refreshBackups} class:disabled={loading} tabindex="0"> 80 32 {#if loading} 81 33 Loading... 82 34 {:else} 83 - Load Backups 35 + Refresh 84 36 {/if} 85 37 </button> 86 - {#if backups !== null} 87 - {#each backups[rootPath] as child} 88 - {#if rootPath === '/'} 89 - <Item 90 - map={backups} 91 - selectedPath={$page.fullPath + '/' + $page.name} 92 - name={child} 93 - fullPath={'/' + child} 94 - on:click={sidebarClick} /> 95 - {:else} 96 - <Item 97 - map={backups} 98 - selectedPath={$page.fullPath + '/' + $page.name} 99 - name={child} 100 - fullPath={rootPath + '/' + child} 101 - on:click={sidebarClick} /> 102 - {/if} 103 - {/each} 104 - {/if} 38 + <div class="content"> 39 + {#if $backups !== null} 40 + {#each $backups.dirs[$backups.rootPath] as child} 41 + {#if $backups.rootPath === '/'} 42 + <Item 43 + map={$backups.dirs} 44 + selectedPath={$page.fullPath + '/' + $page.name} 45 + name={child} 46 + fullPath={'/' + child} 47 + on:click={sidebarClick} /> 48 + {:else} 49 + <Item 50 + map={$backups.dirs} 51 + selectedPath={$page.fullPath + '/' + $page.name} 52 + name={child} 53 + fullPath={$backups.rootPath + '/' + child} 54 + on:click={sidebarClick} /> 55 + {/if} 56 + {/each} 57 + {/if} 58 + </div> 105 59 </div> 106 - <div class="page" /> 60 + <Page /> 107 61 108 62 <style lang="sass"> 109 63 $easing: cubic-bezier(0.4, 0.0, 0.2, 1) 110 64 :global(html) 111 65 height: 100% 112 66 background-color: hsl(230, 72%, 7%) 113 - font-family: Karla, Arial, Helvetica, sans-serif 67 + font-family: 'Open Sans', Arial, Helvetica, sans-serif 114 68 font-size: 18px 115 69 color: #f2f2f2 70 + color-scheme: dark 116 71 :global(body) 117 72 height: 100% 118 73 margin: 0px ··· 120 75 box-sizing: border-box 121 76 border-top: 0px 122 77 background-image: radial-gradient(circle at 50% 27%, hsl(230, 65%, 33%), transparent 100%),radial-gradient(circle at 0% 90%, hsl(230, 53%, 38%), transparent 40%),radial-gradient(circle at 5% 1%, hsl(230, 71%, 4%), transparent 100%),radial-gradient(circle at 50% 50%, #000000, #000000 100%) 78 + background-image: radial-gradient(circle at -10% -10%, hsl(230, 65%, 30%), hsl(230, 72%, 9%) 110%) 79 + background-image: radial-gradient(circle at -10% -10%, #25282F, #14101C 110%) 80 + :global(p) 81 + color: hsla(216, 50%, 85%, 0.8) 82 + font-size: 15px 123 83 .sidebar 124 84 flex-shrink: 0 125 85 flex-grow: 1 ··· 133 93 border-right: 1px solid hsla(230, 100%, 85%, 0.12) 134 94 display: flex 135 95 flex-direction: column 136 - .page 137 - width: 100% 138 - height: 100% 139 96 button 97 + font-family: inherit 140 98 user-select: none 141 99 cursor: default 142 100 outline: none 143 101 box-sizing: border-box 144 - padding: 9px 18px 102 + padding: 7px 18px 145 103 text-align: center 146 104 font-size: 13px 147 105 margin: 15px ··· 149 107 border-radius: 5px 150 108 background-color: hsla(230, 80%, 75%, 0.2) 151 109 color: hsla(230, 100%, 90%, 0.8) 152 - font-weight: 500 110 + font-weight: 530 153 111 text-shadow: 0px 0.1em 0.8em hsla(230, 30%, 7%, 1) 154 112 transition: all 240ms $easing 155 113 &:focus 156 114 box-shadow: 0px 0px 0px 2px hsla(230, 100%, 80%, 0.5) 157 115 &.disabled, &:hover 158 - font-weight: 700 159 - letter-spacing: 0.02em 116 + font-weight: 600 117 + letter-spacing: 0.05em 160 118 &.disabled, &:active 161 119 opacity: 0.75 120 + .content 121 + overflow: auto 122 + height: 10px 123 + flex-grow: 1 162 124 </style>
+87
src/Page.svelte
··· 1 + <script lang="ts"> 2 + import { runCmd } from './general' 3 + import PageItem, { ItemClickEvent } from './PageItem.svelte' 4 + import { backups, page } from './page' 5 + 6 + let selectedPath = '' 7 + 8 + type DirItem = { 9 + [key: string]: number 10 + } 11 + type DirMap = { 12 + [key: string]: DirItem 13 + } 14 + let dirMap: DirMap | null = null 15 + 16 + let loading = false 17 + async function compare() { 18 + if (loading || $backups === null) { 19 + return 20 + } 21 + const fullPathParent = $page.fullPath.substring(0, $page.fullPath.lastIndexOf('/')) 22 + const dir = $backups.dirs[fullPathParent] 23 + const selectedBackupIndex = dir.indexOf($page.name) 24 + if (selectedBackupIndex >= 1) { 25 + const prevBackup = fullPathParent + '/' + dir[selectedBackupIndex - 1] 26 + const args = { old: prevBackup, new: $page.fullPath } 27 + loading = true 28 + dirMap = (await runCmd('compare_backups', args)) as DirMap 29 + console.log(dirMap) 30 + } 31 + loading = false 32 + } 33 + 34 + function itemClick(e: ItemClickEvent) { 35 + if (e.detail.isFolder) { 36 + e.detail.toggleChildren() 37 + } 38 + selectedPath = e.detail.fullPath + '/' + e.detail.name 39 + } 40 + </script> 41 + 42 + {#if $page.fullPath === ''} 43 + <main class="empty"> 44 + <p>You can open a backup from the sidebar when it's loaded</p> 45 + <button on:click={compare}>Load</button> 46 + </main> 47 + {:else} 48 + <main> 49 + <div class="bar">{$page.fullPath}</div> 50 + <button on:click={compare}> 51 + {#if loading} 52 + Loading... 53 + {:else} 54 + Load 55 + {/if} 56 + </button> 57 + {#if dirMap !== null} 58 + {#each Object.entries(dirMap['/']) as [childName, size]} 59 + <PageItem 60 + map={dirMap} 61 + {selectedPath} 62 + name={childName} 63 + fullPath={'/' + childName} 64 + on:click={itemClick} /> 65 + {/each} 66 + {/if} 67 + </main> 68 + {/if} 69 + 70 + <style lang="sass"> 71 + main 72 + width: 100% 73 + height: 100% 74 + box-sizing: border-box 75 + overflow: auto 76 + main.empty 77 + display: flex 78 + align-items: center 79 + justify-content: center 80 + font-size: 14px 81 + padding: 30px 82 + text-align: center 83 + .bar 84 + background-color: hsla(230, 80%, 90%, 0.1) 85 + font-size: 13px 86 + padding: 5px 10px 87 + </style>
+90
src/PageItem.svelte
··· 1 + <script lang="ts" context="module"> 2 + export type ItemClickEventDetail = { 3 + name: string 4 + fullPath: string 5 + isFolder: boolean 6 + toggleChildren: () => void 7 + } 8 + export type ItemClickEvent = CustomEvent<ItemClickEventDetail> 9 + </script> 10 + 11 + <script lang="ts"> 12 + import { createEventDispatcher } from 'svelte' 13 + 14 + type DirItem = { 15 + [key: string]: number 16 + } 17 + type DirMap = { 18 + [key: string]: DirItem 19 + } 20 + 21 + export let map: DirMap 22 + 23 + export let name: string 24 + export let fullPath: string 25 + $: isFolder = map[fullPath] !== undefined 26 + 27 + export let selectedPath: string 28 + export let open = false 29 + 30 + export let indentLevel = 0 31 + 32 + const dispatch = createEventDispatcher<{ click: ItemClickEventDetail }>() 33 + 34 + function itemClick() { 35 + dispatch('click', { 36 + name, 37 + fullPath, 38 + isFolder, 39 + toggleChildren: () => { 40 + open = !open 41 + }, 42 + }) 43 + } 44 + </script> 45 + 46 + <div 47 + class="item" 48 + class:open 49 + on:click={itemClick} 50 + style={`padding-left: ${14 * indentLevel + 10}px`} 51 + class:selected={selectedPath === fullPath + '/' + name}> 52 + {#if isFolder} 53 + <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" 54 + ><path d="M21 12l-18 12v-24z" /></svg> 55 + {/if} 56 + {name} 57 + </div> 58 + <div class="children"> 59 + {#if open && isFolder} 60 + {#each Object.entries(map[fullPath]) as [childName, size]} 61 + <svelte:self 62 + {map} 63 + {selectedPath} 64 + name={childName} 65 + fullPath={fullPath + '/' + childName} 66 + on:click 67 + indentLevel={indentLevel + 1} /> 68 + {/each} 69 + {/if} 70 + </div> 71 + 72 + <style lang="sass"> 73 + .item 74 + font-size: 14px 75 + color: hsla(216, 50%, 70%, 0.75) 76 + cursor: default 77 + user-select: none 78 + padding: 4px 0px 79 + box-sizing: border-box 80 + width: 100% 81 + &.selected 82 + background-color: hsla(216, 70%, 70%, 0.2) 83 + &.open svg 84 + transform: rotate(90deg) 85 + svg 86 + fill: hsla(216, 50%, 70%, 0.75) 87 + width: 10px 88 + height: 10px 89 + margin-right: 1px 90 + </style>
+5 -2
src/general.ts
··· 5 5 } 6 6 7 7 // eslint-disable-next-line @typescript-eslint/no-explicit-any 8 - export async function runCmd<T = any>(cmd: string, options: { [key: string]: T } = {}) { 9 - return (await invoke(cmd, options).catch(popup)) as T 8 + export async function runCmd<T = any>(cmd: string, options: { [key: string]: any } = {}) { 9 + return (await invoke(cmd, options).catch((msg) => { 10 + popup(msg) 11 + throw msg 12 + })) as T 10 13 }
+3 -3
src/index.html
··· 6 6 7 7 <style> 8 8 @font-face { 9 - font-family: Karla; 10 - src: url("/Karla-VariableFont_wght.ttf"); 11 - font-weight: 200 800; 9 + font-family: 'Open Sans'; 10 + src: url("/OpenSans-VariableFont_wdth,wght.ttf"); 11 + font-weight: 300 800; 12 12 font-stretch: 75% 125; 13 13 font-style: normal; 14 14 }
+67
src/page.ts
··· 1 1 import { writable } from 'svelte/store' 2 + import { runCmd } from './general' 3 + 4 + export type Backups = { 5 + dirs: DirMap 6 + rootPath: string 7 + } 8 + export type DirMap = { 9 + '/': string[] 10 + [name: string]: string[] 11 + } 12 + 13 + export const backups = writable<Backups | null>(null) 2 14 3 15 export const page = writable({ 4 16 fullPath: '', 5 17 name: '', 6 18 }) 19 + 20 + export function close() { 21 + page.set({ 22 + fullPath: '', 23 + name: '', 24 + }) 25 + backups.set(null) 26 + } 27 + 28 + function parseDirs(paths: string[]): DirMap { 29 + const dirmap: DirMap = { '/': [] } 30 + paths.forEach((path) => { 31 + let base = path.substring(path.lastIndexOf('/') + 1) 32 + let parent = path.substring(0, path.lastIndexOf('/')) 33 + do { 34 + if (parent === '') { 35 + dirmap['/'].push(base) 36 + break 37 + } else if (dirmap[parent] === undefined) { 38 + dirmap[parent] = [base] 39 + } else { 40 + dirmap[parent].push(base) 41 + break 42 + } 43 + base = parent.substring(parent.lastIndexOf('/') + 1) 44 + parent = parent.substring(0, parent.lastIndexOf('/')) 45 + } while (dirmap[parent] === undefined) 46 + }) 47 + return dirmap 48 + } 49 + 50 + export function parseStdout(stdout: string | null): Backups | null { 51 + if (stdout === null) { 52 + return null 53 + } 54 + const paths = stdout.split('\n').filter((path) => path !== '') 55 + const dirs = parseDirs(paths) 56 + let rootPath = '/' 57 + if (dirs[rootPath].length === 1 && dirs[rootPath][0] === 'Volumes') { 58 + rootPath += 'Volumes' 59 + if (dirs[rootPath].length === 1 && dirs[rootPath][0] === 'Time Machine Backups') { 60 + rootPath += '/Time Machine Backups' 61 + if (dirs[rootPath].length === 1 && dirs[rootPath][0] === 'Backups.backupdb') { 62 + rootPath += '/Backups.backupdb' 63 + } 64 + } 65 + } 66 + return { dirs, rootPath } 67 + } 68 + 69 + export async function loadBackups() { 70 + const stdout = (await runCmd('load_backups')) as string | null 71 + backups.set(parseStdout(stdout)) 72 + console.log(parseStdout(stdout)) 73 + }