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,7 @@
interface RightRailFooterExtensionsProps {
className?: string;
}
export function RightRailFooterExtensions(_props: RightRailFooterExtensionsProps) {
return null;
}
@@ -13,6 +13,7 @@ import { Tooltip } from '@app/components/shared/Tooltip';
import { ViewerContext } from '@app/contexts/ViewerContext';
import { useSignature } from '@app/contexts/SignatureContext';
import LocalIcon from '@app/components/shared/LocalIcon';
import { RightRailFooterExtensions } from '@app/components/rightRail/RightRailFooterExtensions';
import { useSidebarContext } from '@app/contexts/SidebarContext';
import { RightRailButtonConfig, RightRailRenderContext, RightRailSection } from '@app/types/rightRail';
@@ -224,6 +225,8 @@ export default function RightRail() {
</div>
<div className="right-rail-spacer" />
<RightRailFooterExtensions className="right-rail-footer" />
</div>
</div>
);
@@ -17,6 +17,9 @@
align-items: center;
gap: 0.75rem;
padding: 1rem 0.5rem;
width: 100%;
height: 100%;
flex: 1;
}
.right-rail-section {
+8 -1
View File
@@ -20,14 +20,21 @@ import { useFilesModalContext } from "@app/contexts/FilesModalContext";
import AppConfigModal from "@app/components/shared/AppConfigModal";
import ToolPanelModePrompt from "@app/components/tools/ToolPanelModePrompt";
import AdminAnalyticsChoiceModal from "@app/components/shared/AdminAnalyticsChoiceModal";
import { useHomePageExtensions } from "@app/pages/useHomePageExtensions";
import "@app/pages/HomePage.css";
type MobileView = "tools" | "workbench";
interface HomePageProps {
openedFile?: File | null;
}
export default function HomePage() {
export default function HomePage({ openedFile }: HomePageProps = {}) {
const { t } = useTranslation();
// Extension hook for desktop-specific behavior (e.g., file opening)
useHomePageExtensions(openedFile);
const {
sidebarRefs,
} = useSidebarContext();
@@ -0,0 +1,10 @@
import { useEffect } from 'react';
/**
* Extension point for HomePage behaviour.
* Core version does nothing.
*/
export function useHomePageExtensions(_openedFile?: File | null) {
useEffect(() => {
}, [_openedFile]);
}
+2 -1
View File
@@ -1,10 +1,11 @@
import axios from 'axios';
import { handleHttpError } from '@app/services/httpErrorHandler';
import { setupApiInterceptors } from '@app/services/apiClientSetup';
import { getApiBaseUrl } from '@app/services/apiClientConfig';
// Create axios instance with default config
const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/',
baseURL: getApiBaseUrl(),
responseType: 'json',
});
@@ -0,0 +1,7 @@
/**
* Get the base URL for API requests.
* Core version uses simple environment variable.
*/
export function getApiBaseUrl(): string {
return import.meta.env.VITE_API_BASE_URL || '/';
}
+234
View File
@@ -0,0 +1,234 @@
import { createContext, useContext, useEffect, useState, ReactNode, useCallback } from 'react';
import apiClient from '@app/services/apiClient';
import { springAuth } from '@app/auth/springAuthClient';
import type { Session, User, AuthError, AuthChangeEvent } from '@app/auth/springAuthClient';
/**
* Auth Context Type
* Simplified version without SaaS-specific features (credits, subscriptions)
*/
interface AuthContextType {
session: Session | null;
user: User | null;
loading: boolean;
error: AuthError | null;
signOut: () => Promise<void>;
refreshSession: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType>({
session: null,
user: null,
loading: true,
error: null,
signOut: async () => {},
refreshSession: async () => {},
});
/**
* Auth Provider Component
*
* Manages authentication state and provides it to the entire app.
* Integrates with Spring Security + JWT backend.
*/
export function AuthProvider({ children }: { children: ReactNode }) {
const [session, setSession] = useState<Session | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<AuthError | null>(null);
/**
* Refresh current session
*/
const refreshSession = useCallback(async () => {
try {
setLoading(true);
setError(null);
console.debug('[Auth] Refreshing session...');
const { data, error } = await springAuth.refreshSession();
if (error) {
console.error('[Auth] Session refresh error:', error);
setError(error);
setSession(null);
} else {
console.debug('[Auth] Session refreshed successfully');
setSession(data.session);
}
} catch (err) {
console.error('[Auth] Unexpected error during session refresh:', err);
setError(err as AuthError);
} finally {
setLoading(false);
}
}, []);
/**
* Sign out user
*/
const signOut = useCallback(async () => {
try {
setError(null);
console.debug('[Auth] Signing out...');
const { error } = await springAuth.signOut();
if (error) {
console.error('[Auth] Sign out error:', error);
setError(error);
} else {
console.debug('[Auth] Signed out successfully');
setSession(null);
}
} catch (err) {
console.error('[Auth] Unexpected error during sign out:', err);
setError(err as AuthError);
}
}, []);
/**
* Initialize auth on mount
*/
useEffect(() => {
let mounted = true;
const initializeAuth = async () => {
try {
console.debug('[Auth] Initializing auth...');
// First check if login is enabled
const configResponse = await apiClient.get('/api/v1/config/app-config');
if (configResponse.status === 200) {
const config = configResponse.data;
// If login is disabled, skip authentication entirely
if (config.enableLogin === false) {
console.debug('[Auth] Login disabled - skipping authentication');
if (mounted) {
setSession(null);
setLoading(false);
}
return;
}
}
// Login is enabled, proceed with normal auth check
const { data, error } = await springAuth.getSession();
if (!mounted) return;
if (error) {
console.error('[Auth] Initial session error:', error);
setError(error);
} else {
console.debug('[Auth] Initial session loaded:', {
hasSession: !!data.session,
userId: data.session?.user?.id,
email: data.session?.user?.email,
});
setSession(data.session);
}
} catch (err) {
console.error('[Auth] Unexpected error during auth initialization:', err);
if (mounted) {
setError(err as AuthError);
}
} finally {
if (mounted) {
setLoading(false);
}
}
};
initializeAuth();
// Subscribe to auth state changes
const { data: { subscription } } = springAuth.onAuthStateChange(
async (event: AuthChangeEvent, newSession: Session | null) => {
if (!mounted) return;
console.debug('[Auth] Auth state change:', {
event,
hasSession: !!newSession,
userId: newSession?.user?.id,
email: newSession?.user?.email,
timestamp: new Date().toISOString(),
});
// Schedule state update
setTimeout(() => {
if (mounted) {
setSession(newSession);
setError(null);
// Handle specific events
if (event === 'SIGNED_OUT') {
console.debug('[Auth] User signed out, clearing session');
} else if (event === 'SIGNED_IN') {
console.debug('[Auth] User signed in successfully');
} else if (event === 'TOKEN_REFRESHED') {
console.debug('[Auth] Token refreshed');
} else if (event === 'USER_UPDATED') {
console.debug('[Auth] User updated');
}
}
}, 0);
}
);
return () => {
mounted = false;
subscription.unsubscribe();
};
}, []);
const value: AuthContextType = {
session,
user: session?.user ?? null,
loading,
error,
signOut,
refreshSession,
};
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
}
/**
* Hook to access auth context
* Must be used within AuthProvider
*/
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
/**
* Debug hook to expose auth state for debugging
* Can be used in development to monitor auth state
*/
export function useAuthDebug() {
const auth = useAuth();
useEffect(() => {
console.debug('[Auth Debug] Current auth state:', {
hasSession: !!auth.session,
hasUser: !!auth.user,
loading: auth.loading,
hasError: !!auth.error,
userId: auth.user?.id,
email: auth.user?.email,
});
}, [auth.session, auth.user, auth.loading, auth.error]);
return auth;
}
@@ -0,0 +1,437 @@
/**
* Spring Auth Client
*
* This client integrates with the Spring Security + JWT backend.
* - Uses localStorage for JWT storage (sent via Authorization header)
* - JWT validation handled server-side
* - No email confirmation flow (auto-confirmed on registration)
*/
import apiClient from '@app/services/apiClient';
// Auth types
export interface User {
id: string;
email: string;
username: string;
role: string;
enabled?: boolean;
is_anonymous?: boolean;
app_metadata?: Record<string, any>;
}
export interface Session {
user: User;
access_token: string;
expires_in: number;
expires_at?: number;
}
export interface AuthError {
message: string;
status?: number;
}
export interface AuthResponse {
user: User | null;
session: Session | null;
error: AuthError | null;
}
export type AuthChangeEvent =
| 'SIGNED_IN'
| 'SIGNED_OUT'
| 'TOKEN_REFRESHED'
| 'USER_UPDATED';
type AuthChangeCallback = (event: AuthChangeEvent, session: Session | null) => void;
class SpringAuthClient {
private listeners: AuthChangeCallback[] = [];
private sessionCheckInterval: NodeJS.Timeout | null = null;
private readonly SESSION_CHECK_INTERVAL = 60000; // 1 minute
private readonly TOKEN_REFRESH_THRESHOLD = 300000; // 5 minutes before expiry
constructor() {
// Start periodic session validation
this.startSessionMonitoring();
}
/**
* Helper to get CSRF token from cookie
*/
private getCsrfToken(): string | null {
const cookies = document.cookie.split(';');
for (const cookie of cookies) {
const [name, value] = cookie.trim().split('=');
if (name === 'XSRF-TOKEN') {
return value;
}
}
return null;
}
/**
* Get current session
* JWT is stored in localStorage and sent via Authorization header
*/
async getSession(): Promise<{ data: { session: Session | null }; error: AuthError | null }> {
try {
// Get JWT from localStorage
const token = localStorage.getItem('stirling_jwt');
if (!token) {
console.debug('[SpringAuth] getSession: No JWT in localStorage');
return { data: { session: null }, error: null };
}
// Verify with backend
// Note: We pass the token explicitly here, overriding the interceptor's default
const response = await apiClient.get('/api/v1/auth/me', {
headers: {
'Authorization': `Bearer ${token}`,
},
});
const data = response.data;
// Create session object
const session: Session = {
user: data.user,
access_token: token,
expires_in: 3600,
expires_at: Date.now() + 3600 * 1000,
};
console.debug('[SpringAuth] getSession: Session retrieved successfully');
return { data: { session }, error: null };
} catch (error: any) {
console.error('[SpringAuth] getSession error:', error);
// If 401/403, token is invalid - clear it
if (error?.response?.status === 401 || error?.response?.status === 403) {
localStorage.removeItem('stirling_jwt');
console.debug('[SpringAuth] getSession: Not authenticated');
return { data: { session: null }, error: null };
}
// Clear potentially invalid token on other errors too
localStorage.removeItem('stirling_jwt');
return {
data: { session: null },
error: { message: error?.response?.data?.message || error?.message || 'Unknown error' },
};
}
}
/**
* Sign in with email and password
*/
async signInWithPassword(credentials: {
email: string;
password: string;
}): Promise<AuthResponse> {
try {
const response = await apiClient.post('/api/v1/auth/login', {
username: credentials.email,
password: credentials.password
}, {
withCredentials: true, // Include cookies for CSRF
});
const data = response.data;
const token = data.session.access_token;
// Store JWT in localStorage
localStorage.setItem('stirling_jwt', token);
console.log('[SpringAuth] JWT stored in localStorage');
const session: Session = {
user: data.user,
access_token: token,
expires_in: data.session.expires_in,
expires_at: Date.now() + data.session.expires_in * 1000,
};
// Notify listeners
this.notifyListeners('SIGNED_IN', session);
return { user: data.user, session, error: null };
} catch (error: any) {
console.error('[SpringAuth] signInWithPassword error:', error);
const errorMessage = error?.response?.data?.error || error?.message || 'Login failed';
return {
user: null,
session: null,
error: { message: errorMessage },
};
}
}
/**
* Sign up new user
*/
async signUp(credentials: {
email: string;
password: string;
options?: { data?: { full_name?: string }; emailRedirectTo?: string };
}): Promise<AuthResponse> {
try {
const response = await apiClient.post('/api/v1/user/register', {
username: credentials.email,
password: credentials.password,
}, {
withCredentials: true,
});
const data = response.data;
// Note: Spring backend auto-confirms users (no email verification)
// Return user but no session (user needs to login)
return { user: data.user, session: null, error: null };
} catch (error: any) {
console.error('[SpringAuth] signUp error:', error);
const errorMessage = error?.response?.data?.error || error?.message || 'Registration failed';
return {
user: null,
session: null,
error: { message: errorMessage },
};
}
}
/**
* Sign in with OAuth provider (GitHub, Google, etc.)
* Redirects to Spring OAuth2 authorization endpoint
*/
async signInWithOAuth(params: {
provider: 'github' | 'google' | 'apple' | 'azure';
options?: { redirectTo?: string; queryParams?: Record<string, any> };
}): Promise<{ error: AuthError | null }> {
try {
const redirectUrl = `/oauth2/authorization/${params.provider}`;
console.log('[SpringAuth] Redirecting to OAuth:', redirectUrl);
window.location.assign(redirectUrl);
return { error: null };
} catch (error) {
return {
error: { message: error instanceof Error ? error.message : 'OAuth redirect failed' },
};
}
}
/**
* Send password reset email
* Not used in OSS version, but included for completeness
*/
async resetPasswordForEmail(email: string): Promise<{ data: object; error: AuthError | null }> {
try {
await apiClient.post('/api/v1/auth/reset-password', {
email,
}, {
withCredentials: true,
});
return { data: {}, error: null };
} catch (error: any) {
console.error('[SpringAuth] resetPasswordForEmail error:', error);
return {
data: {},
error: {
message: error?.response?.data?.error || error?.message || 'Password reset failed',
},
};
}
}
/**
* Sign out user (invalidate session)
*/
async signOut(): Promise<{ error: AuthError | null }> {
try {
const response = await apiClient.post('/api/v1/auth/logout', null, {
headers: {
'X-CSRF-TOKEN': this.getCsrfToken() || '',
},
withCredentials: true,
});
if (response.status === 200) {
console.debug('[SpringAuth] signOut: Success');
}
// Clean up local storage
localStorage.removeItem('stirling_jwt');
// Notify listeners
this.notifyListeners('SIGNED_OUT', null);
return { error: null };
} catch (error: any) {
console.error('[SpringAuth] signOut error:', error);
return {
error: {
message: error?.response?.data?.error || error?.message || 'Logout failed',
},
};
}
}
/**
* Refresh JWT token
*/
async refreshSession(): Promise<{ data: { session: Session | null }; error: AuthError | null }> {
try {
const response = await apiClient.post('/api/v1/auth/refresh', null, {
headers: {
'X-CSRF-TOKEN': this.getCsrfToken() || '',
},
withCredentials: true,
});
const data = response.data;
const token = data.session.access_token;
// Update local storage with new token
localStorage.setItem('stirling_jwt', token);
const session: Session = {
user: data.user,
access_token: token,
expires_in: data.session.expires_in,
expires_at: Date.now() + data.session.expires_in * 1000,
};
// Notify listeners
this.notifyListeners('TOKEN_REFRESHED', session);
return { data: { session }, error: null };
} catch (error: any) {
console.error('[SpringAuth] refreshSession error:', error);
localStorage.removeItem('stirling_jwt');
// Handle different error statuses
if (error?.response?.status === 401 || error?.response?.status === 403) {
return { data: { session: null }, error: { message: 'Token refresh failed - please log in again' } };
}
return {
data: { session: null },
error: { message: error?.response?.data?.message || error?.message || 'Token refresh failed' },
};
}
}
/**
* Listen to auth state changes
*/
onAuthStateChange(callback: AuthChangeCallback): { data: { subscription: { unsubscribe: () => void } } } {
this.listeners.push(callback);
return {
data: {
subscription: {
unsubscribe: () => {
this.listeners = this.listeners.filter((cb) => cb !== callback);
},
},
},
};
}
// Private helper methods
private notifyListeners(event: AuthChangeEvent, session: Session | null) {
// Use setTimeout to avoid calling callbacks synchronously
setTimeout(() => {
this.listeners.forEach((callback) => {
try {
callback(event, session);
} catch (error) {
console.error('[SpringAuth] Error in auth state change listener:', error);
}
});
}, 0);
}
private startSessionMonitoring() {
// Periodically check session validity
// Since we use HttpOnly cookies, we just need to check with the server
this.sessionCheckInterval = setInterval(async () => {
try {
// Try to get current session
const { data } = await this.getSession();
// If we have a session, proactively refresh if needed
// (The server will handle token expiry, but we can be proactive)
if (data.session) {
const timeUntilExpiry = (data.session.expires_at || 0) - Date.now();
// Refresh if token expires soon
if (timeUntilExpiry > 0 && timeUntilExpiry < this.TOKEN_REFRESH_THRESHOLD) {
console.log('[SpringAuth] Proactively refreshing token');
await this.refreshSession();
}
}
} catch (error) {
console.error('[SpringAuth] Session monitoring error:', error);
}
}, this.SESSION_CHECK_INTERVAL);
}
public destroy() {
if (this.sessionCheckInterval) {
clearInterval(this.sessionCheckInterval);
}
}
}
export const springAuth = new SpringAuthClient();
/**
* Get current user
*/
export const getCurrentUser = async () => {
const { data } = await springAuth.getSession();
return data.session?.user || null;
};
/**
* Check if user is anonymous
*/
export const isUserAnonymous = (user: User | null) => {
return user?.is_anonymous === true;
};
/**
* Create an anonymous user object for use when login is disabled
* This provides a consistent User interface throughout the app
*/
export const createAnonymousUser = (): User => {
return {
id: 'anonymous',
email: 'anonymous@local',
username: 'Anonymous User',
role: 'USER',
enabled: true,
is_anonymous: true,
app_metadata: {
provider: 'anonymous',
},
};
};
/**
* Create an anonymous session for use when login is disabled
*/
export const createAnonymousSession = (): Session => {
return {
user: createAnonymousUser(),
access_token: '',
expires_in: Number.MAX_SAFE_INTEGER,
expires_at: Number.MAX_SAFE_INTEGER,
};
};
// Export auth client as default for convenience
export default springAuth;
@@ -0,0 +1,80 @@
import React, { useMemo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Tooltip, useMantineTheme, useComputedColorScheme, rem } from '@mantine/core';
import { useBackendHealth } from '@app/hooks/useBackendHealth';
interface BackendHealthIndicatorProps {
className?: string;
}
export const BackendHealthIndicator: React.FC<BackendHealthIndicatorProps> = ({
className = ''
}) => {
const { t } = useTranslation();
const theme = useMantineTheme();
const colorScheme = useComputedColorScheme('light');
const { isHealthy, isChecking, checkHealth } = useBackendHealth();
const label = useMemo(() => {
if (isChecking) {
return t('backendHealth.checking', 'Checking backend status...');
}
if (isHealthy) {
return t('backendHealth.online', 'Backend Online');
}
return t('backendHealth.offline', 'Backend Offline');
}, [isChecking, isHealthy, t]);
const dotColor = useMemo(() => {
if (isChecking) {
return theme.colors.yellow?.[5] ?? '#fcc419';
}
if (isHealthy) {
return theme.colors.green?.[5] ?? '#37b24d';
}
return theme.colors.red?.[6] ?? '#e03131';
}, [isChecking, isHealthy, theme.colors.green, theme.colors.red, theme.colors.yellow]);
const handleKeyDown = useCallback((event: React.KeyboardEvent<HTMLSpanElement>) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
checkHealth();
}
}, [checkHealth]);
return (
<Tooltip
label={label}
position="left"
offset={12}
withArrow
withinPortal
color={colorScheme === 'dark' ? undefined : 'dark'}
>
<Box
component="span"
className={className ? `${className}` : undefined}
role="status"
aria-live="polite"
aria-label={label}
tabIndex={0}
onClick={checkHealth}
onKeyDown={handleKeyDown}
style={{
width: rem(12),
height: rem(12),
borderRadius: '50%',
backgroundColor: dotColor,
boxShadow: colorScheme === 'dark'
? '0 0 0 2px rgba(255, 255, 255, 0.18)'
: '0 0 0 2px rgba(0, 0, 0, 0.08)',
cursor: 'pointer',
display: 'inline-block',
outline: 'none',
}}
/>
</Tooltip>
);
};
@@ -0,0 +1,23 @@
import { Box, rem } from '@mantine/core';
import { BackendHealthIndicator } from '@app/components/BackendHealthIndicator';
interface RightRailFooterExtensionsProps {
className?: string;
}
export function RightRailFooterExtensions({ className }: RightRailFooterExtensionsProps) {
return (
<Box
className={className}
style={{
width: '100%',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
paddingBottom: rem(12),
}}
>
<BackendHealthIndicator />
</Box>
);
}
@@ -0,0 +1,135 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import apiClient from '@app/services/apiClient';
// Retry configuration
const MAX_RETRIES = 5;
const INITIAL_DELAY = 1000; // 1 second
/**
* Sleep utility for delays
*/
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
export interface AppConfig {
baseUrl?: string;
contextPath?: string;
serverPort?: number;
appNameNavbar?: string;
languages?: string[];
enableLogin?: boolean;
enableEmailInvites?: boolean;
isAdmin?: boolean;
enableAlphaFunctionality?: boolean;
enableAnalytics?: boolean | null;
enablePosthog?: boolean | null;
enableScarf?: boolean | null;
premiumEnabled?: boolean;
premiumKey?: string;
termsAndConditions?: string;
privacyPolicy?: string;
cookiePolicy?: string;
impressum?: string;
accessibilityStatement?: string;
runningProOrHigher?: boolean;
runningEE?: boolean;
license?: string;
SSOAutoLogin?: boolean;
serverCertificateEnabled?: boolean;
error?: string;
}
interface AppConfigContextValue {
config: AppConfig | null;
loading: boolean;
error: string | null;
refetch: () => Promise<void>;
}
// Create context
const AppConfigContext = createContext<AppConfigContextValue | undefined>(undefined);
/**
* Provider component that fetches and provides app configuration
* Should be placed at the top level of the app, before any components that need config
*/
export const AppConfigProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [config, setConfig] = useState<AppConfig | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchConfig = async () => {
setLoading(true);
setError(null);
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
if (attempt > 0) {
const delay = INITIAL_DELAY * Math.pow(2, attempt - 1);
console.log(`[AppConfig] Retry attempt ${attempt}/${MAX_RETRIES} after ${delay}ms delay...`);
await sleep(delay);
} else {
console.log('[AppConfig] Fetching app config...');
}
const response = await apiClient.get<AppConfig>('/api/v1/config/app-config');
setConfig(response.data);
console.log('[AppConfig] Successfully fetched app config');
setLoading(false);
return; // Success - exit function
} catch (err: any) {
const status = err?.response?.status;
// Check if we should retry (network errors or 5xx errors)
const shouldRetry = (!status || status >= 500) && attempt < MAX_RETRIES;
if (shouldRetry) {
console.warn(`[AppConfig] Attempt ${attempt + 1} failed (status ${status || 'network error'}):`, err.message, '- will retry...');
continue;
}
// Final attempt failed or non-retryable error (4xx)
const errorMessage = err?.response?.data?.message || err?.message || 'Unknown error occurred';
setError(errorMessage);
console.error(`[AppConfig] Failed to fetch app config after ${attempt + 1} attempts:`, err);
break;
}
}
setLoading(false);
};
useEffect(() => {
fetchConfig();
}, []);
const value: AppConfigContextValue = {
config,
loading,
error,
refetch: fetchConfig,
};
return (
<AppConfigContext.Provider value={value}>
{children}
</AppConfigContext.Provider>
);
};
/**
* Hook to access application configuration
* Must be used within AppConfigProvider
*/
export function useAppConfig(): AppConfigContextValue {
const context = useContext(AppConfigContext);
if (context === undefined) {
throw new Error('useAppConfig must be used within AppConfigProvider');
}
return context;
}
@@ -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 };
}
@@ -0,0 +1,26 @@
import { useEffect } from 'react';
/**
* Desktop override: Handle file opened with app (Tauri mode)
*/
export function useHomePageExtensions(openedFile?: File | null) {
useEffect(() => {
if (openedFile) {
const loadOpenedFile = async () => {
try {
// TAURI NOTE: Implement file opening logic here
// // Add to active files if not already present
// await addToActiveFiles(openedFile);
// // Switch to viewer mode to show the opened file
// setCurrentView('viewer');
// setReaderMode(true);
} catch (error) {
console.error('Failed to load opened file:', error);
}
};
loadOpenedFile();
}
}, [openedFile]);
}
+95
View File
@@ -0,0 +1,95 @@
import { Navigate, useLocation } from 'react-router-dom';
import { useState, useEffect } from 'react';
import { useOpenedFile } from '@app/hooks/useOpenedFile';
import { useBackendInitializer } from '@app/hooks/useBackendInitializer';
import { fileOpenService } from '@app/services/fileOpenService';
import { useAuth } from '@app/auth/UseSession';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import HomePage from '@app/pages/HomePage';
import Login from '@app/routes/Login';
/**
* Landing component - Smart router based on authentication status
*
* If login is disabled: Show HomePage directly (anonymous mode)
* If user is authenticated: Show HomePage
* If user is not authenticated: Show Login or redirect to /login
*/
export default function Landing() {
const { session, loading: authLoading } = useAuth();
const { config, loading: configLoading } = useAppConfig();
const location = useLocation();
const loading = authLoading || configLoading;
console.log('[Landing] State:', {
pathname: location.pathname,
loading,
hasSession: !!session,
loginEnabled: config?.enableLogin,
});
// Initialize backend on app startup
useBackendInitializer();
// Handle file opened with app (Tauri mode)
const { openedFilePath, loading: openedFileLoading } = useOpenedFile();
const [openedFile, setOpenedFile] = useState<File | null>(null);
// Load opened file once when path is available
useEffect(() => {
if (openedFilePath && !openedFileLoading) {
const loadOpenedFile = async () => {
try {
const fileData = await fileOpenService.readFileAsArrayBuffer(openedFilePath);
if (fileData) {
// Create a File object from the ArrayBuffer
const file = new File([fileData.arrayBuffer], fileData.fileName, {
type: 'application/pdf'
});
setOpenedFile(file);
}
} catch (error) {
console.error('Failed to load opened file:', error);
}
};
loadOpenedFile();
}
}, [openedFilePath, openedFileLoading]);
// Show loading while checking auth and config
if (loading) {
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-3"></div>
<div className="text-gray-600">
Loading...
</div>
</div>
</div>
);
}
// If login is disabled, show app directly (anonymous mode)
if (config?.enableLogin === false) {
console.debug('[Landing] Login disabled - showing app in anonymous mode');
return <HomePage openedFile={openedFile} />;
}
// If we have a session, show the main app
if (session) {
return <HomePage openedFile={openedFile} />;
}
// If we're at home route ("/"), show login directly (marketing/landing page)
// Otherwise navigate to login (fixes URL mismatch for tool routes)
const isHome = location.pathname === '/' || location.pathname === '';
if (isHome) {
return <Login />;
}
// For non-home routes without auth, navigate to login (preserves from location)
return <Navigate to="/login" replace state={{ from: location }} />;
}
@@ -0,0 +1,17 @@
import { isTauri } from '@tauri-apps/api/core';
/**
* Desktop override: Determine base URL depending on Tauri environment
*/
export function getApiBaseUrl(): string {
if (!isTauri()) {
return import.meta.env.VITE_API_BASE_URL || '/';
}
if (import.meta.env.DEV) {
// During tauri dev we rely on Vite proxy, so use relative path to avoid CORS preflight
return '/';
}
return import.meta.env.VITE_DESKTOP_BACKEND_URL || 'http://localhost:8080';
}
@@ -0,0 +1,147 @@
import { invoke, isTauri } from '@tauri-apps/api/core';
export interface FileOpenService {
getOpenedFile(): Promise<string | null>;
readFileAsArrayBuffer(filePath: string): Promise<{ fileName: string; arrayBuffer: ArrayBuffer } | null>;
clearOpenedFile(): Promise<void>;
onFileOpened(callback: (filePath: string) => void): () => void; // Returns unlisten function
}
class TauriFileOpenService implements FileOpenService {
async getOpenedFile(): Promise<string | null> {
try {
console.log('🔍 Calling invoke(get_opened_file)...');
const result = await invoke<string | null>('get_opened_file');
console.log('🔍 invoke(get_opened_file) returned:', result);
return result;
} catch (error) {
console.error('❌ Failed to get opened file:', error);
return null;
}
}
async readFileAsArrayBuffer(filePath: string): Promise<{ fileName: string; arrayBuffer: ArrayBuffer } | null> {
try {
const { readFile } = await import('@tauri-apps/plugin-fs');
const fileData = await readFile(filePath);
const fileName = filePath.split(/[\\/]/).pop() || 'opened-file.pdf';
return {
fileName,
arrayBuffer: fileData.buffer.slice(fileData.byteOffset, fileData.byteOffset + fileData.byteLength)
};
} catch (error) {
console.error('Failed to read file:', error);
return null;
}
}
async clearOpenedFile(): Promise<void> {
try {
console.log('🔍 Calling invoke(clear_opened_file)...');
await invoke('clear_opened_file');
console.log('✅ Successfully cleared opened file');
} catch (error) {
console.error('❌ Failed to clear opened file:', error);
}
}
onFileOpened(callback: (filePath: string) => void): () => void {
let cleanup: (() => void) | null = null;
let isCleanedUp = false;
const setupEventListeners = async () => {
try {
// Check if already cleaned up before async setup completes
if (isCleanedUp) {
return;
}
// Only import if in Tauri environment
if (isTauri()) {
const { listen } = await import('@tauri-apps/api/event');
// Check again after async import
if (isCleanedUp) {
return;
}
// Listen for macOS native file open events
const unlistenMacOS = await listen('macos://open-file', (event) => {
console.log('📂 macOS native file open event:', event.payload);
callback(event.payload as string);
});
// Listen for fallback file open events
const unlistenFallback = await listen('file-opened', (event) => {
console.log('📂 Fallback file open event:', event.payload);
callback(event.payload as string);
});
// Set up cleanup function only if not already cleaned up
if (!isCleanedUp) {
cleanup = () => {
try {
unlistenMacOS();
unlistenFallback();
console.log('✅ File event listeners cleaned up');
} catch (error) {
console.error('❌ Error during file event cleanup:', error);
}
};
} else {
// Clean up immediately if cleanup was called during setup
try {
unlistenMacOS();
unlistenFallback();
} catch (error) {
console.error('❌ Error during immediate cleanup:', error);
}
}
}
} catch (error) {
console.error('❌ Failed to setup file event listeners:', error);
}
};
setupEventListeners();
// Return cleanup function
return () => {
isCleanedUp = true;
if (cleanup) {
cleanup();
}
};
}
}
class WebFileOpenService implements FileOpenService {
async getOpenedFile(): Promise<string | null> {
// In web mode, there's no file association support
return null;
}
async readFileAsArrayBuffer(_filePath: string): Promise<{ fileName: string; arrayBuffer: ArrayBuffer } | null> {
// In web mode, cannot read arbitrary file paths
return null;
}
async clearOpenedFile(): Promise<void> {
// In web mode, no file clearing needed
}
onFileOpened(_callback: (filePath: string) => void): () => void {
// In web mode, no file events - return no-op cleanup function
console.log('️ Web mode: File event listeners not supported');
return () => {
// No-op cleanup for web mode
};
}
}
// Export the appropriate service based on environment
export const fileOpenService: FileOpenService = isTauri()
? new TauriFileOpenService()
: new WebFileOpenService();
@@ -0,0 +1,61 @@
import { invoke } from '@tauri-apps/api/core';
export class TauriBackendService {
private static instance: TauriBackendService;
private backendStarted = false;
static getInstance(): TauriBackendService {
if (!TauriBackendService.instance) {
TauriBackendService.instance = new TauriBackendService();
}
return TauriBackendService.instance;
}
isBackendRunning(): boolean {
return this.backendStarted;
}
async startBackend(backendUrl?: string): Promise<void> {
if (this.backendStarted) {
return;
}
try {
const result = await invoke('start_backend', { backendUrl });
console.log('Backend started:', result);
this.backendStarted = true;
// Wait for backend to be healthy
await this.waitForHealthy();
} catch (error) {
console.error('Failed to start backend:', error);
throw error;
}
}
async checkBackendHealth(): Promise<boolean> {
if (!this.backendStarted) {
return false;
}
try {
return await invoke('check_backend_health');
} catch (error) {
console.error('Health check failed:', error);
return false;
}
}
private async waitForHealthy(maxAttempts = 60): Promise<void> {
for (let i = 0; i < maxAttempts; i++) {
const isHealthy = await this.checkBackendHealth();
if (isHealthy) {
console.log('Backend is healthy');
return;
}
await new Promise(resolve => setTimeout(resolve, 1000));
}
throw new Error('Backend failed to become healthy after 60 seconds');
}
}
export const tauriBackendService = TauriBackendService.getInstance();