558 lines
18 KiB
TypeScript
558 lines
18 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { getChromeApi } from '../utils/chromeApi';
|
|
import {
|
|
Box,
|
|
Typography,
|
|
Button,
|
|
TextField,
|
|
Paper,
|
|
Alert,
|
|
CircularProgress,
|
|
Divider,
|
|
Tabs,
|
|
Tab,
|
|
List,
|
|
ListItem,
|
|
ListItemText,
|
|
ListItemSecondaryAction,
|
|
IconButton,
|
|
Dialog,
|
|
DialogTitle,
|
|
DialogContent,
|
|
DialogActions,
|
|
Chip
|
|
} from '@mui/material';
|
|
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
|
import VisibilityIcon from '@mui/icons-material/Visibility';
|
|
// DeleteIcon removed as it's not used
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { useSessionStore } from '../store/sessionStore';
|
|
|
|
interface ScriptResult {
|
|
id: string;
|
|
timestamp: number;
|
|
script: string;
|
|
result: string;
|
|
success: boolean;
|
|
}
|
|
|
|
interface PendingScript {
|
|
id: string;
|
|
title: string;
|
|
description: string;
|
|
script: string;
|
|
tags: string[];
|
|
timestamp: number;
|
|
}
|
|
|
|
const ScriptPage = () => {
|
|
const navigate = useNavigate();
|
|
const { isSessionUnlocked, currentKeypair } = useSessionStore();
|
|
|
|
const [tabValue, setTabValue] = useState<number>(0);
|
|
const [scriptInput, setScriptInput] = useState<string>('');
|
|
const [isExecuting, setIsExecuting] = useState<boolean>(false);
|
|
const [executionResult, setExecutionResult] = useState<string | null>(null);
|
|
const [executionSuccess, setExecutionSuccess] = useState<boolean | null>(null);
|
|
const [scriptResults, setScriptResults] = useState<ScriptResult[]>([]);
|
|
const [pendingScripts, setPendingScripts] = useState<PendingScript[]>([]);
|
|
const [selectedPendingScript, setSelectedPendingScript] = useState<PendingScript | null>(null);
|
|
const [scriptDialogOpen, setScriptDialogOpen] = useState<boolean>(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// Redirect if not unlocked
|
|
useEffect(() => {
|
|
if (!isSessionUnlocked) {
|
|
navigate('/');
|
|
}
|
|
}, [isSessionUnlocked, navigate]);
|
|
|
|
// Load pending scripts from storage
|
|
useEffect(() => {
|
|
const loadPendingScripts = async () => {
|
|
try {
|
|
const chromeApi = getChromeApi();
|
|
const data = await chromeApi.storage.local.get('pendingScripts');
|
|
if (data.pendingScripts) {
|
|
setPendingScripts(data.pendingScripts);
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to load pending scripts:', err);
|
|
}
|
|
};
|
|
|
|
if (isSessionUnlocked) {
|
|
loadPendingScripts();
|
|
}
|
|
}, [isSessionUnlocked]);
|
|
|
|
// Load script history from storage
|
|
useEffect(() => {
|
|
const loadScriptResults = async () => {
|
|
try {
|
|
const chromeApi = getChromeApi();
|
|
const data = await chromeApi.storage.local.get('scriptResults');
|
|
if (data.scriptResults) {
|
|
setScriptResults(data.scriptResults);
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to load script results:', err);
|
|
}
|
|
};
|
|
|
|
if (isSessionUnlocked) {
|
|
loadScriptResults();
|
|
}
|
|
}, [isSessionUnlocked]);
|
|
|
|
const handleTabChange = (_: React.SyntheticEvent, newValue: number) => {
|
|
setTabValue(newValue);
|
|
};
|
|
|
|
const handleExecuteScript = async () => {
|
|
if (!scriptInput.trim()) return;
|
|
|
|
setIsExecuting(true);
|
|
setError(null);
|
|
setExecutionResult(null);
|
|
setExecutionSuccess(null);
|
|
|
|
try {
|
|
// Call the WASM run_rhai function via our store
|
|
const result = await useSessionStore.getState().executeScript(scriptInput);
|
|
|
|
setExecutionResult(result);
|
|
setExecutionSuccess(true);
|
|
|
|
// Save to history
|
|
const newResult: ScriptResult = {
|
|
id: `script-${Date.now()}`,
|
|
timestamp: Date.now(),
|
|
script: scriptInput,
|
|
result,
|
|
success: true
|
|
};
|
|
|
|
const updatedResults = [newResult, ...scriptResults].slice(0, 20); // Keep last 20
|
|
setScriptResults(updatedResults);
|
|
|
|
// Save to storage
|
|
const chromeApi = getChromeApi();
|
|
await chromeApi.storage.local.set({ scriptResults: updatedResults });
|
|
} catch (err) {
|
|
setError((err as Error).message || 'Failed to execute script');
|
|
setExecutionSuccess(false);
|
|
setExecutionResult('Execution failed');
|
|
} finally {
|
|
setIsExecuting(false);
|
|
}
|
|
};
|
|
|
|
const handleViewPendingScript = (script: PendingScript) => {
|
|
setSelectedPendingScript(script);
|
|
setScriptDialogOpen(true);
|
|
};
|
|
|
|
const handleApprovePendingScript = async () => {
|
|
if (!selectedPendingScript) return;
|
|
|
|
setScriptDialogOpen(false);
|
|
setScriptInput(selectedPendingScript.script);
|
|
setTabValue(0); // Switch to execute tab
|
|
|
|
// Remove from pending list
|
|
const updatedPendingScripts = pendingScripts.filter(
|
|
script => script.id !== selectedPendingScript.id
|
|
);
|
|
|
|
setPendingScripts(updatedPendingScripts);
|
|
const chromeApi = getChromeApi();
|
|
await chromeApi.storage.local.set({ pendingScripts: updatedPendingScripts });
|
|
setSelectedPendingScript(null);
|
|
};
|
|
|
|
const handleRejectPendingScript = async () => {
|
|
if (!selectedPendingScript) return;
|
|
|
|
// Remove from pending list
|
|
const updatedPendingScripts = pendingScripts.filter(
|
|
script => script.id !== selectedPendingScript.id
|
|
);
|
|
|
|
setPendingScripts(updatedPendingScripts);
|
|
const chromeApi = getChromeApi();
|
|
await chromeApi.storage.local.set({ pendingScripts: updatedPendingScripts });
|
|
|
|
setScriptDialogOpen(false);
|
|
setSelectedPendingScript(null);
|
|
};
|
|
|
|
const handleClearHistory = async () => {
|
|
setScriptResults([]);
|
|
const chromeApi = getChromeApi();
|
|
await chromeApi.storage.local.set({ scriptResults: [] });
|
|
};
|
|
|
|
if (!isSessionUnlocked) {
|
|
return null; // Will redirect via useEffect
|
|
}
|
|
|
|
return (
|
|
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
|
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
|
|
<Tabs
|
|
value={tabValue}
|
|
onChange={handleTabChange}
|
|
aria-label="script tabs"
|
|
variant="scrollable"
|
|
scrollButtons="auto"
|
|
allowScrollButtonsMobile
|
|
sx={{ minHeight: '48px' }}
|
|
>
|
|
<Tab label="Execute" sx={{ minHeight: '48px', py: 0 }} />
|
|
<Tab
|
|
label={
|
|
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
|
Pending
|
|
{pendingScripts.length > 0 && (
|
|
<Chip
|
|
label={pendingScripts.length}
|
|
size="small"
|
|
color="primary"
|
|
sx={{ ml: 1 }}
|
|
/>
|
|
)}
|
|
</Box>
|
|
}
|
|
sx={{ minHeight: '48px', py: 0 }}
|
|
/>
|
|
<Tab label="History" sx={{ minHeight: '48px', py: 0 }} />
|
|
</Tabs>
|
|
</Box>
|
|
|
|
{/* Execute Tab */}
|
|
{tabValue === 0 && (
|
|
<Box sx={{
|
|
p: 2,
|
|
flexGrow: 1,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
overflow: 'hidden',
|
|
height: 'calc(100% - 48px)' // Subtract tab height
|
|
}}>
|
|
<Box sx={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
overflow: 'auto',
|
|
height: '100%',
|
|
pb: 2 // Add padding at bottom for scrolling
|
|
}}>
|
|
{!currentKeypair && (
|
|
<Alert severity="warning" sx={{ mb: 2 }}>
|
|
No keypair selected. Select a keypair to enable script execution with signing capabilities.
|
|
</Alert>
|
|
)}
|
|
|
|
{error && (
|
|
<Alert severity="error" sx={{ mb: 2 }}>
|
|
{error}
|
|
</Alert>
|
|
)}
|
|
|
|
<TextField
|
|
label="Rhai Script"
|
|
multiline
|
|
rows={6} // Reduced from 8 to leave more space for results
|
|
value={scriptInput}
|
|
onChange={(e) => setScriptInput(e.target.value)}
|
|
fullWidth
|
|
variant="outlined"
|
|
placeholder="Enter your Rhai script here..."
|
|
sx={{ mb: 2 }}
|
|
disabled={isExecuting}
|
|
/>
|
|
|
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 2 }}>
|
|
<Button
|
|
variant="contained"
|
|
color="primary"
|
|
startIcon={<PlayArrowIcon />}
|
|
onClick={handleExecuteScript}
|
|
disabled={isExecuting || !scriptInput.trim()}
|
|
>
|
|
{isExecuting ? <CircularProgress size={24} /> : 'Execute'}
|
|
</Button>
|
|
</Box>
|
|
|
|
{executionResult && (
|
|
<Paper
|
|
variant="outlined"
|
|
sx={{
|
|
p: 2,
|
|
bgcolor: executionSuccess ? 'success.dark' : 'error.dark',
|
|
color: 'white',
|
|
overflowY: 'auto',
|
|
mb: 2, // Add margin at bottom
|
|
minHeight: '100px', // Ensure minimum height for visibility
|
|
maxHeight: '200px' // Limit maximum height
|
|
}}
|
|
>
|
|
<Typography variant="subtitle2" gutterBottom>
|
|
Execution Result:
|
|
</Typography>
|
|
<Typography
|
|
variant="body2"
|
|
component="pre"
|
|
sx={{
|
|
whiteSpace: 'pre-wrap',
|
|
wordBreak: 'break-word',
|
|
fontFamily: 'monospace'
|
|
}}
|
|
>
|
|
{executionResult}
|
|
</Typography>
|
|
</Paper>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
)}
|
|
|
|
{/* Pending Scripts Tab */}
|
|
{tabValue === 1 && (
|
|
<Box sx={{ p: 2, flexGrow: 1, display: 'flex', flexDirection: 'column' }}>
|
|
{pendingScripts.length === 0 ? (
|
|
<Paper sx={{ p: 3, textAlign: 'center' }}>
|
|
<Typography variant="body1" color="text.secondary">
|
|
No pending scripts. Incoming scripts from connected WebSocket servers will appear here.
|
|
</Typography>
|
|
</Paper>
|
|
) : (
|
|
<Paper variant="outlined" sx={{ flexGrow: 1, overflow: 'auto' }}>
|
|
<List disablePadding>
|
|
{pendingScripts.map((script, index) => (
|
|
<Box key={script.id}>
|
|
{index > 0 && <Divider />}
|
|
<ListItem>
|
|
<ListItemText
|
|
primary={script.title}
|
|
secondary={
|
|
<>
|
|
<Typography variant="body2" color="text.secondary">
|
|
{script.description || 'No description'}
|
|
</Typography>
|
|
<Box sx={{ mt: 0.5 }}>
|
|
{script.tags.map(tag => (
|
|
<Chip
|
|
key={tag}
|
|
label={tag}
|
|
size="small"
|
|
color={tag === 'remote' ? 'secondary' : 'primary'}
|
|
variant="outlined"
|
|
sx={{ mr: 0.5 }}
|
|
/>
|
|
))}
|
|
</Box>
|
|
</>
|
|
}
|
|
/>
|
|
<ListItemSecondaryAction>
|
|
<IconButton
|
|
edge="end"
|
|
onClick={() => handleViewPendingScript(script)}
|
|
aria-label="view script"
|
|
>
|
|
<VisibilityIcon />
|
|
</IconButton>
|
|
</ListItemSecondaryAction>
|
|
</ListItem>
|
|
</Box>
|
|
))}
|
|
</List>
|
|
</Paper>
|
|
)}
|
|
</Box>
|
|
)}
|
|
|
|
{/* History Tab */}
|
|
{tabValue === 2 && (
|
|
<Box sx={{
|
|
p: 2,
|
|
flexGrow: 1,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
overflow: 'hidden',
|
|
height: 'calc(100% - 48px)' // Subtract tab height
|
|
}}>
|
|
<Box sx={{
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
overflow: 'auto',
|
|
height: '100%',
|
|
pb: 2 // Add padding at bottom for scrolling
|
|
}}>
|
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 2 }}>
|
|
<Button
|
|
variant="outlined"
|
|
color="error"
|
|
size="small"
|
|
onClick={handleClearHistory}
|
|
disabled={scriptResults.length === 0}
|
|
>
|
|
Clear History
|
|
</Button>
|
|
</Box>
|
|
|
|
{scriptResults.length === 0 ? (
|
|
<Paper sx={{ p: 3, textAlign: 'center' }}>
|
|
<Typography variant="body1" color="text.secondary">
|
|
No script execution history yet.
|
|
</Typography>
|
|
</Paper>
|
|
) : (
|
|
<Paper variant="outlined" sx={{ flexGrow: 1, overflow: 'auto' }}>
|
|
<List disablePadding>
|
|
{scriptResults.map((result, index) => (
|
|
<Box key={result.id}>
|
|
{index > 0 && <Divider />}
|
|
<ListItem>
|
|
<ListItemText
|
|
primary={
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
<Typography variant="subtitle2">
|
|
{new Date(result.timestamp).toLocaleString()}
|
|
</Typography>
|
|
<Chip
|
|
label={result.success ? 'Success' : 'Failed'}
|
|
size="small"
|
|
color={result.success ? 'success' : 'error'}
|
|
variant="outlined"
|
|
/>
|
|
</Box>
|
|
}
|
|
secondary={
|
|
<Typography
|
|
variant="body2"
|
|
color="text.secondary"
|
|
sx={{
|
|
whiteSpace: 'nowrap',
|
|
overflow: 'hidden',
|
|
textOverflow: 'ellipsis',
|
|
maxWidth: '280px'
|
|
}}
|
|
>
|
|
{result.script}
|
|
</Typography>
|
|
}
|
|
/>
|
|
<ListItemSecondaryAction>
|
|
<IconButton
|
|
edge="end"
|
|
onClick={() => {
|
|
setScriptInput(result.script);
|
|
setTabValue(0);
|
|
}}
|
|
aria-label="reuse script"
|
|
>
|
|
<PlayArrowIcon />
|
|
</IconButton>
|
|
</ListItemSecondaryAction>
|
|
</ListItem>
|
|
</Box>
|
|
))}
|
|
</List>
|
|
</Paper>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
)}
|
|
|
|
{/* Pending Script Dialog */}
|
|
<Dialog
|
|
open={scriptDialogOpen}
|
|
onClose={() => setScriptDialogOpen(false)}
|
|
maxWidth="md"
|
|
fullWidth
|
|
>
|
|
<DialogTitle>
|
|
{selectedPendingScript?.title || 'Script Details'}
|
|
</DialogTitle>
|
|
<DialogContent>
|
|
{selectedPendingScript && (
|
|
<>
|
|
<Typography variant="subtitle2" gutterBottom>
|
|
Description:
|
|
</Typography>
|
|
<Typography variant="body2" paragraph>
|
|
{selectedPendingScript.description || 'No description provided'}
|
|
</Typography>
|
|
|
|
<Box sx={{ mb: 2 }}>
|
|
{selectedPendingScript.tags.map(tag => (
|
|
<Chip
|
|
key={tag}
|
|
label={tag}
|
|
size="small"
|
|
color={tag === 'remote' ? 'secondary' : 'primary'}
|
|
sx={{ mr: 0.5 }}
|
|
/>
|
|
))}
|
|
</Box>
|
|
|
|
<Typography variant="subtitle2" gutterBottom>
|
|
Script Content:
|
|
</Typography>
|
|
<Paper
|
|
variant="outlined"
|
|
sx={{
|
|
p: 2,
|
|
bgcolor: 'background.paper',
|
|
maxHeight: '300px',
|
|
overflow: 'auto'
|
|
}}
|
|
>
|
|
<Typography
|
|
variant="body2"
|
|
component="pre"
|
|
sx={{
|
|
whiteSpace: 'pre-wrap',
|
|
wordBreak: 'break-word',
|
|
fontFamily: 'monospace'
|
|
}}
|
|
>
|
|
{selectedPendingScript.script}
|
|
</Typography>
|
|
</Paper>
|
|
|
|
<Alert severity="warning" sx={{ mt: 2 }}>
|
|
<Typography variant="body2">
|
|
{selectedPendingScript.tags.includes('remote')
|
|
? 'This is a remote script. If approved, your signature will be sent to the server and the script may execute remotely.'
|
|
: 'This script will execute locally in your browser extension if approved.'}
|
|
</Typography>
|
|
</Alert>
|
|
</>
|
|
)}
|
|
</DialogContent>
|
|
<DialogActions>
|
|
<Button
|
|
onClick={handleRejectPendingScript}
|
|
color="error"
|
|
variant="outlined"
|
|
>
|
|
Reject
|
|
</Button>
|
|
<Button
|
|
onClick={handleApprovePendingScript}
|
|
color="primary"
|
|
variant="contained"
|
|
>
|
|
Approve
|
|
</Button>
|
|
</DialogActions>
|
|
</Dialog>
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
export default ScriptPage;
|