github maziggy/bambuddy v0.2.4b1-daily.20260424
Daily Beta Build v0.2.4b1-daily.20260424

pre-release10 hours ago

Note

This is a daily beta build (2026-04-24). It contains the latest fixes and improvements but may have undiscovered issues.

Docker users: Update by pulling the new image:

docker pull ghcr.io/maziggy/bambuddy:daily

or

docker pull maziggy/bambuddy:daily


**Tip:** Use [Watchtower](https://containrrr.dev/watchtower/) to automatically update when new daily builds are pushed.

Added

  • Tailscale integration for virtual printers (builds on #1070 by legend813) — Opt-in per-VP Tailscale toggle brings each virtual printer into the tailnet, so it's reachable from any tailnet device over a private WireGuard tunnel without port forwarding or public exposure. When enabled, Bambuddy provisions a Let's Encrypt cert for the VP's MagicDNS hostname via tailscale cert and the MQTT/FTPS listeners serve it. Slicer-side caveat worth knowing up front: both Bambu Studio and OrcaSlicer only accept IP addresses (not hostnames) in the Add Printer dialog, so the LE cert's hostname validation doesn't apply — users still need the Bambuddy CA imported into the slicer, same as LAN mode. The practical benefit here is the private tunnel (remote access without DDNS / port forwarding / public exposure), not cert-import elimination. Default is opt-out (toggle off) so users without Tailscale don't see cert-provisioning attempts or log noise. When a user flips the toggle on a host without a working Tailscale binary, the backend returns 409 tailscale_not_available and the UI reverts + surfaces a specific toast pointing at the setup steps (install Tailscale → tailscale uptailscale set --operator=<user> → enable HTTPS in the tailnet admin console). Docker image now ships the tailscale CLI pre-installed; users wire up by uncommenting the /var/run/tailscale/tailscaled.sock volume mount in docker-compose.yml. The MagicDNS hostname is surfaced on the VP card with a copy-to-clipboard button (modern navigator.clipboard in secure contexts, document.execCommand fallback for plain-HTTP contexts with textarea cleanup in finally). Cert renewal runs daily in-process and restarts only the affected VP's TLS listeners. New i18n keys virtualPrinter.tailscaleDisabled.{title,description} + virtualPrinter.toast.{tailscaleNotAvailable,copyFailed} across all 8 locales with full translations. 3 new backend integration tests for the 409 guard, 2 unit tests for the _cancel_restart_task self-await guard, 4 unit tests for the settings-dedupe migration, and 3 new frontend tests for the clipboard fallback path. Thanks to legend813 for the original opt-out toggle PR that this was built on top of.
  • Library Trash Bin + Admin Bulk Purge + Auto-Purge (#1008) — Library files now move to a trash bin on delete instead of being hard-deleted from disk, with a configurable retention window (default 30 days) before a background sweeper permanently removes them. Admins get a new "Purge old" action on the File Manager that shows a live preview of count + total size before moving every file older than N days (with an opt-in toggle for never-printed files, on by default) into the trash in one shot. A new Auto-purge setting in Settings → File Manager runs the same purge automatically on a 24-hour cadence when enabled — files still go to Trash first so the retention window remains the safety net; default-off so existing installs don't surprise anyone. Both the per-user delete flow and the admin bulk purge go through the same trash — regular users see and manage their own trashed files; admins see everyone's. External (linked) files bypass trash and keep the original hard-delete behaviour since their bytes aren't under Bambuddy's control. New library:purge permission gates the admin operations; retention is adjustable inline on the Trash page for admins. Adds nullable deleted_at column on library_files with an index (dialect-aware migration: DATETIME on SQLite, TIMESTAMP on PostgreSQL, since raw DATETIME is SQLite-only syntax); every LibraryFile query site now routes through a new LibraryFile.active() classmethod so trashed rows can't leak into listings, print dispatch, MakerWorld dedupe, or stats. 17 new backend integration tests + 8 new frontend component/page tests; localised across all 8 UI languages. Thanks to cadtoolbox for the proposal and the follow-up answers that tightened the spec.
  • Archive Auto-Purge (#1008 follow-up) — Settings → Archives now has an auto-purge toggle plus a Purge archives now action on the Archives page header (next to Upload 3MF, mirroring File Manager's placement) that hard-deletes print archives not printed within a configurable window (default 365 days, min 7, max 10 years) with the same live-preview modal as the library purge. Reprinting an archive reuses the row and updates its completed_at, so the purge honours the most recent print completion — a two-year-old archive you reprinted yesterday is not eligible for deletion. Unlike the library trash, archives are hard-deleted: print history is a decaying timeline, so there is no trash bin intermediate; download or favourite anything you want to keep first. The sweeper runs on the same 15-minute scheduler as the library trash but throttles actual purge runs to once per 24h so a tight tick cadence doesn't churn the DB. Each purged archive goes through the existing safety-checked ArchiveService.delete_archive path so the 3MF, thumbnail, timelapse, source 3MF, F3D, and photo folder are all cleaned up together with the DB row. Gated by a new dedicated archives:purge permission (Administrators group by default, backfilled on upgrade); 9 new backend integration tests; localised across all 8 UI languages.
  • MakerWorld Integration — Paste any makerworld.com/models/… URL on the new MakerWorld sidebar page to pull the full model metadata, plate list, creator/license info, and per-plate images, then one-click Save or Save & Slice in Bambu Studio / OrcaSlicer per plate. Closes the last workflow gap for LAN-only users who still had to keep the Bambu Handy app installed solely to send MakerWorld models to their printers. Reuses the existing Bambu Cloud login token for download authentication — no separate OAuth flow, no companion browser extension, no cookie paste. LibraryFile now tracks source_type + source_url, so re-importing the same plate dedupes to the existing library entry. Search / browse-catalogue is intentionally out of scope because MakerWorld's public search endpoint isn't reachable from a server-originated request; the URL-paste flow covers the actual discovery pattern (Reddit / YouTube / shared links).
    Endpoint route (non-obvious, ~1 day of reverse engineering)Pr0zak/YASTL#51 documented that makerworld.com-hosted design-service endpoints are cookie-gated (Cloudflare WAF serves a generic "Please log in to download models" to any non-browser bearer request), but the same backend is exposed unblocked at api.bambulab.com. The working path turned out to be GET https://api.bambulab.com/v1/iot-service/api/user/profile/{profileId}?model_id={alphanumericModelId} with Authorization: Bearer <cloud_token> — a different service (iot-service, not design-service) and a different host, accepting the same bearer the user already signs in with. Response carries a 5-minute-TTL presigned S3 URL (s3.us-west-2.amazonaws.com/…?at=…&exp=…&key=…). The modelId query param is the alphanumeric identifier (e.g. US2bb73b106683e5) that only appears in the design response body, not the integer designId from the /models/{N} URL — so the import flow fetches design metadata first, reads modelId, then calls iot-service. S3 presigned URLs must be fetched with urllib.request (not httpx / curl_cffi) because the signature is computed over the exact query-string bytes and any normalising encoder breaks it with SignatureDoesNotMatch 400s (YASTL#52 describes the same issue). Every other published reverse-engineering project we evaluated (schwarztim/bambu-mcp, kata-kas/MMP) solved the gating by shipping "paste your browser cookie" flows; reusing the existing Bambu Cloud bearer is a substantially cleaner UX and the only fully-automated path.
    UI and UX features — per-plate picker with inline Save / Save & Slice in Bambu Studio / OrcaSlicer buttons, Import all to batch-import every plate sequentially, folder picker on the page (default: auto-created top-level "MakerWorld" folder), image gallery lightbox per plate (keyboard ←/→/Esc), two-column sticky layout with Recent imports sidebar (last 10 MakerWorld imports), per-plate inline follow-up actions after import (View in File Manager / Open in Bambu Studio / Open in OrcaSlicer / Remove from library), per-plate delete via the standard Bambuddy confirm modal (no browser confirm()), elapsed-time + phase label ("Resolving … 3 s", "Downloading … 18 s") during the synchronous import POST so users see progress on large 3MFs, URL-change detection that drops the preview when the pasted URL diverges from the resolved one (fixes a class of "I thought I was importing model B but got A" dedupe confusion), rich error toasts per-phase, and the slicer-open path reuses Bambuddy's existing token-embedded library download (/library/files/{id}/dl/{token}/{filename}) so the handoff works even with auth enabled. Localised across all eight UI languages.
    Security hardening — the MakerWorld description HTML is user-authored and goes through DOMPurify.sanitize() before dangerouslySetInnerHTML. <img> tags inside summaries are rewritten to route through Bambuddy's /makerworld/thumbnail proxy so the SPA's img-src 'self' data: blob: CSP stays unwidened. Thumbnail proxy now uses follow_redirects=False (the host-allowlist guarantee is only meaningful on the initial URL — a 302 to 169.254.169.254 would otherwise bypass it). The 3MF CDN fetch sends only User-Agent — the Bambu Cloud bearer is never forwarded to the CDN. S3 presigned-URL fetch uses a urllib.request opener with a no-op HTTPRedirectHandler for the same reason. Filenames from MakerWorld responses are os.path.basename'd before persisting, so a malicious name: "../../evil.3mf" cannot surface a path-traversal string into the DB / UI (on-disk storage uses a UUID filename regardless). New routes respect the MAKERWORLD_VIEW (resolve / recent-imports / status) and MAKERWORLD_IMPORT (import) permissions. SSRF guard on downloads rejects any host that isn't makerworld.bblmw.com, public-cdn.bblmw.com, or a .amazonaws.com subdomain.
    Test coverage — 46 unit tests for services/makerworld.py (header shape, API base, get_design/get_design_instances/get_profile, get_profile_download 200/401/403/404/no-token, download_3mf SSRF rejection of 4 hostile hosts, S3 path delegation, CDN path with minimal headers, size-cap, _download_s3_urllib happy/redirect/size/network paths, fetch_thumbnail with follow_redirects=False); 19 route tests (/resolve, /import with folder autocreation + explicit folder + dedupe + filename basename + profile_id response, /recent-imports with empty-list / ordering / pydantic shape / limit clamping, _canonical_url unit); 12 frontend tests (button labels, slicer-name interpolation, URL-change detection, inline post-import actions, Recent imports rendering, DOMPurify <script> strip).

Changed

  • Settings page: permission-gated instead of admin-only — the Settings sidebar entry has always been visible to any user holding settings:read, but the route guard required admin role, so a non-admin with settings:read would see the entry, click it, and get silently redirected back to the dashboard. The route guard now matches the sidebar: any user with settings:read can open the page, and the individual tabs / cards continue to enforce their own per-feature permissions (users:read, groups:update, oidc:*, etc. — many of them admin-only, some not). Group editor routes moved to permission-based guards too (groups:create for /groups/new, groups:update for /groups/:id/edit), so permission delegation works end-to-end. Admins retain full access since admins implicitly hold every permission.

Fixed

  • Uploads to writable external folders silently landed in internal storage (#1112) — LibraryFolder has an external_readonly flag, so the model already distinguishes writable from read-only external mounts, but POST /library/files rejected only the read-only branch and then unconditionally wrote to get_library_files_dir() with a UUID-scoped filename. The resulting LibraryFile row linked back to the external folder via folder_id, so the file showed up in the Bambuddy UI and could be printed, but the bytes physically lived in archive/library/files/ and never touched the mount — invisible from any other machine accessing the same NAS/SMB share. New _resolve_upload_destination() helper detects writable external targets and writes through to <external_path>/<filename> (keeping the original filename so the file is recognisable on the mount), with guards for missing/inaccessible path (400), non-writable mount (400), pre-existing filename on the mount (409 — no silent overwrite; the user is expected to rename and retry, matching how scan treats external files as externally-owned bytes), and a resolve + relative_to path-traversal guard on the joined destination. DB row now matches what scan produces: is_external=True, file_path=<absolute external path>, so the existing download / delete / dedupe paths work unchanged (to_absolute_path already fast-paths is_absolute() inputs, and external-file deletion already bypasses trash and only drops the DB row + internal thumbnail). POST /library/files/extract-zip is now rejected against any external folder (not just read-only) with a clear "extract the ZIP on the external mount and run Scan" message — the nested-subfolder creation path would need to mkdir on the mount and create matching is_external=True LibraryFolder rows, which is a separate design round, and the Scan flow already handles that shape. 7 new integration tests cover: bytes land on the mount; DB row has is_external=True + absolute file_path; filename collision → 409 with prior bytes preserved; vanished external path → 400; path-traversal filename never escapes the external dir; extract-zip into writable external rejected with the Scan hint; root uploads unchanged.
  • Queue item stuck at "printing" when print failed before reaching RUNNING (#1111) — Dispatching a file sliced for the wrong nozzle size (or any other pre-print error: AMS fault, wrong plate, nozzle not installed, etc.) left the queue item stuck at status="printing" forever, blocking every subsequent pending item for that printer (check_queue seeds busy_printers from any row in 'printing' state and skips further dispatches for those printer IDs). Completion detection in BambuMQTTClient._process_message required the print to have reached RUNNING — either via _previous_gcode_state == "RUNNING" or the _was_running fallback — but a nozzle-mismatch failure transitions the printer IDLE → PREPARE → FAILED without ever entering RUNNING, so neither branch matched and on_print_complete never fired. The diagnostic log line at bambu_mqtt.py:2690 ("State is FAILED but completion NOT triggered: prev=PREPARE, was_running=False") confirmed the path. Completion now also fires on FAILED from a pre-print state (PREPARE or SLICING) — restricted to those two so a stale FAILED on first connection (prev=None) still can't accidentally advance an unrelated queue item. Additionally, when a queue item transitions to failed the handler in main.py now populates error_message from the printer's current HMS error list, rendered via the existing backend/app/services/hms_errors.py lookup table (e.g. [0500_4038] The nozzle diameter in sliced file is not consistent with the current nozzle setting. This file can't be printed.) — previously error_message was left NULL, so users saw "failed" with no hint at the cause. 5 new unit tests in TestPrePrintFailureCompletion cover PREPARE→FAILED and SLICING→FAILED firing, IDLE→FAILED and initial-FAILED not firing (boot-time safety), and HMS errors being passed through in the callback payload; 6 new tests in test_hms_error_summary.py cover the error-message formatter (known-code lookup, unknown-code fallback, multi-error join, malformed-entry tolerance, all-malformed → None, empty → None). Thanks to MartinNYHC for the report.
  • Tailscale cert-renewal restart silently failed mid-way (follow-up to #1070) — The daily renewal path creates an asyncio.Task to restart VP services with the new cert. Inside that task, stop_server() / stop_proxy() call _cancel_restart_task(), which cancelled+awaited the currently-running task (itself). The self-await raised RuntimeError, got caught by the broad exception handler, but the cancel flag was still set — so the next await in stop_server raised CancelledError and aborted the restart partway through. The VP kept running the OLD expired cert until the process was manually restarted, silently defeating the feature. _cancel_restart_task now checks asyncio.current_task() and skips the cancel+await when the caller IS the restart task itself. Two new regression tests cover the self-cancel and outside-cancel paths.
  • Settings table filled with duplicate rows on legacy SQLite installs — pre-UNIQUE-constraint databases stored the settings.key column without a unique index, so the seed loop's INSERT OR IGNORE silently degraded to a plain INSERT and every systemctl restart bambuddy added another row of advanced_auth_enabled / smtp_auth_enabled. After a handful of restarts, scalar_one_or_none() in is_advanced_auth_enabled and similar sites blew up with MultipleResultsFound, 500'ing the login flow. run_migrations now dedupes (keeps MIN(id) per key) and creates the missing ix_settings_key unique index before the seed loop runs. Postgres installs were unaffected. 4 new regression tests cover legacy-with-dupes, legacy-already-clean (idempotent), and fresh-install (no-op) paths.
  • Virtual printer card's Tailscale FQDN copy button failed on HTTPnavigator.clipboard.writeText is only available in secure contexts (HTTPS / localhost). When Bambuddy is reached over plain HTTP via a LAN or Tailscale IP, the clipboard API is blocked and the copy button silently failed with a generic "Failed to update settings" toast. Added a legacy document.execCommand('copy') fallback via a hidden textarea for non-secure contexts; the textarea is removed in a finally block so it doesn't leak into the DOM on exception paths. New virtualPrinter.toast.copyFailed i18n key across all 8 locales for the rare case where both paths fail.
  • Install script failed for first-time users — three separate permission issues in install/install.sh stopped the native installer mid-way: (a) download_bambuddy chowned the empty install dir to the service user BEFORE running git clone as the current user → permission denied on .git; (b) setup_virtualenv created the venv as the service user but then ran pip install --upgrade pip as the current user → permission denied writing venv/bin/pip; (c) build_frontend would have hit the same pattern on npm ci. All three now route through sudo -u "$SERVICE_USER" (or sudo -H -u for npm so HOME is set correctly for the npm cache). The git-clone fix runs as root then chowns the tree. macOS path unchanged (no service user there).
  • H2C dual-nozzle detection missed post-2026 serial batches (#1105) — Bambu has started shipping H2C units with a new serial prefix (31B8B… observed on a January 2026 unit) instead of the legacy 094… shared by the H2D/H2C/H2S family. The K-profile edit flow (backend/app/api/routes/kprofiles.py) and the delete-K-profile MQTT path (backend/app/services/bambu_mqtt.py::delete_kprofile) branch on serial prefix to pick the dual-nozzle command format, so units with the new prefix were silently falling into the single-nozzle branch and getting the wrong K-profile payload shape. Added 31B8B (5-char match covering the model code + revision bytes, leaving the revision-letter slot free to iterate) alongside the existing 094 and 20P9 prefixes; runtime paths that auto-detect dual-nozzle from device.extruder.info were already prefix-agnostic. New regression test test_h2c_new_prefix_uses_dual_nozzle_format in test_bambu_mqtt.py. Thanks to m4rtini2 for the report.
  • Spoolman iframe silently blank on HTTPS Bambuddy with HTTP Spoolman (#1096) — Users behind an HTTPS reverse proxy (Traefik / Nginx / Caddy) pointing the Spoolman URL at plain HTTP saw the Filament tab render as a blank page with only a console-side Mixed Content warning. CSP was fine (the #1054 fix already allowed frame-src http:), but browsers enforce mixed-content blocking independently of CSP — an HTTP iframe inside an HTTPS parent is always blocked. Bambuddy can't technically fix this (the browser is correct to refuse), so instead of the silent blank frame the Filament page now detects the protocol mismatch (window.location.protocol === 'https:' plus Spoolman URL starting with http://) and renders an inline warning card explaining the root cause, pointing users at the right fix (put Spoolman behind the same HTTPS reverse proxy and update the Spoolman URL in Settings), and offering an "Open Spoolman in a new tab" button as an immediate workaround — a standalone tab isn't subject to mixed-content rules. Localised across all 8 UI languages. Thanks to jsapede for the report.
  • Reprint-from-Archive left created_by_id as NULL (#730 follow-up) — 0.2.4b1 fixed user attribution for Direct Print / File Manager / Library prints, but the reprint path was still unattributed on the archive row. Reprint intentionally reuses the source archive (to avoid duplicate rows — see register_expected_print), so an archive auto-created from a printer-initiated print with no known user stayed created_by_id=NULL forever, even after multiple reprints by authenticated Bambuddy users. Print Log got the reprinter's username correctly (via _print_user_info), but the Statistics per-user filter — which reads archive.created_by_id — kept showing the archive as unassigned. Fix in main.py's print-complete handler: when the archive has no created_by_id and a print-session user is set (which reprint always sets via set_current_print_user), back-fill the archive's attribution. Never overwrites an existing attribution — the original uploader keeps ownership; NULL archives are the only ones touched. Thanks to 3823u44238 for the detailed retest that caught this.
  • Settings: failed-save toast looped forever when the user lacked settings:update — the Settings page runs a debounced auto-save effect that fires PATCH /settings whenever localSettings diverges from the last server snapshot. When a delegated user with settings:read but not settings:update toggled a control, the effect fired PATCH, got 403, and kept re-firing every ~500 ms producing an endless stream of identical "Failed to save" toasts. Gated at three points so the mutation is never attempted without permission: (1) the updateSetting callback — every onChange path — shows one settings.toast.noPermissionUpdate toast and short-circuits before diverging localSettings; (2) the debounced-save effect safety-nets the same check in case any call site bypassed updateSetting; (3) the language <select> was a fire-and-forget direct api.updateSettings call that always flashed a success toast regardless of outcome — it now goes through updateMutation with the same permission guard. New settings.toast.noPermissionUpdate key added across all 8 locales with full translations (not English-fallback).
  • Groups: edits to custom-group permissions appeared lost on reopen (#1083) — creating a custom group and reopening the editor showed the correct permissions, but after editing that group's permissions and saving, reopening the editor within ~1 minute displayed the pre-edit snapshot as if the save had failed. The backend PATCH /api/v1/groups/{id} was persisting correctly (now covered by four new integration tests in test_groups_api.py, including a direct DB read after update); the issue was purely in the frontend React Query cache — GroupEditPage.onSuccess invalidated ['groups'] (the list) but left the ['group', id] detail cache stale, and with the app-wide 60 s staleTime the next mount served the cached pre-update body instead of refetching. onSuccess now primes the ['group', id] detail cache with the PATCH response body so the next mount hits fresh data immediately without a round-trip. Create-path invalidates ['group'] for symmetry. Regression test in GroupEditPage.test.tsx verifies the detail cache contains the updated permissions after save.
  • Setup: re-enabling auth could 422 on a password the form no longer needs — after disabling authentication and re-enabling it (common when switching between local auth and LDAP, or recovering from a bad config), the setup form still sends admin_password in the body even though the backend route ignores it when an admin user already exists. The SetupRequest Pydantic schema enforced password complexity (uppercase + lowercase + digit + special char) unconditionally, so any existing password that predated the complexity rule — or a legitimate LDAP-mode placeholder — triggered 422 Value error, Password must contain at least one special character before the route body could decide to ignore the field. Complexity validation has moved out of the schema and into the route body, scoped to the branch that actually creates a new local admin. Re-enabling auth with an existing admin (or any LDAP user) now accepts whatever the form sends; fresh first-time setup still rejects weak passwords with a clear 400. Two regression tests added in test_auth_api.py: weak password rejected at setup when creating the first admin, weak/placeholder password accepted when an admin already exists.
  • Queue: batch (quantity>1) double-dispatched onto the same printer — scheduling an ASAP print with quantity > 1 could end up with two queue items in 'printing' status for the same printer, surfaced in the logs as BUG: Multiple queue items in 'printing' status for printer N. The scheduler's in-memory busy_printers set was seeded empty each tick and only populated after _start_print succeeded in the current iteration, so on the next tick (30 s later) _is_printer_idle() read the printer's live MQTT state — which on H2D / P1 series lags several seconds behind the print command and still reported IDLE / FINISH — and dispatched the second batch item onto the already-running printer. check_queue() now queries PrintQueueItem for status='printing' rows and seeds busy_printers with their printer IDs before iterating pending items, so any printer with an outstanding dispatched job is excluded regardless of what MQTT currently reports. Regression covered in test_phantom_print_hardening.py (TestBusyPrinterSeedingFromPrintingItems): seeding query returns printers with 'printing' rows only, returns empty when none exist, and end-to-end check_queue() does not call _start_print for a pending item whose printer already has a 'printing' row even when _is_printer_idle() is forced True.
  • Queue: active-item progress bar flashed 100% before dropping to 0% — immediately after a queue item was dispatched, the per-item progress bar on the Queue page showed 100% (or whatever the prior print's final mc_percent was) for the few seconds between dispatch and the printer's MQTT state transitioning to RUNNING. Frontend QueuePage.tsx read status.progress directly from the printer's live MQTT snapshot, which carries over the last reported value from the previous print until the new one starts ticking. The progress bar, remaining time, ETA, and layer counter are now gated on status.state being RUNNING or PAUSE; in any other state (including FINISH from the prior print, IDLE, or PREPARE while heating) the bar renders at 0% with no stale ETA/layer values.
  • "Open in Slicer" fails on Windows / Linux for any filename containing spaces or special characters (#1059) — clicking "Open in Slicer" from the File Manager or Archives page produced one of three symptoms depending on the file: .3mf files opened Bambu Studio / OrcaSlicer but the app showed "Importing to Bambu Studio failed. Please download the file and open it manually" (the file on disk was 0 bytes); .stl files greyed the button out; .step couldn't be previewed at all. The protocol-handler URL emitted by frontend/src/utils/slicer.ts for OrcaSlicer (orcaslicer://open?file=<URL>) and Windows/Linux Bambu Studio (bambustudio://open?file=<URL>) was built by plain string concatenation with no encodeURIComponent() — the macOS bambustudioopen://<URL> branch was already encoding correctly, which is why macOS users didn't see this. A stale comment block in the file claimed the browser preserves the URL in the query string so no encoding is needed; that's true for the browser-to-OS handoff but ignores that the slicer itself calls url_decode() on the received query (BS post_init() calls url_decode then split_str; OrcaSlicer's Downloader regex-extracts then url_decode). Any already-percent-encoded character in the download URL — most commonly %20 from filenames with spaces, which Bambuddy's archive paths produce naturally — decoded to a literal space and the slicer's subsequent HTTP GET came back 0 bytes or 404. All three URL forms now encodeURIComponent() the file URL, so the slicer sees the correctly-encoded URL after its own url_decode. The comment block is corrected to document the actual invariant. Regression test in slicer.test.ts feeds the exact issue reproduction URL (Toothpick%20Launcher%20Print-in-Place.3mf) and asserts %2520 appears in the generated orcaslicer:// href — so any future refactor that drops the encoding fails CI. Thanks to jsapede for the double-encoding diagnosis and AllanonBrooks and lunaticds for the original reports.

Don't miss a new bambuddy release

NewReleases is sending notifications on new releases.