Desktop/remove hard requirement auth wall on desktop (#5956)

Co-authored-by: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
EthanHealy01
2026-03-23 19:36:48 +00:00
committed by GitHub
co-authored by Claude Sonnet 4.6
parent 081b1ec49e
commit 2e2b55e87d
87 changed files with 1522 additions and 339 deletions
+139 -72
View File
@@ -1,13 +1,17 @@
import { ReactNode, useEffect, useState } from "react";
import { ReactNode, useEffect, useRef, useState } from "react";
import { AppProviders as ProprietaryAppProviders } from "@proprietary/components/AppProviders";
import { DesktopConfigSync } from '@app/components/DesktopConfigSync';
import { DesktopBannerInitializer } from '@app/components/DesktopBannerInitializer';
import { SaveShortcutListener } from '@app/components/SaveShortcutListener';
import { SetupWizard } from '@app/components/SetupWizard';
import { DesktopOnboardingModal } from '@app/components/DesktopOnboardingModal';
import { SignInModal } from '@app/components/SignInModal';
import { OPEN_SIGN_IN_EVENT } from '@app/constants/signInEvents';
import { ToolActionsContext } from '@app/contexts/ToolActionsContext';
import { useFirstLaunchCheck } from '@app/hooks/useFirstLaunchCheck';
import { useBackendInitializer } from '@app/hooks/useBackendInitializer';
import { DESKTOP_DEFAULT_APP_CONFIG } from '@app/config/defaultAppConfig';
import { connectionModeService } from '@app/services/connectionModeService';
import { STIRLING_SAAS_URL } from '@app/constants/connection';
import { tauriBackendService } from '@app/services/tauriBackendService';
import { selfHostedServerMonitor } from '@app/services/selfHostedServerMonitor';
import { authService } from '@app/services/authService';
@@ -41,25 +45,116 @@ const COMMON_TOOL_ENDPOINTS = [
*/
export function AppProviders({ children }: { children: ReactNode }) {
const { isFirstLaunch, setupComplete } = useFirstLaunchCheck();
const [connectionMode, setConnectionMode] = useState<'saas' | 'selfhosted' | null>(null);
const [connectionMode, setConnectionMode] = useState<'saas' | 'selfhosted' | 'local' | null>(null);
const [authChecked, setAuthChecked] = useState(false);
const [isAuthenticated, setIsAuthenticated] = useState(false);
// Load connection mode on mount
// When auth check finds no valid session, record the sign-in detail here so the
// dispatch useEffect below can fire it only after SignInModal has mounted.
const [pendingSignIn, setPendingSignIn] = useState<{ locked: boolean } | null>(null);
// Prevent first-launch setup from running twice when connectionMode state update re-triggers the effect
const firstLaunchInitiated = useRef(false);
// Key incremented on every connection mode change after initial load — forces SaaS provider
// tree to remount without a full page reload (avoids Windows WebView2 freeze on window.location.reload()).
const [appKey, setAppKey] = useState(0);
const hasLoadedInitialMode = useRef(false);
// Load connection mode on mount and subscribe to future changes
useEffect(() => {
void connectionModeService.getCurrentMode().then(setConnectionMode);
void connectionModeService.getCurrentMode().then((mode) => {
setConnectionMode(mode);
hasLoadedInitialMode.current = true;
});
const unsub = connectionModeService.subscribeToModeChanges((config) => {
setConnectionMode(config.mode);
// Remount the SaaS provider tree when transitioning between saas/local modes so
// Supabase client state is reset without a full page reload (avoids the Windows
// WebView2 freeze that window.location.reload() causes during an OAuth flow).
// Switching TO selfhosted skips the remount — self-hosted mode doesn't use the
// SaaS providers and remounting mid-wizard resets authChecked, navigating away.
// Switching FROM selfhosted TO saas DOES trigger a remount (mode !== 'selfhosted')
// which is intentional — the SaaS provider tree needs fresh state after login.
if (hasLoadedInitialMode.current && config.mode !== 'selfhosted') {
setAppKey(k => k + 1);
}
});
return unsub;
}, []);
useEffect(() => {
// Wait until connection mode is loaded before checking auth
if (connectionMode === null) return;
if (!isFirstLaunch && setupComplete) {
authService.isAuthenticated()
.then(setIsAuthenticated)
.catch(() => setIsAuthenticated(false))
.finally(() => setAuthChecked(true));
if (connectionMode === 'local') {
// Even in local mode, check for a valid JWT — on Windows, the OAuth callback
// can complete without switchToSaaS() being called (race condition), leaving
// LOCAL_MODE_STORAGE_KEY set while the user has a valid session. Upgrade to
// SaaS mode automatically so credits/billing/team features work correctly.
authService.isAuthenticated()
.then(async (isAuth) => {
if (isAuth) {
await connectionModeService.switchToSaaS(STIRLING_SAAS_URL).catch(console.error);
setConnectionMode('saas');
}
})
.finally(() => setAuthChecked(true));
} else {
let pendingDetail: { locked: boolean } | null = null;
authService.isAuthenticated()
.then(async (isAuth) => {
if (!isAuth) {
const cfg = await connectionModeService.getCurrentConfig().catch(() => null);
if (cfg?.lock_connection_mode) {
// Provisioned deployment — stay in the configured mode and prompt for credentials.
// Don't fall back to local; the admin has locked the connection mode.
pendingDetail = { locked: true };
} else {
// JWT expired — fall back to local so local tools still work, then prompt
// for re-authentication via the sign-in modal.
await connectionModeService.switchToLocal().catch(console.error);
setConnectionMode('local');
pendingDetail = { locked: false };
}
}
})
.catch(async () => {
const cfg = await connectionModeService.getCurrentConfig().catch(() => null);
if (cfg?.lock_connection_mode) {
// Auth check threw (e.g. network error) but mode is locked — still prompt for
// credentials so the user can sign in when connectivity is restored.
pendingDetail = { locked: true };
} else {
await connectionModeService.switchToLocal().catch(console.error);
setConnectionMode('local');
pendingDetail = { locked: false };
}
})
.finally(() => {
setAuthChecked(true);
// Schedule sign-in via state so the dispatch useEffect fires AFTER
// SignInModal mounts (children effects run before parent effects).
if (pendingDetail) {
setPendingSignIn(pendingDetail);
}
});
}
} else if (isFirstLaunch && !setupComplete) {
setAuthChecked(true);
setIsAuthenticated(false);
// Auto-enter local mode on first launch — skip the setup wizard entirely.
// The onboarding carousel + sign-in toast will be shown inside the main app.
// Start the backend explicitly here because shouldMonitorBackend relies on
// setupComplete (still false from the hook), so useBackendInitializer won't fire.
// Guard against re-running when setConnectionMode('local') below triggers this effect.
if (firstLaunchInitiated.current) return;
firstLaunchInitiated.current = true;
connectionModeService.switchToLocal()
.then(() => tauriBackendService.startBackend())
.catch(console.error)
.finally(() => {
setConnectionMode('local');
setAuthChecked(true);
});
}
}, [isFirstLaunch, setupComplete]);
}, [isFirstLaunch, setupComplete, connectionMode]);
// Initialize backend health monitoring for self-hosted mode
useEffect(() => {
@@ -80,7 +175,7 @@ export function AppProviders({ children }: { children: ReactNode }) {
// Initialize monitoring for bundled backend (already started in Rust)
// This sets up port detection and health checks
const shouldMonitorBackend = setupComplete && !isFirstLaunch && connectionMode === 'saas';
const shouldMonitorBackend = setupComplete && !isFirstLaunch && (connectionMode === 'saas' || connectionMode === 'local');
useBackendInitializer(shouldMonitorBackend);
// Preload endpoint availability for the local bundled backend.
@@ -90,6 +185,7 @@ export function AppProviders({ children }: { children: ReactNode }) {
// individual requests per-tool when the remote server goes offline).
const shouldPreloadLocalEndpoints =
(setupComplete && !isFirstLaunch && connectionMode === 'saas') ||
(setupComplete && !isFirstLaunch && connectionMode === 'local') ||
(setupComplete && !isFirstLaunch && connectionMode === 'selfhosted');
useEffect(() => {
if (!shouldPreloadLocalEndpoints) return;
@@ -109,6 +205,15 @@ export function AppProviders({ children }: { children: ReactNode }) {
return unsubscribe;
}, [shouldPreloadLocalEndpoints, connectionMode]);
// Dispatch sign-in event only after authChecked=true so SignInModal is mounted.
// Using useEffect (not setTimeout) guarantees child effects (SignInModal's listener
// registration) run before this parent effect fires the event.
useEffect(() => {
if (!authChecked || !pendingSignIn) return;
window.dispatchEvent(new CustomEvent(OPEN_SIGN_IN_EVENT, { detail: pendingSignIn }));
setPendingSignIn(null);
}, [authChecked, pendingSignIn]);
useEffect(() => {
if (!authChecked) {
return;
@@ -145,58 +250,12 @@ export function AppProviders({ children }: { children: ReactNode }) {
);
}
// Show setup wizard on first launch
if (isFirstLaunch && !setupComplete) {
return (
<ProprietaryAppProviders
appConfigRetryOptions={{
maxRetries: 5,
initialDelay: 1000,
}}
appConfigProviderProps={{
initialConfig: DESKTOP_DEFAULT_APP_CONFIG,
bootstrapMode: 'non-blocking',
autoFetch: false,
}}
>
<SetupWizard
onComplete={() => {
window.location.reload();
}}
/>
</ProprietaryAppProviders>
);
}
// Show setup wizard when not authenticated (desktop login flow).
if (authChecked && !isAuthenticated) {
return (
<ProprietaryAppProviders
appConfigRetryOptions={{
maxRetries: 5,
initialDelay: 1000,
}}
appConfigProviderProps={{
initialConfig: DESKTOP_DEFAULT_APP_CONFIG,
bootstrapMode: 'non-blocking',
autoFetch: false,
}}
>
<SetupWizard
onComplete={() => {
window.location.reload();
}}
/>
</ProprietaryAppProviders>
);
}
// Normal app flow
return (
<ProprietaryAppProviders
appConfigRetryOptions={{
maxRetries: 5,
initialDelay: 1000, // 1 second, with exponential backoff
initialDelay: 1000,
}}
appConfigProviderProps={{
initialConfig: DESKTOP_DEFAULT_APP_CONFIG,
@@ -204,17 +263,25 @@ export function AppProviders({ children }: { children: ReactNode }) {
autoFetch: false,
}}
>
<SaaSTeamProvider>
<SaasBillingProvider>
<SaaSCheckoutProvider>
<DesktopConfigSync />
<DesktopBannerInitializer />
<SaveShortcutListener />
<CreditModalBootstrap />
{children}
</SaaSCheckoutProvider>
</SaasBillingProvider>
</SaaSTeamProvider>
<ToolActionsContext.Provider value={{
onEndpointUnavailableClick: () => window.dispatchEvent(new CustomEvent(OPEN_SIGN_IN_EVENT)),
}}>
<SaaSTeamProvider key={appKey}>
<SaasBillingProvider>
<SaaSCheckoutProvider>
<DesktopConfigSync />
<DesktopBannerInitializer />
<SaveShortcutListener />
<CreditModalBootstrap />
{children}
{/* Desktop onboarding modal: welcome slide → sign-in slide, shown once on first launch */}
<DesktopOnboardingModal />
{/* Global sign-in modal, opened via stirling:open-sign-in event */}
<SignInModal />
</SaaSCheckoutProvider>
</SaasBillingProvider>
</SaaSTeamProvider>
</ToolActionsContext.Provider>
</ProprietaryAppProviders>
);
}
@@ -3,7 +3,7 @@ import { Stack, Card, Badge, Button, Text, Group } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { connectionModeService, ConnectionConfig } from '@app/services/connectionModeService';
import { authService, UserInfo } from '@app/services/authService';
import { STIRLING_SAAS_URL } from '@app/constants/connection';
import { OPEN_SIGN_IN_EVENT } from '@app/constants/signInEvents';
export const ConnectionSettings: React.FC = () => {
const { t } = useTranslation();
@@ -24,31 +24,31 @@ export const ConnectionSettings: React.FC = () => {
};
loadConfig();
const unsubscribe = connectionModeService.subscribeToModeChanges(loadConfig);
return unsubscribe;
}, []);
const handleLogout = async () => {
try {
setLoading(true);
await authService.logout();
if (!config?.lock_connection_mode) {
// Switch to SaaS mode
await connectionModeService.switchToSaaS(STIRLING_SAAS_URL);
// Reset setup completion to force login screen on reload
await connectionModeService.resetSetupCompletion();
// Save server URL before clearing so user can easily reconnect (self-hosted only)
if (config?.mode === 'selfhosted' && config?.server_config?.url) {
localStorage.setItem('server_url', config.server_config.url);
}
await authService.logout();
// Always switch to local after logout so the app remains usable
await connectionModeService.switchToLocal();
// Reload config
const newConfig = await connectionModeService.getCurrentConfig();
setConfig(newConfig);
setUserInfo(null);
// Clear URL to home page before reload so we don't return to settings after re-login
// Clear URL to home page so we don't return to settings after re-login
window.history.replaceState({}, '', '/');
// Reload the page to clear all state and show login screen
window.location.reload();
// No reload needed — AppProviders remounts the SaaS provider tree via
// connectionModeService subscription when mode changes to local.
} catch (error) {
console.error('Logout failed:', error);
} finally {
@@ -56,6 +56,10 @@ export const ConnectionSettings: React.FC = () => {
}
};
const handleSignIn = () => {
window.dispatchEvent(new CustomEvent(OPEN_SIGN_IN_EVENT));
};
if (!config) {
return <Text>{t('common.loading', 'Loading...')}</Text>;
}
@@ -66,13 +70,27 @@ export const ConnectionSettings: React.FC = () => {
<Stack gap="md">
<Group justify="space-between">
<Text fw={600}>{t('settings.connection.title', 'Connection Mode')}</Text>
<Badge color={config.mode === 'saas' ? 'blue' : 'green'} variant="light">
<Badge
color={config.mode === 'saas' ? 'blue' : config.mode === 'local' ? 'white' : 'green'}
variant="light"
>
{config.mode === 'saas'
? t('settings.connection.mode.saas', 'Stirling Cloud')
: t('settings.connection.mode.selfhosted', 'Self-Hosted')}
: config.mode === 'local'
? t('settings.connection.mode.local', 'Local Only')
: t('settings.connection.mode.selfhosted', 'Self-Hosted')}
</Badge>
</Group>
{config.mode === 'local' && (
<Text size="sm" c="dimmed">
{t(
'settings.connection.localDescription',
'You are using the local backend without an account. Some tools requiring cloud processing or a self-hosted server are unavailable.'
)}
</Text>
)}
{(config.mode === 'saas' || config.mode === 'selfhosted') && config.server_config && (
<>
<div>
@@ -99,9 +117,15 @@ export const ConnectionSettings: React.FC = () => {
)}
<Group mt="md">
<Button onClick={handleLogout} color="red" variant="light" disabled={loading}>
{t('settings.connection.logout', 'Log Out')}
</Button>
{config.mode === 'local' ? (
<Button onClick={handleSignIn} color="blue" variant="light">
{t('settings.connection.signIn', 'Sign In')}
</Button>
) : (
<Button onClick={handleLogout} color="red" variant="light" disabled={loading}>
{t('settings.connection.logout', 'Log Out')}
</Button>
)}
</Group>
</Stack>
</Card>
@@ -0,0 +1,176 @@
import { useState, useEffect, useMemo } from 'react';
import { Modal, Stack, Group, Button, ActionIcon } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import CloseIcon from '@mui/icons-material/Close';
import LocalIcon from '@app/components/shared/LocalIcon';
import AnimatedSlideBackground from '@app/components/onboarding/slides/AnimatedSlideBackground';
import OnboardingStepper from '@app/components/onboarding/OnboardingStepper';
import { SetupWizard } from '@app/components/SetupWizard';
import WelcomeSlide from '@app/components/onboarding/slides/WelcomeSlide';
import { Z_INDEX_OVER_FULLSCREEN_SURFACE } from '@app/styles/zIndex';
import styles from '@app/components/onboarding/InitialOnboardingModal/InitialOnboardingModal.module.css';
import { connectionModeService } from '@app/services/connectionModeService';
const ONBOARDING_KEY = 'stirling-desktop-onboarding-seen';
const SIGN_IN_GRADIENT: [string, string] = ['#3B82F6', '#7C3AED'];
/**
* Desktop-specific onboarding modal.
* Shown on first launch: welcome slide → sign-in slide.
* Replaces the core onboarding (which targets server/admin users).
*/
export function DesktopOnboardingModal() {
const { t } = useTranslation();
const [visible, setVisible] = useState(() => !localStorage.getItem(ONBOARDING_KEY));
const [step, setStep] = useState(0);
// null = still checking, true = locked (suppress modal), false = not locked (show modal)
const [isLocked, setIsLocked] = useState<boolean | null>(null);
// Provisioned (locked) deployments skip the onboarding entirely — the non-dismissible
// SignInModal handles authentication and shows the correct self-hosted login flow.
useEffect(() => {
connectionModeService.getCurrentConfig().then((cfg) => {
setIsLocked(cfg.lock_connection_mode && !!cfg.server_config?.url);
});
}, []);
const dismissFinal = () => {
localStorage.setItem(ONBOARDING_KEY, 'true');
setVisible(false);
};
// X on slide 0 advances to sign-in slide rather than dismissing entirely
const handleClose = () => {
if (step === 0) {
setStep(1);
} else {
dismissFinal();
}
};
const handleComplete = () => {
localStorage.setItem(ONBOARDING_KEY, 'true');
setVisible(false);
// No reload needed — AppProviders subscribes to connectionModeService and remounts
// the SaaS provider tree when mode changes, avoiding the Windows WebView2 freeze
// that window.location.reload() causes during a backgrounded OAuth flow.
};
// Call WelcomeSlide as a data factory (not a component render) — memoised so it
// isn't reconstructed on every render while the modal is open.
const welcomeSlide = useMemo(() => WelcomeSlide(), []);
const totalSteps = 2;
if (!visible || isLocked === null || isLocked) return null;
return (
<Modal
opened={visible}
onClose={handleClose}
closeOnClickOutside={step === 1}
centered
size="lg"
radius="lg"
withCloseButton={false}
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
styles={{
body: { padding: 0 },
content: {
overflow: 'hidden',
border: 'none',
background: 'var(--bg-surface)',
maxHeight: '90vh',
display: 'flex',
flexDirection: 'column',
},
}}
>
<Stack gap={0} className={styles.modalContent} style={{ height: '100%', maxHeight: '90vh', display: 'flex', flexDirection: 'column' }}>
{/* Hero section — gradient changes per slide */}
<div className={styles.heroWrapper} style={{ flexShrink: 0 }}>
<AnimatedSlideBackground
gradientStops={(step === 0 ? welcomeSlide.background.gradientStops : SIGN_IN_GRADIENT) as [string, string]}
circles={welcomeSlide.background.circles}
isActive
slideKey={step === 0 ? 'desktop-welcome' : 'desktop-sign-in'}
/>
<ActionIcon
onClick={handleClose}
radius="md"
size={36}
style={{
position: 'absolute',
top: 16,
right: 16,
backgroundColor: 'rgba(255, 255, 255, 0.2)',
color: 'white',
backdropFilter: 'blur(4px)',
zIndex: 10,
}}
styles={{ root: { '&:hover': { backgroundColor: 'rgba(255, 255, 255, 0.3)' } } }}
>
<CloseIcon fontSize="small" />
</ActionIcon>
<div className={styles.heroLogo} key={`logo-${step}`}>
<div className={styles.heroLogoCircle}>
{step === 0 ? (
<LocalIcon icon="rocket-launch" width={64} height={64} className={styles.heroIcon} />
) : (
<LocalIcon icon="login" width={64} height={64} className={styles.heroIcon} />
)}
</div>
</div>
</div>
{/* Body section */}
<div
className={styles.modalBody}
style={{ flex: 1, overflowY: 'auto', overflowX: 'hidden' }}
>
{step === 0 ? (
// Welcome slide
<Stack gap={16}>
<div className={`${styles.title} ${styles.titleText}`}>
{welcomeSlide.title}
</div>
<div className={styles.bodyText}>
<div className={`${styles.bodyCopy} ${styles.bodyCopyInner}`}>
{welcomeSlide.body}
</div>
<style>{`.${styles.bodyCopyInner} strong { color: var(--onboarding-title); font-weight: 600; }`}</style>
</div>
<OnboardingStepper totalSteps={totalSteps} activeStep={step} />
<div className={styles.buttonContainer}>
<Group justify="flex-end">
<Button
onClick={() => setStep(1)}
styles={{
root: {
background: 'var(--onboarding-primary-button-bg)',
color: 'var(--onboarding-primary-button-text)',
},
}}
>
{t('onboarding.buttons.next', 'Next →')}
</Button>
</Group>
</div>
</Stack>
) : (
// Sign-in slide
<Stack gap={12}>
<OnboardingStepper totalSteps={totalSteps} activeStep={step} />
<SetupWizard
noLayout
onComplete={handleComplete}
onClose={dismissFinal}
/>
</Stack>
)}
</div>
</Stack>
</Modal>
);
}
@@ -15,6 +15,8 @@ interface SaaSLoginScreenProps {
onOAuthSuccess: (userInfo: UserInfo) => Promise<void>;
onSelfHostedClick: () => void;
onSwitchToSignup: () => void;
onSkipSignIn?: () => void;
onClose?: () => void;
loading: boolean;
error: string | null;
}
@@ -25,6 +27,8 @@ export const SaaSLoginScreen: React.FC<SaaSLoginScreenProps> = ({
onOAuthSuccess,
onSelfHostedClick,
onSwitchToSignup,
onSkipSignIn,
onClose,
loading,
error,
}) => {
@@ -57,7 +61,7 @@ export const SaaSLoginScreen: React.FC<SaaSLoginScreenProps> = ({
return (
<>
<LoginHeader title={t('setup.saas.title', 'Sign in to Stirling Cloud')} />
<LoginHeader title={t('setup.saas.title', 'Sign in to Stirling Cloud')} onClose={onClose} />
<ErrorMessage error={displayError} />
@@ -110,6 +114,19 @@ export const SaaSLoginScreen: React.FC<SaaSLoginScreenProps> = ({
</div>
<SelfHostedLink onClick={onSelfHostedClick} disabled={loading} />
{onSkipSignIn && (
<div className="navigation-link-container" style={{ marginTop: '0.5rem', textAlign: 'center' }}>
<button
type="button"
onClick={onSkipSignIn}
className="navigation-link-button"
disabled={loading}
>
{t('setup.login.skipSignIn', 'Continue without signing in')}
</button>
</div>
)}
</>
);
};
@@ -35,8 +35,6 @@ export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, load
url = `https://${url}`;
setCustomUrl(url); // Update the input field
}
localStorage.setItem('server_url', url);
// Validate URL format
try {
const urlObj = new URL(url);
@@ -150,7 +148,7 @@ export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, load
console.error('[ServerSelection] Configuration fetch error details:', errorMessage);
setTestError(
t('setup.server.error.configFetch', 'Failed to fetch server configuration: {{error}}', {
t('setup.server.error.configFetchError', 'Failed to fetch server configuration: {{error}}', {
error: errorMessage
})
);
@@ -158,7 +156,8 @@ export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, load
return;
}
// Connection successful - pass URL, OAuth providers, and login method
// Connection successful persist URL so it pre-fills on next sign-in
localStorage.setItem('server_url', url);
console.log('[ServerSelection] ✅ Server selection complete, proceeding to login');
onSelect({
url,
@@ -1,11 +1,12 @@
import React, { useEffect, useState } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Stack, Text, Button, Alert, Loader, Center } from '@mantine/core';
import { DesktopAuthLayout } from '@app/components/SetupWizard/DesktopAuthLayout';
import { SaaSLoginScreen } from '@app/components/SetupWizard/SaaSLoginScreen';
import { SaaSSignupScreen } from '@app/components/SetupWizard/SaaSSignupScreen';
import { ServerSelectionScreen } from '@app/components/SetupWizard/ServerSelectionScreen';
import { SelfHostedLoginScreen } from '@app/components/SetupWizard/SelfHostedLoginScreen';
import { ServerConfig, connectionModeService } from '@app/services/connectionModeService';
import { ServerConfig, SSOProviderConfig, connectionModeService } from '@app/services/connectionModeService';
import { AuthServiceError, authService, UserInfo } from '@app/services/authService';
import { tauriBackendService } from '@app/services/tauriBackendService';
import { STIRLING_SAAS_URL } from '@app/constants/connection';
@@ -21,9 +22,13 @@ enum SetupStep {
interface SetupWizardProps {
onComplete: () => void;
/** Omit the DesktopAuthLayout wrapper — use when rendering inside a modal */
noLayout?: boolean;
/** Called when the user dismisses the wizard (modal close button) */
onClose?: () => void;
}
export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete, noLayout = false, onClose }) => {
const { t } = useTranslation();
const [activeStep, setActiveStep] = useState<SetupStep>(SetupStep.SaaSLogin);
const [serverConfig, setServerConfig] = useState<ServerConfig | null>({ url: STIRLING_SAAS_URL });
@@ -32,6 +37,8 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
const [selfHostedMfaCode, setSelfHostedMfaCode] = useState('');
const [selfHostedMfaRequired, setSelfHostedMfaRequired] = useState(false);
const [lockConnectionMode, setLockConnectionMode] = useState(false);
const [lockedServerUnreachable, setLockedServerUnreachable] = useState(false);
const [lockedServerChecking, setLockedServerChecking] = useState(false);
const handleSaaSLogin = async (username: string, password: string) => {
if (!serverConfig) {
@@ -81,6 +88,24 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
}
};
const handleLocalMode = async () => {
try {
setLoading(true);
setError(null);
// Save the server URL so it pre-fills on reconnect
if (serverConfig?.url) {
localStorage.setItem('server_url', serverConfig.url);
}
await connectionModeService.switchToLocal();
tauriBackendService.startBackend().catch(console.error);
onComplete();
} catch (err) {
console.error('Failed to continue in local mode:', err);
setError(err instanceof Error ? err.message : String(err));
setLoading(false);
}
};
const handleSelfHostedClick = () => {
if (lockConnectionMode) {
return;
@@ -285,58 +310,73 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
}
};
useEffect(() => {
const loadConfig = async () => {
const currentConfig = await connectionModeService.getCurrentConfig();
if (currentConfig.lock_connection_mode && currentConfig.server_config?.url) {
setLockConnectionMode(true);
const loadLockedConfig = useCallback(async () => {
const currentConfig = await connectionModeService.getCurrentConfig();
if (!currentConfig.lock_connection_mode) return;
// server_config may be null when the user switched to local mode from a locked deployment.
// Fall back to the URL saved by switchToLocal() so the wizard still shows locked login.
const serverUrl = currentConfig.server_config?.url
|| localStorage.getItem('stirling-provisioned-server-url');
if (!serverUrl) return;
// Re-fetch OAuth providers for the saved server URL
const savedUrl = currentConfig.server_config.url.replace(/\/+$/, ''); // Remove trailing slashes
let updatedConfig = { ...currentConfig.server_config };
setLockConnectionMode(true);
setLockedServerUnreachable(false);
setLockedServerChecking(true);
try {
console.log('[SetupWizard] Re-fetching OAuth providers for saved server:', savedUrl);
const response = await fetch(`${savedUrl}/api/v1/proprietary/ui-data/login`);
const savedUrl = serverUrl.replace(/\/+$/, '');
let updatedConfig: ServerConfig = { ...(currentConfig.server_config ?? { url: savedUrl }) };
if (response.ok) {
const data = await response.json();
const enabledProviders: any[] = [];
const providerEntries = Object.entries(data.providerList || {});
try {
const response = await fetch(`${savedUrl}/api/v1/proprietary/ui-data/login`);
providerEntries.forEach(([path, label]) => {
const id = path.split('/').pop();
if (id) {
enabledProviders.push({
id,
path,
label: typeof label === 'string' ? label : undefined,
});
}
if (response.ok) {
const data = await response.json();
const enabledProviders: SSOProviderConfig[] = [];
const providerEntries = Object.entries(data.providerList || {});
providerEntries.forEach(([path, label]) => {
const id = path.split('/').pop();
if (id) {
enabledProviders.push({
id,
path,
label: typeof label === 'string' ? label : undefined,
});
updatedConfig = {
...updatedConfig,
enabledOAuthProviders: enabledProviders.length > 0 ? enabledProviders : undefined,
loginMethod: data.loginMethod || 'all',
};
console.log('[SetupWizard] Updated config with OAuth providers:', updatedConfig);
}
} catch (err) {
console.error('[SetupWizard] Failed to re-fetch OAuth providers:', err);
}
});
updatedConfig = {
...updatedConfig,
enabledOAuthProviders: enabledProviders.length > 0 ? enabledProviders : undefined,
loginMethod: data.loginMethod || 'all',
};
setServerConfig(updatedConfig);
setLockedServerChecking(false);
setActiveStep(SetupStep.SelfHostedLogin);
} else {
// Server responded but with an error — still show login form
updatedConfig = { ...updatedConfig, loginMethod: 'all' };
setServerConfig(updatedConfig);
setLockedServerChecking(false);
setActiveStep(SetupStep.SelfHostedLogin);
}
};
void loadConfig();
} catch (err) {
// Network error — server is unreachable
console.error('[SetupWizard] Server unreachable:', err);
setServerConfig(updatedConfig);
setLockedServerChecking(false);
setLockedServerUnreachable(true);
setActiveStep(SetupStep.SelfHostedLogin);
}
}, []);
return (
<DesktopAuthLayout>
useEffect(() => {
void loadLockedConfig();
}, [loadLockedConfig]);
const wizardContent = (
<>
{/* Step Content */}
{!lockConnectionMode && activeStep === SetupStep.SaaSLogin && (
<SaaSLoginScreen
@@ -345,6 +385,8 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
onOAuthSuccess={handleSaaSLoginOAuth}
onSelfHostedClick={handleSelfHostedClick}
onSwitchToSignup={handleSwitchToSignup}
onSkipSignIn={handleLocalMode}
onClose={onClose}
loading={loading}
error={error}
/>
@@ -367,19 +409,68 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
/>
)}
{activeStep === SetupStep.SelfHostedLogin && (
<SelfHostedLoginScreen
serverUrl={serverConfig?.url || ''}
enabledOAuthProviders={serverConfig?.enabledOAuthProviders}
loginMethod={serverConfig?.loginMethod}
onLogin={handleSelfHostedLogin}
onOAuthSuccess={handleSelfHostedOAuthSuccess}
mfaCode={selfHostedMfaCode}
setMfaCode={setSelfHostedMfaCode}
requiresMfa={selfHostedMfaRequired}
loading={loading}
error={error}
/>
{lockConnectionMode && lockedServerChecking && (
<Center py="xl">
<Loader size="md" />
</Center>
)}
{activeStep === SetupStep.SelfHostedLogin && lockedServerUnreachable && !lockedServerChecking && (
<Stack gap="md" style={{ padding: '0.5rem 0' }}>
<Alert color="orange" title={t('setup.selfhosted.unreachable.title', 'Cannot connect to server')}>
<Text size="sm">
{t('setup.selfhosted.unreachable.message', 'Could not reach {{url}}. Check that the server is running and accessible.', {
url: serverConfig?.url,
})}
</Text>
</Alert>
<Button
variant="filled"
color="blue"
fullWidth
loading={loading}
onClick={() => void loadLockedConfig()}
>
{t('setup.selfhosted.unreachable.retry', 'Retry')}
</Button>
<Button
variant="subtle"
color="white"
fullWidth
onClick={handleLocalMode}
>
{t('setup.selfhosted.unreachable.continueOffline', 'Use local tools instead')}
</Button>
</Stack>
)}
{activeStep === SetupStep.SelfHostedLogin && !lockedServerUnreachable && !lockedServerChecking && (
<>
<SelfHostedLoginScreen
serverUrl={serverConfig?.url || ''}
enabledOAuthProviders={serverConfig?.enabledOAuthProviders}
loginMethod={serverConfig?.loginMethod}
onLogin={handleSelfHostedLogin}
onOAuthSuccess={handleSelfHostedOAuthSuccess}
mfaCode={selfHostedMfaCode}
setMfaCode={setSelfHostedMfaCode}
requiresMfa={selfHostedMfaRequired}
loading={loading}
error={error}
/>
{lockConnectionMode && (
<div className="navigation-link-container" style={{ marginTop: '1.5rem' }}>
<button
type="button"
onClick={handleLocalMode}
className="navigation-link-button"
disabled={loading}
>
{t('setup.selfhosted.switchToLocal', 'Use local tools instead')}
</button>
</div>
)}
</>
)}
{/* Back Button */}
@@ -394,6 +485,16 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
</button>
</div>
)}
</>
);
if (noLayout) {
return <div style={{ padding: '2rem' }}>{wizardContent}</div>;
}
return (
<DesktopAuthLayout>
{wizardContent}
</DesktopAuthLayout>
);
};
@@ -0,0 +1,47 @@
import { useEffect, useState } from 'react';
import { Modal } from '@mantine/core';
import { SetupWizard } from '@app/components/SetupWizard';
import { OPEN_SIGN_IN_EVENT } from '@app/constants/signInEvents';
import { Z_INDEX_SIGN_IN_MODAL } from '@app/styles/zIndex';
export function SignInModal() {
const [opened, setOpened] = useState(false);
const [locked, setLocked] = useState(false);
useEffect(() => {
const handler = (e: Event) => {
const detail = (e as CustomEvent).detail;
setLocked(detail?.locked === true);
setOpened(true);
};
window.addEventListener(OPEN_SIGN_IN_EVENT, handler);
return () => window.removeEventListener(OPEN_SIGN_IN_EVENT, handler);
}, []);
if (!opened) return null;
return (
<Modal
opened={opened}
onClose={() => { if (!locked) setOpened(false); }}
size={520}
centered
withCloseButton={false}
closeOnClickOutside={!locked}
closeOnEscape={!locked}
padding={0}
radius="lg"
zIndex={Z_INDEX_SIGN_IN_MODAL}
>
<SetupWizard
noLayout
onClose={() => setOpened(false)}
onComplete={() => {
setOpened(false);
// No reload needed — AppProviders remounts the SaaS provider tree via
// connectionModeService subscription when mode changes.
}}
/>
</Modal>
);
}
@@ -1,10 +1,103 @@
import { Box, rem } from '@mantine/core';
import { BackendHealthIndicator } from '@app/components/BackendHealthIndicator';
import { useState, useEffect, useMemo } from 'react';
import { Box, Tooltip, rem, useComputedColorScheme } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { connectionModeService, type ConnectionMode } from '@app/services/connectionModeService';
import { selfHostedServerMonitor, type SelfHostedServerState } from '@app/services/selfHostedServerMonitor';
import { useBackendHealth } from '@app/hooks/useBackendHealth';
import { OPEN_SIGN_IN_EVENT } from '@app/constants/signInEvents';
interface RightRailFooterExtensionsProps {
className?: string;
}
function ConnectionStatusDot() {
const { t } = useTranslation();
const colorScheme = useComputedColorScheme('light');
const [connectionMode, setConnectionMode] = useState<ConnectionMode | null>(null);
const [selfHostedState, setSelfHostedState] = useState<SelfHostedServerState>(
() => selfHostedServerMonitor.getSnapshot()
);
const { isOnline, checkHealth } = useBackendHealth();
useEffect(() => {
void connectionModeService.getCurrentMode().then(setConnectionMode);
const unsubscribe = connectionModeService.subscribeToModeChanges((config) => {
setConnectionMode(config.mode);
});
return unsubscribe;
}, []);
useEffect(() => {
return selfHostedServerMonitor.subscribe(setSelfHostedState);
}, []);
const { label, color } = useMemo(() => {
if (connectionMode === 'saas') {
return {
label: t('connectionMode.status.saas', 'Connected to Stirling Cloud'),
color: '#3b82f6',
};
}
if (connectionMode === 'selfhosted') {
const serverOnline = selfHostedState.isOnline;
const serverChecking = selfHostedState.status === 'checking';
const backendLabel = serverChecking
? t('connectionMode.status.selfhostedChecking', 'Connected to self-hosted server (checking...)')
: serverOnline
? t('connectionMode.status.selfhostedOnline', 'Connected to self-hosted server')
: t('connectionMode.status.selfhostedOffline', 'Self-hosted server unreachable');
return {
label: backendLabel,
color: serverChecking ? '#fcc419' : serverOnline ? '#37b24d' : '#e03131',
};
}
// local
return {
label: isOnline
? t('connectionMode.status.localOnline', 'Offline mode running')
: t('connectionMode.status.localOffline', 'Offline mode running'),
color: '#868e96',
};
}, [connectionMode, selfHostedState, isOnline, t]);
return (
<Tooltip
label={label}
position="left"
offset={12}
withArrow
withinPortal
color={colorScheme === 'dark' ? undefined : 'dark'}
>
<Box
component="span"
role="status"
aria-label={label}
tabIndex={0}
onClick={() => {
if (connectionMode === 'local') {
window.dispatchEvent(new CustomEvent(OPEN_SIGN_IN_EVENT));
} else {
void checkHealth();
}
}}
style={{
width: rem(10),
height: rem(10),
borderRadius: '50%',
backgroundColor: color,
boxShadow: colorScheme === 'dark'
? '0 0 0 2px rgba(255, 255, 255, 0.15)'
: '0 0 0 2px rgba(0, 0, 0, 0.07)',
display: 'inline-block',
cursor: 'pointer',
outline: 'none',
}}
/>
</Tooltip>
);
}
export function RightRailFooterExtensions({ className }: RightRailFooterExtensionsProps) {
return (
<Box
@@ -17,7 +110,7 @@ export function RightRailFooterExtensions({ className }: RightRailFooterExtensio
paddingBottom: rem(12),
}}
>
<BackendHealthIndicator />
<ConnectionStatusDot />
</Box>
);
}
@@ -51,9 +51,10 @@ export function SelfHostedOfflineBanner() {
() => !!tauriBackendService.getBackendUrl()
);
// Load connection mode once on mount
// Load connection mode and keep it live via subscription
useEffect(() => {
void connectionModeService.getCurrentMode().then(setConnectionMode);
return connectionModeService.subscribeToModeChanges(config => setConnectionMode(config.mode));
}, []);
// Subscribe to self-hosted server status changes
@@ -20,23 +20,12 @@ export const useConfigNavSections = (
): ConfigNavSection[] => {
const { t } = useTranslation();
// Check if in SaaS mode and authenticated (for Team section visibility)
const [isSaasMode, setIsSaasMode] = useState<boolean>(false);
const [connectionMode, setConnectionMode] = useState<string | null>(null);
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
useEffect(() => {
const checkAccess = async () => {
const mode = await connectionModeService.getCurrentMode();
const auth = await authService.isAuthenticated();
setIsSaasMode(mode === 'saas');
setIsAuthenticated(auth);
};
checkAccess();
// Subscribe to connection mode changes
const unsubscribe = connectionModeService.subscribeToModeChanges(checkAccess);
return unsubscribe;
void connectionModeService.getCurrentMode().then(setConnectionMode);
return connectionModeService.subscribeToModeChanges(config => setConnectionMode(config.mode));
}, []);
// Subscribe to auth changes
@@ -47,9 +36,33 @@ export const useConfigNavSections = (
return unsubscribe;
}, []);
const isSaasMode = connectionMode === 'saas';
const isLocalMode = connectionMode === 'local';
// Get the proprietary sections (includes core Preferences + admin sections)
const sections = useProprietaryConfigNavSections(isAdmin, runningEE, loginEnabled);
const connectionModeSection: ConfigNavSection = {
title: t('settings.connection.title', 'Connection Mode'),
items: [
{
key: 'connectionMode',
label: t('settings.connection.title', 'Connection Mode'),
icon: 'desktop-cloud-rounded',
component: <ConnectionSettings />,
},
],
};
// In local mode only show Preferences + Connection Mode — everything else
// requires a server and will 500 or show irrelevant admin UI.
if (isLocalMode) {
const result: ConfigNavSection[] = [];
if (sections.length > 0) result.push(sections[0]);
result.push(connectionModeSection);
return result;
}
// Identifies self-hosted admin sections by their first item's stable key.
// Using item keys avoids dependency on translated section titles (#17).
const SELF_HOSTED_SECTION_FIRST_KEYS = new Set([
@@ -67,17 +80,7 @@ export const useConfigNavSections = (
if (sections.length > 0) result.push(sections[0]);
// Connection Mode always sits immediately after Preferences
result.push({
title: t('settings.connection.title', 'Connection Mode'),
items: [
{
key: 'connectionMode',
label: t('settings.connection.title', 'Connection Mode'),
icon: 'desktop-cloud-rounded',
component: <ConnectionSettings />,
},
],
});
result.push(connectionModeSection);
// Plan & Billing and Team sections only when authenticated in SaaS mode
if (isSaasMode && isAuthenticated) {
@@ -106,12 +109,17 @@ export const useConfigNavSections = (
}
// Append remaining proprietary sections, skipping self-hosted admin sections in SaaS mode
// and hiding the Account section when not authenticated.
for (const section of sections.slice(1)) {
const firstItemKey = section.items[0]?.key;
if (isSaasMode && firstItemKey && SELF_HOSTED_SECTION_FIRST_KEYS.has(firstItemKey)) {
continue;
}
result.push(section);
const filteredItems = isAuthenticated
? section.items
: section.items.filter(item => item.key !== 'account');
if (filteredItems.length === 0) continue;
result.push({ ...section, items: filteredItems });
}
return result;
@@ -54,7 +54,9 @@ export function CreditModalBootstrap() {
toolId: customEvent.detail?.operationType,
requiredCredits: customEvent.detail?.requiredCredits,
});
setInsufficientOpen(true);
// Show the plans banner (CreditExhaustedModal) instead of the simpler
// InsufficientCreditsModal — same experience as clicking the upgrade button.
setExhaustedOpen(true);
};
window.addEventListener(CREDIT_EVENTS.EXHAUSTED, handleExhausted);
@@ -0,0 +1,51 @@
import { useState, useEffect } from 'react';
import CoreToolButton from '@core/components/tools/toolPicker/ToolButton';
import { getToolDisabledReason } from '@app/components/tools/fullscreen/shared';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { ToolRegistryEntry } from '@app/data/toolsTaxonomy';
import { connectionModeService, type ConnectionMode } from '@app/services/connectionModeService';
import type { ToolId } from '@app/types/toolId';
type CoreToolButtonProps = React.ComponentProps<typeof CoreToolButton>;
/**
* Desktop override of ToolButton.
* In local mode, unavailable tools (except comingSoon/selfHostedOffline) navigate directly
* to the tool UI — the execute button there shows the disabled state with a "click to sign in"
* tooltip, keeping the tool's settings visible and letting the user explore before committing.
* In selfhosted/saas mode the tool renders as visually unavailable (dimmed, no badge).
*/
const ToolButton: React.FC<CoreToolButtonProps> = (props) => {
const { toolAvailability, handleToolSelectForced } = useToolWorkflow();
const { config } = useAppConfig();
const premiumEnabled = config?.premiumEnabled;
const [connectionMode, setConnectionMode] = useState<ConnectionMode | null>(null);
useEffect(() => {
void connectionModeService.getCurrentMode().then(setConnectionMode);
return connectionModeService.subscribeToModeChanges((cfg) => setConnectionMode(cfg.mode));
}, []);
const disabledReason = getToolDisabledReason(
props.id as string,
props.tool as ToolRegistryEntry,
toolAvailability,
premiumEnabled
);
// In local mode, pass a handler so CoreToolButton renders the tool as "cloud-available"
// (full opacity, cloud badge, clickable). Clicking navigates to the tool normally so the
// user can see the settings; the disabled execute button handles the sign-in prompt.
// comingSoon and selfHostedOffline tools remain dimmed — they have no usable UI to show.
const handleUnavailableClick =
connectionMode === 'local' &&
disabledReason !== 'comingSoon' &&
disabledReason !== 'selfHostedOffline'
? () => handleToolSelectForced(props.id as ToolId)
: undefined;
return <CoreToolButton {...props} onUnavailableClick={handleUnavailableClick} />;
};
export default ToolButton;
@@ -0,0 +1,54 @@
import { useState, useEffect } from 'react';
import { Group, Text, Button } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { connectionModeService, type ConnectionMode } from '@app/services/connectionModeService';
import { OPEN_SIGN_IN_EVENT } from '@app/constants/signInEvents';
/**
* Desktop-only footer shown at the bottom of the tool list.
* In local (offline) mode: prompts the user to sign in to unlock cloud tools.
* In other modes: renders nothing.
*/
export function ToolPickerFooterExtensions() {
const { t } = useTranslation();
const [connectionMode, setConnectionMode] = useState<ConnectionMode | null>(null);
useEffect(() => {
void connectionModeService.getCurrentMode().then(setConnectionMode);
const unsubscribe = connectionModeService.subscribeToModeChanges((config) => {
setConnectionMode(config.mode);
});
return unsubscribe;
}, []);
if (connectionMode !== 'local') return null;
return (
<Group
gap="xs"
align="center"
justify="space-between"
wrap="nowrap"
px="sm"
py={10}
style={{
borderTop: '1px solid var(--border-default)',
background: 'var(--bg-toolbar)',
flexShrink: 0,
}}
>
<Text size="xs" c="dimmed" style={{ flex: 1, minWidth: 0 }}>
{t('localMode.toolPicker.message', 'Sign in to unlock all tools.')}
</Text>
<Button
size="compact-xs"
variant="light"
color="blue"
style={{ flexShrink: 0 }}
onClick={() => window.dispatchEvent(new CustomEvent(OPEN_SIGN_IN_EVENT))}
>
{t('localMode.toolPicker.signIn', 'Sign In')}
</Button>
</Group>
);
}
@@ -0,0 +1,5 @@
/**
* CustomEvent name for opening the desktop sign-in modal (SetupWizard).
* Kept in a leaf module so apiClientSetup and others avoid importing SignInModal (heavy graph).
*/
export const OPEN_SIGN_IN_EVENT = 'stirling:open-sign-in';
@@ -118,7 +118,7 @@ export function SaaSTeamProvider({ children }: { children: ReactNode }) {
}
try {
const response = await apiClient.get<Team[]>('/api/v1/team/my');
const response = await apiClient.get<Team[]>('/api/v1/team/my', { suppressErrorToast: true });
setTeams(response.data);
const activeTeam = response.data[0];
@@ -144,7 +144,7 @@ export function SaaSTeamProvider({ children }: { children: ReactNode }) {
}
try {
const response = await apiClient.get<TeamMember[]>(`/api/v1/team/${teamId}/members`);
const response = await apiClient.get<TeamMember[]>(`/api/v1/team/${teamId}/members`, { suppressErrorToast: true });
setTeamMembers(response.data);
} catch (error) {
console.error('[SaaSTeamContext] Failed to fetch team members:', error);
@@ -158,7 +158,7 @@ export function SaaSTeamProvider({ children }: { children: ReactNode }) {
}
try {
const response = await apiClient.get<TeamInvitation[]>(`/api/v1/team/${teamId}/invitations`);
const response = await apiClient.get<TeamInvitation[]>(`/api/v1/team/${teamId}/invitations`, { suppressErrorToast: true });
setTeamInvitations(response.data);
} catch (error) {
console.error('[SaaSTeamContext] Failed to fetch team invitations:', error);
@@ -174,7 +174,7 @@ export function SaaSTeamProvider({ children }: { children: ReactNode }) {
console.log('[SaaSTeamContext] Fetching received team invitations');
try {
const response = await apiClient.get<TeamInvitation[]>('/api/v1/team/invitations/pending');
const response = await apiClient.get<TeamInvitation[]>('/api/v1/team/invitations/pending', { suppressErrorToast: true });
console.log('[SaaSTeamContext] Received invitations response:', response.data);
setReceivedInvitations(response.data);
} catch (error) {
@@ -1,5 +1,4 @@
import { connectionModeService } from '@app/services/connectionModeService';
import { STIRLING_SAAS_URL } from '@app/constants/connection';
type SignOutFn = () => Promise<void>;
@@ -17,13 +16,16 @@ export function useAccountLogout() {
await signOut();
const currentConfig = await connectionModeService.getCurrentConfig();
if (!currentConfig.lock_connection_mode) {
await connectionModeService.switchToSaaS(STIRLING_SAAS_URL);
await connectionModeService.resetSetupCompletion().catch(() => {});
// Save server URL before clearing so user can easily reconnect (self-hosted only)
if (currentConfig.mode === 'selfhosted' && currentConfig.server_config?.url) {
localStorage.setItem('server_url', currentConfig.server_config.url);
}
// Always switch to local after logout so the app remains usable
await connectionModeService.switchToLocal();
window.history.replaceState({}, '', '/');
window.location.reload();
// No reload needed — AppProviders remounts the SaaS provider tree via
// connectionModeService subscription when mode changes to local.
return;
} catch (err) {
console.warn('[Desktop AccountLogout] Desktop-specific logout failed, falling back to redirect', err);
@@ -30,6 +30,7 @@ export function useBackendHealth() {
useEffect(() => {
void connectionModeService.getCurrentMode().then(setConnectionMode);
return connectionModeService.subscribeToModeChanges(config => setConnectionMode(config.mode));
}, []);
useEffect(() => {
@@ -106,13 +106,13 @@ export function useEndpointEnabled(endpoint: string): {
const locallyEnabled = response.data;
// DESKTOP ENHANCEMENT: In SaaS mode, assume all endpoints are available
// Even if not supported locally, they will route to SaaS backend
if (!locallyEnabled) {
const mode = await connectionModeService.getCurrentMode();
// DESKTOP ENHANCEMENT: In SaaS mode, assume all endpoints are available
// Even if not supported locally, they will route to SaaS backend
if (mode === 'saas') {
console.debug(`[useEndpointEnabled] Endpoint ${endpoint} not supported locally but available via SaaS routing`);
setEnabled(true); // Available via SaaS
setEnabled(true);
return;
}
}
@@ -302,9 +302,10 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
return acc;
}, {} as Record<string, boolean>);
const mode = await connectionModeService.getCurrentMode();
// DESKTOP ENHANCEMENT: In SaaS mode, mark all disabled endpoints as available
// They will route to SaaS backend
const mode = await connectionModeService.getCurrentMode();
if (mode === 'saas') {
const disabledEndpoints = Object.keys(details).filter(key => !details[key].enabled);
@@ -315,6 +316,7 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
}
}
setEndpointDetails(prev => ({ ...prev, ...details }));
setEndpointStatus(prev => ({ ...prev, ...statusMap }));
} catch (err: unknown) {
+12
View File
@@ -0,0 +1,12 @@
import HomePage from '@app/pages/HomePage';
/**
* Desktop override of Landing.
* In desktop builds, authentication is managed entirely by AppProviders,
* the DesktopOnboardingModal, and the SignInModal — never by routing to /login.
* Always render the main app; the onboarding/sign-in modals appear on top
* when authentication is required.
*/
export default function Landing() {
return <HomePage />;
}
+14
View File
@@ -0,0 +1,14 @@
import { Navigate } from 'react-router-dom';
/**
* Desktop override of the /login route.
* The legacy web login page must never appear in desktop builds — authentication
* is handled exclusively through the DesktopOnboardingModal and SignInModal.
* Any navigation to /login (e.g. from Spring Boot auth redirects) is intercepted
* here and immediately redirected to /.
* The sign-in modal is opened by the desktop httpErrorHandler before navigation
* occurs, so no additional dispatch is needed here.
*/
export default function Login() {
return <Navigate to="/" replace />;
}
@@ -0,0 +1,41 @@
import { ActionIcon } from '@mantine/core';
import CloseIcon from '@mui/icons-material/Close';
import { useLogoAssets } from '@app/hooks/useLogoAssets';
interface LoginHeaderProps {
title: string;
subtitle?: string;
centerOnly?: boolean;
onClose?: () => void;
}
/**
* Desktop override of LoginHeader.
* Renders icon + title + optional close button all in one row.
*/
export default function LoginHeader({ title, subtitle, centerOnly = false, onClose }: LoginHeaderProps) {
const { tooltipLogo } = useLogoAssets();
return (
<div className={`login-header${centerOnly ? ' login-header-centered' : ''}`} style={{ marginBottom: '2rem' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '0.75rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem', flex: 1, minWidth: 0 }}>
<img src={tooltipLogo} alt="Stirling PDF" style={{ width: 36, height: 36, flexShrink: 0 }} />
{title && <h1 className="login-title" style={{ margin: 0 }}>{title}</h1>}
</div>
{onClose && (
<ActionIcon
onClick={onClose}
radius="md"
size={32}
variant="subtle"
style={{ flexShrink: 0, color: 'var(--text-secondary)', outline: 'none' }}
>
<CloseIcon fontSize="small" />
</ActionIcon>
)}
</div>
{subtitle && <p className="login-subtitle">{subtitle}</p>}
</div>
);
}
@@ -7,6 +7,7 @@ import { operationRouter } from '@app/services/operationRouter';
import { authService } from '@app/services/authService';
import { connectionModeService } from '@app/services/connectionModeService';
import { STIRLING_SAAS_URL, STIRLING_SAAS_BACKEND_API_URL } from '@app/constants/connection';
import { OPEN_SIGN_IN_EVENT } from '@app/constants/signInEvents';
import i18n from '@app/i18n';
const BACKEND_TOAST_COOLDOWN_MS = 4000;
@@ -52,8 +53,6 @@ export function setupApiInterceptors(client: AxiosInstance): void {
extendedConfig.url = `${baseUrl}${extendedConfig.url}`;
}
localStorage.setItem('server_url', baseUrl);
// Debug logging
console.debug(`[apiClientSetup] Request to: ${extendedConfig.url}`);
@@ -162,6 +161,12 @@ export function setupApiInterceptors(client: AxiosInstance): void {
if (originalRequest.skipAuthRedirect) {
return Promise.reject(error);
}
// If no Authorization header was sent, the user was never authenticated —
// the 401 is expected (e.g. endpoint availability checks when not signed in).
// Don't attempt a refresh or open the sign-in modal in that case.
if (!originalRequest.headers.Authorization) {
return Promise.reject(error);
}
originalRequest._retry = true;
console.debug(`[apiClientSetup] 401 error, attempting token refresh for: ${originalRequest.url}`);
@@ -194,13 +199,8 @@ export function setupApiInterceptors(client: AxiosInstance): void {
return client.request(originalRequest);
}
// Refresh failed - user needs to login again
alert({
alertType: 'error',
title: i18n.t('auth.sessionExpired', 'Session Expired'),
body: i18n.t('auth.pleaseLoginAgain', 'Please login again.'),
isPersistentPopup: false,
});
// Refresh failed - prompt for re-authentication via the sign-in modal.
window.dispatchEvent(new CustomEvent(OPEN_SIGN_IN_EVENT, { detail: { locked: false } }));
}
// Handle 403 Forbidden - unauthorized access
+33 -2
View File
@@ -192,6 +192,22 @@ export class AuthService {
this.notifyListeners();
}
/**
* Dev-only: treat any stored JWT as expired so cold start and auth checks mimic
* "access token dead" (local fallback + sign-in nudge) without editing storage by hand.
* Enable with VITE_DEV_SIMULATE_EXPIRED_JWT=true in .env.desktop — only works in dev builds.
*/
private shouldSimulateExpiredJwt(): boolean {
// Stop simulating once the user has freshly authenticated in this session
// (e.g. after completing re-auth via the sign-in modal). This prevents the
// simulation from looping: expired → modal → re-auth → expired → modal…
if (this.authStatus === 'authenticated') return false;
return (
import.meta.env.DEV &&
String(import.meta.env.VITE_DEV_SIMULATE_EXPIRED_JWT ?? '').toLowerCase() === 'true'
);
}
async completeSupabaseSession(accessToken: string, serverUrl: string): Promise<UserInfo> {
if (!accessToken || !accessToken.trim()) {
throw new Error('Invalid access token');
@@ -455,6 +471,12 @@ export class AuthService {
// Cache token if found (backend will validate expiry)
if (token && token.trim().length > 0) {
if (this.shouldSimulateExpiredJwt()) {
console.warn(
'[Desktop AuthService] DEV: VITE_DEV_SIMULATE_EXPIRED_JWT — ignoring stored access token (simulates expiry)'
);
return null;
}
this.cachedToken = token;
console.log('[Desktop AuthService] ✅ Token cached in memory after retrieval');
return token;
@@ -507,6 +529,12 @@ export class AuthService {
}
isTokenExpiringSoon(token: string, leewaySeconds = 30): boolean {
if (this.shouldSimulateExpiredJwt()) {
console.warn(
'[Desktop AuthService] DEV: VITE_DEV_SIMULATE_EXPIRED_JWT — treating token as expired (isTokenExpiringSoon)'
);
return true;
}
try {
const parts = token.split('.');
if (parts.length < 2) {
@@ -718,8 +746,11 @@ export class AuthService {
this.setAuthStatus('unauthenticated', null);
console.log('[Desktop AuthService] Auth state initialized as unauthenticated');
// Defensive: ensure any partial tokens are purged to prevent auto-login loops
await this.clearTokenEverywhere().catch(() => {});
// Defensive: ensure any partial tokens are purged to prevent auto-login loops.
// Skip when simulating expiry — the token is real and must not be destroyed.
if (!this.shouldSimulateExpiredJwt()) {
await this.clearTokenEverywhere().catch(() => {});
}
}
}
@@ -2,7 +2,7 @@ import { invoke } from '@tauri-apps/api/core';
import { fetch } from '@tauri-apps/plugin-http';
import { endpointAvailabilityService } from '@app/services/endpointAvailabilityService';
export type ConnectionMode = 'saas' | 'selfhosted';
export type ConnectionMode = 'saas' | 'selfhosted' | 'local';
export interface SSOProviderConfig {
id: string;
@@ -36,6 +36,8 @@ export interface ConnectionTestResult {
diagnostics?: DiagnosticResult[];
}
export const LOCAL_MODE_STORAGE_KEY = 'stirling-local-mode';
export class ConnectionModeService {
private static instance: ConnectionModeService;
private currentConfig: ConnectionConfig | null = null;
@@ -82,12 +84,35 @@ export class ConnectionModeService {
private async loadConfig(): Promise<void> {
try {
const config = await invoke<ConnectionConfig>('get_connection_config');
const localFlag = localStorage.getItem(LOCAL_MODE_STORAGE_KEY);
if (config.mode === 'saas' && localFlag === 'true') {
// User previously chose local-only mode.
config.mode = 'local';
} else if (
config.mode === 'saas' &&
config.server_config === null &&
!config.lock_connection_mode &&
localFlag === null
) {
// Fresh install: Rust has never been given a server URL, the connection
// mode has never been explicitly set (no localStorage flag either direction),
// and there is no provisioning lock. Default to local so the user sees
// the bundled backend instead of a broken SaaS-mode UI.
// MSI installs with STIRLING_SERVER_URL are excluded because they have a
// non-null server_config; locked provisioned installs are excluded by the
// lock_connection_mode guard.
config.mode = 'local';
}
this.currentConfig = config;
this.configLoadedOnce = true;
} catch (error) {
console.error('Failed to load connection config:', error);
// Default to SaaS mode on error
this.currentConfig = { mode: 'saas', server_config: null, lock_connection_mode: false };
// Default to local mode on error — safer than showing SaaS UI for a
// desktop app whose bundled backend is always available.
this.currentConfig = { mode: 'local', server_config: null, lock_connection_mode: false };
this.configLoadedOnce = true;
}
}
@@ -97,6 +122,9 @@ export class ConnectionModeService {
throw new Error('Connection mode is locked by provisioning');
}
// Clear local-only flag if switching to a real account
localStorage.removeItem(LOCAL_MODE_STORAGE_KEY);
console.log('Switching to SaaS mode');
const serverConfig: ServerConfig = { url: saasServerUrl };
@@ -117,7 +145,37 @@ export class ConnectionModeService {
console.log('Switched to SaaS mode successfully');
}
async switchToLocal(): Promise<void> {
console.log('Switching to local-only mode');
// Persist local mode preference via localStorage so no Rust enum change is needed.
// The Rust store records this as 'saas' (same bundled-backend behaviour); we overlay
// the 'local' distinction purely on the TypeScript side.
localStorage.setItem(LOCAL_MODE_STORAGE_KEY, 'true');
// When a locked provisioned deployment falls back to local, preserve the server URL
// so the SetupWizard can pre-fill it if the user tries to sign in again.
if (this.currentConfig?.lock_connection_mode && this.currentConfig.server_config?.url) {
localStorage.setItem('stirling-provisioned-server-url', this.currentConfig.server_config.url);
}
await invoke('set_connection_mode', {
mode: 'saas',
serverConfig: null,
});
this.currentConfig = { mode: 'local', server_config: null, lock_connection_mode: this.currentConfig?.lock_connection_mode ?? false };
// Clear endpoint availability cache when mode changes
endpointAvailabilityService.clearCache();
this.notifyListeners();
}
async switchToSelfHosted(serverConfig: ServerConfig): Promise<void> {
// Clear local-only flag if switching to a real account
localStorage.removeItem(LOCAL_MODE_STORAGE_KEY);
console.log('Switching to self-hosted mode:', serverConfig);
await invoke('set_connection_mode', {
@@ -0,0 +1,20 @@
import { handleHttpError as coreHandleHttpError } from '@core/services/httpErrorHandler';
/**
* Desktop override of handleHttpError.
* In desktop builds, 401 errors must never navigate to /login — the legacy web
* login page must not appear. Instead, open the SignInModal for re-authentication.
* All other error handling delegates to the core implementation.
*/
export async function handleHttpError(error: any): Promise<boolean> {
const status: number | undefined = error?.response?.status;
if (status === 401) {
// In desktop builds, 401s are handled by the auth service (token refresh + toast
// shown by apiClientSetup). Authentication is done via the onboarding modal or
// SignInModal — never by navigating to /login or opening a popup here.
return true; // Suppress toast
}
return coreHandleHttpError(error);
}
@@ -27,9 +27,7 @@ export class OperationRouter {
const mode = await connectionModeService.getCurrentMode();
// Current implementation: simple mode-based routing
if (mode === 'saas') {
// SaaS mode: For now, all operations run locally
// Future enhancement: complex operations will be sent to SaaS server
if (mode === 'saas' || mode === 'local') {
return 'local';
}
@@ -133,6 +131,35 @@ export class OperationRouter {
async getBaseUrl(operation?: string): Promise<string> {
const mode = await connectionModeService.getCurrentMode();
// Local-only mode: route everything to local backend; open settings if tool unavailable
if (mode === 'local') {
if (operation && this.isToolEndpoint(operation)) {
const endpointName = this.extractEndpointName(operation);
const backendUrl = tauriBackendService.getBackendUrl();
if (backendUrl) {
const supportedLocally = await endpointAvailabilityService.isEndpointSupportedLocally(
endpointName,
backendUrl
);
if (!supportedLocally) {
// Open the connection settings so the user can sign in
window.dispatchEvent(new CustomEvent('appConfig:navigate', { detail: { key: 'connectionMode' } }));
throw new Error(
i18n.t(
'localMode.toolUnavailable',
'This tool requires an account. Sign in to Stirling Cloud or connect to a self-hosted server to use it.'
)
);
}
}
}
const backendUrl = tauriBackendService.getBackendUrl();
if (!backendUrl) {
throw new Error('Backend URL not available - backend may still be starting');
}
return backendUrl.replace(/\/$/, '');
}
// Always route team endpoints to SaaS backend (existing logic)
if (mode === 'saas' && this.isSaaSBackendEndpoint(operation)) {
if (!STIRLING_SAAS_BACKEND_API_URL) {
@@ -148,39 +175,47 @@ export class OperationRouter {
const endpointToCheck = this.extractEndpointName(operation);
console.debug(`[operationRouter] Checking capability for ${operation} -> endpoint name: ${endpointToCheck}`);
const supportedLocally = await endpointAvailabilityService.isEndpointSupportedLocally(
endpointToCheck,
tauriBackendService.getBackendUrl()
);
console.debug(`[operationRouter] Endpoint ${endpointToCheck} supported locally: ${supportedLocally}`);
const backendUrl = tauriBackendService.getBackendUrl();
const backendHealthy = tauriBackendService.isOnline;
if (!supportedLocally) {
// Local backend doesn't support this - check if SaaS supports it
const supportedOnSaaS = await endpointAvailabilityService.isEndpointSupportedOnSaaS(endpointToCheck);
console.debug(`[operationRouter] Endpoint ${endpointToCheck} supported on SaaS: ${supportedOnSaaS}`);
// If the local backend isn't ready (no URL yet, or not yet healthy), skip the
// capability check and fall through to local routing — the backend-readiness check
// in the Axios interceptor will block non-GET requests until the backend is healthy.
if (backendUrl && backendHealthy) {
const supportedLocally = await endpointAvailabilityService.isEndpointSupportedLocally(
endpointToCheck,
backendUrl
);
console.debug(`[operationRouter] Endpoint ${endpointToCheck} supported locally: ${supportedLocally}`);
if (!supportedOnSaaS) {
// Neither local nor SaaS support this - throw error
console.error(`[operationRouter] Endpoint ${endpointToCheck} not supported on local or SaaS backend`);
throw new Error(
`This operation (${endpointToCheck}) is not available. It may require a self-hosted instance with additional features enabled.`
);
if (!supportedLocally) {
// Local backend doesn't support this - check if SaaS supports it
const supportedOnSaaS = await endpointAvailabilityService.isEndpointSupportedOnSaaS(endpointToCheck);
console.debug(`[operationRouter] Endpoint ${endpointToCheck} supported on SaaS: ${supportedOnSaaS}`);
if (!supportedOnSaaS) {
// Neither local nor SaaS support this - throw error
console.error(`[operationRouter] Endpoint ${endpointToCheck} not supported on local or SaaS backend`);
throw new Error(
`This operation (${endpointToCheck}) is not available. It may require a self-hosted instance with additional features enabled.`
);
}
// SaaS supports it - route to SaaS backend
if (!STIRLING_SAAS_BACKEND_API_URL) {
console.error('[operationRouter] VITE_SAAS_BACKEND_API_URL not configured');
throw new Error(
'Cloud processing is required for this tool but VITE_SAAS_BACKEND_API_URL is not configured. ' +
'Please check your environment configuration.'
);
}
console.debug(`[operationRouter] Routing ${operation} to SaaS backend (not supported locally, but supported on SaaS)`);
return STIRLING_SAAS_BACKEND_API_URL.replace(/\/$/, '');
}
// SaaS supports it - route to SaaS backend
if (!STIRLING_SAAS_BACKEND_API_URL) {
console.error('[operationRouter] VITE_SAAS_BACKEND_API_URL not configured');
throw new Error(
'Cloud processing is required for this tool but VITE_SAAS_BACKEND_API_URL is not configured. ' +
'Please check your environment configuration.'
);
}
console.debug(`[operationRouter] Routing ${operation} to SaaS backend (not supported locally, but supported on SaaS)`);
return STIRLING_SAAS_BACKEND_API_URL.replace(/\/$/, '');
// Supported locally - continue with local backend
console.debug(`[operationRouter] Routing ${operation} to local backend (supported locally)`);
}
// Supported locally - continue with local backend
console.debug(`[operationRouter] Routing ${operation} to local backend (supported locally)`);
}
// Self-hosted fallback: when the remote server is offline, route tool endpoints
@@ -268,11 +303,13 @@ export class OperationRouter {
// NEW: Skip if endpoint will be routed to SaaS due to local unavailability
const mode = await connectionModeService.getCurrentMode();
if (mode === 'saas' && endpoint && this.isToolEndpoint(endpoint)) {
// For UI data endpoints, extract the endpoint name
const backendUrl = tauriBackendService.getBackendUrl();
// Backend not ready — don't skip the readiness check; let it gate the request.
if (!backendUrl || !tauriBackendService.isOnline) return false;
const endpointToCheck = this.extractEndpointName(endpoint);
const supportedLocally = await endpointAvailabilityService.isEndpointSupportedLocally(
endpointToCheck,
tauriBackendService.getBackendUrl()
backendUrl
);
return !supportedLocally; // Skip check if not supported locally
}
@@ -288,7 +325,9 @@ export class OperationRouter {
*/
async willRouteToSaaS(endpoint: string): Promise<boolean> {
const mode = await connectionModeService.getCurrentMode();
if (mode !== 'saas') return false;
// In local mode, show cloud badge for tools not supported locally
// (clicking them will prompt sign-in via onUnavailableClick)
if (mode !== 'saas' && mode !== 'local') return false;
// Team endpoints always go to SaaS
if (this.isSaaSBackendEndpoint(endpoint)) return true;
@@ -104,7 +104,8 @@ class SelfHostedServerMonitor {
connectTimeout: REQUEST_TIMEOUT_MS,
});
if (response.ok) {
// 401/403 means the server is running but requires authentication — treat as online
if (response.ok || response.status === 401 || response.status === 403) {
this.updateState({ status: 'online', isOnline: true });
} else {
this.updateState({ status: 'offline', isOnline: false });
@@ -1,5 +1,6 @@
import { invoke } from '@tauri-apps/api/core';
import { fetch } from '@tauri-apps/plugin-http';
import { alert } from '@app/components/toast';
export type BackendStatus = 'stopped' | 'starting' | 'healthy' | 'unhealthy';
@@ -11,6 +12,12 @@ export class TauriBackendService {
private healthMonitor: Promise<void> | null = null;
private startPromise: Promise<void> | null = null;
private statusListeners = new Set<(status: BackendStatus) => void>();
/** True when we own the backend process (startBackend called, not initializeExternalBackend) */
private isLocalBackend = false;
private recoveryTimer: ReturnType<typeof setTimeout> | null = null;
private isRecovering = false;
private restartAttempts = 0;
private static readonly MAX_RESTART_ATTEMPTS = 3;
static getInstance(): TauriBackendService {
if (!TauriBackendService.instance) {
@@ -52,6 +59,79 @@ export class TauriBackendService {
}
this.backendStatus = status;
this.statusListeners.forEach(listener => listener(status));
if (status === 'healthy') {
// Reset restart counter on successful recovery so future failures get a full retry budget.
this.restartAttempts = 0;
}
// Auto-recovery: when our own backend goes unhealthy, try restarting it
// before reporting as permanently offline.
if (status === 'unhealthy' && this.isLocalBackend && !this.isRecovering) {
this.scheduleRecovery();
}
}
private scheduleRecovery() {
if (this.recoveryTimer) return;
// Give it a 2s grace period — transient failures (e.g. during logout/reload)
// should resolve on their own before we attempt a full restart.
this.recoveryTimer = setTimeout(() => {
this.recoveryTimer = null;
if (this.backendStatus !== 'unhealthy') return; // Recovered on its own
void this.attemptRestart();
}, 2000);
}
async attemptRestart(): Promise<void> {
if (this.isRecovering) return;
if (this.restartAttempts >= TauriBackendService.MAX_RESTART_ATTEMPTS) {
console.error(`[TauriBackendService] Backend failed after ${TauriBackendService.MAX_RESTART_ATTEMPTS} restart attempts, giving up.`);
alert({
alertType: 'error',
title: 'Backend failed to restart',
body: 'The local backend could not be recovered. Please restart the app.',
isPersistentPopup: true,
});
return;
}
this.restartAttempts++;
console.log(`[TauriBackendService] Backend unhealthy, attempting restart (${this.restartAttempts}/${TauriBackendService.MAX_RESTART_ATTEMPTS})...`);
alert({
alertType: 'warning',
title: 'Backend stopped unexpectedly',
body: `Attempting to restart... (${this.restartAttempts}/${TauriBackendService.MAX_RESTART_ATTEMPTS})`,
durationMs: 5000,
});
this.isRecovering = true;
// Reset started flag so startBackend() will run again
this.backendStarted = false;
this.startPromise = null;
this.setStatus('starting');
try {
await this.startBackend();
this.restartAttempts = 0; // Reset on successful restart
this.isRecovering = false;
console.log('[TauriBackendService] Backend restarted successfully.');
alert({
alertType: 'success',
title: 'Backend restarted',
body: 'The local backend is back online.',
durationMs: 4000,
});
} catch (err) {
console.error('[TauriBackendService] Restart failed:', err);
// Set isRecovering = false BEFORE setStatus to prevent re-triggering scheduleRecovery
// if the max attempts check above doesn't catch it next time.
this.isRecovering = false;
if (this.restartAttempts < TauriBackendService.MAX_RESTART_ATTEMPTS) {
this.setStatus('unhealthy'); // Will trigger another scheduleRecovery
} else {
// Don't call setStatus('unhealthy') — the status is already unhealthy and calling it
// again would bypass the dedup check and re-trigger scheduleRecovery.
console.error('[TauriBackendService] Max restart attempts reached, backend is permanently unhealthy.');
}
}
}
/**
@@ -80,6 +160,8 @@ export class TauriBackendService {
return;
}
this.isLocalBackend = true; // We own this backend process
if (this.startPromise) {
return this.startPromise;
}
@@ -190,6 +272,13 @@ export class TauriBackendService {
reset(): void {
this.backendStarted = false;
this.backendPort = null;
this.isLocalBackend = false;
this.isRecovering = false;
this.restartAttempts = 0;
if (this.recoveryTimer) {
clearTimeout(this.recoveryTimer);
this.recoveryTimer = null;
}
this.setStatus('stopped');
this.healthMonitor = null;
this.startPromise = null;