mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-08-11 00:30:12 -04:00
Merge branch 'dev' into feature/russian-localization
This commit is contained in:
commit
cfcefa47d3
37 changed files with 1258 additions and 204 deletions
|
|
@ -8,6 +8,9 @@ All notable changes to Bambuddy will be documented in this file.
|
|||
- **Orca Cloud profile sync now connects by approving a code instead of the copy-paste sign-in** — Connecting Bambuddy to Orca Cloud used to mean opening an OAuth sign-in in a new tab, watching it redirect to a `localhost` URL that fails to load, then copying that dead URL out of the address bar and pasting it back into Bambuddy. That dance existed only because Orca's auth backend (Supabase) accepts no redirect target other than `localhost`, and the deliberately-broken redirect page confused nearly everyone who reached it. OrcaSlicer has since shipped a first-class external-app pairing API (the OAuth 2.0 Device Authorization Grant, RFC 8628), so the flow is now: click **Connect**, approve a short code on your Orca Cloud settings page, and Bambuddy pairs itself — no redirect, no paste, no client secret, and it behaves identically from a LAN IP, `localhost`, or behind a reverse proxy. Bambuddy requests **read-only** access (it only lists and views your Orca Cloud profiles), keeps the pairing alive with the API's rotating refresh tokens (validated end-to-end against Orca's staging and production servers), and stores nothing beyond the issued token pair. The profile list and detail views are unchanged, so nothing downstream of the connect step looks different. The old paste-based sign-in and the email/password fallback are removed. Points at production Orca Cloud by default; `ORCA_CLOUD_API_BASE` overrides the endpoint for testing.
|
||||
|
||||
### Fixed
|
||||
- **Every SpoolBuddy screen crashed the moment a text field was focused (#2616, reporters @MartinNYHC, @agentdr8)** — Tapping the Search box on the SpoolBuddy inventory, or the Search / Color Name / Brand fields on the write-tag New Spool tab, blanked the UI with a minified React error #130 ("Element type is invalid… but got: object"). It hit both internal and Spoolman inventories, so it was not data-specific. **Root cause.** The SpoolBuddy shell mounts an on-screen keyboard (`VirtualKeyboard`) that pops up on `focusin` for any text input — which is why every field on every SpoolBuddy page tripped it, while the main app (no on-screen keyboard) was fine. That component does `import Keyboard from 'react-simple-keyboard'`, a CommonJS package, and under the current bundler's CJS→ESM interop the default import resolves to the module **namespace object** (`{ KeyboardReact, default }`) rather than the component itself. Rendering that object as `<Keyboard>` put an object where an element type belongs, and React threw. (The discrepancy is interop-specific: the test runner hands back the real component, so it only manifested in the browser build — which is why it needed a runtime, not a type, fix.) **Fix.** A small `resolveInteropDefault` helper unwraps such an interop-wrapped default: it returns the value as-is when it's already a usable element type (function/class, tag string, or a `$$typeof`-marked forwardRef/memo/lazy) and otherwise falls through to `.default` and named exports. `VirtualKeyboard` resolves the real `react-simple-keyboard` component through it, so the keyboard renders under any interop shape. Covered by unit tests for the resolver against the object shape, a named-only export, a forwardRef object, and a bare component, plus a render test that mounts the keyboard on input focus.
|
||||
- **The streaming overlay (`/overlay`) showed nothing in OBS when login was enabled (#2613, reporter @MartinNYHC)** — With authentication on, the overlay page worked when opened in a browser where you were already signed in, but stayed blank in OBS. The reporter suspected their Cloudflare/remote setup; it was unrelated. **Root cause.** The `/overlay/{id}` *route* renders without a login, but every piece of data it draws is auth-gated — printer status and name (`PRINTERS_READ`), one setting (`SETTINGS_READ`), and the camera stream (a camera-stream token). In your own browser those ride the JWT from local storage and the app-wide stream-token sync; OBS is a fresh browser with **no session**, so the status calls 401'd and the overlay never populated (the same would happen in any private/incognito window — remote access was never the cause). Unlike the Cam Wall (`/camwall?token=…`), the overlay had no token mode, and a long-lived token couldn't help because the JWT-gated status/settings endpoints reject it. **Fix.** The overlay is now a self-contained kiosk surface. A new **Streaming Overlay** long-lived-token scope is offered under Settings → API Keys (with a ready-made `/overlay/{id}?token=…` URL copied once on creation); the overlay page reads `?token=` from the URL and, in that mode, authenticates its status and camera calls with the token instead of a JWT (and skips the WebSocket, falling back to its existing 2 s poll). A new token-authenticated `GET /printers/{id}/overlay-status` returns exactly the fields the overlay draws — name, camera rotation, live print state, and the one setting — and nothing else. The scope is deliberately **separate from `camwall`**: the overlay names the file on screen, which the Cam Wall is trusted never to expose, so folding it in would have silently widened every existing wall token. The logged-in path (opening the overlay while signed in) is unchanged. Docs updated to explain the token and stop claiming the overlay needs no authentication. Covered by backend tests (scope boundaries in both directions — an overlay token can't reach the Cam Wall feed and a camwall/camera-stream token can't reach the overlay feed — plus the payload shape, disconnected-printer shape, and revoked/absent/garbage-token rejection) and frontend tests (kiosk mode reads the token feed and carries the token to the camera, never touches the JWT-only status endpoint or a socket; the mint UI offers the scope and hands over the assembled OBS URL).
|
||||
- **Reassigning a queue item while it was dispatching split it across two printers (#2615, reporter @Jostxxl)** — Editing a queue item's printer while its FTP upload was already in flight left the queue row pointing at one printer while the archive, expected-print registration, and the physical `project_file` command had gone to another. On a farm this made the reassigned-to printer look broken (marked `printing` but never sent the job), left the row permanently inconsistent, and could trigger a duplicate dispatch after a restart. **Root cause.** A queue row stays `status='pending'` for the entire (multi-minute) FTP upload — status only flips to `printing` at the very end. The edit route only blocked non-`pending` rows, so a `PATCH` during the upload window was accepted; the in-flight dispatch kept using the printer it had snapshotted at the start, while the DB row's `printer_id` changed underneath it. The existing #1853 CAS guards *cancellation* mid-dispatch, not *reassignment*. **Fix.** A `dispatching_at` claim is stamped atomically on the row (`WHERE status='pending' AND dispatching_at IS NULL`) the moment the scheduler begins dispatching, before any slow I/O, and cleared when dispatch ends. While it's held, both edit routes reject changes — the single-item `PATCH` returns **409** (re-checked immediately before the write to close the read-modify-write gap), and bulk edits skip the row — and the scheduler's selection query won't re-pick it. Startup reconciliation clears any claim orphaned by a crash mid-dispatch (no dispatch coroutine survives a restart, so every claim present at boot is stale), so a stale token can never wedge an item out of the queue. The row stays `pending` throughout, so no status-consumer, UI, completion, or reconciliation path had to change. To move a dispatching item, cancel it first (the coordinated escape) and re-queue. New column `print_queue.dispatching_at` (nullable timestamp, dialect-safe DDL — SQLite `DATETIME` / Postgres `TIMESTAMP`). Covered by scheduler tests (claim is exclusive, fails on non-pending rows, releases on every exit, skips an already-claimed row, startup clears stale claims) and API tests (reassign returns 409 with `printer_id` unchanged, bulk skips the claimed row, an unclaimed pending row still edits normally).
|
||||
- **A single plate printed from a multi-plate 3MF recorded the whole file's filament in statistics (#2614, reporter @Jostxxl)** — Dispatching one selected plate of a sliced multi-plate 3MF through the queue could log the **entire file's** filament against that one plate. The reporter's `heart 3.gcode.3mf` has 22 plates totalling ~12.0 kg; every completed plate recorded `12006.49 g`, so 13 runs inflated lifetime/user/project/filament stats by ~156 kg from one file. **Root cause.** The per-run value written to `PrintLogEntry.filament_used_grams` prefers the AMS-tracked spool delta, but when the tracker measured nothing (no inventory assignment on the printer) a *completed* run fell back to `PrintArchive.filament_used_grams` — which is deliberately the **sum over every plate** of the source 3MF (correct for the archive card and project rollup, #1593). The archive's `plate_id` (persisted by #2603) was never consulted on this path, so the whole-file total was copied verbatim; `cost` had the same defect, falling back to the whole-file `archive.cost`. **Forward fix.** When the archive carries a `plate_id` and its 3MF is on disk, the completed-run fallback now uses that plate's own slicer estimate (`extract_plate_metadata_from_3mf`, the same plate-scoped parse the inventory tracker uses), and scales cost by the plate's share of the whole. The tracker-measured path is unchanged (measured spool deltas still win) and single-plate archives are unaffected (plate value equals the whole-file value). **Backfill.** A startup migration repairs rows already written: for completed print-log entries whose stored grams **exactly equal** the linked archive's whole-file value (the mis-copy signature) and whose archive has a `plate_id` and an on-disk 3MF, it recomputes the plate-scoped grams + cost. The exact-match guard means tracker-measured rows (a rounded spool-delta sum) and partial-progress rows (scaled to progress) are never touched; it's idempotent (a corrected row no longer matches) and data-only, identical on SQLite and Postgres. Logs how many rows and how many grams of over-count it removed. Covered by unit tests for the forward helper (plate scoping, cost scaling, fallbacks when there's no plate_id / no file / unreadable estimate) and the backfill (mis-copy repaired, tracker/partial rows untouched, missing-3MF skipped, single-plate not relabelled, idempotent).
|
||||
- **Progress notification ran off the left edge of the screen in the installed iPhone PWA (#2612)** — On an iPhone 13 Pro with Bambuddy added to the Home Screen, the print-dispatch progress toast was clipped off the left side of the display — text like "prints", "plate_6", and "MB (21.0%)" bled past the edge. **Root cause.** The toast viewport is anchored `right-20` (80 px from the right, to clear the bug-report bubble) and the dispatch toast has a fixed `w-[420px]`. On a phone that's 390 CSS px wide, 420 + 80 overflows the left edge by ~110 px — the toast simply didn't fit. **Fix.** Every toast now carries a viewport-relative `max-width` (`calc(100vw - 6rem - safe-area insets)`) so it can never exceed the screen; on desktop the 420 px still wins. The viewport's position is also now safe-area-aware (`env(safe-area-inset-*)` on bottom/right) so an installed PWA clears the home indicator and a landscape notch, and the per-job filename row gets `min-w-0`/`shrink-0` so long names truncate instead of pushing the toast wide at the narrower phone width. Frontend-only; no backend, schema, or i18n change. Covered by a test pinning the viewport-relative width cap.
|
||||
- **Multi-plate queue prints lost the selected plate in Print History and a stopped-while-offline print stayed "printing" (#2603, reporter @Jostxxl)** — Cancelling a print queued from a specific plate of a multi-plate 3MF showed it in Print History as **Plate 1**, so you couldn't tell which plate to requeue. **Root cause.** The archive derives its plate from the *filename*, but a whole multi-plate 3MF uploads under one name with no plate suffix, so the parser defaulted to plate 1 and `extra_data` held all-plates aggregate metadata; the queue row kept the correct plate but nothing copied it onto the archive, which had no plate field at all. **Fix.** `print_archives` gains a nullable `plate_id`, copied from the queue item at dispatch (both the archive-based and library-file paths), exposed in the archive API, and rendered in Print History (falling back to no plate label only when genuinely unknown). A startup backfill copies the plate onto existing archives from their linked queue rows, so already-cancelled prints recover their plate. Additionally, **stopping a printing item while the printer was offline left the linked archive stuck at "printing"** — the queue row was cancelled but, with no printer to send an MQTT completion, nothing ever reconciled the archive. The offline-stop path now closes the archive out directly (status `cancelled`, `failure_reason` "Stopped by user (printer was offline)"); the online path is unchanged and still leaves the archive to the MQTT completion event. Column add + backfill are identical on SQLite and Postgres. Covered by tests for plate persistence, the backfill (including no-clobber/idempotency), and the offline vs online stop reconcile.
|
||||
|
|
|
|||
|
|
@ -774,7 +774,10 @@ async def bulk_update_queue_items(
|
|||
skipped_count = 0
|
||||
|
||||
for item in items:
|
||||
if item.status != "pending":
|
||||
# Skip non-pending rows and rows a dispatch worker has claimed (#2615) —
|
||||
# editing a claimed row mid-upload would split it from the in-flight
|
||||
# dispatch, so it's excluded from the bulk change (cancel to move it).
|
||||
if item.status != "pending" or item.dispatching_at is not None:
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
|
|
@ -1082,6 +1085,14 @@ async def update_queue_item(
|
|||
if item.status != "pending":
|
||||
raise HTTPException(400, "Can only update pending items")
|
||||
|
||||
# Dispatch claim (#2615): the row is pending but a scheduler worker has
|
||||
# already claimed it and is uploading to its printer. Editing now (e.g.
|
||||
# reassigning printer_id) would split the queue row from the in-flight
|
||||
# archive/expected-print/physical command. Reject until dispatch finishes;
|
||||
# to move it, cancel first (the coordinated escape) and re-queue.
|
||||
if item.dispatching_at is not None:
|
||||
raise HTTPException(409, "Item is being dispatched — cancel it first to make changes")
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
|
||||
# Normalize target_model if being updated
|
||||
|
|
@ -1153,6 +1164,16 @@ async def update_queue_item(
|
|||
json.dumps(update_data["nozzle_mapping"]) if update_data["nozzle_mapping"] else None
|
||||
)
|
||||
|
||||
# Re-check the dispatch claim right before mutating (#2615). Several awaited
|
||||
# validations ran since the guard above, and a scheduler worker may have
|
||||
# claimed the row in that gap. A fresh read (item isn't dirty yet, so no
|
||||
# autoflush races the check) narrows the window to effectively nothing.
|
||||
claimed = (
|
||||
await db.execute(select(PrintQueueItem.dispatching_at).where(PrintQueueItem.id == item_id))
|
||||
).scalar_one_or_none()
|
||||
if claimed is not None:
|
||||
raise HTTPException(409, "Item is being dispatched — cancel it first to make changes")
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(item, field, value)
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||
from backend.app.core import database
|
||||
from backend.app.core.auth import (
|
||||
RequireCameraStreamTokenIfAuthEnabled,
|
||||
RequireOverlayTokenIfAuthEnabled,
|
||||
RequirePermissionIfAuthEnabled,
|
||||
is_auth_enabled,
|
||||
)
|
||||
|
|
@ -821,6 +822,70 @@ async def get_printer_status(
|
|||
)
|
||||
|
||||
|
||||
@router.get("/{printer_id}/overlay-status")
|
||||
async def get_overlay_status(
|
||||
printer_id: int,
|
||||
_: None = RequireOverlayTokenIfAuthEnabled,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Everything the streaming overlay (#2613) draws for one printer.
|
||||
|
||||
A token-authenticated sibling of ``get_printer_status`` for embeds with no
|
||||
login session — OBS loads ``/overlay/{id}?token=...`` and this feeds it.
|
||||
Deliberately flat and minimal (name, camera rotation, live print state, and
|
||||
the one setting the overlay reads) rather than the full ``PrinterStatus``:
|
||||
a token holder gets exactly the fields the overlay renders, nothing more.
|
||||
|
||||
Unlike the Cam Wall feed this *includes the print filename* — the overlay
|
||||
names the part on screen — which is why it sits behind its own ``overlay``
|
||||
scope rather than ``camwall``.
|
||||
"""
|
||||
from backend.app.api.routes.settings import get_setting
|
||||
|
||||
result = await db.execute(select(Printer).where(Printer.id == printer_id))
|
||||
printer = result.scalar_one_or_none()
|
||||
if not printer:
|
||||
raise HTTPException(404, "Printer not found")
|
||||
|
||||
time_format = await get_setting(db, "time_format") or "system"
|
||||
state = printer_manager.get_status(printer_id)
|
||||
|
||||
if not state:
|
||||
# Never connected this run — mirror get_printer_status()'s disconnected
|
||||
# shape so the overlay renders its offline state rather than erroring.
|
||||
return {
|
||||
"id": printer_id,
|
||||
"name": printer.name,
|
||||
"camera_rotation": printer.camera_rotation or 0,
|
||||
"connected": False,
|
||||
"state": None,
|
||||
"current_print": None,
|
||||
"gcode_file": None,
|
||||
"progress": None,
|
||||
"remaining_time": None,
|
||||
"layer_num": None,
|
||||
"total_layers": None,
|
||||
"stg_cur_name": None,
|
||||
"time_format": time_format,
|
||||
}
|
||||
|
||||
return {
|
||||
"id": printer_id,
|
||||
"name": printer.name,
|
||||
"camera_rotation": printer.camera_rotation or 0,
|
||||
"connected": state.connected,
|
||||
"state": state.state,
|
||||
"current_print": state.current_print,
|
||||
"gcode_file": state.gcode_file,
|
||||
"progress": state.progress,
|
||||
"remaining_time": state.remaining_time,
|
||||
"layer_num": state.layer_num,
|
||||
"total_layers": state.total_layers,
|
||||
"stg_cur_name": get_derived_status_name(state, printer.model),
|
||||
"time_format": time_format,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{printer_id}/current-print-user")
|
||||
async def get_current_print_user(
|
||||
printer_id: int,
|
||||
|
|
|
|||
|
|
@ -725,6 +725,23 @@ async def verify_camwall_token(token: str) -> bool:
|
|||
return record is not None
|
||||
|
||||
|
||||
async def verify_overlay_token(token: str) -> bool:
|
||||
"""Verify a streaming-overlay token (#2613). Reusable — does not consume it.
|
||||
|
||||
Like :func:`verify_camwall_token`, only the matching long-lived scope passes:
|
||||
the overlay status feed names the file being printed, so it must not be
|
||||
reachable by a ``camwall`` token (which is trusted to hide the part name) or
|
||||
a bare ``camera_stream`` token (handed out for video alone). The 60-minute
|
||||
ephemeral token belongs to a logged-in browser, which reaches the same data
|
||||
through the ordinary printers API and has no need of this endpoint.
|
||||
"""
|
||||
async with async_session() as db:
|
||||
from backend.app.services.long_lived_tokens import verify_token as verify_long_lived
|
||||
|
||||
record = await verify_long_lived(db, token, scope="overlay")
|
||||
return record is not None
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Verify a password against a hash.
|
||||
|
||||
|
|
@ -1774,6 +1791,31 @@ def require_camwall_token_if_auth_enabled():
|
|||
RequireCamWallTokenIfAuthEnabled = Depends(require_camwall_token_if_auth_enabled())
|
||||
|
||||
|
||||
def require_overlay_token_if_auth_enabled():
|
||||
"""Dependency that validates a streaming-overlay token query param when auth
|
||||
is enabled.
|
||||
|
||||
Used by the read-only overlay status feed (#2613), which OBS (or any
|
||||
embed with no login session) loads with the token in the URL because it
|
||||
has no JWT to carry.
|
||||
"""
|
||||
|
||||
async def checker(token: str | None = None) -> None:
|
||||
async with async_session() as db:
|
||||
if not await is_auth_enabled(db):
|
||||
return # Auth disabled, allow access
|
||||
if not token or not await verify_overlay_token(token):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Valid overlay token required. Create one under Settings > API Keys with the 'Streaming Overlay' scope.",
|
||||
)
|
||||
|
||||
return checker
|
||||
|
||||
|
||||
RequireOverlayTokenIfAuthEnabled = Depends(require_overlay_token_if_auth_enabled())
|
||||
|
||||
|
||||
def require_ownership_permission(
|
||||
all_permission: str | Permission,
|
||||
own_permission: str | Permission,
|
||||
|
|
|
|||
|
|
@ -1521,6 +1521,22 @@ async def run_migrations(conn):
|
|||
except (OperationalError, ProgrammingError):
|
||||
pass # Already applied
|
||||
|
||||
# Migration: Add dispatching_at claim column to print_queue (#2615). Nullable
|
||||
# timestamp; the type differs by dialect (SQLite DATETIME vs Postgres
|
||||
# TIMESTAMP) so an existing-DB upgrade doesn't hit "type datetime does not
|
||||
# exist" on Postgres. On a fresh DB create_all() already built the column, so
|
||||
# the ALTER is swallowed as "already exists".
|
||||
#
|
||||
# Placed AFTER the print_queue_new2 table-recreate above: that recreate
|
||||
# (SQLite-only, and only on ancient DBs whose archive_id is still NOT NULL)
|
||||
# rebuilds print_queue from an explicit column list that doesn't carry this
|
||||
# column, so adding it earlier would let the recreate silently drop it. Adding
|
||||
# it here means it survives that path.
|
||||
if is_sqlite():
|
||||
await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN dispatching_at DATETIME")
|
||||
else:
|
||||
await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN dispatching_at TIMESTAMP")
|
||||
|
||||
# Migration: Add HA energy sensor entity columns to smart_plugs
|
||||
await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN ha_power_entity VARCHAR(100)")
|
||||
await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN ha_energy_today_entity VARCHAR(100)")
|
||||
|
|
|
|||
|
|
@ -6622,6 +6622,13 @@ PUBLIC_API_PATTERNS = [
|
|||
# Camera (streams loaded via <img> tag)
|
||||
"/camera/stream", # /printers/{id}/camera/stream
|
||||
"/camera/snapshot", # /printers/{id}/camera/snapshot
|
||||
# Streaming-overlay status feed (#2613): OBS loads /overlay/{id} with no login
|
||||
# and this backs it, authenticated by an ``overlay``-scoped token in the query
|
||||
# string (same reasoning as the camera streams above — no header to carry a
|
||||
# JWT). "Public" only means the middleware steps aside; the route still runs
|
||||
# RequireOverlayTokenIfAuthEnabled, which rejects an absent, expired, revoked,
|
||||
# or wrong-scoped token — a camwall or camera_stream token does NOT open it.
|
||||
"/overlay-status", # /printers/{id}/overlay-status
|
||||
# Slicer token-authenticated downloads — protocol handlers (bambustudioopen://,
|
||||
# orcaslicer://) cannot send auth headers. These endpoints validate a short-lived
|
||||
# download token in the URL path instead.
|
||||
|
|
|
|||
|
|
@ -111,6 +111,16 @@ class PrintQueueItem(Base):
|
|||
# Status: pending, printing, completed, failed, skipped, cancelled
|
||||
status: Mapped[str] = mapped_column(String(20), default="pending")
|
||||
|
||||
# Dispatch claim (#2615). Set atomically by the scheduler the moment it
|
||||
# begins dispatching this row and cleared when dispatch ends. The row stays
|
||||
# `status='pending'` throughout the (slow) FTP upload, which left a window
|
||||
# where a concurrent PATCH could reassign printer_id mid-upload and split the
|
||||
# queue row from the archive/expected-print/physical command. While this is
|
||||
# set the edit routes reject changes (409) and the scheduler won't re-select
|
||||
# the row. Startup reconciliation clears any left over by a crash mid-dispatch
|
||||
# (no coroutine survives a restart), so a stale claim never wedges an item.
|
||||
dispatching_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
# Cleared by the per-printer "Resume after failure" action (#1818) so the
|
||||
# scheduler's `_check_previous_success` lookback skips this row. Without
|
||||
# this, a single `failed` or `aborted` print poisoned every later
|
||||
|
|
|
|||
|
|
@ -45,11 +45,17 @@ MAX_TOKEN_LIFETIME_DAYS = 365
|
|||
# Cam Wall draws: printer names and print state (#2531).
|
||||
# Strictly wider than camera_stream, so it gets its own scope
|
||||
# rather than quietly extending tokens already handed out.
|
||||
ALLOWED_SCOPES: frozenset[str] = frozenset({"camera_stream", "camwall"})
|
||||
# overlay — the streaming overlay (#2613): the camera stream plus the
|
||||
# single-printer status the /overlay page draws, which unlike
|
||||
# the Cam Wall *includes the print filename*. A distinct grant
|
||||
# precisely because it reveals the part name a camwall token
|
||||
# is trusted never to expose, so folding it into camwall would
|
||||
# silently widen every wall token already handed out.
|
||||
ALLOWED_SCOPES: frozenset[str] = frozenset({"camera_stream", "camwall", "overlay"})
|
||||
|
||||
# Scopes the camera stream / snapshot endpoints honour. A Cam Wall token has to
|
||||
# be able to pull the video its own tiles are showing.
|
||||
STREAM_SCOPES: tuple[str, ...] = ("camera_stream", "camwall")
|
||||
# Scopes the camera stream / snapshot endpoints honour. A Cam Wall or overlay
|
||||
# token has to be able to pull the video its own view is showing.
|
||||
STREAM_SCOPES: tuple[str, ...] = ("camera_stream", "camwall", "overlay")
|
||||
|
||||
# Don't write to last_used_at more than once per minute per token. MJPEG
|
||||
# streams call verify() at most once per fetch (the browser holds the
|
||||
|
|
|
|||
|
|
@ -286,6 +286,8 @@ class PrintScheduler:
|
|||
self._running = True
|
||||
logger.info("Print scheduler started")
|
||||
|
||||
await self._clear_stale_dispatch_claims()
|
||||
|
||||
while self._running:
|
||||
dispatched = False
|
||||
try:
|
||||
|
|
@ -297,6 +299,25 @@ class PrintScheduler:
|
|||
# not stall behind the idle interval; otherwise sleep normally (#2555).
|
||||
await asyncio.sleep(self._fast_check_interval if dispatched else self._check_interval)
|
||||
|
||||
async def _clear_stale_dispatch_claims(self) -> None:
|
||||
"""Clear dispatch claims left behind by a crash/restart mid-upload (#2615).
|
||||
|
||||
A claim is only ever held by a live dispatch coroutine, and no coroutine
|
||||
survives a process restart — so every ``dispatching_at`` present at startup
|
||||
is stale. Clearing them lets those still-pending rows be re-selected for a
|
||||
fresh, consistent dispatch instead of being wedged out of the selection
|
||||
query forever. Called once at the top of ``run()``."""
|
||||
try:
|
||||
async with async_session() as db:
|
||||
res = await db.execute(
|
||||
update(PrintQueueItem).where(PrintQueueItem.dispatching_at.is_not(None)).values(dispatching_at=None)
|
||||
)
|
||||
await db.commit()
|
||||
if res.rowcount:
|
||||
logger.info("Cleared %d stale dispatch claim(s) at startup (#2615)", res.rowcount)
|
||||
except Exception as exc:
|
||||
logger.error("Failed to clear stale dispatch claims at startup: %s", exc)
|
||||
|
||||
def stop(self):
|
||||
"""Stop the scheduler."""
|
||||
self._running = False
|
||||
|
|
@ -320,6 +341,11 @@ class PrintScheduler:
|
|||
result = await db.execute(
|
||||
select(PrintQueueItem)
|
||||
.where(PrintQueueItem.status == "pending")
|
||||
# Never re-select a row a dispatch worker has already claimed
|
||||
# (#2615) — belt-and-suspenders with the _inflight exclusion
|
||||
# below, and the guard that lets an orphaned claim be ignored
|
||||
# until startup reconciliation clears it.
|
||||
.where(PrintQueueItem.dispatching_at.is_(None))
|
||||
# archive/library_file are read by the cross-model gate
|
||||
# (#2578); eager-load once per pass instead of a lazy-load
|
||||
# (which would raise in async) per item.
|
||||
|
|
@ -339,6 +365,8 @@ class PrintScheduler:
|
|||
result = await db.execute(
|
||||
select(PrintQueueItem)
|
||||
.where(PrintQueueItem.status == "pending")
|
||||
# Skip rows already claimed by a dispatch worker (#2615).
|
||||
.where(PrintQueueItem.dispatching_at.is_(None))
|
||||
.options(
|
||||
selectinload(PrintQueueItem.archive),
|
||||
selectinload(PrintQueueItem.library_file),
|
||||
|
|
@ -880,11 +908,56 @@ class PrintScheduler:
|
|||
transfer's duration.
|
||||
"""
|
||||
async with async_session() as item_db:
|
||||
item = await item_db.get(PrintQueueItem, item_id)
|
||||
if not item:
|
||||
logger.info("Queue item %s vanished before dispatch — skipping", item_id)
|
||||
# Claim the row for dispatch BEFORE reading the printer snapshot or
|
||||
# touching any slow I/O (#2615). The claim is an atomic CAS on
|
||||
# (status='pending', dispatching_at IS NULL); while it's held the edit
|
||||
# routes reject reassignment (409), so printer_id can't change out from
|
||||
# under the in-flight upload and split the queue row from the
|
||||
# archive/expected-print/physical command.
|
||||
if not await self._claim_for_dispatch(item_db, item_id):
|
||||
logger.info(
|
||||
"Queue item %s not claimable for dispatch (cancelled, removed, or already claimed) — skipping",
|
||||
item_id,
|
||||
)
|
||||
return
|
||||
await self._start_print(item_db, item)
|
||||
try:
|
||||
item = await item_db.get(PrintQueueItem, item_id)
|
||||
if not item:
|
||||
logger.info("Queue item %s vanished after claim — skipping", item_id)
|
||||
return
|
||||
await self._start_print(item_db, item)
|
||||
finally:
|
||||
# Release the claim on every exit. Once dispatch has finished the
|
||||
# row's status carries the lock (printing/failed/cancelled are all
|
||||
# != pending), so the token is only needed for the duration of the
|
||||
# upload. A row left pending (e.g. busy-printer deferral) becomes
|
||||
# dispatchable again on the next tick.
|
||||
await self._clear_dispatch_claim(item_db, item_id)
|
||||
|
||||
async def _claim_for_dispatch(self, db: AsyncSession, item_id: int) -> bool:
|
||||
"""Atomically stamp ``dispatching_at`` on a still-pending, unclaimed row.
|
||||
|
||||
Returns True if this call won the claim, False if the row was already
|
||||
claimed, no longer pending (cancelled mid-tick), or removed. The CAS is
|
||||
the load-bearing guard against reassign-during-dispatch (#2615)."""
|
||||
res = await db.execute(
|
||||
update(PrintQueueItem)
|
||||
.where(PrintQueueItem.id == item_id)
|
||||
.where(PrintQueueItem.status == "pending")
|
||||
.where(PrintQueueItem.dispatching_at.is_(None))
|
||||
.values(dispatching_at=datetime.now(timezone.utc))
|
||||
)
|
||||
await db.commit()
|
||||
return res.rowcount > 0
|
||||
|
||||
async def _clear_dispatch_claim(self, db: AsyncSession, item_id: int) -> None:
|
||||
"""Clear the dispatch claim (#2615). Best-effort: a failure here must not
|
||||
mask the dispatch outcome, and startup reconciliation clears any leftover."""
|
||||
try:
|
||||
await db.execute(update(PrintQueueItem).where(PrintQueueItem.id == item_id).values(dispatching_at=None))
|
||||
await db.commit()
|
||||
except Exception as exc:
|
||||
logger.warning("Queue item %s: failed to clear dispatch claim: %s", item_id, exc)
|
||||
|
||||
async def _find_idle_printer_for_model(
|
||||
self,
|
||||
|
|
|
|||
216
backend/tests/integration/test_overlay_status_api.py
Normal file
216
backend/tests/integration/test_overlay_status_api.py
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
"""Integration tests for the token-authenticated streaming-overlay feed (#2613).
|
||||
|
||||
Like the Cam Wall feed, the overlay endpoint exists as its own scope-gated
|
||||
route because a kiosk/OBS URL is not a secret. But it is deliberately *wider*
|
||||
than the Cam Wall: it names the file being printed (the overlay draws the part
|
||||
on screen). So the tests that matter are the scope boundaries — an overlay
|
||||
token must not reach the Cam Wall feed and vice versa, a camwall token must not
|
||||
reach the overlay feed (that would leak the filename it is trusted to hide) —
|
||||
plus the positive path and the disconnected-printer shape.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
|
||||
|
||||
|
||||
async def _setup_admin(async_client: AsyncClient, *, suffix: str) -> str:
|
||||
await async_client.post(
|
||||
"/api/v1/auth/setup",
|
||||
json={
|
||||
"auth_enabled": True,
|
||||
"admin_username": f"overlayadmin{suffix}",
|
||||
"admin_password": "AdminPass1!",
|
||||
},
|
||||
)
|
||||
login = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": f"overlayadmin{suffix}", "password": "AdminPass1!"},
|
||||
)
|
||||
return login.json()["access_token"]
|
||||
|
||||
|
||||
async def _mint(async_client: AsyncClient, jwt: str, *, scope: str, name: str = "obs") -> str:
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/tokens",
|
||||
headers={"Authorization": f"Bearer {jwt}"},
|
||||
json={"name": name, "expires_in_days": 30, "scope": scope},
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
assert response.json()["scope"] == scope
|
||||
return response.json()["token"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def printer_row(db_session):
|
||||
"""Insert the printer straight into the DB.
|
||||
|
||||
POST /printers probes the real device before it will store a row, and there
|
||||
is no printer on the other end of a test run.
|
||||
"""
|
||||
from backend.app.models.printer import Printer
|
||||
|
||||
printer = Printer(
|
||||
name="Stream P1S",
|
||||
ip_address="192.168.1.88",
|
||||
access_code="12345678",
|
||||
serial_number="01P00A000000002",
|
||||
model="P1S",
|
||||
)
|
||||
db_session.add(printer)
|
||||
await db_session.commit()
|
||||
return printer
|
||||
|
||||
|
||||
class TestOverlayFeedAuth:
|
||||
async def test_no_token_is_rejected(self, async_client: AsyncClient, printer_row):
|
||||
await _setup_admin(async_client, suffix="_notoken")
|
||||
response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status")
|
||||
assert response.status_code == 401
|
||||
|
||||
async def test_garbage_token_is_rejected(self, async_client: AsyncClient, printer_row):
|
||||
await _setup_admin(async_client, suffix="_garbage")
|
||||
response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token=bblt_aaaaaaaa_nope")
|
||||
assert response.status_code == 401
|
||||
|
||||
async def test_camera_stream_token_cannot_reach_the_feed(self, async_client: AsyncClient, printer_row):
|
||||
"""A ``camera_stream`` token was handed out for video alone — it must not
|
||||
acquire the live print status (and filename) just because a new feature
|
||||
shipped.
|
||||
"""
|
||||
jwt = await _setup_admin(async_client, suffix="_streamscope")
|
||||
stream_token = await _mint(async_client, jwt, scope="camera_stream")
|
||||
|
||||
response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={stream_token}")
|
||||
assert response.status_code == 401
|
||||
|
||||
async def test_camwall_token_cannot_reach_the_feed(self, async_client: AsyncClient, printer_row):
|
||||
"""The crux of a *separate* scope from camwall.
|
||||
|
||||
A Cam Wall token is trusted precisely because it can never name the part
|
||||
being printed. The overlay feed does name it, so a camwall token must be
|
||||
rejected here — otherwise every wall token silently gains filename
|
||||
visibility.
|
||||
"""
|
||||
jwt = await _setup_admin(async_client, suffix="_camwallscope")
|
||||
camwall_token = await _mint(async_client, jwt, scope="camwall")
|
||||
|
||||
response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={camwall_token}")
|
||||
assert response.status_code == 401
|
||||
|
||||
async def test_overlay_token_reaches_the_feed(self, async_client: AsyncClient, printer_row):
|
||||
jwt = await _setup_admin(async_client, suffix="_rightscope")
|
||||
overlay_token = await _mint(async_client, jwt, scope="overlay")
|
||||
|
||||
response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={overlay_token}")
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["name"] == "Stream P1S"
|
||||
|
||||
async def test_revoked_overlay_token_is_rejected(self, async_client: AsyncClient, printer_row):
|
||||
jwt = await _setup_admin(async_client, suffix="_revoked")
|
||||
created = await async_client.post(
|
||||
"/api/v1/auth/tokens",
|
||||
headers={"Authorization": f"Bearer {jwt}"},
|
||||
json={"name": "obs", "expires_in_days": 30, "scope": "overlay"},
|
||||
)
|
||||
overlay_token = created.json()["token"]
|
||||
await async_client.delete(
|
||||
f"/api/v1/auth/tokens/{created.json()['id']}",
|
||||
headers={"Authorization": f"Bearer {jwt}"},
|
||||
)
|
||||
|
||||
response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={overlay_token}")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
class TestOverlayFeedPayload:
|
||||
async def test_payload_shape_includes_filename_fields(self, async_client: AsyncClient, printer_row):
|
||||
"""Unlike the Cam Wall, the overlay *does* carry the filename fields —
|
||||
that is what distinguishes the scope. Assert the exact key set so the
|
||||
payload can't silently grow to leak more than the overlay draws.
|
||||
"""
|
||||
jwt = await _setup_admin(async_client, suffix="_payload")
|
||||
overlay_token = await _mint(async_client, jwt, scope="overlay")
|
||||
|
||||
response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={overlay_token}")
|
||||
assert response.status_code == 200
|
||||
entry = response.json()
|
||||
|
||||
# Never the secrets — the URL is on a public stream.
|
||||
for leaked in ("serial_number", "ip_address", "access_code"):
|
||||
assert leaked not in entry, f"{leaked} must not be served to an overlay token"
|
||||
|
||||
assert set(entry) == {
|
||||
"id",
|
||||
"name",
|
||||
"camera_rotation",
|
||||
"connected",
|
||||
"state",
|
||||
"current_print",
|
||||
"gcode_file",
|
||||
"progress",
|
||||
"remaining_time",
|
||||
"layer_num",
|
||||
"total_layers",
|
||||
"stg_cur_name",
|
||||
"time_format",
|
||||
}
|
||||
|
||||
async def test_disconnected_printer_reports_connected_false(self, async_client: AsyncClient, printer_row):
|
||||
"""No MQTT client runs in tests, so the printer has no state — the
|
||||
overlay must render its offline state rather than erroring.
|
||||
"""
|
||||
jwt = await _setup_admin(async_client, suffix="_offline")
|
||||
overlay_token = await _mint(async_client, jwt, scope="overlay")
|
||||
|
||||
response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={overlay_token}")
|
||||
entry = response.json()
|
||||
assert entry["connected"] is False
|
||||
assert entry["state"] is None
|
||||
assert entry["current_print"] is None
|
||||
|
||||
async def test_unknown_printer_is_404_not_401(self, async_client: AsyncClient):
|
||||
"""A valid token for a printer id that doesn't exist is a 404 — the token
|
||||
passed the gate, the resource simply isn't there.
|
||||
"""
|
||||
jwt = await _setup_admin(async_client, suffix="_404")
|
||||
overlay_token = await _mint(async_client, jwt, scope="overlay")
|
||||
|
||||
response = await async_client.get(f"/api/v1/printers/99999/overlay-status?token={overlay_token}")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestOverlayTokenReachesTheVideo:
|
||||
"""The overlay draws the camera feed, so the same token has to satisfy the
|
||||
camera-stream gate.
|
||||
"""
|
||||
|
||||
async def test_overlay_token_passes_the_camera_stream_gate(self, async_client: AsyncClient):
|
||||
from backend.app.core.auth import verify_camera_stream_token
|
||||
|
||||
jwt = await _setup_admin(async_client, suffix="_video")
|
||||
overlay_token = await _mint(async_client, jwt, scope="overlay")
|
||||
|
||||
assert await verify_camera_stream_token(overlay_token) is True
|
||||
|
||||
async def test_overlay_gate_rejects_camera_stream_and_camwall(self, async_client: AsyncClient):
|
||||
from backend.app.core.auth import verify_overlay_token
|
||||
|
||||
jwt = await _setup_admin(async_client, suffix="_gate")
|
||||
stream_token = await _mint(async_client, jwt, scope="camera_stream")
|
||||
camwall_token = await _mint(async_client, jwt, scope="camwall", name="wall")
|
||||
|
||||
assert await verify_overlay_token(stream_token) is False
|
||||
assert await verify_overlay_token(camwall_token) is False
|
||||
|
||||
async def test_camwall_gate_rejects_an_overlay_token(self, async_client: AsyncClient):
|
||||
"""Symmetric guard: the new scope must not widen the Cam Wall either."""
|
||||
from backend.app.core.auth import verify_camwall_token
|
||||
|
||||
jwt = await _setup_admin(async_client, suffix="_gate_camwall")
|
||||
overlay_token = await _mint(async_client, jwt, scope="overlay")
|
||||
|
||||
assert await verify_camwall_token(overlay_token) is False
|
||||
|
|
@ -333,6 +333,57 @@ class TestPrintQueueAPI:
|
|||
assert result["bed_levelling"] is False
|
||||
assert result["timelapse"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_reassign_rejected_while_dispatching(
|
||||
self, async_client: AsyncClient, queue_item_factory, printer_factory, db_session
|
||||
):
|
||||
"""#2615: a claimed (in-flight) row rejects edits with 409, so its printer
|
||||
can't be reassigned out from under the running FTP upload."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
item = await queue_item_factory(dispatching_at=datetime.now(timezone.utc))
|
||||
other = await printer_factory()
|
||||
original_printer_id = item.printer_id
|
||||
|
||||
response = await async_client.patch(f"/api/v1/queue/{item.id}", json={"printer_id": other.id})
|
||||
assert response.status_code == 409
|
||||
|
||||
await db_session.refresh(item)
|
||||
assert item.printer_id == original_printer_id, "printer_id must not change on a dispatching row"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_bulk_update_skips_dispatching_item(
|
||||
self, async_client: AsyncClient, queue_item_factory, printer_factory, db_session
|
||||
):
|
||||
"""#2615: bulk edits skip a claimed row rather than splitting it."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
item = await queue_item_factory(dispatching_at=datetime.now(timezone.utc))
|
||||
other = await printer_factory()
|
||||
original_printer_id = item.printer_id
|
||||
|
||||
response = await async_client.patch("/api/v1/queue/bulk", json={"item_ids": [item.id], "printer_id": other.id})
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["skipped_count"] == 1
|
||||
assert body["updated_count"] == 0
|
||||
|
||||
await db_session.refresh(item)
|
||||
assert item.printer_id == original_printer_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_update_allowed_on_unclaimed_pending_item(
|
||||
self, async_client: AsyncClient, queue_item_factory, db_session
|
||||
):
|
||||
"""Regression guard: a normal pending row (no claim) still edits fine."""
|
||||
item = await queue_item_factory()
|
||||
response = await async_client.patch(f"/api/v1/queue/{item.id}", json={"plate_id": 7})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["plate_id"] == 7
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_get_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):
|
||||
|
|
|
|||
|
|
@ -107,13 +107,14 @@ async def test_create_rejects_expiry_above_policy_cap(db_session, alice: User):
|
|||
|
||||
|
||||
async def test_create_rejects_unsupported_scope(db_session, alice: User):
|
||||
"""The scope set is closed: ``camera_stream`` (#1108) and ``camwall`` (#2531).
|
||||
"""The scope set is closed: ``camera_stream`` (#1108), ``camwall`` (#2531),
|
||||
and ``overlay`` (#2613).
|
||||
|
||||
Pinned deliberately. Adding a scope should be a decision someone makes on
|
||||
purpose — a new value here means a new class of thing a URL-borne token can
|
||||
reach, so it should not be possible to add one without this line failing.
|
||||
"""
|
||||
assert {"camera_stream", "camwall"} == set(ALLOWED_SCOPES)
|
||||
assert {"camera_stream", "camwall", "overlay"} == set(ALLOWED_SCOPES)
|
||||
with pytest.raises(ValueError, match="unsupported scope"):
|
||||
await create_token(
|
||||
db_session,
|
||||
|
|
|
|||
138
backend/tests/unit/test_scheduler_reassign_race_2615.py
Normal file
138
backend/tests/unit/test_scheduler_reassign_race_2615.py
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
"""Reassign-during-dispatch race regression (#2615).
|
||||
|
||||
A queue row stays ``status='pending'`` for the whole (slow) FTP upload — status
|
||||
only flips to ``printing`` at the very end. That left a window where a PATCH
|
||||
could reassign ``printer_id`` mid-upload while the in-flight dispatch kept using
|
||||
the old printer, splitting the queue row from the archive / expected-print /
|
||||
physical command. The fix is a ``dispatching_at`` claim, stamped atomically
|
||||
before any slow I/O, that the edit routes reject on and the scheduler won't
|
||||
re-select. These tests cover the claim primitives, the guaranteed release, and
|
||||
the startup reconciliation that clears a claim orphaned by a crash mid-dispatch.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
import backend.app.models # noqa: F401 - populate Base.metadata
|
||||
import backend.app.services.print_scheduler as scheduler_module
|
||||
from backend.app.core.database import Base
|
||||
from backend.app.models.print_queue import PrintQueueItem
|
||||
from backend.app.models.printer import Printer
|
||||
from backend.app.services.print_scheduler import PrintScheduler
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def ctx():
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
sm = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
async with sm() as db:
|
||||
printer = Printer(name="P", serial_number="S", ip_address="127.0.0.1", access_code="c", model="X1C")
|
||||
db.add(printer)
|
||||
await db.flush()
|
||||
item = PrintQueueItem(printer_id=printer.id, status="pending")
|
||||
db.add(item)
|
||||
await db.commit()
|
||||
item_id = item.id
|
||||
|
||||
try:
|
||||
yield SimpleNamespace(sm=sm, item_id=item_id, printer_id=printer.id)
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def _get(ctx, item_id=None):
|
||||
async with ctx.sm() as db:
|
||||
return await db.get(PrintQueueItem, item_id or ctx.item_id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_claim_stamps_pending_row_and_is_exclusive(ctx):
|
||||
sched = PrintScheduler()
|
||||
async with ctx.sm() as db:
|
||||
assert await sched._claim_for_dispatch(db, ctx.item_id) is True
|
||||
assert (await _get(ctx)).dispatching_at is not None
|
||||
|
||||
# A second claim on an already-claimed row loses.
|
||||
async with ctx.sm() as db:
|
||||
assert await sched._claim_for_dispatch(db, ctx.item_id) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_claim_fails_on_non_pending_row(ctx):
|
||||
sched = PrintScheduler()
|
||||
async with ctx.sm() as db:
|
||||
item = await db.get(PrintQueueItem, ctx.item_id)
|
||||
item.status = "printing"
|
||||
await db.commit()
|
||||
async with ctx.sm() as db:
|
||||
assert await sched._claim_for_dispatch(db, ctx.item_id) is False
|
||||
assert (await _get(ctx)).dispatching_at is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_clear_releases_the_claim(ctx):
|
||||
sched = PrintScheduler()
|
||||
async with ctx.sm() as db:
|
||||
await sched._claim_for_dispatch(db, ctx.item_id)
|
||||
async with ctx.sm() as db:
|
||||
await sched._clear_dispatch_claim(db, ctx.item_id)
|
||||
assert (await _get(ctx)).dispatching_at is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_one_claims_then_releases_around_start_print(ctx):
|
||||
sched = PrintScheduler()
|
||||
seen = {}
|
||||
|
||||
async def fake_start_print(db, item):
|
||||
# Observe the claim is held while dispatch runs.
|
||||
row = await db.get(PrintQueueItem, item.id)
|
||||
seen["claimed_during"] = row.dispatching_at is not None
|
||||
|
||||
with (
|
||||
patch.object(scheduler_module, "async_session", ctx.sm),
|
||||
patch.object(sched, "_start_print", side_effect=fake_start_print) as sp,
|
||||
):
|
||||
await sched._dispatch_one(ctx.item_id)
|
||||
|
||||
assert seen["claimed_during"] is True, "claim must be held while dispatch runs"
|
||||
sp.assert_awaited_once()
|
||||
# Released on exit so a deferred (still-pending) row can re-dispatch.
|
||||
assert (await _get(ctx)).dispatching_at is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_one_skips_an_already_claimed_row(ctx):
|
||||
sched = PrintScheduler()
|
||||
# Pre-claim the row (as if another worker owns it).
|
||||
async with ctx.sm() as db:
|
||||
await sched._claim_for_dispatch(db, ctx.item_id)
|
||||
|
||||
with (
|
||||
patch.object(scheduler_module, "async_session", ctx.sm),
|
||||
patch.object(sched, "_start_print", new=AsyncMock()) as sp,
|
||||
):
|
||||
await sched._dispatch_one(ctx.item_id)
|
||||
|
||||
sp.assert_not_called() # claim lost → no dispatch
|
||||
# And it must NOT clear the other worker's claim.
|
||||
assert (await _get(ctx)).dispatching_at is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_startup_reconciliation_clears_stale_claims(ctx):
|
||||
sched = PrintScheduler()
|
||||
async with ctx.sm() as db:
|
||||
await sched._claim_for_dispatch(db, ctx.item_id)
|
||||
assert (await _get(ctx)).dispatching_at is not None
|
||||
|
||||
with patch.object(scheduler_module, "async_session", ctx.sm):
|
||||
await sched._clear_stale_dispatch_claims()
|
||||
|
||||
assert (await _get(ctx)).dispatching_at is None, "a claim orphaned by a restart must be cleared"
|
||||
43
frontend/src/__tests__/components/VirtualKeyboard.test.tsx
Normal file
43
frontend/src/__tests__/components/VirtualKeyboard.test.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
/**
|
||||
* Regression for #2616. react-simple-keyboard ships as CommonJS; under the
|
||||
* bundler's CJS interop the default import can arrive as the module namespace
|
||||
* object rather than the Keyboard component, so rendering <Keyboard> throws
|
||||
* React #130 ("Element type is invalid ... got: object"). The on-screen keyboard
|
||||
* mounts on every SpoolBuddy screen the instant a text input is focused, so the
|
||||
* crash hit inventory search and the write-tag New Spool fields alike.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest';
|
||||
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
|
||||
import { VirtualKeyboard } from '../../components/VirtualKeyboard';
|
||||
|
||||
// focusin schedules a 100ms scrollIntoView on the focused input; jsdom doesn't
|
||||
// implement it, so stub it or the deferred call throws an unhandled error after
|
||||
// the test completes.
|
||||
beforeEach(() => {
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('VirtualKeyboard (#2616)', () => {
|
||||
it('renders the keyboard when a text input is focused (no invalid-element-type crash)', () => {
|
||||
render(
|
||||
<div>
|
||||
<input type="text" placeholder="Search spools..." />
|
||||
<VirtualKeyboard />
|
||||
</div>,
|
||||
);
|
||||
|
||||
const input = screen.getByPlaceholderText('Search spools...');
|
||||
// The shell listens on document focusin, so drive a real focus event.
|
||||
fireEvent.focusIn(input);
|
||||
|
||||
// A key from the layout must be on screen — proves <Keyboard> resolved to a
|
||||
// real component instead of throwing on an object element type.
|
||||
expect(screen.getByText('q')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -120,6 +120,39 @@ describe('CameraTokensPage', () => {
|
|||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers the overlay scope and shows a ready-made OBS overlay URL (#2613)', async () => {
|
||||
server.use(
|
||||
http.get('*/api/v1/auth/tokens', () => HttpResponse.json([])),
|
||||
http.post('*/api/v1/auth/tokens', async ({ request }) => {
|
||||
const body = await request.json();
|
||||
expect(body).toMatchObject({ name: 'OBS', scope: 'overlay' });
|
||||
return HttpResponse.json(
|
||||
token({
|
||||
id: 43,
|
||||
name: 'OBS',
|
||||
scope: 'overlay',
|
||||
token: 'bblt_abcd1234_secretsecretsecretsecretsecret',
|
||||
}),
|
||||
{ status: 201 },
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<CameraTokensPage />);
|
||||
|
||||
await screen.findByText(/no tokens yet/i);
|
||||
await user.type(screen.getByLabelText(/token name/i), 'OBS');
|
||||
await user.selectOptions(screen.getByLabelText(/scope/i), 'overlay');
|
||||
await user.click(screen.getByRole('button', { name: /^create$/i }));
|
||||
|
||||
// The created modal hands over the assembled OBS overlay URL carrying the
|
||||
// token, not just the raw token.
|
||||
expect(await screen.findByText(/overlay url for obs/i)).toBeInTheDocument();
|
||||
const url = screen.getByText(/\/overlay\/1\?token=/);
|
||||
expect(url).toHaveTextContent('token=bblt_abcd1234_secretsecretsecretsecretsecret');
|
||||
});
|
||||
|
||||
it('clamps the days input to the 365-day policy cap', async () => {
|
||||
server.use(
|
||||
http.get('*/api/v1/auth/tokens', () => HttpResponse.json([])),
|
||||
|
|
|
|||
|
|
@ -368,4 +368,66 @@ describe('StreamOverlayPage', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('kiosk token mode (#2613)', () => {
|
||||
const mockOverlayPrinting = {
|
||||
id: 1,
|
||||
name: 'X1 Carbon',
|
||||
camera_rotation: 0,
|
||||
connected: true,
|
||||
state: 'RUNNING',
|
||||
current_print: 'KioskBenchy.gcode.3mf',
|
||||
gcode_file: 'plate_1.gcode',
|
||||
progress: 67,
|
||||
remaining_time: 40,
|
||||
layer_num: 10,
|
||||
total_layers: 20,
|
||||
stg_cur_name: null,
|
||||
time_format: 'system',
|
||||
};
|
||||
|
||||
it('reads the token-authed overlay-status feed and carries the token to the camera', async () => {
|
||||
let overlayHit = false;
|
||||
server.use(
|
||||
http.get('/api/v1/printers/:id/overlay-status', () => {
|
||||
overlayHit = true;
|
||||
return HttpResponse.json(mockOverlayPrinting);
|
||||
})
|
||||
);
|
||||
|
||||
renderOverlayPage(1, '?token=obs-tok');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('KioskBenchy')).toBeInTheDocument();
|
||||
});
|
||||
expect(overlayHit).toBe(true);
|
||||
expect(screen.getByText('67%')).toBeInTheDocument();
|
||||
|
||||
// The camera <img> must carry the same kiosk token — a fresh OBS browser
|
||||
// has no session to mint a camera stream token from.
|
||||
const img = screen.getByAltText('Camera stream') as HTMLImageElement;
|
||||
expect(img.src).toContain('token=obs-tok');
|
||||
});
|
||||
|
||||
it('does not touch the JWT-only status endpoint or a WebSocket in kiosk mode', async () => {
|
||||
let statusHit = false;
|
||||
server.use(
|
||||
http.get('/api/v1/printers/:id/overlay-status', () => HttpResponse.json(mockOverlayPrinting)),
|
||||
http.get('/api/v1/printers/:id/status', () => {
|
||||
statusHit = true;
|
||||
return HttpResponse.json(mockStatusIdle);
|
||||
})
|
||||
);
|
||||
|
||||
renderOverlayPage(1, '?token=obs-tok');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('KioskBenchy')).toBeInTheDocument();
|
||||
});
|
||||
// The logged-in status query is disabled when a token is present, so an
|
||||
// unauthenticated OBS browser never fires a doomed 401 (or opens a socket).
|
||||
expect(statusHit).toBe(false);
|
||||
expect(WebSocket).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
46
frontend/src/__tests__/utils/interopDefault.test.ts
Normal file
46
frontend/src/__tests__/utils/interopDefault.test.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
/**
|
||||
* Unit tests for resolveInteropDefault (#2616).
|
||||
*
|
||||
* The browser build resolved react-simple-keyboard's CommonJS default import to
|
||||
* the module namespace object ({ KeyboardReact, default }) instead of the
|
||||
* component, so <Keyboard> threw React #130 ("got: object"). vitest's own interop
|
||||
* happens to hand back the component, so a render test can't catch the
|
||||
* regression — these assert the resolver directly against both shapes.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { resolveInteropDefault } from '../../utils/interopDefault';
|
||||
|
||||
const Comp = function Keyboard() {
|
||||
return null;
|
||||
};
|
||||
|
||||
describe('resolveInteropDefault', () => {
|
||||
it('returns a bare function component unchanged', () => {
|
||||
expect(resolveInteropDefault(Comp)).toBe(Comp);
|
||||
});
|
||||
|
||||
it('unwraps the CJS interop namespace object via .default (the #2616 shape)', () => {
|
||||
const moduleObject = { default: Comp, KeyboardReact: Comp };
|
||||
expect(resolveInteropDefault(moduleObject, ['KeyboardReact'])).toBe(Comp);
|
||||
});
|
||||
|
||||
it('falls back to a named export when there is no .default', () => {
|
||||
const moduleObject = { KeyboardReact: Comp };
|
||||
expect(resolveInteropDefault(moduleObject, ['KeyboardReact'])).toBe(Comp);
|
||||
});
|
||||
|
||||
it('leaves a forwardRef/memo object (with $$typeof) untouched', () => {
|
||||
const forwardRefLike = { $$typeof: Symbol.for('react.forward_ref'), render: Comp };
|
||||
expect(resolveInteropDefault(forwardRefLike)).toBe(forwardRefLike);
|
||||
});
|
||||
|
||||
it('returns a string tag unchanged', () => {
|
||||
expect(resolveInteropDefault('div')).toBe('div');
|
||||
});
|
||||
|
||||
it('returns the value unchanged when nothing usable is found', () => {
|
||||
const opaque = { something: 1 };
|
||||
expect(resolveInteropDefault(opaque, ['KeyboardReact'])).toBe(opaque);
|
||||
});
|
||||
});
|
||||
|
|
@ -294,7 +294,7 @@ export interface SystemHealthResult {
|
|||
// 'camera_stream' reaches the video endpoints only. 'camwall' additionally
|
||||
// reaches the read-only Cam Wall feed, which names the printers (#2531), so it
|
||||
// is a separate scope rather than a widening of tokens already in the wild.
|
||||
export type LongLivedTokenScope = 'camera_stream' | 'camwall';
|
||||
export type LongLivedTokenScope = 'camera_stream' | 'camwall' | 'overlay';
|
||||
|
||||
export interface LongLivedCameraToken {
|
||||
id: number;
|
||||
|
|
@ -324,6 +324,26 @@ export interface CamWallPrinter {
|
|||
hms_errors: HMSError[];
|
||||
}
|
||||
|
||||
// Streaming-overlay feed (#2613). The subset of print state the /overlay page
|
||||
// draws for one printer, served behind an `overlay`-scoped token so OBS embeds
|
||||
// with no login session can read it. Unlike CamWallPrinter this names the file
|
||||
// being printed (the overlay shows the part on screen).
|
||||
export interface OverlayStatus {
|
||||
id: number;
|
||||
name: string;
|
||||
camera_rotation: number;
|
||||
connected: boolean;
|
||||
state: string | null;
|
||||
current_print: string | null;
|
||||
gcode_file: string | null;
|
||||
progress: number | null;
|
||||
remaining_time: number | null;
|
||||
layer_num: number | null;
|
||||
total_layers: number | null;
|
||||
stg_cur_name: string | null;
|
||||
time_format: 'system' | '12h' | '24h';
|
||||
}
|
||||
|
||||
// Printer types
|
||||
export interface Printer {
|
||||
id: number;
|
||||
|
|
@ -5748,6 +5768,15 @@ export const api = {
|
|||
request<CamWallPrinter[]>(
|
||||
token ? `/camwall/printers?token=${encodeURIComponent(token)}` : '/camwall/printers',
|
||||
),
|
||||
// Token-authenticated streaming-overlay feed (#2613). OBS (or any embed with
|
||||
// no login session) loads /overlay/{id}?token=... and this backs it. `token`
|
||||
// is omitted only when auth is disabled, where the backend gate is a no-op.
|
||||
getOverlayStatus: (printerId: number, token?: string) =>
|
||||
request<OverlayStatus>(
|
||||
token
|
||||
? `/printers/${printerId}/overlay-status?token=${encodeURIComponent(token)}`
|
||||
: `/printers/${printerId}/overlay-status`,
|
||||
),
|
||||
getCameraStreamUrl: (printerId: number, fps = 10) =>
|
||||
withStreamToken(`${API_BASE}/printers/${printerId}/camera/stream?fps=${fps}`),
|
||||
getCameraSnapshotUrl: (printerId: number) =>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,20 @@
|
|||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import Keyboard from 'react-simple-keyboard';
|
||||
import KeyboardImport from 'react-simple-keyboard';
|
||||
import 'react-simple-keyboard/build/css/index.css';
|
||||
import './VirtualKeyboard.css';
|
||||
import { resolveInteropDefault } from '../utils/interopDefault';
|
||||
|
||||
// react-simple-keyboard is published as CommonJS. Depending on the bundler's
|
||||
// CJS->ESM interop, the default import arrives either as the Keyboard component
|
||||
// itself or as the module namespace object ({ KeyboardReact, default }). Under
|
||||
// the current Vite build (and Node's ESM loader) it's the latter, so rendering
|
||||
// <KeyboardImport> puts an object where an element type belongs and React throws
|
||||
// "Element type is invalid ... got: object" (#130) — crashing every SpoolBuddy
|
||||
// screen the instant a text input is focused and this keyboard mounts (#2616).
|
||||
// Resolve the real component defensively so it renders under any interop shape.
|
||||
// The TYPE of the default import is already the component (from the .d.ts), so
|
||||
// the cast keeps JSX + ref typing intact while fixing only the runtime value.
|
||||
const Keyboard = resolveInteropDefault<typeof KeyboardImport>(KeyboardImport, ['KeyboardReact']);
|
||||
|
||||
const FOCUSABLE_TYPES = new Set(['text', 'password', 'email', 'search', 'url', 'number']);
|
||||
|
||||
|
|
|
|||
|
|
@ -6678,6 +6678,7 @@ export default {
|
|||
scope: {
|
||||
camera_stream: 'Kamera-Stream',
|
||||
camwall: 'Kamera-Wand',
|
||||
overlay: 'Streaming-Overlay',
|
||||
},
|
||||
title: 'Kamera-API-Tokens',
|
||||
navTitle: 'Kamera-API-Tokens',
|
||||
|
|
@ -6696,6 +6697,8 @@ export default {
|
|||
'Ein Kamera-Stream-Token kann ausschließlich Kamera-Streams und Schnappschüsse abrufen. Geeignet für Home Assistant, Frigate oder alles, was eine einzelne Kamera einbettet.',
|
||||
hintCamWall:
|
||||
'Ein Kamera-Wand-Token öffnet /camwall auf einem Bildschirm ohne Anmeldung. Es sieht Name und Status jedes Druckers sowie deren Kamera-Streams. Dateinamen, Adressen und Zugangscodes sieht es nicht.',
|
||||
hintOverlay:
|
||||
'Ein Streaming-Overlay-Token öffnet /overlay/{printerId} auf einem Bildschirm ohne Anmeldung – für OBS oder jeden Livestream. Es sieht den Kamera-Stream eines Druckers sowie dessen Live-Druckstatus, einschließlich des auf dem Bildschirm angezeigten Dateinamens. Adressen und Zugangscodes sieht es nicht.',
|
||||
title: 'Neues Token erstellen',
|
||||
nameLabel: 'Token-Name',
|
||||
namePlaceholder: 'z. B. Home Assistant',
|
||||
|
|
@ -6708,6 +6711,9 @@ export default {
|
|||
camWallUrlTitle: 'Kamera-Wand-Adresse für diesen Bildschirm',
|
||||
camWallUrlHint:
|
||||
'Diese Adresse auf dem Bildschirm öffnen. Wer die Adresse lesen kann, kann die Kamera-Wand sehen — behandeln Sie sie wie einen Schlüssel. Widerrufen Sie das Token, um den Bildschirm abzuschalten.',
|
||||
overlayUrlTitle: 'Overlay-Adresse für OBS',
|
||||
overlayUrlHint:
|
||||
'Fügen Sie dies in OBS als Browser-Quelle hinzu. Ändern Sie die Zahl in /overlay/1 auf die Nummer Ihres Druckers (aus dessen Adresse auf der Seite „Drucker“). Wer die Adresse lesen kann, kann den Stream sehen – behandeln Sie sie wie einen Schlüssel und widerrufen Sie das Token, um sie abzuschalten.',
|
||||
title: 'Token erstellt – jetzt kopieren',
|
||||
warning:
|
||||
'Dies ist das einzige Mal, dass dieser Token sichtbar ist. Nach dem Schließen dieses Dialogs können Sie ihn nie wieder anzeigen.',
|
||||
|
|
|
|||
|
|
@ -6722,6 +6722,7 @@ export default {
|
|||
scope: {
|
||||
camera_stream: 'Camera stream',
|
||||
camwall: 'Cam Wall',
|
||||
overlay: 'Streaming Overlay',
|
||||
},
|
||||
title: 'Camera API Tokens',
|
||||
navTitle: 'Camera API tokens',
|
||||
|
|
@ -6740,6 +6741,8 @@ export default {
|
|||
'A camera-stream token can only fetch camera streams and snapshots. Use it for Home Assistant, Frigate, or anything embedding a single camera.',
|
||||
hintCamWall:
|
||||
"A Cam Wall token opens /camwall on a screen with no login. It can see every printer's name and state, and their camera streams. It cannot see filenames, addresses or access codes.",
|
||||
hintOverlay:
|
||||
"A Streaming Overlay token opens /overlay/{printerId} on a screen with no login — for OBS or any live stream. It can see one printer's camera stream plus its live print status, including the filename shown on screen. It cannot see addresses or access codes.",
|
||||
title: 'Create new token',
|
||||
nameLabel: 'Token name',
|
||||
namePlaceholder: 'e.g. Home Assistant',
|
||||
|
|
@ -6752,6 +6755,9 @@ export default {
|
|||
camWallUrlTitle: 'Cam Wall URL for this display',
|
||||
camWallUrlHint:
|
||||
'Open this on the screen. Anyone who can read the URL can watch the wall, so treat it like a key — revoke the token to cut the display off.',
|
||||
overlayUrlTitle: 'Overlay URL for OBS',
|
||||
overlayUrlHint:
|
||||
"Add this as a Browser Source in OBS. Change the /overlay/1 number to your printer's number (from its URL on the Printers page). Anyone who can read the URL can watch the stream, so treat it like a key — revoke the token to cut it off.",
|
||||
title: 'Token created — copy it now',
|
||||
warning:
|
||||
'This is the only time this token will be visible. After you close this dialog you can never view it again.',
|
||||
|
|
|
|||
|
|
@ -6687,6 +6687,7 @@ export default {
|
|||
scope: {
|
||||
camera_stream: 'Transmisión de cámara',
|
||||
camwall: 'Muro de cámaras',
|
||||
overlay: 'Superposición de streaming',
|
||||
},
|
||||
title: 'Tokens de API de la cámara',
|
||||
navTitle: 'Tokens de API de la cámara',
|
||||
|
|
@ -6705,6 +6706,8 @@ export default {
|
|||
'Un token de transmisión de cámara solo puede obtener transmisiones e instantáneas. Úsalo para Home Assistant, Frigate o cualquier cosa que incruste una sola cámara.',
|
||||
hintCamWall:
|
||||
'Un token de muro de cámaras abre /camwall en una pantalla sin iniciar sesión. Puede ver el nombre y el estado de cada impresora, y sus transmisiones de cámara. No puede ver nombres de archivo, direcciones ni códigos de acceso.',
|
||||
hintOverlay:
|
||||
'Un token de superposición de streaming abre /overlay/{printerId} en una pantalla sin iniciar sesión, para OBS o cualquier transmisión en vivo. Puede ver la transmisión de la cámara de una impresora y su estado de impresión en vivo, incluido el nombre de archivo que aparece en pantalla. No puede ver direcciones ni códigos de acceso.',
|
||||
title: 'Crear nuevo token',
|
||||
nameLabel: 'Nombre del token',
|
||||
namePlaceholder: 'p. ej. Home Assistant',
|
||||
|
|
@ -6717,6 +6720,9 @@ export default {
|
|||
camWallUrlTitle: 'Dirección del muro de cámaras para esta pantalla',
|
||||
camWallUrlHint:
|
||||
'Abre esta dirección en la pantalla. Cualquiera que pueda leerla puede ver el muro, así que trátala como una llave: revoca el token para dejar la pantalla sin acceso.',
|
||||
overlayUrlTitle: 'Dirección de superposición para OBS',
|
||||
overlayUrlHint:
|
||||
'Agrega esto como Fuente de navegador en OBS. Cambia el número de /overlay/1 por el número de tu impresora (de su dirección en la página Impresoras). Cualquiera que pueda leer la dirección puede ver la transmisión, así que trátala como una llave: revoca el token para cortar el acceso.',
|
||||
title: 'Token creado — cópielo ahora',
|
||||
warning:
|
||||
'Esta es la única vez que este token estará visible. Después de cerrar este diálogo no podrá volver a verlo nunca.',
|
||||
|
|
|
|||
|
|
@ -6666,6 +6666,7 @@ export default {
|
|||
scope: {
|
||||
camera_stream: 'Flux de caméra',
|
||||
camwall: 'Mur de caméras',
|
||||
overlay: 'Incrustation de streaming',
|
||||
},
|
||||
title: 'Jetons API caméra',
|
||||
navTitle: 'Jetons API caméra',
|
||||
|
|
@ -6684,6 +6685,8 @@ export default {
|
|||
'Un jeton de flux de caméra ne peut récupérer que des flux et des instantanés. À utiliser pour Home Assistant, Frigate ou tout ce qui intègre une seule caméra.',
|
||||
hintCamWall:
|
||||
"Un jeton Mur de caméras ouvre /camwall sur un écran sans connexion. Il voit le nom et l'état de chaque imprimante, ainsi que leurs flux de caméra. Il ne voit ni les noms de fichiers, ni les adresses, ni les codes d'accès.",
|
||||
hintOverlay:
|
||||
"Un jeton Incrustation de streaming ouvre /overlay/{printerId} sur un écran sans connexion — pour OBS ou tout autre flux en direct. Il voit le flux de caméra d'une imprimante ainsi que son état d'impression en direct, y compris le nom de fichier affiché à l'écran. Il ne voit ni les adresses ni les codes d'accès.",
|
||||
title: 'Créer un nouveau jeton',
|
||||
nameLabel: 'Nom du jeton',
|
||||
namePlaceholder: 'par ex. Home Assistant',
|
||||
|
|
@ -6696,6 +6699,9 @@ export default {
|
|||
camWallUrlTitle: 'Adresse du mur de caméras pour cet écran',
|
||||
camWallUrlHint:
|
||||
"Ouvrez cette adresse sur l'écran. Quiconque peut lire l'adresse peut regarder le mur : traitez-la comme une clé. Révoquez le jeton pour couper l'écran.",
|
||||
overlayUrlTitle: "Adresse d'incrustation pour OBS",
|
||||
overlayUrlHint:
|
||||
"Ajoutez ceci comme Source navigateur dans OBS. Remplacez le numéro dans /overlay/1 par le numéro de votre imprimante (indiqué dans son adresse sur la page Imprimantes). Quiconque peut lire l'adresse peut regarder le flux : traitez-la comme une clé et révoquez le jeton pour couper l'accès.",
|
||||
title: 'Jeton créé – copiez-le maintenant',
|
||||
warning:
|
||||
'C\'est la seule fois où ce jeton sera visible. Après la fermeture de ce dialogue, vous ne pourrez plus jamais le voir.',
|
||||
|
|
|
|||
|
|
@ -6665,6 +6665,7 @@ export default {
|
|||
scope: {
|
||||
camera_stream: 'Flusso della telecamera',
|
||||
camwall: 'Muro telecamere',
|
||||
overlay: 'Overlay di streaming',
|
||||
},
|
||||
title: 'Token API telecamera',
|
||||
navTitle: 'Token API telecamera',
|
||||
|
|
@ -6683,6 +6684,8 @@ export default {
|
|||
'Un token del flusso della telecamera può recuperare soltanto flussi e istantanee. Usalo per Home Assistant, Frigate o qualsiasi cosa incorpori una singola telecamera.',
|
||||
hintCamWall:
|
||||
'Un token Muro telecamere apre /camwall su uno schermo senza login. Vede nome e stato di ogni stampante e i relativi flussi della telecamera. Non vede nomi di file, indirizzi o codici di accesso.',
|
||||
hintOverlay:
|
||||
'Un token Overlay di streaming apre /overlay/{printerId} su uno schermo senza login, per OBS o qualsiasi diretta streaming. Vede il flusso della telecamera di una stampante e il suo stato di stampa in tempo reale, incluso il nome del file mostrato sullo schermo. Non vede indirizzi o codici di accesso.',
|
||||
title: 'Crea nuovo token',
|
||||
nameLabel: 'Nome token',
|
||||
namePlaceholder: 'es. Home Assistant',
|
||||
|
|
@ -6695,6 +6698,9 @@ export default {
|
|||
camWallUrlTitle: 'Indirizzo del muro telecamere per questo schermo',
|
||||
camWallUrlHint:
|
||||
'Apri questo indirizzo sullo schermo. Chiunque possa leggerlo può guardare il muro, quindi trattalo come una chiave: revoca il token per escludere lo schermo.',
|
||||
overlayUrlTitle: 'Indirizzo overlay per OBS',
|
||||
overlayUrlHint:
|
||||
"Aggiungi questo come Sorgente browser in OBS. Cambia il numero in /overlay/1 con il numero della tua stampante (dall'indirizzo nella pagina Stampanti). Chiunque possa leggere l'indirizzo può guardare lo streaming, quindi trattalo come una chiave: revoca il token per interrompere l'accesso.",
|
||||
title: 'Token creato – copialo ora',
|
||||
warning:
|
||||
'Questa è l\'unica volta in cui questo token sarà visibile. Dopo la chiusura di questa finestra non potrai più visualizzarlo.',
|
||||
|
|
|
|||
|
|
@ -6677,6 +6677,7 @@ export default {
|
|||
scope: {
|
||||
camera_stream: 'カメラストリーム',
|
||||
camwall: 'カメラウォール',
|
||||
overlay: '配信オーバーレイ',
|
||||
},
|
||||
title: 'カメラAPIトークン',
|
||||
navTitle: 'カメラAPIトークン',
|
||||
|
|
@ -6695,6 +6696,8 @@ export default {
|
|||
'カメラストリームトークンで取得できるのは、カメラの映像とスナップショットだけです。Home Assistant や Frigate など、単一のカメラを埋め込む用途に使用してください。',
|
||||
hintCamWall:
|
||||
'カメラウォールトークンは、ログインなしの画面で /camwall を開きます。各プリンターの名前と状態、そしてカメラ映像を見ることができます。ファイル名、アドレス、アクセスコードは見えません。',
|
||||
hintOverlay:
|
||||
'配信オーバーレイトークンは、ログインなしの画面で /overlay/{printerId} を開きます — OBS やライブ配信向けです。1台のプリンターのカメラ映像に加え、画面に表示されるファイル名を含むライブの印刷状況を見ることができます。アドレスやアクセスコードは見えません。',
|
||||
title: '新しいトークンを作成',
|
||||
nameLabel: 'トークン名',
|
||||
namePlaceholder: '例:Home Assistant',
|
||||
|
|
@ -6707,6 +6710,9 @@ export default {
|
|||
camWallUrlTitle: 'この画面用のカメラウォール URL',
|
||||
camWallUrlHint:
|
||||
'この URL を画面で開いてください。URL を読める人は誰でもウォールを見られるため、鍵と同じように扱ってください。トークンを取り消すと、その画面は遮断されます。',
|
||||
overlayUrlTitle: 'OBS 用のオーバーレイ URL',
|
||||
overlayUrlHint:
|
||||
'これを OBS の「ブラウザ」ソース(Browser Source)として追加してください。/overlay/1 の番号を、お使いのプリンターの番号(プリンターページの URL に表示)に変更します。URL を読める人は誰でも配信を見られるため、鍵と同じように扱い、遮断するにはトークンを取り消してください。',
|
||||
title: 'トークンを作成しました – 今すぐコピー',
|
||||
warning:
|
||||
'このトークンが表示されるのは今回限りです。このダイアログを閉じると二度と表示できません。',
|
||||
|
|
|
|||
|
|
@ -6147,6 +6147,7 @@ export default {
|
|||
scope: {
|
||||
camera_stream: '카메라 스트림',
|
||||
camwall: '카메라 월',
|
||||
overlay: '스트리밍 오버레이',
|
||||
},
|
||||
title: '카메라 API 토큰',
|
||||
navTitle: '카메라 API 토큰',
|
||||
|
|
@ -6165,6 +6166,8 @@ export default {
|
|||
'카메라 스트림 토큰은 카메라 스트림과 스냅숏만 가져올 수 있습니다. Home Assistant, Frigate 등 카메라 하나를 삽입하는 용도로 사용하세요.',
|
||||
hintCamWall:
|
||||
'카메라 월 토큰은 로그인 없이 화면에서 /camwall을 엽니다. 모든 프린터의 이름과 상태, 카메라 스트림을 볼 수 있습니다. 파일 이름, 주소, 액세스 코드는 볼 수 없습니다.',
|
||||
hintOverlay:
|
||||
'스트리밍 오버레이 토큰은 로그인 없이 화면에서 /overlay/{printerId}을 엽니다 — OBS나 모든 라이브 방송용입니다. 프린터 한 대의 카메라 스트림과 화면에 표시되는 파일 이름을 포함한 실시간 인쇄 상태를 볼 수 있습니다. 주소나 액세스 코드는 볼 수 없습니다.',
|
||||
title: '새 토큰 만들기',
|
||||
nameLabel: '토큰 이름',
|
||||
namePlaceholder: '예: Home Assistant',
|
||||
|
|
@ -6176,6 +6179,9 @@ export default {
|
|||
camWallUrlTitle: '이 화면용 카메라 월 주소',
|
||||
camWallUrlHint:
|
||||
'이 주소를 화면에서 여세요. 주소를 읽을 수 있는 사람은 누구나 월을 볼 수 있으므로 열쇠처럼 다루세요. 토큰을 취소하면 해당 화면의 접근이 차단됩니다.',
|
||||
overlayUrlTitle: 'OBS용 오버레이 주소',
|
||||
overlayUrlHint:
|
||||
'OBS에서 이것을 브라우저 소스로 추가하세요. /overlay/1의 숫자를 프린터의 번호(프린터 페이지의 주소에 표시됨)로 변경하세요. 주소를 읽을 수 있는 사람은 누구나 스트림을 볼 수 있으므로 열쇠처럼 다루세요 — 접근을 차단하려면 토큰을 취소하세요.',
|
||||
title: '토큰 생성됨 — 지금 복사하세요',
|
||||
warning: '이 토큰은 이 번만 볼 수 있습니다. 이 대화상자를 닫으면 다시는 볼 수 없습니다.',
|
||||
copy: '복사',
|
||||
|
|
|
|||
|
|
@ -6665,6 +6665,7 @@ export default {
|
|||
scope: {
|
||||
camera_stream: 'Transmissão da câmera',
|
||||
camwall: 'Mural de câmeras',
|
||||
overlay: 'Sobreposição de streaming',
|
||||
},
|
||||
title: 'Tokens da API de câmera',
|
||||
navTitle: 'Tokens da API de câmera',
|
||||
|
|
@ -6683,6 +6684,8 @@ export default {
|
|||
'Um token de transmissão da câmera só consegue buscar transmissões e instantâneos. Use-o no Home Assistant, no Frigate ou em qualquer coisa que incorpore uma única câmera.',
|
||||
hintCamWall:
|
||||
'Um token do mural de câmeras abre /camwall em uma tela sem login. Ele vê o nome e o estado de cada impressora e as transmissões das câmeras. Não vê nomes de arquivo, endereços nem códigos de acesso.',
|
||||
hintOverlay:
|
||||
'Um token de sobreposição de streaming abre /overlay/{printerId} em uma tela sem login — para o OBS ou qualquer transmissão ao vivo. Ele vê a transmissão da câmera de uma impressora e seu status de impressão ao vivo, incluindo o nome de arquivo mostrado na tela. Não vê endereços nem códigos de acesso.',
|
||||
title: 'Criar novo token',
|
||||
nameLabel: 'Nome do token',
|
||||
namePlaceholder: 'ex. Home Assistant',
|
||||
|
|
@ -6695,6 +6698,9 @@ export default {
|
|||
camWallUrlTitle: 'Endereço do mural de câmeras para esta tela',
|
||||
camWallUrlHint:
|
||||
'Abra este endereço na tela. Qualquer pessoa que consiga lê-lo pode assistir ao painel, então trate-o como uma chave: revogue o token para cortar o acesso da tela.',
|
||||
overlayUrlTitle: 'Endereço de sobreposição para OBS',
|
||||
overlayUrlHint:
|
||||
'Adicione isto como Fonte de navegador no OBS. Altere o número em /overlay/1 para o número da sua impressora (do endereço dela na página Impressoras). Qualquer pessoa que consiga ler o endereço pode assistir à transmissão, então trate-o como uma chave: revogue o token para cortar o acesso.',
|
||||
title: 'Token criado – copie agora',
|
||||
warning:
|
||||
'Esta é a única vez que este token será visível. Após fechar este diálogo, você nunca poderá vê-lo novamente.',
|
||||
|
|
|
|||
|
|
@ -6618,6 +6618,7 @@ export default {
|
|||
scope: {
|
||||
camera_stream: 'Kamera akışı',
|
||||
camwall: 'Kamera duvarı',
|
||||
overlay: 'Yayın bindirmesi',
|
||||
},
|
||||
title: 'Kamera API Belirteçleri',
|
||||
navTitle: 'Kamera API belirteçleri',
|
||||
|
|
@ -6636,6 +6637,8 @@ export default {
|
|||
'Kamera akışı belirteci yalnızca kamera akışlarını ve anlık görüntüleri alabilir. Home Assistant, Frigate veya tek bir kamerayı gömen her şey için kullanın.',
|
||||
hintCamWall:
|
||||
'Kamera duvarı belirteci, oturum açmadan bir ekranda /camwall adresini açar. Her yazıcının adını ve durumunu, ayrıca kamera akışlarını görebilir. Dosya adlarını, adresleri veya erişim kodlarını göremez.',
|
||||
hintOverlay:
|
||||
'Yayın bindirmesi belirteci, oturum açmadan bir ekranda /overlay/{printerId} adresini açar — OBS veya herhangi bir canlı yayın için. Bir yazıcının kamera akışını ve ekranda gösterilen dosya adı dahil canlı yazdırma durumunu görebilir. Adresleri veya erişim kodlarını göremez.',
|
||||
title: 'Yeni belirteç oluştur',
|
||||
nameLabel: 'Belirteç adı',
|
||||
namePlaceholder: 'örn. Home Assistant',
|
||||
|
|
@ -6648,6 +6651,9 @@ export default {
|
|||
camWallUrlTitle: 'Bu ekran için kamera duvarı adresi',
|
||||
camWallUrlHint:
|
||||
'Bu adresi ekranda açın. Adresi okuyabilen herkes duvarı izleyebilir, bu yüzden onu bir anahtar gibi görün; ekranın erişimini kesmek için belirteci iptal edin.',
|
||||
overlayUrlTitle: 'OBS için bindirme adresi',
|
||||
overlayUrlHint:
|
||||
'Bunu OBS\'ye Tarayıcı Kaynağı olarak ekleyin. /overlay/1 içindeki sayıyı yazıcınızın numarasıyla değiştirin (Yazıcılar sayfasındaki adresinden). Adresi okuyabilen herkes yayını izleyebilir, bu yüzden onu bir anahtar gibi görün — erişimi kesmek için belirteci iptal edin.',
|
||||
title: 'Belirteç oluşturuldu — şimdi kopyalayın',
|
||||
warning:
|
||||
'Bu, bu belirtecin görünür olacağı tek seferdir. Bu iletişim kutusunu kapattıktan sonra onu bir daha asla görüntüleyemezsiniz.',
|
||||
|
|
|
|||
|
|
@ -6664,6 +6664,7 @@ export default {
|
|||
scope: {
|
||||
camera_stream: '摄像头视频流',
|
||||
camwall: '摄像头墙',
|
||||
overlay: '直播叠加层',
|
||||
},
|
||||
title: '摄像头 API 令牌',
|
||||
navTitle: '摄像头 API 令牌',
|
||||
|
|
@ -6682,6 +6683,8 @@ export default {
|
|||
'摄像头视频流令牌只能获取摄像头视频流和快照。适用于 Home Assistant、Frigate 或任何嵌入单个摄像头的场景。',
|
||||
hintCamWall:
|
||||
'摄像头墙令牌可在无需登录的屏幕上打开 /camwall,能看到每台打印机的名称和状态以及摄像头视频流,但看不到文件名、地址或访问码。',
|
||||
hintOverlay:
|
||||
'直播叠加层令牌可在无需登录的屏幕上打开 /overlay/{printerId}——供 OBS 或任何直播使用。它能看到一台打印机的摄像头视频流以及实时打印状态,包括屏幕上显示的文件名,但看不到地址或访问码。',
|
||||
title: '创建新令牌',
|
||||
nameLabel: '令牌名称',
|
||||
namePlaceholder: '例如 Home Assistant',
|
||||
|
|
@ -6694,6 +6697,9 @@ export default {
|
|||
camWallUrlTitle: '此屏幕的摄像头墙网址',
|
||||
camWallUrlHint:
|
||||
'在屏幕上打开此网址。任何能看到该网址的人都能观看摄像头墙,请像对待钥匙一样对待它——撤销令牌即可切断该屏幕的访问。',
|
||||
overlayUrlTitle: '用于 OBS 的叠加层网址',
|
||||
overlayUrlHint:
|
||||
'在 OBS 中将其添加为“浏览器”源(Browser Source)。将 /overlay/1 中的数字改为您打印机的编号(可在“打印机”页面的网址中查看)。任何能看到该网址的人都能观看直播,请像对待钥匙一样对待它——撤销令牌即可切断访问。',
|
||||
title: '令牌已创建 — 立即复制',
|
||||
warning:
|
||||
'这是此令牌唯一一次可见。关闭此对话框后您将无法再次查看。',
|
||||
|
|
|
|||
|
|
@ -6664,6 +6664,7 @@ export default {
|
|||
scope: {
|
||||
camera_stream: '攝影機串流',
|
||||
camwall: '攝影機牆',
|
||||
overlay: '直播疊加層',
|
||||
},
|
||||
title: '攝影機 API 權杖',
|
||||
navTitle: '攝影機 API 權杖',
|
||||
|
|
@ -6682,6 +6683,8 @@ export default {
|
|||
'攝影機串流權杖只能取得攝影機串流與快照。適用於 Home Assistant、Frigate 或任何嵌入單一攝影機的情境。',
|
||||
hintCamWall:
|
||||
'攝影機牆權杖可在無須登入的螢幕上開啟 /camwall,能看到每台印表機的名稱與狀態以及攝影機串流,但看不到檔案名稱、位址或存取碼。',
|
||||
hintOverlay:
|
||||
'直播疊加層權杖可在無須登入的螢幕上開啟 /overlay/{printerId}——供 OBS 或任何直播使用。它能看到一台印表機的攝影機串流以及即時列印狀態,包括螢幕上顯示的檔案名稱,但看不到位址或存取碼。',
|
||||
title: '建立新權杖',
|
||||
nameLabel: '權杖名稱',
|
||||
namePlaceholder: '例如 Home Assistant',
|
||||
|
|
@ -6694,6 +6697,9 @@ export default {
|
|||
camWallUrlTitle: '此螢幕的攝影機牆網址',
|
||||
camWallUrlHint:
|
||||
'在螢幕上開啟此網址。任何能看到該網址的人都能觀看攝影機牆,請像對待鑰匙一樣對待它——撤銷權杖即可切斷該螢幕的存取。',
|
||||
overlayUrlTitle: '用於 OBS 的疊加層網址',
|
||||
overlayUrlHint:
|
||||
'在 OBS 中將其新增為「瀏覽器」來源(Browser Source)。將 /overlay/1 中的數字改為您印表機的編號(可在「印表機」頁面的網址中查看)。任何能看到該網址的人都能觀看直播,請像對待鑰匙一樣對待它——撤銷權杖即可切斷存取。',
|
||||
title: '權杖已建立 — 立即複製',
|
||||
warning:
|
||||
'這是此權杖唯一一次可見。關閉此對話框後您將無法再次查看。',
|
||||
|
|
|
|||
|
|
@ -101,6 +101,7 @@ function CreateTokenForm({ onCreated }: CreateTokenFormProps) {
|
|||
>
|
||||
<option value="camera_stream">{t('cameraTokens.scope.camera_stream', 'Camera stream')}</option>
|
||||
<option value="camwall">{t('cameraTokens.scope.camwall', 'Cam Wall')}</option>
|
||||
<option value="overlay">{t('cameraTokens.scope.overlay', 'Streaming Overlay')}</option>
|
||||
</select>
|
||||
<input
|
||||
type="number"
|
||||
|
|
@ -133,10 +134,15 @@ function CreateTokenForm({ onCreated }: CreateTokenFormProps) {
|
|||
'cameraTokens.create.hintCamWall',
|
||||
'A Cam Wall token opens /camwall on a screen with no login — it can see every printer\'s name and state, and their camera streams. It cannot see filenames, addresses or access codes.',
|
||||
)
|
||||
: t(
|
||||
'cameraTokens.create.hintCameraStream',
|
||||
'A camera-stream token can only fetch camera streams and snapshots. Use it for Home Assistant, Frigate, or anything embedding a single camera.',
|
||||
)}
|
||||
: scope === 'overlay'
|
||||
? t(
|
||||
'cameraTokens.create.hintOverlay',
|
||||
'A Streaming Overlay token opens /overlay/{printerId} on a screen with no login — for OBS or any live stream. It can see one printer\'s camera stream plus its live print status, including the filename shown on screen. It cannot see addresses or access codes.',
|
||||
)
|
||||
: t(
|
||||
'cameraTokens.create.hintCameraStream',
|
||||
'A camera-stream token can only fetch camera streams and snapshots. Use it for Home Assistant, Frigate, or anything embedding a single camera.',
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-bambu-gray mt-1">
|
||||
{t(
|
||||
|
|
@ -217,6 +223,14 @@ function JustCreatedModal({ token, onClose }: JustCreatedModalProps) {
|
|||
? `${window.location.origin}/camwall?token=${encodeURIComponent(plaintext)}`
|
||||
: null;
|
||||
|
||||
// For an overlay token, likewise the artefact is the URL. It targets one
|
||||
// printer, so we template printer 1 and tell the user to swap in the number
|
||||
// from the printer's URL on the main page (#2613).
|
||||
const overlayUrl =
|
||||
token.scope === 'overlay' && plaintext
|
||||
? `${window.location.origin}/overlay/1?token=${encodeURIComponent(plaintext)}`
|
||||
: null;
|
||||
|
||||
const copyText = async (value: string) => {
|
||||
if (!value) return;
|
||||
try {
|
||||
|
|
@ -300,6 +314,32 @@ function JustCreatedModal({ token, onClose }: JustCreatedModalProps) {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
{overlayUrl && (
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-medium text-white mb-1">
|
||||
{t('cameraTokens.created.overlayUrlTitle', 'Overlay URL for OBS')}
|
||||
</p>
|
||||
<p className="text-xs text-bambu-gray mb-2">
|
||||
{t(
|
||||
'cameraTokens.created.overlayUrlHint',
|
||||
'Add this as a Browser Source in OBS. Change the /overlay/1 number to your printer\'s number (from its URL on the Printers page). Anyone who can read the URL can watch the stream, so treat it like a key — revoke the token to cut it off.',
|
||||
)}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 px-3 py-2 bg-bambu-dark rounded-md text-bambu-green text-xs break-all font-mono select-all">
|
||||
{overlayUrl}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyText(overlayUrl)}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-bambu-green text-white rounded-md hover:bg-bambu-green/90"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
{t('cameraTokens.created.copy', 'Copy')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import { Layers, Clock, Timer, Printer } from 'lucide-react';
|
||||
import { api, ApiError, withStreamToken } from '../api/client';
|
||||
import type { PrinterStatus } from '../api/client';
|
||||
import { formatDuration, formatETA, type TimeFormat } from '../utils/date';
|
||||
|
||||
type TFunction = (key: string, options?: Record<string, unknown>) => string;
|
||||
|
|
@ -57,7 +56,9 @@ function parseConfig(params: URLSearchParams): OverlayConfig {
|
|||
};
|
||||
}
|
||||
|
||||
function getStatusText(status: PrinterStatus, t: TFunction): string {
|
||||
// Accepts the minimal shape shared by PrinterStatus (logged-in path) and the
|
||||
// token-authed OverlayStatus (kiosk path) — both carry state + stg_cur_name.
|
||||
function getStatusText(status: { state: string | null; stg_cur_name?: string | null }, t: TFunction): string {
|
||||
if (status.stg_cur_name) return status.stg_cur_name;
|
||||
|
||||
switch (status.state) {
|
||||
|
|
@ -117,32 +118,59 @@ export function StreamOverlayPage() {
|
|||
const config = useMemo(() => parseConfig(searchParams), [searchParams]);
|
||||
const sizes = getSizeClasses(config.size);
|
||||
|
||||
// Fetch printer info
|
||||
const { data: printer } = useQuery({
|
||||
queryKey: ['printer', id],
|
||||
queryFn: () => api.getPrinter(id),
|
||||
enabled: id > 0,
|
||||
});
|
||||
// Kiosk mode (#2613): OBS and other embeds have no login session, so they
|
||||
// pass an `overlay`-scoped token in the URL. When present, every data call
|
||||
// (status + camera stream) is authenticated by that token instead of a JWT.
|
||||
const token = searchParams.get('token');
|
||||
const kiosk = token != null && token !== '';
|
||||
|
||||
// Fetch printer status with polling
|
||||
const { data: status } = useQuery({
|
||||
queryKey: ['printerStatus', id],
|
||||
queryFn: () => api.getPrinterStatus(id),
|
||||
enabled: id > 0,
|
||||
// Kiosk path: one token-authenticated call for name + live status + the one
|
||||
// setting the overlay reads. No JWT, so this is the only feed available.
|
||||
const { data: overlay } = useQuery({
|
||||
queryKey: ['overlayStatus', id, token],
|
||||
queryFn: () => api.getOverlayStatus(id, token ?? undefined),
|
||||
enabled: id > 0 && kiosk,
|
||||
refetchInterval: 2000,
|
||||
});
|
||||
|
||||
// Logged-in path: the ordinary JWT-authenticated queries, unchanged. Disabled
|
||||
// in kiosk mode so an unauthenticated OBS browser never fires a doomed 401.
|
||||
const { data: printerData } = useQuery({
|
||||
queryKey: ['printer', id],
|
||||
queryFn: () => api.getPrinter(id),
|
||||
enabled: id > 0 && !kiosk,
|
||||
});
|
||||
|
||||
const { data: statusData } = useQuery({
|
||||
queryKey: ['printerStatus', id],
|
||||
queryFn: () => api.getPrinterStatus(id),
|
||||
enabled: id > 0 && !kiosk,
|
||||
refetchInterval: 2000,
|
||||
});
|
||||
|
||||
// Fetch settings info
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ['settings'],
|
||||
queryFn: api.getSettings,
|
||||
enabled: !kiosk,
|
||||
});
|
||||
|
||||
const timeFormat: TimeFormat = settings?.time_format || 'system';
|
||||
// Normalize the two sources into the shape the render below reads. Memoized
|
||||
// because the title effect depends on `printer` — a fresh object literal each
|
||||
// render would re-run it (and reset document.title) on every poll tick.
|
||||
const printer = useMemo(
|
||||
() =>
|
||||
kiosk
|
||||
? overlay && { name: overlay.name, camera_rotation: overlay.camera_rotation }
|
||||
: printerData,
|
||||
[kiosk, overlay, printerData],
|
||||
);
|
||||
const status = kiosk ? overlay : statusData;
|
||||
const timeFormat: TimeFormat = (kiosk ? overlay?.time_format : settings?.time_format) || 'system';
|
||||
|
||||
// WebSocket for real-time updates
|
||||
// WebSocket for real-time updates (JWT-authenticated; skipped in kiosk mode,
|
||||
// where the token can't mint a ws-token — the 2s poll above is the feed).
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
if (!id || kiosk) return;
|
||||
|
||||
let ws: WebSocket | null = null;
|
||||
let cancelled = false;
|
||||
|
|
@ -153,10 +181,10 @@ export function StreamOverlayPage() {
|
|||
// Bearer tokens, not cookies, for JWT auth). Auth-disabled deployments
|
||||
// succeed even without a token.
|
||||
(async () => {
|
||||
let token: string | undefined;
|
||||
let wsToken: string | undefined;
|
||||
try {
|
||||
const resp = await api.getWebSocketToken();
|
||||
token = resp.token;
|
||||
wsToken = resp.token;
|
||||
} catch (err) {
|
||||
// A 401 (JWT expired) / 403 (no WEBSOCKET_CONNECT permission) is an
|
||||
// auth decision — a tokenless socket would just be closed 4401, so
|
||||
|
|
@ -171,7 +199,7 @@ export function StreamOverlayPage() {
|
|||
if (cancelled) return;
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const tokenParam = token ? `?token=${encodeURIComponent(token)}` : '';
|
||||
const tokenParam = wsToken ? `?token=${encodeURIComponent(wsToken)}` : '';
|
||||
const wsUrl = `${protocol}//${window.location.host}/api/v1/ws${tokenParam}`;
|
||||
ws = new WebSocket(wsUrl);
|
||||
|
||||
|
|
@ -195,7 +223,7 @@ export function StreamOverlayPage() {
|
|||
cancelled = true;
|
||||
if (ws) ws.close();
|
||||
};
|
||||
}, [id, queryClient]);
|
||||
}, [id, kiosk, queryClient]);
|
||||
|
||||
// Update document title
|
||||
useEffect(() => {
|
||||
|
|
@ -230,7 +258,13 @@ export function StreamOverlayPage() {
|
|||
|
||||
const isPrinting = status.state === 'RUNNING' || status.state === 'PAUSE';
|
||||
const progress = status.progress || 0;
|
||||
const streamUrl = withStreamToken(`/api/v1/printers/${id}/camera/stream?fps=${config.fps}&t=${imageKey}`);
|
||||
// Append the kiosk token directly rather than leaning on withStreamToken's
|
||||
// module cache — the cache is populated by an effect and would miss the first
|
||||
// render (a 401 flash before the retry). The logged-in path keeps the cache.
|
||||
const camPath = `/api/v1/printers/${id}/camera/stream?fps=${config.fps}&t=${imageKey}`;
|
||||
const streamUrl = kiosk && token
|
||||
? `${camPath}&token=${encodeURIComponent(token)}`
|
||||
: withStreamToken(camPath);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-black relative overflow-hidden">
|
||||
|
|
|
|||
39
frontend/src/utils/interopDefault.ts
Normal file
39
frontend/src/utils/interopDefault.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
/**
|
||||
* Unwrap a default import that a bundler's CommonJS->ESM interop may have
|
||||
* wrapped in a module namespace object.
|
||||
*
|
||||
* Some CommonJS packages set `module.exports = { default: X, Named: X }`.
|
||||
* Depending on the bundler (and differing between the browser build, the test
|
||||
* runner, and Node's own ESM loader), `import X from 'pkg'` can hand you that
|
||||
* whole object instead of `X`. Rendering such an object as a React component
|
||||
* throws "Element type is invalid ... got: object" (React error #130) — see
|
||||
* #2616, where react-simple-keyboard's default import arrived as the namespace
|
||||
* object and crashed every SpoolBuddy screen on input focus.
|
||||
*
|
||||
* This returns the value unchanged when it is already a usable React element
|
||||
* type (a function/class component, a tag string, or an object carrying a React
|
||||
* `$$typeof` marker such as forwardRef/memo/lazy). Otherwise it tries `.default`
|
||||
* and then each of `fallbackKeys` in order, returning the first usable one, and
|
||||
* finally falls back to the original value.
|
||||
*/
|
||||
export function resolveInteropDefault<T = unknown>(value: unknown, fallbackKeys: string[] = []): T {
|
||||
if (isRenderableType(value)) return value as T;
|
||||
|
||||
if (value !== null && typeof value === 'object') {
|
||||
const obj = value as Record<string, unknown>;
|
||||
if (isRenderableType(obj.default)) return obj.default as T;
|
||||
for (const key of fallbackKeys) {
|
||||
if (isRenderableType(obj[key])) return obj[key] as T;
|
||||
}
|
||||
}
|
||||
|
||||
return value as T;
|
||||
}
|
||||
|
||||
/** True when `v` is something React can render as an element type. */
|
||||
function isRenderableType(v: unknown): boolean {
|
||||
if (typeof v === 'function' || typeof v === 'string') return true;
|
||||
// forwardRef / memo / lazy / context objects are valid element types and are
|
||||
// distinguished from a plain interop wrapper by their React `$$typeof` marker.
|
||||
return typeof v === 'object' && v !== null && '$$typeof' in v;
|
||||
}
|
||||
2
static/assets/index-CKAbipPc.css
Normal file
2
static/assets/index-CKAbipPc.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -26,8 +26,8 @@
|
|||
|
||||
<!-- Splash screens for iOS -->
|
||||
<link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
|
||||
<script type="module" crossorigin src="/assets/index-CREN25a-.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CZwzTgpo.css">
|
||||
<script type="module" crossorigin src="/assets/index-Cqi3-E-p.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CKAbipPc.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue