Files
Moku/src-tauri/src/lib.rs
T
2026-03-29 15:38:39 -05:00

555 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::path::PathBuf;
use std::sync::Mutex;
use std::io::Write;
use sysinfo::Disks;
use serde::Serialize;
use tauri::{Manager, WindowEvent};
#[cfg(target_os = "windows")]
use tauri::Emitter;
use tauri_plugin_shell::{ShellExt, process::CommandChild};
use walkdir::WalkDir;
struct ServerState(Mutex<Option<CommandChild>>);
#[derive(Serialize)]
pub struct StorageInfo {
manga_bytes: u64,
total_bytes: u64,
free_bytes: u64,
path: String,
}
#[derive(Serialize, Debug)]
#[serde(tag = "kind", content = "message")]
pub enum SpawnError {
NotConfigured(String),
SpawnFailed(String),
}
// ── Update types ──────────────────────────────────────────────────────────────
/// A single GitHub release returned to the frontend.
#[derive(Serialize, Clone)]
pub struct ReleaseInfo {
pub tag_name: String,
pub name: String,
pub body: String,
pub published_at: String,
pub html_url: String,
}
/// Progress event emitted during download — matches what the frontend listens for.
#[derive(Clone, serde::Serialize)]
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
struct UpdateProgress {
downloaded: u64,
total: Option<u64>,
}
/// Strip the \\?\ extended-length path prefix that Windows adds to long paths.
/// Java and many other tools do not accept this prefix and will fail silently.
fn strip_unc(path: PathBuf) -> PathBuf {
let s = path.to_string_lossy();
if let Some(stripped) = s.strip_prefix(r"\\?\") {
PathBuf::from(stripped)
} else {
path
}
}
fn resolve_downloads_path(downloads_path: &str) -> PathBuf {
if !downloads_path.trim().is_empty() {
return PathBuf::from(downloads_path);
}
let base = std::env::var("XDG_DATA_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| dirs::data_dir().unwrap_or_else(|| PathBuf::from("/")));
base.join("Tachidesk/downloads")
}
#[tauri::command]
fn get_storage_info(downloads_path: String) -> Result<StorageInfo, String> {
let path = resolve_downloads_path(&downloads_path);
let manga_bytes = if path.exists() {
WalkDir::new(&path)
.into_iter()
.filter_map(|e| e.ok())
.filter_map(|e| e.metadata().ok())
.filter(|m| m.is_file())
.map(|m| m.len())
.sum()
} else {
0
};
let stat_path = if path.exists() {
path.clone()
} else {
dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"))
};
let disks = Disks::new_with_refreshed_list();
let disk = disks
.iter()
.filter(|d| stat_path.starts_with(d.mount_point()))
.max_by_key(|d| d.mount_point().as_os_str().len())
.ok_or_else(|| "Could not find disk for path".to_string())?;
Ok(StorageInfo {
manga_bytes,
total_bytes: disk.total_space(),
free_bytes: disk.available_space(),
path: path.to_string_lossy().into_owned(),
})
}
/// Returns the OS/monitor DPI scale factor for the window's current monitor.
/// This is the real hardware scale — 1.0 on standard displays, 2.0 on HiDPI/4K,
/// 1.251.5 on Windows displays with OS-level scaling applied.
/// The frontend multiplies this by the user's uiZoom preference to get the
/// final effective zoom applied to document.documentElement.
#[tauri::command]
fn get_platform_ui_scale(window: tauri::Window) -> f64 {
window.scale_factor().unwrap_or(1.0)
}
fn kill_tachidesk(app: &tauri::AppHandle) {
let state = app.state::<ServerState>();
if let Some(child) = state.0.lock().unwrap().take() {
let _ = child.kill();
}
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let _ = std::process::Command::new("taskkill")
.args(["/F", "/FI", "IMAGENAME eq java.exe"])
.creation_flags(CREATE_NO_WINDOW)
.status();
// Poll until no java.exe remains (up to ~3 s) so the installer can
// overwrite the JRE DLLs without hitting a sharing-violation error.
for _ in 0..30 {
let still_running = std::process::Command::new("tasklist")
.args(["/FI", "IMAGENAME eq java.exe", "/NH"])
.creation_flags(CREATE_NO_WINDOW)
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).contains("java.exe"))
.unwrap_or(false);
if !still_running {
break;
}
std::thread::sleep(std::time::Duration::from_millis(100));
}
}
#[cfg(not(target_os = "windows"))]
let _ = std::process::Command::new("pkill")
.args(["-f", "tachidesk"])
.status();
}
const DEFAULT_SERVER_CONF: &str = r#"server.ip = "127.0.0.1"
server.port = 4567
server.webUIEnabled = false
server.initialOpenInBrowserEnabled = false
server.systemTrayEnabled = false
server.webUIInterface = "browser"
server.webUIFlavor = "WebUI"
server.webUIChannel = "stable"
server.electronPath = ""
server.debugLogsEnabled = false
server.downloadAsCbz = true
server.autoDownloadNewChapters = false
server.globalUpdateInterval = 12
server.maxSourcesInParallel = 6
server.extensionRepos = []
"#;
fn seed_server_conf(data_dir: &PathBuf) {
let conf_path = data_dir.join("server.conf");
if !conf_path.exists() {
if let Err(e) = std::fs::create_dir_all(data_dir) {
eprintln!("Could not create Suwayomi data dir: {e}");
return;
}
if let Err(e) = std::fs::write(&conf_path, DEFAULT_SERVER_CONF) {
eprintln!("Could not write server.conf: {e}");
}
return;
}
let Ok(contents) = std::fs::read_to_string(&conf_path) else { return };
let patched = patch_conf_key(
patch_conf_key(
patch_conf_key(contents, "server.webUIEnabled", "false"),
"server.initialOpenInBrowserEnabled", "false",
),
"server.systemTrayEnabled", "false",
);
let _ = std::fs::write(&conf_path, patched);
}
fn patch_conf_key(text: String, key: &str, value: &str) -> String {
let replacement = format!("{key} = {value}");
let lines: Vec<&str> = text.lines().collect();
if let Some(pos) = lines.iter().position(|l| l.trim_start().starts_with(key)) {
let mut out = lines
.iter()
.enumerate()
.map(|(i, l)| if i == pos { replacement.as_str() } else { l })
.collect::<Vec<_>>()
.join("\n");
out.push('\n');
return out;
}
let mut out = text;
if !out.ends_with('\n') { out.push('\n'); }
out.push_str(&replacement);
out.push('\n');
out
}
fn suwayomi_data_dir() -> PathBuf {
#[cfg(target_os = "windows")]
{
dirs::data_dir()
.unwrap_or_else(|| PathBuf::from("C:\\ProgramData"))
.join("moku\\tachidesk")
}
#[cfg(target_os = "macos")]
{
dirs::data_dir()
.unwrap_or_else(|| dirs::home_dir().unwrap_or_else(|| PathBuf::from("~")))
.join("dev.moku.app/tachidesk")
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
{
let base = std::env::var("XDG_DATA_HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| dirs::data_dir().unwrap_or_else(|| PathBuf::from("/tmp")));
base.join("moku/tachidesk")
}
}
struct ServerInvocation {
bin: String,
args: Vec<String>,
working_dir: Option<PathBuf>,
}
fn do_log(log: &mut Option<std::fs::File>, msg: &str) {
if let Some(f) = log {
let _ = writeln!(f, "{}", msg);
}
}
fn resolve_server_binary(
binary: &str,
app: &tauri::AppHandle,
log: &mut Option<std::fs::File>,
) -> Result<ServerInvocation, SpawnError> {
do_log(log, &format!("[resolve] binary arg = {:?}", binary));
// 1. User-specified binary path
if !binary.trim().is_empty() {
let path = strip_unc(PathBuf::from(binary.trim()));
do_log(log, &format!("[resolve] user path: {:?} exists={}", path, path.exists()));
if path.exists() {
return Ok(ServerInvocation {
bin: path.to_string_lossy().into_owned(),
args: vec![],
working_dir: path.parent().map(|p| p.to_path_buf()),
});
}
return Err(SpawnError::NotConfigured(
format!("Configured binary not found: {}", path.display()),
));
}
// 2. Bundled sidecar (Windows / Linux AppImage)
#[cfg(not(target_os = "macos"))]
{
let resource_dir = app.path().resource_dir().unwrap_or_default();
let candidates = ["suwayomi-launcher", "suwayomi-launcher.sh", "tachidesk-server"];
for name in &candidates {
let p = resource_dir.join(name);
do_log(log, &format!("[resolve] sidecar candidate: {:?} exists={}", p, p.exists()));
if p.exists() {
do_log(log, &format!("[resolve] using sidecar: {:?}", p));
return Ok(ServerInvocation {
bin: p.to_string_lossy().into_owned(),
args: vec![],
working_dir: Some(resource_dir),
});
}
}
}
// 3. macOS app bundle — look in MacOS/ and Resources/
#[cfg(target_os = "macos")]
{
let resource_dir = app.path().resource_dir().unwrap_or_default();
let macos_dir = resource_dir.parent()
.map(|p| p.join("MacOS"))
.unwrap_or_default();
let candidates = ["suwayomi-launcher", "suwayomi-launcher.sh", "tachidesk-server"];
// Search MacOS/ first (correct location), then Resources/ as fallback
// for flat dev layouts where the script sits next to resources.
for search_dir in &[&macos_dir, &resource_dir] {
for name in &candidates {
let p = search_dir.join(name);
do_log(log, &format!("[resolve] macOS candidate: {:?} exists={}", p, p.exists()));
if p.exists() {
do_log(log, &format!("[resolve] using macOS sidecar: {:?}", p));
return Ok(ServerInvocation {
bin: p.to_string_lossy().into_owned(),
args: vec![],
working_dir: None,
});
}
}
}
}
do_log(log, "[resolve] trying PATH fallback");
for name in &["suwayomi-server", "tachidesk-server"] {
let found = std::process::Command::new("which")
.arg(name)
.output()
.map(|o| o.status.success())
.unwrap_or(false);
do_log(log, &format!("[resolve] PATH check {:?}: found={}", name, found));
if found {
do_log(log, &format!("[resolve] using PATH binary: {}", name));
return Ok(ServerInvocation {
bin: name.to_string(),
args: vec![],
working_dir: None,
});
}
}
do_log(log, "[resolve] FAILED — no binary found anywhere");
Err(SpawnError::NotConfigured(
"Server binary not found. Install Suwayomi-Server or set the path in Settings.".to_string(),
))
}
#[tauri::command]
fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), SpawnError> {
{
let state = app.state::<ServerState>();
if state.0.lock().unwrap().is_some() {
return Ok(());
}
}
let data_dir = suwayomi_data_dir();
let log_path = data_dir.join("moku-spawn.log");
let _ = std::fs::create_dir_all(&data_dir);
let mut log = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
.ok();
do_log(&mut log, "");
do_log(&mut log, "========================================");
do_log(&mut log, &format!("[spawn_server] called at {:?}", std::time::SystemTime::now()));
do_log(&mut log, &format!("[spawn_server] binary arg = {:?}", binary));
do_log(&mut log, &format!("[spawn_server] data_dir = {:?}", data_dir));
do_log(&mut log, &format!("[spawn_server] log file = {:?}", log_path));
do_log(&mut log, &format!("[spawn_server] APPDATA = {:?}", std::env::var("APPDATA")));
do_log(&mut log, &format!("[spawn_server] LOCALAPPDATA = {:?}", std::env::var("LOCALAPPDATA")));
do_log(&mut log, &format!("[spawn_server] current_dir = {:?}", std::env::current_dir()));
seed_server_conf(&data_dir);
do_log(&mut log, "[spawn_server] server.conf seeded");
let mut invocation = match resolve_server_binary(&binary, &app, &mut log) {
Ok(i) => i,
Err(e) => {
do_log(&mut log, &format!("[spawn_server] resolve FAILED: {:?}", e));
return Err(e);
}
};
let bin_display = invocation.bin.clone();
let rootdir_flag = format!(
"-Dsuwayomi.tachidesk.config.server.rootDir={}",
data_dir.to_string_lossy()
);
invocation.args.insert(0, rootdir_flag);
let working_dir = invocation.working_dir
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
do_log(&mut log, &format!("[spawn_server] bin = {:?}", bin_display));
do_log(&mut log, &format!("[spawn_server] args = {:?}", invocation.args));
do_log(&mut log, &format!("[spawn_server] working_dir = {:?}", working_dir));
let cmd = app.shell()
.command(&invocation.bin)
.env("JAVA_TOOL_OPTIONS", "-Djava.awt.headless=true")
.args(&invocation.args)
.current_dir(&working_dir);
do_log(&mut log, "[spawn_server] calling cmd.spawn()...");
match cmd.spawn() {
Ok((_rx, child)) => {
do_log(&mut log, &format!("[spawn_server] SUCCESS — spawned: {}", bin_display));
*app.state::<ServerState>().0.lock().unwrap() = Some(child);
Ok(())
}
Err(e) => {
do_log(&mut log, &format!("[spawn_server] SPAWN FAILED: {}", e));
do_log(&mut log, &format!("[spawn_server] error kind: {:?}", e));
Err(SpawnError::SpawnFailed(e.to_string()))
}
}
}
#[tauri::command]
fn kill_server(app: tauri::AppHandle) -> Result<(), String> {
kill_tachidesk(&app);
Ok(())
}
// ── Update commands ───────────────────────────────────────────────────────────
/// Fetch the list of all GitHub releases so the frontend can show a version picker.
/// Uses tauri-plugin-http so it goes through Tauri's permission system.
#[tauri::command]
async fn list_releases() -> Result<Vec<ReleaseInfo>, String> {
use tauri_plugin_http::reqwest;
let client = reqwest::Client::builder()
.user_agent("Moku")
.build()
.map_err(|e| e.to_string())?;
let resp = client
.get("https://api.github.com/repos/Youwes09/Moku/releases?per_page=30")
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Err(format!("GitHub API returned {}", resp.status()));
}
#[derive(serde::Deserialize)]
struct GhRelease {
tag_name: String,
name: Option<String>,
body: Option<String>,
published_at: Option<String>,
html_url: String,
}
let body = resp.text().await.map_err(|e| e.to_string())?;
let releases: Vec<GhRelease> = serde_json::from_str(&body).map_err(|e| e.to_string())?;
Ok(releases
.into_iter()
.map(|r| ReleaseInfo {
tag_name: r.tag_name.clone(),
name: r.name.unwrap_or_else(|| r.tag_name.clone()),
body: r.body.unwrap_or_default(),
published_at: r.published_at.unwrap_or_default(),
html_url: r.html_url,
})
.collect())
}
/// Download and install the latest update using tauri-plugin-updater.
/// Emits `update-progress` events with `{ downloaded, total }` while downloading.
/// On Windows the installer runs in passive (silent) mode; the frontend prompts restart.
/// On other platforms this command is a no-op — the frontend opens the GitHub page instead.
#[tauri::command]
#[allow(unused_variables)]
async fn download_and_install_update(app: tauri::AppHandle) -> Result<(), String> {
#[cfg(not(target_os = "windows"))]
return Err("Native install is Windows-only; open the GitHub release page instead.".into());
#[cfg(target_os = "windows")]
{
use tauri_plugin_updater::UpdaterExt;
let updater = app.updater().map_err(|e| e.to_string())?;
let update = updater.check().await.map_err(|e| e.to_string())?;
let Some(update) = update else {
return Err("No update available from the updater endpoint.".into());
};
let app_clone = app.clone();
update
.download_and_install(
move |downloaded, total| {
let _ = app_clone.emit("update-progress", UpdateProgress { downloaded: downloaded as u64, total });
},
|| {},
)
.await
.map_err(|e| e.to_string())?;
Ok(())
}
}
/// Restart the app after a successful update install.
#[tauri::command]
fn restart_app(app: tauri::AppHandle) {
tauri::process::restart(&app.env());
}
// ── App entry point ───────────────────────────────────────────────────────────
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_drpc::init())
.plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.manage(ServerState(Mutex::new(None)))
.invoke_handler(tauri::generate_handler![
get_storage_info,
spawn_server,
kill_server,
get_platform_ui_scale,
list_releases,
download_and_install_update,
restart_app,
])
.setup(|_app| Ok(()))
.on_window_event(|window, event| {
if let WindowEvent::Destroyed = event {
kill_tachidesk(window.app_handle());
}
})
.run(tauri::generate_context!())
.expect("error while running moku");
}