From 214dc20c2eafab077d7034da25f9db18c2e33f57 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Tue, 17 Mar 2026 13:01:08 +0000 Subject: [PATCH] Hotfix-cant-run-tools-when-no-credits (#5955) Tested: * Can sign in on saas -> can run local tools with or without credits-> can run saas only tools (if credits) -> can't run saas only tools without credits * Can sign in self-hosted -> can run all tools on remote if available -> can run local when self-hosted unavailable Clouds show on saas tools when connected Tools are disabled when connected to self-hosted but cannot find server. You also get banner #cantwaitforplaywritetests --- .../tools/toolPicker/ToolButton.tsx | 7 ++-- .../tools/convert/useConvertOperation.ts | 5 +++ .../hooks/tools/shared/toolOperationTypes.ts | 8 ++++- .../hooks/tools/shared/useToolOperation.ts | 33 ++++++++++--------- frontend/src/core/hooks/useCreditCheck.ts | 4 +-- .../desktop/hooks/useConversionCloudStatus.ts | 10 ++++-- frontend/src/desktop/hooks/useCreditCheck.ts | 19 +++++++++-- frontend/src/saas/hooks/useCreditCheck.ts | 4 +-- 8 files changed, 61 insertions(+), 29 deletions(-) diff --git a/frontend/src/core/components/tools/toolPicker/ToolButton.tsx b/frontend/src/core/components/tools/toolPicker/ToolButton.tsx index 529b895c5e..7b87d377ba 100644 --- a/frontend/src/core/components/tools/toolPicker/ToolButton.tsx +++ b/frontend/src/core/components/tools/toolPicker/ToolButton.tsx @@ -15,7 +15,7 @@ import { ToolId } from "@app/types/toolId"; import { getToolDisabledReason, getDisabledLabel } from "@app/components/tools/fullscreen/shared"; import { useAppConfig } from "@app/contexts/AppConfigContext"; import { CloudBadge } from "@app/components/shared/CloudBadge"; -import { useToolCloudStatus } from "@app/hooks/useToolCloudStatus"; +import { useWillUseCloud } from "@app/hooks/useWillUseCloud"; interface ToolButtonProps { id: ToolId; @@ -41,8 +41,9 @@ const ToolButton: React.FC = ({ id, tool, isSelected, onSelect, const fav = isFavorite(id as ToolId); // Check if this tool will route to SaaS backend (desktop only) - const endpointName = tool.endpoints?.[0]; - const usesCloud = useToolCloudStatus(endpointName); + const rawEndpoint = tool.operationConfig?.endpoint; + const endpointString = typeof rawEndpoint === 'string' ? rawEndpoint : undefined; + const usesCloud = useWillUseCloud(endpointString); const handleClick = (id: ToolId) => { if (isUnavailable) return; diff --git a/frontend/src/core/hooks/tools/convert/useConvertOperation.ts b/frontend/src/core/hooks/tools/convert/useConvertOperation.ts index 98ead906f6..945199ee16 100644 --- a/frontend/src/core/hooks/tools/convert/useConvertOperation.ts +++ b/frontend/src/core/hooks/tools/convert/useConvertOperation.ts @@ -194,6 +194,11 @@ export const convertOperationConfig = { customProcessor: convertProcessor, // Can't use callback version here operationType: 'convert', defaultParameters, + endpoint: (params: ConvertParameters): string | undefined => { + if (!params.fromExtension || !params.toExtension) return undefined; + const actualToExtension = params.toExtension === 'pdfx' ? 'pdfa' : params.toExtension; + return getEndpointUrl(params.fromExtension, actualToExtension) ?? undefined; + }, } as const; export const useConvertOperation = (parameters?: ConvertParameters) => { diff --git a/frontend/src/core/hooks/tools/shared/toolOperationTypes.ts b/frontend/src/core/hooks/tools/shared/toolOperationTypes.ts index eb568b25d4..7269c0f456 100644 --- a/frontend/src/core/hooks/tools/shared/toolOperationTypes.ts +++ b/frontend/src/core/hooks/tools/shared/toolOperationTypes.ts @@ -101,7 +101,13 @@ export interface CustomToolOperationConfig extends BaseToolOperationCon toolType: ToolType.custom; buildFormData?: undefined; - endpoint?: undefined; + + /** + * Optional endpoint for routing decisions (credit check, cloud detection). + * Not used for the API call itself — customProcessor handles that directly. + * Provide a function when the endpoint depends on runtime parameters. + */ + endpoint?: string | ((params: TParams) => string | undefined); /** * Custom processing logic that completely bypasses standard file processing. diff --git a/frontend/src/core/hooks/tools/shared/useToolOperation.ts b/frontend/src/core/hooks/tools/shared/useToolOperation.ts index 6696ed2b30..7f3857c27f 100644 --- a/frontend/src/core/hooks/tools/shared/useToolOperation.ts +++ b/frontend/src/core/hooks/tools/shared/useToolOperation.ts @@ -70,14 +70,15 @@ export const useToolOperation = ( const { processFiles, cancelOperation: cancelApiCalls } = useToolApiCalls(); const { generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles } = useToolResources(); - const { checkCredits } = useCreditCheck(config.operationType); - - // Determine endpoint for cloud usage check - const endpointString = config.toolType !== ToolType.custom && config.endpoint + // Determine endpoint for cloud usage check and credit routing. + // For function endpoints, use defaultParameters to get a representative static value. + const endpointString = config.endpoint ? (typeof config.endpoint === 'function' - ? (config.defaultParameters ? config.endpoint(config.defaultParameters) : undefined) + ? (config.defaultParameters ? (config.endpoint(config.defaultParameters) ?? undefined) : undefined) : config.endpoint) : undefined; + + const { checkCredits } = useCreditCheck(config.operationType, endpointString); const willUseCloud = useWillUseCloud(endpointString); // Track last operation for undo functionality @@ -114,22 +115,24 @@ export const useToolOperation = ( return; } - // Get endpoint (static or dynamic) for backend readiness check - const endpoint = config.customProcessor - ? undefined // Custom processors may not have endpoints - : typeof config.endpoint === 'function' - ? config.endpoint(params) - : config.endpoint; + // Resolve the runtime endpoint from params (static string or function result). + // Custom processors may omit endpoint entirely — result is undefined in that case. + const runtimeEndpoint: string | undefined = config.endpoint + ? (typeof config.endpoint === 'function' ? (config.endpoint(params) ?? undefined) : config.endpoint) + : undefined; - // Credit check — no-op in core builds, real check in desktop/SaaS versions - const creditError = await checkCredits(); + // Credit check — no-op in core builds, real check in desktop/SaaS versions. + // Pass runtime endpoint so the check can determine if this routes locally (no credits needed). + const creditError = await checkCredits(runtimeEndpoint); if (creditError !== null) { actions.setError(creditError); return; } - // Backend readiness check (will skip for SaaS-routed endpoints) - const backendReady = await ensureBackendReady(endpoint); + // Backend readiness check (will skip for SaaS-routed endpoints). + // Custom processors without an endpoint skip this — they manage their own backend calls. + const endpointForReadyCheck = config.toolType !== ToolType.custom ? runtimeEndpoint : undefined; + const backendReady = await ensureBackendReady(endpointForReadyCheck); if (!backendReady) { actions.setError(t('backendHealth.offline', 'Embedded backend is offline. Please try again shortly.')); return; diff --git a/frontend/src/core/hooks/useCreditCheck.ts b/frontend/src/core/hooks/useCreditCheck.ts index 642859e14e..e0d05c80cb 100644 --- a/frontend/src/core/hooks/useCreditCheck.ts +++ b/frontend/src/core/hooks/useCreditCheck.ts @@ -3,8 +3,8 @@ * Desktop layer shadows this with the real implementation * In web builds, always allows the operation (no credit system) */ -export function useCreditCheck(_operationType?: string) { +export function useCreditCheck(_operationType?: string, _endpoint?: string) { return { - checkCredits: async (): Promise => null, // null = allowed + checkCredits: async (_runtimeEndpoint?: string): Promise => null, // null = allowed }; } diff --git a/frontend/src/desktop/hooks/useConversionCloudStatus.ts b/frontend/src/desktop/hooks/useConversionCloudStatus.ts index a86d574eb0..182afa4a14 100644 --- a/frontend/src/desktop/hooks/useConversionCloudStatus.ts +++ b/frontend/src/desktop/hooks/useConversionCloudStatus.ts @@ -100,14 +100,18 @@ export function useConversionCloudStatus(): ConversionStatus { pairs.map(async ([fromExt, toExt, endpointName]) => { const key = `${fromExt}-${toExt}`; try { - const combined = await endpointAvailabilityService.checkEndpointCombined( + // In SaaS mode, everything is available (locally or via cloud routing). + // Only check local support to determine willUseCloud — the same approach + // used by useMultipleEndpointsEnabled's SaaS enhancement. + const availableLocally = await endpointAvailabilityService.isEndpointSupportedLocally( endpointName, tauriBackendService.getBackendUrl() ); - return { key, isAvailable: combined.isAvailable, willUseCloud: combined.willUseCloud, localOnly: combined.localOnly }; + return { key, isAvailable: true, willUseCloud: !availableLocally, localOnly: false }; } catch (error) { console.error(`[useConversionCloudStatus] Endpoint check failed for ${key}:`, error); - return { key, isAvailable: false, willUseCloud: false, localOnly: false }; + // On error, assume available via cloud (safe default in SaaS mode) + return { key, isAvailable: true, willUseCloud: true, localOnly: false }; } }) ); diff --git a/frontend/src/desktop/hooks/useCreditCheck.ts b/frontend/src/desktop/hooks/useCreditCheck.ts index 17178ad99a..56691c8182 100644 --- a/frontend/src/desktop/hooks/useCreditCheck.ts +++ b/frontend/src/desktop/hooks/useCreditCheck.ts @@ -4,6 +4,7 @@ import { useSaaSBilling } from '@app/contexts/SaasBillingContext'; import { useSaaSMode } from '@app/hooks/useSaaSMode'; import { getToolCreditCost } from '@app/utils/creditCosts'; import { CREDIT_EVENTS } from '@app/constants/creditEvents'; +import { operationRouter } from '@app/services/operationRouter'; import type { ToolId } from '@app/types/toolId'; /** @@ -15,15 +16,27 @@ import type { ToolId } from '@app/types/toolId'; * Returns null when the operation is allowed, or an error message string * when it should be blocked. */ -export function useCreditCheck(operationType?: string) { +export function useCreditCheck(operationType?: string, endpoint?: string) { const billing = useSaaSBilling(); const isSaaSMode = useSaaSMode(); const { t } = useTranslation(); - const checkCredits = useCallback(async (): Promise => { + const checkCredits = useCallback(async (runtimeEndpoint?: string): Promise => { if (!isSaaSMode) return null; // Credits only apply in SaaS mode, not self-hosted if (!billing) return null; + // If the operation routes to the local backend, no credits are consumed — skip check. + // runtimeEndpoint (from params at execution time) takes priority over hook-level endpoint. + const ep = runtimeEndpoint ?? endpoint; + if (ep) { + try { + const willUseSaaS = await operationRouter.willRouteToSaaS(ep); + if (!willUseSaaS) return null; + } catch { + // If routing check fails, fall through to credit check as a safe default. + } + } + const { creditBalance, loading } = billing; const requiredCredits = getToolCreditCost(operationType as ToolId); @@ -44,7 +57,7 @@ export function useCreditCheck(operationType?: string) { } return null; - }, [billing, isSaaSMode, operationType, t]); + }, [billing, isSaaSMode, operationType, endpoint, t]); return { checkCredits }; } diff --git a/frontend/src/saas/hooks/useCreditCheck.ts b/frontend/src/saas/hooks/useCreditCheck.ts index d30a6489bd..c9aac57f4a 100644 --- a/frontend/src/saas/hooks/useCreditCheck.ts +++ b/frontend/src/saas/hooks/useCreditCheck.ts @@ -5,11 +5,11 @@ import { getToolCreditCost } from '@app/utils/creditCosts'; import { openPlanSettings } from '@app/utils/appSettings'; import type { ToolId } from '@app/types/toolId'; -export function useCreditCheck(operationType?: string) { +export function useCreditCheck(operationType?: string, _endpoint?: string) { const { hasSufficientCredits, isPro, creditBalance, refreshCredits } = useCredits(); const { t } = useTranslation(); - const checkCredits = useCallback(async (): Promise => { + const checkCredits = useCallback(async (_runtimeEndpoint?: string): Promise => { const requiredCredits = getToolCreditCost(operationType as ToolId); const creditCheck = hasSufficientCredits(requiredCredits);