Browse Source

Queue VP target can be cleared; script adds the slicer certificate

- Queue mode: the target picker's first entry reads "Any available
  <model>" and clears the target (null), with a hint saying where jobs
  go; the API takes an explicit null as "clear"; proxy mode still needs
  a printer
- Settings > Virtual Printer: download a script for Windows (.cmd) or
  Linux / macOS (.sh) with this instance's CA written in; it adds the CA
  to Bambu Studio's and OrcaSlicer's printer.cer, keeps a backup, does
  nothing when present, and drops an earlier issue of the same CA
- Windows: elevates itself and installs a SYSTEM task (startup, logon,
  hourly) that re-adds the CA after slicer updates; it only touches
  slicers under Program Files and skips paths with links
- Tests run both scripts against a fake slicer tree (pwsh when present)
maziggy 2 days ago
parent
commit
8cc60bad16
100 changed files with 1175 additions and 15 deletions
  1. 2 0
      CHANGELOG.md
  2. 35 1
      backend/app/api/routes/virtual_printers.py
  3. 292 0
      backend/app/data/slicer_cert/bambuddy-slicer-cert.cmd
  4. 216 0
      backend/app/data/slicer_cert/bambuddy-slicer-cert.sh
  5. 42 0
      backend/app/services/virtual_printer/slicer_cert_installer.py
  6. 85 0
      backend/tests/integration/test_virtual_printer_api.py
  7. 206 0
      backend/tests/unit/services/test_slicer_cert_installer.py
  8. 57 0
      frontend/src/__tests__/components/VirtualPrinterCard.test.tsx
  9. 53 0
      frontend/src/__tests__/components/VirtualPrinterListCaInstaller.test.tsx
  10. 7 1
      frontend/src/api/client.ts
  11. 21 3
      frontend/src/components/VirtualPrinterCard.tsx
  12. 38 0
      frontend/src/components/VirtualPrinterList.tsx
  13. 7 0
      frontend/src/i18n/locales/de.ts
  14. 7 0
      frontend/src/i18n/locales/en.ts
  15. 7 0
      frontend/src/i18n/locales/es.ts
  16. 7 0
      frontend/src/i18n/locales/fr.ts
  17. 7 0
      frontend/src/i18n/locales/it.ts
  18. 7 0
      frontend/src/i18n/locales/ja.ts
  19. 8 1
      frontend/src/i18n/locales/ko.ts
  20. 7 0
      frontend/src/i18n/locales/nl.ts
  21. 7 0
      frontend/src/i18n/locales/pl.ts
  22. 7 0
      frontend/src/i18n/locales/pt-BR.ts
  23. 7 0
      frontend/src/i18n/locales/ru.ts
  24. 7 0
      frontend/src/i18n/locales/sv.ts
  25. 7 0
      frontend/src/i18n/locales/tr.ts
  26. 7 0
      frontend/src/i18n/locales/uk.ts
  27. 7 0
      frontend/src/i18n/locales/zh-CN.ts
  28. 7 0
      frontend/src/i18n/locales/zh-TW.ts
  29. 0 0
      static/assets/AdditionalSection-pLrsdgdj.js
  30. 0 0
      static/assets/AmsUnitCard-DY7lmJsz.js
  31. 0 0
      static/assets/ArchivesPage-BT2XUk6S.js
  32. 0 0
      static/assets/AreaChart-BAokb3od.js
  33. 0 0
      static/assets/AssignToAmsModal-BxUvw0zV.js
  34. 0 0
      static/assets/AuthContext-PU3ap1fN.js
  35. 1 1
      static/assets/Button-KJ9adUYB.js
  36. 0 0
      static/assets/CamWallPage-CMasY3Oc.js
  37. 0 0
      static/assets/CameraDiagnoseModal-Cb6vz5bM.js
  38. 0 0
      static/assets/CameraPage-DNXfCi6T.js
  39. 1 1
      static/assets/Card-C_y3K6sN.js
  40. 0 0
      static/assets/CartesianChart-HdmmnFGF.js
  41. 0 0
      static/assets/ColumnConfigModal-BMqJJjyj.js
  42. 0 0
      static/assets/ConfigureAmsSlotModal-Cpn3NhiX.js
  43. 0 0
      static/assets/ConfirmModal-CfY6dEko.js
  44. 0 0
      static/assets/ConfirmOutcomeDialog-DQsY4GQc.js
  45. 0 0
      static/assets/ConnectAuthorizePage-CrE2ceBN.js
  46. 0 0
      static/assets/ConnectionDiagnostic-X2u1rQlE.js
  47. 0 0
      static/assets/ContextMenu-DaXsC3qL.js
  48. 0 0
      static/assets/CopyButton--gXirSLb.js
  49. 0 0
      static/assets/EmbeddedCameraViewer-Bf0gRUCt.js
  50. 0 0
      static/assets/ErrorBarContext-xsiujNkq.js
  51. 0 0
      static/assets/ExternalLinkPage-C_1OZwBj.js
  52. 1 1
      static/assets/FilamentSwatch-DyaIHELd.js
  53. 0 1
      static/assets/FileManagerPage-CGQxvG5e.js
  54. 0 0
      static/assets/FileUploadModal-CtBwOVAf.js
  55. 0 0
      static/assets/FinancePage-ypEbtK3i.js
  56. 0 0
      static/assets/GCodeViewerPage-D2mcp22v.js
  57. 0 0
      static/assets/GcodeToolpathViewer-DcKmUf94.js
  58. 0 0
      static/assets/GroupEditPage-ByDaG_EK.js
  59. 0 0
      static/assets/IconPicker-2dCY0eBf.js
  60. 0 0
      static/assets/ImagePreviewModal-DUdJiOZN.js
  61. 0 0
      static/assets/InventoryPage-DHptXKPV.js
  62. 0 0
      static/assets/Layout-Dt7Cwwyb.js
  63. 0 0
      static/assets/LibraryTrashPage-jVtHCuqX.js
  64. 0 0
      static/assets/LoginPage-GFM57w-5.js
  65. 0 0
      static/assets/MaintenancePage-Dt3UIg76.js
  66. 0 0
      static/assets/MergeDuplicatesModal-vFI_ITnI.js
  67. 0 0
      static/assets/ModelSourcesPage-CFzyIjzA.js
  68. 0 0
      static/assets/NotificationsPage-C-zC1yEc.js
  69. 1 1
      static/assets/NumberInput-DiEjrvBB.js
  70. 1 1
      static/assets/OverlayFrame-2JWsWg1w.js
  71. 0 0
      static/assets/PdfPreviewModal-CnUUC79N.js
  72. 0 0
      static/assets/PrintModal-oBcvLlYd.js
  73. 0 0
      static/assets/PrinterLocationsPage-CGddh_4L.js
  74. 0 0
      static/assets/PrintersPage-BTvcUeq5.js
  75. 0 0
      static/assets/ProfilesPage-B1ZECMEn.js
  76. 0 0
      static/assets/ProjectDetailPage-5pebL_r1.js
  77. 0 0
      static/assets/ProjectsPage-BZ0fkgSi.js
  78. 0 0
      static/assets/QueryClientProvider-3QLKTZ0G.js
  79. 0 0
      static/assets/QueuePage-BVBOo3S0.js
  80. 0 0
      static/assets/ReferenceLine-C0tUR45U.js
  81. 0 0
      static/assets/RichTextEditor-DaiEYm3H.js
  82. 0 0
      static/assets/SettingsPage-CZlRSTOS.js
  83. 0 0
      static/assets/SettingsPage-tsvqDoR0.js
  84. 0 0
      static/assets/SetupPage-B3HNMSS-.js
  85. 0 0
      static/assets/SkipObjectsModal-DWgDnb6C.js
  86. 0 0
      static/assets/SliceModal-BjdA8nt3.js
  87. 0 0
      static/assets/SpoolBuddyAmsPage-jd6Tpq6t.js
  88. 0 0
      static/assets/SpoolBuddyDashboard-qxpQMp1v.js
  89. 0 0
      static/assets/SpoolBuddyInventoryPage-CXH0eKES.js
  90. 1 1
      static/assets/SpoolBuddySettingsPage-DBqGJylo.js
  91. 0 0
      static/assets/SpoolBuddyWriteTagPage-FRI8ts_m.js
  92. 1 1
      static/assets/SpoolIcon-CSEM0KL-.js
  93. 0 0
      static/assets/SpreadsheetPreviewModal-Cm0q8uJN.js
  94. 0 0
      static/assets/StatsPage-mDOIPIch.js
  95. 0 0
      static/assets/StreamOverlayPage-iGoO9ok4.js
  96. 0 0
      static/assets/SystemHealthPanel-Cy_UXuVv.js
  97. 0 0
      static/assets/SystemInfoPage-BLsq8Y6v.js
  98. 0 0
      static/assets/ThemeContext-DXpbaZJG.js
  99. 1 1
      static/assets/ThumbnailHoverPreview-Df78X31l.js
  100. 0 0
      static/assets/ToastContext-9c4_oWA_.js

+ 2 - 0
CHANGELOG.md

@@ -5,10 +5,12 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.3.1b1] - Unreleased
 ## [1.3.1b1] - Unreleased
 
 
 ### Added
 ### Added
+- **A script adds the virtual printer certificate to the slicers** — Bambu Studio and OrcaSlicer only connect to a virtual printer whose certificate is in their printer.cer file, and every slicer update or reinstall puts the original file back, so the certificate had to be added by hand again. The **Slicer certificate** card under Settings > Virtual Printer now downloads a script for **Windows** or **Linux / macOS** with this Bambuddy's certificate already in it. It finds Bambu Studio and OrcaSlicer, adds the certificate, and keeps the original file next to it. It changes nothing where the certificate is already there, and it removes an older certificate of the same Bambuddy, which would otherwise stop the slicer from connecting. On Windows it is a double-click: it asks for administrator rights, and it installs a task that adds the certificate again at every start, at every sign-in and once an hour, so a slicer update no longer breaks the connection (`/uninstall` removes the task). On Linux and macOS it runs with bash, and is run again after a slicer update. Wiki updated.
 - **WLED lights show the printer's status (#1528, requested and contributed by @d3nn3s08 in #3124)** — A WLED controller can now show what each printer is doing. Under **Settings > WLED**, pick a printer, enter its WLED address and assign one of the presets saved in WLED to each state: Idle, Preparing, Printing, Paused, Finished, Failed / Error, Awaiting plate clear, Filament problem, HMS error and Offline. Colours, effects and segments stay in WLED; Bambuddy only switches presets. A state without a preset falls back to the next one that has one (a filament runout without its own preset shows Paused). **Awaiting plate clear** applies only while the queue's **Require plate clear** setting holds the printer, and **Filament problem** only when the printer paused because the filament ran out. An optional timeout switches Finished back to Idle. The page can test the connection, load the presets from WLED and try each preset on the strip. If WLED is unreachable, Bambuddy retries every 30 seconds and logs the outage once. Several printers can share one controller when each preset is saved with "Checked segments only". The tab needs the `printers:update` permission.
 - **WLED lights show the printer's status (#1528, requested and contributed by @d3nn3s08 in #3124)** — A WLED controller can now show what each printer is doing. Under **Settings > WLED**, pick a printer, enter its WLED address and assign one of the presets saved in WLED to each state: Idle, Preparing, Printing, Paused, Finished, Failed / Error, Awaiting plate clear, Filament problem, HMS error and Offline. Colours, effects and segments stay in WLED; Bambuddy only switches presets. A state without a preset falls back to the next one that has one (a filament runout without its own preset shows Paused). **Awaiting plate clear** applies only while the queue's **Require plate clear** setting holds the printer, and **Filament problem** only when the printer paused because the filament ran out. An optional timeout switches Finished back to Idle. The page can test the connection, load the presets from WLED and try each preset on the strip. If WLED is unreachable, Bambuddy retries every 30 seconds and logs the outage once. Several printers can share one controller when each preset is saved with "Checked segments only". The tab needs the `printers:update` permission.
 - **Cam Wall tiles show temperatures and finish time, and can pause, stop and switch the light (#3479, sponsor vote Q4 2026)** — With the **Full** status overlay, every connected tile shows its nozzle (both on a dual-nozzle printer), bed and chamber temperatures, and a printing tile adds its finish time beside the time left. Hovering over a tile shows **Pause/Resume** and **Stop**, with the same confirmations as the printer card, and a **chamber light** switch; **Tile controls: Always** in the wall settings keeps them on screen, and a touch screen always shows them. The controls need the `printers:control` permission; a user without it, an offline printer and a Cam Wall token (kiosk) wall get none.
 - **Cam Wall tiles show temperatures and finish time, and can pause, stop and switch the light (#3479, sponsor vote Q4 2026)** — With the **Full** status overlay, every connected tile shows its nozzle (both on a dual-nozzle printer), bed and chamber temperatures, and a printing tile adds its finish time beside the time left. Hovering over a tile shows **Pause/Resume** and **Stop**, with the same confirmations as the printer card, and a **chamber light** switch; **Tile controls: Always** in the wall settings keeps them on screen, and a touch screen always shows them. The controls need the `printers:control` permission; a user without it, an offline printer and a Cam Wall token (kiosk) wall get none.
 
 
 ### Changed
 ### Changed
+- **A queue virtual printer shows where its jobs go, and its target can be cleared** — In Print Queue mode a virtual printer without a target printer sends each job to the next free printer of its model, but the card's target list only said "Select a printer...", and once a printer was picked there was no way back. The first entry now reads **Any available [model]** and switches the virtual printer back to that, and a line under the list says where the jobs go. The access code stays the same, so the slicer stays bound. In the other server modes the entry reads **None**; proxy mode still needs a printer. Wiki updated.
 - **Profile defaults is compact and has Apply all** — Settings → Filament → Profile defaults showed every default as a large box with all its presets and K profiles, so a few products already filled the page. Each default is now one line (product, colour, slicer preset, and how many printer presets and K profiles it has), sorted by product, with a search box; click a line to see its presets and K profiles. The list scrolls on its own. **Apply all** applies every default to the spools in stock in one go, and like Apply to existing spools it only fills in what a spool doesn't have yet. Wiki updated.
 - **Profile defaults is compact and has Apply all** — Settings → Filament → Profile defaults showed every default as a large box with all its presets and K profiles, so a few products already filled the page. Each default is now one line (product, colour, slicer preset, and how many printer presets and K profiles it has), sorted by product, with a search box; click a line to see its presets and K profiles. The list scrolls on its own. **Apply all** applies every default to the spools in stock in one go, and like Apply to existing spools it only fills in what a spool doesn't have yet. Wiki updated.
 - **Slicer sidecars on Bambu Studio 02.09.01.70 and OrcaSlicer 2.5.0 (#3442)** — The Bambu Studio sidecar now ships the H3C printer presets (0.2, 0.4, 0.6 and 0.8 mm nozzle). OrcaSlicer has no H3C presets yet. OrcaSlicer 2.5.0 ships its bundled presets in a new packed format, so the sidecar image now fetches the same release's presets as JSON for its preset handling. Slicing with a printer preset saved under a name of its own keeps working on 2.5.0; the sliced file then names the Bambu preset it was saved from as its printer preset. Sidecar images built for older slicer versions work as before.
 - **Slicer sidecars on Bambu Studio 02.09.01.70 and OrcaSlicer 2.5.0 (#3442)** — The Bambu Studio sidecar now ships the H3C printer presets (0.2, 0.4, 0.6 and 0.8 mm nozzle). OrcaSlicer has no H3C presets yet. OrcaSlicer 2.5.0 ships its bundled presets in a new packed format, so the sidecar image now fetches the same release's presets as JSON for its preset handling. Slicing with a printer preset saved under a name of its own keeps working on 2.5.0; the sliced file then names the Bambu preset it was saved from as its printer preset. Sidecar images built for older slicer versions work as before.
 - **"Any model" jobs go to the printer with the fewest print hours (#3480, sponsor vote Q4 2026)** — When several printers of a model can take a job, the queue used to pick the first one in its list, so on a farm the same printer got most of the work. It now picks the one with the fewest print hours (the Maintenance counter, including baseline hours entered there), so jobs and wear spread across the farm. Preferred colours still come first, and the queue powers on the least used printer too when it has to switch one on with an Auto Power On plug.
 - **"Any model" jobs go to the printer with the fewest print hours (#3480, sponsor vote Q4 2026)** — When several printers of a model can take a job, the queue used to pick the first one in its list, so on a farm the same printer got most of the work. It now picks the one with the fewest print hours (the Maintenance counter, including baseline hours entered there), so jobs and wear spread across the farm. Preferred colours still come first, and the queue powers on the least used printer too when it has to switch one on with an Auto Power On plug.

+ 35 - 1
backend/app/api/routes/virtual_printers.py

@@ -327,6 +327,34 @@ async def get_ca_certificate(
         return JSONResponse(status_code=500, content={"detail": "Could not generate the CA certificate"})
         return JSONResponse(status_code=500, content={"detail": "Could not generate the CA certificate"})
 
 
 
 
+@router.get("/ca-certificate/installer/{platform}")
+async def get_ca_certificate_installer(
+    platform: str,
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
+):
+    """Return a script that adds the CA to Bambu Studio and OrcaSlicer.
+
+    ``platform`` is ``windows`` (a .cmd) or ``unix`` (a .sh for Linux and
+    macOS). The CA is written into the script, which then needs no access to
+    Bambuddy. Returns ``{"filename", "content"}``.
+    """
+    from backend.app.services.virtual_printer import virtual_printer_manager
+    from backend.app.services.virtual_printer.slicer_cert_installer import (
+        INSTALLER_PLATFORMS,
+        build_slicer_cert_installer,
+    )
+
+    if platform not in INSTALLER_PLATFORMS:
+        return JSONResponse(status_code=404, content={"detail": f"Unknown platform '{platform}'"})
+    try:
+        info = 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"})
+    installer = build_slicer_cert_installer(platform, info["pem"], info["fingerprint_sha256"])
+    return {"filename": installer.filename, "content": installer.content}
+
+
 @router.get("/{vp_id}/diagnostic", response_model=VPDiagnosticResult)
 @router.get("/{vp_id}/diagnostic", response_model=VPDiagnosticResult)
 async def diagnose_virtual_printer(
 async def diagnose_virtual_printer(
     vp_id: int,
     vp_id: int,
@@ -431,7 +459,13 @@ async def update_virtual_printer(
         if body.access_code and len(body.access_code) != 8:
         if body.access_code and len(body.access_code) != 8:
             return JSONResponse(status_code=400, content={"detail": "Access code must be exactly 8 characters"})
             return JSONResponse(status_code=400, content={"detail": "Access code must be exactly 8 characters"})
         vp.access_code = body.access_code
         vp.access_code = body.access_code
-    if body.target_printer_id is not None:
+    if "target_printer_id" in body.model_fields_set and body.target_printer_id is None:
+        # An explicit null clears the target: a queue VP then sends its jobs
+        # to any printer of its model. The access code stays what it was, so
+        # a slicer already bound keeps working. A proxy VP needs a target, so
+        # the enable checks below turn it off.
+        vp.target_printer_id = None
+    elif body.target_printer_id is not None:
         from backend.app.models.printer import Printer
         from backend.app.models.printer import Printer
 
 
         result = await db.execute(select(Printer).where(Printer.id == body.target_printer_id))
         result = await db.execute(select(Printer).where(Printer.id == body.target_printer_id))

+ 292 - 0
backend/app/data/slicer_cert/bambuddy-slicer-cert.cmd

@@ -0,0 +1,292 @@
+@echo off
+rem Bambuddy: let Bambu Studio and OrcaSlicer connect to this Bambuddy's
+rem virtual printers. Double-click this file and confirm the administrator
+rem prompt. It adds this Bambuddy's certificate to the slicers' printer.cer,
+rem and installs a task that adds it again after a slicer update.
+rem
+rem   bambuddy-slicer-cert.cmd /uninstall   removes that task again
+rem
+rem The PowerShell code below the marker line does the work.
+setlocal
+set "BAMBUDDY_SELF=%~f0"
+set "BAMBUDDY_ARGS=%*"
+rem One line, so cmd.exe has read it all before PowerShell runs: /uninstall
+rem deletes the installed copy of this file.
+powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "$s = [IO.File]::ReadAllText($env:BAMBUDDY_SELF); $m = '#BAMBUDDY' + '_POWERSHELL'; Invoke-Expression $s.Substring($s.IndexOf($m))" && exit /b 0 || exit /b 1
+#BAMBUDDY_POWERSHELL
+# Made by Bambuddy for the certificate with SHA-256 fingerprint
+# __BAMBUDDY_CA_FINGERPRINT__
+# Written for Windows PowerShell 5.1, which every Windows 10 and 11 has.
+
+$ErrorActionPreference = 'Stop'
+
+$CaPem = @'
+__BAMBUDDY_CA_PEM__
+'@
+
+$Fingerprint = '__BAMBUDDY_CA_FINGERPRINT__'
+$Id = ($Fingerprint -replace ':', '').Substring(0, 8)
+$TaskName = "Bambuddy slicer certificate $Id"
+# Under Program Files, where only administrators can create or change
+# anything: the task runs the copy kept there as SYSTEM.
+$InstallRoot = if ($env:ProgramFiles) { Join-Path $env:ProgramFiles 'Bambuddy slicer certificate' } else { $null }
+$InstallDir = if ($InstallRoot) { Join-Path $InstallRoot $Id } else { $null }
+$Arguments = " $env:BAMBUDDY_ARGS ".ToLowerInvariant()
+$Quiet = $Arguments.Contains(' /quiet ')
+$Uninstall = $Arguments.Contains(' /uninstall ')
+# Only for Bambuddy's own tests: look for printer.cer under these folders,
+# and do nothing that needs Windows (administrator rights, the task).
+$TestRoots = $env:BAMBUDDY_CERT_TEST_ROOTS
+$SystemBases = @($env:ProgramFiles, ${env:ProgramFiles(x86)}, $env:ProgramW6432) | Where-Object { $_ } |
+    ForEach-Object { [IO.Path]::GetFullPath($_).TrimEnd('\') + '\' } | Sort-Object -Unique
+
+function Write-Line([string]$Text) {
+    if ($Quiet) {
+        $script:Log += $Text + "`r`n"
+    } else {
+        Write-Host $Text
+    }
+}
+
+function Get-CertBodies([string]$Text) {
+    # The base64 text of each certificate, without line breaks: the same
+    # certificate gives the same string however the file wraps its lines.
+    $pattern = '-----BEGIN CERTIFICATE-----([A-Za-z0-9+/=\s]+?)-----END CERTIFICATE-----'
+    foreach ($match in [regex]::Matches($Text, $pattern)) {
+        [pscustomobject]@{
+            Body  = ($match.Groups[1].Value -replace '\s', '')
+            Match = $match
+        }
+    }
+}
+
+function Get-Subject([string]$Body) {
+    try {
+        $cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2(, [Convert]::FromBase64String($Body))
+        return $cert.Subject
+    } catch {
+        return $null
+    }
+}
+
+function Find-CertFiles {
+    if ($TestRoots) {
+        foreach ($root in $TestRoots.Split(';')) {
+            Get-ChildItem -LiteralPath $root -Recurse -Filter printer.cer -File -ErrorAction SilentlyContinue |
+                ForEach-Object { $_.FullName }
+        }
+        return
+    }
+    # Only administrators can write below Program Files. The task runs as
+    # SYSTEM and must never write where a user could plant a link, so it
+    # changes nothing else; a run by hand also covers other install folders.
+    $folders = @()
+    foreach ($base in $SystemBases) {
+        $folders += Join-Path $base 'Bambu Studio'
+        $folders += Join-Path $base 'OrcaSlicer'
+    }
+    # Installed somewhere else: the uninstall entries say where.
+    $keys = @(
+        'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*',
+        'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*'
+    )
+    if (-not $Quiet) {
+        $keys += 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*'
+    }
+    foreach ($key in $keys) {
+        Get-ItemProperty -Path $key -ErrorAction SilentlyContinue |
+            Where-Object { $_.PSObject.Properties['DisplayName'] -and $_.DisplayName -match 'Bambu ?Studio|Orca ?Slicer' } |
+            ForEach-Object {
+                if ($_.PSObject.Properties['InstallLocation'] -and $_.InstallLocation) {
+                    $folders += $_.InstallLocation.Trim('"')
+                }
+                if ($_.PSObject.Properties['DisplayIcon'] -and $_.DisplayIcon) {
+                    $folders += Split-Path -Parent (($_.DisplayIcon -split ',')[0].Trim('"'))
+                }
+            }
+    }
+    if (-not $Quiet -and $env:LOCALAPPDATA) {
+        # Installed for this user only.
+        Get-ChildItem -Path (Join-Path $env:LOCALAPPDATA 'Programs') -Directory -ErrorAction SilentlyContinue |
+            Where-Object { $_.Name -match 'Bambu ?Studio|Orca ?Slicer' } |
+            ForEach-Object { $folders += $_.FullName }
+    }
+
+    $folders | Where-Object { $_ } | ForEach-Object {
+        Join-Path $_ 'resources\cert\printer.cer'
+    } | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } |
+        ForEach-Object { [IO.Path]::GetFullPath((Resolve-Path -LiteralPath $_).ProviderPath) } |
+        Where-Object {
+            $path = $_
+            -not $Quiet -or (Test-UnderProgramFiles $path)
+        } |
+        Sort-Object -Unique
+}
+
+function Test-UnderProgramFiles([string]$Path) {
+    return [bool]($SystemBases | Where-Object { $Path.StartsWith($_, [StringComparison]::OrdinalIgnoreCase) })
+}
+
+function Test-Link([string]$Path) {
+    # True when the file or a folder above it is a link (symbolic link,
+    # junction): writing through it could change a file somewhere else.
+    $item = Get-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue
+    while ($item) {
+        if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { return $true }
+        $item = if ($item -is [IO.FileInfo]) { $item.Directory } else { $item.Parent }
+    }
+    return $false
+}
+
+function Update-CertFile([string]$Path) {
+    $ours = (Get-CertBodies $CaPem | Select-Object -First 1).Body
+    if (-not $TestRoots -and (Test-Link $Path)) {
+        Write-Line "  skipped, the path contains a link: $Path"
+        return $true
+    }
+    $text = [IO.File]::ReadAllText($Path)
+    $certs = @(Get-CertBodies $text)
+    if ($certs | Where-Object { $_.Body -eq $ours }) {
+        Write-Line "  already there: $Path"
+        return $true
+    }
+    try {
+        # A slicer uses the first certificate with the right name, so an
+        # earlier issue of this CA (same name, same key, other dates) would
+        # hide this one: it goes. Not the bare legacy name: older Bambuddy
+        # installs all share it, and it may be another one's.
+        $subject = Get-Subject $ours
+        if ($subject -eq 'CN=Virtual Printer CA') { $subject = $null }
+        $replaced = $false
+        $newText = $text
+        foreach ($cert in ($certs | Sort-Object { $_.Match.Index } -Descending)) {
+            if ($subject -and (Get-Subject $cert.Body) -eq $subject) {
+                $newText = $newText.Remove($cert.Match.Index, $cert.Match.Length)
+                $replaced = $true
+            }
+        }
+        $newline = if ($text.Contains("`r`n")) { "`r`n" } else { "`n" }
+        $newText = $newText.TrimEnd() + $newline + ($CaPem.Trim() -replace "`r?`n", $newline) + $newline
+
+        $backup = "$Path.before-bambuddy"
+        if (-not (Test-Path -LiteralPath $backup)) {
+            Copy-Item -LiteralPath $Path -Destination $backup
+        }
+        # Written next to the file and then moved over it, so the slicer
+        # never sees half a file.
+        $tmp = "$Path.bambuddy-new"
+        [IO.File]::WriteAllText($tmp, $newText, (New-Object System.Text.UTF8Encoding($false)))
+        try {
+            # One step on NTFS, and keeps the file's permissions.
+            # [NullString]: PowerShell would pass $null as "", which Replace refuses.
+            [IO.File]::Replace($tmp, $Path, [NullString]::Value)
+        } catch {
+            Move-Item -LiteralPath $tmp -Destination $Path -Force
+        }
+        if ($replaced) {
+            Write-Line "  replaced an older Bambuddy certificate: $Path"
+        } else {
+            Write-Line "  added: $Path"
+        }
+        return $true
+    } catch {
+        Write-Line "  FAILED: $Path ($($_.Exception.Message))"
+        return $false
+    }
+}
+
+function Test-Admin {
+    $identity = [Security.Principal.WindowsIdentity]::GetCurrent()
+    return (New-Object Security.Principal.WindowsPrincipal($identity)).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
+}
+
+function Install-Task {
+    # The task runs this copy as SYSTEM. It lives under Program Files,
+    # whose permissions it inherits: only administrators can change it.
+    New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
+    if (Test-Link $InstallDir) { throw "$InstallDir contains a link" }
+    $copy = Join-Path $InstallDir 'bambuddy-slicer-cert.cmd'
+    if ([IO.Path]::GetFullPath($env:BAMBUDDY_SELF) -ne [IO.Path]::GetFullPath($copy)) {
+        Copy-Item -LiteralPath $env:BAMBUDDY_SELF -Destination $copy -Force
+    }
+
+    $action = New-ScheduledTaskAction -Execute 'cmd.exe' -Argument "/c `"`"$copy`" /quiet`""
+    $triggers = @(
+        (New-ScheduledTaskTrigger -AtStartup),
+        (New-ScheduledTaskTrigger -AtLogOn),
+        (New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(5) -RepetitionInterval (New-TimeSpan -Hours 1) -RepetitionDuration (New-TimeSpan -Days 3650))
+    )
+    $principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest
+    $settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Minutes 5)
+    Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $triggers -Principal $principal -Settings $settings `
+        -Description 'Adds the Bambuddy certificate to Bambu Studio and OrcaSlicer again after a slicer update.' -Force | Out-Null
+}
+
+$script:Log = ''
+$exitCode = 0
+try {
+    if (-not $TestRoots -and -not (Test-Admin)) {
+        Write-Host 'Bambuddy needs administrator rights to change the slicers. Please confirm the prompt.'
+        $elevated = @{ FilePath = $env:BAMBUDDY_SELF; Verb = 'RunAs' }
+        if ($env:BAMBUDDY_ARGS) { $elevated.ArgumentList = $env:BAMBUDDY_ARGS }
+        Start-Process @elevated
+        exit 0
+    }
+
+    if ($Uninstall) {
+        Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue
+        Remove-Item -LiteralPath $InstallDir -Recurse -Force -ErrorAction SilentlyContinue
+        if (-not (Get-ChildItem -LiteralPath $InstallRoot -Force -ErrorAction SilentlyContinue)) {
+            Remove-Item -LiteralPath $InstallRoot -Force -ErrorAction SilentlyContinue
+        }
+        Write-Line 'The automatic check is removed. The certificate stays in the slicers.'
+    } else {
+        Write-Line 'Bambuddy slicer certificate'
+        Write-Line ''
+        $files = @(Find-CertFiles)
+        $failed = $false
+        foreach ($file in $files) {
+            if (-not (Update-CertFile $file)) { $failed = $true }
+        }
+        Write-Line ''
+        if ($files.Count -eq 0) {
+            Write-Line 'No Bambu Studio or OrcaSlicer installation found.'
+            $exitCode = 2
+        } elseif ($failed) {
+            Write-Line 'Not every slicer could be updated, see above.'
+            $exitCode = 1
+        } elseif (-not $TestRoots -and (Get-Process -Name 'bambu-studio', 'orca-slicer' -ErrorAction SilentlyContinue)) {
+            Write-Line 'Done. A slicer is running: close it completely and start it again.'
+        } else {
+            Write-Line 'Done. The slicer uses the certificate the next time it starts.'
+        }
+
+        if (-not $Quiet -and -not $TestRoots) {
+            Install-Task
+            Write-Line ''
+            Write-Line 'Windows now checks this every hour and at every start, and adds the'
+            Write-Line 'certificate again after a slicer update. To stop that, run this file'
+            Write-Line 'with /uninstall.'
+            $elsewhere = @($files | Where-Object { -not (Test-UnderProgramFiles $_) })
+            if ($elsewhere.Count -gt 0) {
+                Write-Line ''
+                Write-Line 'The automatic check only changes slicers under Program Files. Run this'
+                Write-Line 'file again after updating the slicer in:'
+                foreach ($file in $elsewhere) { Write-Line "  $file" }
+            }
+        }
+    }
+} catch {
+    Write-Line "Error: $($_.Exception.Message)"
+    $exitCode = 1
+}
+
+if ($Quiet -and -not $TestRoots) {
+    try {
+        [IO.File]::WriteAllText((Join-Path $InstallDir 'last-run.log'), ((Get-Date).ToString('s') + "`r`n" + $script:Log))
+    } catch { }
+}
+if (-not $Quiet -and -not $TestRoots) {
+    Read-Host 'Press Enter to close' | Out-Null
+}
+exit $exitCode

+ 216 - 0
backend/app/data/slicer_cert/bambuddy-slicer-cert.sh

@@ -0,0 +1,216 @@
+#!/usr/bin/env bash
+# Bambuddy: let Bambu Studio and OrcaSlicer connect to this Bambuddy's virtual
+# printers.
+#
+# The slicers only talk to printers whose certificate is listed in their file
+# printer.cer. This script adds this Bambuddy's certificate to every
+# printer.cer it finds (Linux and macOS), keeps a copy of the original next to
+# it (printer.cer.before-bambuddy), and changes nothing where the certificate
+# is already there. Slicer updates and reinstalls replace printer.cer, so run
+# it again after one.
+#
+#   bash bambuddy-slicer-cert.sh
+#
+# It asks for your password (sudo) when a slicer is installed system-wide.
+#
+# Made by Bambuddy for the certificate with SHA-256 fingerprint
+# __BAMBUDDY_CA_FINGERPRINT__
+
+set -u
+
+CA_PEM=$(
+	cat <<'BAMBUDDY_CA_PEM'
+__BAMBUDDY_CA_PEM__
+BAMBUDDY_CA_PEM
+)
+
+# Only for Bambuddy's own tests: look for printer.cer under these directories
+# instead of the usual places, and never use sudo.
+TEST_ROOTS=${BAMBUDDY_CERT_TEST_ROOTS:-}
+
+# Started with "sudo bash ...": look in the home folder of whoever ran sudo,
+# not root's, for Flatpaks and extracted AppImages.
+if [ -z "$TEST_ROOTS" ] && [ "$(id -u)" -eq 0 ] && [ -n "${SUDO_USER:-}" ]; then
+	case "$SUDO_USER" in
+	*[!A-Za-z0-9._-]*) ;;
+	*) HOME=$(eval echo "~$SUDO_USER") ;;
+	esac
+fi
+
+# The base64 text of each certificate in a PEM file, one per line, without
+# line breaks: the same certificate gives the same line, however the file
+# wraps or ends its lines.
+cert_bodies() {
+	tr -d '\r' <"$1" | awk '
+		/-----BEGIN CERTIFICATE-----/ { inside = 1; body = ""; next }
+		/-----END CERTIFICATE-----/ { if (inside) print body; inside = 0; next }
+		inside { gsub(/[ \t]/, ""); body = body $0 }
+	'
+}
+
+# The PEM text of certificate number $2 (from 1) in file $1.
+cert_block() {
+	tr -d '\r' <"$1" | awk -v want="$2" '
+		/-----BEGIN CERTIFICATE-----/ { n++ }
+		n == want && !done { print }
+		n == want && /-----END CERTIFICATE-----/ { done = 1 }
+	'
+}
+
+cert_subject() {
+	openssl x509 -noout -subject -nameopt RFC2253 2>/dev/null
+}
+
+find_cert_files() {
+	if [ -n "$TEST_ROOTS" ]; then
+		for root in $TEST_ROOTS; do
+			find "$root" -type f -name printer.cer 2>/dev/null
+		done
+		return
+	fi
+	if [ "$(uname -s)" = "Darwin" ]; then
+		for apps in /Applications "$HOME/Applications"; do
+			[ -d "$apps" ] || continue
+			find "$apps" -maxdepth 1 -iname '*.app' \( -iname '*bambu*' -o -iname '*orca*' \) 2>/dev/null |
+				while IFS= read -r app; do
+					[ -f "$app/Contents/Resources/cert/printer.cer" ] && printf '%s\n' "$app/Contents/Resources/cert/printer.cer"
+				done
+		done
+		return
+	fi
+	# Packages (/usr, /opt), Flatpak (system and user), and AppImages
+	# extracted into the home folder (squashfs-root).
+	for root in /usr/share /usr/local/share /usr/lib /opt /var/lib/flatpak/app "$HOME/.local/share/flatpak/app"; do
+		[ -d "$root" ] || continue
+		# A Flatpak's file sits 10 levels down
+		# (<id>/x86_64/stable/<commit>/files/share/<slicer>/resources/cert).
+		find "$root" -maxdepth 12 -type f -name printer.cer \( -ipath '*bambu*' -o -ipath '*orca*' \) 2>/dev/null
+	done
+	# An extracted AppImage is a folder squashfs-root, wherever it was
+	# extracted; it is a slicer's when it holds the slicer's program.
+	find "$HOME" -maxdepth 5 -type d -name squashfs-root 2>/dev/null |
+		while IFS= read -r extracted; do
+			if [ -n "$(find "$extracted" -maxdepth 2 \( -iname '*bambu*' -o -iname '*orca*' \) 2>/dev/null | head -n 1)" ]; then
+				find "$extracted" -maxdepth 6 -type f -name printer.cer 2>/dev/null
+			fi
+		done
+}
+
+file_mode() {
+	stat -c '%a' "$1" 2>/dev/null || stat -f '%Lp' "$1" 2>/dev/null || echo 644
+}
+
+# Adds the certificate to one printer.cer. Prints one line saying what it did.
+# Returns 0 when the file has the certificate afterwards, 1 when it failed.
+update_file() {
+	local file=$1 dir sudo="" ours count i block subject drop="" tmp mode
+	dir=$(dirname "$file")
+	ours=$(printf '%s\n' "$CA_PEM" | awk '/BEGIN/{i=1;next} /END/{i=0} i{gsub(/[ \t\r]/,"");b=b $0} END{print b}')
+
+	if cert_bodies "$file" | grep -qxF "$ours"; then
+		echo "  already there: $file"
+		return 0
+	fi
+
+	# A slicer uses the first certificate with the right name, so an earlier
+	# issue of this CA (same name, same key, other dates) would hide this
+	# one: it goes. Not the bare legacy name: older Bambuddy installs all
+	# share it, and it may be another one's.
+	if command -v openssl >/dev/null 2>&1; then
+		subject=$(printf '%s\n' "$CA_PEM" | cert_subject)
+		[ "$subject" = "subject=CN=Virtual Printer CA" ] && subject=""
+		count=$(cert_bodies "$file" | wc -l | tr -d ' ')
+		i=1
+		while [ "$i" -le "$count" ]; do
+			block=$(cert_block "$file" "$i")
+			if [ -n "$subject" ] && [ "$(printf '%s\n' "$block" | cert_subject)" = "$subject" ]; then
+				drop="$drop $i"
+			fi
+			i=$((i + 1))
+		done
+	fi
+
+	if [ -z "$TEST_ROOTS" ] && [ ! -w "$dir" ]; then
+		sudo="sudo"
+	fi
+
+	tmp=$(mktemp "${TMPDIR:-/tmp}/bambuddy-cert.XXXXXX") || return 1
+	tr -d '\r' <"$file" | awk -v drop="$drop " '
+		/-----BEGIN CERTIFICATE-----/ { n++; skip = index(drop, " " n " ") > 0 }
+		!skip { print }
+		skip && /-----END CERTIFICATE-----/ { skip = 0 }
+	' >"$tmp"
+	printf '%s\n' "$CA_PEM" >>"$tmp"
+	mode=$(file_mode "$file")
+
+	# Written next to the file and then moved over it, so the slicer never
+	# sees half a file, and a Flatpak's shared copy is not changed in place.
+	if { [ -e "$file.before-bambuddy" ] || $sudo cp -p "$file" "$file.before-bambuddy"; } &&
+		$sudo cp "$tmp" "$file.bambuddy-new" &&
+		$sudo chmod "$mode" "$file.bambuddy-new" &&
+		$sudo mv -f "$file.bambuddy-new" "$file"; then
+		rm -f "$tmp"
+		if [ -n "$drop" ]; then
+			echo "  replaced an older Bambuddy certificate: $file"
+		else
+			echo "  added: $file"
+		fi
+		return 0
+	fi
+	rm -f "$tmp"
+	$sudo rm -f "$file.bambuddy-new" 2>/dev/null
+	echo "  FAILED: $file"
+	if [ "$(uname -s)" = "Darwin" ]; then
+		echo "    macOS may block changes to apps: allow your terminal under"
+		echo "    System Settings > Privacy & Security > App Management, then run this again."
+	fi
+	return 1
+}
+
+main() {
+	local files failed=0 found=0 file
+	echo "Bambuddy slicer certificate"
+	echo
+
+	files=$(find_cert_files | sort -u)
+	if [ -n "$files" ]; then
+		while IFS= read -r file; do
+			found=1
+			update_file "$file" || failed=1
+		done <<EOF
+$files
+EOF
+	fi
+
+	if [ -z "$TEST_ROOTS" ] && [ "$(uname -s)" != "Darwin" ]; then
+		appimages=$(find "$HOME" -maxdepth 3 -type f \( -iname '*bambu*.appimage' -o -iname '*orca*.appimage' \) 2>/dev/null)
+		if [ -n "$appimages" ]; then
+			echo
+			echo "An AppImage can't be changed. To use one with Bambuddy, extract it once"
+			echo "and start the slicer from the extracted copy:"
+			echo "  ./<slicer>.AppImage --appimage-extract    (creates squashfs-root)"
+			echo "  bash $0                                    (run this script again)"
+			echo "  ./squashfs-root/AppRun                     (start the slicer like this from now on)"
+			echo "Found: $(printf '%s' "$appimages" | tr '\n' ' ')"
+		fi
+	fi
+
+	echo
+	if [ "$found" -eq 0 ]; then
+		echo "No Bambu Studio or OrcaSlicer installation found."
+		echo "See the Bambuddy wiki (Virtual Printer > Certificate Installation) to add it by hand."
+		return 2
+	fi
+	if [ "$failed" -ne 0 ]; then
+		echo "Not every slicer could be updated, see above."
+		return 1
+	fi
+	if [ -z "$TEST_ROOTS" ] && pgrep -if 'bambu.?studio|orca.?slicer' >/dev/null 2>&1; then
+		echo "Done. A slicer is running: quit it completely and start it again."
+	else
+		echo "Done. The slicer uses the certificate the next time it starts."
+	fi
+	return 0
+}
+
+main "$@"

+ 42 - 0
backend/app/services/virtual_printer/slicer_cert_installer.py

@@ -0,0 +1,42 @@
+"""Scripts that add the virtual-printer CA to the slicers' printer.cer.
+
+Bambu Studio and OrcaSlicer trust only the certificates in their own
+``printer.cer``, and every slicer update or reinstall puts the stock file
+back. Settings -> Virtual Printer offers these scripts for download with this
+instance's CA written into them, so they need no API key and no connection to
+Bambuddy: one for Windows (a ``.cmd`` that elevates itself and installs a task
+that re-checks after updates) and one for Linux and macOS.
+"""
+
+from dataclasses import dataclass
+from pathlib import Path
+
+_TEMPLATES = Path(__file__).resolve().parents[2] / "data" / "slicer_cert"
+
+# platform -> template file, which is also the download's file name.
+_PLATFORMS = {
+    "windows": "bambuddy-slicer-cert.cmd",
+    "unix": "bambuddy-slicer-cert.sh",
+}
+
+INSTALLER_PLATFORMS = tuple(_PLATFORMS)
+
+
+@dataclass(frozen=True)
+class SlicerCertInstaller:
+    filename: str
+    content: str
+
+
+def build_slicer_cert_installer(platform: str, pem: str, fingerprint: str) -> SlicerCertInstaller:
+    """Return the script for ``platform`` with the CA in ``pem`` written into it.
+
+    Raises ``KeyError`` for an unknown platform.
+    """
+    filename = _PLATFORMS[platform]
+    template = (_TEMPLATES / filename).read_text(encoding="utf-8")
+    content = template.replace("__BAMBUDDY_CA_PEM__", pem.strip()).replace("__BAMBUDDY_CA_FINGERPRINT__", fingerprint)
+    if filename.endswith(".cmd"):
+        # cmd.exe misreads a batch file with bare LF line ends.
+        content = content.replace("\r\n", "\n").replace("\n", "\r\n")
+    return SlicerCertInstaller(filename=filename, content=content)

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

@@ -408,6 +408,30 @@ class TestVirtualPrinterCaCertificateAPI:
         # A 200 (not 422 from int-parsing "ca-certificate") proves route ordering.
         # A 200 (not 422 from int-parsing "ca-certificate") proves route ordering.
         assert response.status_code == 200
         assert response.status_code == 200
 
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    @pytest.mark.parametrize(("platform", "filename"), [("windows", ".cmd"), ("unix", ".sh")])
+    async def test_installer_carries_this_instance_ca(self, async_client: AsyncClient, platform, filename):
+        ca = (await async_client.get("/api/v1/virtual-printers/ca-certificate")).json()
+
+        response = await async_client.get(f"/api/v1/virtual-printers/ca-certificate/installer/{platform}")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["filename"].endswith(filename)
+        body = ca["pem"].strip().splitlines()[1]
+        assert body in result["content"]
+        assert ca["fingerprint_sha256"] in result["content"]
+        assert "__BAMBUDDY_" not in result["content"]
+        assert "PRIVATE KEY" not in result["content"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_installer_for_an_unknown_platform_is_404(self, async_client: AsyncClient):
+        response = await async_client.get("/api/v1/virtual-printers/ca-certificate/installer/amiga")
+
+        assert response.status_code == 404
+
 
 
 class TestVirtualPrinterDiagnosticAPI:
 class TestVirtualPrinterDiagnosticAPI:
     """Integration tests for GET /api/v1/virtual-printers/{vp_id}/diagnostic."""
     """Integration tests for GET /api/v1/virtual-printers/{vp_id}/diagnostic."""
@@ -586,6 +610,67 @@ class TestVirtualPrinterAccessCodeInheritance:
         assert vp.access_code == "REALCODE"
         assert vp.access_code == "REALCODE"
 
 
 
 
+class TestVirtualPrinterClearTarget:
+    """A null target_printer_id clears the target (queue VP: any printer of
+    its model); leaving the field out keeps it."""
+
+    @staticmethod
+    async def _queue_vp(async_client: AsyncClient, target_id: int, **extra) -> int:
+        response = await async_client.post(
+            "/api/v1/virtual-printers",
+            json={"name": "QueueVP", "mode": "queue", "target_printer_id": target_id, **extra},
+        )
+        assert response.status_code == 200
+        return response.json()["id"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_null_clears_the_target_and_keeps_the_code(self, async_client: AsyncClient, printer_factory):
+        target = await printer_factory(name="Real X1C", access_code="REALCODE")
+        vp_id = await self._queue_vp(async_client, target.id)
+
+        response = await async_client.put(f"/api/v1/virtual-printers/{vp_id}", json={"target_printer_id": None})
+
+        assert response.status_code == 200
+        assert response.json()["target_printer_id"] is None
+        assert response.json()["access_code_set"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_leaving_the_field_out_keeps_the_target(self, async_client: AsyncClient, printer_factory):
+        target = await printer_factory(name="Real X1C", access_code="REALCODE")
+        vp_id = await self._queue_vp(async_client, target.id)
+
+        response = await async_client.put(f"/api/v1/virtual-printers/{vp_id}", json={"auto_dispatch": False})
+
+        assert response.status_code == 200
+        assert response.json()["target_printer_id"] == target.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_clearing_a_proxy_target_turns_the_proxy_off(self, async_client: AsyncClient, printer_factory):
+        target = await printer_factory(name="Real X1C", access_code="REALCODE")
+        response = await async_client.post(
+            "/api/v1/virtual-printers",
+            json={
+                "name": "ProxyVP",
+                "mode": "proxy",
+                "target_printer_id": target.id,
+                "bind_ip": "192.168.1.52",
+                "enabled": True,
+            },
+        )
+        assert response.status_code == 200
+        vp_id = response.json()["id"]
+
+        with patch("backend.app.services.virtual_printer.virtual_printer_manager.sync_from_db", AsyncMock()):
+            response = await async_client.put(f"/api/v1/virtual-printers/{vp_id}", json={"target_printer_id": None})
+
+        assert response.status_code == 200
+        assert response.json()["target_printer_id"] is None
+        assert response.json()["enabled"] is False
+
+
 class TestVirtualPrinterSerialSurface:
 class TestVirtualPrinterSerialSurface:
     """Proxy-mode VPs must surface the target printer's serial in API responses.
     """Proxy-mode VPs must surface the target printer's serial in API responses.
 
 

+ 206 - 0
backend/tests/unit/services/test_slicer_cert_installer.py

@@ -0,0 +1,206 @@
+"""The slicer certificate scripts (Settings -> Virtual Printer).
+
+The bash script runs for real against a folder laid out like a slicer
+install. The PowerShell part of the Windows script runs the same way when
+``pwsh`` is installed (GitHub's Ubuntu runners have it); what needs Windows
+itself (administrator rights, the scheduled task) stays out of the test run.
+"""
+
+import datetime
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+from cryptography import x509
+from cryptography.hazmat.primitives import hashes, serialization
+from cryptography.hazmat.primitives.asymmetric import ec
+from cryptography.x509.oid import NameOID
+
+from backend.app.services.virtual_printer.slicer_cert_installer import build_slicer_cert_installer
+
+CA_NAME = "Virtual Printer CA TEST1234"
+FINGERPRINT = ":".join(["AB", "CD", "EF", "01"] * 8)
+
+
+def _make_cert(common_name: str) -> str:
+    key = ec.generate_private_key(ec.SECP256R1())
+    name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, common_name)])
+    now = datetime.datetime.now(datetime.timezone.utc)
+    cert = (
+        x509.CertificateBuilder()
+        .subject_name(name)
+        .issuer_name(name)
+        .public_key(key.public_key())
+        .serial_number(x509.random_serial_number())
+        .not_valid_before(now)
+        .not_valid_after(now + datetime.timedelta(days=30))
+        .sign(key, hashes.SHA256())
+    )
+    return cert.public_bytes(serialization.Encoding.PEM).decode("ascii")
+
+
+def _body(pem: str) -> str:
+    return "".join(line for line in pem.strip().splitlines() if "-----" not in line)
+
+
+def _bodies(path: Path) -> list[str]:
+    text = path.read_text().replace("\r", "")
+    return [_body(chunk) for chunk in text.split("-----BEGIN CERTIFICATE-----")[1:]]
+
+
+@pytest.fixture(scope="module")
+def certs() -> dict[str, str]:
+    return {
+        "ours": _make_cert(CA_NAME),
+        "ours_before_reset": _make_cert(CA_NAME),
+        "stock1": _make_cert("BBL CA"),
+        "stock2": _make_cert("BBL CA2 RSA"),
+    }
+
+
+def _slicer(root: Path, name: str, content: str) -> Path:
+    path = root / name / "resources" / "cert" / "printer.cer"
+    path.parent.mkdir(parents=True)
+    path.write_text(content, newline="")
+    return path
+
+
+class TestBuild:
+    def test_windows_script_has_the_ca_and_crlf_line_ends(self, certs):
+        installer = build_slicer_cert_installer("windows", certs["ours"], FINGERPRINT)
+
+        assert installer.filename == "bambuddy-slicer-cert.cmd"
+        assert _body(certs["ours"])[:60] in installer.content
+        assert FINGERPRINT in installer.content
+        assert "__BAMBUDDY_" not in installer.content
+        assert "\n" not in installer.content.replace("\r\n", "")
+
+    def test_unix_script_has_the_ca_and_lf_line_ends(self, certs):
+        installer = build_slicer_cert_installer("unix", certs["ours"], FINGERPRINT)
+
+        assert installer.filename == "bambuddy-slicer-cert.sh"
+        assert _body(certs["ours"])[:60] in installer.content
+        assert "__BAMBUDDY_" not in installer.content
+        assert "\r" not in installer.content
+
+    def test_unknown_platform(self, certs):
+        with pytest.raises(KeyError):
+            build_slicer_cert_installer("amiga", certs["ours"], FINGERPRINT)
+
+
+def _runner(kind: str, tmp_path: Path, pem: str):
+    """Return a function that runs the script once over ``tmp_path / 'apps'``."""
+    content = build_slicer_cert_installer(kind, pem, FINGERPRINT).content
+    if kind == "unix":
+        script = tmp_path / "bambuddy-slicer-cert.sh"
+        script.write_text(content)
+        cmd = ["bash", str(script)]
+        separator = " "
+    else:
+        # The .cmd wrapper only runs on Windows; the PowerShell part after
+        # its marker is what does the work.
+        script = tmp_path / "bambuddy-slicer-cert.ps1"
+        script.write_text(content[content.index("#BAMBUDDY_POWERSHELL") :], newline="")
+        cmd = [shutil.which("pwsh") or "pwsh", "-NoProfile", "-NonInteractive", "-File", str(script)]
+        separator = ";"
+
+    def run(*roots: Path) -> subprocess.CompletedProcess:
+        return subprocess.run(
+            cmd,
+            capture_output=True,
+            text=True,
+            timeout=120,
+            env={
+                "PATH": "/usr/bin:/bin:/usr/local/bin",
+                "HOME": str(tmp_path),
+                "BAMBUDDY_CERT_TEST_ROOTS": separator.join(map(str, roots)),
+            },
+        )
+
+    return run
+
+
+KINDS = [
+    pytest.param(
+        "unix",
+        marks=pytest.mark.skipif(
+            not (shutil.which("bash") and shutil.which("openssl")), reason="needs bash and openssl"
+        ),
+    ),
+    pytest.param("windows", marks=pytest.mark.skipif(not shutil.which("pwsh"), reason="needs pwsh")),
+]
+
+
+@pytest.mark.parametrize("kind", KINDS)
+class TestScripts:
+    def test_adds_the_ca_keeps_the_rest_and_a_backup(self, kind, tmp_path, certs):
+        apps = tmp_path / "apps"
+        stock = certs["stock1"] + certs["stock2"]
+        studio = _slicer(apps, "Bambu Studio", stock)
+        orca = _slicer(apps, "OrcaSlicer", stock)
+
+        result = _runner(kind, tmp_path, certs["ours"])(apps)
+
+        assert result.returncode == 0, result.stdout + result.stderr
+        for path in (studio, orca):
+            assert _bodies(path) == [_body(certs["stock1"]), _body(certs["stock2"]), _body(certs["ours"])]
+            assert Path(f"{path}.before-bambuddy").read_text() == stock
+            assert not Path(f"{path}.bambuddy-new").exists()
+        assert "added" in result.stdout
+
+    def test_a_second_run_changes_nothing(self, kind, tmp_path, certs):
+        apps = tmp_path / "apps"
+        studio = _slicer(apps, "Bambu Studio", certs["stock1"])
+        run = _runner(kind, tmp_path, certs["ours"])
+        run(apps)
+        after_first = studio.read_bytes()
+
+        result = run(apps)
+
+        assert result.returncode == 0, result.stdout + result.stderr
+        assert studio.read_bytes() == after_first
+        assert "already there" in result.stdout
+
+    def test_an_older_certificate_of_this_bambuddy_is_replaced(self, kind, tmp_path, certs):
+        # Same name, other key: a slicer would take the first and fail.
+        apps = tmp_path / "apps"
+        studio = _slicer(apps, "Bambu Studio", certs["stock1"] + certs["ours_before_reset"] + certs["stock2"])
+
+        result = _runner(kind, tmp_path, certs["ours"])(apps)
+
+        assert result.returncode == 0, result.stdout + result.stderr
+        assert _bodies(studio) == [_body(certs["stock1"]), _body(certs["stock2"]), _body(certs["ours"])]
+        assert "replaced" in result.stdout
+
+    def test_another_install_with_the_bare_legacy_name_is_kept(self, kind, tmp_path, certs):
+        # Every Bambuddy before per-install names called its CA just
+        # "Virtual Printer CA": that one may be another instance's.
+        ours = _make_cert("Virtual Printer CA")
+        other = _make_cert("Virtual Printer CA")
+        apps = tmp_path / "apps"
+        studio = _slicer(apps, "Bambu Studio", certs["stock1"] + other)
+
+        result = _runner(kind, tmp_path, ours)(apps)
+
+        assert result.returncode == 0, result.stdout + result.stderr
+        assert _bodies(studio) == [_body(certs["stock1"]), _body(other), _body(ours)]
+
+    def test_a_file_with_crlf_and_no_final_newline(self, kind, tmp_path, certs):
+        apps = tmp_path / "apps"
+        studio = _slicer(apps, "Bambu Studio", certs["stock1"].strip().replace("\n", "\r\n"))
+
+        result = _runner(kind, tmp_path, certs["ours"])(apps)
+
+        assert result.returncode == 0, result.stdout + result.stderr
+        assert _bodies(studio) == [_body(certs["stock1"]), _body(certs["ours"])]
+        text = studio.read_text().replace("\r", "")
+        assert "-----END CERTIFICATE----------BEGIN" not in text
+
+    def test_no_slicer_found(self, kind, tmp_path, certs):
+        (tmp_path / "apps").mkdir()
+
+        result = _runner(kind, tmp_path, certs["ours"])(tmp_path / "apps")
+
+        assert result.returncode == 2
+        assert "No Bambu Studio or OrcaSlicer" in result.stdout

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

@@ -573,3 +573,60 @@ describe('VirtualPrinterCard - header does not overflow the card', () => {
     expect(card.className).toContain('overflow-hidden');
     expect(card.className).toContain('overflow-hidden');
   });
   });
 });
 });
+
+// The empty entry of the target picker used to be a prompt that could not be
+// chosen, so a target once set could never be cleared. In queue mode it now
+// reads "Any available <model>" and clears the target (null); proxy mode
+// needs a printer, so there it stays a prompt.
+describe('VirtualPrinterCard - target printer can be cleared', () => {
+  const printers = [
+    { id: 7, name: 'Workshop X1C', ip_address: '192.168.1.50', access_code: 'TGTCODE1', model: 'X1C', is_active: true },
+  ];
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+    vi.mocked(multiVirtualPrinterApi.update).mockResolvedValue(createMockPrinter());
+    vi.mocked(api.getPrinters).mockResolvedValue(printers as unknown as Awaited<ReturnType<typeof api.getPrinters>>);
+  });
+
+  const targetSelect = () =>
+    screen.getAllByRole('combobox').find((el) => (el as HTMLSelectElement).querySelector('option[value="7"]')) as HTMLSelectElement;
+
+  it('queue mode without a target offers any printer of the model', async () => {
+    render(<VirtualPrinterCard printer={createMockPrinter({ mode: 'queue' })} models={models} />);
+
+    await waitFor(() => expect(targetSelect()).toBeTruthy());
+    expect(targetSelect().selectedOptions[0].textContent).toBe('Any available X1C');
+    expect(screen.getByText(/first free X1C/)).toBeInTheDocument();
+  });
+
+  it('choosing "any" in queue mode clears the target', async () => {
+    const user = userEvent.setup();
+    render(<VirtualPrinterCard printer={createMockPrinter({ mode: 'queue', target_printer_id: 7 })} models={models} />);
+    await waitFor(() => expect(targetSelect().value).toBe('7'));
+    expect(screen.getByText('Every job goes to this printer.')).toBeInTheDocument();
+
+    await user.selectOptions(targetSelect(), '');
+
+    await waitFor(() =>
+      expect(multiVirtualPrinterApi.update).toHaveBeenCalledWith(1, { target_printer_id: null }),
+    );
+  });
+
+  it('proxy mode keeps the empty entry as a prompt', async () => {
+    const user = userEvent.setup();
+    render(<VirtualPrinterCard printer={createMockPrinter({ mode: 'proxy', target_printer_id: 7 })} models={models} />);
+    await waitFor(() => expect(targetSelect().value).toBe('7'));
+    expect(targetSelect().options[0].textContent).toBe('Select a printer...');
+
+    await user.selectOptions(targetSelect(), '');
+
+    expect(multiVirtualPrinterApi.update).not.toHaveBeenCalled();
+  });
+
+  it('archive mode calls the empty entry none', async () => {
+    render(<VirtualPrinterCard printer={createMockPrinter({ mode: 'archive' })} models={models} />);
+    await waitFor(() => expect(targetSelect()).toBeTruthy());
+    expect(targetSelect().selectedOptions[0].textContent).toBe('None');
+  });
+});

+ 53 - 0
frontend/src/__tests__/components/VirtualPrinterListCaInstaller.test.tsx

@@ -0,0 +1,53 @@
+/**
+ * The slicer certificate card offers a script per platform with this
+ * instance's CA written into it (Settings -> Virtual Printer).
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { VirtualPrinterList } from '../../components/VirtualPrinterList';
+
+vi.mock('../../api/client', () => ({
+  multiVirtualPrinterApi: {
+    list: vi.fn().mockResolvedValue({ printers: [], models: {} }),
+    getCaCertificate: vi.fn().mockResolvedValue({ pem: 'PEM', fingerprint_sha256: 'AB:CD', not_valid_after: '' }),
+    getCaCertificateInstaller: vi.fn(),
+  },
+  virtualPrinterApi: {
+    getSettings: vi.fn().mockResolvedValue({ archive_name_source: 'metadata' }),
+    updateSettings: vi.fn(),
+  },
+  api: {
+    getSettings: vi.fn().mockResolvedValue({}),
+    getPrinters: vi.fn().mockResolvedValue([]),
+  },
+}));
+
+const mockDownload = vi.fn();
+vi.mock('../../utils/clipboard', async (importOriginal) => ({
+  ...(await importOriginal<typeof import('../../utils/clipboard')>()),
+  downloadTextFile: (...args: unknown[]) => mockDownload(...args),
+}));
+
+import { multiVirtualPrinterApi } from '../../api/client';
+
+describe('VirtualPrinterList - slicer certificate scripts', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+  });
+
+  it.each([
+    ['Windows', 'windows', 'bambuddy-slicer-cert.cmd'],
+    ['Linux / macOS', 'unix', 'bambuddy-slicer-cert.sh'],
+  ])('%s downloads the script the backend made', async (label, platform, filename) => {
+    vi.mocked(multiVirtualPrinterApi.getCaCertificateInstaller).mockResolvedValue({ filename, content: 'SCRIPT' });
+    const user = userEvent.setup();
+    render(<VirtualPrinterList />);
+
+    await user.click(await screen.findByRole('button', { name: label }));
+
+    await waitFor(() => expect(mockDownload).toHaveBeenCalledWith('SCRIPT', filename, 'application/octet-stream'));
+    expect(multiVirtualPrinterApi.getCaCertificateInstaller).toHaveBeenCalledWith(platform);
+  });
+});

+ 7 - 1
frontend/src/api/client.ts

@@ -9315,7 +9315,8 @@ export const multiVirtualPrinterApi = {
     mode?: string;
     mode?: string;
     model?: string;
     model?: string;
     access_code?: string;
     access_code?: string;
-    target_printer_id?: number;
+    // null clears the target (a queue VP then sends to any printer of its model).
+    target_printer_id?: number | null;
     auto_dispatch?: boolean;
     auto_dispatch?: boolean;
     queue_force_color_match?: boolean;
     queue_force_color_match?: boolean;
     save_ams_mapping?: boolean;
     save_ams_mapping?: boolean;
@@ -9340,6 +9341,11 @@ export const multiVirtualPrinterApi = {
   getCaCertificate: () =>
   getCaCertificate: () =>
     request<VPCaCertificate>('/virtual-printers/ca-certificate'),
     request<VPCaCertificate>('/virtual-printers/ca-certificate'),
 
 
+  // A script that adds the CA to Bambu Studio and OrcaSlicer, with the CA
+  // written into it: 'windows' (.cmd) or 'unix' (.sh, Linux and macOS).
+  getCaCertificateInstaller: (platform: 'windows' | 'unix') =>
+    request<{ filename: string; content: string }>(`/virtual-printers/ca-certificate/installer/${platform}`),
+
   diagnose: (id: number) =>
   diagnose: (id: number) =>
     request<VPDiagnosticResult>(`/virtual-printers/${id}/diagnostic`),
     request<VPDiagnosticResult>(`/virtual-printers/${id}/diagnostic`),
 };
 };

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

@@ -215,12 +215,12 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
     updateMutation.mutate({ model });
     updateMutation.mutate({ model });
   };
   };
 
 
-  const handleTargetPrinterChange = (printerId: number) => {
+  const handleTargetPrinterChange = (printerId: number | null) => {
     // The new target's access code becomes this VP's access code on the
     // The new target's access code becomes this VP's access code on the
     // backend write. If the slicer was already bound with the old code,
     // backend write. If the slicer was already bound with the old code,
     // it has to rebind; flag this so the user doesn't sit there confused.
     // it has to rebind; flag this so the user doesn't sit there confused.
     const previousCode = targetPrinter?.access_code;
     const previousCode = targetPrinter?.access_code;
-    const nextCode = printers?.find(p => p.id === printerId)?.access_code;
+    const nextCode = printerId === null ? undefined : printers?.find(p => p.id === printerId)?.access_code;
     setLocalTargetPrinterId(printerId);
     setLocalTargetPrinterId(printerId);
     setPendingAction('targetPrinter');
     setPendingAction('targetPrinter');
     updateMutation.mutate(
     updateMutation.mutate(
@@ -686,19 +686,37 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
                 <select
                 <select
                   value={localTargetPrinterId ?? ''}
                   value={localTargetPrinterId ?? ''}
                   onChange={(e) => {
                   onChange={(e) => {
+                    if (e.target.value === '') {
+                      // Proxy mode needs a printer, so its empty entry is only a prompt.
+                      if (localMode !== 'proxy' && localTargetPrinterId !== null) handleTargetPrinterChange(null);
+                      return;
+                    }
                     const id = parseInt(e.target.value, 10);
                     const id = parseInt(e.target.value, 10);
                     if (!isNaN(id)) handleTargetPrinterChange(id);
                     if (!isNaN(id)) handleTargetPrinterChange(id);
                   }}
                   }}
                   disabled={pendingAction === 'targetPrinter'}
                   disabled={pendingAction === 'targetPrinter'}
                   className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-1.5 text-white text-sm appearance-none cursor-pointer disabled:opacity-50 pr-10"
                   className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-1.5 text-white text-sm appearance-none cursor-pointer disabled:opacity-50 pr-10"
                 >
                 >
-                  <option value="">{t('virtualPrinter.targetPrinter.placeholder')}</option>
+                  <option value="">
+                    {localMode === 'proxy'
+                      ? t('virtualPrinter.targetPrinter.placeholder')
+                      : localMode === 'queue'
+                        ? t('virtualPrinter.targetPrinter.anyOfModel', { model: models[localModel] || localModel })
+                        : t('virtualPrinter.targetPrinter.none')}
+                  </option>
                   {printers?.map((p) => (
                   {printers?.map((p) => (
                     <option key={p.id} value={p.id}>{p.name} ({p.ip_address})</option>
                     <option key={p.id} value={p.id}>{p.name} ({p.ip_address})</option>
                   ))}
                   ))}
                 </select>
                 </select>
                 <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
                 <ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-bambu-gray pointer-events-none" />
               </div>
               </div>
+              {localMode === 'queue' && (
+                <p className="text-xs text-bambu-gray mt-1">
+                  {localTargetPrinterId !== null
+                    ? t('virtualPrinter.targetPrinter.queueFixedHint')
+                    : t('virtualPrinter.targetPrinter.queueAnyHint', { model: models[localModel] || localModel })}
+                </p>
+              )}
             </div>
             </div>
 
 
             {/* Bind Interface */}
             {/* Bind Interface */}

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

@@ -11,6 +11,12 @@ import { copyTextToClipboard, downloadTextFile } from '../utils/clipboard';
 import { VirtualPrinterCard } from './VirtualPrinterCard';
 import { VirtualPrinterCard } from './VirtualPrinterCard';
 import { VirtualPrinterAddDialog } from './VirtualPrinterAddDialog';
 import { VirtualPrinterAddDialog } from './VirtualPrinterAddDialog';
 
 
+// Operating system names, the same in every language.
+const INSTALLER_PLATFORMS = [
+  ['windows', 'Windows'],
+  ['unix', 'Linux / macOS'],
+] as const;
+
 export function VirtualPrinterList() {
 export function VirtualPrinterList() {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
@@ -68,6 +74,19 @@ export function VirtualPrinterList() {
     downloadTextFile(caCert.pem, 'bambuddy-virtual-printer-ca.crt', 'application/x-pem-file');
     downloadTextFile(caCert.pem, 'bambuddy-virtual-printer-ca.crt', 'application/x-pem-file');
   };
   };
 
 
+  const [installerPending, setInstallerPending] = useState<'windows' | 'unix' | null>(null);
+  const handleDownloadInstaller = async (platform: 'windows' | 'unix') => {
+    setInstallerPending(platform);
+    try {
+      const installer = await multiVirtualPrinterApi.getCaCertificateInstaller(platform);
+      downloadTextFile(installer.content, installer.filename, 'application/octet-stream');
+    } catch (error) {
+      showToast((error as Error).message || t('virtualPrinter.caCert.installerFailed'), 'error');
+    } finally {
+      setInstallerPending(null);
+    }
+  };
+
   if (isLoading) {
   if (isLoading) {
     return (
     return (
       <Card>
       <Card>
@@ -160,6 +179,25 @@ export function VirtualPrinterList() {
                 <p className="text-xs text-bambu-gray mt-1">
                 <p className="text-xs text-bambu-gray mt-1">
                   {t('virtualPrinter.caCert.description')}
                   {t('virtualPrinter.caCert.description')}
                 </p>
                 </p>
+                <div className="flex flex-wrap items-center gap-2 mt-2">
+                  <span className="text-xs text-white">{t('virtualPrinter.caCert.installerTitle')}</span>
+                  {INSTALLER_PLATFORMS.map(([platform, label]) => (
+                    <button
+                      key={platform}
+                      onClick={() => handleDownloadInstaller(platform)}
+                      disabled={installerPending !== null}
+                      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"
+                    >
+                      {installerPending === platform
+                        ? <Loader2 className="w-3.5 h-3.5 animate-spin" />
+                        : <Download className="w-3.5 h-3.5" />}
+                      {label}
+                    </button>
+                  ))}
+                </div>
+                <p className="text-xs text-bambu-gray mt-1">
+                  {t('virtualPrinter.caCert.installerHint')}
+                </p>
                 {caCert && (
                 {caCert && (
                   <p
                   <p
                     className="text-[10px] text-bambu-gray font-mono mt-1 truncate"
                     className="text-[10px] text-bambu-gray font-mono mt-1 truncate"

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

@@ -6358,6 +6358,10 @@ export default {
     targetPrinter: {
     targetPrinter: {
       title: 'Zieldrucker',
       title: 'Zieldrucker',
       placeholder: 'Drucker auswählen...',
       placeholder: 'Drucker auswählen...',
+      anyOfModel: 'Beliebiger freier {{model}}',
+      none: 'Keiner',
+      queueFixedHint: 'Jeder Auftrag geht an diesen Drucker.',
+      queueAnyHint: 'Jeder Auftrag geht an den ersten freien {{model}}. Wähle einen Drucker, um alle Aufträge an diesen zu senden.',
     },
     },
     remoteInterface: {
     remoteInterface: {
       title: 'Netzwerkschnittstelle überschreiben',
       title: 'Netzwerkschnittstelle überschreiben',
@@ -6413,6 +6417,9 @@ export default {
       copied: 'Kopiert',
       copied: 'Kopiert',
       download: 'Herunterladen',
       download: 'Herunterladen',
       fingerprint: 'SHA-256',
       fingerprint: 'SHA-256',
+      installerTitle: 'Automatisch zum Slicer hinzufügen:',
+      installerHint: 'Lädt ein Skript für Bambu Studio und OrcaSlicer herunter. Windows: doppelklicken und die Abfrage bestätigen; danach fügt es das Zertifikat nach jedem Slicer-Update wieder hinzu. Linux / macOS: mit bash ausführen, und nach einem Slicer-Update erneut.',
+      installerFailed: 'Das Skript konnte nicht erstellt werden',
     },
     },
     howItWorks: {
     howItWorks: {
       title: 'So funktioniert es',
       title: 'So funktioniert es',

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

@@ -6411,6 +6411,10 @@ export default {
     targetPrinter: {
     targetPrinter: {
       title: 'Target Printer',
       title: 'Target Printer',
       placeholder: 'Select a printer...',
       placeholder: 'Select a printer...',
+      anyOfModel: 'Any available {{model}}',
+      none: 'None',
+      queueFixedHint: 'Every job goes to this printer.',
+      queueAnyHint: 'Each job goes to the first free {{model}}. Choose a printer to send all jobs to that one.',
     },
     },
     remoteInterface: {
     remoteInterface: {
       title: 'Network Interface Override',
       title: 'Network Interface Override',
@@ -6466,6 +6470,9 @@ export default {
       copied: 'Copied',
       copied: 'Copied',
       download: 'Download',
       download: 'Download',
       fingerprint: 'SHA-256',
       fingerprint: 'SHA-256',
+      installerTitle: 'Add it to the slicer automatically:',
+      installerHint: 'Downloads a script for Bambu Studio and OrcaSlicer. Windows: double-click it and confirm the prompt; it then adds the certificate again after every slicer update. Linux / macOS: run it with bash, and again after a slicer update.',
+      installerFailed: 'Could not create the script',
     },
     },
     howItWorks: {
     howItWorks: {
       title: 'How it works',
       title: 'How it works',

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

@@ -6366,6 +6366,10 @@ export default {
     targetPrinter: {
     targetPrinter: {
       title: 'Impresora de destino',
       title: 'Impresora de destino',
       placeholder: 'Seleccionar una impresora...',
       placeholder: 'Seleccionar una impresora...',
+      anyOfModel: 'Cualquier {{model}} disponible',
+      none: 'Ninguna',
+      queueFixedHint: 'Cada trabajo va a esta impresora.',
+      queueAnyHint: 'Cada trabajo va a la primera {{model}} libre. Elige una impresora para enviar todos los trabajos a ella.',
     },
     },
     remoteInterface: {
     remoteInterface: {
       title: 'Anulación de la interfaz de red',
       title: 'Anulación de la interfaz de red',
@@ -6421,6 +6425,9 @@ export default {
       copied: 'Copiado',
       copied: 'Copiado',
       download: 'Descargar',
       download: 'Descargar',
       fingerprint: 'SHA-256',
       fingerprint: 'SHA-256',
+      installerTitle: 'Añadirlo al laminador automáticamente:',
+      installerHint: 'Descarga un script para Bambu Studio y OrcaSlicer. Windows: haz doble clic y confirma el aviso; después vuelve a añadir el certificado tras cada actualización del laminador. Linux / macOS: ejecútalo con bash, y de nuevo tras una actualización del laminador.',
+      installerFailed: 'No se pudo crear el script',
     },
     },
     howItWorks: {
     howItWorks: {
       title: 'Cómo funciona',
       title: 'Cómo funciona',

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

@@ -6346,6 +6346,10 @@ export default {
     targetPrinter: {
     targetPrinter: {
       title: 'Imprimante cible',
       title: 'Imprimante cible',
       placeholder: 'Choisir imprimante...',
       placeholder: 'Choisir imprimante...',
+      anyOfModel: 'N\'importe quelle {{model}} disponible',
+      none: 'Aucune',
+      queueFixedHint: 'Chaque tâche va à cette imprimante.',
+      queueAnyHint: 'Chaque tâche va à la première {{model}} libre. Choisissez une imprimante pour y envoyer toutes les tâches.',
     },
     },
     remoteInterface: {
     remoteInterface: {
       title: 'Exception Interface Réseau',
       title: 'Exception Interface Réseau',
@@ -6446,6 +6450,9 @@ export default {
       copied: 'Copié',
       copied: 'Copié',
       download: 'Télécharger',
       download: 'Télécharger',
       fingerprint: 'SHA-256',
       fingerprint: 'SHA-256',
+      installerTitle: 'L\'ajouter automatiquement au trancheur :',
+      installerHint: 'Télécharge un script pour Bambu Studio et OrcaSlicer. Windows : double-cliquez dessus et confirmez la demande ; il rajoute ensuite le certificat après chaque mise à jour du trancheur. Linux / macOS : lancez-le avec bash, et à nouveau après une mise à jour du trancheur.',
+      installerFailed: 'Impossible de créer le script',
     },
     },
   },
   },
 
 

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

@@ -6345,6 +6345,10 @@ export default {
     targetPrinter: {
     targetPrinter: {
       title: 'Stampante target',
       title: 'Stampante target',
       placeholder: 'Seleziona una stampante...',
       placeholder: 'Seleziona una stampante...',
+      anyOfModel: 'Qualsiasi {{model}} disponibile',
+      none: 'Nessuna',
+      queueFixedHint: 'Ogni lavoro va a questa stampante.',
+      queueAnyHint: 'Ogni lavoro va alla prima {{model}} libera. Scegli una stampante per inviare tutti i lavori a quella.',
     },
     },
     remoteInterface: {
     remoteInterface: {
       title: 'Sovrascrittura interfaccia di rete',
       title: 'Sovrascrittura interfaccia di rete',
@@ -6445,6 +6449,9 @@ export default {
       copied: 'Copiato',
       copied: 'Copiato',
       download: 'Scarica',
       download: 'Scarica',
       fingerprint: 'SHA-256',
       fingerprint: 'SHA-256',
+      installerTitle: 'Aggiungilo automaticamente allo slicer:',
+      installerHint: 'Scarica uno script per Bambu Studio e OrcaSlicer. Windows: fai doppio clic e conferma la richiesta; poi aggiunge di nuovo il certificato dopo ogni aggiornamento dello slicer. Linux / macOS: eseguilo con bash, e di nuovo dopo un aggiornamento dello slicer.',
+      installerFailed: 'Impossibile creare lo script',
     },
     },
   },
   },
 
 

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

@@ -6359,6 +6359,10 @@ export default {
     targetPrinter: {
     targetPrinter: {
       title: 'ターゲットプリンター',
       title: 'ターゲットプリンター',
       placeholder: 'プリンターを選択...',
       placeholder: 'プリンターを選択...',
+      anyOfModel: '空いている任意の{{model}}',
+      none: 'なし',
+      queueFixedHint: 'すべてのジョブがこのプリンターに送られます。',
+      queueAnyHint: '各ジョブは最初に空いた{{model}}に送られます。プリンターを選ぶと、すべてのジョブがそのプリンターに送られます。',
     },
     },
     remoteInterface: {
     remoteInterface: {
       title: 'ネットワークインターフェース上書き',
       title: 'ネットワークインターフェース上書き',
@@ -6459,6 +6463,9 @@ export default {
       copied: 'コピーしました',
       copied: 'コピーしました',
       download: 'ダウンロード',
       download: 'ダウンロード',
       fingerprint: 'SHA-256',
       fingerprint: 'SHA-256',
+      installerTitle: 'スライサーに自動で追加:',
+      installerHint: 'Bambu Studio と OrcaSlicer 用のスクリプトをダウンロードします。Windows: ダブルクリックして確認に応じてください。以降、スライサーの更新ごとに証明書を再度追加します。Linux / macOS: bash で実行し、スライサーの更新後にも再度実行してください。',
+      installerFailed: 'スクリプトを作成できませんでした',
     },
     },
   },
   },
 
 

+ 8 - 1
frontend/src/i18n/locales/ko.ts

@@ -6095,6 +6095,10 @@ export default {
     targetPrinter: {
     targetPrinter: {
       title: '대상 프린터',
       title: '대상 프린터',
       placeholder: '프린터 선택...',
       placeholder: '프린터 선택...',
+      anyOfModel: '사용 가능한 모든 {{model}}',
+      none: '없음',
+      queueFixedHint: '모든 작업이 이 프린터로 전송됩니다.',
+      queueAnyHint: '각 작업은 처음 비어 있는 {{model}}(으)로 전송됩니다. 프린터를 선택하면 모든 작업이 해당 프린터로 전송됩니다.',
     },
     },
     remoteInterface: {
     remoteInterface: {
       title: '네트워크 인터페이스 재정의',
       title: '네트워크 인터페이스 재정의',
@@ -6194,7 +6198,10 @@ export default {
       copy: '복사',
       copy: '복사',
       copied: '복사됨',
       copied: '복사됨',
       download: '다운로드',
       download: '다운로드',
-      fingerprint: 'SHA-256'
+      fingerprint: 'SHA-256',
+      installerTitle: '슬라이서에 자동으로 추가:',
+      installerHint: 'Bambu Studio와 OrcaSlicer용 스크립트를 다운로드합니다. Windows: 더블클릭하고 확인 창을 승인하세요. 이후 슬라이서가 업데이트될 때마다 인증서를 다시 추가합니다. Linux / macOS: bash로 실행하고, 슬라이서 업데이트 후 다시 실행하세요.',
+      installerFailed: '스크립트를 만들 수 없습니다'
     }
     }
   },
   },
   modelViewer: {
   modelViewer: {

+ 7 - 0
frontend/src/i18n/locales/nl.ts

@@ -6409,6 +6409,10 @@ export default {
     targetPrinter: {
     targetPrinter: {
       title: 'Doelprinter',
       title: 'Doelprinter',
       placeholder: 'Selecteer een printer...',
       placeholder: 'Selecteer een printer...',
+      anyOfModel: 'Elke beschikbare {{model}}',
+      none: 'Geen',
+      queueFixedHint: 'Elke taak gaat naar deze printer.',
+      queueAnyHint: 'Elke taak gaat naar de eerste vrije {{model}}. Kies een printer om alle taken naar die printer te sturen.',
     },
     },
     remoteInterface: {
     remoteInterface: {
       title: 'Overschrijving netwerkinterface',
       title: 'Overschrijving netwerkinterface',
@@ -6464,6 +6468,9 @@ export default {
       copied: 'Gekopieerd',
       copied: 'Gekopieerd',
       download: 'Downloaden',
       download: 'Downloaden',
       fingerprint: 'SHA-256',
       fingerprint: 'SHA-256',
+      installerTitle: 'Automatisch aan de slicer toevoegen:',
+      installerHint: 'Downloadt een script voor Bambu Studio en OrcaSlicer. Windows: dubbelklik erop en bevestig de vraag; daarna voegt het het certificaat na elke slicer-update opnieuw toe. Linux / macOS: voer het uit met bash, en opnieuw na een slicer-update.',
+      installerFailed: 'Het script kon niet worden gemaakt',
     },
     },
     howItWorks: {
     howItWorks: {
       title: 'Hoe het werkt',
       title: 'Hoe het werkt',

+ 7 - 0
frontend/src/i18n/locales/pl.ts

@@ -6411,6 +6411,10 @@ export default {
     targetPrinter: {
     targetPrinter: {
       title: 'Drukarka docelowa',
       title: 'Drukarka docelowa',
       placeholder: 'Wybierz drukarkę...',
       placeholder: 'Wybierz drukarkę...',
+      anyOfModel: 'Dowolna dostępna {{model}}',
+      none: 'Brak',
+      queueFixedHint: 'Każde zadanie trafia do tej drukarki.',
+      queueAnyHint: 'Każde zadanie trafia do pierwszej wolnej {{model}}. Wybierz drukarkę, aby wysyłać wszystkie zadania do niej.',
     },
     },
     remoteInterface: {
     remoteInterface: {
       title: 'Wymuszenie interfejsu sieciowego',
       title: 'Wymuszenie interfejsu sieciowego',
@@ -6466,6 +6470,9 @@ export default {
       copied: 'Skopiowano',
       copied: 'Skopiowano',
       download: 'Pobierz',
       download: 'Pobierz',
       fingerprint: 'SHA-256',
       fingerprint: 'SHA-256',
+      installerTitle: 'Dodaj automatycznie do slicera:',
+      installerHint: 'Pobiera skrypt dla Bambu Studio i OrcaSlicer. Windows: kliknij go dwukrotnie i potwierdź monit; potem dodaje certyfikat ponownie po każdej aktualizacji slicera. Linux / macOS: uruchom go przez bash, i ponownie po aktualizacji slicera.',
+      installerFailed: 'Nie udało się utworzyć skryptu',
     },
     },
     howItWorks: {
     howItWorks: {
       title: 'Jak to działa',
       title: 'Jak to działa',

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

@@ -6345,6 +6345,10 @@ export default {
     targetPrinter: {
     targetPrinter: {
       title: 'Impressora Alvo',
       title: 'Impressora Alvo',
       placeholder: 'Selecione uma impressora...',
       placeholder: 'Selecione uma impressora...',
+      anyOfModel: 'Qualquer {{model}} disponível',
+      none: 'Nenhuma',
+      queueFixedHint: 'Todo trabalho vai para esta impressora.',
+      queueAnyHint: 'Cada trabalho vai para a primeira {{model}} livre. Escolha uma impressora para enviar todos os trabalhos para ela.',
     },
     },
     remoteInterface: {
     remoteInterface: {
       title: 'Substituição da Interface de Rede',
       title: 'Substituição da Interface de Rede',
@@ -6445,6 +6449,9 @@ export default {
       copied: 'Copiado',
       copied: 'Copiado',
       download: 'Baixar',
       download: 'Baixar',
       fingerprint: 'SHA-256',
       fingerprint: 'SHA-256',
+      installerTitle: 'Adicionar ao fatiador automaticamente:',
+      installerHint: 'Baixa um script para o Bambu Studio e o OrcaSlicer. Windows: clique duas vezes e confirme o aviso; depois ele adiciona o certificado de novo após cada atualização do fatiador. Linux / macOS: execute com bash, e de novo após uma atualização do fatiador.',
+      installerFailed: 'Não foi possível criar o script',
     },
     },
   },
   },
 
 

+ 7 - 0
frontend/src/i18n/locales/ru.ts

@@ -6083,6 +6083,10 @@ export default {
     targetPrinter: {
     targetPrinter: {
       title: "Целевой принтер",
       title: "Целевой принтер",
       placeholder: "Выберите принтер...",
       placeholder: "Выберите принтер...",
+      anyOfModel: "Любой свободный {{model}}",
+      none: "Нет",
+      queueFixedHint: "Каждое задание отправляется на этот принтер.",
+      queueAnyHint: "Каждое задание отправляется на первый свободный {{model}}. Выберите принтер, чтобы отправлять все задания на него.",
     },
     },
     remoteInterface: {
     remoteInterface: {
       title: "Переопределение сетевого интерфейса",
       title: "Переопределение сетевого интерфейса",
@@ -6138,6 +6142,9 @@ export default {
       copied: "Скопировано",
       copied: "Скопировано",
       download: "Скачать",
       download: "Скачать",
       fingerprint: "SHA-256",
       fingerprint: "SHA-256",
+      installerTitle: "Добавить в слайсер автоматически:",
+      installerHint: "Скачивает скрипт для Bambu Studio и OrcaSlicer. Windows: дважды щёлкните по нему и подтвердите запрос; после этого он добавляет сертификат снова после каждого обновления слайсера. Linux / macOS: запустите через bash, и снова после обновления слайсера.",
+      installerFailed: "Не удалось создать скрипт",
     },
     },
     howItWorks: {
     howItWorks: {
       title: "Как это работает",
       title: "Как это работает",

+ 7 - 0
frontend/src/i18n/locales/sv.ts

@@ -6440,6 +6440,10 @@ errors: {
     targetPrinter: {
     targetPrinter: {
       title: 'Målskrivare',
       title: 'Målskrivare',
       placeholder: 'Välj en skrivare...',
       placeholder: 'Välj en skrivare...',
+      anyOfModel: 'Valfri ledig {{model}}',
+      none: 'Ingen',
+      queueFixedHint: 'Varje jobb går till den här skrivaren.',
+      queueAnyHint: 'Varje jobb går till den första lediga {{model}}. Välj en skrivare för att skicka alla jobb dit.',
     },
     },
     remoteInterface: {
     remoteInterface: {
       title: 'Åsidosättning för nätverksgränssnitt',
       title: 'Åsidosättning för nätverksgränssnitt',
@@ -6495,6 +6499,9 @@ errors: {
       copied: 'Kopierad',
       copied: 'Kopierad',
       download: 'Ladda ner',
       download: 'Ladda ner',
       fingerprint: 'SHA-256',
       fingerprint: 'SHA-256',
+      installerTitle: 'Lägg till i slicern automatiskt:',
+      installerHint: 'Laddar ner ett skript för Bambu Studio och OrcaSlicer. Windows: dubbelklicka och bekräfta frågan; sedan lägger det till certifikatet igen efter varje slicer-uppdatering. Linux / macOS: kör det med bash, och igen efter en slicer-uppdatering.',
+      installerFailed: 'Skriptet kunde inte skapas',
     },
     },
     howItWorks: {
     howItWorks: {
       title: 'Hur det fungerar',
       title: 'Hur det fungerar',

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

@@ -6322,6 +6322,10 @@ export default {
     targetPrinter: {
     targetPrinter: {
       title: 'Hedef Yazıcı',
       title: 'Hedef Yazıcı',
       placeholder: 'Bir yazıcı seçin...',
       placeholder: 'Bir yazıcı seçin...',
+      anyOfModel: 'Herhangi bir uygun {{model}}',
+      none: 'Yok',
+      queueFixedHint: 'Her iş bu yazıcıya gider.',
+      queueAnyHint: 'Her iş ilk boştaki {{model}} yazıcısına gider. Tüm işleri ona göndermek için bir yazıcı seçin.',
     },
     },
     remoteInterface: {
     remoteInterface: {
       title: 'Ağ Arayüzü Geçersiz Kılma',
       title: 'Ağ Arayüzü Geçersiz Kılma',
@@ -6377,6 +6381,9 @@ export default {
       copied: 'Kopyalandı',
       copied: 'Kopyalandı',
       download: 'İndir',
       download: 'İndir',
       fingerprint: 'SHA-256',
       fingerprint: 'SHA-256',
+      installerTitle: 'Dilimleyiciye otomatik ekle:',
+      installerHint: 'Bambu Studio ve OrcaSlicer için bir betik indirir. Windows: çift tıklayın ve istemi onaylayın; ardından her dilimleyici güncellemesinden sonra sertifikayı yeniden ekler. Linux / macOS: bash ile çalıştırın ve dilimleyici güncellemesinden sonra tekrar çalıştırın.',
+      installerFailed: 'Betik oluşturulamadı',
     },
     },
     howItWorks: {
     howItWorks: {
       title: 'Nasıl çalışır',
       title: 'Nasıl çalışır',

+ 7 - 0
frontend/src/i18n/locales/uk.ts

@@ -6399,6 +6399,10 @@ export default {
     targetPrinter: {
     targetPrinter: {
       title: "Цільовий принтер",
       title: "Цільовий принтер",
       placeholder: "Виберіть принтер...",
       placeholder: "Виберіть принтер...",
+      anyOfModel: "Будь-який вільний {{model}}",
+      none: "Немає",
+      queueFixedHint: "Кожне завдання надсилається на цей принтер.",
+      queueAnyHint: "Кожне завдання надсилається на перший вільний {{model}}. Виберіть принтер, щоб надсилати всі завдання на нього.",
     },
     },
     remoteInterface: {
     remoteInterface: {
       title: "Перевизначення мережевого інтерфейсу",
       title: "Перевизначення мережевого інтерфейсу",
@@ -6454,6 +6458,9 @@ export default {
       copied: "Скопійовано",
       copied: "Скопійовано",
       download: "Завантажити",
       download: "Завантажити",
       fingerprint: "SHA-256",
       fingerprint: "SHA-256",
+      installerTitle: "Додати до слайсера автоматично:",
+      installerHint: "Завантажує скрипт для Bambu Studio і OrcaSlicer. Windows: двічі клацніть по ньому й підтвердьте запит; після цього він додає сертифікат знову після кожного оновлення слайсера. Linux / macOS: запустіть через bash, і знову після оновлення слайсера.",
+      installerFailed: "Не вдалося створити скрипт",
     },
     },
     howItWorks: {
     howItWorks: {
       title: "Як це працює",
       title: "Як це працює",

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

@@ -6345,6 +6345,10 @@ export default {
     targetPrinter: {
     targetPrinter: {
       title: '目标打印机',
       title: '目标打印机',
       placeholder: '选择打印机...',
       placeholder: '选择打印机...',
+      anyOfModel: '任意空闲的 {{model}}',
+      none: '无',
+      queueFixedHint: '所有任务都发送到这台打印机。',
+      queueAnyHint: '每个任务发送到第一台空闲的 {{model}}。选择一台打印机可将所有任务发送到该打印机。',
     },
     },
     remoteInterface: {
     remoteInterface: {
       title: '网络接口覆盖',
       title: '网络接口覆盖',
@@ -6400,6 +6404,9 @@ export default {
       copied: '已复制',
       copied: '已复制',
       download: '下载',
       download: '下载',
       fingerprint: 'SHA-256',
       fingerprint: 'SHA-256',
+      installerTitle: '自动添加到切片软件:',
+      installerHint: '下载适用于 Bambu Studio 和 OrcaSlicer 的脚本。Windows:双击并确认提示;之后每次切片软件更新后它都会重新添加证书。Linux / macOS:用 bash 运行,切片软件更新后再运行一次。',
+      installerFailed: '无法创建脚本',
     },
     },
     howItWorks: {
     howItWorks: {
       title: '工作原理',
       title: '工作原理',

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

@@ -6345,6 +6345,10 @@ export default {
     targetPrinter: {
     targetPrinter: {
       title: '目標印表機',
       title: '目標印表機',
       placeholder: '選擇印表機...',
       placeholder: '選擇印表機...',
+      anyOfModel: '任一閒置的 {{model}}',
+      none: '無',
+      queueFixedHint: '所有工作都傳送到這台印表機。',
+      queueAnyHint: '每個工作傳送到第一台閒置的 {{model}}。選擇一台印表機可將所有工作傳送到該印表機。',
     },
     },
     remoteInterface: {
     remoteInterface: {
       title: '網路介面覆蓋',
       title: '網路介面覆蓋',
@@ -6400,6 +6404,9 @@ export default {
       copied: '已複製',
       copied: '已複製',
       download: '下載',
       download: '下載',
       fingerprint: 'SHA-256',
       fingerprint: 'SHA-256',
+      installerTitle: '自動加入切片軟體:',
+      installerHint: '下載適用於 Bambu Studio 與 OrcaSlicer 的指令碼。Windows:按兩下並確認提示;之後每次切片軟體更新後都會重新加入憑證。Linux / macOS:用 bash 執行,切片軟體更新後再執行一次。',
+      installerFailed: '無法建立指令碼',
     },
     },
     howItWorks: {
     howItWorks: {
       title: '工作原理',
       title: '工作原理',

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


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


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


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


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


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


+ 1 - 1
static/assets/Button-Dk615dwb.js → static/assets/Button-KJ9adUYB.js

@@ -1 +1 @@
-import{w as e}from"./client-MyvT0tak.js";var t=e();function n({variant:e=`primary`,size:n=`md`,className:r=``,children:i,...a}){return(0,t.jsx)(`button`,{className:`inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-bambu-dark disabled:opacity-50 disabled:cursor-not-allowed ${{primary:`bg-bambu-green hover:bg-bambu-green-light text-white focus:ring-bambu-green`,secondary:`bg-bambu-dark-tertiary hover:bg-bambu-gray-dark text-white focus:ring-bambu-gray`,danger:`bg-red-600 hover:bg-red-700 text-white focus:ring-red-500`,ghost:`bg-transparent hover:bg-bambu-dark-tertiary text-bambu-gray-light hover:text-white`}[e]} ${{sm:`px-3 py-1.5 text-sm gap-1.5 min-h-[44px] md:min-h-0`,md:`px-4 py-2 text-sm gap-2 min-h-[44px] md:min-h-0`,lg:`px-6 py-3 text-base gap-2 min-h-[48px] md:min-h-0`}[n]} ${r}`,...a,children:i})}export{n as t};
+import{w as e}from"./client-B6bKL_Bn.js";var t=e();function n({variant:e=`primary`,size:n=`md`,className:r=``,children:i,...a}){return(0,t.jsx)(`button`,{className:`inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-bambu-dark disabled:opacity-50 disabled:cursor-not-allowed ${{primary:`bg-bambu-green hover:bg-bambu-green-light text-white focus:ring-bambu-green`,secondary:`bg-bambu-dark-tertiary hover:bg-bambu-gray-dark text-white focus:ring-bambu-gray`,danger:`bg-red-600 hover:bg-red-700 text-white focus:ring-red-500`,ghost:`bg-transparent hover:bg-bambu-dark-tertiary text-bambu-gray-light hover:text-white`}[e]} ${{sm:`px-3 py-1.5 text-sm gap-1.5 min-h-[44px] md:min-h-0`,md:`px-4 py-2 text-sm gap-2 min-h-[44px] md:min-h-0`,lg:`px-6 py-3 text-base gap-2 min-h-[48px] md:min-h-0`}[n]} ${r}`,...a,children:i})}export{n as t};

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


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


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


+ 1 - 1
static/assets/Card-D08KwdrO.js → static/assets/Card-C_y3K6sN.js

@@ -1 +1 @@
-import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-MyvT0tak.js";var r=e(t(),1),i=n(),a=(0,r.createContext)(`normal`);function o({density:e,children:t}){return(0,i.jsx)(a.Provider,{value:e,children:t})}function s({children:e,className:t=``,onClick:n,onContextMenu:r,...a}){return(0,i.jsx)(`div`,{className:`bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary card-shadow ${n?`cursor-pointer`:``} ${t}`,onClick:n,onContextMenu:r,...a,children:e})}function c({children:e,className:t=``,dense:n}){let o=(0,r.useContext)(a)===`dense`;return(0,i.jsx)(`div`,{className:`${n??o?`px-4 py-2.5`:`px-6 py-4`} border-b border-bambu-dark-tertiary ${t}`,children:e})}function l({children:e,className:t=``,dense:n}){let o=(0,r.useContext)(a)===`dense`;return(0,i.jsx)(`div`,{className:`${n??o?`p-4`:`p-6`} ${t}`,children:e})}export{c as i,l as n,o as r,s as t};
+import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-B6bKL_Bn.js";var r=e(t(),1),i=n(),a=(0,r.createContext)(`normal`);function o({density:e,children:t}){return(0,i.jsx)(a.Provider,{value:e,children:t})}function s({children:e,className:t=``,onClick:n,onContextMenu:r,...a}){return(0,i.jsx)(`div`,{className:`bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary card-shadow ${n?`cursor-pointer`:``} ${t}`,onClick:n,onContextMenu:r,...a,children:e})}function c({children:e,className:t=``,dense:n}){let o=(0,r.useContext)(a)===`dense`;return(0,i.jsx)(`div`,{className:`${n??o?`px-4 py-2.5`:`px-6 py-4`} border-b border-bambu-dark-tertiary ${t}`,children:e})}function l({children:e,className:t=``,dense:n}){let o=(0,r.useContext)(a)===`dense`;return(0,i.jsx)(`div`,{className:`${n??o?`p-4`:`p-6`} ${t}`,children:e})}export{c as i,l as n,o as r,s as t};

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


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


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


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


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


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


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


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


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


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


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


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


+ 1 - 1
static/assets/FilamentSwatch-CjUd2OuH.js → static/assets/FilamentSwatch-DyaIHELd.js

@@ -1 +1 @@
-import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-MyvT0tak.js";import{n as r,r as i}from"./filamentSwatchHelpers-C3r5m07g.js";var a=e(t(),1),o=n();function s({rgba:e,extraColors:t,effectType:n,subtype:s,className:c=`w-5 h-5`,shape:l=`circle`,style:u,title:d,effectSize:f}){let p=(0,a.useMemo)(()=>i(t),[t]),m=(0,a.useMemo)(()=>r({effectSize:f,rgba:e,extraColors:t,effectType:n,subtype:s}),[f,e,t,n,s]),h=m.backgroundImage,g=m.backgroundSize,_=l===`circle`||l===`pill`?`rounded-full`:`rounded`,v=(()=>{if(!e)return;let t=e.replace(/^#/,``);return t.length>=8&&t.substring(6,8).toLowerCase()!==`ff`?`#${t.substring(0,8)}`:`#${t.substring(0,6)}`})(),y=d??(p.length>0?p.join(`, `):v);return(0,o.jsx)(`span`,{"data-testid":`filament-swatch`,className:`${c} ${_} border border-black/20 inline-block flex-shrink-0`,style:{backgroundImage:h,backgroundSize:g,...u},title:y})}export{s as t};
+import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-B6bKL_Bn.js";import{n as r,r as i}from"./filamentSwatchHelpers-C3r5m07g.js";var a=e(t(),1),o=n();function s({rgba:e,extraColors:t,effectType:n,subtype:s,className:c=`w-5 h-5`,shape:l=`circle`,style:u,title:d,effectSize:f}){let p=(0,a.useMemo)(()=>i(t),[t]),m=(0,a.useMemo)(()=>r({effectSize:f,rgba:e,extraColors:t,effectType:n,subtype:s}),[f,e,t,n,s]),h=m.backgroundImage,g=m.backgroundSize,_=l===`circle`||l===`pill`?`rounded-full`:`rounded`,v=(()=>{if(!e)return;let t=e.replace(/^#/,``);return t.length>=8&&t.substring(6,8).toLowerCase()!==`ff`?`#${t.substring(0,8)}`:`#${t.substring(0,6)}`})(),y=d??(p.length>0?p.join(`, `):v);return(0,o.jsx)(`span`,{"data-testid":`filament-swatch`,className:`${c} ${_} border border-black/20 inline-block flex-shrink-0`,style:{backgroundImage:h,backgroundSize:g,...u},title:y})}export{s as t};

File diff suppressed because it is too large
+ 0 - 1
static/assets/FileManagerPage-CGQxvG5e.js


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


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


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


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


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


File diff suppressed because it is too large
+ 0 - 0
static/assets/IconPicker-2dCY0eBf.js


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


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


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


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


File diff suppressed because it is too large
+ 0 - 0
static/assets/LoginPage-GFM57w-5.js


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


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


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


File diff suppressed because it is too large
+ 0 - 0
static/assets/NotificationsPage-C-zC1yEc.js


+ 1 - 1
static/assets/NumberInput-Dpt1IWQn.js → static/assets/NumberInput-DiEjrvBB.js

@@ -1 +1 @@
-import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-MyvT0tak.js";var r=e(t(),1),i=n();function a({value:e,onChange:t,min:n,max:a,integer:o=!0,fallback:s,onBlur:c,...l}){let[u,d]=(0,r.useState)(null),f=e=>o?parseInt(e,10):parseFloat(e),p=e=>(n===void 0||e>=n)&&(a===void 0||e<=a),m=e=>Math.min(a??1/0,Math.max(n??-1/0,e));return(0,i.jsx)(`input`,{...l,type:`number`,min:n,max:a,value:u??e??``,onChange:n=>{let r=n.target.value;d(r);let i=f(r);!Number.isNaN(i)&&p(i)&&i!==e&&t(i)},onBlur:n=>{if(u!==null){let n=f(u),r=Number.isNaN(n)?s:n;r!==void 0&&m(r)!==e&&t(m(r)),d(null)}c?.(n)}})}export{a as t};
+import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-B6bKL_Bn.js";var r=e(t(),1),i=n();function a({value:e,onChange:t,min:n,max:a,integer:o=!0,fallback:s,onBlur:c,...l}){let[u,d]=(0,r.useState)(null),f=e=>o?parseInt(e,10):parseFloat(e),p=e=>(n===void 0||e>=n)&&(a===void 0||e<=a),m=e=>Math.min(a??1/0,Math.max(n??-1/0,e));return(0,i.jsx)(`input`,{...l,type:`number`,min:n,max:a,value:u??e??``,onChange:n=>{let r=n.target.value;d(r);let i=f(r);!Number.isNaN(i)&&p(i)&&i!==e&&t(i)},onBlur:n=>{if(u!==null){let n=f(u),r=Number.isNaN(n)?s:n;r!==void 0&&m(r)!==e&&t(m(r)),d(null)}c?.(n)}})}export{a as t};

+ 1 - 1
static/assets/OverlayFrame-DKqJYLlo.js → static/assets/OverlayFrame-2JWsWg1w.js

@@ -1 +1 @@
-import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{a as t,k as n,w as r}from"./client-MyvT0tak.js";var i=e(n(),1);function a(e,n=null,r=0){let[a,o]=(0,i.useState)(null);return(0,i.useEffect)(()=>{if(!e)return;let i=new AbortController,a;return t.getOverlayLogo(n,i.signal).then(e=>{i.signal.aborted||(e?(a=URL.createObjectURL(e),o({url:a,token:n,revision:r})):o(null))}).catch(()=>{i.signal.aborted||o(null)}),()=>{i.abort(),a&&URL.revokeObjectURL(a)}},[e,n,r]),e&&a?.token===n&&a.revision===r?a.url:null}var o={logo:!1,logoRevision:0,from:``,to:``};function s(e){return/^#[0-9a-f]{6}$/i.test(e)}function c(e,t){return e&&t&&s(e)&&s(t)?`linear-gradient(to right, ${e}, ${t})`:void 0}function l(e){if(e)return{backgroundImage:e,backgroundClip:`text`,WebkitBackgroundClip:`text`,color:`transparent`,WebkitTextFillColor:`transparent`}}var u={landscape:{width:1920,height:1080},portrait:{width:1080,height:1920}},d=r();function f({layout:e,preview:t=!1,children:n}){let r=(0,i.useRef)(null),[a,o]=(0,i.useState)(0),{width:s,height:c}=u[e];(0,i.useLayoutEffect)(()=>{let e=r.current;if(!e)return;let t=()=>o(Math.min(e.clientWidth/s,e.clientHeight/c));if(t(),typeof ResizeObserver>`u`)return window.addEventListener(`resize`,t),()=>window.removeEventListener(`resize`,t);let n=new ResizeObserver(t);return n.observe(e),()=>n.disconnect()},[s,c]);let l={width:s,height:c,"--overlay-vmin":`10.8px`,"--overlay-height":`${c}px`,position:`absolute`,top:`50%`,left:`50%`,transform:`translate(-50%, -50%) scale(${a})`,transformOrigin:`center`};return(0,d.jsx)(`div`,{ref:r,className:t?`relative w-full overflow-hidden`:`fixed inset-0 overflow-hidden`,style:t?{aspectRatio:`${s} / ${c}`}:void 0,children:(0,d.jsx)(`div`,{style:l,children:n})})}export{c as a,s as i,u as n,l as o,o as r,a as s,f as t};
+import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{a as t,k as n,w as r}from"./client-B6bKL_Bn.js";var i=e(n(),1);function a(e,n=null,r=0){let[a,o]=(0,i.useState)(null);return(0,i.useEffect)(()=>{if(!e)return;let i=new AbortController,a;return t.getOverlayLogo(n,i.signal).then(e=>{i.signal.aborted||(e?(a=URL.createObjectURL(e),o({url:a,token:n,revision:r})):o(null))}).catch(()=>{i.signal.aborted||o(null)}),()=>{i.abort(),a&&URL.revokeObjectURL(a)}},[e,n,r]),e&&a?.token===n&&a.revision===r?a.url:null}var o={logo:!1,logoRevision:0,from:``,to:``};function s(e){return/^#[0-9a-f]{6}$/i.test(e)}function c(e,t){return e&&t&&s(e)&&s(t)?`linear-gradient(to right, ${e}, ${t})`:void 0}function l(e){if(e)return{backgroundImage:e,backgroundClip:`text`,WebkitBackgroundClip:`text`,color:`transparent`,WebkitTextFillColor:`transparent`}}var u={landscape:{width:1920,height:1080},portrait:{width:1080,height:1920}},d=r();function f({layout:e,preview:t=!1,children:n}){let r=(0,i.useRef)(null),[a,o]=(0,i.useState)(0),{width:s,height:c}=u[e];(0,i.useLayoutEffect)(()=>{let e=r.current;if(!e)return;let t=()=>o(Math.min(e.clientWidth/s,e.clientHeight/c));if(t(),typeof ResizeObserver>`u`)return window.addEventListener(`resize`,t),()=>window.removeEventListener(`resize`,t);let n=new ResizeObserver(t);return n.observe(e),()=>n.disconnect()},[s,c]);let l={width:s,height:c,"--overlay-vmin":`10.8px`,"--overlay-height":`${c}px`,position:`absolute`,top:`50%`,left:`50%`,transform:`translate(-50%, -50%) scale(${a})`,transformOrigin:`center`};return(0,d.jsx)(`div`,{ref:r,className:t?`relative w-full overflow-hidden`:`fixed inset-0 overflow-hidden`,style:t?{aspectRatio:`${s} / ${c}`}:void 0,children:(0,d.jsx)(`div`,{style:l,children:n})})}export{c as a,s as i,u as n,l as o,o as r,a as s,f as t};

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


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


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


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


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


File diff suppressed because it is too large
+ 0 - 0
static/assets/ProjectDetailPage-5pebL_r1.js


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


File diff suppressed because it is too large
+ 0 - 0
static/assets/QueryClientProvider-3QLKTZ0G.js


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


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


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


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


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


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


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


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


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


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


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


File diff suppressed because it is too large
+ 1 - 1
static/assets/SpoolBuddySettingsPage-DBqGJylo.js


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


+ 1 - 1
static/assets/SpoolIcon-Cp29egKy.js → static/assets/SpoolIcon-CSEM0KL-.js

@@ -1 +1 @@
-import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-MyvT0tak.js";import{n as r}from"./filamentSwatchHelpers-C3r5m07g.js";import{t as i}from"./spoolPaint-Imx_KAjK.js";var a=e(t(),1),o=n();function s({spool:e,inset:t,size:n,border:i}){let s=n>=80?`card`:`preview`,c=(0,a.useMemo)(()=>r({effectSize:s,rgba:e.rgba,extraColors:e.extra_colors,effectType:e.effect_type,subtype:e.subtype}),[s,e.rgba,e.extra_colors,e.effect_type,e.subtype]),l=`${t*100}%`;return(0,o.jsx)(`div`,{"data-testid":`spool-paint`,className:`absolute rounded-full box-border`,style:{top:l,left:l,right:l,bottom:l,...c,backgroundPosition:`center`,border:i}})}function c({inset:e}){let t=`${e*100}%`;return(0,o.jsx)(`div`,{"data-testid":`spool-shade`,className:`absolute rounded-full`,style:{top:t,left:t,right:t,bottom:t,backgroundColor:`rgba(0, 0, 0, 0.15)`}})}function l({color:e,isEmpty:t,size:n=32,spool:r}){return t?(0,o.jsx)(`div`,{className:`rounded-full border-2 border-dashed border-zinc-500 flex items-center justify-center`,style:{width:n,height:n},children:(0,o.jsx)(`div`,{className:`w-2 h-2 rounded-full bg-zinc-600`})}):i(r)?(0,o.jsxs)(`div`,{className:`relative shrink-0`,style:{width:n,height:n},children:[(0,o.jsx)(s,{spool:r,size:n,inset:2/32,border:`${1.5*n/32}px solid rgba(255, 255, 255, 0.7)`}),(0,o.jsx)(c,{inset:5/32})]}):(0,o.jsxs)(`svg`,{width:n,height:n,viewBox:`0 0 32 32`,children:[(0,o.jsx)(`circle`,{cx:`16`,cy:`16`,r:`14`,fill:e,stroke:`white`,strokeWidth:`1.5`,strokeOpacity:`0.7`}),(0,o.jsx)(`circle`,{cx:`16`,cy:`16`,r:`11`,fill:e,style:{filter:`brightness(0.85)`}})]})}export{s as n,c as r,l as t};
+import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{k as t,w as n}from"./client-B6bKL_Bn.js";import{n as r}from"./filamentSwatchHelpers-C3r5m07g.js";import{t as i}from"./spoolPaint-Imx_KAjK.js";var a=e(t(),1),o=n();function s({spool:e,inset:t,size:n,border:i}){let s=n>=80?`card`:`preview`,c=(0,a.useMemo)(()=>r({effectSize:s,rgba:e.rgba,extraColors:e.extra_colors,effectType:e.effect_type,subtype:e.subtype}),[s,e.rgba,e.extra_colors,e.effect_type,e.subtype]),l=`${t*100}%`;return(0,o.jsx)(`div`,{"data-testid":`spool-paint`,className:`absolute rounded-full box-border`,style:{top:l,left:l,right:l,bottom:l,...c,backgroundPosition:`center`,border:i}})}function c({inset:e}){let t=`${e*100}%`;return(0,o.jsx)(`div`,{"data-testid":`spool-shade`,className:`absolute rounded-full`,style:{top:t,left:t,right:t,bottom:t,backgroundColor:`rgba(0, 0, 0, 0.15)`}})}function l({color:e,isEmpty:t,size:n=32,spool:r}){return t?(0,o.jsx)(`div`,{className:`rounded-full border-2 border-dashed border-zinc-500 flex items-center justify-center`,style:{width:n,height:n},children:(0,o.jsx)(`div`,{className:`w-2 h-2 rounded-full bg-zinc-600`})}):i(r)?(0,o.jsxs)(`div`,{className:`relative shrink-0`,style:{width:n,height:n},children:[(0,o.jsx)(s,{spool:r,size:n,inset:2/32,border:`${1.5*n/32}px solid rgba(255, 255, 255, 0.7)`}),(0,o.jsx)(c,{inset:5/32})]}):(0,o.jsxs)(`svg`,{width:n,height:n,viewBox:`0 0 32 32`,children:[(0,o.jsx)(`circle`,{cx:`16`,cy:`16`,r:`14`,fill:e,stroke:`white`,strokeWidth:`1.5`,strokeOpacity:`0.7`}),(0,o.jsx)(`circle`,{cx:`16`,cy:`16`,r:`11`,fill:e,style:{filter:`brightness(0.85)`}})]})}export{s as n,c as r,l as t};

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


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


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


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


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


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


+ 1 - 1
static/assets/ThumbnailHoverPreview-DYx6XfzV.js → static/assets/ThumbnailHoverPreview-Df78X31l.js

@@ -1 +1 @@
-import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{C as t,k as n,w as r}from"./client-MyvT0tak.js";import{t as i}from"./react-dom-hYJuSgam.js";var a=t(`coins`,[[`circle`,{cx:`8`,cy:`8`,r:`6`,key:`3yglwk`}],[`path`,{d:`M18.09 10.37A6 6 0 1 1 10.34 18`,key:`t5s6rm`}],[`path`,{d:`M7 6h1v4`,key:`1obek4`}],[`path`,{d:`m16.71 13.88.7.71-2.82 2.82`,key:`1rbuyh`}]]),o=e(n(),1),s=e(i(),1),c=r(),l=256,u=8,d=8;function f({src:e,children:t,className:n,size:r=l}){let i=(0,o.useRef)(null),[a,f]=(0,o.useState)(null),p=(0,o.useCallback)(()=>{if(!e||!window.matchMedia(`(hover: hover) and (pointer: fine)`).matches)return;let t=i.current?.getBoundingClientRect();if(!t)return;let n=t.right+u,a=n+r+d<=window.innerWidth?n:Math.max(d,t.left-u-r),o=Math.max(d,Math.min(t.top,window.innerHeight-r-d));f({left:a,top:o})},[e,r]),m=(0,o.useCallback)(()=>f(null),[]);return(0,o.useEffect)(()=>{if(a)return window.addEventListener(`scroll`,m,!0),()=>window.removeEventListener(`scroll`,m,!0)},[a,m]),(0,c.jsxs)(`div`,{ref:i,className:n,onMouseEnter:p,onMouseLeave:m,children:[t,a&&e&&(0,s.createPortal)((0,c.jsx)(`img`,{src:e,alt:``,"data-testid":`thumbnail-hover-preview`,style:{left:a.left,top:a.top,width:r,height:r},className:`fixed z-[60] pointer-events-none object-cover rounded-lg shadow-2xl border-2 border-bambu-dark-tertiary bg-bambu-dark`}),document.body)]})}export{a as n,f as t};
+import{i as e}from"./rolldown-runtime-aKtaBQYM.js";import{C as t,k as n,w as r}from"./client-B6bKL_Bn.js";import{t as i}from"./react-dom-Dlxsc6eh.js";var a=t(`coins`,[[`circle`,{cx:`8`,cy:`8`,r:`6`,key:`3yglwk`}],[`path`,{d:`M18.09 10.37A6 6 0 1 1 10.34 18`,key:`t5s6rm`}],[`path`,{d:`M7 6h1v4`,key:`1obek4`}],[`path`,{d:`m16.71 13.88.7.71-2.82 2.82`,key:`1rbuyh`}]]),o=e(n(),1),s=e(i(),1),c=r(),l=256,u=8,d=8;function f({src:e,children:t,className:n,size:r=l}){let i=(0,o.useRef)(null),[a,f]=(0,o.useState)(null),p=(0,o.useCallback)(()=>{if(!e||!window.matchMedia(`(hover: hover) and (pointer: fine)`).matches)return;let t=i.current?.getBoundingClientRect();if(!t)return;let n=t.right+u,a=n+r+d<=window.innerWidth?n:Math.max(d,t.left-u-r),o=Math.max(d,Math.min(t.top,window.innerHeight-r-d));f({left:a,top:o})},[e,r]),m=(0,o.useCallback)(()=>f(null),[]);return(0,o.useEffect)(()=>{if(a)return window.addEventListener(`scroll`,m,!0),()=>window.removeEventListener(`scroll`,m,!0)},[a,m]),(0,c.jsxs)(`div`,{ref:i,className:n,onMouseEnter:p,onMouseLeave:m,children:[t,a&&e&&(0,s.createPortal)((0,c.jsx)(`img`,{src:e,alt:``,"data-testid":`thumbnail-hover-preview`,style:{left:a.left,top:a.top,width:r,height:r},className:`fixed z-[60] pointer-events-none object-cover rounded-lg shadow-2xl border-2 border-bambu-dark-tertiary bg-bambu-dark`}),document.body)]})}export{a as n,f as t};

File diff suppressed because it is too large
+ 0 - 0
static/assets/ToastContext-9c4_oWA_.js


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