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,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 {
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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 || '/';
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user