fix(slicer): encode file URL in protocol-handler scheme on Windows/Linux

"Open in Slicer" emitted `orcaslicer://open?file=<URL>` and
  `bambustudio://open?file=<URL>` by plain string concatenation, relying
  on a stale comment that claimed the browser preserves URLs in the query
  string. That ignores the slicer's own `url_decode()` on the received
  query (BS post_init → url_decode + split_str; OrcaSlicer Downloader
  regex + url_decode), so any already-percent-encoded character — most
  commonly `%20` from filenames with spaces — decoded to a literal space
  and the slicer's subsequent HTTP GET returned 0 bytes or 404.

  All three URL forms now use `encodeURIComponent()` (matching what the
  macOS `bambustudioopen://` branch was already doing, which is why the
  bug didn't surface on macOS). Corrected the file-level comment to
  document the actual invariant.

  Regression test in slicer.test.ts feeds the exact issue reproduction
  URL and asserts `%2520` appears in the generated href.
This commit is contained in:
maziggy
2026-04-23 10:03:44 +02:00
parent 62f2e616a0
commit 76b997fc8a
5 changed files with 25 additions and 18 deletions
+1
View File
@@ -13,6 +13,7 @@ All notable changes to Bambuddy will be documented in this file.
- **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](https://github.com/maziggy/bambuddy/issues/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.
## [0.2.3.2] - 2020-04-22
+11 -6
View File
@@ -51,7 +51,7 @@ describe('slicer utility', () => {
expect(appendSpy).toHaveBeenCalled();
expect(createdLink.href).toContain('bambustudio://open?file=');
expect(createdLink.href).toContain('http://localhost:8000/file.3mf');
expect(createdLink.href).toContain(encodeURIComponent('http://localhost:8000/file.3mf'));
expect(clickSpy).toHaveBeenCalled();
expect(removeSpy).toHaveBeenCalled();
});
@@ -78,15 +78,20 @@ describe('slicer utility', () => {
expect(createdLink.href).toContain('open?file=');
});
it('does not encode the file URL for orcaslicer', () => {
it('encodes filenames with spaces so the slicer receives %20 after url_decode (issue #1059)', () => {
vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (Windows NT 10.0)');
const url = 'http://localhost:8000/api/v1/archives/1/file/My Model.3mf';
// A download URL that already contains percent-encoded spaces — this is how
// Bambuddy emits archive paths (the filename in the URL is URL-path-encoded).
const url = 'http://localhost:8000/api/v1/archives/1/dl/TOKEN/Toothpick%20Launcher%20Print-in-Place.3mf';
openInSlicer(url, 'orcaslicer');
// The href should contain the raw URL (browser may normalize it but it should not be double-encoded)
// Each %20 in the input must become %2520 in the href so that after the
// slicer's own url_decode() it comes back as %20 (preserving the original
// URL). Without this, the slicer would decode %20 → literal space and its
// subsequent HTTP fetch would fail.
expect(createdLink.href).toContain('orcaslicer://open?file=');
// Should NOT contain %253A (double-encoded colon)
expect(createdLink.href).not.toContain('%253A');
expect(createdLink.href).toContain('Toothpick%2520Launcher%2520Print-in-Place.3mf');
expect(createdLink.href).not.toContain('Toothpick%20Launcher');
});
it('defaults to bambu_studio when no slicer specified', () => {
+11 -10
View File
@@ -15,9 +15,13 @@
* - (orcaslicer|bambustudio|...)://open?file=<URL>
* - bambustudioopen://<URL>
*
* Key insight: Using ?file= query format, the browser's URL parser preserves
* http:// in the query string without any encoding. Only the macOS-specific
* bambustudioopen:// format needs encodeURIComponent (BS calls url_decode).
* Key insight: every form needs encodeURIComponent on the file URL, because
* the slicer calls url_decode() on the received query (post_init calls
* url_decode then split_str; MacOpenURL strips the prefix then url_decode;
* OrcaSlicer's Downloader regex-extracts then url_decode). Without encoding,
* any already-percent-encoded character in the download URL (most commonly
* %20 in filenames with spaces) decodes to a literal space and the slicer's
* subsequent HTTP fetch fails with a 0-byte body or 404. See issue #1059.
*/
export type SlicerType = 'bambu_studio' | 'orcaslicer';
@@ -51,22 +55,19 @@ export function detectPlatform(): Platform {
export function openInSlicer(downloadUrl: string, slicer: SlicerType = 'bambu_studio'): void {
let url: string;
const encoded = encodeURIComponent(downloadUrl);
if (slicer === 'orcaslicer') {
// OrcaSlicer: ?file= query format — http:// preserved in query string
url = `orcaslicer://open?file=${downloadUrl}`;
url = `orcaslicer://open?file=${encoded}`;
} else {
const platform = detectPlatform();
if (platform === 'macos') {
// macOS only: bambustudioopen scheme via MacOpenURL() callback.
// Must encode because bare http:// in authority gets mangled by browser.
// BS calls url_decode() after stripping "bambustudioopen://" prefix.
url = `bambustudioopen://${encodeURIComponent(downloadUrl)}`;
url = `bambustudioopen://${encoded}`;
} else {
// Windows/Linux: bambustudio://open?file= via post_init() CLI args.
// The ?file= query format preserves http:// without encoding.
// IMPORTANT: On Linux, BS only handles "bambustudio://open" prefix —
// it does NOT process "bambustudioopen://" (that's macOS-only).
url = `bambustudio://open?file=${downloadUrl}`;
url = `bambustudio://open?file=${encoded}`;
}
}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -26,7 +26,7 @@
<!-- Splash screens for iOS -->
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
<script type="module" crossorigin src="/assets/index-B2bhLHKc.js"></script>
<script type="module" crossorigin src="/assets/index-DbCeLLRM.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CiCRNaHx.css">
</head>
<body>