Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 46 additions & 3 deletions backend/staticfiles/synthesis/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
53 changes: 50 additions & 3 deletions frontend/src/components/dialogs/project-info-dialog.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -19,7 +19,7 @@ interface ProjectInfoDialogProps extends InstanceProps<ProjectInfo>, Partial<Pro
* }
*/
const ProjectInfoDialog = ({ isOpen, onResolve, onReject,
name, version, description, author, image }: ProjectInfoDialogProps) => {
name, version, description, author, image, category, tags }: ProjectInfoDialogProps) => {

// Name of package. Use empty string if not defined
const [nameInput, setName] = useState(name || '');
Expand All @@ -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) => {
Expand Down Expand Up @@ -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)
});
}

Expand Down Expand Up @@ -125,6 +140,38 @@ const ProjectInfoDialog = ({ isOpen, onResolve, onReject,
onChange={(event) => setAuthor(event.target.value)}
fullWidth
/>

<DialogContentText>
Category
</DialogContentText>
<TextField
select
margin="dense"
variant='outlined'
value={categoryInput}
onChange={(event) => setCategory(event.target.value)}
fullWidth
>
{ALLOWED_CATEGORIES.map((cat) => (
<MenuItem key={cat} value={cat}>
{cat}
</MenuItem>
))}
</TextField>

<DialogContentText>
Tags (comma separated)
</DialogContentText>
<TextField
margin="dense"
type="text"
variant='outlined'
value={tagsInput}
onChange={(event) => setTags(event.target.value)}
placeholder="e.g. cv2, camera, face detection"
fullWidth
/>

<DialogContentText>
Image
</DialogContentText>
Expand Down
221 changes: 221 additions & 0 deletions frontend/src/components/marketplace/MarketplacePanel.tsx
Original file line number Diff line number Diff line change
@@ -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<MarketplacePanelProps> = ({ open, onClose, editor }) => {
const classes = useStyles();
const [blocks, setBlocks] = useState<RegistryBlock[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState<string>('');

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<string, RegistryBlock[]>);

return (
<Drawer
anchor="left"
open={open}
onClose={onClose}
classes={{ paper: classes.drawerPaper }}
>
<div className={classes.header}>
<Typography variant="h5" style={{ fontWeight: 'bold' }}>Marketplace</Typography>
<IconButton onClick={onClose}>
<CloseIcon />
</IconButton>
</div>

<TextField
variant="outlined"
fullWidth
placeholder="Search blocks, tags..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
style={{ marginBottom: 20 }}
/>

{loading ? (
<Box display="flex" justifyContent="center" padding={4}>
<CircularProgress />
</Box>
) : error ? (
<Typography color="error">{error}</Typography>
) : (
<List>
{Object.entries(groupedBlocks).map(([category, catBlocks]) => (
<React.Fragment key={category}>
<Typography variant="subtitle1" style={{ fontWeight: 'bold', marginTop: 10, color: '#555' }}>
{category}
</Typography>
{catBlocks.map((block) => (
<ListItem key={block.id} className={classes.blockItem}>
<Box width="100%" display="flex" justifyContent="space-between" alignItems="center">
<Box>
<Typography variant="h6" style={{ fontSize: '1.1rem' }}>{block.name}</Typography>
<Typography variant="caption" color="textSecondary">
v{block.version} • by {block.author}
</Typography>
</Box>
<Button variant="contained" color="primary" size="small" onClick={() => handleInstall(block)}>
Install
</Button>
</Box>
<Typography variant="body2" style={{ marginTop: 8, color: '#666' }}>
{block.description}
</Typography>
{block.tags && block.tags.length > 0 && (
<Box display="flex" flexWrap="wrap" mt={1}>
{block.tags.map(tag => (
<Chip key={tag} label={tag} size="small" style={{ marginRight: 4, marginBottom: 4 }} />
))}
</Box>
)}
</ListItem>
))}
</React.Fragment>
))}
{filteredBlocks.length === 0 && (
<Typography color="textSecondary" style={{ marginTop: 20, textAlign: 'center' }}>
No blocks found matching "{searchQuery}"
</Typography>
)}
</List>
)}
</Drawer>
);
};

export default MarketplacePanel;
Loading
Loading