diff --git a/.claude/commands/bump-version.md b/.claude/commands/bump-version.md index ee1c013a..95b85d7d 100644 --- a/.claude/commands/bump-version.md +++ b/.claude/commands/bump-version.md @@ -56,10 +56,6 @@ Correct flow: - Use the `mcp__time__get_current_time` MCP tool with timezone `America/New_York` to get accurate timestamp - Format: `**Last Updated:** YYYY-MM-DD HH:mm ET (America/New_York)` - d. **docs/README.md**: - - Update any version references in user-facing documentation - - Update download links similar to main README.md - 5. **Summary**: - Show the user which files were updated - Display old version → new version @@ -71,7 +67,7 @@ Correct flow: ``` Current: 1.0.3-alpha.1 New: 1.0.3-alpha.2 -Updated: package.json, README.md (alpha link), CLAUDE.md timestamp, docs/README.md +Updated: package.json, README.md (alpha link), CLAUDE.md timestamp ``` **Stable Release:** @@ -79,14 +75,14 @@ Updated: package.json, README.md (alpha link), CLAUDE.md timestamp, docs/README. Current: 1.0.3-alpha.5 Confirm: Ready to release 1.0.3 stable? [User confirms] New: 1.0.3 -Updated: package.json, README.md (stable link), CLAUDE.md timestamp, docs/README.md +Updated: package.json, README.md (stable link), CLAUDE.md timestamp ``` **After Stable → Next Alpha:** ``` Current: 1.0.3 New: 1.0.4-alpha.1 -Updated: package.json, README.md (alpha link), CLAUDE.md timestamp, docs/README.md +Updated: package.json, README.md (alpha link), CLAUDE.md timestamp ``` ## Validation diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..d9d1a090 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +# Shell scripts and Linux packaging data must keep LF endings regardless of the checkout platform. +# With core.autocrlf=true on Windows these files would otherwise be checked out with CRLF, and a +# packaged postinst starting with `#!/bin/bash\r` fails on Linux with "bad interpreter". +*.sh text eol=lf +assets/linux/apparmor-profile text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..174a6699 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,138 @@ +name: CI + +on: + push: + branches: [main, alpha] + pull_request: + branches: [main, alpha] + workflow_dispatch: + inputs: + emulator-ref: + description: 'flashforge-emulator-v2 git ref to run the E2E track against' + required: false + default: 'v0.4.0' + type: string + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + name: Type-check, lint, build, test + runs-on: ubuntu-latest + steps: + - name: Checkout FlashForgeUI + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.23.0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'pnpm' + + + - name: Install dependencies + shell: bash + run: pnpm install --frozen-lockfile + + - name: Type check + run: pnpm type-check + + - name: Lint + run: pnpm lint + + - name: Build (main + renderer + webui) + run: pnpm build + + - name: Unit tests + run: pnpm test + + e2e-emulator: + name: E2E (emulator + WebUI) + runs-on: ubuntu-latest + needs: verify + steps: + - name: Checkout FlashForgeUI + uses: actions/checkout@v4 + + # The emulator lives in a separate public repo and provides the printers this + # track drives, so no real hardware is needed. Pinned to a release tag so an + # emulator change cannot silently break or silently change this CI; bump the + # tag (or dispatch with emulator-ref) to pick up a new emulator release. + - name: Checkout flashforge-emulator-v2 + uses: actions/checkout@v4 + with: + repository: GhostTypes/flashforge-emulator-v2 + ref: ${{ inputs.emulator-ref || 'v0.4.0' }} + path: emulator + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.23.0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'pnpm' + + + - name: Install dependencies + shell: bash + run: pnpm install --frozen-lockfile + + - name: Install emulator dependencies + working-directory: emulator + run: npm ci + env: + # Only the emulator's headless runtime is used here; skip the ~110 MB + # Electron binary download. The app-under-test gets its Electron from + # this repo's node_modules, not the emulator's. + ELECTRON_SKIP_BINARY_DOWNLOAD: '1' + + # The browser track needs the chromium binary itself, not just its system libraries. + - name: Install Playwright chromium + run: pnpm exec playwright install --with-deps chromium + + # Electron refuses to start if its SUID sandbox helper is not root-owned and + # setuid, which is how pnpm unpacks it. Fixing the binary keeps the app launching + # exactly as it does for users, rather than weakening it with --no-sandbox. + - name: Enable the Electron sandbox helper + run: | + ELECTRON_DIR="$(dirname "$(node -p "require('electron')")")" + sudo chown root:root "$ELECTRON_DIR/chrome-sandbox" + sudo chmod 4755 "$ELECTRON_DIR/chrome-sandbox" + + # The suite drives a real Electron window, so CI needs a virtual display. + - name: Run emulator E2E track + run: xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" pnpm test:e2e:electron:emulator + env: + FF_EMULATOR_ROOT: ${{ github.workspace }}/emulator + + # Browser coverage of the WebUI. It launches the real app with --headless against the + # same emulator, so it needs Electron and a display too - hence this job rather than + # the fast verify one. Runs even if the Electron track failed, since the two cover + # different surfaces and one failing should not hide the other. + - name: Run WebUI browser E2E + if: success() || failure() + run: xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" pnpm test:e2e + env: + FF_EMULATOR_ROOT: ${{ github.workspace }}/emulator + + - name: Upload Playwright artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-e2e-artifacts + path: | + test-results/ + playwright-report/ + retention-days: 7 + if-no-files-found: ignore diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 797a03a8..27fcd54a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,13 +13,46 @@ on: default: false jobs: + verify: + name: Verify (type-check, lint, test) + runs-on: ubuntu-latest + steps: + - name: Checkout FlashForgeUI + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.23.0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: 'pnpm' + + + - name: Install dependencies + shell: bash + run: pnpm install --frozen-lockfile + + - name: Type check + run: pnpm type-check + + - name: Lint + run: pnpm lint + + - name: Unit tests + run: pnpm test + build_and_release: + needs: verify runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: os: [windows-latest, macos-latest, ubuntu-latest] - node-version: [18] # LTS version, good for Electron + node-version: [22] # matches the Node runtime Electron 39 bundles steps: - name: Checkout FlashForgeUI @@ -36,15 +69,6 @@ jobs: node-version: ${{ matrix.node-version }} cache: 'pnpm' - - name: Configure GitHub Packages Authentication - shell: bash - run: | - echo "Setting up GitHub Packages authentication" - echo "@ghosttypes:registry=https://npm.pkg.github.com" >> .npmrc - echo "@parallel-7:registry=https://npm.pkg.github.com" >> .npmrc - echo "//npm.pkg.github.com/:_authToken=${{ secrets.GITHUB_TOKEN }}" >> .npmrc - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Install dependencies shell: bash @@ -97,6 +121,7 @@ jobs: dist/**/*.dmg dist/**/*.AppImage dist/**/*.nupkg + dist/**/*.blockmap dist/*.yml !dist/**/elevate.exe !dist/win-unpacked/** @@ -129,7 +154,7 @@ jobs: shell: bash run: | mkdir release_assets - find artifacts/ -type f \( -name "*.zip" -o -name "*.deb" -o -name "*.rpm" -o -name "*Setup*.exe" -o -name "FlashForgeUI*.exe" -o -name "*.dmg" -o -name "*.AppImage" -o -name "*.nupkg" -o -name "*.yml" \) | grep -v "elevate.exe" | while IFS= read -r file; do + find artifacts/ -type f \( -name "*.zip" -o -name "*.deb" -o -name "*.rpm" -o -name "*Setup*.exe" -o -name "FlashForgeUI*.exe" -o -name "*.dmg" -o -name "*.AppImage" -o -name "*.nupkg" -o -name "*.blockmap" -o -name "*.yml" \) | grep -v "elevate.exe" | while IFS= read -r file; do filename=$(basename "$file") cp "$file" "release_assets/$filename" echo "Copied $file to release_assets/$filename" @@ -225,6 +250,11 @@ jobs: uses: softprops/action-gh-release@v2 with: tag_name: ${{ env.VERSION_TAG }} + # Anchor the tag to the dispatched commit. Without this, GitHub creates a + # missing tag at the repository DEFAULT branch (main) rather than the ref + # the workflow ran on, so releases cut from `alpha` were tagged against + # main and did not contain the code that was actually built and shipped. + target_commitish: ${{ github.sha }} name: ${{ env.RELEASE_NAME }} body_path: ${{ env.RELEASE_NOTES_PATH }} draft: false diff --git a/.gitignore b/.gitignore index f4930417..af1285fc 100644 --- a/.gitignore +++ b/.gitignore @@ -94,6 +94,7 @@ temp/ # Playwright test artifacts test-results/ +playwright-report/ # Editor directories and files .vscode/ @@ -132,3 +133,4 @@ debug_logs/* hook_debug_log.txt __pycache__/ .pi/session-memory.json + diff --git a/CHANGELOG.md b/CHANGELOG.md index 81797908..5e7b7277 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,290 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.5] - 2026-10-10 + +The 1.0.5 stable release consolidates the 1.0.5-alpha cycle (alpha.1 through alpha.13). It adds Creator 5 and Creator 5 Pro support. On the Adventurer 5M / 5M Pro / AD5X it adds an SSH-based printer file manager and remote reboot. The WebUI can now upload jobs. Spoolman tracks filament per job on material-station printers. The app can start with your system and minimize to the tray. Connections to modern printers are faster. Every change is now tested in CI against a printer emulator. The per-alpha entries below have the full details. + +### Creator 5 / Creator 5 Pro +- **New: Creator 5 and Creator 5 Pro support.** These printers have no legacy TCP channel. The app identifies them by the USB product ID in their discovery broadcast and connects over HTTP only. +- Four-slot material station support. It uses the same Material Station card as the AD5X, with the Creator 5's own filament palette (24 colors, 21 materials) and per-slot material and color editing. The card was renamed from "IFS Station" to "Material Station", and saved layouts migrate automatically. +- A unified temperature card shows all four tool nozzles, the bed, and the chamber in one place. Chamber temperature can be set and cleared, and each tool's **Off** button turns that tool head off. Tool heads are labeled T1–T4, as on the printer's own screen. +- The Creator 5 Pro has filtration controls and its door sensor. The plain Creator 5 has neither, so both are hidden on it. +- A paused Creator 5 Pro now shows as **Paused**, no longer as `Unknown`. The printer pauses itself when it detects a clog, so this matters most at the worst moment. +- **Known limitations:** + - These printers cannot start a job that is already stored on them, so **Start Recent** and **Start Local** are disabled. Upload a fresh `.3mf` instead. + - Raw G-code commands, including **Home Axes**, need the TCP channel these printers do not have, so they are disabled. + - The SSH file manager, calibration assistant, and reboot do not support the Creator 5 series yet. + +### Connecting to Printers +- Modern printers (5M, 5M Pro, AD5X, Creator 5 series) are identified from their discovery broadcast. This removes an extra network probe from every connect. Legacy printers are still probed as before. +- A new **manual connect form** replaces the old "Enter IP" prompt. It asks for the IP address, printer type, serial number, and check code. The check code is no longer asked for a second time. +- Headless `--printers` accepts `creator-5` and `creator-5-pro` types and an optional serial field: `IP:TYPE[:CHECKCODE[:SERIAL]]`. + +### Job Upload & Material Matching +- **The WebUI can upload a job.** Phones and remote browsers can now send `.gcode`, `.gx`, and `.3mf` files. The dialog, metadata, warnings, and material matching are the same as on the desktop. +- Multi-material uploads send the correct tool numbers. A plate that uses filaments 1 and 3 now prints with T0 and T2. Before this fix it printed with T0 and T1, so the second tool fed from the wrong slot. +- The matching dialog was reworked. Each color mismatch now gets its own warning line, both sides of every mapping show a color swatch, and the confirm button always reads **Confirm**. After matching, the upload dialog shows the mappings as chips. Its button reads **Start Print** or **Send File**, depending on whether **Start Now** is ticked. +- The WebUI matching dialog now waits for the material station to report in after a connect or a printer switch, rather than showing "not connected". + +### Spoolman +- **Per-job filament tracking for material-station printers (AD5X with a station, Creator 5, Creator 5 Pro).** When you match materials for a job, you also pick the Spoolman spool in each slot, or choose "Do not track". + - When the print finishes, each spool is charged the slicer's estimate for its tool. + - A cancelled or failed print is charged only for what was printed up to the point where it stopped. + - The choice applies to that one print only, because the spools on the printer can change at any time. +- The per-slot spool assignment was removed from the Material Station editor. **Set from Spoolman** still sets a slot's material and color. +- Creator 5 tracking has not been tested on a real printer yet. Feedback is welcome in [FlashForgeWebUI #21](https://github.com/Parallel-7/FlashForgeWebUI/issues/21). + +### File Manager, SSH & Reboot (Adventurer 5M / 5M Pro / AD5X) +These features need root SSH access, which the [FlashForge-EasySSH](https://github.com/Parallel-7/FlashForge-EasySSH) USB tool sets up. +- **New: Printer file manager** (a top-bar shortcut). Browse, rename, and delete (one or many) files on the printer's internal storage and on USB, with thumbnail previews. +- **New: Settings → SSH.** Each printer has one set of SSH credentials, encrypted at rest, which the file manager and the calibration assistant share. The defaults match EasySSH (`root` / `flashforge`, port 22). Credentials saved for the calibration assistant in 1.0.4 are not carried over. If yours were not the defaults, enter them again here. +- **New: Reboot** in the menu. A full-screen overlay follows the printer through the restart and reconnects the camera once the printer is back. +- All three work in the WebUI as well. Passwords can be changed from a browser but never read back. + +### Start with System & Tray +- **Start with system** and **Start minimized** settings for Windows, macOS, and Linux. Start minimized applies only when the system launches the app. A manual launch always shows the window. +- A system tray icon (Show / Quit), and an optional **Minimize to tray** setting. It is off by default, because stock GNOME needs an extension to show tray icons. +- On Linux, autostart uses a standard `~/.config/autostart` entry that points at the stable AppImage path. + +### Status, WebUI & Notifications +- The ETA no longer creeps forward while a print is paused or heating. It shows `--:--` until the print is advancing again, and the Discord notification leaves the ETA out. +- The WebUI Recent/Local file dialog shows thumbnail previews. They share the desktop's thumbnail cache. +- The TVOC air-quality reading shows on every printer that reports it. +- Fixed the WebUI calibration dialog layout and see-through popup backgrounds. + +### Headless & Stability +- Fixed headless mode exiting at startup because the WebUI server was started twice. Thanks to [@jsalmon00](https://github.com/jsalmon00) ([#80](https://github.com/Parallel-7/FlashForgeUI-Electron/pull/80)). +- The go2rtc camera process now stops whenever the app exits, including after an error. + +### Linux Packaging +- The `.deb` and `.rpm` packages install their AppArmor profile again, so the app starts on Ubuntu 24.04 and later. These packages are the recommended download there. +- The AppImage detects when it has no usable sandbox and falls back to `--no-sandbox`, so it still launches. + +### Diagnostics & Updates +- Debug mode also saves all console output to a `console-.log` file. The WebUI debug routes can list and download it. +- Releases now include `.exe.blockmap` files, so Windows updates download only what changed. +- The update channel now matches the installed build unless you choose one yourself. + +### Testing & CI +- CI type-checks, lints, builds, and runs the unit tests on every push and pull request, and the release workflow runs the same checks first. +- An end-to-end suite drives the real desktop app against the [FlashForge printer emulator](https://github.com/GhostTypes/flashforge-emulator-v2) (5M Pro, AD5X, Creator 5, Creator 5 Pro) in CI. The same specs run against real printers on a test bench. +- The WebUI browser tests now run against the real headless server, replacing a mock. +- CI and release builds use Node 22, matching the Node runtime that ships with Electron 39. + +### Documentation +- User documentation moved to the [GitHub wiki](https://github.com/Parallel-7/FlashForgeUI-Electron/wiki). +- New `CONTRIBUTING.md` describing the alpha-first development and release workflow. + +### Dependencies +- `@ghosttypes/ff-api` 1.3 → 2.0.3, and `@parallel-7/slicer-meta` 1.3.0 → 1.5.0. +- **Security update.** About 100 newly published dependency advisories are patched (5 critical, more than 50 high). The changes: + - Updated: `express` 5.3.0, `electron-updater` 6.8.9, `electron-builder` 26.15.3. + - Moved to patched versions: `axios` 1.20.0, `adm-zip` 0.6.1 (used to read 3MF files), `proxy-addr`, `js-yaml`, `tar`, `qs`, `ip-address`, `postcss`, `shell-quote`, `brace-expansion`, `@xmldom/xmldom`, `handlebars`, `nanoid`, `source-map-js`, and `browserslist`. + - Still open: the remaining advisories are in Electron 39 itself, or in tools used only during development that have no fix yet. + +## [1.0.5-alpha.13] - 2026-09-26 + +### Added + +- **Per-job Spoolman tracking for material-station printers (Creator 5, Creator 5 Pro, AD5X with a material station).** These printers do not report filament use per tool, so the app estimates it. When you match materials for a job the app starts, the matching dialog now also asks for the Spoolman spool in each slot, or "Do not track". This works in the desktop dialog, the built-in WebUI, and the standalone WebUI. The choice applies to that one print only. When the print ends, the slicer's estimate for each tool is charged to its spool, and the choice is deleted. The app never remembers a spool per slot or per printer, because the spools on the printer can change at any time. + - A completed print charges each spool its full estimate. + - A cancelled or failed print charges each tool for what it printed up to the last progress value. The app reads the gcode inside the 3MF when you upload it and finds how much of each tool's filament is used at each point of the file. The printer's progress is the position in that same file, so a tool that only prints the top half of a model is charged nothing if the print stops at 40%. + - Pause and resume charge nothing. + - If the app was not running when the print ended, it charges the last saved progress on the next status update and marks the result "approximate". A completed print is charged in full. + - Only prints the app starts are tracked. A file sent without **Start Now**, a print started on the printer, and a reprint of the same file from the printer's screen are not tracked. + - On the AD5X, a file already on the printer is tracked when you start it through the matching dialog. The estimate comes from the printer's file list, and a cancel is charged by progress, because the app has no gcode for that file. + - The Spoolman panel shows the tracked print, its spool per tool, and the result of the last charge. The per-slot spool assignment and the "Set from Spoolman" assignment in the Material Station editor are removed. "Set from Spoolman" still sets the slot's material and color. + - Creator 5 and Creator 5 Pro tracking is not yet tested on a real printer. If you own one, please tell us how it works in [issue #21](https://github.com/Parallel-7/FlashForgeWebUI/issues/21). + +### Fixed + +- **Multi-material uploads now send the correct tool numbers.** A 3MF lists only the filaments a plate uses, each with its slicer number. A plate that uses filaments 1 and 3 prints with tools T0 and T2, but the desktop uploader and the built-in WebUI numbered the tools by their position in the list (T0 and T1). The printer then fed the second tool from the wrong slot. The tool number now comes from the filament number. +- **The built-in WebUI's matching dialog waits for the material station.** Right after a connect or a printer switch, the station status can take several seconds to arrive. The dialog read it once and showed "Material station not connected" with no slots. It now keeps asking for about 15 seconds. + +## [1.0.5-alpha.12] - 2026-08-21 + +### Added + +- **The built-in WebUI can now upload a job.** Until now, **Upload Job** existed only in the desktop window, so a phone or a remote browser could start files already on the printer but could not send a new one. The WebUI dialog is a 1:1 port of the desktop one: file picker, **Start Now** and **Auto Level**, the plate thumbnail, and the same metadata grid — printer model, filament type and use, slicer name and version, slice date, print time, first layer time, layer height, infill, layer count, support, and slice warnings. Every warning and error message is the desktop text, word for word. The **Upload Job** button sits with the other job controls and turns on whenever the printer is ready for a new job. + + Material matching runs on the AD5X and the Creator 5 series and reuses the WebUI's existing matching dialog. The Creator 5 series is deliberately **not** gated here. It cannot start a job that already sits on the printer, because that firmware reports no per-file tool data — but a fresh 3MF carries its own, so upload is the one path that works on those models. + + A browser knows no local file path, so the upload runs in three steps. The file goes to the WebUI server first. The server parses it and returns the metadata. The start request then hands the printer backend a real path, which is the same input the desktop IPC handler gives it. The staged file lives in a temporary directory, is limited to 512 MB, and is deleted after the job starts or the dialog closes. Stale files expire after 30 minutes and on server shutdown. The server accepts only a bare `.gcode`, `.gx`, or `.3mf` file name, and rejects a path or any other extension. The progress bar shows the real browser-to-server transfer, which is the slow part over Wi-Fi. + + The same port landed in the standalone [FlashForgeWebUI](https://github.com/Parallel-7/FlashForgeWebUI) (issue #19). The two web UIs stay in step. + +### Changed + +- **Material matching and the upload dialog were reworked for clarity.** The matching dialog's confirm button now always reads **Confirm** — it confirms the tool-to-slot mappings, and the flow that opened the dialog decides what happens next. It previously read **Start Print** in both web UIs, and on the desktop it flipped between the two labels depending on which flow opened the dialog; either way it promised an action the dialog itself does not perform. The mismatch wording is now identical on all three surfaces — the desktop dialog, the built-in WebUI, and the standalone FlashForgeWebUI. + + Color mismatches now have a card of their own, above the two lists. It carries one line per mismatched mapping, rebuilt on every change, so it always reflects the whole set — previously only the most recent mismatch produced a warning, so mapping a second tool hid the first problem. Each mapping row also gained a color swatch on both sides, so the expected color and the loaded color sit beside the text that compares them. + + After matching is confirmed, the upload dialog shows the mappings as chips — tool, slot, and material name, with a color swatch for each side — so the set can be checked at a glance before the job is sent. The dialog's primary button is now named after what it does: **Start Print** with **Start Now** ticked, **Send File** otherwise (it read **OK** before). Slice warnings moved out of the metadata grid into their own card, and the print-time highlight became a full-width banner across the top of the grid. On a phone the built-in WebUI stacks the preview above the metadata and keeps the **Start Now** / **Auto Level** checkboxes on one line. + + The desktop job uploader window is taller and its minimum height was raised, to fit the new cards. The metadata pane now shows a slim scrollbar instead of none — the pane clips its content in a window that cannot be resized, and a hidden scrollbar left no hint there was more to read. The stacked narrow-width layout no longer applies at the window's own width, and the file row and its two checkboxes stay on one line instead of stacking. + +### Fixed + +- **Local and recent job start is now disabled for the Creator 5 series.** The Creator 5 / Creator 5 Pro firmware neither sends nor accepts material mappings over the HTTP API, so starting a previously uploaded local or recent job dead-ended at material selection — only a fresh `.3mf` upload and start works. **Start Recent** and **Start Local** are now disabled with the message "Local job management is not available on this printer." The message is the same everywhere: the desktop controls, the job picker, and the embedded WebUI. Fresh uploads are unaffected. + +- **Home Axes no longer shows as available on the Creator 5 series.** Two bugs kept the button wrongly enabled on these HTTP-only printers. The shared backend base class hardcoded G-code availability in `buildFeatureSet()`, silently discarding the Creator 5 backend's "unavailable" declaration. And the renderer learned G-code availability from an event whose context guard dropped the boot-time payload in a race. The feature set builder now honors each backend's declaration, and the renderer derives the capability from the active printer's model, so it stays correct across printer switches. **Home Axes** requires the TCP G-code passthrough these printers do not have. + +### Testing & CI + +- The emulator end-to-end track now covers four printers: Adventurer 5M Pro, AD5X, Creator 5, and Creator 5 Pro (the two Creator 5 models require a [flashforge-emulator-v2](https://github.com/GhostTypes/flashforge-emulator-v2) build with Creator 5 support). A new spec pins the gating above per model — **Start Recent** / **Start Local** disabled with the exact message on the Creator 5 series, enabled on every other model, and **Home Axes** still disabled on the series — and it caught both fixed bugs above on its first run. The out-of-band test client now also accepts the Creator 5 firmware's bare file-name `/gcodeList` responses (no per-file detail), so upload verification covers both response shapes. `docs/TESTING.md` updated: 31 tests plus 2 conditional skips. + +## [1.0.5-alpha.11] - 2026-08-10 + +### Fixed +- **A paused Creator 5 Pro no longer shows as `Unknown`.** The printer reports the raw status `"pause"`, while the API library only knew the documented `"pausing"` and `"paused"` — so every pause fell through to `Unknown`, and it did so at the worst possible moment: the printer pauses itself when it detects a clog, so the status went blank precisely when you needed to know why the print had stopped. Fixed upstream in `@ghosttypes/ff-api` 2.0.0, which this release bumps to; `"downloading"` now reads as `Busy` for the same reason. Observed on a Creator 5 Pro running firmware 1.9.4. + +- **The ETA no longer walks forward while a print is paused.** The displayed completion time was computed as `now + remaining` on every poll, which only holds still while the firmware is counting `estimatedTime` down. It freezes that field the moment the print stops advancing, so with one term fixed and the clock still moving the ETA stepped forward a minute every minute — pause on a clog for an hour and the app claimed the print would finish an hour later than when the pause started, receding indefinitely. Nothing suppressed it: `isActive` deliberately includes `Paused`, so the desktop job stats, the built-in WebUI panel, and the Discord notification embed all kept recomputing. Each now shows `--:--` (and the Discord embed omits the ETA field) unless the print is actually advancing, via the new `isPrintAdvancing` predicate in `shared/types/polling.ts` — which covers `Printing` only, since the pre-print `Heating` warmup does not advance the job either and drifts identically, just for minutes rather than hours. The *duration* readouts were never wrong and are unchanged. + + Worth knowing why it went unnoticed: the desktop shows only the wall-clock time ("3:45 PM") with no duration beside it, so there was nothing on screen to contradict it. The same bug exists in FlashForgeWebUI and in both API libraries, fixed in each; it originates in the C# `ff-5mp-api` (`MachineInfo.cs:210`) that every port inherited the line from. + +### Start at Boot +- New **Minimize to tray** setting (Settings -> Advanced -> Startup, off by default). With it enabled, minimizing the window hides it to the system tray instead of leaving it in the taskbar, and a hidden boot start goes straight to the tray with no taskbar entry at all. Previously the window was only iconified, so it kept its taskbar button even though a tray icon was present (#75). +- The setting falls back to a normal minimize on desktops where the tray icon could not be created, so the window can never be hidden with no way to get it back. Stock GNOME needs an AppIndicator extension for tray icons to appear, which is why the setting ships off by default — enable it once you can see the icon. +- A one-time notification on first hide points out that the app is still running in the tray. + +### Testing & CI +- Rebuilt the Electron end-to-end suite around two tracks sharing one set of specs: an **emulator** track that needs no hardware and runs in CI, and a **hardware** track that drives real bench printers. Covers connect (saved, discovery, manual), upload with material matching, LED control, multi-printer contexts, and the SFTP round-trip. +- The WebUI browser tests now run against the **real headless server** instead of a hand-written mock of the API. They launch the actual app with `--headless` against emulated printers, so real routes, authentication, and websocket traffic are exercised — the mock could not catch a single server-side regression. Added coverage for rejected logins, unauthorized API access, and token revocation on logout. +- CI now runs both suites on every push and pull request, alongside type-check, lint, build, and unit tests. Type-checking covers the test tree as well, which is why the old suite was able to rot unnoticed. +- Fixed emulator processes surviving teardown on Linux and leaving printer ports bound, which failed the following test with `EADDRINUSE`. +- The [FlashForge printer emulator](https://github.com/GhostTypes/flashforge-emulator-v2) used by these tests is now public under the MIT license. +- New `docs/TESTING.md` documenting each test surface, both tracks, and the remaining coverage gaps. + +### Fixed +- **WebUI: starting a print on a Creator 5 opened an empty material matching dialog.** The Creator 5 series reports file names only on `/gcodeList`, with none of the per-tool material data the dialog is built from, so the modal opened with nothing to map — and its confirm button still passed validation, because zero mappings satisfied zero required tools. Material matching from the recent-files list is AD5X-only; Creator 5 files now go straight to the normal start. Matching when uploading a 3mf is unaffected. + +### Dependencies +- `@ghosttypes/ff-api` 1.8.0 -> 2.0.0. The major bump is the library making `FFMachineInfo.CompletionTime` nullable for the same paused-print reason described above; this app never read that field (it derives its own ETA from the remaining duration), so nothing here changed but the pin. + +## [1.0.5-alpha.10] - 2026-07-31 + +Hotfix for a crash in the job uploader introduced in 1.0.5-alpha.9. + +### Job Uploader +- Fixed pressing **OK** doing nothing on printers without a material station. The AD5X-only file-type check was generalized to cover the Creator 5 series in alpha.9, but one branch still referenced the removed `isAD5X` variable, so confirming any upload threw `ReferenceError: isAD5X is not defined` and the job was never sent. The safety check now uses the material-station result, and its message no longer names the AD5X specifically since the branch also covers the Creator 5 / Creator 5 Pro. + +### Build & CI +- Added a CI workflow that type-checks, lints, builds, and runs the unit tests on every push and pull request to `main` and `alpha`, and gated the release workflow behind the same checks. The alpha.9 crash was a plain type error that `tsc --noEmit` would have caught before packaging. +- CI and release builds now run on Node 22, matching the Node runtime bundled with Electron 39 (previously Node 18, which is end-of-life). +- Fixed the WebUI route tests leaving keep-alive sockets open when closing their fixture servers, which stalled each test for the full keep-alive timeout on Node 18. + +## [1.0.5-alpha.9] - 2026-07-26 + +### Connecting to Printers +- Modern printers are now identified from the USB product ID in their discovery broadcast instead of an unauthenticated TCP probe. The probe was previously skipped only for the HTTP-only Creator 5 series; it is now skipped for every modern model (5M, 5M Pro, AD5X, Creator 5, Creator 5 Pro), removing a redundant round trip from every modern connect. The broadcast already carries the serial and name, and the capability flags come from the library after `initialize()`, so the probe had nothing left to contribute. Printers with no product ID — genuine legacy printers, and manual connects that chose "Legacy Printer" — are still probed as before. +- New **manual connect form**, replacing the single "Enter IP" prompt. Because a named model is no longer probed, the form collects the details the broadcast would have supplied: IP address, printer type, serial number, and check code. Legacy remains IP-only. The check code entered here is used directly, so you are no longer prompted for it a second time. +- Headless `--printers` now accepts `creator-5` / `creator-5-pro` type tokens and an optional fourth `SERIAL` field (`IP:TYPE[:CHECKCODE[:SERIAL]]`). The Creator tokens mark HTTP-only printers so the flow skips the TCP probe they cannot answer, and a serial is required for them since it cannot be recovered by probing. Documented in the user guide with examples. + +### File Manager +- New SFTP-based file manager (topbar shortcut) for the Adventurer 5M / 5M Pro / AD5X. Browse, multi-select delete, and rename gcode/3MF files on internal storage and USB, with thumbnails resolved through the local cache, printer-side cache PNGs, and ranged-read `.3mf` extraction (no full-file download). USB presence is probed via `/proc/mounts`. + +### SSH Settings +- Centralized per-printer SSH credentials (Settings -> SSH), keyed by serial and encrypted at rest. Defaults assume the sibling [FlashForge-EasySSH](https://github.com/Parallel-7/FlashForge-EasySSH) USB provisioning (`root`/`flashforge`, port 22); the file manager and calibration assistant now share this one store. The old per-calibration credential fields were removed (old data is ignored). + +### Reboot +- New **Reboot** action in the hamburger menu for the Adventurer 5M / 5M Pro / AD5X, performed over SSH. A full-screen overlay tracks the whole lifecycle (rebooting -> waiting -> reconnecting services -> back online) and proactively restarts the camera stream, so the printer reconnects seamlessly without leaving the tab in an error state. + +### WebUI +- The full SSH toolset now works in the browser: the file manager, the calibration assistant, SSH settings editing, and remote reboot match what the desktop app can do. Passwords are write-only — they can be changed but never read back from the browser. +- Reboot progress now broadcasts over the WebUI socket, so a reboot started from the desktop app shows its lifecycle in any open browser session (and vice versa). +- Fixed the calibration dialog being too narrow and its export button rendering comically large. +- Fixed see-through popup backgrounds caused by a broken style definition. +- Updated the slicer file reader to its latest version. +- The **Home Axes** button is now disabled on HTTP-only printers (Creator 5 / Creator 5 Pro). Raw G-code passthrough — including the `~G28` homing command — runs over the legacy TCP channel those printers don't expose, so it could never have worked on them. G-code availability is now reported to clients via `features.gcodeCommands`, and the same gating covers the desktop controls grid, not just the browser UI. +- Aligned the WebUI request-validation schemas with the standalone project: added `GCodeCommandRequestSchema`, `ModelPreviewRequestSchema`, `CommandDataValidators`, `StandardAPIResponseSchema`, and `SlotConfigRequestSchema`. +- The material-station slot-config endpoint now validates through the shared `SlotConfigRequestSchema` (replacing hand-rolled checks). The schema was tightened to the correct rules — a required material name and a strict 6-digit hex color — matching the AD5X / Creator 5 fixed material and color palettes; the previously permissive 3/6/8-digit hex and nullable material are no longer accepted. + +### Diagnostics & Updates +- Debug mode now captures all main-process console output (plus renderer log-panel messages) to a new `console-.log`, alongside the targeted `debug-*.log` instrumentation channel. Console logs are also exposed through the WebUI debug routes (list / download / latest). +- `electron-log` is now mocked globally in Jest, so tests no longer pollute the real `main.log`. +- The release workflow now uploads `.exe.blockmap` assets so differential updates work (retrofitted for v1.0.4 and v1.0.5-alpha.8 manually). +- `UpdateChannel` now defaults to match the installed build (alpha installs track the alpha channel) unless explicitly chosen, and `disableWebInstaller` is set to silence the electron-updater warning. + +### Start at Boot +- Refactored the Linux "start with system" implementation (follow-up to the #75 boot feature). Electron's native login-item API is Windows/macOS only, so Linux now gets a proper freedesktop autostart entry in `~/.config/autostart/`, resolved against the stable AppImage path rather than its temporary mount point. The entry also carries `TryExec` and `Icon`, and escapes literal `%` correctly. +- Fixed "start minimized" doing nothing visible on Linux. The window was being minimized before it had ever been mapped, which leaves no taskbar button at all; it is now shown first and minimized once ready. +- "Start minimized" now applies only to sessions the system auto-launched. Launching the app yourself always shows the window — previously the setting also hid manual launches, which looked like the app failing to start. +- Added a system tray icon (Show / Quit) as a reliable way back to the window, since some desktop environments — Wayland sessions in particular — ignore requests from an app to minimize itself. + +### Linux Packaging +- Fixed the `.deb` and `.rpm` packages not installing an AppArmor profile. Ubuntu 23.10+ blocks unprivileged user namespaces, and without the profile Chromium has no usable sandbox and the app does not start at all on Ubuntu 24.04+. The custom post-install script had been silently replacing electron-builder's own template, which also dropped the `chrome-sandbox` permission handling. +- The AppImage now detects when it has no usable sandbox and falls back to `--no-sandbox` so it still launches on Ubuntu 24.04+. The `.deb`/`.rpm` packages are unaffected and remain fully sandboxed; on Ubuntu 24.04+ they are the recommended download. +- Documented Linux autostart behavior, the sandbox situation, and headless start-at-boot via a systemd user unit in the user guide. + +### Documentation +- Moved the project documentation from `docs/README.md` into the GitHub wiki. The in-repo copy was removed, and the references that pointed at it (the top-level README, the headless-WebUI guide, the version-bump command, and CLAUDE.md) were updated to the wiki. + +### Dependencies +- Patched all eight open Dependabot security advisories (#190–#199): bumped `js-yaml` and `tar`, and added overrides for `shell-quote` (critical), `form-data`, and `esbuild`; bumped `vite`, `tsx`, and `@babel/core`. No application source changes. +- Bumped `@ghosttypes/ff-api` to `^1.7.1` for proper loopback-address printer discovery, and for the Material Station capability fix: `FFMachineInfo.HasMatlStation` was a raw copy of an AD5X-only `/detail` field that the Creator 5 series never sends, so it arrived `undefined` on a Creator 5 Pro with four loaded slots. The desktop app gates the material station on `MatlStationInfo` rather than that flag, so nothing here was affected, but 1.7.0 should not ship in a new build. + +## [1.0.5-alpha.8] - 2026-07-05 + +### WebUI +- The file dialog (Recent/Local) now shows a lazily loaded thumbnail preview for each job, with a "No Preview" fallback. Previews reuse the same on-disk thumbnail cache as the desktop job picker (`userData/Thumbnails/{serial}/`), so they are shared between the desktop and web UIs and appear instantly on re-open. +- Fixed the TVOC air-quality reading in the file/filtration card: it now displays on every printer that reports it (e.g. the Adventurer 5M Pro) instead of only the Creator 5 Pro. The Creator 5 Pro now shows the filtration card with its controls disabled rather than hiding it entirely. + +## [1.0.5-alpha.7] - 2026-07-04 + +### Start at Boot +- New **Start with system** and **Start minimized** preferences (#75). The app can register itself as an OS login item (hidden on macOS, `--hidden` launch on Windows/Linux) and boot straight to the tray/minimized. Both toggles live in Settings and re-apply live when changed; dev builds honor the toggles without touching OS login-item settings. + +## [1.0.5-alpha.6] - 2026-06-28 + +### Creator 5 / Creator 5 Pro +- The per-tool **Off** button now actually turns the tool head off. The Creator 5 firmware only treats `0` as "off" inside the per-nozzle command and ignored the `-100` sentinel the cancel path was sending (`@ghosttypes/ff-api` 1.6.1). +- Tool heads are now displayed as **T1–T4** to match the printer's own UI (the wire protocol stays 0-based). + +## [1.0.5-alpha.5] - 2026-06-28 + +### Creator 5 / Creator 5 Pro +- Fixed chamber and per-tool nozzle temperature control: the popped-out temperature card's chamber/tool buttons were silently blocked by the component-dialog IPC allowlist and sent no request (only the bed worked). +- Filtration controls now gate on the backend capability flag instead of the unreliable `/product` fan heuristic, so filtration is enabled on the Creator 5 Pro and hidden on the plain Creator 5. +- The dashboard now reliably swaps the generic temperature card for the unified Creator 5 temperature card on connect, keyed off the authoritative USB product ID; existing testers' layouts migrate automatically on load. + +## [1.0.5-alpha.4] - 2026-06-26 + +### Creator 5 / Creator 5 Pro +- Reworked Creator 5 backend and fixed print uploads +- Added a unified temperature card showing all four tool nozzles alongside the bed and chamber in one place. +- Chamber temperature can now be set and cleared on the Creator 5 / 5 Pro. +- Updated to `@ghosttypes/ff-api` 1.6.0 + +## [1.0.5-alpha.3] - 2026-06-25 + +### Creator 5 / Creator 5 Pro +- Fixed a bug causing the Creator 5 / 5 Pro to be treated as a legacy printer during discovery, which prevented it from connecting. + +## [1.0.5-alpha.2] - 2026-06-24 + +Follow-up to the first Creator 5 alpha. The headline fix is that the Creator 5 / 5 Pro now actually connect — alpha.1 couldn't reach them because it assumed a legacy TCP channel these printers don't have. + +### Creator 5 / Creator 5 Pro +- Fixed the connection: the Creator 5 and 5 Pro are HTTP-only (no legacy TCP server on port 8899), so they're now detected by USB product ID and connect over HTTP alone instead of failing on the dead TCP probe +- Temperature control now routes over HTTP on these printers, so setting and clearing nozzle/bed temps works without a TCP client +- Pause, resume, cancel, and status no longer error out when there's no TCP fallback +- Added the Creator 5's own filament palette (24 colors, 21 materials), which differs from the AD5X — slot colors now match what the printer shows instead of snapping to the nearest AD5X color +- Updated to `@ghosttypes/ff-api` 1.3.6, which allows per-slot material/color editing on the Creator 5 + +### Material Station +- Renamed the `ifs-station` grid component to `material-station` now that it serves the Creator 5 as well as the AD5X; existing saved layouts migrate automatically + +## [1.0.5-alpha.1] - 2026-06-24 + +First alpha of the 1.0.5 cycle, adding initial Creator 5 / Creator 5 Pro support. + +### Creator 5 / Creator 5 Pro +- New `Creator5Backend` routes the Creator 5 and Creator 5 Pro to a dedicated backend that reuses the AD5X 4-slot material station and dual HTTP + TCP transport, with the Creator 5's per-tool nozzle temperatures, built-in camera, and native print-start material matching +- New **Tool Temperatures** grid component shows all four nozzle temperatures; add it from the component picker +- Creator 5 Pro capability gating: filtration / aux fan and the real door sensor are enabled for the Pro; the plain Creator 5 correctly hides the (sensorless) door status +- Built on `@ghosttypes/ff-api` 1.3.4, which adds Creator 5 detail parsing, per-tool temps, and the native `/printGcode` start-job command + ## [1.0.4] - 2026-05-31 The 1.0.4 stable release consolidates the 1.0.4-alpha cycle: a major new Calibration Assistant, a complete camera-streaming rework on go2rtc, a full security hardening pass, and a modern testing and tooling foundation. @@ -877,7 +1161,8 @@ The 1.0.4 stable release consolidates the 1.0.4-alpha cycle: a major new Calibra ### Changed - README.md - Added download link to v1.0.0 release and feature comparison table (fcd8750) -[Unreleased]: https://github.com/Parallel-7/FlashForgeUI-Electron/compare/v1.0.4...alpha +[Unreleased]: https://github.com/Parallel-7/FlashForgeUI-Electron/compare/v1.0.5...alpha +[1.0.5]: https://github.com/Parallel-7/FlashForgeUI-Electron/compare/v1.0.4...v1.0.5 [1.0.4]: https://github.com/Parallel-7/FlashForgeUI-Electron/compare/v1.0.4-alpha.4...v1.0.4 [1.0.4-alpha.4]: https://github.com/Parallel-7/FlashForgeUI-Electron/compare/v1.0.4-alpha.3...v1.0.4-alpha.4 [1.0.4-alpha.3]: https://github.com/Parallel-7/FlashForgeUI-Electron/compare/v1.0.4-alpha.2...v1.0.4-alpha.3 diff --git a/CLAUDE.md b/CLAUDE.md index e32fdbc5..ac0fad96 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # FlashForgeUI-Electron Development Guide -**Last Updated:** 2026-03-06 17:54 ET (America/New_York) +**Last Updated:** 2026-08-10 19:50 ET (America/New_York) This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. @@ -22,7 +22,7 @@ The information in this file directly influences how Claude Code understands and ## Project Overview -FlashForgeUI is an Electron-based desktop and headless controller for FlashForge printers. It supports multi-context printing, material station workflows, Spoolman-powered filament tracking, go2rtc-based camera streaming (WebRTC/MSE), Discord + desktop notifications, and a fully authenticated WebUI. The app runs on Windows/macOS/Linux with both GUI and headless entry points (headless automatically boots the WebUI server). +FlashForgeUI is an Electron-based desktop and headless controller for FlashForge printers. It supports multi-context printing, material station workflows (AD5X and Creator 5 / Creator 5 Pro), Spoolman-powered filament tracking, go2rtc-based camera streaming (WebRTC/MSE), Discord + desktop notifications, and a fully authenticated WebUI. The app runs on Windows/macOS/Linux with both GUI and headless entry points (headless automatically boots the WebUI server). SSH/SFTP features (the SFTP file manager and the calibration assistant) target the Adventurer 5M / 5M Pro / AD5X and rely on root SSH/SFTP provisioned by the sibling **FlashForge-EasySSH** USB tool (defaulting to seamless `root`/`flashforge` credentials); Creator 5 / Creator 5 Pro SSH features are planned but not yet implemented. See the *Companion Project* section below. --- @@ -38,6 +38,7 @@ For detailed architectural information, see the comprehensive reference document - **[INTEGRATIONS.md](ai_docs/INTEGRATIONS.md)** - Camera streaming, Spoolman, notifications, Discord, persistence - **[THEME_SYSTEM.md](ai_docs/THEME_SYSTEM.md)** - CSS variables, theme computation, design patterns, hardcoded CSS detection - **[TOOLING.md](ai_docs/TOOLING.md)** - Development tools, commands, testing constraints, code search MCP tools +- **[docs/TESTING.md](docs/TESTING.md)** - Test surfaces, the emulator/hardware E2E tracks, CI pipeline, known coverage gaps --- @@ -57,7 +58,7 @@ For detailed architectural information, see the comprehensive reference document - **Documentation**: every repo-maintained `.ts`, `.tsx`, `.js`, `.jsx`, and `.cjs` source file should begin with an `@fileoverview` block describing purpose, key exports, and relationships. Use `pnpm docs:check` to catch gaps under `src/` and `pnpm docs:combine` to refresh `fileoverview-report.md`. -- **Testing workflow**: `pnpm test` runs Jest over `src/`, `pnpm test:e2e` runs browser Playwright against the built WebUI fixture server, and `pnpm test:e2e:electron*` covers desktop Playwright flows. `test:all` currently excludes the Electron suites, so run the Electron slice explicitly when desktop behavior changes. +- **Testing workflow**: **ALWAYS read [docs/TESTING.md](docs/TESTING.md) before writing, changing, or reasoning about any test.** It is the authoritative description of every test surface, the two Electron E2E tracks (`emulator` vs `hardware`, selected by `FFUI_E2E_TRACK`), what each spec actually proves, the CI pipeline, and the known coverage gaps. In brief: `pnpm test` runs Jest over `src/`, `pnpm test:e2e` runs browser Playwright against the built WebUI fixture server, `pnpm test:e2e:electron:emulator` runs the CI-safe Electron track against `flashforge-emulator-v2`, and `pnpm test:e2e:electron:hardware` runs the same specs against real bench printers. `test:all` excludes the Electron suites, so run the Electron slice explicitly when desktop behavior changes. - **Validation**: run the smallest meaningful checks (`pnpm type-check`, `pnpm lint`, targeted scripts) before handing work back. Reserve `pnpm build*` for user requests or when architectural changes demand it. @@ -102,7 +103,29 @@ For detailed architectural information, see the comprehensive reference document 12. **WebUI cache regressions**: built WebUI assets must remain version-stamped and served with no-cache headers. The browser Playwright suite exists specifically to catch stale asset mixes, icon hydration mismatches, and camera bootstrap regressions before release. -13. **Desktop E2E boundaries**: use `tests/e2e/electron/desktop-smoke.spec.ts` for live `%APPDATA%` smoke coverage and `tests/e2e/electron/desktop-emulator.spec.ts` for isolated emulator-backed lifecycle coverage. On Windows, prefer the dedicated `package.json` scripts over ad hoc Playwright grep invocations. +13. **Desktop E2E is two tracks over one set of specs**: `tests/e2e/electron/specs/*.spec.ts` run against either the emulator or real hardware depending on `FFUI_E2E_TRACK`; specs branch on target *capabilities* (`hasMaterialStation`, `supportsSftp`, `supportsManualConnect`), never on the track name. Don't fork a spec per track. Prefer the dedicated `package.json` scripts over ad hoc Playwright invocations. See [docs/TESTING.md](docs/TESTING.md). + +14. **Release builds are unsigned (no code-signing cert)**: there is no Windows (or macOS) code-signing certificate, so every release artifact - both CI and local `pnpm build:win` - is `NotSigned`. The `signtool.exe` lines in the local build log run but produce no valid Authenticode signature. This is expected, not a regression; Windows users get the normal SmartScreen "unknown publisher" prompt. Don't chase a "missing signature" as a bug. + +15. **Release channel is derived from the `package.json` version**: `electron-builder-config.cjs` parses the prerelease suffix - a `-alpha.N` version yields channel `alpha`/`alpha.yml` (prerelease), a bare `X.Y.Z` yields channel `latest`/`latest.yml` (stable). Bumping the version is all that's needed to flip the channel; don't hardcode it. The release workflow (`.github/workflows/release.yml`, `workflow_dispatch`) reads notes from the matching `## [] - ` CHANGELOG section, and `softprops/action-gh-release` keyed on the tag will publish an existing **draft** of that tag *keeping the draft's body* (it does not overwrite with the templated notes) - so don't pre-create a draft if you want the workflow's templated notes. + +16. **WebUI camera streams through an authenticated proxy (single-port)**: browsers must NEVER connect directly to go2rtc's unauthenticated API port (1984). `CameraStreamProxy.ts` (`src/main/webui/server/`) tunnels streams through the authenticated WebUI server at `/api/camera/ws?src=&token=` on the WebUI port; the token is validated against `AuthManager`. For remote/WebUI access, forward the single WebUI port only — do not expose 1984. See the [Auto Start](https://github.com/Parallel-7/FlashForgeUI-Electron/wiki/Auto-Start) wiki page and issue #76. + +17. **Single widget-creation path in `gridController.ts`**: do not duplicate grid-widget factories. The reload/context-switch and palette-add paths must route through the shared `createGridWidget()` + `createComponentForGrid()` primitives used by initial load. A divergent factory omits component cases (material-station, tool-temps, creator5-temperature) and produces blank tiles and missing edit-mode delete (X) buttons. The stale duplicate `createComponentInstance()` was removed in the fix for issue #77. + +18. **Component-dialog preload must mirror the main preload's `material` IPC surface**: the material-station widget renders inside a separate dialog window that loads `component-dialog-preload.ts`. If that preload lacks the `material` namespace (`material:set-slot`, `material:configure-slot`, `spoolman.openSpoolSelection`) and the `onSpoolPickedForSlot` listener, setting material type/color fails with "Material control is not available". Keep the two preloads in sync when adding material/Spoolman IPC channels. + +19. **Commit messages reference issues without auto-closing keywords**: when referencing a GitHub issue, omit `Fixes`/`Closes`/`Resolves #N` (these auto-close the issue on merge). The issue number may appear in the title or body — only the auto-close verbs are forbidden. + +20. **Component availability is centralized**: `ComponentDefinition.availability` ('grid' | 'shortcut' | 'both', default 'both') in `src/shared/component-definitions.ts` controls where a component surfaces. Grid surfaces (palette handlers, widget factories) must consume `getGridComponentDefinitions()` / `isGridComponent()`; the topbar shortcut system consumes `getShortcutComponentDefinitions()` / `isShortcutComponent()`. Shortcut-only entries that open a dedicated window declare `shortcutOpenChannel` (e.g. the file manager's `file-manager:open`) — `shortcutButtons.ts` sends that channel instead of `component-dialog:open`. Never enumerate `COMPONENT_REGISTRY_DATA` directly from a surface-specific consumer. + +21. **File manager is SFTP-based and shortcut-only**: `FileManagerService` (`src/main/services/FileManagerService.ts`) manages printer files over SSH/SFTP (easyssh provisioning, default root/flashforge, per-printer calibration SSH credentials honored). AD5X files live in `/usr/data/gcodes` (thumbs in `thum/` + `3mf/thum/`, USB under `/mnt`); 5M-series files in `/data` (thumbs in `/data/uploadThumbnail`, USB under `/mnt/usb`). Thumbnails resolve local ThumbnailCacheService → printer cache PNGs → ranged-read extraction of `Metadata/plate_1_small.png` from `.3mf` zips (no full-file download). USB presence is probed via `/proc/mounts`, not directory listings. + +22. **Centralized SSH settings + FlashForge-EasySSH provisioning**: SSH/SFTP features (file manager + calibration assistant) resolve credentials from one centralized per-printer store, `SSHSettingsService` (`src/main/services/SSHSettingsService.ts`, keyed by serial number, persisted to `ssh-settings.json` with passwords encrypted via `SecureStorage`) — NOT from `PrinterCalibrationData`. Defaults assume the user provisioned root SSH/SFTP with the sibling **FlashForge-EasySSH** USB tool (`root`/`flashforge`, port 22); non-default creds are edited once in Settings → SSH (desktop-only; the WebUI has no SSH-edit UI yet). The file manager and calibration share the store but use separate connection keys (`file-manager:` vs calibration keys) on the shared `SSHConnectionManager`. The legacy `sshHost`/`sshPort`/`sshUsername`/`sshPassword`/`sshKeyPath` fields on `PrinterCalibrationData` are now dead code slated for removal. + +23. **The wall-clock ETA is gated on `isPrintAdvancing`, not on `isActive`**: `isPrintAdvancing` (`src/shared/types/polling.ts`) covers `Printing` and nothing else, and every surface that converts a remaining duration into a clock time must gate on it — the desktop job stats, the WebUI panel (`src/main/webui/static/ui/panels.ts`), the browser-side mirror in `src/main/webui/static/shared/formatting.ts`, and the Discord embed. `isActive` is the wrong predicate here because it deliberately includes `Paused`. The reason: `now + remaining` only holds still while the firmware counts `estimatedTime` down, and it freezes that field the moment the print stops advancing — so the ETA stepped forward a minute every minute and receded for as long as a pause lasted. **`Heating` is excluded along with the paused states**: the pre-print warmup does not advance the job either and drifts identically, just for minutes rather than hours. Duration readouts are unaffected and need no gate. This went unnoticed for so long because the desktop shows the clock time with no duration beside it, so nothing on screen contradicted it. The same predicate exists in FlashForgeWebUI and in both API libraries — a change to the rule belongs in all of them. + +24. **`@ghosttypes/ff-api` is pinned at `^2.0.0`** (since 1.0.5-alpha.11). The 2.0.0 major was one breaking change — `FFMachineInfo.CompletionTime` became `Date | null` — which this app is immune to because it never reads that field; it derives its own ETA from the remaining duration. 2.0.0 is also what brings the Creator 5 Pro `"pause"` status mapping, so a paused C5 Pro no longer reads as `Unknown`. Note the library publishes to **GitHub Packages**, not public npm: `.npmrc` needs the `@ghosttypes:registry` line and a token, which CI writes from `secrets.GITHUB_TOKEN`. --- @@ -122,26 +145,46 @@ For detailed architectural information, see the comprehensive reference document ### Backends & Printers -- `src/main/printer-backends/*.ts` - Legacy, Adventurer5M, Adventurer5M Pro, AD5X implementations +- `src/main/printer-backends/*.ts` - Legacy, Adventurer5M, Adventurer5M Pro, AD5X, Creator 5 / Creator 5 Pro implementations +- `src/main/printer-backends/MaterialStationBackend.ts` - shared abstract base for the 4-slot material-station printers; AD5X and Creator 5 both extend it - `src/main/printer-backends/ad5x/*` - material station transforms/types/utils -### Model Detection (TCP-First Bootstrap, PID-Aware) +### Model Detection (Discovery-PID-First, TCP Fallback) -- **Bootstrap order matters.** The HTTP `/detail` endpoint requires authentication (`serialNumber` + `checkCode`), so during the very first connection — before the user has supplied a check code — we cannot read the firmware-set `pid` from `/detail`. `ConnectionEstablishmentService.ts` therefore opens an unauthenticated TCP `M115` first via `tcpClient.getPrinterInfo()` and uses the resulting `TypeName` (firmware-controlled, e.g. `"FlashForge Adventurer 5M Pro"`) for backend selection in `PrinterUtils.ts` (`detectPrinterModelType` / `detectPrinterFamily`). This is correct and intentional — `TypeName` is firmware-set and not the same as the user-mutable `Name` from `/detail`. +- **Modern printers are typed from the discovery broadcast, not a TCP probe.** The UDP discovery response (276-byte modern packet) carries the USB product ID at `0x88`, the serial number at `0x92` and the name at `0x00`. `ConnectionEstablishmentService.createTemporaryConnection()` short-circuits on any product ID in `NEW_API_PRODUCT_IDS` (35 = 5M, 36 = 5M Pro, 38 = AD5X, 40 = Creator 5, 41 = Creator 5 Pro) and synthesizes the type info from that packet — **no TCP probe runs for any modern model**. This is safe because `establishDualAPIConnection` consumes none of the probe's output, capability flags come from `FiveMClient.initialize()` post-init, and reachability is covered by `initialize()` hitting authenticated `/detail`. (Verified against real hardware: a 5M Pro and an AD5X both return their serial at `0x92` and a correct PID at `0x88`.) +- **The TCP probe remains the fallback for anything with no product ID.** Genuine legacy printers, and manual/headless connects that named no model, still open an unauthenticated TCP `M115` via `tcpClient.getPrinterInfo()`; the resulting `TypeName` (firmware-controlled, e.g. `"FlashForge Adventurer 5M Pro"`) drives backend selection in `PrinterUtils.ts` (`detectPrinterModelType` / `detectPrinterFamily`). `TypeName` is firmware-set and not the same as the user-mutable `Name` from `/detail`. +- **Manual connects supply what the broadcast would have.** Because a named model skips the probe, the manual connect dialog (`src/renderer/src/ui/manual-connect-dialog/`, shown from the connect-choice "Enter IP" branch) collects IP + printer type + serial + check code, and `ConnectionFlowManager.connectDirectlyToIP()` maps the selected type to a product ID hint. Selecting `Legacy Printer` sends no product ID and is probed as before; `connectDirectlyToIP` still accepts a bare IP string for that path. - **Once paired, trust the library.** After the check code is provided and `FiveMClient.initialize()` succeeds, `client.isPro` / `client.isAD5X` / `info.Pid` (from `@ghosttypes/ff-api>=1.3.1`) are derived from the firmware `pid` (35 = 5M, 36 = 5M Pro, 38 = AD5X). All later capability gating should read those flags, not re-substring-match `info.Name` — that field is user-mutable via the LCD or cloud and re-deriving capabilities from it re-introduces the bug fixed in `ff-5mp-hass#13`. - **Don't mutate `client.isAD5X` after the fact.** If the upstream library disagrees with what you expected, fix the library or the backend selection upstream — don't patch the flag locally. +- **HTTP-only models (Creator 5 / Creator 5 Pro) bypass TCP.** These printers expose no legacy TCP server on port 8899. `ConnectionEstablishmentService.ts` checks `isHttpOnlyModel()` and connects them over HTTP directly — even if a stale `forceLegacyMode` flag is saved, the bad flag is healed on the next successful HTTP connect. Backend routing for these uses the authoritative USB product ID carried through discovery, not the post-auth `/detail` endpoint. ### Renderer & Components - `src/renderer/src/renderer.ts`, `src/renderer/src/gridController.ts`, `src/renderer/src/shortcutButtons.ts`, `src/renderer/src/perPrinterStorage.ts`, `src/renderer/src/logging.ts` -- `src/renderer/src/ui/components/**` (ComponentManager, printer tabs, job info, etc.) + `src/renderer/src/ui/gridstack/**` for layout/palette logic +- `src/renderer/src/ui/components/**` (ComponentManager, printer tabs, job info, the `creator5-temperature` unified tool/bed/chamber card, etc.) + `src/renderer/src/ui/gridstack/**` for layout/palette logic - `src/renderer/src/ui/component-dialog/**` - component dialog renderer + preload mirrors +- The material-station grid component is named `material-station` (renamed from `ifs-station`; saved layouts auto-migrate) and serves both AD5X and Creator 5. Filament palettes are per-printer-family (AD5X vs Creator 5) and snap Spoolman colors to the nearest swatch via CIEDE2000 color distance. ### IPC & Windows - `src/main/ipc/handlers/index.ts` + domain handlers in `src/main/ipc/handlers/*.ts`, `camera-ipc-handler.ts`, `printer-context-handlers.ts`, `WindowControlHandlers.ts`, `DialogHandlers.ts` - `src/main/windows/WindowManager.ts`, `src/main/windows/WindowFactory.ts`, `src/main/windows/factories/*`, `src/main/windows/dialogs/*` +### SSH & File Access (SFTP) + +Both the file manager and the calibration assistant operate over root SSH/SFTP provisioned by the sibling **FlashForge-EasySSH** USB tool (default `root`/`flashforge`, port 22). Credentials resolve from one centralized per-printer store, not per-feature. + +- `src/main/services/SSHSettingsService.ts` - centralized per-printer SSH credential store (keyed by serial, `ssh-settings.json` + `SecureStorage`; defaults to easyssh `root`/`flashforge`); consumed by both file manager and calibration +- `src/main/ipc/handlers/ssh-settings-handlers.ts` - `ssh-settings:get/update/reset` IPC surface +- `src/renderer/src/ui/settings/sections/SSHSection.ts` - Settings → SSH tab (desktop-only editing; WebUI currently has no SSH-edit UI) +- `src/shared/types/ssh-settings.ts` - SSH settings types + defaults +- `src/main/services/FileManagerService.ts` - SFTP listing/delete/rename/thumbnails (pooled via SSHConnectionManager under `file-manager:` keys, separate from calibration keys) +- `src/main/ipc/handlers/file-manager-handlers.ts` - `file-manager:*` IPC surface (pinned to the context active when the dialog opened) +- `src/renderer/src/ui/file-manager/*` - dialog HTML/CSS/preload/renderer (Internal + USB tabs, multi-select, confirm overlays) +- `src/shared/types/file-manager.ts` - shared IPC payload types; opened only via the topbar shortcut (`availability: 'shortcut'` registry entry) + +Currently targets AD5X + Adventurer 5M / 5M Pro only (Creator 5 / Creator 5 Pro are NOT yet supported — `FileManagerService.isModelSupported()` returns false for them). + ### Settings Dialog - `src/renderer/src/ui/settings/settings-renderer.ts` - main orchestrator for dual settings management (global + per-printer) @@ -153,6 +196,7 @@ For detailed architectural information, see the comprehensive reference document - `src/main/services/Go2rtcService.ts`, `Go2rtcBinaryManager.ts` - `src/main/ipc/camera-ipc-handler.ts`, `src/main/webui/server/routes/camera-routes.ts` +- `src/main/webui/server/CameraStreamProxy.ts` - authenticated WebSocket proxy that tunnels browser streams through the WebUI port (no direct go2rtc exposure) - `src/main/services/notifications/*`, `src/main/services/discord/DiscordNotificationService.ts` ### Spoolman & Filament @@ -165,17 +209,21 @@ For detailed architectural information, see the comprehensive reference document - `src/main/utils/HeadlessArguments.ts`, `HeadlessDetection.ts`, `HeadlessLogger.ts`, `src/main/managers/HeadlessManager.ts` - `src/main/webui/server/*` (WebUIManager, AuthManager, WebSocketManager, route modules) + `src/main/webui/static/*` (AppState, Transport, features, grid) -- `docs/README.md` - user-facing headless instructions (keep updated) +- **[Headless Mode](https://github.com/Parallel-7/FlashForgeUI-Electron/wiki/Headless-Mode)** wiki page - user-facing headless instructions (keep updated) ### Testing & Automation +**Authoritative reference: [docs/TESTING.md](docs/TESTING.md)** - read it before touching any test. + - `src/**/__tests__/*` - Jest coverage for managers, services, calibration, WebUI server/routes, WebUI static helpers, and build utilities - `tests/e2e/browser/webui-smoke.spec.ts`, `tests/e2e/browser/webui-auth.spec.ts`, `tests/e2e/browser/helpers/webui-fixture-server.ts` - browser Playwright coverage for the built WebUI -- `tests/e2e/electron/desktop-smoke.spec.ts` - live desktop smoke test against the local FlashForgeUI profile -- `tests/e2e/electron/desktop-emulator.spec.ts`, `tests/e2e/electron/helpers/emulator-harness.ts` - emulator-backed Electron lifecycle coverage across modern and legacy printers -- `tests/fixtures/calibration/` - synthetic calibration fixture data (not referenced by automated tests; used for manual local testing) -- `scripts/run-playwright-electron-live.cjs`, `scripts/run-playwright-electron-emulator.cjs` - entry points for the Electron Playwright suites -- `package.json` - canonical place for the Electron slice scripts (`test:e2e:electron:emulator`, `:legacy`, `:legacy-multi`, `:modern-multi`, `:smoke`, `:live`) +- `tests/e2e/electron/specs/*.spec.ts` - connect, upload, LED, multi-printer, and SFTP round-trip coverage; each runs on whichever track `FFUI_E2E_TRACK` selects +- `tests/e2e/electron/support/track.ts` - track selection and target enumeration; `printer-target.ts` - the emulator/hardware abstraction; `hardware-config.ts` - out-of-tree credential resolution + discovery-by-serial +- `tests/e2e/electron/helpers/emulator-harness.ts` - boots/tears down `flashforge-emulator-v2` instances (process-group kill on POSIX; see docs/TESTING.md before changing teardown) +- `tests/e2e/electron/discord-hardware.spec.ts` - hardware-only Discord webhook coverage, deliberately outside `specs/` so the emulator runner skips it +- `tests/fixtures/print-files/` - real slicer output driving the upload specs; `tests/fixtures/calibration/` - synthetic calibration data for manual local testing +- `scripts/run-playwright-electron-{emulator,hardware,discord}.cjs` - entry points for the Electron Playwright tracks +- `.github/workflows/ci.yml` - `verify` (type-check, lint, build, Jest) + `e2e-emulator` (emulator track under xvfb) --- @@ -189,11 +237,23 @@ For detailed architectural information, see the comprehensive reference document ### Other Documentation - **`GEMINI.md`, `QWEN.md`**: Sibling agent guides for cross-AI alignment -- **`docs/README.md`**: User-facing setup + headless instructions (update alongside feature changes) +- **[Wiki](https://github.com/Parallel-7/FlashForgeUI-Electron/wiki)**: User-facing setup + headless instructions (update alongside feature changes) - **`ai_specs/*`**: Authoritative specs for in-flight features; always review before touching scoped areas - **`ai_specs/CAMERA_PRIORITY_SPEC.md`**: Camera proxy + RTSP behavior specification - **`ai_specs/webui-push-notifications.md`**: Upcoming WebUI push feature plan +### Companion Project: FlashForge-EasySSH + +**FlashForge-EasySSH** ([github.com/Parallel-7/FlashForge-EasySSH](https://github.com/Parallel-7/FlashForge-EasySSH), locally `../flashforge-easyssh`) is the upstream sibling tool that provisions the root SSH/SFTP surface FlashForgeUI's file manager and calibration assistant consume. It is a USB-boot provisioning script (`flashforge_init.sh`) — no disassembly, no serial cable, no firmware hack — that auto-runs at printer power-on and configures SSH + SFTP on **Adventurer 5M / 5M Pro / AD5X only** (Creator 5 / Creator 5 Pro use a different method and are out of scope). + +- **Supported models:** 5M / 5M Pro ship *no sshd* — EasySSH bundles a static OpenSSH (`sshd`/`ssh-keygen`/`scp`) + init script. AD5X ships Dropbear SSH but *no `sftp-server`* — EasySSH stages a static MIPS `sftp-server` via a `/usr/libexec` bind-mount + a one-line idempotent, marker-guarded boot hook in `/usr/prog/app_startup.sh` (the writable boot orchestrator; init.d scripts run before the `/etc` overlay mounts and never execute). +- **Default credentials:** `root` / `flashforge` (salt `ffuiroot`); 5M uses MD5-crypt (`$1$`), AD5X uses SHA-256-crypt (`$5$`). These are the exact defaults `SSHSettingsService` assumes. +- **AD5X firmware-update caveat:** a FlashForge firmware update may overwrite `/usr/prog` and drop the SFTP boot hook — SSH keeps working, only SFTP breaks; re-running the USB stick restores the hook. The staged `sftp-server` on `/usr/data` survives regardless. +- **No uninstall path:** reverting requires reflashing printer firmware. +- **Canonical file paths** the file manager reads match EasySSH's docs: AD5X gcode in `/usr/data/gcodes` (thumbs `thum/` + `3mf/thum/`), 5M-series gcode in `/data` (thumbs `/data/uploadThumbnail`). + +The two projects are complementary: EasySSH provisions the printer's SSH/SFTP surface and is the provenance of the `root`/`flashforge` defaults; FlashForgeUI consumes it. Keep this guide's file-path and credential references in sync with EasySSH's `README.md` when either changes. + ### Code Inventory - **`fileoverview-report.md`** (repo root): Aggregates every `@fileoverview` block across `src/**/*.{ts,tsx,js,jsx}`. Use it to understand module responsibilities quickly before editing and regenerate it with `pnpm docs:combine` after large doc/header refreshes. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..39e82a0e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,249 @@ +# Contributing to FlashForgeUI + +Thanks for your interest in improving FlashForgeUI! This guide covers how the project is +organized, how to set up a development environment, the standards we hold code to, and +how changes travel from a pull request to a stable release. + +If you are reporting a bug or requesting a feature rather than writing code, please +[open an issue](https://github.com/Parallel-7/FlashForgeUI-Electron/issues). Include your +printer model, firmware version, operating system, and FlashForgeUI version. + +--- + +## TL;DR + +1. **Work from `alpha`, never from `main`.** Branch from `alpha` and open your pull + request against `alpha`. +2. Run `pnpm type-check`, `pnpm lint`, `pnpm build`, and `pnpm test` before opening the PR. + CI runs the same checks. +3. Add a line under `## [Unreleased]` in `CHANGELOG.md` for anything a user would notice. + +--- + +## Branches and the Release Cycle + +| Branch | Purpose | +| --- | --- | +| `alpha` | **Active development.** All work lands here first, and alpha pre-releases are cut from here. | +| `main` | **Stable releases only.** It only changes when a finished alpha cycle is promoted. The README on `main` is what users see. | +| `feature/*`, `fix/*`, … | Your working branches. Create them from `alpha` and merge them back into `alpha`. | + +Pull requests that target `main` will be asked to retarget `alpha`. The only PRs +that go into `main` are the maintainers' release PRs. + +### How a change becomes a release + +``` + your branch ──PR──▶ alpha ──▶ 1.0.6-alpha.1 ──▶ 1.0.6-alpha.2 ──▶ … (pre-releases) + │ + └──PR (alpha → main)──▶ main ──▶ 1.0.6 (stable release) + │ + alpha ◀── sync main back ───────────┘ + └──▶ 1.0.7-alpha.1 ──▶ … (next cycle starts) +``` + +1. **Development.** Contributions are merged into `alpha`. +2. **Alpha releases.** When enough has landed, a maintainer cuts an alpha pre-release + (`X.Y.Z-alpha.N`) from `alpha`. Alpha users get it through the in-app updater. +3. **Stable release.** When an alpha cycle is ready, `alpha` is PR'd into `main` and + merged, and the stable `X.Y.Z` release is cut from `main`. +4. **Next cycle.** `main` is synced back into `alpha`, and the next alpha **bumps the + patch number**: `X.Y.(Z+1)-alpha.1`. + +> **Why the bump matters:** semver ranks `1.0.5` above every `1.0.5-alpha.N`. Publishing +> `1.0.5-alpha.14` after the `1.0.5` stable would look *older* to the updater and break +> alpha updates. After a stable release, the next alpha is always `1.0.6-alpha.1`. + +--- + +## Development Setup + +### Prerequisites + +| Tool | Version | Notes | +| --- | --- | --- | +| [Node.js](https://nodejs.org/) | 22.x | Matches the Node runtime bundled with Electron 39 and the version CI uses | +| [pnpm](https://pnpm.io/) | 10.x | Run `corepack enable` to use the exact version pinned in `package.json`. Don't use npm or yarn: the lockfile is `pnpm-lock.yaml` | +| Git | any recent | | +| [Go](https://go.dev/) | optional | Only needed for the documentation helper scripts (`pnpm docs:check`, `pnpm docs:combine`) | + +Every dependency, including the `@ghosttypes/ff-api` and `@parallel-7/slicer-meta` +printer libraries, installs from the public npm registry. You don't need a token or an +`.npmrc`. + +### Getting the code + +```bash +git clone https://github.com/Parallel-7/FlashForgeUI-Electron.git +cd FlashForgeUI-Electron +git checkout alpha +git checkout -b feature/my-change + +pnpm install # also downloads the bundled go2rtc camera binary +pnpm dev # launches the app with hot reload +``` + +If you fork the repository, keep your fork's `alpha` up to date with +`Parallel-7/FlashForgeUI-Electron:alpha` and branch from it. + +### Useful commands + +| Command | What it does | +| --- | --- | +| `pnpm dev` | Builds the WebUI and starts the app with hot reload | +| `pnpm build` | Builds the main process, renderer, and WebUI | +| `pnpm type-check` | Type-checks the app (`src/`) and the test tree (`tests/`) | +| `pnpm lint` / `pnpm lint:fix` | Biome lint, or lint plus format with fixes applied | +| `pnpm test` | Jest unit and integration tests | +| `pnpm test:e2e` | Browser Playwright tests for the WebUI | +| `pnpm test:e2e:electron:emulator` | Desktop end-to-end tests against the printer emulator | +| `pnpm build:win` / `build:mac` / `build:linux` | Package installers for a platform | +| `pnpm docs:check` | Lists source files missing an `@fileoverview` header (needs Go) | + +To run the app without its desktop window (the WebUI server only), see the +[Headless Mode](https://github.com/Parallel-7/FlashForgeUI-Electron/wiki/Headless-Mode) +wiki page. + +--- + +## Project Layout + +| Path | Contents | +| --- | --- | +| `src/main/` | Electron main process: printer backends, connection and polling managers, services (camera, Spoolman, notifications, SSH), IPC handlers, windows | +| `src/main/webui/` | WebUI: the Express/WebSocket server (`server/`) and the browser client (`static/`) | +| `src/preload/` | Context bridges between the main process and renderer windows | +| `src/renderer/` | Desktop UI: the GridStack dashboard, components, and dialogs | +| `src/shared/` | Types and helpers shared across processes | +| `tests/e2e/` | Playwright suites (browser WebUI + Electron) | +| `docs/TESTING.md` | The authoritative guide to every test surface | + +`ai_docs/` has deeper architecture notes. Start with `ARCHITECTURE.md` and +`MULTI_CONTEXT.md`. + +--- + +## Code Standards + +- **TypeScript everywhere**, strict types, no `any` without a good reason. +- **Biome** handles linting and formatting. `pnpm lint` must pass with no errors. Fix + the code rather than suppressing a rule. +- **File headers:** every source file under `src/` starts with an `@fileoverview` block. + It should say what the file is for, its key exports, and how it relates to nearby + modules. `pnpm docs:check` finds files that are missing one. +- **Never hardcode colors in CSS.** Use the theme variables (`--theme-primary`, + `--surface-elevated`, …) so light, dark, and custom themes keep working. See + `ai_docs/THEME_SYSTEM.md`. +- **One code path for desktop and headless.** Both modes share the same connection, + polling, and camera stack. Avoid branching on headless mode unless there is no other way. +- **WebUI parity.** User-facing features should work in the WebUI as well as the desktop + app where that makes sense. The WebUI code is shared with the standalone + [FlashForgeWebUI](https://github.com/Parallel-7/FlashForgeWebUI) project, so mention in + your PR if a change should be ported there too. +- **Gate features on printer capabilities, not on printer names.** The printer name is + user-editable. Use the backend's feature flags and the model detected at connect time. +- **Keep both preloads in sync.** If you add IPC channels that pop-out component dialogs + need, add them to the component-dialog preload as well as the main preload. +- **Printer protocol bugs belong upstream.** If the bug is in how the printer API is + spoken, fix it in [`ff-5mp-api-ts`](https://github.com/GhostTypes/ff-5mp-api-ts) + (`@ghosttypes/ff-api`), then bump the dependency here. Don't patch around the library. + +--- + +## Testing + +**Read [`docs/TESTING.md`](docs/TESTING.md) before you write or change a test.** It +explains what each suite actually proves and where the known gaps are. + +- **Unit / integration (`pnpm test`):** Jest tests live next to the code in `__tests__/` + folders. New logic should come with tests. +- **Desktop E2E, emulator track (`pnpm test:e2e:electron:emulator`):** drives the real + app against emulated printers (Adventurer 5M Pro, AD5X, Creator 5, Creator 5 Pro) with + no hardware needed. It expects a checkout of + [`flashforge-emulator-v2`](https://github.com/GhostTypes/flashforge-emulator-v2) next to + this repository. If yours is elsewhere, set `FF_EMULATOR_ROOT` to its path. CI runs + this track on every push and PR. +- **Desktop E2E, hardware track (`pnpm test:e2e:electron:hardware`):** the same specs + run against real printers on your network. Specs branch on printer *capabilities*, + never on which track is running. Don't fork a spec per track. +- **Real printers:** if your change touches printer communication, please test it on a + real printer if you have one, and say which model and firmware you used in the PR. + +--- + +## Commits and Pull Requests + +**Commit messages** follow [Conventional Commits](https://www.conventionalcommits.org/): + +``` +feat(webui): add job upload to the browser UI +fix(creator5): show paused state instead of Unknown +chore(deps): bump @ghosttypes/ff-api to 2.0.3 +docs: clarify headless setup +``` + +Common types: `feat`, `fix`, `refactor`, `perf`, `test`, `docs`, `ci`, `build`, `chore`. +The scope is optional but helpful (`webui`, `spoolman`, `camera`, `creator5`, …). + +**Referencing issues:** mention the issue number (`#123`) in the title or body, but +**don't** use auto-closing keywords like `Fixes #123` or `Closes #123`. Issues +are closed by hand once the fix has been confirmed in a release. + +**Before opening a PR:** + +- [ ] Branched from, and targeting, **`alpha`** +- [ ] `pnpm type-check`, `pnpm lint`, `pnpm build`, and `pnpm test` all pass +- [ ] Tests added or updated for the change +- [ ] `CHANGELOG.md` updated under `## [Unreleased]` for user-visible changes +- [ ] `@fileoverview` headers present on new files +- [ ] Printer model and firmware listed if tested on hardware +- [ ] The wiki needs an update for user-facing changes (say so in the PR) + +Keep PRs focused: one feature or fix per PR is much easier to review than a bundle. + +--- + +## Releasing (maintainers) + +Releases are built by the **Build and Release Electron App** workflow +(`.github/workflows/release.yml`). It runs on `workflow_dispatch`, builds Windows, +macOS, and Linux, tags `v`, and publishes a GitHub Release with the updater +manifests. The update channel is derived from the version: `X.Y.Z-alpha.N` publishes to +the `alpha` channel, and a bare `X.Y.Z` publishes to `latest`. + +The release notes are taken from the `CHANGELOG.md` section whose heading matches the +version exactly: `## [] - `. Without that section, the release gets +only a bare link. + +### Alpha release + +1. On `alpha`, bump `version` in `package.json` (e.g. `1.0.6-alpha.1` → `1.0.6-alpha.2`). +2. Move the `## [Unreleased]` notes into a new `## [1.0.6-alpha.2] - ` section, + and leave an empty `## [Unreleased]` above it. +3. Commit and push to `alpha`. +4. Dispatch the workflow on `alpha` with `prerelease: true`: + `gh workflow run release.yml --ref alpha -f version=1.0.6-alpha.2 -f prerelease=true` + +### Stable release + +1. On `alpha`, set `version` to the stable number (`1.0.6-alpha.N` → `1.0.6`). Add a + `## [1.0.6] - ` section that sums up the whole alpha cycle, and make sure the + README is current. +2. Make sure `alpha` contains everything on `main` (merge `main` into `alpha` if needed), + so the release PR has no conflicts. +3. Open a PR from `alpha` into `main`, let CI pass, and merge it. +4. Live-test the build on real printers. +5. Dispatch the workflow on `main` with `prerelease: false`: + `gh workflow run release.yml --ref main -f version=1.0.6 -f prerelease=false` +6. Sync `main` back into `alpha`. Then bump `alpha` to the next patch alpha + (`1.0.7-alpha.1`) with a fresh `## [Unreleased]` section. + +Don't pre-create a draft GitHub Release for the tag. The workflow publishes an existing +draft and keeps the draft's body instead of the generated notes. + +--- + +## License + +By contributing, you agree that your contributions will be licensed under the project's +MIT License. diff --git a/README.md b/README.md index 38a46499..ca5a6889 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ ![Platforms](https://img.shields.io/badge/Platforms-Win%20%7C%20macOS%20%7C%20Linux-3178c6?style=flat) ![Downloads](https://img.shields.io/github/downloads/Parallel-7/FlashForgeUI-Electron/total?style=flat&color=brightgreen) -![Version](https://img.shields.io/badge/Version-1.0.5--alpha.11-orange?style=flat) +![Version](https://img.shields.io/github/v/release/Parallel-7/FlashForgeUI-Electron?include_prereleases&style=flat&color=orange&label=Version) ![License](https://img.shields.io/badge/License-MIT-blue?style=flat) **Monitor and control your printer from your desktop or any device on your network — with more features than any official FlashForge software, completely free.** @@ -29,8 +29,6 @@ FlashForgeUI connects directly to your FlashForge printer over your local network — no cloud account required. Watch live camera and temperature data, start and manage prints, control LEDs and filtration, and keep an eye on multiple printers at once. When you're away from your desk, the built-in WebUI gives you the same control from your phone or any browser, and headless mode lets you run it as a lightweight always-on server. -It works with everything from the latest Adventurer 5M / 5M Pro / AD5X to older legacy models, automatically picking the right way to talk to each printer. -
## How It Compares @@ -57,14 +55,19 @@ It works with everything from the latest Adventurer 5M / 5M Pro / AD5X to older | Capability | What You Get | | --- | --- | | **Live Monitoring** | Real-time status, temperatures, progress, layer count, and ETA — updated every few seconds | -| **Full Print Control** | Start, pause, resume, and cancel jobs • upload `.gcode` / `.gx` / `.3mf` files • browse recent and local jobs with thumbnail previews | -| **[Camera Streaming](https://github.com/Parallel-7/FlashForgeUI-Electron/wiki/Custom-Camera-Setup)** | Auto-detected OEM cameras plus custom RTSP/HTTP sources • watch from multiple devices at once | -| **[Multi-Printer Management](https://github.com/Parallel-7/FlashForgeUI-Electron/wiki/Connecting-to-Multiple-Printers)** | Connect to several printers concurrently • tabbed desktop UI and dropdown WebUI selector • independent per-printer settings | -| **[Remote Access (WebUI)](https://github.com/Parallel-7/FlashForgeUI-Electron/wiki/WebUI-Setup)** | Password-protected, mobile-friendly web interface with full feature parity to the desktop app | +| **Full Print Control** | Start, pause, resume, and cancel jobs • upload `.gcode` / `.gx` / `.3mf` files from the desktop or any browser • browse recent and local jobs with thumbnail previews | +| **Material Station** | 4-slot material station support for the AD5X and Creator 5 series • set slot material and color • tool-to-slot matching with color mismatch warnings when starting multi-material jobs | +| **[Camera Streaming](https://github.com/Parallel-7/FlashForgeUI-Electron/wiki/4.-Custom-Camera-Setup)** | Auto-detected OEM cameras plus custom RTSP/HTTP sources • watch from multiple devices at once | +| **[Multi-Printer Management](https://github.com/Parallel-7/FlashForgeUI-Electron/wiki/2.-Connecting-to-Multiple-Printers)** | Connect to several printers concurrently • tabbed desktop UI and dropdown WebUI selector • independent per-printer settings | +| **[Remote Access (WebUI)](https://github.com/Parallel-7/FlashForgeUI-Electron/wiki/3.-WebUI-Setup)** | Password-protected, mobile-friendly web interface with full feature parity to the desktop app | | **[Headless Mode](https://github.com/Parallel-7/FlashForgeUI-Electron/wiki/Headless-Mode)** | Run as a server with no desktop UI — ideal for a Raspberry Pi or always-on machine | -| **[Spoolman Integration](https://github.com/Parallel-7/FlashForgeUI-Electron/wiki/Spoolman-Integration)** | Track filament usage automatically and assign spools per printer | +| **[Spoolman Integration](https://github.com/Parallel-7/FlashForgeUI-Electron/wiki/5.-Spoolman-Integration)** | Track filament usage automatically • assign a spool per printer, or pick a spool per slot for each job on material-station printers | | **[Notifications](https://github.com/Parallel-7/FlashForgeUI-Electron/wiki/Notifications)** | Desktop and Discord alerts for print completion, cooldown, and more | | **Hardware Controls** | Temperature, LED, and filtration control • axis homing • clear platform • direct command terminal | +| **Printer File Manager** | Browse, rename, and delete files stored on the printer and on USB, with thumbnail previews — desktop and WebUI • 5M / 5M Pro / AD5X, requires SSH access via [FlashForge-EasySSH](https://github.com/Parallel-7/FlashForge-EasySSH) | +| **Calibration Assistant** | Read and analyze on-printer calibration data to help diagnose print quality — same models and SSH requirement as the file manager | +| **Remote Reboot** | Restart the printer from the app or a browser and reconnect automatically once it is back — same models and SSH requirement as the file manager | +| **Start with System** | Optionally launch at login on Windows, macOS, and Linux — start minimized, minimize to the tray, and let prints run unattended | | **[Customizable UI](https://github.com/Parallel-7/FlashForgeUI-Electron/wiki/Customizing-the-Desktop-UI)** | Drag-and-drop layouts, light/dark themes, and custom color palettes on both desktop and WebUI |
@@ -75,7 +78,8 @@ It works with everything from the latest Adventurer 5M / 5M Pro / AD5X to older | Printer | Support | Material Station | Notes | | --- | --- | --- | --- | -| **Creator 5** | Full | Yes (4 slots) | Unable to start local printer files, no filtration control | +| **Creator 5 Pro** | Full | Yes (4 slots) | Includes filtration control • start jobs by uploading a `.3mf` (stored files can't be started) | +| **Creator 5** | Full | Yes (4 slots) | Start jobs by uploading a `.3mf` (stored files can't be started) | | **AD5X** | Full | Yes (4 slots) | Multi-material 3MF printing | | **Adventurer 5M Pro** | Full | No | Includes filtration control | | **Adventurer 5M** | Full | No | | @@ -84,6 +88,8 @@ It works with everything from the latest Adventurer 5M / 5M Pro / AD5X to older **Newer printers (5M series and up) require LAN-only mode and a one-time pairing code — see [Pairing Your Printer](https://github.com/Parallel-7/FlashForgeUI-Electron/wiki/1.-Pairing-your-printer).** +The SSH-based tools (file manager, calibration assistant, remote reboot) currently support the Adventurer 5M / 5M Pro / AD5X only. The Creator 5 series does not accept raw G-code commands such as axis homing. +
@@ -93,9 +99,9 @@ It works with everything from the latest Adventurer 5M / 5M Pro / AD5X to older | Step | Instructions | | --- | --- | | **1. Download** | Grab the [latest release](https://github.com/Parallel-7/FlashForgeUI-Electron/releases/latest) for your platform | -| **2. Install** | **Windows:** run the `.exe` installer
**macOS:** open the `.dmg` and drag to Applications
**Linux:** use the `.AppImage`, `.deb`, or `.rpm` package | +| **2. Install** | **Windows:** run the `.exe` installer
**macOS:** open the `.dmg` and drag to Applications
**Linux:** install the `.deb` or `.rpm`, or run the `.AppImage` directly — on Ubuntu 24.04+ prefer the `.deb`, see [Linux Notes](https://github.com/Parallel-7/FlashForgeUI-Electron/wiki/Auto-Start#sandbox--apparmor-on-ubuntu-2404) | | **3. Connect** | Launch the app, use auto-discovery or enter your printer's IP, and add the [pairing code](https://github.com/Parallel-7/FlashForgeUI-Electron/wiki/1.-Pairing-your-printer) if prompted | -| **4. Configure** | Optional: set up [remote access](https://github.com/Parallel-7/FlashForgeUI-Electron/wiki/WebUI-Setup), [Discord alerts](https://github.com/Parallel-7/FlashForgeUI-Electron/wiki/Notifications), [Spoolman](https://github.com/Parallel-7/FlashForgeUI-Electron/wiki/Spoolman-Integration), and [custom cameras](https://github.com/Parallel-7/FlashForgeUI-Electron/wiki/Custom-Camera-Setup) | +| **4. Configure** | Optional: set up [remote access](https://github.com/Parallel-7/FlashForgeUI-Electron/wiki/3.-WebUI-Setup), [Discord alerts](https://github.com/Parallel-7/FlashForgeUI-Electron/wiki/Notifications), [Spoolman](https://github.com/Parallel-7/FlashForgeUI-Electron/wiki/5.-Spoolman-Integration), and [custom cameras](https://github.com/Parallel-7/FlashForgeUI-Electron/wiki/4.-Custom-Camera-Setup) | Full setup walkthroughs and troubleshooting live in the **[Wiki](https://github.com/Parallel-7/FlashForgeUI-Electron/wiki)**. @@ -107,7 +113,7 @@ Full setup walkthroughs and troubleshooting live in the **[Wiki](https://github.
-This project uses [pnpm](https://pnpm.io/). +You need [Node.js](https://nodejs.org/) 22 and [pnpm](https://pnpm.io/) 10 (`corepack enable` picks up the pinned version). All dependencies, including the `@ghosttypes` and `@parallel-7` scoped packages, are public on npm. You do not need an `.npmrc`, a token, or GitHub Packages access to build from source. `pnpm install` also downloads the bundled [go2rtc](https://github.com/AlexxIT/go2rtc) camera binary. ```bash pnpm install # install dependencies @@ -120,10 +126,18 @@ pnpm build:linux # package Linux artifacts
+## Contributing + +
+ +Contributions are welcome. Development happens on the **`alpha`** branch, and `main` only receives stable releases. Branch from `alpha` and open your pull request against `alpha`. **[CONTRIBUTING.md](CONTRIBUTING.md)** covers setup, code standards, testing, and the release workflow. + +
+ ![Electron](https://img.shields.io/badge/Electron-39.8.10-47848f?style=flat&logo=electron) ![TypeScript](https://img.shields.io/badge/TypeScript-5.9.3-3178c6?style=flat&logo=typescript) -![Vite](https://img.shields.io/badge/Vite-7.3.2-646cff?style=flat&logo=vite) -![Express](https://img.shields.io/badge/Express-5.2.1-000000?style=flat&logo=express) +![Vite](https://img.shields.io/badge/Vite-7.3.6-646cff?style=flat&logo=vite) +![Express](https://img.shields.io/badge/Express-5.3.0-000000?style=flat&logo=express) ## License diff --git a/__mocks__/electron-log.js b/__mocks__/electron-log.js new file mode 100644 index 00000000..2f144c07 --- /dev/null +++ b/__mocks__/electron-log.js @@ -0,0 +1,38 @@ +/** + * @fileoverview Global Jest mock for electron-log + * + * Prevents unit tests from writing to the real user-profile log file + * (%APPDATA%/FlashForgeUI/logs/main.log). Without this, any main-process + * module that imports electron-log at module level (AutoLaunchService, + * AutoUpdateService, ...) pollutes the production log with test-fixture + * output on every `pnpm test` run. Wired up via moduleNameMapper in + * jest.config.cjs so it applies to every test automatically. + */ + +const noop = () => {}; + +const logger = { + error: noop, + warn: noop, + info: noop, + verbose: noop, + debug: noop, + silly: noop, + log: noop, + transports: { + file: { level: false }, + console: { level: false }, + ipc: { level: false }, + remote: { level: false }, + }, + scope: () => logger, + create: () => logger, + initialize: noop, + errorHandler: { startCatching: noop, stopCatching: noop }, + eventLogger: { startLogging: noop, stopLogging: noop }, + hooks: [], + variables: {}, +}; + +module.exports = logger; +module.exports.default = logger; diff --git a/ai_docs/UI_COMPONENTS.md b/ai_docs/UI_COMPONENTS.md index d37c7abb..1d7f4c38 100644 --- a/ai_docs/UI_COMPONENTS.md +++ b/ai_docs/UI_COMPONENTS.md @@ -56,7 +56,7 @@ The settings dialog uses a modular, section-based architecture for improved main 1. CSS & Icon Loading (Lucide icons) 2. Debug State Initialization (initializeDebugState) 3. Platform Detection & Theme Application -4. Legacy UI Controller Setup (LegacyUiController) +4. Shell Controller Setup (ShellController) 5. Shortcut System Initialization 6. Placeholder UI Setup 7. Polling Listeners (polling-update IPC) @@ -67,7 +67,7 @@ The settings dialog uses a modular, section-based architecture for improved main **Key Controllers**: - `RendererGridController`: Manages GridStack integration and component lifecycle -- `LegacyUiController`: Handles legacy UI updates and compatibility +- `ShellController`: Owns the window chrome — window controls, hamburger menu (incl. Edit Layout), and loading overlay - `ShortcutButtonController`: Manages top-bar shortcuts and dialog wiring **Per-Printer State Tracking**: @@ -241,7 +241,7 @@ Polling updates forwarded - `src/renderer/src/renderer.ts`, `src/renderer/src/gridController.ts`, `src/renderer/src/shortcutButtons.ts`, `src/renderer/src/perPrinterStorage.ts`, `src/renderer/src/logging.ts` - `src/renderer/src/ui/components/**` (ComponentManager, printer tabs, job info, etc.) + `src/renderer/src/ui/gridstack/**` for layout/palette logic - `src/renderer/src/ui/component-dialog/**` – component dialog renderer + preload mirrors -- `src/renderer/src/ui/legacy/LegacyUiController.ts` – legacy UI compatibility layer +- `src/renderer/src/ui/shell/ShellController.ts` – window chrome (controls, menu, loading overlay) **GridStack System** - `src/renderer/src/ui/gridstack/GridStackManager.ts` – grid initialization and widget management diff --git a/ai_docs/WEBUI_HEADLESS.md b/ai_docs/WEBUI_HEADLESS.md index 6a645232..183869ef 100644 --- a/ai_docs/WEBUI_HEADLESS.md +++ b/ai_docs/WEBUI_HEADLESS.md @@ -12,7 +12,7 @@ This document covers headless mode operations and the WebUI server architecture. - `HeadlessManager.initialize()` (invoked from `src/index.ts`) forces `WebUIEnabled`, applies overrides, connects printers (respecting discovery + saved printers), starts the WebUI server, launches polling/camera proxies, and sets up graceful shutdown. -- Headless mode is documented for users in `docs/README.md` (update that doc whenever CLI or defaults change). `DEFAULT_CONFIG.WebUIPort` is **3000**; any mention of 3001 is legacy. +- Headless mode is documented for users in the [Headless Mode](https://github.com/Parallel-7/FlashForgeUI-Electron/wiki/Headless-Mode) wiki page (update it whenever CLI or defaults change). `DEFAULT_CONFIG.WebUIPort` is **3000**; any mention of 3001 is legacy. - The WebUI server (`src/webui/server/WebUIManager.ts`) wires Express, authentication (`AuthManager`), route registration (`server/routes/*.ts` for camera, contexts, jobs, printer control/status, spoolman, temperature, theme, filtration), and `WebSocketManager` for per-context real-time updates. Routes reuse the same services/IPC calls as the desktop UI—avoid duplicating logic. @@ -165,4 +165,4 @@ class AppState { **Headless & WebUI** - `src/main/utils/HeadlessArguments.ts`, `HeadlessDetection.ts`, `HeadlessLogger.ts`, `src/main/managers/HeadlessManager.ts` - `src/main/webui/server/*` (WebUIManager, AuthManager, WebSocketManager, route modules) + `src/main/webui/static/*` (AppState, Transport, features, grid) -- `docs/README.md` – user-facing headless instructions (keep updated) +- [Headless Mode](https://github.com/Parallel-7/FlashForgeUI-Electron/wiki/Headless-Mode) wiki – user-facing headless instructions (keep updated) diff --git a/assets/linux/afterInstall.sh b/assets/linux/afterInstall.sh index 70c849ec..401391b4 100644 --- a/assets/linux/afterInstall.sh +++ b/assets/linux/afterInstall.sh @@ -1,42 +1,85 @@ #!/bin/bash -# Post-installation script for FlashForgeUI -# This script runs after the package is installed - -# Set proper permissions for the application -chmod +x /opt/FlashForgeUI/FlashForgeUI || true - -# Create desktop entry if it doesn't exist -if [ ! -f /usr/share/applications/FlashForgeUI.desktop ]; then - cat > /usr/share/applications/FlashForgeUI.desktop << EOF -[Desktop Entry] -Name=FlashForgeUI -Comment=Monitoring and Control software for FlashForge printers -Exec=/opt/FlashForgeUI/FlashForgeUI -Icon=FlashForgeUI -Type=Application -Categories=Utility; -StartupNotify=true -EOF -fi - -# Update desktop database -if command -v update-desktop-database >/dev/null 2>&1; then +# Post-installation script for FlashForgeUI (deb / rpm). +# +# IMPORTANT: supplying this file via `deb.afterInstall` / `rpm.afterInstall` REPLACES +# electron-builder's own after-install template wholesale - it is not merged. Everything upstream +# does must therefore be reproduced here, or it silently stops happening. This script is a copy of +# app-builder-lib/templates/linux/after-install.tpl with the FlashForgeUI-specific legacy cleanup +# appended at the end. When bumping electron-builder, diff that template against this file. +# +# The two upstream behaviors that matter most, and that a previous hand-written version of this +# script dropped: +# 1. Installing the AppArmor profile. Ubuntu 23.10+ ships +# kernel.apparmor_restrict_unprivileged_userns=1, which blocks unprivileged user namespaces +# and therefore Chromium's namespace sandbox. Without a profile granting `userns`, the app +# does not start at all on Ubuntu 24.04+. +# 2. Setting the SUID bit on chrome-sandbox when the kernel has no working user namespaces +# (e.g. Debian defaults), so Chromium can fall back to the SUID sandbox. + +if type update-alternatives >/dev/null 2>&1; then + # Remove previous link if it doesn't use update-alternatives + if [ -L '/usr/bin/FlashForgeUI' ] && [ -e '/usr/bin/FlashForgeUI' ] && [ "$(readlink '/usr/bin/FlashForgeUI')" != '/etc/alternatives/FlashForgeUI' ]; then + rm -f '/usr/bin/FlashForgeUI' + fi + update-alternatives --install '/usr/bin/FlashForgeUI' 'FlashForgeUI' '/opt/FlashForgeUI/FlashForgeUI' 100 || ln -sf '/opt/FlashForgeUI/FlashForgeUI' '/usr/bin/FlashForgeUI' +else + ln -sf '/opt/FlashForgeUI/FlashForgeUI' '/usr/bin/FlashForgeUI' +fi + +# Check if user namespaces are supported by the kernel and working with a quick test: +if ! { [ -L /proc/self/ns/user ] && unshare --user true; }; then + # Use SUID chrome-sandbox only on systems without user namespaces: + chmod 4755 '/opt/FlashForgeUI/chrome-sandbox' || true +else + chmod 0755 '/opt/FlashForgeUI/chrome-sandbox' || true +fi + +if hash update-mime-database 2>/dev/null; then + update-mime-database /usr/share/mime || true +fi + +if hash update-desktop-database 2>/dev/null; then update-desktop-database /usr/share/applications || true fi -# Create symlink in /usr/local/bin for CLI access (optional) -if [ ! -f /usr/local/bin/FlashForgeUI ]; then - ln -sf /opt/FlashForgeUI/FlashForgeUI /usr/local/bin/FlashForgeUI || true +# Install apparmor profile. (Ubuntu 24+) +# First check if the version of AppArmor running on the device supports our profile. +# This is in order to keep backwards compatibility with Ubuntu 22.04 which does not support abi/4.0. +# In that case, we just skip installing the profile since the app runs fine without it on 22.04. +# +# Those apparmor_parser flags are akin to performing a dry run of loading a profile. +# https://wiki.debian.org/AppArmor/HowToUse#Dumping_profiles +if apparmor_status --enabled > /dev/null 2>&1; then + APPARMOR_PROFILE_SOURCE='/opt/FlashForgeUI/resources/apparmor-profile' + APPARMOR_PROFILE_TARGET='/etc/apparmor.d/FlashForgeUI' + if apparmor_parser --skip-kernel-load --debug "$APPARMOR_PROFILE_SOURCE" > /dev/null 2>&1; then + cp -f "$APPARMOR_PROFILE_SOURCE" "$APPARMOR_PROFILE_TARGET" + + # Updating the current AppArmor profile is not possible and probably not meaningful in a + # chroot'ed environment (e.g. image builders for client machines). + if ! { [ -x '/usr/bin/ischroot' ] && /usr/bin/ischroot; } && hash apparmor_parser 2>/dev/null; then + # Extra flags taken from dh_apparmor: by using '-W -T' we ensure that any abstraction + # updates are also pulled in. + apparmor_parser --replace --write-cache --skip-read-cache "$APPARMOR_PROFILE_TARGET" + fi + else + echo "Skipping the installation of the AppArmor profile as this version of AppArmor does not seem to support the bundled profile" + fi fi -# Clean up old flashforge-ui-ts entries from previous installations +# --- FlashForgeUI-specific: clean up artifacts of the pre-rename flashforge-ui-ts packages --- + if [ -f /usr/share/applications/flashforge-ui-ts.desktop ]; then rm -f /usr/share/applications/flashforge-ui-ts.desktop || true fi if [ -L /usr/local/bin/flashforge-ui-ts ]; then rm -f /usr/local/bin/flashforge-ui-ts || true fi +# Older FlashForgeUI packages symlinked into /usr/local/bin; /usr/bin (handled above via +# update-alternatives) is the correct location, so drop the stale duplicate. +if [ -L /usr/local/bin/FlashForgeUI ]; then + rm -f /usr/local/bin/FlashForgeUI || true +fi exit 0 - \ No newline at end of file diff --git a/assets/linux/afterRemove.sh b/assets/linux/afterRemove.sh index 2f628d52..2b4662eb 100644 --- a/assets/linux/afterRemove.sh +++ b/assets/linux/afterRemove.sh @@ -1,22 +1,31 @@ #!/bin/bash -# Post-removal script for FlashForgeUI -# This script runs after the package is removed +# Post-removal script for FlashForgeUI (deb / rpm). +# +# As with afterInstall.sh, supplying this file REPLACES electron-builder's after-remove template +# rather than extending it - so the upstream body is reproduced here verbatim, with the +# FlashForgeUI-specific legacy cleanup appended. Diff against +# app-builder-lib/templates/linux/after-remove.tpl when bumping electron-builder. -# Remove desktop entries (both old and new) -if [ -f /usr/share/applications/flashforge-ui-ts.desktop ]; then - rm -f /usr/share/applications/flashforge-ui-ts.desktop || true -fi -if [ -f /usr/share/applications/FlashForgeUI.desktop ]; then - rm -f /usr/share/applications/FlashForgeUI.desktop || true +# Delete the link to the binary +if type update-alternatives >/dev/null 2>&1; then + update-alternatives --remove 'FlashForgeUI' '/usr/bin/FlashForgeUI' +else + rm -f '/usr/bin/FlashForgeUI' fi -# Update desktop database -if command -v update-desktop-database >/dev/null 2>&1; then - update-desktop-database /usr/share/applications || true +APPARMOR_PROFILE_DEST='/etc/apparmor.d/FlashForgeUI' + +# Remove apparmor profile. +if [ -f "$APPARMOR_PROFILE_DEST" ]; then + rm -f "$APPARMOR_PROFILE_DEST" fi -# Remove symlinks from /usr/local/bin (both old and new) +# --- FlashForgeUI-specific: clean up artifacts of the pre-rename flashforge-ui-ts packages --- + +if [ -f /usr/share/applications/flashforge-ui-ts.desktop ]; then + rm -f /usr/share/applications/flashforge-ui-ts.desktop || true +fi if [ -L /usr/local/bin/flashforge-ui-ts ]; then rm -f /usr/local/bin/flashforge-ui-ts || true fi @@ -24,11 +33,8 @@ if [ -L /usr/local/bin/FlashForgeUI ]; then rm -f /usr/local/bin/FlashForgeUI || true fi -# Remove any leftover configuration files (optional) -# Note: We typically don't remove user data/config files in afterRemove -# but you can uncomment the lines below if needed -# rm -rf /home/*/.config/FlashForgeUI || true -# rm -rf /root/.config/FlashForgeUI || true +if command -v update-desktop-database >/dev/null 2>&1; then + update-desktop-database /usr/share/applications || true +fi exit 0 - \ No newline at end of file diff --git a/assets/linux/apparmor-profile b/assets/linux/apparmor-profile new file mode 100644 index 00000000..19e7b576 --- /dev/null +++ b/assets/linux/apparmor-profile @@ -0,0 +1,31 @@ +abi , +include + +# AppArmor profile for FlashForgeUI (deb / rpm installs). +# +# Ubuntu 23.10+ ships kernel.apparmor_restrict_unprivileged_userns=1, which forbids unprivileged +# processes from creating user namespaces. Chromium needs them for its namespace sandbox, so +# without a profile granting `userns` the app aborts during startup and never draws a window. +# See https://bugs.launchpad.net/ubuntu/+source/apparmor/+bug/2046844. +# +# flags=(unconfined) means this profile does not actually confine the application - it exists +# solely to carry the `userns` grant, which is the pattern the AppArmor maintainers recommend for +# application packages. +# +# The attachment uses brace expansion to cover both entry points, because the Linux build wraps +# the Electron binary in a small launcher script (see scripts/linux/after-pack.cjs): +# /opt/FlashForgeUI/FlashForgeUI - the launcher script +# /opt/FlashForgeUI/FlashForgeUI.bin - the real Electron binary the launcher execs +# Covering the .bin path is the part that matters: that is the process which actually creates the +# user namespace, and it must match a profile granting `userns` regardless of how the interpreted +# launcher script resolves its own attachment. +# +# This file overrides app-builder-lib/templates/linux/apparmor-profile.tpl, whose attachment only +# covers the unwrapped binary path. + +profile "FlashForgeUI" "/opt/FlashForgeUI/FlashForgeUI{,.bin}" flags=(unconfined) { + userns, + + # Site-specific additions and overrides. See local/README for details. + include if exists +} diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index 28c12de6..00000000 --- a/docs/README.md +++ /dev/null @@ -1,188 +0,0 @@ -# FlashForgeUI User Guide - -## Initial Setup -Legacy printers are supported out of the box , as they don't use the new HTTP API. - -For new printers (5M series+), you will need to enable LAN-Only mode to connect with FlashForgeUI - -> Enabling LAN-only mode will *prevent* FlashCloud/PolarCloud from working, but provides the benefit of a true direct-connection to the printer. You will notice faster control and a smoother camera stream when comparing to Orca-FlashForge, or any of their cloud services. - -The LAN-only mode setting is located in the same screen as the pairing code, see below - -## 5M & AD5X Pairing Code -The Adventurer 5M, 5M Pro, and AD5X require a pairing code when connecting for the first time. - -You can find the code in this settings menu on the printer (Printer ID = pairing code) -image - -## Camera Setup -For 5M-series printers, OEM cameras are detected automatically whenever the printer reports a `cameraStreamUrl`. You do not need to enable any custom camera setting for an official FlashForge camera. - -If you disable the printer-side camera in firmware, FlashForgeUI will treat the camera as unavailable until it is re-enabled on the printer. - -For a separate custom RTSP or HTTP camera, enable the custom camera option in settings and paste the full camera URL into the camera URL box. You will then be able to view it from the Desktop and WebUI. - -## Custom LED Setup -For users with an Adventurer 5M or AD5X that have installed custom LEDs , you'll need to enable the "Custom LEDs" option in settings. This tells the program that you've installed your own LEDs, and allows you to control them from the Desktop / WebUI - - -## Headless Mode Usage -For Linux and MacOS, replace `FlashForgeUI.exe` with the correct way to start from the CLI, for your OS. The `--enable-logging` flag is only needed for Windows, or if it's not spawning a new CLI window after starting the program. - -For MacOS, the command structure starts with -```bash -open "/Applications/FlashForgeUI.app/Contents/MacOS/FlashForgeUI" -``` - -For Linux, (coming soon...) - -## Starting Headless Mode - -Launch FlashForgeUI with the `--headless` flag: - -```bash -FlashForgeUI.exe --enable-logging --headless -``` - -The WebUI will be accessible at `http://localhost:3000` by default. - -## Command-Line Arguments - -### Core Flags - -**`--headless`** -- Runs without the desktop UI -- Starts the WebUI server automatically -- Required for all headless operations - -### Printer Connection Modes - -**`--last-used`** -- Connects to the last printer you used -```bash -FlashForgeUI.exe --enable-logging --headless --last-used -``` - -**`--all-saved-printers`** -- Connects to all saved printers -- Enables multi-printer mode with dropdown selector -```bash -FlashForgeUI.exe --enable-logging --headless --all-saved-printers -``` - -**`--printers=`** -- Connects to specific printer(s) by IP address and type -- Format: `--printers="::,::,..."` -- Type: `new` (5M family) or `legacy` (older models) -- Checkcode: Required for `new` type printers (8-digit code) - -Single printer example: -```bash -FlashForgeUI.exe --enable-logging --headless --printers="192.168.1.100:new:12345678" -``` - -Multiple printers example: -```bash -FlashForgeUI.exe --enable-logging --headless --printers="192.168.1.100:new:12345678,192.168.1.101:legacy" -``` - -### WebUI Server Configuration - -**`--webui-port=`** -- Sets the WebUI server port (default: 3000) -```bash -FlashForgeUI.exe --enable-logging --headless --webui-port=8080 -``` - -**`--webui-password=`** -- Overrides the default WebUI password -```bash -FlashForgeUI.exe --enable-logging --headless --webui-password=mypassword -``` - -### Debug Logging - -**`--debug`** -- Enables debug logging to file -- Logs are saved to timestamped files in the app's logs directory -- Includes verbose application logs, status updates, and general debugging info -- Can be combined with `--debug-network` for comprehensive logging -```bash -FlashForgeUI.exe --enable-logging --headless --last-used --debug -``` - -**`--debug-network`** -- Enables network-specific debug logging -- Requires debug mode to be enabled (via `--debug` flag or the DebugMode config setting) -- Logs connection attempts, failures, polling errors, and disconnections -- Useful for diagnosing printer connectivity issues -```bash -FlashForgeUI.exe --enable-logging --headless --last-used --debug --debug-network -``` - -Debug logs can be downloaded from the WebUI at: -- `/api/debug/logs` - List all debug log files -- `/api/debug/latest` - Download most recent debug log -- `/api/debug/network-logs` - List all network debug log files -- `/api/debug/network-latest` - Download most recent network log - -## Common Usage Examples - -### Single Printer (Last Used) -```bash -FlashForgeUI.exe --enable-logging --headless --last-used -``` - -### Multiple Printers (All Saved) -```bash -FlashForgeUI.exe --enable-logging --headless --all-saved-printers -``` - -### Specific Printer by IP (New API) -```bash -FlashForgeUI.exe --enable-logging --headless --printers="192.168.1.146:new:12345678" -``` - -### Specific Printer by IP (Legacy API) -```bash -FlashForgeUI.exe --enable-logging -headless --printers="192.168.1.100:legacy" -``` - -### Multiple Specific Printers -```bash -FlashForgeUI.exe --enable-logging --headless --printers="192.168.1.146:new:12345678,192.168.1.129:new:87654321" -``` - -### Custom Port and Password -```bash -FlashForgeUI.exe --enable-logging --headless --last-used --webui-port=8080 --webui-password=secret -``` - -## Accessing the WebUI - -Once running, access the WebUI from any browser on your network: - -``` -http://:3000 -``` - -Default password is configured in your application settings (or use `--webui-password=` to override). - -## Multi-Printer Mode - -When using `--all-saved-printers` or specifying multiple printers with `--printers=`, the WebUI provides: - -- **Printer Selector**: Dropdown to switch between printers -- **Per-Printer Camera**: Each printer gets its own camera stream (ports 8181+) -- **Independent Control**: Each printer maintains its own state and features - -## Troubleshooting - -### My printer is not being discovered automatically -- If your printer is before the 5M series, automatic discovery won't work. Use the direct IP connection option, and it will be saved for future usage. -- If your printer is 5M series+, first make sure LAN-only mode has been properly enabled. After verifying, make sure your PC and printer are on the same network. Occasionally the printer will not respond to the scan, so simply re-scanning can cause your printer to appear. - -### ETA and/or filament usage is not correct/being reported -- The file has been sliced with OrcaSlicer and lacks the correct (and correct ordering of) metadata. FlashForge printers only "broadcast" this information to the API for files sliced by Orca-FlashForge. Both slicers include the information, but in different formats, and FlashForge printers only look for/accept the format from Orca-FlashForge. -- Please download and set up [this](https://github.com/GhostTypes/orca2flashforge) post-process script -- If you already have a script for MD5 generation, remove it and add `-m` to the end of this. It will generate both the MD5 hash and the corrected metadata. diff --git a/docs/SPOOLMAN-SLOT-BLUEPRINT.md b/docs/SPOOLMAN-SLOT-BLUEPRINT.md new file mode 100644 index 00000000..18bc08fb --- /dev/null +++ b/docs/SPOOLMAN-SLOT-BLUEPRINT.md @@ -0,0 +1,155 @@ +# Blueprint: Set an AD5X IFS slot's material/color from a Spoolman spool + +**Status:** not implemented here yet — this is a porting guide. +**Origin:** pioneered in the Android app (`flashforgeui-app`); this is the backport. +**Difference from Android:** Android scans an NFC-tagged spool. Here there's no NFC, so the user +**picks a spool from a list** (the existing Spoolman spool selector) instead. + +--- + +## 1. What we're building + +On the AD5X IFS slot editor, add a **"Set from Spoolman"** affordance. The flow: + +1. User opens a slot's editor and taps **Set from Spoolman**. +2. A **spool picker** opens — a searchable list of the user's Spoolman spools (reuse the existing + spool-selection UI; see §4). +3. User picks a spool. We read its `material` + `color_hex` from Spoolman. +4. We **snap** those to the printer's fixed lists (see §3) — the AD5X only renders 14 known + materials and 24 known colors; arbitrary values won't draw an icon on the printer screen. +5. We **apply** them to the slot via the slot-config command (`msConfig_cmd`; see §5 — this command + is a prerequisite that must be backported first). +6. Show what matched ("Slot 2 → PLA · Red, from "), then refresh the station. + +Gate the whole affordance on **Spoolman being enabled/configured**. AD5X-only (IFS). + +--- + +## 2. Reference implementation (Android) + +Port the logic from these files in `flashforgeui-app`: + +- `app/src/main/java/me/ghost/ffui/ui/components/IfsPalette.kt` — the palette + `nearestColor` + (CIEDE2000) + `nearestMaterial`. **This is the core reusable piece.** +- `app/src/test/java/me/ghost/ffui/ui/components/IfsPaletteMatchingTest.kt` — the test cases to + re-port (all 24 swatches + live-Spoolman fixtures + material mappings). +- `app/src/main/java/me/ghost/ffui/ui/dashboard/SlotEditorSheet.kt` — the scan→resolve→match→apply + state machine (swap the NFC trigger for the spool picker). + +Authoritative wire/data reference: `flashforge-api-docs/docs-wiki/AD5X-IFS-Material-Station.md` +(the 24 colors, 14 materials, and the `msConfig_cmd` payload all come from here). + +--- + +## 3. Core logic to port: the palette + nearest-match + +Create one small, framework-agnostic module (pure TS, no DOM/Electron deps) — e.g. +`ifs-palette.ts`. It holds the fixed lists and two pure functions. + +### 3a. The fixed lists (exact — do not edit; from the API docs) + +**Materials (14):** +``` +PLA, PLA-CF, PETG, PETG-CF, ABS, TPU, SILK, PA, PA-CF, PAHT-CF, PC, PC-ABS, PET-CF, PPS-CF +``` + +**Colors (24) — name → hex:** +``` +White #FFFFFF Yellow #FEF043 Light Green #DCF478 Green #0ACC38 +Dark Green #067749 Teal #0C6283 Cyan #0DE2A0 Light Blue #75D9F3 +Blue #45A8F9 Dark Blue #2750E0 Purple #46328E Violet #A03CF7 +Magenta #F330F9 Pink #D4B0DC Coral #F95D73 Red #F72224 +Brown #7C4B00 Orange #F98D33 Cream #FDEBD5 Tan #D3C4A3 +Dark Brown #AF7836 Gray #898989 Light Gray #BCBCBC Black #161616 +``` + +### 3b. `nearestColor(hex) → PaletteColor | null` + +Snap an arbitrary `#RRGGBB` (also accept `RRGGBB` and `RRGGBBAA` — drop alpha) to the nearest of +the 24 swatches using **CIEDE2000** distance (not plain Euclidean Lab / ΔE76). + +> **Why CIEDE2000, not ΔE76:** ΔE76 mismatches the saturated blue/red regions. On the live Spoolman +> library it mapped pure blue `#0000FF` → **Violet** and burgundy `#951e23` → **Coral**. CIEDE2000 +> fixes both (`#0000FF` → **Dark Blue**, `#951e23` → **Red**). Verified against real data; the math +> is ~microseconds so there's no perf reason to cut the corner. + +Pipeline: parse hex → linearize sRGB → XYZ (D65) → CIELAB → CIEDE2000 vs each precomputed palette +Lab → argmin. Precompute the 24 palette Lab values once. Return `null` if the hex can't be parsed. +(Port the exact `hexToLab` + `ciede2000` from `IfsPalette.kt`; the Kotlin is plain `Math.*` and +translates 1:1 to TS `Math.*`.) + +### 3c. `nearestMaterial(raw) → string | null` + +1. **Exact** match on the whole normalized string (uppercase, strip non-alphanumerics) — so + `"PLA-CF"`, `"petg-cf"`, `"PLA+"` resolve to `PLA-CF` / `PETG-CF` / `PLA`. +2. Else match the **leading token** (before the first space): `"PLA Matte"` → `PLA`, + `"PETG-CF Pro"` → `PETG-CF`. +3. Else `null` → the caller **keeps the slot's current material**. + +> **Why leading-token, not longest-prefix:** a prefix rule wrongly snaps `"PCTG"` → `PC` and +> `"PA6"` → `PA` (chemically unrelated). Leading-token lets those fall through to `null` instead. + +--- + +## 4. Data plumbing (already present in this repo) + +- **Fetch one spool:** `SpoolmanService.getSpoolById(spoolId)` already exists. Use it (or the + already-loaded list item) to get `{ filament: { material, color_hex, multi_color_hexes, name } }`. + Prefer `color_hex`; fall back to the first of `multi_color_hexes`. If neither, surface an error + ("spool has no color in Spoolman"). +- **Spool list / picker UI:** the Spoolman feature already renders/selects spools (Electron: + `window.api.spoolman.openSpoolSelection()` in `ifs-station.ts`/`spoolman.ts`; WebUI: the + `spoolman` static feature + `spoolman-routes`). Reuse that selector as the picker rather than + building a new one. + +--- + +## 5. PREREQUISITE — the slot-config command must be backported first + +The apply step needs `msConfig_cmd` (set a slot's material + color). **It does not exist in +`ff-5mp-api-ts` yet** (verified: no `msConfig_cmd`/`ms_cmd` anywhere in this repo or the library). +It currently lives only in `ff-5mp-api-kt` (`FlashForgeHttpApi.configureSlot` / +`AD5XBackend.setSlotMaterial`). + +See **`ff-5mp-api-ts/docs/BACKPORT-FROM-KT.md`** — port `configureSlot` (msConfig_cmd) + `slotAction` +(ms_cmd) there first, then this feature can call it. Wire payload is in +`AD5X-IFS-Material-Station.md` (`POST /control`, `cmd: "msConfig_cmd"`, `args: { slot, mt, rgb }` +where `rgb` is hex **without** `#`). As a stopgap you could POST that `/control` body directly from +the app backend, but adding it to the library is the right home. + +--- + +## 6. Per-project placement + +**FlashForgeUI-Electron:** +- Palette/match util: `src/shared/` (pure, importable by main + renderer). +- Apply path: an IPC handler near `src/main/ipc/handlers/material-handlers.ts` calling the + (backported) slot-config command. +- UI: the "Set from Spoolman" button + picker wiring in + `src/renderer/src/ui/components/ifs-station/ifs-station.ts`. + +**FlashForgeWebUI:** +- Palette/match util: a static module under `src/webui/static/` (browser) — or `src/shared` if one + exists — imported by the IFS feature. +- Apply path: a server route near `src/webui/server/routes/spoolman-routes.ts` (or the material + route) calling the slot-config command. +- UI: extend the IFS rendering in `src/webui/static/features/` with the button + picker modal. + +--- + +## 7. Tests + +Port `IfsPaletteMatchingTest` to Jest: +- Each of the 24 swatches resolves to itself; a small (±6) neighborhood snaps back to it. +- Synthetic primaries (`#FF0000`→Red, `#0000FF`→**Dark Blue**, `#00FFFF`→Light Blue, …). +- Live-Spoolman fixtures (e.g. `#0000FF`→Dark Blue, `#951e23`→Red, `#6c4f4c`→Brown). +- `nearestMaterial`: `"PLA Matte"`→PLA, `"PETG-CF Pro"`→PETG-CF, `"PCTG"`/`"PA6"`/`"Nylon"`→null. + +--- + +## 8. Notes + +- Matching is **microseconds** — never the bottleneck. Any post-apply lag is network (Spoolman + fetch + printer write), so there's nothing to optimize in the math. +- Auto-apply vs. confirm-first is a UX call; Android auto-applies after the pick. A confirm step + (showing the snapped swatch before writing) is reasonable for a desktop/web picker. diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 00000000..4812d4c7 --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,398 @@ +# Testing Guide + +**Last Updated:** 2026-08-14 + +This document describes every automated test surface in FlashForgeUI, what each one +actually proves, and where the gaps are. Read it before adding tests so new coverage +lands on the right surface, and before trusting a green run to mean "this works." + +--- + +## The Four Surfaces + +| Surface | Runner | Command | Needs | In CI | +| --- | --- | --- | --- | --- | +| Unit / integration | Jest | `pnpm test` | nothing | yes | +| WebUI browser E2E | Playwright (chromium) | `pnpm test:e2e` | emulator + Electron | yes | +| Electron E2E — **emulator track** | Playwright (`_electron`) | `pnpm test:e2e:electron:emulator` | `flashforge-emulator-v2` checkout | yes | +| Electron E2E — **hardware track** | Playwright (`_electron`) | `pnpm test:e2e:electron:hardware` | real printers on the LAN | no (impossible) | +| Discord webhook (hardware) | Playwright (`_electron`) | `pnpm test:e2e:electron:discord` | real AD5X + webhook relay | no | + +`pnpm type-check` covers **both** `tsconfig.json` (`src/`) and `tsconfig.e2e.json` +(`tests/` + both Playwright configs). This matters: before the e2e tsconfig was wired +in, the Electron suite rotted for months without a single type error surfacing. + +--- + +## Track Selection + +The Electron suite has one set of spec files serving two tracks. The track is chosen +by a single environment variable read in `tests/e2e/electron/support/track.ts`: + +``` +FFUI_E2E_TRACK=emulator # default — boots flashforge-emulator-v2 instances +FFUI_E2E_TRACK=hardware # drives the real printers on the developer's bench +``` + +The runner scripts set it for you; you should never need to export it by hand. + +Specs never fork on the track name. They parameterize over **target descriptors** and +branch on *capabilities*: + +- `hasMaterialStation` — AD5X / Creator 5 raise the material matching dialog +- `supportsSftp` — only real EasySSH-provisioned printers have an SSH server +- `supportsManualConnect` — see the port note below + +A track with nothing to run reports a **skip reason** rather than silently passing with +zero tests (`describeTrackSkipReason()`). + +### Why `supportsManualConnect` exists + +The manual connect form collects IP + type + serial + check code but no ports, so it +always dials the firmware defaults `8899`/`8898`. Real printers always listen there. +The HTTP-only Creator 5 series answers on those same defaults in the single-printer +specs, so manual connect applies to it as well. +Only one emulator instance per machine can, so the multi-printer descriptors shift +every instance after the first by +100 per position (`8999`/`8998`, then +`9099`/`9098`, …). Shifted instances stay +reachable by discovery and by a seeded profile — both carry explicit ports — but not by +the manual form, so that one case skips itself with an explanatory message. + +--- + +## Emulator Track + +**Command:** `pnpm test:e2e:electron:emulator` (builds first, then runs +`tests/e2e/electron/specs`) + +**Printers:** four models, one instance booted per printer describe block, from +[`flashforge-emulator-v2`](https://github.com/GhostTypes/flashforge-emulator-v2): + +| Label | Model | Serial | Ports | Material station | +| --- | --- | --- | --- | --- | +| Adventurer 5M Pro (emulated) | `adventurer-5m-pro` | `E2E-SN-5MPRO` | 8899 / 8898 | no | +| AD5X (emulated) | `adventurer-5x` | `E2E-SN-AD5X` | 8899 / 8898 | yes | +| Creator 5 (emulated) | `creator-5` | `E2E-SN-CREATOR5` | 8899 / 8898 | yes | +| Creator 5 Pro (emulated) | `creator-5-pro` | `E2E-SN-CREATOR5PRO` | 8899 / 8898 | yes | + +Check code is `123`. Simulation runs in `manual` mode so nothing advances on its own. + +The Creator 5 series instances are **HTTP-only**: the emulator binds no TCP server for +them, neither on the defaults nor on the shifted multi-printer ports. It still +advertises a `tcpPort` in discovery, because real Creator 5 firmware does the same and +the suite must see the real behavior, not a tidied-up one. This needs the emulator +commit that added the models (`6563d9f`); older checkouts reject them. + +**Emulator location:** defaults to `../flashforge-emulator-v2` beside this checkout; +override with `FF_EMULATOR_ROOT`. CI checks the public repo out into `emulator/`. + +**Current size:** 31 tests run + 2 conditional skips (33 collected), ~6 minutes, +one worker. + +### Process-group teardown (do not "simplify" this) + +`emulator-harness.ts` spawns `npm run headless:instance`, so the process that actually +binds the printer ports is a **grandchild** (npm → tsx). npm does not forward `SIGTERM` +to it. Signalling the child alone leaves the grandchild alive still holding 8899/8898, +and the next describe dies with `EADDRINUSE`. + +The harness therefore spawns `detached` on POSIX and signals the whole process group +via `process.kill(-pid, …)`. Windows has no process groups and uses `taskkill /T` +instead, which is why this bug was invisible locally and only appeared on the first CI +run. + +--- + +## Hardware Track + +**Command:** `pnpm test:e2e:electron:hardware` + +Same specs, real printers. Cannot run in CI — it needs printers on the LAN. + +### Credential resolution + +Deliberately **outside** the working tree so they can never be committed even if +`.gitignore` is wrong. In order: + +1. `$FFUI_E2E_HARDWARE_CONFIG` — explicit path to a JSON config +2. `~/.flashforgeui-e2e/hardware-printers.json` +3. The developer's real FlashForgeUI profile (**read-only**), which already holds + serials and check codes + +**IP addresses are never read from any of those.** They resolve live by UDP discovery +keyed on serial, because printer IPs move with DHCP — the bench AD5X moved `.132` → +`.133` between two runs. Serial is the only stable identity a printer has. A +powered-off printer produces "did not answer discovery… discovered instead: …" rather +than a bare timeout mid-test. + +### Safety protocol + +Uploads always run with **Start Now explicitly unchecked**, and every upload is +followed by `assertNotPrinting()`, which reads `/detail` back and — if a print somehow +started — cancels it, waits, and sends Clear Platform to return the printer to a +controllable state. That path should never fire; if it does, the test fails loudly. +Uploaded fixtures are removed in `afterEach` so hardware runs leave no residue. + +### Profile isolation + +Every launch gets a fresh temp `userData` via `FFUI_USER_DATA_DIR`, and +`launchFFUI()` asserts the app *honored* it before any test touches the UI. If the app +ignored the variable the run would mutate the developer's real `%APPDATA%/FlashForgeUI` +profile, so it refuses to continue. **Do not relax that assertion.** + +--- + +## What The Tracks Test + +### `specs/connect.spec.ts` — 3 tests per printer + +- **Saved-printer auto-connect** — seeded `printer_details.json`, app connects on boot, + dashboard renders, tab carries the printer's name +- **Network discovery** — open connect flow → scan → match the row **by serial** (the + only identity that survives DHCP) → connect +- **Manual connect** — the regression net for 1.0.5-alpha.9. A named printer type skips + the TCP probe, so the form must supply the serial and check code the discovery + broadcast would have carried, and must use the check code it collected **without a + second prompt**. Also asserts modern types require credentials and legacy does not. + +### `specs/upload.spec.ts` — 2 tests per printer + +- **Upload without starting a print** — the regression net for the 1.0.5-alpha.10 + hotfix, where pressing OK threw `ReferenceError: isAD5X is not defined` and silently + uploaded nothing. A test that only checked the dialog rendered would have missed it, + because the failure was in the confirm handler. So the assertion is that the file + **actually landed on the printer**, verified out of band. +- **Material matching dialog rules** — appears for every 3MF on a material-station + printer (single filament included, since each tool must still be bound to a slot); + must **never** appear on 5M-series. Requirement-row count matches the filament count + the parser reported. + +Out-of-band verification reads `/gcodeList` through the test client. The client prefers +`gcodeListDetail` and falls back to bare `gcodeList` names when that field is absent. +Real Creator 5 firmware returns names only, and the emulator matches that shape, so the +same assertion verifies uploads against both response shapes. + +Fixtures are real slicer output (`tests/fixtures/print-files/`), not synthetic stubs, +because the upload path parses embedded metadata and per-tool filament data. Their +tool counts were read from the parser, not from upstream file names — those names are +actively misleading. + +### `specs/led.spec.ts` — 1 test per printer + +On → off → on, with every assertion reading `lightStatus` back from the printer's own +`/detail` endpoint rather than the button's rendered state. A button that toggles its +own styling while the command never reaches the printer is exactly the failure this +catches, so trusting the UI here would defeat the purpose. Ends with the LED back on. + +### `specs/creator5-gating.spec.ts` — 1 test per printer, +1 Creator 5-only + +Pins the Creator 5 series gating contract from commit `58717fb`. Creator 5 / Creator 5 +Pro firmware cannot exchange material mappings over the local API, so starting a local +or recent job dead-ends at material selection. On the series, the renderer disables +`#btn-start-recent` and `#btn-start-local` with the title `Disabled: Local job +management is not available on this printer.` Every other model keeps both enabled. +A Creator 5-only test also pins `#btn-home-axes` as disabled with a title matching +`Disabled: G-code unavailable` — the series is HTTP-only and offers no G-code +passthrough. That second test skips itself on the other two printers, which is exactly +where the run's two conditional skips come from. + +The spec paid for itself on its first run by catching two real bugs: + +- `BasePrinterBackend.buildFeatureSet()` hardcoded `gcodeCommands` and + `statusMonitoring` as always available, and silently discarded the child backend's + own declarations. `Creator5Backend`'s `available: false` never reached the renderer. +- The renderer's `gcodeAvailable` only propagated through `backend-initialized`, whose + `contextId` guard dropped the boot-time payload — the context event that sets + `activeContextId` races it. `modelType` traveled via context events and was correct; + `gcodeAvailable` was not. + +### `specs/multi-printer.spec.ts` — 1 test + +Two printers connected simultaneously, each in its own context; both tabs report +connected and each carries its own printer's name. This is where cross-talk bugs hide — +one printer's polling updating another's tab, or a context switch leaving stale data. + +Follows the real user flow rather than a shortcut: with more than one saved printer the +app raises the auto-connect choice dialog, the user picks one, and the second is added +with the "+" tab button (which itself raises a warning that must be acknowledged). +Seeding two printers and waiting for two tabs would simply hang. + +### `specs/sftp-roundtrip.spec.ts` — hardware only + +Upload through the GUI → find it in the SFTP file manager → delete it there → confirm +it is gone from the printer itself, not just from the dialog's rendering. Proves the +whole SSH stack: credentials from `SSHSettingsService`, pooled connection per context, +listing and delete both over SFTP. Skipped on the emulator track, which runs no SSH +server. + +### `discord-hardware.spec.ts` — hardware only, separate runner + +Lives outside `specs/` so the emulator runner never collects it. Drives a real AD5X +with a local webhook relay and asserts the Discord payload — including camera snapshots +— matches what the UI is showing. Needs `FFUI_E2E_AD5X_IP` + `FFUI_E2E_AD5X_CHECK_CODE`. + +### Cross-cutting: renderer error guard + +Every Electron test ends with `assertNoRendererErrors()`, which fails the test on any +`pageerror` or `console.error` from **any** window (allowlisting only Electron's +built-in Autofill noise). This is what turns "the UI looked fine" into a real +assertion, and it is how a silent `ReferenceError` in a confirm handler gets caught. + +--- + +## Unit / Integration (Jest) + +55 suites, ~1,880 tests, `src/**/__tests__/`. Node environment, ts-jest. `electron-log` +is mocked so tests can never write to the real user-profile log. + +Heaviest coverage: + +- **WebUI server** (14 suites) — AuthManager, WebSocketManager, WebUIManager, + CameraStreamProxy, security middleware, static asset options, and every route module +- **Calibration** (8 suites) — FFT processing, shaper analysis, bed/screw solving, + Klipper config parsing, deviation analysis, workflow engine +- **Services** (12 suites) — connection establishment, polling coordination, discovery, + data transformation, SSH settings, Go2rtc, saved printers +- **WebUI static client** (9 suites) — Transport, authentication, camera, + context-switching, Spoolman, icons, palette, formatting +- **Shared** — material colour matching (CIEDE2000), palettes, printer settings defaults + +--- + +## WebUI Browser E2E (Playwright) + +`tests/e2e/browser/`, 8 tests, ~20 seconds. Runs in the CI `e2e` job. + +**There is no fixture server and nothing is stubbed.** `startHeadlessWebUI()` boots +emulator printers, seeds an isolated profile, launches the real app with +`--headless --all-saved-printers`, and waits for the WebUI server *the app itself* +starts. Every response comes from the production stack: real `createAPIRoutes`, real +`AuthManager` and auth middleware, real security and static-asset middleware, real +`WebSocketManager` broadcasting real polling data from the emulated printers. + +This also gives headless mode its only end-to-end coverage. + +| Test | Proves | +| --- | --- | +| rejects a bad password | the real `AuthManager` refuses it and the UI stays locked with no token stored | +| logs in / remembers token | dashboard appears, `#connection-text` reads `Connected` (so the websocket passed the real auth gate), and the stored token is accepted by the real auth middleware | +| restores the session | a remembered token survives reload with no second prompt | +| refuses API access | anonymous and forged tokens both get a real 401 | +| revokes on logout | a logged-out token stops working — a real security property the fixture never implemented | +| versioned assets | every local ``/` + + diff --git a/src/renderer/src/ui/gridstack/ComponentRegistry.ts b/src/renderer/src/ui/gridstack/ComponentRegistry.ts index 01527d6c..b6439998 100644 --- a/src/renderer/src/ui/gridstack/ComponentRegistry.ts +++ b/src/renderer/src/ui/gridstack/ComponentRegistry.ts @@ -25,8 +25,6 @@ * console.log(camera.defaultSize); // { w: 6, h: 6 } * } * ``` - * - * @module ui/gridstack/ComponentRegistry */ import { COMPONENT_REGISTRY_DATA } from '@shared/component-definitions.js'; diff --git a/src/renderer/src/ui/gridstack/EditModeController.ts b/src/renderer/src/ui/gridstack/EditModeController.ts index 053e6fa8..9efe1f8b 100644 --- a/src/renderer/src/ui/gridstack/EditModeController.ts +++ b/src/renderer/src/ui/gridstack/EditModeController.ts @@ -1,50 +1,9 @@ /** - * @fileoverview Edit mode controller for GridStack dashboard - * - * Manages the edit mode state for the GridStack dashboard, including CTRL+E - * keyboard shortcut handling, visual indicators, grid enable/disable coordination, - * and component palette window management. Provides auto-save on exit and - * change tracking for unsaved modifications. - * - * Key exports: - * - EditModeController: Main edit mode controller class - * - editModeController: Singleton instance for application-wide use - * - * Features: - * - CTRL+E (or CMD+E on Mac) keyboard shortcut toggle - * - Visual edit mode indicator with instructions - * - Grid editing enable/disable (dragging and resizing) - * - Component palette window coordination (open on enter, close on exit) - * - Auto-save on exit with change tracking - * - Unsaved changes indicator - * - Reset to default layout - * - Force save operation - * - * Usage: - * ```typescript - * import { editModeController } from './EditModeController'; - * import { gridStackManager } from './GridStackManager'; - * import { layoutPersistence } from './LayoutPersistence'; - * - * // Initialize (call after GridStack and LayoutPersistence) - * editModeController.initialize(gridStackManager, layoutPersistence); - * - * // Programmatically toggle edit mode - * editModeController.toggle(); - * - * // Check if edit mode is enabled - * if (editModeController.isEnabled()) { - * console.log('Edit mode active'); - * } - * - * // Force save current layout - * editModeController.forceSave(); - * - * // Reset to default layout - * await editModeController.resetToDefault(); - * ``` - * - * @module ui/gridstack/EditModeController + * @fileoverview EditModeController drives the dashboard's edit mode: the + * Ctrl/Cmd+E toggle, the visual indicator, enabling grid dragging and + * resizing, and opening/closing the component palette. Leaving edit mode + * saves the layout; unsaved changes are tracked and flagged in the + * indicator. */ import { getLucideIcons, initializeLucideIcons } from '../../renderer/utils/icons.js'; @@ -90,6 +49,39 @@ export class EditModeController { /** Whether controller is initialized */ private initialized = false; + /** Subscribers notified when edit mode enabled/availability changes */ + private readonly stateChangeListeners: Array<(state: { enabled: boolean; available: boolean }) => void> = []; + + /** + * Subscribe to edit mode state changes (enabled + availability). + * The listener is invoked immediately with the current state so callers + * can synchronize their UI without duplicating the initial read. + * @param listener - Callback invoked on every state change + * @returns Disposer that removes the listener + */ + onStateChange(listener: (state: { enabled: boolean; available: boolean }) => void): () => void { + this.stateChangeListeners.push(listener); + listener({ enabled: this.state.enabled, available: this.editingAvailable }); + + return () => { + const index = this.stateChangeListeners.indexOf(listener); + if (index !== -1) { + this.stateChangeListeners.splice(index, 1); + } + }; + } + + /** + * Notify all subscribers of the current edit mode state + */ + private emitStateChange(): void { + const snapshot = { enabled: this.state.enabled, available: this.editingAvailable }; + // Iterate a copy so listeners that unsubscribe during the callback are not skipped. + for (const listener of [...this.stateChangeListeners]) { + listener(snapshot); + } + } + /** * Initialize the edit mode controller * @param gridManager - GridStack manager instance @@ -258,6 +250,8 @@ export class EditModeController { console.warn('EditModeController: API not available to open palette'); } + this.emitStateChange(); + console.log('EditModeController: Edit mode enabled'); } catch (error) { console.error('EditModeController: Failed to enter edit mode:', error); @@ -311,6 +305,8 @@ export class EditModeController { console.warn('EditModeController: API not available to close palette'); } + this.emitStateChange(); + console.log('EditModeController: Edit mode disabled'); } catch (error) { console.error('EditModeController: Failed to exit edit mode:', error); @@ -458,9 +454,15 @@ export class EditModeController { * Set whether edit mode can be enabled (based on printer availability) */ setAvailability(available: boolean): void { + const changed = this.editingAvailable !== available; this.editingAvailable = available; if (!available && this.state.enabled) { + // exitEditMode emits its own state change with the updated availability this.exitEditMode(); + return; + } + if (changed) { + this.emitStateChange(); } } diff --git a/src/renderer/src/ui/gridstack/GridStackManager.ts b/src/renderer/src/ui/gridstack/GridStackManager.ts index 5636a4a0..5c115cdb 100644 --- a/src/renderer/src/ui/gridstack/GridStackManager.ts +++ b/src/renderer/src/ui/gridstack/GridStackManager.ts @@ -1,50 +1,10 @@ /** - * @fileoverview GridStack.js wrapper and manager for FlashForgeUI - * - * Provides a clean TypeScript API wrapper around GridStack.js for managing - * the dashboard grid. Handles widget lifecycle, drag-and-drop configuration, - * layout serialization, and event management. Follows the principle of "don't - * hack the framework" - all grid logic is delegated to GridStack with minimal - * abstraction overhead. - * - * Key exports: - * - GridStackManager: Main grid management class - * - gridStackManager: Singleton instance for application-wide use - * - * Features: - * - Widget add/remove/update operations with validation - * - Enable/disable editing mode (dragging and resizing) - * - Layout serialization/deserialization for persistence - * - External drag-in support (from component palette) - * - Event system (change, added, removed, dropped) - * - Batch operations for performance optimization - * - Grid compaction and layout cleanup - * - Lifecycle management and cleanup - * - * Usage: - * ```typescript - * import { gridStackManager } from './GridStackManager'; - * - * // Initialize grid with options - * gridStackManager.initialize({ column: 12, cellHeight: 80 }); - * - * // Add widget - * const element = createGridWidget('camera-preview'); - * gridStackManager.addWidget(widgetConfig, element); - * - * // Enable editing - * gridStackManager.enable(); - * - * // Listen for changes - * gridStackManager.onChange((widgets) => { - * console.log('Grid changed:', widgets); - * }); - * - * // Serialize current layout - * const layout = gridStackManager.serialize(); - * ``` - * - * @module ui/gridstack/GridStackManager + * @fileoverview GridStackManager wraps GridStack.js for the dashboard grid: + * widget add/remove/update, edit-mode enable/disable, layout serialization, + * and the change/added/removed/dropped events. Design rule: delegate to + * GridStack instead of working around it — no framework hacks. Supports + * external drag-in from the component palette. Exported as the singleton + * gridStackManager. */ import { logVerbose } from '@shared/logging.js'; diff --git a/src/renderer/src/ui/gridstack/__tests__/defaults.migration.test.ts b/src/renderer/src/ui/gridstack/__tests__/defaults.migration.test.ts new file mode 100644 index 00000000..88b39769 --- /dev/null +++ b/src/renderer/src/ui/gridstack/__tests__/defaults.migration.test.ts @@ -0,0 +1,57 @@ +/** + * @fileoverview Tests for legacy component-id migration in saved layouts. + * + * Component ids are persisted into stored grid layouts, so renaming a component + * requires mapping its old id on load (see `LEGACY_COMPONENT_ID_RENAMES`). These + * tests lock in the `ifs-station` -> `material-station` rename so an existing + * user's saved layout keeps its material-station widget instead of orphaning it. + */ + +import { + LEGACY_COMPONENT_ID_RENAMES, + mergeWithDefaults, + migrateComponentId, +} from '../defaults.js'; +import type { GridStackWidgetConfig, LayoutConfig } from '../types.js'; + +describe('migrateComponentId', () => { + it('maps the legacy ifs-station id to material-station', () => { + expect(migrateComponentId('ifs-station')).toBe('material-station'); + }); + + it('is the identity for the current id and unrelated ids', () => { + expect(migrateComponentId('material-station')).toBe('material-station'); + expect(migrateComponentId('camera-preview')).toBe('camera-preview'); + expect(migrateComponentId('temperature-controls')).toBe('temperature-controls'); + }); + + it('exposes the rename in LEGACY_COMPONENT_ID_RENAMES', () => { + expect(LEGACY_COMPONENT_ID_RENAMES['ifs-station']).toBe('material-station'); + }); +}); + +describe('mergeWithDefaults migration', () => { + const widget = (componentId: string): GridStackWidgetConfig => ({ + componentId, + x: 0, + y: 0, + w: 6, + h: 3, + }); + + it('rewrites a saved widget with the legacy ifs-station id', () => { + const saved: Partial = { + version: 1, + gridOptions: {}, + widgets: [widget('ifs-station'), widget('camera-preview')], + timestamp: new Date().toISOString(), + }; + + const merged = mergeWithDefaults(saved); + const ids = merged.widgets.map((w) => w.componentId); + + expect(ids).toContain('material-station'); + expect(ids).not.toContain('ifs-station'); + expect(ids).toContain('camera-preview'); // unrelated widget untouched + }); +}); diff --git a/src/renderer/src/ui/gridstack/defaults.ts b/src/renderer/src/ui/gridstack/defaults.ts index c09b36eb..098a283b 100644 --- a/src/renderer/src/ui/gridstack/defaults.ts +++ b/src/renderer/src/ui/gridstack/defaults.ts @@ -20,13 +20,110 @@ * - Right middle: Model preview (6w×3h, columns 6-11, rows 3-5) * - Right bottom: Job stats (6w×2h, columns 6-11, rows 6-7) * - Bottom: Status bar (4 components, 3w×1h each, row 8) - * - * @module ui/gridstack/defaults */ +import type { PrinterModelType } from '@shared/types/printer-backend/index.js'; import { getComponentDefinition } from './ComponentRegistry.js'; import type { GridOptions, GridStackWidgetConfig, LayoutConfig } from './types.js'; +/** + * Legacy → current component-id renames, applied when loading a saved layout. + * Component ids are persisted in stored layouts (and layout history), so renaming + * a component requires mapping its old id here or saved widgets would orphan. + * Add one entry per rename; keep old entries indefinitely. + */ +export const LEGACY_COMPONENT_ID_RENAMES: Readonly> = { + // The IFS station component was renamed to "material-station" (it also serves + // the Creator 5 series now, not just the AD5X IFS). + 'ifs-station': 'material-station', +}; + +/** Map a possibly-legacy component id to its current id (identity if unchanged). */ +export function migrateComponentId(componentId: string): string { + return LEGACY_COMPONENT_ID_RENAMES[componentId] ?? componentId; +} + +/** + * Current layout schema version. Bumped when a one-time, capability-conditional + * migration needs to run against a printer's persisted layout (see + * {@link migrateLayoutForToolChanger}). A layout is migrated at most once: after + * it runs, the layout is stamped with this version and saved, so it never repeats. + * + * v3: the v2 tool-changer migration could be silently dropped when it ran before the + * layout persistence layer was initialized, leaving early testers stamped at v2 but + * still showing the generic temperature card. Bumping to v3 forces the (now correctly + * timed) migration to run once more for those layouts; it is idempotent for layouts + * that already carry the Creator 5 card. + */ +export const LAYOUT_SCHEMA_VERSION = 3; + +/** The unified Creator 5 temperature card. */ +const C5_TEMP_CARD_ID = 'creator5-temperature'; +/** Generic/legacy temperature cards folded into the Creator 5 card on a tool-changer. */ +const C5_TEMP_REPLACED_IDS = new Set(['temperature-controls', 'tool-temps']); + +/** + * Whether a model type identifies a Creator 5 series tool-changer. Reads the + * authoritative PID-derived {@link PrinterModelType} (`creator-5` / `creator-5-pro`) + * rather than substring-matching the user-mutable model/name string. + */ +export function isCreator5ModelType(modelType: PrinterModelType | null | undefined): boolean { + return modelType === 'creator-5' || modelType === 'creator-5-pro'; +} + +/** + * One-time, version-gated migration for tool-changer printers (Creator 5 / 5 Pro). + * + * Replaces the generic `temperature-controls` and read-only `tool-temps` widgets + * with the single, settable `creator5-temperature` card (preserving the temp + * card's grid position; the card is a singleton so duplicates are collapsed). + * Non-tool-changer layouts and already-migrated layouts are returned unchanged + * (same reference), so callers can persist only when something actually changed. + * + * @param layout The loaded layout for a specific printer. + * @param isToolChanger Whether the connected printer is a Creator 5 series. + * @returns The migrated layout (new object) or the original (unchanged) reference. + */ +export function migrateLayoutForToolChanger( + layout: LayoutConfig, + isToolChanger: boolean +): LayoutConfig { + if (!isToolChanger || (layout.version ?? 1) >= LAYOUT_SCHEMA_VERSION) { + return layout; + } + + let hasCard = false; + const widgets: GridStackWidgetConfig[] = []; + for (const widget of layout.widgets) { + const id = migrateComponentId(widget.componentId); + + if (id === C5_TEMP_CARD_ID) { + if (hasCard) continue; // singleton — keep the first + hasCard = true; + widgets.push({ ...widget, componentId: id }); + continue; + } + + if (C5_TEMP_REPLACED_IDS.has(id)) { + if (hasCard) continue; // a C5 card already took a slot; drop the redundant one + const def = getComponentDefinition(C5_TEMP_CARD_ID); + widgets.push({ + ...widget, + componentId: C5_TEMP_CARD_ID, + minW: def?.minSize.w ?? widget.minW, + minH: def?.minSize.h ?? widget.minH, + id: `widget-${C5_TEMP_CARD_ID}`, + }); + hasCard = true; + continue; + } + + widgets.push(widget); + } + + return { ...layout, widgets, version: LAYOUT_SCHEMA_VERSION }; +} + /** * Default grid options matching current layout behavior */ @@ -206,17 +303,20 @@ export function mergeWithDefaults(userLayout: Partial): LayoutConf // Get user widgets or default widgets const baseWidgets = userLayout.widgets ?? defaultLayout.widgets; - // Apply ComponentRegistry minSize values to each widget + // Migrate any legacy component ids first (saved layouts may reference old ids), + // then apply current ComponentRegistry minSize values to each widget. const widgetsWithUpdatedMinSize = baseWidgets.map((widget) => { - const componentDef = getComponentDefinition(widget.componentId); + const componentId = migrateComponentId(widget.componentId); + const migrated = componentId === widget.componentId ? widget : { ...widget, componentId }; + const componentDef = getComponentDefinition(componentId); if (componentDef) { return { - ...widget, + ...migrated, minW: componentDef.minSize.w, minH: componentDef.minSize.h, }; } - return widget; + return migrated; }); return { diff --git a/src/renderer/src/ui/job-picker/job-picker-preload.ts b/src/renderer/src/ui/job-picker/job-picker-preload.ts index 141d32c2..57b262e4 100644 --- a/src/renderer/src/ui/job-picker/job-picker-preload.ts +++ b/src/renderer/src/ui/job-picker/job-picker-preload.ts @@ -49,6 +49,9 @@ const jobPickerAPI = { getFeatures: async (): Promise => { return await ipcRenderer.invoke('printer:get-features'); }, + getSpoolmanStatus: async (): Promise => { + return await ipcRenderer.invoke('spoolman:get-status'); + }, getLocalJobs: async (): Promise<{ success: boolean; jobs: readonly unknown[]; error?: string }> => { return (await ipcRenderer.invoke('job-picker:get-local-jobs')) as { success: boolean; @@ -65,7 +68,12 @@ const jobPickerAPI = { }, startJob: async ( fileName: string, - options: { leveling: boolean; startNow: boolean; materialMappings?: unknown[] } + options: { + leveling: boolean; + startNow: boolean; + materialMappings?: unknown[]; + spoolAssignments?: Array<{ toolId: number; spoolId: number | null }>; + } ): Promise<{ success: boolean; error?: string }> => { return (await ipcRenderer.invoke('job-picker:start-job', fileName, options)) as { success: boolean; @@ -79,6 +87,7 @@ const jobPickerAPI = { fileName: string; toolDatas: readonly unknown[]; leveling: boolean; + trackSpools?: boolean; }): Promise => { const payload = { ...data, context: 'job-start' as const }; return (await ipcRenderer.invoke('show-material-matching-dialog', payload)) as unknown[] | null; diff --git a/src/renderer/src/ui/job-picker/job-picker-renderer.ts b/src/renderer/src/ui/job-picker/job-picker-renderer.ts index 5139223c..39e05805 100644 --- a/src/renderer/src/ui/job-picker/job-picker-renderer.ts +++ b/src/renderer/src/ui/job-picker/job-picker-renderer.ts @@ -20,8 +20,10 @@ // Handles file grid display, selection, and thumbnail loading import { logVerbose } from '@shared/logging.js'; -import type { AD5XJobInfo } from '@shared/types/printer-backend/index.js'; +import type { AD5XJobInfo, PrinterModelType } from '@shared/types/printer-backend/index.js'; +import { isCreator5ModelType } from '../gridstack/defaults.js'; import { initializeLucideIconsFromGlobal } from '../shared/lucide.js'; +import { describeStoredFileTracking } from './stored-file-tracking.js'; interface JobPickerAPI { readonly onInit: (callback: (data: JobPickerInitData) => void) => void; @@ -31,17 +33,24 @@ interface JobPickerAPI { readonly selectJob: (data: JobSelectionData) => void; readonly requestThumbnail: (filename: string) => void; readonly getFeatures: () => Promise; + readonly getSpoolmanStatus: () => Promise; readonly getLocalJobs: () => Promise<{ success: boolean; jobs: readonly unknown[]; error?: string }>; readonly getRecentJobs: () => Promise<{ success: boolean; jobs: readonly unknown[]; error?: string }>; readonly startJob: ( fileName: string, - options: { leveling: boolean; startNow: boolean; materialMappings?: unknown[] } + options: { + leveling: boolean; + startNow: boolean; + materialMappings?: unknown[]; + spoolAssignments?: Array<{ toolId: number; spoolId: number | null }>; + } ) => Promise<{ success: boolean; error?: string }>; readonly showMaterialInfo: (data: MaterialInfoData) => void; readonly showMaterialMatching: (data: { fileName: string; toolDatas: readonly unknown[]; leveling: boolean; + trackSpools?: boolean; }) => Promise; readonly showSingleColorConfirmation: (data: { fileName: string; leveling: boolean }) => Promise; } @@ -98,12 +107,23 @@ const logDebug = (message: string, ...args: unknown[]): void => { let selectedFile: string | null = null; let printerCapabilities: PrinterJobManagementFeatures | null = null; let selectedPrinterModel: string | null = null; +// Whether the connected printer has a material station (IFS). Drives the +// material-matching / single-color-confirmation flow. Derived from the printer's +// capabilities rather than the model name, so every material-station printer +// (AD5X, Creator 5 / 5 Pro, and any future model) is handled the same way. +let hasMaterialStation = false; let currentJobsData: readonly unknown[] = []; // Store full job data for AD5X +// Spoolman tracking inputs for the stored-file indicator (null until the +// status IPC resolves; the indicator is hidden when Spoolman is disabled). +let spoolmanStatusInputs: { + readonly spoolmanEnabled: boolean; +} | null = null; // Type definitions for printer features interface PrinterFeatures { readonly jobManagement: PrinterJobManagementFeatures; readonly modelType?: string; + readonly materialStation?: { readonly available?: boolean }; } interface PrinterJobManagementFeatures { @@ -167,6 +187,17 @@ function isMultiColorJob(job: AD5XJobInfo): boolean { return !!(job.toolDatas && job.toolDatas.length > 0); } +/** Minimal shape of the `spoolman:get-status` payload used by the indicator. */ +interface SpoolmanStatusShape { + readonly enabled: boolean; +} + +function isSpoolmanStatus(value: unknown): value is SpoolmanStatusShape { + if (!value || typeof value !== 'object') return false; + const obj = value as Record; + return 'enabled' in obj && typeof obj.enabled === 'boolean'; +} + // DOM element references let loadingElement: HTMLElement | null = null; let fileListElement: HTMLElement | null = null; @@ -208,6 +239,17 @@ function initializeJobPicker(): void { // The main process will tell us whether to load recent or local files } +/** + * Whether the connected printer is a Creator 5 series tool-changer. Mirrors the + * desktop controls-grid guard: resident local/recent jobs cannot be started on + * these models because the firmware accepts no material mappings over the local + * API. The IPC-supplied model type is a loose string on the wire, so it is cast + * to the authoritative {@link PrinterModelType} before the centralized check. + */ +function isCreator5Series(): boolean { + return isCreator5ModelType(selectedPrinterModel as PrinterModelType | null); +} + /** * Initialize dialog by checking capabilities and loading jobs * @param isRecentFiles - Whether to load recent files or local files @@ -215,6 +257,14 @@ function initializeJobPicker(): void { async function initializeDialog(isRecentFiles: boolean): Promise { await checkPrinterCapabilities(); + // Defensive: Creator 5 series cannot start resident local/recent jobs. The + // desktop controls-grid disables the entry buttons; this catches a picker + // reached via stale IPC and avoids opening an unusable file list. + if (isCreator5Series()) { + showMessage('Local job management is not available on this printer.'); + return; + } + // Load the appropriate type of jobs based on the parameter if (isRecentFiles && printerCapabilities?.recentJobs) { @@ -223,7 +273,7 @@ async function initializeDialog(isRecentFiles: boolean): Promise { await loadLocalJobs(); } else { // Show appropriate message for unsupported features - showCapabilityMessage(isRecentFiles); + showCapabilityMessage(); } } @@ -259,8 +309,26 @@ async function checkPrinterCapabilities(): Promise { selectedPrinterModel = features.modelType; } + // Capability-based material-station detection (AD5X, Creator 5 / 5 Pro, …). + hasMaterialStation = features.materialStation?.available === true; + + // Spoolman inputs for the stored-file tracking indicator. Best-effort: + // a missing/unavailable status simply hides the indicator. + try { + const spoolmanStatus = await api.getSpoolmanStatus(); + if (isSpoolmanStatus(spoolmanStatus)) { + spoolmanStatusInputs = { + spoolmanEnabled: spoolmanStatus.enabled === true, + }; + logDebug('Job picker: Spoolman status loaded (enabled:', spoolmanStatusInputs.spoolmanEnabled, ')'); + } + } catch (spoolmanError) { + logDebug('Job picker: Spoolman status unavailable', spoolmanError); + } + logDebug('Job picker: Printer capabilities loaded', printerCapabilities); logDebug('Job picker: Printer model type:', selectedPrinterModel); + logDebug('Job picker: Has material station:', hasMaterialStation); } else { console.error('Job picker: Invalid features format received'); } @@ -502,6 +570,28 @@ function createFileItem(filename: string): HTMLElement { fileItem.appendChild(thumbnail); fileItem.appendChild(filenameElement); + // Spoolman tracked/untracked indicator for stored files (station printers). + // Mirrors the file modal in the WebUI: hidden when Spoolman is disabled or + // the printer has no material station; see stored-file-tracking.ts. + const trackingHint = describeStoredFileTracking( + isAD5XJobInfo(jobData) ? jobData : undefined, + { + spoolmanEnabled: spoolmanStatusInputs?.spoolmanEnabled === true, + hasStation: hasMaterialStation, + } + ); + if (trackingHint) { + const trackingElement = document.createElement('div'); + trackingElement.className = `spoolman-track-hint spoolman-track-hint--${ + trackingHint.tracked ? '' : 'un' + }tracked`; + trackingElement.textContent = trackingHint.label; + if (trackingHint.tooltip) { + trackingElement.title = trackingHint.tooltip; + } + fileItem.appendChild(trackingElement); + } + // Add click handler for selection fileItem.addEventListener('click', () => handleFileSelection(filename, fileItem)); @@ -611,20 +701,29 @@ async function handleSelectJob(): Promise { return; } + // Defensive: Creator 5 series cannot start resident local/recent jobs. + if (isCreator5Series()) { + showMessage('Local job management is not available on this printer.'); + return; + } + // Check if job starting is supported if (!printerCapabilities?.startJobs) { showMessage('Job starting is not available for your printer model'); return; } - // For AD5X printers, check if this is a multi-color job + // For material-station printers (AD5X, Creator 5 / 5 Pro), check if this is a + // multi-color job that needs material matching before starting. logDebug( - 'Job picker: Checking for AD5X multi-color - model:', + 'Job picker: Checking for material-station multi-color - model:', selectedPrinterModel, + ', hasMaterialStation:', + hasMaterialStation, ', startNow:', startNowCheckbox.checked ); - if (selectedPrinterModel === 'ad5x' && startNowCheckbox.checked) { + if (hasMaterialStation && startNowCheckbox.checked) { const jobData = findJobDataByFilename(selectedFile); logDebug('Job picker: Found job data for', selectedFile, ':', jobData); @@ -648,13 +747,15 @@ async function handleSelectJob(): Promise { logDebug('Job picker: AD5X multi-color job detected, showing material matching'); try { - const materialMappings = await api.showMaterialMatching({ + const confirmedMappings = await api.showMaterialMatching({ fileName: selectedFile, toolDatas: jobData.toolDatas || [], leveling: levelingCheckbox.checked, + // The job starts now, so the dialog asks for a Spoolman spool per tool. + trackSpools: true, }); - if (!materialMappings) { + if (!confirmedMappings) { // User cancelled return; } @@ -665,10 +766,20 @@ async function handleSelectJob(): Promise { loadingElement.innerHTML = '
Starting multi-color job...
'; } + // The dialog attaches the Spoolman spool choice to each mapping. Split + // it off: the printer must receive plain mappings only. + type MappingWithSpool = Record & { toolId: number; spoolId?: number | null }; + const withSpools = confirmedMappings as MappingWithSpool[]; + const materialMappings = withSpools.map(({ spoolId: _spoolId, ...mapping }) => mapping); + const spoolAssignments = withSpools + .filter((mapping) => mapping.spoolId !== undefined) + .map((mapping) => ({ toolId: mapping.toolId, spoolId: mapping.spoolId ?? null })); + const result = await api.startJob(selectedFile, { leveling: levelingCheckbox.checked, startNow: true, materialMappings, + spoolAssignments: spoolAssignments.length > 0 ? spoolAssignments : undefined, }); if (result.success) { @@ -819,11 +930,8 @@ async function loadRecentJobs(): Promise { /** * Show capability message when feature is not supported */ -function showCapabilityMessage(isRecentFiles: boolean): void { - const message = isRecentFiles - ? 'Recent job management is not available for your printer model' - : 'Local job management is not available for your printer model'; - showErrorState(message); +function showCapabilityMessage(): void { + showErrorState('Local job management is not available on this printer.'); } /** diff --git a/src/renderer/src/ui/job-picker/job-picker.css b/src/renderer/src/ui/job-picker/job-picker.css index 5fc79ac8..c408b66c 100644 --- a/src/renderer/src/ui/job-picker/job-picker.css +++ b/src/renderer/src/ui/job-picker/job-picker.css @@ -126,6 +126,16 @@ max-width: 100%; } +/* Spoolman tracked/untracked indicator for stored files (station printers). */ +.spoolman-track-hint--tracked { + color: var(--theme-success, #4caf50); +} + +.spoolman-track-hint--untracked { + color: color-mix(in srgb, var(--theme-text) 55%, transparent); + cursor: help; +} + /* Template provides: .dialog-footer styles */ .options { diff --git a/src/renderer/src/ui/job-picker/stored-file-tracking.ts b/src/renderer/src/ui/job-picker/stored-file-tracking.ts new file mode 100644 index 00000000..33cbb1f3 --- /dev/null +++ b/src/renderer/src/ui/job-picker/stored-file-tracking.ts @@ -0,0 +1,70 @@ +/** + * @fileoverview Tracked/untracked indicator for stored-file prints on + * material-station printers (AD5X with station), desktop job-picker edition. + * + * Mirrors the main-process rule in src/main/services/job-tracking.ts so the + * job picker can tell the user, before they start a job stored on the + * printer, whether Spoolman will track it. The desktop picker opens the + * matching dialog for every file with per-tool data, and the dialog asks for + * a spool per tool, so such a file can be tracked. A file without per-tool + * data or weights cannot. + * + * The webui static client keeps its own copy + * (src/main/webui/static/shared/stored-file-tracking.ts) because the two run + * in separate bundles; the predicate is duplicated on purpose (same situation + * as isPrintAdvancing). Change all copies together. + */ + +import type { AD5XJobInfo } from '@shared/types/printer-backend/backend-operations'; + +/** Rendered indicator for one stored file. Null = show nothing. */ +export interface StoredFileTrackingHint { + readonly tracked: boolean; + readonly label: string; + readonly tooltip?: string; +} + +const TRACKABLE_LABEL = 'Spoolman: choose spools when you match materials'; +const UNTRACKED_LABEL = 'Spoolman: not tracked · upload via app for tracking'; + +/** + * Compute the tracking indicator for a stored file. + * + * @param job - AD5X file metadata from the recent/local job list + * @param options - Spoolman enablement and station support for the context + * @returns the hint to render, or null when tracking is not applicable + */ +export function describeStoredFileTracking( + job: AD5XJobInfo | undefined, + options: { + spoolmanEnabled: boolean; + hasStation: boolean; + } +): StoredFileTrackingHint | null { + if (!options.spoolmanEnabled || !options.hasStation) { + return null; + } + + // Only AD5X file metadata is rich enough; Creator 5 stored files cannot be + // started from the app and non-station printers use the single-spool flow. + if (!job || job._type !== 'ad5x') { + return null; + } + + const toolDatas = job.toolDatas ?? []; + if (toolDatas.length === 0) { + return { + tracked: false, + label: UNTRACKED_LABEL, + tooltip: 'the printer reports no per-tool data for this file', + }; + } + if (!toolDatas.some((tool) => tool.filamentWeight > 0)) { + return { + tracked: false, + label: UNTRACKED_LABEL, + tooltip: 'the printer reports no filament weight for this file', + }; + } + return { tracked: true, label: TRACKABLE_LABEL }; +} diff --git a/src/renderer/src/ui/job-uploader/job-uploader-preload.ts b/src/renderer/src/ui/job-uploader/job-uploader-preload.ts index 8657be8e..ae94e3f1 100644 --- a/src/renderer/src/ui/job-uploader/job-uploader-preload.ts +++ b/src/renderer/src/ui/job-uploader/job-uploader-preload.ts @@ -78,7 +78,11 @@ interface JobUploaderAPI { receiveMetadata: (func: (result: MetadataResult) => void) => void; removeListeners: () => void; // New methods for 3MF multi-color support - showMaterialMatchingDialog: (filePath: string, toolData: FFGcodeToolData[]) => Promise; + showMaterialMatchingDialog: ( + filePath: string, + toolData: FFGcodeToolData[], + trackSpools?: boolean + ) => Promise; showSingleColorDialog: (filePath: string, filament: FilamentInfo) => void; uploadFileAD5X: ( filePath: string, @@ -87,7 +91,8 @@ interface JobUploaderAPI { materialMappings?: AD5XMaterialMapping[] ) => Promise; // Helper methods - isAD5XPrinter: () => Promise; + // True when the connected printer has a material station (AD5X, Creator 5 / 5 Pro, …). + hasMaterialStation: () => Promise; // Progress reporting methods receiveUploadProgress: (func: (progress: UploadProgress) => void) => void; receiveUploadComplete: (func: (result: UploadCompletionResult) => void) => void; @@ -101,11 +106,22 @@ const uploadFileAD5X = async ( materialMappings?: AD5XMaterialMapping[] ): Promise => { try { + // The matching dialog attaches the Spoolman spool choice to each mapping. + // Split it off: the printer must receive plain mappings only. + type MappingWithSpool = AD5XMaterialMapping & { spoolId?: number | null }; + const withSpools = (materialMappings ?? []) as MappingWithSpool[]; + const spoolAssignments = withSpools + .filter((mapping) => mapping.spoolId !== undefined) + .map((mapping) => ({ toolId: mapping.toolId, spoolId: mapping.spoolId ?? null })); + const plainMappings = materialMappings + ? withSpools.map(({ spoolId: _spoolId, ...mapping }) => mapping) + : undefined; const result = (await ipcRenderer.invoke('upload-file-ad5x', { filePath, startPrint: startNow, levelingBeforePrint: autoLevel, - materialMappings, + materialMappings: plainMappings, + spoolAssignments: spoolAssignments.length > 0 ? spoolAssignments : undefined, })) as AD5XUploadResult; return result; } catch (error) { @@ -149,7 +165,8 @@ const jobUploaderAPI: JobUploaderAPI = { // New methods for 3MF multi-color support showMaterialMatchingDialog: async ( filePath: string, - toolData: FFGcodeToolData[] + toolData: FFGcodeToolData[], + trackSpools = false ): Promise => { const fileName = filePath.split(/[\\/]/).pop() || filePath; const dialogData = { @@ -157,6 +174,7 @@ const jobUploaderAPI: JobUploaderAPI = { toolDatas: toolData, leveling: true, // Default to enabled context: 'file-upload' as const, // Specify context for button text + trackSpools, }; try { @@ -207,13 +225,13 @@ const jobUploaderAPI: JobUploaderAPI = { uploadFileAD5X, - isAD5XPrinter: async (): Promise => { + hasMaterialStation: async (): Promise => { try { - const isAD5X = (await ipcRenderer.invoke('is-ad5x-printer')) as boolean; - console.log('isAD5XPrinter result:', isAD5X); - return isAD5X; + const result = (await ipcRenderer.invoke('has-material-station')) as boolean; + console.log('hasMaterialStation result:', result); + return result; } catch (error) { - console.warn('Error checking AD5X printer status:', error); + console.warn('Error checking material-station capability:', error); return false; } }, diff --git a/src/renderer/src/ui/job-uploader/job-uploader-renderer.ts b/src/renderer/src/ui/job-uploader/job-uploader-renderer.ts index 6e910407..26d71211 100644 --- a/src/renderer/src/ui/job-uploader/job-uploader-renderer.ts +++ b/src/renderer/src/ui/job-uploader/job-uploader-renderer.ts @@ -73,6 +73,8 @@ interface DialogElements { firstLayerTime: HTMLElement | null; warningsContainer: HTMLElement | null; warningsList: HTMLElement | null; + mappingSummary: HTMLElement | null; + mappingSummaryList: HTMLElement | null; okButton: HTMLButtonElement | null; cancelButton: HTMLButtonElement | null; closeButton: HTMLButtonElement | null; @@ -105,8 +107,11 @@ const getJobUploaderAPI = (): JobUploaderAPI => { * expected by the material matching dialog */ function convertFilamentsToToolData(filaments: FilamentInfo[]): FFGcodeToolData[] { + // The tool id is the filament's gcode tool (T0-T3), not its list position: + // a 3MF lists only the filaments a plate uses, each with its 1-based slicer + // id, so a plate that uses filaments 1 and 3 prints with T0 and T2. return filaments.map((filament, index) => ({ - toolId: index, + toolId: toolIdForFilament(filament.id, index), materialName: filament.type || 'Unknown', materialColor: filament.color || '#FFFFFF', // Default to white if no color specified filamentWeight: parseFloat(filament.usedG || '0'), @@ -114,12 +119,18 @@ function convertFilamentsToToolData(filaments: FilamentInfo[]): FFGcodeToolData[ })); } +/** Gcode tool index for a 3MF filament id (1-based), or the list position without one. */ +function toolIdForFilament(id: string | null | undefined, index: number): number { + const parsed = Number.parseInt(String(id ?? ''), 10); + return Number.isInteger(parsed) && parsed >= 1 ? parsed - 1 : index; +} + /** * Determine if file is a 3MF and should use enhanced upload flow */ async function shouldUseEnhanced3MFFlow(api: JobUploaderAPI, filePath: string): Promise { - const isAD5X = await api.isAD5XPrinter(); - if (!isAD5X) { + const hasMaterialStation = await api.hasMaterialStation(); + if (!hasMaterialStation) { return false; } @@ -173,6 +184,8 @@ document.addEventListener('DOMContentLoaded', (): void => { firstLayerTime: document.getElementById('meta-first-layer-time'), warningsContainer: document.getElementById('warnings-container'), warningsList: document.getElementById('meta-warnings'), + mappingSummary: document.getElementById('mapping-summary'), + mappingSummaryList: document.getElementById('mapping-summary-list'), okButton: document.getElementById('btn-ok') as HTMLButtonElement, cancelButton: document.getElementById('btn-cancel') as HTMLButtonElement, closeButton: document.getElementById('btn-close') as HTMLButtonElement, @@ -206,6 +219,7 @@ document.addEventListener('DOMContentLoaded', (): void => { // Initialize UI state resetMetadata(elements); setOKButtonState(elements, false); + updatePrimaryButtonLabel(elements); console.log('Job Uploader Dialog initialized successfully'); }); @@ -229,6 +243,13 @@ function setupEventHandlers(elements: DialogElements, api: JobUploaderAPI): void }); } + // "Start Now" decides whether the primary button sends or prints + if (elements.startNowCheckbox) { + elements.startNowCheckbox.addEventListener('change', (): void => { + updatePrimaryButtonLabel(elements); + }); + } + // Cancel button click handler if (elements.cancelButton) { elements.cancelButton.addEventListener('click', (): void => { @@ -277,6 +298,7 @@ function handleFileSelected(elements: DialogElements, filePath: string | null): // Clear any previously saved material mappings when a new file is selected savedMaterialMappings = null; + renderMappingSummary(elements); if (filePath) { currentFilePath = filePath; @@ -320,17 +342,17 @@ async function handleMetadataResult( showLoading(elements, false); if (result && !result.error) { - // Check if this is an AD5X printer with a non-3MF file - const isAD5X = await api.isAD5XPrinter(); + // Material-station printers (AD5X, Creator 5 / 5 Pro) only accept 3MF files, + // which carry the per-tool material data the firmware needs for matching. + const hasMaterialStation = await api.hasMaterialStation(); const is3MF = currentFilePath?.toLowerCase().endsWith('.3mf'); - if (isAD5X && !is3MF) { - // AD5X printers only support 3MF files + if (hasMaterialStation && !is3MF) { const fileName = currentFilePath?.split(/[\\/]/).pop() || 'file'; const fileExtension = fileName.split('.').pop()?.toUpperCase() || 'unknown'; if (elements.filePathDisplay) { - elements.filePathDisplay.textContent = 'Unsupported file type for AD5X printer'; + elements.filePathDisplay.textContent = 'Unsupported file type for this printer'; } resetMetadata(elements); @@ -338,7 +360,7 @@ async function handleMetadataResult( currentFilePath = null; alert( - `AD5X printers only support 3MF files.\n\nThe selected ${fileExtension} file cannot be uploaded to this printer.\n\nPlease select a 3MF file that has been sliced specifically for the AD5X printer.` + `This printer only supports 3MF files.\n\nThe selected ${fileExtension} file cannot be uploaded to this printer.\n\nPlease select a 3MF file sliced for this printer.` ); return; } @@ -364,16 +386,24 @@ async function handleMetadataResult( const toolData = convertFilamentsToToolData(filaments); if (api.showMaterialMatchingDialog) { - const mappings = await api.showMaterialMatchingDialog(currentFilePath, toolData); + // A spool per tool is asked only when the job starts now: a file + // sent without Start Now is never Spoolman-tracked. + const mappings = await api.showMaterialMatchingDialog( + currentFilePath, + toolData, + elements.startNowCheckbox?.checked ?? false + ); if (mappings && Array.isArray(mappings)) { // Save material mappings for later use savedMaterialMappings = mappings; console.log('Material mappings saved:', mappings); // Enable OK button now that mappings are confirmed + renderMappingSummary(elements); setOKButtonState(elements, true); } else { // User cancelled - disable OK button console.log('Material matching cancelled by user'); + renderMappingSummary(elements); setOKButtonState(elements, false); } return; // Don't enable OK button here, handled above @@ -410,14 +440,14 @@ async function handleMetadataResult( async function handleUploadJob(elements: DialogElements, api: JobUploaderAPI): Promise { if (!currentFilePath) return; - // Check if this should use AD5X upload path - const isAD5X = await api.isAD5XPrinter(); + // Material-station printers (AD5X, Creator 5 / 5 Pro) use the material-aware + // 3MF upload path; everything else uses the regular upload below. + const hasMaterialStation = await api.hasMaterialStation(); const is3MF = currentFilePath.toLowerCase().endsWith('.3mf'); - const useAD5XUpload = isAD5X && is3MF; + const useAD5XUpload = hasMaterialStation && is3MF; if (useAD5XUpload) { - // Use AD5X upload for 3MF files on AD5X printer - console.log('Using AD5X upload for 3MF file'); + console.log('Using material-station 3MF upload path'); try { const result = await api.uploadFileAD5X( @@ -434,12 +464,12 @@ async function handleUploadJob(elements: DialogElements, api: JobUploaderAPI): P console.error('AD5X upload failed:', error); alert(`Upload failed: ${error instanceof Error ? error.message : String(error)}`); } - } else if (isAD5X && !is3MF) { + } else if (hasMaterialStation && !is3MF) { // This should not happen due to earlier validation, but safety check - alert('AD5X printers only support 3MF files. Please select a valid 3MF file.'); + alert('This printer only supports 3MF files. Please select a valid 3MF file.'); return; } else { - // Use regular upload for non-AD5X printers or non-3MF files + // Use regular upload for printers without a material station const payload: UploadJobPayload = { filePath: currentFilePath, startNow: elements.startNowCheckbox?.checked || false, @@ -571,28 +601,18 @@ function populateMetadata(elements: DialogElements, data: MetadataResult): void elements.eta.textContent = data.slicer?.printEta || '-'; } - // New Print Information Fields - Use available data or fallbacks + // New Print Information Fields - from slicer-meta 1.4.0 (Orca-family; null otherwise) if (elements.layerHeight) { - // Try to get layer height from threeMf or file with proper type checking - const threeMfData = data.threeMf as Record | undefined; - const fileData = data.file as Record | undefined; - const layerHeight = - (threeMfData?.layerHeight as number | undefined) || (fileData?.layerHeight as number | undefined); - elements.layerHeight.textContent = layerHeight ? `${layerHeight} mm` : '-'; + const layerHeight = data.file?.layerHeight; + elements.layerHeight.textContent = layerHeight != null ? `${layerHeight} mm` : '-'; } if (elements.infill) { - // Try to get infill from threeMf or file with proper type checking - const threeMfData = data.threeMf as Record | undefined; - const fileData = data.file as Record | undefined; - const infill = (threeMfData?.infill as number | undefined) || (fileData?.infill as number | undefined); - elements.infill.textContent = infill ? `${infill}%` : '-'; + const infill = data.file?.infillDensity; + elements.infill.textContent = infill != null ? `${infill}%` : '-'; } if (elements.layers) { - // Try to get layer count from threeMf or file with proper type checking - const threeMfData = data.threeMf as Record | undefined; - const fileData = data.file as Record | undefined; - const layerCount = (threeMfData?.layerCount as number | undefined) || (fileData?.layers as number | undefined); - elements.layers.textContent = layerCount ? layerCount.toString() : '-'; + const layerCount = data.file?.layerCount; + elements.layers.textContent = layerCount != null ? layerCount.toString() : '-'; } // First Layer Time (3MF only - from slicer-meta 1.2.0) @@ -653,6 +673,10 @@ function resetMetadata(elements: DialogElements): void { } }); + // A reset display has no file behind it, so any mappings are stale + savedMaterialMappings = null; + renderMappingSummary(elements); + // Reset warnings container if (elements.warningsContainer) { elements.warningsContainer.style.display = 'none'; @@ -684,6 +708,72 @@ function setOKButtonState(elements: DialogElements, enabled: boolean): void { } } +/** + * Name the primary button after what pressing it does: with "Start Now" ticked + * the printer begins the job, otherwise the file is only transferred. Both web + * UIs carry the same labels. + */ +function updatePrimaryButtonLabel(elements: DialogElements): void { + if (elements.okButton) { + elements.okButton.textContent = elements.startNowCheckbox?.checked ? 'Start Print' : 'Send File'; + } +} + +/** + * Show the confirmed tool -> slot pairs on the main screen so they can be + * checked at a glance before the job is sent. + */ +function renderMappingSummary(elements: DialogElements): void { + const { mappingSummary, mappingSummaryList } = elements; + if (!mappingSummary || !mappingSummaryList) { + return; + } + + mappingSummaryList.textContent = ''; + + if (!savedMaterialMappings || savedMaterialMappings.length === 0) { + mappingSummary.style.display = 'none'; + return; + } + + savedMaterialMappings.forEach((mapping) => { + const chip = document.createElement('div'); + chip.className = 'mapping-chip'; + + chip.appendChild(createMappingSwatch(mapping.toolMaterialColor)); + chip.appendChild(createMappingText('mapping-chip-label', `Tool ${mapping.toolId + 1}`)); + chip.appendChild(createMappingText('mapping-chip-arrow', '→')); + chip.appendChild(createMappingSwatch(mapping.slotMaterialColor)); + chip.appendChild(createMappingText('mapping-chip-label', `Slot ${mapping.slotId}`)); + chip.appendChild(createMappingText('mapping-chip-material', mapping.materialName || 'Unknown')); + + const spoolId = (mapping as { spoolId?: number | null }).spoolId; + if (spoolId !== undefined) { + chip.appendChild( + createMappingText('mapping-chip-spool', spoolId === null ? 'Not tracked' : `Spool #${spoolId}`) + ); + } + + mappingSummaryList.appendChild(chip); + }); + + mappingSummary.style.display = 'block'; +} + +function createMappingSwatch(color: string): HTMLSpanElement { + const swatch = document.createElement('span'); + swatch.className = 'mapping-chip-swatch'; + swatch.style.backgroundColor = color || '#cccccc'; + return swatch; +} + +function createMappingText(className: string, text: string): HTMLSpanElement { + const element = document.createElement('span'); + element.className = className; + element.textContent = text; + return element; +} + /** * Show or hide upload progress overlay */ diff --git a/src/renderer/src/ui/job-uploader/job-uploader.css b/src/renderer/src/ui/job-uploader/job-uploader.css index 08b2eef8..58a34012 100644 --- a/src/renderer/src/ui/job-uploader/job-uploader.css +++ b/src/renderer/src/ui/job-uploader/job-uploader.css @@ -74,6 +74,83 @@ color: var(--theme-text); } +/* Warnings Card */ +.warnings-card { + margin-bottom: 20px; + padding: 12px 16px; + background: var(--surface-elevated); + border: 1px solid var(--border-color); + border-radius: 8px; +} + +.warnings-card-title { + margin: 0 0 8px; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: color-mix(in srgb, var(--theme-text) 70%, transparent); +} + +/* Material Mapping Summary */ +.mapping-summary { + margin-bottom: 20px; + padding: 12px 16px; + background: var(--surface-elevated); + border: 1px solid var(--border-color); + border-radius: 8px; +} + +.mapping-summary-title { + margin: 0 0 8px; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--theme-text-secondary, var(--theme-text)); +} + +.mapping-summary-list { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.mapping-chip { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + background: var(--surface-muted); + border: 1px solid var(--border-color); + border-radius: 999px; + font-size: 13px; + color: var(--theme-text); +} + +.mapping-chip-swatch { + width: 12px; + height: 12px; + border-radius: 50%; + border: 1px solid var(--border-color); + flex-shrink: 0; +} + +.mapping-chip-arrow { + opacity: 0.7; +} + +.mapping-chip-spool { + padding-left: 4px; + opacity: 0.7; + font-style: italic; +} + +.mapping-chip-material { + padding-left: 4px; + opacity: 0.7; +} + input[type="checkbox"] { cursor: pointer; accent-color: var(--theme-primary); @@ -189,15 +266,25 @@ input[type="checkbox"] { padding: 20px; display: grid; grid-template-columns: 1fr 1fr; - grid-template-rows: repeat(4, auto); - gap: 16px 24px; + gap: 14px 20px; + align-content: start; overflow-y: auto; - scrollbar-width: none; - -ms-overflow-style: none; + /* A slim but visible scrollbar: the pane clips its content in a window the + user cannot resize, so hiding it left no sign there was more to read. */ + scrollbar-width: thin; } .metadata-section::-webkit-scrollbar { - display: none; + width: 8px; +} + +.metadata-section::-webkit-scrollbar-thumb { + background: var(--border-color-light); + border-radius: 4px; +} + +.metadata-section::-webkit-scrollbar-track { + background: transparent; } .metadata-group { @@ -219,23 +306,25 @@ input[type="checkbox"] { word-break: break-word; } -/* Print Time Highlight in Metadata Grid */ +/* Print Time Highlight - full-width banner at the top of the metadata card */ .print-time-highlight { + grid-column: 1 / -1; + order: -1; background: color-mix(in srgb, var(--theme-primary) 10%, transparent); border: 1px solid color-mix(in srgb, var(--theme-primary) 30%, transparent); border-radius: 6px; - padding: 16px; - text-align: center; + padding: 10px 16px; display: flex; - flex-direction: column; + flex-direction: row; + align-items: baseline; justify-content: center; - min-height: 60px; + gap: 10px; } .print-time-highlight .group-title { color: color-mix(in srgb, var(--theme-text) 70%, transparent); font-size: 13px; - margin-bottom: 8px; + margin-bottom: 0; font-weight: 500; } @@ -400,25 +489,39 @@ input[type="checkbox"] { } /* Responsive design */ -@media (max-width: 950px) { +/* The window itself is 950px wide (min 875), so this breakpoint sits below it: + at 950px the stacked layout was applying to every normal-sized dialog. */ +@media (max-width: 860px) { .content { grid-template-columns: 1fr; - grid-template-rows: auto auto auto auto; + grid-template-rows: auto auto; gap: 12px; } + .preview-section { + grid-column: 1; + grid-row: 1; + } + + .metadata-section { + grid-column: 1; + grid-row: 2; + grid-template-columns: 1fr; + } + .column { padding: 12px; } + /* The file row and the options row stay inline - stacking them wasted a + full-width button on "Browse..." and turned two checkboxes into a list. */ .file-section { - flex-direction: column; gap: 8px; } .options-section { - flex-direction: column; - gap: 12px; + flex-wrap: wrap; + gap: 20px; } .preview-column .thumbnail { diff --git a/src/renderer/src/ui/job-uploader/job-uploader.html b/src/renderer/src/ui/job-uploader/job-uploader.html index a1ff2a7f..e3d72c8e 100644 --- a/src/renderer/src/ui/job-uploader/job-uploader.html +++ b/src/renderer/src/ui/job-uploader/job-uploader.html @@ -35,6 +35,18 @@
+ + + + + +
@@ -90,10 +102,25 @@

Estimated Print Time:

First Layer Time:

-
- - @@ -102,7 +129,7 @@

Warnings:

diff --git a/src/renderer/src/ui/manual-connect-dialog/manual-connect-dialog-preload.ts b/src/renderer/src/ui/manual-connect-dialog/manual-connect-dialog-preload.ts new file mode 100644 index 00000000..5216a60d --- /dev/null +++ b/src/renderer/src/ui/manual-connect-dialog/manual-connect-dialog-preload.ts @@ -0,0 +1,74 @@ +/** + * @fileoverview Preload script for the manual printer-connection dialog. + * + * Provides the secure bridge for the multi-field manual connect form (IP address, + * printer type, serial number, check code). Mirrors the input-dialog preload's + * unique-response-channel pattern so several dialogs can be open without their + * results crossing over. + * + * Key exports: + * - ManualConnectDialogAPI: secure API for dialog init and result submission + * - ManualConnectDialogInitOptions: configuration received from the main process + */ + +import { contextBridge, ipcRenderer } from 'electron'; +import type { ManualConnectResult } from '@shared/types/manual-connect.js'; + +// Response channel for this dialog instance, supplied by the main process on init +let responseChannel: string | null = null; + +// Valid channels for security +const validReceiveChannels = ['manual-connect:init', 'theme-changed']; + +/** Initialization payload sent by the main process. */ +export interface ManualConnectDialogInitOptions { + title?: string; + message?: string; + defaultIpAddress?: string; + responseChannel: string; +} + +/** IPC surface exposed to the dialog renderer. */ +export interface ManualConnectDialogAPI { + receive: (channel: string, func: (options: unknown) => void) => void; + submit: (result: ManualConnectResult) => Promise; + cancel: () => Promise; +} + +const invokeResponse = async (payload: ManualConnectResult | null): Promise => { + if (!responseChannel) { + const error = new Error('Manual connect dialog response channel not set!'); + console.error(error.message); + throw error; + } + + try { + await ipcRenderer.invoke(responseChannel, payload); + } catch (error) { + console.error('Failed to send manual connect dialog result:', error); + throw error; + } +}; + +const manualConnectDialogAPI: ManualConnectDialogAPI = { + receive: (channel: string, func: (options: unknown) => void): void => { + if (validReceiveChannels.includes(channel)) { + ipcRenderer.on(channel, (_event, options: ManualConnectDialogInitOptions) => { + if (options?.responseChannel) { + responseChannel = options.responseChannel; + } + func(options); + }); + } + }, + + submit: (result: ManualConnectResult): Promise => invokeResponse(result), + + cancel: (): Promise => invokeResponse(null), +}; + +contextBridge.exposeInMainWorld('api', { + dialog: { + manualConnect: manualConnectDialogAPI, + }, +}); diff --git a/src/renderer/src/ui/manual-connect-dialog/manual-connect-dialog-renderer.ts b/src/renderer/src/ui/manual-connect-dialog/manual-connect-dialog-renderer.ts new file mode 100644 index 00000000..6404064e --- /dev/null +++ b/src/renderer/src/ui/manual-connect-dialog/manual-connect-dialog-renderer.ts @@ -0,0 +1,280 @@ +/** + * @fileoverview Renderer process for the manual printer-connection dialog. + * + * Collects everything a manual connect needs now that modern printers are typed + * from their USB product ID instead of being TCP-probed: IP address, printer type, + * and — for modern models — the serial number and check code the discovery + * broadcast would otherwise have supplied. Legacy selections hide the serial and + * check-code fields since those printers are still probed over TCP. + * + * Key features: + * - Printer-type dropdown driven by the shared MANUAL_CONNECT_PRINTER_TYPES list + * - Per-type field visibility and validation (serial + check code for modern) + * - Inline validation errors, Enter to submit, Escape to cancel + */ + +export {}; + +import type { ThemeColors } from '@shared/types/config.js'; +import { + isModernManualConnectType, + MANUAL_CONNECT_PRINTER_TYPES, + type ManualConnectPrinterType, + type ManualConnectResult, +} from '@shared/types/manual-connect.js'; +import { initializeLucideIconsFromGlobal } from '../shared/lucide.js'; +import { applyDialogTheme } from '../shared/theme-utils.js'; +import type { + ManualConnectDialogAPI, + ManualConnectDialogInitOptions, +} from './manual-connect-dialog-preload.cts'; + +const IPV4_PATTERN = + /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; + +interface DialogElements { + readonly title: HTMLElement | null; + readonly message: HTMLElement | null; + readonly ipInput: HTMLInputElement; + readonly typeSelect: HTMLSelectElement; + readonly serialField: HTMLElement; + readonly serialInput: HTMLInputElement; + readonly checkCodeField: HTMLElement; + readonly checkCodeInput: HTMLInputElement; + readonly hint: HTMLElement; + readonly error: HTMLElement; + readonly connectButton: HTMLButtonElement; + readonly cancelButton: HTMLButtonElement; + readonly closeButton: HTMLButtonElement; +} + +const getManualConnectAPI = (): ManualConnectDialogAPI => { + const api = window.api?.dialog?.manualConnect as ManualConnectDialogAPI | undefined; + if (!api) { + throw new Error('[ManualConnectDialog] dialog API bridge is not available'); + } + return api; +}; + +document.addEventListener('DOMContentLoaded', (): void => { + initializeLucideIconsFromGlobal(['x']); + + const elements = resolveElements(); + if (!elements) { + console.error('Manual connect dialog: required DOM elements not found'); + return; + } + + populatePrinterTypes(elements.typeSelect); + applyTypeVisibility(elements); + + const api = getManualConnectAPI(); + + api.receive('manual-connect:init', (data: unknown): void => { + initializeDialog(elements, data as ManualConnectDialogInitOptions); + }); + + api.receive('theme-changed', (data: unknown): void => { + applyDialogTheme(data as ThemeColors); + }); + + setupEventHandlers(elements, api); +}); + +function resolveElements(): DialogElements | null { + const ipInput = document.getElementById('input-ip') as HTMLInputElement | null; + const typeSelect = document.getElementById('input-type') as HTMLSelectElement | null; + const serialField = document.getElementById('field-serial'); + const serialInput = document.getElementById('input-serial') as HTMLInputElement | null; + const checkCodeField = document.getElementById('field-check-code'); + const checkCodeInput = document.getElementById('input-check-code') as HTMLInputElement | null; + const hint = document.getElementById('field-hint'); + const error = document.getElementById('dialog-error'); + const connectButton = document.getElementById('dialog-connect') as HTMLButtonElement | null; + const cancelButton = document.getElementById('dialog-cancel') as HTMLButtonElement | null; + const closeButton = document.getElementById('dialog-close') as HTMLButtonElement | null; + + if ( + !ipInput || + !typeSelect || + !serialField || + !serialInput || + !checkCodeField || + !checkCodeInput || + !hint || + !error || + !connectButton || + !cancelButton || + !closeButton + ) { + return null; + } + + return { + title: document.getElementById('dialog-title'), + message: document.getElementById('dialog-message'), + ipInput, + typeSelect, + serialField, + serialInput, + checkCodeField, + checkCodeInput, + hint, + error, + connectButton, + cancelButton, + closeButton, + }; +} + +function populatePrinterTypes(select: HTMLSelectElement): void { + for (const { value, label } of MANUAL_CONNECT_PRINTER_TYPES) { + const option = document.createElement('option'); + option.value = value; + option.textContent = label; + select.appendChild(option); + } +} + +function initializeDialog(elements: DialogElements, options: ManualConnectDialogInitOptions): void { + if (elements.title && options.title) { + elements.title.textContent = options.title; + } + if (elements.message && options.message) { + elements.message.textContent = options.message; + } + if (options.defaultIpAddress) { + elements.ipInput.value = options.defaultIpAddress; + } + + elements.ipInput.focus(); + if (elements.ipInput.value) { + elements.ipInput.select(); + } +} + +/** Show serial + check code only for modern models; legacy printers are still probed. */ +function applyTypeVisibility(elements: DialogElements): void { + const isModern = isModernManualConnectType(selectedType(elements)); + + elements.serialField.classList.toggle('hidden', !isModern); + elements.checkCodeField.classList.toggle('hidden', !isModern); + elements.hint.classList.toggle('hidden', !isModern); +} + +function selectedType(elements: DialogElements): ManualConnectPrinterType { + return elements.typeSelect.value as ManualConnectPrinterType; +} + +function setupEventHandlers(elements: DialogElements, api: ManualConnectDialogAPI): void { + elements.typeSelect.addEventListener('change', (): void => { + applyTypeVisibility(elements); + clearError(elements); + }); + + elements.connectButton.addEventListener('click', (): void => { + submitDialog(elements, api); + }); + + for (const button of [elements.cancelButton, elements.closeButton]) { + button.addEventListener('click', (): void => { + cancelDialog(api); + }); + } + + // Enter submits from any text field + for (const input of [elements.ipInput, elements.serialInput, elements.checkCodeInput]) { + input.addEventListener('keydown', (event: KeyboardEvent): void => { + if (event.key === 'Enter') { + event.preventDefault(); + submitDialog(elements, api); + } + }); + input.addEventListener('input', (): void => { + input.classList.remove('invalid'); + clearError(elements); + }); + } + + document.addEventListener('keydown', (event: KeyboardEvent): void => { + if (event.key === 'Escape') { + event.preventDefault(); + cancelDialog(api); + } + }); +} + +function submitDialog(elements: DialogElements, api: ManualConnectDialogAPI): void { + const result = validate(elements); + if (!result) { + return; + } + + api.submit(result).catch((error: unknown) => { + console.error('Error submitting manual connect dialog:', error); + }); +} + +/** + * Validate the form, highlighting the first offending field. Returns null when + * the form is not yet submittable. + */ +function validate(elements: DialogElements): ManualConnectResult | null { + clearError(elements); + + const ipAddress = elements.ipInput.value.trim(); + if (!IPV4_PATTERN.test(ipAddress)) { + return failValidation(elements, elements.ipInput, 'Enter a valid IPv4 address.'); + } + + const printerType = selectedType(elements); + + if (!isModernManualConnectType(printerType)) { + return { ipAddress, printerType }; + } + + // Modern printers are typed from their product ID and never TCP-probed, so the + // serial can no longer be recovered automatically — it has to be supplied here. + const serialNumber = elements.serialInput.value.trim(); + if (!serialNumber) { + return failValidation( + elements, + elements.serialInput, + 'Serial number is required for modern printers.' + ); + } + + const checkCode = elements.checkCodeInput.value.trim(); + if (!checkCode) { + return failValidation( + elements, + elements.checkCodeInput, + 'Check code is required for modern printers.' + ); + } + + return { ipAddress, printerType, serialNumber, checkCode }; +} + +function failValidation( + elements: DialogElements, + field: HTMLInputElement, + message: string +): null { + elements.error.textContent = message; + elements.error.hidden = false; + field.classList.add('invalid'); + field.focus(); + return null; +} + +function clearError(elements: DialogElements): void { + elements.error.hidden = true; + elements.error.textContent = ''; +} + +function cancelDialog(api: ManualConnectDialogAPI): void { + api.cancel().catch((error: unknown) => { + console.error('Error cancelling manual connect dialog:', error); + }); +} diff --git a/src/renderer/src/ui/manual-connect-dialog/manual-connect-dialog.css b/src/renderer/src/ui/manual-connect-dialog/manual-connect-dialog.css new file mode 100644 index 00000000..ad574593 --- /dev/null +++ b/src/renderer/src/ui/manual-connect-dialog/manual-connect-dialog.css @@ -0,0 +1,167 @@ +/* Manual Connect Dialog CSS - Rounded Styling Pattern */ +/* Import shared template for consistent styling */ +@import url("../shared/rounded-dialog-template.css"); + +/* Content scrolls if the window is short - the form is taller than a plain input dialog */ +.dialog-content { + overflow-y: auto; +} + +/* Intro message */ +.dialog-message { + font-size: 13px; + line-height: 1.4; + color: var(--theme-text); + margin-bottom: 16px; + text-align: center; + opacity: 0.85; +} + +/* Labelled form rows */ +.field { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 12px; +} + +.field.hidden { + display: none; +} + +.field-label { + flex: 0 0 104px; + font-size: 13px; + color: var(--theme-text); + text-align: right; +} + +.field-input { + flex: 1 1 auto; + min-width: 0; + background-color: var(--surface-elevated); + border: 1px solid var(--border-color); + padding: 8px 10px; + color: var(--theme-text); + font-size: 13px; + font-family: inherit; + border-radius: 6px; + outline: none; + transition: all 0.2s ease; +} + +.field-input:focus { + border-color: var(--theme-primary); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--theme-primary) 30%, transparent); +} + +.field-input::placeholder { + color: color-mix(in srgb, var(--theme-text) 45%, transparent); +} + +/* Invalid field highlight */ +.field-input.invalid { + border-color: var(--theme-error, #e05252); +} + +select.field-input { + cursor: pointer; + appearance: none; + background-image: linear-gradient(45deg, transparent 50%, currentColor 50%), + linear-gradient(135deg, currentColor 50%, transparent 50%); + background-position: calc(100% - 16px) calc(50% + 1px), calc(100% - 11px) calc(50% + 1px); + background-size: 5px 5px, 5px 5px; + background-repeat: no-repeat; + padding-right: 30px; +} + +/* Contextual hint under the form */ +.field-hint { + font-size: 11px; + line-height: 1.4; + color: color-mix(in srgb, var(--theme-text) 65%, transparent); + margin: 4px 0 12px 116px; +} + +.field-hint.hidden { + display: none; +} + +/* Validation error banner */ +.dialog-error { + font-size: 12px; + line-height: 1.4; + color: var(--theme-error, #e05252); + background-color: color-mix(in srgb, var(--theme-error, #e05252) 12%, transparent); + border: 1px solid color-mix(in srgb, var(--theme-error, #e05252) 40%, transparent); + border-radius: 6px; + padding: 8px 10px; + margin-bottom: 12px; + text-align: center; +} + +.dialog-error[hidden] { + display: none; +} + +/* Button container styling */ +.dialog-buttons { + display: flex; + justify-content: center; + gap: 10px; + padding-top: 4px; +} + +/* Button styling - inherit most styles from template */ +.dialog-button { + background-color: var(--theme-primary); + border-color: var(--theme-primary); +} + +.dialog-button:hover { + background-color: var(--theme-primary-hover); + border-color: var(--theme-primary-hover); +} + +/* Cancel button variant */ +.dialog-button.cancel { + background-color: var(--surface-elevated); + border-color: var(--border-color); +} + +.dialog-button.cancel:hover { + background-color: var(--surface-muted); + border-color: var(--border-color-light); +} + +/* Responsive adjustments */ +@media (max-width: 420px) { + .dialog-content { + padding: 20px; + } + + .field { + flex-direction: column; + align-items: stretch; + gap: 4px; + } + + .field-label { + flex: none; + text-align: left; + } + + .field-hint { + margin-left: 0; + } + + .dialog-buttons { + flex-direction: column-reverse; + align-items: center; + gap: 8px; + } + + .dialog-button { + width: 140px; + } +} diff --git a/src/renderer/src/ui/manual-connect-dialog/manual-connect-dialog.html b/src/renderer/src/ui/manual-connect-dialog/manual-connect-dialog.html new file mode 100644 index 00000000..fc96cc31 --- /dev/null +++ b/src/renderer/src/ui/manual-connect-dialog/manual-connect-dialog.html @@ -0,0 +1,72 @@ + + + + + + + Manual Printer Connection + + + +
+
+
Manual Printer Connection
+ +
+
+
+ Enter your printer's details to connect directly. +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ Modern printers need the serial number and check code shown on the printer's + network/status screen. +
+ + + +
+ + +
+
+
+ + + + diff --git a/src/renderer/src/ui/material-matching-dialog/material-matching-dialog-preload.ts b/src/renderer/src/ui/material-matching-dialog/material-matching-dialog-preload.ts index 4216fb51..461baaa5 100644 --- a/src/renderer/src/ui/material-matching-dialog/material-matching-dialog-preload.ts +++ b/src/renderer/src/ui/material-matching-dialog/material-matching-dialog-preload.ts @@ -29,6 +29,17 @@ interface MaterialMatchingInitData { readonly filamentWeight: number; }[]; readonly leveling: boolean; + /** True when the dialog asks for a Spoolman spool per tool. */ + readonly trackSpools?: boolean; +} + +/** Spool offered in the dialog for per-job Spoolman tracking. */ +interface TrackingSpool { + readonly id: number; + readonly name: string; + readonly vendor: string | null; + readonly material: string | null; + readonly remainingWeight: number | null; } interface MaterialMapping { @@ -37,6 +48,8 @@ interface MaterialMapping { readonly materialName: string; readonly toolMaterialColor: string; readonly slotMaterialColor: string; + /** Spoolman spool for the tool; null = do not track; absent = not asked. */ + readonly spoolId?: number | null; } interface MaterialStationStatus { @@ -71,6 +84,11 @@ const materialMatchingDialogAPI = { ipcRenderer.send('material-matching:confirm', mappings); }, + // Spools for per-job Spoolman tracking (null when tracking does not apply) + getTrackingSpools: (): Promise => { + return ipcRenderer.invoke('material-matching:get-tracking-spools'); + }, + // Get material station status getMaterialStationStatus: (): Promise => { return ipcRenderer.invoke('get-material-station-status'); diff --git a/src/renderer/src/ui/material-matching-dialog/material-matching-dialog-renderer.ts b/src/renderer/src/ui/material-matching-dialog/material-matching-dialog-renderer.ts index e92194cc..939b19bc 100644 --- a/src/renderer/src/ui/material-matching-dialog/material-matching-dialog-renderer.ts +++ b/src/renderer/src/ui/material-matching-dialog/material-matching-dialog-renderer.ts @@ -5,7 +5,7 @@ * material station slots. Validates material type compatibility, warns on color differences, * and prevents invalid mappings (empty slots, type mismatches, duplicate assignments). Provides * visual feedback through color swatches, selection states, and real-time mapping display. - * Context-aware button text (Start Print vs Confirm) based on workflow origin. + * Confirms mappings only - the caller decides whether the job is sent or started. * * Key features: * - Dual-panel selection: print requirements and available IFS slots @@ -52,6 +52,17 @@ interface AD5XMaterialMapping { readonly materialName: string; readonly toolMaterialColor: string; readonly slotMaterialColor: string; + /** Spoolman spool for the tool; null = do not track; absent = not asked. */ + readonly spoolId?: number | null; +} + +/** Spool offered in the dialog for per-job Spoolman tracking. */ +interface TrackingSpool { + readonly id: number; + readonly name: string; + readonly vendor: string | null; + readonly material: string | null; + readonly remainingWeight: number | null; } // Utility functions (inlined to avoid require errors) @@ -60,8 +71,9 @@ function validateMaterialCompatibility(tool: FFGcodeToolData, slot: MaterialSlot return tool.materialName === slot.materialType; } +/** Shared wording - keep in step with the two web UIs. */ function createColorDifferenceWarning(toolId: number, toolColor: string, slotId: number, slotColor: string): string { - return `Color difference detected: Tool ${toolId + 1} expects ${toolColor} but Slot ${slotId} has ${slotColor}. This is allowed but may affect print appearance.`; + return `Tool ${toolId + 1} expects ${toolColor} but Slot ${slotId} has ${slotColor}.`; } function createMaterialMismatchError( @@ -83,6 +95,7 @@ interface MaterialMatchingDialogAPI { readonly closeDialog: () => void; readonly confirmMappings: (mappings: AD5XMaterialMapping[]) => void; readonly getMaterialStationStatus: () => Promise; + readonly getTrackingSpools?: () => Promise; receive?: (channel: string, func: (...args: unknown[]) => void) => void; } @@ -90,7 +103,9 @@ interface MaterialMatchingInitData { readonly fileName: string; readonly toolDatas: readonly FFGcodeToolData[]; readonly leveling: boolean; - readonly context?: 'job-start' | 'file-upload'; // Context to determine button text + readonly context?: 'job-start' | 'file-upload'; // Which flow opened the dialog + /** True when the caller starts the job now, so a spool per tool is asked. */ + readonly trackSpools?: boolean; } let cachedMaterialMatchingAPI: MaterialMatchingDialogAPI | null = null; @@ -113,6 +128,13 @@ let materialStation: MaterialStationStatus | null = null; let selectedTool: number | null = null; let selectedSlot: number | null = null; const currentMappings: Map = new Map(); +/** Spools offered for per-job tracking; null when tracking does not apply. */ +let trackingSpools: TrackingSpool[] | null = null; +/** Spool choice per tool: a spool id, null for "do not track", or no entry. */ +const spoolChoices: Map = new Map(); + +const SPOOL_PLACEHOLDER_VALUE = ''; +const SPOOL_UNTRACKED_VALUE = 'none'; // DOM elements let printRequirementsElement: HTMLElement | null = null; @@ -177,20 +199,110 @@ function setupIpcListeners(api: MaterialMatchingDialogAPI): void { console.log('Material matching: Received init data', data); initData = data; - // Set button text based on context + // The label is the same in every flow and on every UI surface: this dialog + // only confirms mappings, the caller decides what happens with them. if (confirmButton) { - if (data.context === 'file-upload') { - confirmButton.textContent = 'Confirm'; - } else { - confirmButton.textContent = 'Start Print'; // Default for job-start context - } + confirmButton.textContent = 'Confirm'; } await loadMaterialStation(api); displayPrintRequirements(); displayIFSSlots(); updateMappingsDisplay(); + + trackingSpools = null; + spoolChoices.clear(); + if (data.trackSpools && api.getTrackingSpools) { + try { + trackingSpools = await api.getTrackingSpools(); + } catch (error) { + console.warn('Material matching: could not load Spoolman spools', error); + trackingSpools = null; + } + } + const hint = document.getElementById('spool-hint'); + if (hint) { + hint.style.display = trackingSpools !== null ? 'block' : 'none'; + } + updateMappingsDisplay(); + updateConfirmButton(); + }); +} + +function normalizeMaterial(value: string | null | undefined): string { + return (value ?? '').trim().toLowerCase(); +} + +function describeSpool(spool: TrackingSpool): string { + const parts = [`#${spool.id}`, spool.vendor, spool.name].filter(Boolean); + const remaining = + typeof spool.remainingWeight === 'number' ? ` (${Math.round(spool.remainingWeight)} g left)` : ''; + return `${parts.join(' ')}${remaining}`; +} + +/** + * Spool picker for one mapping. Spools whose material matches the tool come + * first, so the usual choice is near the top. + */ +function createSpoolSelect(mapping: AD5XMaterialMapping, spools: readonly TrackingSpool[]): HTMLSelectElement { + const select = document.createElement('select'); + select.className = 'mapping-spool'; + select.dataset.toolId = String(mapping.toolId); + select.setAttribute('aria-label', `Spoolman spool for tool ${mapping.toolId + 1}`); + + const placeholder = document.createElement('option'); + placeholder.value = SPOOL_PLACEHOLDER_VALUE; + placeholder.textContent = 'Choose a spool\u2026'; + placeholder.disabled = true; + select.appendChild(placeholder); + + const untracked = document.createElement('option'); + untracked.value = SPOOL_UNTRACKED_VALUE; + untracked.textContent = 'Do not track'; + select.appendChild(untracked); + + const wanted = normalizeMaterial(mapping.materialName); + const sorted = [...spools].sort((a, b) => { + const aMatch = normalizeMaterial(a.material) === wanted ? 0 : 1; + const bMatch = normalizeMaterial(b.material) === wanted ? 0 : 1; + return aMatch - bMatch || a.id - b.id; + }); + for (const spool of sorted) { + const option = document.createElement('option'); + option.value = String(spool.id); + option.textContent = describeSpool(spool); + select.appendChild(option); + } + + const choice = spoolChoices.get(mapping.toolId); + select.value = + choice === undefined ? SPOOL_PLACEHOLDER_VALUE : choice === null ? SPOOL_UNTRACKED_VALUE : String(choice); + + select.addEventListener('click', (event) => event.stopPropagation()); + select.addEventListener('change', () => { + if (select.value === SPOOL_UNTRACKED_VALUE) { + spoolChoices.set(mapping.toolId, null); + } else if (select.value === SPOOL_PLACEHOLDER_VALUE) { + spoolChoices.delete(mapping.toolId); + } else { + spoolChoices.set(mapping.toolId, Number(select.value)); + } + updateConfirmButton(); }); + return select; +} + +/** True when tracking does not apply, or every mapped tool has a spool choice. */ +function allSpoolsChosen(): boolean { + if (trackingSpools === null) { + return true; + } + for (const toolId of currentMappings.keys()) { + if (!spoolChoices.has(toolId)) { + return false; + } + } + return true; } /** @@ -414,12 +526,7 @@ function createMapping(): void { return; } - // Check for color differences - if (hasColorDifference(tool.materialColor, slot.materialColor)) { - showWarning(createColorDifferenceWarning(tool.toolId, tool.materialColor, selectedSlot, slot.materialColor || '')); - } - - // Add mapping + // Add mapping - the colour warnings are rebuilt from the whole set below currentMappings.set(tool.toolId, mapping); // Reset selections @@ -438,6 +545,7 @@ function updateAllDisplays(): void { displayIFSSlots(); updateMappingsDisplay(); updateConfirmButton(); + refreshColorWarnings(); } /** @@ -519,6 +627,9 @@ function createMappingItem(mapping: AD5XMaterialMapping): HTMLElement { removeButton.addEventListener('click', () => removeMapping(mapping.toolId)); item.appendChild(content); + if (trackingSpools !== null) { + item.appendChild(createSpoolSelect(mapping, trackingSpools)); + } item.appendChild(removeButton); return item; @@ -529,6 +640,7 @@ function createMappingItem(mapping: AD5XMaterialMapping): HTMLElement { */ function removeMapping(toolId: number): void { currentMappings.delete(toolId); + spoolChoices.delete(toolId); updateAllDisplays(); hideMessages(); } @@ -541,7 +653,7 @@ function updateConfirmButton(): void { // Enable only if all tools are mapped const allMapped = initData.toolDatas.every((tool) => currentMappings.has(tool.toolId)); - confirmButton.disabled = !allMapped; + confirmButton.disabled = !allMapped || !allSpoolsChosen(); } /** @@ -551,17 +663,41 @@ function showError(message: string): void { if (!errorMessageElement) return; errorMessageElement.textContent = message; errorMessageElement.style.display = 'block'; - if (warningMessageElement) warningMessageElement.style.display = 'none'; } /** - * Show warning message + * One line per mapping whose tool color does not match the slot it was mapped + * to. Rebuilt from the current mappings on every change, so the card always + * reflects the whole set rather than the last click. */ -function showWarning(message: string): void { - if (!warningMessageElement) return; - warningMessageElement.textContent = message; +function refreshColorWarnings(): void { + const list = document.getElementById('warning-message-list'); + if (!warningMessageElement || !list) return; + + list.textContent = ''; + + const mismatched = Array.from(currentMappings.values()).filter((mapping) => + hasColorDifference(mapping.toolMaterialColor, mapping.slotMaterialColor) + ); + + if (mismatched.length === 0) { + warningMessageElement.style.display = 'none'; + return; + } + + mismatched.forEach((mapping) => { + const item = document.createElement('div'); + item.className = 'warning-card-item'; + item.textContent = createColorDifferenceWarning( + mapping.toolId, + mapping.toolMaterialColor, + mapping.slotId, + mapping.slotMaterialColor + ); + list.appendChild(item); + }); + warningMessageElement.style.display = 'block'; - if (errorMessageElement) errorMessageElement.style.display = 'none'; } /** @@ -569,7 +705,7 @@ function showWarning(message: string): void { */ function hideMessages(): void { if (errorMessageElement) errorMessageElement.style.display = 'none'; - if (warningMessageElement) warningMessageElement.style.display = 'none'; + refreshColorWarnings(); } /** @@ -585,8 +721,15 @@ function handleClose(): void { function handleConfirm(): void { if (!initData) return; - // Convert mappings to array - const mappings = Array.from(currentMappings.values()); + if (!allSpoolsChosen()) { + showError('Choose a Spoolman spool for each tool, or choose "Do not track".'); + return; + } + + // Convert mappings to array; attach the spool choice when one was asked. + const mappings = Array.from(currentMappings.values()).map((mapping) => + trackingSpools === null ? mapping : { ...mapping, spoolId: spoolChoices.get(mapping.toolId) ?? null } + ); // Ensure all tools are mapped if (mappings.length !== initData.toolDatas.length) { @@ -606,6 +749,8 @@ function cleanup(): void { selectedTool = null; selectedSlot = null; currentMappings.clear(); + trackingSpools = null; + spoolChoices.clear(); } // Initialize when DOM is ready diff --git a/src/renderer/src/ui/material-matching-dialog/material-matching-dialog.css b/src/renderer/src/ui/material-matching-dialog/material-matching-dialog.css index 4e10535a..7de11bb6 100644 --- a/src/renderer/src/ui/material-matching-dialog/material-matching-dialog.css +++ b/src/renderer/src/ui/material-matching-dialog/material-matching-dialog.css @@ -246,25 +246,49 @@ } /* Messages */ -.error-message, -.warning-message { - margin-top: 16px; +.error-message { + margin-bottom: 16px; padding: 12px; border-radius: 6px; font-size: 14px; line-height: 1.4; -} - -.error-message { background-color: color-mix(in srgb, var(--error-color) 10%, transparent); border: 1px solid color-mix(in srgb, var(--error-color) 30%, transparent); color: var(--error-color); } -.warning-message { +/* Colour mismatch card - sits above the columns so it is read before mapping */ +.warning-card { + margin-bottom: 16px; + padding: 12px 16px; + border-radius: 6px; background-color: color-mix(in srgb, var(--warning-color) 10%, transparent); border: 1px solid color-mix(in srgb, var(--warning-color) 30%, transparent); +} + +.warning-card-title { + margin: 0 0 8px; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--warning-color); +} + +.warning-card-item { + display: flex; + align-items: baseline; + gap: 6px; + padding: 3px 0; + font-size: 14px; + line-height: 1.4; + color: var(--theme-text); +} + +.warning-card-item::before { + content: "⚠"; color: var(--warning-color); + flex-shrink: 0; } /* Button styling - inherit from template with overrides */ @@ -334,3 +358,25 @@ padding: 16px; } } + +/* Spoolman spool picker (per-job tracking) */ +.spool-hint { + margin: 0 0 8px; + font-size: 12px; + color: var(--text-color-secondary, inherit); +} + +.mapping-spool { + flex: 1 1 160px; + min-width: 0; + max-width: 240px; + margin: 0 8px; + padding: 4px 6px; + border-radius: 4px; + border: 1px solid var(--border-color); + background-color: var(--surface-elevated); + color: inherit; + font: inherit; + font-size: 12px; +} + diff --git a/src/renderer/src/ui/material-matching-dialog/material-matching-dialog.html b/src/renderer/src/ui/material-matching-dialog/material-matching-dialog.html index 4158a1b7..b417edea 100644 --- a/src/renderer/src/ui/material-matching-dialog/material-matching-dialog.html +++ b/src/renderer/src/ui/material-matching-dialog/material-matching-dialog.html @@ -19,6 +19,13 @@

Match Print Materials to IFS Slots

+ + + +
@@ -39,19 +46,19 @@

Available IFS Slots

Material Mappings

+
- - -
diff --git a/src/renderer/src/ui/palette/palette-preload.ts b/src/renderer/src/ui/palette/palette-preload.ts index e411fc18..ea19ae0c 100644 --- a/src/renderer/src/ui/palette/palette-preload.ts +++ b/src/renderer/src/ui/palette/palette-preload.ts @@ -1,39 +1,9 @@ /** - * @fileoverview Preload script for Component Palette window - * - * Securely exposes IPC communication APIs to the palette window renderer process - * via Electron's contextBridge. Provides type-safe methods for component queries, - * window control, and status updates with proper sandboxing and security. - * - * Exposed APIs (window.paletteAPI): - * - close(): Close palette window - * - getAvailableComponents(): Query component registry for all available components - * - notifyOpened(): Signal palette window opened to main process - * - onUpdateStatus(callback): Listen for grid status updates from main window - * - notifyComponentRemove(componentId): Request component removal from grid - * - notifyComponentAdd(componentId): Request component addition to grid - * - * Component Status Updates: - * The main window broadcasts status updates to the palette when components are - * added or removed from the grid, allowing the palette to update its UI to reflect - * which components are currently in use and which are available for addition. - * - * Security: - * - Uses Electron contextBridge for sandboxed renderer communication - * - Validates IPC channel names and data formats - * - Type-safe interfaces for all API methods - * - Error handling with graceful fallbacks - * - No direct access to Node.js APIs from renderer - * - * IPC Channels: - * - palette:close (send): Close button handler - * - palette:get-components (invoke): Query component definitions - * - palette:opened (send): Palette window opened notification - * - palette:update-status (receive): Component status updates - * - palette:remove-component (send): Component removal request - * - palette:add-component (send): Component addition request - * - * @module ui/palette/palette-preload + * @fileoverview Preload for the Component Palette window. Exposes + * window.paletteAPI through Electron's contextBridge: component queries, + * open/close signals, add/remove requests, and grid-status updates pushed + * from the main window. This is the palette renderer's only bridge to + * main-process APIs. */ import { contextBridge, ipcRenderer } from 'electron'; diff --git a/src/renderer/src/ui/printer-selection/printer-selection-preload.ts b/src/renderer/src/ui/printer-selection/printer-selection-preload.ts index 9c4ce664..ab405c43 100644 --- a/src/renderer/src/ui/printer-selection/printer-selection-preload.ts +++ b/src/renderer/src/ui/printer-selection/printer-selection-preload.ts @@ -29,6 +29,10 @@ interface PrinterInfo { readonly model?: string; readonly status?: string; readonly firmwareVersion?: string; + // USB product ID / type from discovery — forwarded back on selection so the + // connection flow can identify HTTP-only models (Creator 5 / 5 Pro) by PID. + readonly productId?: number; + readonly productType?: number; } // Interface for saved printer information with additional metadata diff --git a/src/renderer/src/ui/printer-selection/printer-selection-renderer.ts b/src/renderer/src/ui/printer-selection/printer-selection-renderer.ts index 8bf9761c..a1df5063 100644 --- a/src/renderer/src/ui/printer-selection/printer-selection-renderer.ts +++ b/src/renderer/src/ui/printer-selection/printer-selection-renderer.ts @@ -40,6 +40,10 @@ interface PrinterInfo { readonly model?: string; readonly status?: string; readonly firmwareVersion?: string; + // Carried through from discovery and echoed back on selection so the main + // process can identify HTTP-only models (Creator 5 / 5 Pro) by product ID. + readonly productId?: number; + readonly productType?: number; } const PRINTER_SELECTION_LOG_NAMESPACE = 'PrinterSelectionRenderer'; diff --git a/src/renderer/src/ui/settings/sections/InputDependencySection.ts b/src/renderer/src/ui/settings/sections/InputDependencySection.ts index 676bcfbc..2a5c35da 100644 --- a/src/renderer/src/ui/settings/sections/InputDependencySection.ts +++ b/src/renderer/src/ui/settings/sections/InputDependencySection.ts @@ -1,9 +1,9 @@ /** * @fileoverview Handles enable/disable logic for dependent settings inputs. * - * Centralizes state transitions for the WebUI, Spoolman, Discord, and per-printer - * fields so the main settings renderer can simply call `updateStates` when - * relevant toggles change. + * Centralizes state transitions for the WebUI, Spoolman, Discord, per-printer, + * and startup fields so the main settings renderer can simply call `updateStates` + * when relevant toggles change. */ // src/ui/settings/sections/InputDependencySection.ts @@ -22,7 +22,7 @@ export class InputDependencySection { this.webUIEnabledToggle = options.webUIEnabledToggle; } - updateStates(perPrinterControlsEnabled: boolean, autoDownloadSupported: boolean): void { + updateStates(perPrinterControlsEnabled: boolean, autoDownloadSupported: boolean, isPackaged: boolean): void { const webUIEnabled = this.inputs.get('web-ui')?.checked || false; const passwordRequired = this.inputs.get('web-ui-password-required')?.checked ?? true; this.setInputEnabled('web-ui-port', webUIEnabled); @@ -58,6 +58,15 @@ export class InputDependencySection { if (!autoDownloadSupported) { this.setInputEnabled('auto-download-updates', false); } + + // Startup behavior. + // "Start with system" (auto-launch) is only relevant in packaged builds; in + // development the checkbox is disabled. "Start minimized" only makes sense + // when the app launches at boot, so it stays disabled until "Start with + // system" is checked. + this.setInputEnabled('start-at-boot', isPackaged); + const startAtBootChecked = this.inputs.get('start-at-boot')?.checked || false; + this.setInputEnabled('start-minimized', startAtBootChecked); } private setInputEnabled(inputId: string, enabled: boolean): void { diff --git a/src/renderer/src/ui/settings/sections/SSHSection.ts b/src/renderer/src/ui/settings/sections/SSHSection.ts new file mode 100644 index 00000000..073d23ad --- /dev/null +++ b/src/renderer/src/ui/settings/sections/SSHSection.ts @@ -0,0 +1,209 @@ +/** + * @fileoverview Settings section for centralized per-printer SSH credentials. + * + * Manages the SSH tab of the settings dialog: loads the active printer's + * resolved SSH settings (easy-SSH defaults applied), tracks edits to + * username/password/port/key-path, saves them through the ssh-settings IPC + * surface, and offers a one-click reset back to the flashforge-easyssh + * defaults. These credentials feed every SSH consumer (file manager, + * calibration assistant, WebUI calibration routes). + */ + +import type { SSHSettingsResponse, SSHSettingsUpdate } from '@shared/types/ssh-settings.js'; + +/** Preload bridge for the SSH settings IPC surface. */ +export interface ISSHSettingsAPI { + readonly get: () => Promise; + readonly save: (update: SSHSettingsUpdate) => Promise; + readonly reset: () => Promise; + readonly browseKeyFile: () => Promise<{ filePath?: string } | null>; +} + +interface SSHSectionDependencies { + readonly document: Document; + readonly sshSettingsAPI?: ISSHSettingsAPI; + /** Notify the settings renderer that there are unsaved SSH changes. */ + readonly onDirty: () => void; +} + +/** + * Settings dialog section handling the SSH tab. + */ +export class SSHSection { + private readonly deps: SSHSectionDependencies; + + private emptyState: HTMLElement | null = null; + private content: HTMLElement | null = null; + private usernameInput: HTMLInputElement | null = null; + private passwordInput: HTMLInputElement | null = null; + private portInput: HTMLInputElement | null = null; + private keyPathInput: HTMLInputElement | null = null; + private browseButton: HTMLButtonElement | null = null; + private resetButton: HTMLButtonElement | null = null; + private statusElement: HTMLElement | null = null; + private statusTimeout: ReturnType | null = null; + + private dirty = false; + private hasPrinter = false; + + constructor(deps: SSHSectionDependencies) { + this.deps = deps; + } + + initialize(): void { + const doc = this.deps.document; + this.emptyState = doc.getElementById('ssh-settings-empty-state'); + this.content = doc.getElementById('ssh-settings-content'); + this.usernameInput = doc.getElementById('ssh-settings-username') as HTMLInputElement | null; + this.passwordInput = doc.getElementById('ssh-settings-password') as HTMLInputElement | null; + this.portInput = doc.getElementById('ssh-settings-port') as HTMLInputElement | null; + this.keyPathInput = doc.getElementById('ssh-settings-key-path') as HTMLInputElement | null; + this.browseButton = doc.getElementById('btn-ssh-settings-key-browse') as HTMLButtonElement | null; + this.resetButton = doc.getElementById('btn-ssh-settings-reset') as HTMLButtonElement | null; + this.statusElement = doc.getElementById('ssh-settings-status'); + + const markDirty = (): void => this.markDirty(); + for (const input of [this.usernameInput, this.passwordInput, this.portInput, this.keyPathInput]) { + input?.addEventListener('input', markDirty); + input?.addEventListener('change', markDirty); + } + + this.browseButton?.addEventListener('click', () => void this.handleBrowseKey()); + this.resetButton?.addEventListener('click', () => void this.handleReset()); + } + + /** Load current settings for the active printer into the form. */ + async load(): Promise { + const api = this.deps.sshSettingsAPI; + if (!api) { + this.setPrinterAvailable(false); + return; + } + + try { + const response = await api.get(); + if (!response.settings) { + this.setPrinterAvailable(false); + return; + } + + this.setPrinterAvailable(true); + if (this.usernameInput) this.usernameInput.value = response.settings.username; + if (this.passwordInput) this.passwordInput.value = response.settings.password; + if (this.portInput) this.portInput.value = String(response.settings.port); + if (this.keyPathInput) this.keyPathInput.value = response.settings.keyPath || ''; + this.dirty = false; + } catch (error) { + console.error('[Settings] Failed to load SSH settings:', error); + this.setPrinterAvailable(false); + } + } + + /** Whether there are unsaved SSH changes. */ + isDirty(): boolean { + return this.dirty; + } + + /** Persist the current form values. Returns false on validation/save failure. */ + async save(): Promise { + const api = this.deps.sshSettingsAPI; + if (!api || !this.hasPrinter || !this.dirty) { + return true; + } + + const port = Number.parseInt(this.portInput?.value || '', 10); + if (Number.isNaN(port) || port < 1 || port > 65535) { + this.showStatus('Invalid SSH port (1-65535)', true); + return false; + } + + const update: SSHSettingsUpdate = { + username: this.usernameInput?.value ?? '', + password: this.passwordInput?.value ?? '', + port, + keyPath: this.keyPathInput?.value ?? '', + }; + + const success = await api.save(update); + if (success) { + this.dirty = false; + } else { + this.showStatus('Failed to save SSH settings', true); + } + return success; + } + + dispose(): void { + if (this.statusTimeout) { + clearTimeout(this.statusTimeout); + } + } + + private markDirty(): void { + if (!this.hasPrinter) { + return; + } + this.dirty = true; + this.deps.onDirty(); + } + + private setPrinterAvailable(available: boolean): void { + this.hasPrinter = available; + if (this.emptyState) this.emptyState.hidden = available; + if (this.content) this.content.hidden = !available; + } + + private async handleBrowseKey(): Promise { + const api = this.deps.sshSettingsAPI; + if (!api) { + return; + } + + try { + const result = await api.browseKeyFile(); + if (result?.filePath && this.keyPathInput) { + this.keyPathInput.value = result.filePath; + this.markDirty(); + } + } catch (error) { + console.error('[Settings] Failed to browse for SSH key:', error); + } + } + + private async handleReset(): Promise { + const api = this.deps.sshSettingsAPI; + if (!api || !this.hasPrinter) { + return; + } + + try { + const success = await api.reset(); + if (success) { + await this.load(); + this.showStatus('SSH settings reset to defaults'); + } else { + this.showStatus('Failed to reset SSH settings', true); + } + } catch (error) { + console.error('[Settings] Failed to reset SSH settings:', error); + this.showStatus('Failed to reset SSH settings', true); + } + } + + private showStatus(message: string, isError: boolean = false): void { + if (!this.statusElement) { + return; + } + this.statusElement.textContent = message; + this.statusElement.style.color = isError ? 'var(--error-color, #e53e3e)' : 'var(--success-color, #4CAF50)'; + + if (this.statusTimeout) { + clearTimeout(this.statusTimeout); + } + this.statusTimeout = setTimeout(() => { + if (this.statusElement) { + this.statusElement.textContent = ''; + } + }, 3000); + } +} diff --git a/src/renderer/src/ui/settings/settings-preload.ts b/src/renderer/src/ui/settings/settings-preload.ts index d13276e1..621c6ae8 100644 --- a/src/renderer/src/ui/settings/settings-preload.ts +++ b/src/renderer/src/ui/settings/settings-preload.ts @@ -17,6 +17,7 @@ * * - window.windowControls: Generic window operations * - minimize/close/closeGeneric: Window state management + * - window.IS_PACKAGED: Whether the app is running from a packaged (production) build */ // src/ui/settings/settings-preload.ts @@ -28,6 +29,7 @@ import type { ISettingsAPI, UpdateStatusResponse, } from '@shared/types/external.js'; +import type { SSHSettingsResponse, SSHSettingsUpdate } from '@shared/types/ssh-settings.js'; import { contextBridge, ipcRenderer } from 'electron'; // Ensure this file is treated as a module @@ -78,6 +80,23 @@ const printerSettingsAPI: IPrinterSettingsAPI = { }, }; +const sshSettingsAPI = { + get: async (): Promise => { + return (await ipcRenderer.invoke('ssh-settings:get')) as SSHSettingsResponse; + }, + save: async (update: SSHSettingsUpdate): Promise => { + const result: unknown = await ipcRenderer.invoke('ssh-settings:save', update); + return typeof result === 'boolean' ? result : false; + }, + reset: async (): Promise => { + const result: unknown = await ipcRenderer.invoke('ssh-settings:reset'); + return typeof result === 'boolean' ? result : false; + }, + browseKeyFile: async (): Promise<{ filePath?: string } | null> => { + return (await ipcRenderer.invoke('calibration:open-ssh-key-file')) as { filePath?: string } | null; + }, +} as const; + const autoUpdateAPI: IAutoUpdateAPI = { checkForUpdates: async (): Promise<{ success: boolean; error?: string }> => { return (await ipcRenderer.invoke('check-for-updates')) as { success: boolean; error?: string }; @@ -95,12 +114,20 @@ contextBridge.exposeInMainWorld('api', { settings: settingsAPI, printerSettings: printerSettingsAPI, autoUpdate: autoUpdateAPI, + sshSettings: sshSettingsAPI, }, }); +export type ISSHSettingsAPI = typeof sshSettingsAPI; + // Generic window controls for sub-windows contextBridge.exposeInMainWorld('windowControls', { minimize: () => ipcRenderer.send('dialog-window-minimize'), close: () => ipcRenderer.send('dialog-window-close'), closeGeneric: () => ipcRenderer.send('close-current-window'), }); + +// Expose packaged state (production vs development) synchronously to the settings renderer. +// Resolved via synchronous IPC because the sandboxed preload cannot access the main-process +// `app` module (app.isPackaged) directly. +contextBridge.exposeInMainWorld('IS_PACKAGED', ipcRenderer.sendSync('app:is-packaged')); diff --git a/src/renderer/src/ui/settings/settings-renderer.ts b/src/renderer/src/ui/settings/settings-renderer.ts index bbd1abbf..d904128c 100644 --- a/src/renderer/src/ui/settings/settings-renderer.ts +++ b/src/renderer/src/ui/settings/settings-renderer.ts @@ -47,6 +47,7 @@ import { DiscordWebhookSection } from './sections/DiscordWebhookSection.js'; import { InputDependencySection } from './sections/InputDependencySection.js'; import { PrinterContextSection } from './sections/PrinterContextSection.js'; import { RoundedUISection } from './sections/RoundedUISection.js'; +import { type ISSHSettingsAPI, SSHSection } from './sections/SSHSection.js'; import { SpoolmanTestSection } from './sections/SpoolmanTestSection.js'; import { TabSection } from './sections/TabSection.js'; import type { MutableSettings } from './types.js'; @@ -74,6 +75,10 @@ const resolveAutoUpdateAPI = (): IAutoUpdateAPI | undefined => { return window.api?.dialog?.autoUpdate as IAutoUpdateAPI | undefined; }; +const resolveSSHSettingsAPI = (): ISSHSettingsAPI | undefined => { + return window.api?.dialog?.sshSettings as ISSHSettingsAPI | undefined; +}; + type PerPrinterSettingsConfigKey = | 'CustomCamera' | 'CustomCameraUrl' @@ -115,6 +120,9 @@ const INPUT_TO_CONFIG_MAP: Record = { 'spoolman-enabled': 'SpoolmanEnabled', 'spoolman-server-url': 'SpoolmanServerUrl', 'spoolman-update-mode': 'SpoolmanUpdateMode', + 'start-at-boot': 'StartAtBoot', + 'start-minimized': 'StartMinimized', + 'minimize-to-tray': 'MinimizeToTray', }; class SettingsRenderer { @@ -132,6 +140,14 @@ class SettingsRenderer { private hasUnsavedChanges: boolean = false; private autoDownloadSupported: boolean = true; private perPrinterControlsEnabled: boolean = true; + /** + * Whether the app is running from a packaged (production) build. + * Defaults to `true` so production users can toggle "Start with system". + * Whether the app is running from a packaged (production) build, exposed + * synchronously by the settings preload as window.IS_PACKAGED. Used to + * disable the "Start with system" checkbox in development builds. + */ + private readonly isPackaged: boolean = window.IS_PACKAGED; private desktopThemeSection: DesktopThemeSection | null = null; private tabSection: TabSection | null = null; private dependencySection: InputDependencySection | null = null; @@ -140,6 +156,7 @@ class SettingsRenderer { private discordWebhookSection: DiscordWebhookSection | null = null; private printerContextSection: PrinterContextSection | null = null; private roundedUISection: RoundedUISection | null = null; + private sshSection: SSHSection | null = null; private webUIEnabledToggle: HTMLInputElement | null = null; private readonly settingsAPI?: ISettingsAPI = resolveSettingsAPI(); private readonly printerSettingsAPI?: IPrinterSettingsAPI = resolvePrinterSettingsAPI(); @@ -249,6 +266,16 @@ class SettingsRenderer { document, settings: this.settings, }); + + this.sshSection = new SSHSection({ + document, + sshSettingsAPI: resolveSSHSettingsAPI(), + onDirty: () => { + this.hasUnsavedChanges = true; + this.updateSaveButtonState(); + }, + }); + this.sshSection.initialize(); } private setupEventListeners(): void { @@ -372,6 +399,8 @@ class SettingsRenderer { await this.roundedUISection.initialize(); } + await this.sshSection?.load(); + this.updateInputStates(); } catch (error) { console.error('Failed to request config:', error); @@ -495,13 +524,28 @@ class SettingsRenderer { console.log(`[Settings] Updated global setting ${configKey}:`, value); } + // "Start minimized" only makes sense when the app launches at boot. When "Start with + // system" is turned off, clear StartMinimized so the app does not keep starting minimized + // via a stale value behind the now-disabled checkbox. + if (configKey === 'StartAtBoot' && value === false) { + const startMinimizedInput = this.inputs.get('start-minimized'); + if (startMinimizedInput?.checked) { + startMinimizedInput.checked = false; + } + this.settings.global['StartMinimized'] = false; + } + this.hasUnsavedChanges = true; this.updateSaveButtonState(); this.updateInputStates(); } private updateInputStates(): void { - this.dependencySection?.updateStates(this.perPrinterControlsEnabled, this.autoDownloadSupported); + this.dependencySection?.updateStates( + this.perPrinterControlsEnabled, + this.autoDownloadSupported, + this.isPackaged + ); } private updateSaveButtonState(): void { @@ -534,6 +578,15 @@ class SettingsRenderer { } } + // Save per-printer SSH settings when edited + if (this.sshSection?.isDirty()) { + const sshSuccess = await this.sshSection.save(); + if (!sshSuccess) { + this.showSaveStatus('Failed to save SSH settings', true); + return; + } + } + if (success) { this.hasUnsavedChanges = false; this.updateSaveButtonState(); @@ -699,6 +752,7 @@ class SettingsRenderer { this.discordWebhookSection?.dispose(); this.autoUpdateSection?.dispose(); this.printerContextSection?.dispose(); + this.sshSection?.dispose(); // Note: No longer need to remove IPC listeners since we're using promises } diff --git a/src/renderer/src/ui/settings/settings.css b/src/renderer/src/ui/settings/settings.css index 177993d7..1eeceee5 100644 --- a/src/renderer/src/ui/settings/settings.css +++ b/src/renderer/src/ui/settings/settings.css @@ -686,3 +686,18 @@ input[type="checkbox"] { font-size: 0.85em; color: var(--text-color-secondary); } + +/* SSH settings tab */ +.input-with-button { + display: flex; + gap: 8px; + align-items: center; +} + +.input-with-button .settings-input { + flex: 1; +} + +#ssh-settings-status { + margin-left: 12px; +} diff --git a/src/renderer/src/ui/settings/settings.html b/src/renderer/src/ui/settings/settings.html index e182e3e0..34b5c4d1 100644 --- a/src/renderer/src/ui/settings/settings.html +++ b/src/renderer/src/ui/settings/settings.html @@ -39,6 +39,9 @@ + @@ -369,6 +372,71 @@

Auto-Update Settings

+ +
+

Startup

+ +
+ Launch FlashForgeUI automatically when you sign in to your computer. +
+ +
+ Open the app minimized instead of showing the window. +
+ +
+ + +