Просмотр исходного кода

chore(deps): dompurify 3.4.10 -> 3.4.11 (GHSA-cmwh-pvxp-8882, moderate)

maziggy 2 месяцев назад
Родитель
Сommit
11227f65b3

+ 1 - 1
BACKERS.md

@@ -13,7 +13,7 @@ If you sponsor and your name isn't here within 48h, please write an email to mar
 
 
 ## Corporate Sponsors ($300/mo+)
 ## Corporate Sponsors ($300/mo+)
 
 
-*None yet — be the first. Your logo on the bambuddy.cool homepage and press.html, plus co-marketing.*
+- [@northpole3dprinting](https://github.com/northpole3dprinting)
 
 
 ## Sustaining Sponsors ($150/mo+)
 ## Sustaining Sponsors ($150/mo+)
 
 

Разница между файлами не показана из-за своего большого размера
+ 1 - 0
CHANGELOG.md


+ 5 - 0
README.md

@@ -23,6 +23,11 @@
   <a href="https://ko-fi.com/maziggy"><img src="https://img.shields.io/badge/Ko--fi-Support-ff5e5b?style=flat-square&logo=ko-fi&logoColor=white" alt="Ko-fi" target=_blank></a>
   <a href="https://ko-fi.com/maziggy"><img src="https://img.shields.io/badge/Ko--fi-Support-ff5e5b?style=flat-square&logo=ko-fi&logoColor=white" alt="Ko-fi" target=_blank></a>
 </p>
 </p>
 
 
+<p align="center">
+  <sub><strong>Backed by</strong></sub><br>
+  <a href="https://northpole3dprinting.com/"><img src="static/img/sponsors/northpole-3d-printing.jpg" alt="North Pole 3D Printing" height="60"></a>
+</p>
+
 <p align="center">
 <p align="center">
   <a href="https://demo.bambuddy.cool"><strong>🎮 Try the Live Demo</strong></a> •
   <a href="https://demo.bambuddy.cool"><strong>🎮 Try the Live Demo</strong></a> •
   <a href="#-features">Features</a> •
   <a href="#-features">Features</a> •

+ 6 - 0
backend/app/api/routes/virtual_printers.py

@@ -39,6 +39,7 @@ class VirtualPrinterCreate(BaseModel):
     target_printer_id: int | None = None
     target_printer_id: int | None = None
     auto_dispatch: bool = True
     auto_dispatch: bool = True
     queue_force_color_match: bool = False
     queue_force_color_match: bool = False
+    gcode_injection: bool = False
     bind_ip: str | None = None
     bind_ip: str | None = None
     remote_interface_ip: str | None = None
     remote_interface_ip: str | None = None
 
 
@@ -52,6 +53,7 @@ class VirtualPrinterUpdate(BaseModel):
     target_printer_id: int | None = None
     target_printer_id: int | None = None
     auto_dispatch: bool | None = None
     auto_dispatch: bool | None = None
     queue_force_color_match: bool | None = None
     queue_force_color_match: bool | None = None
+    gcode_injection: bool | None = None
     bind_ip: str | None = None
     bind_ip: str | None = None
     remote_interface_ip: str | None = None
     remote_interface_ip: str | None = None
     tailscale_disabled: bool | None = None
     tailscale_disabled: bool | None = None
@@ -107,6 +109,7 @@ async def _vp_to_dict(vp, db: AsyncSession, status: dict | None = None) -> dict:
         "target_printer_id": vp.target_printer_id,
         "target_printer_id": vp.target_printer_id,
         "auto_dispatch": vp.auto_dispatch,
         "auto_dispatch": vp.auto_dispatch,
         "queue_force_color_match": vp.queue_force_color_match,
         "queue_force_color_match": vp.queue_force_color_match,
+        "gcode_injection": vp.gcode_injection,
         "bind_ip": vp.bind_ip,
         "bind_ip": vp.bind_ip,
         "remote_interface_ip": vp.remote_interface_ip,
         "remote_interface_ip": vp.remote_interface_ip,
         "tailscale_disabled": vp.tailscale_disabled,
         "tailscale_disabled": vp.tailscale_disabled,
@@ -242,6 +245,7 @@ async def create_virtual_printer(
         target_printer_id=body.target_printer_id,
         target_printer_id=body.target_printer_id,
         auto_dispatch=body.auto_dispatch,
         auto_dispatch=body.auto_dispatch,
         queue_force_color_match=body.queue_force_color_match,
         queue_force_color_match=body.queue_force_color_match,
+        gcode_injection=body.gcode_injection,
         bind_ip=body.bind_ip,
         bind_ip=body.bind_ip,
         remote_interface_ip=body.remote_interface_ip,
         remote_interface_ip=body.remote_interface_ip,
         serial_suffix=new_suffix,
         serial_suffix=new_suffix,
@@ -419,6 +423,8 @@ async def update_virtual_printer(
         vp.auto_dispatch = body.auto_dispatch
         vp.auto_dispatch = body.auto_dispatch
     if body.queue_force_color_match is not None:
     if body.queue_force_color_match is not None:
         vp.queue_force_color_match = body.queue_force_color_match
         vp.queue_force_color_match = body.queue_force_color_match
+    if body.gcode_injection is not None:
+        vp.gcode_injection = body.gcode_injection
     if body.bind_ip is not None:
     if body.bind_ip is not None:
         vp.bind_ip = body.bind_ip
         vp.bind_ip = body.bind_ip
     if body.remote_interface_ip is not None:
     if body.remote_interface_ip is not None:

+ 8 - 0
backend/app/core/database.py

@@ -959,6 +959,14 @@ async def run_migrations(conn):
             conn, "ALTER TABLE virtual_printers ADD COLUMN queue_force_color_match BOOLEAN DEFAULT FALSE"
             conn, "ALTER TABLE virtual_printers ADD COLUMN queue_force_color_match BOOLEAN DEFAULT FALSE"
         )
         )
 
 
+    # Per-VP opt-in for auto-print G-code injection (#1516). Default false so
+    # existing gcode_snippets users don't silently start injecting on VP/Studio
+    # Send jobs after upgrading.
+    if is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE virtual_printers ADD COLUMN gcode_injection BOOLEAN DEFAULT 0")
+    else:
+        await _safe_execute(conn, "ALTER TABLE virtual_printers ADD COLUMN gcode_injection BOOLEAN DEFAULT FALSE")
+
     # Migration: Add target_parts_count column to projects for tracking total parts needed
     # Migration: Add target_parts_count column to projects for tracking total parts needed
     await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN target_parts_count INTEGER")
     await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN target_parts_count INTEGER")
 
 

+ 5 - 0
backend/app/models/virtual_printer.py

@@ -49,6 +49,11 @@ class VirtualPrinter(Base):
     )  # queue mode: pin per-slot type+color from the 3MF onto the queue
     )  # queue mode: pin per-slot type+color from the 3MF onto the queue
     # item so the scheduler refuses to dispatch onto a printer with the wrong
     # item so the scheduler refuses to dispatch onto a printer with the wrong
     # filament loaded (#1188).
     # filament loaded (#1188).
+    gcode_injection: Mapped[bool] = mapped_column(
+        Boolean, server_default="false"
+    )  # queue mode: opt this VP's Send/Print jobs into per-model G-code snippet
+    # injection (#1516). Default off so existing gcode_snippets users don't
+    # silently start injecting; no-op when no snippets exist for the model.
     model: Mapped[str | None] = mapped_column(String(50), nullable=True)  # SSDP model code (server mode)
     model: Mapped[str | None] = mapped_column(String(50), nullable=True)  # SSDP model code (server mode)
     access_code: Mapped[str | None] = mapped_column(String(8), nullable=True)  # 8 chars (server mode)
     access_code: Mapped[str | None] = mapped_column(String(8), nullable=True)  # 8 chars (server mode)
     target_printer_id: Mapped[int | None] = mapped_column(
     target_printer_id: Mapped[int | None] = mapped_column(

+ 9 - 0
backend/app/services/virtual_printer/manager.py

@@ -138,6 +138,7 @@ class VirtualPrinterInstance:
         target_printer_id: int | None = None,
         target_printer_id: int | None = None,
         auto_dispatch: bool = True,
         auto_dispatch: bool = True,
         queue_force_color_match: bool = False,
         queue_force_color_match: bool = False,
+        gcode_injection: bool = False,
         bind_ip: str = "",
         bind_ip: str = "",
         remote_interface_ip: str = "",
         remote_interface_ip: str = "",
         tailscale_disabled: bool = True,
         tailscale_disabled: bool = True,
@@ -160,6 +161,7 @@ class VirtualPrinterInstance:
         self.target_printer_id = target_printer_id
         self.target_printer_id = target_printer_id
         self.auto_dispatch = auto_dispatch
         self.auto_dispatch = auto_dispatch
         self.queue_force_color_match = queue_force_color_match
         self.queue_force_color_match = queue_force_color_match
+        self.gcode_injection = gcode_injection
         self.bind_ip = bind_ip
         self.bind_ip = bind_ip
         self.remote_interface_ip = remote_interface_ip
         self.remote_interface_ip = remote_interface_ip
         self.tailscale_disabled = tailscale_disabled
         self.tailscale_disabled = tailscale_disabled
@@ -668,6 +670,11 @@ class VirtualPrinterInstance:
                             vibration_cali=vibration_cali,
                             vibration_cali=vibration_cali,
                             layer_inspect=layer_inspect,
                             layer_inspect=layer_inspect,
                             timelapse=timelapse,
                             timelapse=timelapse,
+                            # Per-VP opt-in for auto-print G-code injection (#1516).
+                            # Default off; when on, the scheduler still no-ops unless
+                            # gcode_snippets are configured for the target model, so it's
+                            # effectively "inject when enabled AND snippets exist".
+                            gcode_injection=self.gcode_injection,
                         )
                         )
                         db.add(queue_item)
                         db.add(queue_item)
                         await db.flush()  # populate queue_item.id before logging
                         await db.flush()  # populate queue_item.id before logging
@@ -1226,6 +1233,7 @@ class VirtualPrinterManager:
                 # instance silently keeps the old value until process
                 # instance silently keeps the old value until process
                 # restart (#1552 follow-up family).
                 # restart (#1552 follow-up family).
                 or instance.queue_force_color_match != vp.queue_force_color_match
                 or instance.queue_force_color_match != vp.queue_force_color_match
+                or instance.gcode_injection != vp.gcode_injection
                 or proxy_target_changed
                 or proxy_target_changed
             )
             )
 
 
@@ -1279,6 +1287,7 @@ class VirtualPrinterManager:
                     target_printer_id=vp.target_printer_id,
                     target_printer_id=vp.target_printer_id,
                     auto_dispatch=vp.auto_dispatch,
                     auto_dispatch=vp.auto_dispatch,
                     queue_force_color_match=vp.queue_force_color_match,
                     queue_force_color_match=vp.queue_force_color_match,
+                    gcode_injection=vp.gcode_injection,
                     bind_ip=vp.bind_ip or "",
                     bind_ip=vp.bind_ip or "",
                     remote_interface_ip=vp.remote_interface_ip or "",
                     remote_interface_ip=vp.remote_interface_ip or "",
                     tailscale_disabled=vp.tailscale_disabled,
                     tailscale_disabled=vp.tailscale_disabled,

+ 50 - 4
backend/app/utils/threemf_tools.py

@@ -5,6 +5,7 @@ per-layer filament usage data from the embedded G-code. This enables
 accurate partial usage reporting for multi-material prints.
 accurate partial usage reporting for multi-material prints.
 """
 """
 
 
+import hashlib
 import json
 import json
 import logging
 import logging
 import math
 import math
@@ -546,6 +547,7 @@ _HEADER_PLACEHOLDER_ALIASES = {
 _HEADER_KEY_RE = re.compile(r"^;\s*([^:]+?)\s*:\s*(.+?)\s*$")
 _HEADER_KEY_RE = re.compile(r"^;\s*([^:]+?)\s*:\s*(.+?)\s*$")
 _PLACEHOLDER_RE = re.compile(r"\{([a-zA-Z_][a-zA-Z0-9_]*)\}")
 _PLACEHOLDER_RE = re.compile(r"\{([a-zA-Z_][a-zA-Z0-9_]*)\}")
 _START_GCODE_END_MARKER = "; MACHINE_START_GCODE_END"
 _START_GCODE_END_MARKER = "; MACHINE_START_GCODE_END"
+_EXECUTABLE_BLOCK_END_MARKER = "; EXECUTABLE_BLOCK_END"
 
 
 
 
 def _parse_3mf_gcode_header(content: str) -> dict[str, str]:
 def _parse_3mf_gcode_header(content: str) -> dict[str, str]:
@@ -618,6 +620,29 @@ def _inject_start_at_marker(content: str, snippet: str) -> str:
     return content[:line_start] + snippet.rstrip("\n") + "\n" + content[line_start:]
     return content[:line_start] + snippet.rstrip("\n") + "\n" + content[line_start:]
 
 
 
 
+def _inject_end_before_marker(content: str, snippet: str) -> str:
+    """Insert snippet immediately before `; EXECUTABLE_BLOCK_END`.
+
+    The end snippet must run *inside* the executable block. Bambu firmware
+    (verified on a P1S) does not execute G-code that sits after
+    `; EXECUTABLE_BLOCK_END`, so appending to the file end silently drops the
+    snippet — auto-eject / plate-clear moves never fire. Inserting before the
+    marker places the snippet after the printer's own machine-end sequence but
+    still within the executed block. Falls back to appending at the file end if
+    the marker isn't present.
+    """
+    marker_idx = content.find(_EXECUTABLE_BLOCK_END_MARKER)
+    if marker_idx == -1:
+        logger.warning(
+            "G-code injection: '%s' not found, appending end snippet to file end",
+            _EXECUTABLE_BLOCK_END_MARKER,
+        )
+        return content.rstrip("\n") + "\n" + snippet.rstrip("\n") + "\n"
+    line_start = content.rfind("\n", 0, marker_idx)
+    line_start = 0 if line_start == -1 else line_start + 1
+    return content[:line_start] + snippet.rstrip("\n") + "\n" + content[line_start:]
+
+
 def inject_gcode_into_3mf(
 def inject_gcode_into_3mf(
     source_path: Path,
     source_path: Path,
     plate_id: int,
     plate_id: int,
@@ -629,8 +654,12 @@ def inject_gcode_into_3mf(
     Snippets support `{placeholder}` substitution against values parsed from
     Snippets support `{placeholder}` substitution against values parsed from
     the 3MF G-code header block (e.g. `{max_layer_z}` → `16.00`). Start
     the 3MF G-code header block (e.g. `{max_layer_z}` → `16.00`). Start
     snippets are anchored to the `; MACHINE_START_GCODE_END` marker so they
     snippets are anchored to the `; MACHINE_START_GCODE_END` marker so they
-    run after the printer's own startup (#422). End snippets are appended
-    after the last line of the print.
+    run after the printer's own startup (#422). End snippets are inserted just
+    before `; EXECUTABLE_BLOCK_END` so they run inside the executable block —
+    Bambu firmware (P1S) ignores g-code placed after that marker.
+
+    The plate's `.gcode.md5` sidecar is recomputed so firmware that validates
+    it against the gcode (e.g. P1S) still accepts the modified file.
 
 
     Args:
     Args:
         source_path: Path to the original 3MF file.
         source_path: Path to the original 3MF file.
@@ -672,10 +701,25 @@ def inject_gcode_into_3mf(
 
 
             if start_gcode:
             if start_gcode:
                 resolved = _substitute_placeholders(start_gcode, header)
                 resolved = _substitute_placeholders(start_gcode, header)
+                # Log the post-substitution snippet so the actually-injected G-code
+                # (placeholders like {max_layer_z} already resolved) is visible at DEBUG.
+                logger.debug("G-code injection [%s]: resolved START snippet:\n%s", target_gcode, resolved)
                 gcode_content = _inject_start_at_marker(gcode_content, resolved)
                 gcode_content = _inject_start_at_marker(gcode_content, resolved)
             if end_gcode:
             if end_gcode:
                 resolved = _substitute_placeholders(end_gcode, header)
                 resolved = _substitute_placeholders(end_gcode, header)
-                gcode_content = gcode_content.rstrip("\n") + "\n" + resolved + "\n"
+                logger.debug("G-code injection [%s]: resolved END snippet:\n%s", target_gcode, resolved)
+                gcode_content = _inject_end_before_marker(gcode_content, resolved)
+
+            # The printer validates the plate gcode against an embedded
+            # `<plate>.gcode.md5` sidecar (uppercase hex, no trailing newline).
+            # Rewriting the gcode without refreshing this hash makes firmware
+            # reject the file at load (P1S: HMS 0500-4003 "unable to parse"),
+            # so recompute it from the exact bytes we're about to write.
+            gcode_bytes = gcode_content.encode("utf-8")
+            md5_name = target_gcode + ".md5"
+            # Not a security hash — this reproduces Bambu's `.gcode.md5` sidecar
+            # format, so flag it as non-security for the linters (ruff S324 / bandit B324).
+            md5_value = hashlib.md5(gcode_bytes, usedforsecurity=False).hexdigest().upper().encode("ascii")
 
 
             # Write modified 3MF to temp file
             # Write modified 3MF to temp file
             with tempfile.NamedTemporaryFile(delete=False, suffix=".3mf") as tmp:
             with tempfile.NamedTemporaryFile(delete=False, suffix=".3mf") as tmp:
@@ -685,7 +729,9 @@ def inject_gcode_into_3mf(
                 for item in zf.namelist():
                 for item in zf.namelist():
                     info = zf.getinfo(item)
                     info = zf.getinfo(item)
                     if item == target_gcode:
                     if item == target_gcode:
-                        zf_write.writestr(info, gcode_content.encode("utf-8"))
+                        zf_write.writestr(info, gcode_bytes)
+                    elif item == md5_name:
+                        zf_write.writestr(info, md5_value)
                     else:
                     else:
                         zf_write.writestr(info, zf.read(item))
                         zf_write.writestr(info, zf.read(item))
 
 

+ 47 - 0
backend/tests/integration/test_virtual_printer_api.py

@@ -353,6 +353,53 @@ class TestVirtualPrinterAutoDispatchAPI:
         assert get_resp.json()["auto_dispatch"] is False
         assert get_resp.json()["auto_dispatch"] is False
 
 
 
 
+class TestVirtualPrinterGcodeInjectionAPI:
+    """Integration tests for gcode_injection (#1516) on /api/v1/virtual-printers endpoints."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_virtual_printer_gcode_injection_default_off(self, async_client: AsyncClient):
+        """Verify creating a VP without gcode_injection defaults to false (opt-in)."""
+        response = await async_client.post(
+            "/api/v1/virtual-printers",
+            json={
+                "name": "TestDefaultInjection",
+                "mode": "queue",
+                "access_code": "12345678",
+            },
+        )
+
+        assert response.status_code == 200
+        assert response.json()["gcode_injection"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_virtual_printer_gcode_injection(self, async_client: AsyncClient):
+        """Verify gcode_injection can be toggled via PUT and persists."""
+        create_resp = await async_client.post(
+            "/api/v1/virtual-printers",
+            json={
+                "name": "TestToggleInjection",
+                "mode": "queue",
+                "access_code": "12345678",
+            },
+        )
+        assert create_resp.status_code == 200
+        vp_id = create_resp.json()["id"]
+        assert create_resp.json()["gcode_injection"] is False
+
+        update_resp = await async_client.put(
+            f"/api/v1/virtual-printers/{vp_id}",
+            json={"gcode_injection": True},
+        )
+        assert update_resp.status_code == 200
+        assert update_resp.json()["gcode_injection"] is True
+
+        get_resp = await async_client.get(f"/api/v1/virtual-printers/{vp_id}")
+        assert get_resp.status_code == 200
+        assert get_resp.json()["gcode_injection"] is True
+
+
 class TestVirtualPrinterTailscaleToggleAPI:
 class TestVirtualPrinterTailscaleToggleAPI:
     """The Tailscale toggle is informational — toggling either way always succeeds.
     """The Tailscale toggle is informational — toggling either way always succeeds.
 
 

+ 141 - 0
backend/tests/unit/services/test_virtual_printer.py

@@ -573,6 +573,109 @@ class TestVirtualPrinterInstance:
         queue_item = added_items[0]
         queue_item = added_items[0]
         assert queue_item.manual_start is True
         assert queue_item.manual_start is True
 
 
+    @pytest.mark.asyncio
+    async def test_add_to_print_queue_gcode_injection_on(self, tmp_path):
+        """#1516: queue items opt into injection when the VP has gcode_injection=True."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        mock_db = AsyncMock()
+        added_items = []
+        mock_db.add = MagicMock(side_effect=added_items.append)
+        mock_db.commit = AsyncMock()
+
+        mock_session_factory = MagicMock()
+        mock_session_ctx = AsyncMock()
+        mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
+        mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
+        mock_session_factory.return_value = mock_session_ctx
+
+        inst = VirtualPrinterInstance(
+            vp_id=13,
+            name="InjectOn",
+            mode="queue",
+            model="C11",
+            access_code="12345678",
+            serial_suffix="391800013",
+            gcode_injection=True,
+            base_dir=tmp_path,
+            session_factory=mock_session_factory,
+        )
+
+        file_path = tmp_path / "test.3mf"
+        file_path.write_bytes(b"fake3mf")
+
+        mock_archive = MagicMock()
+        mock_archive.id = 1
+        mock_archive.print_name = "test"
+
+        with (
+            patch(
+                "backend.app.api.routes.settings.get_setting",
+                new_callable=AsyncMock,
+                return_value=None,
+            ),
+            patch(
+                "backend.app.services.archive.ArchiveService.archive_print",
+                new_callable=AsyncMock,
+                return_value=mock_archive,
+            ),
+        ):
+            await inst._add_to_print_queue(file_path, "192.168.1.100")
+
+        assert len(added_items) == 1
+        assert added_items[0].gcode_injection is True
+
+    @pytest.mark.asyncio
+    async def test_add_to_print_queue_gcode_injection_off_by_default(self, tmp_path):
+        """#1516: queue items do NOT inject when the VP leaves gcode_injection at its default."""
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        mock_db = AsyncMock()
+        added_items = []
+        mock_db.add = MagicMock(side_effect=added_items.append)
+        mock_db.commit = AsyncMock()
+
+        mock_session_factory = MagicMock()
+        mock_session_ctx = AsyncMock()
+        mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
+        mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
+        mock_session_factory.return_value = mock_session_ctx
+
+        inst = VirtualPrinterInstance(
+            vp_id=14,
+            name="InjectOff",
+            mode="queue",
+            model="C11",
+            access_code="12345678",
+            serial_suffix="391800014",
+            base_dir=tmp_path,
+            session_factory=mock_session_factory,
+        )
+
+        file_path = tmp_path / "test.3mf"
+        file_path.write_bytes(b"fake3mf")
+
+        mock_archive = MagicMock()
+        mock_archive.id = 1
+        mock_archive.print_name = "test"
+
+        with (
+            patch(
+                "backend.app.api.routes.settings.get_setting",
+                new_callable=AsyncMock,
+                return_value=None,
+            ),
+            patch(
+                "backend.app.services.archive.ArchiveService.archive_print",
+                new_callable=AsyncMock,
+                return_value=mock_archive,
+            ),
+        ):
+            await inst._add_to_print_queue(file_path, "192.168.1.100")
+
+        assert len(added_items) == 1
+        assert added_items[0].gcode_injection is False
+
     @pytest.mark.asyncio
     @pytest.mark.asyncio
     async def test_add_to_print_queue_uses_workflow_defaults_from_settings(self, tmp_path):
     async def test_add_to_print_queue_uses_workflow_defaults_from_settings(self, tmp_path):
         """#1235: VP queue-mode constructed PrintQueueItem without specifying
         """#1235: VP queue-mode constructed PrintQueueItem without specifying
@@ -1650,6 +1753,7 @@ class TestVirtualPrinterManager:
             "auto_dispatch": True,
             "auto_dispatch": True,
             "tailscale_disabled": True,  # Opt-in default (#1070 UX fix)
             "tailscale_disabled": True,  # Opt-in default (#1070 UX fix)
             "queue_force_color_match": False,  # default — must be explicit so MagicMock truthiness doesn't trip the change detector
             "queue_force_color_match": False,  # default — must be explicit so MagicMock truthiness doesn't trip the change detector
+            "gcode_injection": False,  # same reason as above
             "position": 0,
             "position": 0,
         }
         }
         defaults.update(overrides)
         defaults.update(overrides)
@@ -1850,6 +1954,43 @@ class TestVirtualPrinterManager:
 
 
         mock_remove.assert_not_called()
         mock_remove.assert_not_called()
 
 
+    @pytest.mark.asyncio
+    async def test_sync_from_db_restarts_on_gcode_injection_toggle(self, manager, tmp_path):
+        """Toggling gcode_injection in the DB must restart the running instance.
+
+        Without this, the in-memory ``self.gcode_injection`` keeps its old value
+        and ``_add_to_print_queue`` stamps the stale flag on every new queue
+        item — so disabling injection in the UI silently has no effect until
+        the process restarts.
+        """
+        from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
+
+        inst = VirtualPrinterInstance(
+            vp_id=1,
+            name="TestVP",
+            mode="archive",
+            model="C11",
+            access_code="12345678",
+            serial_suffix="391800001",
+            gcode_injection=True,
+            base_dir=tmp_path,
+        )
+        inst.stop_server = AsyncMock()
+        manager._instances[1] = inst
+
+        db_vp = self._make_db_vp(gcode_injection=False)
+        self._setup_sync_mocks(manager, [db_vp], tmp_path)
+
+        with patch.object(manager, "remove_instance", new_callable=AsyncMock) as mock_remove:
+            with patch("backend.app.services.virtual_printer.manager.VirtualPrinterInstance") as MockInst:
+                mock_new = MagicMock()
+                mock_new.start_server = AsyncMock()
+                MockInst.return_value = mock_new
+
+                await manager.sync_from_db()
+
+            mock_remove.assert_called_once_with(1)
+
 
 
 class TestFTPSession:
 class TestFTPSession:
     """Tests for FTP session handling."""
     """Tests for FTP session handling."""

+ 124 - 5
backend/tests/unit/test_gcode_injection.py

@@ -1,5 +1,6 @@
 """Unit tests for G-code injection into 3MF files (#422)."""
 """Unit tests for G-code injection into 3MF files (#422)."""
 
 
+import hashlib
 import tempfile
 import tempfile
 import zipfile
 import zipfile
 from pathlib import Path
 from pathlib import Path
@@ -234,6 +235,92 @@ M104 S0
 """
 """
 
 
 
 
+class TestMd5SidecarRecompute:
+    """The plate `.gcode.md5` sidecar must match the injected gcode (P1S rejects
+    a stale hash with HMS 0500-4003)."""
+
+    def _make_3mf_with_md5(self, gcode: str, plate_id: int = 1) -> Path:
+        """A 3MF that carries a (deliberately wrong) md5 sidecar, like a real
+        sliced .gcode.3mf does."""
+        tmp_path = _make_temp_path()
+        with zipfile.ZipFile(tmp_path, "w", zipfile.ZIP_DEFLATED) as zf:
+            zf.writestr(f"Metadata/plate_{plate_id}.gcode", gcode)
+            zf.writestr(f"Metadata/plate_{plate_id}.gcode.md5", "STALEHASHVALUE")
+            zf.writestr("Metadata/slice_info.config", "<config></config>")
+        return tmp_path
+
+    def test_md5_recomputed_to_match_injected_gcode(self):
+        source = self._make_3mf_with_md5("G28\nM400\n")
+        result = None
+        try:
+            result = inject_gcode_into_3mf(source, 1, None, "M104 S0")
+            assert result is not None
+            with zipfile.ZipFile(result, "r") as zf:
+                gcode = zf.read("Metadata/plate_1.gcode")
+                sidecar = zf.read("Metadata/plate_1.gcode.md5")
+            expected = hashlib.md5(gcode, usedforsecurity=False).hexdigest().upper().encode("ascii")
+            assert sidecar == expected
+            assert sidecar != b"STALEHASHVALUE"
+        finally:
+            source.unlink(missing_ok=True)
+            if result:
+                result.unlink(missing_ok=True)
+
+    def test_sidecar_is_uppercase_hex_no_newline(self):
+        """Match Bambu's on-disk format exactly: uppercase, no trailing newline."""
+        source = self._make_3mf_with_md5("G28\n")
+        result = None
+        try:
+            result = inject_gcode_into_3mf(source, 1, "; START", None)
+            assert result is not None
+            with zipfile.ZipFile(result, "r") as zf:
+                sidecar = zf.read("Metadata/plate_1.gcode.md5")
+            assert sidecar == sidecar.upper()
+            assert not sidecar.endswith(b"\n")
+            assert len(sidecar) == 32
+        finally:
+            source.unlink(missing_ok=True)
+            if result:
+                result.unlink(missing_ok=True)
+
+    def test_no_md5_member_is_not_created(self):
+        """A 3MF without an md5 sidecar shouldn't gain one (firmware isn't
+        validating it, and inventing a member could surprise older files)."""
+        source = _make_test_3mf("G28\n")  # no .md5 member
+        result = None
+        try:
+            result = inject_gcode_into_3mf(source, 1, "; START", None)
+            assert result is not None
+            with zipfile.ZipFile(result, "r") as zf:
+                names = zf.namelist()
+            assert "Metadata/plate_1.gcode.md5" not in names
+        finally:
+            source.unlink(missing_ok=True)
+            if result:
+                result.unlink(missing_ok=True)
+
+    def test_other_member_compression_preserved(self):
+        """Non-target members keep their original compression (P1S preview
+        parser chokes on re-DEFLATEd STORE'd PNGs)."""
+        tmp_path = _make_temp_path()
+        with zipfile.ZipFile(tmp_path, "w") as zf:
+            zf.writestr(zipfile.ZipInfo("Metadata/plate_1.gcode"), "G28\n")
+            # A STORE'd member (compress_type=0), like an embedded preview PNG.
+            stored = zipfile.ZipInfo("Metadata/plate_1.png")
+            stored.compress_type = zipfile.ZIP_STORED
+            zf.writestr(stored, b"\x89PNG\r\n\x1a\n" + b"\x00" * 64)
+        result = None
+        try:
+            result = inject_gcode_into_3mf(tmp_path, 1, None, "; END")
+            assert result is not None
+            with zipfile.ZipFile(result, "r") as zf:
+                assert zf.getinfo("Metadata/plate_1.png").compress_type == zipfile.ZIP_STORED
+        finally:
+            tmp_path.unlink(missing_ok=True)
+            if result:
+                result.unlink(missing_ok=True)
+
+
 class TestStartAnchoredInjection:
 class TestStartAnchoredInjection:
     """Tests for #422 follow-up: start g-code injected at MACHINE_START_GCODE_END."""
     """Tests for #422 follow-up: start g-code injected at MACHINE_START_GCODE_END."""
 
 
@@ -281,9 +368,10 @@ class TestStartAnchoredInjection:
             if result:
             if result:
                 result.unlink(missing_ok=True)
                 result.unlink(missing_ok=True)
 
 
-    def test_end_still_appended_at_eof(self):
-        """End g-code keeps the existing append-to-EOF behaviour even with marker present."""
-        source = _make_test_3mf(_BAMBU_GCODE_TEMPLATE)
+    def test_end_falls_back_to_eof_without_block_marker(self):
+        """Files without ; EXECUTABLE_BLOCK_END (older / non-Bambu slicers) keep the
+        append-to-EOF fallback for end snippets."""
+        source = _make_test_3mf(_BAMBU_GCODE_TEMPLATE)  # template has no EXECUTABLE_BLOCK_END
         try:
         try:
             result = inject_gcode_into_3mf(source, 1, None, "; SWAPMOD-END")
             result = inject_gcode_into_3mf(source, 1, None, "; SWAPMOD-END")
             assert result is not None
             assert result is not None
@@ -292,8 +380,39 @@ class TestStartAnchoredInjection:
                 gcode = zf.read("Metadata/plate_1.gcode").decode("utf-8")
                 gcode = zf.read("Metadata/plate_1.gcode").decode("utf-8")
 
 
             assert gcode.endswith("; SWAPMOD-END\n")
             assert gcode.endswith("; SWAPMOD-END\n")
-            # Marker anchor is irrelevant for end snippets.
-            assert gcode.index("; SWAPMOD-END") > gcode.index("; MACHINE_START_GCODE_END")
+        finally:
+            source.unlink(missing_ok=True)
+            if result:
+                result.unlink(missing_ok=True)
+
+    def test_end_lands_before_executable_block_end(self):
+        """With ; EXECUTABLE_BLOCK_END present, the end snippet sits INSIDE the
+        executable block (just before the marker). Bambu firmware (P1S) does not
+        run g-code placed after that marker, so appending to EOF would silently
+        drop auto-eject / plate-clear moves."""
+        gcode_src = (
+            "; HEADER_BLOCK_START\n; max_z_height: 16.00\n; HEADER_BLOCK_END\n"
+            "; MACHINE_START_GCODE_END\n"
+            "G1 X10 Y10 Z0.2\n"
+            "M104 S0 ; printer machine-end\n"
+            "; EXECUTABLE_BLOCK_END\n"
+        )
+        source = _make_test_3mf(gcode_src)
+        try:
+            result = inject_gcode_into_3mf(source, 1, None, "; EJECT-SWEEP")
+            assert result is not None
+
+            with zipfile.ZipFile(result, "r") as zf:
+                gcode = zf.read("Metadata/plate_1.gcode").decode("utf-8")
+
+            snippet_idx = gcode.index("; EJECT-SWEEP")
+            marker_idx = gcode.index("; EXECUTABLE_BLOCK_END")
+            # Snippet is inside the block, before the end marker.
+            assert snippet_idx < marker_idx
+            # The printer's own machine-end still precedes our snippet.
+            assert gcode.index("M104 S0 ; printer machine-end") < snippet_idx
+            # Nothing executable remains after the marker.
+            assert gcode[marker_idx:].strip() == "; EXECUTABLE_BLOCK_END"
         finally:
         finally:
             source.unlink(missing_ok=True)
             source.unlink(missing_ok=True)
             if result:
             if result:

+ 3 - 3
frontend/package-lock.json

@@ -4087,9 +4087,9 @@
       "peer": true
       "peer": true
     },
     },
     "node_modules/dompurify": {
     "node_modules/dompurify": {
-      "version": "3.4.10",
-      "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.10.tgz",
-      "integrity": "sha512-0xzNv0e7oYC6yyuOGZIABPM4qtg3QxLFniDNPP4ZP90wR8Yq3zgwpRbrNiT4N3IKqDbbYFEJLV+JWEs19aZ//w==",
+      "version": "3.4.11",
+      "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz",
+      "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==",
       "optionalDependencies": {
       "optionalDependencies": {
         "@types/trusted-types": "^2.0.7"
         "@types/trusted-types": "^2.0.7"
       }
       }

+ 96 - 0
frontend/src/__tests__/components/PrintModal.test.tsx

@@ -1142,6 +1142,102 @@ describe('PrintModal', () => {
     });
     });
   });
   });
 
 
+  describe('reprint G-code injection dispatch (#422 / auto-eject)', () => {
+    // Guards the fix: when "Inject auto-print G-code" is ticked on a reprint with
+    // quantity > 1, ALL copies must go through the queue so every one is injected by
+    // the scheduler. The first copy must NOT be dispatched immediately via the direct
+    // reprint path — that path bypasses injection and would leave the first copy stuck
+    // on the plate for auto-eject setups.
+    const withSnippets = () =>
+      http.get('/api/v1/settings/', () =>
+        HttpResponse.json({ gcode_snippets: { X1C: { start_gcode: '', end_gcode: 'M400' } } }),
+      );
+
+    it('injection ON queues all copies and dispatches none immediately', async () => {
+      const reprintCalls: unknown[] = [];
+      const queueCalls: Record<string, unknown>[] = [];
+      server.use(
+        withSnippets(),
+        http.post('/api/v1/archives/:id/reprint', async ({ request }) => {
+          reprintCalls.push(await request.json().catch(() => ({})));
+          return HttpResponse.json({ status: 'dispatched' });
+        }),
+        http.post('/api/v1/queue/', async ({ request }) => {
+          queueCalls.push((await request.json()) as Record<string, unknown>);
+          return HttpResponse.json({ id: queueCalls.length, status: 'pending' });
+        }),
+      );
+
+      const user = userEvent.setup();
+      render(
+        <PrintModal
+          mode="reprint"
+          archiveId={1}
+          archiveName="Benchy"
+          initialSelectedPrinterIds={[1]}
+          onClose={mockOnClose}
+          onSuccess={mockOnSuccess}
+        />
+      );
+
+      // Quantity > 1 so the injection checkbox is offered
+      const qty = (await screen.findByLabelText('Quantity')) as HTMLInputElement;
+      await user.tripleClick(qty);
+      await user.keyboard('3');
+      expect(qty.value).toBe('3');
+
+      // Checkbox only renders once snippets are loaded AND quantity > 1
+      await user.click(await screen.findByLabelText(/inject auto-print/i));
+
+      await user.click(document.querySelector('button[type="submit"]') as HTMLElement);
+
+      await waitFor(() => expect(queueCalls.length).toBe(1));
+      // One queue item carrying all copies, and zero immediate reprint dispatches
+      expect(queueCalls[0].quantity).toBe(3);
+      expect(reprintCalls.length).toBe(0);
+    });
+
+    it('injection OFF keeps the immediate first copy and queues the rest', async () => {
+      const reprintCalls: unknown[] = [];
+      const queueCalls: Record<string, unknown>[] = [];
+      server.use(
+        withSnippets(),
+        http.post('/api/v1/archives/:id/reprint', async ({ request }) => {
+          reprintCalls.push(await request.json().catch(() => ({})));
+          return HttpResponse.json({ status: 'dispatched' });
+        }),
+        http.post('/api/v1/queue/', async ({ request }) => {
+          queueCalls.push((await request.json()) as Record<string, unknown>);
+          return HttpResponse.json({ id: queueCalls.length, status: 'pending' });
+        }),
+      );
+
+      const user = userEvent.setup();
+      render(
+        <PrintModal
+          mode="reprint"
+          archiveId={1}
+          archiveName="Benchy"
+          initialSelectedPrinterIds={[1]}
+          onClose={mockOnClose}
+          onSuccess={mockOnSuccess}
+        />
+      );
+
+      const qty = (await screen.findByLabelText('Quantity')) as HTMLInputElement;
+      await user.tripleClick(qty);
+      await user.keyboard('3');
+      expect(qty.value).toBe('3');
+
+      // Leave injection unticked → first copy prints immediately, rest queue
+      await user.click(document.querySelector('button[type="submit"]') as HTMLElement);
+
+      await waitFor(() => expect(reprintCalls.length).toBe(1));
+      expect(queueCalls.length).toBe(1);
+      expect(queueCalls[0].quantity).toBe(2);
+    });
+  });
+
   describe('project_id forwarding', () => {
   describe('project_id forwarding', () => {
     beforeEach(() => {
     beforeEach(() => {
       // Additional handlers needed for library file mode
       // Additional handlers needed for library file mode

+ 3 - 0
frontend/src/api/client.ts

@@ -6662,6 +6662,7 @@ export interface VirtualPrinterConfig {
   target_printer_id: number | null;
   target_printer_id: number | null;
   auto_dispatch: boolean;
   auto_dispatch: boolean;
   queue_force_color_match: boolean;
   queue_force_color_match: boolean;
+  gcode_injection: boolean;
   tailscale_disabled: boolean;
   tailscale_disabled: boolean;
   bind_ip: string | null;
   bind_ip: string | null;
   remote_interface_ip: string | null;
   remote_interface_ip: string | null;
@@ -6688,6 +6689,7 @@ export const multiVirtualPrinterApi = {
     target_printer_id?: number;
     target_printer_id?: number;
     auto_dispatch?: boolean;
     auto_dispatch?: boolean;
     queue_force_color_match?: boolean;
     queue_force_color_match?: boolean;
+    gcode_injection?: boolean;
     bind_ip?: string;
     bind_ip?: string;
     remote_interface_ip?: string;
     remote_interface_ip?: string;
   }) =>
   }) =>
@@ -6705,6 +6707,7 @@ export const multiVirtualPrinterApi = {
     target_printer_id?: number;
     target_printer_id?: number;
     auto_dispatch?: boolean;
     auto_dispatch?: boolean;
     queue_force_color_match?: boolean;
     queue_force_color_match?: boolean;
+    gcode_injection?: boolean;
     tailscale_disabled?: boolean;
     tailscale_disabled?: boolean;
     bind_ip?: string;
     bind_ip?: string;
     remote_interface_ip?: string;
     remote_interface_ip?: string;

+ 49 - 23
frontend/src/components/PrintModal/index.tsx

@@ -763,32 +763,43 @@ export function PrintModal({
 
 
           try {
           try {
             if (mode === 'reprint' && !useStagger) {
             if (mode === 'reprint' && !useStagger) {
-              // Reprint mode - start print immediately (single plate only, multi-select not available)
               const printerMapping = getMappingForPrinter(printerId);
               const printerMapping = getMappingForPrinter(printerId);
-              if (isLibraryFile) {
-                await api.printLibraryFile(libraryFileId!, printerId, {
-                  plate_id: selectedPlate ?? undefined,
-                  plate_name: selectedPlateName,
-                  ams_mapping: printerMapping,
-                  ...printOptions,
-                  project_id: projectId,
-                  cleanup_library_after_dispatch: cleanupLibraryAfterDispatch,
-                });
-              } else {
-                // project_id is intentionally omitted here: reprintArchive targets an existing
-                // archive that already carries its own project association from the original print.
-                await api.reprintArchive(archiveId!, printerId, {
-                  plate_id: selectedPlate ?? undefined,
-                  plate_name: selectedPlateName,
-                  ams_mapping: printerMapping,
-                  ...printOptions,
-                });
-              }
-              // Queue remaining copies if quantity > 1
-              if (effectiveQuantity > 1) {
+              if (scheduleOptions.gcodeInjection && effectiveQuantity > 1) {
+                // Auto-print injection only happens in the scheduler path. A direct
+                // immediate reprint bypasses it, so the first copy would print without
+                // its end-snippet and stay stuck on the plate — defeating auto-eject and
+                // blocking the injected copies queued behind it. Queue *all* copies so
+                // every one is dispatched (and injected) by the scheduler.
                 const queueData = getQueueData(printerId, plateId);
                 const queueData = getQueueData(printerId, plateId);
-                queueData.quantity = effectiveQuantity - 1;
+                queueData.quantity = effectiveQuantity;
                 await addToQueueMutation.mutateAsync(queueData);
                 await addToQueueMutation.mutateAsync(queueData);
+              } else {
+                // Reprint mode - start print immediately (single plate only, multi-select not available)
+                if (isLibraryFile) {
+                  await api.printLibraryFile(libraryFileId!, printerId, {
+                    plate_id: selectedPlate ?? undefined,
+                    plate_name: selectedPlateName,
+                    ams_mapping: printerMapping,
+                    ...printOptions,
+                    project_id: projectId,
+                    cleanup_library_after_dispatch: cleanupLibraryAfterDispatch,
+                  });
+                } else {
+                  // project_id is intentionally omitted here: reprintArchive targets an existing
+                  // archive that already carries its own project association from the original print.
+                  await api.reprintArchive(archiveId!, printerId, {
+                    plate_id: selectedPlate ?? undefined,
+                    plate_name: selectedPlateName,
+                    ams_mapping: printerMapping,
+                    ...printOptions,
+                  });
+                }
+                // Queue remaining copies if quantity > 1
+                if (effectiveQuantity > 1) {
+                  const queueData = getQueueData(printerId, plateId);
+                  queueData.quantity = effectiveQuantity - 1;
+                  await addToQueueMutation.mutateAsync(queueData);
+                }
               }
               }
             } else if (mode === 'edit-queue-item' && progressCounter === 1) {
             } else if (mode === 'edit-queue-item' && progressCounter === 1) {
               // Edit mode - update the original queue item for the first entry
               // Edit mode - update the original queue item for the first entry
@@ -885,6 +896,21 @@ export function PrintModal({
   // Quantity only applies for single-printer or model-based assignment (not multi-printer)
   // Quantity only applies for single-printer or model-based assignment (not multi-printer)
   const effectiveQuantity = (assignmentMode === 'printer' && selectedPrinters.length > 1) ? 1 : quantity;
   const effectiveQuantity = (assignmentMode === 'printer' && selectedPrinters.length > 1) ? 1 : quantity;
 
 
+  // Keep scheduleOptions.gcodeInjection in sync with the checkbox's render
+  // condition. The checkbox only renders for reprint + snippets configured +
+  // quantity > 1, so if the user ticks it at quantity 2 then drops back to 1
+  // the box hides but the state stays true — and the immediate-reprint path
+  // would then silently bypass injection.
+  useEffect(() => {
+    if (
+      mode === 'reprint' &&
+      scheduleOptions.gcodeInjection &&
+      (effectiveQuantity <= 1 || !settings?.gcode_snippets)
+    ) {
+      setScheduleOptions((opts) => ({ ...opts, gcodeInjection: false }));
+    }
+  }, [mode, effectiveQuantity, settings?.gcode_snippets, scheduleOptions.gcodeInjection]);
+
   // Modal title and action button text based on mode
   // Modal title and action button text based on mode
   const getModalConfig = () => {
   const getModalConfig = () => {
     const printerCount = selectedPrinters.length;
     const printerCount = selectedPrinters.length;

+ 32 - 0
frontend/src/components/VirtualPrinterCard.tsx

@@ -55,6 +55,7 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
   const [localModel, setLocalModel] = useState(printer.model || '');
   const [localModel, setLocalModel] = useState(printer.model || '');
   const [localAutoDispatch, setLocalAutoDispatch] = useState(printer.auto_dispatch ?? true);
   const [localAutoDispatch, setLocalAutoDispatch] = useState(printer.auto_dispatch ?? true);
   const [localQueueForceColorMatch, setLocalQueueForceColorMatch] = useState(printer.queue_force_color_match ?? false);
   const [localQueueForceColorMatch, setLocalQueueForceColorMatch] = useState(printer.queue_force_color_match ?? false);
+  const [localGcodeInjection, setLocalGcodeInjection] = useState(printer.gcode_injection ?? false);
   const [localTailscaleDisabled, setLocalTailscaleDisabled] = useState(printer.tailscale_disabled ?? true);
   const [localTailscaleDisabled, setLocalTailscaleDisabled] = useState(printer.tailscale_disabled ?? true);
   const [showAccessCode, setShowAccessCode] = useState(false);
   const [showAccessCode, setShowAccessCode] = useState(false);
   const [pendingAction, setPendingAction] = useState<string | null>(null);
   const [pendingAction, setPendingAction] = useState<string | null>(null);
@@ -100,6 +101,7 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
       setLocalModel(printer.model || '');
       setLocalModel(printer.model || '');
       setLocalAutoDispatch(printer.auto_dispatch ?? true);
       setLocalAutoDispatch(printer.auto_dispatch ?? true);
       setLocalQueueForceColorMatch(printer.queue_force_color_match ?? false);
       setLocalQueueForceColorMatch(printer.queue_force_color_match ?? false);
+      setLocalGcodeInjection(printer.gcode_injection ?? false);
       setLocalTailscaleDisabled(printer.tailscale_disabled ?? true);
       setLocalTailscaleDisabled(printer.tailscale_disabled ?? true);
     }
     }
   }, [printer, pendingAction]);
   }, [printer, pendingAction]);
@@ -437,6 +439,36 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
               </div>
               </div>
             )}
             )}
 
 
+            {/* G-code injection toggle - only for queue mode (#1516) */}
+            {localMode === 'queue' && (
+              <div className="pt-2 border-t border-bambu-dark-tertiary">
+                <div className="flex items-center justify-between gap-3">
+                  <div className="min-w-0">
+                    <div className="text-white text-sm font-medium">{t('virtualPrinter.gcodeInjection.title')}</div>
+                    <div className="text-[10px] text-bambu-gray">{t('virtualPrinter.gcodeInjection.description')}</div>
+                  </div>
+                  <button
+                    onClick={() => {
+                      const newVal = !localGcodeInjection;
+                      setLocalGcodeInjection(newVal);
+                      setPendingAction('gcodeInjection');
+                      updateMutation.mutate({ gcode_injection: newVal });
+                    }}
+                    disabled={pendingAction === 'gcodeInjection'}
+                    className={`relative w-10 h-5 rounded-full transition-colors flex-shrink-0 ${
+                      localGcodeInjection ? 'bg-bambu-green' : 'bg-bambu-dark-tertiary'
+                    } ${pendingAction === 'gcodeInjection' ? 'opacity-50' : ''}`}
+                  >
+                    <span
+                      className={`absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full transition-transform ${
+                        localGcodeInjection ? 'translate-x-5' : ''
+                      }`}
+                    />
+                  </button>
+                </div>
+              </div>
+            )}
+
             {/* Tailscale toggle */}
             {/* Tailscale toggle */}
             <div className="pt-2 border-t border-bambu-dark-tertiary">
             <div className="pt-2 border-t border-bambu-dark-tertiary">
               <div className="flex items-center justify-between gap-3">
               <div className="flex items-center justify-between gap-3">

+ 4 - 0
frontend/src/i18n/locales/de.ts

@@ -4583,6 +4583,10 @@ export default {
       title: 'Farbabgleich erzwingen',
       title: 'Farbabgleich erzwingen',
       description: 'Druckaufträge nur an Drucker senden, bei denen der genaue Filament-Typ und die genaue Farbe geladen sind. Standardmäßig deaktiviert — ohne diese Option verwendet die Warteschlange nur den Drucker-Modell-Abgleich und wählt möglicherweise einen Drucker mit der falschen Farbe.',
       description: 'Druckaufträge nur an Drucker senden, bei denen der genaue Filament-Typ und die genaue Farbe geladen sind. Standardmäßig deaktiviert — ohne diese Option verwendet die Warteschlange nur den Drucker-Modell-Abgleich und wählt möglicherweise einen Drucker mit der falschen Farbe.',
     },
     },
+    gcodeInjection: {
+      title: 'G-code-Injektion',
+      description: 'Wendet die in den Einstellungen pro Modell konfigurierten G-code-Snippets auf Jobs dieses VP an. Standardmäßig aus.',
+    },
     tailscaleDisabled: {
     tailscaleDisabled: {
       title: 'Tailscale-Integration',
       title: 'Tailscale-Integration',
       description: 'Aktivieren, um diesen VP als per Tailscale erreichbar zu markieren. Zeigt die Tailscale-Adresse des Hosts an, damit du weißt, welche IP du im Slicer eintragen musst. Der CA-Import bleibt unverändert — diese Option hat keinen Einfluss auf Zertifikate.',
       description: 'Aktivieren, um diesen VP als per Tailscale erreichbar zu markieren. Zeigt die Tailscale-Adresse des Hosts an, damit du weißt, welche IP du im Slicer eintragen musst. Der CA-Import bleibt unverändert — diese Option hat keinen Einfluss auf Zertifikate.',

+ 4 - 0
frontend/src/i18n/locales/en.ts

@@ -4604,6 +4604,10 @@ export default {
       title: 'Force color match',
       title: 'Force color match',
       description: 'Refuse to dispatch onto a printer that does not have the exact filament type and color loaded. Off by default — without this, the queue uses model-only matching and may pick a printer with the wrong color loaded.',
       description: 'Refuse to dispatch onto a printer that does not have the exact filament type and color loaded. Off by default — without this, the queue uses model-only matching and may pick a printer with the wrong color loaded.',
     },
     },
+    gcodeInjection: {
+      title: 'G-code injection',
+      description: 'Apply the per-model G-code snippets configured in Settings to jobs from this VP. Off by default.',
+    },
     tailscaleDisabled: {
     tailscaleDisabled: {
       title: 'Tailscale integration',
       title: 'Tailscale integration',
       description: 'Enable to mark this VP as exposed over Tailscale. Shows the host\'s Tailscale address so you know which IP to paste into the slicer. The CA-import step is unchanged — this toggle has no effect on certificates.',
       description: 'Enable to mark this VP as exposed over Tailscale. Shows the host\'s Tailscale address so you know which IP to paste into the slicer. The CA-import step is unchanged — this toggle has no effect on certificates.',

+ 4 - 0
frontend/src/i18n/locales/es.ts

@@ -4592,6 +4592,10 @@ export default {
       title: 'Forzar la coincidencia de color',
       title: 'Forzar la coincidencia de color',
       description: 'Negarse a enviar a una impresora que no tiene cargados el tipo y el color exactos de filamento. Desactivado de forma predeterminada — sin esto, la cola usa la coincidencia solo por modelo y puede elegir una impresora con el color equivocado cargado.',
       description: 'Negarse a enviar a una impresora que no tiene cargados el tipo y el color exactos de filamento. Desactivado de forma predeterminada — sin esto, la cola usa la coincidencia solo por modelo y puede elegir una impresora con el color equivocado cargado.',
     },
     },
+    gcodeInjection: {
+      title: 'Inyección de G-code',
+      description: 'Aplica los fragmentos de G-code configurados por modelo en Ajustes a los trabajos de esta IV. Desactivado de forma predeterminada.',
+    },
     tailscaleDisabled: {
     tailscaleDisabled: {
       title: 'Integración con Tailscale',
       title: 'Integración con Tailscale',
       description: 'Actívelo para marcar esta IV como expuesta a través de Tailscale. Muestra la dirección de Tailscale del host para que sepa qué IP pegar en el laminador. El paso de importación de la CA no cambia — este interruptor no tiene ningún efecto sobre los certificados.',
       description: 'Actívelo para marcar esta IV como expuesta a través de Tailscale. Muestra la dirección de Tailscale del host para que sepa qué IP pegar en el laminador. El paso de importación de la CA no cambia — este interruptor no tiene ningún efecto sobre los certificados.',

+ 4 - 0
frontend/src/i18n/locales/fr.ts

@@ -4573,6 +4573,10 @@ export default {
       title: 'Forcer la correspondance des couleurs',
       title: 'Forcer la correspondance des couleurs',
       description: 'Refuser l\'envoi vers une imprimante qui n\'a pas exactement le type de filament et la couleur chargés. Désactivé par défaut — sans cela, la file d\'attente utilise uniquement la correspondance par modèle et peut choisir une imprimante avec la mauvaise couleur.',
       description: 'Refuser l\'envoi vers une imprimante qui n\'a pas exactement le type de filament et la couleur chargés. Désactivé par défaut — sans cela, la file d\'attente utilise uniquement la correspondance par modèle et peut choisir une imprimante avec la mauvaise couleur.',
     },
     },
+    gcodeInjection: {
+      title: 'Injection G-code',
+      description: 'Applique les extraits de G-code configurés par modèle dans les Paramètres aux travaux de ce VP. Désactivé par défaut.',
+    },
     tailscaleDisabled: {
     tailscaleDisabled: {
       title: 'Intégration Tailscale',
       title: 'Intégration Tailscale',
       description: 'Activez pour marquer ce VP comme exposé via Tailscale. Affiche l\'adresse Tailscale de l\'hôte pour que vous sachiez quelle IP coller dans le slicer. L\'étape d\'import CA est inchangée — cette bascule n\'a aucun effet sur les certificats.',
       description: 'Activez pour marquer ce VP comme exposé via Tailscale. Affiche l\'adresse Tailscale de l\'hôte pour que vous sachiez quelle IP coller dans le slicer. L\'étape d\'import CA est inchangée — cette bascule n\'a aucun effet sur les certificats.',

+ 4 - 0
frontend/src/i18n/locales/it.ts

@@ -4572,6 +4572,10 @@ export default {
       title: 'Forza corrispondenza colori',
       title: 'Forza corrispondenza colori',
       description: 'Rifiuta di inviare a una stampante che non ha esattamente il tipo di filamento e il colore caricato. Disattivato per impostazione predefinita — senza questo, la coda usa solo la corrispondenza per modello e potrebbe scegliere una stampante con il colore sbagliato.',
       description: 'Rifiuta di inviare a una stampante che non ha esattamente il tipo di filamento e il colore caricato. Disattivato per impostazione predefinita — senza questo, la coda usa solo la corrispondenza per modello e potrebbe scegliere una stampante con il colore sbagliato.',
     },
     },
+    gcodeInjection: {
+      title: 'Iniezione G-code',
+      description: 'Applica gli snippet G-code configurati per modello nelle Impostazioni ai lavori di questo VP. Disattivato per impostazione predefinita.',
+    },
     tailscaleDisabled: {
     tailscaleDisabled: {
       title: 'Integrazione Tailscale',
       title: 'Integrazione Tailscale',
       description: 'Abilita per contrassegnare questo VP come esposto tramite Tailscale. Mostra l\'indirizzo Tailscale dell\'host così sai quale IP incollare nello slicer. Il passo di importazione CA è invariato — questo toggle non ha effetto sui certificati.',
       description: 'Abilita per contrassegnare questo VP come esposto tramite Tailscale. Mostra l\'indirizzo Tailscale dell\'host così sai quale IP incollare nello slicer. Il passo di importazione CA è invariato — questo toggle non ha effetto sui certificati.',

+ 4 - 0
frontend/src/i18n/locales/ja.ts

@@ -4584,6 +4584,10 @@ export default {
       title: '色の一致を強制',
       title: '色の一致を強制',
       description: '正確なフィラメントタイプと色がロードされていないプリンターへの送信を拒否します。デフォルトはオフ — これがないと、キューはモデルのみのマッチングを使用し、間違った色がロードされたプリンターを選ぶ可能性があります。',
       description: '正確なフィラメントタイプと色がロードされていないプリンターへの送信を拒否します。デフォルトはオフ — これがないと、キューはモデルのみのマッチングを使用し、間違った色がロードされたプリンターを選ぶ可能性があります。',
     },
     },
+    gcodeInjection: {
+      title: 'G-codeインジェクション',
+      description: '設定でモデルごとに構成したG-codeスニペットを、このVPのジョブに適用します。デフォルトはオフです。',
+    },
     tailscaleDisabled: {
     tailscaleDisabled: {
       title: 'Tailscale統合',
       title: 'Tailscale統合',
       description: 'このVPがTailscale経由で公開されていることをマークするには有効にしてください。スライサーに貼り付けるIPがわかるよう、ホストのTailscaleアドレスを表示します。CAインポート手順は変更されません — このトグルは証明書に影響しません。',
       description: 'このVPがTailscale経由で公開されていることをマークするには有効にしてください。スライサーに貼り付けるIPがわかるよう、ホストのTailscaleアドレスを表示します。CAインポート手順は変更されません — このトグルは証明書に影響しません。',

+ 4 - 0
frontend/src/i18n/locales/ko.ts

@@ -4324,6 +4324,10 @@ export default {
       title: '색상 일치 강제',
       title: '색상 일치 강제',
       description: '정확한 필라멘트 유형과 색상이 장착되지 않은 프린터에는 발송을 거부합니다. 기본적으로 꺼져 있음 — 이 옵션 없이는 대기열이 모델 전용 매칭을 사용하여 잘못된 색상이 장착된 프린터를 선택할 수 있습니다.'
       description: '정확한 필라멘트 유형과 색상이 장착되지 않은 프린터에는 발송을 거부합니다. 기본적으로 꺼져 있음 — 이 옵션 없이는 대기열이 모델 전용 매칭을 사용하여 잘못된 색상이 장착된 프린터를 선택할 수 있습니다.'
     },
     },
+    gcodeInjection: {
+      title: 'G-code 주입',
+      description: '설정에서 모델별로 구성한 G-code 스니펫을 이 가상 프린터의 작업에 적용합니다. 기본값은 꺼짐입니다.'
+    },
     tailscaleDisabled: {
     tailscaleDisabled: {
       title: 'Tailscale 통합',
       title: 'Tailscale 통합',
       description: '이 가상 프린터가 Tailscale을 통해 노출되도록 표시하려면 활성화하세요. 슬라이서에 붙여넣을 IP를 알 수 있도록 호스트의 Tailscale 주소를 표시합니다. CA 가져오기 단계는 변경되지 않음 — 이 토글은 인증서에 영향을 미치지 않습니다.'
       description: '이 가상 프린터가 Tailscale을 통해 노출되도록 표시하려면 활성화하세요. 슬라이서에 붙여넣을 IP를 알 수 있도록 호스트의 Tailscale 주소를 표시합니다. CA 가져오기 단계는 변경되지 않음 — 이 토글은 인증서에 영향을 미치지 않습니다.'

+ 4 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -4572,6 +4572,10 @@ export default {
       title: 'Forçar correspondência de cor',
       title: 'Forçar correspondência de cor',
       description: 'Recusa enviar para uma impressora que não tenha exatamente o tipo e cor de filamento carregados. Desativado por padrão — sem isto, a fila usa apenas correspondência por modelo e pode escolher uma impressora com a cor errada carregada.',
       description: 'Recusa enviar para uma impressora que não tenha exatamente o tipo e cor de filamento carregados. Desativado por padrão — sem isto, a fila usa apenas correspondência por modelo e pode escolher uma impressora com a cor errada carregada.',
     },
     },
+    gcodeInjection: {
+      title: 'Injeção de G-code',
+      description: 'Aplica os trechos de G-code configurados por modelo nas Configurações aos trabalhos deste VP. Desativado por padrão.',
+    },
     tailscaleDisabled: {
     tailscaleDisabled: {
       title: 'Integração Tailscale',
       title: 'Integração Tailscale',
       description: 'Ative para marcar este VP como exposto via Tailscale. Mostra o endereço Tailscale do host para você saber qual IP colar no fatiador. A etapa de importação CA é inalterada — este toggle não afeta certificados.',
       description: 'Ative para marcar este VP como exposto via Tailscale. Mostra o endereço Tailscale do host para você saber qual IP colar no fatiador. A etapa de importação CA é inalterada — este toggle não afeta certificados.',

+ 4 - 0
frontend/src/i18n/locales/tr.ts

@@ -4547,6 +4547,10 @@ export default {
       title: 'Renk eşleşmesini zorla',
       title: 'Renk eşleşmesini zorla',
       description: 'Tam olarak doğru filament türü ve rengi yüklü olmayan bir yazıcıya sevk etmeyi reddet. Varsayılan olarak kapalı — bu olmadan kuyruk yalnızca model eşleşmesi kullanır ve yanlış renk yüklü bir yazıcı seçebilir.',
       description: 'Tam olarak doğru filament türü ve rengi yüklü olmayan bir yazıcıya sevk etmeyi reddet. Varsayılan olarak kapalı — bu olmadan kuyruk yalnızca model eşleşmesi kullanır ve yanlış renk yüklü bir yazıcı seçebilir.',
     },
     },
+    gcodeInjection: {
+      title: 'G-code enjeksiyonu',
+      description: "Ayarlar'da model bazında yapılandırılan G-code parçacıklarını bu VP'nin işlerine uygular. Varsayılan olarak kapalı.",
+    },
     tailscaleDisabled: {
     tailscaleDisabled: {
       title: 'Tailscale entegrasyonu',
       title: 'Tailscale entegrasyonu',
       description: "Bu VP'yi Tailscale üzerinden açıkta olarak işaretlemek için etkinleştirin. Dilimleyiciye hangi IP'yi yapıştıracağınızı bilmeniz için ana bilgisayarın Tailscale adresini gösterir. CA içe aktarma adımı değişmedi — bu anahtarın sertifikalar üzerinde etkisi yoktur.",
       description: "Bu VP'yi Tailscale üzerinden açıkta olarak işaretlemek için etkinleştirin. Dilimleyiciye hangi IP'yi yapıştıracağınızı bilmeniz için ana bilgisayarın Tailscale adresini gösterir. CA içe aktarma adımı değişmedi — bu anahtarın sertifikalar üzerinde etkisi yoktur.",

+ 4 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -4572,6 +4572,10 @@ export default {
       title: '强制颜色匹配',
       title: '强制颜色匹配',
       description: '拒绝派发到没有完全相同耗材类型和颜色的打印机。默认关闭 — 不启用时,队列仅按型号匹配,可能选到颜色错误的打印机。',
       description: '拒绝派发到没有完全相同耗材类型和颜色的打印机。默认关闭 — 不启用时,队列仅按型号匹配,可能选到颜色错误的打印机。',
     },
     },
+    gcodeInjection: {
+      title: 'G-code 注入',
+      description: '将“设置”中按型号配置的 G-code 片段应用到此 VP 的作业。默认关闭。',
+    },
     tailscaleDisabled: {
     tailscaleDisabled: {
       title: 'Tailscale 集成',
       title: 'Tailscale 集成',
       description: '启用以将此 VP 标记为通过 Tailscale 公开。显示主机的 Tailscale 地址,以便您知道要粘贴到切片器中的 IP。CA 导入步骤保持不变 — 此开关对证书无影响。',
       description: '启用以将此 VP 标记为通过 Tailscale 公开。显示主机的 Tailscale 地址,以便您知道要粘贴到切片器中的 IP。CA 导入步骤保持不变 — 此开关对证书无影响。',

+ 4 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -4572,6 +4572,10 @@ export default {
       title: '強制顏色匹配',
       title: '強制顏色匹配',
       description: '拒絕派發到沒有完全相同耗材類型和顏色的印表機。預設關閉 — 不啟用時,佇列僅按型號匹配,可能選到顏色錯誤的印表機。',
       description: '拒絕派發到沒有完全相同耗材類型和顏色的印表機。預設關閉 — 不啟用時,佇列僅按型號匹配,可能選到顏色錯誤的印表機。',
     },
     },
+    gcodeInjection: {
+      title: 'G-code 注入',
+      description: '將「設定」中依型號設定的 G-code 片段套用到此 VP 的作業。預設關閉。',
+    },
     tailscaleDisabled: {
     tailscaleDisabled: {
       title: 'Tailscale 整合',
       title: 'Tailscale 整合',
       description: '啟用以將此 VP 標記為透過 Tailscale 公開。顯示主機的 Tailscale 位址,以便您知道要貼上到切片器中的 IP。CA 匯入步驟保持不變 — 此切換對憑證無影響。',
       description: '啟用以將此 VP 標記為透過 Tailscale 公開。顯示主機的 Tailscale 位址,以便您知道要貼上到切片器中的 IP。CA 匯入步驟保持不變 — 此切換對憑證無影響。',

Некоторые файлы не были показаны из-за большого количества измененных файлов