Files
Stirling-PDF/frontend/editor/src-tauri/src/commands/backend.rs
T
0a50e765b7 Restructure/frontend editor (#6404)
## Move editor under `frontend/editor/`

Pure restructure: `frontend/` becomes the workspace, `frontend/editor/`
holds
  the PDF editor. 1775 file renames + 40 wiring edits. No logic changes.

  ### Why

`frontend/` is currently the editor — its `src/`, `public/`,
`src-tauri/`,
  config files all sit at the root. Promoting `frontend/` to a
workspace and putting the editor in a sibling folder leaves room for
future
apps to drop in alongside it, sharing one `package.json` /
`node_modules` /
  lint config / Storybook.

  ### What moves

  frontend/
  ├── editor/                ← NEW: everything editor-specific
  │   ├── src/               ← was frontend/src/
  │   ├── public/            ← was frontend/public/
  │   ├── src-tauri/         ← was frontend/src-tauri/
│ ├── index.html, vite.config.ts, vitest.config.ts, playwright.config.ts
  │   ├── tsconfig*.json, tailwind.config.js, postcss.config.js
  │   ├── scripts/
  │   ├── .env, .env.desktop, .env.saas
  │   └── DeveloperGuide.md
├── package.json, package-lock.json, node_modules/ ← workspace install
  ├── eslint.config.mjs, .prettierrc, .prettierignore ← shared tooling
  ├── .gitignore
  └── README.md

  ### Wiring edits (40 files)

  - `.taskfiles/frontend.yml`, `desktop.yml`, `e2e.yml`
  - `build.gradle`, `app/core/build.gradle`
- `eslint.config.mjs`, `frontend/package.json`, `.gitignore`,
`.prettierignore`
  - `docker/frontend/Dockerfile`
  - 8 `.github/workflows/*.yml`, plus `.github/dependabot.yml`,
    `.github/config/.files.yaml`, `.github/labeler-config-srvaroa.yml`
  - `scripts/translations/**`
- Docs: `AGENTS.md`, `CLAUDE.md`, `ADDING_TOOLS.md`,
`DeveloperGuide.md`,
`WINDOWS_SIGNING.md`, `devGuide/HowToAddNewLanguage.md`,
`frontend/README.md`,
    `frontend/editor/DeveloperGuide.md`

Plus 3 renamed + edited: `editor/vite.config.ts` (env path +
node_modules
  walk-up), `editor/scripts/setup-env.mts` (renamed from `.ts` for
`import.meta.url`), `editor/scripts/build-provisioner.mjs` (resolve
src-tauri
  relative to script).

  ### Verification

  | Check | Result |
  |---|---|
  | `task frontend:typecheck:all` (6 variants) | exit 0 |
  | `task frontend:lint` (eslint + dpdm) | exit 0 |
  | `task frontend:format:check` | exit 0 |
  | `task frontend:test` | 657 tests pass, 50 files |
| `task frontend:build:{core,proprietary,saas,desktop,prototypes}` | all
green |
| `task desktop:build` | full Tauri pipeline →
`Stirling-PDF_2.11.0_x64_en-US.msi` |
  | `playwright test --list --project=stubbed` | 172 tests discovered |

`task desktop:build` exercises the heaviest path — Rust + WiX + MSI
bundle
against the moved `editor/src-tauri/`. If anything in the restructure
was
  wrong it wouldn't have built.

  ### Test plan

  - [ ] `frontend-validation.yml` green
  - [ ] `e2e-stubbed.yml` green
  - [ ] `tauri-build.yml` green on at least one platform
  - [ ] `check_toml.yml` runs on a translation-touching PR

---------

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
2026-05-22 13:40:34 +01:00

479 lines
19 KiB
Rust

use tauri_plugin_shell::ShellExt;
use tauri::Manager;
use std::sync::Mutex;
use std::path::{Path, PathBuf};
use crate::utils::{add_log, app_data_dir};
use crate::state::connection_state::{AppConnectionState, ConnectionMode};
// Store backend process handle and port globally
static BACKEND_PROCESS: Mutex<Option<tauri_plugin_shell::process::CommandChild>> = Mutex::new(None);
static BACKEND_STARTING: Mutex<bool> = Mutex::new(false);
static BACKEND_PORT: Mutex<Option<u16>> = Mutex::new(None);
// Helper function to reset starting flag
fn reset_starting_flag() {
let mut starting_guard = BACKEND_STARTING.lock().unwrap();
*starting_guard = false;
}
// Extract port number from "Stirling-PDF running on port: PORT" log line
fn extract_port_from_running_log(log_line: &str) -> Option<u16> {
// Look for pattern: "running on port: PORT"
if let Some(start) = log_line.find("running on port: ") {
let after_prefix = &log_line[start + 17..]; // Skip "running on port: "
// Take digits until whitespace or end of line
let port_str: String = after_prefix.chars()
.take_while(|c| c.is_ascii_digit())
.collect();
return port_str.parse::<u16>().ok();
}
None
}
// Check if backend is already running or starting
fn check_backend_status() -> Result<(), String> {
// Check if backend is already running
{
let process_guard = BACKEND_PROCESS.lock().unwrap();
if process_guard.is_some() {
add_log("⚠️ Backend process already running, skipping start".to_string());
return Err("Backend already running".to_string());
}
}
// Check and set starting flag to prevent multiple simultaneous starts
{
let mut starting_guard = BACKEND_STARTING.lock().unwrap();
if *starting_guard {
add_log("⚠️ Backend already starting, skipping duplicate start".to_string());
return Err("Backend startup already in progress".to_string());
}
*starting_guard = true;
}
Ok(())
}
// Find the bundled JRE and return the java executable path
fn find_bundled_jre(resource_dir: &PathBuf) -> Result<PathBuf, String> {
let jre_dir = resource_dir.join("runtime").join("jre");
let java_executable = if cfg!(windows) {
jre_dir.join("bin").join("java.exe")
} else {
jre_dir.join("bin").join("java")
};
if !java_executable.exists() {
let error_msg = format!("❌ Bundled JRE not found at: {:?}", java_executable);
add_log(error_msg.clone());
return Err(error_msg);
}
add_log(format!("✅ Found bundled JRE: {:?}", java_executable));
Ok(java_executable)
}
// Find the Stirling-PDF JAR file
fn find_stirling_jar(resource_dir: &PathBuf) -> Result<PathBuf, String> {
let libs_dir = resource_dir.join("libs");
let mut jar_files: Vec<_> = std::fs::read_dir(&libs_dir)
.map_err(|e| {
let error_msg = format!("Failed to read libs directory: {}. Make sure the JAR is copied to libs/", e);
add_log(error_msg.clone());
error_msg
})?
.filter_map(|entry| entry.ok())
.filter(|entry| {
let path = entry.path();
// Match any .jar file containing "stirling-pdf" (case-insensitive)
path.extension().and_then(|s| s.to_str()).map(|ext| ext.eq_ignore_ascii_case("jar")).unwrap_or(false)
&& path.file_name()
.and_then(|f| f.to_str())
.map(|name| name.to_ascii_lowercase().contains("stirling-pdf"))
.unwrap_or(false)
})
.collect();
if jar_files.is_empty() {
let error_msg = "No Stirling-PDF JAR found in libs directory.".to_string();
add_log(error_msg.clone());
return Err(error_msg);
}
// Sort by filename to get the latest version (case-insensitive)
jar_files.sort_by(|a, b| {
let name_a = a.file_name().to_string_lossy().to_ascii_lowercase();
let name_b = b.file_name().to_string_lossy().to_ascii_lowercase();
name_b.cmp(&name_a) // Reverse order to get latest first
});
let jar_path = jar_files[0].path();
add_log(format!("📋 Selected JAR: {:?}", jar_path.file_name().unwrap()));
Ok(jar_path)
}
// Normalize path to remove Windows UNC prefix
fn normalize_path(path: &PathBuf) -> PathBuf {
if cfg!(windows) {
let path_str = path.to_string_lossy();
if path_str.starts_with(r"\\?\") {
PathBuf::from(&path_str[4..]) // Remove \\?\ prefix
} else {
path.clone()
}
} else {
path.clone()
}
}
fn migrate_legacy_workspace(legacy_dir: &PathBuf, target_root: &PathBuf) -> std::io::Result<()> {
for entry in std::fs::read_dir(legacy_dir)? {
let entry = entry?;
let file_type = entry.file_type()?;
let src_path = entry.path();
let dest_path = target_root.join(entry.file_name());
if file_type.is_dir() {
copy_dir_recursive(&src_path, &dest_path)?;
} else if file_type.is_file() {
if let Some(parent) = dest_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::copy(&src_path, &dest_path)?;
}
}
Ok(())
}
fn copy_dir_recursive(src: &Path, dest: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(dest)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let file_type = entry.file_type()?;
let src_path = entry.path();
let dest_path = dest.join(entry.file_name());
if file_type.is_dir() {
copy_dir_recursive(&src_path, &dest_path)?;
} else if file_type.is_file() {
std::fs::copy(&src_path, &dest_path)?;
}
}
Ok(())
}
// Create, configure and run the Java command to run Stirling-PDF JAR
fn run_stirling_pdf_jar(app: &tauri::AppHandle, java_path: &PathBuf, jar_path: &PathBuf) -> Result<(), String> {
// Get platform-specific application data directory for Tauri mode
let app_data_dir = app_data_dir();
// Create subdirectories for different purposes
let config_dir = app_data_dir.join("configs");
let log_dir = app_data_dir.join("logs");
let work_dir = app_data_dir.clone();
let legacy_work_dir = app_data_dir.join("workspace");
// Create all necessary directories
std::fs::create_dir_all(&app_data_dir).ok();
std::fs::create_dir_all(&log_dir).ok();
std::fs::create_dir_all(&work_dir).ok();
std::fs::create_dir_all(&config_dir).ok();
// Migrate legacy workspace content into the app data root before launch.
if legacy_work_dir.exists() {
add_log(format!("📦 Migrating legacy workspace from {}", legacy_work_dir.display()));
if let Err(err) = migrate_legacy_workspace(&legacy_work_dir, &app_data_dir) {
add_log(format!("⚠️ Failed to migrate legacy workspace: {}", err));
} else {
match std::fs::remove_dir_all(&legacy_work_dir) {
Ok(_) => add_log("✅ Removed legacy workspace directory after migration".to_string()),
Err(err) => add_log(format!("⚠️ Failed to remove legacy workspace: {}", err)),
}
}
}
add_log(format!("📁 App data directory: {}", app_data_dir.display()));
add_log(format!("📁 Log directory: {}", log_dir.display()));
add_log(format!("📁 Working directory: {}", work_dir.display()));
add_log(format!("📁 Config directory: {}", config_dir.display()));
// Define all Java options with Tauri-specific paths
let log_path_option = format!("-Dlogging.file.path={}", log_dir.display());
let java_options = vec![
"-Xmx2g",
"-DBROWSER_OPEN=false",
"-DSTIRLING_PDF_TAURI_MODE=true",
&log_path_option,
"-Dlogging.file.name=stirling-pdf.log",
"-Dserver.port=0", // Let OS assign an available port
"-Dsecurity.enableLogin=false", // Disable login for desktop mode
"-Dsecurity.csrfDisabled=true", // Disable CSRF for desktop mode
"-jar",
jar_path.to_str().unwrap(),
];
// Log the equivalent command for external testing
let java_command = format!(
"TAURI_PARENT_PID={} \"{}\" {}",
std::process::id(),
java_path.display(),
java_options.join(" ")
);
add_log(format!("🔧 Equivalent command: {}", java_command));
add_log(format!("📁 Backend logs will be in: {}", log_dir.display()));
// Additional macOS-specific checks
if cfg!(target_os = "macos") {
// Check if java executable has execute permissions
if let Ok(metadata) = std::fs::metadata(java_path) {
let permissions = metadata.permissions();
add_log(format!("🔍 Java executable permissions: {:?}", permissions));
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = permissions.mode();
add_log(format!("🔍 Java executable mode: 0o{:o}", mode));
if mode & 0o111 == 0 {
add_log("⚠️ Java executable may not have execute permissions".to_string());
}
}
}
// Check if we can read the JAR file
if let Ok(metadata) = std::fs::metadata(jar_path) {
add_log(format!("📦 JAR file size: {} bytes", metadata.len()));
} else {
add_log("⚠️ Cannot read JAR file metadata".to_string());
}
}
let sidecar_command = app
.shell()
.command(java_path.to_str().unwrap())
.args(java_options)
.current_dir(&work_dir) // Set working directory to writable location
.env("TAURI_PARENT_PID", std::process::id().to_string())
.env("STIRLING_PDF_CONFIG_DIR", config_dir.to_str().unwrap())
.env("STIRLING_PDF_LOG_DIR", log_dir.to_str().unwrap())
.env("STIRLING_PDF_WORK_DIR", work_dir.to_str().unwrap());
add_log("⚙️ Starting backend with bundled JRE...".to_string());
let (rx, child) = sidecar_command
.spawn()
.map_err(|e| {
let error_msg = format!("❌ Failed to spawn sidecar: {}", e);
add_log(error_msg.clone());
error_msg
})?;
// Store the process handle
{
let mut process_guard = BACKEND_PROCESS.lock().unwrap();
*process_guard = Some(child);
}
add_log("✅ Backend started with bundled JRE, monitoring output...".to_string());
// Start monitoring output
monitor_backend_output(rx);
Ok(())
}
// Monitor backend output in a separate task
fn monitor_backend_output(mut rx: tauri::async_runtime::Receiver<tauri_plugin_shell::process::CommandEvent>) {
tokio::spawn(async move {
let mut _startup_detected = false;
let mut error_count = 0;
while let Some(event) = rx.recv().await {
match event {
tauri_plugin_shell::process::CommandEvent::Stdout(output) => {
let output_str = String::from_utf8_lossy(&output);
// Strip exactly one trailing newline to avoid double newlines
let output_str = output_str.strip_suffix('\n').unwrap_or(&output_str);
add_log(format!("📤 Backend: {}", output_str));
// Look for actual runtime port from web server initialization
// Format: "Stirling-PDF running on port: PORT"
if output_str.contains("running on port:") {
_startup_detected = true;
if let Some(port) = extract_port_from_running_log(&output_str) {
let mut port_guard = BACKEND_PORT.lock().unwrap();
*port_guard = Some(port);
add_log(format!("🎉 Backend started on port: {}", port));
add_log(format!("🔌 Navigate to: http://localhost:{}/", port));
}
}
if output_str.contains("Started SPDFApplication") {
_startup_detected = true;
add_log(format!("🎉 Backend startup completed: {}", output_str));
}
}
tauri_plugin_shell::process::CommandEvent::Stderr(output) => {
let output_str = String::from_utf8_lossy(&output);
// Strip exactly one trailing newline to avoid double newlines
let output_str = output_str.strip_suffix('\n').unwrap_or(&output_str);
add_log(format!("📥 Backend Error: {}", output_str));
// Look for error indicators
if output_str.contains("ERROR") || output_str.contains("Exception") || output_str.contains("FATAL") {
error_count += 1;
add_log(format!("⚠️ Backend error #{}: {}", error_count, output_str));
}
// Look for specific common issues
if output_str.contains("Address already in use") {
add_log("🚨 CRITICAL: Port 8080 is already in use by another process!".to_string());
}
if output_str.contains("java.lang.ClassNotFoundException") {
add_log("🚨 CRITICAL: Missing Java dependencies!".to_string());
}
if output_str.contains("java.io.FileNotFoundException") {
add_log("🚨 CRITICAL: Required file not found!".to_string());
}
}
tauri_plugin_shell::process::CommandEvent::Error(error) => {
add_log(format!("❌ Backend process error: {}", error));
}
tauri_plugin_shell::process::CommandEvent::Terminated(payload) => {
add_log(format!("💀 Backend terminated with code: {:?}", payload.code));
if let Some(code) = payload.code {
match code {
0 => println!("✅ Process terminated normally"),
1 => println!("❌ Process terminated with generic error"),
2 => println!("❌ Process terminated due to misuse"),
126 => println!("❌ Command invoked cannot execute"),
127 => println!("❌ Command not found"),
128 => println!("❌ Invalid exit argument"),
130 => println!("❌ Process terminated by Ctrl+C"),
_ => println!("❌ Process terminated with code: {}", code),
}
}
// Clear the stored process handle
let mut process_guard = BACKEND_PROCESS.lock().unwrap();
*process_guard = None;
}
_ => {
println!("🔍 Unknown command event: {:?}", event);
}
}
}
if error_count > 0 {
println!("⚠️ Backend process ended with {} errors detected", error_count);
}
});
}
// Command to start the backend with bundled JRE
#[tauri::command]
pub async fn start_backend(
app: tauri::AppHandle,
connection_state: tauri::State<'_, AppConnectionState>,
) -> Result<String, String> {
add_log("🚀 start_backend() called - Attempting to start backend with bundled JRE...".to_string());
// Check connection mode
let mode = {
let state = connection_state.0.lock().map_err(|e| {
let error_msg = format!("❌ Failed to access connection state: {}", e);
add_log(error_msg.clone());
error_msg
})?;
state.mode.clone()
};
match mode {
ConnectionMode::SaaS => {
add_log("☁️ Running in SaaS mode - starting local backend".to_string());
}
ConnectionMode::SelfHosted => {
add_log("🌐 Running in Self-Hosted mode - starting local backend (for hybrid execution support)".to_string());
}
ConnectionMode::Local => {
add_log("💻 Running in Local-only mode - starting local backend".to_string());
}
}
// Check if backend is already running or starting
if let Err(msg) = check_backend_status() {
return Ok(msg);
}
// Use Tauri's resource API to find the bundled JRE and JAR
let resource_dir = app.path().resource_dir().map_err(|e| {
let error_msg = format!("❌ Failed to get resource directory: {}", e);
add_log(error_msg.clone());
reset_starting_flag();
error_msg
})?;
add_log(format!("🔍 Resource directory: {:?}", resource_dir));
// Find the bundled JRE
let java_executable = find_bundled_jre(&resource_dir).map_err(|e| {
reset_starting_flag();
e
})?;
// Find the Stirling-PDF JAR
let jar_path = find_stirling_jar(&resource_dir).map_err(|e| {
reset_starting_flag();
e
})?;
// Normalize the paths to remove Windows UNC prefix
let normalized_java_path = normalize_path(&java_executable);
let normalized_jar_path = normalize_path(&jar_path);
add_log(format!("📦 Found JAR file: {:?}", jar_path));
add_log(format!("📦 Normalized JAR path: {:?}", normalized_jar_path));
add_log(format!("📦 Normalized Java path: {:?}", normalized_java_path));
// Create and start the Java command
run_stirling_pdf_jar(&app, &normalized_java_path, &normalized_jar_path).map_err(|e| {
reset_starting_flag();
e
})?;
// Reset the starting flag since startup is complete
reset_starting_flag();
add_log("✅ Backend startup sequence completed, starting flag cleared".to_string());
Ok("Backend startup initiated successfully with bundled JRE".to_string())
}
// Get the dynamically assigned backend port
#[tauri::command]
pub fn get_backend_port() -> Option<u16> {
let port_guard = BACKEND_PORT.lock().unwrap();
*port_guard
}
// Cleanup function to stop backend on app exit
pub fn cleanup_backend() {
let mut process_guard = BACKEND_PROCESS.lock().unwrap();
if let Some(child) = process_guard.take() {
let pid = child.pid();
add_log(format!("🧹 App shutting down, cleaning up backend process (PID: {})", pid));
match child.kill() {
Ok(_) => {
add_log(format!("✅ Backend process (PID: {}) terminated during cleanup", pid));
}
Err(e) => {
add_log(format!("❌ Failed to terminate backend process during cleanup: {}", e));
println!("❌ Failed to terminate backend process during cleanup: {}", e);
}
}
}
}