Browse Source

Post work PR #1070

maziggy 1 month ago
parent
commit
9c5c2a765f

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


+ 1 - 1
README.md

@@ -60,7 +60,7 @@ You don't need to be a developer for the docs or moderator roles. If you enjoy w
 **Print from anywhere in the world** — Bambuddy's new Proxy Mode acts as a secure relay between your slicer and printer:
 
 - 🔒 **End-to-end TLS encryption** — FTP, file transfer, and camera are transparently proxied with the printer's real TLS certificate
-- 🛡️ **VPN recommended** — Use Tailscale/WireGuard for full data encryption ([details](https://wiki.bambuddy.cool/features/virtual-printer/))
+- 🛡️ **Optional Tailscale integration** — per-VP toggle + Docker socket mount brings Bambuddy into your tailnet, so virtual printers are reachable from any tailnet device over a private WireGuard tunnel without port forwarding ([setup](https://wiki.bambuddy.cool/features/virtual-printer/)). Bambuddy's self-signed CA import is still required for the slicer side because both Bambu Studio and OrcaSlicer only accept IP addresses in the Add Printer dialog — the Tailscale benefit here is the tunnel, not cert-import elimination.
 - 🌍 **No cloud dependency** — Direct connection through your own Bambuddy server
 - 🔑 **Uses printer's access code** — No additional credentials needed
 - ⚡ **Full-speed printing** — Transparent TCP proxy, only MQTT is decrypted for IP rewriting

+ 193 - 0
backend/tests/unit/test_settings_dedupe_migration.py

@@ -0,0 +1,193 @@
+"""Regression test for the settings table dedupe + unique-index migration.
+
+Legacy SQLite installs created the `settings` table without a UNIQUE constraint
+on `key`. The seed loop's `INSERT OR IGNORE` silently degraded to a plain INSERT
+on every restart, duplicating rows. After a handful of restarts, any code path
+calling `scalar_one_or_none()` on a `SELECT settings WHERE key = :k` query
+(e.g. `is_advanced_auth_enabled`) blew up with `MultipleResultsFound` and 500'd.
+
+`run_migrations` now deletes dup rows (keeping MIN(id) per key) and creates the
+missing unique index before the seed loop. This test verifies the fix and its
+idempotency on both fresh and legacy schemas.
+"""
+
+from __future__ import annotations
+
+import pytest
+from sqlalchemy import text
+from sqlalchemy.exc import IntegrityError
+from sqlalchemy.ext.asyncio import create_async_engine
+
+from backend.app.core.database import run_migrations
+
+
+@pytest.fixture(autouse=True)
+def force_sqlite_dialect(monkeypatch):
+    """Force the SQLite branch in run_migrations regardless of test env settings."""
+    from backend.app.core import db_dialect
+
+    monkeypatch.setattr(db_dialect, "is_sqlite", lambda: True)
+    monkeypatch.setattr(db_dialect, "is_postgres", lambda: False)
+    from backend.app.core import database as database_module
+
+    monkeypatch.setattr(database_module, "is_sqlite", lambda: True)
+
+
+def _register_all_models():
+    """Import every model so Base.metadata knows about them. run_migrations touches
+    multiple tables, so the full schema has to exist before calling it — mirrors the
+    pattern in test_ldap_migration.py."""
+    from backend.app.models import (  # noqa: F401
+        ams_history,
+        ams_label,
+        api_key,
+        archive,
+        color_catalog,
+        external_link,
+        filament,
+        group,
+        kprofile_note,
+        maintenance,
+        notification,
+        notification_template,
+        print_queue,
+        printer,
+        project,
+        project_bom,
+        settings,
+        slot_preset,
+        smart_plug,
+        smart_plug_energy_snapshot,
+        spool,
+        spool_assignment,
+        spool_catalog,
+        spool_k_profile,
+        spool_usage_history,
+        spoolbuddy_device,
+        user,
+        user_email_pref,
+        virtual_printer,
+    )
+
+
+@pytest.fixture
+async def legacy_engine():
+    """Simulate a pre-UNIQUE install: full schema via create_all, then drop the
+    settings table and re-create it in the legacy shape (no UNIQUE on key).
+    This matches real-world upgrades where everything else is modern and only
+    the settings table carries the stale schema."""
+    from backend.app.core.database import Base
+
+    _register_all_models()
+
+    engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
+    async with engine.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+        await conn.execute(text("DROP TABLE settings"))
+        await conn.execute(
+            text("""
+            CREATE TABLE settings (
+                id INTEGER PRIMARY KEY,
+                key TEXT,
+                value TEXT,
+                created_at TEXT,
+                updated_at TEXT
+            )
+            """)
+        )
+    yield engine
+    await engine.dispose()
+
+
+@pytest.fixture
+async def fresh_engine():
+    """Simulate a fresh install: every table created from SQLAlchemy models, which
+    DOES emit the unique index on settings.key. Verifies the migration is a no-op."""
+    from backend.app.core.database import Base
+
+    _register_all_models()
+
+    engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
+    async with engine.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+    yield engine
+    await engine.dispose()
+
+
+# -----------------------------------------------------------------------------
+# Legacy schema tests
+# -----------------------------------------------------------------------------
+
+
+async def test_legacy_schema_allows_duplicate_keys_before_migration(legacy_engine):
+    """Sanity check: the legacy schema really does permit duplicates — protects
+    the migration test below from becoming a false-positive if the fixture drifts."""
+    async with legacy_engine.begin() as conn:
+        await conn.execute(text("INSERT INTO settings (key, value) VALUES ('advanced_auth_enabled', 'false')"))
+        await conn.execute(text("INSERT INTO settings (key, value) VALUES ('advanced_auth_enabled', 'false')"))
+        result = await conn.execute(text("SELECT COUNT(*) FROM settings WHERE key = 'advanced_auth_enabled'"))
+        assert result.scalar_one() == 2
+
+
+async def test_migration_dedupes_and_adds_unique_index(legacy_engine):
+    """Given a legacy DB with duplicate rows for the same key, run_migrations
+    should (a) delete duplicates keeping the lowest id, (b) add the unique index,
+    (c) make future duplicate inserts fail with IntegrityError."""
+    # Seed: two duplicate rows for the same key, with distinguishable values.
+    async with legacy_engine.begin() as conn:
+        await conn.execute(text("INSERT INTO settings (id, key, value) VALUES (1, 'advanced_auth_enabled', 'old')"))
+        await conn.execute(text("INSERT INTO settings (id, key, value) VALUES (2, 'advanced_auth_enabled', 'new')"))
+        # Also seed an unrelated key that should survive untouched.
+        await conn.execute(text("INSERT INTO settings (id, key, value) VALUES (3, 'other_key', 'keep_me')"))
+
+    async with legacy_engine.begin() as conn:
+        await run_migrations(conn)
+
+    async with legacy_engine.begin() as conn:
+        # Only the MIN(id) row for the duplicated key remains.
+        rows = (await conn.execute(text("SELECT id, value FROM settings WHERE key = 'advanced_auth_enabled'"))).all()
+        assert len(rows) == 1
+        assert rows[0].id == 1
+        assert rows[0].value == "old"
+
+        # Untouched key still present.
+        other = (await conn.execute(text("SELECT value FROM settings WHERE key = 'other_key'"))).scalar_one()
+        assert other == "keep_me"
+
+        # Unique constraint is now enforced — inserting a duplicate fails.
+        with pytest.raises(IntegrityError):
+            await conn.execute(text("INSERT INTO settings (key, value) VALUES ('advanced_auth_enabled', 'x')"))
+
+
+async def test_migration_is_idempotent_on_already_clean_legacy(legacy_engine):
+    """Running the migration twice must not crash — the second run finds no
+    duplicates and the CREATE UNIQUE INDEX IF NOT EXISTS is a no-op."""
+    async with legacy_engine.begin() as conn:
+        await conn.execute(text("INSERT INTO settings (key, value) VALUES ('k', 'v')"))
+
+    async with legacy_engine.begin() as conn:
+        await run_migrations(conn)
+    async with legacy_engine.begin() as conn:
+        await run_migrations(conn)
+
+    async with legacy_engine.begin() as conn:
+        count = (await conn.execute(text("SELECT COUNT(*) FROM settings WHERE key = 'k'"))).scalar_one()
+        assert count == 1
+
+
+# -----------------------------------------------------------------------------
+# Fresh-install test — migration must be a safe no-op
+# -----------------------------------------------------------------------------
+
+
+async def test_migration_is_noop_on_fresh_install(fresh_engine):
+    """Fresh installs get the unique index from `create_all`. Running the
+    migration must not crash and must not alter the schema."""
+    async with fresh_engine.begin() as conn:
+        await run_migrations(conn)
+
+    async with fresh_engine.begin() as conn:
+        # Unique constraint still present — duplicate insert fails.
+        await conn.execute(text("INSERT INTO settings (key, value) VALUES ('k', 'v1')"))
+        with pytest.raises(IntegrityError):
+            await conn.execute(text("INSERT INTO settings (key, value) VALUES ('k', 'v2')"))

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

@@ -230,3 +230,90 @@ describe('VirtualPrinterCard - tailscale toggle', () => {
     });
   });
 });
+
+describe('VirtualPrinterCard - Tailscale FQDN copy', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+    vi.mocked(multiVirtualPrinterApi.update).mockResolvedValue(createMockPrinter());
+  });
+
+  const fqdn = 'test-host.tail1234.ts.net';
+
+  function getCopyButton() {
+    // The copy button is a <button> with a title attribute. Use title to locate it.
+    const candidates = screen.getAllByRole('button');
+    return candidates.find(btn => /copy/i.test(btn.getAttribute('title') || '')) as HTMLButtonElement;
+  }
+
+  it('uses navigator.clipboard.writeText in a secure context', async () => {
+    const user = userEvent.setup();
+    const writeTextMock = vi.fn().mockResolvedValue(undefined);
+    // JSDOM defaults isSecureContext to true; confirm and stub clipboard.
+    Object.defineProperty(window, 'isSecureContext', { value: true, configurable: true });
+    Object.defineProperty(navigator, 'clipboard', {
+      value: { writeText: writeTextMock },
+      configurable: true,
+    });
+
+    const printer = createMockPrinter({
+      status: { running: true, pending_files: 0, tailscale_fqdn: fqdn },
+    });
+    render(<VirtualPrinterCard printer={printer} models={models} />);
+
+    const copyBtn = getCopyButton();
+    expect(copyBtn).toBeTruthy();
+    await user.click(copyBtn);
+
+    await waitFor(() => {
+      expect(writeTextMock).toHaveBeenCalledWith(fqdn);
+    });
+  });
+
+  it('falls back to execCommand("copy") when clipboard API is unavailable (HTTP)', async () => {
+    const user = userEvent.setup();
+    // Simulate non-secure context: no clipboard API available.
+    Object.defineProperty(window, 'isSecureContext', { value: false, configurable: true });
+    Object.defineProperty(navigator, 'clipboard', { value: undefined, configurable: true });
+
+    const execCommandMock = vi.fn().mockReturnValue(true);
+    document.execCommand = execCommandMock;
+
+    const printer = createMockPrinter({
+      status: { running: true, pending_files: 0, tailscale_fqdn: fqdn },
+    });
+    render(<VirtualPrinterCard printer={printer} models={models} />);
+
+    const copyBtn = getCopyButton();
+    await user.click(copyBtn);
+
+    await waitFor(() => {
+      expect(execCommandMock).toHaveBeenCalledWith('copy');
+    });
+    // Fallback path: textarea is appended, used, then removed in `finally`.
+    // After the click resolves, no stray textareas should remain in the DOM.
+    expect(document.querySelectorAll('textarea').length).toBe(0);
+  });
+
+  it('always cleans up the hidden textarea even if execCommand throws', async () => {
+    const user = userEvent.setup();
+    Object.defineProperty(window, 'isSecureContext', { value: false, configurable: true });
+    Object.defineProperty(navigator, 'clipboard', { value: undefined, configurable: true });
+
+    document.execCommand = vi.fn().mockImplementation(() => {
+      throw new Error('synthetic execCommand failure');
+    });
+
+    const printer = createMockPrinter({
+      status: { running: true, pending_files: 0, tailscale_fqdn: fqdn },
+    });
+    render(<VirtualPrinterCard printer={printer} models={models} />);
+
+    const copyBtn = getCopyButton();
+    await user.click(copyBtn);
+
+    // The `finally` block must remove the textarea regardless of the exception.
+    await waitFor(() => {
+      expect(document.querySelectorAll('textarea').length).toBe(0);
+    });
+  });
+});

+ 3 - 38
frontend/src/components/VirtualPrinterList.tsx

@@ -1,7 +1,7 @@
 import { useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { useQuery } from '@tanstack/react-query';
-import { Loader2, Plus, Printer, ExternalLink, AlertTriangle, Info, ShieldCheck, ShieldOff } from 'lucide-react';
+import { Loader2, Plus, Printer, ExternalLink, AlertTriangle, Info } from 'lucide-react';
 import { multiVirtualPrinterApi } from '../api/client';
 import { Card, CardContent } from './Card';
 import { Button } from './Button';
@@ -18,12 +18,6 @@ export function VirtualPrinterList() {
     refetchInterval: 10000,
   });
 
-  const { data: tailscaleData } = useQuery({
-    queryKey: ['tailscale-status'],
-    queryFn: multiVirtualPrinterApi.getTailscaleStatus,
-    refetchInterval: 30000,
-  });
-
   if (isLoading) {
     return (
       <Card>
@@ -39,8 +33,8 @@ export function VirtualPrinterList() {
 
   return (
     <div className="space-y-4">
-      {/* Top row - Setup Required (1 col) + Tailscale (1 col) + How it works (2 cols) */}
-      <div className="grid grid-cols-1 lg:grid-cols-4 gap-4 items-stretch">
+      {/* Top row - Setup Required (1 col) + How it works (2 cols) */}
+      <div className="grid grid-cols-1 lg:grid-cols-3 gap-4 items-stretch">
         <Card className="border-l-4 border-l-yellow-500">
           <CardContent className="py-3 px-4">
             <div className="flex items-start gap-2">
@@ -62,35 +56,6 @@ export function VirtualPrinterList() {
           </CardContent>
         </Card>
 
-        <Card className={tailscaleData?.available ? 'border-l-4 border-l-green-500' : 'border-l-4 border-l-bambu-dark-tertiary'}>
-          <CardContent className="py-3 px-4">
-            <div className="flex items-start gap-2">
-              {tailscaleData?.available
-                ? <ShieldCheck className="w-4 h-4 text-green-400 shrink-0 mt-0.5" />
-                : <ShieldOff className="w-4 h-4 text-bambu-gray shrink-0 mt-0.5" />
-              }
-              <div className="text-xs">
-                <div className="flex items-center gap-1.5">
-                  <span className={`w-1.5 h-1.5 rounded-full ${tailscaleData?.available ? 'bg-green-400 animate-pulse' : 'bg-gray-500'}`} />
-                  <p className={`font-medium ${tailscaleData?.available ? 'text-white' : 'text-bambu-gray'}`}>
-                    {tailscaleData?.available
-                      ? t('virtualPrinter.tailscale.connected')
-                      : t('virtualPrinter.tailscale.notAvailable')}
-                  </p>
-                </div>
-                {tailscaleData?.available ? (
-                  <>
-                    <p className="text-bambu-gray mt-1 font-mono break-all">{tailscaleData.fqdn}</p>
-                    <p className="text-green-400/80 mt-1">{t('virtualPrinter.tailscale.trustedCert')}</p>
-                  </>
-                ) : (
-                  <p className="text-bambu-gray mt-1">{t('virtualPrinter.tailscale.notAvailableHint')}</p>
-                )}
-              </div>
-            </div>
-          </CardContent>
-        </Card>
-
         <Card className="lg:col-span-2">
           <CardContent className="py-3 px-4">
             <div className="flex items-start gap-2">

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

@@ -3953,15 +3953,6 @@ export default {
       description: 'Die virtuelle Druckerfunktion erfordert zusätzliche Systemkonfiguration, bevor sie funktioniert. Dies beinhaltet Portweiterleitung, Firewall-Regeln und plattformspezifische Einstellungen.',
       readGuide: 'Lese die Einrichtungsanleitung vor dem Aktivieren',
     },
-    tailscale: {
-      connected: 'Tailscale verbunden',
-      notAvailable: 'Tailscale nicht aktiv',
-      trustedCert: "Let's Encrypt-Zertifikat — keine CA-Einrichtung nötig",
-      notAvailableHint: 'Installiere Tailscale für vertrauenswürdige TLS-Zertifikate',
-      disableTitle: 'Tailscale-Integration deaktivieren',
-      enabledHint: 'Tailscale automatisch erkennen und Let\'s Encrypt-Zertifikat verwenden wenn verfügbar',
-      disabledHint: 'Tailscale deaktiviert — selbstsigniertes Zertifikat wird verwendet',
-    },
     howItWorks: {
       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.',

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

@@ -3961,15 +3961,6 @@ export default {
       description: 'The virtual printer feature requires additional system configuration before it will work. This includes port forwarding, firewall rules, and platform-specific settings.',
       readGuide: 'Read the setup guide before enabling',
     },
-    tailscale: {
-      connected: 'Tailscale connected',
-      notAvailable: 'Tailscale not active',
-      trustedCert: "Let's Encrypt cert — no CA setup needed",
-      notAvailableHint: 'Install Tailscale for trusted TLS certs',
-      disableTitle: 'Disable Tailscale integration',
-      enabledHint: 'Auto-detect Tailscale and use Let\'s Encrypt cert when available',
-      disabledHint: 'Tailscale disabled — using self-signed cert',
-    },
     howItWorks: {
       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.',

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

@@ -3880,15 +3880,6 @@ export default {
       description: 'Nécessite des réglages système (ports, pare-feu).',
       readGuide: 'Lire le guide de configuration',
     },
-    tailscale: {
-      connected: 'Tailscale connecté',
-      notAvailable: 'Tailscale inactif',
-      trustedCert: "Certificat Let's Encrypt — aucune configuration CA requise",
-      notAvailableHint: 'Installez Tailscale pour des certificats TLS approuvés',
-      disableTitle: 'Désactiver l\'intégration Tailscale',
-      enabledHint: 'Détecter Tailscale automatiquement et utiliser le certificat Let\'s Encrypt si disponible',
-      disabledHint: 'Tailscale désactivé — certificat auto-signé utilisé',
-    },
     howItWorks: {
       title: 'Fonctionnement',
       step1: 'Sur le même LAN, les imprimantes virtuelles apparaissent automatiquement dans votre slicer (Bambu Studio / OrcaSlicer). Depuis d\'autres réseaux, ajoutez-les manuellement par adresse IP et code d\'accès.',

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

@@ -3879,15 +3879,6 @@ export default {
       description: 'La stampante virtuale richiede configurazioni di sistema aggiuntive prima di funzionare. Include port forwarding, regole firewall e impostazioni specifiche della piattaforma.',
       readGuide: 'Leggi la guida prima di abilitare',
     },
-    tailscale: {
-      connected: 'Tailscale connesso',
-      notAvailable: 'Tailscale non attivo',
-      trustedCert: "Certificato Let's Encrypt — nessuna configurazione CA richiesta",
-      notAvailableHint: 'Installa Tailscale per certificati TLS attendibili',
-      disableTitle: 'Disabilita integrazione Tailscale',
-      enabledHint: 'Rileva automaticamente Tailscale e usa il certificato Let\'s Encrypt se disponibile',
-      disabledHint: 'Tailscale disabilitato — viene usato il certificato auto-firmato',
-    },
     howItWorks: {
       title: 'Come funziona',
       step1: 'Sulla stessa LAN, le stampanti virtuali appaiono automaticamente nel tuo slicer (Bambu Studio / OrcaSlicer). Da altre reti, aggiungile manualmente tramite indirizzo IP e codice di accesso.',

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

@@ -3918,15 +3918,6 @@ export default {
       description: '仮想プリンター機能を使用するには追加のシステム設定が必要です。ポートフォワーディング、ファイアウォールルール、プラットフォーム固有の設定が含まれます。',
       readGuide: '有効にする前にセットアップガイドをお読みください',
     },
-    tailscale: {
-      connected: 'Tailscale 接続済み',
-      notAvailable: 'Tailscale 未接続',
-      trustedCert: "Let's Encrypt 証明書 — CA設定不要",
-      notAvailableHint: '信頼できるTLS証明書にはTailscaleをインストール',
-      disableTitle: 'Tailscale統合を無効にする',
-      enabledHint: 'Tailscaleを自動検出し、利用可能な場合はLet\'s Encrypt証明書を使用',
-      disabledHint: 'Tailscale無効 — 自己署名証明書を使用中',
-    },
     howItWorks: {
       title: '仕組み',
       step1: '同じLAN上では、仮想プリンターはスライサー(Bambu Studio / OrcaSlicer)に自動的に表示されます。他のネットワークからは、IPアドレスとアクセスコードで手動で追加してください。',

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

@@ -3893,15 +3893,6 @@ export default {
       description: 'O recurso de impressora virtual requer configuração adicional do sistema antes de funcionar. Isso inclui encaminhamento de portas, regras de firewall e configurações específicas da plataforma.',
       readGuide: 'Leia o guia de configuração antes de ativar',
     },
-    tailscale: {
-      connected: 'Tailscale conectado',
-      notAvailable: 'Tailscale inativo',
-      trustedCert: "Certificado Let's Encrypt — sem configuração de CA necessária",
-      notAvailableHint: 'Instale o Tailscale para certificados TLS confiáveis',
-      disableTitle: 'Desativar integração com Tailscale',
-      enabledHint: 'Detectar Tailscale automaticamente e usar certificado Let\'s Encrypt quando disponível',
-      disabledHint: 'Tailscale desativado — usando certificado autoassinado',
-    },
     howItWorks: {
       title: 'Como funciona',
       step1: 'Complete o guia de configuração para sua plataforma',

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

@@ -3945,15 +3945,6 @@ export default {
       description: '虚拟打印机功能需要额外的系统配置才能工作。包括端口转发、防火墙规则和平台特定设置。',
       readGuide: '启用前请阅读设置指南',
     },
-    tailscale: {
-      connected: 'Tailscale 已连接',
-      notAvailable: 'Tailscale 未激活',
-      trustedCert: "Let's Encrypt 证书 — 无需配置 CA",
-      notAvailableHint: '安装 Tailscale 以获取受信任的 TLS 证书',
-      disableTitle: '禁用 Tailscale 集成',
-      enabledHint: '自动检测 Tailscale,可用时使用 Let\'s Encrypt 证书',
-      disabledHint: 'Tailscale 已禁用 — 使用自签名证书',
-    },
     howItWorks: {
       title: '工作原理',
       step1: '在同一局域网中,虚拟打印机会通过发现机制自动出现在您的切片软件(Bambu Studio / OrcaSlicer)中。从其他网络,通过 IP 地址和访问码手动添加。',

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

@@ -3945,15 +3945,6 @@ export default {
       description: '虛擬印表機功能需要額外的系統設定才能工作。包括埠轉發、防火牆規則和平臺特定設定。',
       readGuide: '啟用前請閱讀設定指南',
     },
-    tailscale: {
-      connected: 'Tailscale 已連線',
-      notAvailable: 'Tailscale 未啟用',
-      trustedCert: "Let's Encrypt 憑證 — 無需設定 CA",
-      notAvailableHint: '安裝 Tailscale 以取得受信任的 TLS 憑證',
-      disableTitle: '停用 Tailscale 整合',
-      enabledHint: '自動偵測 Tailscale,可用時使用 Let\'s Encrypt 憑證',
-      disabledHint: 'Tailscale 已停用 — 使用自簽憑證',
-    },
     howItWorks: {
       title: '工作原理',
       step1: '在同一區域網路中,虛擬印表機會透過發現機制自動出現在您的切片軟體(Bambu Studio / OrcaSlicer)中。從其他網路,透過 IP 位址和存取碼手動新增。',

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


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


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


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-C_WG2aox.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-DGaysySO.css">
+    <script type="module" crossorigin src="/assets/index-C8M6qM08.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-CgXLDG6B.css">
   </head>
   <body>
     <div id="root"></div>

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