fix(printers): copy buttons in Printer Info modal work on plain HTTP (#1174)

PrinterInfoModal's CopyButton only tried navigator.clipboard.writeText(),
  which is gated by the secure-context requirement (HTTPS or localhost).
  On the typical Bambuddy deployment shape — bare-IP HTTP on the LAN —
  navigator.clipboard is undefined; the existing try/catch swallowed the
  TypeError, the icon never flipped to the tick, and nothing landed on
  the user's clipboard.

  Fixed by adding the same off-screen-textarea + document.execCommand('copy')
  fallback that CameraTokensPage's plaintext-token modal already uses for
  plain-HTTP LAN deployments. Gate on `navigator.clipboard && window.isSecureContext`,
  fall back to the legacy path otherwise, and surface the success-tick only
  when the copy actually landed (return early without flipping `copied` if
  execCommand returns false). The try/finally around the textarea guarantees
  DOM cleanup even when the browser throws on a restricted context.

  3 new component tests in PrinterInfoModal.test.tsx cover the secure-context
  happy path (navigator.clipboard.writeText is called with the correct value),
  the plain-HTTP fallback path (execCommand is invoked, no leaked textarea
  left in the DOM), and the finally cleanup when execCommand throws
  synthetically.
This commit is contained in:
maziggy
2026-05-01 08:21:37 +02:00
parent 25eab96817
commit 59c58362ba
5 changed files with 164 additions and 4 deletions
+2
View File
@@ -10,6 +10,8 @@ All notable changes to Bambuddy will be documented in this file.
- **Filament Track Switch (FTS) support — print modal filament dropdown is no longer empty when an X2D / H2D has the FTS accessory installed** ([#1162](https://github.com/maziggy/bambuddy/issues/1162), reported by @mkavalecz) — When the FTS accessory is installed the printer's MQTT changes one nibble of the per-AMS `info` bitmask: bits 8-11 flip from a fixed extruder ID (0x0 / 0x1) to `0xE` ("uninitialized"), because the AMS is no longer wired to a single nozzle — the FTS dynamically routes any slot to either extruder. Bambuddy's MQTT parser already skipped 0xE entries when building `ams_extruder_map` (matching BambuStudio's reading for boot-time transient state), so with the FTS installed the map ended up empty and the print modal's filament dropdown — which filters by `extruderId === nozzle_id` to prevent cross-nozzle assignment ("position of left hotend is abnormal" failures) — filtered out *every* loaded slot. Net effect: empty Filament Mapping dropdown on every dual-nozzle print with the FTS, even when the AMS was fully loaded with the right material. Detection comes from a new MQTT field — `print.device.fila_switch` — which is non-null only when the accessory is installed; it carries the routing topology as two arrays: `in[track] = currently fed slot (-1 = empty)` and `out[track] = extruder this track terminates at`. The fix surfaces this through a new `FilaSwitchState` dataclass on `PrinterState` (`installed`, `in_slots`, `out_extruders`, `stat`, `info`) and the equivalent `FilaSwitchResponse` Pydantic schema on the `GET /printers/{id}/status` route. Frontend (`useFilamentMapping.ts` + `FilamentMapping.tsx`) skips the per-extruder filter when `printerStatus.fila_switch?.installed === true` so any compatible AMS slot can satisfy any nozzle's filament requirement, since the FTS handles the routing. Slots currently fed into a track also get a routing badge in the dropdown — `[L]` or `[R]` — so the user can tell at a glance which slot the FTS is currently routing where (idle slots get no badge: they can be routed to either extruder on demand). The hard "no cross-nozzle assignment" filter on real dual-nozzle printers without the FTS stays untouched (still trips the same way it always has — `fila_switch == null` keeps the existing behaviour). 4 backend tests in `test_bambu_mqtt.py::TestFilamentTrackSwitchDetection` (default-not-installed, detect-from-MQTT-using-the-reporter's-bundle, no-fila_switch-field-stays-not-installed, missing-in-out-arrays-don't-crash) and 2 frontend tests in `useFilamentMapping.test.ts` (FTS-active drops the nozzle filter; explicit `fila_switch: null` keeps the filter applied). Upstream fila_switch payloads with anything other than the documented shape are tolerated — `installed` flips on the *presence* of the field, the routing arrays default to empty lists if missing, and the dropdown skips the badge for slots not currently in `in_slots`.
### Fixed
- **Printer Info modal: serial-number and IP-address copy buttons silently did nothing on plain-HTTP LAN deployments** ([#1174](https://github.com/maziggy/bambuddy/issues/1174), reported by @BurntOutHylian) — `PrinterInfoModal`'s `CopyButton` only tried `navigator.clipboard.writeText()`, which is gated by the secure-context requirement (HTTPS or localhost). On the typical Bambuddy deployment shape — bare-IP HTTP on the LAN — `navigator.clipboard` is undefined; the existing `try/catch` swallowed the resulting `TypeError`, the icon never flipped to the tick, and nothing landed on the user's clipboard. Fixed by adding the same off-screen-textarea + `document.execCommand('copy')` fallback that `CameraTokensPage`'s plaintext-token modal already uses for plain-HTTP LAN deployments: gate on `navigator.clipboard && window.isSecureContext`, fall back to the legacy path otherwise, and surface the success-tick only when the copy actually landed (return early without flipping `copied` if `execCommand('copy')` returns false). The `try/finally` around the textarea guarantees DOM cleanup even when the browser throws on a restricted context. 3 new component tests in `PrinterInfoModal.test.tsx` cover (a) secure-context happy path uses `navigator.clipboard.writeText`, (b) plain-HTTP fallback path actually invokes `execCommand('copy')` and leaves no leaked textarea in the DOM, (c) `finally` cleanup removes the textarea even when `execCommand` throws synthetically. Thanks to @BurntOutHylian for the precise file/line pointer in the report.
- **Queue auto-dispatched the next print onto a fouled bed after an aborted or cancelled print** ([#1171](https://github.com/maziggy/bambuddy/issues/1171), reported by @tom5677) — When a print ended with status `aborted` (printer self-abort, or a user stopping the print on the printer's own touchscreen) or `cancelled` (user stopping the print via the Bambuddy queue UI), the plate-clear gate added in [#961](https://github.com/maziggy/bambuddy/issues/961) was *not* raised — only `completed` and `failed` triggered it (`backend/app/main.py:2660`). Result: the queue scheduler dispatched the next pending item ~2 seconds after the abort, with the previous print's material still on the bed. The reporter saw two prints (P1P + P1S) auto-start onto fouled beds within seconds of each other after touchscreen-aborts, and explicitly flagged the risk of damage to the printer; a third printer (his second P1S) behaved correctly because its previous print had ended `completed`. The original code's comment ("user-cancelled prints don't require a plate-clear ack — nothing printed on the bed") only holds if you cancel right at layer 1; cancelling a 12-hour print at hour 11 leaves a fouled bed too. Fix: the gate is now raised for every terminal status — `completed`, `failed`, `aborted`, `cancelled` — matching the safety contract that the user must acknowledge the bed is clear before any next queued print starts. The gate is user-clearable on the Printers page, so worst case for a layer-1 cancel the user clicks "Clear Plate" once. Touchscreen-aborts are particularly important to gate because Bambuddy's "user stopped via UI" override (`_user_stopped_printers` → `aborted` mapped to `cancelled`) only fires when the user stops via the Bambuddy queue; a touchscreen-stop reports `aborted` straight through. Regression coverage in `test_print_lifecycle.py::TestPlateClearGate`: parametrised across all four terminal statuses (asserts `set_awaiting_plate_clear(printer_id, True)` is called for each), plus a defence-in-depth test that an unrecognised future status string never silently raises the gate.
- **Printer card always shows the first plate's thumbnail when printing a multi-plate 3MF** ([#1166](https://github.com/maziggy/bambuddy/issues/1166), reported by @smandon) — On printers running firmware that drops the plate path from `print.gcode_file` (the reporter's case: P1S 01.10.00.00, but the same shape appears on other firmware revisions), the printer reports `gcode_file: MyModel.3mf` instead of `gcode_file: /Metadata/plate_4.gcode`. The `/printers/{id}/cover` route's regex (`plate_(\d+)\.gcode`) found nothing in the bare `.3mf` filename, defaulted to plate 1, and the printer card showed `Metadata/plate_1.png` from the 3MF — even though the user dispatched plate 4. Same problem hit `current_plate_id` on the status response (printer card detail row showed plate 1). Two-pronged fix on a precedence ladder: **(1) Bambuddy now records the plate it dispatched** — `start_print()` writes `(dispatched_plate_id, dispatched_subtask)` onto `PrinterState` at publish time, and a new `resolve_plate_id(state)` helper prefers that record over the gcode_file regex when `dispatched_subtask == state.subtask_name` (the subtask check rejects stale entries from a prior Bambuddy-dispatched print bleeding into a Studio-direct dispatch). **(2) After the 3MF lands on disk, the cover route scans the zip for a unique `Metadata/plate_*.gcode` entry**: per-plate archives sliced separately in Bambu Studio bundle thumbnails for *every* plate but only the *active* plate's gcode, so a single match unambiguously identifies the plate even when no Bambuddy dispatch exists (Studio-direct flow). Final fallback is plate 1, unchanged. The cover-byte cache key was also simplified — `plate_num` was removed from the key now that resolution is late-bound; `clear_cover_cache()` already runs on every print start, so different plates of the same project always re-fetch a fresh thumbnail. Coverage: 5 unit tests in `test_printer_manager.py::TestResolvePlateId` (dispatch precedence, stale-subtask guard, gcode regex fallback, default-1 path, missing-subtask guard), 4 unit tests in `test_bambu_mqtt.py::TestStartPrintRecordsDispatchedPlate` (dispatch record set/cleared/overwritten/skipped on disconnect), 2 integration tests in `test_printers_api.py` (dispatch wins over plate-1 default; 3MF-scan fallback for per-plate archive without dispatch). Studio-direct multi-plate prints (no dispatch record AND multiple plate gcodes in the 3MF) still default to plate 1 — matches the firmware's own ambiguity, not regressed by this change.
@@ -0,0 +1,136 @@
/**
* Tests for PrinterInfoModal — focused on the CopyButton clipboard fallback
* (#1174). Bambuddy is commonly deployed over plain HTTP on a LAN, where
* `navigator.clipboard` is gated by the secure-context requirement and the
* previous code (which only tried the modern API and silently swallowed the
* failure) left both copy buttons inert.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { render } from '../utils';
import { PrinterInfoModal } from '../../components/PrinterInfoModal';
import type { Printer } from '../../api/client';
function mockPrinter(): Printer {
return {
id: 1,
name: 'Test P1S',
serial_number: '01S00A123456789',
ip_address: '192.168.1.42',
access_code: '12345678',
model: 'P1S',
location: null,
nozzle_count: 1,
is_active: true,
auto_archive: true,
external_camera_url: null,
external_camera_type: null,
external_camera_enabled: false,
camera_rotation: 0,
plate_detection_enabled: false,
created_at: '2026-01-01T00:00:00Z',
updated_at: '2026-01-01T00:00:00Z',
};
}
function getCopyButtons() {
// CopyButton renders as an icon button with title="Copy to clipboard".
return screen.getAllByRole('button').filter(
btn => /copy/i.test(btn.getAttribute('title') || ''),
);
}
describe('PrinterInfoModal — CopyButton clipboard fallback (#1174)', () => {
let originalIsSecureContext: PropertyDescriptor | undefined;
let originalClipboard: PropertyDescriptor | undefined;
let originalExecCommand: typeof document.execCommand;
beforeEach(() => {
originalIsSecureContext = Object.getOwnPropertyDescriptor(window, 'isSecureContext');
originalClipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard');
originalExecCommand = document.execCommand;
});
afterEach(() => {
if (originalIsSecureContext) {
Object.defineProperty(window, 'isSecureContext', originalIsSecureContext);
}
if (originalClipboard) {
Object.defineProperty(navigator, 'clipboard', originalClipboard);
}
document.execCommand = originalExecCommand;
vi.clearAllMocks();
});
it('uses navigator.clipboard.writeText in a secure context (HTTPS / localhost)', async () => {
const user = userEvent.setup();
const writeTextMock = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(window, 'isSecureContext', { value: true, configurable: true });
Object.defineProperty(navigator, 'clipboard', {
value: { writeText: writeTextMock },
configurable: true,
});
render(<PrinterInfoModal printer={mockPrinter()} onClose={() => {}} />);
const buttons = getCopyButtons();
expect(buttons.length).toBeGreaterThanOrEqual(2); // serial + ip at minimum
// Click the first copy button (IP address row appears first).
await user.click(buttons[0]);
await waitFor(() => {
expect(writeTextMock).toHaveBeenCalled();
});
// The value passed must be a real string from the printer fixture, not "".
expect(writeTextMock.mock.calls[0][0]).toMatch(/^(192\.168\.1\.42|01S00A123456789)$/);
});
it('falls back to execCommand("copy") on plain-HTTP LAN deployments — pre-fix #1174 path', async () => {
// Repro of the reporter's exact environment: plain-HTTP, no clipboard API.
// Pre-fix the catch-block silently swallowed the TypeError on
// navigator.clipboard.writeText and the icon never flipped to the tick.
const user = userEvent.setup();
Object.defineProperty(window, 'isSecureContext', { value: false, configurable: true });
Object.defineProperty(navigator, 'clipboard', { value: undefined, configurable: true });
const execCommandMock = vi.fn().mockReturnValue(true);
document.execCommand = execCommandMock;
render(<PrinterInfoModal printer={mockPrinter()} onClose={() => {}} />);
const buttons = getCopyButtons();
await user.click(buttons[0]);
await waitFor(() => {
expect(execCommandMock).toHaveBeenCalledWith('copy');
});
// Off-screen textarea must be cleaned up on the success path; otherwise
// every click would leak a hidden DOM node.
expect(document.querySelectorAll('textarea').length).toBe(0);
});
it('cleans up the off-screen textarea even when execCommand throws', async () => {
// The fallback path uses a try/finally around execCommand. The finally
// block must remove the textarea even if the browser rejects the copy
// (e.g. permission denied), so a hostile / restricted environment doesn't
// leak DOM nodes per click.
const user = userEvent.setup();
Object.defineProperty(window, 'isSecureContext', { value: false, configurable: true });
Object.defineProperty(navigator, 'clipboard', { value: undefined, configurable: true });
document.execCommand = vi.fn().mockImplementation(() => {
throw new Error('synthetic execCommand failure');
});
render(<PrinterInfoModal printer={mockPrinter()} onClose={() => {}} />);
const buttons = getCopyButtons();
await user.click(buttons[0]);
await waitFor(() => {
expect(document.querySelectorAll('textarea').length).toBe(0);
});
});
});
+24 -2
View File
@@ -18,12 +18,34 @@ function CopyButton({ value }: { value: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
// navigator.clipboard is gated by the secure-context requirement, so on
// plain-HTTP LAN deployments (#1174) the API is undefined and the previous
// code silently swallowed the failure — the icon never flipped to the tick
// and nothing landed on the user's clipboard. Fall back to the legacy
// execCommand path via an off-screen textarea, matching the pattern used
// by CameraTokensPage's plaintext-token modal.
try {
await navigator.clipboard.writeText(value);
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(value);
} else {
const ta = document.createElement('textarea');
ta.value = value;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
try {
ta.select();
const ok = document.execCommand('copy');
if (!ok) return;
} finally {
document.body.removeChild(ta);
}
}
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Clipboard may not be available in non-secure contexts
// Both paths failed (no clipboard API, no execCommand). Leave the icon
// unchanged so the user knows nothing was copied.
}
};
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-D4GSGeyj.js"></script>
<script type="module" crossorigin src="/assets/index-BM5VeuBp.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-7GmlJb0k.css">
</head>
<body>