diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml new file mode 100644 index 0000000..0866015 --- /dev/null +++ b/.github/workflows/build-linux.yml @@ -0,0 +1,177 @@ +name: Build Linux + +on: + workflow_dispatch: + inputs: + version: + description: "Version to build (e.g. 0.4.0)" + required: true + +jobs: + frontend: + name: Build frontend + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: latest + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build + run: pnpm build + + - name: Upload dist + uses: actions/upload-artifact@v4 + with: + name: frontend-dist-linux + path: dist/ + retention-days: 1 + + tauri: + name: Tauri (Linux x64) + needs: frontend + runs-on: ubuntu-22.04 + + steps: + - uses: actions/checkout@v4 + + - name: Download frontend dist + uses: actions/download-artifact@v4 + with: + name: frontend-dist-linux + path: dist/ + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + libwebkit2gtk-4.1-dev \ + libgtk-3-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev \ + patchelf + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-unknown-linux-gnu + + - name: Rust cache + uses: Swatinem/rust-cache@v2 + with: + workspaces: src-tauri + + - uses: pnpm/action-setup@v4 + with: + version: latest + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install JS dependencies + run: pnpm install --frozen-lockfile + + - name: Download Suwayomi (Linux x64) + run: | + curl -fsSL \ + "https://github.com/Suwayomi/Suwayomi-Server/releases/download/v2.1.1867/Suwayomi-Server-v2.1.1867-linux-x64.tar.gz" \ + -o suwayomi-linux.tar.gz + + echo "b2344bd73c4e26bede63cdb4b44b1b4168d8a8500b3b2b1a0219519a3ef708fe suwayomi-linux.tar.gz" | sha256sum -c - + + mkdir -p suwayomi-extracted + tar -xzf suwayomi-linux.tar.gz -C suwayomi-extracted --strip-components=1 + + echo "Extracted bundle contents (top-level):" + ls -la suwayomi-extracted/ + + - name: Stage Suwayomi bundle + run: | + mkdir -p src-tauri/binaries + + JAVA=$(find suwayomi-extracted -path "*/jre/bin/java" | head -1) + JAR=$(find suwayomi-extracted -name "Suwayomi-Launcher.jar" | head -1) + + if [ -z "$JAVA" ]; then + echo "ERROR: jre/bin/java 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:" + find suwayomi-extracted -type f | head -50 + exit 1 + fi + + echo "Found java: $JAVA" + echo "Found jar: $JAR" + + # Copy full bundle as resources so jar + jre tree are available + # at runtime via resource_dir/suwayomi-bundle/. + cp -r suwayomi-extracted src-tauri/binaries/suwayomi-bundle + + - name: Validate staging + run: | + echo "--- Validating bundle java ---" + find src-tauri/binaries/suwayomi-bundle -path "*/jre/bin/java" \ + | grep -q . \ + || (echo "ERROR: jre/bin/java missing" && exit 1) + + echo "--- Validating bundle jar ---" + find src-tauri/binaries/suwayomi-bundle -name "Suwayomi-Launcher.jar" \ + | grep -q . \ + || (echo "ERROR: Suwayomi-Launcher.jar missing" && exit 1) + + echo "Staging OK" + + - name: Patch tauri.conf.json for CI + run: | + # Suppress the frontend rebuild (dist/ already built by frontend job). + sed -i 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json + + # Remove externalBin entirely for Linux — we use bundled resources, + # no sidecar registration needed. + # Inject the suwayomi-bundle resource glob. + python3 - << 'PYEOF' +import json +with open("src-tauri/tauri.conf.json") as f: + conf = json.load(f) +conf["bundle"]["resources"] = ["binaries/suwayomi-bundle/**"] +conf["bundle"]["externalBin"] = [] +with open("src-tauri/tauri.conf.json", "w") as f: + json.dump(conf, f, indent=2) +print("tauri.conf.json patched — resources injected, externalBin cleared") +PYEOF + + - name: Build Tauri app (Linux x64) + uses: tauri-apps/tauri-action@v0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + args: --target x86_64-unknown-linux-gnu + + - name: Upload AppImage + uses: actions/upload-artifact@v4 + with: + name: moku-linux-x64-appimage + path: src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/appimage/*.AppImage + retention-days: 7 + + - name: Upload .deb + uses: actions/upload-artifact@v4 + with: + name: moku-linux-x64-deb + path: src-tauri/target/x86_64-unknown-linux-gnu/release/bundle/deb/*.deb + retention-days: 7 diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index 2b43062..baef77e 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -83,49 +83,85 @@ jobs: unzip -q suwayomi-windows.zip -d suwayomi-raw - TOP_DIRS=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type d) - TOP_FILES=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type f) - TOP_DIR_COUNT=$(echo "$TOP_DIRS" | grep -c . || true) - + - name: Extract Suwayomi bundle + shell: bash + run: | mkdir -p suwayomi-extracted - if [ "$TOP_DIR_COUNT" -eq 1 ] && [ -z "$TOP_FILES" ]; then - mv "$TOP_DIRS"/* suwayomi-extracted/ + + # Handle both zip layouts: single top-level dir, or files at root + TOP_DIRS=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type d | wc -l) + TOP_FILES=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type f | wc -l) + + if [ "$TOP_DIRS" -eq 1 ] && [ "$TOP_FILES" -eq 0 ]; then + INNER=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type d | head -1) + cp -r "$INNER"/. suwayomi-extracted/ else - mv suwayomi-raw/* suwayomi-extracted/ + cp -r suwayomi-raw/. suwayomi-extracted/ fi + echo "Extracted bundle contents (top-level):" + ls -la suwayomi-extracted/ + - name: Stage Suwayomi bundle 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: could not find jre/bin/javaw.exe — bundle contents:" - find suwayomi-extracted -type f | head -40 + echo "ERROR: jre/bin/javaw.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:" + find suwayomi-extracted -type f | head -50 exit 1 fi echo "Found javaw: $JAVAW" + echo "Found jar: $JAR" - # Copy full bundle so jar + jre tree are available at runtime. - # lib.rs looks for suwayomi-bundle/jre/bin/javaw.exe in the resource dir. + # Copy full bundle as resources — lib.rs invokes the bundled + # java directly via resource_dir/suwayomi-bundle/jre/bin/javaw.exe. cp -r suwayomi-extracted src-tauri/binaries/suwayomi-bundle + - name: Validate staging + shell: bash + run: | + echo "--- Validating bundle javaw ---" + find src-tauri/binaries/suwayomi-bundle -path "*/jre/bin/javaw.exe" \ + | grep -q . \ + || (echo "ERROR: jre/bin/javaw.exe missing" && exit 1) + + echo "--- Validating bundle jar ---" + find src-tauri/binaries/suwayomi-bundle -name "Suwayomi-Launcher.jar" \ + | grep -q . \ + || (echo "ERROR: Suwayomi-Launcher.jar missing" && exit 1) + + echo "Staging OK" + - name: Patch tauri.conf.json for CI shell: bash run: | + # Suppress the frontend rebuild (dist/ already built by frontend job). sed -i 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json - # Inject the suwayomi-bundle resource so Tauri bundles it with the app. - # Done here (after staging) so the path exists when Tauri validates it. + + # Remove externalBin entirely for Windows — we invoke the bundled + # javaw directly as a raw path, no sidecar registration needed. + # Inject the suwayomi-bundle resource glob. python3 - << 'PYEOF' import json with open("src-tauri/tauri.conf.json") as f: conf = json.load(f) conf["bundle"]["resources"] = ["binaries/suwayomi-bundle/**"] +conf["bundle"]["externalBin"] = [] with open("src-tauri/tauri.conf.json", "w") as f: json.dump(conf, f, indent=2) +print("tauri.conf.json patched — resources injected, externalBin cleared") PYEOF - name: Build Tauri app (Windows x64) diff --git a/.gitignore b/.gitignore index 8578b8c..5aaf47f 100644 --- a/.gitignore +++ b/.gitignore @@ -38,4 +38,4 @@ src-tauri/gen/ build-dir/ repo/ *.flatpak -.flatpak-builder/ \ No newline at end of file +.flatpak-builder/\n# --- Staged sidecar binaries (platform-specific, never commit) ---\nsrc-tauri/binaries/ diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 25fe530..4138f6a 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -10,13 +10,10 @@ { "identifier": "shell:allow-spawn", "allow": [ - { "name": "tachidesk-server" }, - { "name": "suwayomi-server" }, - { "name": "suwayomi-server-aarch64-apple-darwin" }, - { "name": "suwayomi-server-x86_64-apple-darwin" }, - { "name": "javaw", "args": true }, - { "name": "which" }, - { "name": "where" } + { "name": "java", "args": true }, + { "name": "javaw", "args": true }, + { "name": "suwayomi-server", "args": true }, + { "name": "tachidesk-server", "args": true } ] } ] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 566372c..de6674d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -181,19 +181,37 @@ fn suwayomi_data_dir() -> PathBuf { } struct ServerInvocation { - bin: std::ffi::OsString, - prefix_args: Vec, + // 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 { + #[cfg(target_os = "windows")] + let java = bundle_dir.join("jre").join("bin").join("javaw.exe"); + + #[cfg(not(target_os = "windows"))] + let java = bundle_dir.join("jre").join("bin").join("java"); + + if java.exists() { Some(java) } else { None } +} + fn resolve_server_binary( binary: &str, app: &tauri::AppHandle, ) -> Result { + // User-supplied explicit path — pass straight through. if !binary.trim().is_empty() { return Ok(ServerInvocation { - bin: std::ffi::OsString::from(binary), - prefix_args: vec![], + bin: binary.to_string(), + args: vec![], working_dir: None, }); } @@ -201,71 +219,86 @@ fn resolve_server_binary( let resource_dir = app .path() .resource_dir() - .map_err(|e| SpawnError::SpawnFailed(format!("Could not locate resource dir: {e}")))?; + .map_err(|e| SpawnError::SpawnFailed(format!("resource_dir error: {e}")))?; - #[cfg(target_os = "windows")] + // ── 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 javaw = bundle_dir.join("jre").join("bin").join("javaw.exe"); let jar = bundle_dir.join("Suwayomi-Launcher.jar"); - if javaw.exists() && jar.exists() { - return Ok(ServerInvocation { - bin: javaw.into_os_string(), - prefix_args: vec![ - "-jar".to_string(), - jar.to_string_lossy().into_owned(), - ], - working_dir: Some(bundle_dir), - }); + 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), + }); + } } - - return Err(SpawnError::NotConfigured( - "No bundled server found. Set the server path in Settings.".to_string(), - )); } + // ── 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 = [ - "suwayomi-server-aarch64-apple-darwin", - "suwayomi-server-x86_64-apple-darwin", - ]; - - #[cfg(not(any(target_os = "windows", target_os = "macos")))] - let candidates = ["suwayomi-server"]; - - #[cfg(not(target_os = "windows"))] - for name in &candidates { - let p = resource_dir.join(name); - if p.exists() { - return Ok(ServerInvocation { - bin: p.into_os_string(), - prefix_args: vec![], - working_dir: None, - }); + { + let candidates = [ + "suwayomi-server-aarch64-apple-darwin", + "suwayomi-server-x86_64-apple-darwin", + "suwayomi-server", + ]; + for name in &candidates { + let p = resource_dir.join(name); + if p.exists() { + return Ok(ServerInvocation { + bin: p.to_string_lossy().into_owned(), + args: vec![], + working_dir: None, + }); + } } } - // Fall back to PATH — covers Nix, distro packages, and any system install. - // Windows always hits the early return above so this block is Linux/macOS only. - #[cfg(not(target_os = "windows"))] - for name in &["tachidesk-server", "suwayomi-server"] { - if std::process::Command::new("which") + // ── 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. + 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) - { + .unwrap_or(false); + + if found { return Ok(ServerInvocation { - bin: std::ffi::OsString::from(name), - prefix_args: vec![], + bin: name.to_string(), + args: vec![], working_dir: None, }); } } Err(SpawnError::NotConfigured( - "Server binary not found. Set the path in Settings.".to_string(), + "Server binary not found. Install Suwayomi-Server or set the path in Settings.".to_string(), )) } @@ -273,8 +306,7 @@ fn resolve_server_binary( fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), SpawnError> { { let state = app.state::(); - let guard = state.0.lock().unwrap(); - if guard.is_some() { + if state.0.lock().unwrap().is_some() { return Ok(()); } } @@ -282,37 +314,35 @@ fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), SpawnError> let data_dir = suwayomi_data_dir(); seed_server_conf(&data_dir); - let invocation = resolve_server_binary(&binary, &app)?; - let bin_display = invocation.bin.clone(); + let mut invocation = resolve_server_binary(&binary, &app)?; + let bin_display = invocation.bin.clone(); let rootdir_flag = format!( "-Dsuwayomi.tachidesk.config.server.rootDir={}", data_dir.to_string_lossy() ); - let args: Vec = invocation.prefix_args - .into_iter() - .chain(std::iter::once(rootdir_flag)) - .collect(); + // 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()); let cmd = app.shell() .command(&invocation.bin) .env("JAVA_TOOL_OPTIONS", "-Djava.awt.headless=true") - .args(&args) - .current_dir( - invocation.working_dir - .unwrap_or_else(|| std::env::current_dir().unwrap_or_default()) - ); + .args(&invocation.args) + .current_dir(&working_dir); match cmd.spawn() { Ok((_rx, child)) => { - println!("Spawned server: {:?}", bin_display); - let state = app.state::(); - *state.0.lock().unwrap() = Some(child); + println!("Spawned server: {}", bin_display); + *app.state::().0.lock().unwrap() = Some(child); Ok(()) } Err(e) => { - eprintln!("Failed to spawn {:?}: {}", bin_display, e); + eprintln!("Failed to spawn {}: {}", bin_display, e); Err(SpawnError::SpawnFailed(e.to_string())) } } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index f0d8864..2981144 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -39,6 +39,9 @@ "icons/icon.ico", "icons/icon.png" ], + "externalBin": [ + "binaries/suwayomi-server" + ], "windows": { "nsis": { "installerIcon": "icons/icon.ico", @@ -51,4 +54,4 @@ "open": true } } -} \ No newline at end of file +} diff --git a/src-tauri/tauri.dev.conf.json b/src-tauri/tauri.dev.conf.json index 6f7aaec..b245eb4 100644 --- a/src-tauri/tauri.dev.conf.json +++ b/src-tauri/tauri.dev.conf.json @@ -9,5 +9,8 @@ "devtools": true } ] + }, + "bundle": { + "externalBin": [] } -} \ No newline at end of file +}