mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Feat: Import & Export Store + Update Trigger
This commit is contained in:
+83
-19
@@ -405,17 +405,14 @@ fn resolve_server_binary(
|
|||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[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 resource_dir = app.path().resource_dir().unwrap_or_default();
|
||||||
let contents_dir = resource_dir
|
let contents_dir = resource_dir
|
||||||
.parent() // Moku.app/Contents/
|
.parent()
|
||||||
.unwrap_or(&resource_dir)
|
.unwrap_or(&resource_dir)
|
||||||
.to_path_buf();
|
.to_path_buf();
|
||||||
|
|
||||||
do_log(log, &format!("[resolve] macOS contents_dir = {:?}", contents_dir));
|
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] = &[
|
const NATIVE_NAMES: &[&str] = &[
|
||||||
"suwayomi-server-aarch64-apple-darwin",
|
"suwayomi-server-aarch64-apple-darwin",
|
||||||
"suwayomi-server-x86_64-apple-darwin",
|
"suwayomi-server-x86_64-apple-darwin",
|
||||||
@@ -425,11 +422,8 @@ fn resolve_server_binary(
|
|||||||
"tachidesk-server",
|
"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_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 {
|
'outer: for depth in 0u8..=8 {
|
||||||
let entries: Vec<PathBuf> = WalkDir::new(&contents_dir)
|
let entries: Vec<PathBuf> = WalkDir::new(&contents_dir)
|
||||||
@@ -444,7 +438,6 @@ fn resolve_server_binary(
|
|||||||
for dir in &entries {
|
for dir in &entries {
|
||||||
do_log(log, &format!("[resolve] scanning depth={} dir={:?}", depth, dir));
|
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 {
|
for name in NATIVE_NAMES {
|
||||||
let p = dir.join(name);
|
let p = dir.join(name);
|
||||||
if p.exists() {
|
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() {
|
if found_java.is_none() {
|
||||||
let java_exe = dir.join("bin").join("java");
|
let java_exe = dir.join("bin").join("java");
|
||||||
if java_exe.exists() {
|
if java_exe.exists() {
|
||||||
do_log(log, &format!("[resolve] found java: {:?}", java_exe));
|
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();
|
let mut search = dir.as_path();
|
||||||
'jar: for _ in 0..5 {
|
'jar: for _ in 0..5 {
|
||||||
if let Ok(rd) = std::fs::read_dir(search) {
|
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");
|
let bin_sibling = search.join("bin");
|
||||||
if let Ok(rd) = std::fs::read_dir(&bin_sibling) {
|
if let Ok(rd) = std::fs::read_dir(&bin_sibling) {
|
||||||
for entry in rd.filter_map(|e| e.ok()) {
|
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()
|
.build()
|
||||||
.map_err(|e| e.to_string())?;
|
.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 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())?;
|
let resp = client.get(&url).send().await.map_err(|e| e.to_string())?;
|
||||||
if !resp.status().is_success() {
|
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"))
|
.find(|a| a.name.ends_with("_x64-setup.exe"))
|
||||||
.ok_or_else(|| format!("No x64-setup.exe asset found in release {}", tag))?;
|
.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 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())?;
|
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);
|
drop(file);
|
||||||
|
|
||||||
// Launch the NSIS installer silently without a visible cmd window.
|
|
||||||
use std::os::windows::process::CommandExt;
|
use std::os::windows::process::CommandExt;
|
||||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||||
std::process::Command::new(&tmp_path)
|
std::process::Command::new(&tmp_path)
|
||||||
@@ -731,7 +715,6 @@ fn open_path(path: String) -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn pick_downloads_folder(app: tauri::AppHandle) -> Option<String> {
|
async fn pick_downloads_folder(app: tauri::AppHandle) -> Option<String> {
|
||||||
use tauri_plugin_dialog::DialogExt;
|
use tauri_plugin_dialog::DialogExt;
|
||||||
@@ -742,6 +725,83 @@ async fn pick_downloads_folder(app: tauri::AppHandle) -> Option<String> {
|
|||||||
.map(|p| p.to_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)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
@@ -766,6 +826,10 @@ pub fn run() {
|
|||||||
restart_app,
|
restart_app,
|
||||||
open_path,
|
open_path,
|
||||||
pick_downloads_folder,
|
pick_downloads_folder,
|
||||||
|
export_app_data,
|
||||||
|
import_app_data,
|
||||||
|
auto_backup_app_data,
|
||||||
|
get_auto_backup_dir,
|
||||||
])
|
])
|
||||||
.setup(|_app| Ok(()))
|
.setup(|_app| Ok(()))
|
||||||
.on_window_event(|window, event| {
|
.on_window_event(|window, event| {
|
||||||
|
|||||||
@@ -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 { listen } from "@tauri-apps/api/event";
|
||||||
import { getVersion } from "@tauri-apps/api/app";
|
import { getVersion } from "@tauri-apps/api/app";
|
||||||
import { open as openUrl } from "@tauri-apps/plugin-shell";
|
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; }
|
interface ReleaseInfo { tag_name: string; name: string; body: string; published_at: string; html_url: string; }
|
||||||
type UpdatePhase = "idle" | "downloading" | "launching" | "ready" | "error";
|
type UpdatePhase = "idle" | "downloading" | "launching" | "ready" | "error";
|
||||||
@@ -87,6 +88,7 @@
|
|||||||
targetTag = release.tag_name; updatePhase = "downloading"; updateError = null; dlBytes = 0; dlTotal = null;
|
targetTag = release.tag_name; updatePhase = "downloading"; updateError = null; dlBytes = 0; dlTotal = null;
|
||||||
try {
|
try {
|
||||||
if (IS_WINDOWS) {
|
if (IS_WINDOWS) {
|
||||||
|
await autoBackupAppData();
|
||||||
try { await invoke("kill_server"); } catch {}
|
try { await invoke("kill_server"); } catch {}
|
||||||
await invoke("download_and_install_update", { tag: release.tag_name });
|
await invoke("download_and_install_update", { tag: release.tag_name });
|
||||||
updatePhase = "ready";
|
updatePhase = "ready";
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import { SET_DOWNLOADS_PATH, SET_LOCAL_SOURCE_PATH } from "@api/mutations/downloads";
|
import { SET_DOWNLOADS_PATH, SET_LOCAL_SOURCE_PATH } from "@api/mutations/downloads";
|
||||||
import { untrack } from "svelte";
|
import { untrack } from "svelte";
|
||||||
import { store, updateSettings, addToast } from "@store/state.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; }
|
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 extraScanDirs = $state<string[]>([...(store.settings.extraScanDirs ?? [])]);
|
||||||
let newScanDir = $state("");
|
let newScanDir = $state("");
|
||||||
let multiStorageInfos = $state<(StorageInfo & { label: string })[]>([]);
|
let multiStorageInfos = $state<(StorageInfo & { label: string })[]>([]);
|
||||||
let advStorageOpen = $state(false);
|
let advStorageOpen = $state(false);
|
||||||
let backupSectionOpen = $state(false);
|
let backupSectionOpen = $state(false);
|
||||||
|
let appDataSectionOpen = $state(false);
|
||||||
|
|
||||||
async function fetchStorage() {
|
async function fetchStorage() {
|
||||||
storageLoading = true; storageError = null;
|
storageLoading = true; storageError = null;
|
||||||
@@ -324,6 +326,39 @@
|
|||||||
finally { validateLoading = false; }
|
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(() => { untrack(() => { loadBackupList(); fetchStorage(); }); });
|
||||||
$effect(() => { return () => stopRestorePoll(); });
|
$effect(() => { return () => stopRestorePoll(); });
|
||||||
</script>
|
</script>
|
||||||
@@ -512,7 +547,6 @@
|
|||||||
{#if !isExternalServer}
|
{#if !isExternalServer}
|
||||||
<button class="s-btn" onclick={browseExtraScanDir}>Browse</button>
|
<button class="s-btn" onclick={browseExtraScanDir}>Browse</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -638,4 +672,56 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
Reference in New Issue
Block a user