Merge remote-tracking branch 'origin/V2' into PaymentSelfhost

This commit is contained in:
Connor Yoh
2025-11-18 14:28:42 +00:00
76 changed files with 91892 additions and 91789 deletions

View File

@@ -1,5 +1,6 @@
import { Suspense } from "react";
import { AppProviders } from "@app/components/AppProviders";
import { AppLayout } from "@app/components/AppLayout";
import { LoadingFallback } from "@app/components/shared/LoadingFallback";
import HomePage from "@app/pages/HomePage";
import OnboardingTour from "@app/components/onboarding/OnboardingTour";
@@ -16,8 +17,10 @@ export default function App() {
return (
<Suspense fallback={<LoadingFallback />}>
<AppProviders>
<HomePage />
<OnboardingTour />
<AppLayout>
<HomePage />
<OnboardingTour />
</AppLayout>
</AppProviders>
</Suspense>
);

View File

@@ -0,0 +1,31 @@
import { ReactNode } from 'react';
import { useBanner } from '@app/contexts/BannerContext';
interface AppLayoutProps {
children: ReactNode;
}
/**
* App layout wrapper that handles banner rendering and viewport sizing
* Automatically adjusts child components to fit remaining space after banner
*/
export function AppLayout({ children }: AppLayoutProps) {
const { banner } = useBanner();
return (
<>
<style>{`
.h-screen,
.right-rail {
height: 100% !important;
}
`}</style>
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
{banner}
<div style={{ flex: 1, minHeight: 0, height: 0 }}>
{children}
</div>
</div>
</>
);
}

View File

@@ -16,6 +16,7 @@ import { OnboardingProvider } from "@app/contexts/OnboardingContext";
import { TourOrchestrationProvider } from "@app/contexts/TourOrchestrationContext";
import { AdminTourOrchestrationProvider } from "@app/contexts/AdminTourOrchestrationContext";
import { PageEditorProvider } from "@app/contexts/PageEditorContext";
import { BannerProvider } from "@app/contexts/BannerContext";
import ErrorBoundary from "@app/components/shared/ErrorBoundary";
import { useScarfTracking } from "@app/hooks/useScarfTracking";
import { useAppInitialization } from "@app/hooks/useAppInitialization";
@@ -50,22 +51,23 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
<PreferencesProvider>
<RainbowThemeProvider>
<ErrorBoundary>
<OnboardingProvider>
<AppConfigProvider
retryOptions={appConfigRetryOptions}
{...appConfigProviderProps}
>
<ScarfTrackingInitializer />
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
<AppInitializer />
<ToolRegistryProvider>
<NavigationProvider>
<FilesModalProvider>
<ToolWorkflowProvider>
<HotkeyProvider>
<SidebarProvider>
<ViewerProvider>
<PageEditorProvider>
<BannerProvider>
<OnboardingProvider>
<AppConfigProvider
retryOptions={appConfigRetryOptions}
{...appConfigProviderProps}
>
<ScarfTrackingInitializer />
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
<AppInitializer />
<ToolRegistryProvider>
<NavigationProvider>
<FilesModalProvider>
<ToolWorkflowProvider>
<HotkeyProvider>
<SidebarProvider>
<ViewerProvider>
<PageEditorProvider>
<SignatureProvider>
<RightRailProvider>
<TourOrchestrationProvider>
@@ -76,16 +78,17 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
</RightRailProvider>
</SignatureProvider>
</PageEditorProvider>
</ViewerProvider>
</SidebarProvider>
</HotkeyProvider>
</ToolWorkflowProvider>
</FilesModalProvider>
</NavigationProvider>
</ToolRegistryProvider>
</FileContextProvider>
</AppConfigProvider>
</OnboardingProvider>
</ViewerProvider>
</SidebarProvider>
</HotkeyProvider>
</ToolWorkflowProvider>
</FilesModalProvider>
</NavigationProvider>
</ToolRegistryProvider>
</FileContextProvider>
</AppConfigProvider>
</OnboardingProvider>
</BannerProvider>
</ErrorBoundary>
</RainbowThemeProvider>
</PreferencesProvider>

View File

@@ -1,4 +1,4 @@
import React from "react";
import React, { useMemo } from "react";
import { TourProvider, useTour, type StepType } from '@reactour/tour';
import { useOnboarding } from '@app/contexts/OnboardingContext';
import { useTranslation } from 'react-i18next';
@@ -10,6 +10,7 @@ import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import CheckIcon from '@mui/icons-material/Check';
import TourWelcomeModal from '@app/components/onboarding/TourWelcomeModal';
import '@app/components/onboarding/OnboardingTour.css';
import i18n from "@app/i18n";
// Enum case order defines order steps will appear
enum TourStep {
@@ -120,7 +121,7 @@ export default function OnboardingTour() {
} = useAdminTourOrchestration();
// Define steps as object keyed by enum - TypeScript ensures all keys are present
const stepsConfig: Record<TourStep, StepType> = {
const stepsConfig: Record<TourStep, StepType> = useMemo(() => ({
[TourStep.ALL_TOOLS]: {
selector: '[data-tour="tool-panel"]',
content: t('onboarding.allTools', 'This is the <strong>Tools</strong> panel, where you can browse and select from all available PDF tools.'),
@@ -248,10 +249,10 @@ export default function OnboardingTour() {
position: 'right',
padding: 10,
},
};
}), [t]);
// Define admin tour steps
const adminStepsConfig: Record<AdminTourStep, StepType> = {
const adminStepsConfig: Record<AdminTourStep, StepType> = useMemo(() => ({
[AdminTourStep.WELCOME]: {
selector: '[data-tour="config-button"]',
content: t('adminOnboarding.welcome', "Welcome to the <strong>Admin Tour</strong>! Let's explore the powerful enterprise features and settings available to system administrators."),
@@ -363,7 +364,7 @@ export default function OnboardingTour() {
removeAllGlows();
},
},
};
}), [t]);
// Select steps based on tour type
const steps = tourType === 'admin'
@@ -416,7 +417,7 @@ export default function OnboardingTour() {
}}
/>
<TourProvider
key={tourType}
key={`${tourType}-${i18n.language}`}
steps={steps}
maskClassName={tourType === 'admin' ? 'admin-tour-mask' : undefined}
onClickClose={handleCloseTour}

View File

@@ -0,0 +1,72 @@
import React from 'react';
import { Paper, Group, Text, Button, ActionIcon } from '@mantine/core';
import LocalIcon from '@app/components/shared/LocalIcon';
interface InfoBannerProps {
icon: string;
message: string;
buttonText: string;
buttonIcon?: string;
onButtonClick: () => void;
onDismiss: () => void;
loading?: boolean;
show?: boolean;
}
/**
* Generic info banner component for displaying dismissible messages at the top of the app
*/
export const InfoBanner: React.FC<InfoBannerProps> = ({
icon,
message,
buttonText,
buttonIcon = 'check-circle-rounded',
onButtonClick,
onDismiss,
loading = false,
show = true,
}) => {
if (!show) {
return null;
}
return (
<Paper
p="sm"
radius={0}
style={{
background: 'var(--mantine-color-blue-0)',
borderBottom: '1px solid var(--mantine-color-blue-2)',
position: 'relative',
}}
>
<Group gap="sm" align="center" wrap="nowrap">
<LocalIcon icon={icon} width="1.2rem" height="1.2rem" style={{ color: 'var(--mantine-color-blue-6)', flexShrink: 0 }} />
<Text fw={500} size="sm" style={{ color: 'var(--mantine-color-blue-9)' }}>
{message}
</Text>
<Button
variant="light"
color="blue"
size="xs"
onClick={onButtonClick}
loading={loading}
leftSection={<LocalIcon icon={buttonIcon} width="0.9rem" height="0.9rem" />}
style={{ flexShrink: 0 }}
>
{buttonText}
</Button>
</Group>
<ActionIcon
variant="subtle"
color="gray"
size="sm"
onClick={onDismiss}
aria-label="Dismiss"
style={{ position: 'absolute', top: '50%', right: '0.5rem', transform: 'translateY(-50%)' }}
>
<LocalIcon icon="close-rounded" width="1rem" height="1rem" />
</ActionIcon>
</Paper>
);
};

View File

@@ -272,8 +272,13 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({ position = 'bottom-
<ScrollArea h={190} type="scroll">
<div className={styles.languageGrid}>
{languageOptions.map((option, index) => {
// Enable languages with >90% translation completion
const enabledLanguages = ['en-GB', 'ar-AR', 'de-DE', 'es-ES', 'fr-FR', 'it-IT', 'pt-BR', 'ru-RU', 'zh-CN'];
const enabledLanguages = [
'en-GB', 'zh-CN', 'zh-TW', 'ar-AR', 'fa-IR', 'tr-TR', 'uk-UA', 'zh-BO', 'sl-SI',
'ru-RU', 'ja-JP', 'ko-KR', 'hu-HU', 'ga-IE', 'bg-BG', 'es-ES', 'hi-IN', 'hr-HR',
'el-GR', 'ml-ML', 'pt-BR', 'pl-PL', 'pt-PT', 'sk-SK', 'sr-LATN-RS', 'no-NB',
'th-TH', 'vi-VN', 'az-AZ', 'eu-ES', 'de-DE', 'sv-SE', 'it-IT', 'ca-CA', 'id-ID',
'ro-RO', 'fr-FR', 'nl-NL', 'da-DK', 'cs-CZ'
];
const isDisabled = !enabledLanguages.includes(option.value);
return (

View File

@@ -191,7 +191,6 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
height: '100%',
width: '100%',
position: 'relative',
overflow: 'hidden',
flex: 1,
minHeight: 0,
minWidth: 0,
@@ -287,8 +286,6 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
minHeight: 0,
minWidth: 0,
contain: 'strict',
display: 'flex',
justifyContent: 'center',
}}
>
<Scroller

View File

@@ -7,9 +7,7 @@ import {
determineAutoZoom,
DEFAULT_FALLBACK_ZOOM,
DEFAULT_VISIBILITY_THRESHOLD,
measureRenderedPageRect,
useFitWidthResize,
ZoomViewport,
} from '@app/utils/viewerZoom';
import { getFirstPageAspectRatioFromStub } from '@app/utils/pageMetadata';
@@ -73,18 +71,6 @@ export function ZoomAPIBridge() {
}
}, [spreadMode, zoomState?.zoomLevel, scheduleAutoZoom, requestFitWidth]);
const getViewportSnapshot = useCallback((): ZoomViewport | null => {
if (!zoomState || typeof zoomState !== 'object') {
return null;
}
if ('viewport' in zoomState) {
const candidate = (zoomState as { viewport?: ZoomViewport | null }).viewport;
return candidate ?? null;
}
return null;
}, [zoomState]);
const isManagedZoom =
!!zoom &&
@@ -119,7 +105,7 @@ export function ZoomAPIBridge() {
}
const fitWidthZoom = zoomState.currentZoomLevel;
if (!fitWidthZoom || fitWidthZoom <= 0) {
if (!fitWidthZoom || fitWidthZoom <= 0 || fitWidthZoom === 1) {
return;
}
@@ -137,37 +123,23 @@ export function ZoomAPIBridge() {
const pagesPerSpread = currentSpreadMode !== SpreadMode.None ? 2 : 1;
const metadataAspectRatio = getFirstPageAspectRatioFromStub(firstFileStub);
const viewport = getViewportSnapshot();
if (cancelled) {
return;
}
const metrics = viewport ?? {};
const viewportWidth =
metrics.clientWidth ?? metrics.width ?? window.innerWidth ?? 0;
const viewportHeight =
metrics.clientHeight ?? metrics.height ?? window.innerHeight ?? 0;
const viewportWidth = window.innerWidth ?? 0;
const viewportHeight = window.innerHeight ?? 0;
if (viewportWidth <= 0 || viewportHeight <= 0) {
return;
}
const pageRect = await measureRenderedPageRect({
shouldCancel: () => cancelled,
});
if (cancelled) {
return;
}
const decision = determineAutoZoom({
viewportWidth,
viewportHeight,
fitWidthZoom,
pagesPerSpread,
pageRect: pageRect
? { width: pageRect.width, height: pageRect.height }
: undefined,
pageRect: undefined,
metadataAspectRatio: metadataAspectRatio ?? null,
visibilityThreshold: DEFAULT_VISIBILITY_THRESHOLD,
fallbackZoom: DEFAULT_FALLBACK_ZOOM,
@@ -197,7 +169,6 @@ export function ZoomAPIBridge() {
firstFileId,
firstFileStub,
requestFitWidth,
getViewportSnapshot,
autoZoomTick,
spreadMode,
triggerImmediateZoomUpdate,

View File

@@ -0,0 +1,26 @@
import { createContext, useContext, useState, ReactNode } from 'react';
interface BannerContextType {
banner: ReactNode;
setBanner: (banner: ReactNode) => void;
}
const BannerContext = createContext<BannerContextType | undefined>(undefined);
export function BannerProvider({ children }: { children: ReactNode }) {
const [banner, setBanner] = useState<ReactNode>(null);
return (
<BannerContext.Provider value={{ banner, setBanner }}>
{children}
</BannerContext.Provider>
);
}
export function useBanner() {
const context = useContext(BannerContext);
if (!context) {
throw new Error('useBanner must be used within BannerProvider');
}
return context;
}

View File

@@ -7,6 +7,7 @@ import { getApiBaseUrl } from '@app/services/apiClientConfig';
const apiClient = axios.create({
baseURL: getApiBaseUrl(),
responseType: 'json',
withCredentials: true,
});
// Setup interceptors (core does nothing, proprietary adds JWT auth)

View File

@@ -1,6 +1,6 @@
import { useEffect, useRef } from 'react';
export const DEFAULT_VISIBILITY_THRESHOLD = 80; // Require at least 80% of the page height to be visible
export const DEFAULT_VISIBILITY_THRESHOLD = 70; // Require at least 70% of the page height to be visible
export const DEFAULT_FALLBACK_ZOOM = 1.44; // 144% fallback when no reliable metadata is present
export interface ZoomViewport {
@@ -36,47 +36,33 @@ export function determineAutoZoom({
visibilityThreshold = DEFAULT_VISIBILITY_THRESHOLD,
fallbackZoom = DEFAULT_FALLBACK_ZOOM,
}: AutoZoomParams): AutoZoomDecision {
// Get aspect ratio from pageRect or metadata
const rectWidth = pageRect?.width ?? 0;
const rectHeight = pageRect?.height ?? 0;
const aspectRatio: number | null =
rectWidth > 0 ? rectHeight / rectWidth : metadataAspectRatio ?? null;
let renderedHeight: number | null = rectHeight > 0 ? rectHeight : null;
if (!renderedHeight || renderedHeight <= 0) {
if (aspectRatio == null || aspectRatio <= 0) {
return { type: 'fallback', zoom: Math.min(fitWidthZoom, fallbackZoom) };
}
const pageWidth = viewportWidth / (fitWidthZoom * pagesPerSpread);
const pageHeight = pageWidth * aspectRatio;
renderedHeight = pageHeight * fitWidthZoom;
// Need aspect ratio to proceed
if (!aspectRatio || aspectRatio <= 0) {
return { type: 'fallback', zoom: Math.min(fitWidthZoom, fallbackZoom) };
}
if (!renderedHeight || renderedHeight <= 0) {
return { type: 'fitWidth' };
}
const isLandscape = aspectRatio !== null && aspectRatio < 1;
// Landscape pages need 100% visibility, portrait need the specified threshold
const isLandscape = aspectRatio < 1;
const targetVisibility = isLandscape ? 100 : visibilityThreshold;
const visiblePercent = (viewportHeight / renderedHeight) * 100;
// Calculate zoom level that shows targetVisibility% of page height
const pageHeightAtFitWidth = (viewportWidth / pagesPerSpread) * aspectRatio;
const heightBasedZoom = fitWidthZoom * (viewportHeight / pageHeightAtFitWidth) / (targetVisibility / 100);
if (visiblePercent >= targetVisibility) {
// Use whichever zoom is smaller (more zoomed out) to satisfy both width and height constraints
if (heightBasedZoom < fitWidthZoom) {
// Need to zoom out from fitWidth to show enough height
return { type: 'adjust', zoom: heightBasedZoom };
} else {
// fitWidth already shows enough
return { type: 'fitWidth' };
}
const allowableHeightRatio = targetVisibility / 100;
const zoomScale =
viewportHeight / (allowableHeightRatio * renderedHeight);
const targetZoom = Math.min(fitWidthZoom, fitWidthZoom * zoomScale);
if (Math.abs(targetZoom - fitWidthZoom) < 0.001) {
return { type: 'fitWidth' };
}
return { type: 'adjust', zoom: targetZoom };
}
export interface MeasurePageRectOptions {

View File

@@ -1,6 +1,7 @@
import { ReactNode } from "react";
import { AppProviders as ProprietaryAppProviders } from "@proprietary/components/AppProviders";
import { DesktopConfigSync } from '@app/components/DesktopConfigSync';
import { DesktopBannerInitializer } from '@app/components/DesktopBannerInitializer';
import { DESKTOP_DEFAULT_APP_CONFIG } from '@app/config/defaultAppConfig';
/**
@@ -22,6 +23,7 @@ export function AppProviders({ children }: { children: ReactNode }) {
}}
>
<DesktopConfigSync />
<DesktopBannerInitializer />
{children}
</ProprietaryAppProviders>
);

View File

@@ -0,0 +1,13 @@
import { useEffect } from 'react';
import { useBanner } from '@app/contexts/BannerContext';
import { DefaultAppBanner } from '@app/components/shared/DefaultAppBanner';
export function DesktopBannerInitializer() {
const { setBanner } = useBanner();
useEffect(() => {
setBanner(<DefaultAppBanner />);
}, [setBanner]);
return null;
}

View File

@@ -0,0 +1,27 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { InfoBanner } from '@app/components/shared/InfoBanner';
import { useDefaultApp } from '@app/hooks/useDefaultApp';
export const DefaultAppBanner: React.FC = () => {
const { t } = useTranslation();
const { isDefault, isLoading, handleSetDefault } = useDefaultApp();
const [dismissed, setDismissed] = useState(false);
const handleDismissPrompt = () => {
setDismissed(true);
};
return (
<InfoBanner
icon="picture-as-pdf-rounded"
message={t('defaultApp.prompt.message', 'Make Stirling PDF your default application for opening PDF files.')}
buttonText={t('defaultApp.setDefault', 'Set Default')}
buttonIcon="check-circle-rounded"
onButtonClick={handleSetDefault}
onDismiss={handleDismissPrompt}
loading={isLoading}
show={!dismissed && isDefault === false}
/>
);
};

View File

@@ -0,0 +1,40 @@
import React from 'react';
import { Paper, Text, Button, Group } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useDefaultApp } from '@app/hooks/useDefaultApp';
export const DefaultAppSettings: React.FC = () => {
const { t } = useTranslation();
const { isDefault, isLoading, handleSetDefault } = useDefaultApp();
return (
<Paper withBorder p="md" radius="md">
<Group justify="space-between" align="center">
<div>
<Text fw={500} size="sm">
{t('settings.general.defaultPdfEditor', 'Default PDF editor')}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{isDefault === true
? t('settings.general.defaultPdfEditorActive', 'Stirling PDF is your default PDF editor')
: isDefault === false
? t('settings.general.defaultPdfEditorInactive', 'Another application is set as default')
: t('settings.general.defaultPdfEditorChecking', 'Checking...')}
</Text>
</div>
<Button
variant={isDefault ? 'light' : 'filled'}
color="blue"
size="sm"
onClick={handleSetDefault}
loading={isLoading}
disabled={isDefault === true}
>
{isDefault
? t('settings.general.defaultPdfEditorSet', 'Already Default')
: t('settings.general.setAsDefault', 'Set as Default')}
</Button>
</Group>
</Paper>
);
};

View File

@@ -0,0 +1,18 @@
import React from 'react';
import { Stack } from '@mantine/core';
import CoreGeneralSection from '@core/components/shared/config/configSections/GeneralSection';
import { DefaultAppSettings } from '@app/components/shared/config/configSections/DefaultAppSettings';
/**
* Desktop extension of GeneralSection that adds default PDF editor settings
*/
const GeneralSection: React.FC = () => {
return (
<Stack gap="lg">
<DefaultAppSettings />
<CoreGeneralSection />
</Stack>
);
};
export default GeneralSection;

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef } from 'react';
import { useEffect } from 'react';
import { useBackendInitializer } from '@app/hooks/useBackendInitializer';
import { useOpenedFile } from '@app/hooks/useOpenedFile';
import { fileOpenService } from '@app/services/fileOpenService';
@@ -20,75 +20,42 @@ export function useAppInitialization(): void {
// Handle files opened with app (Tauri mode)
const { openedFilePaths, loading: openedFileLoading } = useOpenedFile();
// Track if we've already loaded the initial files to prevent duplicate loads
const initialFilesLoadedRef = useRef(false);
// Load opened files and add directly to FileContext
useEffect(() => {
if (openedFilePaths.length > 0 && !openedFileLoading && !initialFilesLoadedRef.current) {
initialFilesLoadedRef.current = true;
const loadOpenedFiles = async () => {
try {
const filesArray: File[] = [];
// Load all files in parallel
await Promise.all(
openedFilePaths.map(async (filePath) => {
try {
const fileData = await fileOpenService.readFileAsArrayBuffer(filePath);
if (fileData) {
const file = new File([fileData.arrayBuffer], fileData.fileName, {
type: 'application/pdf'
});
filesArray.push(file);
console.log('[Desktop] Loaded file:', fileData.fileName);
}
} catch (error) {
console.error('[Desktop] Failed to load file:', filePath, error);
}
})
);
if (filesArray.length > 0) {
// Add all files to FileContext at once
await addFiles(filesArray);
console.log(`[Desktop] ${filesArray.length} opened file(s) added to FileContext`);
}
} catch (error) {
console.error('[Desktop] Failed to load opened files:', error);
}
};
loadOpenedFiles();
if (openedFilePaths.length === 0 || openedFileLoading) {
return;
}
}, [openedFilePaths, openedFileLoading, addFiles]);
// Listen for runtime file-opened events (from second instances on Windows/Linux)
useEffect(() => {
const handleRuntimeFileOpen = async (filePath: string) => {
const loadOpenedFiles = async () => {
try {
console.log('[Desktop] Runtime file-opened event received:', filePath);
const fileData = await fileOpenService.readFileAsArrayBuffer(filePath);
if (fileData) {
// Create a File object from the ArrayBuffer
const file = new File([fileData.arrayBuffer], fileData.fileName, {
type: 'application/pdf'
});
const filesArray: File[] = [];
// Add directly to FileContext
await addFiles([file]);
console.log('[Desktop] Runtime opened file added to FileContext:', fileData.fileName);
await Promise.all(
openedFilePaths.map(async (filePath) => {
try {
const fileData = await fileOpenService.readFileAsArrayBuffer(filePath);
if (fileData) {
const file = new File([fileData.arrayBuffer], fileData.fileName, {
type: 'application/pdf'
});
filesArray.push(file);
console.log('[Desktop] Loaded file:', fileData.fileName);
}
} catch (error) {
console.error('[Desktop] Failed to load file:', filePath, error);
}
})
);
if (filesArray.length > 0) {
await addFiles(filesArray);
console.log(`[Desktop] ${filesArray.length} opened file(s) added to FileContext`);
}
} catch (error) {
console.error('[Desktop] Failed to load runtime opened file:', error);
console.error('[Desktop] Failed to load opened files:', error);
}
};
// Set up event listener and get cleanup function
const unlisten = fileOpenService.onFileOpened(handleRuntimeFileOpen);
// Clean up listener on unmount
return unlisten;
}, [addFiles]);
loadOpenedFiles();
}, [openedFilePaths, openedFileLoading, addFiles]);
}

View File

@@ -0,0 +1,61 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { defaultAppService } from '@app/services/defaultAppService';
import { alert } from '@app/components/toast';
export const useDefaultApp = () => {
const { t } = useTranslation();
const [isDefault, setIsDefault] = useState<boolean | null>(null);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
checkDefaultStatus();
}, []);
const checkDefaultStatus = async () => {
try {
const status = await defaultAppService.isDefaultPdfHandler();
setIsDefault(status);
} catch (error) {
console.error('Failed to check default status:', error);
}
};
const handleSetDefault = async () => {
setIsLoading(true);
try {
const result = await defaultAppService.setAsDefaultPdfHandler();
if (result === 'set_successfully') {
alert({
alertType: 'success',
title: t('defaultApp.success.title', 'Default App Set'),
body: t('defaultApp.success.message', 'Stirling PDF is now your default PDF editor'),
});
setIsDefault(true);
} else if (result === 'opened_settings') {
alert({
alertType: 'neutral',
title: t('defaultApp.settingsOpened.title', 'Settings Opened'),
body: t('defaultApp.settingsOpened.message', 'Please select Stirling PDF in your system settings'),
});
}
} catch (error) {
console.error('Failed to set default:', error);
alert({
alertType: 'error',
title: t('defaultApp.error.title', 'Error'),
body: t('defaultApp.error.message', 'Failed to set default PDF handler'),
});
} finally {
setIsLoading(false);
}
};
return {
isDefault,
isLoading,
checkDefaultStatus,
handleSetDefault,
};
};

View File

@@ -1,45 +1,46 @@
import { useState, useEffect } from 'react';
import { fileOpenService } from '@app/services/fileOpenService';
import { listen } from '@tauri-apps/api/event';
export function useOpenedFile() {
const [openedFilePaths, setOpenedFilePaths] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const checkForOpenedFile = async () => {
console.log('🔍 Checking for opened file(s)...');
// Function to read and process files from storage
const readFilesFromStorage = async () => {
console.log('🔍 Reading files from storage...');
try {
const filePaths = await fileOpenService.getOpenedFiles();
console.log('🔍 fileOpenService.getOpenedFiles() returned:', filePaths);
if (filePaths.length > 0) {
console.log(`App opened with ${filePaths.length} file(s):`, filePaths);
console.log(`Found ${filePaths.length} file(s) in storage:`, filePaths);
setOpenedFilePaths(filePaths);
// Clear the files from service state after consuming them
await fileOpenService.clearOpenedFiles();
} else {
console.log(' No files were opened with the app');
}
} catch (error) {
console.error('❌ Failed to check for opened files:', error);
console.error('❌ Failed to read files from storage:', error);
} finally {
setLoading(false);
}
};
checkForOpenedFile();
// Read files on mount
readFilesFromStorage();
// Listen for runtime file open events (abstracted through service)
const unlistenRuntimeEvents = fileOpenService.onFileOpened((filePath: string) => {
console.log('📂 Runtime file open event:', filePath);
setOpenedFilePaths(prev => [...prev, filePath]);
// Listen for files-changed events (when new files are added to storage)
let unlisten: (() => void) | undefined;
listen('files-changed', async () => {
console.log('📂 files-changed event received, re-reading storage...');
await readFilesFromStorage();
}).then(unlistenFn => {
unlisten = unlistenFn;
});
// Cleanup function
return () => {
unlistenRuntimeEvents();
if (unlisten) unlisten();
};
}, []);

View File

@@ -0,0 +1,70 @@
import { invoke } from '@tauri-apps/api/core';
/**
* Service for managing default PDF handler settings
* Note: Uses localStorage for machine-specific preferences (not synced to server)
*/
export const defaultAppService = {
/**
* Check if Stirling PDF is the default PDF handler
*/
async isDefaultPdfHandler(): Promise<boolean> {
try {
const result = await invoke<boolean>('is_default_pdf_handler');
return result;
} catch (error) {
console.error('[DefaultApp] Failed to check default handler:', error);
return false;
}
},
/**
* Set or prompt to set Stirling PDF as default PDF handler
* Returns a status string indicating what happened
*/
async setAsDefaultPdfHandler(): Promise<'set_successfully' | 'opened_settings' | 'error'> {
try {
const result = await invoke<string>('set_as_default_pdf_handler');
return result as 'set_successfully' | 'opened_settings';
} catch (error) {
console.error('[DefaultApp] Failed to set default handler:', error);
return 'error';
}
},
/**
* Check if user has dismissed the default app prompt (machine-specific)
*/
hasUserDismissedPrompt(): boolean {
try {
const dismissed = localStorage.getItem('stirlingpdf_default_app_prompt_dismissed');
return dismissed === 'true';
} catch {
return false;
}
},
/**
* Mark that user has dismissed the default app prompt (machine-specific)
*/
setPromptDismissed(dismissed: boolean): void {
try {
localStorage.setItem('stirlingpdf_default_app_prompt_dismissed', dismissed ? 'true' : 'false');
} catch (error) {
console.error('[DefaultApp] Failed to save prompt preference:', error);
}
},
/**
* Check if we should show the default app prompt
* Returns true if: user hasn't dismissed it AND app is not default handler
*/
async shouldShowPrompt(): Promise<boolean> {
if (this.hasUserDismissedPrompt()) {
return false;
}
const isDefault = await this.isDefaultPdfHandler();
return !isDefault;
},
};

View File

@@ -1,6 +1,7 @@
import { Suspense } from "react";
import { Routes, Route } from "react-router-dom";
import { AppProviders } from "@app/components/AppProviders";
import { AppLayout } from "@app/components/AppLayout";
import { LoadingFallback } from "@app/components/shared/LoadingFallback";
import Landing from "@app/routes/Landing";
import Login from "@app/routes/Login";
@@ -22,17 +23,19 @@ export default function App() {
return (
<Suspense fallback={<LoadingFallback />}>
<AppProviders>
<Routes>
{/* Auth routes - no nested providers needed */}
<Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} />
<Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/invite/:token" element={<InviteAccept />} />
<AppLayout>
<Routes>
{/* Auth routes - no nested providers needed */}
<Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} />
<Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/invite/:token" element={<InviteAccept />} />
{/* Main app routes - Landing handles auth logic */}
<Route path="/*" element={<Landing />} />
</Routes>
<OnboardingTour />
{/* Main app routes - Landing handles auth logic */}
<Route path="/*" element={<Landing />} />
</Routes>
<OnboardingTour />
</AppLayout>
</AppProviders>
</Suspense>
);

View File

@@ -107,7 +107,7 @@ class SpringAuthClient {
for (const cookie of cookies) {
const [name, value] = cookie.trim().split('=');
if (name === 'XSRF-TOKEN') {
return value;
return decodeURIComponent(value);
}
}
return null;
@@ -278,7 +278,7 @@ class SpringAuthClient {
try {
const response = await apiClient.post('/api/v1/auth/logout', null, {
headers: {
'X-CSRF-TOKEN': this.getCsrfToken() || '',
'X-XSRF-TOKEN': this.getCsrfToken() || '',
},
withCredentials: true,
});
@@ -311,7 +311,7 @@ class SpringAuthClient {
try {
const response = await apiClient.post('/api/v1/auth/refresh', null, {
headers: {
'X-CSRF-TOKEN': this.getCsrfToken() || '',
'X-XSRF-TOKEN': this.getCsrfToken() || '',
},
withCredentials: true,
});

View File

@@ -9,17 +9,38 @@ function getJwtTokenFromStorage(): string | null {
}
}
function getXsrfToken(): string | null {
try {
const cookies = document.cookie.split(';');
for (const cookie of cookies) {
const [name, value] = cookie.trim().split('=');
if (name === 'XSRF-TOKEN') {
return decodeURIComponent(value);
}
}
return null;
} catch (error) {
console.error('[API Client] Failed to read XSRF token from cookies:', error);
return null;
}
}
export function setupApiInterceptors(client: AxiosInstance): void {
// Install request interceptor to add JWT token
client.interceptors.request.use(
(config) => {
const jwtToken = getJwtTokenFromStorage();
const xsrfToken = getXsrfToken();
if (jwtToken && !config.headers.Authorization) {
config.headers.Authorization = `Bearer ${jwtToken}`;
console.debug('[API Client] Added JWT token from localStorage to Authorization header');
}
if (xsrfToken && !config.headers['X-XSRF-TOKEN']) {
config.headers['X-XSRF-TOKEN'] = xsrfToken;
}
return config;
},
(error) => {