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. +
+ ); + } + + return ( + // h-14 (3.5 rem) is the fixed header height defined in Layout.tsx. + // Subtracting it prevents a double scrollbar inside the layout shell. +