diff --git a/backend/staticfiles/synthesis/main.py b/backend/staticfiles/synthesis/main.py index 47e75263..49aed123 100644 --- a/backend/staticfiles/synthesis/main.py +++ b/backend/staticfiles/synthesis/main.py @@ -50,6 +50,40 @@ def clean_shared_memory(signum, frame, names, processes): sys.exit(0) +def process_wrapper(method, inputs, outputs, parameters, sync): + """ + Wrapper that restores sys.stdin before running a block's main(). + + On macOS / Windows, multiprocessing uses 'spawn' which starts a fresh + Python interpreter where sys.stdin is None. Calling input() in that + state raises EOFError immediately (Issue #359). + + Strategy: + 1. If fd 0 is a real TTY, open /dev/tty so the block gets a proper + interactive terminal (works even inside Docker with -it). + 2. Otherwise fall back to os.fdopen(0) for piped / non-TTY contexts. + 3. If both fail, leave sys.stdin unchanged and let the block handle it. + """ + import sys + import os + + if os.isatty(0): + # fd 0 is connected to a real terminal — give the block full TTY access + try: + sys.stdin = open('/dev/tty', 'r') + except OSError: + # /dev/tty not available (rare); fall back to wrapping fd 0 directly + try: + sys.stdin = os.fdopen(0) + except OSError: + pass # leave sys.stdin as-is; block may not need input() + # If fd 0 is NOT a tty (pipe / redirect / Docker without -it), + # we do NOT restore stdin — input() will raise EOFError, which is the + # correct, expected behaviour in a non-interactive environment. + + method(inputs, outputs, parameters, sync) + + def main(): """ Main function @@ -117,14 +151,23 @@ def main(): for block_id, block in blocks.items(): name = BLOCK_DIRECTORY + "." + block["name"] mod = importlib.import_module(name) - method = method = getattr(mod, FUNCTION_NAME) + method = getattr(mod, FUNCTION_NAME) + + # Ensure block_data entry exists even for blocks with no wires / parameters + block_data[block_id] = block_data.get( + block_id, {"inputs": {}, "outputs": {}, "parameters": {}} + ) inputs = Inputs(block_data[block_id]["inputs"]) outputs = Outputs(block_data[block_id]["outputs"]) parameters = Parameters(block_data[block_id]["parameters"]) - freq = block_data[block_id]["frequency"] + # Default frequency to 30 Hz if not specified by any wire / synchronize_frequency entry + freq = block_data[block_id].get("frequency", 30) processes.append( - multiprocessing.Process(target=method, args=(inputs, outputs, parameters, Synchronise(1 / (freq if freq != 0 else 30)))) + multiprocessing.Process( + target=process_wrapper, + args=(method, inputs, outputs, parameters, Synchronise(1 / (freq if freq != 0 else 30))) + ) ) # Register handler for Ctrl+C diff --git a/frontend/src/components/dialogs/project-info-dialog.tsx b/frontend/src/components/dialogs/project-info-dialog.tsx index 2a6be78a..be892b4f 100644 --- a/frontend/src/components/dialogs/project-info-dialog.tsx +++ b/frontend/src/components/dialogs/project-info-dialog.tsx @@ -1,4 +1,4 @@ -import { Button, Dialog, DialogActions, DialogContent, DialogContentText, TextField } from '@material-ui/core'; +import { Button, Dialog, DialogActions, DialogContent, DialogContentText, TextField, MenuItem } from '@material-ui/core'; import React, { ChangeEvent, useState } from 'react'; import { create, InstanceProps } from 'react-modal-promise'; import { ProjectInfo } from '../../core/constants'; @@ -19,7 +19,7 @@ interface ProjectInfoDialogProps extends InstanceProps, Partial { + name, version, description, author, image, category, tags }: ProjectInfoDialogProps) => { // Name of package. Use empty string if not defined const [nameInput, setName] = useState(name || ''); @@ -31,6 +31,19 @@ const ProjectInfoDialog = ({ isOpen, onResolve, onReject, const [authorInput, setAuthor] = useState(author || ''); // Icon of package. Use empty string if not defined const [imageInput, setImage] = useState(image || ''); + // Category of the package + const [categoryInput, setCategory] = useState(category || ''); + // Tags of the package (comma separated) + const [tagsInput, setTags] = useState(tags ? tags.join(', ') : ''); + + const ALLOWED_CATEGORIES = [ + "Computer Vision", + "Control Systems", + "Locomotion", + "Machine Learning", + "Utilities", + "ROS2" + ]; const fileReader = new FileReader(); fileReader.onload = (event) => { @@ -62,7 +75,9 @@ const ProjectInfoDialog = ({ isOpen, onResolve, onReject, version: versionInput, description: descriptionInput, author: authorInput, - image: imageInput + image: imageInput, + category: categoryInput, + tags: tagsInput.split(',').map(t => t.trim()).filter(t => t.length > 0) }); } @@ -125,6 +140,38 @@ const ProjectInfoDialog = ({ isOpen, onResolve, onReject, onChange={(event) => setAuthor(event.target.value)} fullWidth /> + + + Category + + setCategory(event.target.value)} + fullWidth + > + {ALLOWED_CATEGORIES.map((cat) => ( + + {cat} + + ))} + + + + Tags (comma separated) + + setTags(event.target.value)} + placeholder="e.g. cv2, camera, face detection" + fullWidth + /> + Image diff --git a/frontend/src/components/marketplace/MarketplacePanel.tsx b/frontend/src/components/marketplace/MarketplacePanel.tsx new file mode 100644 index 00000000..4dbd5969 --- /dev/null +++ b/frontend/src/components/marketplace/MarketplacePanel.tsx @@ -0,0 +1,221 @@ +import React, { useEffect, useState } from 'react'; +import { + Box, + Button, + CircularProgress, + Drawer, + IconButton, + List, + ListItem, + Typography, + TextField, + Chip, + Divider, + makeStyles, + Theme, + createStyles +} from '@material-ui/core'; +import CloseIcon from '@material-ui/icons/Close'; +import { RegistryBlock, RegistryResponse, validateMarketplaceBlock } from '../../core/validator'; +import Editor from '../../core/editor'; + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + drawerPaper: { + width: 400, + padding: theme.spacing(2), + backgroundColor: theme.palette.background.default, + }, + header: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: theme.spacing(2), + }, + blockItem: { + flexDirection: 'column', + alignItems: 'flex-start', + padding: theme.spacing(2), + border: `1px solid ${theme.palette.divider}`, + borderRadius: theme.shape.borderRadius, + marginBottom: theme.spacing(1), + }, + blockHeader: { + width: '100%', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + }, + }) +); + +interface MarketplacePanelProps { + open: boolean; + onClose: () => void; + editor: Editor; +} + +const MarketplacePanel: React.FC = ({ open, onClose, editor }) => { + const classes = useStyles(); + const [blocks, setBlocks] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + + useEffect(() => { + if (open && blocks.length === 0) { + fetchRegistry(); + } + }, [open]); + + const fetchRegistry = async () => { + setLoading(true); + setError(null); + try { + const response = await fetch('https://raw.githubusercontent.com/Sarvesh-Mishra1981/VisualCircuit-resources/main/marketplace/registry.json'); + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + const data: RegistryResponse = await response.json(); + setBlocks(data.blocks); + } catch (err: any) { + console.error("Failed to fetch registry:", err); + setError("Failed to load marketplace blocks."); + } finally { + setLoading(false); + } + }; + + const handleInstall = async (block: RegistryBlock) => { + try { + const response = await fetch(block.url); + if (!response.ok) { + throw new Error(`Could not download block data (Status: ${response.status})`); + } + const blockData = await response.json(); + const isValid = validateMarketplaceBlock(blockData); + + if (isValid) { + // Save to Local Storage + let installedBlocks = []; + try { + const stored = localStorage.getItem('vc_marketplace_blocks'); + if (stored) { + installedBlocks = JSON.parse(stored); + } + } catch (e) { + console.warn("Could not read local storage", e); + } + + // Avoid duplicates by checking package name + const exists = installedBlocks.find((b: any) => b.package && b.package.name === blockData.package.name); + if (!exists) { + installedBlocks.push(blockData); + localStorage.setItem('vc_marketplace_blocks', JSON.stringify(installedBlocks)); + + // Dispatch event so the MenuBar updates + window.dispatchEvent(new Event('vc_marketplace_updated')); + + alert(`Successfully added ${block.name} to your Downloads Menu!`); + } else { + alert(`${block.name} is already in your Downloads!`); + } + } else { + alert(`Failed to validate block ${block.name}.`); + } + } catch (err: any) { + console.error("Installation failed:", err); + alert(`Installation failed: ${err.message}`); + } + }; + + const filteredBlocks = blocks.filter(block => { + const query = searchQuery.toLowerCase(); + const matchesName = block.name.toLowerCase().includes(query); + const matchesDesc = block.description.toLowerCase().includes(query); + const matchesTags = block.tags?.some(tag => tag.toLowerCase().includes(query)); + return matchesName || matchesDesc || matchesTags; + }); + + const groupedBlocks = filteredBlocks.reduce((acc, block) => { + const cat = block.category || 'Uncategorized'; + if (!acc[cat]) acc[cat] = []; + acc[cat].push(block); + return acc; + }, {} as Record); + + return ( + +
+ Marketplace + + + +
+ + setSearchQuery(e.target.value)} + style={{ marginBottom: 20 }} + /> + + {loading ? ( + + + + ) : error ? ( + {error} + ) : ( + + {Object.entries(groupedBlocks).map(([category, catBlocks]) => ( + + + {category} + + {catBlocks.map((block) => ( + + + + {block.name} + + v{block.version} • by {block.author} + + + + + + {block.description} + + {block.tags && block.tags.length > 0 && ( + + {block.tags.map(tag => ( + + ))} + + )} + + ))} + + ))} + {filteredBlocks.length === 0 && ( + + No blocks found matching "{searchQuery}" + + )} + + )} +
+ ); +}; + +export default MarketplacePanel; diff --git a/frontend/src/components/menu/index.tsx b/frontend/src/components/menu/index.tsx index e6855244..9a0e55d5 100644 --- a/frontend/src/components/menu/index.tsx +++ b/frontend/src/components/menu/index.tsx @@ -1,12 +1,13 @@ import { AppBar, Button, Toolbar, useTheme } from '@material-ui/core'; import { ClickEvent, Menu, MenuItem, SubMenu } from '@szhsin/react-menu'; import html2canvas from 'html2canvas'; -import React, { ChangeEvent } from 'react'; +import React, { ChangeEvent, useEffect, useState } from 'react'; import logo from '../../assets/images/logo.png'; import { PROJECT_FILE_EXTENSION } from '../../core/constants'; import Editor from '../../core/editor'; import { textFile2DataURL } from '../../core/utils'; import { collectionBlocks, CollectionBlockType } from '../blocks/collection/collection-factory'; +import MarketplacePanel from '../marketplace/MarketplacePanel'; import './styles.scss'; @@ -32,6 +33,27 @@ function MenuBar(props: MenuBarProps) { const projectReader : FileHelper = {'fileName': '', 'reader': new FileReader()}; const blockReader: FileHelper = {'fileName': '', 'reader': new FileReader()}; const { editor } = props; + + // State + const [marketplaceOpen, setMarketplaceOpen] = useState(false); + const [downloads, setDownloads] = useState([]); + + const loadDownloads = () => { + try { + const stored = localStorage.getItem('vc_marketplace_blocks'); + if (stored) { + setDownloads(JSON.parse(stored)); + } + } catch (e) { + console.error("Failed to load downloads", e); + } + }; + + useEffect(() => { + loadDownloads(); + window.addEventListener('vc_marketplace_updated', loadDownloads); + return () => window.removeEventListener('vc_marketplace_updated', loadDownloads); + }, []); /** * Callback for when a block is selected. @@ -250,6 +272,13 @@ function MenuBar(props: MenuBarProps) { const processingBlocks = blocksEntries(collectionBlocks.processing, 'processing'); const driverBlocks = blocksEntries(collectionBlocks.drivers, 'drivers'); + const groupedDownloads = downloads.reduce((acc, block) => { + const cat = block.package?.category || 'Uncategorized'; + if (!acc[cat]) acc[cat] = []; + acc[cat].push(block); + return acc; + }, {} as Record); + // TODO: Localise string instead of hardcoding it. return ( @@ -278,6 +307,15 @@ function MenuBar(props: MenuBarProps) { Github Releases + + +
Basic} @@ -304,6 +342,24 @@ function MenuBar(props: MenuBarProps) { theming={isDark ? 'dark' : undefined}> {driverBlocks} + + {downloads.length > 0 && ( + Downloads} + theming={isDark ? 'dark' : undefined}> + {Object.entries(groupedDownloads).map(([cat, catBlocks]: [string, any]) => ( + + {catBlocks.map((b: any) => ( + editor.addAsBlock(b, b.package.name)}> + {b.package.name} + + ))} + + ))} + + )} {/* Hidden file input field for opening project file selection dialog. */} onFileUpload(event, blockReader)} hidden /> + + setMarketplaceOpen(false)} + editor={editor} + /> ) } diff --git a/frontend/src/core/editor.ts b/frontend/src/core/editor.ts index 4e64b3e4..8c1a20dd 100644 --- a/frontend/src/core/editor.ts +++ b/frontend/src/core/editor.ts @@ -411,6 +411,32 @@ class Editor { } } + /** + * Extracts nodes and wires from a project JSON and places them directly onto the canvas, + * completely bypassing the PackageBlock (Macro) encapsulation. + */ + public addAsRawBlocks(jsonModel: any) { + // 1. Use the existing loadPackage to handle deep cloning and UUID regeneration safely + const packageBlock = loadPackage(jsonModel); + + if (packageBlock && packageBlock.model) { + // 2. Deserialize the regenerated JSON into a temporary model to instantiate the NodeModels + const tempModel = new DiagramModel(); + tempModel.deserializeModel(packageBlock.model, this.engine); + + // 3. Move all instantiated nodes and links directly onto the active canvas! + Object.values(tempModel.getNodes()).forEach((node) => { + this.activeModel.addNode(node); + }); + + Object.values(tempModel.getLinks()).forEach((link) => { + this.activeModel.addLink(link); + }); + + this.engine.repaintCanvas(); + } + } + /** * Open a block as current project (model) * @param node Block to be opened diff --git a/frontend/src/core/validator.ts b/frontend/src/core/validator.ts new file mode 100644 index 00000000..e57056fd --- /dev/null +++ b/frontend/src/core/validator.ts @@ -0,0 +1,52 @@ +export interface RegistryBlock { + id: string; + name: string; + author: string; + version: string; + description: string; + url: string; + category?: string; + tags?: string[]; +} + +export interface RegistryResponse { + blocks: RegistryBlock[]; +} + +/** + * Validates if the downloaded JSON represents a structurally valid VisualCircuit block. + * This is a structural sanity check to prevent the UI from crashing, not a deep code validation. + * @param blockData The parsed JSON object downloaded from the marketplace + * @returns boolean True if the block has the required schema + */ +export function validateMarketplaceBlock(blockData: any): boolean { + if (!blockData || typeof blockData !== 'object') { + return false; + } + + // A standard VisualCircuit block should have an 'editor' and 'package' object. + // This matches the format used in frontend/src/core/editor.ts -> loadProject / addAsBlock + if (!blockData.editor || typeof blockData.editor !== 'object') { + console.error("Validation Error: Missing or invalid 'editor' object in block."); + return false; + } + + if (!blockData.package || typeof blockData.package !== 'object') { + console.error("Validation Error: Missing or invalid 'package' object in block."); + return false; + } + + // Ensure the package has a name (which is required to display it) + if (!blockData.package.name || typeof blockData.package.name !== 'string') { + console.error("Validation Error: Block package is missing a 'name'."); + return false; + } + + // If it has dependencies or design, they should be objects + if (blockData.design && typeof blockData.design !== 'object') { + console.error("Validation Error: 'design' must be an object if present."); + return false; + } + + return true; +}