mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
Extensions Modal Improvements (#4293)
This commit is contained in:
+17
-139
@@ -2,9 +2,9 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import { IpcRendererEvent } from 'electron';
|
||||
import { HashRouter, Routes, Route, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { ErrorUI } from './components/ErrorBoundary';
|
||||
import { ConfirmationModal } from './components/ui/ConfirmationModal';
|
||||
import { ExtensionInstallModal } from './components/modals/ExtensionInstallModal';
|
||||
import { useExtensionInstallModal } from './hooks/useExtensionInstallModal';
|
||||
import { ToastContainer } from 'react-toastify';
|
||||
import { extractExtensionName } from './components/settings/extensions/utils';
|
||||
import { GoosehintsModal } from './components/GoosehintsModal';
|
||||
import AnnouncementModal from './components/AnnouncementModal';
|
||||
import { generateSessionId } from './sessions';
|
||||
@@ -25,7 +25,6 @@ import { DraftProvider } from './contexts/DraftContext';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import { useConfig } from './components/ConfigContext';
|
||||
import { ModelAndProviderProvider } from './components/ModelAndProviderContext';
|
||||
import { addExtensionFromDeepLink as addExtensionFromDeepLinkV2 } from './components/settings/extensions';
|
||||
import PermissionSettingsView from './components/settings/permission/PermissionSetting';
|
||||
|
||||
import { type SessionDetails } from './sessions';
|
||||
@@ -352,11 +351,6 @@ const ExtensionsRoute = () => {
|
||||
|
||||
export default function App() {
|
||||
const [fatalError, setFatalError] = useState<string | null>(null);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [pendingLink, setPendingLink] = useState<string | null>(null);
|
||||
const [modalMessage, setModalMessage] = useState<string>('');
|
||||
const [extensionConfirmLabel, setExtensionConfirmLabel] = useState<string>('');
|
||||
const [extensionConfirmTitle, setExtensionConfirmTitle] = useState<string>('');
|
||||
const [isLoadingSession, setIsLoadingSession] = useState(false);
|
||||
const [isGoosehintsModalOpen, setIsGoosehintsModalOpen] = useState(false);
|
||||
const [agentWaitingMessage, setAgentWaitingMessage] = useState<string | null>(null);
|
||||
@@ -372,6 +366,8 @@ export default function App() {
|
||||
|
||||
const { getExtensions, addExtension, read } = useConfig();
|
||||
const initAttemptedRef = useRef(false);
|
||||
const { modalState, modalConfig, dismissModal, confirmInstall } =
|
||||
useExtensionInstallModal(addExtension);
|
||||
|
||||
// Create a setView function for useChat hook - we'll use window.history instead of navigate
|
||||
const setView = (view: View, viewOptions: ViewOptions = {}) => {
|
||||
@@ -422,18 +418,6 @@ export default function App() {
|
||||
|
||||
const { chat, setChat } = useChat({ setIsLoadingSession, setView, setPairChat });
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (initAttemptedRef.current) {
|
||||
console.log('Initialization already attempted, skipping...');
|
||||
@@ -667,84 +651,6 @@ export default function App() {
|
||||
return () => window.electron.off('set-view', handleSetView);
|
||||
}, []);
|
||||
|
||||
const config = window.electron.getConfig();
|
||||
const STRICT_ALLOWLIST = config.GOOSE_ALLOWLIST_WARNING !== 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: string) =>
|
||||
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;
|
||||
@@ -758,41 +664,15 @@ export default function App() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (pendingLink) {
|
||||
console.log(`Confirming installation of extension from: ${pendingLink}`);
|
||||
setModalVisible(false);
|
||||
try {
|
||||
await addExtensionFromDeepLinkV2(pendingLink, addExtension, (view: string, options) => {
|
||||
console.log('Extension deep link handler called with view:', view, 'options:', options);
|
||||
switch (view) {
|
||||
case 'settings':
|
||||
window.location.hash = '#/extensions';
|
||||
// Store the config for the extensions route
|
||||
window.history.replaceState(options, '', '#/extensions');
|
||||
break;
|
||||
default:
|
||||
window.location.hash = `#/${view}`;
|
||||
}
|
||||
});
|
||||
console.log('Extension installation successful');
|
||||
} catch (error) {
|
||||
console.error('Failed to add extension:', error);
|
||||
} finally {
|
||||
setPendingLink(null);
|
||||
}
|
||||
const handleExtensionConfirm = async () => {
|
||||
const result = await confirmInstall();
|
||||
if (result.success) {
|
||||
console.log('Extension installation completed successfully');
|
||||
} else {
|
||||
console.log('Extension installation blocked by allowlist restrictions');
|
||||
setModalVisible(false);
|
||||
console.error('Extension installation failed:', result.error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
console.log('Cancelled extension installation.');
|
||||
setModalVisible(false);
|
||||
setPendingLink(null);
|
||||
};
|
||||
|
||||
if (fatalError) {
|
||||
return <ErrorUI error={new Error(fatalError)} />;
|
||||
}
|
||||
@@ -824,16 +704,14 @@ export default function App() {
|
||||
closeOnClick
|
||||
pauseOnHover
|
||||
/>
|
||||
{modalVisible && (
|
||||
<ConfirmationModal
|
||||
isOpen={modalVisible}
|
||||
message={modalMessage}
|
||||
confirmLabel={extensionConfirmLabel}
|
||||
title={extensionConfirmTitle}
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
)}
|
||||
<ExtensionInstallModal
|
||||
isOpen={modalState.isOpen}
|
||||
modalType={modalState.modalType}
|
||||
config={modalConfig}
|
||||
onConfirm={handleExtensionConfirm}
|
||||
onCancel={dismissModal}
|
||||
isSubmitting={modalState.isPending}
|
||||
/>
|
||||
<div className="relative w-screen h-screen overflow-hidden bg-background-muted flex flex-col">
|
||||
<div className="titlebar-drag-region" />
|
||||
<Routes>
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '../ui/dialog';
|
||||
import { Button } from '../ui/button';
|
||||
import { ModalType, ExtensionModalConfig } from '../../types/extension';
|
||||
|
||||
interface ExtensionInstallModalProps {
|
||||
isOpen: boolean;
|
||||
modalType: ModalType;
|
||||
config: ExtensionModalConfig | null;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
isSubmitting?: boolean;
|
||||
}
|
||||
|
||||
export function ExtensionInstallModal({
|
||||
isOpen,
|
||||
modalType,
|
||||
config,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
isSubmitting = false,
|
||||
}: ExtensionInstallModalProps) {
|
||||
if (!config) return null;
|
||||
|
||||
const getConfirmButtonVariant = () => {
|
||||
switch (modalType) {
|
||||
case 'blocked':
|
||||
return 'outline';
|
||||
case 'untrusted':
|
||||
return 'destructive';
|
||||
case 'trusted':
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const getTitleClassName = () => {
|
||||
switch (modalType) {
|
||||
case 'blocked':
|
||||
return 'text-red-600 dark:text-red-400';
|
||||
case 'untrusted':
|
||||
return 'text-yellow-600 dark:text-yellow-400';
|
||||
case 'trusted':
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onCancel()}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className={getTitleClassName()}>{config.title}</DialogTitle>
|
||||
<DialogDescription className="whitespace-pre-wrap text-left">
|
||||
{config.message}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter className="pt-4">
|
||||
{config.showSingleButton ? (
|
||||
<Button onClick={onCancel} disabled={isSubmitting} variant={getConfirmButtonVariant()}>
|
||||
{config.confirmLabel}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="outline" onClick={onCancel} disabled={isSubmitting}>
|
||||
{config.cancelLabel}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onConfirm}
|
||||
disabled={isSubmitting}
|
||||
variant={getConfirmButtonVariant()}
|
||||
>
|
||||
{isSubmitting ? 'Installing...' : config.confirmLabel}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useExtensionInstallModal } from './useExtensionInstallModal';
|
||||
import { addExtensionFromDeepLink } from '../components/settings/extensions/deeplink';
|
||||
|
||||
const mockElectron = {
|
||||
getConfig: vi.fn(),
|
||||
getAllowedExtensions: vi.fn(),
|
||||
logInfo: vi.fn(),
|
||||
processExtensionLink: vi.fn(),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock('../components/settings/extensions/utils', () => ({
|
||||
extractExtensionName: vi.fn((link: string) => {
|
||||
const url = new URL(link);
|
||||
return url.searchParams.get('name') || 'Unknown Extension';
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../components/settings/extensions/deeplink', () => ({
|
||||
addExtensionFromDeepLink: vi.fn(),
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
value: {
|
||||
electron: mockElectron,
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
|
||||
mockElectron.getConfig.mockReturnValue({
|
||||
GOOSE_ALLOWLIST_WARNING: false,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('useExtensionInstallModal', () => {
|
||||
const mockAddExtension = vi.fn();
|
||||
|
||||
describe('Initial State', () => {
|
||||
it('should initialize with correct default state', () => {
|
||||
const { result } = renderHook(() => useExtensionInstallModal(mockAddExtension));
|
||||
|
||||
expect(result.current.modalState).toEqual({
|
||||
isOpen: false,
|
||||
modalType: 'trusted',
|
||||
extensionInfo: null,
|
||||
isPending: false,
|
||||
error: null,
|
||||
});
|
||||
expect(result.current.modalConfig).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Extension Request Handling', () => {
|
||||
it('should handle trusted extension (default behaviour, no allowlist)', async () => {
|
||||
mockElectron.getAllowedExtensions.mockResolvedValue([]);
|
||||
|
||||
const { result } = renderHook(() => useExtensionInstallModal(mockAddExtension));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleExtensionRequest(
|
||||
'goose://extension?cmd=npx&arg=test-extension&name=TestExt'
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.current.modalState.isOpen).toBe(true);
|
||||
expect(result.current.modalState.modalType).toBe('trusted');
|
||||
expect(result.current.modalState.extensionInfo?.name).toBe('TestExt');
|
||||
expect(result.current.modalConfig?.title).toBe('Confirm Extension Installation');
|
||||
});
|
||||
|
||||
it('should handle trusted extension (from allowlist)', async () => {
|
||||
mockElectron.getAllowedExtensions.mockResolvedValue(['npx test-extension']);
|
||||
|
||||
const { result } = renderHook(() => useExtensionInstallModal(mockAddExtension));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleExtensionRequest(
|
||||
'goose://extension?cmd=npx&arg=test-extension&name=AllowedExt'
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.current.modalState.modalType).toBe('trusted');
|
||||
expect(result.current.modalConfig?.title).toBe('Confirm Extension Installation');
|
||||
});
|
||||
|
||||
it('should handle warning mode', async () => {
|
||||
mockElectron.getConfig.mockReturnValue({
|
||||
GOOSE_ALLOWLIST_WARNING: true,
|
||||
});
|
||||
|
||||
mockElectron.getAllowedExtensions.mockResolvedValue(['uvx allowed-package']);
|
||||
|
||||
const { result } = renderHook(() => useExtensionInstallModal(mockAddExtension));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleExtensionRequest(
|
||||
'goose://extension?cmd=npx&arg=untrusted-extension&name=UntrustedExt'
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.current.modalState.modalType).toBe('untrusted');
|
||||
expect(result.current.modalConfig?.title).toBe('Install Untrusted Extension?');
|
||||
expect(result.current.modalConfig?.confirmLabel).toBe('Install Anyway');
|
||||
expect(result.current.modalConfig?.showSingleButton).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle blocked extension', async () => {
|
||||
mockElectron.getAllowedExtensions.mockResolvedValue(['uvx allowed-package']);
|
||||
|
||||
const { result } = renderHook(() => useExtensionInstallModal(mockAddExtension));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleExtensionRequest(
|
||||
'goose://extension?cmd=npx&arg=blocked-extension&name=BlockedExt'
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.current.modalState.modalType).toBe('blocked');
|
||||
expect(result.current.modalConfig?.title).toBe('Extension Installation Blocked');
|
||||
expect(result.current.modalConfig?.confirmLabel).toBe('OK');
|
||||
expect(result.current.modalConfig?.showSingleButton).toBe(true);
|
||||
expect(result.current.modalConfig?.isBlocked).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Modal Actions', () => {
|
||||
it('should dismiss modal correctly', async () => {
|
||||
const { result } = renderHook(() => useExtensionInstallModal(mockAddExtension));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleExtensionRequest('goose://extension?cmd=npx&arg=test&name=Test');
|
||||
});
|
||||
|
||||
expect(result.current.modalState.isOpen).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current.dismissModal();
|
||||
});
|
||||
|
||||
expect(result.current.modalState.isOpen).toBe(false);
|
||||
expect(result.current.modalState.extensionInfo).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle successful extension installation', async () => {
|
||||
vi.mocked(addExtensionFromDeepLink).mockResolvedValue(undefined);
|
||||
mockElectron.getAllowedExtensions.mockResolvedValue([]);
|
||||
|
||||
const { result } = renderHook(() => useExtensionInstallModal(mockAddExtension));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleExtensionRequest('goose://extension?cmd=npx&arg=test&name=Test');
|
||||
});
|
||||
|
||||
let installResult;
|
||||
await act(async () => {
|
||||
installResult = await result.current.confirmInstall();
|
||||
});
|
||||
|
||||
expect(installResult).toEqual({ success: true });
|
||||
expect(addExtensionFromDeepLink).toHaveBeenCalledWith(
|
||||
'goose://extension?cmd=npx&arg=test&name=Test',
|
||||
mockAddExtension,
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(result.current.modalState.isOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle failed extension installation', async () => {
|
||||
const error = new Error('Installation failed');
|
||||
vi.mocked(addExtensionFromDeepLink).mockRejectedValue(error);
|
||||
mockElectron.getAllowedExtensions.mockResolvedValue([]);
|
||||
|
||||
const { result } = renderHook(() => useExtensionInstallModal(mockAddExtension));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleExtensionRequest('goose://extension?cmd=npx&arg=test&name=Test');
|
||||
});
|
||||
|
||||
let installResult;
|
||||
await act(async () => {
|
||||
installResult = await result.current.confirmInstall();
|
||||
});
|
||||
|
||||
expect(installResult).toEqual({
|
||||
success: false,
|
||||
error: 'Installation failed',
|
||||
});
|
||||
expect(result.current.modalState.error).toBe('Installation failed');
|
||||
});
|
||||
|
||||
it('should not install blocked extensions', async () => {
|
||||
mockElectron.getAllowedExtensions.mockResolvedValue(['uvx allowed-package']);
|
||||
|
||||
const { result } = renderHook(() => useExtensionInstallModal(mockAddExtension));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleExtensionRequest(
|
||||
'goose://extension?cmd=npx&arg=blocked&name=Blocked'
|
||||
);
|
||||
});
|
||||
|
||||
expect(result.current.modalState.modalType).toBe('blocked');
|
||||
|
||||
let installResult;
|
||||
await act(async () => {
|
||||
installResult = await result.current.confirmInstall();
|
||||
});
|
||||
|
||||
expect(installResult).toEqual({
|
||||
success: false,
|
||||
error: 'No pending extension to install',
|
||||
});
|
||||
expect(addExtensionFromDeepLink).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { IpcRendererEvent } from 'electron';
|
||||
import { extractExtensionName } from '../components/settings/extensions/utils';
|
||||
import { addExtensionFromDeepLink } from '../components/settings/extensions/deeplink';
|
||||
import type { ExtensionConfig } from '../api/types.gen';
|
||||
import {
|
||||
ExtensionModalState,
|
||||
ExtensionInfo,
|
||||
ModalType,
|
||||
ExtensionModalConfig,
|
||||
ExtensionInstallResult,
|
||||
} from '../types/extension';
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
export const useExtensionInstallModal = (
|
||||
addExtension?: (name: string, config: ExtensionConfig, enabled: boolean) => Promise<void>
|
||||
) => {
|
||||
const [modalState, setModalState] = useState<ExtensionModalState>({
|
||||
isOpen: false,
|
||||
modalType: 'trusted',
|
||||
extensionInfo: null,
|
||||
isPending: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const [pendingLink, setPendingLink] = useState<string | null>(null);
|
||||
|
||||
const determineModalType = async (
|
||||
command: string,
|
||||
_remoteUrl: string | null
|
||||
): Promise<ModalType> => {
|
||||
try {
|
||||
const config = window.electron.getConfig();
|
||||
const ALLOWLIST_WARNING_MODE = config.GOOSE_ALLOWLIST_WARNING === true;
|
||||
|
||||
// If warning mode is enabled, always show warning but allow installation
|
||||
if (ALLOWLIST_WARNING_MODE) {
|
||||
return 'untrusted';
|
||||
}
|
||||
|
||||
const allowedCommands = await window.electron.getAllowedExtensions();
|
||||
|
||||
// If no allowlist configured
|
||||
if (!allowedCommands || allowedCommands.length === 0) {
|
||||
return 'trusted';
|
||||
}
|
||||
|
||||
const isCommandAllowed = allowedCommands.some((allowedCmd: string) =>
|
||||
command.startsWith(allowedCmd)
|
||||
);
|
||||
|
||||
return isCommandAllowed ? 'trusted' : 'blocked';
|
||||
} catch (error) {
|
||||
console.error('Error checking allowlist:', error);
|
||||
return 'trusted';
|
||||
}
|
||||
};
|
||||
|
||||
const generateModalConfig = (
|
||||
modalType: ModalType,
|
||||
extensionInfo: ExtensionInfo
|
||||
): ExtensionModalConfig => {
|
||||
const { name, command, remoteUrl } = extensionInfo;
|
||||
|
||||
switch (modalType) {
|
||||
case 'blocked':
|
||||
return {
|
||||
title: 'Extension Installation Blocked',
|
||||
message: `\n\nThis extension command is not in the allowed list and its installation is blocked.\n\nExtension: ${name}\nCommand: ${command || remoteUrl}\n\nContact your administrator to request approval for this extension.`,
|
||||
confirmLabel: 'OK',
|
||||
cancelLabel: '',
|
||||
showSingleButton: true,
|
||||
isBlocked: true,
|
||||
};
|
||||
|
||||
case 'untrusted': {
|
||||
const securityMessage = `\n\nThis extension command is not in the allowed list and will be able to access your conversations and provide additional functionality.\n\nInstalling extensions from untrusted sources may pose security risks.`;
|
||||
|
||||
return {
|
||||
title: 'Install Untrusted Extension?',
|
||||
message: `${securityMessage}\n\nExtension: ${name}\n${remoteUrl ? `URL: ${remoteUrl}` : `Command: ${command}`}\n\nContact your administrator if you are unsure about this.`,
|
||||
confirmLabel: 'Install Anyway',
|
||||
cancelLabel: 'Cancel',
|
||||
showSingleButton: false,
|
||||
isBlocked: false,
|
||||
};
|
||||
}
|
||||
|
||||
case 'trusted':
|
||||
default:
|
||||
return {
|
||||
title: 'Confirm Extension Installation',
|
||||
message: `Are you sure you want to install the ${name} extension?\n\nCommand: ${command || remoteUrl}`,
|
||||
confirmLabel: 'Yes',
|
||||
cancelLabel: 'No',
|
||||
showSingleButton: false,
|
||||
isBlocked: false,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const handleExtensionRequest = useCallback(async (link: string): Promise<void> => {
|
||||
try {
|
||||
console.log(`Processing extension request: ${link}`);
|
||||
|
||||
const command = extractCommand(link);
|
||||
const remoteUrl = extractRemoteUrl(link);
|
||||
const extName = extractExtensionName(link);
|
||||
|
||||
const extensionInfo: ExtensionInfo = {
|
||||
name: extName,
|
||||
command: command,
|
||||
remoteUrl: remoteUrl || undefined,
|
||||
link: link,
|
||||
};
|
||||
|
||||
const modalType = await determineModalType(command, remoteUrl);
|
||||
|
||||
setModalState({
|
||||
isOpen: true,
|
||||
modalType,
|
||||
extensionInfo,
|
||||
isPending: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
setPendingLink(modalType === 'blocked' ? null : link);
|
||||
|
||||
window.electron.logInfo(`Extension modal opened: ${modalType} for ${extName}`);
|
||||
} catch (error) {
|
||||
console.error('Error processing extension request:', error);
|
||||
setModalState((prev) => ({
|
||||
...prev,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
}));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const dismissModal = useCallback(() => {
|
||||
setModalState({
|
||||
isOpen: false,
|
||||
modalType: 'trusted',
|
||||
extensionInfo: null,
|
||||
isPending: false,
|
||||
error: null,
|
||||
});
|
||||
setPendingLink(null);
|
||||
}, []);
|
||||
|
||||
const confirmInstall = useCallback(async (): Promise<ExtensionInstallResult> => {
|
||||
if (!pendingLink) {
|
||||
return { success: false, error: 'No pending extension to install' };
|
||||
}
|
||||
|
||||
setModalState((prev) => ({ ...prev, isPending: true }));
|
||||
|
||||
try {
|
||||
console.log(`Confirming installation of extension from: ${pendingLink}`);
|
||||
|
||||
dismissModal();
|
||||
|
||||
if (addExtension) {
|
||||
await addExtensionFromDeepLink(pendingLink, addExtension, () => {
|
||||
console.log('Extension installation completed, navigating to extensions');
|
||||
});
|
||||
} else {
|
||||
throw new Error('addExtension function not provided to hook');
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Installation failed';
|
||||
console.error('Extension installation failed:', error);
|
||||
|
||||
setModalState((prev) => ({
|
||||
...prev,
|
||||
error: errorMessage,
|
||||
isPending: false,
|
||||
}));
|
||||
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
}, [pendingLink, dismissModal, addExtension]);
|
||||
|
||||
const getModalConfig = (): ExtensionModalConfig | null => {
|
||||
if (!modalState.extensionInfo) return null;
|
||||
return generateModalConfig(modalState.modalType, modalState.extensionInfo);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
console.log('Setting up extension install modal handler');
|
||||
|
||||
const handleAddExtension = async (_event: IpcRendererEvent, ...args: unknown[]) => {
|
||||
const link = args[0] as string;
|
||||
await handleExtensionRequest(link);
|
||||
};
|
||||
|
||||
window.electron.on('add-extension', handleAddExtension);
|
||||
|
||||
return () => {
|
||||
window.electron.off('add-extension', handleAddExtension);
|
||||
};
|
||||
}, [handleExtensionRequest]);
|
||||
|
||||
return {
|
||||
modalState,
|
||||
modalConfig: getModalConfig(),
|
||||
handleExtensionRequest,
|
||||
dismissModal,
|
||||
confirmInstall,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
export type ModalType = 'blocked' | 'untrusted' | 'trusted';
|
||||
|
||||
export interface ExtensionInfo {
|
||||
name: string;
|
||||
command?: string;
|
||||
remoteUrl?: string;
|
||||
link: string;
|
||||
}
|
||||
|
||||
export interface ExtensionModalState {
|
||||
isOpen: boolean;
|
||||
modalType: ModalType;
|
||||
extensionInfo: ExtensionInfo | null;
|
||||
isPending: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface ExtensionModalConfig {
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel: string;
|
||||
cancelLabel: string;
|
||||
showSingleButton: boolean;
|
||||
isBlocked: boolean;
|
||||
}
|
||||
|
||||
export interface ExtensionInstallResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
Reference in New Issue
Block a user