Feat: Bundle Suwayomi JRE for Windows/Linux

This commit is contained in:
Youwes09
2026-03-20 22:13:43 -05:00
parent 272e026210
commit 406819ccca
7 changed files with 333 additions and 87 deletions
+177
View File
@@ -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
+49 -13
View File
@@ -83,49 +83,85 @@ jobs:
unzip -q suwayomi-windows.zip -d suwayomi-raw unzip -q suwayomi-windows.zip -d suwayomi-raw
TOP_DIRS=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type d) - name: Extract Suwayomi bundle
TOP_FILES=$(find suwayomi-raw -mindepth 1 -maxdepth 1 -type f) shell: bash
TOP_DIR_COUNT=$(echo "$TOP_DIRS" | grep -c . || true) run: |
mkdir -p suwayomi-extracted 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 else
mv suwayomi-raw/* suwayomi-extracted/ cp -r suwayomi-raw/. suwayomi-extracted/
fi fi
echo "Extracted bundle contents (top-level):"
ls -la suwayomi-extracted/
- name: Stage Suwayomi bundle - name: Stage Suwayomi bundle
shell: bash shell: bash
run: | run: |
mkdir -p src-tauri/binaries mkdir -p src-tauri/binaries
JAVAW=$(find suwayomi-extracted -path "*/jre/bin/javaw.exe" | head -1) 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 if [ -z "$JAVAW" ]; then
echo "ERROR: could not find jre/bin/javaw.exe — bundle contents:" echo "ERROR: jre/bin/javaw.exe not found. Bundle contents:"
find suwayomi-extracted -type f | head -40 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 exit 1
fi fi
echo "Found javaw: $JAVAW" echo "Found javaw: $JAVAW"
echo "Found jar: $JAR"
# Copy full bundle so jar + jre tree are available at runtime. # Copy full bundle as resources — lib.rs invokes the bundled
# lib.rs looks for suwayomi-bundle/jre/bin/javaw.exe in the resource dir. # java directly via resource_dir/suwayomi-bundle/jre/bin/javaw.exe.
cp -r suwayomi-extracted src-tauri/binaries/suwayomi-bundle 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 - name: Patch tauri.conf.json for CI
shell: bash shell: bash
run: | run: |
# Suppress the frontend rebuild (dist/ already built by frontend job).
sed -i 's/"beforeBuildCommand": "pnpm build"/"beforeBuildCommand": ""/' src-tauri/tauri.conf.json 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' python3 - << 'PYEOF'
import json import json
with open("src-tauri/tauri.conf.json") as f: with open("src-tauri/tauri.conf.json") as f:
conf = json.load(f) conf = json.load(f)
conf["bundle"]["resources"] = ["binaries/suwayomi-bundle/**"] conf["bundle"]["resources"] = ["binaries/suwayomi-bundle/**"]
conf["bundle"]["externalBin"] = []
with open("src-tauri/tauri.conf.json", "w") as f: with open("src-tauri/tauri.conf.json", "w") as f:
json.dump(conf, f, indent=2) json.dump(conf, f, indent=2)
print("tauri.conf.json patched — resources injected, externalBin cleared")
PYEOF PYEOF
- name: Build Tauri app (Windows x64) - name: Build Tauri app (Windows x64)
+1 -1
View File
@@ -38,4 +38,4 @@ src-tauri/gen/
build-dir/ build-dir/
repo/ repo/
*.flatpak *.flatpak
.flatpak-builder/ .flatpak-builder/\n# --- Staged sidecar binaries (platform-specific, never commit) ---\nsrc-tauri/binaries/
+4 -7
View File
@@ -10,13 +10,10 @@
{ {
"identifier": "shell:allow-spawn", "identifier": "shell:allow-spawn",
"allow": [ "allow": [
{ "name": "tachidesk-server" }, { "name": "java", "args": true },
{ "name": "suwayomi-server" }, { "name": "javaw", "args": true },
{ "name": "suwayomi-server-aarch64-apple-darwin" }, { "name": "suwayomi-server", "args": true },
{ "name": "suwayomi-server-x86_64-apple-darwin" }, { "name": "tachidesk-server", "args": true }
{ "name": "javaw", "args": true },
{ "name": "which" },
{ "name": "where" }
] ]
} }
] ]
+94 -64
View File
@@ -181,19 +181,37 @@ fn suwayomi_data_dir() -> PathBuf {
} }
struct ServerInvocation { struct ServerInvocation {
bin: std::ffi::OsString, // Absolute path to java/javaw (bundled JRE) or a PATH-resident binary name.
prefix_args: Vec<String>, // 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<String>,
// Set to the bundle dir so the jar can resolve its relative lib paths.
working_dir: Option<PathBuf>, working_dir: Option<PathBuf>,
} }
// 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<PathBuf> {
#[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( fn resolve_server_binary(
binary: &str, binary: &str,
app: &tauri::AppHandle, app: &tauri::AppHandle,
) -> Result<ServerInvocation, SpawnError> { ) -> Result<ServerInvocation, SpawnError> {
// User-supplied explicit path — pass straight through.
if !binary.trim().is_empty() { if !binary.trim().is_empty() {
return Ok(ServerInvocation { return Ok(ServerInvocation {
bin: std::ffi::OsString::from(binary), bin: binary.to_string(),
prefix_args: vec![], args: vec![],
working_dir: None, working_dir: None,
}); });
} }
@@ -201,71 +219,86 @@ fn resolve_server_binary(
let resource_dir = app let resource_dir = app
.path() .path()
.resource_dir() .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=<path> -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 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"); let jar = bundle_dir.join("Suwayomi-Launcher.jar");
if javaw.exists() && jar.exists() { if let Some(java) = find_java_in_bundle(&bundle_dir) {
return Ok(ServerInvocation { if jar.exists() {
bin: javaw.into_os_string(), return Ok(ServerInvocation {
prefix_args: vec![ bin: java.to_string_lossy().into_owned(),
"-jar".to_string(), args: vec![
jar.to_string_lossy().into_owned(), "-jar".to_string(),
], jar.to_string_lossy().into_owned(),
working_dir: Some(bundle_dir), ],
}); 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")] #[cfg(target_os = "macos")]
let candidates = [ {
"suwayomi-server-aarch64-apple-darwin", let candidates = [
"suwayomi-server-x86_64-apple-darwin", "suwayomi-server-aarch64-apple-darwin",
]; "suwayomi-server-x86_64-apple-darwin",
"suwayomi-server",
#[cfg(not(any(target_os = "windows", target_os = "macos")))] ];
let candidates = ["suwayomi-server"]; for name in &candidates {
let p = resource_dir.join(name);
#[cfg(not(target_os = "windows"))] if p.exists() {
for name in &candidates { return Ok(ServerInvocation {
let p = resource_dir.join(name); bin: p.to_string_lossy().into_owned(),
if p.exists() { args: vec![],
return Ok(ServerInvocation { working_dir: None,
bin: p.into_os_string(), });
prefix_args: vec![], }
working_dir: None,
});
} }
} }
// Fall back to PATH — covers Nix, distro packages, and any system install. // ── PATH fallback (all platforms) ────────────────────────────────────────
// Windows always hits the early return above so this block is Linux/macOS only. // Covers:
#[cfg(not(target_os = "windows"))] // - nix develop (tachidesk-server in devShell.nativeBuildInputs)
for name in &["tachidesk-server", "suwayomi-server"] { // - nix run .#moku (wrapProgram --prefix PATH injects tachidesk-server)
if std::process::Command::new("which") // - 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) .arg(name)
.output() .output()
.map(|o| o.status.success()) .map(|o| o.status.success())
.unwrap_or(false) .unwrap_or(false);
{
if found {
return Ok(ServerInvocation { return Ok(ServerInvocation {
bin: std::ffi::OsString::from(name), bin: name.to_string(),
prefix_args: vec![], args: vec![],
working_dir: None, working_dir: None,
}); });
} }
} }
Err(SpawnError::NotConfigured( 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> { fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), SpawnError> {
{ {
let state = app.state::<ServerState>(); let state = app.state::<ServerState>();
let guard = state.0.lock().unwrap(); if state.0.lock().unwrap().is_some() {
if guard.is_some() {
return Ok(()); return Ok(());
} }
} }
@@ -282,37 +314,35 @@ fn spawn_server(binary: String, app: tauri::AppHandle) -> Result<(), SpawnError>
let data_dir = suwayomi_data_dir(); let data_dir = suwayomi_data_dir();
seed_server_conf(&data_dir); seed_server_conf(&data_dir);
let invocation = resolve_server_binary(&binary, &app)?; let mut invocation = resolve_server_binary(&binary, &app)?;
let bin_display = invocation.bin.clone(); let bin_display = invocation.bin.clone();
let rootdir_flag = format!( let rootdir_flag = format!(
"-Dsuwayomi.tachidesk.config.server.rootDir={}", "-Dsuwayomi.tachidesk.config.server.rootDir={}",
data_dir.to_string_lossy() data_dir.to_string_lossy()
); );
let args: Vec<String> = invocation.prefix_args // Insert rootdir at position 0 so it always precedes -jar for the JVM.
.into_iter() // For PATH-resident Nix wrapper scripts the flag is forwarded via "$@".
.chain(std::iter::once(rootdir_flag)) invocation.args.insert(0, rootdir_flag);
.collect();
let working_dir = invocation.working_dir
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
let cmd = app.shell() let cmd = app.shell()
.command(&invocation.bin) .command(&invocation.bin)
.env("JAVA_TOOL_OPTIONS", "-Djava.awt.headless=true") .env("JAVA_TOOL_OPTIONS", "-Djava.awt.headless=true")
.args(&args) .args(&invocation.args)
.current_dir( .current_dir(&working_dir);
invocation.working_dir
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default())
);
match cmd.spawn() { match cmd.spawn() {
Ok((_rx, child)) => { Ok((_rx, child)) => {
println!("Spawned server: {:?}", bin_display); println!("Spawned server: {}", bin_display);
let state = app.state::<ServerState>(); *app.state::<ServerState>().0.lock().unwrap() = Some(child);
*state.0.lock().unwrap() = Some(child);
Ok(()) Ok(())
} }
Err(e) => { Err(e) => {
eprintln!("Failed to spawn {:?}: {}", bin_display, e); eprintln!("Failed to spawn {}: {}", bin_display, e);
Err(SpawnError::SpawnFailed(e.to_string())) Err(SpawnError::SpawnFailed(e.to_string()))
} }
} }
+3
View File
@@ -39,6 +39,9 @@
"icons/icon.ico", "icons/icon.ico",
"icons/icon.png" "icons/icon.png"
], ],
"externalBin": [
"binaries/suwayomi-server"
],
"windows": { "windows": {
"nsis": { "nsis": {
"installerIcon": "icons/icon.ico", "installerIcon": "icons/icon.ico",
+3
View File
@@ -9,5 +9,8 @@
"devtools": true "devtools": true
} }
] ]
},
"bundle": {
"externalBin": []
} }
} }