diff --git a/.commitlintrc.json b/.commitlintrc.json new file mode 100644 index 0000000..860e27a --- /dev/null +++ b/.commitlintrc.json @@ -0,0 +1,7 @@ +{ + "extends": ["@commitlint/config-conventional"], + "rules": { + "header-max-length": [2, "always", 100], + "body-max-line-length": [1, "always", 100] + } +} diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..9e741c1 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,41 @@ +version: 2 +updates: + # Desktop Go backend (Wails) + - package-ecosystem: gomod + directory: /desktop + schedule: + interval: weekly + open-pull-requests-limit: 5 + commit-message: + prefix: chore + include: scope + + # Desktop frontend (Vite/TS, pnpm) + - package-ecosystem: npm + directory: /desktop/frontend + schedule: + interval: weekly + open-pull-requests-limit: 5 + commit-message: + prefix: chore + include: scope + + # Device web UI (Vite/TS, pnpm) + - package-ecosystem: npm + directory: /web + schedule: + interval: weekly + open-pull-requests-limit: 5 + commit-message: + prefix: chore + include: scope + + # GitHub Actions used by the workflows + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + commit-message: + prefix: ci + include: scope diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..741442f --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,112 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + changes: + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + outputs: + firmware: ${{ steps.filter.outputs.firmware }} + web: ${{ steps.filter.outputs.web }} + desktop: ${{ steps.filter.outputs.desktop }} + steps: + - uses: actions/checkout@v7 + - uses: dorny/paths-filter@v3 + id: filter + with: + filters: | + firmware: + - 'firmware/**' + - 'tools/gen_db_header.py' + - 'material_database.json' + web: + - 'web/**' + - 'tools/build_web.py' + - 'tools/gen_db_header.py' + - 'material_database.json' + desktop: + - 'desktop/**' + + firmware: + needs: changes + if: needs.changes.outputs.firmware == 'true' + name: Build firmware (ESP32-C3) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + - name: Cache PlatformIO + uses: actions/cache@v6 + with: + path: | + ~/.platformio + ~/.cache/pip + key: pio-${{ runner.os }}-${{ hashFiles('firmware/platformio.ini') }} + - name: Install PlatformIO + run: pip install platformio + - name: Prepare generated + secret files + run: | + python tools/gen_db_header.py + cp firmware/include/secrets.h.example firmware/include/secrets.h + - name: Build + run: pio run -d firmware + + web: + needs: changes + if: needs.changes.outputs.web == 'true' + name: Build web assets + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + - uses: actions/setup-node@v6 + with: + node-version: "22" + - name: Enable pnpm (via corepack) + run: corepack enable + - name: Generate DB header + build/gzip web assets + # build_web.py runs the Vite build (pnpm) then gzips web/dist. + run: | + python tools/gen_db_header.py + python tools/build_web.py + + desktop: + needs: changes + if: needs.changes.outputs.desktop == 'true' + name: Build desktop app (Wails) + # Pinned to 22.04 for libwebkit2gtk-4.0-dev (dropped on ubuntu-24.04). + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v5 + with: + go-version: "1.23" + - uses: actions/setup-node@v6 + with: + node-version: "22" + - name: Enable pnpm (via corepack) + run: corepack enable + - name: Install Wails system deps + run: | + sudo apt-get update + sudo apt-get install -y build-essential pkg-config libgtk-3-dev libwebkit2gtk-4.0-dev + - name: Install Wails CLI + run: go install github.com/wailsapp/wails/v2/cmd/wails@v2.12.0 + - name: Build + # `wails build` generates bindings, installs frontend deps (pnpm), + # type-checks (tsc) + bundles the frontend, then compiles the Go binary. + run: cd desktop && wails build + - name: Lint frontend (ESLint) + # deps + generated bindings already present from the build above. + run: cd desktop/frontend && pnpm lint diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml new file mode 100644 index 0000000..e8340e0 --- /dev/null +++ b/.github/workflows/claude-code-review.yml @@ -0,0 +1,44 @@ +name: Claude Code Review + +on: + pull_request: + types: [opened, synchronize, ready_for_review, reopened] + # Optional: Only run on specific file changes + # paths: + # - "src/**/*.ts" + # - "src/**/*.tsx" + # - "src/**/*.js" + # - "src/**/*.jsx" + +jobs: + claude-review: + # Optional: Filter by PR author + # if: | + # github.event.pull_request.user.login == 'external-contributor' || + # github.event.pull_request.user.login == 'new-developer' || + # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' + + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + fetch-depth: 1 + + - name: Run Claude Code Review + id: claude-review + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' + plugins: 'code-review@claude-code-plugins' + prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md + # or https://code.claude.com/docs/en/cli-reference for available options + diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 0000000..580e80a --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,50 @@ +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +jobs: + claude: + if: | + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + actions: read # Required for Claude to read CI results on PRs + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + fetch-depth: 1 + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + + # This is an optional setting that allows Claude to read CI results on PRs + additional_permissions: | + actions: read + + # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. + # prompt: 'Update the pull request description to include a summary of changes.' + + # Optional: Add claude_args to customize behavior and configuration + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md + # or https://code.claude.com/docs/en/cli-reference for available options + # claude_args: '--allowed-tools Bash(gh pr *)' + diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml new file mode 100644 index 0000000..3b68db1 --- /dev/null +++ b/.github/workflows/commitlint.yml @@ -0,0 +1,30 @@ +name: Commit Lint + +# Enforce Conventional Commits (Angular style) so release-please can derive +# versions + changelog. The PR-title check matters most for squash-merges +# (the title becomes the commit on main); the commits check covers merge commits. +on: + pull_request: + types: [opened, edited, synchronize, reopened] + +permissions: + contents: read + pull-requests: read + +jobs: + pr-title: + name: Lint PR title + runs-on: ubuntu-latest + steps: + - uses: amannn/action-semantic-pull-request@v5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + commits: + name: Lint commits + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + - uses: wagoid/commitlint-github-action@v6 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8fba0fe --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,131 @@ +name: Release + +# release-please maintains a Release PR from Conventional Commits; merging it +# tags vX.Y.Z + creates the GitHub Release. The build jobs run in the SAME +# workflow (a GITHUB_TOKEN-created release can't trigger a separate workflow), +# gated on release_created, and attach firmware + desktop artifacts to the tag. +# Firmware and desktop share one lockstep version (from the tag) so they always +# match (same minor is the compatibility requirement). +on: + push: + branches: [main] + +permissions: + contents: write + pull-requests: write + +jobs: + release-please: + runs-on: ubuntu-latest + outputs: + release_created: ${{ steps.rp.outputs.release_created }} + tag_name: ${{ steps.rp.outputs.tag_name }} + version: ${{ steps.rp.outputs.version }} + steps: + - uses: googleapis/release-please-action@v4 + id: rp + with: + token: ${{ secrets.GITHUB_TOKEN }} + + firmware: + needs: release-please + if: ${{ needs.release-please.outputs.release_created == 'true' }} + name: Build + attach firmware + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + - uses: actions/setup-node@v6 + with: + node-version: "22" + - name: Enable pnpm (via corepack) + run: corepack enable + - name: Install PlatformIO + run: pip install platformio + - name: Prepare generated + secret files + # build_web.py runs the Vite build (pnpm) then gzips web/dist. + run: | + python tools/gen_db_header.py + python tools/build_web.py + cp firmware/include/secrets.h.example firmware/include/secrets.h + - name: Build firmware + filesystem (version from tag) + env: + PLATFORMIO_BUILD_FLAGS: -DFW_VERSION='"${{ needs.release-please.outputs.version }}"' + run: | + pio run -d firmware + pio run -d firmware -t buildfs + - name: Collect artifacts + checksums + run: | + out=firmware/.pio/build/xiao_esp32c3 + cp "$out/firmware.bin" firmware.bin + cp "$out/littlefs.bin" littlefs.bin 2>/dev/null || cp "$out/spiffs.bin" littlefs.bin + md5sum firmware.bin littlefs.bin | tee checksums-md5.txt + sha256sum firmware.bin littlefs.bin | tee checksums-sha256.txt + - name: Attach to release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release upload "${{ needs.release-please.outputs.tag_name }}" \ + firmware.bin littlefs.bin checksums-md5.txt checksums-sha256.txt --clobber + + desktop: + needs: release-please + if: ${{ needs.release-please.outputs.release_created == 'true' }} + name: Build + attach desktop (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + include: + # UPX is skipped on macOS: modern macOS kills UPX-packed binaries and + # it breaks code signing. Linux/Windows compress fine. + - os: macos-latest + artifact: SpoolID-macos.zip + upx: "" + - os: windows-latest + artifact: SpoolID-windows.zip + upx: "-upx" + - os: ubuntu-22.04 + artifact: SpoolID-linux.zip + upx: "-upx" + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v5 + with: + go-version: "1.23" + - uses: actions/setup-node@v6 + with: + node-version: "22" + - name: Enable pnpm (via corepack) + run: corepack enable + - name: Install Wails system deps (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y build-essential pkg-config libgtk-3-dev libwebkit2gtk-4.0-dev + - name: Install Wails CLI + run: go install github.com/wailsapp/wails/v2/cmd/wails@v2.12.0 + - name: Install UPX + if: matrix.upx != '' + shell: bash + run: | + if [ "$RUNNER_OS" = "Linux" ]; then sudo apt-get install -y upx-ucl; fi + if [ "$RUNNER_OS" = "Windows" ]; then choco install upx -y; fi + - name: Build (version from tag) + run: cd desktop && wails build ${{ matrix.upx }} -ldflags "-X main.version=${{ needs.release-please.outputs.version }}" + - name: Package artifact + shell: bash + run: | + cd desktop/build/bin + case "${{ runner.os }}" in + macOS) zip -ry "$GITHUB_WORKSPACE/${{ matrix.artifact }}" SpoolID.app ;; + Windows) 7z a "$GITHUB_WORKSPACE/${{ matrix.artifact }}" SpoolID.exe ;; + Linux) zip -j "$GITHUB_WORKSPACE/${{ matrix.artifact }}" SpoolID ;; + esac + - name: Attach to release + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh release upload "${{ needs.release-please.outputs.tag_name }}" "${{ matrix.artifact }}" --clobber diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7b653bf --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# macOS junk +.DS_Store + +# secrets (real creds; commit only the .example) +firmware/include/secrets.h + +# python +.venv/ +__pycache__/ + +# platformio +firmware/.pio/ + +# generated assets (regenerated by tools/) +firmware/data/*.gz +firmware/src/material_db_default.h + +# generated from shared/spec.json by tools/gen_spec.py +firmware/src/generated_spec.h +web/spec.js + +# desktop (Wails: Go backend + Vite/TS frontend) — generated, rebuilt by wails +desktop/build/bin/ +# stray `go build ./...` output (module is named spoolid) +desktop/spoolid +desktop/frontend/node_modules/ +# vite output (embedded by go:embed at build) +desktop/frontend/dist/ +# generated Go<->JS bindings (wails) +desktop/frontend/wailsjs/ +# copied from repo root by the copy-db npm script +desktop/frontend/material_database.json +# wails frontend-change hash cache +desktop/frontend/package.json.md5 +# wails dev-mode Info.plist variant (generated by `wails dev`) +desktop/build/darwin/Info.dev.plist + +# web (Vite/TS UI; built + gzipped into firmware/data by tools/build_web.py) +web/node_modules/ +web/dist/ diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..e18ee07 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.0.0" +} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c7cb989 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,104 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +ESP32-C3 + RC522 writer that programs Creality-format MIFARE Classic 1K RFID tags so a +Creality K2/K1 printer auto-recognizes third-party / refilled filament spools. Three +parts: **firmware** (the device), **web** UI (served by the device), **desktop** Wails app +— Go serial backend + Vite/TS web UI (drives the device over USB serial). + +## Architecture (the big picture) + +**Firmware is the single source of truth for all data and logic.** It owns the AES keys, +the 48-char spoolData format, weight→length codes, and the UI lists (color swatches, spool +sizes, baud rates, log levels). It builds AND encrypts every payload. + +**Web and desktop are thin clients** — they only collect the user's selections and send +them. They hold no baked constants: they fetch the UI lists from the device at startup. +- web → HTTP (`web_server.cpp`): `/api/spec`, `/api/db`, `/api/write`, `/api/read`, `/api/status`, `/api/config`, `/api/ota` +- desktop → line-JSON over USB CDC (`serial_proto.cpp`): `getspec`, `write`, `read`, `status`, `dump`, `getconfig`, `setconfig`, `dbbegin`/`dbdata`/`dbend`, `otabegin`/`otadata`/`otaend` + +OTA: the device self-flashes via ESP `Update` (`ota.cpp`, MD5 + A/B rollback); clients only +relay image bytes (web POST stream, or serial base64 chunks). `getspec` also returns `version`; +the desktop gates compatibility on major.minor. Versioning/releases are automated (release-please ++ the `release` workflow); see the "Releases & over-the-air updates" section in README. + +Read: `read` decrypts blocks 4/5/6 (`rfid::readTag` + `crypto::decryptChunk`) → 48-char spoolData, +parsed by `spool::parse` into materialId/color/weight. Clients pre-fill the write form with those +values so the user reviews and writes them back (no dedicated clone path — write always rebuilds). + +Both transports are thin shells over the same core: `rfid_writer`, `config`, `material_db`, +`ui_spec`, `spool_data`, `creality_crypto`. When adding a capability, add it to the core and +expose it through *both* `web_server.cpp` and `serial_proto.cpp`. + +### Crypto (do not get this wrong — it determines whether the printer reads the tag) +`firmware/src/creality_crypto.cpp` — AES-128-ECB with two fixed keys (`U_KEY`, `D_KEY`, +reverse-engineered from the K2-RFID reference: https://github.com/DnG-Crafts/K2-RFID). Mode 0 (`U_KEY`): the UID replicated to 16 +bytes is the **plaintext**; encrypt it and take the first 6 bytes as the per-tag MIFARE key +("ekey"). Mode 1 (`D_KEY`): encrypt each 16-char spoolData chunk. The UID is never the key. +Keys live only in firmware; clients never see or compute crypto. + +### spoolData (48 hex chars) +`AB124` + `0276` + `A2` + (`1`+5-char material id) + (`0`+RRGGBB) + lengthCode + serial(6) + +`000000` + `00000000`. Built in `spool_data.cpp`. + +### Material database +`material_database.json` shape: `result.list[]`, each item has `.base{id,brand,name,meterialType,colors,...}` +(note the misspelling **meterialType**) and `.kvParam{...}`. `tools/gen_db_header.py` gzips it +into a PROGMEM C header shipped as the default; users can replace it by uploading a +`material_database.json` — the client gzips it (browser `CompressionStream` for web, +`compress/gzip` for the desktop app) since the ESP32 has no runtime gzip compressor, then +sends it to `/api/db` (web) or via `dbbegin`/`dbdata`/`dbend` (serial), stored to LittleFS. + +## Build / run commands + +PlatformIO tooling lives in a repo-root venv (prefer `python3.14`). The desktop app uses +Go + Node + the [Wails CLI](https://wails.io/docs/gettingstarted/installation), not Python. + +```bash +# venv (first time; firmware/tools only) +python3.14 -m venv .venv && .venv/bin/pip install platformio + +# Firmware (run the two generators FIRST — their outputs are git-ignored) +.venv/bin/python tools/gen_db_header.py # -> firmware/src/material_db_default.h +.venv/bin/python tools/build_web.py # web/* -> firmware/data/*.gz +cp firmware/include/secrets.h.example firmware/include/secrets.h # if missing +.venv/bin/pio run -d firmware # compile +.venv/bin/pio run -d firmware -t upload # flash firmware +.venv/bin/pio run -d firmware -t uploadfs # flash LittleFS web assets +.venv/bin/pio device monitor -d firmware # serial @115200 + +# Desktop app (Wails: Go backend + Vite/TS frontend). +# One-time: install Go 1.23+, Node 18+ with pnpm (corepack enable), and the Wails CLI: +# go install github.com/wailsapp/wails/v2/cmd/wails@latest +cd desktop +wails dev # hot-reload dev window +wails build # -> desktop/build/bin/SpoolID.app (== what CI runs) +``` + +After editing anything in `web/`, re-run `tools/build_web.py` (the device serves the gzipped +copies, not the sources). After editing `material_database.json`, re-run `tools/gen_db_header.py`. + +## Gotchas + +- **Generated files are git-ignored and required to build**: `firmware/src/material_db_default.h`, + `firmware/data/*.gz`, `firmware/include/secrets.h`. A fresh checkout won't compile until you + run `gen_db_header.py` + `build_web.py` and create `secrets.h`. CI does this in `.github/workflows/ci.yml`. +- **If `material_database.json` is lost**, recover it by gunzipping the byte array in the + generated `firmware/src/material_db_default.h` (content-identical, reformatted compact). +- **Desktop (Wails) generated files are git-ignored** and rebuilt by `wails build`/`wails dev`: + `desktop/frontend/wailsjs/` (Go↔JS bindings), `desktop/frontend/dist/`, `desktop/build/bin/`, + and `desktop/frontend/material_database.json` (copied from the repo root by the `copy-db` pnpm + script, then bundled into the frontend). The Go backend (`app.go`) is **serial-only**; the + material DB is parsed in the frontend (`frontend/src/db.ts`), like the web client does. +- **Board is XIAO ESP32-C3 with built-in USB-Serial-JTAG** (VID:PID `303A:1001`), not a UART + bridge. `platformio.ini` sets `monitor_rts=0`/`monitor_dtr=0` so the monitor doesn't hold the + chip in reset. The idle `loop()` prints nothing — to confirm the device is alive, send + `{"cmd":"status"}` and expect a JSON reply, or press RST with the monitor open for boot logs. +- **Serial framing**: responses are single-line JSON; firmware log lines are prefixed with `[`. + Clients must ignore non-`{` lines. Commands accept CR, LF, or CRLF line endings. +- Firmware pins (RC522↔XIAO): SS=GPIO6, RST=GPIO5, SCK=GPIO21(D6), MISO=GPIO20(D7), MOSI=GPIO10. + SCK/MISO deliberately avoid GPIO8/GPIO9 — those are C3 boot strapping pins; wiring + the RC522 there holds the chip in download mode at power-up (manual reset needed to boot). diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..c0feb9d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Mateus Demboski + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..58a93b0 --- /dev/null +++ b/README.md @@ -0,0 +1,155 @@ +# SpoolID + +Write Creality-format RFID filament tags so a Creality K2/K1 auto-recognizes +third-party or refilled spools. ESP32-C3 + RC522 writer with a built-in web UI and +a cross-platform desktop app (Wails — Go backend + web UI) over USB serial. + +## Layout + +``` +firmware/ PlatformIO project (Seeed XIAO ESP32-C3, Arduino) +web/ UI source -> gzipped into firmware/data/ by tools/build_web.py +desktop/ Wails desktop app — Go serial backend + Vite/TS web UI +tools/ gen_db_header.py (PROGMEM DB), build_web.py (asset gzip) +material_database.json Creality material DB (default, baked into firmware) +``` + +### Firmware is the source of truth; web + desktop are thin clients + +The firmware owns all data and logic: AES keys, the spoolData format, weight->length +codes, and the UI lists (color swatches, spool sizes, baud rates, log levels). It +builds and encrypts every payload — clients only send the user's selections. + +Clients hold **no baked constants**. They fetch the UI lists from the device at +startup and render them: + +- web: `GET /api/spec` (see `web/app.js`, `web/config.html`) +- desktop: serial `{"cmd":"getspec"}` (see `desktop/frontend/src/main.ts`) + +The firmware-owned lists live in `firmware/src/ui_spec.cpp`. Behaviour-critical data +(keys/format/codes) stays firmware-only and is never exposed. + +## Hardware + +| RC522 | XIAO ESP32-C3 | +|-------|---------------| +| 3.3V | 3.3V | +| GND | GND | +| SDA/SS| GPIO6 (D4) | +| RST | GPIO5 (D3) | +| MOSI | GPIO10 (D10) | +| MISO | GPIO20 (D7) | +| SCK | GPIO21 (D6) | +| IRQ | NC | + +> SCK/MISO avoid GPIO8/GPIO9 — those are ESP32-C3 boot strapping pins; wiring the +> RC522 to them holds the chip in download mode at power-up (needs a manual reset +> to start). D6/D7 are safe. + +Optional active buzzer on `BUZZER_PIN` in `firmware/src/main.cpp` (default `2` = D0; set `-1` to disable). + +## Build & flash firmware + +```bash +# 1. regenerate the PROGMEM default DB + gzipped web assets +.venv/bin/python tools/gen_db_header.py +.venv/bin/python tools/build_web.py + +# 2. copy secrets template and fill WiFi/AP defaults (optional; configurable later) +cp firmware/include/secrets.h.example firmware/include/secrets.h + +# 3. flash code + filesystem +cd firmware +pio run -t upload # firmware +pio run -t uploadfs # LittleFS web assets (data/*.gz) +pio device monitor # serial @115200 +``` + +First boot tries STA WiFi; if it fails it starts the `SpoolID` access point with a +captive portal at the device IP — open it and use the Config page. Once on WiFi the +device is also reachable at `http://spoolid.local` (mDNS hostname, configurable). + +## Using it + +- **Web:** browse to the device IP. Main page = brand → filament → size → color → + Write, then tap a blank MIFARE Classic 1K tag on the reader. **Read** decrypts a + tapped tag and pre-fills the form with its material/color/weight so you can review + and Write it back. Config page = DB upload/pull, WiFi/AP, log level, baud. +- **Desktop app (Wails — Go + web UI):** pick a serial port and **Connect** in the + window, then use the Write / Config views (same brand → filament → size → color → + Write flow, plus Read pre-fill). Prerequisites: [Go](https://go.dev/dl/) 1.23+, + [Node](https://nodejs.org/) 18+ with **pnpm** (`corepack enable`), and the + [Wails CLI](https://wails.io/docs/gettingstarted/installation) + (`go install github.com/wailsapp/wails/v2/cmd/wails@latest`). + ```bash + cd desktop + wails dev # hot-reload dev window + wails build # -> desktop/build/bin/SpoolID.app (.exe / binary on other OSes) + ``` + `wails build` produces a single native binary per platform (macOS `.app`, Windows + `.exe`, Linux ELF) — no runtime, no bundled interpreter. The material DB is copied + from the repo root into `desktop/frontend/` at build time (`copy-db` pnpm script) + and bundled into the frontend. + +## Serial protocol (USB CDC, line-JSON) + +Responses are single-line JSON; log lines start with `[`. Commands: + +```json +{"cmd":"write","materialId":"01001","color":"1200F6","weight":"1KG"} +{"cmd":"status"} +{"cmd":"read"} // decrypt a tapped tag -> materialId/color/weight +{"cmd":"dump"} // raw (encrypted) blocks 4-6 of a tapped tag +{"cmd":"beep"} // fire the buzzer once (test) +{"cmd":"getspec"} // firmware-owned UI lists + firmware version +{"cmd":"getconfig"} / {"cmd":"setconfig", ...} +{"cmd":"dbbegin","size":N} / {"cmd":"dbdata","b":""} / {"cmd":"dbend"} // upload a gzipped DB +{"cmd":"otabegin","size":N,"target":"app","md5":"..."} // start an OTA session +{"cmd":"otadata","b":""} // stream image bytes (* N) +{"cmd":"otaend"} // verify + reboot into the new image +``` + +`getspec` also returns `version` (firmware semver); clients gate compatibility on +the major.minor pair. The web transport mirrors OTA at `POST /api/ota?target=app|fs&md5=`. + +## Releases & over-the-air updates + +Versioning is automated from Conventional Commits (Angular). `release-please` +maintains a Release PR (version bump + CHANGELOG); merging it tags `vX.Y.Z` and the +Release workflow attaches firmware (`firmware.bin`, `littlefs.bin` + checksums) and +desktop builds (macOS/Windows/Linux; the Linux/Windows binaries are UPX-compressed, +macOS is not — the OS rejects packed binaries). Firmware and desktop share one +**lockstep version** and must match on the minor — the desktop warns on a mismatch. + +Updating a device (no cable re-flash after the first install): + +- **Desktop:** Config → *Firmware update* — paste the release asset URL + (`firmware.bin` or `littlefs.bin`), pick the target, Flash. The app downloads it + and streams it to the device over serial. +- **Web:** Config page → *Firmware update* — select a `.bin` and upload (or + `POST /api/ota?target=app|fs&md5=` directly with the raw image body). + +Either way the device does the flashing itself via ESP `Update` (MD5-verified, A/B +rollback), then reboots. Clients only relay bytes — no TLS/esptool on the MCU. + +> First install and any partition-table change still need a one-time USB flash +> (`pio run -t upload` + `-t uploadfs`), which wipes NVS + LittleFS. + +## spoolData (48 hex chars) + +`AB124` + vendor `0276` + `A2` + filamentId(`1`+base.id) + color(`0`+RRGGBB) + +lengthCode + serial(6) + reserve `000000` + `00000000`. +Length codes: 1KG=0330, 750G=0247, 600G=0198, 500G=0165, 250G=0082. + +## Crypto + +AES-128-ECB with two fixed keys reverse-engineered from the Creality firmware +(matches the [K2-RFID](https://github.com/DnG-Crafts/K2-RFID) reference project): + +- **u_key** — derive the 6-byte MIFARE key: encrypt the UID (replicated to 16 bytes) + and take the first 6 bytes. The UID is the *plaintext*, not the key. +- **d_key** — encrypt each 16-char spoolData chunk written to blocks 4/5/6. + +Both keys live only in `firmware/src/creality_crypto.cpp` (clients never see them). +The reference's hand-rolled AES was verified byte-identical to standard AES-128-ECB, +so the mbedtls implementation here produces the same output the printer expects. diff --git a/desktop/app.go b/desktop/app.go new file mode 100644 index 0000000..93e33c9 --- /dev/null +++ b/desktop/app.go @@ -0,0 +1,402 @@ +package main + +import ( + "bytes" + "compress/gzip" + "context" + "crypto/md5" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/wailsapp/wails/v2/pkg/runtime" + "go.bug.st/serial" + "go.bug.st/serial/enumerator" +) + +// espVID is the USB vendor ID of the XIAO ESP32-C3's built-in USB-Serial-JTAG. +const espVID = "303A" + +// ghRepo is the GitHub repo queried for release auto-discovery. +const ghRepo = "mateusdemboski/SpoolID" + +// App holds the serial connection and exposes bound methods to the frontend. +// Every method just moves a line-JSON command to the device and parses the +// single-line JSON reply — the firmware builds and encrypts everything. +type App struct { + ctx context.Context + mu sync.Mutex + port serial.Port +} + +// NewApp creates the application struct bound to the frontend. +func NewApp() *App { return &App{} } + +// Version returns the desktop app's semantic version (set via -ldflags at +// release build). The frontend gates compatibility on the major.minor pair +// against the firmware version reported by getspec. +func (a *App) Version() string { return version } + +// pushDB gzips a material_database.json and streams it to the device over serial +// (dbbegin -> dbdata -> dbend); the device stores it to LittleFS for its web UI. +func (a *App) pushDB(raw []byte) error { + var buf bytes.Buffer + gw := gzip.NewWriter(&buf) + if _, err := gw.Write(raw); err != nil { + return err + } + if err := gw.Close(); err != nil { + return err + } + data := buf.Bytes() + + a.mu.Lock() + defer a.mu.Unlock() + + begin, err := a.sendLocked(map[string]interface{}{"cmd": "dbbegin", "size": len(data)}, 10*time.Second) + if err != nil { + return fmt.Errorf("dbbegin: %w", err) + } + if ok, _ := begin["ok"].(bool); !ok { + return fmt.Errorf("dbbegin rejected: %v", begin["error"]) + } + + const chunk = 4096 // matches OTA_CHUNK in the firmware + for off := 0; off < len(data); off += chunk { + end := off + chunk + if end > len(data) { + end = len(data) + } + b64 := base64.StdEncoding.EncodeToString(data[off:end]) + r, err := a.sendLocked(map[string]interface{}{"cmd": "dbdata", "b": b64}, 10*time.Second) + if err != nil { + return fmt.Errorf("dbdata @%d: %w", off, err) + } + if ok, _ := r["ok"].(bool); !ok { + return fmt.Errorf("dbdata rejected: %v", r["error"]) + } + } + + fin, err := a.sendLocked(map[string]interface{}{"cmd": "dbend"}, 10*time.Second) + if err != nil { + return fmt.Errorf("dbend: %w", err) + } + if ok, _ := fin["ok"].(bool); !ok { + return fmt.Errorf("dbend rejected: %v", fin["error"]) + } + return nil +} + +// UploadDB opens a file picker for a material_database.json and pushes it to the +// device. Returns the uploaded file name, or "" if the picker was cancelled. +func (a *App) UploadDB() (string, error) { + path, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{ + Title: "Select material_database.json", + Filters: []runtime.FileFilter{{DisplayName: "JSON (*.json)", Pattern: "*.json"}}, + }) + if err != nil { + return "", fmt.Errorf("file dialog: %w", err) + } + if path == "" { + return "", nil // cancelled + } + raw, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("read: %w", err) + } + if err := a.pushDB(raw); err != nil { + return "", err + } + return filepath.Base(path), nil +} + +// PullDBFromPrinter fetches material_database.json from a Creality printer over +// the network (TLS/HTTP on the desktop) and pushes it to the device. host is +// "ip" or "ip:port". +func (a *App) PullDBFromPrinter(host string) error { + url := "http://" + host + "/downloads/defData/material_database.json" + resp, err := http.Get(url) + if err != nil { + return fmt.Errorf("printer: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("printer: HTTP %d", resp.StatusCode) + } + raw, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("printer: %w", err) + } + if len(raw) == 0 { + return fmt.Errorf("printer returned an empty database") + } + return a.pushDB(raw) +} + +// Release describes the latest GitHub release for update auto-discovery. +type Release struct { + Version string `json:"version"` // tag without a leading "v" + Tag string `json:"tag"` // the raw tag (vX.Y.Z) + URL string `json:"url"` // release page (html_url) + Firmware string `json:"firmware"` // firmware.bin download URL ("" if absent) + Filesystem string `json:"filesystem"` // littlefs.bin download URL ("" if absent) +} + +// CheckUpdate queries the GitHub Releases API for the latest release and returns +// its version + the firmware/filesystem asset URLs, so the UI can offer a +// one-click update instead of a pasted URL. +func (a *App) CheckUpdate() (*Release, error) { + req, _ := http.NewRequest(http.MethodGet, + "https://api.github.com/repos/"+ghRepo+"/releases/latest", nil) + req.Header.Set("Accept", "application/vnd.github+json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("github: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNotFound { + return nil, fmt.Errorf("no releases published yet") + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("github: HTTP %d", resp.StatusCode) + } + + var gh struct { + TagName string `json:"tag_name"` + HTMLURL string `json:"html_url"` + Assets []struct { + Name string `json:"name"` + URL string `json:"browser_download_url"` + } `json:"assets"` + } + if err := json.NewDecoder(resp.Body).Decode(&gh); err != nil { + return nil, fmt.Errorf("github: %w", err) + } + + rel := &Release{Tag: gh.TagName, Version: strings.TrimPrefix(gh.TagName, "v"), URL: gh.HTMLURL} + for _, as := range gh.Assets { + switch as.Name { + case "firmware.bin": + rel.Firmware = as.URL + case "littlefs.bin": + rel.Filesystem = as.URL + } + } + return rel, nil +} + +func (a *App) startup(ctx context.Context) { a.ctx = ctx } + +func (a *App) shutdown(_ context.Context) { + a.mu.Lock() + defer a.mu.Unlock() + a.closeLocked() +} + +func (a *App) closeLocked() { + if a.port != nil { + _ = a.port.Close() + a.port = nil + } +} + +// PortInfo describes a serial port for the picker. IsDevice flags a likely +// SpoolID writer (the XIAO ESP32-C3 USB-Serial-JTAG), so the UI can label it and +// auto-connect when it's the only one. +type PortInfo struct { + Name string `json:"name"` + Label string `json:"label"` + IsDevice bool `json:"isDevice"` +} + +// ListPorts returns the available serial ports with friendly labels, flagging +// the ones that look like a SpoolID device (by USB VID). +func (a *App) ListPorts() ([]PortInfo, error) { + ports, err := enumerator.GetDetailedPortsList() + if err != nil { + return nil, fmt.Errorf("enumerate ports: %w", err) + } + out := make([]PortInfo, 0, len(ports)) + for _, p := range ports { + info := PortInfo{Name: p.Name, Label: p.Name} + if p.IsUSB { + if strings.EqualFold(p.VID, espVID) { + info.IsDevice = true + info.Label = "SpoolID device — " + p.Name + } else if p.Product != "" { + info.Label = p.Product + " — " + p.Name + } + } + out = append(out, info) + } + return out, nil +} + +// Connect opens a serial port. DTR/RTS are deasserted so the ESP32-C3 +// USB-Serial-JTAG isn't held in reset. +func (a *App) Connect(port string, baud int) error { + a.mu.Lock() + defer a.mu.Unlock() + a.closeLocked() + + p, err := serial.Open(port, &serial.Mode{BaudRate: baud}) + if err != nil { + return fmt.Errorf("open %s: %w", port, err) + } + if err := p.SetReadTimeout(300 * time.Millisecond); err != nil { + _ = p.Close() + return fmt.Errorf("set timeout: %w", err) + } + _ = p.SetDTR(false) + _ = p.SetRTS(false) + time.Sleep(300 * time.Millisecond) + _ = p.ResetInputBuffer() + a.port = p + return nil +} + +// Disconnect closes the current serial port, if any. +func (a *App) Disconnect() error { + a.mu.Lock() + defer a.mu.Unlock() + a.closeLocked() + return nil +} + +// Send writes one JSON command line and returns the device's JSON reply. +func (a *App) Send(cmd map[string]interface{}) (map[string]interface{}, error) { + a.mu.Lock() + defer a.mu.Unlock() + return a.sendLocked(cmd, 8*time.Second) +} + +// sendLocked is the request/response core; the caller must hold a.mu. Firmware +// log lines start with '['; only a line starting with '{' is a reply. +func (a *App) sendLocked(cmd map[string]interface{}, timeout time.Duration) (map[string]interface{}, error) { + if a.port == nil { + return nil, fmt.Errorf("not connected") + } + + line, err := json.Marshal(cmd) + if err != nil { + return nil, err + } + _ = a.port.ResetInputBuffer() + if _, err := a.port.Write(append(line, '\n')); err != nil { + return nil, fmt.Errorf("write: %w", err) + } + + deadline := time.Now().Add(timeout) + buf := make([]byte, 512) + var acc []byte + for time.Now().Before(deadline) { + n, err := a.port.Read(buf) + if err != nil { + return nil, fmt.Errorf("read: %w", err) + } + if n == 0 { + continue // read timeout; keep waiting until the deadline + } + acc = append(acc, buf[:n]...) + for { + i := bytes.IndexByte(acc, '\n') + if i < 0 { + break + } + raw := bytes.TrimSpace(acc[:i]) + acc = acc[i+1:] + if len(raw) == 0 || raw[0] != '{' { + continue // log line or noise + } + var reply map[string]interface{} + if err := json.Unmarshal(raw, &reply); err != nil { + continue + } + return reply, nil + } + } + return nil, fmt.Errorf("no JSON response from device") +} + +// FlashFirmware downloads an image (TLS handled here on the desktop) and relays +// it to the device over serial: otabegin -> otadata(base64) * N -> otaend. The +// device self-flashes via ESP Update (MD5-verified, A/B rollback). Progress is +// emitted as the "ota:progress" event (0..1). `filesystem` targets the LittleFS +// image instead of the app. The device reboots on success, so the port is +// dropped for the UI to reconnect. +func (a *App) FlashFirmware(url string, filesystem bool) error { + runtime.EventsEmit(a.ctx, "ota:progress", 0.0) + + resp, err := http.Get(url) + if err != nil { + return fmt.Errorf("download: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("download: HTTP %d", resp.StatusCode) + } + data, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("download: %w", err) + } + if len(data) == 0 { + return fmt.Errorf("empty image") + } + sum := md5.Sum(data) + md5hex := hex.EncodeToString(sum[:]) + + target := "app" + if filesystem { + target = "fs" + } + + a.mu.Lock() + defer a.mu.Unlock() + + begin, err := a.sendLocked(map[string]interface{}{ + "cmd": "otabegin", "size": len(data), "target": target, "md5": md5hex, + }, 10*time.Second) + if err != nil { + return fmt.Errorf("otabegin: %w", err) + } + if ok, _ := begin["ok"].(bool); !ok { + return fmt.Errorf("otabegin rejected: %v", begin["error"]) + } + + const chunk = 4096 // matches OTA_CHUNK in the firmware + for off := 0; off < len(data); off += chunk { + end := off + chunk + if end > len(data) { + end = len(data) + } + b64 := base64.StdEncoding.EncodeToString(data[off:end]) + r, err := a.sendLocked(map[string]interface{}{"cmd": "otadata", "b": b64}, 10*time.Second) + if err != nil { + return fmt.Errorf("otadata @%d: %w", off, err) + } + if ok, _ := r["ok"].(bool); !ok { + return fmt.Errorf("otadata rejected: %v", r["error"]) + } + runtime.EventsEmit(a.ctx, "ota:progress", float64(end)/float64(len(data))) + } + + fin, err := a.sendLocked(map[string]interface{}{"cmd": "otaend"}, 20*time.Second) + if err != nil { + return fmt.Errorf("otaend: %w", err) + } + if ok, _ := fin["ok"].(bool); !ok { + return fmt.Errorf("otaend rejected: %v", fin["error"]) + } + + a.closeLocked() // device reboots + re-enumerates USB; UI reconnects + runtime.EventsEmit(a.ctx, "ota:progress", 1.0) + return nil +} diff --git a/desktop/build/appicon.png b/desktop/build/appicon.png new file mode 100644 index 0000000..99b303b Binary files /dev/null and b/desktop/build/appicon.png differ diff --git a/desktop/build/darwin/Info.plist b/desktop/build/darwin/Info.plist new file mode 100644 index 0000000..d17a747 --- /dev/null +++ b/desktop/build/darwin/Info.plist @@ -0,0 +1,63 @@ + + + + CFBundlePackageType + APPL + CFBundleName + {{.Info.ProductName}} + CFBundleExecutable + {{.OutputFilename}} + CFBundleIdentifier + com.wails.{{.Name}} + CFBundleVersion + {{.Info.ProductVersion}} + CFBundleGetInfoString + {{.Info.Comments}} + CFBundleShortVersionString + {{.Info.ProductVersion}} + CFBundleIconFile + iconfile + LSMinimumSystemVersion + 10.13.0 + NSHighResolutionCapable + true + NSHumanReadableCopyright + {{.Info.Copyright}} + {{if .Info.FileAssociations}} + CFBundleDocumentTypes + + {{range .Info.FileAssociations}} + + CFBundleTypeExtensions + + {{.Ext}} + + CFBundleTypeName + {{.Name}} + CFBundleTypeRole + {{.Role}} + CFBundleTypeIconFile + {{.IconName}} + + {{end}} + + {{end}} + {{if .Info.Protocols}} + CFBundleURLTypes + + {{range .Info.Protocols}} + + CFBundleURLName + com.wails.{{.Scheme}} + CFBundleURLSchemes + + {{.Scheme}} + + CFBundleTypeRole + {{.Role}} + + {{end}} + + {{end}} + + diff --git a/desktop/frontend/eslint.config.js b/desktop/frontend/eslint.config.js new file mode 100644 index 0000000..cfd115a --- /dev/null +++ b/desktop/frontend/eslint.config.js @@ -0,0 +1,20 @@ +// @ts-check +import js from "@eslint/js"; +import tseslint from "typescript-eslint"; + +// Frontend lint (ESLint + typescript-eslint, flat config). Generated code +// (wailsjs bindings), build output, and the copied DB are ignored. +export default tseslint.config( + { + ignores: ["dist", "wailsjs", "node_modules", "material_database.json"], + }, + js.configs.recommended, + ...tseslint.configs.recommended, + { + rules: { + // The serial/HTTP layer deals in dynamic JSON, so `any` at those + // boundaries (Reply, invoke wrappers) is intentional. + "@typescript-eslint/no-explicit-any": "off", + }, + }, +); diff --git a/desktop/frontend/index.html b/desktop/frontend/index.html new file mode 100644 index 0000000..bbfc743 --- /dev/null +++ b/desktop/frontend/index.html @@ -0,0 +1,150 @@ + + + + + + SpoolID + + +
+

SpoolID

+ Creality RFID spool writer + +
+ + +
+
+

Connect a device

+

Scanning for SpoolID devices…

+
+ + +
+ + +
+
+
+ + + + + + +
+ +
+ + + + +
+ Color +
+
+ + #000000 + +
+
+ +
+ + +
+
+

+
+ + + +
+ + + + diff --git a/desktop/frontend/package.json b/desktop/frontend/package.json new file mode 100644 index 0000000..f1159a8 --- /dev/null +++ b/desktop/frontend/package.json @@ -0,0 +1,20 @@ +{ + "name": "spoolid-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "copy-db": "node -e \"require('fs').copyFileSync('../../material_database.json','material_database.json')\"", + "dev": "pnpm copy-db && vite", + "build": "pnpm copy-db && tsc && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "eslint": "^10.6.0", + "typescript": "~5.6.0", + "typescript-eslint": "^8.62.1", + "vite": "^6.0.0" + } +} diff --git a/desktop/frontend/pnpm-lock.yaml b/desktop/frontend/pnpm-lock.yaml new file mode 100644 index 0000000..e16e009 --- /dev/null +++ b/desktop/frontend/pnpm-lock.yaml @@ -0,0 +1,1408 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@10.6.0) + eslint: + specifier: ^10.6.0 + version: 10.6.0 + typescript: + specifier: ~5.6.0 + version: 5.6.3 + typescript-eslint: + specifier: ^8.62.1 + version: 8.62.1(eslint@10.6.0)(typescript@5.6.3) + vite: + specifier: ^6.0.0 + version: 6.4.3 + +packages: + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.6.0': + resolution: {integrity: sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/js@10.0.1': + resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.7.2': + resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@rollup/rollup-android-arm-eabi@4.62.2': + resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.2': + resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.2': + resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.2': + resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.2': + resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.2': + resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.62.2': + resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.62.2': + resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.62.2': + resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.62.2': + resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.62.2': + resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.2': + resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.2': + resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.2': + resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} + cpu: [x64] + os: [win32] + + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@typescript-eslint/eslint-plugin@8.62.1': + resolution: {integrity: sha512-4EQM77WgVNxj7OkL/5b/D/xZsw00G577+UriYTC7JF5opcF3T2AuoeY7ueLaZgSVjSgCS6yOAJB5bRGLPSJUzA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.62.1 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.62.1': + resolution: {integrity: sha512-sPhE4iHuJDSvoAiec+Ro8JyXw8f0ql13HFR82P99nCm9GwTEKG0KYLvDe6REk8BCXuit6vJAv/Yxg5ABaNS2rA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.62.1': + resolution: {integrity: sha512-yQ3RgY5RkSBpsNS1Bx/JQEcA24FOSdfGktoyprAr5u18390UQdtVcfnEv4nIrIshNnavlVyZBKxQwT1fIAE6cg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.62.1': + resolution: {integrity: sha512-r4d249KbQ1SFdpeStvob8Ih6aPPIzfqllPVOtvhve6ZcpuVcYo5/7zUWckKpHE7StASX4kTKZTLf0WQm/wPkcg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.62.1': + resolution: {integrity: sha512-xadytJqX9vJVQ2fdQjkcIVigwaOJNWkpjdLt6cEQ+xPnrI1fkp+/jZE/I97k9KUjqtpd25i0HeyZf3T6dutv2g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.62.1': + resolution: {integrity: sha512-aXM5xlqXiTxPibXB93cLAURfT3rlizf7uMXISCXy66Isr/9hISJx3yDsKl0L7lKa51b8JpFuNKby0/O0pEm9jg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.62.1': + resolution: {integrity: sha512-ooCzJFaf+Hg+uG6fA3NRFGuFjlfNlDhBthbv4ZPU/0elCAFUfnyXUvf/WOpHz/jYwSmvU2GkR2LtyUfy1AxZ1Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.62.1': + resolution: {integrity: sha512-xMcW9oP9u7fAMXYs9A65CVmtLQe2r//oXINHfi8HV+oiqhih17sbLdhXr4540YWlgpDKQdY854OL5ZrdCiQsAA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.62.1': + resolution: {integrity: sha512-sHtbPfuKNZCG+ih8SyjjucqRntSVmp8XgL5u6o9mAhiSn8ds5o/M/XdM0abweme2Tln3szOstOrZ9OXitvPh0g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.62.1': + resolution: {integrity: sha512-4g3BLxfdTMy8iZG0MaBkadnlRrCJ74cQiFbyEVMrkwIoqdyaXXQM22cotDvrl4x28wgIZ9rEJRoM+mmhSJpJ1g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + brace-expansion@5.0.7: + resolution: {integrity: sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==} + engines: {node: 18 || 20 || >=22} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.6.0: + resolution: {integrity: sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.15: + resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss@8.5.16: + resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + rollup@4.62.2: + resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typescript-eslint@8.62.1: + resolution: {integrity: sha512-vymnnM5g0AKQDSAyfP12nMIBvgwgA42syg74kkuZ4x1VuTzwQKwc5h9rGxeShCjny5o+zWAb6OEoz7XLgrIkIw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@5.6.3: + resolution: {integrity: sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==} + engines: {node: '>=14.17'} + hasBin: true + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + vite@6.4.3: + resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + +snapshots: + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@10.6.0)': + dependencies: + eslint: 10.6.0 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.23.5': + dependencies: + '@eslint/object-schema': 3.0.5 + debug: 4.4.3 + minimatch: 10.2.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.6.0': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/js@10.0.1(eslint@10.6.0)': + optionalDependencies: + eslint: 10.6.0 + + '@eslint/object-schema@3.0.5': {} + + '@eslint/plugin-kit@0.7.2': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@rollup/rollup-android-arm-eabi@4.62.2': + optional: true + + '@rollup/rollup-android-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-x64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.2': + optional: true + + '@types/esrecurse@4.3.1': {} + + '@types/estree@1.0.9': {} + + '@types/json-schema@7.0.15': {} + + '@typescript-eslint/eslint-plugin@8.62.1(@typescript-eslint/parser@8.62.1(eslint@10.6.0)(typescript@5.6.3))(eslint@10.6.0)(typescript@5.6.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.62.1(eslint@10.6.0)(typescript@5.6.3) + '@typescript-eslint/scope-manager': 8.62.1 + '@typescript-eslint/type-utils': 8.62.1(eslint@10.6.0)(typescript@5.6.3) + '@typescript-eslint/utils': 8.62.1(eslint@10.6.0)(typescript@5.6.3) + '@typescript-eslint/visitor-keys': 8.62.1 + eslint: 10.6.0 + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.6.3) + typescript: 5.6.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.62.1(eslint@10.6.0)(typescript@5.6.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.62.1 + '@typescript-eslint/types': 8.62.1 + '@typescript-eslint/typescript-estree': 8.62.1(typescript@5.6.3) + '@typescript-eslint/visitor-keys': 8.62.1 + debug: 4.4.3 + eslint: 10.6.0 + typescript: 5.6.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.62.1(typescript@5.6.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.62.1(typescript@5.6.3) + '@typescript-eslint/types': 8.62.1 + debug: 4.4.3 + typescript: 5.6.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.62.1': + dependencies: + '@typescript-eslint/types': 8.62.1 + '@typescript-eslint/visitor-keys': 8.62.1 + + '@typescript-eslint/tsconfig-utils@8.62.1(typescript@5.6.3)': + dependencies: + typescript: 5.6.3 + + '@typescript-eslint/type-utils@8.62.1(eslint@10.6.0)(typescript@5.6.3)': + dependencies: + '@typescript-eslint/types': 8.62.1 + '@typescript-eslint/typescript-estree': 8.62.1(typescript@5.6.3) + '@typescript-eslint/utils': 8.62.1(eslint@10.6.0)(typescript@5.6.3) + debug: 4.4.3 + eslint: 10.6.0 + ts-api-utils: 2.5.0(typescript@5.6.3) + typescript: 5.6.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.62.1': {} + + '@typescript-eslint/typescript-estree@8.62.1(typescript@5.6.3)': + dependencies: + '@typescript-eslint/project-service': 8.62.1(typescript@5.6.3) + '@typescript-eslint/tsconfig-utils': 8.62.1(typescript@5.6.3) + '@typescript-eslint/types': 8.62.1 + '@typescript-eslint/visitor-keys': 8.62.1 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.6.3) + typescript: 5.6.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.62.1(eslint@10.6.0)(typescript@5.6.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.6.0) + '@typescript-eslint/scope-manager': 8.62.1 + '@typescript-eslint/types': 8.62.1 + '@typescript-eslint/typescript-estree': 8.62.1(typescript@5.6.3) + eslint: 10.6.0 + typescript: 5.6.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.62.1': + dependencies: + '@typescript-eslint/types': 8.62.1 + eslint-visitor-keys: 5.0.1 + + acorn-jsx@5.3.2(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + + acorn@8.17.0: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + balanced-match@4.0.4: {} + + brace-expansion@5.0.7: + dependencies: + balanced-match: 4.0.4 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + escape-string-regexp@4.0.0: {} + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.9 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.6.0: + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.6.0) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.6.0 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.2 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@11.2.0: + dependencies: + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) + eslint-visitor-keys: 5.0.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + + fsevents@2.3.3: + optional: true + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + imurmurhash@0.1.4: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + isexe@2.0.0: {} + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.7 + + ms@2.1.3: {} + + nanoid@3.3.15: {} + + natural-compare@1.4.0: {} + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postcss@8.5.16: + dependencies: + nanoid: 3.3.15 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + punycode@2.3.1: {} + + rollup@4.62.2: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.62.2 + '@rollup/rollup-android-arm64': 4.62.2 + '@rollup/rollup-darwin-arm64': 4.62.2 + '@rollup/rollup-darwin-x64': 4.62.2 + '@rollup/rollup-freebsd-arm64': 4.62.2 + '@rollup/rollup-freebsd-x64': 4.62.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 + '@rollup/rollup-linux-arm-musleabihf': 4.62.2 + '@rollup/rollup-linux-arm64-gnu': 4.62.2 + '@rollup/rollup-linux-arm64-musl': 4.62.2 + '@rollup/rollup-linux-loong64-gnu': 4.62.2 + '@rollup/rollup-linux-loong64-musl': 4.62.2 + '@rollup/rollup-linux-ppc64-gnu': 4.62.2 + '@rollup/rollup-linux-ppc64-musl': 4.62.2 + '@rollup/rollup-linux-riscv64-gnu': 4.62.2 + '@rollup/rollup-linux-riscv64-musl': 4.62.2 + '@rollup/rollup-linux-s390x-gnu': 4.62.2 + '@rollup/rollup-linux-x64-gnu': 4.62.2 + '@rollup/rollup-linux-x64-musl': 4.62.2 + '@rollup/rollup-openbsd-x64': 4.62.2 + '@rollup/rollup-openharmony-arm64': 4.62.2 + '@rollup/rollup-win32-arm64-msvc': 4.62.2 + '@rollup/rollup-win32-ia32-msvc': 4.62.2 + '@rollup/rollup-win32-x64-gnu': 4.62.2 + '@rollup/rollup-win32-x64-msvc': 4.62.2 + fsevents: 2.3.3 + + semver@7.8.5: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + source-map-js@1.2.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + ts-api-utils@2.5.0(typescript@5.6.3): + dependencies: + typescript: 5.6.3 + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typescript-eslint@8.62.1(eslint@10.6.0)(typescript@5.6.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.62.1(@typescript-eslint/parser@8.62.1(eslint@10.6.0)(typescript@5.6.3))(eslint@10.6.0)(typescript@5.6.3) + '@typescript-eslint/parser': 8.62.1(eslint@10.6.0)(typescript@5.6.3) + '@typescript-eslint/typescript-estree': 8.62.1(typescript@5.6.3) + '@typescript-eslint/utils': 8.62.1(eslint@10.6.0)(typescript@5.6.3) + eslint: 10.6.0 + typescript: 5.6.3 + transitivePeerDependencies: + - supports-color + + typescript@5.6.3: {} + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + vite@6.4.3: + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.16 + rollup: 4.62.2 + tinyglobby: 0.2.17 + optionalDependencies: + fsevents: 2.3.3 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + yocto-queue@0.1.0: {} diff --git a/desktop/frontend/pnpm-workspace.yaml b/desktop/frontend/pnpm-workspace.yaml new file mode 100644 index 0000000..ae578a1 --- /dev/null +++ b/desktop/frontend/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +# Allow esbuild (Vite's bundler) to run its install script. pnpm blocks build +# scripts by default; without this, `pnpm install` errors ERR_PNPM_IGNORED_BUILDS. +allowBuilds: + esbuild: true diff --git a/desktop/frontend/src/db.ts b/desktop/frontend/src/db.ts new file mode 100644 index 0000000..756de36 --- /dev/null +++ b/desktop/frontend/src/db.ts @@ -0,0 +1,43 @@ +// Material database: parsed in the frontend from the bundled Creality JSON +// (mirrors the web client, which fetches /api/db and parses in the browser). +// The JSON is copied from the repo root into frontend/ by the `copy-db` script. +import rawDb from "../material_database.json"; + +export interface Material { + id: string; + brand: string; + name: string; + type: string; +} + +interface DbDoc { + result?: { + list?: Array<{ + base?: { id?: string; brand?: string; name?: string; meterialType?: string }; + }>; + }; +} + +// loadDb flattens result.list[].base into Material rows. Note the DB's +// misspelled type key, "meterialType". +export function loadDb(): Material[] { + const doc = rawDb as DbDoc; + const out: Material[] = []; + for (const it of doc.result?.list ?? []) { + const b = it.base; + if (!b?.id) continue; + out.push({ id: b.id, brand: b.brand ?? "", name: b.name ?? "", type: b.meterialType ?? "" }); + } + return out; +} + +export const brands = (items: Material[]): string[] => + [...new Set(items.map((m) => m.brand))].sort(); + +export const byBrand = (items: Material[], brand: string): Material[] => + items.filter((m) => m.brand === brand); + +export const nameFor = (items: Material[], id: string): string => { + const m = items.find((x) => x.id === id); + return m ? `${m.brand} ${m.name}` : ""; +}; diff --git a/desktop/frontend/src/main.ts b/desktop/frontend/src/main.ts new file mode 100644 index 0000000..60d5b4f --- /dev/null +++ b/desktop/frontend/src/main.ts @@ -0,0 +1,635 @@ +// SpoolID desktop UI. Firmware is the source of truth: on connect we fetch the +// UI lists (swatches, sizes, log levels, baud rates) and current config over +// serial, then drive the same brand -> filament -> size -> color -> Write flow +// as the web UI. Serial calls are bound Go methods (see serial.ts). +import "./theme.css"; +import { + appVersion, + checkUpdate, + connect, + disconnect, + flashFirmware, + listPorts, + onOtaProgress, + pullDb, + send, + uploadDb, +} from "./serial"; +import type { PortInfo, Release, Reply } from "./serial"; +import { brands, byBrand, loadDb, nameFor } from "./db"; +import type { Material } from "./db"; + +const BAUD = 115200; + +// Friendly names for the Creality swatch palette (presentation only; the hex +// list comes from the device spec). Unknown hex shows as "Custom". +const COLOR_NAMES: Record = { + "1200F6": "Blue", + "3894E1": "Light Blue", + "FEFF01": "Yellow", + "F8D531": "Gold", + "F38E24": "Orange", + "52D048": "Green", + "00FEBE": "Teal", + "B700F3": "Purple", + "EE301A": "Red", + "FA5959": "Coral", + "FFFFFF": "White", + "D8D8D8": "Light Gray", + "4C4C4C": "Dark Gray", + "782543": "Maroon", + "000000": "Black", +}; + +const $ = (id: string): T => + document.getElementById(id) as T; + +// ---- state ---- +let items: Material[] = []; +let swatches: string[] = []; // hex strings, no leading '#' +let color = "000000"; +let polling: number | null = null; +let appVer = ""; // desktop version, for the compat gate +let latest: Release | null = null; // latest GitHub release (auto-discovery) + +// ---- element refs ---- +const el = { + port: $("port"), + rescan: $("rescan"), + connect: $("connect"), + disconnect: $("disconnect"), + connStatus: $("connStatus"), + gateHint: $("gateHint"), + spinner: $("spinner"), + navWrite: $("navWrite"), + navConfig: $("navConfig"), + writeView: $("writeView"), + configView: $("configView"), + brand: $("brand"), + filament: $("filament"), + size: $("size"), + swatches: $("swatches"), + chip: $("chip"), + hex: $("hex"), + custom: $("custom"), + write: $("write"), + read: $("read"), + readout: $("readout"), + writeStatus: $("writeStatus"), + wifiSsid: $("wifiSsid"), + wifiPass: $("wifiPass"), + hostname: $("hostname"), + apSsid: $("apSsid"), + apPass: $("apPass"), + logLevel: $("logLevel"), + baud: $("baud"), + save: $("save"), + configStatus: $("configStatus"), + subDevice: $("subDevice"), + subDb: $("subDb"), + subOta: $("subOta"), + paneDevice: $("paneDevice"), + paneDb: $("paneDb"), + paneOta: $("paneOta"), + uploadDb: $("uploadDb"), + dbHost: $("dbHost"), + dbPull: $("dbPull"), + dbStatus: $("dbStatus"), + compat: $("compat"), + otaUrl: $("otaUrl"), + otaTarget: $("otaTarget"), + flash: $("flash"), + otaBar: $("otaBar"), + otaStatus: $("otaStatus"), + checkUpdate: $("checkUpdate"), + updateInfo: $("updateInfo"), + updateActions: $("updateActions"), + flashLatestApp: $("flashLatestApp"), + flashLatestFs: $("flashLatestFs"), +}; + +// Controls that require an open connection. +const gated = (): (HTMLInputElement | HTMLSelectElement | HTMLButtonElement)[] => [ + el.brand, el.filament, el.size, el.logLevel, el.baud, el.custom, + el.write, el.read, el.save, el.uploadDb, el.dbHost, el.dbPull, + el.wifiSsid, el.wifiPass, el.hostname, el.apSsid, el.apPass, + el.otaUrl, el.otaTarget, el.flash, el.flashLatestApp, el.flashLatestFs, +]; + +// ---- helpers ---- +function setConn(msg: string, err: boolean): void { + el.connStatus.textContent = msg; + el.connStatus.className = "conn " + (err ? "err" : "ok"); +} + +function setStatus(node: HTMLElement, msg: string, kind: "" | "ok" | "err" = ""): void { + node.textContent = msg; + node.className = "status" + (kind ? " " + kind : ""); +} + +function options(node: HTMLSelectElement, values: string[]): void { + node.innerHTML = values.map((v) => ``).join(""); +} + +function setEnabled(on: boolean): void { + for (const node of gated()) node.disabled = !on; +} + +// ---- view switch ---- +function show(which: "write" | "config"): void { + el.writeView.hidden = which !== "write"; + el.configView.hidden = which !== "config"; + el.navWrite.classList.toggle("active", which === "write"); + el.navConfig.classList.toggle("active", which === "config"); +} + +// ---- color ---- +function colorLabel(hex: string): string { + return `${COLOR_NAMES[hex.toUpperCase()] ?? "Custom"} #${hex}`; +} + +function setColor(hex: string): void { + color = hex.toUpperCase(); + el.chip.style.background = "#" + color; + el.hex.textContent = "#" + color; + document.querySelectorAll(".swatch").forEach((s) => + s.classList.toggle("sel", !s.dataset.custom && s.dataset.hex === color)); +} + +function buildSwatches(): void { + el.swatches.innerHTML = ""; + swatches.forEach((hex) => { + const d = document.createElement("div"); + d.className = "swatch"; + d.dataset.hex = hex; + d.style.background = "#" + hex; + d.title = colorLabel(hex); + d.onclick = () => selectSwatch(hex); + el.swatches.appendChild(d); + }); + // Trailing "custom" tile reveals the hex input. + const c = document.createElement("div"); + c.className = "swatch"; + c.dataset.custom = "1"; + c.title = "Custom hex"; + c.textContent = "+"; + c.style.display = "grid"; + c.style.placeItems = "center"; + c.style.border = "2px dashed var(--border)"; + c.style.color = "var(--muted)"; + c.onclick = () => selectCustom(); + el.swatches.appendChild(c); +} + +function selectSwatch(hex: string): void { + el.custom.hidden = true; + setColor(hex); +} + +function selectCustom(): void { + el.custom.hidden = false; + document.querySelectorAll(".swatch").forEach((s) => s.classList.remove("sel")); + el.custom.focus(); + applyCustom(); +} + +function applyCustom(): void { + const hex = el.custom.value.trim().replace(/^#/, ""); + if (/^[0-9a-fA-F]{6}$/.test(hex)) setColor(hex); +} + +// ---- device spec + config ---- +function applySpec(spec: Reply): void { + swatches = (spec.colorSwatches ?? []) as string[]; + options(el.size, (spec.weightLabels ?? []) as string[]); + options(el.logLevel, (spec.logLevels ?? []) as string[]); + options(el.baud, ((spec.baudRates ?? []) as number[]).map(String)); + buildSwatches(); + if (swatches.length) selectSwatch(swatches[0]); + checkCompat(String(spec.version ?? "")); +} + +// ---- version compatibility gate ---- +// Desktop and firmware must share the same major.minor (full match preferred). +// Dev builds (0.0.0-dev / missing) skip the check. +function minorOf(v: string): string | null { + const m = /^(\d+)\.(\d+)\./.exec(v); + return m ? `${m[1]}.${m[2]}` : null; +} + +function checkCompat(fwVer: string): void { + const app = minorOf(appVer); + const fw = minorOf(fwVer); + if (!app || !fw) { + el.compat.hidden = true; // a dev build on either side — don't nag + return; + } + if (app === fw) { + el.compat.hidden = true; + return; + } + el.compat.hidden = false; + el.compat.textContent = + `⚠ version mismatch — desktop v${appVer} vs firmware v${fwVer}. ` + + `Update so both share the same minor (Config → Firmware update).`; +} + +function applyConfig(cfg: Reply): void { + el.wifiSsid.value = cfg.wifiSsid ?? ""; + el.hostname.value = cfg.hostname ?? ""; + el.apSsid.value = cfg.apSsid ?? ""; + if (cfg.logLevel != null) el.logLevel.selectedIndex = Number(cfg.logLevel); + if (cfg.baud != null) el.baud.value = String(cfg.baud); +} + +// ---- brand/filament ---- +function fillBrands(): void { + options(el.brand, brands(items)); + fillFilaments(); +} + +function fillFilaments(): void { + const list = byBrand(items, el.brand.value); + el.filament.innerHTML = list + .map((m) => ``) + .join(""); +} + +// ---- connection ---- +type State = "disconnected" | "connecting" | "connected"; +let pollTimer: number | null = null; +let autoConnect = true; // re-armed when no device is present (unplug -> replug) + +function setState(s: State): void { + document.body.dataset.state = s; + el.disconnect.hidden = s !== "connected"; + el.spinner.hidden = s !== "connecting"; + const busy = s === "connecting"; + el.connect.disabled = busy; + el.port.disabled = busy; + el.rescan.disabled = busy; +} + +// Refresh the port list, keeping the current pick (or preferring a device), and +// update the gate hint. Returns the discovered ports. +async function refreshPorts(): Promise { + let ports: PortInfo[]; + try { + ports = await listPorts(); + } catch { + return []; + } + const prev = el.port.value; + el.port.innerHTML = ports + .map((p) => ``).join(""); + const device = ports.find((p) => p.isDevice); + if (ports.some((p) => p.name === prev)) el.port.value = prev; + else if (device) el.port.value = device.name; + + el.gateHint.textContent = ports.length === 0 + ? "Scanning for a SpoolID device… plug one in over USB." + : device + ? (autoConnect ? "Found a SpoolID device — connecting…" : "SpoolID device found — click Connect.") + : "Select a serial port, then Connect."; + return ports; +} + +// While disconnected, poll for ports and auto-connect a lone SpoolID device. +// autoConnect re-arms once no device is present, so a manual Disconnect doesn't +// immediately reconnect, but unplugging + replugging does. +async function poll(): Promise { + if (document.body.dataset.state !== "disconnected") return; + const ports = await refreshPorts(); + const devices = ports.filter((p) => p.isDevice); + if (devices.length === 0) { + autoConnect = true; + return; + } + if (devices.length === 1 && autoConnect) { + el.port.value = devices[0].name; + await doConnect(); + } +} + +function startPolling(): void { + if (pollTimer === null) pollTimer = window.setInterval(() => void poll(), 2000); +} + +function stopPolling(): void { + if (pollTimer !== null) { clearInterval(pollTimer); pollTimer = null; } +} + +async function doConnect(): Promise { + const port = el.port.value; + if (!port) return setConn("pick a serial port", true); + stopPolling(); + setState("connecting"); + setConn("", false); + try { + await connect(port, BAUD); + } catch (e) { + setState("disconnected"); + setConn(`open failed: ${e}`, true); + startPolling(); + return; + } + try { + const spec = await send({ cmd: "getspec" }); + applySpec(spec); + const cfg = await send({ cmd: "getconfig" }); + applyConfig(cfg); + fillBrands(); + } catch (e) { + setState("disconnected"); + setConn(`serial error: ${e}`, true); + startPolling(); + return; + } + setEnabled(true); + setState("connected"); +} + +async function doDisconnect(): Promise { + try { + await disconnect(); + } catch { + /* already gone */ + } + autoConnect = false; // don't immediately reconnect the same device + setEnabled(false); + el.compat.hidden = true; + setState("disconnected"); + setConn("", false); + startPolling(); + void refreshPorts(); +} + +// ---- write / read ---- +async function doWrite(): Promise { + const materialId = el.filament.value; + const weight = el.size.value; + if (!materialId || !weight) { + return setStatus(el.writeStatus, "pick a filament and size first", "err"); + } + el.write.disabled = true; + setStatus(el.writeStatus, "staging — tap a tag on the reader…"); + try { + const resp = await send({ cmd: "write", materialId, color, weight }); + if (!resp.ok) { + setStatus(el.writeStatus, `error: ${resp.error}`, "err"); + el.write.disabled = false; + return; + } + if (polling == null) polling = window.setInterval(pollStatus, 700); + } catch (e) { + setStatus(el.writeStatus, `serial error: ${e}`, "err"); + el.write.disabled = false; + } +} + +async function pollStatus(): Promise { + try { + const st = await send({ cmd: "status" }); + const last = st.last; + if (!last || !last.done) return; + if (polling != null) { clearInterval(polling); polling = null; } + el.write.disabled = false; + if (last.ok) { + const enc = last.encrypted ? " (re-encrypted)" : ""; + setStatus(el.writeStatus, `written ✓ UID ${last.uid}${enc}`, "ok"); + } else { + setStatus(el.writeStatus, `error: ${last.error}`, "err"); + } + } catch (e) { + if (polling != null) { clearInterval(polling); polling = null; } + el.write.disabled = false; + setStatus(el.writeStatus, `serial error: ${e}`, "err"); + } +} + +async function doRead(): Promise { + el.read.disabled = true; + setStatus(el.writeStatus, "reading — tap a tag on the reader…"); + try { + const j = await send({ cmd: "read" }); + if (!j.ok) { + setStatus(el.writeStatus, `read failed: ${j.error}`, "err"); + return; + } + applyRead(j); + setStatus(el.writeStatus, "tag read ✓ — review values and Write", "ok"); + } catch (e) { + setStatus(el.writeStatus, `serial error: ${e}`, "err"); + } finally { + el.read.disabled = false; + } +} + +function applyRead(j: Reply): void { + const materialId = String(j.materialId ?? ""); + const m = items.find((x) => x.id === materialId); + if (m) { + el.brand.value = m.brand; + fillFilaments(); + el.filament.value = m.id; + } + if (j.weight) el.size.value = j.weight; + + const hex = String(j.color ?? "").toUpperCase(); + if (hex) { + if (swatches.some((s) => s.toUpperCase() === hex)) { + selectSwatch(hex); + } else { + selectCustom(); + el.custom.value = hex; + applyCustom(); + } + } + + const label = m ? `${m.brand} ${m.name}` : nameFor(items, materialId) || `id ${materialId}`; + const locked = j.encrypted ? " · locked" : ""; + el.readout.textContent = `read: ${label} · ${j.weight ?? "?"} · #${hex}${locked}`; +} + +// ---- config ---- +async function doSave(): Promise { + const cmd: Record = { + cmd: "setconfig", + wifiSsid: el.wifiSsid.value, + hostname: el.hostname.value, + apSsid: el.apSsid.value, + logLevel: el.logLevel.selectedIndex, + }; + if (el.baud.value) cmd.baud = Number(el.baud.value); + if (el.wifiPass.value) cmd.wifiPass = el.wifiPass.value; + if (el.apPass.value) cmd.apPass = el.apPass.value; + await simple(cmd, el.configStatus, "saved ✓ reboot device to apply WiFi", "save failed"); +} + +// ---- config sub-tabs (Device / Material database / OTA) ---- +function showConfigPane(which: "device" | "db" | "ota"): void { + el.paneDevice.hidden = which !== "device"; + el.paneDb.hidden = which !== "db"; + el.paneOta.hidden = which !== "ota"; + el.subDevice.classList.toggle("active", which === "device"); + el.subDb.classList.toggle("active", which === "db"); + el.subOta.classList.toggle("active", which === "ota"); +} + +// ---- material database: upload a file, or pull from a printer ---- +async function doUploadDb(): Promise { + el.uploadDb.disabled = true; + setStatus(el.dbStatus, "uploading to device…"); + try { + const name = await uploadDb(); + if (name) setStatus(el.dbStatus, `uploaded ${name} ✓`, "ok"); + else setStatus(el.dbStatus, ""); // cancelled + } catch (e) { + setStatus(el.dbStatus, `upload failed: ${e}`, "err"); + } finally { + el.uploadDb.disabled = false; + } +} + +async function doPullDb(): Promise { + const host = el.dbHost.value.trim(); + if (!host) return setStatus(el.dbStatus, "enter the printer host/IP", "err"); + el.dbPull.disabled = true; + setStatus(el.dbStatus, "fetching from printer + uploading…"); + try { + await pullDb(host); + setStatus(el.dbStatus, "pulled from printer ✓", "ok"); + } catch (e) { + setStatus(el.dbStatus, `pull failed: ${e}`, "err"); + } finally { + el.dbPull.disabled = false; + } +} + +async function simple(cmd: Record, node: HTMLElement, okMsg: string, failMsg: string): Promise { + try { + const resp = await send(cmd); + if (resp.ok) setStatus(node, okMsg, "ok"); + else setStatus(node, `${failMsg}: ${resp.error}`, "err"); + } catch (e) { + setStatus(node, `serial error: ${e}`, "err"); + } +} + +// ---- firmware OTA (desktop downloads an image + relays it to the device) ---- +let flashing = false; + +function setProgress(pct: number): void { + const wrap = el.otaBar.parentElement as HTMLElement; + wrap.hidden = pct <= 0 || pct >= 1; + el.otaBar.style.width = `${Math.round(pct * 100)}%`; +} + +async function runFlash(url: string, fs: boolean): Promise { + if (flashing) return; + if (!url) return setStatus(el.otaStatus, "no image URL", "err"); + flashing = true; + el.flash.disabled = true; + setProgress(0.001); + setStatus(el.otaStatus, "downloading + flashing — do not unplug the device…"); + try { + await flashFirmware(url, fs); + setStatus(el.otaStatus, "flashed ✓ device rebooting — reconnect when it's back", "ok"); + setConn("device rebooting after update", true); + setEnabled(false); + } catch (e) { + setStatus(el.otaStatus, `flash failed: ${e}`, "err"); + } finally { + flashing = false; + el.flash.disabled = false; + setProgress(0); + } +} + +function doFlash(): void { + void runFlash(el.otaUrl.value.trim(), el.otaTarget.value === "fs"); +} + +// ---- update auto-discovery (GitHub Releases) ---- +async function doCheckUpdate(): Promise { + el.checkUpdate.disabled = true; + setStatus(el.updateInfo, "checking GitHub for the latest release…"); + try { + latest = await checkUpdate(); + const newer = appVer && latest.version && cmpVer(latest.version, appVer) > 0; + const tag = latest.tag || `v${latest.version}`; + setStatus( + el.updateInfo, + newer ? `${tag} available (you have v${appVer})` : `up to date (latest ${tag})`, + newer ? "ok" : "", + ); + el.updateActions.hidden = false; + el.flashLatestApp.disabled = !latest.firmware; + el.flashLatestFs.disabled = !latest.filesystem; + } catch (e) { + latest = null; + el.updateActions.hidden = true; + setStatus(el.updateInfo, `update check failed: ${e}`, "err"); + } finally { + el.checkUpdate.disabled = false; + } +} + +// Compare dotted numeric versions (prerelease suffixes ignored). >0 if a > b. +function cmpVer(a: string, b: string): number { + const pa = a.split(/[.\-+]/).map(Number); + const pb = b.split(/[.\-+]/).map(Number); + for (let i = 0; i < 3; i++) { + const d = (pa[i] || 0) - (pb[i] || 0); + if (d) return d; + } + return 0; +} + +// ---- boot ---- +async function boot(): Promise { + el.rescan.onclick = () => void refreshPorts(); + el.connect.onclick = () => void doConnect(); + el.disconnect.onclick = () => void doDisconnect(); + el.navWrite.onclick = () => show("write"); + el.navConfig.onclick = () => show("config"); + el.brand.onchange = fillFilaments; + el.custom.oninput = applyCustom; + el.write.onclick = () => void doWrite(); + el.read.onclick = () => void doRead(); + el.save.onclick = () => void doSave(); + el.subDevice.onclick = () => showConfigPane("device"); + el.subDb.onclick = () => showConfigPane("db"); + el.subOta.onclick = () => showConfigPane("ota"); + el.uploadDb.onclick = () => void doUploadDb(); + el.dbPull.onclick = () => void doPullDb(); + el.flash.onclick = () => doFlash(); + // Auto-pick the image type from the URL (littlefs.bin -> fs, else app). + el.otaUrl.oninput = () => { + const u = el.otaUrl.value.toLowerCase(); + if (u) el.otaTarget.value = /littlefs|spiffs|filesystem/.test(u) ? "fs" : "app"; + }; + el.checkUpdate.onclick = () => void doCheckUpdate(); + el.flashLatestApp.onclick = () => void runFlash(latest?.firmware ?? "", false); + el.flashLatestFs.onclick = () => void runFlash(latest?.filesystem ?? "", true); + onOtaProgress((pct) => setProgress(pct)); + + setEnabled(false); + show("write"); + showConfigPane("device"); + setState("disconnected"); + + appVer = await appVersion().catch(() => ""); + + try { + items = loadDb(); + } catch (e) { + setConn(`failed to load material DB: ${e}`, true); + } + + // Discover ports, auto-connect a lone device, and keep scanning otherwise. + await poll(); + startPolling(); +} + +void boot(); diff --git a/desktop/frontend/src/serial.ts b/desktop/frontend/src/serial.ts new file mode 100644 index 0000000..2ed1af7 --- /dev/null +++ b/desktop/frontend/src/serial.ts @@ -0,0 +1,65 @@ +// Typed wrappers around the Wails-generated Go bindings. The generated files in +// ../wailsjs/ are produced by `wails dev` / `wails build` / `wails generate module`. +// The Go backend is serial-only; the material DB is parsed in the frontend (db.ts). +import { + ListPorts, + Connect, + Disconnect, + Send, + Version, + FlashFirmware, + CheckUpdate, + UploadDB, + PullDBFromPrinter, +} from "../wailsjs/go/main/App"; +import { EventsOn } from "../wailsjs/runtime/runtime"; + +export type Cmd = Record; +export type Reply = Record; + +export interface Release { + version: string; + tag: string; + url: string; + firmware: string; + filesystem: string; +} + +export interface PortInfo { + name: string; + label: string; + isDevice: boolean; +} + +export const listPorts = (): Promise => + ListPorts() as Promise; + +export const connect = (port: string, baud: number): Promise => + Connect(port, baud); + +export const disconnect = (): Promise => Disconnect(); + +export const send = (cmd: Cmd): Promise => + Send(cmd as { [key: string]: any }); + +// Desktop app version (for the firmware/desktop compatibility gate). +export const appVersion = (): Promise => Version(); + +// Download a firmware/filesystem image and relay it to the device over serial. +export const flashFirmware = (url: string, filesystem: boolean): Promise => + FlashFirmware(url, filesystem); + +// Subscribe to OTA progress (0..1) emitted during flashFirmware. +export const onOtaProgress = (cb: (pct: number) => void): void => { + EventsOn("ota:progress", cb); +}; + +// Query the GitHub Releases API for the latest release + asset URLs. +export const checkUpdate = (): Promise => CheckUpdate() as Promise; + +// Pick a material_database.json and upload it to the device (returns its name, +// or "" if cancelled). +export const uploadDb = (): Promise => UploadDB(); + +// Fetch the material DB from a printer host and push it to the device. +export const pullDb = (host: string): Promise => PullDBFromPrinter(host); diff --git a/desktop/frontend/src/theme.css b/desktop/frontend/src/theme.css new file mode 100644 index 0000000..90cfeaf --- /dev/null +++ b/desktop/frontend/src/theme.css @@ -0,0 +1,194 @@ +/* CrealityPrint-inspired dark theme (palette lifted from the old Tkinter gui.py). */ +:root { + --accent: #15c059; + --accent-act: #17cc5f; + --accent-text: #08130c; + --bg: #1b1b1b; + --surface: #2e2e2e; + --border: #3e3e45; + --text: #e8e8e8; + --muted: #bbbbbe; + --err: #e5484d; +} + +* { box-sizing: border-box; } + +/* The [hidden] attribute must win over element display rules below (e.g. the + `section { display: grid }` used for the two views and `.hexinput`). */ +[hidden] { display: none !important; } + +html, body { height: 100%; } + +body { + margin: 0; + font: 15px/1.5 system-ui, -apple-system, sans-serif; + background: var(--bg); + color: var(--text); + -webkit-user-select: none; + user-select: none; +} + +/* Header */ +header { + display: flex; + align-items: baseline; + gap: 10px; + padding: 16px 18px 6px; +} +header h1 { margin: 0; font-size: 24px; font-weight: 700; color: var(--accent); } +.subtitle { color: var(--muted); font-size: 13px; } + +/* Connection bar */ +.connbar { + display: flex; + gap: 8px; + padding: 8px 18px 0; +} +.connbar .grow { flex: 1; } +.conn { + padding: 4px 18px 0; + font-size: 13px; + min-height: 1.4em; +} +.conn.err { color: var(--err); } +.conn.ok { color: var(--accent-act); } + +/* Connect gate (shown until a device is connected) */ +#disconnect { margin-left: auto; } +.ghost { + background: transparent; + color: var(--muted); + border: 1px solid var(--border); + padding: 6px 12px; +} +.ghost:hover:not(:disabled) { background: var(--surface); color: var(--text); } +.gate { display: grid; place-items: center; padding: 40px 18px; } +.card { + width: 100%; + max-width: 420px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 14px; + padding: 22px; + display: grid; + gap: 12px; +} +.card h2 { margin: 0; font-size: 17px; font-weight: 600; } +.hint { margin: 0; color: var(--muted); font-size: 13px; } +.card .connbar { padding: 0; } +.card .conn { padding: 0; min-height: 0; } + +/* Loading spinner */ +.spinner { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 13px; } +.ring { + width: 14px; + height: 14px; + border-radius: 50%; + border: 2px solid var(--border); + border-top-color: var(--accent); + animation: spin 0.7s linear infinite; +} +@keyframes spin { to { transform: rotate(360deg); } } +@media (prefers-reduced-motion: reduce) { .ring { animation: none; } } + +/* View gating: hide the app until connected. */ +#nav, #app { display: none; } +body[data-state="connected"] .gate { display: none; } +body[data-state="connected"] #nav { display: flex; } +body[data-state="connected"] #app { display: block; } + +/* Tabs */ +.tabs { + display: flex; + gap: 6px; + padding: 12px 18px 8px; + border-bottom: 1px solid var(--border); + margin-bottom: 8px; +} +.tab { + background: transparent; + color: var(--muted); + border: none; + padding: 6px 14px; + border-radius: 8px; + cursor: pointer; + font: inherit; +} +.tab:hover { background: var(--surface); } +.tab.active { background: var(--accent); color: var(--accent-text); font-weight: 600; } + +/* Config sub-tabs */ +.subtabs { display: flex; gap: 6px; margin-bottom: 14px; } +.subtab { + background: var(--surface); + color: var(--muted); + border: 1px solid var(--border); + padding: 6px 12px; + border-radius: 8px; + cursor: pointer; + font: inherit; + font-size: 13px; +} +.subtab:hover { color: var(--text); } +.subtab.active { background: var(--accent); color: var(--accent-text); border-color: var(--accent); font-weight: 600; } +.pane { display: grid; gap: 14px; } + +/* Layout */ +main { padding: 4px 18px 22px; display: block; } +section { display: grid; gap: 14px; } + +label { display: grid; gap: 6px; font-size: 13px; color: var(--muted); } + +select, input, button { + font: inherit; + color: var(--text); + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + padding: 9px 10px; +} +select:disabled, input:disabled, button:disabled { opacity: .45; cursor: default; } +input:focus, select:focus { outline: 2px solid var(--accent); outline-offset: -1px; } + +fieldset { border: 1px solid var(--border); border-radius: 10px; display: grid; gap: 12px; } +legend { color: var(--muted); font-size: 13px; padding: 0 6px; } + +/* Buttons */ +button { cursor: pointer; font-weight: 500; } +button:hover:not(:disabled) { background: var(--border); } +button.accent { + background: var(--accent); + color: var(--accent-text); + border-color: var(--accent); + font-weight: 600; +} +button.accent:hover:not(:disabled) { background: var(--accent-act); border-color: var(--accent-act); } +button.icon { padding: 9px 12px; } +.btnrow { display: flex; gap: 10px; } +.btnrow .accent { flex: 1; } + +/* Color picker */ +.swatches { display: grid; grid-template-columns: repeat(8, 1fr); gap: 8px; } +.swatch { + aspect-ratio: 1; + border-radius: 8px; + border: 2px solid transparent; + cursor: pointer; +} +.swatch.sel { border-color: var(--text); outline: 2px solid var(--accent); } +.preview { display: flex; align-items: center; gap: 10px; } +.chip { width: 28px; height: 28px; border-radius: 6px; border: 1px solid var(--border); display: inline-block; } +.hexinput { width: 8em; text-transform: uppercase; } + +/* Compatibility banner (firmware/desktop version mismatch) */ +.compat { padding: 4px 18px 0; font-size: 13px; color: #e5a03d; } + +/* OTA progress bar */ +.progress { height: 8px; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; overflow: hidden; } +.bar { height: 100%; width: 0; background: var(--accent); transition: width .12s linear; } + +/* Status text */ +.readout { font-size: 13px; color: var(--accent-act); min-height: 1.4em; } +.status { min-height: 1.4em; font-size: 13px; color: var(--muted); margin: 0; } +.status.err { color: var(--err); } +.status.ok { color: var(--accent-act); } diff --git a/desktop/frontend/tsconfig.json b/desktop/frontend/tsconfig.json new file mode 100644 index 0000000..e00b575 --- /dev/null +++ b/desktop/frontend/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2021", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2021", "DOM", "DOM.Iterable"], + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "isolatedModules": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src"] +} diff --git a/desktop/frontend/vite.config.ts b/desktop/frontend/vite.config.ts new file mode 100644 index 0000000..fc0aa12 --- /dev/null +++ b/desktop/frontend/vite.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "vite"; + +// Wails serves the built frontend from the embedded frontend/dist over its own +// asset protocol, so keep asset paths relative and don't auto-open a browser. +export default defineConfig({ + base: "./", + clearScreen: false, + build: { + target: "es2021", + outDir: "dist", + emptyOutDir: true, + }, +}); diff --git a/desktop/go.mod b/desktop/go.mod new file mode 100644 index 0000000..e2cfc7f --- /dev/null +++ b/desktop/go.mod @@ -0,0 +1,40 @@ +module spoolid + +go 1.23 + +require ( + github.com/wailsapp/wails/v2 v2.12.0 + go.bug.st/serial v1.6.4 +) + +require ( + git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect + github.com/bep/debounce v1.2.1 // indirect + github.com/creack/goselect v0.1.2 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect + github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect + github.com/labstack/echo/v4 v4.13.3 // indirect + github.com/labstack/gommon v0.4.2 // indirect + github.com/leaanthony/go-ansi-parser v1.6.1 // indirect + github.com/leaanthony/gosod v1.0.4 // indirect + github.com/leaanthony/slicer v1.6.0 // indirect + github.com/leaanthony/u v1.1.1 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/samber/lo v1.49.1 // indirect + github.com/tkrajina/go-reflector v0.5.8 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasttemplate v1.2.2 // indirect + github.com/wailsapp/go-webview2 v1.0.22 // indirect + github.com/wailsapp/mimetype v1.4.1 // indirect + golang.org/x/crypto v0.33.0 // indirect + golang.org/x/net v0.35.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.22.0 // indirect +) diff --git a/desktop/go.sum b/desktop/go.sum new file mode 100644 index 0000000..4ceecd6 --- /dev/null +++ b/desktop/go.sum @@ -0,0 +1,87 @@ +git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA= +git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc= +github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= +github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= +github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0= +github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck= +github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= +github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY= +github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g= +github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= +github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= +github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc= +github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA= +github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A= +github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU= +github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI= +github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw= +github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js= +github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8= +github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M= +github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI= +github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= +github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew= +github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ= +github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= +github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58= +github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc= +github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs= +github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= +github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c= +github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg= +go.bug.st/serial v1.6.4 h1:7FmqNPgVp3pu2Jz5PoPtbZ9jJO5gnEnZIvnI1lzve8A= +go.bug.st/serial v1.6.4/go.mod h1:nofMJxTeNVny/m6+KaafC6vJGj3miwQZ6vW4BZUGJPI= +golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= +golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/desktop/main.go b/desktop/main.go new file mode 100644 index 0000000..1c175e6 --- /dev/null +++ b/desktop/main.go @@ -0,0 +1,41 @@ +package main + +import ( + "embed" + "log" + + "github.com/wailsapp/wails/v2" + "github.com/wailsapp/wails/v2/pkg/options" + "github.com/wailsapp/wails/v2/pkg/options/assetserver" +) + +//go:embed all:frontend/dist +var assets embed.FS + +// version is the app's semantic version. Release CI overrides it via +// -ldflags "-X main.version=X.Y.Z"; local builds keep the dev fallback. +var version = "0.0.0-dev" + +// SpoolID desktop app: a thin serial client for the ESP32-C3 writer. The Go +// backend owns only the USB link + the embedded material DB; the firmware +// remains the source of truth for crypto/format/logic. UI is web tech served +// from the embedded frontend build. +func main() { + app := NewApp() + + err := wails.Run(&options.App{ + Title: "SpoolID", + Width: 600, + Height: 895, + MinWidth: 480, + MinHeight: 640, + BackgroundColour: &options.RGBA{R: 27, G: 27, B: 27, A: 255}, + AssetServer: &assetserver.Options{Assets: assets}, + OnStartup: app.startup, + OnShutdown: app.shutdown, + Bind: []interface{}{app}, + }) + if err != nil { + log.Fatal(err) + } +} diff --git a/desktop/wails.json b/desktop/wails.json new file mode 100644 index 0000000..5f3df08 --- /dev/null +++ b/desktop/wails.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://wails.io/schemas/config.v2.json", + "name": "SpoolID", + "outputfilename": "SpoolID", + "frontend:install": "pnpm install", + "frontend:build": "pnpm build", + "frontend:dev:watcher": "pnpm dev", + "frontend:dev:serverUrl": "auto", + "author": { + "name": "Mateus Demboski", + "email": "mateus@demboski.dev" + }, + "info": { + "productName": "SpoolID", + "productVersion": "1.0.0", + "comments": "Creality RFID spool tag writer — desktop serial client" + } +} diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..00d7c4d --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "release-type": "simple", + "include-component-in-tag": false, + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": false, + "packages": { + ".": { + "changelog-path": "CHANGELOG.md" + } + }, + "changelog-sections": [ + { "type": "feat", "section": "Features" }, + { "type": "fix", "section": "Bug Fixes" }, + { "type": "perf", "section": "Performance Improvements" }, + { "type": "revert", "section": "Reverts" }, + { "type": "docs", "section": "Documentation" }, + { "type": "refactor", "section": "Code Refactoring" }, + { "type": "build", "section": "Build System", "hidden": true }, + { "type": "ci", "section": "Continuous Integration", "hidden": true }, + { "type": "test", "section": "Tests", "hidden": true }, + { "type": "chore", "section": "Miscellaneous", "hidden": true } + ] +} diff --git a/web/config.html b/web/config.html new file mode 100644 index 0000000..1fc7175 --- /dev/null +++ b/web/config.html @@ -0,0 +1,85 @@ + + + + + + SpoolID · Config + + + +
+

SpoolID · Config

+ Back +
+ +
+ + + +
+
+ WiFi (station) + + + +
+ +
+ Access point (fallback) + + +
+ +
+ Serial + + +
+ + +

+
+ + + + + + +
+ + + + diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..accef95 --- /dev/null +++ b/web/index.html @@ -0,0 +1,49 @@ + + + + + + SpoolID + + + +
+

SpoolID

+ Config +
+ +
+ + + + + + +
+ Color +
+ +
+ + #1200F6 +
+
+ + + +
+ +

+
+ + + + diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..4f16fc7 --- /dev/null +++ b/web/package.json @@ -0,0 +1,15 @@ +{ + "name": "spoolid-web", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "devDependencies": { + "typescript": "~5.6.0", + "vite": "^8.1.3" + } +} diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml new file mode 100644 index 0000000..96620a6 --- /dev/null +++ b/web/pnpm-lock.yaml @@ -0,0 +1,495 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + typescript: + specifier: ~5.6.0 + version: 5.6.3 + vite: + specifier: ^8.1.3 + version: 8.1.3 + +packages: + + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + + '@napi-rs/wasm-runtime@1.1.6': + resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oxc-project/types@0.138.0': + resolution: {integrity: sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==} + + '@rolldown/binding-android-arm64@1.1.4': + resolution: {integrity: sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.1.4': + resolution: {integrity: sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.1.4': + resolution: {integrity: sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.1.4': + resolution: {integrity: sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.1.4': + resolution: {integrity: sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.1.4': + resolution: {integrity: sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.1.4': + resolution: {integrity: sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.1.4': + resolution: {integrity: sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.1.4': + resolution: {integrity: sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.1.4': + resolution: {integrity: sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.1.4': + resolution: {integrity: sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.1.4': + resolution: {integrity: sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.1.4': + resolution: {integrity: sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.1.4': + resolution: {integrity: sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.1.4': + resolution: {integrity: sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + nanoid@3.3.15: + resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + postcss@8.5.16: + resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} + engines: {node: ^10 || ^12 || >=14} + + rolldown@1.1.4: + resolution: {integrity: sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@5.6.3: + resolution: {integrity: sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==} + engines: {node: '>=14.17'} + hasBin: true + + vite@8.1.3: + resolution: {integrity: sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.3.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + +snapshots: + + '@emnapi/core@1.11.1': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@oxc-project/types@0.138.0': {} + + '@rolldown/binding-android-arm64@1.1.4': + optional: true + + '@rolldown/binding-darwin-arm64@1.1.4': + optional: true + + '@rolldown/binding-darwin-x64@1.1.4': + optional: true + + '@rolldown/binding-freebsd-x64@1.1.4': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.1.4': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.1.4': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.1.4': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.1.4': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.1.4': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.1.4': + optional: true + + '@rolldown/binding-linux-x64-musl@1.1.4': + optional: true + + '@rolldown/binding-openharmony-arm64@1.1.4': + optional: true + + '@rolldown/binding-wasm32-wasi@1.1.4': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.1.4': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.1.4': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + + detect-libc@2.1.2: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + fsevents@2.3.3: + optional: true + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + nanoid@3.3.15: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + postcss@8.5.16: + dependencies: + nanoid: 3.3.15 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rolldown@1.1.4: + dependencies: + '@oxc-project/types': 0.138.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.1.4 + '@rolldown/binding-darwin-arm64': 1.1.4 + '@rolldown/binding-darwin-x64': 1.1.4 + '@rolldown/binding-freebsd-x64': 1.1.4 + '@rolldown/binding-linux-arm-gnueabihf': 1.1.4 + '@rolldown/binding-linux-arm64-gnu': 1.1.4 + '@rolldown/binding-linux-arm64-musl': 1.1.4 + '@rolldown/binding-linux-ppc64-gnu': 1.1.4 + '@rolldown/binding-linux-s390x-gnu': 1.1.4 + '@rolldown/binding-linux-x64-gnu': 1.1.4 + '@rolldown/binding-linux-x64-musl': 1.1.4 + '@rolldown/binding-openharmony-arm64': 1.1.4 + '@rolldown/binding-wasm32-wasi': 1.1.4 + '@rolldown/binding-win32-arm64-msvc': 1.1.4 + '@rolldown/binding-win32-x64-msvc': 1.1.4 + + source-map-js@1.2.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tslib@2.8.1: + optional: true + + typescript@5.6.3: {} + + vite@8.1.3: + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.5 + postcss: 8.5.16 + rolldown: 1.1.4 + tinyglobby: 0.2.17 + optionalDependencies: + fsevents: 2.3.3 diff --git a/web/pnpm-workspace.yaml b/web/pnpm-workspace.yaml new file mode 100644 index 0000000..ae578a1 --- /dev/null +++ b/web/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +# Allow esbuild (Vite's bundler) to run its install script. pnpm blocks build +# scripts by default; without this, `pnpm install` errors ERR_PNPM_IGNORED_BUILDS. +allowBuilds: + esbuild: true diff --git a/web/public/style.css b/web/public/style.css new file mode 100644 index 0000000..a080768 --- /dev/null +++ b/web/public/style.css @@ -0,0 +1,82 @@ +:root { + --bg: #14161a; + --card: #1e2127; + --fg: #e7e9ee; + --muted: #9aa0aa; + --accent: #3894e1; + --ok: #52d048; + --err: #ee301a; +} +* { box-sizing: border-box; } + +/* [hidden] must win over the element display rules below (e.g. .pane grid). */ +[hidden] { display: none !important; } + +/* Config sub-tabs */ +.subtabs { display: flex; gap: 8px; margin-bottom: 16px; } +.subtab { + background: var(--card); + color: var(--muted); + border: 1px solid #2a2e36; + border-radius: 8px; + padding: 8px 12px; + cursor: pointer; + font: inherit; + font-size: 14px; +} +.subtab:hover { color: var(--fg); } +.subtab.active { background: var(--accent); color: #fff; border-color: var(--accent); } +.pane { display: grid; gap: 16px; } +body { + margin: 0; + font: 16px/1.5 system-ui, sans-serif; + background: var(--bg); + color: var(--fg); +} +header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 18px; + background: var(--card); + border-bottom: 1px solid #2a2e36; +} +header h1 { font-size: 20px; margin: 0; } +.nav { color: var(--accent); text-decoration: none; font-size: 14px; } +main { max-width: 460px; margin: 0 auto; padding: 18px; display: grid; gap: 16px; } +label { display: grid; gap: 6px; font-size: 14px; color: var(--muted); } +select, input, button { + font: inherit; + color: var(--fg); + background: var(--card); + border: 1px solid #2a2e36; + border-radius: 8px; + padding: 10px; +} +fieldset { border: 1px solid #2a2e36; border-radius: 10px; } +legend { color: var(--muted); font-size: 14px; padding: 0 6px; } +.swatches { display: grid; grid-template-columns: repeat(8, 1fr); gap: 8px; } +.swatch { + aspect-ratio: 1; + border-radius: 8px; + border: 2px solid transparent; + cursor: pointer; +} +.swatch.sel { border-color: var(--fg); outline: 2px solid var(--accent); } +.custom { flex-direction: row; align-items: center; margin-top: 12px; } +.preview { display: flex; align-items: center; gap: 10px; margin-top: 10px; } +.chip { width: 28px; height: 28px; border-radius: 6px; border: 1px solid #2a2e36; display: inline-block; } +button { + background: var(--accent); + color: #fff; + border: none; + font-weight: 600; + cursor: pointer; + padding: 13px; +} +button:disabled { opacity: .5; cursor: default; } +.readout { display: flex; align-items: center; gap: 10px; font-size: 14px; color: var(--muted); min-height: 1.4em; margin: 4px 0 12px; } +.status { min-height: 1.4em; font-size: 14px; } +.status.ok { color: var(--ok); } +.status.err { color: var(--err); } +.row { display: grid; gap: 6px; } diff --git a/web/src/app.ts b/web/src/app.ts new file mode 100644 index 0000000..ad9e10f --- /dev/null +++ b/web/src/app.ts @@ -0,0 +1,153 @@ +// SpoolID device web UI — main page (brand -> filament -> size -> color -> Write). +// Firmware is the source of truth: constants come from /api/spec, data from /api/db. +// (style.css is served as a static asset via , not imported.) + +// Helpers are inlined (not a shared module) so each page bundles to a single +// self-contained file — no shared chunk for the device to route. +const $ = (id: string): T => + document.getElementById(id) as T; +const sel = (id: string) => $(id); +const inp = (id: string) => $(id); +const btn = (id: string) => $(id); + +function setStatus(msg: string, err = false): void { + const s = $("status"); + s.textContent = msg; + s.className = "status" + (err ? " err" : msg ? " ok" : ""); +} + +interface Material { + id: string; + brand: string; + name: string; + type: string; +} + +let items: Material[] = []; +let swatches: string[] = []; // hex strings with a leading '#' +let color = "#1200F6"; +let polling: number | null = null; + +function setColor(hex: string): void { + color = hex.toUpperCase(); + $("hex").textContent = color; + $("chip").style.background = color; + document.querySelectorAll(".swatch").forEach((s) => + s.classList.toggle("sel", s.dataset.hex === color)); +} + +function buildSwatches(): void { + const wrap = $("swatches"); + wrap.innerHTML = ""; + swatches.forEach((hex) => { + const d = document.createElement("div"); + d.className = "swatch"; + d.dataset.hex = hex; + d.style.background = hex; + d.title = hex; + d.onclick = () => { setColor(hex); inp("custom").value = hex; }; + wrap.appendChild(d); + }); + inp("custom").oninput = (e) => setColor((e.target as HTMLInputElement).value); +} + +async function loadSpec(): Promise { + try { + const spec = await (await fetch("/api/spec")).json(); + swatches = (spec.colorSwatches || []).map((h: string) => "#" + h); + sel("size").innerHTML = (spec.weightLabels || []) + .map((w: string) => ``).join(""); + buildSwatches(); + if (swatches.length) setColor(swatches[0]); + } catch { + setStatus("failed to load device spec", true); + } +} + +function fillFilaments(): void { + const brand = sel("brand").value; + const list = items.filter((i) => i.brand === brand); + sel("filament").innerHTML = list + .map((i) => ``).join(""); +} + +function fillBrands(): void { + const brands = [...new Set(items.map((i) => i.brand))].sort(); + sel("brand").innerHTML = brands.map((b) => ``).join(""); + fillFilaments(); +} + +async function loadDb(): Promise { + try { + const doc = await (await fetch("/api/db")).json(); // gzip auto-decoded + items = (doc.result?.list || []).map((it: any) => ({ + id: it.base.id, brand: it.base.brand, name: it.base.name, type: it.base.meterialType, + })); + fillBrands(); + } catch { + setStatus("failed to load material DB", true); + } +} + +async function pollStatus(): Promise { + const st = await (await fetch("/api/status")).json(); + if (st.last && st.last.done) { + if (polling !== null) { clearInterval(polling); polling = null; } + btn("write").disabled = false; + if (st.last.ok) { + setStatus(`written ✓ UID ${st.last.uid}${st.last.encrypted ? " (re-encrypted)" : ""}`, false); + } else { + setStatus(`error: ${st.last.error}`, true); + } + } +} + +function applyRead(j: any): void { + const item = items.find((i) => i.id === j.materialId); + if (item) { sel("brand").value = item.brand; fillFilaments(); sel("filament").value = item.id; } + if (j.weight) sel("size").value = j.weight; + if (j.color) { setColor("#" + j.color); inp("custom").value = "#" + j.color; } + const name = item ? `${item.brand} ${item.name}` : `id ${j.materialId}`; + $("readout").textContent = + `read: ${name} · ${j.weight || "?"} · #${j.color}${j.encrypted ? " · locked" : ""}`; +} + +async function readTag(): Promise { + btn("read").disabled = true; + setStatus("reading… tap a tag on the reader", false); + try { + const j = await (await fetch("/api/read")).json(); + if (!j.ok) { setStatus(`read failed: ${j.error}`, true); return; } + applyRead(j); + setStatus("tag read ✓ — review values and Write", false); + } catch { + setStatus("read error", true); + } finally { + btn("read").disabled = false; + } +} + +async function write(): Promise { + btn("write").disabled = true; + setStatus("staging… tap a tag on the reader", false); + const body = { + materialId: sel("filament").value, + color: color.replace("#", ""), + weight: sel("size").value, + }; + const j = await (await fetch("/api/write", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + })).json(); + if (!j.ok) { btn("write").disabled = false; setStatus(`error: ${j.error}`, true); return; } + if (polling === null) polling = window.setInterval(pollStatus, 700); +} + +sel("brand").onchange = fillFilaments; +btn("write").onclick = write; +btn("read").onclick = readTag; +void loadSpec(); +void loadDb(); + +export {}; diff --git a/web/src/config.ts b/web/src/config.ts new file mode 100644 index 0000000..c4a27ab --- /dev/null +++ b/web/src/config.ts @@ -0,0 +1,131 @@ +// SpoolID device web UI — config page: Device / Material database / OTA sub-tabs. +// Firmware is the source of truth: the selects come from /api/spec. +// (style.css is served as a static asset via , not imported.) + +// Helpers are inlined (not a shared module) so each page bundles to a single +// self-contained file — no shared chunk for the device to route. +const $ = (id: string): T => + document.getElementById(id) as T; +const sel = (id: string) => $(id); +const inp = (id: string) => $(id); +const btn = (id: string) => $(id); + +function setStatus(node: HTMLElement, msg: string, err = false): void { + node.textContent = msg; + node.className = "status" + (err ? " err" : msg ? " ok" : ""); +} + +const statusEl = () => $("status"); +const dbStatusEl = () => $("dbStatus"); +const otaStatusEl = () => $("otaStatus"); + +// ---- sub-tabs ---- +function showPane(which: "device" | "db" | "ota"): void { + $("paneDevice").hidden = which !== "device"; + $("paneDb").hidden = which !== "db"; + $("paneOta").hidden = which !== "ota"; + btn("subDevice").classList.toggle("active", which === "device"); + btn("subDb").classList.toggle("active", which === "db"); + btn("subOta").classList.toggle("active", which === "ota"); +} +btn("subDevice").onclick = () => showPane("device"); +btn("subDb").onclick = () => showPane("db"); +btn("subOta").onclick = () => showPane("ota"); +showPane("device"); + +// ---- device settings ---- +async function loadSpec(): Promise { + const spec = await (await fetch("/api/spec")).json(); + sel("logLevel").innerHTML = (spec.logLevels || []) + .map((l: string, i: number) => ``).join(""); + sel("baud").innerHTML = (spec.baudRates || []) + .map((b: number) => ``).join(""); +} + +async function load(): Promise { + const c = await (await fetch("/api/config")).json(); + inp("wifiSsid").value = c.wifiSsid || ""; + inp("hostname").value = c.hostname || ""; + inp("apSsid").value = c.apSsid || ""; + sel("logLevel").value = String(c.logLevel ?? 3); + sel("baud").value = String(c.baud ?? 115200); +} + +btn("save").onclick = async () => { + const body: Record = { + apSsid: inp("apSsid").value, + wifiSsid: inp("wifiSsid").value, + hostname: inp("hostname").value, + logLevel: Number(sel("logLevel").value), + baud: Number(sel("baud").value), + }; + if (inp("wifiPass").value) body.wifiPass = inp("wifiPass").value; + if (inp("apPass").value) body.apPass = inp("apPass").value; + const j = await (await fetch("/api/config", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + })).json(); + setStatus(statusEl(), j.ok ? "saved ✓ reboot to apply WiFi changes" : "save failed", !j.ok); +}; + +// ---- material database upload ---- +btn("upload").onclick = async () => { + const f = inp("dbfile").files?.[0]; + if (!f) return setStatus(dbStatusEl(), "pick a file first", true); + setStatus(dbStatusEl(), "gzipping…", false); + // Compress in the browser; the ESP32 has no runtime gzip compressor. + const gz = await new Response( + f.stream().pipeThrough(new CompressionStream("gzip")) + ).blob(); + const res = await fetch("/api/db", { + method: "POST", + headers: { "Content-Type": "application/octet-stream" }, + body: gz, + }); + setStatus(dbStatusEl(), (await res.json()).ok ? "DB uploaded ✓" : "upload failed", !res.ok); +}; + +// ---- firmware OTA (upload a .bin; the device self-flashes via Update) ---- +// Auto-pick the image type from the file name (littlefs.bin -> fs, else app). +inp("otaFile").onchange = () => { + const name = (inp("otaFile").files?.[0]?.name || "").toLowerCase(); + if (name) sel("otaTarget").value = /littlefs|spiffs|filesystem/.test(name) ? "fs" : "app"; +}; + +// XHR (not fetch) for upload progress. MD5 is optional firmware-side, so the +// browser path skips it (SubtleCrypto has no MD5). +btn("flash").onclick = () => { + const f = inp("otaFile").files?.[0]; + if (!f) return setStatus(otaStatusEl(), "pick a .bin file first", true); + const target = sel("otaTarget").value; + const prog = $("otaProg"); + prog.hidden = false; + prog.value = 0; + btn("flash").disabled = true; + setStatus(otaStatusEl(), "uploading + flashing — do not power off the device…", false); + + const xhr = new XMLHttpRequest(); + xhr.open("POST", `/api/ota?target=${target}`); + xhr.upload.onprogress = (e) => { + if (e.lengthComputable) prog.value = (e.loaded / e.total) * 100; + }; + xhr.onload = () => { + btn("flash").disabled = false; + prog.hidden = true; + let ok = false, error = ""; + try { const j = JSON.parse(xhr.responseText); ok = j.ok; error = j.error || ""; } catch { /* non-JSON */ } + if (ok) setStatus(otaStatusEl(), "flashed ✓ device rebooting — reconnect when it's back", false); + else setStatus(otaStatusEl(), `flash failed: ${error || "HTTP " + xhr.status}`, true); + }; + xhr.onerror = () => { + btn("flash").disabled = false; + prog.hidden = true; + setStatus(otaStatusEl(), "upload failed (connection lost?)", true); + }; + xhr.send(f); +}; + +void loadSpec().then(load); + +export {}; diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..8e0b44e --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2021", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2021", "DOM", "DOM.Iterable"], + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "isolatedModules": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..e4bf5e0 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,29 @@ +import { defineConfig } from "vite"; + +// The device serves fixed gzipped filenames from LittleFS, so emit predictable, +// unhashed names: index page -> app.js, config page -> config.js, shared css -> +// style.css. The two entries are self-contained (no shared import) so there's no +// extra chunk for the device to route. +export default defineConfig({ + base: "/", + build: { + outDir: "dist", + emptyOutDir: true, + minify: "esbuild", + cssCodeSplit: false, + // No benefit on a local device app; avoids an extra polyfill chunk the + // firmware would otherwise have to serve. + modulePreload: false, + rollupOptions: { + input: { + index: "index.html", + config: "config.html", + }, + output: { + entryFileNames: "[name].js", + chunkFileNames: "[name].js", + assetFileNames: "style.css", + }, + }, + }, +});