Browse Source

feat(virtual-printer): setup diagnostic + one-click slicer-certificate export

  Two recurring virtual-printer support pains, both on the Virtual Printers
  settings page.

  Setup check: a stethoscope action on each VP card runs a pass/fail/warn/skip
  checklist — VP enabled, services running, bind interface still exists, access
  code set, target printer (proxy mode), and a live TCP probe of the FTP / MQTT
  / discovery ports on the bind IP. start_server swallows per-service bind
  errors, so a service object can exist while nothing is listening; probing the
  bind IP from outside is the only reliable signal and it catches the common
  "VP not visible in the slicer" bind-IP-conflict and stale-interface cases.

  Slicer certificate: virtual printers present a TLS cert signed by a shared CA
  the slicer must trust. Until now users had to docker exec in and cat
  bbl_ca.crt. A "Slicer certificate" row on the settings card now offers Copy
  and Download (bambuddy-virtual-printer-ca.crt) plus the SHA-256 fingerprint.
  GET /virtual-printers/ca-certificate returns only the public certificate; the
  CA private key never leaves the backend. The CA is generated on demand so the
  button works before the first VP is enabled.

  Backend:
  - services/virtual_printer/diagnostic.py — run_vp_diagnostic + port probes
  - schemas/virtual_printer.py — VPDiagnosticResult
  - CertificateService.get_ca_certificate_info() + manager helper
  - routes: GET /virtual-printers/ca-certificate, /{vp_id}/diagnostic

  Frontend:
  - VirtualPrinterDiagnosticModal.tsx; stethoscope button on VirtualPrinterCard
  - caCert row on VirtualPrinterList; utils/clipboard.ts (shared copy w/
    non-secure-context fallback + downloadTextFile), de-duplicating the
    existing FQDN-copy logic
  - vpDiagnostic.* + virtualPrinter.caCert.* across all 9 locales

  9 backend unit tests + 4 route integration tests + 6 frontend tests.
  Backend ruff clean, frontend build clean, i18n parity green.
maziggy 6 days ago
parent
commit
6bc6a1d683

File diff suppressed because it is too large
+ 1 - 0
CHANGELOG.md


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

@@ -10,6 +10,7 @@ from backend.app.core.auth import RequirePermissionIfAuthEnabled
 from backend.app.core.database import get_db
 from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.core.permissions import Permission
 from backend.app.models.user import User
 from backend.app.models.user import User
+from backend.app.schemas.virtual_printer import VPDiagnosticResult
 
 
 # Imported at module scope so tests can patch
 # Imported at module scope so tests can patch
 # backend.app.api.routes.virtual_printers.tailscale_service.
 # backend.app.api.routes.virtual_printers.tailscale_service.
@@ -254,6 +255,49 @@ async def get_tailscale_status(
     )
     )
 
 
 
 
+@router.get("/ca-certificate")
+async def get_ca_certificate(
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+):
+    """Return the shared virtual-printer CA certificate (PEM) for slicer trust import.
+
+    One CA is shared by every virtual printer — the user imports it into their
+    slicer's trust store once. Only the public certificate is returned; the CA
+    private key never leaves the backend.
+    """
+    from backend.app.services.virtual_printer import virtual_printer_manager
+
+    try:
+        return virtual_printer_manager.get_ca_certificate_info()
+    except Exception as e:
+        logger.error("Failed to obtain virtual printer CA certificate: %s", e)
+        return JSONResponse(status_code=500, content={"detail": "Could not generate the CA certificate"})
+
+
+@router.get("/{vp_id}/diagnostic", response_model=VPDiagnosticResult)
+async def diagnose_virtual_printer(
+    vp_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+):
+    """Run setup diagnostics for a virtual printer.
+
+    Probes the VP's own bind IP and services so the user can self-diagnose the
+    common "my virtual printer doesn't show up in the slicer" failures.
+    """
+    from backend.app.models.virtual_printer import VirtualPrinter
+    from backend.app.services.virtual_printer import virtual_printer_manager
+    from backend.app.services.virtual_printer.diagnostic import run_vp_diagnostic
+
+    result = await db.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))
+    vp = result.scalar_one_or_none()
+    if not vp:
+        return JSONResponse(status_code=404, content={"detail": "Virtual printer not found"})
+
+    instance = virtual_printer_manager.get_instance(vp.id)
+    return await run_vp_diagnostic(vp, instance)
+
+
 @router.get("/{vp_id}")
 @router.get("/{vp_id}")
 async def get_virtual_printer(
 async def get_virtual_printer(
     vp_id: int,
     vp_id: int,

+ 23 - 0
backend/app/schemas/virtual_printer.py

@@ -0,0 +1,23 @@
+"""Schemas for virtual printer diagnostics."""
+
+from pydantic import BaseModel
+
+from backend.app.schemas.printer import DiagnosticCheck
+
+
+class VPDiagnosticResult(BaseModel):
+    """Result of a virtual-printer setup diagnostic run.
+
+    Mirrors ``PrinterDiagnosticResult`` but keyed to a virtual printer: the
+    checks probe the VP's own bind IP and local services rather than a remote
+    printer. ``checks[].id`` values are VP-specific (enabled, running,
+    bind_interface, access_code, target_printer, port_ftps, port_mqtt,
+    port_bind, certificate); the frontend renders the localized title and
+    fix text from id + status.
+    """
+
+    vp_id: int
+    vp_name: str
+    mode: str
+    overall: str  # "ok" | "warnings" | "problems"
+    checks: list[DiagnosticCheck]

+ 22 - 0
backend/app/services/virtual_printer/certificate.py

@@ -330,6 +330,28 @@ class CertificateService:
         logger.info("  Printer: CN=%s", self.serial)
         logger.info("  Printer: CN=%s", self.serial)
         return self.cert_path, self.key_path
         return self.cert_path, self.key_path
 
 
+    def get_ca_certificate_info(self) -> dict:
+        """Return the shared CA certificate as PEM text plus identifying metadata.
+
+        Generates the CA if it does not exist yet. Safe to expose over the
+        API: this is the *public* CA certificate users import into their
+        slicer's trust store. The CA private key (``bbl_ca.key``) is never
+        included and never leaves the backend.
+
+        Returns:
+            Dict with ``pem`` (PEM-encoded certificate), ``fingerprint_sha256``
+            (colon-separated uppercase hex) and ``not_valid_after`` (ISO 8601).
+        """
+        _ca_key, ca_cert = self._get_or_create_ca()
+        pem = ca_cert.public_bytes(serialization.Encoding.PEM).decode("ascii")
+        digest = ca_cert.fingerprint(hashes.SHA256()).hex().upper()
+        fingerprint = ":".join(digest[i : i + 2] for i in range(0, len(digest), 2))
+        return {
+            "pem": pem,
+            "fingerprint_sha256": fingerprint,
+            "not_valid_after": ca_cert.not_valid_after_utc.isoformat(),
+        }
+
     def delete_printer_certificate(self) -> None:
     def delete_printer_certificate(self) -> None:
         """Delete only the printer certificate (preserves CA)."""
         """Delete only the printer certificate (preserves CA)."""
         for path in [self.cert_path, self.key_path]:
         for path in [self.cert_path, self.key_path]:

+ 170 - 0
backend/app/services/virtual_printer/diagnostic.py

@@ -0,0 +1,170 @@
+"""Setup diagnostic for a virtual printer.
+
+A virtual printer fails for the user in ways a real printer never does: the
+bind IP no longer exists after a host/network change, a service silently
+failed to bind its port, the access code was never set, the slicer was never
+told to trust the CA. The manager swallows per-service start errors
+(``run_with_logging`` in ``start_server``), so a service object can exist
+while nothing is actually listening — the only reliable signal is probing the
+bind IP's ports from the outside, which is what this does.
+
+Each check carries a stable ``id`` and a ``status`` of pass / fail / warn /
+skip; the frontend renders the localized title and fix text keyed on that
+id + status.
+"""
+
+import asyncio
+import logging
+
+from backend.app.models.virtual_printer import VirtualPrinter
+from backend.app.schemas.printer import DiagnosticCheck
+from backend.app.schemas.virtual_printer import VPDiagnosticResult
+
+logger = logging.getLogger(__name__)
+
+# Server-mode listening ports — see virtual_printer/manager.py start_server().
+PORT_FTPS = 990  # implicit FTPS — slicer file upload
+PORT_MQTT = 8883  # MQTT over TLS — control + status
+PORT_BIND = 3002  # bind/detect (TLS) — slicer discovery handshake
+
+_PORT_PROBE_TIMEOUT = 2.0
+
+
+async def _check_port(ip: str, port: int, timeout: float = _PORT_PROBE_TIMEOUT) -> bool:
+    """Test TCP connectivity to ip:port. Returns True if something is listening."""
+    try:
+        _reader, writer = await asyncio.wait_for(asyncio.open_connection(ip, port), timeout=timeout)
+        writer.close()
+        try:
+            await writer.wait_closed()
+        except Exception:
+            pass
+        return True
+    except Exception:
+        return False
+
+
+async def run_vp_diagnostic(vp: VirtualPrinter, instance) -> VPDiagnosticResult:
+    """Run setup checks for a virtual printer.
+
+    Args:
+        vp: The virtual printer DB row.
+        instance: The running ``VirtualPrinterInstance`` from the manager, or
+            ``None`` if the VP is not currently instantiated.
+    """
+    checks: list[DiagnosticCheck] = []
+    is_proxy = vp.mode == "proxy"
+    running = bool(instance and instance.is_running)
+
+    # --- VP enabled ---
+    checks.append(DiagnosticCheck(id="enabled", status="pass" if vp.enabled else "fail"))
+
+    # --- Instance running ---
+    if not vp.enabled:
+        checks.append(DiagnosticCheck(id="running", status="skip"))
+    else:
+        checks.append(DiagnosticCheck(id="running", status="pass" if running else "fail"))
+
+    # --- Bind interface still exists ---
+    # A bind IP picked weeks ago can vanish after a Docker restart or a router
+    # handing out a different lease — the VP then binds nothing and is invisible.
+    if not vp.bind_ip:
+        checks.append(DiagnosticCheck(id="bind_interface", status="fail"))
+    else:
+        from backend.app.services.network_utils import find_interface_for_ip
+
+        iface = find_interface_for_ip(vp.bind_ip)
+        checks.append(
+            DiagnosticCheck(
+                id="bind_interface",
+                status="pass" if iface else "fail",
+                params={"bind_ip": vp.bind_ip},
+            )
+        )
+
+    # --- Access code (non-proxy modes only) ---
+    if is_proxy:
+        checks.append(DiagnosticCheck(id="access_code", status="skip"))
+    else:
+        checks.append(DiagnosticCheck(id="access_code", status="pass" if vp.access_code else "fail"))
+
+    # --- Target printer (proxy mode only) ---
+    if not is_proxy:
+        checks.append(DiagnosticCheck(id="target_printer", status="skip"))
+    elif not vp.target_printer_id:
+        checks.append(DiagnosticCheck(id="target_printer", status="fail"))
+    else:
+        from backend.app.services.printer_manager import printer_manager
+
+        state = printer_manager.get_status(vp.target_printer_id)
+        online = bool(state and state.connected)
+        # A configured-but-offline target degrades proxying but isn't a setup
+        # error on the VP's side — warn rather than fail.
+        checks.append(DiagnosticCheck(id="target_printer", status="pass" if online else "warn"))
+
+    # --- Service ports actually listening on the bind IP ---
+    # The decisive check: a service object can exist while its socket never
+    # bound (port already in use, permission denied) because start errors are
+    # logged and swallowed. Probe the bind IP directly.
+    bind_ip = vp.bind_ip
+    if not running or not bind_ip:
+        for cid, port in (("port_ftps", PORT_FTPS), ("port_mqtt", PORT_MQTT), ("port_bind", PORT_BIND)):
+            checks.append(DiagnosticCheck(id=cid, status="skip", params={"port": port}))
+    elif is_proxy:
+        # Proxy mode listens on dynamic ports reported by the proxy manager,
+        # and runs no bind/detect server.
+        proxy_status = instance.get_status().get("proxy", {})
+        ftp_port = proxy_status.get("ftp_port")
+        mqtt_port = proxy_status.get("mqtt_port")
+        ftp_ok = await _check_port(bind_ip, ftp_port) if ftp_port else False
+        mqtt_ok = await _check_port(bind_ip, mqtt_port) if mqtt_port else False
+        checks.append(
+            DiagnosticCheck(
+                id="port_ftps",
+                status="pass" if ftp_ok else "fail",
+                params={"port": ftp_port or PORT_FTPS},
+            )
+        )
+        checks.append(
+            DiagnosticCheck(
+                id="port_mqtt",
+                status="pass" if mqtt_ok else "fail",
+                params={"port": mqtt_port or PORT_MQTT},
+            )
+        )
+        checks.append(DiagnosticCheck(id="port_bind", status="skip", params={"port": PORT_BIND}))
+    else:
+        ftp_ok, mqtt_ok, bind_ok = await asyncio.gather(
+            _check_port(bind_ip, PORT_FTPS),
+            _check_port(bind_ip, PORT_MQTT),
+            _check_port(bind_ip, PORT_BIND),
+        )
+        checks.append(DiagnosticCheck(id="port_ftps", status="pass" if ftp_ok else "fail", params={"port": PORT_FTPS}))
+        checks.append(DiagnosticCheck(id="port_mqtt", status="pass" if mqtt_ok else "fail", params={"port": PORT_MQTT}))
+        checks.append(DiagnosticCheck(id="port_bind", status="pass" if bind_ok else "fail", params={"port": PORT_BIND}))
+
+    # --- TLS certificate ---
+    # When running, the cert chain must exist on disk for the slicer's TLS
+    # handshake to succeed. This is a pass/fail on the file; the localized
+    # detail text reminds the user to import the CA into the slicer.
+    if not running:
+        checks.append(DiagnosticCheck(id="certificate", status="skip"))
+    else:
+        cert_ok = bool(instance and instance.cert_path.exists())
+        checks.append(DiagnosticCheck(id="certificate", status="pass" if cert_ok else "fail"))
+
+    statuses = {c.status for c in checks}
+    if "fail" in statuses:
+        overall = "problems"
+    elif "warn" in statuses:
+        overall = "warnings"
+    else:
+        overall = "ok"
+
+    return VPDiagnosticResult(
+        vp_id=vp.id,
+        vp_name=vp.name,
+        mode=vp.mode,
+        overall=overall,
+        checks=checks,
+    )

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

@@ -877,6 +877,18 @@ class VirtualPrinterManager:
         """Inject the global printer_manager so non-proxy VPs can mirror their target's MQTT stream."""
         """Inject the global printer_manager so non-proxy VPs can mirror their target's MQTT stream."""
         self._printer_manager = printer_manager
         self._printer_manager = printer_manager
 
 
+    def get_ca_certificate_info(self) -> dict:
+        """Return the shared virtual-printer CA certificate for slicer-trust import.
+
+        The CA is shared by every VP (one import covers all of them). It is
+        generated on demand here if no VP has triggered cert generation yet,
+        so the "copy/download certificate" UI works even before the first VP
+        is enabled.
+        """
+        certs_dir = self._base_dir / "certs"
+        cert_service = CertificateService(cert_dir=certs_dir, shared_ca_dir=certs_dir)
+        return cert_service.get_ca_certificate_info()
+
     @property
     @property
     def is_enabled(self) -> bool:
     def is_enabled(self) -> bool:
         """Check if any virtual printer is running."""
         """Check if any virtual printer is running."""

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

@@ -384,3 +384,58 @@ class TestVirtualPrinterTailscaleToggleAPI:
         assert enable_resp.json()["tailscale_disabled"] is False
         assert enable_resp.json()["tailscale_disabled"] is False
         assert disable_resp.status_code == 200
         assert disable_resp.status_code == 200
         assert disable_resp.json()["tailscale_disabled"] is True
         assert disable_resp.json()["tailscale_disabled"] is True
+
+
+class TestVirtualPrinterCaCertificateAPI:
+    """Integration tests for GET /api/v1/virtual-printers/ca-certificate."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_ca_certificate_returns_pem(self, async_client: AsyncClient):
+        """The shared CA certificate is returned as PEM with identifying metadata."""
+        response = await async_client.get("/api/v1/virtual-printers/ca-certificate")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["pem"].startswith("-----BEGIN CERTIFICATE-----")
+        assert "PRIVATE KEY" not in result["pem"]  # never expose the CA key
+        assert len(result["fingerprint_sha256"].split(":")) == 32
+        assert result["not_valid_after"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_ca_certificate_route_precedes_vp_id_route(self, async_client: AsyncClient):
+        """'ca-certificate' must not be swallowed by the /{vp_id} int route."""
+        response = await async_client.get("/api/v1/virtual-printers/ca-certificate")
+        # A 200 (not 422 from int-parsing "ca-certificate") proves route ordering.
+        assert response.status_code == 200
+
+
+class TestVirtualPrinterDiagnosticAPI:
+    """Integration tests for GET /api/v1/virtual-printers/{vp_id}/diagnostic."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_diagnose_unknown_vp_returns_404(self, async_client: AsyncClient):
+        response = await async_client.get("/api/v1/virtual-printers/999999/diagnostic")
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_diagnose_disabled_vp_reports_problems(self, async_client: AsyncClient):
+        """A freshly created (disabled) VP fails the 'enabled' check."""
+        create_resp = await async_client.post(
+            "/api/v1/virtual-printers",
+            json={"name": "TestDiagVP", "mode": "immediate", "access_code": "12345678"},
+        )
+        assert create_resp.status_code == 200
+        vp_id = create_resp.json()["id"]
+
+        response = await async_client.get(f"/api/v1/virtual-printers/{vp_id}/diagnostic")
+        assert response.status_code == 200
+        result = response.json()
+        assert result["vp_id"] == vp_id
+        assert result["overall"] == "problems"
+        by_id = {c["id"]: c["status"] for c in result["checks"]}
+        assert by_id["enabled"] == "fail"
+        assert by_id["running"] == "skip"

+ 167 - 0
backend/tests/unit/services/test_vp_diagnostic.py

@@ -0,0 +1,167 @@
+"""Unit tests for the virtual printer setup diagnostic."""
+
+import tempfile
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+from backend.app.services.virtual_printer.certificate import CertificateService
+from backend.app.services.virtual_printer.diagnostic import run_vp_diagnostic
+
+_DIAG = "backend.app.services.virtual_printer.diagnostic._check_port"
+_FIND_IFACE = "backend.app.services.network_utils.find_interface_for_ip"
+
+
+def _vp(**overrides):
+    """A virtual-printer DB row stand-in with sensible healthy defaults."""
+    base = {
+        "id": 1,
+        "name": "Test VP",
+        "mode": "immediate",
+        "enabled": True,
+        "bind_ip": "192.168.1.50",
+        "access_code": "12345678",
+        "target_printer_id": None,
+    }
+    base.update(overrides)
+    return SimpleNamespace(**base)
+
+
+class _FakeInstance:
+    """Minimal VirtualPrinterInstance stand-in for the diagnostic."""
+
+    def __init__(self, running=True, cert_exists=True, proxy_status=None):
+        self.is_running = running
+        self._cert_exists = cert_exists
+        self._proxy_status = proxy_status
+
+    @property
+    def cert_path(self):
+        return SimpleNamespace(exists=lambda: self._cert_exists)
+
+    def get_status(self):
+        return {"proxy": self._proxy_status} if self._proxy_status is not None else {}
+
+
+def _checks(result):
+    return {c.id: c.status for c in result.checks}
+
+
+class TestRunVpDiagnostic:
+    @pytest.mark.asyncio
+    async def test_disabled_vp_reports_problems(self):
+        """A disabled VP fails the 'enabled' check; running/port checks skip."""
+        result = await run_vp_diagnostic(_vp(enabled=False, bind_ip=None, access_code=None), None)
+        c = _checks(result)
+        assert result.overall == "problems"
+        assert c["enabled"] == "fail"
+        assert c["running"] == "skip"
+        assert c["port_ftps"] == c["port_mqtt"] == c["port_bind"] == "skip"
+        assert c["certificate"] == "skip"
+
+    @pytest.mark.asyncio
+    async def test_running_server_vp_all_pass(self):
+        """Enabled + running + every port listening + cert present → overall ok."""
+        with (
+            patch(_DIAG, AsyncMock(return_value=True)),
+            patch(_FIND_IFACE, return_value={"name": "eth0", "ip": "192.168.1.50"}),
+        ):
+            result = await run_vp_diagnostic(_vp(), _FakeInstance())
+        c = _checks(result)
+        assert result.overall == "ok"
+        assert c["enabled"] == "pass"
+        assert c["running"] == "pass"
+        assert c["bind_interface"] == "pass"
+        assert c["access_code"] == "pass"
+        assert c["target_printer"] == "skip"  # not proxy mode
+        assert c["port_ftps"] == c["port_mqtt"] == c["port_bind"] == "pass"
+        assert c["certificate"] == "pass"
+
+    @pytest.mark.asyncio
+    async def test_port_not_listening_is_a_problem(self):
+        """A service object can exist while its socket never bound — the probe
+        is what catches it, so a dead port must surface as a failure."""
+        with (
+            patch(_DIAG, AsyncMock(return_value=False)),
+            patch(_FIND_IFACE, return_value={"name": "eth0", "ip": "192.168.1.50"}),
+        ):
+            result = await run_vp_diagnostic(_vp(), _FakeInstance())
+        c = _checks(result)
+        assert result.overall == "problems"
+        assert c["port_ftps"] == c["port_mqtt"] == c["port_bind"] == "fail"
+
+    @pytest.mark.asyncio
+    async def test_stale_bind_ip_fails_interface_check(self):
+        """A bind IP that no longer matches any interface fails the check."""
+        with (
+            patch(_DIAG, AsyncMock(return_value=True)),
+            patch(_FIND_IFACE, return_value=None),
+        ):
+            result = await run_vp_diagnostic(_vp(), _FakeInstance())
+        c = _checks(result)
+        assert c["bind_interface"] == "fail"
+        assert result.overall == "problems"
+
+    @pytest.mark.asyncio
+    async def test_missing_access_code_fails_non_proxy(self):
+        with (
+            patch(_DIAG, AsyncMock(return_value=True)),
+            patch(_FIND_IFACE, return_value={"name": "eth0", "ip": "192.168.1.50"}),
+        ):
+            result = await run_vp_diagnostic(_vp(access_code=None), _FakeInstance())
+        assert _checks(result)["access_code"] == "fail"
+
+    @pytest.mark.asyncio
+    async def test_proxy_mode_skips_access_code_and_bind_port(self):
+        """Proxy mode has no access code and runs no bind/detect server."""
+        instance = _FakeInstance(proxy_status={"ftp_port": 3001, "mqtt_port": 3003})
+        with (
+            patch(_DIAG, AsyncMock(return_value=True)),
+            patch(_FIND_IFACE, return_value={"name": "eth0", "ip": "192.168.1.50"}),
+        ):
+            result = await run_vp_diagnostic(_vp(mode="proxy", target_printer_id=7), instance)
+        c = _checks(result)
+        assert c["access_code"] == "skip"
+        assert c["port_bind"] == "skip"
+        assert c["port_ftps"] == "pass"
+        assert c["port_mqtt"] == "pass"
+
+    @pytest.mark.asyncio
+    async def test_proxy_without_target_fails(self):
+        """Proxy mode with no target printer fails the target check."""
+        with (
+            patch(_DIAG, AsyncMock(return_value=True)),
+            patch(_FIND_IFACE, return_value={"name": "eth0", "ip": "192.168.1.50"}),
+        ):
+            result = await run_vp_diagnostic(
+                _vp(mode="proxy", target_printer_id=None, access_code=None), _FakeInstance()
+            )
+        c = _checks(result)
+        assert c["target_printer"] == "fail"
+        assert result.overall == "problems"
+
+
+class TestCaCertificateInfo:
+    def test_get_ca_certificate_info_generates_and_returns_pem(self):
+        """The CA is generated on demand; the returned PEM is the public cert."""
+        with tempfile.TemporaryDirectory() as d:
+            service = CertificateService(cert_dir=Path(d), shared_ca_dir=Path(d))
+            info = service.get_ca_certificate_info()
+        assert info["pem"].startswith("-----BEGIN CERTIFICATE-----")
+        assert "-----END CERTIFICATE-----" in info["pem"]
+        # SHA-256 fingerprint: 32 colon-separated uppercase hex bytes.
+        parts = info["fingerprint_sha256"].split(":")
+        assert len(parts) == 32
+        assert all(len(p) == 2 and p == p.upper() for p in parts)
+        assert info["not_valid_after"]
+
+    def test_ca_certificate_info_is_stable_across_calls(self):
+        """A second call reuses the persisted CA — same fingerprint, no key leak."""
+        with tempfile.TemporaryDirectory() as d:
+            service = CertificateService(cert_dir=Path(d), shared_ca_dir=Path(d))
+            first = service.get_ca_certificate_info()
+            second = service.get_ca_certificate_info()
+        assert first["fingerprint_sha256"] == second["fingerprint_sha256"]
+        assert "PRIVATE KEY" not in first["pem"]

+ 1 - 1
frontend/scripts/check-i18n-parity.mjs

@@ -124,7 +124,7 @@ function isAlwaysAllowedIdentical(value) {
   if (/^https?:\/\//.test(value)) return true;          // URL
   if (/^https?:\/\//.test(value)) return true;          // URL
   if (/^ON,\s+true,\s+1$/.test(value)) return true;     // literal example "ON, true, 1"
   if (/^ON,\s+true,\s+1$/.test(value)) return true;     // literal example "ON, true, 1"
   // Brand / technical names that ship verbatim everywhere.
   // Brand / technical names that ship verbatim everywhere.
-  if (/^(Bambuddy|BamBuddy|SpoolBuddy|Bambu Lab|Bambu Studio|Bambu Studio 2\.6\+|Bambu Studio sidecar URL|OrcaSlicer|OrcaSlicer sidecar URL|MakerWorld|Spoolman|\(Spoolman\)|Spoolman URL|Tailscale|GitHub|GitLab|Gitea|Forgejo|Discord|MQTT|FTP|HTTPS?|JSON|YAML|RTSP|TLS|SSL|CSRF|OIDC|SSO|SSO \/ OIDC|LDAP|TOTP|2FA|MFA|API|AMS|CRC|SHA256|kWh|MB|GB|KB|RGBA?|HSL|RGB|UTC|ISO|UI|HTTP|HTTP Method|H2D|H2D Pro|X1C|X1E|P1S|P1P|A1|A1 Mini|H2C|N3F|N3S|PETG|PLA|ABS|PA|TPU|PEI|PA-CF|PVA|HIPS|ASA|PC|PETG-HF|G\.code|G-code|gcode|cm³|°C|°F|GCODE|SOURCE|ntfy|Pushover|Telegram|Webhook|Webhook URL|Home Assistant|Home Assistant URL|CallMeBot\/WhatsApp|Bambuddy URL|Cool Plate|Cool Plate SuperTack|Engineering Plate|High Temp Plate|Smooth PEI Plate|Textured PEI Plate|Ext-L|Ext-R|ISO \(YYYY-MM-DD\))$/.test(value)) return true;
+  if (/^(Bambuddy|BamBuddy|SpoolBuddy|Bambu Lab|Bambu Studio|Bambu Studio 2\.6\+|Bambu Studio sidecar URL|OrcaSlicer|OrcaSlicer sidecar URL|MakerWorld|Spoolman|\(Spoolman\)|Spoolman URL|Tailscale|GitHub|GitLab|Gitea|Forgejo|Discord|MQTT|FTP|HTTPS?|JSON|YAML|RTSP|TLS|SSL|CSRF|OIDC|SSO|SSO \/ OIDC|LDAP|TOTP|2FA|MFA|API|AMS|CRC|SHA256|SHA-256|kWh|MB|GB|KB|RGBA?|HSL|RGB|UTC|ISO|UI|HTTP|HTTP Method|H2D|H2D Pro|X1C|X1E|P1S|P1P|A1|A1 Mini|H2C|N3F|N3S|PETG|PLA|ABS|PA|TPU|PEI|PA-CF|PVA|HIPS|ASA|PC|PETG-HF|G\.code|G-code|gcode|cm³|°C|°F|GCODE|SOURCE|ntfy|Pushover|Telegram|Webhook|Webhook URL|Home Assistant|Home Assistant URL|CallMeBot\/WhatsApp|Bambuddy URL|Cool Plate|Cool Plate SuperTack|Engineering Plate|High Temp Plate|Smooth PEI Plate|Textured PEI Plate|Ext-L|Ext-R|ISO \(YYYY-MM-DD\))$/.test(value)) return true;
   return false;
   return false;
 }
 }
 
 

+ 75 - 0
frontend/src/__tests__/components/VirtualPrinterDiagnosticModal.test.tsx

@@ -0,0 +1,75 @@
+/**
+ * Tests for the VirtualPrinterDiagnosticModal component.
+ */
+
+import { describe, it, expect, vi } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+import { render } from '../utils';
+import { VirtualPrinterDiagnosticModal } from '../../components/VirtualPrinterDiagnosticModal';
+import type { VPDiagnosticResult } from '../../api/client';
+
+const problemResult: VPDiagnosticResult = {
+  vp_id: 3,
+  vp_name: 'Garage VP',
+  mode: 'immediate',
+  overall: 'problems',
+  checks: [
+    { id: 'enabled', status: 'pass', params: {} },
+    { id: 'running', status: 'fail', params: {} },
+    { id: 'port_mqtt', status: 'fail', params: { port: 8883 } },
+  ],
+};
+
+/** Stub the diagnostic endpoint and count how often it is hit. */
+function setupDiagnostic(result: VPDiagnosticResult): { calls: () => number } {
+  let count = 0;
+  server.use(
+    http.get('*/virtual-printers/:id/diagnostic', () => {
+      count += 1;
+      return HttpResponse.json(result);
+    }),
+  );
+  return { calls: () => count };
+}
+
+describe('VirtualPrinterDiagnosticModal', () => {
+  it('runs the diagnostic on mount and renders the checks', async () => {
+    const probe = setupDiagnostic(problemResult);
+
+    render(<VirtualPrinterDiagnosticModal vpId={3} vpName="Garage VP" onClose={() => {}} />);
+
+    expect(await screen.findByText(/Found problems that explain/)).toBeInTheDocument();
+    expect(probe.calls()).toBe(1);
+    // Per-check titles render; the port param is interpolated into the title.
+    expect(screen.getByText('Services running')).toBeInTheDocument();
+    expect(screen.getByText('Control service (port 8883)')).toBeInTheDocument();
+  });
+
+  it('re-runs the diagnostic when "Run again" is clicked', async () => {
+    const probe = setupDiagnostic(problemResult);
+    const user = userEvent.setup();
+
+    render(<VirtualPrinterDiagnosticModal vpId={3} vpName="Garage VP" onClose={() => {}} />);
+
+    await screen.findByText(/Found problems that explain/);
+    expect(probe.calls()).toBe(1);
+
+    await user.click(screen.getByText('Run again'));
+    await waitFor(() => expect(probe.calls()).toBe(2));
+  });
+
+  it('calls onClose when the Close button is clicked', async () => {
+    setupDiagnostic({ ...problemResult, overall: 'ok' });
+    const onClose = vi.fn();
+    const user = userEvent.setup();
+
+    render(<VirtualPrinterDiagnosticModal vpId={3} vpName="Garage VP" onClose={onClose} />);
+
+    await screen.findByText(/set up correctly/);
+    await user.click(screen.getByText('Close'));
+    expect(onClose).toHaveBeenCalled();
+  });
+});

+ 66 - 0
frontend/src/__tests__/utils/clipboard.test.ts

@@ -0,0 +1,66 @@
+/**
+ * Tests for the clipboard / file-download helpers.
+ */
+
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { copyTextToClipboard, downloadTextFile } from '../../utils/clipboard';
+
+describe('copyTextToClipboard', () => {
+  afterEach(() => {
+    vi.restoreAllMocks();
+  });
+
+  it('uses navigator.clipboard in a secure context', async () => {
+    const writeText = vi.fn().mockResolvedValue(undefined);
+    vi.stubGlobal('navigator', { clipboard: { writeText } });
+    vi.stubGlobal('isSecureContext', true);
+
+    const ok = await copyTextToClipboard('hello');
+
+    expect(ok).toBe(true);
+    expect(writeText).toHaveBeenCalledWith('hello');
+    vi.unstubAllGlobals();
+  });
+
+  it('falls back to execCommand when clipboard write rejects', async () => {
+    const writeText = vi.fn().mockRejectedValue(new Error('blocked'));
+    vi.stubGlobal('navigator', { clipboard: { writeText } });
+    vi.stubGlobal('isSecureContext', true);
+    const execCommand = vi.fn().mockReturnValue(true);
+    // jsdom does not implement execCommand — supply it for the fallback path.
+    (document as unknown as { execCommand: unknown }).execCommand = execCommand;
+
+    const ok = await copyTextToClipboard('lan-fallback');
+
+    expect(ok).toBe(true);
+    expect(execCommand).toHaveBeenCalledWith('copy');
+    vi.unstubAllGlobals();
+  });
+});
+
+describe('downloadTextFile', () => {
+  it('triggers a download with the given filename', () => {
+    const createObjectURL = vi.fn().mockReturnValue('blob:fake');
+    const revokeObjectURL = vi.fn();
+    (URL as unknown as { createObjectURL: unknown }).createObjectURL = createObjectURL;
+    (URL as unknown as { revokeObjectURL: unknown }).revokeObjectURL = revokeObjectURL;
+
+    let clickedHref = '';
+    let clickedDownload = '';
+    const clickSpy = vi
+      .spyOn(HTMLAnchorElement.prototype, 'click')
+      .mockImplementation(function (this: HTMLAnchorElement) {
+        clickedHref = this.href;
+        clickedDownload = this.download;
+      });
+
+    downloadTextFile('cert-body', 'bambuddy-virtual-printer-ca.crt', 'application/x-pem-file');
+
+    expect(clickSpy).toHaveBeenCalled();
+    expect(clickedHref).toContain('blob:fake');
+    expect(clickedDownload).toBe('bambuddy-virtual-printer-ca.crt');
+    expect(revokeObjectURL).toHaveBeenCalledWith('blob:fake');
+
+    clickSpy.mockRestore();
+  });
+});

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

@@ -6383,8 +6383,47 @@ export const multiVirtualPrinterApi = {
 
 
   getTailscaleStatus: () =>
   getTailscaleStatus: () =>
     request<TailscaleStatusResponse>('/virtual-printers/tailscale-status'),
     request<TailscaleStatusResponse>('/virtual-printers/tailscale-status'),
+
+  getCaCertificate: () =>
+    request<VPCaCertificate>('/virtual-printers/ca-certificate'),
+
+  diagnose: (id: number) =>
+    request<VPDiagnosticResult>(`/virtual-printers/${id}/diagnostic`),
 };
 };
 
 
+/** The shared CA certificate every virtual printer presents — imported once
+ *  into the slicer's trust store. Only the public certificate is returned. */
+export interface VPCaCertificate {
+  pem: string;
+  fingerprint_sha256: string;
+  not_valid_after: string;
+}
+
+export type VPDiagnosticStatus = 'pass' | 'fail' | 'warn' | 'skip';
+
+export interface VPDiagnosticCheck {
+  id:
+    | 'enabled'
+    | 'running'
+    | 'bind_interface'
+    | 'access_code'
+    | 'target_printer'
+    | 'port_ftps'
+    | 'port_mqtt'
+    | 'port_bind'
+    | 'certificate';
+  status: VPDiagnosticStatus;
+  params: Record<string, string | number>;
+}
+
+export interface VPDiagnosticResult {
+  vp_id: number;
+  vp_name: string;
+  mode: string;
+  overall: 'ok' | 'warnings' | 'problems';
+  checks: VPDiagnosticCheck[];
+}
+
 export interface TailscaleStatusResponse {
 export interface TailscaleStatusResponse {
   available: boolean;
   available: boolean;
   fqdn: string;
   fqdn: string;

+ 20 - 27
frontend/src/components/VirtualPrinterCard.tsx

@@ -3,14 +3,16 @@ import { useTranslation } from 'react-i18next';
 import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
 import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
 import {
 import {
   Loader2, Check, AlertTriangle, Eye, EyeOff, Info,
   Loader2, Check, AlertTriangle, Eye, EyeOff, Info,
-  ChevronDown, ChevronRight, ArrowRightLeft, Trash2, ShieldCheck, Copy,
+  ChevronDown, ChevronRight, ArrowRightLeft, Trash2, ShieldCheck, Copy, Stethoscope,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api, multiVirtualPrinterApi } from '../api/client';
 import { api, multiVirtualPrinterApi } from '../api/client';
 import type { VirtualPrinterConfig } from '../api/client';
 import type { VirtualPrinterConfig } from '../api/client';
 import { Card, CardContent } from './Card';
 import { Card, CardContent } from './Card';
 import { Button } from './Button';
 import { Button } from './Button';
 import { ConfirmModal } from './ConfirmModal';
 import { ConfirmModal } from './ConfirmModal';
+import { VirtualPrinterDiagnosticModal } from './VirtualPrinterDiagnosticModal';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
+import { copyTextToClipboard } from '../utils/clipboard';
 
 
 type LocalMode = 'immediate' | 'review' | 'print_queue' | 'proxy';
 type LocalMode = 'immediate' | 'review' | 'print_queue' | 'proxy';
 
 
@@ -48,6 +50,7 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
   const [showAccessCode, setShowAccessCode] = useState(false);
   const [showAccessCode, setShowAccessCode] = useState(false);
   const [pendingAction, setPendingAction] = useState<string | null>(null);
   const [pendingAction, setPendingAction] = useState<string | null>(null);
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+  const [showDiagnostic, setShowDiagnostic] = useState(false);
   const [fqdnCopied, setFqdnCopied] = useState(false);
   const [fqdnCopied, setFqdnCopied] = useState(false);
 
 
   // Host-level Tailscale identity (same for every VP) — shown inline on the card when
   // Host-level Tailscale identity (same for every VP) — shown inline on the card when
@@ -66,32 +69,7 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
     e.stopPropagation();
     e.stopPropagation();
     const fqdn = tailscaleFqdn;
     const fqdn = tailscaleFqdn;
     if (!fqdn) return;
     if (!fqdn) return;
-    let ok = false;
-    // Modern API — only works in secure contexts (HTTPS / localhost).
-    if (navigator.clipboard && window.isSecureContext) {
-      try {
-        await navigator.clipboard.writeText(fqdn);
-        ok = true;
-      } catch {
-        // fall through to legacy
-      }
-    }
-    // Legacy fallback for HTTP (common when Bambuddy is reached over LAN / tailnet IP).
-    if (!ok) {
-      const ta = document.createElement('textarea');
-      ta.value = fqdn;
-      ta.style.position = 'fixed';
-      ta.style.opacity = '0';
-      document.body.appendChild(ta);
-      try {
-        ta.select();
-        ok = document.execCommand('copy');
-      } catch {
-        ok = false;
-      } finally {
-        if (ta.parentNode) ta.parentNode.removeChild(ta);
-      }
-    }
+    const ok = await copyTextToClipboard(fqdn);
     if (ok) {
     if (ok) {
       setFqdnCopied(true);
       setFqdnCopied(true);
       showToast(t('printers.copied'));
       showToast(t('printers.copied'));
@@ -298,6 +276,13 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
                 onKeyDown={(e) => e.key === 'Enter' && handleNameChange()}
                 onKeyDown={(e) => e.key === 'Enter' && handleNameChange()}
                 className="flex-1 text-sm text-white bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-1.5 focus:border-bambu-green focus:outline-none"
                 className="flex-1 text-sm text-white bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-1.5 focus:border-bambu-green focus:outline-none"
               />
               />
+              <button
+                onClick={() => setShowDiagnostic(true)}
+                className="p-1.5 text-bambu-gray hover:text-bambu-green transition-colors flex-shrink-0"
+                title={t('vpDiagnostic.runButton')}
+              >
+                <Stethoscope className="w-4 h-4" />
+              </button>
               <button
               <button
                 onClick={() => setShowDeleteConfirm(true)}
                 onClick={() => setShowDeleteConfirm(true)}
                 className="p-1.5 text-bambu-gray hover:text-red-400 transition-colors flex-shrink-0"
                 className="p-1.5 text-bambu-gray hover:text-red-400 transition-colors flex-shrink-0"
@@ -625,6 +610,14 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
         />
         />
       )}
       )}
 
 
+      {showDiagnostic && (
+        <VirtualPrinterDiagnosticModal
+          vpId={printer.id}
+          vpName={printer.name}
+          onClose={() => setShowDiagnostic(false)}
+        />
+      )}
+
     </>
     </>
   );
   );
 }
 }

+ 159 - 0
frontend/src/components/VirtualPrinterDiagnosticModal.tsx

@@ -0,0 +1,159 @@
+import { useEffect } from 'react';
+import { useMutation } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import {
+  X,
+  Stethoscope,
+  CheckCircle2,
+  XCircle,
+  AlertTriangle,
+  MinusCircle,
+  Loader2,
+} from 'lucide-react';
+import {
+  multiVirtualPrinterApi,
+  type VPDiagnosticCheck,
+  type VPDiagnosticStatus,
+  type VPDiagnosticResult,
+} from '../api/client';
+
+function StatusIcon({ status }: { status: VPDiagnosticStatus }) {
+  if (status === 'pass') return <CheckCircle2 className="w-5 h-5 text-bambu-green flex-shrink-0" />;
+  if (status === 'fail') return <XCircle className="w-5 h-5 text-red-400 flex-shrink-0" />;
+  if (status === 'warn') return <AlertTriangle className="w-5 h-5 text-amber-400 flex-shrink-0" />;
+  return <MinusCircle className="w-5 h-5 text-bambu-gray flex-shrink-0" />;
+}
+
+/**
+ * Setup-check modal for a single virtual printer. Opens straight into the
+ * check (run on mount); "Run again" re-runs it. Each row's title and fix
+ * text are localized via `vpDiagnostic.check.<id>.*`.
+ */
+export function VirtualPrinterDiagnosticModal({
+  vpId,
+  vpName,
+  onClose,
+}: {
+  vpId: number;
+  vpName: string;
+  onClose: () => void;
+}) {
+  const { t } = useTranslation();
+
+  const diagnose = useMutation({
+    mutationFn: (): Promise<VPDiagnosticResult> => multiVirtualPrinterApi.diagnose(vpId),
+  });
+
+  useEffect(() => {
+    diagnose.mutate();
+    // Run once on mount — re-running is the explicit "Run again" button.
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, []);
+
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') onClose();
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [onClose]);
+
+  const result = diagnose.data;
+
+  const overallClass =
+    result?.overall === 'ok'
+      ? 'bg-bambu-green/10 border-bambu-green/30 text-bambu-green'
+      : result?.overall === 'warnings'
+        ? 'bg-amber-500/10 border-amber-500/30 text-amber-300'
+        : 'bg-red-500/10 border-red-500/30 text-red-300';
+
+  const renderCheck = (check: VPDiagnosticCheck) => {
+    const detail = t(`vpDiagnostic.check.${check.id}.${check.status}`, {
+      ...check.params,
+      defaultValue: '',
+    });
+    return (
+      <li
+        key={check.id}
+        className={`flex items-start gap-3 bg-bambu-dark rounded-lg px-4 py-2.5 ${
+          check.status === 'skip' ? 'opacity-60' : ''
+        }`}
+      >
+        <div className="mt-0.5">
+          <StatusIcon status={check.status} />
+        </div>
+        <div className="flex-1 min-w-0">
+          <div className="text-sm text-white">
+            {t(`vpDiagnostic.check.${check.id}.title`, check.params)}
+          </div>
+          {detail && <div className="text-xs text-bambu-gray mt-0.5">{detail}</div>}
+        </div>
+      </li>
+    );
+  };
+
+  return (
+    <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4" onClick={onClose}>
+      <div
+        className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-lg flex flex-col max-h-[85vh]"
+        onClick={(e) => e.stopPropagation()}
+      >
+        <div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
+          <div className="flex items-center gap-2 min-w-0">
+            <Stethoscope className="w-5 h-5 text-bambu-green flex-shrink-0" />
+            <h2 className="text-lg font-semibold text-white truncate">
+              {t('vpDiagnostic.title', { name: vpName })}
+            </h2>
+          </div>
+          <button
+            onClick={onClose}
+            className="text-bambu-gray hover:text-white transition-colors"
+            title={t('common.close')}
+          >
+            <X className="w-5 h-5" />
+          </button>
+        </div>
+
+        <div className="p-6 space-y-4 overflow-y-auto">
+          {diagnose.isPending && (
+            <div className="flex items-center gap-2 text-bambu-gray">
+              <Loader2 className="w-4 h-4 animate-spin" />
+              <span>{t('vpDiagnostic.running')}</span>
+            </div>
+          )}
+
+          {diagnose.isError && (
+            <div className="rounded-lg bg-red-500/10 border border-red-500/30 px-4 py-3 text-sm text-red-300">
+              {t('vpDiagnostic.runFailed', { error: (diagnose.error as Error).message })}
+            </div>
+          )}
+
+          {result && (
+            <div className="space-y-4">
+              <ol className="space-y-2">{result.checks.map(renderCheck)}</ol>
+              <div className={`rounded-lg border px-4 py-3 text-sm ${overallClass}`}>
+                {t(`vpDiagnostic.overall.${result.overall}`)}
+              </div>
+            </div>
+          )}
+        </div>
+
+        <div className="px-6 py-4 border-t border-bambu-dark-tertiary flex justify-end gap-2">
+          <button
+            onClick={() => diagnose.mutate()}
+            disabled={diagnose.isPending}
+            className="px-4 py-2 bg-bambu-dark hover:bg-bambu-dark-tertiary disabled:opacity-50 text-white text-sm rounded-lg transition-colors"
+          >
+            {t('vpDiagnostic.retry')}
+          </button>
+          <button
+            onClick={onClose}
+            className="px-4 py-2 bg-bambu-green hover:bg-bambu-green/90 text-white text-sm rounded-lg transition-colors"
+          >
+            {t('common.close')}
+          </button>
+        </div>
+      </div>
+    </div>
+  );
+}

+ 106 - 28
frontend/src/components/VirtualPrinterList.tsx

@@ -1,12 +1,13 @@
 import { useState } from 'react';
 import { useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
 import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Loader2, Plus, Printer, ExternalLink, AlertTriangle, Info, FileText } from 'lucide-react';
+import { Loader2, Plus, Printer, ExternalLink, AlertTriangle, Info, FileText, ShieldCheck, Copy, Check, Download } from 'lucide-react';
 import { multiVirtualPrinterApi, virtualPrinterApi } from '../api/client';
 import { multiVirtualPrinterApi, virtualPrinterApi } from '../api/client';
 import { Card, CardContent } from './Card';
 import { Card, CardContent } from './Card';
 import { Button } from './Button';
 import { Button } from './Button';
 import { Toggle } from './Toggle';
 import { Toggle } from './Toggle';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
+import { copyTextToClipboard, downloadTextFile } from '../utils/clipboard';
 import { VirtualPrinterCard } from './VirtualPrinterCard';
 import { VirtualPrinterCard } from './VirtualPrinterCard';
 import { VirtualPrinterAddDialog } from './VirtualPrinterAddDialog';
 import { VirtualPrinterAddDialog } from './VirtualPrinterAddDialog';
 
 
@@ -41,6 +42,32 @@ export function VirtualPrinterList() {
 
 
   const useFilename = globalSettings?.archive_name_source === 'filename';
   const useFilename = globalSettings?.archive_name_source === 'filename';
 
 
+  // Shared CA certificate — the slicer imports it once to trust every VP's
+  // TLS connection. Generated on demand by the backend, never changes.
+  const { data: caCert } = useQuery({
+    queryKey: ['vp-ca-certificate'],
+    queryFn: multiVirtualPrinterApi.getCaCertificate,
+    staleTime: Infinity,
+  });
+  const [caCopied, setCaCopied] = useState(false);
+
+  const handleCopyCert = async () => {
+    if (!caCert) return;
+    const ok = await copyTextToClipboard(caCert.pem);
+    if (ok) {
+      setCaCopied(true);
+      showToast(t('virtualPrinter.caCert.copied'));
+      setTimeout(() => setCaCopied(false), 2000);
+    } else {
+      showToast(t('virtualPrinter.toast.copyFailed'), 'error');
+    }
+  };
+
+  const handleDownloadCert = () => {
+    if (!caCert) return;
+    downloadTextFile(caCert.pem, 'bambuddy-virtual-printer-ca.crt', 'application/x-pem-file');
+  };
+
   if (isLoading) {
   if (isLoading) {
     return (
     return (
       <Card>
       <Card>
@@ -96,37 +123,88 @@ export function VirtualPrinterList() {
         </Card>
         </Card>
       </div>
       </div>
 
 
-      {/* Global VP behavior settings */}
-      <Card>
-        <CardContent className="py-3 px-4">
-          <div className="flex items-start gap-3">
-            <FileText className="w-4 h-4 text-bambu-green flex-shrink-0 mt-1" />
-            <div className="flex-1 min-w-0">
-              <div className="flex items-center justify-between gap-3">
-                <p className="text-sm text-white font-medium">
-                  {t('virtualPrinter.archiveNameSource.title')}
+      {/* Global VP behavior settings — two side-by-side cards, not full width */}
+      <div className="grid grid-cols-1 lg:grid-cols-2 gap-4 items-stretch">
+        {/* Slicer CA certificate — shared by every VP, imported into the
+            slicer's trust store once instead of fetching it from the CLI. */}
+        <Card>
+          <CardContent className="py-3 px-4">
+            <div className="flex items-start gap-3">
+              <ShieldCheck className="w-4 h-4 text-bambu-green flex-shrink-0 mt-1" />
+              <div className="flex-1 min-w-0">
+                <div className="flex items-center justify-between gap-3">
+                  <p className="text-sm text-white font-medium">
+                    {t('virtualPrinter.caCert.title')}
+                  </p>
+                  <div className="flex items-center gap-2 flex-shrink-0">
+                    <button
+                      onClick={handleCopyCert}
+                      disabled={!caCert}
+                      className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs rounded bg-bambu-dark-secondary border border-bambu-dark-tertiary text-white hover:border-bambu-gray disabled:opacity-50 transition-colors"
+                    >
+                      {caCopied
+                        ? <Check className="w-3.5 h-3.5 text-bambu-green" />
+                        : <Copy className="w-3.5 h-3.5" />}
+                      {caCopied ? t('virtualPrinter.caCert.copied') : t('virtualPrinter.caCert.copy')}
+                    </button>
+                    <button
+                      onClick={handleDownloadCert}
+                      disabled={!caCert}
+                      className="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs rounded bg-bambu-dark-secondary border border-bambu-dark-tertiary text-white hover:border-bambu-gray disabled:opacity-50 transition-colors"
+                    >
+                      <Download className="w-3.5 h-3.5" />
+                      {t('virtualPrinter.caCert.download')}
+                    </button>
+                  </div>
+                </div>
+                <p className="text-xs text-bambu-gray mt-1">
+                  {t('virtualPrinter.caCert.description')}
                 </p>
                 </p>
-                <div className="flex items-center gap-2">
-                  <span className={`text-xs ${useFilename ? 'text-bambu-gray' : 'text-white'}`}>
-                    {t('virtualPrinter.archiveNameSource.metadata')}
-                  </span>
-                  <Toggle
-                    checked={useFilename}
-                    onChange={(checked) => archiveNameSourceMutation.mutate(checked ? 'filename' : 'metadata')}
-                    disabled={archiveNameSourceMutation.isPending}
-                  />
-                  <span className={`text-xs ${useFilename ? 'text-white' : 'text-bambu-gray'}`}>
-                    {t('virtualPrinter.archiveNameSource.filename')}
-                  </span>
+                {caCert && (
+                  <p
+                    className="text-[10px] text-bambu-gray font-mono mt-1 truncate"
+                    title={caCert.fingerprint_sha256}
+                  >
+                    {t('virtualPrinter.caCert.fingerprint')}: {caCert.fingerprint_sha256}
+                  </p>
+                )}
+              </div>
+            </div>
+          </CardContent>
+        </Card>
+
+        {/* Archive name source */}
+        <Card>
+          <CardContent className="py-3 px-4">
+            <div className="flex items-start gap-3">
+              <FileText className="w-4 h-4 text-bambu-green flex-shrink-0 mt-1" />
+              <div className="flex-1 min-w-0">
+                <div className="flex items-center justify-between gap-3">
+                  <p className="text-sm text-white font-medium">
+                    {t('virtualPrinter.archiveNameSource.title')}
+                  </p>
+                  <div className="flex items-center gap-2 flex-shrink-0">
+                    <span className={`text-xs ${useFilename ? 'text-bambu-gray' : 'text-white'}`}>
+                      {t('virtualPrinter.archiveNameSource.metadata')}
+                    </span>
+                    <Toggle
+                      checked={useFilename}
+                      onChange={(checked) => archiveNameSourceMutation.mutate(checked ? 'filename' : 'metadata')}
+                      disabled={archiveNameSourceMutation.isPending}
+                    />
+                    <span className={`text-xs ${useFilename ? 'text-white' : 'text-bambu-gray'}`}>
+                      {t('virtualPrinter.archiveNameSource.filename')}
+                    </span>
+                  </div>
                 </div>
                 </div>
+                <p className="text-xs text-bambu-gray mt-1">
+                  {t('virtualPrinter.archiveNameSource.description')}
+                </p>
               </div>
               </div>
-              <p className="text-xs text-bambu-gray mt-1">
-                {t('virtualPrinter.archiveNameSource.description')}
-              </p>
             </div>
             </div>
-          </div>
-        </CardContent>
-      </Card>
+          </CardContent>
+        </Card>
+      </div>
 
 
       {/* Header with add button */}
       {/* Header with add button */}
       <div className="flex items-center justify-between">
       <div className="flex items-center justify-between">

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

@@ -4364,6 +4364,14 @@ export default {
       metadata: 'Metadaten',
       metadata: 'Metadaten',
       filename: 'Dateiname',
       filename: 'Dateiname',
     },
     },
+    caCert: {
+      title: 'Slicer-Zertifikat',
+      description: 'Virtuelle Drucker verwenden ein TLS-Zertifikat, das von der Bambuddy-CA signiert ist. Importieren Sie dieses CA-Zertifikat einmalig in den Vertrauensspeicher Ihres Slicers, damit er die Verbindung akzeptiert — kein Abrufen über die Kommandozeile mehr nötig.',
+      copy: 'Kopieren',
+      copied: 'Kopiert',
+      download: 'Herunterladen',
+      fingerprint: 'SHA-256',
+    },
     howItWorks: {
     howItWorks: {
       title: 'So funktioniert es',
       title: 'So funktioniert es',
       step1: 'Im selben LAN erscheinen virtuelle Drucker automatisch in deinem Slicer (Bambu Studio / OrcaSlicer). Aus anderen Netzwerken füge sie manuell per IP-Adresse und Zugangscode hinzu.',
       step1: 'Im selben LAN erscheinen virtuelle Drucker automatisch in deinem Slicer (Bambu Studio / OrcaSlicer). Aus anderen Netzwerken füge sie manuell per IP-Adresse und Zugangscode hinzu.',
@@ -5526,6 +5534,59 @@ export default {
     },
     },
   },
   },
 
 
+  vpDiagnostic: {
+    title: 'Einrichtungsprüfung — {{name}}',
+    runButton: 'Einrichtungsprüfung ausführen',
+    running: 'Einrichtungsprüfung läuft...',
+    runFailed: 'Einrichtungsprüfung konnte nicht ausgeführt werden: {{error}}',
+    retry: 'Erneut prüfen',
+    overall: {
+      ok: 'Alle Prüfungen bestanden — dieser virtuelle Drucker ist korrekt eingerichtet.',
+      warnings: 'Der virtuelle Drucker sollte funktionieren, aber einige Punkte brauchen Aufmerksamkeit.',
+      problems: 'Es wurden Probleme gefunden, die erklären, warum der Slicer diesen virtuellen Drucker nicht sehen oder nutzen kann.',
+    },
+    check: {
+      enabled: {
+        title: 'Virtueller Drucker aktiviert',
+        fail: 'Dieser virtuelle Drucker ist ausgeschaltet. Schalten Sie ihn ein, damit er erkennbar wird.',
+      },
+      running: {
+        title: 'Dienste laufen',
+        fail: 'Der virtuelle Drucker ist aktiviert, aber seine Dienste laufen nicht. Prüfen Sie das Bambuddy-Protokoll — meist stoppt sie ein Bind-IP-Konflikt oder ein Berechtigungsfehler.',
+      },
+      bind_interface: {
+        title: 'Bind-Netzwerkschnittstelle',
+        fail: 'Die Bind-Schnittstelle ist nicht gesetzt oder existiert auf diesem Host nicht mehr. Wählen Sie im Dropdown "Bind-Schnittstelle" eine aktuelle Schnittstelle.',
+      },
+      access_code: {
+        title: 'Zugangscode gesetzt',
+        fail: 'Es ist kein Zugangscode gesetzt. Dem Slicer muss derselbe 8-stellige Zugangscode angegeben werden, den Sie hier festlegen.',
+      },
+      target_printer: {
+        title: 'Zieldrucker',
+        fail: 'Es ist kein Zieldrucker ausgewählt. Der Proxy-Modus benötigt einen echten Drucker zum Weiterleiten.',
+        warn: 'Der Zieldrucker ist gerade offline — die Weiterleitung wird fortgesetzt, sobald er sich wieder verbindet.',
+      },
+      port_ftps: {
+        title: 'Datei-Upload-Dienst (Port {{port}})',
+        fail: 'Auf Port {{port}} der Bind-IP lauscht nichts, daher kann der Slicer keine Dateien hochladen. Ursache ist meist ein Port-Konflikt auf dieser Schnittstelle.',
+      },
+      port_mqtt: {
+        title: 'Steuerungsdienst (Port {{port}})',
+        fail: 'Auf Port {{port}} der Bind-IP lauscht nichts, daher kann der Slicer sich nicht verbinden oder den Status anzeigen.',
+      },
+      port_bind: {
+        title: 'Erkennungsdienst (Port {{port}})',
+        fail: 'Auf Port {{port}} der Bind-IP lauscht nichts, daher schlägt der Erkennungs-Handshake des Slicers fehl.',
+      },
+      certificate: {
+        title: 'TLS-Zertifikat',
+        pass: 'Zertifikat bereit. Stellen Sie sicher, dass das Bambuddy-CA-Zertifikat (oben) in den Vertrauensspeicher Ihres Slicers importiert ist.',
+        fail: 'Das TLS-Zertifikat für diesen virtuellen Drucker fehlt. Prüfen Sie, ob das Bambuddy-Datenverzeichnis beschreibbar ist.',
+      },
+    },
+  },
+
   bugReport: {
   bugReport: {
     title: 'Fehler melden',
     title: 'Fehler melden',
     description: 'Beschreibung',
     description: 'Beschreibung',

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

@@ -4377,6 +4377,14 @@ export default {
       metadata: 'Metadata',
       metadata: 'Metadata',
       filename: 'Filename',
       filename: 'Filename',
     },
     },
+    caCert: {
+      title: 'Slicer certificate',
+      description: 'Virtual printers use a TLS certificate signed by the Bambuddy CA. Import this CA certificate into your slicer\'s trust store once so it accepts the connection — no need to copy it from the command line.',
+      copy: 'Copy',
+      copied: 'Copied',
+      download: 'Download',
+      fingerprint: 'SHA-256',
+    },
     howItWorks: {
     howItWorks: {
       title: 'How it works',
       title: 'How it works',
       step1: 'On the same LAN, virtual printers appear in your slicer (Bambu Studio / OrcaSlicer) automatically via discovery. From other networks, add them manually by IP address and access code.',
       step1: 'On the same LAN, virtual printers appear in your slicer (Bambu Studio / OrcaSlicer) automatically via discovery. From other networks, add them manually by IP address and access code.',
@@ -5539,6 +5547,59 @@ export default {
     },
     },
   },
   },
 
 
+  vpDiagnostic: {
+    title: 'Setup check — {{name}}',
+    runButton: 'Run setup check',
+    running: 'Running setup check...',
+    runFailed: 'Could not run the setup check: {{error}}',
+    retry: 'Run again',
+    overall: {
+      ok: 'All checks passed — this virtual printer is set up correctly.',
+      warnings: 'The virtual printer should work, but some things need attention.',
+      problems: 'Found problems that explain why the slicer can\'t see or use this virtual printer.',
+    },
+    check: {
+      enabled: {
+        title: 'Virtual printer enabled',
+        fail: 'This virtual printer is switched off. Toggle it on to make it discoverable.',
+      },
+      running: {
+        title: 'Services running',
+        fail: 'The virtual printer is enabled but its services are not running. Check the Bambuddy log — a bind IP conflict or a permission error usually stops them.',
+      },
+      bind_interface: {
+        title: 'Bind network interface',
+        fail: 'The bind interface is not set, or no longer exists on this host. Pick a current interface in the Bind Interface dropdown.',
+      },
+      access_code: {
+        title: 'Access code set',
+        fail: 'No access code is set. The slicer must be given the same 8-character access code you set here.',
+      },
+      target_printer: {
+        title: 'Target printer',
+        fail: 'No target printer is selected. Proxy mode needs a real printer to forward to.',
+        warn: 'The target printer is offline right now — proxying will resume once it reconnects.',
+      },
+      port_ftps: {
+        title: 'File-upload service (port {{port}})',
+        fail: 'Nothing is listening on port {{port}} of the bind IP, so the slicer cannot upload files. A port conflict on this interface is the usual cause.',
+      },
+      port_mqtt: {
+        title: 'Control service (port {{port}})',
+        fail: 'Nothing is listening on port {{port}} of the bind IP, so the slicer cannot connect or show status.',
+      },
+      port_bind: {
+        title: 'Discovery service (port {{port}})',
+        fail: 'Nothing is listening on port {{port}} of the bind IP, so the slicer\'s discovery handshake fails.',
+      },
+      certificate: {
+        title: 'TLS certificate',
+        pass: 'Certificate ready. Make sure the Bambuddy CA certificate (above) is imported into your slicer\'s trust store.',
+        fail: 'The TLS certificate for this virtual printer is missing. Check that the Bambuddy data directory is writable.',
+      },
+    },
+  },
+
   bugReport: {
   bugReport: {
     title: 'Report a Bug',
     title: 'Report a Bug',
     description: 'Description',
     description: 'Description',

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

@@ -4373,6 +4373,14 @@ export default {
       metadata: 'Metadatos',
       metadata: 'Metadatos',
       filename: 'Nombre de archivo',
       filename: 'Nombre de archivo',
     },
     },
+    caCert: {
+      title: 'Certificado del laminador',
+      description: 'Las impresoras virtuales usan un certificado TLS firmado por la CA de Bambuddy. Importe este certificado de CA en el almacén de confianza de su laminador una sola vez para que acepte la conexión — sin necesidad de copiarlo desde la línea de comandos.',
+      copy: 'Copiar',
+      copied: 'Copiado',
+      download: 'Descargar',
+      fingerprint: 'SHA-256',
+    },
     howItWorks: {
     howItWorks: {
       title: 'Cómo funciona',
       title: 'Cómo funciona',
       step1: 'En la misma LAN, las impresoras virtuales aparecen automáticamente en su laminador (Bambu Studio / OrcaSlicer) mediante detección. Desde otras redes, añádalas manualmente por dirección IP y código de acceso.',
       step1: 'En la misma LAN, las impresoras virtuales aparecen automáticamente en su laminador (Bambu Studio / OrcaSlicer) mediante detección. Desde otras redes, añádalas manualmente por dirección IP y código de acceso.',
@@ -5535,6 +5543,59 @@ export default {
     },
     },
   },
   },
 
 
+  vpDiagnostic: {
+    title: 'Comprobación de configuración — {{name}}',
+    runButton: 'Ejecutar comprobación de configuración',
+    running: 'Ejecutando comprobación de configuración...',
+    runFailed: 'No se pudo ejecutar la comprobación de configuración: {{error}}',
+    retry: 'Volver a comprobar',
+    overall: {
+      ok: 'Todas las comprobaciones se superaron — esta impresora virtual está configurada correctamente.',
+      warnings: 'La impresora virtual debería funcionar, pero algunas cosas requieren atención.',
+      problems: 'Se encontraron problemas que explican por qué el laminador no puede ver ni usar esta impresora virtual.',
+    },
+    check: {
+      enabled: {
+        title: 'Impresora virtual habilitada',
+        fail: 'Esta impresora virtual está apagada. Actívela para que sea detectable.',
+      },
+      running: {
+        title: 'Servicios en ejecución',
+        fail: 'La impresora virtual está habilitada pero sus servicios no se están ejecutando. Revise el registro de Bambuddy — normalmente los detiene un conflicto de IP de enlace o un error de permisos.',
+      },
+      bind_interface: {
+        title: 'Interfaz de red de enlace',
+        fail: 'La interfaz de enlace no está configurada o ya no existe en este host. Elija una interfaz actual en el menú "Interfaz de enlace".',
+      },
+      access_code: {
+        title: 'Código de acceso configurado',
+        fail: 'No hay ningún código de acceso configurado. Al laminador se le debe dar el mismo código de acceso de 8 caracteres que configura aquí.',
+      },
+      target_printer: {
+        title: 'Impresora de destino',
+        fail: 'No hay ninguna impresora de destino seleccionada. El modo proxy necesita una impresora real a la que reenviar.',
+        warn: 'La impresora de destino está desconectada ahora mismo — el reenvío se reanudará cuando vuelva a conectarse.',
+      },
+      port_ftps: {
+        title: 'Servicio de subida de archivos (puerto {{port}})',
+        fail: 'No hay nada escuchando en el puerto {{port}} de la IP de enlace, por lo que el laminador no puede subir archivos. La causa habitual es un conflicto de puerto en esta interfaz.',
+      },
+      port_mqtt: {
+        title: 'Servicio de control (puerto {{port}})',
+        fail: 'No hay nada escuchando en el puerto {{port}} de la IP de enlace, por lo que el laminador no puede conectarse ni mostrar el estado.',
+      },
+      port_bind: {
+        title: 'Servicio de detección (puerto {{port}})',
+        fail: 'No hay nada escuchando en el puerto {{port}} de la IP de enlace, por lo que falla el protocolo de detección del laminador.',
+      },
+      certificate: {
+        title: 'Certificado TLS',
+        pass: 'Certificado listo. Asegúrese de que el certificado de CA de Bambuddy (arriba) esté importado en el almacén de confianza de su laminador.',
+        fail: 'Falta el certificado TLS de esta impresora virtual. Compruebe que el directorio de datos de Bambuddy tenga permisos de escritura.',
+      },
+    },
+  },
+
   bugReport: {
   bugReport: {
     title: 'Informar de un error',
     title: 'Informar de un error',
     description: 'Descripción',
     description: 'Descripción',

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

@@ -4411,6 +4411,14 @@ export default {
       metadata: 'Métadonnées',
       metadata: 'Métadonnées',
       filename: 'Nom de fichier',
       filename: 'Nom de fichier',
     },
     },
+    caCert: {
+      title: 'Certificat du slicer',
+      description: 'Les imprimantes virtuelles utilisent un certificat TLS signé par l\'autorité de certification Bambuddy. Importez ce certificat CA dans le magasin de confiance de votre slicer une seule fois pour qu\'il accepte la connexion — plus besoin de le copier depuis la ligne de commande.',
+      copy: 'Copier',
+      copied: 'Copié',
+      download: 'Télécharger',
+      fingerprint: 'SHA-256',
+    },
   },
   },
 
 
   // Model Viewer
   // Model Viewer
@@ -5516,6 +5524,59 @@ export default {
     },
     },
   },
   },
 
 
+  vpDiagnostic: {
+    title: 'Vérification de configuration — {{name}}',
+    runButton: 'Lancer la vérification',
+    running: 'Vérification de configuration en cours...',
+    runFailed: 'Impossible de lancer la vérification de configuration : {{error}}',
+    retry: 'Relancer',
+    overall: {
+      ok: 'Toutes les vérifications ont réussi — cette imprimante virtuelle est correctement configurée.',
+      warnings: 'L\'imprimante virtuelle devrait fonctionner, mais certains points nécessitent votre attention.',
+      problems: 'Des problèmes ont été trouvés qui expliquent pourquoi le slicer ne voit pas ou ne peut pas utiliser cette imprimante virtuelle.',
+    },
+    check: {
+      enabled: {
+        title: 'Imprimante virtuelle activée',
+        fail: 'Cette imprimante virtuelle est désactivée. Activez-la pour qu\'elle soit détectable.',
+      },
+      running: {
+        title: 'Services en cours d\'exécution',
+        fail: 'L\'imprimante virtuelle est activée mais ses services ne sont pas en cours d\'exécution. Consultez le journal Bambuddy — un conflit d\'IP de liaison ou une erreur de permission les arrête généralement.',
+      },
+      bind_interface: {
+        title: 'Interface réseau de liaison',
+        fail: 'L\'interface de liaison n\'est pas définie ou n\'existe plus sur cet hôte. Choisissez une interface actuelle dans la liste "Interface de liaison".',
+      },
+      access_code: {
+        title: 'Code d\'accès défini',
+        fail: 'Aucun code d\'accès n\'est défini. Le slicer doit recevoir le même code d\'accès à 8 caractères que celui que vous définissez ici.',
+      },
+      target_printer: {
+        title: 'Imprimante cible',
+        fail: 'Aucune imprimante cible n\'est sélectionnée. Le mode proxy a besoin d\'une vraie imprimante vers laquelle transférer.',
+        warn: 'L\'imprimante cible est hors ligne pour le moment — le transfert reprendra dès qu\'elle se reconnectera.',
+      },
+      port_ftps: {
+        title: 'Service de téléversement de fichiers (port {{port}})',
+        fail: 'Rien n\'écoute sur le port {{port}} de l\'IP de liaison, le slicer ne peut donc pas téléverser de fichiers. La cause habituelle est un conflit de port sur cette interface.',
+      },
+      port_mqtt: {
+        title: 'Service de contrôle (port {{port}})',
+        fail: 'Rien n\'écoute sur le port {{port}} de l\'IP de liaison, le slicer ne peut donc pas se connecter ni afficher l\'état.',
+      },
+      port_bind: {
+        title: 'Service de détection (port {{port}})',
+        fail: 'Rien n\'écoute sur le port {{port}} de l\'IP de liaison, la poignée de main de détection du slicer échoue donc.',
+      },
+      certificate: {
+        title: 'Certificat TLS',
+        pass: 'Certificat prêt. Assurez-vous que le certificat CA Bambuddy (ci-dessus) est importé dans le magasin de confiance de votre slicer.',
+        fail: 'Le certificat TLS de cette imprimante virtuelle est manquant. Vérifiez que le répertoire de données Bambuddy est accessible en écriture.',
+      },
+    },
+  },
+
   bugReport: {
   bugReport: {
     title: 'Signaler un bug',
     title: 'Signaler un bug',
     description: 'Description',
     description: 'Description',

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

@@ -4410,6 +4410,14 @@ export default {
       metadata: 'Metadati',
       metadata: 'Metadati',
       filename: 'Nome file',
       filename: 'Nome file',
     },
     },
+    caCert: {
+      title: 'Certificato dello slicer',
+      description: 'Le stampanti virtuali usano un certificato TLS firmato dalla CA di Bambuddy. Importa questo certificato CA nell\'archivio attendibile del tuo slicer una sola volta affinché accetti la connessione — senza doverlo copiare dalla riga di comando.',
+      copy: 'Copia',
+      copied: 'Copiato',
+      download: 'Scarica',
+      fingerprint: 'SHA-256',
+    },
   },
   },
 
 
   // Model Viewer
   // Model Viewer
@@ -5515,6 +5523,59 @@ export default {
     },
     },
   },
   },
 
 
+  vpDiagnostic: {
+    title: 'Controllo configurazione — {{name}}',
+    runButton: 'Esegui controllo configurazione',
+    running: 'Controllo configurazione in corso...',
+    runFailed: 'Impossibile eseguire il controllo della configurazione: {{error}}',
+    retry: 'Esegui di nuovo',
+    overall: {
+      ok: 'Tutti i controlli superati — questa stampante virtuale è configurata correttamente.',
+      warnings: 'La stampante virtuale dovrebbe funzionare, ma alcuni aspetti richiedono attenzione.',
+      problems: 'Sono stati trovati problemi che spiegano perché lo slicer non riesce a vedere o usare questa stampante virtuale.',
+    },
+    check: {
+      enabled: {
+        title: 'Stampante virtuale abilitata',
+        fail: 'Questa stampante virtuale è spenta. Attivala per renderla rilevabile.',
+      },
+      running: {
+        title: 'Servizi in esecuzione',
+        fail: 'La stampante virtuale è abilitata ma i suoi servizi non sono in esecuzione. Controlla il log di Bambuddy — di solito li ferma un conflitto di IP di binding o un errore di permessi.',
+      },
+      bind_interface: {
+        title: 'Interfaccia di rete di binding',
+        fail: 'L\'interfaccia di binding non è impostata o non esiste più su questo host. Scegli un\'interfaccia attuale nel menu "Interfaccia di binding".',
+      },
+      access_code: {
+        title: 'Codice di accesso impostato',
+        fail: 'Nessun codice di accesso impostato. Allo slicer deve essere fornito lo stesso codice di accesso di 8 caratteri impostato qui.',
+      },
+      target_printer: {
+        title: 'Stampante di destinazione',
+        fail: 'Nessuna stampante di destinazione selezionata. La modalità proxy richiede una stampante reale a cui inoltrare.',
+        warn: 'La stampante di destinazione è offline in questo momento — l\'inoltro riprenderà quando si riconnetterà.',
+      },
+      port_ftps: {
+        title: 'Servizio di caricamento file (porta {{port}})',
+        fail: 'Nulla è in ascolto sulla porta {{port}} dell\'IP di binding, quindi lo slicer non può caricare i file. La causa abituale è un conflitto di porta su questa interfaccia.',
+      },
+      port_mqtt: {
+        title: 'Servizio di controllo (porta {{port}})',
+        fail: 'Nulla è in ascolto sulla porta {{port}} dell\'IP di binding, quindi lo slicer non può connettersi o mostrare lo stato.',
+      },
+      port_bind: {
+        title: 'Servizio di rilevamento (porta {{port}})',
+        fail: 'Nulla è in ascolto sulla porta {{port}} dell\'IP di binding, quindi l\'handshake di rilevamento dello slicer fallisce.',
+      },
+      certificate: {
+        title: 'Certificato TLS',
+        pass: 'Certificato pronto. Assicurati che il certificato CA di Bambuddy (sopra) sia importato nell\'archivio attendibile del tuo slicer.',
+        fail: 'Il certificato TLS per questa stampante virtuale è mancante. Verifica che la directory dei dati di Bambuddy sia scrivibile.',
+      },
+    },
+  },
+
   bugReport: {
   bugReport: {
     title: 'Segnala un bug',
     title: 'Segnala un bug',
     description: 'Descrizione',
     description: 'Descrizione',

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

@@ -4422,6 +4422,14 @@ export default {
       metadata: 'メタデータ',
       metadata: 'メタデータ',
       filename: 'ファイル名',
       filename: 'ファイル名',
     },
     },
+    caCert: {
+      title: 'スライサー証明書',
+      description: '仮想プリンターは Bambuddy CA が署名した TLS 証明書を使用します。この CA 証明書をスライサーの信頼ストアに一度インポートすると接続が受け入れられます — コマンドラインからコピーする必要はありません。',
+      copy: 'コピー',
+      copied: 'コピーしました',
+      download: 'ダウンロード',
+      fingerprint: 'SHA-256',
+    },
   },
   },
 
 
   // Model Viewer
   // Model Viewer
@@ -5527,6 +5535,59 @@ export default {
     },
     },
   },
   },
 
 
+  vpDiagnostic: {
+    title: 'セットアップチェック — {{name}}',
+    runButton: 'セットアップチェックを実行',
+    running: 'セットアップチェックを実行中...',
+    runFailed: 'セットアップチェックを実行できませんでした: {{error}}',
+    retry: '再実行',
+    overall: {
+      ok: 'すべてのチェックに合格しました — この仮想プリンターは正しく設定されています。',
+      warnings: '仮想プリンターは動作するはずですが、いくつかの点に注意が必要です。',
+      problems: 'スライサーがこの仮想プリンターを認識または使用できない理由となる問題が見つかりました。',
+    },
+    check: {
+      enabled: {
+        title: '仮想プリンターが有効',
+        fail: 'この仮想プリンターはオフです。検出可能にするにはオンに切り替えてください。',
+      },
+      running: {
+        title: 'サービスが稼働中',
+        fail: '仮想プリンターは有効ですが、サービスが稼働していません。Bambuddy のログを確認してください — 通常はバインド IP の競合または権限エラーが原因で停止します。',
+      },
+      bind_interface: {
+        title: 'バインドネットワークインターフェース',
+        fail: 'バインドインターフェースが設定されていないか、このホストに存在しなくなっています。「バインドインターフェース」のドロップダウンから現在のインターフェースを選択してください。',
+      },
+      access_code: {
+        title: 'アクセスコードが設定済み',
+        fail: 'アクセスコードが設定されていません。ここで設定したものと同じ 8 文字のアクセスコードをスライサーに入力する必要があります。',
+      },
+      target_printer: {
+        title: 'ターゲットプリンター',
+        fail: 'ターゲットプリンターが選択されていません。プロキシモードには転送先となる実際のプリンターが必要です。',
+        warn: 'ターゲットプリンターは現在オフラインです — 再接続されると転送が再開されます。',
+      },
+      port_ftps: {
+        title: 'ファイルアップロードサービス(ポート {{port}})',
+        fail: 'バインド IP のポート {{port}} で待ち受けているものがないため、スライサーはファイルをアップロードできません。通常はこのインターフェースでのポート競合が原因です。',
+      },
+      port_mqtt: {
+        title: '制御サービス(ポート {{port}})',
+        fail: 'バインド IP のポート {{port}} で待ち受けているものがないため、スライサーは接続もステータス表示もできません。',
+      },
+      port_bind: {
+        title: '検出サービス(ポート {{port}})',
+        fail: 'バインド IP のポート {{port}} で待ち受けているものがないため、スライサーの検出ハンドシェイクが失敗します。',
+      },
+      certificate: {
+        title: 'TLS 証明書',
+        pass: '証明書の準備ができています。Bambuddy CA 証明書(上記)がスライサーの信頼ストアにインポートされていることを確認してください。',
+        fail: 'この仮想プリンターの TLS 証明書がありません。Bambuddy のデータディレクトリが書き込み可能か確認してください。',
+      },
+    },
+  },
+
   bugReport: {
   bugReport: {
     title: 'バグを報告',
     title: 'バグを報告',
     description: '説明',
     description: '説明',

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

@@ -4410,6 +4410,14 @@ export default {
       metadata: 'Metadados',
       metadata: 'Metadados',
       filename: 'Nome do arquivo',
       filename: 'Nome do arquivo',
     },
     },
+    caCert: {
+      title: 'Certificado do slicer',
+      description: 'As impressoras virtuais usam um certificado TLS assinado pela CA do Bambuddy. Importe este certificado CA para o armazenamento de confiança do seu slicer uma única vez para que ele aceite a conexão — sem precisar copiá-lo pela linha de comando.',
+      copy: 'Copiar',
+      copied: 'Copiado',
+      download: 'Baixar',
+      fingerprint: 'SHA-256',
+    },
   },
   },
 
 
   // Model Viewer
   // Model Viewer
@@ -5515,6 +5523,59 @@ export default {
     },
     },
   },
   },
 
 
+  vpDiagnostic: {
+    title: 'Verificação de configuração — {{name}}',
+    runButton: 'Executar verificação de configuração',
+    running: 'Executando verificação de configuração...',
+    runFailed: 'Não foi possível executar a verificação de configuração: {{error}}',
+    retry: 'Verificar novamente',
+    overall: {
+      ok: 'Todas as verificações passaram — esta impressora virtual está configurada corretamente.',
+      warnings: 'A impressora virtual deve funcionar, mas alguns pontos precisam de atenção.',
+      problems: 'Foram encontrados problemas que explicam por que o slicer não consegue ver ou usar esta impressora virtual.',
+    },
+    check: {
+      enabled: {
+        title: 'Impressora virtual habilitada',
+        fail: 'Esta impressora virtual está desligada. Ative-a para torná-la detectável.',
+      },
+      running: {
+        title: 'Serviços em execução',
+        fail: 'A impressora virtual está habilitada, mas seus serviços não estão em execução. Verifique o log do Bambuddy — geralmente um conflito de IP de vínculo ou um erro de permissão os interrompe.',
+      },
+      bind_interface: {
+        title: 'Interface de rede de vínculo',
+        fail: 'A interface de vínculo não está definida ou não existe mais neste host. Escolha uma interface atual no menu "Interface de vínculo".',
+      },
+      access_code: {
+        title: 'Código de acesso definido',
+        fail: 'Nenhum código de acesso definido. O slicer precisa receber o mesmo código de acesso de 8 caracteres que você define aqui.',
+      },
+      target_printer: {
+        title: 'Impressora de destino',
+        fail: 'Nenhuma impressora de destino selecionada. O modo proxy precisa de uma impressora real para a qual encaminhar.',
+        warn: 'A impressora de destino está offline no momento — o encaminhamento será retomado quando ela se reconectar.',
+      },
+      port_ftps: {
+        title: 'Serviço de upload de arquivos (porta {{port}})',
+        fail: 'Nada está escutando na porta {{port}} do IP de vínculo, então o slicer não consegue enviar arquivos. A causa habitual é um conflito de porta nesta interface.',
+      },
+      port_mqtt: {
+        title: 'Serviço de controle (porta {{port}})',
+        fail: 'Nada está escutando na porta {{port}} do IP de vínculo, então o slicer não consegue conectar nem mostrar o status.',
+      },
+      port_bind: {
+        title: 'Serviço de descoberta (porta {{port}})',
+        fail: 'Nada está escutando na porta {{port}} do IP de vínculo, então a negociação de descoberta do slicer falha.',
+      },
+      certificate: {
+        title: 'Certificado TLS',
+        pass: 'Certificado pronto. Verifique se o certificado CA do Bambuddy (acima) está importado no armazenamento de confiança do seu slicer.',
+        fail: 'O certificado TLS desta impressora virtual está ausente. Verifique se o diretório de dados do Bambuddy tem permissão de escrita.',
+      },
+    },
+  },
+
   bugReport: {
   bugReport: {
     title: 'Reportar um bug',
     title: 'Reportar um bug',
     description: 'Descrição',
     description: 'Descrição',

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

@@ -4353,6 +4353,14 @@ export default {
       metadata: '元数据',
       metadata: '元数据',
       filename: '文件名',
       filename: '文件名',
     },
     },
+    caCert: {
+      title: '切片软件证书',
+      description: '虚拟打印机使用由 Bambuddy CA 签发的 TLS 证书。将此 CA 证书导入切片软件的信任库一次,切片软件即可接受连接 — 无需再通过命令行获取。',
+      copy: '复制',
+      copied: '已复制',
+      download: '下载',
+      fingerprint: 'SHA-256',
+    },
     howItWorks: {
     howItWorks: {
       title: '工作原理',
       title: '工作原理',
       step1: '在同一局域网中,虚拟打印机会通过发现机制自动出现在您的切片软件(Bambu Studio / OrcaSlicer)中。从其他网络,通过 IP 地址和访问码手动添加。',
       step1: '在同一局域网中,虚拟打印机会通过发现机制自动出现在您的切片软件(Bambu Studio / OrcaSlicer)中。从其他网络,通过 IP 地址和访问码手动添加。',
@@ -5514,6 +5522,59 @@ export default {
     },
     },
   },
   },
 
 
+  vpDiagnostic: {
+    title: '设置检查 — {{name}}',
+    runButton: '运行设置检查',
+    running: '正在运行设置检查...',
+    runFailed: '无法运行设置检查:{{error}}',
+    retry: '重新运行',
+    overall: {
+      ok: '所有检查均已通过 — 此虚拟打印机已正确设置。',
+      warnings: '虚拟打印机应该可以工作,但有些方面需要注意。',
+      problems: '发现的问题可以解释切片软件为何无法看到或使用此虚拟打印机。',
+    },
+    check: {
+      enabled: {
+        title: '虚拟打印机已启用',
+        fail: '此虚拟打印机已关闭。请将其打开以使其可被发现。',
+      },
+      running: {
+        title: '服务正在运行',
+        fail: '虚拟打印机已启用,但其服务未运行。请检查 Bambuddy 日志 — 通常是绑定 IP 冲突或权限错误使其停止。',
+      },
+      bind_interface: {
+        title: '绑定网络接口',
+        fail: '绑定接口未设置,或在此主机上已不存在。请在"绑定接口"下拉菜单中选择一个当前的接口。',
+      },
+      access_code: {
+        title: '已设置访问码',
+        fail: '未设置访问码。必须向切片软件提供与您在此处设置的相同的 8 位访问码。',
+      },
+      target_printer: {
+        title: '目标打印机',
+        fail: '未选择目标打印机。代理模式需要一台实际的打印机进行转发。',
+        warn: '目标打印机当前处于离线状态 — 重新连接后将恢复转发。',
+      },
+      port_ftps: {
+        title: '文件上传服务(端口 {{port}})',
+        fail: '绑定 IP 的端口 {{port}} 上没有任何监听,因此切片软件无法上传文件。通常是此接口上的端口冲突所致。',
+      },
+      port_mqtt: {
+        title: '控制服务(端口 {{port}})',
+        fail: '绑定 IP 的端口 {{port}} 上没有任何监听,因此切片软件无法连接或显示状态。',
+      },
+      port_bind: {
+        title: '发现服务(端口 {{port}})',
+        fail: '绑定 IP 的端口 {{port}} 上没有任何监听,因此切片软件的发现握手会失败。',
+      },
+      certificate: {
+        title: 'TLS 证书',
+        pass: '证书已就绪。请确保已将 Bambuddy CA 证书(上方)导入切片软件的信任库。',
+        fail: '此虚拟打印机的 TLS 证书缺失。请检查 Bambuddy 数据目录是否可写。',
+      },
+    },
+  },
+
   bugReport: {
   bugReport: {
     title: '报告错误',
     title: '报告错误',
     description: '描述',
     description: '描述',

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

@@ -4353,6 +4353,14 @@ export default {
       metadata: '元資料',
       metadata: '元資料',
       filename: '檔名',
       filename: '檔名',
     },
     },
+    caCert: {
+      title: '切片軟體憑證',
+      description: '虛擬印表機使用由 Bambuddy CA 簽發的 TLS 憑證。將此 CA 憑證匯入切片軟體的信任庫一次,切片軟體即可接受連線 — 無需再透過命令列取得。',
+      copy: '複製',
+      copied: '已複製',
+      download: '下載',
+      fingerprint: 'SHA-256',
+    },
     howItWorks: {
     howItWorks: {
       title: '工作原理',
       title: '工作原理',
       step1: '在同一區域網路中,虛擬印表機會透過發現機制自動出現在您的切片軟體(Bambu Studio / OrcaSlicer)中。從其他網路,透過 IP 位址和存取碼手動新增。',
       step1: '在同一區域網路中,虛擬印表機會透過發現機制自動出現在您的切片軟體(Bambu Studio / OrcaSlicer)中。從其他網路,透過 IP 位址和存取碼手動新增。',
@@ -5514,6 +5522,59 @@ export default {
     },
     },
   },
   },
 
 
+  vpDiagnostic: {
+    title: '設定檢查 — {{name}}',
+    runButton: '執行設定檢查',
+    running: '正在執行設定檢查...',
+    runFailed: '無法執行設定檢查:{{error}}',
+    retry: '重新執行',
+    overall: {
+      ok: '所有檢查均已通過 — 此虛擬印表機已正確設定。',
+      warnings: '虛擬印表機應該可以運作,但有些方面需要注意。',
+      problems: '發現的問題可以解釋切片軟體為何無法看到或使用此虛擬印表機。',
+    },
+    check: {
+      enabled: {
+        title: '虛擬印表機已啟用',
+        fail: '此虛擬印表機已關閉。請將其開啟以使其可被探索。',
+      },
+      running: {
+        title: '服務正在執行',
+        fail: '虛擬印表機已啟用,但其服務未執行。請檢查 Bambuddy 記錄 — 通常是繫結 IP 衝突或權限錯誤使其停止。',
+      },
+      bind_interface: {
+        title: '繫結網路介面',
+        fail: '繫結介面未設定,或在此主機上已不存在。請在「繫結介面」下拉選單中選擇一個目前的介面。',
+      },
+      access_code: {
+        title: '已設定存取碼',
+        fail: '未設定存取碼。必須向切片軟體提供與您在此處設定的相同的 8 位存取碼。',
+      },
+      target_printer: {
+        title: '目標印表機',
+        fail: '未選擇目標印表機。代理模式需要一台實際的印表機進行轉送。',
+        warn: '目標印表機目前處於離線狀態 — 重新連線後將恢復轉送。',
+      },
+      port_ftps: {
+        title: '檔案上傳服務(連接埠 {{port}})',
+        fail: '繫結 IP 的連接埠 {{port}} 上沒有任何監聽,因此切片軟體無法上傳檔案。通常是此介面上的連接埠衝突所致。',
+      },
+      port_mqtt: {
+        title: '控制服務(連接埠 {{port}})',
+        fail: '繫結 IP 的連接埠 {{port}} 上沒有任何監聽,因此切片軟體無法連線或顯示狀態。',
+      },
+      port_bind: {
+        title: '探索服務(連接埠 {{port}})',
+        fail: '繫結 IP 的連接埠 {{port}} 上沒有任何監聽,因此切片軟體的探索交握會失敗。',
+      },
+      certificate: {
+        title: 'TLS 憑證',
+        pass: '憑證已就緒。請確保已將 Bambuddy CA 憑證(上方)匯入切片軟體的信任庫。',
+        fail: '此虛擬印表機的 TLS 憑證遺失。請檢查 Bambuddy 資料目錄是否可寫入。',
+      },
+    },
+  },
+
   bugReport: {
   bugReport: {
     title: '報告錯誤',
     title: '報告錯誤',
     description: '描述',
     description: '描述',

+ 43 - 0
frontend/src/utils/clipboard.ts

@@ -0,0 +1,43 @@
+/**
+ * Copy text to the clipboard, with a fallback for non-secure contexts.
+ *
+ * Bambuddy is usually reached over plain HTTP on a LAN / tailnet IP, where
+ * `navigator.clipboard` is unavailable — so the hidden-textarea + execCommand
+ * fallback is required, not optional. Returns true if the copy succeeded.
+ */
+export async function copyTextToClipboard(text: string): Promise<boolean> {
+  if (navigator.clipboard && window.isSecureContext) {
+    try {
+      await navigator.clipboard.writeText(text);
+      return true;
+    } catch {
+      // Fall through to the legacy path.
+    }
+  }
+  const ta = document.createElement('textarea');
+  ta.value = text;
+  ta.style.position = 'fixed';
+  ta.style.opacity = '0';
+  document.body.appendChild(ta);
+  try {
+    ta.select();
+    return document.execCommand('copy');
+  } catch {
+    return false;
+  } finally {
+    if (ta.parentNode) ta.parentNode.removeChild(ta);
+  }
+}
+
+/** Trigger a browser download of `text` as a file named `filename`. */
+export function downloadTextFile(text: string, filename: string, mimeType = 'text/plain'): void {
+  const blob = new Blob([text], { type: mimeType });
+  const url = URL.createObjectURL(blob);
+  const a = document.createElement('a');
+  a.href = url;
+  a.download = filename;
+  document.body.appendChild(a);
+  a.click();
+  a.remove();
+  URL.revokeObjectURL(url);
+}

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-BpyZgjkS.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-CVVS0VHp.js"></script>
+    <script type="module" crossorigin src="/assets/index-BpyZgjkS.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-DYdMf_Qm.css">
     <link rel="stylesheet" crossorigin href="/assets/index-DYdMf_Qm.css">
   </head>
   </head>
   <body>
   <body>

Some files were not shown because too many files changed in this diff