+
Binary File
+
+ {activeFile.name} (
+ {activeFile.content instanceof Blob
+ ? `${activeFile.content.size} bytes`
+ : "ArrayBuffer"}
+ )
+
+
Cannot edit binary files.
+
+ )
) : (
Select a file to start editing
@@ -853,6 +887,7 @@ export default function ScapeEditor() {
isCollapsed={false}
files={files}
scapeName={scape?.name}
+ scapeId={id}
onDeleteFile={deleteFileDirectly}
outputLogs={outputLogs}
onExecCommand={handleExecCommand}
@@ -872,6 +907,7 @@ export default function ScapeEditor() {
isCollapsed={true}
files={files}
scapeName={scape?.name}
+ scapeId={id}
onDeleteFile={deleteFileDirectly}
outputLogs={outputLogs}
onExecCommand={handleExecCommand}
diff --git a/src/runners/python/PythonRunner.tsx b/src/runners/python/PythonRunner.tsx
index c642cd18..c0638703 100644
--- a/src/runners/python/PythonRunner.tsx
+++ b/src/runners/python/PythonRunner.tsx
@@ -234,6 +234,7 @@ export const PythonRunner = memo(
files: currentFiles.map((f) => ({
name: f.name,
content: f.content,
+ language: f.language,
})),
entryPoint,
},
diff --git a/src/runners/python/worker.ts b/src/runners/python/worker.ts
index ed1ed2fd..9e2e0c88 100644
--- a/src/runners/python/worker.ts
+++ b/src/runners/python/worker.ts
@@ -51,10 +51,19 @@ const loadPyodide = async (): Promise
=> {
try {
await pyodide.loadPackage(["micropip"])
} catch {
+ // Fallback or retry
await pyodide.loadPackage(["micropip"])
}
- // Verify micropip loading
+ // Install and patch pyodide-http to enable 'requests'
+ await pyodide.runPythonAsync(`
+ import micropip
+ await micropip.install('pyodide-http')
+ import pyodide_http
+ pyodide_http.patch_all()
+ `)
+
+ // Verify imports (optional but good for stability)
await pyodide.runPythonAsync(`
import sys
import importlib
@@ -202,9 +211,42 @@ self.onmessage = async (e: MessageEvent) => {
builtins.input = _input
`)
- // 1. Write files to Virtual FS
+ // 1. Clean up Virtual FS (Remove old user files)
+ // Safer and Easier: Use Python to clean up the current directory
+ // This handles recursion and permissions cleanly and avoids missing definitions in PyodideInterface
+ await py.runPythonAsync(`
+ import os
+ import shutil
+
+ # Keep internal pyodide stuff if any, generally safe to wipe .
+ for item in os.listdir('.'):
+ if item in ['.', '..']: continue
+ try:
+ if os.path.isfile(item) or os.path.islink(item):
+ os.unlink(item)
+ elif os.path.isdir(item):
+ shutil.rmtree(item)
+ except Exception as e:
+ pass
+ `)
+
+ // 2. Write files to Virtual FS
postMessage({ type: "STATUS", payload: "Writing files..." })
+
+ // Sort files so folders/parents come first if possible, though mkdir -p handles it.
+ // Actually, standard naive approach works if we differentiate folders.
+
for (const file of files) {
+ if (file.language === "folder") {
+ try {
+ py.FS.mkdir(file.name)
+ } catch {
+ // Ignore if exists
+ }
+ continue
+ }
+
+ // It's a file
const parts = file.name.split("/")
if (parts.length > 1) {
let currentPath = ""
@@ -217,7 +259,22 @@ self.onmessage = async (e: MessageEvent) => {
}
}
}
- py.FS.writeFile(file.name, file.content)
+
+ let contentToWrite = file.content
+ if (contentToWrite instanceof ArrayBuffer) {
+ contentToWrite = new Uint8Array(contentToWrite)
+ }
+
+ // Ensure we don't overwrite a directory with a file of same name (shouldn't happen with valid tree)
+ try {
+ py.FS.writeFile(file.name, contentToWrite)
+ } catch (err: any) {
+ // If 'FS error', it might be because a directory exists at this path?
+ // Or parent missing?
+ // We already tried to create parents.
+ console.warn(`Failed to write ${file.name}: ${err.message}`)
+ // Don't throw, let other files try to write
+ }
}
// 2. Run Entry Point
diff --git a/src/runners/web/WebRunner.tsx b/src/runners/web/WebRunner.tsx
index a7fccddf..aa42a5c2 100644
--- a/src/runners/web/WebRunner.tsx
+++ b/src/runners/web/WebRunner.tsx
@@ -89,38 +89,98 @@ export const WebRunner = memo(
}
}, [files, activePreviewPath])
- // 1. Create Blob URLs for all files
- const [blobUrls, setBlobUrls] = useState>({})
+ // 1. Create Data URLs for all files (Support Credentialless Iframe)
+ const [assetUrls, setAssetUrls] = useState>({})
useEffect(() => {
- const newUrls: Record = {}
-
- files.forEach((file) => {
- let content = file.content
- let type = "text/javascript"
+ let isMounted = true
+
+ const generateUrls = async () => {
+ const rawAssets: Record = {}
+ const processedAssets: Record = {}
+
+ // 1. Process Leaf Assets (Images, Fonts, WASM, JSON, etc.)
+ // These have no dependencies.
+ const leafFiles = files.filter(
+ (f) => !f.name.endsWith(".css") && !f.name.endsWith(".js") && !f.name.endsWith(".html")
+ )
+ const leafPromises = leafFiles.map(async (file) => {
+ let type = "application/octet-stream"
+ if (file.name.endsWith(".png")) type = "image/png"
+ else if (file.name.endsWith(".jpg") || file.name.endsWith(".jpeg")) type = "image/jpeg"
+ else if (file.name.endsWith(".svg")) type = "image/svg+xml"
+ else if (file.name.endsWith(".gif")) type = "image/gif"
+ else if (file.name.endsWith(".webp")) type = "image/webp"
+ else if (file.name.endsWith(".wasm")) type = "application/wasm"
+ else if (file.name.endsWith(".json")) type = "application/json"
+
+ let blob: Blob
+ if (typeof file.content === "string") {
+ blob = new Blob([file.content], { type })
+ } else {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ blob = new Blob([file.content as any], { type })
+ }
- if (file.name.endsWith(".js")) {
- // Rewrite relative imports to bare imports
+ return new Promise((resolve) => {
+ const reader = new FileReader()
+ reader.onloadend = () => {
+ if (reader.result && typeof reader.result === "string") {
+ rawAssets[file.name] = reader.result
+ }
+ resolve()
+ }
+ reader.readAsDataURL(blob)
+ })
+ })
+ await Promise.all(leafPromises)
+
+ // 2. Process CSS (Inject Leaf Assets)
+ const cssFiles = files.filter((f) => f.name.endsWith(".css"))
+ cssFiles.forEach((file) => {
+ let content = file.content as string
+ // Replace url('./asset.png') or url("asset.png") with Data URI
+ content = content.replace(
+ /url\(\s*['"]?(\.\/)?([^'")]+)['"]?\s*\)/gi,
+ (match, _dotSlash, name) => {
+ const cleanName = name // The regex group 2 captures the name
+ if (rawAssets[cleanName]) return `url('${rawAssets[cleanName]}')`
+ return match
+ }
+ )
+ // Convert to Base64
+ const encoded = btoa(unescape(encodeURIComponent(content)))
+ processedAssets[file.name] = `data:text/css;base64,${encoded}`
+ })
+
+ // 3. Process JS (Imports)
+ const jsFiles = files.filter((f) => f.name.endsWith(".js"))
+ jsFiles.forEach((file) => {
+ let content = file.content as string
+ // Try to handle imports?
+ // Ideally we replace imports with Data URIs too, but circular deps are hard.
+ // For now, simpler: just bare imports or blob imports if we had them.
+ // Since we use importmap, we don't need to replace imports in content generally,
+ // EXCEPT relative paths if we want them to work without importmap magic.
+ // But map handles "file.js".
+ // We do need to handle other assets in JS? No, usually handled by runtime fetch, which we can't easily patch inside strict syntax.
+
+ // Just rewrite relative imports for Module system
content = content.replace(/from\s+['"]\.\/([^'"]+)['"]/g, "from '$1'")
content = content.replace(/import\s+['"]\.\/([^'"]+)['"]/g, "import '$1'")
- }
-
- if (file.name.endsWith(".css")) type = "text/css"
- if (file.name.endsWith(".html")) type = "text/html"
- const blob = new Blob([content], { type })
- newUrls[file.name] = URL.createObjectURL(blob)
- })
+ const encoded = btoa(unescape(encodeURIComponent(content)))
+ processedAssets[file.name] = `data:text/javascript;base64,${encoded}`
+ })
- // eslint-disable-next-line react-hooks/set-state-in-effect
- setBlobUrls(newUrls)
+ if (isMounted) {
+ setAssetUrls({ ...rawAssets, ...processedAssets })
+ }
+ }
- // Cleanup with delay to prevent 404s on rapid reloads
+ generateUrls()
return () => {
- const urlsToRevoke = Object.values(newUrls)
- setTimeout(() => {
- urlsToRevoke.forEach((url) => URL.revokeObjectURL(url))
- }, 5000)
+ isMounted = false
}
}, [files])
@@ -131,14 +191,18 @@ export const WebRunner = memo(
return `File '${activePreviewPath}' not found
`
}
+ if (typeof htmlFile.content !== "string") {
+ return `Cannot render binary file '${activePreviewPath}' as HTML
`
+ }
+
// Build Import Map
const imports: Record = {
three: "https://esm.sh/three@0.160.0",
"three/": "https://esm.sh/three@0.160.0/",
}
- // Map bare filenames to Blob URLs
- Object.entries(blobUrls).forEach(([name, url]) => {
+ // Map bare filenames to Data URLs
+ Object.entries(assetUrls).forEach(([name, url]) => {
if (name.endsWith(".js")) {
imports[name] = url
}
@@ -146,7 +210,7 @@ export const WebRunner = memo(
const importMap = { imports }
- let processedHtml = htmlFile.content
+ let processedHtml = htmlFile.content as string
// Inject Import Map, WebGL Shim, and Navigation Interceptor
const importMapScript = `
@@ -199,32 +263,75 @@ export const WebRunner = memo(
processedHtml = importMapScript + processedHtml
}
- // Inject CSS Link Replacements
+ // Generic Replacer for src, href, and url()
+ const replaceUrl = (name: string) => {
+ // Strictly ignore external URLs and data URIs
+ if (
+ name.startsWith("http://") ||
+ name.startsWith("https://") ||
+ name.startsWith("//") ||
+ name.startsWith("data:") ||
+ name.startsWith("blob:")
+ ) {
+ return name
+ }
+
+ const cleanName = name.replace(/^\.\//, "")
+ if (assetUrls[cleanName]) return assetUrls[cleanName]
+ return name // No match, return original
+ }
+
+ // Inject CSS Link Replacements (href)
processedHtml = processedHtml.replace(
/]*href=["']([^"']+)["'][^>]*>/gi,
(match, href) => {
- const cleanName = href.replace(/^\.\//, "")
- if (blobUrls[cleanName] && cleanName.endsWith(".css")) {
- return ``
- }
- return match
+ const newUrl = replaceUrl(href)
+ return match.replace(href, newUrl)
}
)
- // Inject JS Script Replacements
+ // Inject JS Script Replacements (src)
processedHtml = processedHtml.replace(
/