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

This commit is contained in:
maziggy
2026-06-28 12:35:24 +02:00
27 changed files with 628 additions and 36 deletions
+1 -1
View File
@@ -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+)
*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+)
+5
View File
File diff suppressed because one or more lines are too long
+5
View File
@@ -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>
</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">
<a href="https://demo.bambuddy.cool"><strong>🎮 Try the Live Demo</strong></a> •
<a href="#-features">Features</a> •
@@ -39,6 +39,7 @@ class VirtualPrinterCreate(BaseModel):
target_printer_id: int | None = None
auto_dispatch: bool = True
queue_force_color_match: bool = False
gcode_injection: bool = False
bind_ip: str | None = None
remote_interface_ip: str | None = None
@@ -52,6 +53,7 @@ class VirtualPrinterUpdate(BaseModel):
target_printer_id: int | None = None
auto_dispatch: bool | None = None
queue_force_color_match: bool | None = None
gcode_injection: bool | None = None
bind_ip: str | None = None
remote_interface_ip: str | 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,
"auto_dispatch": vp.auto_dispatch,
"queue_force_color_match": vp.queue_force_color_match,
"gcode_injection": vp.gcode_injection,
"bind_ip": vp.bind_ip,
"remote_interface_ip": vp.remote_interface_ip,
"tailscale_disabled": vp.tailscale_disabled,
@@ -242,6 +245,7 @@ async def create_virtual_printer(
target_printer_id=body.target_printer_id,
auto_dispatch=body.auto_dispatch,
queue_force_color_match=body.queue_force_color_match,
gcode_injection=body.gcode_injection,
bind_ip=body.bind_ip,
remote_interface_ip=body.remote_interface_ip,
serial_suffix=new_suffix,
@@ -419,6 +423,8 @@ async def update_virtual_printer(
vp.auto_dispatch = body.auto_dispatch
if body.queue_force_color_match is not None:
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:
vp.bind_ip = body.bind_ip
if body.remote_interface_ip is not None:
+8
View File
@@ -959,6 +959,14 @@ async def run_migrations(conn):
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
await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN target_parts_count INTEGER")
+5
View File
@@ -49,6 +49,11 @@ class VirtualPrinter(Base):
) # 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
# 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)
access_code: Mapped[str | None] = mapped_column(String(8), nullable=True) # 8 chars (server mode)
target_printer_id: Mapped[int | None] = mapped_column(
@@ -138,6 +138,7 @@ class VirtualPrinterInstance:
target_printer_id: int | None = None,
auto_dispatch: bool = True,
queue_force_color_match: bool = False,
gcode_injection: bool = False,
bind_ip: str = "",
remote_interface_ip: str = "",
tailscale_disabled: bool = True,
@@ -160,6 +161,7 @@ class VirtualPrinterInstance:
self.target_printer_id = target_printer_id
self.auto_dispatch = auto_dispatch
self.queue_force_color_match = queue_force_color_match
self.gcode_injection = gcode_injection
self.bind_ip = bind_ip
self.remote_interface_ip = remote_interface_ip
self.tailscale_disabled = tailscale_disabled
@@ -668,6 +670,11 @@ class VirtualPrinterInstance:
vibration_cali=vibration_cali,
layer_inspect=layer_inspect,
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)
await db.flush() # populate queue_item.id before logging
@@ -1226,6 +1233,7 @@ class VirtualPrinterManager:
# instance silently keeps the old value until process
# restart (#1552 follow-up family).
or instance.queue_force_color_match != vp.queue_force_color_match
or instance.gcode_injection != vp.gcode_injection
or proxy_target_changed
)
@@ -1279,6 +1287,7 @@ class VirtualPrinterManager:
target_printer_id=vp.target_printer_id,
auto_dispatch=vp.auto_dispatch,
queue_force_color_match=vp.queue_force_color_match,
gcode_injection=vp.gcode_injection,
bind_ip=vp.bind_ip or "",
remote_interface_ip=vp.remote_interface_ip or "",
tailscale_disabled=vp.tailscale_disabled,
+50 -4
View File
@@ -5,6 +5,7 @@ per-layer filament usage data from the embedded G-code. This enables
accurate partial usage reporting for multi-material prints.
"""
import hashlib
import json
import logging
import math
@@ -546,6 +547,7 @@ _HEADER_PLACEHOLDER_ALIASES = {
_HEADER_KEY_RE = re.compile(r"^;\s*([^:]+?)\s*:\s*(.+?)\s*$")
_PLACEHOLDER_RE = re.compile(r"\{([a-zA-Z_][a-zA-Z0-9_]*)\}")
_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]:
@@ -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:]
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(
source_path: Path,
plate_id: int,
@@ -629,8 +654,12 @@ def inject_gcode_into_3mf(
Snippets support `{placeholder}` substitution against values parsed from
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
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:
source_path: Path to the original 3MF file.
@@ -672,10 +701,25 @@ def inject_gcode_into_3mf(
if start_gcode:
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)
if end_gcode:
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
with tempfile.NamedTemporaryFile(delete=False, suffix=".3mf") as tmp:
@@ -685,7 +729,9 @@ def inject_gcode_into_3mf(
for item in zf.namelist():
info = zf.getinfo(item)
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:
zf_write.writestr(info, zf.read(item))
@@ -353,6 +353,53 @@ class TestVirtualPrinterAutoDispatchAPI:
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:
"""The Tailscale toggle is informational — toggling either way always succeeds.
@@ -573,6 +573,109 @@ class TestVirtualPrinterInstance:
queue_item = added_items[0]
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
async def test_add_to_print_queue_uses_workflow_defaults_from_settings(self, tmp_path):
"""#1235: VP queue-mode constructed PrintQueueItem without specifying
@@ -1650,6 +1753,7 @@ class TestVirtualPrinterManager:
"auto_dispatch": True,
"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
"gcode_injection": False, # same reason as above
"position": 0,
}
defaults.update(overrides)
@@ -1850,6 +1954,43 @@ class TestVirtualPrinterManager:
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:
"""Tests for FTP session handling."""
+124 -5
View File
@@ -1,5 +1,6 @@
"""Unit tests for G-code injection into 3MF files (#422)."""
import hashlib
import tempfile
import zipfile
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:
"""Tests for #422 follow-up: start g-code injected at MACHINE_START_GCODE_END."""
@@ -281,9 +368,10 @@ class TestStartAnchoredInjection:
if result:
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:
result = inject_gcode_into_3mf(source, 1, None, "; SWAPMOD-END")
assert result is not None
@@ -292,8 +380,39 @@ class TestStartAnchoredInjection:
gcode = zf.read("Metadata/plate_1.gcode").decode("utf-8")
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:
source.unlink(missing_ok=True)
if result:
+3 -3
View File
@@ -4087,9 +4087,9 @@
"peer": true
},
"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": {
"@types/trusted-types": "^2.0.7"
}
@@ -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', () => {
beforeEach(() => {
// Additional handlers needed for library file mode
+3
View File
@@ -6662,6 +6662,7 @@ export interface VirtualPrinterConfig {
target_printer_id: number | null;
auto_dispatch: boolean;
queue_force_color_match: boolean;
gcode_injection: boolean;
tailscale_disabled: boolean;
bind_ip: string | null;
remote_interface_ip: string | null;
@@ -6688,6 +6689,7 @@ export const multiVirtualPrinterApi = {
target_printer_id?: number;
auto_dispatch?: boolean;
queue_force_color_match?: boolean;
gcode_injection?: boolean;
bind_ip?: string;
remote_interface_ip?: string;
}) =>
@@ -6705,6 +6707,7 @@ export const multiVirtualPrinterApi = {
target_printer_id?: number;
auto_dispatch?: boolean;
queue_force_color_match?: boolean;
gcode_injection?: boolean;
tailscale_disabled?: boolean;
bind_ip?: string;
remote_interface_ip?: string;
+49 -23
View File
@@ -763,32 +763,43 @@ export function PrintModal({
try {
if (mode === 'reprint' && !useStagger) {
// Reprint mode - start print immediately (single plate only, multi-select not available)
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);
queueData.quantity = effectiveQuantity - 1;
queueData.quantity = effectiveQuantity;
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) {
// 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)
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
const getModalConfig = () => {
const printerCount = selectedPrinters.length;
@@ -55,6 +55,7 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
const [localModel, setLocalModel] = useState(printer.model || '');
const [localAutoDispatch, setLocalAutoDispatch] = useState(printer.auto_dispatch ?? true);
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 [showAccessCode, setShowAccessCode] = useState(false);
const [pendingAction, setPendingAction] = useState<string | null>(null);
@@ -100,6 +101,7 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
setLocalModel(printer.model || '');
setLocalAutoDispatch(printer.auto_dispatch ?? true);
setLocalQueueForceColorMatch(printer.queue_force_color_match ?? false);
setLocalGcodeInjection(printer.gcode_injection ?? false);
setLocalTailscaleDisabled(printer.tailscale_disabled ?? true);
}
}, [printer, pendingAction]);
@@ -437,6 +439,36 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
</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 */}
<div className="pt-2 border-t border-bambu-dark-tertiary">
<div className="flex items-center justify-between gap-3">
+4
View File
@@ -4583,6 +4583,10 @@ export default {
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.',
},
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: {
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.',
+4
View File
@@ -4604,6 +4604,10 @@ export default {
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.',
},
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: {
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.',
+4
View File
@@ -4592,6 +4592,10 @@ export default {
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.',
},
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: {
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.',
+4
View File
@@ -4573,6 +4573,10 @@ export default {
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.',
},
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: {
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.',
+4
View File
@@ -4572,6 +4572,10 @@ export default {
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.',
},
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: {
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.',
+4
View File
@@ -4584,6 +4584,10 @@ export default {
title: '色の一致を強制',
description: '正確なフィラメントタイプと色がロードされていないプリンターへの送信を拒否します。デフォルトはオフ — これがないと、キューはモデルのみのマッチングを使用し、間違った色がロードされたプリンターを選ぶ可能性があります。',
},
gcodeInjection: {
title: 'G-codeインジェクション',
description: '設定でモデルごとに構成したG-codeスニペットを、このVPのジョブに適用します。デフォルトはオフです。',
},
tailscaleDisabled: {
title: 'Tailscale統合',
description: 'このVPがTailscale経由で公開されていることをマークするには有効にしてください。スライサーに貼り付けるIPがわかるよう、ホストのTailscaleアドレスを表示します。CAインポート手順は変更されません — このトグルは証明書に影響しません。',
+4
View File
@@ -4324,6 +4324,10 @@ export default {
title: '색상 일치 강제',
description: '정확한 필라멘트 유형과 색상이 장착되지 않은 프린터에는 발송을 거부합니다. 기본적으로 꺼져 있음 — 이 옵션 없이는 대기열이 모델 전용 매칭을 사용하여 잘못된 색상이 장착된 프린터를 선택할 수 있습니다.'
},
gcodeInjection: {
title: 'G-code 주입',
description: '설정에서 모델별로 구성한 G-code 스니펫을 이 가상 프린터의 작업에 적용합니다. 기본값은 꺼짐입니다.'
},
tailscaleDisabled: {
title: 'Tailscale 통합',
description: '이 가상 프린터가 Tailscale을 통해 노출되도록 표시하려면 활성화하세요. 슬라이서에 붙여넣을 IP를 알 수 있도록 호스트의 Tailscale 주소를 표시합니다. CA 가져오기 단계는 변경되지 않음 — 이 토글은 인증서에 영향을 미치지 않습니다.'
+4
View File
@@ -4572,6 +4572,10 @@ export default {
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.',
},
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: {
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.',
+4
View File
@@ -4547,6 +4547,10 @@ export default {
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.',
},
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: {
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.",
+4
View File
@@ -4572,6 +4572,10 @@ export default {
title: '强制颜色匹配',
description: '拒绝派发到没有完全相同耗材类型和颜色的打印机。默认关闭 — 不启用时,队列仅按型号匹配,可能选到颜色错误的打印机。',
},
gcodeInjection: {
title: 'G-code 注入',
description: '将“设置”中按型号配置的 G-code 片段应用到此 VP 的作业。默认关闭。',
},
tailscaleDisabled: {
title: 'Tailscale 集成',
description: '启用以将此 VP 标记为通过 Tailscale 公开。显示主机的 Tailscale 地址,以便您知道要粘贴到切片器中的 IP。CA 导入步骤保持不变 — 此开关对证书无影响。',
+4
View File
@@ -4572,6 +4572,10 @@ export default {
title: '強制顏色匹配',
description: '拒絕派發到沒有完全相同耗材類型和顏色的印表機。預設關閉 — 不啟用時,佇列僅按型號匹配,可能選到顏色錯誤的印表機。',
},
gcodeInjection: {
title: 'G-code 注入',
description: '將「設定」中依型號設定的 G-code 片段套用到此 VP 的作業。預設關閉。',
},
tailscaleDisabled: {
title: 'Tailscale 整合',
description: '啟用以將此 VP 標記為透過 Tailscale 公開。顯示主機的 Tailscale 位址,以便您知道要貼上到切片器中的 IP。CA 匯入步驟保持不變 — 此切換對憑證無影響。',