mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 21:47:39 +02:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user