mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +02:00
V2 Tauri integration (#3854)
# Description of Changes Please provide a summary of the changes, including: ## Add PDF File Association Support for Tauri App ### 🎯 **Features Added** - PDF file association configuration in Tauri - Command line argument detection for opened files - Automatic file loading when app is launched via "Open with" - Cross-platform support (Windows/macOS) ### 🔧 **Technical Changes** - Added `fileAssociations` in `tauri.conf.json` for PDF files - New `get_opened_file` Tauri command to detect file arguments - `fileOpenService` with Tauri fs plugin integration - `useOpenedFile` hook for React integration - Improved backend health logging during startup (reduced noise) ### 🧪 **Testing** See * https://v2.tauri.app/start/prerequisites/ * [DesktopApplicationDevelopmentGuide.md](DesktopApplicationDevelopmentGuide.md) ```bash # Test file association during development: cd frontend npm install cargo tauri dev --no-watch -- -- "path/to/file.pdf" ``` For production testing: 1. Build: npm run tauri build 2. Install the built app 3. Right-click PDF → "Open with" → Stirling-PDF 🚀 User Experience - Users can now double-click PDF files to open them directly in Stirling-PDF - Files automatically load in the viewer when opened via file association - Seamless integration with OS file handling --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#6-testing) for more details. --------- Co-authored-by: Connor Yoh <[email protected]> Co-authored-by: James Brunton <[email protected]> Co-authored-by: James Brunton <[email protected]>
This commit is contained in:
co-authored by
Connor Yoh
James Brunton
James Brunton
parent
f3eed4428d
commit
4c0c9b28ef
@@ -0,0 +1,379 @@
|
||||
use tauri_plugin_shell::ShellExt;
|
||||
use tauri::Manager;
|
||||
use std::sync::Mutex;
|
||||
use std::path::PathBuf;
|
||||
use crate::utils::add_log;
|
||||
|
||||
// Store backend process handle globally
|
||||
static BACKEND_PROCESS: Mutex<Option<tauri_plugin_shell::process::CommandChild>> = Mutex::new(None);
|
||||
static BACKEND_STARTING: Mutex<bool> = Mutex::new(false);
|
||||
|
||||
// Helper function to reset starting flag
|
||||
fn reset_starting_flag() {
|
||||
let mut starting_guard = BACKEND_STARTING.lock().unwrap();
|
||||
*starting_guard = false;
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
|
||||
// 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 = if cfg!(target_os = "macos") {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
|
||||
PathBuf::from(home).join("Library").join("Application Support").join("Stirling-PDF")
|
||||
} else if cfg!(target_os = "windows") {
|
||||
let appdata = std::env::var("APPDATA").unwrap_or_else(|_| std::env::temp_dir().to_string_lossy().to_string());
|
||||
PathBuf::from(appdata).join("Stirling-PDF")
|
||||
} else {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
|
||||
PathBuf::from(home).join(".config").join("Stirling-PDF")
|
||||
};
|
||||
|
||||
// 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.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();
|
||||
|
||||
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_DESKTOP_UI=false",
|
||||
"-DSTIRLING_PDF_TAURI_MODE=true",
|
||||
&log_path_option,
|
||||
"-Dlogging.file.name=stirling-pdf.log",
|
||||
"-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);
|
||||
add_log(format!("📤 Backend: {}", output_str));
|
||||
|
||||
// Look for startup indicators
|
||||
if output_str.contains("Started SPDFApplication") ||
|
||||
output_str.contains("Navigate to "){
|
||||
_startup_detected = true;
|
||||
add_log(format!("🎉 Backend startup detected: {}", output_str));
|
||||
}
|
||||
|
||||
// Look for port binding
|
||||
if output_str.contains("8080") {
|
||||
add_log(format!("🔌 Port 8080 related output: {}", output_str));
|
||||
}
|
||||
}
|
||||
tauri_plugin_shell::process::CommandEvent::Stderr(output) => {
|
||||
let output_str = String::from_utf8_lossy(&output);
|
||||
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) -> Result<String, String> {
|
||||
add_log("🚀 start_backend() called - Attempting to start backend with bundled JRE...".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
|
||||
})?;
|
||||
|
||||
// Wait for the backend to start
|
||||
println!("⏳ Waiting for backend startup...");
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10000)).await;
|
||||
|
||||
// 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())
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
use crate::utils::add_log;
|
||||
use std::sync::Mutex;
|
||||
|
||||
// Store the opened file path globally
|
||||
static OPENED_FILE: Mutex<Option<String>> = Mutex::new(None);
|
||||
|
||||
// Set the opened file path (called by macOS file open events)
|
||||
pub fn set_opened_file(file_path: String) {
|
||||
let mut opened_file = OPENED_FILE.lock().unwrap();
|
||||
*opened_file = Some(file_path.clone());
|
||||
add_log(format!("📂 File opened via file open event: {}", file_path));
|
||||
}
|
||||
|
||||
// Command to get opened file path (if app was launched with a file)
|
||||
#[tauri::command]
|
||||
pub async fn get_opened_file() -> Result<Option<String>, String> {
|
||||
// First check if we have a file from macOS file open events
|
||||
{
|
||||
let opened_file = OPENED_FILE.lock().unwrap();
|
||||
if let Some(ref file_path) = *opened_file {
|
||||
add_log(format!("📂 Returning stored opened file: {}", file_path));
|
||||
return Ok(Some(file_path.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to command line arguments (Windows/Linux)
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
|
||||
// Look for a PDF file argument (skip the first arg which is the executable)
|
||||
for arg in args.iter().skip(1) {
|
||||
if arg.ends_with(".pdf") && std::path::Path::new(arg).exists() {
|
||||
add_log(format!("📂 PDF file opened via command line: {}", arg));
|
||||
return Ok(Some(arg.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
// Command to clear the opened file (after processing)
|
||||
#[tauri::command]
|
||||
pub async fn clear_opened_file() -> Result<(), String> {
|
||||
let mut opened_file = OPENED_FILE.lock().unwrap();
|
||||
*opened_file = None;
|
||||
add_log("📂 Cleared opened file".to_string());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// Command to check if backend is healthy
|
||||
#[tauri::command]
|
||||
pub async fn check_backend_health() -> Result<bool, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
||||
|
||||
match client.get("http://localhost:8080/api/v1/info/status").send().await {
|
||||
Ok(response) => {
|
||||
let status = response.status();
|
||||
if status.is_success() {
|
||||
match response.text().await {
|
||||
Ok(_body) => {
|
||||
println!("✅ Backend health check successful");
|
||||
Ok(true)
|
||||
}
|
||||
Err(e) => {
|
||||
println!("⚠️ Failed to read health response: {}", e);
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!("⚠️ Health check failed with status: {}", status);
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// Only log connection errors if they're not the common "connection refused" during startup
|
||||
if !e.to_string().contains("connection refused") && !e.to_string().contains("No connection could be made") {
|
||||
println!("❌ Health check error: {}", e);
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
pub mod backend;
|
||||
pub mod health;
|
||||
pub mod files;
|
||||
|
||||
pub use backend::{start_backend, cleanup_backend};
|
||||
pub use health::check_backend_health;
|
||||
pub use files::{get_opened_file, clear_opened_file, set_opened_file};
|
||||
Reference in New Issue
Block a user