diff --git a/BACKERS.md b/BACKERS.md index 94b76cc7c..71a189da7 100644 --- a/BACKERS.md +++ b/BACKERS.md @@ -13,7 +13,7 @@ If you sponsor and your name isn't here within 48h, please write an email to mar ## Corporate Sponsors ($300/mo+) -*None yet — be the first. Your logo on the bambuddy.cool homepage and press.html, plus co-marketing.* +- [@northpole3dprinting](https://github.com/northpole3dprinting) ## Sustaining Sponsors ($150/mo+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 73f8b7e90..ddbc4b4a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to Bambuddy will be documented in this file. ### Security - **Vite 7 → 8 major bump** — Bambuddy's frontend now builds with Vite 8 (`^7.3.2` → `^8.0.16`) and the matching plugin-react release (`@vitejs/plugin-react` `^5.1.1` → `^5.2.0`). Headline architectural change: Vite 8 swaps Rollup for **Rolldown** as the default bundler — same plugin contract, Rust-backed core, slightly different chunk layout / output bytes (no functional regression). The bump also lifts the transitive `esbuild` floor to 0.28.1, which closes the last open advisory in the audit chain. **Bambuddy-side surface audited:** `vite.config.ts` uses only stable contracts that survived the v8 cut — `defineConfig`, the `Connect` type, the custom `serveGcodeViewer` `configureServer` middleware plugin (proxies `/gcode-viewer/*` to the repo's sibling `gcode_viewer/` directory in dev), the `server.proxy` with WebSocket upgrade for `/api/v1/ws`, `build.outDir`/`emptyOutDir`/`chunkSizeWarningLimit`, and `resolve.alias` for `@`. `base: '/'` regression guard from #1221 is unaffected. No SSR, no library mode, no CSS preprocessors, no exotic plugins. `vitest@4.1.8` already accepts vite 8 in its peer range (`^6 || ^7 || ^8`); no test-runner bump required. **Node:** vite 8 requires `^20.19.0 || >=22.12.0`; CI Node 20.x line satisfies this. **What this is NOT:** plugin-react v6 — that line requires `babel-plugin-react-compiler` + `@rolldown/plugin-babel` as peers and is a separate scope. `npm run build`, `npm run lint`, `npx vitest run` all clean; `npm audit` clean. - **Frontend dependency bumps** — Routine version updates across the runtime, build, and test dependency surface. **Runtime:** `dompurify` 3.4.0 → 3.4.10. `package.json` floor raised from `^3.4.0` to `^3.4.10` so fresh installs cannot land on the deprecated 3.4.4 release. Three call sites use string-output sanitisation (`frontend/src/pages/MakerworldPage.tsx`, `frontend/src/pages/ProjectDetailPage.tsx`, `frontend/src/components/ProjectPageModal.tsx`); release notes 3.4.1 → 3.4.10 reviewed for behavioural changes — 3.4.4 widened the default allow-list with `selectedcontent` + `command` + `commandfor` (all valid modern HTML, harmless for our two default-allow-list call sites), and `ProjectPageModal` is unaffected anyway because it sets an explicit `ALLOWED_TAGS` / `ALLOWED_ATTR` whitelist. **Build / lint / test tooling (transitive, dev-only):** `@babel/core` 7.29.0 → 7.29.7 (pulled by `@vitejs/plugin-react` and `eslint-plugin-react-hooks`), `vite` 7.3.2 → 7.3.5, `markdown-it` 14.1.1 → 14.2.0 (pulled by `@tiptap/extension-link` → `@tiptap/pm` → `prosemirror-markdown`; Bambuddy never calls `markdown-it.render` directly so the change is transparent), `js-yaml` 4.1.1 → 4.2.0 (pulled by `eslint`), `form-data` 4.0.5 → 4.0.6 + `ws` 8.20.1 → 8.21.0 (both pulled by `jsdom` in the test runtime). All bumps inside existing semver ranges except `dompurify`. No source changes required. +- **`dompurify` 3.4.10 → 3.4.11** — Follow-up patch closes a moderate-severity advisory affecting `setConfig()` callers: the previous hook clone-guard added in 3.4.7 could be bypassed via `setConfig()`, leaving a permanent `ALLOWED_ATTR` pollution that the next `sanitize()` call inherited. **Bambuddy's exposure is nil** — `git grep DOMPurify.setConfig` returns zero hits across the entire codebase; all three sanitisation sites (`frontend/src/pages/MakerworldPage.tsx`, `frontend/src/pages/ProjectDetailPage.tsx`, `frontend/src/components/ProjectPageModal.tsx`) call `DOMPurify.sanitize(html)` or `DOMPurify.sanitize(html, {ALLOWED_TAGS, ALLOWED_ATTR})` directly, never through `setConfig()`. The bump is taken as defence-in-depth to keep XSS-sensitive surface area current and to silence `npm audit` so future audit-fix runs don't auto-bundle unintended changes. **Mechanical lockfile bump only:** the existing `^3.4.10` range already permitted 3.4.11, so `package.json` is unchanged; `package-lock.json` updates the resolved URL + integrity hash for the one entry. Verification: `npm audit` reports 0 vulnerabilities, `MakerworldPage.test.tsx`'s 12 DOMPurify sanitisation cases pass, `npm run build` clean. ### Added - **Centralised sidebar layout + per-page hide toggles (#1673, contributed by @EdwardChamberlain)** — Sidebar item ordering and visibility move from inline `Layout.tsx` state to a dedicated module so the same persistence rules apply whether the user is reordering with drag-and-drop, toggling an item off, or accepting the admin-pushed default. New `frontend/src/utils/sidebarLayout.ts` owns the localStorage round-trip (`sidebarOrder` + `sidebarHiddenSystemItems` keys), the `SIDEBAR_LAYOUT_CHANGED_EVENT` cross-tab refresh broadcast, and the `isExternalSidebarItemId` helper that distinguishes the new `ext-*` external link prefix from built-in nav. **Hide / show toggle:** every built-in sidebar entry (Printers / Inventory / Archives / Queue / Projects / File Manager / Makerworld / Profiles / Maintenance / Statistics — Settings is intentionally non-hideable) now carries an eye icon in the Sidebar settings card; click it to drop that entry from the rendered sidebar. Hidden IDs persist per-user via localStorage so personal taste survives reloads without leaking to other users on a shared install. Re-show by clicking the eye again. The previous drag-to-reorder UX is retired in this PR — the hide list + admin default order cover the same "I never use the Stats page" / "give me Files first" needs without the affordance ambiguity of the rearrange handle. **Admin default order:** new `default_sidebar_order` setting (validated server-side at `backend/app/schemas/settings.py:533+`) holds a JSON object `{order: string[], hiddenSystemItemIds: string[]}` that admins set once from Settings → General → Sidebar (Set Default toggle). On first login per user, `Layout.tsx`'s `useEffect` reads the admin default, filters it against the current `defaultNavItems` + valid external IDs (so a deleted external link or a removed built-in doesn't strand in someone's stored order), applies it locally, and records a per-user `sidebarDefaultApplied_` localStorage flag so the default is one-shot — later user-driven changes aren't clobbered on every login. **Settings card:** `ExternalLinksSettings.tsx` is the single source of truth for the Sidebar card (`card-sidebar-links`) in Settings → General. The header now carries the **Set Default** toggle (visible only when the caller holds `settings:write`), a **Reset** button (clears both `sidebarOrder` + `sidebarHiddenSystemItems` to defaults), and the **Add Link** button (opens the external-link create modal). The body lists every sidebar item — built-in or external — with the eye toggle inline on each row. The header row uses `flex-wrap` on the outer container and the right-side control group so the Add Link button doesn't overflow the card's right edge when Column 3 sits at its narrow `lg:max-w-sm` (384px) width. **Settings → General reordering (post-merge polish):** the **Updates** card moved to the top of Column 3 (above the new Sidebar card); the **Data Management** card moved to the bottom of Column 2 (after Library Auto-Purge) so the General tab balances better with the new Sidebar card taking column 3's vertical real estate. Anchor IDs `card-updates`, `card-data`, `card-sidebar-links` are preserved so deep-links + the in-app `registerSettingsSearch` index still resolve. **Layout merge edge case:** the PR's refactor of `Layout.tsx::isHidden` accidentally dropped the dev-side notifications gate (`!authEnabled || !advancedAuthStatus?.advanced_auth_enabled || settings?.user_notifications_enabled === false`) and its `advancedAuthStatus` useQuery. The merged shape keeps three gates in priority order — `hiddenSystemItemIds.includes(id)` first (cheapest, explicit user intent), then the array-aware `navPermissions` check from #1755 (granular `*:read_own` / `*:read_all` tiers), then the notifications-specific gate — so a user without advanced auth doesn't suddenly see the Notifications entry. **Backend:** `default_sidebar_order` settings field accepts both shapes (plain array OR `{order, hiddenSystemItemIds}` object) for backward compat with installs that saved an array under an earlier draft of this work. Validator rejects any `hiddenSystemItemIds` that isn't a `list[str]` with 422. **Tests:** 17 new backend cases in `test_sidebar_settings.py` pinning the validator (empty / JSON-array / JSON-object / mixed-types / hostile shapes). Frontend: 5 new `Layout.test.tsx` cases pinning the hide-toggle behaviour (hidden ID drops the entry, hidden ID for Settings is ignored — `settings` is non-hideable, eye-click round-trips through localStorage, `SIDEBAR_LAYOUT_CHANGED_EVENT` triggers a re-read across tabs) and 255 added/changed lines in `SettingsPage.test.tsx` covering the admin-default toggle and the eye-icon visibility column. **i18n:** new keys in the `externalLinks.*` namespace (sidebarLayout / sidebarLayoutDescription / visibleInSidebar / hiddenFromSidebar / requiredInSidebar / setDefault / etc.), full translations in all 10 non-en locales (de / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW). Parity check 5168 leaves per locale. Vitest test timeout raised in `vitest.config.ts` to absorb the `userEvent.setup({delay: null})` cases in the heavier `SettingsPage` flows. Full vitest run green; ESLint clean; `npm run build` clean; ruff clean. @@ -15,6 +16,8 @@ All notable changes to Bambuddy will be documented in this file. - **Admin-configurable session lifetime (#1706, reported by @AD3DStuff)** — The 24-hour session cap that ships with Bambuddy was an intentional security hardening (audit finding M-2 reduced it from 7 days), but the "Remember Me" checkbox only controlled storage location (localStorage vs sessionStorage), not session duration. iPhone PWA users and homelab admins on trusted networks were getting kicked out every 24 hours with no way to extend it. **New setting:** `session_max_hours` under Settings → Users with three presets (24h / 7 days / 30 days) plus a custom field, hard-capped at 30 days (720h). Default remains 24h so existing deployments and the M-2 audit baseline are untouched until an admin opts in. The Settings card surfaces a yellow warning whenever the value exceeds 24h: "Longer sessions reduce automatic logout protection. Recommended only for trusted single-user deployments." **Backend wiring:** new `resolve_session_max_minutes(db)` helper in `backend/app/core/auth.py` reads the setting, clamps to [1h, 720h], and falls back to 24h on missing / blank / unparseable values. The helper is called at all four token-issuance sites — plain `/auth/login`, 2FA TOTP/email completion, 2FA backup-code completion, and OIDC callback — so a long-session policy works uniformly regardless of how the user authenticates. DB errors in the resolver are deliberately NOT caught: login is already inside a transaction and a broken DB must abort the login rather than silently extend or shrink the session lifetime. Defense-in-depth `SESSION_MAX_HOURS_HARD_CEILING = 720` clamps any tampered DB row above the Pydantic ceiling. Already-issued tokens keep their original expiry — the new setting only affects future logins, so an admin lowering the value can't retroactively revoke active sessions and an admin raising it can't retroactively extend them. **What this does NOT change:** the "Remember Me" checkbox still controls only storage location (cleared on browser close vs persisted across restarts). The relabel from misleading-UX-perspective is left for a separate follow-up — that's a UX choice independent of the session-policy mechanism. API tokens (`MAX_TOKEN_LIFETIME_DAYS`), camera stream tokens (60min), WebSocket tokens (60min), and slicer download tokens (5min) keep their own TTLs and are unaffected. **Tests:** 15 new cases in `backend/tests/integration/test_session_policy.py` split across three classes. `TestResolveSessionMaxMinutes` pins the clamping resolver — missing row, empty string, unparseable value, zero/negative, 1h minimum, 7-day passthrough, 30-day passthrough, above-ceiling clamp. `TestLoginRespectsSessionPolicy` decodes the JWT `exp` claim end-to-end and asserts the token returned by `/auth/login` honours the configured ceiling for the default-24h, configured-7d, and above-ceiling-clamp cases. `TestSettingsAPIExposesSessionMaxHours` round-trips the field through `/settings/` (default = 24, valid update persists as int's string form, zero rejected with 422, above-ceiling rejected with 422). Existing 202-case auth + MFA suite still green. **i18n:** 8 new keys in `settings.sessionPolicy.*` namespace; full translations in all 10 non-en locales (de / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW), no English fallback. Parity check 5149 leaves per locale. ESLint clean; `npm run build` clean; ruff clean. +- **Per-VP "G-code injection" toggle for Studio Send / FTP uploads (#1516, contributed by @phieb)** — Queue-mode Virtual Printers gain a per-VP opt-in toggle that applies the Settings → G-code Snippets per-model start/end snippets to every job that lands via the VP — Bambu Studio's "Send", OrcaSlicer's "Print Plate", the VP's own FTP upload path. Before this change the snippets were only applied to items queued through the PrintModal's "Inject auto-print G-code" checkbox; VP-incoming jobs silently bypassed injection regardless of how the snippets were configured. **Default off so upgraders don't silently start injecting**: existing `gcode_snippets` installs keep their previous behaviour until the per-VP toggle is explicitly enabled. When on, the scheduler still no-ops unless `gcode_snippets` are configured for the target printer model, so the effective semantics are "inject when enabled AND snippets exist." **DB column:** new `virtual_printers.gcode_injection BOOLEAN DEFAULT FALSE` with a branched `is_sqlite()` migration (SQLite `DEFAULT 0` / Postgres `DEFAULT FALSE`) matching the `queue_force_color_match` / `tailscale_disabled` precedent. **Multi-plate stamping:** the flag is set on every plate's `PrintQueueItem` inside the per-plate loop introduced by #1697 / #1188, so a multi-plate "Send all" upload now gets snippets injected on each plate consistently — the original PR only stamped the first plate; the merge resolution wove the flag into the loop. **Live-toggle correctness:** the `_sync_from_db_locked` change detector now compares `instance.gcode_injection != vp.gcode_injection`, so toggling the value in the UI triggers a VP restart instead of letting the in-memory instance keep the stale flag and silently propagate it onto every subsequent upload — same shape as the #1552 family. Backed by a dedicated `test_sync_from_db_restarts_on_gcode_injection_toggle`. **UI:** new toggle on `VirtualPrinterCard.tsx` (queue mode only — the toggle is hidden in archive/review/proxy modes since the feature is queue-specific), with the standard `updateMutation` save-on-click + toast on success, plus the `pendingAction='gcodeInjection'` opacity dim during the round-trip. **PrintModal hardening:** when "Inject auto-print G-code" is ticked on a reprint at quantity > 1, the modal now routes ALL copies through the queue (not just copies 2..N) so the scheduler injects every dispatch — see the separate reprint-quantity entry below for the full motivation. A new `useEffect` clears the stale `gcodeInjection` state if the user ticks the box at quantity 2, then drops back to quantity 1 — the checkbox hides at that point and the state must follow, otherwise the immediate-reprint path would silently bypass injection. **Diagnostics:** the resolved start/end snippets (with `{placeholder}` substitution already applied) are logged at DEBUG so any "snippet didn't run" report can be traced from a log bundle. **i18n:** new `virtualPrinter.gcodeInjection.title` + `description` keys translated in all 11 locales (de / en / es / fr / it / ja / ko / pt-BR / tr / zh-CN / zh-TW); parity check 5188 leaves per locale, no English fallback. **Tests:** 2 new unit cases in `test_virtual_printer.py` (queue items opt in / out based on the VP flag), 2 new integration cases in `test_virtual_printer_api.py` (create defaults to false, PUT round-trips the value), 1 new sync-restart case, plus updates to `_make_db_vp` so the change-detector test fixture carries an explicit `False` rather than relying on `MagicMock` truthiness. 2 new PrintModal vitest cases pin the reprint dispatch matrix (injection ON queues all copies and dispatches none immediately; injection OFF keeps the immediate first copy and queues the rest). Full backend pytest 6167/6167; full frontend vitest 2154/2154; ruff clean; `npm run build` clean. + ### Fixed - **Docker installer fails on the default `/opt/bambuddy` path with "Permission denied" (#1774, reported by @jmoore-skild)** — `install/docker-install.sh::create_install_dir` (line 252) ran `mkdir -p "$INSTALL_PATH"` without sudo while `DEFAULT_INSTALL_PATH="/opt/bambuddy"` (line 32) — root-owned on every Linux distro. `set -e` at line 20 then aborted the whole run before docker compose could ever pull the image. Anyone following the documented `curl … | bash` flow as a normal user hit this immediately. The native installer at `install/install.sh:361` already handles the same situation correctly with `sudo mkdir -p` + `sudo chown`; the Docker variant just never got the same treatment. **Why the fix isn't a default-path change:** the contributor's first instinct was to drop the default to `~/bambuddy` since the Docker installer only writes `docker-compose.yml` + `.env` on the host (real app data lives in named volumes), but `install/update.sh:4` and `install/update_macos.sh:4` both default `INSTALL_DIR` to `/opt/bambuddy`, and `install/README.md:274` documents `INSTALL_DIR=/opt/bambuddy sudo ./update.sh` for the update flow — changing the install default without coordinating the update path would silently break self-service updates for anyone following the docs verbatim. The actual gap is the missing privilege escalation in `create_install_dir`, not the default path. **Fix:** `create_install_dir` now tries `mkdir -p "$INSTALL_PATH" 2>/dev/null` first — the cheap no-sudo path covers `--path ~/bambuddy`, `--path /srv/bambuddy`, and any other writable target — and only falls back to `sudo mkdir -p "$INSTALL_PATH"` + `sudo chown -R "$USER:$USER" "$INSTALL_PATH"` when the unprivileged attempt fails. The chown is load-bearing: without it, the script would later try to write `docker-compose.yml` and `.env` into a root-owned dir as the unprivileged invoking user, kicking off a cascade of EACCES failures further down. Idempotent on re-run (the second `mkdir -p` succeeds against the now-owned dir, no second sudo prompt). `set -e` survives the redirected stderr because the `if !` construct is the documented escape from bash's exit-on-error semantics for an expected-failure check. **Smoke-tested all three branches:** writable target → no sudo prompt fires; idempotent re-run → no second sudo prompt; the failing-mkdir-then-fallback path → `set -e` survives intact. **What this does NOT change:** the default install path stays `/opt/bambuddy` for parity with `install.sh` / `update.sh` / the documented update flow; the Windows mirror at `install/docker-install.ps1` already uses `$env:USERPROFILE\bambuddy` (per-user convention on Windows) and is untouched. No docs change required — `install/README.md` and the wiki Docker page (`bambuddy-wiki/docs/getting-started/docker.md`) both still accurately describe the behaviour. - **MakerWorld import/resolve/status fail under API-key auth even when the owner has a Bambu Cloud login (#1777, reported by @Mx772)** — The reporter (working on a browser extension that drives Bambuddy via `X-API-Key`) noticed that `POST /api/v1/makerworld/import` and `POST /api/v1/makerworld/resolve` returned `{"detail":"Downloading files from MakerWorld requires a Bambu Cloud login"}` even when the key's owning user had a valid stored Bambu Cloud session, and the same imports succeeded from the web UI. Root cause is exactly the shape the reporter traced: `require_permission_if_auth_enabled` in `backend/app/core/auth.py:1414` deliberately returns `current_user=None` for API-keyed callers — the comment at line 1408 makes this explicit and points at `cloud.py` for the resolver. The MakerWorld routes never got that resolver wired in, so `_build_service(db, None)` → `get_stored_token(db, None)` → no token → the "requires Bambu Cloud login" branch fires regardless of what the owning account has set up. Same shape #1182 fixed for cloud slicer presets, and the canonical fix for non-`/cloud/*` routes is already in the codebase as `resolve_api_key_cloud_owner` (cloud.py:128-160) — used by `slicer_presets.py:491` and `library.py:3871`. The MakerWorld routes were missing the wire-up. **Fix:** Three routes get the extra `api_key_cloud_owner: User | None = Depends(resolve_api_key_cloud_owner)` parameter — `get_status`, `resolve_url`, `import_instance` — and each resolves `cloud_token_user = current_user or api_key_cloud_owner` before calling `get_stored_token` / `_build_service`. `import_instance` additionally uses `cloud_token_user.id` for the `owner_id` argument to `save_3mf_bytes_to_library` (which translates to `LibraryFile.created_by_id`), so library rows imported via API key are now attributed to the key's owner instead of staying NULL. `/recent-imports` is unchanged — it only uses `current_user` as a permission gate (`_ = current_user`) and never touches the cloud token. The fix preserves fail-closed semantics for keys *without* the `can_access_cloud` flag: `resolve_api_key_cloud_owner` already fences on `api_key.user_id is not None and api_key.can_access_cloud` (cloud.py:158), so a key with only the per-route scope (`can_read_status` / `can_manage_library`) still surfaces the "requires Bambu Cloud login" error path — no new auth gap. **Two scope fields the API key needs:** the per-route scope (`MAKERWORLD_VIEW` → `can_read_status`, `MAKERWORLD_IMPORT` → `can_manage_library` per `_APIKEY_SCOPE_BY_PERMISSION` in `core/auth.py`) AND the orthogonal `can_access_cloud` flag (separate column on the `api_keys` table). The fix doesn't change that surface — it just stops dropping valid `can_access_cloud=True` keys on the floor. **Tests:** 6 new cases in `backend/tests/integration/test_makerworld_apikey_auth.py` pinning the full surface — API key with `can_access_cloud=True` + owner-has-token → `/status` reports `has_cloud_token=True`, `/resolve` builds the service with the owner User (asserted on the `_build_service` mock's call args), `/import` succeeds end-to-end and the resulting `LibraryFile.created_by_id` matches the API-key owner; API key with `can_access_cloud=False` → status still reports `has_cloud_token=False` (no widening) and import-row's `created_by_id` stays NULL; JWT-authenticated parity check confirms the existing user-session flow is unchanged by the added `Depends`. 6/6 new tests green; full backend suite (6157 tests) still green; ruff clean. No frontend change, no DB migration, no new permission, no new dependency. The reporter's browser extension and any other API-keyed Home Assistant / automation integration unblocks immediately on next deploy. @@ -221,6 +224,8 @@ All notable changes to Bambuddy will be documented in this file. - **Label printing produced two identical PDFs per click (#1628)** — `LabelTemplatePickerModal.tsx::openBlobInNewTab` called `window.open(url, '_blank', 'noopener,noreferrer')` and treated a `null` return as "popup blocked → fall back to `` click." Per the WindowFeatures spec, `noopener` deliberately forces `window.open` to return `null` even on success, so the `if (!win)` fallback fired on EVERY click. Path 1 (window.open) opened the blob tab — on Linux Chromium without an inline PDF viewer the OS saved a random-named copy (the `zo70GhSL.pdf` / `f7w0OcDi.pdf` files in the reporter's screenshot). Path 2 (fallback) downloaded a second copy named `bambuddy-labels.pdf`. Two identical PDFs per click. Fix: drop `noopener,noreferrer`. The blob is same-origin (created via `URL.createObjectURL` from our own fetch response), the destination is a passive PDF preview tab with no script context to abuse `window.opener`, and `noreferrer` is a no-op for blob URLs. After removal, `window.open` returns a real window reference on success → `if (!win)` only fires on genuine popup-block, single PDF per click. Existing 17 vitest cases in `LabelTemplatePickerModal.test.tsx` still pass; the change is comment + one parameter. - **Scheduled local backup time is now interpreted as local time, not UTC (#1602 follow-up)** — Pre-fix: the time-of-day picker in Settings → Scheduled Local Backups stored the value as `HH:MM` and `_calculate_next_run` in `backend/app/services/local_backup.py` interpreted it as UTC (`datetime.now(timezone.utc).replace(hour=..., minute=...)`), so a UTC+3 user who wanted a 21:00 local backup had to enter 18:00. The UI hinted at this with a literal "UTC" label, but it was still surprising. Post-fix: the picker is interpreted in the container's local timezone, resolved from the `TZ` env var via `zoneinfo.ZoneInfo` (same source the Support page's `environment.timezone` already shows). UTC fallback when `TZ` is unset or unrecognised — preserves the legacy behaviour rather than crashing. The UI now shows the resolved zone name next to the time field (`Local time (Europe/Berlin)` / `Yerel saat (Europe/Istanbul)` / etc.) via a new i18n key `backup.localTimeHint` with real translations across all 10 non-English locales, replacing the old `backup.utc` literal. New `timezone` field on `/api/local-backup/status` exposes the resolved zone to the UI. **One-time behaviour change for existing users**: anyone who entered a UTC time as a workaround (per #1602's UTC+3 reporter — "I have to write 18:00 to get 21:00 local") will see the first scheduled cycle after upgrade run at their local TZ offset earlier than expected. Re-enter the time as local once and it's correct from then on. No migration is shipped; the population is small and migrating around a DST boundary would be ambiguous. **Tests** (`backend/tests/unit/test_local_backup.py`): existing 5 cases pinned with `monkeypatch.setenv("TZ", "UTC")` so they don't depend on the test runner's TZ; 5 new — Europe/Berlin local→UTC, Europe/Istanbul (the #1602 reporter's zone) local→UTC, no-TZ-env UTC fallback, unrecognised-TZ UTC fallback, DST spring-forward gap (Europe/Berlin 2026-03-29 02:30 wall-clock doesn't exist) asserting no crash. All 30 tests pass. Frontend i18n parity green at 5007 keys across 10 non-English locales. +- **Auto-print end snippets silently dropped on P1S, and modified 3MFs rejected with HMS 0500-4003 (#1516, contributed by @phieb)** — Two compounding firmware quirks made the original #422 G-code injection unusable on the P1S — the most common reporter platform for auto-eject / plate-clear automation. **(1) End snippet dropped after `; EXECUTABLE_BLOCK_END`**: the snippet was appended to the end of `Metadata/plate_N.gcode`, but Bambu firmware (verified on a P1S) does not execute G-code that sits **after** the `; EXECUTABLE_BLOCK_END` marker. An auto-eject sweep injected to clear the plate ran on every other Bambu model but silently no-op'd on P1S — print finished, plate stayed loaded, the next queued copy stalled behind it. The injection now anchors the end snippet **before** `; EXECUTABLE_BLOCK_END` so it sits inside the executed block, after the printer's own machine-end sequence (cooldown / M104 S0 / etc) but before the firmware stops parsing. Files without the marker — older slicer versions, non-Bambu sources — keep the existing append-to-EOF behaviour with a warning log; the test suite pins both paths. **(2) Stale `.gcode.md5` sidecar rejected by P1S firmware**: every plate carries a `Metadata/plate_N.gcode.md5` sidecar that the P1S validates against the gcode body on load. Rewriting the gcode without refreshing the hash made the P1S reject the file with `HMS 0500-4003 "unable to parse"` and abort the print — exactly when the injection had succeeded. `inject_gcode_into_3mf` now recomputes the sidecar from the exact bytes about to be written and re-packs it into the 3MF in the same pass, matching Bambu's on-disk format exactly (uppercase hex, 32 chars, no trailing newline). The MD5 is a firmware integrity check, not a security primitive, so the call is flagged `hashlib.md5(..., usedforsecurity=False)` to keep ruff S324 / Bandit B324 clean. 3MFs **without** an `.md5` sidecar member (older files, manual hand-builds) do not gain one — inventing a member could surprise older firmware that doesn't expect it; if the source had no sidecar the firmware wasn't validating it anyway. Non-target zip members keep their original compression intact (the P1S preview parser chokes on re-DEFLATEd PNGs that the source had stored uncompressed). **Live-tested on a P1S** with `{max_layer_z}` placeholder substitution: injection happens, the file loads cleanly, the end snippet executes, the recomputed hash validates against the modified body. **Tests** (`test_gcode_injection.py`): 4 new cases in `TestMd5SidecarRecompute` (sidecar matches the exact gcode bytes after injection, uppercase-hex-no-newline format parity with Bambu's on-disk shape, no `.md5` member is invented when source lacks one, non-target zip member compression is preserved) + 1 new case `test_end_lands_before_executable_block_end` pinning the in-block placement when the marker is present + 1 case renamed from `test_end_still_appended_at_eof` to `test_end_falls_back_to_eof_without_block_marker` to reflect that EOF append is now the **fallback** path, not the primary one. 198 VP + gcode_injection tests in the slice green; full backend 6167/6167; ruff clean. +- **Reprint with quantity > 1 + auto-print G-code injection now injects *every* copy, including the first (#1516, contributed by @phieb)** — since auto-print injection landed (#422), ticking **Inject auto-print G-code** in the Reprint dialog only applied to copies 2…N: the first copy was dispatched immediately via the direct reprint path, which bypasses the scheduler that performs injection, so it printed *without* the start/end snippets. For auto-eject / plate-clear setups (Farmloop, SwapMod, AutoClear, Printflow 3D) this left the first copy stuck on the plate, blocking the injected copies queued behind it. When injection is enabled and quantity > 1, the Reprint flow now queues *all* copies so each one is dispatched — and injected — by the scheduler. Behaviour with injection off is unchanged (first copy still prints immediately, the rest queue), as are the single-copy reprint, Add-to-Queue, Edit-queue-item, and stagger paths. ## [0.2.4.5] - 2026-06-03 diff --git a/README.md b/README.md index 8eca0f32f..be50bfc85 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,11 @@ Ko-fi

+

+ Backed by
+ North Pole 3D Printing +

+

🎮 Try the Live Demo • Features • diff --git a/backend/app/api/routes/virtual_printers.py b/backend/app/api/routes/virtual_printers.py index 466323a6a..a8f11d141 100644 --- a/backend/app/api/routes/virtual_printers.py +++ b/backend/app/api/routes/virtual_printers.py @@ -39,6 +39,7 @@ class VirtualPrinterCreate(BaseModel): target_printer_id: int | None = None auto_dispatch: bool = True queue_force_color_match: bool = False + gcode_injection: bool = False bind_ip: str | None = None remote_interface_ip: str | None = None @@ -52,6 +53,7 @@ class VirtualPrinterUpdate(BaseModel): target_printer_id: int | None = None auto_dispatch: bool | None = None queue_force_color_match: bool | None = None + gcode_injection: bool | None = None bind_ip: str | None = None remote_interface_ip: str | None = None tailscale_disabled: bool | None = None @@ -107,6 +109,7 @@ async def _vp_to_dict(vp, db: AsyncSession, status: dict | None = None) -> dict: "target_printer_id": vp.target_printer_id, "auto_dispatch": vp.auto_dispatch, "queue_force_color_match": vp.queue_force_color_match, + "gcode_injection": vp.gcode_injection, "bind_ip": vp.bind_ip, "remote_interface_ip": vp.remote_interface_ip, "tailscale_disabled": vp.tailscale_disabled, @@ -242,6 +245,7 @@ async def create_virtual_printer( target_printer_id=body.target_printer_id, auto_dispatch=body.auto_dispatch, queue_force_color_match=body.queue_force_color_match, + gcode_injection=body.gcode_injection, bind_ip=body.bind_ip, remote_interface_ip=body.remote_interface_ip, serial_suffix=new_suffix, @@ -419,6 +423,8 @@ async def update_virtual_printer( vp.auto_dispatch = body.auto_dispatch if body.queue_force_color_match is not None: vp.queue_force_color_match = body.queue_force_color_match + if body.gcode_injection is not None: + vp.gcode_injection = body.gcode_injection if body.bind_ip is not None: vp.bind_ip = body.bind_ip if body.remote_interface_ip is not None: diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 098890c3b..cd3849ca5 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -959,6 +959,14 @@ async def run_migrations(conn): conn, "ALTER TABLE virtual_printers ADD COLUMN queue_force_color_match BOOLEAN DEFAULT FALSE" ) + # Per-VP opt-in for auto-print G-code injection (#1516). Default false so + # existing gcode_snippets users don't silently start injecting on VP/Studio + # Send jobs after upgrading. + if is_sqlite(): + await _safe_execute(conn, "ALTER TABLE virtual_printers ADD COLUMN gcode_injection BOOLEAN DEFAULT 0") + else: + await _safe_execute(conn, "ALTER TABLE virtual_printers ADD COLUMN gcode_injection BOOLEAN DEFAULT FALSE") + # Migration: Add target_parts_count column to projects for tracking total parts needed await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN target_parts_count INTEGER") diff --git a/backend/app/models/virtual_printer.py b/backend/app/models/virtual_printer.py index 4d7b1bc4b..fb598c620 100644 --- a/backend/app/models/virtual_printer.py +++ b/backend/app/models/virtual_printer.py @@ -49,6 +49,11 @@ class VirtualPrinter(Base): ) # queue mode: pin per-slot type+color from the 3MF onto the queue # item so the scheduler refuses to dispatch onto a printer with the wrong # filament loaded (#1188). + gcode_injection: Mapped[bool] = mapped_column( + Boolean, server_default="false" + ) # queue mode: opt this VP's Send/Print jobs into per-model G-code snippet + # injection (#1516). Default off so existing gcode_snippets users don't + # silently start injecting; no-op when no snippets exist for the model. model: Mapped[str | None] = mapped_column(String(50), nullable=True) # SSDP model code (server mode) access_code: Mapped[str | None] = mapped_column(String(8), nullable=True) # 8 chars (server mode) target_printer_id: Mapped[int | None] = mapped_column( diff --git a/backend/app/services/virtual_printer/manager.py b/backend/app/services/virtual_printer/manager.py index f3bca91f3..d93f4623e 100644 --- a/backend/app/services/virtual_printer/manager.py +++ b/backend/app/services/virtual_printer/manager.py @@ -138,6 +138,7 @@ class VirtualPrinterInstance: target_printer_id: int | None = None, auto_dispatch: bool = True, queue_force_color_match: bool = False, + gcode_injection: bool = False, bind_ip: str = "", remote_interface_ip: str = "", tailscale_disabled: bool = True, @@ -160,6 +161,7 @@ class VirtualPrinterInstance: self.target_printer_id = target_printer_id self.auto_dispatch = auto_dispatch self.queue_force_color_match = queue_force_color_match + self.gcode_injection = gcode_injection self.bind_ip = bind_ip self.remote_interface_ip = remote_interface_ip self.tailscale_disabled = tailscale_disabled @@ -668,6 +670,11 @@ class VirtualPrinterInstance: vibration_cali=vibration_cali, layer_inspect=layer_inspect, timelapse=timelapse, + # Per-VP opt-in for auto-print G-code injection (#1516). + # Default off; when on, the scheduler still no-ops unless + # gcode_snippets are configured for the target model, so it's + # effectively "inject when enabled AND snippets exist". + gcode_injection=self.gcode_injection, ) db.add(queue_item) await db.flush() # populate queue_item.id before logging @@ -1226,6 +1233,7 @@ class VirtualPrinterManager: # instance silently keeps the old value until process # restart (#1552 follow-up family). or instance.queue_force_color_match != vp.queue_force_color_match + or instance.gcode_injection != vp.gcode_injection or proxy_target_changed ) @@ -1279,6 +1287,7 @@ class VirtualPrinterManager: target_printer_id=vp.target_printer_id, auto_dispatch=vp.auto_dispatch, queue_force_color_match=vp.queue_force_color_match, + gcode_injection=vp.gcode_injection, bind_ip=vp.bind_ip or "", remote_interface_ip=vp.remote_interface_ip or "", tailscale_disabled=vp.tailscale_disabled, diff --git a/backend/app/utils/threemf_tools.py b/backend/app/utils/threemf_tools.py index e0a092a57..dcf57d141 100644 --- a/backend/app/utils/threemf_tools.py +++ b/backend/app/utils/threemf_tools.py @@ -5,6 +5,7 @@ per-layer filament usage data from the embedded G-code. This enables accurate partial usage reporting for multi-material prints. """ +import hashlib import json import logging import math @@ -546,6 +547,7 @@ _HEADER_PLACEHOLDER_ALIASES = { _HEADER_KEY_RE = re.compile(r"^;\s*([^:]+?)\s*:\s*(.+?)\s*$") _PLACEHOLDER_RE = re.compile(r"\{([a-zA-Z_][a-zA-Z0-9_]*)\}") _START_GCODE_END_MARKER = "; MACHINE_START_GCODE_END" +_EXECUTABLE_BLOCK_END_MARKER = "; EXECUTABLE_BLOCK_END" def _parse_3mf_gcode_header(content: str) -> dict[str, str]: @@ -618,6 +620,29 @@ def _inject_start_at_marker(content: str, snippet: str) -> str: return content[:line_start] + snippet.rstrip("\n") + "\n" + content[line_start:] +def _inject_end_before_marker(content: str, snippet: str) -> str: + """Insert snippet immediately before `; EXECUTABLE_BLOCK_END`. + + The end snippet must run *inside* the executable block. Bambu firmware + (verified on a P1S) does not execute G-code that sits after + `; EXECUTABLE_BLOCK_END`, so appending to the file end silently drops the + snippet — auto-eject / plate-clear moves never fire. Inserting before the + marker places the snippet after the printer's own machine-end sequence but + still within the executed block. Falls back to appending at the file end if + the marker isn't present. + """ + marker_idx = content.find(_EXECUTABLE_BLOCK_END_MARKER) + if marker_idx == -1: + logger.warning( + "G-code injection: '%s' not found, appending end snippet to file end", + _EXECUTABLE_BLOCK_END_MARKER, + ) + return content.rstrip("\n") + "\n" + snippet.rstrip("\n") + "\n" + line_start = content.rfind("\n", 0, marker_idx) + line_start = 0 if line_start == -1 else line_start + 1 + return content[:line_start] + snippet.rstrip("\n") + "\n" + content[line_start:] + + def inject_gcode_into_3mf( source_path: Path, plate_id: int, @@ -629,8 +654,12 @@ def inject_gcode_into_3mf( Snippets support `{placeholder}` substitution against values parsed from the 3MF G-code header block (e.g. `{max_layer_z}` → `16.00`). Start snippets are anchored to the `; MACHINE_START_GCODE_END` marker so they - run after the printer's own startup (#422). End snippets are appended - after the last line of the print. + run after the printer's own startup (#422). End snippets are inserted just + before `; EXECUTABLE_BLOCK_END` so they run inside the executable block — + Bambu firmware (P1S) ignores g-code placed after that marker. + + The plate's `.gcode.md5` sidecar is recomputed so firmware that validates + it against the gcode (e.g. P1S) still accepts the modified file. Args: source_path: Path to the original 3MF file. @@ -672,10 +701,25 @@ def inject_gcode_into_3mf( if start_gcode: resolved = _substitute_placeholders(start_gcode, header) + # Log the post-substitution snippet so the actually-injected G-code + # (placeholders like {max_layer_z} already resolved) is visible at DEBUG. + logger.debug("G-code injection [%s]: resolved START snippet:\n%s", target_gcode, resolved) gcode_content = _inject_start_at_marker(gcode_content, resolved) if end_gcode: resolved = _substitute_placeholders(end_gcode, header) - gcode_content = gcode_content.rstrip("\n") + "\n" + resolved + "\n" + logger.debug("G-code injection [%s]: resolved END snippet:\n%s", target_gcode, resolved) + gcode_content = _inject_end_before_marker(gcode_content, resolved) + + # The printer validates the plate gcode against an embedded + # `.gcode.md5` sidecar (uppercase hex, no trailing newline). + # Rewriting the gcode without refreshing this hash makes firmware + # reject the file at load (P1S: HMS 0500-4003 "unable to parse"), + # so recompute it from the exact bytes we're about to write. + gcode_bytes = gcode_content.encode("utf-8") + md5_name = target_gcode + ".md5" + # Not a security hash — this reproduces Bambu's `.gcode.md5` sidecar + # format, so flag it as non-security for the linters (ruff S324 / bandit B324). + md5_value = hashlib.md5(gcode_bytes, usedforsecurity=False).hexdigest().upper().encode("ascii") # Write modified 3MF to temp file with tempfile.NamedTemporaryFile(delete=False, suffix=".3mf") as tmp: @@ -685,7 +729,9 @@ def inject_gcode_into_3mf( for item in zf.namelist(): info = zf.getinfo(item) if item == target_gcode: - zf_write.writestr(info, gcode_content.encode("utf-8")) + zf_write.writestr(info, gcode_bytes) + elif item == md5_name: + zf_write.writestr(info, md5_value) else: zf_write.writestr(info, zf.read(item)) diff --git a/backend/tests/integration/test_virtual_printer_api.py b/backend/tests/integration/test_virtual_printer_api.py index e33e2287e..8896bc0c4 100644 --- a/backend/tests/integration/test_virtual_printer_api.py +++ b/backend/tests/integration/test_virtual_printer_api.py @@ -353,6 +353,53 @@ class TestVirtualPrinterAutoDispatchAPI: assert get_resp.json()["auto_dispatch"] is False +class TestVirtualPrinterGcodeInjectionAPI: + """Integration tests for gcode_injection (#1516) on /api/v1/virtual-printers endpoints.""" + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_create_virtual_printer_gcode_injection_default_off(self, async_client: AsyncClient): + """Verify creating a VP without gcode_injection defaults to false (opt-in).""" + response = await async_client.post( + "/api/v1/virtual-printers", + json={ + "name": "TestDefaultInjection", + "mode": "queue", + "access_code": "12345678", + }, + ) + + assert response.status_code == 200 + assert response.json()["gcode_injection"] is False + + @pytest.mark.asyncio + @pytest.mark.integration + async def test_update_virtual_printer_gcode_injection(self, async_client: AsyncClient): + """Verify gcode_injection can be toggled via PUT and persists.""" + create_resp = await async_client.post( + "/api/v1/virtual-printers", + json={ + "name": "TestToggleInjection", + "mode": "queue", + "access_code": "12345678", + }, + ) + assert create_resp.status_code == 200 + vp_id = create_resp.json()["id"] + assert create_resp.json()["gcode_injection"] is False + + update_resp = await async_client.put( + f"/api/v1/virtual-printers/{vp_id}", + json={"gcode_injection": True}, + ) + assert update_resp.status_code == 200 + assert update_resp.json()["gcode_injection"] is True + + get_resp = await async_client.get(f"/api/v1/virtual-printers/{vp_id}") + assert get_resp.status_code == 200 + assert get_resp.json()["gcode_injection"] is True + + class TestVirtualPrinterTailscaleToggleAPI: """The Tailscale toggle is informational — toggling either way always succeeds. diff --git a/backend/tests/unit/services/test_virtual_printer.py b/backend/tests/unit/services/test_virtual_printer.py index 94c51a501..f8da7e894 100644 --- a/backend/tests/unit/services/test_virtual_printer.py +++ b/backend/tests/unit/services/test_virtual_printer.py @@ -573,6 +573,109 @@ class TestVirtualPrinterInstance: queue_item = added_items[0] assert queue_item.manual_start is True + @pytest.mark.asyncio + async def test_add_to_print_queue_gcode_injection_on(self, tmp_path): + """#1516: queue items opt into injection when the VP has gcode_injection=True.""" + from backend.app.services.virtual_printer.manager import VirtualPrinterInstance + + mock_db = AsyncMock() + added_items = [] + mock_db.add = MagicMock(side_effect=added_items.append) + mock_db.commit = AsyncMock() + + mock_session_factory = MagicMock() + mock_session_ctx = AsyncMock() + mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db) + mock_session_ctx.__aexit__ = AsyncMock(return_value=False) + mock_session_factory.return_value = mock_session_ctx + + inst = VirtualPrinterInstance( + vp_id=13, + name="InjectOn", + mode="queue", + model="C11", + access_code="12345678", + serial_suffix="391800013", + gcode_injection=True, + base_dir=tmp_path, + session_factory=mock_session_factory, + ) + + file_path = tmp_path / "test.3mf" + file_path.write_bytes(b"fake3mf") + + mock_archive = MagicMock() + mock_archive.id = 1 + mock_archive.print_name = "test" + + with ( + patch( + "backend.app.api.routes.settings.get_setting", + new_callable=AsyncMock, + return_value=None, + ), + patch( + "backend.app.services.archive.ArchiveService.archive_print", + new_callable=AsyncMock, + return_value=mock_archive, + ), + ): + await inst._add_to_print_queue(file_path, "192.168.1.100") + + assert len(added_items) == 1 + assert added_items[0].gcode_injection is True + + @pytest.mark.asyncio + async def test_add_to_print_queue_gcode_injection_off_by_default(self, tmp_path): + """#1516: queue items do NOT inject when the VP leaves gcode_injection at its default.""" + from backend.app.services.virtual_printer.manager import VirtualPrinterInstance + + mock_db = AsyncMock() + added_items = [] + mock_db.add = MagicMock(side_effect=added_items.append) + mock_db.commit = AsyncMock() + + mock_session_factory = MagicMock() + mock_session_ctx = AsyncMock() + mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db) + mock_session_ctx.__aexit__ = AsyncMock(return_value=False) + mock_session_factory.return_value = mock_session_ctx + + inst = VirtualPrinterInstance( + vp_id=14, + name="InjectOff", + mode="queue", + model="C11", + access_code="12345678", + serial_suffix="391800014", + base_dir=tmp_path, + session_factory=mock_session_factory, + ) + + file_path = tmp_path / "test.3mf" + file_path.write_bytes(b"fake3mf") + + mock_archive = MagicMock() + mock_archive.id = 1 + mock_archive.print_name = "test" + + with ( + patch( + "backend.app.api.routes.settings.get_setting", + new_callable=AsyncMock, + return_value=None, + ), + patch( + "backend.app.services.archive.ArchiveService.archive_print", + new_callable=AsyncMock, + return_value=mock_archive, + ), + ): + await inst._add_to_print_queue(file_path, "192.168.1.100") + + assert len(added_items) == 1 + assert added_items[0].gcode_injection is False + @pytest.mark.asyncio async def test_add_to_print_queue_uses_workflow_defaults_from_settings(self, tmp_path): """#1235: VP queue-mode constructed PrintQueueItem without specifying @@ -1650,6 +1753,7 @@ class TestVirtualPrinterManager: "auto_dispatch": True, "tailscale_disabled": True, # Opt-in default (#1070 UX fix) "queue_force_color_match": False, # default — must be explicit so MagicMock truthiness doesn't trip the change detector + "gcode_injection": False, # same reason as above "position": 0, } defaults.update(overrides) @@ -1850,6 +1954,43 @@ class TestVirtualPrinterManager: mock_remove.assert_not_called() + @pytest.mark.asyncio + async def test_sync_from_db_restarts_on_gcode_injection_toggle(self, manager, tmp_path): + """Toggling gcode_injection in the DB must restart the running instance. + + Without this, the in-memory ``self.gcode_injection`` keeps its old value + and ``_add_to_print_queue`` stamps the stale flag on every new queue + item — so disabling injection in the UI silently has no effect until + the process restarts. + """ + from backend.app.services.virtual_printer.manager import VirtualPrinterInstance + + inst = VirtualPrinterInstance( + vp_id=1, + name="TestVP", + mode="archive", + model="C11", + access_code="12345678", + serial_suffix="391800001", + gcode_injection=True, + base_dir=tmp_path, + ) + inst.stop_server = AsyncMock() + manager._instances[1] = inst + + db_vp = self._make_db_vp(gcode_injection=False) + self._setup_sync_mocks(manager, [db_vp], tmp_path) + + with patch.object(manager, "remove_instance", new_callable=AsyncMock) as mock_remove: + with patch("backend.app.services.virtual_printer.manager.VirtualPrinterInstance") as MockInst: + mock_new = MagicMock() + mock_new.start_server = AsyncMock() + MockInst.return_value = mock_new + + await manager.sync_from_db() + + mock_remove.assert_called_once_with(1) + class TestFTPSession: """Tests for FTP session handling.""" diff --git a/backend/tests/unit/test_gcode_injection.py b/backend/tests/unit/test_gcode_injection.py index ad298ee94..5b0fddf11 100644 --- a/backend/tests/unit/test_gcode_injection.py +++ b/backend/tests/unit/test_gcode_injection.py @@ -1,5 +1,6 @@ """Unit tests for G-code injection into 3MF files (#422).""" +import hashlib import tempfile import zipfile from pathlib import Path @@ -234,6 +235,92 @@ M104 S0 """ +class TestMd5SidecarRecompute: + """The plate `.gcode.md5` sidecar must match the injected gcode (P1S rejects + a stale hash with HMS 0500-4003).""" + + def _make_3mf_with_md5(self, gcode: str, plate_id: int = 1) -> Path: + """A 3MF that carries a (deliberately wrong) md5 sidecar, like a real + sliced .gcode.3mf does.""" + tmp_path = _make_temp_path() + with zipfile.ZipFile(tmp_path, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr(f"Metadata/plate_{plate_id}.gcode", gcode) + zf.writestr(f"Metadata/plate_{plate_id}.gcode.md5", "STALEHASHVALUE") + zf.writestr("Metadata/slice_info.config", "") + return tmp_path + + def test_md5_recomputed_to_match_injected_gcode(self): + source = self._make_3mf_with_md5("G28\nM400\n") + result = None + try: + result = inject_gcode_into_3mf(source, 1, None, "M104 S0") + assert result is not None + with zipfile.ZipFile(result, "r") as zf: + gcode = zf.read("Metadata/plate_1.gcode") + sidecar = zf.read("Metadata/plate_1.gcode.md5") + expected = hashlib.md5(gcode, usedforsecurity=False).hexdigest().upper().encode("ascii") + assert sidecar == expected + assert sidecar != b"STALEHASHVALUE" + finally: + source.unlink(missing_ok=True) + if result: + result.unlink(missing_ok=True) + + def test_sidecar_is_uppercase_hex_no_newline(self): + """Match Bambu's on-disk format exactly: uppercase, no trailing newline.""" + source = self._make_3mf_with_md5("G28\n") + result = None + try: + result = inject_gcode_into_3mf(source, 1, "; START", None) + assert result is not None + with zipfile.ZipFile(result, "r") as zf: + sidecar = zf.read("Metadata/plate_1.gcode.md5") + assert sidecar == sidecar.upper() + assert not sidecar.endswith(b"\n") + assert len(sidecar) == 32 + finally: + source.unlink(missing_ok=True) + if result: + result.unlink(missing_ok=True) + + def test_no_md5_member_is_not_created(self): + """A 3MF without an md5 sidecar shouldn't gain one (firmware isn't + validating it, and inventing a member could surprise older files).""" + source = _make_test_3mf("G28\n") # no .md5 member + result = None + try: + result = inject_gcode_into_3mf(source, 1, "; START", None) + assert result is not None + with zipfile.ZipFile(result, "r") as zf: + names = zf.namelist() + assert "Metadata/plate_1.gcode.md5" not in names + finally: + source.unlink(missing_ok=True) + if result: + result.unlink(missing_ok=True) + + def test_other_member_compression_preserved(self): + """Non-target members keep their original compression (P1S preview + parser chokes on re-DEFLATEd STORE'd PNGs).""" + tmp_path = _make_temp_path() + with zipfile.ZipFile(tmp_path, "w") as zf: + zf.writestr(zipfile.ZipInfo("Metadata/plate_1.gcode"), "G28\n") + # A STORE'd member (compress_type=0), like an embedded preview PNG. + stored = zipfile.ZipInfo("Metadata/plate_1.png") + stored.compress_type = zipfile.ZIP_STORED + zf.writestr(stored, b"\x89PNG\r\n\x1a\n" + b"\x00" * 64) + result = None + try: + result = inject_gcode_into_3mf(tmp_path, 1, None, "; END") + assert result is not None + with zipfile.ZipFile(result, "r") as zf: + assert zf.getinfo("Metadata/plate_1.png").compress_type == zipfile.ZIP_STORED + finally: + tmp_path.unlink(missing_ok=True) + if result: + result.unlink(missing_ok=True) + + class TestStartAnchoredInjection: """Tests for #422 follow-up: start g-code injected at MACHINE_START_GCODE_END.""" @@ -281,9 +368,10 @@ class TestStartAnchoredInjection: if result: result.unlink(missing_ok=True) - def test_end_still_appended_at_eof(self): - """End g-code keeps the existing append-to-EOF behaviour even with marker present.""" - source = _make_test_3mf(_BAMBU_GCODE_TEMPLATE) + def test_end_falls_back_to_eof_without_block_marker(self): + """Files without ; EXECUTABLE_BLOCK_END (older / non-Bambu slicers) keep the + append-to-EOF fallback for end snippets.""" + source = _make_test_3mf(_BAMBU_GCODE_TEMPLATE) # template has no EXECUTABLE_BLOCK_END try: result = inject_gcode_into_3mf(source, 1, None, "; SWAPMOD-END") assert result is not None @@ -292,8 +380,39 @@ class TestStartAnchoredInjection: gcode = zf.read("Metadata/plate_1.gcode").decode("utf-8") assert gcode.endswith("; SWAPMOD-END\n") - # Marker anchor is irrelevant for end snippets. - assert gcode.index("; SWAPMOD-END") > gcode.index("; MACHINE_START_GCODE_END") + finally: + source.unlink(missing_ok=True) + if result: + result.unlink(missing_ok=True) + + def test_end_lands_before_executable_block_end(self): + """With ; EXECUTABLE_BLOCK_END present, the end snippet sits INSIDE the + executable block (just before the marker). Bambu firmware (P1S) does not + run g-code placed after that marker, so appending to EOF would silently + drop auto-eject / plate-clear moves.""" + gcode_src = ( + "; HEADER_BLOCK_START\n; max_z_height: 16.00\n; HEADER_BLOCK_END\n" + "; MACHINE_START_GCODE_END\n" + "G1 X10 Y10 Z0.2\n" + "M104 S0 ; printer machine-end\n" + "; EXECUTABLE_BLOCK_END\n" + ) + source = _make_test_3mf(gcode_src) + try: + result = inject_gcode_into_3mf(source, 1, None, "; EJECT-SWEEP") + assert result is not None + + with zipfile.ZipFile(result, "r") as zf: + gcode = zf.read("Metadata/plate_1.gcode").decode("utf-8") + + snippet_idx = gcode.index("; EJECT-SWEEP") + marker_idx = gcode.index("; EXECUTABLE_BLOCK_END") + # Snippet is inside the block, before the end marker. + assert snippet_idx < marker_idx + # The printer's own machine-end still precedes our snippet. + assert gcode.index("M104 S0 ; printer machine-end") < snippet_idx + # Nothing executable remains after the marker. + assert gcode[marker_idx:].strip() == "; EXECUTABLE_BLOCK_END" finally: source.unlink(missing_ok=True) if result: diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2bb94b0d1..7484d2d1a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -4087,9 +4087,9 @@ "peer": true }, "node_modules/dompurify": { - "version": "3.4.10", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.10.tgz", - "integrity": "sha512-0xzNv0e7oYC6yyuOGZIABPM4qtg3QxLFniDNPP4ZP90wR8Yq3zgwpRbrNiT4N3IKqDbbYFEJLV+JWEs19aZ//w==", + "version": "3.4.11", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", + "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", "optionalDependencies": { "@types/trusted-types": "^2.0.7" } diff --git a/frontend/src/__tests__/components/PrintModal.test.tsx b/frontend/src/__tests__/components/PrintModal.test.tsx index 8b207f12f..5a722eec9 100644 --- a/frontend/src/__tests__/components/PrintModal.test.tsx +++ b/frontend/src/__tests__/components/PrintModal.test.tsx @@ -1142,6 +1142,102 @@ describe('PrintModal', () => { }); }); + describe('reprint G-code injection dispatch (#422 / auto-eject)', () => { + // Guards the fix: when "Inject auto-print G-code" is ticked on a reprint with + // quantity > 1, ALL copies must go through the queue so every one is injected by + // the scheduler. The first copy must NOT be dispatched immediately via the direct + // reprint path — that path bypasses injection and would leave the first copy stuck + // on the plate for auto-eject setups. + const withSnippets = () => + http.get('/api/v1/settings/', () => + HttpResponse.json({ gcode_snippets: { X1C: { start_gcode: '', end_gcode: 'M400' } } }), + ); + + it('injection ON queues all copies and dispatches none immediately', async () => { + const reprintCalls: unknown[] = []; + const queueCalls: Record[] = []; + server.use( + withSnippets(), + http.post('/api/v1/archives/:id/reprint', async ({ request }) => { + reprintCalls.push(await request.json().catch(() => ({}))); + return HttpResponse.json({ status: 'dispatched' }); + }), + http.post('/api/v1/queue/', async ({ request }) => { + queueCalls.push((await request.json()) as Record); + return HttpResponse.json({ id: queueCalls.length, status: 'pending' }); + }), + ); + + const user = userEvent.setup(); + render( + + ); + + // Quantity > 1 so the injection checkbox is offered + const qty = (await screen.findByLabelText('Quantity')) as HTMLInputElement; + await user.tripleClick(qty); + await user.keyboard('3'); + expect(qty.value).toBe('3'); + + // Checkbox only renders once snippets are loaded AND quantity > 1 + await user.click(await screen.findByLabelText(/inject auto-print/i)); + + await user.click(document.querySelector('button[type="submit"]') as HTMLElement); + + await waitFor(() => expect(queueCalls.length).toBe(1)); + // One queue item carrying all copies, and zero immediate reprint dispatches + expect(queueCalls[0].quantity).toBe(3); + expect(reprintCalls.length).toBe(0); + }); + + it('injection OFF keeps the immediate first copy and queues the rest', async () => { + const reprintCalls: unknown[] = []; + const queueCalls: Record[] = []; + server.use( + withSnippets(), + http.post('/api/v1/archives/:id/reprint', async ({ request }) => { + reprintCalls.push(await request.json().catch(() => ({}))); + return HttpResponse.json({ status: 'dispatched' }); + }), + http.post('/api/v1/queue/', async ({ request }) => { + queueCalls.push((await request.json()) as Record); + return HttpResponse.json({ id: queueCalls.length, status: 'pending' }); + }), + ); + + const user = userEvent.setup(); + render( + + ); + + const qty = (await screen.findByLabelText('Quantity')) as HTMLInputElement; + await user.tripleClick(qty); + await user.keyboard('3'); + expect(qty.value).toBe('3'); + + // Leave injection unticked → first copy prints immediately, rest queue + await user.click(document.querySelector('button[type="submit"]') as HTMLElement); + + await waitFor(() => expect(reprintCalls.length).toBe(1)); + expect(queueCalls.length).toBe(1); + expect(queueCalls[0].quantity).toBe(2); + }); + }); + describe('project_id forwarding', () => { beforeEach(() => { // Additional handlers needed for library file mode diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 37b95ac78..15b73822a 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -6662,6 +6662,7 @@ export interface VirtualPrinterConfig { target_printer_id: number | null; auto_dispatch: boolean; queue_force_color_match: boolean; + gcode_injection: boolean; tailscale_disabled: boolean; bind_ip: string | null; remote_interface_ip: string | null; @@ -6688,6 +6689,7 @@ export const multiVirtualPrinterApi = { target_printer_id?: number; auto_dispatch?: boolean; queue_force_color_match?: boolean; + gcode_injection?: boolean; bind_ip?: string; remote_interface_ip?: string; }) => @@ -6705,6 +6707,7 @@ export const multiVirtualPrinterApi = { target_printer_id?: number; auto_dispatch?: boolean; queue_force_color_match?: boolean; + gcode_injection?: boolean; tailscale_disabled?: boolean; bind_ip?: string; remote_interface_ip?: string; diff --git a/frontend/src/components/PrintModal/index.tsx b/frontend/src/components/PrintModal/index.tsx index 182eb1160..16a0ebe6f 100644 --- a/frontend/src/components/PrintModal/index.tsx +++ b/frontend/src/components/PrintModal/index.tsx @@ -763,32 +763,43 @@ export function PrintModal({ try { if (mode === 'reprint' && !useStagger) { - // Reprint mode - start print immediately (single plate only, multi-select not available) const printerMapping = getMappingForPrinter(printerId); - if (isLibraryFile) { - await api.printLibraryFile(libraryFileId!, printerId, { - plate_id: selectedPlate ?? undefined, - plate_name: selectedPlateName, - ams_mapping: printerMapping, - ...printOptions, - project_id: projectId, - cleanup_library_after_dispatch: cleanupLibraryAfterDispatch, - }); - } else { - // project_id is intentionally omitted here: reprintArchive targets an existing - // archive that already carries its own project association from the original print. - await api.reprintArchive(archiveId!, printerId, { - plate_id: selectedPlate ?? undefined, - plate_name: selectedPlateName, - ams_mapping: printerMapping, - ...printOptions, - }); - } - // Queue remaining copies if quantity > 1 - if (effectiveQuantity > 1) { + if (scheduleOptions.gcodeInjection && effectiveQuantity > 1) { + // Auto-print injection only happens in the scheduler path. A direct + // immediate reprint bypasses it, so the first copy would print without + // its end-snippet and stay stuck on the plate — defeating auto-eject and + // blocking the injected copies queued behind it. Queue *all* copies so + // every one is dispatched (and injected) by the scheduler. const queueData = getQueueData(printerId, plateId); - queueData.quantity = effectiveQuantity - 1; + queueData.quantity = effectiveQuantity; await addToQueueMutation.mutateAsync(queueData); + } else { + // Reprint mode - start print immediately (single plate only, multi-select not available) + if (isLibraryFile) { + await api.printLibraryFile(libraryFileId!, printerId, { + plate_id: selectedPlate ?? undefined, + plate_name: selectedPlateName, + ams_mapping: printerMapping, + ...printOptions, + project_id: projectId, + cleanup_library_after_dispatch: cleanupLibraryAfterDispatch, + }); + } else { + // project_id is intentionally omitted here: reprintArchive targets an existing + // archive that already carries its own project association from the original print. + await api.reprintArchive(archiveId!, printerId, { + plate_id: selectedPlate ?? undefined, + plate_name: selectedPlateName, + ams_mapping: printerMapping, + ...printOptions, + }); + } + // Queue remaining copies if quantity > 1 + if (effectiveQuantity > 1) { + const queueData = getQueueData(printerId, plateId); + queueData.quantity = effectiveQuantity - 1; + await addToQueueMutation.mutateAsync(queueData); + } } } else if (mode === 'edit-queue-item' && progressCounter === 1) { // Edit mode - update the original queue item for the first entry @@ -885,6 +896,21 @@ export function PrintModal({ // Quantity only applies for single-printer or model-based assignment (not multi-printer) const effectiveQuantity = (assignmentMode === 'printer' && selectedPrinters.length > 1) ? 1 : quantity; + // Keep scheduleOptions.gcodeInjection in sync with the checkbox's render + // condition. The checkbox only renders for reprint + snippets configured + + // quantity > 1, so if the user ticks it at quantity 2 then drops back to 1 + // the box hides but the state stays true — and the immediate-reprint path + // would then silently bypass injection. + useEffect(() => { + if ( + mode === 'reprint' && + scheduleOptions.gcodeInjection && + (effectiveQuantity <= 1 || !settings?.gcode_snippets) + ) { + setScheduleOptions((opts) => ({ ...opts, gcodeInjection: false })); + } + }, [mode, effectiveQuantity, settings?.gcode_snippets, scheduleOptions.gcodeInjection]); + // Modal title and action button text based on mode const getModalConfig = () => { const printerCount = selectedPrinters.length; diff --git a/frontend/src/components/VirtualPrinterCard.tsx b/frontend/src/components/VirtualPrinterCard.tsx index 8b044e6e3..dd03b7b9c 100644 --- a/frontend/src/components/VirtualPrinterCard.tsx +++ b/frontend/src/components/VirtualPrinterCard.tsx @@ -55,6 +55,7 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps) const [localModel, setLocalModel] = useState(printer.model || ''); const [localAutoDispatch, setLocalAutoDispatch] = useState(printer.auto_dispatch ?? true); const [localQueueForceColorMatch, setLocalQueueForceColorMatch] = useState(printer.queue_force_color_match ?? false); + const [localGcodeInjection, setLocalGcodeInjection] = useState(printer.gcode_injection ?? false); const [localTailscaleDisabled, setLocalTailscaleDisabled] = useState(printer.tailscale_disabled ?? true); const [showAccessCode, setShowAccessCode] = useState(false); const [pendingAction, setPendingAction] = useState(null); @@ -100,6 +101,7 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps) setLocalModel(printer.model || ''); setLocalAutoDispatch(printer.auto_dispatch ?? true); setLocalQueueForceColorMatch(printer.queue_force_color_match ?? false); + setLocalGcodeInjection(printer.gcode_injection ?? false); setLocalTailscaleDisabled(printer.tailscale_disabled ?? true); } }, [printer, pendingAction]); @@ -437,6 +439,36 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps) )} + {/* G-code injection toggle - only for queue mode (#1516) */} + {localMode === 'queue' && ( +

+
+
+
{t('virtualPrinter.gcodeInjection.title')}
+
{t('virtualPrinter.gcodeInjection.description')}
+
+ +
+
+ )} + {/* Tailscale toggle */}
diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index cb6a03a48..19d9fb170 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -4583,6 +4583,10 @@ export default { title: 'Farbabgleich erzwingen', description: 'Druckaufträge nur an Drucker senden, bei denen der genaue Filament-Typ und die genaue Farbe geladen sind. Standardmäßig deaktiviert — ohne diese Option verwendet die Warteschlange nur den Drucker-Modell-Abgleich und wählt möglicherweise einen Drucker mit der falschen Farbe.', }, + gcodeInjection: { + title: 'G-code-Injektion', + description: 'Wendet die in den Einstellungen pro Modell konfigurierten G-code-Snippets auf Jobs dieses VP an. Standardmäßig aus.', + }, tailscaleDisabled: { title: 'Tailscale-Integration', description: 'Aktivieren, um diesen VP als per Tailscale erreichbar zu markieren. Zeigt die Tailscale-Adresse des Hosts an, damit du weißt, welche IP du im Slicer eintragen musst. Der CA-Import bleibt unverändert — diese Option hat keinen Einfluss auf Zertifikate.', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 276a17133..48a288434 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -4604,6 +4604,10 @@ export default { title: 'Force color match', description: 'Refuse to dispatch onto a printer that does not have the exact filament type and color loaded. Off by default — without this, the queue uses model-only matching and may pick a printer with the wrong color loaded.', }, + gcodeInjection: { + title: 'G-code injection', + description: 'Apply the per-model G-code snippets configured in Settings to jobs from this VP. Off by default.', + }, tailscaleDisabled: { title: 'Tailscale integration', description: 'Enable to mark this VP as exposed over Tailscale. Shows the host\'s Tailscale address so you know which IP to paste into the slicer. The CA-import step is unchanged — this toggle has no effect on certificates.', diff --git a/frontend/src/i18n/locales/es.ts b/frontend/src/i18n/locales/es.ts index 16f44311c..4d01ade21 100644 --- a/frontend/src/i18n/locales/es.ts +++ b/frontend/src/i18n/locales/es.ts @@ -4592,6 +4592,10 @@ export default { title: 'Forzar la coincidencia de color', description: 'Negarse a enviar a una impresora que no tiene cargados el tipo y el color exactos de filamento. Desactivado de forma predeterminada — sin esto, la cola usa la coincidencia solo por modelo y puede elegir una impresora con el color equivocado cargado.', }, + gcodeInjection: { + title: 'Inyección de G-code', + description: 'Aplica los fragmentos de G-code configurados por modelo en Ajustes a los trabajos de esta IV. Desactivado de forma predeterminada.', + }, tailscaleDisabled: { title: 'Integración con Tailscale', description: 'Actívelo para marcar esta IV como expuesta a través de Tailscale. Muestra la dirección de Tailscale del host para que sepa qué IP pegar en el laminador. El paso de importación de la CA no cambia — este interruptor no tiene ningún efecto sobre los certificados.', diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index b70b37414..9dd4fc682 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -4573,6 +4573,10 @@ export default { title: 'Forcer la correspondance des couleurs', description: 'Refuser l\'envoi vers une imprimante qui n\'a pas exactement le type de filament et la couleur chargés. Désactivé par défaut — sans cela, la file d\'attente utilise uniquement la correspondance par modèle et peut choisir une imprimante avec la mauvaise couleur.', }, + gcodeInjection: { + title: 'Injection G-code', + description: 'Applique les extraits de G-code configurés par modèle dans les Paramètres aux travaux de ce VP. Désactivé par défaut.', + }, tailscaleDisabled: { title: 'Intégration Tailscale', description: 'Activez pour marquer ce VP comme exposé via Tailscale. Affiche l\'adresse Tailscale de l\'hôte pour que vous sachiez quelle IP coller dans le slicer. L\'étape d\'import CA est inchangée — cette bascule n\'a aucun effet sur les certificats.', diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts index 8a0c66125..89f347cf9 100644 --- a/frontend/src/i18n/locales/it.ts +++ b/frontend/src/i18n/locales/it.ts @@ -4572,6 +4572,10 @@ export default { title: 'Forza corrispondenza colori', description: 'Rifiuta di inviare a una stampante che non ha esattamente il tipo di filamento e il colore caricato. Disattivato per impostazione predefinita — senza questo, la coda usa solo la corrispondenza per modello e potrebbe scegliere una stampante con il colore sbagliato.', }, + gcodeInjection: { + title: 'Iniezione G-code', + description: 'Applica gli snippet G-code configurati per modello nelle Impostazioni ai lavori di questo VP. Disattivato per impostazione predefinita.', + }, tailscaleDisabled: { title: 'Integrazione Tailscale', description: 'Abilita per contrassegnare questo VP come esposto tramite Tailscale. Mostra l\'indirizzo Tailscale dell\'host così sai quale IP incollare nello slicer. Il passo di importazione CA è invariato — questo toggle non ha effetto sui certificati.', diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index 9cb1d4abd..d7204cbbe 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -4584,6 +4584,10 @@ export default { title: '色の一致を強制', description: '正確なフィラメントタイプと色がロードされていないプリンターへの送信を拒否します。デフォルトはオフ — これがないと、キューはモデルのみのマッチングを使用し、間違った色がロードされたプリンターを選ぶ可能性があります。', }, + gcodeInjection: { + title: 'G-codeインジェクション', + description: '設定でモデルごとに構成したG-codeスニペットを、このVPのジョブに適用します。デフォルトはオフです。', + }, tailscaleDisabled: { title: 'Tailscale統合', description: 'このVPがTailscale経由で公開されていることをマークするには有効にしてください。スライサーに貼り付けるIPがわかるよう、ホストのTailscaleアドレスを表示します。CAインポート手順は変更されません — このトグルは証明書に影響しません。', diff --git a/frontend/src/i18n/locales/ko.ts b/frontend/src/i18n/locales/ko.ts index ef76fa045..9f35011f8 100644 --- a/frontend/src/i18n/locales/ko.ts +++ b/frontend/src/i18n/locales/ko.ts @@ -4324,6 +4324,10 @@ export default { title: '색상 일치 강제', description: '정확한 필라멘트 유형과 색상이 장착되지 않은 프린터에는 발송을 거부합니다. 기본적으로 꺼져 있음 — 이 옵션 없이는 대기열이 모델 전용 매칭을 사용하여 잘못된 색상이 장착된 프린터를 선택할 수 있습니다.' }, + gcodeInjection: { + title: 'G-code 주입', + description: '설정에서 모델별로 구성한 G-code 스니펫을 이 가상 프린터의 작업에 적용합니다. 기본값은 꺼짐입니다.' + }, tailscaleDisabled: { title: 'Tailscale 통합', description: '이 가상 프린터가 Tailscale을 통해 노출되도록 표시하려면 활성화하세요. 슬라이서에 붙여넣을 IP를 알 수 있도록 호스트의 Tailscale 주소를 표시합니다. CA 가져오기 단계는 변경되지 않음 — 이 토글은 인증서에 영향을 미치지 않습니다.' diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts index 46d82d7ee..6cc111142 100644 --- a/frontend/src/i18n/locales/pt-BR.ts +++ b/frontend/src/i18n/locales/pt-BR.ts @@ -4572,6 +4572,10 @@ export default { title: 'Forçar correspondência de cor', description: 'Recusa enviar para uma impressora que não tenha exatamente o tipo e cor de filamento carregados. Desativado por padrão — sem isto, a fila usa apenas correspondência por modelo e pode escolher uma impressora com a cor errada carregada.', }, + gcodeInjection: { + title: 'Injeção de G-code', + description: 'Aplica os trechos de G-code configurados por modelo nas Configurações aos trabalhos deste VP. Desativado por padrão.', + }, tailscaleDisabled: { title: 'Integração Tailscale', description: 'Ative para marcar este VP como exposto via Tailscale. Mostra o endereço Tailscale do host para você saber qual IP colar no fatiador. A etapa de importação CA é inalterada — este toggle não afeta certificados.', diff --git a/frontend/src/i18n/locales/tr.ts b/frontend/src/i18n/locales/tr.ts index ce20c8d58..396482097 100644 --- a/frontend/src/i18n/locales/tr.ts +++ b/frontend/src/i18n/locales/tr.ts @@ -4547,6 +4547,10 @@ export default { title: 'Renk eşleşmesini zorla', description: 'Tam olarak doğru filament türü ve rengi yüklü olmayan bir yazıcıya sevk etmeyi reddet. Varsayılan olarak kapalı — bu olmadan kuyruk yalnızca model eşleşmesi kullanır ve yanlış renk yüklü bir yazıcı seçebilir.', }, + gcodeInjection: { + title: 'G-code enjeksiyonu', + description: "Ayarlar'da model bazında yapılandırılan G-code parçacıklarını bu VP'nin işlerine uygular. Varsayılan olarak kapalı.", + }, tailscaleDisabled: { title: 'Tailscale entegrasyonu', description: "Bu VP'yi Tailscale üzerinden açıkta olarak işaretlemek için etkinleştirin. Dilimleyiciye hangi IP'yi yapıştıracağınızı bilmeniz için ana bilgisayarın Tailscale adresini gösterir. CA içe aktarma adımı değişmedi — bu anahtarın sertifikalar üzerinde etkisi yoktur.", diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 477d29c5e..81b8a3752 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -4572,6 +4572,10 @@ export default { title: '强制颜色匹配', description: '拒绝派发到没有完全相同耗材类型和颜色的打印机。默认关闭 — 不启用时,队列仅按型号匹配,可能选到颜色错误的打印机。', }, + gcodeInjection: { + title: 'G-code 注入', + description: '将“设置”中按型号配置的 G-code 片段应用到此 VP 的作业。默认关闭。', + }, tailscaleDisabled: { title: 'Tailscale 集成', description: '启用以将此 VP 标记为通过 Tailscale 公开。显示主机的 Tailscale 地址,以便您知道要粘贴到切片器中的 IP。CA 导入步骤保持不变 — 此开关对证书无影响。', diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts index af7fa811c..8bc151daa 100644 --- a/frontend/src/i18n/locales/zh-TW.ts +++ b/frontend/src/i18n/locales/zh-TW.ts @@ -4572,6 +4572,10 @@ export default { title: '強制顏色匹配', description: '拒絕派發到沒有完全相同耗材類型和顏色的印表機。預設關閉 — 不啟用時,佇列僅按型號匹配,可能選到顏色錯誤的印表機。', }, + gcodeInjection: { + title: 'G-code 注入', + description: '將「設定」中依型號設定的 G-code 片段套用到此 VP 的作業。預設關閉。', + }, tailscaleDisabled: { title: 'Tailscale 整合', description: '啟用以將此 VP 標記為透過 Tailscale 公開。顯示主機的 Tailscale 位址,以便您知道要貼上到切片器中的 IP。CA 匯入步驟保持不變 — 此切換對憑證無影響。',