diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9f9fcd3..c4bc7a9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -405,17 +405,14 @@ fn resolve_server_binary( #[cfg(target_os = "macos")] { - // Root of Moku.app/Contents/ — scan every subdirectory level by level. let resource_dir = app.path().resource_dir().unwrap_or_default(); let contents_dir = resource_dir - .parent() // Moku.app/Contents/ + .parent() .unwrap_or(&resource_dir) .to_path_buf(); do_log(log, &format!("[resolve] macOS contents_dir = {:?}", contents_dir)); - // Native-binary names we recognise (most specific first so arch-specific - // names win over the generic "suwayomi-server" if both somehow exist). const NATIVE_NAMES: &[&str] = &[ "suwayomi-server-aarch64-apple-darwin", "suwayomi-server-x86_64-apple-darwin", @@ -425,11 +422,8 @@ fn resolve_server_binary( "tachidesk-server", ]; - // Collect every directory inside Contents/, grouped by depth so we - // search shallower levels first (BFS order via WalkDir min/max_depth). - // We go up to depth 8 which is more than enough for any real bundle. let mut found_binary: Option = None; - let mut found_java: Option<(PathBuf, PathBuf)> = None; // (java_exe, jar) + let mut found_java: Option<(PathBuf, PathBuf)> = None; 'outer: for depth in 0u8..=8 { let entries: Vec = WalkDir::new(&contents_dir) @@ -444,7 +438,6 @@ fn resolve_server_binary( for dir in &entries { do_log(log, &format!("[resolve] scanning depth={} dir={:?}", depth, dir)); - // 1. Look for a native server binary in this directory. for name in NATIVE_NAMES { let p = dir.join(name); if p.exists() { @@ -458,15 +451,10 @@ fn resolve_server_binary( } } - // 2. Look for a JRE java binary paired with a .jar in the same - // or sibling directories. We record the first hit and keep - // scanning natives; if no native is ever found we fall back - // to this. if found_java.is_none() { let java_exe = dir.join("bin").join("java"); if java_exe.exists() { do_log(log, &format!("[resolve] found java: {:?}", java_exe)); - // Search upward from the JRE dir for a .jar file. let mut search = dir.as_path(); 'jar: for _ in 0..5 { if let Ok(rd) = std::fs::read_dir(search) { @@ -479,7 +467,6 @@ fn resolve_server_binary( } } } - // Also look in a sibling `bin/` directory. let bin_sibling = search.join("bin"); if let Ok(rd) = std::fs::read_dir(&bin_sibling) { for entry in rd.filter_map(|e| e.ok()) { @@ -648,7 +635,6 @@ async fn download_and_install_update(app: tauri::AppHandle, tag: String) -> Resu .build() .map_err(|e| e.to_string())?; - // Fetch the specific release by tag to get its asset list. let url = format!("https://api.github.com/repos/Youwes09/Moku/releases/tags/{}", tag); let resp = client.get(&url).send().await.map_err(|e| e.to_string())?; if !resp.status().is_success() { @@ -668,7 +654,6 @@ async fn download_and_install_update(app: tauri::AppHandle, tag: String) -> Resu .find(|a| a.name.ends_with("_x64-setup.exe")) .ok_or_else(|| format!("No x64-setup.exe asset found in release {}", tag))?; - // Stream download with progress events. let total = if asset.size > 0 { Some(asset.size) } else { None }; let mut resp = client.get(&asset.browser_download_url).send().await.map_err(|e| e.to_string())?; @@ -683,7 +668,6 @@ async fn download_and_install_update(app: tauri::AppHandle, tag: String) -> Resu } drop(file); - // Launch the NSIS installer silently without a visible cmd window. use std::os::windows::process::CommandExt; const CREATE_NO_WINDOW: u32 = 0x08000000; std::process::Command::new(&tmp_path) @@ -731,7 +715,6 @@ fn open_path(path: String) -> Result<(), String> { Ok(()) } - #[tauri::command] async fn pick_downloads_folder(app: tauri::AppHandle) -> Option { use tauri_plugin_dialog::DialogExt; @@ -742,6 +725,83 @@ async fn pick_downloads_folder(app: tauri::AppHandle) -> Option { .map(|p| p.to_string()) } +fn moku_backup_dir(app: &tauri::AppHandle) -> PathBuf { + app.path().app_data_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join("backups") +} + +#[tauri::command] +async fn export_app_data(app: tauri::AppHandle, json: String) -> Result { + use tauri_plugin_dialog::DialogExt; + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let filename = format!("moku-backup-{}.json", now); + + let path = app.dialog() + .file() + .set_title("Save Moku app data backup") + .set_file_name(&filename) + .blocking_save_file() + .ok_or("Cancelled")?; + + let dest = PathBuf::from(path.to_string()); + std::fs::write(&dest, json.as_bytes()).map_err(|e| e.to_string())?; + + Ok(dest.to_string_lossy().into_owned()) +} + +#[tauri::command] +async fn import_app_data(app: tauri::AppHandle) -> Result { + use tauri_plugin_dialog::DialogExt; + + let path = app.dialog() + .file() + .set_title("Open Moku app data backup") + .blocking_pick_file() + .ok_or("Cancelled")?; + + let src = PathBuf::from(path.to_string()); + let contents = std::fs::read_to_string(&src).map_err(|e| e.to_string())?; + + Ok(contents) +} + +#[tauri::command] +fn auto_backup_app_data(app: tauri::AppHandle, json: String) -> Result<(), String> { + let backup_dir = moku_backup_dir(&app); + std::fs::create_dir_all(&backup_dir).map_err(|e| e.to_string())?; + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let dest = backup_dir.join(format!("auto-moku-backup-{}.json", now)); + std::fs::write(&dest, json.as_bytes()).map_err(|e| e.to_string())?; + + let mut entries: Vec<_> = std::fs::read_dir(&backup_dir) + .map_err(|e| e.to_string())? + .filter_map(|e| e.ok()) + .filter(|e| e.file_name().to_string_lossy().starts_with("auto-moku-backup-")) + .collect(); + + entries.sort_by_key(|e| e.file_name()); + + for old in entries.iter().take(entries.len().saturating_sub(5)) { + let _ = std::fs::remove_file(old.path()); + } + + Ok(()) +} + +#[tauri::command] +fn get_auto_backup_dir(app: tauri::AppHandle) -> String { + moku_backup_dir(&app).to_string_lossy().into_owned() +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -766,6 +826,10 @@ pub fn run() { restart_app, open_path, pick_downloads_folder, + export_app_data, + import_app_data, + auto_backup_app_data, + get_auto_backup_dir, ]) .setup(|_app| Ok(())) .on_window_event(|window, event| { diff --git a/src/core/backup.ts b/src/core/backup.ts new file mode 100644 index 0000000..fa2e9e5 --- /dev/null +++ b/src/core/backup.ts @@ -0,0 +1,38 @@ +import { invoke } from "@tauri-apps/api/core"; + +function collectAppData(): Record { + const data: Record = {}; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key !== null) data[key] = localStorage.getItem(key) ?? ""; + } + return data; +} + +function applyAppData(data: Record): void { + localStorage.clear(); + for (const [key, value] of Object.entries(data)) { + localStorage.setItem(key, value); + } +} + +export async function exportAppData(): Promise { + const json = JSON.stringify(collectAppData(), null, 2); + await invoke("export_app_data", { json }); +} + +export async function importAppData(): Promise { + const json = await invoke("import_app_data"); + const data: Record = JSON.parse(json); + applyAppData(data); + location.reload(); +} + +export async function autoBackupAppData(): Promise { + try { + const json = JSON.stringify(collectAppData()); + await invoke("auto_backup_app_data", { json }); + } catch (e) { + console.warn("[moku] auto-backup failed:", e); + } +} \ No newline at end of file diff --git a/src/features/settings/sections/AboutSettings.svelte b/src/features/settings/sections/AboutSettings.svelte index 12fd104..f80dcd0 100644 --- a/src/features/settings/sections/AboutSettings.svelte +++ b/src/features/settings/sections/AboutSettings.svelte @@ -3,6 +3,7 @@ import { listen } from "@tauri-apps/api/event"; import { getVersion } from "@tauri-apps/api/app"; import { open as openUrl } from "@tauri-apps/plugin-shell"; + import { autoBackupAppData } from "@core/backup"; interface ReleaseInfo { tag_name: string; name: string; body: string; published_at: string; html_url: string; } type UpdatePhase = "idle" | "downloading" | "launching" | "ready" | "error"; @@ -87,6 +88,7 @@ targetTag = release.tag_name; updatePhase = "downloading"; updateError = null; dlBytes = 0; dlTotal = null; try { if (IS_WINDOWS) { + await autoBackupAppData(); try { await invoke("kill_server"); } catch {} await invoke("download_and_install_update", { tag: release.tag_name }); updatePhase = "ready"; diff --git a/src/features/settings/sections/StorageSettings.svelte b/src/features/settings/sections/StorageSettings.svelte index 3a635bb..07678a3 100644 --- a/src/features/settings/sections/StorageSettings.svelte +++ b/src/features/settings/sections/StorageSettings.svelte @@ -8,6 +8,7 @@ import { SET_DOWNLOADS_PATH, SET_LOCAL_SOURCE_PATH } from "@api/mutations/downloads"; import { untrack } from "svelte"; import { store, updateSettings, addToast } from "@store/state.svelte"; + import { exportAppData, importAppData } from "@core/backup"; interface StorageInfo { manga_bytes: number; total_bytes: number; free_bytes: number; path: string; } @@ -52,8 +53,9 @@ let extraScanDirs = $state([...(store.settings.extraScanDirs ?? [])]); let newScanDir = $state(""); let multiStorageInfos = $state<(StorageInfo & { label: string })[]>([]); - let advStorageOpen = $state(false); - let backupSectionOpen = $state(false); + let advStorageOpen = $state(false); + let backupSectionOpen = $state(false); + let appDataSectionOpen = $state(false); async function fetchStorage() { storageLoading = true; storageError = null; @@ -324,6 +326,39 @@ finally { validateLoading = false; } } + let appDataExporting = $state(false); + let appDataImporting = $state(false); + let appDataError = $state(null); + let appDataMsg = $state(null); + let appDataBackupDir = $state(null); + + $effect(() => { + invoke("get_auto_backup_dir").then(d => { appDataBackupDir = d; }).catch(() => {}); + }); + + async function handleExportAppData() { + appDataExporting = true; appDataError = null; appDataMsg = null; + try { + await exportAppData(); + appDataMsg = "Backup saved."; + setTimeout(() => appDataMsg = null, 3000); + } catch (e: any) { + if (String(e).includes("Cancelled")) return; + appDataError = e?.message ?? String(e); + } finally { appDataExporting = false; } + } + + async function handleImportAppData() { + appDataImporting = true; appDataError = null; appDataMsg = null; + try { + await importAppData(); + } catch (e: any) { + if (String(e).includes("Cancelled")) { appDataImporting = false; return; } + appDataError = e?.message ?? String(e); + appDataImporting = false; + } + } + $effect(() => { untrack(() => { loadBackupList(); fetchStorage(); }); }); $effect(() => { return () => stopRestorePoll(); }); @@ -512,7 +547,6 @@ {#if !isExternalServer} {/if} - @@ -638,4 +672,56 @@ {/if} +
+ + {#if appDataSectionOpen} +
+ +
+
+ Export settings + Save all Moku app settings to a JSON file via a native save dialog. +
+ +
+ +
+
+ Import settings + Restore from a previously exported JSON file. Reloads the app immediately. +
+ +
+ + {#if appDataError} +
{appDataError}
+ {/if} + + {#if appDataMsg} +
+ {appDataMsg} +
+ {/if} + + {#if appDataBackupDir} +
+
+ Auto-backup location + Pre-update snapshots are kept here (last 5). +
+ +
+ {/if} + +
+ {/if} +
+ \ No newline at end of file