From 3adce435eeed011a128c62487f8c21830869ae2b Mon Sep 17 00:00:00 2001
From: Nathen Fredrick <34571268+Soopahfly@users.noreply.github.com>
Date: Wed, 22 Apr 2026 10:14:42 +0100
Subject: [PATCH] feat: add embedded GCode viewer (#963)
* feat: add embedded GCode viewer
Adds PrettyGCode as a built-in GCode visualiser embedded directly in the
Bambuddy layout, so users can preview and inspect GCode files without
leaving the dashboard.
---
backend/app/api/routes/library.py | 15 +-
backend/app/main.py | 74 +-
.../tests/integration/test_gcode_viewer.py | 85 +
.../unit/test_library_file_path_guard.py | 115 +
frontend/src/App.tsx | 2 +
frontend/src/components/Layout.tsx | 3 +-
frontend/src/i18n/locales/de.ts | 4 +
frontend/src/i18n/locales/en.ts | 4 +
frontend/src/i18n/locales/fr.ts | 4 +
frontend/src/i18n/locales/it.ts | 4 +
frontend/src/i18n/locales/ja.ts | 4 +
frontend/src/i18n/locales/pt-BR.ts | 4 +
frontend/src/i18n/locales/zh-CN.ts | 4 +
frontend/src/i18n/locales/zh-TW.ts | 4 +
frontend/src/pages/GCodeViewerPage.tsx | 28 +
frontend/vite.config.ts | 69 +-
gcode_viewer/VENDORED.md | 60 +
gcode_viewer/css/prettygcode.css | 417 ++
gcode_viewer/index.html | 264 +
gcode_viewer/js/Line2.js | 31 +
gcode_viewer/js/LineGeometry.js | 104 +
gcode_viewer/js/LineMaterial.js | 391 ++
gcode_viewer/js/LineSegments2.js | 65 +
gcode_viewer/js/LineSegmentsGeometry.js | 258 +
gcode_viewer/js/Lut.js | 185 +
gcode_viewer/js/OBJLoader.js | 797 +++
gcode_viewer/js/bambuddy_adapter.js | 877 +++
gcode_viewer/js/camera-controls.js | 1070 ++++
gcode_viewer/js/dat.gui.js | 2559 ++++++++
gcode_viewer/js/helvetiker_bold.typeface.json | 1 +
gcode_viewer/js/jquery.min.js | 2 +
gcode_viewer/js/models/ExtruderNozzle.mtl | 8 +
gcode_viewer/js/models/ExtruderNozzle.obj | 5282 +++++++++++++++++
gcode_viewer/js/prettygcode.js | 1932 ++++++
gcode_viewer/js/slider-shim.js | 142 +
gcode_viewer/js/three.min.js | 996 ++++
.../{index-aHxaU9HU.js => index-BfEnlXcp.js} | 96 +-
static/index.html | 2 +-
38 files changed, 15895 insertions(+), 67 deletions(-)
create mode 100644 backend/tests/integration/test_gcode_viewer.py
create mode 100644 backend/tests/unit/test_library_file_path_guard.py
create mode 100644 frontend/src/pages/GCodeViewerPage.tsx
create mode 100644 gcode_viewer/VENDORED.md
create mode 100644 gcode_viewer/css/prettygcode.css
create mode 100644 gcode_viewer/index.html
create mode 100644 gcode_viewer/js/Line2.js
create mode 100644 gcode_viewer/js/LineGeometry.js
create mode 100644 gcode_viewer/js/LineMaterial.js
create mode 100644 gcode_viewer/js/LineSegments2.js
create mode 100644 gcode_viewer/js/LineSegmentsGeometry.js
create mode 100644 gcode_viewer/js/Lut.js
create mode 100644 gcode_viewer/js/OBJLoader.js
create mode 100644 gcode_viewer/js/bambuddy_adapter.js
create mode 100644 gcode_viewer/js/camera-controls.js
create mode 100644 gcode_viewer/js/dat.gui.js
create mode 100644 gcode_viewer/js/helvetiker_bold.typeface.json
create mode 100644 gcode_viewer/js/jquery.min.js
create mode 100644 gcode_viewer/js/models/ExtruderNozzle.mtl
create mode 100644 gcode_viewer/js/models/ExtruderNozzle.obj
create mode 100644 gcode_viewer/js/prettygcode.js
create mode 100644 gcode_viewer/js/slider-shim.js
create mode 100644 gcode_viewer/js/three.min.js
rename static/assets/{index-aHxaU9HU.js => index-BfEnlXcp.js} (82%)
diff --git a/backend/app/api/routes/library.py b/backend/app/api/routes/library.py
index e9811fa65..2194c2260 100644
--- a/backend/app/api/routes/library.py
+++ b/backend/app/api/routes/library.py
@@ -104,11 +104,20 @@ def to_absolute_path(relative_path: str | None) -> Path | None:
"""Convert a relative path (from database) to an absolute path for file operations."""
if not relative_path:
return None
- # Handle already-absolute paths (for backwards compatibility during migration)
path = Path(relative_path)
+ # Handle already-absolute paths verbatim (backwards compatibility during migration).
+ # Legacy DB rows may store absolute paths that predate the base_dir layout; the
+ # traversal guard below only applies to relative paths coming from user input.
if path.is_absolute():
- return path
- return Path(app_settings.base_dir) / relative_path
+ return path.resolve()
+ base = Path(app_settings.base_dir).resolve()
+ resolved = (base / relative_path).resolve()
+ # Guard against path traversal — resolved path must stay inside base_dir.
+ # Use is_relative_to() to avoid the /data/app vs /data/app_evil prefix confusion
+ # that a plain startswith(str(base)) check would miss.
+ if not resolved.is_relative_to(base):
+ raise ValueError(f"Path escapes base directory: {relative_path!r}")
+ return resolved
def calculate_file_hash(file_path: Path) -> str:
diff --git a/backend/app/main.py b/backend/app/main.py
index 533d17938..c2c14148b 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -1,5 +1,6 @@
import asyncio
import logging
+import mimetypes as _mimetypes
import posixpath
import time
from contextlib import asynccontextmanager
@@ -4331,19 +4332,37 @@ async def security_headers_middleware(request, call_next):
# - img-src data: / blob:: base64 thumbnails and Blob-URL timelapse previews.
# - media-src blob:: timelapse video player uses Blob URLs.
# - font-src data:: some icon fonts are embedded as data URIs.
- response.headers["Content-Security-Policy"] = (
- "default-src 'self'; "
- "script-src 'self'; "
- "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
- "img-src 'self' data: blob:; "
- "media-src 'self' blob:; "
- "connect-src 'self' ws: wss:; "
- "font-src 'self' data: https://fonts.gstatic.com; "
- "object-src 'none'; "
- "base-uri 'self'; "
- "frame-src 'self' http: https:; "
- "frame-ancestors 'none';"
- )
+ if request.url.path.startswith("/gcode-viewer"):
+ # The gcode viewer is embedded in an iframe served by this same origin,
+ # so frame-ancestors must allow 'self'. prettygcode.js also uses eval()
+ # internally, so script-src needs 'unsafe-eval'.
+ response.headers["Content-Security-Policy"] = (
+ "default-src 'self'; "
+ "script-src 'self' 'unsafe-eval'; "
+ "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
+ "img-src 'self' data: blob:; "
+ "media-src 'self' blob:; "
+ "connect-src 'self' ws: wss:; "
+ "font-src 'self' data: https://fonts.gstatic.com; "
+ "object-src 'none'; "
+ "base-uri 'self'; "
+ "frame-src 'self' http: https:; "
+ "frame-ancestors 'self';"
+ )
+ else:
+ response.headers["Content-Security-Policy"] = (
+ "default-src 'self'; "
+ "script-src 'self'; "
+ "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; "
+ "img-src 'self' data: blob:; "
+ "media-src 'self' blob:; "
+ "connect-src 'self' ws: wss:; "
+ "font-src 'self' data: https://fonts.gstatic.com; "
+ "object-src 'none'; "
+ "base-uri 'self'; "
+ "frame-src 'self' http: https:; "
+ "frame-ancestors 'none';"
+ )
if request.url.scheme == "https":
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
return response
@@ -4591,6 +4610,35 @@ async def serve_sw_register():
return {"error": "sw-register.js not found"}
+# ── GCode viewer static files ────────────────────────────────────────────────
+# Served via explicit routes so ordering is guaranteed (app.mount() loses
+# to the /{full_path:path} catch-all in some Starlette versions).
+_gcode_viewer_dir = (app_settings.static_dir.parent / "gcode_viewer").resolve()
+
+
+def _gcode_viewer_response(rel: str) -> FileResponse:
+ from fastapi import HTTPException as _HTTPException
+
+ safe = (_gcode_viewer_dir / rel).resolve()
+ if not safe.is_relative_to(_gcode_viewer_dir):
+ raise _HTTPException(status_code=403)
+ if safe.is_file():
+ mt, _ = _mimetypes.guess_type(str(safe))
+ return FileResponse(str(safe), media_type=mt or "application/octet-stream")
+ raise _HTTPException(status_code=404)
+
+
+@app.get("/gcode-viewer")
+@app.get("/gcode-viewer/")
+async def serve_gcode_viewer_index() -> FileResponse:
+ return _gcode_viewer_response("index.html")
+
+
+@app.get("/gcode-viewer/{file_path:path}")
+async def serve_gcode_viewer_file(file_path: str) -> FileResponse:
+ return _gcode_viewer_response(file_path)
+
+
# Catch-all route for React Router (must be last)
@app.get("/{full_path:path}")
async def serve_spa(full_path: str):
diff --git a/backend/tests/integration/test_gcode_viewer.py b/backend/tests/integration/test_gcode_viewer.py
new file mode 100644
index 000000000..33e274635
--- /dev/null
+++ b/backend/tests/integration/test_gcode_viewer.py
@@ -0,0 +1,85 @@
+"""Integration tests for the /gcode-viewer static-file routes.
+
+Covers two behaviours added by the GCode viewer PR:
+
+1. Route ordering — /gcode-viewer/* is served by explicit @app.get routes
+ that are registered before the /{full_path:path} SPA catch-all, so the
+ GCode viewer is never accidentally served the React app HTML.
+
+2. Path-traversal guard — requests for paths that escape gcode_viewer/
+ (e.g. /gcode-viewer/../main.py) must return 403, not the file contents.
+"""
+
+import pytest
+from httpx import AsyncClient
+
+
+class TestGCodeViewerRouteOrdering:
+ """Verify the /gcode-viewer routes are reachable and distinct from the SPA."""
+
+ @pytest.mark.asyncio
+ @pytest.mark.integration
+ async def test_gcode_viewer_index_does_not_fall_through_to_spa(
+ self, async_client: AsyncClient
+ ):
+ """GET /gcode-viewer/ must not return the React SPA index.html.
+
+ If route ordering is broken the SPA catch-all returns 200 with
+ Content-Type: text/html and a
body. The correct
+ response is either 200 (gcode_viewer/index.html present) or 404
+ (directory absent in CI) — never the SPA shell.
+ """
+ response = await async_client.get("/gcode-viewer/")
+ # 200 or 404 are both acceptable depending on whether gcode_viewer/
+ # exists in the test environment; the SPA catch-all always returns 200.
+ assert response.status_code in (200, 404)
+ # If a body came back it must NOT be the React SPA shell.
+ assert b'
' not in response.content
+
+ @pytest.mark.asyncio
+ @pytest.mark.integration
+ async def test_gcode_viewer_no_trailing_slash_redirects_or_responds(
+ self, async_client: AsyncClient
+ ):
+ """GET /gcode-viewer (no trailing slash) is handled by the explicit route."""
+ response = await async_client.get("/gcode-viewer", follow_redirects=True)
+ assert response.status_code in (200, 404)
+ assert b'
' not in response.content
+
+
+class TestGCodeViewerPathTraversal:
+ """Verify the path-traversal guard on /gcode-viewer/{file_path:path}.
+
+ HTTP clients (and servers) normalise plain `..` segments before the
+ request reaches a route handler, so `/gcode-viewer/../x` becomes `/x`
+ and hits the SPA catch-all rather than our guard — that normalisation is
+ itself a defence layer. The actual at-risk form is URL-encoded dots
+ (`%2E%2E`) which survive normalisation and land in {file_path:path} as
+ the literal string `../x`. We test that form here.
+ """
+
+ @pytest.mark.asyncio
+ @pytest.mark.integration
+ async def test_encoded_dotdot_traversal_is_forbidden(self, async_client: AsyncClient):
+ """GET /gcode-viewer/%2E%2E/main.py must return 403.
+
+ %2E%2E URL-decodes to .. which is not normalised away by httpx/
+ Starlette, so it reaches _gcode_viewer_response as '../main.py'.
+ Path.is_relative_to(gcode_viewer_dir) then blocks it with 403.
+ """
+ response = await async_client.get("/gcode-viewer/%2E%2E/main.py")
+ assert response.status_code == 403
+
+ @pytest.mark.asyncio
+ @pytest.mark.integration
+ async def test_encoded_nested_dotdot_traversal_is_forbidden(self, async_client: AsyncClient):
+ """GET /gcode-viewer/js/%2E%2E/%2E%2E/main.py must return 403."""
+ response = await async_client.get("/gcode-viewer/js/%2E%2E/%2E%2E/main.py")
+ assert response.status_code == 403
+
+ @pytest.mark.asyncio
+ @pytest.mark.integration
+ async def test_nonexistent_safe_path_returns_404(self, async_client: AsyncClient):
+ """A safe but nonexistent path returns 404, not 403."""
+ response = await async_client.get("/gcode-viewer/does-not-exist.js")
+ assert response.status_code == 404
diff --git a/backend/tests/unit/test_library_file_path_guard.py b/backend/tests/unit/test_library_file_path_guard.py
new file mode 100644
index 000000000..dd239538c
--- /dev/null
+++ b/backend/tests/unit/test_library_file_path_guard.py
@@ -0,0 +1,115 @@
+"""Tests for to_absolute_path() path-traversal guard in library routes.
+
+Covers three behaviours added/changed by the GCode viewer PR:
+
+1. Relative paths that escape base_dir are rejected with ValueError.
+2. Path.is_relative_to() is used instead of startswith(str(base)),
+ avoiding the /data/app vs /data/app_evil prefix-confusion bug.
+3. Legacy absolute paths (pre-migration DB rows) are returned verbatim
+ instead of raising ValueError.
+"""
+
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+
+def _call(relative_path, base_dir):
+ """Call to_absolute_path with base_dir patched to *base_dir*."""
+ from backend.app.api.routes.library import to_absolute_path
+
+ with patch("backend.app.api.routes.library.app_settings") as mock_settings:
+ mock_settings.base_dir = str(base_dir)
+ return to_absolute_path(relative_path)
+
+
+# ---------------------------------------------------------------------------
+# None / empty guard
+# ---------------------------------------------------------------------------
+
+
+class TestNullInputs:
+ def test_none_returns_none(self, tmp_path):
+ assert _call(None, tmp_path) is None
+
+ def test_empty_string_returns_none(self, tmp_path):
+ assert _call("", tmp_path) is None
+
+
+# ---------------------------------------------------------------------------
+# Relative path traversal guard
+# ---------------------------------------------------------------------------
+
+
+class TestRelativePathTraversal:
+ def test_normal_relative_path_resolves(self, tmp_path):
+ """A safe relative path resolves to base_dir / rel."""
+ base = tmp_path / "data"
+ base.mkdir()
+ result = _call("files/model.gcode", base)
+ assert result == (base / "files" / "model.gcode").resolve()
+
+ def test_traversal_via_dotdot_raises(self, tmp_path):
+ """../etc/passwd must be rejected."""
+ base = tmp_path / "data"
+ base.mkdir()
+ with pytest.raises(ValueError, match="escapes base directory"):
+ _call("../etc/passwd", base)
+
+ def test_traversal_via_nested_dotdot_raises(self, tmp_path):
+ """files/../../etc/passwd must be rejected."""
+ base = tmp_path / "data"
+ base.mkdir()
+ with pytest.raises(ValueError, match="escapes base directory"):
+ _call("files/../../etc/passwd", base)
+
+ def test_prefix_confusion_is_blocked(self, tmp_path):
+ """Ensure /data/app_evil/secret is not permitted when base is /data/app.
+
+ A naive startswith(str(base)) check would allow this because
+ '/data/app_evil'.startswith('/data/app') is True.
+ Path.is_relative_to() must be used instead.
+ """
+ # Simulate: base = /tmp/.../data_app, sibling = /tmp/.../data_app_evil
+ base = tmp_path / "data_app"
+ sibling = tmp_path / "data_app_evil"
+ base.mkdir()
+ sibling.mkdir()
+
+ # Construct a relative path that resolves into the *sibling* dir.
+ # from base: ../data_app_evil/secret
+ with pytest.raises(ValueError, match="escapes base directory"):
+ _call("../data_app_evil/secret", base)
+
+
+# ---------------------------------------------------------------------------
+# Legacy absolute path pass-through
+# ---------------------------------------------------------------------------
+
+
+class TestLegacyAbsolutePaths:
+ def test_absolute_path_inside_base_is_returned(self, tmp_path):
+ """An absolute path that happens to be inside base_dir is returned as-is."""
+ base = tmp_path / "data"
+ base.mkdir()
+ abs_path = str(base / "archive" / "old.3mf")
+ result = _call(abs_path, base)
+ assert result == Path(abs_path).resolve()
+
+ def test_absolute_path_outside_base_is_returned(self, tmp_path):
+ """An absolute path outside base_dir is returned verbatim (legacy compat).
+
+ Pre-migration DB rows may store absolute paths that predate the
+ base_dir layout. These must NOT raise ValueError; callers are
+ responsible for further existence checks.
+ """
+ base = tmp_path / "data"
+ base.mkdir()
+ outside = tmp_path / "old_archive" / "legacy.3mf"
+ result = _call(str(outside), base)
+ assert result == outside.resolve()
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 8fbd1d7c4..00396d3d7 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -21,6 +21,7 @@ import { SystemInfoPage } from './pages/SystemInfoPage';
import { LoginPage } from './pages/LoginPage';
import { SetupPage } from './pages/SetupPage';
import { NotificationsPage } from './pages/NotificationsPage';
+import { GCodeViewerPage } from './pages/GCodeViewerPage';
import { useWebSocket } from './hooks/useWebSocket';
import { useStreamTokenSync } from './hooks/useCameraStreamToken';
import { ThemeProvider } from './contexts/ThemeContext';
@@ -195,6 +196,7 @@ function App() {
} />
} />
} />
+ } />
} />
diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx
index 0d301af5f..965573f03 100644
--- a/frontend/src/components/Layout.tsx
+++ b/frontend/src/components/Layout.tsx
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
-import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Disc3, ShieldAlert, Bell, type LucideIcon } from 'lucide-react';
+import { Printer, Archive, Calendar, BarChart3, Cloud, Settings, Sun, Moon, ChevronLeft, ChevronRight, Keyboard, Github, GripVertical, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Disc3, ShieldAlert, Bell, Layers, type LucideIcon } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useTheme } from '../contexts/ThemeContext';
import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
@@ -36,6 +36,7 @@ export const defaultNavItems: NavItem[] = [
{ id: 'projects', to: '/projects', icon: FolderKanban, labelKey: 'nav.projects' },
{ id: 'inventory', to: '/inventory', icon: Disc3, labelKey: 'nav.inventory' },
{ id: 'files', to: '/files', icon: FolderOpen, labelKey: 'nav.files' },
+ { id: 'gcode-viewer', to: '/gcode-viewer', icon: Layers, labelKey: 'nav.gcodeViewer' },
// User-account features: kept adjacent to Settings intentionally
{ id: 'notifications', to: '/notifications', icon: Bell, labelKey: 'nav.notifications' },
{ id: 'settings', to: '/settings', icon: Settings, labelKey: 'nav.settings' },
diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts
index 6c1754290..7eb097000 100644
--- a/frontend/src/i18n/locales/de.ts
+++ b/frontend/src/i18n/locales/de.ts
@@ -10,6 +10,7 @@ export default {
projects: 'Projekte',
inventory: 'Filament',
files: 'Dateimanager',
+ gcodeViewer: 'GCode-Viewer',
notifications: 'Benachrichtigungen',
settings: 'Einstellungen',
system: 'System',
@@ -213,6 +214,7 @@ export default {
chamberLightOff: 'Kammerbeleuchtung ausschalten',
// Files
files: 'Dateien',
+ gcodeViewer: 'GCode-Viewer',
browseFiles: 'Druckerdateien durchsuchen',
// Smart plug
autoOffAfterPrint: 'Automatisches Ausschalten nach Druck',
@@ -2891,6 +2893,7 @@ export default {
lowDiskSpaceWarning: 'Warnung: Wenig Speicherplatz',
lowDiskSpaceDetails: 'Nur {{free}} frei von {{total}} gesamt. Schwellenwert ist auf {{threshold}} GB eingestellt.',
files: 'Dateien',
+ gcodeViewer: 'GCode-Viewer',
folders: 'Ordner',
size: 'Größe',
free: 'Frei',
@@ -2994,6 +2997,7 @@ export default {
createFirstButton: 'Erstes Projekt erstellen',
create: 'Erstellen',
files: 'Dateien',
+ gcodeViewer: 'GCode-Viewer',
prints: 'Drucke',
plates: 'Platten',
parts: 'Teile',
diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts
index d946da842..e73cd083b 100644
--- a/frontend/src/i18n/locales/en.ts
+++ b/frontend/src/i18n/locales/en.ts
@@ -10,6 +10,7 @@ export default {
projects: 'Projects',
inventory: 'Filament',
files: 'File Manager',
+ gcodeViewer: 'GCode Viewer',
notifications: 'Notifications',
settings: 'Settings',
system: 'System',
@@ -213,6 +214,7 @@ export default {
chamberLightOff: 'Turn off chamber light',
// Files
files: 'Files',
+ gcodeViewer: 'GCode Viewer',
browseFiles: 'Browse printer files',
// Smart plug
autoOffAfterPrint: 'Auto power-off after print',
@@ -2894,6 +2896,7 @@ export default {
lowDiskSpaceWarning: 'Low disk space warning',
lowDiskSpaceDetails: 'Only {{free}} free of {{total}} total. Threshold is set to {{threshold}} GB in settings.',
files: 'Files',
+ gcodeViewer: 'GCode Viewer',
folders: 'Folders',
size: 'Size',
free: 'Free',
@@ -2997,6 +3000,7 @@ export default {
createFirstButton: 'Create Your First Project',
create: 'Create',
files: 'Files',
+ gcodeViewer: 'GCode Viewer',
prints: 'Prints',
plates: 'plates',
parts: 'parts',
diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts
index 4d0ffb830..eb4a0d596 100644
--- a/frontend/src/i18n/locales/fr.ts
+++ b/frontend/src/i18n/locales/fr.ts
@@ -10,6 +10,7 @@ export default {
projects: 'Projets',
inventory: 'Filament',
files: 'Gestionnaire de fichiers',
+ gcodeViewer: 'Visionneuse GCode',
notifications: 'Notifications',
settings: 'Paramètres',
system: 'Système',
@@ -213,6 +214,7 @@ export default {
chamberLightOff: 'Éteindre la lumière de la chambre',
// Files
files: 'Fichiers',
+ gcodeViewer: 'Visionneuse GCode',
browseFiles: 'Parcourir les fichiers de l\'imprimante',
// Smart plug
autoOffAfterPrint: 'Extinction auto après impression',
@@ -2813,6 +2815,7 @@ export default {
lowDiskSpaceWarning: 'Espace disque faible',
lowDiskSpaceDetails: '{{free}} libre sur {{total}}. Seuil : {{threshold}} Go.',
files: 'Fichiers',
+ gcodeViewer: 'Visionneuse GCode',
folders: 'Dossiers',
size: 'Taille',
free: 'Libre',
@@ -2916,6 +2919,7 @@ export default {
createFirstButton: 'Créer votre premier projet',
create: 'Créer',
files: 'Fichiers',
+ gcodeViewer: 'Visionneuse GCode',
prints: 'Impressions',
plates: 'plateaux',
parts: 'pièces',
diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts
index a8282a411..2be96b5c5 100644
--- a/frontend/src/i18n/locales/it.ts
+++ b/frontend/src/i18n/locales/it.ts
@@ -10,6 +10,7 @@ export default {
projects: 'Progetti',
inventory: 'Filamento',
files: 'File',
+ gcodeViewer: 'Visualizzatore GCode',
notifications: 'Notifiche',
settings: 'Impostazioni',
system: 'Sistema',
@@ -213,6 +214,7 @@ export default {
chamberLightOff: 'Spegni luce camera',
// Files
files: 'File',
+ gcodeViewer: 'Visualizzatore GCode',
browseFiles: 'Sfoglia file stampante',
// Smart plug
autoOffAfterPrint: 'Spegnimento automatico dopo stampa',
@@ -2812,6 +2814,7 @@ export default {
lowDiskSpaceWarning: 'Avviso spazio disco basso',
lowDiskSpaceDetails: 'Solo {{free}} liberi su {{total}} totali. La soglia e {{threshold}} GB nelle impostazioni.',
files: 'File',
+ gcodeViewer: 'Visualizzatore GCode',
folders: 'Cartelle',
size: 'Dimensione',
free: 'Libero',
@@ -2915,6 +2918,7 @@ export default {
createFirstButton: 'Crea il tuo primo progetto',
create: 'Crea',
files: 'File',
+ gcodeViewer: 'Visualizzatore GCode',
prints: 'Stampe',
plates: 'piatti',
parts: 'parti',
diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts
index faf7f8ed2..045750c08 100644
--- a/frontend/src/i18n/locales/ja.ts
+++ b/frontend/src/i18n/locales/ja.ts
@@ -10,6 +10,7 @@ export default {
projects: 'プロジェクト',
inventory: 'フィラメント',
files: 'ファイル管理',
+ gcodeViewer: 'GCodeビューア',
notifications: '通知',
settings: '設定',
system: 'システム',
@@ -212,6 +213,7 @@ export default {
chamberLightOff: 'チャンバーライトをオフにしました',
// Files
files: 'ファイル',
+ gcodeViewer: 'GCodeビューア',
browseFiles: 'プリンターのファイルを参照',
// Smart plug
autoOffAfterPrint: '印刷後に自動電源オフ',
@@ -2851,6 +2853,7 @@ export default {
lowDiskSpaceWarning: 'ディスク容量不足の警告',
lowDiskSpaceDetails: '{{total}}中{{free}}の空き容量のみ。しきい値は設定で{{threshold}}GBに設定されています。',
files: 'ファイル',
+ gcodeViewer: 'GCodeビューア',
folders: 'フォルダ',
size: 'サイズ',
free: '空き:',
@@ -2954,6 +2957,7 @@ export default {
createFirstButton: '最初のプロジェクトを作成',
create: '作成',
files: 'ファイル',
+ gcodeViewer: 'GCodeビューア',
prints: '印刷',
plates: 'プレート',
parts: 'パーツ',
diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts
index 74c7e17a3..05fef13ef 100644
--- a/frontend/src/i18n/locales/pt-BR.ts
+++ b/frontend/src/i18n/locales/pt-BR.ts
@@ -10,6 +10,7 @@ export default {
projects: 'Projetos',
inventory: 'Inventário',
files: 'Gerenciador de Arquivos',
+ gcodeViewer: 'Visualizador GCode',
notifications: 'Notificações',
settings: 'Configurações',
system: 'Sistema',
@@ -213,6 +214,7 @@ export default {
chamberLightOff: 'Desligar luz da câmara',
// Files
files: 'Arquivos',
+ gcodeViewer: 'Visualizador GCode',
browseFiles: 'Procurar arquivos da impressora',
// Smart plug
autoOffAfterPrint: 'Desligamento automático após impressão',
@@ -2826,6 +2828,7 @@ export default {
lowDiskSpaceWarning: 'Aviso de pouco espaço em disco',
lowDiskSpaceDetails: 'Apenas {{free}} livres de {{total}} no total. O limite está definido para {{threshold}} GB nas configurações.',
files: 'Arquivos',
+ gcodeViewer: 'Visualizador GCode',
folders: 'Pastas',
size: 'Tamanho',
free: 'Livre',
@@ -2929,6 +2932,7 @@ export default {
createFirstButton: 'Crie Seu Primeiro Projeto',
create: 'Criar',
files: 'Arquivos',
+ gcodeViewer: 'Visualizador GCode',
prints: 'Impressões',
plates: 'Placas',
parts: 'Peças',
diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts
index e6f535d5d..7f3bf19fc 100644
--- a/frontend/src/i18n/locales/zh-CN.ts
+++ b/frontend/src/i18n/locales/zh-CN.ts
@@ -10,6 +10,7 @@ export default {
projects: '项目',
inventory: '耗材',
files: '文件管理器',
+ gcodeViewer: 'GCode查看器',
notifications: '通知',
settings: '设置',
system: '系统',
@@ -213,6 +214,7 @@ export default {
chamberLightOff: '关闭腔室灯',
// Files
files: '文件',
+ gcodeViewer: 'GCode查看器',
browseFiles: '浏览打印机文件',
// Smart plug
autoOffAfterPrint: '打印后自动关机',
@@ -2878,6 +2880,7 @@ export default {
lowDiskSpaceWarning: '磁盘空间不足警告',
lowDiskSpaceDetails: '仅剩 {{free}}(总共 {{total}})。阈值设置为 {{threshold}} GB。',
files: '文件',
+ gcodeViewer: 'GCode查看器',
folders: '文件夹',
size: '大小',
free: '剩余',
@@ -2981,6 +2984,7 @@ export default {
createFirstButton: '创建您的第一个项目',
create: '创建',
files: '文件',
+ gcodeViewer: 'GCode查看器',
prints: '打印',
plates: '板',
parts: '零件',
diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts
index 981c08dc8..abf2c5992 100644
--- a/frontend/src/i18n/locales/zh-TW.ts
+++ b/frontend/src/i18n/locales/zh-TW.ts
@@ -10,6 +10,7 @@ export default {
projects: '專案',
inventory: '耗材',
files: '檔案管理器',
+ gcodeViewer: 'GCode 檢視器',
notifications: '通知',
settings: '設定',
system: '系統',
@@ -213,6 +214,7 @@ export default {
chamberLightOff: '關閉腔室燈',
// Files
files: '檔案',
+ gcodeViewer: 'GCode 檢視器',
browseFiles: '瀏覽印表機檔案',
// Smart plug
autoOffAfterPrint: '列印後自動關機',
@@ -2878,6 +2880,7 @@ export default {
lowDiskSpaceWarning: '磁碟空間不足警告',
lowDiskSpaceDetails: '僅剩 {{free}}(總共 {{total}})。閾值設定為 {{threshold}} GB。',
files: '檔案',
+ gcodeViewer: 'GCode 檢視器',
folders: '資料夾',
size: '大小',
free: '剩餘',
@@ -2981,6 +2984,7 @@ export default {
createFirstButton: '建立您的第一個項目',
create: '建立',
files: '檔案',
+ gcodeViewer: 'GCode 檢視器',
prints: '列印',
plates: '板',
parts: '零件',
diff --git a/frontend/src/pages/GCodeViewerPage.tsx b/frontend/src/pages/GCodeViewerPage.tsx
new file mode 100644
index 000000000..1c0bcaf4f
--- /dev/null
+++ b/frontend/src/pages/GCodeViewerPage.tsx
@@ -0,0 +1,28 @@
+export function GCodeViewerPage() {
+ // Safety guard: if this React app is itself inside an iframe (e.g. the
+ // StaticFiles mount isn't registered and serve_spa returned us here),
+ // don't render another iframe — that would create an infinite loop.
+ if (window !== window.top) {
+ return (
+
+ GCode viewer static files not found. Check that the{' '}
+ gcode_viewer/ directory exists and restart uvicorn.
+