Feat: Import & Export Store + Update Trigger

This commit is contained in:
Youwes09
2026-04-23 20:09:50 -05:00
parent bb7256c4f8
commit 4e6be5d9f5
4 changed files with 212 additions and 22 deletions
+83 -19
View File
@@ -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<ServerInvocation> = 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<PathBuf> = 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<String> {
use tauri_plugin_dialog::DialogExt;
@@ -742,6 +725,83 @@ async fn pick_downloads_folder(app: tauri::AppHandle) -> Option<String> {
.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<String, String> {
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<String, String> {
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| {
+38
View File
@@ -0,0 +1,38 @@
import { invoke } from "@tauri-apps/api/core";
function collectAppData(): Record<string, string> {
const data: Record<string, string> = {};
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<string, string>): void {
localStorage.clear();
for (const [key, value] of Object.entries(data)) {
localStorage.setItem(key, value);
}
}
export async function exportAppData(): Promise<void> {
const json = JSON.stringify(collectAppData(), null, 2);
await invoke("export_app_data", { json });
}
export async function importAppData(): Promise<void> {
const json = await invoke<string>("import_app_data");
const data: Record<string, string> = JSON.parse(json);
applyAppData(data);
location.reload();
}
export async function autoBackupAppData(): Promise<void> {
try {
const json = JSON.stringify(collectAppData());
await invoke("auto_backup_app_data", { json });
} catch (e) {
console.warn("[moku] auto-backup failed:", e);
}
}
@@ -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";
@@ -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<string[]>([...(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<string | null>(null);
let appDataMsg = $state<string | null>(null);
let appDataBackupDir = $state<string | null>(null);
$effect(() => {
invoke<string>("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(); });
</script>
@@ -512,7 +547,6 @@
{#if !isExternalServer}
<button class="s-btn" onclick={browseExtraScanDir}>Browse</button>
{/if}
</div>
</div>
@@ -638,4 +672,56 @@
{/if}
</div>
<div class="s-section">
<button class="s-collapsible-trigger" onclick={() => appDataSectionOpen = !appDataSectionOpen}>
<span class="s-label">App-Data Backup</span>
<svg class="s-collapsible-caret" class:open={appDataSectionOpen} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
</button>
{#if appDataSectionOpen}
<div class="s-collapsible-body">
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Export settings</span>
<span class="s-desc">Save all Moku app settings to a JSON file via a native save dialog.</span>
</div>
<button class="s-btn s-btn-accent" onclick={handleExportAppData} disabled={appDataExporting}>
{appDataExporting ? "Saving…" : "Export"}
</button>
</div>
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Import settings</span>
<span class="s-desc">Restore from a previously exported JSON file. Reloads the app immediately.</span>
</div>
<button class="s-btn" onclick={handleImportAppData} disabled={appDataImporting}>
{appDataImporting ? "Importing…" : "Import"}
</button>
</div>
{#if appDataError}
<div class="s-banner s-banner-error">{appDataError}</div>
{/if}
{#if appDataMsg}
<div class="s-row">
<span class="s-desc" style="color:var(--color-success,#4caf50)">{appDataMsg}</span>
</div>
{/if}
{#if appDataBackupDir}
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Auto-backup location</span>
<span class="s-desc">Pre-update snapshots are kept here (last 5).</span>
</div>
<button class="s-btn" onclick={() => invoke("open_path", { path: appDataBackupDir })}>Open folder</button>
</div>
{/if}
</div>
{/if}
</div>
</div>