import { useEffect, useRef, useState } from 'react'; import { IpcRendererEvent } from 'electron'; import { openSharedSessionFromDeepLink, type SessionLinksViewOptions } from './sessionLinks'; import { type SharedSessionDetails } from './sharedSessions'; import { initializeSystem } from './utils/providerUtils'; import { initializeCostDatabase } from './utils/costDatabase'; import { ErrorUI } from './components/ErrorBoundary'; import { ConfirmationModal } from './components/ui/ConfirmationModal'; import { ToastContainer } from 'react-toastify'; import { toastService } from './toasts'; import { extractExtensionName } from './components/settings/extensions/utils'; import { GoosehintsModal } from './components/GoosehintsModal'; import { type ExtensionConfig } from './extensions'; import { type Recipe } from './recipe'; import AnnouncementModal from './components/AnnouncementModal'; import ChatView from './components/ChatView'; import SuspenseLoader from './suspense-loader'; import SettingsView, { SettingsViewOptions } from './components/settings/SettingsView'; import SessionsView from './components/sessions/SessionsView'; import SharedSessionView from './components/sessions/SharedSessionView'; import SchedulesView from './components/schedule/SchedulesView'; import ProviderSettings from './components/settings/providers/ProviderSettingsPage'; import RecipeEditor from './components/RecipeEditor'; import RecipesView from './components/RecipesView'; import { useChat } from './hooks/useChat'; import 'react-toastify/dist/ReactToastify.css'; import { useConfig, MalformedConfigError } from './components/ConfigContext'; import { ModelAndProviderProvider } from './components/ModelAndProviderContext'; import { addExtensionFromDeepLink as addExtensionFromDeepLinkV2 } from './components/settings/extensions'; import { backupConfig, initConfig, readAllConfig, recoverConfig, validateConfig, } from './api/sdk.gen'; import PermissionSettingsView from './components/settings/permission/PermissionSetting'; import { type SessionDetails } from './sessions'; export type View = | 'welcome' | 'chat' | 'settings' | 'moreModels' | 'configureProviders' | 'configPage' | 'ConfigureProviders' | 'settingsV2' | 'sessions' | 'schedules' | 'sharedSession' | 'loading' | 'recipeEditor' | 'recipes' | 'permission'; export type ViewOptions = { // Settings view options extensionId?: string; showEnvVars?: boolean; deepLinkConfig?: ExtensionConfig; // Session view options resumedSession?: SessionDetails; sessionDetails?: SessionDetails; error?: string; shareToken?: string; baseUrl?: string; // Recipe editor options config?: unknown; // Permission view options parentView?: View; // Generic options [key: string]: unknown; }; export type ViewConfig = { view: View; viewOptions?: ViewOptions; }; const getInitialView = (): ViewConfig => { const urlParams = new URLSearchParams(window.location.search); const viewFromUrl = urlParams.get('view'); const windowConfig = window.electron.getConfig(); if (viewFromUrl === 'recipeEditor' && windowConfig?.recipeConfig) { return { view: 'recipeEditor', viewOptions: { config: windowConfig.recipeConfig, }, }; } if (viewFromUrl) { return { view: viewFromUrl as View, viewOptions: {}, }; } return { view: 'loading', viewOptions: {}, }; }; export default function App() { const [fatalError, setFatalError] = useState(null); const [modalVisible, setModalVisible] = useState(false); const [pendingLink, setPendingLink] = useState(null); const [modalMessage, setModalMessage] = useState(''); const [extensionConfirmLabel, setExtensionConfirmLabel] = useState(''); const [extensionConfirmTitle, setExtensionConfirmTitle] = useState(''); const [{ view, viewOptions }, setInternalView] = useState(getInitialView()); const { getExtensions, addExtension, read } = useConfig(); const initAttemptedRef = useRef(false); function extractCommand(link: string): string { const url = new URL(link); const cmd = url.searchParams.get('cmd') || 'Unknown Command'; const args = url.searchParams.getAll('arg').map(decodeURIComponent); return `${cmd} ${args.join(' ')}`.trim(); } function extractRemoteUrl(link: string): string | null { const url = new URL(link); return url.searchParams.get('url'); } const setView = (view: View, viewOptions: ViewOptions = {}) => { console.log(`Setting view to: ${view}`, viewOptions); setInternalView({ view, viewOptions }); }; useEffect(() => { if (initAttemptedRef.current) { console.log('Initialization already attempted, skipping...'); return; } initAttemptedRef.current = true; console.log(`Initializing app with settings v2`); const urlParams = new URLSearchParams(window.location.search); const viewType = urlParams.get('view'); const recipeConfig = window.appConfig.get('recipeConfig'); if (viewType) { if (viewType === 'recipeEditor' && recipeConfig) { console.log('Setting view to recipeEditor with config:', recipeConfig); setView('recipeEditor', { config: recipeConfig }); } else { setView(viewType as View); } return; } const initializeApp = async () => { try { // Initialize cost database early to pre-load pricing data initializeCostDatabase().catch((error) => { console.error('Failed to initialize cost database:', error); }); await initConfig(); try { await readAllConfig({ throwOnError: true }); } catch (error) { const configVersion = localStorage.getItem('configVersion'); const shouldMigrateExtensions = !configVersion || parseInt(configVersion, 10) < 3; if (shouldMigrateExtensions) { await backupConfig({ throwOnError: true }); await initConfig(); } else { // Config appears corrupted, try recovery console.warn('Config file appears corrupted, attempting recovery...'); try { // First try to validate the config try { await validateConfig({ throwOnError: true }); // Config is valid but readAllConfig failed for another reason throw new Error('Unable to read config file, it may be malformed'); } catch (validateError) { console.log('Config validation failed, attempting recovery...'); // Try to recover the config try { const recoveryResult = await recoverConfig({ throwOnError: true }); console.log('Config recovery result:', recoveryResult); // Try to read config again after recovery try { await readAllConfig({ throwOnError: true }); console.log('Config successfully recovered and loaded'); } catch (retryError) { console.warn('Config still corrupted after recovery, reinitializing...'); await initConfig(); } } catch (recoverError) { console.warn('Config recovery failed, reinitializing...'); await initConfig(); } } } catch (recoveryError) { console.error('Config recovery process failed:', recoveryError); throw new Error('Unable to read config file, it may be malformed'); } } } if (recipeConfig === null) { setFatalError('Cannot read recipe config. Please check the deeplink and try again.'); return; } const config = window.electron.getConfig(); const provider = (await read('GOOSE_PROVIDER', false)) ?? config.GOOSE_DEFAULT_PROVIDER; const model = (await read('GOOSE_MODEL', false)) ?? config.GOOSE_DEFAULT_MODEL; if (provider && model) { setView('chat'); try { await initializeSystem(provider as string, model as string, { getExtensions, addExtension, }); } catch (error) { console.error('Error in initialization:', error); if (error instanceof MalformedConfigError) { throw error; } setView('welcome'); } } else { console.log('Missing required configuration, showing onboarding'); setView('welcome'); } } catch (error) { setFatalError( `Initialization failed: ${error instanceof Error ? error.message : 'Unknown error'}` ); setView('welcome'); } toastService.configure({ silent: false }); }; initializeApp().catch((error) => { console.error('Unhandled error in initialization:', error); setFatalError(`${error instanceof Error ? error.message : 'Unknown error'}`); }); }, [read, getExtensions, addExtension]); const [isGoosehintsModalOpen, setIsGoosehintsModalOpen] = useState(false); const [isLoadingSession, setIsLoadingSession] = useState(false); const [sharedSessionError, setSharedSessionError] = useState(null); const [isLoadingSharedSession, setIsLoadingSharedSession] = useState(false); const { chat, setChat } = useChat({ setView, setIsLoadingSession }); useEffect(() => { console.log('Sending reactReady signal to Electron'); try { window.electron.reactReady(); } catch (error) { console.error('Error sending reactReady:', error); setFatalError( `React ready notification failed: ${error instanceof Error ? error.message : 'Unknown error'}` ); } }, []); useEffect(() => { const handleOpenSharedSession = async (_event: IpcRendererEvent, ...args: unknown[]) => { const link = args[0] as string; window.electron.logInfo(`Opening shared session from deep link ${link}`); setIsLoadingSharedSession(true); setSharedSessionError(null); try { await openSharedSessionFromDeepLink( link, (view: View, options?: SessionLinksViewOptions) => { setView(view, options as ViewOptions); } ); } catch (error) { console.error('Unexpected error opening shared session:', error); setView('sessions'); } finally { setIsLoadingSharedSession(false); } }; window.electron.on('open-shared-session', handleOpenSharedSession); return () => { window.electron.off('open-shared-session', handleOpenSharedSession); }; }, []); useEffect(() => { console.log('Setting up keyboard shortcuts'); const handleKeyDown = (event: KeyboardEvent) => { const isMac = window.electron.platform === 'darwin'; if ((isMac ? event.metaKey : event.ctrlKey) && event.key === 'n') { event.preventDefault(); try { const workingDir = window.appConfig.get('GOOSE_WORKING_DIR'); console.log(`Creating new chat window with working dir: ${workingDir}`); window.electron.createChatWindow(undefined, workingDir as string); } catch (error) { console.error('Error creating new window:', error); } } }; window.addEventListener('keydown', handleKeyDown); return () => { window.removeEventListener('keydown', handleKeyDown); }; }, []); useEffect(() => { console.log('Setting up fatal error handler'); const handleFatalError = (_event: IpcRendererEvent, ...args: unknown[]) => { const errorMessage = args[0] as string; console.error('Encountered a fatal error: ', errorMessage); console.error('Current view:', view); console.error('Is loading session:', isLoadingSession); setFatalError(errorMessage); }; window.electron.on('fatal-error', handleFatalError); return () => { window.electron.off('fatal-error', handleFatalError); }; }, [view, isLoadingSession]); useEffect(() => { console.log('Setting up view change handler'); const handleSetView = (_event: IpcRendererEvent, ...args: unknown[]) => { const newView = args[0] as View; const section = args[1] as string | undefined; console.log( `Received view change request to: ${newView}${section ? `, section: ${section}` : ''}` ); if (section && newView === 'settings') { setView(newView, { section }); } else { setView(newView); } }; const urlParams = new URLSearchParams(window.location.search); const viewFromUrl = urlParams.get('view'); if (viewFromUrl) { const windowConfig = window.electron.getConfig(); if (viewFromUrl === 'recipeEditor') { const initialViewOptions = { recipeConfig: windowConfig?.recipeConfig, view: viewFromUrl, }; setView(viewFromUrl, initialViewOptions); } else { setView(viewFromUrl as View); } } window.electron.on('set-view', handleSetView); return () => window.electron.off('set-view', handleSetView); }, []); useEffect(() => { console.log(`View changed to: ${view}`); if (view !== 'chat' && view !== 'recipeEditor') { console.log('Not in chat view, clearing loading session state'); setIsLoadingSession(false); } }, [view]); const config = window.electron.getConfig(); const STRICT_ALLOWLIST = config.GOOSE_ALLOWLIST_WARNING === true ? false : true; useEffect(() => { console.log('Setting up extension handler'); const handleAddExtension = async (_event: IpcRendererEvent, ...args: unknown[]) => { const link = args[0] as string; try { console.log(`Received add-extension event with link: ${link}`); const command = extractCommand(link); const remoteUrl = extractRemoteUrl(link); const extName = extractExtensionName(link); window.electron.logInfo(`Adding extension from deep link ${link}`); setPendingLink(link); let warningMessage = ''; let label = 'OK'; let title = 'Confirm Extension Installation'; let isBlocked = false; let useDetailedMessage = false; if (remoteUrl) { useDetailedMessage = true; } else { try { const allowedCommands = await window.electron.getAllowedExtensions(); if (allowedCommands && allowedCommands.length > 0) { const isCommandAllowed = allowedCommands.some((allowedCmd) => command.startsWith(allowedCmd) ); if (!isCommandAllowed) { useDetailedMessage = true; title = '⛔️ Untrusted Extension ⛔️'; if (STRICT_ALLOWLIST) { isBlocked = true; label = 'Extension Blocked'; warningMessage = '\n\n⛔️ BLOCKED: This extension command is not in the allowed list. ' + 'Installation is blocked by your administrator. ' + 'Please contact your administrator if you need this extension.'; } else { label = 'Override and install'; warningMessage = '\n\n⚠️ WARNING: This extension command is not in the allowed list. ' + 'Installing extensions from untrusted sources may pose security risks. ' + 'Please contact an admin if you are unsure or want to allow this extension.'; } } } } catch (error) { console.error('Error checking allowlist:', error); } } if (useDetailedMessage) { const detailedMessage = remoteUrl ? `You are about to install the ${extName} extension which connects to:\n\n${remoteUrl}\n\nThis extension will be able to access your conversations and provide additional functionality.` : `You are about to install the ${extName} extension which runs the command:\n\n${command}\n\nThis extension will be able to access your conversations and provide additional functionality.`; setModalMessage(`${detailedMessage}${warningMessage}`); } else { const messageDetails = `Command: ${command}`; setModalMessage( `Are you sure you want to install the ${extName} extension?\n\n${messageDetails}` ); } setExtensionConfirmLabel(label); setExtensionConfirmTitle(title); if (isBlocked) { setPendingLink(null); } setModalVisible(true); } catch (error) { console.error('Error handling add-extension event:', error); } }; window.electron.on('add-extension', handleAddExtension); return () => { window.electron.off('add-extension', handleAddExtension); }; }, [STRICT_ALLOWLIST]); useEffect(() => { const handleFocusInput = (_event: IpcRendererEvent, ..._args: unknown[]) => { const inputField = document.querySelector('input[type="text"], textarea') as HTMLInputElement; if (inputField) { inputField.focus(); } }; window.electron.on('focus-input', handleFocusInput); return () => { window.electron.off('focus-input', handleFocusInput); }; }, []); const handleConfirm = async () => { if (pendingLink) { console.log(`Confirming installation of extension from: ${pendingLink}`); setModalVisible(false); try { await addExtensionFromDeepLinkV2(pendingLink, addExtension, (view: string, options) => { setView(view as View, options as ViewOptions); }); console.log('Extension installation successful'); } catch (error) { console.error('Failed to add extension:', error); } finally { setPendingLink(null); } } else { console.log('Extension installation blocked by allowlist restrictions'); setModalVisible(false); } }; const handleCancel = () => { console.log('Cancelled extension installation.'); setModalVisible(false); setPendingLink(null); }; if (fatalError) { return ; } if (isLoadingSession) return (
); return ( `relative min-h-16 mb-4 p-2 rounded-lg flex justify-between overflow-hidden cursor-pointer text-textProminentInverse bg-bgStandardInverse dark:bg-bgAppInverse ` } style={{ width: '380px' }} className="mt-6" position="top-right" autoClose={3000} closeOnClick pauseOnHover /> {modalVisible && ( )}
{view === 'loading' && } {view === 'welcome' && ( setView('chat')} isOnboarding={true} /> )} {view === 'settings' && ( { setView('chat'); }} setView={setView} viewOptions={viewOptions as SettingsViewOptions} /> )} {view === 'ConfigureProviders' && ( setView('chat')} isOnboarding={false} /> )} {view === 'chat' && !isLoadingSession && ( )} {view === 'sessions' && } {view === 'schedules' && setView('chat')} />} {view === 'sharedSession' && ( setView('sessions')} onRetry={async () => { if (viewOptions?.shareToken && viewOptions?.baseUrl) { setIsLoadingSharedSession(true); try { await openSharedSessionFromDeepLink( `goose://sessions/${viewOptions.shareToken}`, (view: View, options?: SessionLinksViewOptions) => { setView(view, options as ViewOptions); }, viewOptions.baseUrl ); } catch (error) { console.error('Failed to retry loading shared session:', error); } finally { setIsLoadingSharedSession(false); } } }} /> )} {view === 'recipeEditor' && ( )} {view === 'recipes' && setView('chat')} />} {view === 'permission' && ( setView((viewOptions as { parentView: View }).parentView)} /> )}
{isGoosehintsModalOpen && ( )} ); }