diff --git a/backend/apps/settings/models.py b/backend/apps/settings/models.py index 7efe35cb..640c2aa4 100644 --- a/backend/apps/settings/models.py +++ b/backend/apps/settings/models.py @@ -12,3 +12,4 @@ class AppSettings(BaseModel): theme: str = "dark" new_agent_shortcut: str = "Meta+l" anthropic_api_key: Optional[str] = None + browser_homepage: str = "https://www.google.com" diff --git a/electron/main.js b/electron/main.js index fffda524..1b02b736 100644 --- a/electron/main.js +++ b/electron/main.js @@ -10,6 +10,7 @@ let backendProcess = null; let backendPort = null; const isPackaged = app.isPackaged; +const isDev = process.env.ELECTRON_DEV === '1'; function getResourcePath(...segments) { if (isPackaged) { @@ -122,11 +123,16 @@ function createWindow() { preload: path.join(__dirname, 'preload.js'), nodeIntegration: false, contextIsolation: true, + webviewTag: true, }, }); - const frontendPath = getResourcePath('frontend', 'index.html'); - mainWindow.loadFile(frontendPath); + if (isDev) { + mainWindow.loadURL(`http://localhost:3000`); + } else { + const frontendPath = getResourcePath('frontend', 'index.html'); + mainWindow.loadFile(frontendPath); + } mainWindow.on('closed', () => { mainWindow = null; @@ -180,9 +186,16 @@ function killBackend() { app.whenReady().then(async () => { try { - await startBackend(); + if (isDev) { + backendPort = parseInt(process.env.OPENSWARM_PORT || '8324', 10); + console.log(`Dev mode: using existing backend on port ${backendPort}`); + } else { + await startBackend(); + } createWindow(); - setupAutoUpdater(); + if (!isDev) { + setupAutoUpdater(); + } } catch (err) { console.error('Failed to start:', err); app.quit(); @@ -190,12 +203,12 @@ app.whenReady().then(async () => { }); app.on('window-all-closed', () => { - killBackend(); + if (!isDev) killBackend(); app.quit(); }); app.on('will-quit', () => { - killBackend(); + if (!isDev) killBackend(); }); app.on('activate', () => { diff --git a/electron/package.json b/electron/package.json index c7c169b8..d521a652 100644 --- a/electron/package.json +++ b/electron/package.json @@ -5,6 +5,7 @@ "main": "main.js", "scripts": { "start": "electron .", + "dev": "ELECTRON_DEV=1 electron .", "dist": "electron-builder --mac --publish never", "dist:all": "electron-builder --mac --win --linux" }, diff --git a/frontend/src/app/pages/Dashboard/BrowserCard.tsx b/frontend/src/app/pages/Dashboard/BrowserCard.tsx new file mode 100644 index 00000000..77df0e42 --- /dev/null +++ b/frontend/src/app/pages/Dashboard/BrowserCard.tsx @@ -0,0 +1,532 @@ +import React, { useState, useRef, useCallback, useEffect } from 'react'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import IconButton from '@mui/material/IconButton'; +import Tooltip from '@mui/material/Tooltip'; +import InputBase from '@mui/material/InputBase'; +import LinearProgress from '@mui/material/LinearProgress'; +import LanguageIcon from '@mui/icons-material/Language'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; +import RefreshIcon from '@mui/icons-material/Refresh'; +import CloseIcon from '@mui/icons-material/Close'; +import LockIcon from '@mui/icons-material/Lock'; +import { + setBrowserCardPosition, + setBrowserCardSize, + removeBrowserCard, + updateBrowserCardUrl, +} from '@/shared/state/dashboardLayoutSlice'; +import { useAppDispatch } from '@/shared/hooks'; +import { useClaudeTokens } from '@/shared/styles/ThemeContext'; + +type ResizeDir = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'; + +const EDGE_THICKNESS = 6; +const CORNER_SIZE = 14; +const MIN_W = 400; +const MIN_H = 300; + +const CURSOR_MAP: Record = { + n: 'ns-resize', s: 'ns-resize', e: 'ew-resize', w: 'ew-resize', + nw: 'nwse-resize', se: 'nwse-resize', ne: 'nesw-resize', sw: 'nesw-resize', +}; + +const HANDLE_DEFS: { dir: ResizeDir; sx: Record }[] = [ + { dir: 'n', sx: { top: -EDGE_THICKNESS / 2, left: CORNER_SIZE, right: CORNER_SIZE, height: EDGE_THICKNESS } }, + { dir: 's', sx: { bottom: -EDGE_THICKNESS / 2, left: CORNER_SIZE, right: CORNER_SIZE, height: EDGE_THICKNESS } }, + { dir: 'w', sx: { left: -EDGE_THICKNESS / 2, top: CORNER_SIZE, bottom: CORNER_SIZE, width: EDGE_THICKNESS } }, + { dir: 'e', sx: { right: -EDGE_THICKNESS / 2, top: CORNER_SIZE, bottom: CORNER_SIZE, width: EDGE_THICKNESS } }, + { dir: 'nw', sx: { top: -EDGE_THICKNESS / 2, left: -EDGE_THICKNESS / 2, width: CORNER_SIZE, height: CORNER_SIZE } }, + { dir: 'ne', sx: { top: -EDGE_THICKNESS / 2, right: -EDGE_THICKNESS / 2, width: CORNER_SIZE, height: CORNER_SIZE } }, + { dir: 'sw', sx: { bottom: -EDGE_THICKNESS / 2, left: -EDGE_THICKNESS / 2, width: CORNER_SIZE, height: CORNER_SIZE } }, + { dir: 'se', sx: { bottom: -EDGE_THICKNESS / 2, right: -EDGE_THICKNESS / 2, width: CORNER_SIZE, height: CORNER_SIZE } }, +]; + +const isElectron = navigator.userAgent.includes('Electron'); + +interface WebviewElement extends HTMLElement { + src: string; + loadURL: (url: string) => Promise; + goBack: () => void; + goForward: () => void; + reload: () => void; + canGoBack: () => boolean; + canGoForward: () => boolean; + getURL: () => string; + getTitle: () => string; + addEventListener: (event: string, listener: (...args: any[]) => void) => void; + removeEventListener: (event: string, listener: (...args: any[]) => void) => void; +} + +interface Props { + browserId: string; + url: string; + cardX: number; + cardY: number; + cardWidth: number; + cardHeight: number; + zoom?: number; + isSelected?: boolean; + multiDragDelta?: { dx: number; dy: number } | null; + onCardSelect?: (id: string, type: 'agent' | 'view' | 'browser', shiftKey: boolean) => void; + onDragStart?: (id: string, type: 'agent' | 'view' | 'browser') => void; + onDragMove?: (dx: number, dy: number) => void; + onDragEnd?: (dx: number, dy: number, didDrag: boolean) => void; +} + +function ensureProtocol(input: string): string { + const trimmed = input.trim(); + if (/^https?:\/\//i.test(trimmed)) return trimmed; + if (/^[a-zA-Z0-9-]+\.[a-zA-Z]{2,}/.test(trimmed)) return `https://${trimmed}`; + return trimmed; +} + +const BrowserCard: React.FC = ({ + browserId, url, cardX, cardY, cardWidth, cardHeight, zoom = 1, + isSelected = false, multiDragDelta, onCardSelect, onDragStart, onDragMove, onDragEnd, +}) => { + const c = useClaudeTokens(); + const dispatch = useAppDispatch(); + const webviewRef = useRef(null); + + const [currentUrl, setCurrentUrl] = useState(url); + const [urlBarValue, setUrlBarValue] = useState(url); + const [pageTitle, setPageTitle] = useState(''); + const [loading, setLoading] = useState(false); + const [canGoBack, setCanGoBack] = useState(false); + const [canGoForward, setCanGoForward] = useState(false); + + // ---- Webview event wiring ---- + useEffect(() => { + if (!isElectron) return; + const wv = webviewRef.current; + if (!wv) return; + + const onNavigate = () => { + const newUrl = wv.getURL(); + setCurrentUrl(newUrl); + setUrlBarValue(newUrl); + setCanGoBack(wv.canGoBack()); + setCanGoForward(wv.canGoForward()); + dispatch(updateBrowserCardUrl({ browserId, url: newUrl })); + }; + + const onTitleUpdate = () => { + setPageTitle(wv.getTitle()); + }; + + const onLoadStart = () => setLoading(true); + const onLoadStop = () => { + setLoading(false); + onNavigate(); + onTitleUpdate(); + }; + + const onNewWindow = (e: any) => { + if (e.url) wv.loadURL(e.url); + }; + + wv.addEventListener('did-navigate', onNavigate); + wv.addEventListener('did-navigate-in-page', onNavigate); + wv.addEventListener('page-title-updated', onTitleUpdate); + wv.addEventListener('did-start-loading', onLoadStart); + wv.addEventListener('did-stop-loading', onLoadStop); + wv.addEventListener('new-window', onNewWindow); + + return () => { + wv.removeEventListener('did-navigate', onNavigate); + wv.removeEventListener('did-navigate-in-page', onNavigate); + wv.removeEventListener('page-title-updated', onTitleUpdate); + wv.removeEventListener('did-start-loading', onLoadStart); + wv.removeEventListener('did-stop-loading', onLoadStop); + wv.removeEventListener('new-window', onNewWindow); + }; + }, [browserId, dispatch]); + + const navigate = useCallback((targetUrl: string) => { + const finalUrl = ensureProtocol(targetUrl); + setUrlBarValue(finalUrl); + if (isElectron && webviewRef.current) { + webviewRef.current.loadURL(finalUrl); + } + setCurrentUrl(finalUrl); + dispatch(updateBrowserCardUrl({ browserId, url: finalUrl })); + }, [browserId, dispatch]); + + const handleUrlKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + navigate(urlBarValue); + } + }, [navigate, urlBarValue]); + + const handleBack = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + webviewRef.current?.goBack(); + }, []); + + const handleForward = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + webviewRef.current?.goForward(); + }, []); + + const handleRefresh = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + webviewRef.current?.reload(); + }, []); + + const handleRemove = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + dispatch(removeBrowserCard(browserId)); + }, [dispatch, browserId]); + + // ---- Drag via header ---- + const DRAG_THRESHOLD = 3; + const dragState = useRef<{ startX: number; startY: number; origX: number; origY: number } | null>(null); + const [isDragging, setIsDragging] = useState(false); + const [localDragPos, setLocalDragPos] = useState<{ x: number; y: number } | null>(null); + const didDrag = useRef(false); + const justDraggedRef = useRef(false); + + const handleDragPointerDown = useCallback((e: React.PointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + dragState.current = { startX: e.clientX, startY: e.clientY, origX: cardX, origY: cardY }; + didDrag.current = false; + setIsDragging(true); + (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); + onDragStart?.(browserId, 'browser'); + }, [cardX, cardY, onDragStart, browserId]); + + const handleDragPointerMove = useCallback((e: React.PointerEvent) => { + if (!dragState.current) return; + const rawDx = e.clientX - dragState.current.startX; + const rawDy = e.clientY - dragState.current.startY; + if (!didDrag.current && Math.sqrt(rawDx * rawDx + rawDy * rawDy) < DRAG_THRESHOLD) return; + didDrag.current = true; + const dx = rawDx / zoom; + const dy = rawDy / zoom; + setLocalDragPos({ + x: dragState.current.origX + dx, + y: dragState.current.origY + dy, + }); + onDragMove?.(dx, dy); + }, [zoom, onDragMove]); + + const handleDragPointerUp = useCallback((e: React.PointerEvent) => { + if (!dragState.current) return; + const dx = (e.clientX - dragState.current.startX) / zoom; + const dy = (e.clientY - dragState.current.startY) / zoom; + if (didDrag.current) { + dispatch(setBrowserCardPosition({ + browserId, + x: dragState.current.origX + dx, + y: dragState.current.origY + dy, + })); + justDraggedRef.current = true; + requestAnimationFrame(() => { justDraggedRef.current = false; }); + } + onDragEnd?.(dx, dy, didDrag.current); + dragState.current = null; + didDrag.current = false; + setLocalDragPos(null); + setIsDragging(false); + (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); + }, [zoom, dispatch, browserId, onDragEnd]); + + // ---- Resize ---- + const resizeRef = useRef<{ + dir: ResizeDir; startX: number; startY: number; + origX: number; origY: number; origW: number; origH: number; + } | null>(null); + const [isResizing, setIsResizing] = useState(false); + const [localResize, setLocalResize] = useState<{ x: number; y: number; w: number; h: number } | null>(null); + + const handleResizeDown = useCallback( + (dir: ResizeDir) => (e: React.PointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + resizeRef.current = { + dir, startX: e.clientX, startY: e.clientY, + origX: cardX, origY: cardY, origW: cardWidth, origH: cardHeight, + }; + setIsResizing(true); + (e.target as HTMLElement).setPointerCapture(e.pointerId); + }, + [cardX, cardY, cardWidth, cardHeight], + ); + + const computeResize = useCallback( + (e: React.PointerEvent) => { + if (!resizeRef.current) return null; + const { dir, startX, startY, origX, origY, origW, origH } = resizeRef.current; + const dx = (e.clientX - startX) / zoom; + const dy = (e.clientY - startY) / zoom; + let newX = origX, newY = origY, newW = origW, newH = origH; + if (dir.includes('e')) newW = origW + dx; + if (dir.includes('w')) { newW = origW - dx; newX = origX + dx; } + if (dir.includes('s')) newH = origH + dy; + if (dir.includes('n')) { newH = origH - dy; newY = origY + dy; } + if (newW < MIN_W) { if (dir.includes('w')) newX = origX + origW - MIN_W; newW = MIN_W; } + if (newH < MIN_H) { if (dir.includes('n')) newY = origY + origH - MIN_H; newH = MIN_H; } + return { x: newX, y: newY, w: newW, h: newH }; + }, + [zoom], + ); + + const handleResizeMove = useCallback( + (e: React.PointerEvent) => { + const result = computeResize(e); + if (result) setLocalResize(result); + }, + [computeResize], + ); + + const handleResizeUp = useCallback((e: React.PointerEvent) => { + if (!resizeRef.current) return; + const result = computeResize(e); + if (result) { + dispatch(setBrowserCardPosition({ browserId, x: result.x, y: result.y })); + dispatch(setBrowserCardSize({ browserId, width: result.w, height: result.h })); + } + resizeRef.current = null; + setLocalResize(null); + setIsResizing(false); + (e.target as HTMLElement).releasePointerCapture(e.pointerId); + }, [computeResize, dispatch, browserId]); + + const mdDx = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dx : 0; + const mdDy = (!isDragging && isSelected && multiDragDelta) ? multiDragDelta.dy : 0; + const displayX = localResize?.x ?? localDragPos?.x ?? (cardX + mdDx); + const displayY = localResize?.y ?? localDragPos?.y ?? (cardY + mdDy); + const displayW = localResize?.w ?? cardWidth; + const displayH = localResize?.h ?? cardHeight; + const noTransition = isDragging || isResizing || (isSelected && !!multiDragDelta); + + const isSecure = currentUrl.startsWith('https://'); + + return ( + { + if (justDraggedRef.current) return; + onCardSelect?.(browserId, 'browser', e.shiftKey); + }} + sx={{ + position: 'absolute', + left: displayX, + top: displayY, + width: displayW, + height: displayH, + borderRadius: `${c.radius.lg}px`, + border: isSelected ? '2px solid #3b82f6' : `1px solid ${c.border.medium}`, + bgcolor: c.bg.surface, + boxShadow: isDragging || isResizing + ? c.shadow.lg + : isSelected + ? `0 0 0 1px #3b82f6, ${c.shadow.md}` + : c.shadow.md, + overflow: 'hidden', + display: 'flex', + flexDirection: 'column', + zIndex: (isDragging || isResizing) ? 100 : 1, + transition: noTransition ? 'none' : 'box-shadow 0.2s', + '&:hover .resize-handle': { opacity: 1 }, + }} + > + {/* Header / drag handle */} + + + + {pageTitle || 'Browser'} + + + + + e.stopPropagation()} + disabled={!canGoBack} + sx={{ color: c.text.muted, p: 0.4, '&:hover': { color: c.text.primary } }} + > + + + + + + + + e.stopPropagation()} + disabled={!canGoForward} + sx={{ color: c.text.muted, p: 0.4, '&:hover': { color: c.text.primary } }} + > + + + + + + + e.stopPropagation()} + sx={{ color: c.text.muted, p: 0.4, '&:hover': { color: c.text.primary } }} + > + + + + + + e.stopPropagation()} + sx={{ color: c.text.ghost, p: 0.4, '&:hover': { color: c.status.error } }} + > + + + + + + {/* URL bar */} + + {isSecure && ( + + )} + setUrlBarValue(e.target.value)} + onKeyDown={handleUrlKeyDown} + onPointerDown={(e) => e.stopPropagation()} + onFocus={(e) => (e.target as HTMLInputElement).select()} + placeholder="Enter URL..." + sx={{ + flex: 1, + fontSize: '0.76rem', + fontFamily: c.font.mono, + color: c.text.secondary, + py: 0, + '& input': { py: '3px' }, + '& input::placeholder': { color: c.text.ghost, opacity: 1 }, + }} + /> + + + {/* Loading indicator */} + {loading && ( + + )} + + {/* Browser body */} + + {isElectron ? ( + + ) : ( + +