maziggy vor 1 Monat
Ursprung
Commit
e8f252d2b8

+ 18 - 2
backend/app/api/routes/settings.py

@@ -853,7 +853,7 @@ async def get_virtual_printer_settings(
         "model": model or DEFAULT_VIRTUAL_PRINTER_MODEL,
         "target_printer_id": int(target_printer_id) if target_printer_id else None,
         "remote_interface_ip": remote_interface_ip or "",
-        "tailscale_disabled": tailscale_disabled_raw == "true" if tailscale_disabled_raw else False,
+        "tailscale_disabled": tailscale_disabled_raw == "true" if tailscale_disabled_raw else True,
         "status": virtual_printer_manager.get_status(),
     }
 
@@ -894,7 +894,8 @@ async def update_virtual_printer_settings(
     current_target_id = int(current_target_id_str) if current_target_id_str else None
     current_remote_iface = await get_setting(db, "virtual_printer_remote_interface_ip") or ""
     current_ts_disabled_raw = await get_setting(db, "virtual_printer_tailscale_disabled")
-    current_ts_disabled = current_ts_disabled_raw == "true" if current_ts_disabled_raw else False
+    # Default True (opt-in) when the setting has never been saved — matches the model default.
+    current_ts_disabled = current_ts_disabled_raw == "true" if current_ts_disabled_raw else True
 
     # Apply updates
     new_enabled = enabled if enabled is not None else current_enabled
@@ -905,6 +906,21 @@ async def update_virtual_printer_settings(
     new_remote_iface = remote_interface_ip if remote_interface_ip is not None else current_remote_iface
     new_ts_disabled = tailscale_disabled if tailscale_disabled is not None else current_ts_disabled
 
+    # Guard: enabling Tailscale (disabled=False) requires the binary to be present. Otherwise
+    # the toggle looks like it worked but the service will silently fall back to self-signed.
+    if tailscale_disabled is False and current_ts_disabled is True:
+        from backend.app.services.virtual_printer.tailscale import tailscale_service
+
+        ts_status = await tailscale_service.get_status()
+        if not ts_status.available:
+            return JSONResponse(
+                status_code=409,
+                content={
+                    "detail": "tailscale_not_available",
+                    "reason": ts_status.error or "tailscale binary not found",
+                },
+            )
+
     # Validate mode
     # "review" is the new name for "queue" (pending review before archiving)
     # "print_queue" archives and adds to print queue (unassigned)

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

@@ -336,6 +336,20 @@ async def update_virtual_printer(
     if body.remote_interface_ip is not None:
         vp.remote_interface_ip = body.remote_interface_ip
     if body.tailscale_disabled is not None:
+        # Guard: user trying to enable Tailscale (disabled=False) must have the binary available.
+        # Otherwise the toggle looks like it works but silently falls back to self-signed.
+        if body.tailscale_disabled is False and vp.tailscale_disabled is True:
+            from backend.app.services.virtual_printer.tailscale import tailscale_service
+
+            ts_status = await tailscale_service.get_status()
+            if not ts_status.available:
+                return JSONResponse(
+                    status_code=409,
+                    content={
+                        "detail": "tailscale_not_available",
+                        "reason": ts_status.error or "tailscale binary not found",
+                    },
+                )
         vp.tailscale_disabled = body.tailscale_disabled
 
     # Auto-inherit model when switching to proxy mode with existing target printer

+ 7 - 2
backend/app/core/database.py

@@ -480,8 +480,13 @@ async def run_migrations(conn):
     # Migration: Add wiki_url column to maintenance_types for documentation links
     await _safe_execute(conn, "ALTER TABLE maintenance_types ADD COLUMN wiki_url VARCHAR(500)")
 
-    # Migration: Add tailscale_disabled column to virtual_printers for user opt-out
-    await _safe_execute(conn, "ALTER TABLE virtual_printers ADD COLUMN tailscale_disabled BOOLEAN DEFAULT 0")
+    # Migration: Add tailscale_disabled column to virtual_printers. Opt-in: default TRUE so
+    # the auto-detect + fallback noise only runs for users who explicitly enable it.
+    # Postgres rejects `DEFAULT 1` for BOOLEAN (#1070 round-2 review).
+    if is_sqlite():
+        await _safe_execute(conn, "ALTER TABLE virtual_printers ADD COLUMN tailscale_disabled BOOLEAN DEFAULT 1")
+    else:
+        await _safe_execute(conn, "ALTER TABLE virtual_printers ADD COLUMN tailscale_disabled BOOLEAN DEFAULT true")
 
     # Migration: Add ams_mapping column to print_queue for storing filament slot assignments
     await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN ams_mapping TEXT")

+ 2 - 2
backend/app/models/virtual_printer.py

@@ -26,8 +26,8 @@ class VirtualPrinter(Base):
     bind_ip: Mapped[str | None] = mapped_column(String(45), nullable=True)  # dedicated IP (proxy mode)
     remote_interface_ip: Mapped[str | None] = mapped_column(String(45), nullable=True)  # SSDP advertise IP
     tailscale_disabled: Mapped[bool] = mapped_column(
-        Boolean, server_default="false"
-    )  # opt out of Tailscale auto-detect
+        Boolean, server_default="true"
+    )  # opt-in: user must explicitly enable; auto-detect only runs then
     serial_suffix: Mapped[str] = mapped_column(String(9), default="391800001")  # unique per printer
     position: Mapped[int] = mapped_column(Integer, default=0)
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())

+ 1 - 1
backend/app/services/virtual_printer/manager.py

@@ -113,7 +113,7 @@ class VirtualPrinterInstance:
         auto_dispatch: bool = True,
         bind_ip: str = "",
         remote_interface_ip: str = "",
-        tailscale_disabled: bool = False,
+        tailscale_disabled: bool = True,
         base_dir: Path,
         session_factory: Callable | None = None,
     ):

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

@@ -338,3 +338,108 @@ class TestVirtualPrinterAutoDispatchAPI:
         get_resp = await async_client.get(f"/api/v1/virtual-printers/{vp_id}")
         assert get_resp.status_code == 200
         assert get_resp.json()["auto_dispatch"] is False
+
+
+class TestVirtualPrinterTailscaleGuardAPI:
+    """Enabling Tailscale on a host without the binary must be rejected with 409."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_enable_tailscale_rejected_when_binary_missing(self, async_client: AsyncClient):
+        """PUT tailscale_disabled=False must 409 when tailscale_service reports unavailable."""
+        from backend.app.services.virtual_printer.tailscale import TailscaleStatus
+
+        create_resp = await async_client.post(
+            "/api/v1/virtual-printers",
+            json={
+                "name": "TestTailscaleGuard",
+                "mode": "immediate",
+                "access_code": "12345678",
+            },
+        )
+        assert create_resp.status_code == 200
+        vp_id = create_resp.json()["id"]
+        # New VPs default to tailscale_disabled=True (opt-in).
+        assert create_resp.json()["tailscale_disabled"] is True
+
+        mock_status = TailscaleStatus(
+            available=False,
+            hostname="",
+            tailnet_name="",
+            fqdn="",
+            error="tailscale binary not found",
+        )
+        with patch(
+            "backend.app.services.virtual_printer.tailscale.tailscale_service.get_status",
+            new=AsyncMock(return_value=mock_status),
+        ):
+            update_resp = await async_client.put(
+                f"/api/v1/virtual-printers/{vp_id}",
+                json={"tailscale_disabled": False},
+            )
+        assert update_resp.status_code == 409
+        assert update_resp.json()["detail"] == "tailscale_not_available"
+
+        # Ensure the row was not modified.
+        get_resp = await async_client.get(f"/api/v1/virtual-printers/{vp_id}")
+        assert get_resp.json()["tailscale_disabled"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_enable_tailscale_allowed_when_binary_present(self, async_client: AsyncClient):
+        """PUT tailscale_disabled=False succeeds when tailscale_service reports available."""
+        from backend.app.services.virtual_printer.tailscale import TailscaleStatus
+
+        create_resp = await async_client.post(
+            "/api/v1/virtual-printers",
+            json={
+                "name": "TestTailscaleAllow",
+                "mode": "immediate",
+                "access_code": "12345678",
+            },
+        )
+        vp_id = create_resp.json()["id"]
+
+        mock_status = TailscaleStatus(
+            available=True,
+            hostname="host",
+            tailnet_name="tail.ts.net",
+            fqdn="host.tail.ts.net",
+            error=None,
+        )
+        with patch(
+            "backend.app.services.virtual_printer.tailscale.tailscale_service.get_status",
+            new=AsyncMock(return_value=mock_status),
+        ):
+            update_resp = await async_client.put(
+                f"/api/v1/virtual-printers/{vp_id}",
+                json={"tailscale_disabled": False},
+            )
+        assert update_resp.status_code == 200
+        assert update_resp.json()["tailscale_disabled"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_disable_tailscale_skips_binary_check(self, async_client: AsyncClient):
+        """Disabling (tailscale_disabled=True) never calls tailscale_service — always allowed."""
+        create_resp = await async_client.post(
+            "/api/v1/virtual-printers",
+            json={
+                "name": "TestTailscaleDisableSkip",
+                "mode": "immediate",
+                "access_code": "12345678",
+            },
+        )
+        vp_id = create_resp.json()["id"]
+
+        # get_status must not be called on the disable path.
+        with patch(
+            "backend.app.services.virtual_printer.tailscale.tailscale_service.get_status",
+            new=AsyncMock(side_effect=AssertionError("get_status must not be called for disable")),
+        ):
+            update_resp = await async_client.put(
+                f"/api/v1/virtual-printers/{vp_id}",
+                json={"tailscale_disabled": True},
+            )
+        assert update_resp.status_code == 200
+        assert update_resp.json()["tailscale_disabled"] is True

+ 5 - 2
backend/tests/unit/services/test_tailscale.py

@@ -211,6 +211,8 @@ class TestVirtualPrinterInstanceTailscale:
     def instance(self, tmp_path):
         from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
 
+        # Tailscale is opt-in (default True); tests in this class exercise the enabled
+        # path, so explicitly opt in.
         return VirtualPrinterInstance(
             vp_id=1,
             name="TestPrinter",
@@ -218,6 +220,7 @@ class TestVirtualPrinterInstanceTailscale:
             model="C11",
             access_code="12345678",
             serial_suffix="391800001",
+            tailscale_disabled=False,
             base_dir=tmp_path,
         )
 
@@ -333,8 +336,8 @@ class TestVirtualPrinterInstanceTailscale:
         assert instance.tailscale_fqdn is None
 
     @pytest.mark.asyncio
-    async def test_tailscale_enabled_by_default_queries_tailscale(self, instance):
-        """When tailscale_disabled=False (default), Tailscale is queried as usual."""
+    async def test_tailscale_enabled_explicitly_queries_tailscale(self, instance):
+        """When tailscale_disabled=False (user opted in), Tailscale is queried as usual."""
         from backend.app.services.virtual_printer.tailscale import TailscaleStatus
 
         mock_ts = MagicMock()

+ 1 - 1
backend/tests/unit/services/test_virtual_printer.py

@@ -449,7 +449,7 @@ class TestVirtualPrinterManager:
             "remote_interface_ip": "",
             "target_printer_id": None,
             "auto_dispatch": True,
-            "tailscale_disabled": False,
+            "tailscale_disabled": True,  # Opt-in default (#1070 UX fix)
             "position": 0,
         }
         defaults.update(overrides)

+ 31 - 0
frontend/src/__tests__/components/VirtualPrinterCard.test.tsx

@@ -198,4 +198,35 @@ describe('VirtualPrinterCard - tailscale toggle', () => {
       expect(multiVirtualPrinterApi.update).toHaveBeenCalledWith(1, { tailscale_disabled: true });
     });
   });
+
+  it('reverts toggle and shows a specific toast when backend rejects enable (tailscale_not_available)', async () => {
+    const user = userEvent.setup();
+    const printer = createMockPrinter({ tailscale_disabled: true });
+    vi.mocked(multiVirtualPrinterApi.update).mockRejectedValueOnce(
+      new Error('tailscale_not_available')
+    );
+
+    render(<VirtualPrinterCard printer={printer} models={models} />);
+
+    await waitFor(() => {
+      expect(screen.getByText('Tailscale integration')).toBeInTheDocument();
+    });
+
+    const title = screen.getByText('Tailscale integration');
+    const section = title.closest('.flex.items-center.justify-between');
+    const toggleButton = section!.querySelector('button') as HTMLButtonElement;
+    // Disabled state → dark-grey background on the track.
+    expect(toggleButton.className).toContain('bg-bambu-dark-tertiary');
+
+    await user.click(toggleButton);
+
+    await waitFor(() => {
+      expect(multiVirtualPrinterApi.update).toHaveBeenCalledWith(1, { tailscale_disabled: false });
+    });
+
+    // After the 409 revert, the toggle goes back to the dark-grey (disabled) state.
+    await waitFor(() => {
+      expect(toggleButton.className).toContain('bg-bambu-dark-tertiary');
+    });
+  });
 });

+ 10 - 3
frontend/src/components/VirtualPrinterCard.tsx

@@ -43,7 +43,7 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
   const [localRemoteInterfaceIp, setLocalRemoteInterfaceIp] = useState(printer.remote_interface_ip || '');
   const [localModel, setLocalModel] = useState(printer.model || '');
   const [localAutoDispatch, setLocalAutoDispatch] = useState(printer.auto_dispatch ?? true);
-  const [localTailscaleDisabled, setLocalTailscaleDisabled] = useState(printer.tailscale_disabled ?? false);
+  const [localTailscaleDisabled, setLocalTailscaleDisabled] = useState(printer.tailscale_disabled ?? true);
   const [showAccessCode, setShowAccessCode] = useState(false);
   const [pendingAction, setPendingAction] = useState<string | null>(null);
   const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
@@ -59,7 +59,7 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
       setLocalRemoteInterfaceIp(printer.remote_interface_ip || '');
       setLocalModel(printer.model || '');
       setLocalAutoDispatch(printer.auto_dispatch ?? true);
-      setLocalTailscaleDisabled(printer.tailscale_disabled ?? false);
+      setLocalTailscaleDisabled(printer.tailscale_disabled ?? true);
     }
   }, [printer, pendingAction]);
 
@@ -84,11 +84,18 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
       setPendingAction(null);
     },
     onError: (error: Error) => {
-      showToast(error.message || t('virtualPrinter.toast.failedToUpdate'), 'error');
+      // Specific: the backend rejected "enable Tailscale" because the binary isn't installed.
+      // Surface a clear reason instead of the raw error code.
+      if (error.message === 'tailscale_not_available') {
+        showToast(t('virtualPrinter.toast.tailscaleNotAvailable'), 'error');
+      } else {
+        showToast(error.message || t('virtualPrinter.toast.failedToUpdate'), 'error');
+      }
       setLocalEnabled(printer.enabled);
       setLocalMode((printer.mode === 'queue' ? 'review' : printer.mode) as LocalMode);
       setLocalTargetPrinterId(printer.target_printer_id);
       setLocalBindIp(printer.bind_ip || '');
+      setLocalTailscaleDisabled(printer.tailscale_disabled ?? true);
       setPendingAction(null);
     },
   });

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

@@ -3984,6 +3984,7 @@ export default {
     toast: {
       updated: 'Virtuelle Druckereinstellungen aktualisiert',
       failedToUpdate: 'Einstellungen konnten nicht aktualisiert werden',
+      tailscaleNotAvailable: 'Tailscale ist auf diesem Host nicht installiert. Installiere Tailscale zuerst und versuche es dann erneut.',
       accessCodeRequired: 'Bitte zuerst einen Zugangscode setzen',
       targetPrinterRequired: 'Bitte zuerst einen Zieldrucker auswählen',
       bindIpRequired: 'Bitte zuerst eine Bind-IP setzen',

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

@@ -3992,6 +3992,7 @@ export default {
     toast: {
       updated: 'Virtual printer settings updated',
       failedToUpdate: 'Failed to update settings',
+      tailscaleNotAvailable: 'Tailscale is not installed on this host. Install Tailscale first, then try again.',
       accessCodeRequired: 'Please set an access code first',
       targetPrinterRequired: 'Please select a target printer first',
       bindIpRequired: 'Please set a bind IP first',

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

@@ -3907,6 +3907,7 @@ export default {
     toast: {
       updated: 'Réglages virtuels mis à jour',
       failedToUpdate: 'Échec mise à jour',
+      tailscaleNotAvailable: 'Tailscale n\'est pas installé sur cet hôte. Installez Tailscale puis réessayez.',
       accessCodeRequired: 'Code d\'accès requis',
       targetPrinterRequired: 'Imprimante cible requise',
       bindIpRequired: 'Veuillez d\'abord définir une adresse IP',

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

@@ -3906,6 +3906,7 @@ export default {
     toast: {
       updated: 'Impostazioni stampante virtuale aggiornate',
       failedToUpdate: 'Aggiornamento impostazioni fallito',
+      tailscaleNotAvailable: 'Tailscale non è installato su questo host. Installa prima Tailscale, poi riprova.',
       accessCodeRequired: 'Imposta prima un codice accesso',
       targetPrinterRequired: 'Seleziona prima una stampante target',
       bindIpRequired: 'Impostare prima un indirizzo IP',

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

@@ -3945,6 +3945,7 @@ export default {
     toast: {
       updated: '仮想プリンター設定を更新しました',
       failedToUpdate: '設定の更新に失敗しました',
+      tailscaleNotAvailable: 'このホストにTailscaleがインストールされていません。先にTailscaleをインストールしてから再試行してください。',
       accessCodeRequired: '先にアクセスコードを設定してください',
       targetPrinterRequired: '先にターゲットプリンターを選択してください',
       bindIpRequired: '先にバインドIPを設定してください',

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

@@ -3920,6 +3920,7 @@ export default {
     toast: {
       updated: 'Configurações da impressora virtual atualizadas',
       failedToUpdate: 'Falha ao atualizar as configurações',
+      tailscaleNotAvailable: 'Tailscale não está instalado neste host. Instale o Tailscale primeiro e tente novamente.',
       accessCodeRequired: 'Defina um código de acesso primeiro',
       targetPrinterRequired: 'Selecione uma impressora alvo primeiro',
       bindIpRequired: 'Defina um IP de ligação primeiro',

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

@@ -3972,6 +3972,7 @@ export default {
     toast: {
       updated: '虚拟打印机设置已更新',
       failedToUpdate: '更新设置失败',
+      tailscaleNotAvailable: '此主机上未安装 Tailscale。请先安装 Tailscale,然后重试。',
       accessCodeRequired: '请先设置访问码',
       targetPrinterRequired: '请先选择目标打印机',
       bindIpRequired: '请先设置绑定 IP',

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

@@ -3972,6 +3972,7 @@ export default {
     toast: {
       updated: '虛擬印表機設定已更新',
       failedToUpdate: '更新設定失敗',
+      tailscaleNotAvailable: '此主機上未安裝 Tailscale。請先安裝 Tailscale,然後重試。',
       accessCodeRequired: '請先設定存取碼',
       targetPrinterRequired: '請先選擇目標印表機',
       bindIpRequired: '請先設定繫結 IP',

Datei-Diff unterdrückt, da er zu groß ist
+ 0 - 0
static/assets/index-Bm0EQe4Z.js


Datei-Diff unterdrückt, da er zu groß ist
+ 0 - 0
static/assets/index-CzGoReuB.css


Datei-Diff unterdrückt, da er zu groß ist
+ 0 - 0
static/assets/index-DGaysySO.css


Datei-Diff unterdrückt, da er zu groß ist
+ 0 - 0
static/assets/index-DSTnupH9.css


Datei-Diff unterdrückt, da er zu groß ist
+ 0 - 0
static/assets/index-MAqCzCmY.js


+ 40 - 40
static/index.html

@@ -1,40 +1,40 @@
-<!doctype html>
-<html lang="en">
-  <head>
-    <meta charset="UTF-8" />
-    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
-    <!-- L-4: Restrict Referer header to origin-only on cross-origin navigation so
-         sensitive tokens in query parameters are not leaked to third-party servers. -->
-    <meta name="referrer" content="strict-origin-when-cross-origin" />
-    <title>Bambuddy</title>
-
-    <!-- PWA Meta Tags -->
-    <meta name="description" content="Monitor and manage your Bambu Lab 3D printers" />
-    <meta name="theme-color" content="#00ae42" />
-    <meta name="mobile-web-app-capable" content="yes" />
-    <meta name="apple-mobile-web-app-capable" content="yes" />
-    <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
-    <meta name="apple-mobile-web-app-title" content="Bambuddy" />
-
-    <!-- Manifest -->
-    <link rel="manifest" href="/manifest.json" />
-
-    <!-- Favicons -->
-    <link rel="icon" type="image/png" sizes="32x32" href="/img/favicon-32x32.png" />
-    <link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.png" />
-    <link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
-
-    <!-- Splash screens for iOS -->
-    <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-D6O7X0cZ.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-DSTnupH9.css">
-  </head>
-  <body>
-    <div id="root"></div>
-
-    <!-- Service Worker Registration (skip on SpoolBuddy kiosk).
-         Kept as an external file so the CSP `script-src 'self'` covers it
-         without needing 'unsafe-inline' or per-build hashes. -->
-    <script src="/sw-register.js"></script>
-  </body>
-</html>
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
+    <!-- L-4: Restrict Referer header to origin-only on cross-origin navigation so
+         sensitive tokens in query parameters are not leaked to third-party servers. -->
+    <meta name="referrer" content="strict-origin-when-cross-origin" />
+    <title>Bambuddy</title>
+
+    <!-- PWA Meta Tags -->
+    <meta name="description" content="Monitor and manage your Bambu Lab 3D printers" />
+    <meta name="theme-color" content="#00ae42" />
+    <meta name="mobile-web-app-capable" content="yes" />
+    <meta name="apple-mobile-web-app-capable" content="yes" />
+    <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
+    <meta name="apple-mobile-web-app-title" content="Bambuddy" />
+
+    <!-- Manifest -->
+    <link rel="manifest" href="/manifest.json" />
+
+    <!-- Favicons -->
+    <link rel="icon" type="image/png" sizes="32x32" href="/img/favicon-32x32.png" />
+    <link rel="icon" type="image/png" sizes="16x16" href="/img/favicon-16x16.png" />
+    <link rel="apple-touch-icon" sizes="180x180" href="/img/apple-touch-icon.png" />
+
+    <!-- Splash screens for iOS -->
+    <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
+    <script type="module" crossorigin src="/assets/index-Bm0EQe4Z.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-DGaysySO.css">
+  </head>
+  <body>
+    <div id="root"></div>
+
+    <!-- Service Worker Registration (skip on SpoolBuddy kiosk).
+         Kept as an external file so the CSP `script-src 'self'` covers it
+         without needing 'unsafe-inline' or per-build hashes. -->
+    <script src="/sw-register.js"></script>
+  </body>
+</html>

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.