feat: confirmation dialog for deeplinks (#783)
This commit is contained in:
@@ -1,338 +0,0 @@
|
||||
import { getApiUrl, getSecretKey } from './config';
|
||||
import { NavigateFunction } from 'react-router-dom';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
// ExtensionConfig type matching the Rust version
|
||||
export type ExtensionConfig =
|
||||
| {
|
||||
type: 'sse';
|
||||
name: string;
|
||||
uri: string;
|
||||
env_keys?: string[];
|
||||
}
|
||||
| {
|
||||
type: 'stdio';
|
||||
name: string;
|
||||
cmd: string;
|
||||
args: string[];
|
||||
env_keys?: string[];
|
||||
}
|
||||
| {
|
||||
type: 'builtin';
|
||||
name: string;
|
||||
env_keys?: string[];
|
||||
};
|
||||
|
||||
// FullExtensionConfig type matching all the fields that come in deep links and are stored in local storage
|
||||
export type FullExtensionConfig = ExtensionConfig & {
|
||||
id: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export interface ExtensionPayload {
|
||||
name?: string;
|
||||
type?: string;
|
||||
cmd?: string;
|
||||
args?: string[];
|
||||
uri?: string;
|
||||
env_keys?: string[];
|
||||
}
|
||||
|
||||
export const BUILT_IN_EXTENSIONS = [
|
||||
{
|
||||
id: 'developer',
|
||||
name: 'Developer',
|
||||
description: 'General development tools useful for software engineering.',
|
||||
enabled: true,
|
||||
type: 'builtin',
|
||||
env_keys: [],
|
||||
},
|
||||
{
|
||||
id: 'nondeveloper',
|
||||
name: 'Non-Developer',
|
||||
description:
|
||||
"General computer control tools that don't require you to be a developer or engineer.",
|
||||
enabled: false,
|
||||
type: 'builtin',
|
||||
env_keys: [],
|
||||
},
|
||||
{
|
||||
id: 'memory',
|
||||
name: 'Memory',
|
||||
description: 'Teach goose your preferences as you go.',
|
||||
enabled: false,
|
||||
type: 'builtin',
|
||||
env_keys: [],
|
||||
},
|
||||
{
|
||||
id: 'jetbrains',
|
||||
name: 'Jetbrains',
|
||||
description: 'Integration with any Jetbrains IDE',
|
||||
enabled: false,
|
||||
type: 'builtin',
|
||||
env_keys: [],
|
||||
},
|
||||
/* TODO re-enable when we have a smoother auth flow {
|
||||
id: 'google_drive',
|
||||
name: 'Google Drive',
|
||||
description: 'Built-in Google Drive integration for file management and access',
|
||||
enabled: false,
|
||||
type: 'builtin',
|
||||
env_keys: [
|
||||
'GOOGLE_DRIVE_OAUTH_PATH',
|
||||
'GOOGLE_DRIVE_CREDENTIALS_PATH',
|
||||
'GOOGLE_DRIVE_OAUTH_CONFIG',
|
||||
],
|
||||
},*/
|
||||
];
|
||||
|
||||
function sanitizeName(name: string) {
|
||||
return name.toLowerCase().replace(/-/g, '').replace(/_/g, '').replace(/\s/g, '');
|
||||
}
|
||||
|
||||
export async function addExtension(
|
||||
extension: FullExtensionConfig,
|
||||
silent: boolean = false
|
||||
): Promise<Response> {
|
||||
try {
|
||||
// Create the config based on the extension type
|
||||
const config = {
|
||||
type: extension.type,
|
||||
...(extension.type === 'stdio' && {
|
||||
name: sanitizeName(extension.name),
|
||||
cmd: await replaceWithShims(extension.cmd),
|
||||
args: extension.args || [],
|
||||
}),
|
||||
...(extension.type === 'sse' && {
|
||||
name: sanitizeName(extension.name),
|
||||
uri: extension.uri,
|
||||
}),
|
||||
...(extension.type === 'builtin' && {
|
||||
name: sanitizeName(extension.name),
|
||||
}),
|
||||
env_keys: extension.env_keys,
|
||||
};
|
||||
|
||||
const response = await fetch(getApiUrl('/extensions/add'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Secret-Key': getSecretKey(),
|
||||
},
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.error) {
|
||||
if (!silent) {
|
||||
toast.success(`Successfully enabled ${extension.name} extension`);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
const errorMessage = `Error adding ${extension.name} extension ${data.message ? `. ${data.message}` : ''}`;
|
||||
console.error(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
return response;
|
||||
} catch (error) {
|
||||
const errorMessage = `Failed to add ${extension.name} extension: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||
console.error(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeExtension(name: string, silent: boolean = false): Promise<Response> {
|
||||
try {
|
||||
const response = await fetch(getApiUrl('/extensions/remove'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Secret-Key': getSecretKey(),
|
||||
},
|
||||
body: JSON.stringify(sanitizeName(name)),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.error) {
|
||||
if (!silent) {
|
||||
toast.success(`Successfully disabled ${name} extension`);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
const errorMessage = `Error removing ${name} extension${data.message ? `. ${data.message}` : ''}`;
|
||||
console.error(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
return response;
|
||||
} catch (error) {
|
||||
const errorMessage = `Failed to remove ${name} extension: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||
console.error(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Store extension config in user_settings
|
||||
function storeExtensionConfig(config: FullExtensionConfig) {
|
||||
try {
|
||||
const userSettingsStr = localStorage.getItem('user_settings');
|
||||
const userSettings = userSettingsStr
|
||||
? JSON.parse(userSettingsStr)
|
||||
: { models: [], extensions: [] };
|
||||
|
||||
// Check if config already exists (based on cmd for stdio, uri for sse, name for builtin)
|
||||
const extensionExists = userSettings.extensions.some(
|
||||
(extension: { id: string }) => extension.id === config.id
|
||||
);
|
||||
|
||||
if (!extensionExists) {
|
||||
userSettings.extensions.push(config);
|
||||
localStorage.setItem('user_settings', JSON.stringify(userSettings));
|
||||
console.log('Extension config stored successfully in user_settings');
|
||||
} else {
|
||||
console.log('Extension config already exists in user_settings');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error storing extension config:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadAndAddStoredExtensions() {
|
||||
try {
|
||||
const userSettingsStr = localStorage.getItem('user_settings');
|
||||
|
||||
if (userSettingsStr) {
|
||||
const userSettings = JSON.parse(userSettingsStr);
|
||||
const enabledExtensions = userSettings.extensions.filter((ext: any) => ext.enabled);
|
||||
console.log('Adding extensions from localStorage: ', enabledExtensions);
|
||||
for (const ext of enabledExtensions) {
|
||||
await addExtension(ext, true);
|
||||
}
|
||||
} else {
|
||||
console.log('Saving default builtin extensions to localStorage');
|
||||
// TODO - Revisit
|
||||
// @ts-expect-error "we actually do always have all the properties required for builtins, but tsc cannot tell for some reason"
|
||||
BUILT_IN_EXTENSIONS.forEach(async (extension: FullExtensionConfig) => {
|
||||
storeExtensionConfig(extension);
|
||||
if (extension.enabled) {
|
||||
await addExtension(extension, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading and activating extensions from localStorage: ', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Update the path to the binary based on the command
|
||||
export async function replaceWithShims(cmd: string) {
|
||||
const binaryPathMap: Record<string, string> = {
|
||||
goosed: await window.electron.getBinaryPath('goosed'),
|
||||
npx: await window.electron.getBinaryPath('npx'),
|
||||
uvx: await window.electron.getBinaryPath('uvx'),
|
||||
};
|
||||
|
||||
if (binaryPathMap[cmd]) {
|
||||
console.log('--------> Replacing command with shim ------>', cmd, binaryPathMap[cmd]);
|
||||
cmd = binaryPathMap[cmd];
|
||||
}
|
||||
|
||||
return cmd;
|
||||
}
|
||||
|
||||
function envVarsRequired(config: ExtensionConfig) {
|
||||
return config.env_keys?.length > 0;
|
||||
}
|
||||
|
||||
function handleError(message: string, shouldThrow = false): void {
|
||||
toast.error(message);
|
||||
console.error(message);
|
||||
if (shouldThrow) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function addExtensionFromDeepLink(url: string, navigate: NavigateFunction) {
|
||||
if (!url.startsWith('goose://extension')) {
|
||||
handleError(
|
||||
'Failed to install extension: Invalid URL: URL must use the goose://extension scheme'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedUrl = new URL(url);
|
||||
|
||||
if (parsedUrl.protocol !== 'goose:') {
|
||||
handleError(
|
||||
'Failed to install extension: Invalid protocol: URL must use the goose:// scheme',
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
const cmd = parsedUrl.searchParams.get('cmd');
|
||||
if (!cmd) {
|
||||
handleError("Failed to install extension: Missing required 'cmd' parameter in the URL", true);
|
||||
}
|
||||
|
||||
// Validate that the command is one of the allowed commands
|
||||
const allowedCommands = ['npx', 'uvx', 'goosed'];
|
||||
if (!allowedCommands.includes(cmd)) {
|
||||
handleError(
|
||||
`Failed to install extension: Invalid command: ${cmd}. Only ${allowedCommands.join(', ')} are allowed.`,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
// Check for security risk with npx -c command
|
||||
const args = parsedUrl.searchParams.getAll('arg');
|
||||
if (cmd === 'npx' && args.includes('-c')) {
|
||||
handleError(
|
||||
'Failed to install extension: npx with -c argument can lead to code injection',
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
const envList = parsedUrl.searchParams.getAll('env');
|
||||
const id = parsedUrl.searchParams.get('id');
|
||||
const name = parsedUrl.searchParams.get('name');
|
||||
const description = parsedUrl.searchParams.get('description');
|
||||
|
||||
// split env based on delimiter to a map
|
||||
const envs = envList.reduce(
|
||||
(acc, env) => {
|
||||
const [key, value] = env.split('=');
|
||||
acc[key] = value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
);
|
||||
|
||||
// Create a ExtensionConfig from the URL parameters
|
||||
const config: FullExtensionConfig = {
|
||||
id,
|
||||
name,
|
||||
type: 'stdio',
|
||||
cmd,
|
||||
args,
|
||||
description,
|
||||
enabled: true,
|
||||
env_keys: Object.keys(envs).length > 0 ? Object.keys(envs) : [],
|
||||
};
|
||||
|
||||
// Store the extension config regardless of env vars status
|
||||
storeExtensionConfig(config);
|
||||
|
||||
// Check if extension requires env vars and go to settings if so
|
||||
if (envVarsRequired(config)) {
|
||||
console.log('Environment variables required, redirecting to settings');
|
||||
navigate(`/settings?extensionId=${config.id}&showEnvVars=true`);
|
||||
return;
|
||||
}
|
||||
|
||||
// If no env vars are required, proceed with extending Goosed
|
||||
await addExtension(config);
|
||||
}
|
||||
Reference in New Issue
Block a user