Vibe mcp apps (#6569)
Co-authored-by: Douwe Osinga <douwe@squareup.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Michael Neale <michael.neale@gmail.com>
This commit is contained in:
@@ -52,6 +52,7 @@ import { getInitialWorkingDir } from './utils/workingDir';
|
||||
import { usePageViewTracking } from './hooks/useAnalytics';
|
||||
import { trackOnboardingCompleted, trackErrorWithContext } from './utils/analytics';
|
||||
import { AppEvents } from './constants/events';
|
||||
import { registerPlatformEventHandlers } from './utils/platform_events';
|
||||
|
||||
function PageViewTracker() {
|
||||
usePageViewTracking();
|
||||
@@ -603,6 +604,11 @@ export function AppInner() {
|
||||
};
|
||||
}, [navigate]);
|
||||
|
||||
// Register platform event handlers for app lifecycle management
|
||||
useEffect(() => {
|
||||
return registerPlatformEventHandlers();
|
||||
}, []);
|
||||
|
||||
if (fatalError) {
|
||||
return <ErrorUI error={errorMessage(fatalError)} />;
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -363,11 +363,9 @@ export type GetToolsQuery = {
|
||||
session_id: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A Goose App combining MCP resource data with Goose-specific metadata
|
||||
*/
|
||||
export type GooseApp = McpAppResource & (WindowProps | null) & {
|
||||
mcpServer?: string | null;
|
||||
mcpServers?: Array<string>;
|
||||
prd?: string | null;
|
||||
};
|
||||
|
||||
export type Icon = {
|
||||
@@ -387,6 +385,15 @@ export type ImageContent = {
|
||||
mimeType: string;
|
||||
};
|
||||
|
||||
export type ImportAppRequest = {
|
||||
html: string;
|
||||
};
|
||||
|
||||
export type ImportAppResponse = {
|
||||
message: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type ImportSessionRequest = {
|
||||
json: string;
|
||||
};
|
||||
@@ -1042,6 +1049,7 @@ export type SystemInfo = {
|
||||
};
|
||||
|
||||
export type SystemNotificationContent = {
|
||||
data?: unknown;
|
||||
msg: string;
|
||||
notificationType: SystemNotificationType;
|
||||
};
|
||||
@@ -1348,6 +1356,69 @@ export type CallToolResponses = {
|
||||
|
||||
export type CallToolResponse2 = CallToolResponses[keyof CallToolResponses];
|
||||
|
||||
export type ExportAppData = {
|
||||
body?: never;
|
||||
path: {
|
||||
/**
|
||||
* Name of the app to export
|
||||
*/
|
||||
name: string;
|
||||
};
|
||||
query?: never;
|
||||
url: '/agent/export_app/{name}';
|
||||
};
|
||||
|
||||
export type ExportAppErrors = {
|
||||
/**
|
||||
* App not found
|
||||
*/
|
||||
404: ErrorResponse;
|
||||
/**
|
||||
* Internal server error
|
||||
*/
|
||||
500: ErrorResponse;
|
||||
};
|
||||
|
||||
export type ExportAppError = ExportAppErrors[keyof ExportAppErrors];
|
||||
|
||||
export type ExportAppResponses = {
|
||||
/**
|
||||
* App HTML exported successfully
|
||||
*/
|
||||
200: string;
|
||||
};
|
||||
|
||||
export type ExportAppResponse = ExportAppResponses[keyof ExportAppResponses];
|
||||
|
||||
export type ImportAppData = {
|
||||
body: ImportAppRequest;
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/agent/import_app';
|
||||
};
|
||||
|
||||
export type ImportAppErrors = {
|
||||
/**
|
||||
* Bad request - Invalid HTML
|
||||
*/
|
||||
400: ErrorResponse;
|
||||
/**
|
||||
* Internal server error
|
||||
*/
|
||||
500: ErrorResponse;
|
||||
};
|
||||
|
||||
export type ImportAppError = ImportAppErrors[keyof ImportAppErrors];
|
||||
|
||||
export type ImportAppResponses = {
|
||||
/**
|
||||
* App imported successfully
|
||||
*/
|
||||
201: ImportAppResponse;
|
||||
};
|
||||
|
||||
export type ImportAppResponse2 = ImportAppResponses[keyof ImportAppResponses];
|
||||
|
||||
export type ListAppsData = {
|
||||
body?: never;
|
||||
path?: never;
|
||||
|
||||
@@ -23,12 +23,13 @@ import {
|
||||
import { Gear } from '../icons';
|
||||
import { View, ViewOptions } from '../../utils/navigationUtils';
|
||||
import { DEFAULT_CHAT_TITLE, useChatContext } from '../../contexts/ChatContext';
|
||||
import { listSessions, listApps, Session } from '../../api';
|
||||
import { listSessions, Session } from '../../api';
|
||||
import { resumeSession, startNewSession, shouldShowNewChatTitle } from '../../sessions';
|
||||
import { useNavigation } from '../../hooks/useNavigation';
|
||||
import { SessionIndicators } from '../SessionIndicators';
|
||||
import { useSidebarSessionStatus } from '../../hooks/useSidebarSessionStatus';
|
||||
import { getInitialWorkingDir } from '../../utils/workingDir';
|
||||
import { useConfig } from '../ConfigContext';
|
||||
|
||||
interface SidebarProps {
|
||||
onSelectSession: (sessionId: string) => void;
|
||||
@@ -60,6 +61,13 @@ const menuItems: NavigationEntry[] = [
|
||||
icon: FileText,
|
||||
tooltip: 'Browse your saved recipes',
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
path: '/apps',
|
||||
label: 'Apps',
|
||||
icon: AppWindow,
|
||||
tooltip: 'MCP and custom apps',
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
path: '/schedules',
|
||||
@@ -74,13 +82,6 @@ const menuItems: NavigationEntry[] = [
|
||||
icon: Puzzle,
|
||||
tooltip: 'Manage your extensions',
|
||||
},
|
||||
{
|
||||
type: 'item',
|
||||
path: '/apps',
|
||||
label: 'Apps',
|
||||
icon: AppWindow,
|
||||
tooltip: 'Browse and launch MCP apps',
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
type: 'item',
|
||||
@@ -195,10 +196,13 @@ SessionList.displayName = 'SessionList';
|
||||
const AppSidebar: React.FC<SidebarProps> = ({ currentPath }) => {
|
||||
const navigate = useNavigate();
|
||||
const chatContext = useChatContext();
|
||||
const configContext = useConfig();
|
||||
const setView = useNavigation();
|
||||
|
||||
const appsExtensionEnabled = !!configContext.extensionsList?.find((ext) => ext.name === 'apps')
|
||||
?.enabled;
|
||||
const [searchParams] = useSearchParams();
|
||||
const [recentSessions, setRecentSessions] = useState<Session[]>([]);
|
||||
const [hasApps, setHasApps] = useState(false);
|
||||
const activeSessionId = searchParams.get('resumeSessionId') ?? undefined;
|
||||
const { getSessionStatus, clearUnread } = useSidebarSessionStatus(activeSessionId);
|
||||
|
||||
@@ -251,21 +255,6 @@ const AppSidebar: React.FC<SidebarProps> = ({ currentPath }) => {
|
||||
loadRecentSessions();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const checkApps = async () => {
|
||||
try {
|
||||
const response = await listApps({
|
||||
throwOnError: true,
|
||||
});
|
||||
setHasApps((response.data?.apps || []).length > 0);
|
||||
} catch (err) {
|
||||
console.warn('Failed to check for apps:', err);
|
||||
}
|
||||
};
|
||||
|
||||
checkApps();
|
||||
}, [currentPath]);
|
||||
|
||||
useEffect(() => {
|
||||
let pollingTimeouts: ReturnType<typeof setTimeout>[] = [];
|
||||
let isPolling = false;
|
||||
@@ -477,8 +466,9 @@ const AppSidebar: React.FC<SidebarProps> = ({ currentPath }) => {
|
||||
};
|
||||
|
||||
const visibleMenuItems = menuItems.filter((entry) => {
|
||||
// Filter out Apps if extension is not enabled
|
||||
if (entry.type === 'item' && entry.path === '/apps') {
|
||||
return hasApps;
|
||||
return appsExtensionEnabled;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
@@ -544,7 +534,6 @@ const AppSidebar: React.FC<SidebarProps> = ({ currentPath }) => {
|
||||
|
||||
<SidebarSeparator />
|
||||
|
||||
{/* Other menu items - filter out Apps if no apps available */}
|
||||
{visibleMenuItems.map((entry, index) => renderMenuItem(entry, index))}
|
||||
</SidebarMenu>
|
||||
</SidebarContent>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { MainPanelLayout } from '../Layout/MainPanelLayout';
|
||||
import { Button } from '../ui/button';
|
||||
import { Play } from 'lucide-react';
|
||||
import { GooseApp, listApps } from '../../api';
|
||||
import { Download, Play, Upload } from 'lucide-react';
|
||||
import { exportApp, GooseApp, importApp, listApps } from '../../api';
|
||||
import { useChatContext } from '../../contexts/ChatContext';
|
||||
import { formatAppName } from '../../utils/conversionUtils';
|
||||
|
||||
const GridLayout = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
@@ -73,6 +74,32 @@ export default function AppsView() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePlatformEvent = (event: Event) => {
|
||||
const customEvent = event as CustomEvent;
|
||||
const eventData = customEvent.detail;
|
||||
|
||||
if (eventData?.extension === 'apps') {
|
||||
const eventSessionId = eventData.sessionId || sessionId;
|
||||
|
||||
// Refresh apps list to get latest state
|
||||
if (eventSessionId) {
|
||||
listApps({
|
||||
throwOnError: false,
|
||||
query: { session_id: eventSessionId },
|
||||
}).then((response) => {
|
||||
if (response.data?.apps) {
|
||||
setApps(response.data.apps);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('platform-event', handlePlatformEvent);
|
||||
return () => window.removeEventListener('platform-event', handlePlatformEvent);
|
||||
}, [sessionId]);
|
||||
|
||||
const loadApps = useCallback(async () => {
|
||||
if (!sessionId) return;
|
||||
|
||||
@@ -104,6 +131,59 @@ export default function AppsView() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadApp = async (app: GooseApp) => {
|
||||
try {
|
||||
const response = await exportApp({
|
||||
throwOnError: true,
|
||||
path: { name: app.name },
|
||||
});
|
||||
|
||||
if (response.data) {
|
||||
const blob = new Blob([response.data as string], { type: 'text/html' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${app.name}.html`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to export app:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to export app');
|
||||
}
|
||||
};
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleImportClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleUploadApp = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const text = await file.text();
|
||||
await importApp({
|
||||
throwOnError: true,
|
||||
body: { html: text },
|
||||
});
|
||||
|
||||
const response = await listApps({
|
||||
throwOnError: true,
|
||||
});
|
||||
setApps(response.data?.apps || []);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error('Failed to import app:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to import app');
|
||||
}
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
// Only show error-only UI if we have no apps to display
|
||||
if (error && apps.length === 0) {
|
||||
return (
|
||||
@@ -119,14 +199,36 @@ export default function AppsView() {
|
||||
return (
|
||||
<MainPanelLayout>
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".html"
|
||||
onChange={handleUploadApp}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<div className="bg-background-default px-8 pb-8 pt-16">
|
||||
<div className="flex flex-col page-transition">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<h1 className="text-4xl font-light">Apps</h1>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleImportClick}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
Import App
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<p className="text-sm text-text-muted mb-2">
|
||||
Applications from your MCP servers and Apps build by goose itself. You can ask it to
|
||||
create new apps through the chat interface and they will appear here.
|
||||
</p>
|
||||
<p className="text-xs text-amber-600 dark:text-amber-500">
|
||||
⚠️ Experimental feature - may change or be removed at any time
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-text-muted mb-4">
|
||||
Applications from your MCP servers that can run in standalone windows.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -140,41 +242,58 @@ export default function AppsView() {
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-medium mb-2">No apps available</h3>
|
||||
<p className="text-sm text-text-muted">
|
||||
Install MCP servers that provide UI resources to see apps here.
|
||||
Open a chat and ask goose for the app you want to have. It can build one for you
|
||||
and that will appear here. Or if somebody shared an app, you can import it using
|
||||
the button above.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<GridLayout>
|
||||
{apps.map((app) => (
|
||||
<div
|
||||
key={`${app.uri}-${app.mcpServer}`}
|
||||
className="flex flex-col p-4 border border-border-muted rounded-lg bg-background-panel hover:border-border-default transition-colors"
|
||||
>
|
||||
<div className="flex-1 mb-4">
|
||||
<h3 className="font-medium text-text-default mb-2">{app.name}</h3>
|
||||
{app.description && (
|
||||
<p className="text-sm text-text-muted mb-2">{app.description}</p>
|
||||
)}
|
||||
{app.mcpServer && (
|
||||
<span className="inline-block px-2 py-1 text-xs bg-background-subtle text-text-muted rounded">
|
||||
{app.mcpServer}
|
||||
</span>
|
||||
)}
|
||||
{apps.map((app) => {
|
||||
const isCustomApp = app.mcpServers?.includes('apps') ?? false;
|
||||
return (
|
||||
<div
|
||||
key={`${app.uri}-${app.mcpServers?.join(',')}`}
|
||||
className="flex flex-col p-4 border border-border-muted rounded-lg bg-background-panel hover:border-border-default transition-colors"
|
||||
>
|
||||
<div className="flex-1 mb-4">
|
||||
<h3 className="font-medium text-text-default mb-2">
|
||||
{formatAppName(app.name)}
|
||||
</h3>
|
||||
{app.description && (
|
||||
<p className="text-sm text-text-muted mb-2">{app.description}</p>
|
||||
)}
|
||||
{app.mcpServers && app.mcpServers.length > 0 && (
|
||||
<span className="inline-block px-2 py-1 text-xs bg-background-subtle text-text-muted rounded">
|
||||
{isCustomApp ? 'Custom app' : app.mcpServers.join(', ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => handleLaunchApp(app)}
|
||||
className="flex items-center gap-2 flex-1"
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
Launch
|
||||
</Button>
|
||||
{isCustomApp && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDownloadApp(app)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => handleLaunchApp(app)}
|
||||
className="flex items-center gap-2 flex-1"
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
Launch
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</GridLayout>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import McpAppRenderer from '../McpApps/McpAppRenderer';
|
||||
import { startAgent, resumeAgent, listApps, stopAgent } from '../../api';
|
||||
import { formatAppName } from '../../utils/conversionUtils';
|
||||
|
||||
export default function StandaloneAppView() {
|
||||
const [searchParams] = useSearchParams();
|
||||
@@ -35,7 +36,7 @@ export default function StandaloneAppView() {
|
||||
|
||||
const apps = response.data?.apps || [];
|
||||
const cachedApp = apps.find(
|
||||
(app) => app.uri === resourceUri && app.mcpServer === extensionName
|
||||
(app) => app.uri === resourceUri && app.mcpServers?.includes(extensionName)
|
||||
);
|
||||
|
||||
if (cachedApp?.text) {
|
||||
@@ -88,7 +89,7 @@ export default function StandaloneAppView() {
|
||||
|
||||
useEffect(() => {
|
||||
if (appName) {
|
||||
document.title = appName;
|
||||
document.title = formatAppName(appName);
|
||||
}
|
||||
}, [appName]);
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from '../types/message';
|
||||
import { errorMessage } from '../utils/conversionUtils';
|
||||
import { showExtensionLoadResults } from '../utils/extensionErrorUtils';
|
||||
import { maybeHandlePlatformEvent } from '../utils/platform_events';
|
||||
|
||||
const resultsCache = new Map<string, { messages: Message[]; session: Session }>();
|
||||
|
||||
@@ -197,7 +198,8 @@ async function streamFromResponse(
|
||||
stream: AsyncIterable<MessageEvent>,
|
||||
initialMessages: Message[],
|
||||
dispatch: React.Dispatch<StreamAction>,
|
||||
onFinish: (error?: string) => void
|
||||
onFinish: (error?: string) => void,
|
||||
sessionId: string
|
||||
): Promise<void> {
|
||||
let currentMessages = initialMessages;
|
||||
|
||||
@@ -249,6 +251,7 @@ async function streamFromResponse(
|
||||
}
|
||||
case 'Notification': {
|
||||
dispatch({ type: 'ADD_NOTIFICATION', payload: event as NotificationEvent });
|
||||
maybeHandlePlatformEvent(event.message, sessionId);
|
||||
break;
|
||||
}
|
||||
case 'Ping':
|
||||
@@ -540,7 +543,7 @@ export function useChatStream({
|
||||
signal: abortControllerRef.current.signal,
|
||||
});
|
||||
|
||||
await streamFromResponse(stream, currentMessages, dispatch, onFinish);
|
||||
await streamFromResponse(stream, currentMessages, dispatch, onFinish, sessionId);
|
||||
} catch (error) {
|
||||
// AbortError is expected when user stops streaming
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
@@ -581,7 +584,7 @@ export function useChatStream({
|
||||
signal: abortControllerRef.current.signal,
|
||||
});
|
||||
|
||||
await streamFromResponse(stream, currentMessages, dispatch, onFinish);
|
||||
await streamFromResponse(stream, currentMessages, dispatch, onFinish, sessionId);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
// Silently handle abort
|
||||
|
||||
+45
-3
@@ -29,6 +29,7 @@ import { expandTilde } from './utils/pathUtils';
|
||||
import log from './utils/logger';
|
||||
import { ensureWinShims } from './utils/winShims';
|
||||
import { addRecentDir, loadRecentDirs } from './utils/recentDirs';
|
||||
import { formatAppName } from './utils/conversionUtils';
|
||||
import {
|
||||
EnvToggles,
|
||||
loadSettings,
|
||||
@@ -516,8 +517,8 @@ let appConfig = {
|
||||
|
||||
const windowMap = new Map<number, BrowserWindow>();
|
||||
const goosedClients = new Map<number, Client>();
|
||||
const appWindows = new Map<string, BrowserWindow>();
|
||||
|
||||
// Track power save blockers per window
|
||||
const windowPowerSaveBlockers = new Map<number, number>(); // windowId -> blockerId
|
||||
// Track pending initial messages per window
|
||||
const pendingInitialMessages = new Map<number, string>(); // windowId -> initialMessage
|
||||
@@ -2484,10 +2485,11 @@ async function appMain() {
|
||||
const baseUrl = new URL(currentUrl).origin;
|
||||
|
||||
const appWindow = new BrowserWindow({
|
||||
title: gooseApp.name,
|
||||
title: formatAppName(gooseApp.name),
|
||||
width: gooseApp.width ?? 800,
|
||||
height: gooseApp.height ?? 600,
|
||||
resizable: gooseApp.resizable ?? true,
|
||||
useContentSize: true,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.js'),
|
||||
nodeIntegration: false,
|
||||
@@ -2498,13 +2500,15 @@ async function appMain() {
|
||||
});
|
||||
|
||||
goosedClients.set(appWindow.id, launchingClient);
|
||||
appWindows.set(gooseApp.name, appWindow);
|
||||
|
||||
appWindow.on('close', () => {
|
||||
goosedClients.delete(appWindow.id);
|
||||
appWindows.delete(gooseApp.name);
|
||||
});
|
||||
|
||||
const workingDir = app.getPath('home');
|
||||
const extensionName = gooseApp.mcpServer ?? '';
|
||||
const extensionName = gooseApp.mcpServers?.[0] ?? '';
|
||||
const standaloneUrl =
|
||||
`${baseUrl}/#/standalone-app?` +
|
||||
`resourceUri=${encodeURIComponent(gooseApp.uri)}` +
|
||||
@@ -2519,6 +2523,44 @@ async function appMain() {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('refresh-app', async (_event, gooseApp: GooseApp) => {
|
||||
try {
|
||||
const appWindow = appWindows.get(gooseApp.name);
|
||||
if (!appWindow || appWindow.isDestroyed()) {
|
||||
console.log(`App window for '${gooseApp.name}' not found or destroyed, skipping refresh`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Bring to front first
|
||||
if (appWindow.isMinimized()) {
|
||||
appWindow.restore();
|
||||
}
|
||||
appWindow.show();
|
||||
appWindow.focus();
|
||||
|
||||
// Then reload
|
||||
await appWindow.webContents.reload();
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh app:', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('close-app', async (_event, appName: string) => {
|
||||
try {
|
||||
const appWindow = appWindows.get(appName);
|
||||
if (!appWindow || appWindow.isDestroyed()) {
|
||||
console.log(`App window for '${appName}' not found or destroyed, skipping close`);
|
||||
return;
|
||||
}
|
||||
|
||||
appWindow.close();
|
||||
} catch (error) {
|
||||
console.error('Failed to close app:', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
|
||||
@@ -138,6 +138,8 @@ type ElectronAPI = {
|
||||
recordRecipeHash: (recipe: Recipe) => Promise<boolean>;
|
||||
openDirectoryInExplorer: (directoryPath: string) => Promise<boolean>;
|
||||
launchApp: (app: GooseApp) => Promise<void>;
|
||||
refreshApp: (app: GooseApp) => Promise<void>;
|
||||
closeApp: (appName: string) => Promise<void>;
|
||||
addRecentDir: (dir: string) => Promise<boolean>;
|
||||
};
|
||||
|
||||
@@ -278,6 +280,8 @@ const electronAPI: ElectronAPI = {
|
||||
openDirectoryInExplorer: (directoryPath: string) =>
|
||||
ipcRenderer.invoke('open-directory-in-explorer', directoryPath),
|
||||
launchApp: (app: GooseApp) => ipcRenderer.invoke('launch-app', app),
|
||||
refreshApp: (app: GooseApp) => ipcRenderer.invoke('refresh-app', app),
|
||||
closeApp: (appName: string) => ipcRenderer.invoke('close-app', appName),
|
||||
addRecentDir: (dir: string) => ipcRenderer.invoke('add-recent-dir', dir),
|
||||
};
|
||||
|
||||
|
||||
@@ -21,3 +21,11 @@ export function errorMessage(err: Error | unknown, default_value?: string) {
|
||||
return default_value || String(err);
|
||||
}
|
||||
}
|
||||
|
||||
export function formatAppName(name: string): string {
|
||||
return name
|
||||
.split(/[-_\s]+/)
|
||||
.filter((word) => word.length > 0)
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { listApps, GooseApp } from '../api';
|
||||
|
||||
interface PlatformEventData {
|
||||
extension: string;
|
||||
sessionId?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface AppsEventData extends PlatformEventData {
|
||||
app_name?: string;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
type PlatformEventHandler = (eventType: string, data: PlatformEventData) => Promise<void>;
|
||||
|
||||
async function handleAppsEvent(eventType: string, eventData: PlatformEventData): Promise<void> {
|
||||
const { app_name, sessionId } = eventData as AppsEventData;
|
||||
|
||||
if (!sessionId) {
|
||||
console.warn('No sessionId in apps platform event, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await listApps({
|
||||
throwOnError: false,
|
||||
query: { session_id: sessionId },
|
||||
});
|
||||
|
||||
const apps = response.data?.apps || [];
|
||||
|
||||
const targetApp = apps.find((app: GooseApp) => app.name === app_name);
|
||||
|
||||
switch (eventType) {
|
||||
case 'app_created':
|
||||
if (targetApp) {
|
||||
await window.electron.launchApp(targetApp).catch((err) => {
|
||||
console.error('Failed to launch newly created app:', err);
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'app_updated':
|
||||
if (targetApp) {
|
||||
await window.electron.refreshApp(targetApp).catch((err) => {
|
||||
console.error('Failed to refresh updated app:', err);
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'app_deleted':
|
||||
if (app_name) {
|
||||
await window.electron.closeApp(app_name).catch((err) => {
|
||||
console.error('Failed to close deleted app:', err);
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn(`Unknown apps event type: ${eventType}`);
|
||||
}
|
||||
}
|
||||
|
||||
const EXTENSION_HANDLERS: Record<string, PlatformEventHandler> = {
|
||||
apps: handleAppsEvent,
|
||||
};
|
||||
|
||||
export function maybeHandlePlatformEvent(notification: unknown, sessionId: string): void {
|
||||
if (notification && typeof notification === 'object' && 'method' in notification) {
|
||||
const msg = notification as { method?: string; params?: unknown };
|
||||
if (msg.method === 'platform_event' && msg.params) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('platform-event', {
|
||||
detail: { ...msg.params, sessionId },
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function registerPlatformEventHandlers(): () => void {
|
||||
const handler = (event: Event) => {
|
||||
const customEvent = event as CustomEvent;
|
||||
const { extension, event_type, ...data } = customEvent.detail;
|
||||
|
||||
const extensionHandler = EXTENSION_HANDLERS[extension];
|
||||
if (extensionHandler) {
|
||||
extensionHandler(event_type, { ...data, extension }).catch((err) => {
|
||||
console.error(`Platform event handler failed for ${extension}:`, err);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('platform-event', handler);
|
||||
return () => window.removeEventListener('platform-event', handler);
|
||||
}
|
||||
Reference in New Issue
Block a user