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:
ConnorYoh
2025-11-05 11:44:59 +00:00
committed by GitHub
co-authored by Connor Yoh James Brunton James Brunton
parent f3eed4428d
commit 4c0c9b28ef
120 changed files with 11005 additions and 1294 deletions
@@ -0,0 +1,91 @@
import { useState, useEffect, useCallback } from 'react';
import { tauriBackendService } from '@app/services/tauriBackendService';
export type BackendStatus = 'starting' | 'healthy' | 'unhealthy' | 'stopped';
interface BackendHealthState {
status: BackendStatus;
message?: string;
lastChecked?: number;
isChecking: boolean;
error: string | null;
}
/**
* Hook to monitor backend health status with retries
*/
export function useBackendHealth(pollingInterval = 5000) {
const [health, setHealth] = useState<BackendHealthState>({
status: tauriBackendService.isBackendRunning() ? 'healthy' : 'stopped',
isChecking: false,
error: null,
});
const checkHealth = useCallback(async () => {
setHealth((current) => ({
...current,
status: current.status === 'healthy' ? 'healthy' : 'starting',
isChecking: true,
error: 'Backend starting up...',
lastChecked: Date.now(),
}));
try {
const isHealthy = await tauriBackendService.checkBackendHealth();
setHealth({
status: isHealthy ? 'healthy' : 'unhealthy',
lastChecked: Date.now(),
message: isHealthy ? 'Backend is healthy' : 'Backend is unavailable',
isChecking: false,
error: isHealthy ? null : 'Backend offline',
});
return isHealthy;
} catch (error) {
console.error('[BackendHealth] Health check failed:', error);
setHealth({
status: 'unhealthy',
lastChecked: Date.now(),
message: 'Backend is unavailable',
isChecking: false,
error: 'Backend offline',
});
return false;
}
}, []);
useEffect(() => {
let isMounted = true;
const initialize = async () => {
setHealth((current) => ({
...current,
status: tauriBackendService.isBackendRunning() ? 'starting' : 'stopped',
isChecking: true,
error: 'Backend starting up...',
}));
await checkHealth();
if (!isMounted) return;
};
initialize();
const interval = setInterval(() => {
if (!isMounted) return;
void checkHealth();
}, pollingInterval);
return () => {
isMounted = false;
clearInterval(interval);
};
}, [checkHealth, pollingInterval]);
return {
...health,
isHealthy: health.status === 'healthy',
checkHealth,
};
}
@@ -0,0 +1,40 @@
import { useEffect } from 'react';
import { useBackendHealth } from '@app/hooks/useBackendHealth';
import { useEndpointConfig } from '@app/hooks/useEndpointConfig';
import { tauriBackendService } from '@app/services/tauriBackendService';
/**
* Hook to initialize backend and monitor health
*/
export function useBackendInitializer() {
const { status, checkHealth } = useBackendHealth();
const { backendUrl } = useEndpointConfig();
useEffect(() => {
// Skip if backend already running
if (tauriBackendService.isBackendRunning()) {
void checkHealth();
return;
}
const initializeBackend = async () => {
try {
console.log('[BackendInitializer] Starting backend...');
await tauriBackendService.startBackend(backendUrl);
console.log('[BackendInitializer] Backend started successfully');
// Begin health checks after a short delay
setTimeout(() => {
void checkHealth();
}, 500);
} catch (error) {
console.error('[BackendInitializer] Failed to start backend:', error);
}
};
// Only start backend if it's not already starting/healthy
if (status !== 'healthy' && status !== 'starting') {
void initializeBackend();
}
}, [status, backendUrl, checkHealth]);
}
@@ -0,0 +1,126 @@
import { useMemo, useState, useEffect } from 'react';
import apiClient from '@app/services/apiClient';
interface EndpointConfig {
backendUrl: string;
}
/**
* Desktop-specific endpoint checker that hits the backend directly via axios.
*/
export function useEndpointEnabled(endpoint: string): {
enabled: boolean | null;
loading: boolean;
error: string | null;
refetch: () => Promise<void>;
} {
const [enabled, setEnabled] = useState<boolean | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchEndpointStatus = async () => {
if (!endpoint) {
setEnabled(null);
setLoading(false);
return;
}
try {
setLoading(true);
setError(null);
const response = await apiClient.get<boolean>('/api/v1/config/endpoint-enabled', {
params: { endpoint },
});
setEnabled(response.data);
} catch (err: any) {
const message = err?.response?.data?.message || err?.message || 'Unknown error occurred';
setError(message);
setEnabled(null);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchEndpointStatus();
}, [endpoint]);
return {
enabled,
loading,
error,
refetch: fetchEndpointStatus,
};
}
export function useMultipleEndpointsEnabled(endpoints: string[]): {
endpointStatus: Record<string, boolean>;
loading: boolean;
error: string | null;
refetch: () => Promise<void>;
} {
const [endpointStatus, setEndpointStatus] = useState<Record<string, boolean>>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchAllEndpointStatuses = async () => {
if (!endpoints || endpoints.length === 0) {
setEndpointStatus({});
setLoading(false);
return;
}
try {
setLoading(true);
setError(null);
const endpointsParam = endpoints.join(',');
const response = await apiClient.get<Record<string, boolean>>('/api/v1/config/endpoints-enabled', {
params: { endpoints: endpointsParam },
});
setEndpointStatus(response.data);
} catch (err: any) {
const message = err?.response?.data?.message || err?.message || 'Unknown error occurred';
setError(message);
const fallbackStatus = endpoints.reduce((acc, endpointName) => {
acc[endpointName] = false;
return acc;
}, {} as Record<string, boolean>);
setEndpointStatus(fallbackStatus);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchAllEndpointStatuses();
}, [endpoints.join(',')]);
return {
endpointStatus,
loading,
error,
refetch: fetchAllEndpointStatuses,
};
}
/**
* Desktop override exposing the backend URL used by the embedded server.
*/
export function useEndpointConfig(): EndpointConfig {
const backendUrl = useMemo(() => {
const runtimeEnv = typeof process !== 'undefined' ? process.env : undefined;
return runtimeEnv?.STIRLING_BACKEND_URL
|| import.meta.env.VITE_DESKTOP_BACKEND_URL
|| import.meta.env.VITE_API_BASE_URL
|| 'http://localhost:8080';
}, []);
return { backendUrl };
}
@@ -0,0 +1,47 @@
import { useState, useEffect } from 'react';
import { fileOpenService } from '@app/services/fileOpenService';
export function useOpenedFile() {
const [openedFilePath, setOpenedFilePath] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const checkForOpenedFile = async () => {
console.log('🔍 Checking for opened file...');
try {
const filePath = await fileOpenService.getOpenedFile();
console.log('🔍 fileOpenService.getOpenedFile() returned:', filePath);
if (filePath) {
console.log('✅ App opened with file:', filePath);
setOpenedFilePath(filePath);
// Clear the file from service state after consuming it
await fileOpenService.clearOpenedFile();
} else {
console.log('️ No file was opened with the app');
}
} catch (error) {
console.error('❌ Failed to check for opened file:', error);
} finally {
setLoading(false);
}
};
checkForOpenedFile();
// Listen for runtime file open events (abstracted through service)
const unlistenRuntimeEvents = fileOpenService.onFileOpened((filePath: string) => {
console.log('📂 Runtime file open event:', filePath);
setOpenedFilePath(filePath);
});
// Cleanup function
return () => {
unlistenRuntimeEvents();
};
}, []);
return { openedFilePath, loading };
}