diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index c2001d7..d263f38 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -100,36 +100,36 @@ jobs: shell: bash run: | mkdir -p src-tauri/binaries - JAVAW=$(find suwayomi-extracted -path "*/jre/bin/javaw.exe" | head -1) - JAR=$(find suwayomi-extracted -name "Suwayomi-Launcher.jar" | head -1) - if [ -z "$JAVAW" ]; then - echo "ERROR: jre/bin/javaw.exe not found. Bundle contents:" + JAVA=$(find suwayomi-extracted -path "*/jre/bin/java.exe" | head -1) + JAR=$(find suwayomi-extracted -name "Suwayomi-Server.jar" | head -1) + if [ -z "$JAVA" ]; then + echo "ERROR: jre/bin/java.exe not found. Bundle contents:" find suwayomi-extracted -type f | head -50 exit 1 fi if [ -z "$JAR" ]; then - echo "ERROR: Suwayomi-Launcher.jar not found. Bundle contents:" + echo "ERROR: Suwayomi-Server.jar not found. Bundle contents:" find suwayomi-extracted -type f | head -50 exit 1 fi - echo "Found javaw: $JAVAW" - echo "Found jar: $JAR" + echo "Found java: $JAVA" + echo "Found jar: $JAR" cp -r suwayomi-extracted src-tauri/binaries/suwayomi-bundle - name: Validate staging shell: bash run: | - find src-tauri/binaries/suwayomi-bundle -path "*/jre/bin/javaw.exe" \ - | grep -q . || (echo "ERROR: jre/bin/javaw.exe missing" && exit 1) - find src-tauri/binaries/suwayomi-bundle -name "Suwayomi-Launcher.jar" \ - | grep -q . || (echo "ERROR: Suwayomi-Launcher.jar missing" && exit 1) + find src-tauri/binaries/suwayomi-bundle -path "*/jre/bin/java.exe" \ + | grep -q . || (echo "ERROR: jre/bin/java.exe missing" && exit 1) + find src-tauri/binaries/suwayomi-bundle -name "Suwayomi-Server.jar" \ + | grep -q . || (echo "ERROR: Suwayomi-Server.jar missing" && exit 1) echo "Staging OK" - name: Patch tauri.conf.json for CI shell: bash run: | sed -i 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json - jq '.bundle.resources = ["binaries/suwayomi-bundle/**"] | .bundle.externalBin = []' \ + jq '.bundle.resources = ["binaries/suwayomi-bundle/bin/Suwayomi-Server.jar", "binaries/suwayomi-bundle/jre/**/*"] | .bundle.externalBin = []' \ src-tauri/tauri.conf.json > tmp.json && mv tmp.json src-tauri/tauri.conf.json echo "tauri.conf.json patched:" cat src-tauri/tauri.conf.json diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 4138f6a..7f960aa 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -7,14 +7,7 @@ "core:default", "shell:allow-open", "shell:allow-kill", - { - "identifier": "shell:allow-spawn", - "allow": [ - { "name": "java", "args": true }, - { "name": "javaw", "args": true }, - { "name": "suwayomi-server", "args": true }, - { "name": "tachidesk-server", "args": true } - ] - } + "shell:allow-spawn", + "shell:allow-execute" ] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index de6674d..28777b8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,5 +1,6 @@ use std::path::PathBuf; use std::sync::Mutex; +use std::io::Write; use sysinfo::Disks; use serde::Serialize; use tauri::{Manager, WindowEvent}; @@ -16,13 +17,24 @@ pub struct StorageInfo { path: String, } -#[derive(Serialize)] +#[derive(Serialize, Debug)] #[serde(tag = "kind", content = "message")] pub enum SpawnError { NotConfigured(String), SpawnFailed(String), } +/// 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); @@ -181,34 +193,40 @@ fn suwayomi_data_dir() -> PathBuf { } struct ServerInvocation { - // Absolute path to java/javaw (bundled JRE) or a PATH-resident binary name. - // All platforms use app.shell().command() — no externalBin/sidecar needed. bin: String, - // Ordered args. rootdir_flag is inserted at position 0 by spawn_server - // so -D flags always precede -jar for the JVM. args: Vec, - // Set to the bundle dir so the jar can resolve its relative lib paths. working_dir: Option, } -// Returns the platform-appropriate java binary inside a bundled JRE tree, -// or None if the expected path doesn't exist. -fn find_java_in_bundle(bundle_dir: &PathBuf) -> Option { +fn find_java_in_bundle(bundle_dir: &PathBuf, log: &mut Option) -> Option { #[cfg(target_os = "windows")] - let java = bundle_dir.join("jre").join("bin").join("javaw.exe"); + let java = bundle_dir.join("jre").join("bin").join("java.exe"); #[cfg(not(target_os = "windows"))] let java = bundle_dir.join("jre").join("bin").join("java"); + do_log(log, &format!("[find_java] checking path: {:?}", java)); + do_log(log, &format!("[find_java] exists: {}", java.exists())); + if java.exists() { Some(java) } else { None } } +fn do_log(log: &mut Option, msg: &str) { + eprintln!("{}", msg); + if let Some(f) = log { + let _ = writeln!(f, "{}", msg); + } +} + fn resolve_server_binary( binary: &str, app: &tauri::AppHandle, + log: &mut Option, ) -> Result { - // User-supplied explicit path — pass straight through. + do_log(log, &format!("[resolve] binary arg = {:?}", binary)); + if !binary.trim().is_empty() { + do_log(log, "[resolve] using user-supplied binary path"); return Ok(ServerInvocation { bin: binary.to_string(), args: vec![], @@ -216,43 +234,69 @@ fn resolve_server_binary( }); } - let resource_dir = app - .path() - .resource_dir() - .map_err(|e| SpawnError::SpawnFailed(format!("resource_dir error: {e}")))?; + let resource_dir = match app.path().resource_dir() { + Ok(p) => { + let stripped = strip_unc(p); + do_log(log, &format!("[resolve] resource_dir (stripped) = {:?}", stripped)); + stripped + } + Err(e) => { + let msg = format!("resource_dir error: {e}"); + do_log(log, &format!("[resolve] ERROR: {}", msg)); + return Err(SpawnError::SpawnFailed(msg)); + } + }; - // ── Windows & Linux: bundled JRE ───────────────────────────────────────── - // CI stages the Suwayomi linux-x64 / windows-x64 bundle as a resource at - // resource_dir/suwayomi-bundle/ (jar + JRE tree). We invoke the bundled - // java binary directly with -jar. - // - // Final arg order (rootdir_flag prepended by spawn_server): - // java -Dsuwayomi...rootDir= -jar Suwayomi-Launcher.jar - // - // -D flags MUST precede -jar or the JVM silently ignores them. #[cfg(not(target_os = "macos"))] { - let bundle_dir = resource_dir.join("suwayomi-bundle"); - let jar = bundle_dir.join("Suwayomi-Launcher.jar"); + // Tauri 2 resource bundling behaviour depends on the config: + // - Structured layout: resource_dir/binaries/suwayomi-bundle/{bin,jre}/... + // - Flat layout: resource_dir/{java.exe,Suwayomi-Server.jar,...} + // We try both so the binary works regardless of which layout the installer produced. + let search_candidates: &[(&str, &str)] = &[ + // Structured — what the config intends + ("binaries/suwayomi-bundle", "binaries/suwayomi-bundle/bin/Suwayomi-Server.jar"), + // Flat — what Tauri 2 actually produces with glob resources + ("", "Suwayomi-Server.jar"), + ]; - if let Some(java) = find_java_in_bundle(&bundle_dir) { - if jar.exists() { - return Ok(ServerInvocation { - bin: java.to_string_lossy().into_owned(), - args: vec![ - "-jar".to_string(), - jar.to_string_lossy().into_owned(), - ], - working_dir: Some(bundle_dir), - }); + for (bundle_rel, jar_rel) in search_candidates { + let bundle_dir = if bundle_rel.is_empty() { + resource_dir.clone() + } else { + resource_dir.join(bundle_rel) + }; + let jar = resource_dir.join(jar_rel); + + do_log(log, &format!("[resolve] trying bundle_dir = {:?}", bundle_dir)); + do_log(log, &format!("[resolve] bundle_dir exists: {}", bundle_dir.exists())); + do_log(log, &format!("[resolve] jar = {:?}", jar)); + do_log(log, &format!("[resolve] jar exists: {}", jar.exists())); + + match find_java_in_bundle(&bundle_dir, log) { + Some(java) => { + do_log(log, &format!("[resolve] java found: {:?}", java)); + if jar.exists() { + do_log(log, "[resolve] both java and jar found — using bundled JRE"); + return Ok(ServerInvocation { + bin: java.to_string_lossy().into_owned(), + args: vec![ + "-jar".to_string(), + jar.to_string_lossy().into_owned(), + ], + working_dir: Some(bundle_dir), + }); + } else { + do_log(log, "[resolve] java found but jar MISSING — trying next candidate"); + } + } + None => { + do_log(log, "[resolve] java NOT found — trying next candidate"); + } } } } - // ── macOS: bundled launcher script ─────────────────────────────────────── - // The macOS workflow stages arch-specific .command launcher scripts as - // externalBin sidecars. They are self-contained (handle JVM invocation - // internally) so we exec the script directly with no extra args. #[cfg(target_os = "macos")] { let candidates = [ @@ -262,7 +306,9 @@ fn resolve_server_binary( ]; for name in &candidates { let p = resource_dir.join(name); + do_log(log, &format!("[resolve] macOS candidate: {:?} exists={}", p, p.exists())); if p.exists() { + do_log(log, &format!("[resolve] using macOS candidate: {:?}", p)); return Ok(ServerInvocation { bin: p.to_string_lossy().into_owned(), args: vec![], @@ -272,15 +318,7 @@ fn resolve_server_binary( } } - // ── PATH fallback (all platforms) ──────────────────────────────────────── - // Covers: - // - nix develop (tachidesk-server in devShell.nativeBuildInputs) - // - nix run .#moku (wrapProgram --prefix PATH injects tachidesk-server) - // - Distro package installs - // - Manual system installs - // - // The Nix wrapper script accepts "$@" passthrough so the rootdir -D flag - // forwarded by spawn_server reaches the underlying JVM correctly. + do_log(log, "[resolve] trying PATH fallback"); for name in &["suwayomi-server", "tachidesk-server"] { let found = std::process::Command::new("which") .arg(name) @@ -288,7 +326,10 @@ fn resolve_server_binary( .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![], @@ -297,6 +338,7 @@ fn resolve_server_binary( } } + 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(), )) @@ -312,37 +354,68 @@ fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), SpawnError> } 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 = resolve_server_binary(&binary, &app)?; - let bin_display = invocation.bin.clone(); + 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() ); - // Insert rootdir at position 0 so it always precedes -jar for the JVM. - // For PATH-resident Nix wrapper scripts the flag is forwarded via "$@". 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)) => { - println!("Spawned server: {}", bin_display); + do_log(&mut log, &format!("[spawn_server] SUCCESS — spawned: {}", bin_display)); *app.state::().0.lock().unwrap() = Some(child); Ok(()) } Err(e) => { - eprintln!("Failed to spawn {}: {}", bin_display, 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())) } } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 2ba2bf5..2074fff 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -43,7 +43,11 @@ "installerIcon": "icons/icon.ico", "installMode": "currentUser" } - } + }, + "resources": [ + "binaries/suwayomi-bundle/bin/Suwayomi-Server.jar", + "binaries/suwayomi-bundle/jre/**/*" + ] }, "plugins": { "shell": { diff --git a/src/App.svelte b/src/App.svelte index caa55ee..044065e 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -85,44 +85,61 @@ return () => clearInterval(pollInterval); }); + // Probe the server in a loop until it responds or we hit MAX_ATTEMPTS. + // Returns a cleanup function that cancels any pending probe. + function startProbe(): () => void { + let cancelled = false, tries = 0; + + async function probe() { + if (cancelled) return; + tries++; + try { + const res = await fetch(`${store.settings.serverUrl}/api/graphql`, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ query: "{ __typename }" }), + signal: AbortSignal.timeout(2000), + }); + if (res.ok && !cancelled) { serverProbeOk = true; return; } + } catch {} + if (tries >= MAX_ATTEMPTS && !cancelled) { failed = true; return; } + if (!cancelled) setTimeout(probe, 800); + } + + // Give the server a moment to start binding its port before the first probe. + setTimeout(probe, 1200); + return () => { cancelled = true; }; + } + onMount(async () => { document.addEventListener("contextmenu", e => e.preventDefault()); (window as any).__mokuShowSplash = () => devSplash = true; + let cancelProbe = () => {}; + if (store.settings.autoStartServer) { - invoke("spawn_server", { binary: store.settings.serverBinary }).catch((err: any) => { + try { + await invoke("spawn_server", { binary: store.settings.serverBinary ?? "" }); + // spawn_server succeeded — JRE found and process started. Begin probing. + cancelProbe = startProbe(); + } catch (err: any) { if (err?.kind === "NotConfigured") { notConfigured = true; } else { - console.warn("Could not start server:", err); + // SpawnFailed — process couldn't be launched (permissions, bad path, etc.) + console.error("spawn_server failed:", err); + failed = true; } - }); - } - - if (!serverProbeOk) { - let cancelled = false, tries = 0; - async function probe() { - if (cancelled) return; - tries++; - try { - const res = await fetch(`${store.settings.serverUrl}/api/graphql`, { - method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ query: "{ __typename }" }), - signal: AbortSignal.timeout(2000), - }); - if (res.ok && !cancelled) { serverProbeOk = true; return; } - } catch {} - if (tries >= MAX_ATTEMPTS && !cancelled) { failed = true; return; } - if (!cancelled) setTimeout(probe, 800); } - setTimeout(probe, 800); + } else { + // autoStartServer is off — user manages the server themselves, just probe. + cancelProbe = startProbe(); } type P = { chapterId: number; mangaId: number; progress: number }[]; unlistenDownload = await listen

("download-progress", e => { setActiveDownloads(e.payload); }); return () => { - cancelled = true; + cancelProbe(); if (store.settings.autoStartServer) invoke("kill_server").catch(() => {}); if (idleTimer) clearTimeout(idleTimer); if (pollInterval) clearInterval(pollInterval); @@ -131,7 +148,13 @@ }; }); - function handleRetry() { failed = false; notConfigured = false; serverProbeOk = false; } + function handleRetry() { + failed = false; + notConfigured = false; + serverProbeOk = false; + // Re-run the full startup flow by reloading — simplest way to reset all state cleanly. + window.location.reload(); + } {#if devSplash}