diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index ee8a94d9..00000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -name: Bug report -about: Create a report to help us improve -title: '' -labels: 'bug' -assignees: '' ---- - -**Describe the bug:** - -A clear and concise description of what the bug is. - -**Steps to reproduce:** - -Steps to reproduce the issue. - -**Expected behavior:** - -A clear and concise description of what you expected to happen. - -**Additional context:** - -Add context for the issue, Windows/Linux, which version of the application and what device do you use diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000..aa144695 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,57 @@ +name: Bug report +description: Create a report to help us improve +labels: [bug] +body: + - type: input + id: version + attributes: + label: App version + description: Shown in the footer of the app window. + placeholder: e.g. 1.4.250 + validations: + required: true + - type: dropdown + id: os + attributes: + label: Operating system + options: + - Windows + - Linux + - macOS + validations: + required: true + - type: input + id: os-details + attributes: + label: OS details + description: Windows/macOS version, or Linux distro + desktop environment (X11/Wayland) and install method (deb/Flatpak/AppImage). + placeholder: e.g. Windows 11 24H2 / Fedora 42 KDE Wayland, Flatpak + - type: input + id: device + attributes: + label: Device + description: Which controller you use. + placeholder: e.g. PCPanel Pro / Mini / RGB, Deej, MIDI controller + - type: textarea + id: description + attributes: + label: Describe the bug + description: A clear and concise description of what the bug is. + validations: + required: true + - type: textarea + id: steps + attributes: + label: Steps to reproduce + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + description: What you expected to happen. + - type: textarea + id: context + attributes: + label: Additional context + description: Anything else that helps — logs, screenshots, involved integrations. diff --git a/.github/ISSUE_TEMPLATE/enhancement.md b/.github/ISSUE_TEMPLATE/enhancement.md deleted file mode 100644 index 8cebab87..00000000 --- a/.github/ISSUE_TEMPLATE/enhancement.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -name: Feature request -about: Want to see a new feature? -title: '' -labels: 'enhancement' -assignees: '' ---- - -Describe what you want to happen and how it should work. Try to be complete in which software -should be controlled, which device you have to control it with (PCPanel pro/mini/rgb) and what you expect the result to be. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000..af642842 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,16 @@ +name: Feature request +description: Want to see a new feature? +labels: [enhancement] +body: + - type: textarea + id: description + attributes: + label: What should happen + description: Describe what you want to happen and how it should work. Try to be complete in which software should be controlled, which device you have to control it with, and what you expect the result to be. + validations: + required: true + - type: input + id: device + attributes: + label: Device + placeholder: e.g. PCPanel Pro / Mini / RGB, Deej, MIDI controller diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md deleted file mode 100644 index f08a132d..00000000 --- a/.github/ISSUE_TEMPLATE/question.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -name: Question -about: Not a bug or a feature, just a question. -title: '' -labels: 'question' -assignees: '' ---- - diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/ISSUE_TEMPLATE/question.yml new file mode 100644 index 00000000..ecc02edb --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.yml @@ -0,0 +1,11 @@ +name: Question +description: Not a bug or a feature, just a question. +labels: [question] +body: + - type: textarea + id: question + attributes: + label: Your question + description: What would you like to know? Include your setup (OS, device, app version — see the app footer) if it is relevant. + validations: + required: true diff --git a/.github/PENDING-CI-WORKFLOWS.md b/.github/PENDING-CI-WORKFLOWS.md new file mode 100644 index 00000000..3369943f --- /dev/null +++ b/.github/PENDING-CI-WORKFLOWS.md @@ -0,0 +1,25 @@ +# Pending CI workflow changes + +`pending-ci-workflows.patch` in this directory holds workflow changes that could +not be pushed from the automated branch because the push credential lacked the +GitHub `workflow` OAuth scope (GitHub rejects any push whose history modifies +`.github/workflows/**` without it). + +Contents of the patch: +- **`.github/workflows/pr-ci.yml`** (new) — runs the JVM test suite (incl. the + GraalVM registration guards) and the frontend build on Windows + Linux for + pull requests. Gated to same-repo PRs only, so fork PRs never consume build + minutes. +- **`build-and-release.yml` / `build-sndctrl-dll.yml`** — every action pinned to + a commit SHA (tag in a trailing comment) and `appimagetool` pinned to the + tagged 1.9.1 release with a sha256 integrity check. + +Apply from the repo root with a `workflow`-scoped token, then delete this note: + + git apply .github/pending-ci-workflows.patch + git rm .github/PENDING-CI-WORKFLOWS.md .github/pending-ci-workflows.patch + git commit -m "ci: add PR CI workflow, pin actions to SHAs" + +`.github/dependabot.yml` (maven + npm + github-actions, weekly, grouped) is +already committed on this branch — it is not a workflow file, so it pushed +normally. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..1012584e --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,22 @@ +## What & why + + + +## AI disclosure (required) + + + +## Checklist + +- [ ] Tests pass (`./mvnw test`) +- [ ] User-visible change: line added at the top of `CHANGELOG.md` +- [ ] AI disclosure above is filled in diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..5af7d771 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,36 @@ +# Weekly dependency updates. Minor + patch bumps are grouped per ecosystem to keep PR noise +# down; major bumps still arrive as individual PRs. +version: 2 +updates: + - package-ecosystem: maven + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + groups: + maven-minor-patch: + update-types: + - minor + - patch + + - package-ecosystem: npm + directory: /src/main/webui + schedule: + interval: weekly + open-pull-requests-limit: 5 + groups: + npm-minor-patch: + update-types: + - minor + - patch + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + groups: + actions-minor-patch: + update-types: + - minor + - patch diff --git a/.github/pending-ci-workflows.patch b/.github/pending-ci-workflows.patch new file mode 100644 index 00000000..6473bad4 --- /dev/null +++ b/.github/pending-ci-workflows.patch @@ -0,0 +1,269 @@ +diff --git a/.github/workflows/build-and-release.yml b/.github/workflows/build-and-release.yml +index 7ab16e3..d281c64 100644 +--- a/.github/workflows/build-and-release.yml ++++ b/.github/workflows/build-and-release.yml +@@ -45,7 +45,7 @@ jobs: + outputs: + genStatus: ${{ steps.gen.outcome }} + steps: +- - uses: actions/checkout@v6 ++ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + # native-image-job-reports is intentionally OFF: it makes setup-graalvm pass + # -H:BuildOutputJSONFile pointing at the runner's TEMP on C:, while the build output lives on +@@ -54,7 +54,7 @@ jobs: + # was the only artifact off the workspace drive; without it every produced artifact stays under + # target/ on one drive and the native build is clean. + - name: Set up GraalVM CE +- uses: graalvm/setup-graalvm@v1 ++ uses: graalvm/setup-graalvm@6f3fa030c4b8f77c1f554a860f593a654538fa38 # v1.5.6 + with: + java-version: ${{ env.JAVA_VERSION }} + distribution: 'graalvm-community' +@@ -203,7 +203,7 @@ jobs: + packaging/windows/pcpanel.iss + + - name: Store artifact +- uses: actions/upload-artifact@v7 ++ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: windows-installer + path: ./target/PCPanel-*-setup.exe +@@ -216,10 +216,10 @@ jobs: + outputs: + genStatus: ${{ steps.gen.outcome }} + steps: +- - uses: actions/checkout@v6 ++ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - name: Set up GraalVM CE +- uses: graalvm/setup-graalvm@v1 ++ uses: graalvm/setup-graalvm@6f3fa030c4b8f77c1f554a860f593a654538fa38 # v1.5.6 + with: + java-version: ${{ env.JAVA_VERSION }} + distribution: 'graalvm-community' +@@ -276,7 +276,7 @@ jobs: + # Cache the bundled kdotool download (Apache-2.0) across runs; the key changes only when + # fetch-kdotool.sh (which pins the version + sha256) changes, so it is downloaded once per pin. + - name: Cache kdotool +- uses: actions/cache@v4 ++ uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: ~/.cache/pcpanel-kdotool + key: kdotool-${{ hashFiles('packaging/linux/fetch-kdotool.sh') }} +@@ -287,10 +287,18 @@ jobs: + bash packaging/linux/build-deb.sh "${{ steps.ver.outputs.version }}" "$exe" target + + - name: Build AppImage ++ env: ++ # Pinned appimagetool release + sha256 (supply-chain integrity: the download is verified ++ # against this digest and the step fails on any mismatch). To refresh: pick the release/ ++ # asset from `gh api repos/AppImage/appimagetool/releases` and copy its `digest` (sha256) ++ # for appimagetool-x86_64.AppImage, then update both values below. ++ APPIMAGETOOL_VERSION: '1.9.1' ++ APPIMAGETOOL_SHA256: 'ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0' + run: | + # Recommended download for immutable distros: single sandbox-free file. + curl -fsSL --retry 3 -o appimagetool \ +- https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-x86_64.AppImage ++ "https://github.com/AppImage/appimagetool/releases/download/${APPIMAGETOOL_VERSION}/appimagetool-x86_64.AppImage" ++ echo "${APPIMAGETOOL_SHA256} appimagetool" | sha256sum -c - + chmod +x appimagetool + export APPIMAGETOOL="$PWD/appimagetool" + export APPIMAGE_EXTRACT_AND_RUN=1 # CI runners have no FUSE +@@ -299,14 +307,14 @@ jobs: + bash packaging/linux/appimage/build-appimage.sh "${{ steps.ver.outputs.version }}" "$exe" target + + - name: Store .deb artifact +- uses: actions/upload-artifact@v7 ++ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: linux-deb + path: ./target/*.deb + if-no-files-found: error + + - name: Store AppImage artifact +- uses: actions/upload-artifact@v7 ++ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: linux-appimage + path: ./target/*.AppImage +@@ -316,7 +324,7 @@ jobs: + # the freedesktop SDK container. This is an inter-job hand-off, not a release asset — preRelease + # strips it before publishing. + - name: Upload native binary for the Flatpak job +- uses: actions/upload-artifact@v7 ++ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: linux-native + path: | +@@ -339,7 +347,7 @@ jobs: + image: bilelmoussaoui/flatpak-github-actions:freedesktop-24.08 + options: --privileged + steps: +- - uses: actions/checkout@v6 ++ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - name: Compute app version + id: ver +@@ -348,14 +356,14 @@ jobs: + echo "version=${base}.${GITHUB_RUN_NUMBER}" >> "$GITHUB_OUTPUT" + + - name: Download native binary +- uses: actions/download-artifact@v8 ++ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: linux-native + path: native + + # Same cache as the buildLinux job: kdotool is downloaded once per pin (see fetch-kdotool.sh). + - name: Cache kdotool +- uses: actions/cache@v4 ++ uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: ~/.cache/pcpanel-kdotool + key: kdotool-${{ hashFiles('packaging/linux/fetch-kdotool.sh') }} +@@ -385,7 +393,7 @@ jobs: + - name: Build Flatpak bundle + id: flatpak + continue-on-error: true +- uses: flatpak/flatpak-github-actions/flatpak-builder@v6 ++ uses: flatpak/flatpak-github-actions/flatpak-builder@401fe28a8384095fc1531b9d320b292f0ee45adb # v6 + with: + bundle: PCPanel-${{ steps.ver.outputs.version }}.flatpak + manifest-path: flatpak-build/com.getpcpanel.PCPanel.yml +@@ -397,7 +405,7 @@ jobs: + run: echo "::warning::Flatpak bundle failed to build; releasing .deb + AppImage only." + + - name: Store Flatpak artifact +- uses: actions/upload-artifact@v7 ++ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: linux-flatpak + path: PCPanel-*.flatpak +@@ -419,10 +427,10 @@ jobs: + runs-on: ${{ matrix.runner }} + timeout-minutes: 45 + steps: +- - uses: actions/checkout@v6 ++ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - name: Set up GraalVM CE +- uses: graalvm/setup-graalvm@v1 ++ uses: graalvm/setup-graalvm@6f3fa030c4b8f77c1f554a860f593a654538fa38 # v1.5.6 + with: + java-version: ${{ env.JAVA_VERSION }} + distribution: 'graalvm-community' +@@ -572,7 +580,7 @@ jobs: + ls -lh "$dmg" + + - name: Store .dmg artifact +- uses: actions/upload-artifact@v7 ++ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: macos-dmg-${{ runner.arch }} + path: ./target/PCPanel-*.dmg +@@ -591,7 +599,7 @@ jobs: + if: ${{ !cancelled() && needs.buildWindows.result == 'success' && needs.buildLinux.result == 'success' }} + steps: + - name: Fetch Sources +- uses: actions/checkout@v6 ++ uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - name: Extract Maven project version + run: echo "version=$(mvn -q -Dexec.executable=echo -Dexec.args='${project.version}' --non-recursive exec:exec)" >> $GITHUB_OUTPUT +@@ -654,7 +662,7 @@ jobs: + --notes-file "$RUNNER_TEMP/release-notes.md" + + - name: Download all build artifacts +- uses: actions/download-artifact@v8 ++ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: dist + merge-multiple: true +diff --git a/.github/workflows/build-sndctrl-dll.yml b/.github/workflows/build-sndctrl-dll.yml +index 5f6259f..013660b 100644 +--- a/.github/workflows/build-sndctrl-dll.yml ++++ b/.github/workflows/build-sndctrl-dll.yml +@@ -11,11 +11,11 @@ jobs: + build: + runs-on: windows-2022 + steps: +- - uses: actions/checkout@v4 ++ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + # The build needs the Windows JNI headers (include/ + include/win32/jni_md.h), nothing more. + - name: Set up JDK (for JNI headers) +- uses: actions/setup-java@v4 ++ uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4.8.0 + with: + distribution: temurin + java-version: '21' +@@ -36,7 +36,7 @@ jobs: + run: dumpbin /exports build\Release\SndCtrl.dll | Select-String "Java_|JNI_" + + - name: Upload SndCtrl.dll +- uses: actions/upload-artifact@v4 ++ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: SndCtrl-dll + path: build/Release/SndCtrl.dll +diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml +new file mode 100644 +index 0000000..adba3db +--- /dev/null ++++ b/.github/workflows/pr-ci.yml +@@ -0,0 +1,53 @@ ++# JVM-mode CI for pull requests: full unit + integration test suite (including the GraalVM ++# reflection/proxy coverage guards) and the Quinoa frontend build on Windows and Linux. ++# No native image is built here — that stays in Build & Release. ++ ++name: PR CI ++env: ++ JAVA_VERSION: '25' # GraalVM CE 25 — keep in sync with build-and-release.yml ++ ++on: ++ pull_request: ++ ++# One run per PR; a newer push supersedes an in-flight run. ++concurrency: ++ group: pr-ci-${{ github.event.pull_request.number }} ++ cancel-in-progress: true ++ ++jobs: ++ test: ++ name: JVM tests (${{ matrix.os }}) ++ # Same-repo PRs only: fork PRs must never consume build minutes. ++ if: github.event.pull_request.head.repo.full_name == github.repository ++ strategy: ++ fail-fast: false ++ matrix: ++ os: [windows-latest, ubuntu-latest] ++ runs-on: ${{ matrix.os }} ++ timeout-minutes: 45 ++ steps: ++ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 ++ ++ - name: Set up GraalVM CE ++ uses: graalvm/setup-graalvm@6f3fa030c4b8f77c1f554a860f593a654538fa38 # v1.5.6 ++ with: ++ java-version: ${{ env.JAVA_VERSION }} ++ distribution: 'graalvm-community' ++ cache: 'maven' ++ ++ # AWT/Swing-touching unit tests (e.g. the overlay tests) need a display on Linux; ++ # mirror the Build & Release Linux job and run the whole build under a virtual X server. ++ - name: Install xvfb (Linux) ++ if: runner.os == 'Linux' ++ run: sudo apt-get update && sudo apt-get install -y xvfb ++ ++ - name: Build and test (Linux) ++ if: runner.os == 'Linux' ++ run: xvfb-run -a ./mvnw -B -Dquarkus.native.enabled=false verify ++ ++ - name: Build and test (Windows) ++ shell: pwsh ++ if: runner.os == 'Windows' ++ # Quote the -D property: PowerShell otherwise splits -Dquarkus.native.enabled=false and Maven ++ # sees ".native.enabled=false" as a bogus lifecycle phase (same as build-and-release.yml). ++ run: .\mvnw.cmd -B "-Dquarkus.native.enabled=false" verify diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 00000000..08617244 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,299 @@ +# PCPanel — architecture & build reference + +The technical reference for this repository: what the app is, how it is built and run, how the +code is organized, and the GraalVM-native-image and native-C++ constraints. Contributor workflow +(IDE setup, PR process, conventions) lives in [CONTRIBUTING.md](CONTRIBUTING.md); agent-specific +instructions live in [CLAUDE.md](CLAUDE.md). + +## What this is + +Third-party/community controller software for [PCPanel](https://getpcpanel.com) USB audio-control +devices (knobs, sliders, buttons with RGB). It is a **desktop application** built as a +**Quarkus** backend (Java 25) serving an **Angular** frontend in a local browser. The app talks +to PCPanel hardware over USB HID and controls OS audio (per-process/device volume, mute, default +device) plus integrations (OBS, Voicemeeter, Elgato Wave Link, Discord, Home Assistant, OSC, MQTT). + +Development focus is Windows; Linux is best-effort. The project was migrated from Spring +Boot + JavaFX to Quarkus + Angular, and ships as a **GraalVM native image** (see git history / +`copilot/migration-to-quarkus-again` branch context). + +## Build & run + +The toolchain is the Maven wrapper (`./mvnw` / `mvnw.cmd`). Java 25 is required (GraalVM CE 25 for +native builds). + +```bash +./mvnw quarkus:dev # dev mode: backend on :7654, Quinoa runs Angular dev server on :4200 with live reload +./mvnw clean package # builds a NATIVE image by default (quarkus.native.enabled=true in pom) +./mvnw clean package -Dquarkus.native.enabled=false # JVM-only jar, much faster, no GraalVM needed +./mvnw test # unit tests (surefire) +./mvnw test -Dtest=ClassName#method # single test +./mvnw verify -Pnative # native build + failsafe integration tests against the runner binary +``` + +- `package` produces a native executable at `target/*-runner` (Linux) / `target/*-runner.exe` (Windows). +- CI (`.github/workflows/build-and-release.yml`) builds the native image on Windows via + `mvn -B verify -Pnative` (so the failsafe integration tests run against the runner binary) and on + Linux and macOS via `mvn -B package -Pnative`, wraps it in installers (Windows Inno Setup `.exe`, + Linux `.deb` / AppImage / Flatpak — see `packaging/`), and publishes a per-branch pre-release. The + native image is + NOT self-contained: it loads companion `*.dll`/`*.so` libraries from its own directory, so every + artifact must bundle them alongside the executable. The Linux artifacts also bundle **`kdotool`** + (Apache-2.0) next to the executable — it resolves the focused window on KDE Plasma (Wayland and X11) + for focus volume. `packaging/linux/fetch-kdotool.sh` pins the version + sha256 and is cache-keyed in + CI on its own hash (download once per pin). `LinuxProcessHelper` prefers a `kdotool` sibling of its + own binary over the `PATH` lookup; `xdotool` is only an optional non-KDE-X11 fallback (kdotool covers + X11, so the two are never both required). Inside the Flatpak, kdotool runs in the sandbox and drives + the host KWin over D-Bus (`--talk-name=org.kde.KWin`); `kdotool-wrapper.sh` points its `TMPDIR` at the + host-visible per-app cache dir (`XDG_CACHE_HOME` = `~/.var/app//cache`, identity-mapped into the + sandbox — *not* `$HOME`, which is an unbacked overlay) so the host KWin can read the temp KWin script + kdotool generates. +- **Releasing:** `` in `pom.xml` is the version source of truth (artifacts are + `.`). Bump it with `packaging/bump-version.sh ` (also updates the + AppStream metadata), then push a `releases/` branch to trigger a pre-release build. CI bakes + the same `.` into the app via `-Dquarkus.application.version=` on `mvn package`, so + the UI footer reports the build number for official builds (local/dev stays at ``, + i.e. `-SNAPSHOT`). +- **Run two instances side by side:** pass the `skipfilecheck` arg (otherwise launching a second + instance just focuses the already-installed one — see `Main`/`FileChecker`). For a separate dev + data dir, set `pcpanel.root=${user.home}/.pcpaneldev/` (dev profile already does this). + +### Frontend (`src/main/webui`, Angular 21) + +Managed by the Quinoa Quarkus extension — normally you don't run it directly; `quarkus:dev` proxies it. +Standalone: `cd src/main/webui && npm install && npm start` (serves :4200, proxies `/api` + `/ws` to :7654). + +**TypeScript types are generated from Java**, not hand-written. The `typescript-generator-maven-plugin` +(runs in the `compile` phase) writes `src/main/webui/src/app/models/generated/backend.types.ts` from +these classPatterns (the authoritative list is in `pom.xml`): + +- `com.getpcpanel.rest.model.**` — the REST/WebSocket DTO contract +- `com.getpcpanel.commands.Commands` — the command-list container +- `com.getpcpanel.**.command.**` — every feature's command classes (each feature owns a `command/` + subpackage; the glob picks up new integrations without a pom edit) +- `com.getpcpanel.device.descriptor.**` — the device-descriptor model +- `**.dto.**` — any DTO package + +When you change a DTO or +command shape, recompile so the frontend contract regenerates — don't edit the generated file. + +## Architecture + +Quarkus CDI (Arc) app. Entry point is `com.getpcpanel.Main` (`@QuarkusMain`); beans are wired by +injection, and cross-cutting communication uses the **CDI event bus** (`jakarta.enterprise.event.Event` +fire + `@Observes`) heavily rather than direct calls. `docs/events.md` catalogs the events with their +firers and observers — keep it current when you add or remove an event. + +**Hardware path (`device/`):** the device layer is the hardware-abstraction layer (HAL) and is **not** +an integration — it provides no commands. `DeviceScanner` (in `device/provider/pcpanel/`) discovers HID +devices via hid4java; `DeviceCommunicationHandler` (one per device, own thread + queue, same package) +reads knob/button input and writes RGB/output. `Device` subclasses (`PCPanelMini/Pro/RGB`, in `device/`) +model each hardware variant; `DeviceHolder` (in `device/`) is the cross-provider registry. Physical input +becomes a `PCPanelControlEvent` / `ButtonClickEvent` on the event bus. + +**Device providers (`device/provider/`, `device/descriptor/`):** the device layer is generalized so +PCPanel is one `DeviceProvider` among several — providers are `@ApplicationScoped` beans discovered via +`Instance` (NOT build-time stereotypes; every build contains all of them). +`DeviceScanner` is the `"pcpanel"` HID provider (`device/provider/pcpanel/`); `DeejSerialProvider` +(serial, jSerialComm, `device/provider/deej/`) and +`MidiProvider` (`javax.sound.midi`, `device/provider/midi/`) are external providers; each provider +absorbs its own IO transport (e.g. `SerialTransport`/`JSerialComm*` under `deej/`). A device is described by a data +`DeviceDescriptor` (analog/digital inputs with source ranges, light/analog outputs, capabilities) +rather than the `DeviceType` enum, which is now PCPanel-provider-internal. Each provider normalizes +its raw analog values to the canonical **0–255** internal domain at its edge (PCPanel RGB 0–100, Deej +0–1023, MIDI 0–127 → 0–255), so `DialValueCalculator`/`KnobSetting`/commands are untouched. Non-PCPanel +devices use a lightless `GenericDevice` — `Device.deviceType()` is nullable, so guard PCPanel/HID-only +paths (lighting, `OutputInterpreter.sendInit`) against `deviceType() == null`. `DeviceSave` persists +`providerId`/`deviceKindId`/`capabilities` (back-filled at connect; legacy saves default to +`pcpanel`). The Angular UI renders any device from its descriptor (`DeviceRendererComponent` → +`PcDeviceComponent` for PCPanel, else `GenericDeviceComponent`). Full design + per-phase status: +`docs/device-layer-generalization-plan.md`. + +**Command model (`commands/` = engine; `integration/*/command/` = the commands):** A user's +per-dial/button configuration is a `Commands` (list of `Command` subclasses). `commands/` holds only the +engine — `Command`, the `Dial/Button/DeviceAction` SPIs, `CommandDispatcher`, `DialValue`, the +`@CommandMeta`/`CommandModule` registry. Each concrete command lives in its feature's package, e.g. +`integration.volume.command.CommandVolumeProcess`, `integration.keyboard.command.CommandKeystroke`/ +`CommandMedia`, `integration.obs.command.CommandObs`. Commands are JSON-polymorphic: +`@JsonTypeInfo(use = Id.NAME)` on `Command` with `@JsonTypeName` ids per subclass, registered as an +explicit allowlist through the `CommandModule` SPI (`CommandSubtypeRegistrar` collects every module's +subtypes into the ObjectMapper). Type ids are names, not class names, so a command's package location +is irrelevant to (de)serialization — see `docs/feature-module-structure.md`. Commands are part of the +generated TS contract. **A package is an `integration` only if it provides commands** — providers/HAL/ +infra are not. + +**Native audio abstraction (`integration/volume/platform/`):** `ISndCtrl` is the OS-audio facade +(volume/mute/default device, focus app) — it is the backend the volume commands drive, so it lives in +the volume feature. Implementations are selected at **build time** by platform stereotypes: +`@WindowsBuild` (`SndCtrlWindows` → JNI to `SndCtrl.dll` via `SndCtrlNative`, both in +`integration/volume/platform/windows/`; C++ source in `src/main/cpp/`) and `@LinuxBuild` +(`SndCtrlPulseAudio` in `platform/linux/`, via JNA/PulseAudio). These stereotypes wrap +Quarkus `@IfBuildProperty(name="pcpanel.build.os", ...)` keyed off `pcpanel.build.os` (set at build +time from `os.detected.name`), so **a given build only contains one platform's beans** — guard +optional platform beans with `Instance` injection, and use `CdiHelper` to fetch beans from +non-CDI code. On Linux many native calls degrade to a no-op `ISndCtrl`. + +**Persistence (`profile/`):** All user config (`Save` → devices/`Profile`s/command maps) is a single +JSON file at `${pcpanel.root}/profiles.json` managed by `SaveService`. Custom Jackson deserializers +(`CommandMapDeserializer`, `KnobSettingMapDeserializer`) handle the polymorphic command maps. The +data dir is `~/.pcpanel` on Windows/macOS; on Linux it is resolved by `util/PcPanelRoot` to honor +`$PCPANEL_ROOT`, then a pre-existing legacy `~/.pcpanel`, then `$XDG_CONFIG_HOME/pcpanel` +(`~/.config/pcpanel`) — this is what makes the Flatpak (sandbox `$HOME` → `~/.var/app//config`) +and immutable distros persist settings without a host grant. `PcPanelRoot.resolve()` is the single +source of truth: `Main` publishes it as the `pcpanel.root` system property for the native image, and +the non-CDI `FileChecker`/`HidDebug` call it directly. See `linux.md` for the user-facing details. + +**Frontend bridge (`rest/`):** the **shared** JAX-RS + websocket bridge: `SettingsResource`, +`PlatformResource`, `SystemResource`, `IconResource`/`ProcessResource` (the app/process picker, shared +across features), `EventWebSocket` at `/ws/events`, `EventBroadcaster`, `LocalHttpGuard`, and the +`rest/model/` DTO+WS contract. Feature-specific resources live with their feature instead: +device-management REST in `device/rest/` (`DeviceResource`, `SerialResource`, `MidiResource`), +volume/overlay REST in `integration/volume/`, each external connector's REST in `integration//rest/`. +The backend pushes device/state snapshots to the Angular UI over the socket. There is no separate window +framework — the "UI" is the browser served by Quinoa. `StaticCacheControl` (another `@Observes Router` +filter) stamps `no-cache` on every UI path except the content-hashed bundle files — Quarkus's static +default (`Cache-Control: immutable, max-age=86400`) otherwise keeps a browser on the previous release's +frontend for up to a day after an app update, without revalidating even on reload (see issue #113). +Production builds ship the frontend source maps (`sourceMap` in `angular.json`'s production config) so +user-reported console errors carry readable TS stack traces. + +**Web-exposure security model:** the API is unauthenticated, so it must stay reachable only from the +local machine. Two layers enforce this: `quarkus.http.host=127.0.0.1` keeps other hosts off, and +`LocalHttpGuard` (a `@Observes Router` Vert.x filter, lowest order) rejects any request whose `Host` +or `Origin` header is not loopback — defeating DNS rebinding and cross-site WebSocket hijacking from a +website the user visits. `EventWebSocket.onOpen` re-checks the handshake with the same +`LocalHttpGuard` helpers as a second layer. Loopback `Origin` is accepted on any port (so dev's +`:4200` Quinoa proxy works); absent `Origin` is allowed (non-browser clients) with the `Host` check as +the backstop. Toggle with `pcpanel.http.local-only` (default true). This does **not** authenticate +*local* callers — defending against other processes on the same machine would need a token and is out +of scope. See also [SECURITY.md](SECURITY.md). + +**Integrations (`integration/*` — command-providing features only):** the external connectors +`integration/obs/` (OBS websocket), `voicemeeter/` (JNA), `wavelink/` + `dev/niels/wavelink/` (Elgato +Wave Link RPC client), `osc/`, `mqtt/` (Eclipse Paho mqttv5), `homeassistant/`, `discord/`; plus the +feature families `volume/`, `keyboard/`, `program/`, `analogbands/`, `profile/`, and `device/` (the +brightness command only). Each owns its `command/` + `CommandModule` and (where applicable) its REST, +SPI impls, and service. End-user setup per integration: `docs/integrations.md`. The on-screen volume +overlay lives in `integration/volume/overlay/`: a Win32 JNA +layered window on Windows (`Win32VolumeOverlay`) and a +desktop-drawn OSD over D-Bus on Linux/Wayland (`LinuxOverlay`, AWT-free) — KDE Plasma's native volume +OSD (`org.kde.osdService.volumeChanged`, the same real-time bar as Plasma's own volume keys) when +plasmashell is on the bus, **else no overlay** (clean no-op). A notification fallback was deliberately +rejected: the freedesktop notification protocol can't guarantee in-place replacement across daemons, so +it risks spamming one notification per knob tick — worse than nothing. The desktop owns placement/styling, +so those `Save` settings don't apply on Linux (the settings UI greys them out). macOS stays a no-op. +Selection is the runtime `Platform` check in `Overlay.createOverlay()`. +`util/tray/` is the system tray (Wayland uses the D-Bus StatusNotifierItem protocol via dbus-java). + +`integration/homeassistant/` is *outbound* control (the app drives Home Assistant), distinct from the +MQTT auto-discovery in `integration/mqtt/` (which lets Home Assistant discover the app). It holds both +its own command types (`integration/homeassistant/command/` — every feature's commands live in its own +`command/` package, all picked up by the single `com.getpcpanel.**.command.**` typescript-generator +classPattern in `pom.xml`) and a minimal REST client +(`HomeAssistantClient`, JDK `HttpClient`, no extra dependency). Multiple servers are configured in +settings (`Save.homeAssistantServers`); a command with a blank server id auto-resolves to the only +configured server (the UI also auto-selects it). **Actions are authored as pasted HA "action" YAML** +(the format HA's Developer Tools → Actions page produces) rather than hand-built pickers — the UI +links out to that page on the configured server. `HaActionYaml` (snakeyaml, `SafeConstructor`) parses +the YAML into the `domain.service` + flat body the REST API wants. The dial command additionally maps +the 0..1 dial position to a number via min/max or an `exp4j` formula (variable `x`) and substitutes it +for the `{{ value }}` token in the YAML before sending. + +## GraalVM native image — important + +Native image config is the most fragile part of the build, and it lives in **two** places that +must be kept in sync: the `quarkus.native.*` properties in **`pom.xml`'s `` block** and +the copy in **`application.properties`**. Which one wins depends on how the build is invoked: a full +`mvn package`/`mvn verify` (CI) runs `process-resources`, which filters `application.properties` onto +the classpath where its `additional-build-args` **outranks** the pom property — so for CI, +`application.properties` is authoritative. When `quarkus:build`/`quarkus:dev` is invoked directly, +`process-resources` has not run, so the pom value is used. **Change both**, or you will get different +native images locally vs. in CI — `NativeBuildArgsParityTest` enforces this and fails the build if the +two `additional-build-args` lists drift apart (the per-OS `${native.awt.args}`/ +`${native.platform.linker.args}` placeholders are ignored since they are identical references in both). +The OS-specific AWT init policy is shared between them via the +Maven-filtered `${native.awt.args}` placeholder (default vs. the `os-mac` profile in `pom.xml`); +`src/main/resources` has `true`, which is what makes that substitution work. +Key constraints baked into those args, change with care: + +- `-J-XX:-UseCompressedOops` is required so `Unsafe.arrayIndexScale` matches the runtime (8-byte + refs); omitting it segfaults jctools. +- JNA, hid4java, jnativehook, dbus, AWT-dependent, and Voicemeeter classes are + `--initialize-at-run-time`; certain AWT font/hint classes are `--initialize-at-build-time`. +- **macOS has no `libawt` in the native image at all** (GraalVM/Quarkus reject AWT there: + `quarkus-awt` is dropped via the `os-non-mac` profile, and the `os-mac` profile defers the whole + AWT/Java2D/Swing/ImageIO subsystem to run-time). So macOS must never *call* AWT: the overlay is a + no-op (`NoOpOverlayWindow`), icons are disabled, keystrokes use CoreGraphics `CGEvent` + (`com.getpcpanel.integration.keyboard.platform.osx.OsxKeyboard`, the macOS `Keyboard` impl), the tray + and `java.awt.Desktop` are skipped. JNA classes + that run `Native.load` in their initializer must be `--initialize-at-run-time` (narrowly, by class — + a package-wide directive would wrongly catch Quarkus's build-time CDI `_Bean` objects). +- Windows-only GUI-subsystem linker flags (`/SUBSYSTEM:WINDOWS`, `/ENTRY:mainCRTStartup`) are MSVC + flags injected only via the `os-windows` profile (`native.platform.linker.args`); they break + GNU ld / ld64, so never add them to the shared block. +- Reachability metadata lives under `src/main/resources/META-INF/native-image/`. Regenerate it with + the tracing agent via the `native-config-gen` Maven profile + (`mvn -Pnative-config-gen test -Dquarkus.native.enabled=false`) or, on Windows, + `generate-native-configs.cmd`. +- A REST DTO returning `List` needs **both** the element record and its array type + (`SomeRecordDto[]`) registered for reflection — Jackson reflectively instantiates the array per + `List` during serialization. The tracing agent only records what it observes, so an endpoint traced + with an empty list silently omits these and then throws `MissingReflectionRegistrationError` → HTTP + 500 at runtime once the list is non-empty (works fine in JVM/dev, which always has reflection). + Don't rely on tracing for this: register them explicitly on the response DTO with + `@RegisterForReflection(targets = { Foo.class, Foo[].class, ... })`, listing every nested element + record and its `[].class` form (see `rest/wavelink/dto/WaveLinkResponseDto`). +- A serialised field of a JDK type Jackson handles via a **built-in `StdSerializer`** needs that + serializer class registered too — e.g. a `java.io.File` field uses + `com.fasterxml.jackson.databind.ser.std.FileSerializer`, whose no-arg ctor must be reflectively + instantiable or the endpoint 500s with "FileSerializer has no default constructor" once the value is + non-null (`/api/audio/applications` → `ISndCtrl.RunningApplication.file`; works in JVM/dev). Register + the serializer by name in `NativeImageConfig.classNames`. The coverage tests don't catch this (it is + Jackson-internal, not a project type), so the reliable check is to **run the native binary and curl the + list/DTO REST endpoints** — JVM/dev mode never reproduces it. +- **Discovery guards catch the above automatically — keep them green.** `ReflectionRegistrationCoverageTest` + walks the Jackson `Command` hierarchy's serialised property graph and fails if any concrete subtype, or + any concrete project record/class it reaches (plus the `Foo[]` array form for `List`/`Set` of a concrete + element), is missing from the `@RegisterForReflection`/reachability-metadata registrations — so a new + command or a record nested in one can't ship unregistered. `ProxyRegistrationCoverageTest` does the same + for JNA `Library` proxies. Both run on every OS/JVM build, so the platform that forgot a registration + need not be the one running the test. When one fails, add the named type to `NativeImageConfig`. +- **jSerialComm (the Deej serial provider) is pinned to 2.10.2** — the last release before it bundled + an Android USB-serial driver (2.10.3+), whose `android.*` references fail the native build under + `--link-at-build-time` (we never run on Android). It also can't self-extract its bundled native lib + in a native image (it locates the jar via `CodeSource`, null there), so the Windows lib is placed + next to the runner exe via `maven-dependency-plugin` (os-windows profile) and loaded from + `java.library.path` — the same companion-DLL model as `SndCtrl.dll`. Linux/macOS would need their + `.so`/`.jnilib` next to the binary too. +- **`javax.sound.midi` (the MIDI provider) does not enumerate devices in the native image** (known + GraalVM limitation): the image links and startup is safe — every `MidiSystem` call is + `Throwable`-guarded so a missing MIDI subsystem can't crash startup or affect PCPanel/Deej — but + `getMidiDeviceInfo()` returns empty in native. MIDI input works in JVM/dev mode only until a custom + JNI `Feature` (and CoreMidi4J on macOS) is added. `com.sun.media.sound` and + `javax.sound.midi.MidiSystem` are `--initialize-at-run-time`. + +## Native C++ (`src/main/cpp/`, Windows DLL) + +`SndCtrl.dll` (audio control via Windows Core Audio). The built DLL is committed at +`src/main/resources/SndCtrl.dll`; the Maven/CI build only bundles it, it does not rebuild it. + +The sources are compiler-portable and build via **CMake** (`src/main/cpp/CMakeLists.txt`) with +either MSVC or MinGW-w64 — including a **cross-compile from Linux** (no Visual Studio needed). See +`src/main/cpp/README.md` for the full instructions; in short: +`apt install g++-mingw-w64-x86-64 cmake`, then `cmake -B build -S src/main/cpp +-DCMAKE_TOOLCHAIN_FILE=$PWD/src/main/cpp/mingw-w64-x86_64.toolchain.cmake -DWIN_JDK_HOME= +&& cmake --build build`. It needs the **Windows** JNI headers (unzip any Windows JDK), not a Linux +JDK's (whose `jni_md.h` would make `jlong` 32-bit and skip the `__declspec(dllexport)` exports). + +What used to make it Visual-Studio-only and how it was removed: ATL `CComPtr`/`CComQIPtr` → +portable `comptr_compat.h` shim; `_bstr_t`/`` → `WideCharToMultiByte`; `__uuidof` on the +custom COM interfaces → explicit `__CRT_UUID_DECL` (guarded by `__MINGW32__`); MSVC's implicit +transitive includes → explicit ones in `pch.h`. All MSVC-path behaviour is preserved (changes are +`#ifdef`-guarded or behaviour-identical). The MinGW DLL statically links libstdc++/libgcc/winpthread +so it has no extra runtime-DLL dependencies (it is ~1 MB stripped vs MSVC's ~70 KB — expected). + +The legacy Visual Studio solution (`SndCtrl.sln`/`.vcxproj`) and the `SndCtrlTest` harness (JNI +access violations otherwise silently close the app) are still present and still work; the one +hardcoded VS setting is the JNI include dir under project properties → C/C++ → General → Additional +Include Directories. **There is no automated test for the DLL** — final verification needs the app +running on Windows against PCPanel hardware. diff --git a/CLAUDE.md b/CLAUDE.md index 55bb7a89..402c2490 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,284 +12,44 @@ information, consider adding it here if it is relevant to the project's goals an functionality (not just the current task) — that is how it becomes shared knowledge rather than one agent's private note. -## What this is +## Required reading: ARCHITECTURE.md -Third-party/community controller software for [PCPanel](https://getpcpanel.com) USB audio-control -devices (knobs, sliders, buttons with RGB). It is a **desktop application** built as a -**Quarkus** backend (Java 25) serving an **Angular** frontend in a local browser. The app talks -to PCPanel hardware over USB HID and controls OS audio (per-process/device volume, mute, default -device) plus integrations (OBS, Voicemeeter, Elgato Wave Link, OSC, MQTT). +**Read [ARCHITECTURE.md](ARCHITECTURE.md) before working on this codebase.** It is the technical +reference — what the app is, build & run commands, the module/package architecture (hardware path, +device providers, command model, persistence, REST/WS bridge, integrations), the GraalVM +native-image constraints, and the native C++ DLL. That material is required context for almost any +task here; this file only covers agent workflow on top of it. -Development focus is Windows; Linux is best-effort. The project was migrated from Spring -Boot + JavaFX to Quarkus + Angular, and ships as a **GraalVM native image** (see git history / -`copilot/migration-to-quarkus-again` branch context). +In one line: third-party/community controller software for [PCPanel](https://getpcpanel.com) USB +audio-control devices — a Quarkus (Java 25) backend serving an Angular frontend in a local browser, +shipped as a GraalVM native image. Development focus is Windows; Linux is best-effort. -## Build & run +## Environment & running (agent notes) -The toolchain is the Maven wrapper (`./mvnw` / `mvnw.cmd`). Java 25 is required (GraalVM CE 25 for -native builds; the `JAVAFX_HOME` instructions in CONTRIBUTING.md are stale — JavaFX is gone). - -JDKs live under `~/.jdks` (IntelliJ's default). If `JAVA_HOME` is unset, point it at the GraalVM 25 -install before running Maven, e.g. `export JAVA_HOME=~/.jdks/graalvm-ce-25.0.2` -(`~/.jdks/liberica-full-21.x` is also present but too old — Java 25 is required). - -```bash -./mvnw quarkus:dev # dev mode: backend on :7654, Quinoa runs Angular dev server on :4200 with live reload -./mvnw clean package # builds a NATIVE image by default (quarkus.native.enabled=true in pom) -./mvnw clean package -Dquarkus.native.enabled=false # JVM-only jar, much faster, no GraalVM needed -./mvnw test # unit tests (surefire); ~10 test classes under src/test/java -./mvnw test -Dtest=ClassName#method # single test -./mvnw verify -Pnative # native build + failsafe integration tests against the runner binary -``` - -- `package` produces a native executable at `target/*-runner` (Linux) / `target/*-runner.exe` (Windows). -- CI (`.github/workflows/build-and-release.yml`) builds the native image on Windows and Linux via - `mvn -B package -Pnative`, wraps it in installers (Windows Inno Setup `.exe`, Linux `.deb` / - AppImage / Flatpak — see `packaging/`), and publishes a per-branch pre-release. The native image is - NOT self-contained: it loads companion `*.dll`/`*.so` libraries from its own directory, so every - artifact must bundle them alongside the executable. The Linux artifacts also bundle **`kdotool`** - (Apache-2.0) next to the executable — it resolves the focused window on KDE Plasma (Wayland and X11) - for focus volume. `packaging/linux/fetch-kdotool.sh` pins the version + sha256 and is cache-keyed in - CI on its own hash (download once per pin). `LinuxProcessHelper` prefers a `kdotool` sibling of its - own binary over the `PATH` lookup; `xdotool` is only an optional non-KDE-X11 fallback (kdotool covers - X11, so the two are never both required). Inside the Flatpak, kdotool runs in the sandbox and drives - the host KWin over D-Bus (`--talk-name=org.kde.KWin`); `kdotool-wrapper.sh` points its `TMPDIR` at the - host-visible per-app cache dir (`XDG_CACHE_HOME` = `~/.var/app//cache`, identity-mapped into the - sandbox — *not* `$HOME`, which is an unbacked overlay) so the host KWin can read the temp KWin script - kdotool generates. -- **Releasing:** `` in `pom.xml` is the version source of truth (artifacts are - `.`). Bump it with `packaging/bump-version.sh ` (also updates the - AppStream metadata), then push a `releases/` branch to trigger a pre-release build. CI bakes - the same `.` into the app via `-Dquarkus.application.version=` on `mvn package`, so - the UI footer reports the build number for official builds (local/dev stays at ``, - i.e. `-SNAPSHOT`). +- The toolchain is the Maven wrapper (`./mvnw` / `mvnw.cmd`); the full command list is in + ARCHITECTURE.md. JDKs live under `~/.jdks` (IntelliJ's default). If `JAVA_HOME` is unset, point it + at the GraalVM 25 install before running Maven, e.g. `export JAVA_HOME=~/.jdks/graalvm-ce-25.0.2` + (`~/.jdks/liberica-full-21.x` is also present but too old — Java 25 is required). - **Run two instances side by side:** pass the `skipfilecheck` arg (otherwise launching a second instance just focuses the already-installed one — see `Main`/`FileChecker`). For a separate dev data dir, set `pcpanel.root=${user.home}/.pcpaneldev/` (dev profile already does this). - -### Frontend (`src/main/webui`, Angular 21) - -Managed by the Quinoa Quarkus extension — normally you don't run it directly; `quarkus:dev` proxies it. -Standalone: `cd src/main/webui && npm install && npm start` (serves :4200, proxies `/api` + `/ws` to :7654). - -**TypeScript types are generated from Java**, not hand-written. The `typescript-generator-maven-plugin` -(runs in the `compile` phase) writes `src/main/webui/src/app/models/generated/backend.types.ts` from -`com.getpcpanel.rest.model.**`, the command classes, and any `**.dto.**`. When you change a DTO or -command shape, recompile so the frontend contract regenerates — don't edit the generated file. - -## Architecture - -Quarkus CDI (Arc) app. Entry point is `com.getpcpanel.Main` (`@QuarkusMain`); beans are wired by -injection, and cross-cutting communication uses the **CDI event bus** (`jakarta.enterprise.event.Event` -fire + `@Observes`) heavily rather than direct calls. `docs/events.md` catalogs the events with their -firers and observers — keep it current when you add or remove an event. - -**Hardware path (`device/`):** the device layer is the hardware-abstraction layer (HAL) and is **not** -an integration — it provides no commands. `DeviceScanner` (in `device/provider/pcpanel/`) discovers HID -devices via hid4java; `DeviceCommunicationHandler` (one per device, own thread + queue, same package) -reads knob/button input and writes RGB/output. `Device` subclasses (`PCPanelMini/Pro/RGB`, in `device/`) -model each hardware variant; `DeviceHolder` (in `device/`) is the cross-provider registry. Physical input -becomes a `PCPanelControlEvent` / `ButtonClickEvent` on the event bus. - -**Device providers (`device/provider/`, `device/descriptor/`):** the device layer is generalized so -PCPanel is one `DeviceProvider` among several — providers are `@ApplicationScoped` beans discovered via -`Instance` (NOT build-time stereotypes; every build contains all of them). -`DeviceScanner` is the `"pcpanel"` HID provider (`device/provider/pcpanel/`); `DeejSerialProvider` -(serial, jSerialComm, `device/provider/deej/`) and -`MidiProvider` (`javax.sound.midi`, `device/provider/midi/`) are external providers; each provider -absorbs its own IO transport (e.g. `SerialTransport`/`JSerialComm*` under `deej/`). A device is described by a data -`DeviceDescriptor` (analog/digital inputs with source ranges, light/analog outputs, capabilities) -rather than the `DeviceType` enum, which is now PCPanel-provider-internal. Each provider normalizes -its raw analog values to the canonical **0–255** internal domain at its edge (PCPanel RGB 0–100, Deej -0–1023, MIDI 0–127 → 0–255), so `DialValueCalculator`/`KnobSetting`/commands are untouched. Non-PCPanel -devices use a lightless `GenericDevice` — `Device.deviceType()` is nullable, so guard PCPanel/HID-only -paths (lighting, `OutputInterpreter.sendInit`) against `deviceType() == null`. `DeviceSave` persists -`providerId`/`deviceKindId`/`capabilities` (back-filled at connect; legacy saves default to -`pcpanel`). The Angular UI renders any device from its descriptor (`DeviceRendererComponent` → -`PcDeviceComponent` for PCPanel, else `GenericDeviceComponent`). Full design + per-phase status: -`docs/device-layer-generalization-plan.md`. - -**Command model (`commands/` = engine; `integration/*/command/` = the commands):** A user's -per-dial/button configuration is a `Commands` (list of `Command` subclasses). `commands/` holds only the -engine — `Command`, the `Dial/Button/DeviceAction` SPIs, `CommandDispatcher`, `DialValue`, the -`@CommandMeta`/`CommandModule` registry. Each concrete command lives in its feature's package, e.g. -`integration.volume.command.CommandVolumeProcess`, `integration.keyboard.command.CommandKeystroke`/ -`CommandMedia`, `integration.obs.command.CommandObs`. Commands are JSON-polymorphic (`@JsonTypeName` -ids, decentralized registry — see `docs/feature-module-structure.md`) and part of the generated TS -contract. **A package is an `integration` only if it provides commands** — providers/HAL/infra are not. - -**Native audio abstraction (`integration/volume/platform/`):** `ISndCtrl` is the OS-audio facade -(volume/mute/default device, focus app) — it is the backend the volume commands drive, so it lives in -the volume feature. Implementations are selected at **build time** by platform stereotypes: -`@WindowsBuild` (`SndCtrlWindows` → JNI to `SndCtrl.dll` via `SndCtrlNative`, both in -`integration/volume/platform/windows/`; C++ source in `src/main/cpp/`) and `@LinuxBuild` -(`SndCtrlPulseAudio` in `platform/linux/`, via JNA/PulseAudio). These stereotypes wrap -Quarkus `@IfBuildProperty(name="pcpanel.build.os", ...)` keyed off `pcpanel.build.os` (set at build -time from `os.detected.name`), so **a given build only contains one platform's beans** — guard -optional platform beans with `Instance` injection, and use `CdiHelper` to fetch beans from -non-CDI code. On Linux many native calls degrade to a no-op `ISndCtrl`. - -**Persistence (`profile/`):** All user config (`Save` → devices/`Profile`s/command maps) is a single -JSON file at `${pcpanel.root}/profiles.json` managed by `SaveService`. Custom Jackson deserializers -(`CommandMapDeserializer`, `KnobSettingMapDeserializer`) handle the polymorphic command maps. The -data dir is `~/.pcpanel` on Windows/macOS; on Linux it is resolved by `util/PcPanelRoot` to honor -`$PCPANEL_ROOT`, then a pre-existing legacy `~/.pcpanel`, then `$XDG_CONFIG_HOME/pcpanel` -(`~/.config/pcpanel`) — this is what makes the Flatpak (sandbox `$HOME` → `~/.var/app//config`) -and immutable distros persist settings without a host grant. `PcPanelRoot.resolve()` is the single -source of truth: `Main` publishes it as the `pcpanel.root` system property for the native image, and -the non-CDI `FileChecker`/`HidDebug` call it directly. See `linux.md` for the user-facing details. - -**Frontend bridge (`rest/`):** the **shared** JAX-RS + websocket bridge: `SettingsResource`, -`PlatformResource`, `SystemResource`, `IconResource`/`ProcessResource` (the app/process picker, shared -across features), `EventWebSocket` at `/ws/events`, `EventBroadcaster`, `LocalHttpGuard`, and the -`rest/model/` DTO+WS contract. Feature-specific resources live with their feature instead: -device-management REST in `device/rest/` (`DeviceResource`, `SerialResource`, `MidiResource`), -volume/overlay REST in `integration/volume/`, each external connector's REST in `integration//rest/`. -The backend pushes device/state snapshots to the Angular UI over the socket. There is no separate window -framework — the "UI" is the browser served by Quinoa. `StaticCacheControl` (another `@Observes Router` -filter) stamps `no-cache` on every UI path except the content-hashed bundle files — Quarkus's static -default (`Cache-Control: immutable, max-age=86400`) otherwise keeps a browser on the previous release's -frontend for up to a day after an app update, without revalidating even on reload (see issue #113). -Production builds ship the frontend source maps (`sourceMap` in `angular.json`'s production config) so -user-reported console errors carry readable TS stack traces. - -**Web-exposure security model:** the API is unauthenticated, so it must stay reachable only from the -local machine. Two layers enforce this: `quarkus.http.host=127.0.0.1` keeps other hosts off, and -`LocalHttpGuard` (a `@Observes Router` Vert.x filter, lowest order) rejects any request whose `Host` -or `Origin` header is not loopback — defeating DNS rebinding and cross-site WebSocket hijacking from a -website the user visits. `EventWebSocket.onOpen` re-checks the handshake with the same -`LocalHttpGuard` helpers as a second layer. Loopback `Origin` is accepted on any port (so dev's -`:4200` Quinoa proxy works); absent `Origin` is allowed (non-browser clients) with the `Host` check as -the backstop. Toggle with `pcpanel.http.local-only` (default true). This does **not** authenticate -*local* callers — defending against other processes on the same machine would need a token and is out -of scope. - -**Integrations (`integration/*` — command-providing features only):** the external connectors -`integration/obs/` (OBS websocket), `voicemeeter/` (JNA), `wavelink/` + `dev/niels/wavelink/` (Elgato -Wave Link RPC client), `osc/`, `mqtt/` (Eclipse Paho mqttv5), `homeassistant/`, `discord/`; plus the -feature families `volume/`, `keyboard/`, `program/`, `analogbands/`, `profile/`, and `device/` (the -brightness command only). Each owns its `command/` + `CommandModule` and (where applicable) its REST, -SPI impls, and service. The on-screen volume overlay lives in `integration/volume/overlay/`: a Win32 JNA -layered window on Windows (`Win32VolumeOverlay`) and a -desktop-drawn OSD over D-Bus on Linux/Wayland (`LinuxOverlay`, AWT-free) — KDE Plasma's native volume -OSD (`org.kde.osdService.volumeChanged`, the same real-time bar as Plasma's own volume keys) when -plasmashell is on the bus, **else no overlay** (clean no-op). A notification fallback was deliberately -rejected: the freedesktop notification protocol can't guarantee in-place replacement across daemons, so -it risks spamming one notification per knob tick — worse than nothing. The desktop owns placement/styling, -so those `Save` settings don't apply on Linux (the settings UI greys them out). macOS stays a no-op. -Selection is the runtime `Platform` check in `Overlay.createOverlay()`. -`util/tray/` is the system tray (Wayland uses the D-Bus StatusNotifierItem protocol via dbus-java). - -`integration/homeassistant/` is *outbound* control (the app drives Home Assistant), distinct from the -MQTT auto-discovery in `integration/mqtt/` (which lets Home Assistant discover the app). It holds both -its own command types (`integration/homeassistant/command/` — every feature's commands live in its own -`command/` package, all picked up by the single `com.getpcpanel.**.command.**` typescript-generator -classPattern in `pom.xml`) and a minimal REST client -(`HomeAssistantClient`, JDK `HttpClient`, no extra dependency). Multiple servers are configured in -settings (`Save.homeAssistantServers`); a command with a blank server id auto-resolves to the only -configured server (the UI also auto-selects it). **Actions are authored as pasted HA "action" YAML** -(the format HA's Developer Tools → Actions page produces) rather than hand-built pickers — the UI -links out to that page on the configured server. `HaActionYaml` (snakeyaml, `SafeConstructor`) parses -the YAML into the `domain.service` + flat body the REST API wants. The dial command additionally maps -the 0..1 dial position to a number via min/max or an `exp4j` formula (variable `x`) and substitutes it -for the `{{ value }}` token in the YAML before sending. - -## GraalVM native image — important - -Native image config is the most fragile part of the build, and it lives in **two** places that -must be kept in sync: the `quarkus.native.*` properties in **`pom.xml`'s `` block** and -the copy in **`application.properties`**. Which one wins depends on how the build is invoked: a full -`mvn package`/`mvn verify` (CI) runs `process-resources`, which filters `application.properties` onto -the classpath where its `additional-build-args` **outranks** the pom property — so for CI, -`application.properties` is authoritative. When `quarkus:build`/`quarkus:dev` is invoked directly, -`process-resources` has not run, so the pom value is used. **Change both**, or you will get different -native images locally vs. in CI — `NativeBuildArgsParityTest` enforces this and fails the build if the -two `additional-build-args` lists drift apart (the per-OS `${native.awt.args}`/ -`${native.platform.linker.args}` placeholders are ignored since they are identical references in both). -The OS-specific AWT init policy is shared between them via the -Maven-filtered `${native.awt.args}` placeholder (default vs. the `os-mac` profile in `pom.xml`); -`src/main/resources` has `true`, which is what makes that substitution work. -Key constraints baked into those args, change with care: - -- `-J-XX:-UseCompressedOops` is required so `Unsafe.arrayIndexScale` matches the runtime (8-byte - refs); omitting it segfaults jctools. -- JNA, hid4java, jnativehook, dbus, AWT-dependent, and Voicemeeter classes are - `--initialize-at-run-time`; certain AWT font/hint classes are `--initialize-at-build-time`. -- **macOS has no `libawt` in the native image at all** (GraalVM/Quarkus reject AWT there: - `quarkus-awt` is dropped via the `os-non-mac` profile, and the `os-mac` profile defers the whole - AWT/Java2D/Swing/ImageIO subsystem to run-time). So macOS must never *call* AWT: the overlay is a - no-op (`NoOpOverlayWindow`), icons are disabled, keystrokes use CoreGraphics `CGEvent` - (`com.getpcpanel.integration.keyboard.platform.osx.OsxKeyboard`, the macOS `Keyboard` impl), the tray - and `java.awt.Desktop` are skipped. JNA classes - that run `Native.load` in their initializer must be `--initialize-at-run-time` (narrowly, by class — - a package-wide directive would wrongly catch Quarkus's build-time CDI `_Bean` objects). -- Windows-only GUI-subsystem linker flags (`/SUBSYSTEM:WINDOWS`, `/ENTRY:mainCRTStartup`) are MSVC - flags injected only via the `os-windows` profile (`native.platform.linker.args`); they break - GNU ld / ld64, so never add them to the shared block. -- Reachability metadata lives under `src/main/resources/META-INF/native-image/`. Regenerate it with - the tracing agent via `generate-native-configs.cmd` (Windows) or the commands in README.md. -- A REST DTO returning `List` needs **both** the element record and its array type - (`SomeRecordDto[]`) registered for reflection — Jackson reflectively instantiates the array per - `List` during serialization. The tracing agent only records what it observes, so an endpoint traced - with an empty list silently omits these and then throws `MissingReflectionRegistrationError` → HTTP - 500 at runtime once the list is non-empty (works fine in JVM/dev, which always has reflection). - Don't rely on tracing for this: register them explicitly on the response DTO with - `@RegisterForReflection(targets = { Foo.class, Foo[].class, ... })`, listing every nested element - record and its `[].class` form (see `rest/wavelink/dto/WaveLinkResponseDto`). -- A serialised field of a JDK type Jackson handles via a **built-in `StdSerializer`** needs that - serializer class registered too — e.g. a `java.io.File` field uses - `com.fasterxml.jackson.databind.ser.std.FileSerializer`, whose no-arg ctor must be reflectively - instantiable or the endpoint 500s with "FileSerializer has no default constructor" once the value is - non-null (`/api/audio/applications` → `ISndCtrl.RunningApplication.file`; works in JVM/dev). Register - the serializer by name in `NativeImageConfig.classNames`. The coverage tests don't catch this (it is - Jackson-internal, not a project type), so the reliable check is to **run the native binary and curl the - list/DTO REST endpoints** — JVM/dev mode never reproduces it. -- **Discovery guards catch the above automatically — keep them green.** `ReflectionRegistrationCoverageTest` - walks the Jackson `Command` hierarchy's serialised property graph and fails if any concrete subtype, or - any concrete project record/class it reaches (plus the `Foo[]` array form for `List`/`Set` of a concrete - element), is missing from the `@RegisterForReflection`/reachability-metadata registrations — so a new - command or a record nested in one can't ship unregistered. `ProxyRegistrationCoverageTest` does the same - for JNA `Library` proxies. Both run on every OS/JVM build, so the platform that forgot a registration - need not be the one running the test. When one fails, add the named type to `NativeImageConfig`. -- **jSerialComm (the Deej serial provider) is pinned to 2.10.2** — the last release before it bundled - an Android USB-serial driver (2.10.3+), whose `android.*` references fail the native build under - `--link-at-build-time` (we never run on Android). It also can't self-extract its bundled native lib - in a native image (it locates the jar via `CodeSource`, null there), so the Windows lib is placed - next to the runner exe via `maven-dependency-plugin` (os-windows profile) and loaded from - `java.library.path` — the same companion-DLL model as `SndCtrl.dll`. Linux/macOS would need their - `.so`/`.jnilib` next to the binary too. -- **`javax.sound.midi` (the MIDI provider) does not enumerate devices in the native image** (known - GraalVM limitation): the image links and startup is safe — every `MidiSystem` call is - `Throwable`-guarded so a missing MIDI subsystem can't crash startup or affect PCPanel/Deej — but - `getMidiDeviceInfo()` returns empty in native. MIDI input works in JVM/dev mode only until a custom - JNI `Feature` (and CoreMidi4J on macOS) is added. `com.sun.media.sound` and - `javax.sound.midi.MidiSystem` are `--initialize-at-run-time`. - -## Native C++ (`src/main/cpp/`, Windows DLL) - -`SndCtrl.dll` (audio control via Windows Core Audio). The built DLL is committed at -`src/main/resources/SndCtrl.dll`; the Maven/CI build only bundles it, it does not rebuild it. - -The sources are compiler-portable and build via **CMake** (`src/main/cpp/CMakeLists.txt`) with -either MSVC or MinGW-w64 — including a **cross-compile from Linux** (no Visual Studio needed). See -`src/main/cpp/README.md` for the full instructions; in short: -`apt install g++-mingw-w64-x86-64 cmake`, then `cmake -B build -S src/main/cpp --DCMAKE_TOOLCHAIN_FILE=$PWD/src/main/cpp/mingw-w64-x86_64.toolchain.cmake -DWIN_JDK_HOME= -&& cmake --build build`. It needs the **Windows** JNI headers (unzip any Windows JDK), not a Linux -JDK's (whose `jni_md.h` would make `jlong` 32-bit and skip the `__declspec(dllexport)` exports). - -What used to make it Visual-Studio-only and how it was removed: ATL `CComPtr`/`CComQIPtr` → -portable `comptr_compat.h` shim; `_bstr_t`/`` → `WideCharToMultiByte`; `__uuidof` on the -custom COM interfaces → explicit `__CRT_UUID_DECL` (guarded by `__MINGW32__`); MSVC's implicit -transitive includes → explicit ones in `pch.h`. All MSVC-path behaviour is preserved (changes are -`#ifdef`-guarded or behaviour-identical). The MinGW DLL statically links libstdc++/libgcc/winpthread -so it has no extra runtime-DLL dependencies (it is ~1 MB stripped vs MSVC's ~70 KB — expected). - -The legacy Visual Studio solution (`SndCtrl.sln`/`.vcxproj`) and the `SndCtrlTest` harness (JNI -access violations otherwise silently close the app) are still present and still work; the one -hardcoded VS setting is the JNI include dir under project properties → C/C++ → General → Additional -Include Directories. **There is no automated test for the DLL** — final verification needs the app -running on Windows against PCPanel hardware. +- **TypeScript types are generated from Java** (`backend.types.ts`) — when you change a DTO or + command shape, recompile so the frontend contract regenerates; never edit the generated file. + The classPattern list is in ARCHITECTURE.md. + +## Agent-critical warnings + +- `docs/events.md` catalogs the CDI events with their firers and observers — **keep it current when + you add or remove an event.** +- **Native image config is the most fragile part of the build.** It lives in two places that must be + kept in sync (pom.xml properties + application.properties), and there is a long list of hard-won + constraints (compressed-oops, initialize-at-run-time classes, reflection registration for DTOs and + Jackson serializers, platform linker flags). Read the "GraalVM native image" section of + ARCHITECTURE.md **before** touching anything native-image related, and keep the discovery guards + (`ReflectionRegistrationCoverageTest`, `ProxyRegistrationCoverageTest`, `NativeBuildArgsParityTest`) + green — when one fails, add the named type to `NativeImageConfig` rather than working around it. +- New `Command` subtypes must be registered for reflection (`NativeImageConfig`) or they fail to + deserialize in the native image — the coverage test catches this; keep it green. ## Git and worktrees @@ -312,9 +72,10 @@ running on Windows against PCPanel hardware. There is an optional **MCP server** (`com.getpcpanel.mcp`, source root `src/mcp/java`) that exposes the running app's runtime state and a hardware-free test harness (synthetic input, virtual devices, audio-state read, log/error access). It is **off by default and never in the shipped build**. Run it in -dev with `./mvnw quarkus:dev -Dpcpanel.mcp=true` — then reach the tools either as **plain REST** under -`http://127.0.0.1:7654/api/mcp/*` (just `curl`, no MCP client — start at `GET /api/mcp`) or as an **MCP -SSE** server at `http://127.0.0.1:7654/mcp/sse`. Two build-time gates: the Maven `mcp` profile +dev with `./mvnw quarkus:dev -Dpcpanel.mcp=true` — then reach the tools as **plain REST** under +`http://127.0.0.1:7654/api/mcp/*` (just `curl`, no MCP client — start at `GET /api/mcp`), as +**MCP Streamable HTTP** at `POST http://127.0.0.1:7654/mcp` (the standard MCP transport), or as +legacy **MCP SSE** at `http://127.0.0.1:7654/mcp/sse`. Two build-time gates: the Maven `mcp` profile (`-Dpcpanel.mcp=true`) compiles it in at all; `pcpanel.mcp.dev` (on in `%dev`) wires the dev tools. Full reference: [`docs/mcp-server.md`](docs/mcp-server.md). @@ -337,6 +98,8 @@ Full reference: [`docs/mcp-server.md`](docs/mcp-server.md). to ask "should I commit?". Committing is the default expectation; only *pushing* needs explicit permission. - Never `git push` until the user explicitly asks for it. +- User-visible changes get a line at the top of `CHANGELOG.md` (above the versioned sections) — + unversioned entries there flow into the next release's notes. ## AI-generated contributions — disclosure diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 19836268..bd83d012 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,7 +23,7 @@ user, see the [README](README.md). - **Packaging:** GraalVM native image, wrapped into per-platform installers in CI. A deeper tour of the architecture (hardware path, command model, persistence, the REST/WebSocket -bridge, integrations) lives in [CLAUDE.md](CLAUDE.md). +bridge, integrations) lives in [ARCHITECTURE.md](ARCHITECTURE.md). ## Prerequisites @@ -32,7 +32,8 @@ bridge, integrations) lives in [CLAUDE.md](CLAUDE.md). - The Maven wrapper (`./mvnw` / `mvnw.cmd`) — no separate Maven install needed. - **Node.js** is only needed if you want to run the Angular dev server standalone; otherwise Quinoa manages it for you during `quarkus:dev`. -- **Windows native code** (optional): Visual Studio, to rebuild `SndCtrl.dll` (see below). +- **Windows native code** (optional): CMake plus a C++ compiler (MSVC Build Tools or MinGW-w64), to + rebuild `SndCtrl.dll` (see below). No Visual Studio IDE required. Development focus is Windows; Linux is best-effort. On Linux you'll also need the device-access setup from [linux.md](linux.md). @@ -83,14 +84,24 @@ npm start # serves :4200, proxies /api + /ws to :7654 ``` **TypeScript types are generated from Java, not hand-written.** The `typescript-generator-maven-plugin` -(in the `compile` phase) writes `src/app/models/generated/backend.types.ts` from -`com.getpcpanel.rest.model.**`, the command classes, and any `**.dto.**`. When you change a DTO or +(in the `compile` phase) writes `src/app/models/generated/backend.types.ts` from the REST/WS DTOs, +the command classes, and the device descriptors (the full classPattern list is in +[ARCHITECTURE.md](ARCHITECTURE.md) and `pom.xml`). When you change a DTO or command shape, **recompile** so the contract regenerates — don't edit the generated file by hand. ## Native C++ (`src/main/cpp/`, Windows only) -A Visual Studio solution builds `SndCtrl.dll` (audio control via Windows Core Audio) and a -`SndCtrlTest` harness. The built DLL is committed at `src/main/resources/SndCtrl.dll`. +`SndCtrl.dll` (audio control via Windows Core Audio) ships **pre-built and committed** at +`src/main/resources/SndCtrl.dll` — the Maven/CI build only bundles it. You only need to rebuild it +when you change the C++ sources. + +The sources build via **CMake** with either MSVC or MinGW-w64 — including a cross-compile from +Linux; no Visual Studio IDE or ATL required. On Windows, +`powershell -File src/main/cpp/build-windows.ps1 -InstallTools` installs the tooling (via winget) +and builds in one go. Full instructions, including the Linux cross-compile, are in +[src/main/cpp/README.md](src/main/cpp/README.md). + +The legacy Visual Studio solution (`SndCtrl.sln`) still works as an alternative: - The one machine-specific setting is the JNI include directory: project properties → `C/C++ → General → Additional Include Directories`. Point it at your JDK's `include` folder. @@ -105,25 +116,28 @@ be kept in sync — the `quarkus.native.*` properties in `pom.xml` and the copy `application.properties` — because which one wins depends on how the build is invoked. **Change both,** or you'll get different images locally vs. in CI. The full set of constraints (compressed-oops flag, `--initialize-at-run-time` classes, platform linker flags, macOS having no `libawt`) is documented in -[CLAUDE.md](CLAUDE.md) — read that section before touching native config. +[ARCHITECTURE.md](ARCHITECTURE.md) — read that section before touching native config. Reachability metadata lives under `src/main/resources/META-INF/native-image/`. Regenerate it with the -tracing agent: +tracing agent via the `native-config-gen` Maven profile (needs a GraalVM JDK): ```shell -mvn test "-DargLine=-agentlib:native-image-agent=config-output-dir=src/main/resources/META-INF/native-image/ -Djava.awt.headless=false" -mvn test "-DargLine=-Dnative -Dquarkus.native.agent-configuration-apply" -Dnative -Dquarkus.native.agent-configuration-apply +mvn -Pnative-config-gen test -Dquarkus.native.enabled=false ``` -On Windows you can use `generate-native-configs.cmd`. +It runs the test suite under `-agentlib:native-image-agent` in merge mode (keeping entries other +platforms captured) and enables the generation-only tests. Afterwards, review the diff and strip any +captured `*Test` infrastructure entries before committing. On Windows, `generate-native-configs.cmd` +wraps the process. ## Releasing `` in `pom.xml` is the version source of truth (artifacts are `.`). Bump it with `packaging/bump-version.sh ` (which also updates the AppStream metadata), then push a `releases/` branch to trigger a release build. CI -(`.github/workflows/build-and-release.yml`) builds the native image on Windows and Linux, wraps it in -the platform installers (`packaging/`) and publishes a per-branch pre-release. +(`.github/workflows/build-and-release.yml`) builds the native image on Windows (`mvn -B verify +-Pnative`, which also runs the failsafe integration tests) and on Linux and macOS (`mvn -B package +-Pnative`), wraps it in the platform installers (`packaging/`) and publishes a per-branch pre-release. ## Coding conventions @@ -139,9 +153,13 @@ the platform installers (`packaging/`) and publishes a per-branch pre-release. ## Submitting changes -Open a pull request against `main` with a clear description of the change and the motivation. If your -change affects behavior users will notice, mention it so it can make the changelog. Bug reports and -feature requests are welcome on the [issue tracker](https://github.com/nvdweem/PCPanel/issues). +Open a pull request against `main` with a clear description of the change and the motivation. Bug +reports and feature requests are welcome on the +[issue tracker](https://github.com/nvdweem/PCPanel/issues). + +**Changelog:** if your change affects behavior users will notice, add a line at the **top** of +`CHANGELOG.md`, above the versioned sections. Unversioned entries there are included in the next +release's notes (see the comment at the top of that file). ## AI-assisted contributions diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..93a64a6b --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,23 @@ +# Security + +## Threat model + +PCPanel is a desktop app: a local Quarkus server drives the UI in your browser. The security model: + +- The HTTP/WebSocket server binds to **loopback only** (`quarkus.http.host=127.0.0.1`), so other + machines cannot reach it. +- `LocalHttpGuard` rejects any request whose `Host` or `Origin` header is not loopback. This defeats + **DNS rebinding** and **cross-site WebSocket hijacking** from web pages you visit; the WebSocket + handshake is re-checked with the same rules. The guard covers REST, static assets and the WS + upgrade, and can be toggled with `pcpanel.http.local-only` (default `true`). +- The API carries **no authentication against other processes on the same machine** — by design. + Anything running locally as your user is already inside the trust boundary. +- Integration credentials (OBS password, Home Assistant tokens, Discord client id/secret, MQTT + credentials) are stored in the local `profiles.json` in your user profile, readable by your user + account only as far as the OS enforces it. + +## Reporting a vulnerability + +Report vulnerabilities privately via a +[GitHub security advisory](https://github.com/nvdweem/PCPanel/security/advisories/new) rather than a +public issue. diff --git a/packaging/smoke-test.sh b/packaging/smoke-test.sh index 6451e944..31781f05 100755 --- a/packaging/smoke-test.sh +++ b/packaging/smoke-test.sh @@ -21,6 +21,13 @@ # --lenient /ep Endpoint that SHOULD return 200 but only warns on failure (repeatable) — # e.g. /api/audio/* on a CI runner with no audio server, where Linux audio # degrades to a no-op ISndCtrl by design. +# --no-baseline Skip the built-in baseline endpoint set. By default every parameterless, +# side-effect-free GET endpoint of the REST API is exercised in addition to +# the CLI-specified ones: integration endpoints that answer 200 with an empty +# list/DTO when their backing service (OBS, Wave Link, Discord, MQTT, Home +# Assistant, ...) is absent are required — a non-200 there is a serialization/ +# reflection break, not a missing backend — while endpoints tied to the OS +# audio stack or to per-platform native libs are lenient. # --require-hid Fail if the startup log reports "Failed to initialize HID services". # --quit-check After the endpoint checks, POST /api/system/quit and assert the process exits # (verifies the in-UI Quit button actually shuts the app down). Runs last. @@ -36,6 +43,7 @@ port=7654 boot_timeout=120 require_hid=0 quit_check=0 +no_baseline=0 require=() lenient=() @@ -44,6 +52,7 @@ while [ $# -gt 0 ]; do --port) port=$2; shift 2 ;; --require) require+=("$2"); shift 2 ;; --lenient) lenient+=("$2"); shift 2 ;; + --no-baseline) no_baseline=1; shift ;; --require-hid) require_hid=1; shift ;; --quit-check) quit_check=1; shift ;; --boot-timeout) boot_timeout=$2; shift 2 ;; @@ -53,6 +62,59 @@ done [ ${#require[@]} -gt 0 ] || require=(/api/devices) +# Baseline: every parameterless, side-effect-free GET endpoint of the REST API. The required tier +# answers 200 with an empty list/DTO on any OS when its backing service isn't there, so a failure +# means a broken build (typically a native-image reflection/registration gap), never a bare CI +# runner. The lenient tier depends on the OS audio stack or on native libs that are only bundled +# on some platforms. Deliberately absent: GET /api/overlay (shows the overlay — a side effect), +# GET /api/icons (needs ?path=), and the /api/devices/{serial}/** tree (needs path params). +baseline_require=( + /api/settings + /api/settings/mqtt + /api/settings/mqtt/status + /api/settings/wavelink + /api/settings/discord + /api/system/onboarding + /api/osc/status + /api/obs/scenes + /api/obs/sources + /api/voicemeeter/basic + /api/voicemeeter/advanced + /api/homeassistant/servers + /api/homeassistant/status + /api/wavelink/devices + /api/discord/users + /api/discord/status + /api/discord/voice-channels + /api/overlay/fonts + /api/midi/devices +) +baseline_lenient=( + /api/serial/ports # jSerialComm's native lib is only bundled next to the exe on Windows + /api/processes # ISndCtrl running-application list — same audio caveat as /api/audio/* + /api/focus-volume/diagnostics # reads the live ISndCtrl focus application + /api/audio/devices/output + /api/audio/devices/input + /api/audio/sessions +) + +contains() { + needle=$1; shift + for _c in "$@"; do + [ "$_c" = "$needle" ] && return 0 + done + return 1 +} + +if [ "$no_baseline" != 1 ]; then + for ep in "${baseline_require[@]}"; do + contains "$ep" "${require[@]}" || require+=("$ep") + done + for ep in "${baseline_lenient[@]}"; do + contains "$ep" "${require[@]}" "${lenient[@]}" || lenient+=("$ep") + done +fi + base="http://127.0.0.1:${port}" log=pcpanel-smoke.log diff --git a/src/main/java/com/getpcpanel/commands/CommandDispatcher.java b/src/main/java/com/getpcpanel/commands/CommandDispatcher.java index 689eb64b..c17bd887 100644 --- a/src/main/java/com/getpcpanel/commands/CommandDispatcher.java +++ b/src/main/java/com/getpcpanel/commands/CommandDispatcher.java @@ -4,6 +4,7 @@ import java.util.concurrent.ConcurrentHashMap; import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.event.Observes; import lombok.extern.log4j.Log4j2; @@ -17,7 +18,11 @@ public final class CommandDispatcher { @PostConstruct public void init() { handler.start(); - Runtime.getRuntime().addShutdownHook(new Thread(handler::doStop, "CommandHandler shutdown hook")); + } + + @PreDestroy + void stop() { + handler.doStop(); } private CommandDispatcher() { @@ -72,6 +77,11 @@ public void run() { private void waitForWaiter() { try { synchronized (waiter) { + // Re-check the queue under the lock before sleeping: onCommand does map.put then + // doNotify, so an event that arrives between our sweep and this wait would otherwise + // have its notify lost and sit unprocessed until the next event. + if (!map.isEmpty() || stopped) + return; waiter.wait(); } } catch (InterruptedException e) { diff --git a/src/main/java/com/getpcpanel/device/DeviceHolder.java b/src/main/java/com/getpcpanel/device/DeviceHolder.java index 386b9a56..0aec81fe 100644 --- a/src/main/java/com/getpcpanel/device/DeviceHolder.java +++ b/src/main/java/com/getpcpanel/device/DeviceHolder.java @@ -8,6 +8,7 @@ import java.util.Map; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; import java.util.function.Function; import java.util.function.Predicate; @@ -28,6 +29,7 @@ import com.getpcpanel.device.descriptor.DeviceDescriptor; import com.getpcpanel.profile.DeviceSave; import com.getpcpanel.profile.SaveService; +import com.getpcpanel.util.concurrent.Debouncer; import lombok.RequiredArgsConstructor; import one.util.streamex.EntryStream; @@ -37,6 +39,7 @@ public class DeviceHolder { private final Map devices = new ConcurrentHashMap<>(); @Inject SaveService saveService; + @Inject Debouncer debouncer; @Inject PcPanelDeviceFactory pcPanelDeviceFactory; @Inject GenericDeviceFactory genericDeviceFactory; @Inject OutputInterpreter outputInterpreter; @@ -63,8 +66,12 @@ public void deviceAdded(@Observes DeviceScanner.DeviceConnectedEvent event) { // Self-identifying persistence (Phase 2): back-fill identity/capabilities from the just- // connected descriptor for legacy saves (migrated providerId only) or whenever the hardware // now reports something different. Persist so a later disconnect can still render the device. + // The backfill above already mutated the in-memory Save; only the persist (and the SaveEvent it + // fires) is deferred. It must not run synchronously here: this observer runs on the device + // provider's thread, and SaveEvent observers (OBS/MQTT/WaveLink/OSC reconfigure logic) running + // re-entrantly on that thread can deadlock it (the same shape as a past startup hang). if (backfillIdentity(deviceSave, event.descriptor())) { - saveService.save(); + debouncer.debounce("DeviceHolder.identityBackfillSave", saveService::save, 500, TimeUnit.MILLISECONDS); } var descriptor = event.descriptor(); var isPcPanel = DescriptorFactory.PROVIDER_ID.equals(descriptor.providerId()); diff --git a/src/main/java/com/getpcpanel/device/provider/pcpanel/DeviceCommunicationHandler.java b/src/main/java/com/getpcpanel/device/provider/pcpanel/DeviceCommunicationHandler.java index d5442921..d71be2ae 100644 --- a/src/main/java/com/getpcpanel/device/provider/pcpanel/DeviceCommunicationHandler.java +++ b/src/main/java/com/getpcpanel/device/provider/pcpanel/DeviceCommunicationHandler.java @@ -25,6 +25,7 @@ import com.getpcpanel.device.descriptor.DeviceDescriptor; import com.getpcpanel.profile.SaveService; import com.getpcpanel.util.Util; +import com.getpcpanel.util.concurrent.AppThreads; import jakarta.enterprise.event.Event; import lombok.Setter; @@ -71,10 +72,8 @@ public DeviceCommunicationHandler(DeviceScanner deviceScanner, SaveService saveS } public void start() { - readerThread = new Thread(this::reader, "HIDReader " + device.getSerialNumber()); - writerThread = new Thread(this::writer, "HIDWriter " + device.getSerialNumber()); - readerThread.setDaemon(true); - writerThread.setDaemon(true); + readerThread = AppThreads.named("HIDReader " + device.getSerialNumber(), true, this::reader); + writerThread = AppThreads.named("HIDWriter " + device.getSerialNumber(), true, this::writer); // Start the rolling-average worker here (not in its constructor) so it does not publish a // reference to this not-yet-constructed handler, and so it only runs once the device is live. rollingAverageSetter.setDaemon(true); @@ -342,6 +341,7 @@ public void shutdown() { @SuppressWarnings("SynchronizationOnLocalVariableOrMethodParameter") private class RollingAverageSetter extends Thread { private final Map>> targets = new ConcurrentHashMap<>(); + private final Object wakeLock = new Object(); @Setter private Integer rollWindowMs; private volatile boolean running = true; @@ -355,11 +355,30 @@ public void setKnob(KnobRotateEvent knob, Integer rollWindowMs) { } target.add(Pair.of(System.currentTimeMillis(), knob.value())); } + synchronized (wakeLock) { + wakeLock.notifyAll(); + } } @Override public void run() { while (running) { + // Park while there is nothing to roll (i.e. until the first rolling-average knob event + // for this device); the 10ms cadence below would otherwise spin forever for nothing. + synchronized (wakeLock) { + while (running && targets.isEmpty()) { + try { + wakeLock.wait(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + } + } + if (!running) { + return; + } + // targets is a ConcurrentHashMap and each knob's deque is guarded individually (see // setKnob/handleRoll), so iterating here needs no map-wide lock — nothing else holds one. targets.forEach(this::handleRoll); @@ -431,6 +450,9 @@ private void removeAllPassedButLast(Deque> timeToValue) { public void shutdown() { running = false; + synchronized (wakeLock) { + wakeLock.notifyAll(); + } } } } diff --git a/src/main/java/com/getpcpanel/device/provider/pcpanel/DeviceScanner.java b/src/main/java/com/getpcpanel/device/provider/pcpanel/DeviceScanner.java index d746d2d9..b7b1d804 100644 --- a/src/main/java/com/getpcpanel/device/provider/pcpanel/DeviceScanner.java +++ b/src/main/java/com/getpcpanel/device/provider/pcpanel/DeviceScanner.java @@ -20,6 +20,7 @@ import com.getpcpanel.device.descriptor.DeviceDescriptor; import com.getpcpanel.device.descriptor.DiscoveryMode; import com.getpcpanel.device.provider.DeviceProvider; +import com.getpcpanel.util.concurrent.AppThreads; import com.getpcpanel.util.os.OsxPermissionHelper; import jakarta.enterprise.context.ApplicationScoped; @@ -103,8 +104,7 @@ public void init() { * restarted. This loop re-enumerates and retries while any open is outstanding, then goes idle. */ private void startReconciliation() { - var t = new Thread(this::reconcileLoop, "pcpanel-device-reconcile"); - t.setDaemon(true); + var t = AppThreads.named("pcpanel-device-reconcile", true, this::reconcileLoop); reconcileThread = t; t.start(); } @@ -139,7 +139,7 @@ private void scheduleNoDeviceFoundCheck() { if (!SystemUtils.IS_OS_MAC) { return; } - var checker = new Thread(() -> { + AppThreads.named("pcpanel-mac-permission-check", true, () -> { try { Thread.sleep(10_000); } catch (InterruptedException e) { @@ -150,9 +150,7 @@ private void scheduleNoDeviceFoundCheck() { log.warn("No PCPanel detected. macOS requires the Input Monitoring permission: " + "System Settings > Privacy & Security > Input Monitoring > enable PCPanel, then restart PCPanel"); } - }, "pcpanel-mac-permission-check"); - checker.setDaemon(true); - checker.start(); + }).start(); } private void reconnectDevicesAfterRestart() { diff --git a/src/main/java/com/getpcpanel/integration/homeassistant/HaUrls.java b/src/main/java/com/getpcpanel/integration/homeassistant/HaUrls.java new file mode 100644 index 00000000..ff0ad76c --- /dev/null +++ b/src/main/java/com/getpcpanel/integration/homeassistant/HaUrls.java @@ -0,0 +1,80 @@ +package com.getpcpanel.integration.homeassistant; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Locale; + +import javax.annotation.Nullable; + +import org.apache.commons.lang3.StringUtils; + +/** + * URL checks for the configured Home Assistant servers. Plain HTTP is the norm for local HA + * installs and must not trigger warnings; only sending an access token unencrypted across a + * non-local network is worth flagging. + */ +public final class HaUrls { + private HaUrls() { + } + + /** + * True only when {@code url} uses plain {@code http} and its host is not local. + * Local means: localhost, loopback (127/8, ::1), RFC1918 (10/8, 172.16/12, 192.168/16), + * link-local (169.254/16, fe80::/10), IPv6 unique-local (fc00::/7), hostnames ending in + * {@code .local}/{@code .lan}/{@code .home.arpa}, and single-label hostnames (no dot). + */ + public static boolean isNonLocalPlainHttp(@Nullable String url) { + if (StringUtils.isBlank(url)) { + return false; + } + URI uri; + try { + uri = new URI(url.trim()); + } catch (URISyntaxException e) { + return false; + } + if (!"http".equalsIgnoreCase(uri.getScheme())) { + return false; + } + var host = uri.getHost(); + return host != null && !isLocalHost(host); + } + + private static boolean isLocalHost(String host) { + var h = StringUtils.stripEnd(host.toLowerCase(Locale.ROOT), "."); + if (h.startsWith("[") && h.endsWith("]")) { + h = h.substring(1, h.length() - 1); + } + if (h.contains(":")) { // IPv6 literal + return "::1".equals(h) || h.startsWith("fe80:") || h.startsWith("fc") || h.startsWith("fd"); + } + var ipv4 = parseIpv4(h); + if (ipv4 != null) { + int a = ipv4[0], b = ipv4[1]; + return a == 127 || a == 10 || (a == 172 && b >= 16 && b <= 31) || (a == 192 && b == 168) || (a == 169 && b == 254); + } + return "localhost".equals(h) || h.endsWith(".localhost") + || !h.contains(".") + || h.endsWith(".local") || h.endsWith(".lan") + || "home.arpa".equals(h) || h.endsWith(".home.arpa"); + } + + @Nullable + private static int[] parseIpv4(String host) { + var parts = StringUtils.split(host, '.'); + if (parts == null || parts.length != 4) { + return null; + } + var result = new int[4]; + for (var i = 0; i < 4; i++) { + if (!StringUtils.isNumeric(parts[i]) || parts[i].length() > 3) { + return null; + } + result[i] = Integer.parseInt(parts[i]); + if (result[i] > 255) { + return null; + } + } + return result; + } +} diff --git a/src/main/java/com/getpcpanel/integration/homeassistant/HomeAssistantService.java b/src/main/java/com/getpcpanel/integration/homeassistant/HomeAssistantService.java index f82a6355..19b6a066 100644 --- a/src/main/java/com/getpcpanel/integration/homeassistant/HomeAssistantService.java +++ b/src/main/java/com/getpcpanel/integration/homeassistant/HomeAssistantService.java @@ -2,6 +2,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; import java.util.Map; @@ -36,6 +37,8 @@ public class HomeAssistantService { /** Short timeout for the UI status probe so an unreachable server can't pin a REST worker thread for * the full 10s service-call timeout (and N dead servers don't add up to N*10s). */ private static final java.time.Duration STATUS_PING_TIMEOUT = java.time.Duration.ofSeconds(2); + /** Shown for plain-http servers on non-local hosts; local http installs are the norm and must not warn. */ + private static final String PLAIN_HTTP_WARNING = "Plain HTTP to a non-local host: the access token is sent unencrypted. Use https."; @Inject SaveService saveService; @Inject ObjectMapper objectMapper; @@ -44,6 +47,8 @@ public class HomeAssistantService { // serverId -> client, rebuilt on every save so url/token edits take effect immediately. private final Map clients = new ConcurrentHashMap<>(); private final Map statusCache = new ConcurrentHashMap<>(); + // Server ids already warned about plain http over a non-local network, so the warning logs once per run. + private final Set plainHttpWarned = ConcurrentHashMap.newKeySet(); void onSave(@Observes SaveEvent event) { rebuild(); @@ -85,6 +90,10 @@ public boolean callAction(@Nullable String serverId, String actionYaml) { log.warn("Home Assistant: no server resolved for id '{}' (configured: {})", serverId, clients.size()); return false; } + var server = client.getServer(); + if (HaUrls.isNonLocalPlainHttp(server.url()) && plainHttpWarned.add(server.id())) { + log.warn("Home Assistant server '{}' ({}) uses plain HTTP to a non-local host; the access token is sent unencrypted", server.name(), server.url()); + } var parsed = HaActionYaml.parse(actionYaml); if (parsed == null) { return false; @@ -115,11 +124,16 @@ private int debounceMs() { public List serverStatuses() { var out = new ArrayList(); for (var server : servers()) { - out.add(new HomeAssistantServerStatus(server.id(), server.name(), server.url(), isConnected(server.id()))); + out.add(new HomeAssistantServerStatus(server.id(), server.name(), server.url(), isConnected(server.id()), warningFor(server.url()))); } return out; } + @Nullable + private static String warningFor(@Nullable String url) { + return HaUrls.isNonLocalPlainHttp(url) ? PLAIN_HTTP_WARNING : null; + } + /** True when any configured server is currently reachable. Drives the settings status dot. */ public boolean isAnyConnected() { return servers().stream().anyMatch(s -> isConnected(s.id())); diff --git a/src/main/java/com/getpcpanel/integration/homeassistant/dto/HomeAssistantServerStatus.java b/src/main/java/com/getpcpanel/integration/homeassistant/dto/HomeAssistantServerStatus.java index b02f9cba..62bd81aa 100644 --- a/src/main/java/com/getpcpanel/integration/homeassistant/dto/HomeAssistantServerStatus.java +++ b/src/main/java/com/getpcpanel/integration/homeassistant/dto/HomeAssistantServerStatus.java @@ -4,7 +4,9 @@ /** * Connection state of a configured Home Assistant server, reported to the UI. The {@code url} lets - * the action editor link out to that server's Developer Tools → Actions page. + * the action editor link out to that server's Developer Tools → Actions page. {@code warning} is a + * user-facing configuration warning (currently: plain HTTP to a non-local host), null when there is + * nothing to flag. */ -public record HomeAssistantServerStatus(String id, String name, @Nullable String url, boolean connected) { +public record HomeAssistantServerStatus(String id, String name, @Nullable String url, boolean connected, @Nullable String warning) { } diff --git a/src/main/java/com/getpcpanel/integration/mqtt/MqttService.java b/src/main/java/com/getpcpanel/integration/mqtt/MqttService.java index de0dddd9..61922abf 100644 --- a/src/main/java/com/getpcpanel/integration/mqtt/MqttService.java +++ b/src/main/java/com/getpcpanel/integration/mqtt/MqttService.java @@ -31,6 +31,7 @@ import com.getpcpanel.integration.mqtt.dto.MqttSettings; import com.getpcpanel.util.concurrent.Debouncer; +import jakarta.annotation.PreDestroy; import jakarta.annotation.Priority; import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.event.Event; @@ -216,6 +217,17 @@ private void connect(MqttSettings mqttSettings) { } } + @PreDestroy + void shutdown() { + // A graceful MQTT disconnect suppresses the last will, so publish the availability payload + // (same empty retained message) explicitly; otherwise the broker only marks us offline after + // the keepalive timeout. + if (isConnected()) { + publish(availabilityTopic, null, true, true); + } + disconnect(); + } + private void disconnect() { var client = mqttClient; mqttClient = null; @@ -253,7 +265,14 @@ public void subscribe(String topic, Function converter, Consumer< var cd = props == null ? null : props.getCorrelationData(); var ignore = cd != null && IGNORE_CORRELATION.equals(new String(cd, StandardCharsets.UTF_8)); if (!ignore) { - consumer.accept(converter.apply(publish.getPayload())); + // Catch per message: a throw from here propagates into Paho's delivery thread, where a + // single malformed (possibly retained) message would kill delivery for every subscription. + try { + consumer.accept(converter.apply(publish.getPayload())); + } catch (Exception e) { + log.warn("Failed to handle MQTT message on {}", t, e); + return; + } send(topic, publish.getPayload(), true, false); // Ensure that the message isn't picked up after restart } }; diff --git a/src/main/java/com/getpcpanel/integration/osc/OSCService.java b/src/main/java/com/getpcpanel/integration/osc/OSCService.java index 3b2b370f..2bf53dcd 100644 --- a/src/main/java/com/getpcpanel/integration/osc/OSCService.java +++ b/src/main/java/com/getpcpanel/integration/osc/OSCService.java @@ -2,6 +2,7 @@ import java.io.IOException; import java.net.InetAddress; +import java.net.InetSocketAddress; import java.util.HashSet; import java.util.List; import java.util.Objects; @@ -35,6 +36,8 @@ @Log4j2 @ApplicationScoped public class OSCService { + /** Upper bound on the autocomplete address set, so a chatty/hostile sender cannot grow it unboundedly. */ + private static final int MAX_ADDRESSES = 500; @Inject SaveService saveService; private OSCPortIn portIn; @@ -43,6 +46,7 @@ public class OSCService { private List prevOscConnections; @Getter private boolean listening; @Getter private final Set addresses = new HashSet<>(); + private boolean addressCapLogged; /** Whether OSC is turned on in the user's settings (gates both the listener and the send targets). */ public boolean isEnabled() { @@ -83,7 +87,8 @@ private void initListen() { stopPortIn(); try { - portIn = new OSCPortIn(prevListenPort); + // Bind to loopback only: the OSC listener is the app's only network-facing socket, keep it off the LAN. + portIn = new OSCPortIn(new InetSocketAddress(InetAddress.getLoopbackAddress(), prevListenPort)); portIn.addPacketListener(new OSCPacketListener() { @Override public void handlePacket(OSCPacketEvent event) { @@ -106,7 +111,12 @@ private void readPacket(OSCPacket packet) { bundle.getPackets().forEach(this::readPacket); } else if (packet instanceof OSCMessage message) { if (CharSequence.compare("f", message.getInfo().getArgumentTypeTags()) == 0) { - addresses.add(message.getAddress()); + if (addresses.size() < MAX_ADDRESSES || addresses.contains(message.getAddress())) { + addresses.add(message.getAddress()); + } else if (!addressCapLogged) { + addressCapLogged = true; + log.debug("OSC address autocomplete set reached its cap of {} entries, ignoring further addresses", MAX_ADDRESSES); + } } } } diff --git a/src/main/java/com/getpcpanel/util/app/AppShutdownState.java b/src/main/java/com/getpcpanel/util/app/AppShutdownState.java index cc2e1d99..c5e899bf 100644 --- a/src/main/java/com/getpcpanel/util/app/AppShutdownState.java +++ b/src/main/java/com/getpcpanel/util/app/AppShutdownState.java @@ -26,6 +26,10 @@ void onShutdown(@Observes ShutdownEvent event) { @PostConstruct void registerJvmShutdownHook() { + // Deliberately a raw JVM hook in addition to the ShutdownEvent observer: JVM shutdown hooks run + // concurrently, so readers on other threads (WebSocket send paths) need this flag set as soon as + // any exit path starts — including exits where Quarkus's own shutdown sequence is delayed or + // never reaches the CDI observer. It only flips an AtomicBoolean, so it is safe at any point. Runtime.getRuntime().addShutdownHook(new Thread(() -> SHUTTING_DOWN.set(true), "AppShutdownState shutdown hook")); } } diff --git a/src/main/java/com/getpcpanel/util/concurrent/AppThreads.java b/src/main/java/com/getpcpanel/util/concurrent/AppThreads.java new file mode 100644 index 00000000..5fc91952 --- /dev/null +++ b/src/main/java/com/getpcpanel/util/concurrent/AppThreads.java @@ -0,0 +1,33 @@ +package com.getpcpanel.util.concurrent; + +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.atomic.AtomicInteger; + +import lombok.extern.log4j.Log4j2; + +/** + * Central factory for the app's ad-hoc threads: every thread gets a name, an explicit daemon flag, + * and an uncaught-exception handler that logs instead of dying silently to stderr. + */ +@Log4j2 +public final class AppThreads { + private AppThreads() { + } + + /** Creates (but does not start) a thread with the given name and daemon flag. */ + public static Thread named(String name, boolean daemon, Runnable r) { + var thread = new Thread(r, name); + thread.setDaemon(daemon); + thread.setUncaughtExceptionHandler((t, e) -> log.error("Uncaught exception in thread {}", t.getName(), e)); + return thread; + } + + /** {@link ThreadFactory} variant for executors; threads are named {@code name}, {@code name-2}, ... */ + public static ThreadFactory factory(String name, boolean daemon) { + var counter = new AtomicInteger(); + return r -> { + var n = counter.incrementAndGet(); + return named(n == 1 ? name : name + '-' + n, daemon, r); + }; + } +} diff --git a/src/main/java/com/getpcpanel/util/concurrent/Debouncer.java b/src/main/java/com/getpcpanel/util/concurrent/Debouncer.java index 57890902..492c21df 100644 --- a/src/main/java/com/getpcpanel/util/concurrent/Debouncer.java +++ b/src/main/java/com/getpcpanel/util/concurrent/Debouncer.java @@ -6,6 +6,7 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; +import java.util.function.LongSupplier; import jakarta.annotation.PreDestroy; import jakarta.enterprise.context.ApplicationScoped; @@ -16,14 +17,28 @@ *

Previously implemented with RxJava ({@code PublishSubject} + {@code debounce}/{@code throttleLatest}), * which pulled in the whole RxJava runtime and its computation thread pool. This plain-Java version * keeps the same semantics with one daemon thread and no extra dependencies. + * + *

The scheduler and the nano-time clock are injectable (package-private constructor) so tests can + * drive time deterministically; the CDI/no-arg path uses the real single-thread scheduler and + * {@link System#nanoTime()}. */ @ApplicationScoped public class Debouncer { - private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> { - var t = new Thread(r, "Debouncer"); - t.setDaemon(true); - return t; - }); + private final ScheduledExecutorService scheduler; + private final LongSupplier nanoTime; + + public Debouncer() { + this(Executors.newSingleThreadScheduledExecutor(r -> { + var t = new Thread(r, "Debouncer"); + t.setDaemon(true); + return t; + }), System::nanoTime); + } + + Debouncer(ScheduledExecutorService scheduler, LongSupplier nanoTime) { + this.scheduler = scheduler; + this.nanoTime = nanoTime; + } private final Map> debounces = new ConcurrentHashMap<>(); private final Map throttles = new ConcurrentHashMap<>(); @@ -67,7 +82,7 @@ public void throttleLeading(Object key, Runnable runnable, long delay, TimeUnit var windowNanos = unit.toNanos(delay); Runnable leading = null; synchronized (throttle) { - var now = System.nanoTime(); + var now = nanoTime.getAsLong(); if (!throttle.scheduled && (!throttle.hasRun || now - throttle.lastRunNanos >= windowNanos)) { throttle.hasRun = true; throttle.lastRunNanos = now; @@ -105,7 +120,7 @@ private void flushLeading(Throttle throttle) { throttle.latest = null; throttle.scheduled = false; if (toRun != null) { - throttle.lastRunNanos = System.nanoTime(); + throttle.lastRunNanos = nanoTime.getAsLong(); } } if (toRun != null) { diff --git a/src/main/java/com/getpcpanel/util/io/FileChecker.java b/src/main/java/com/getpcpanel/util/io/FileChecker.java index 8722919d..8347c148 100644 --- a/src/main/java/com/getpcpanel/util/io/FileChecker.java +++ b/src/main/java/com/getpcpanel/util/io/FileChecker.java @@ -2,6 +2,7 @@ import com.getpcpanel.util.app.ShowMainEvent; import com.getpcpanel.util.app.AppEvents; +import com.getpcpanel.util.concurrent.AppThreads; import java.io.File; import java.io.IOException; @@ -16,7 +17,7 @@ import lombok.extern.log4j.Log4j2; @Log4j2 -public class FileChecker extends Thread { +public class FileChecker implements Runnable { private static final AtomicBoolean started = new AtomicBoolean(false); @SuppressWarnings("FieldCanBeLocal") // If this field is local then the lock will be released. private RandomAccessFile randomFile; @@ -58,17 +59,17 @@ public static void createAndStart() { } catch (IOException e) { log.warn("Unable to determine if the application is already running, pretending it isn't.", e); } - result.start(); + AppThreads.named("File Checker Thread", true, result).start(); } public FileChecker() { - super("File Checker Thread"); - setDaemon(true); if (!reopenFile().delete()) { log.trace("Unable to delete {}", reopenFile()); } - Runtime.getRuntime().addShutdownHook(new Thread(() -> started.set(false), "FileChecker shutdown hook")); + // Raw JVM hook on purpose: FileChecker is created from Main before CDI starts, so it cannot + // observe the Quarkus ShutdownEvent. The hook only clears the started flag. + Runtime.getRuntime().addShutdownHook(AppThreads.named("FileChecker shutdown hook", false, () -> started.set(false))); } private boolean isDuplicate() throws IOException { diff --git a/src/main/java/com/getpcpanel/util/tray/win/TrayServiceWin.java b/src/main/java/com/getpcpanel/util/tray/win/TrayServiceWin.java index 38cc5f65..1848e3be 100644 --- a/src/main/java/com/getpcpanel/util/tray/win/TrayServiceWin.java +++ b/src/main/java/com/getpcpanel/util/tray/win/TrayServiceWin.java @@ -9,6 +9,7 @@ import com.getpcpanel.util.io.FileUtil; import com.getpcpanel.util.app.OpenFolderEvent; import com.getpcpanel.util.app.ShowMainEvent; +import com.getpcpanel.util.concurrent.AppThreads; import com.getpcpanel.util.tray.ITrayService; import com.getpcpanel.util.tray.awt.AwtTrayImpl; import com.sun.jna.Memory; @@ -28,6 +29,7 @@ import com.sun.jna.platform.win32.WinUser.WNDCLASSEX; import com.sun.jna.platform.win32.WinUser.WindowProc; +import jakarta.annotation.PreDestroy; import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.event.Event; import jakarta.inject.Inject; @@ -76,9 +78,15 @@ public class TrayServiceWin implements ITrayService, WindowProc { @Override public void init() { - var thread = new Thread(this::run, "PCPanel Tray"); - thread.setDaemon(true); - thread.start(); + AppThreads.named("PCPanel Tray", true, this::run).start(); + } + + @PreDestroy + void removeIcon() { + var icon = nid; + if (icon != null) { + WinShell32.INSTANCE.Shell_NotifyIcon(WinShell32.NIM_DELETE, icon); + } } private void run() { @@ -125,8 +133,6 @@ private void runLoop() { log.warn("Shell_NotifyIcon(NIM_ADD) failed (error {})", Kernel32.INSTANCE.GetLastError()); return; } - Runtime.getRuntime().addShutdownHook(new Thread( - () -> WinShell32.INSTANCE.Shell_NotifyIcon(WinShell32.NIM_DELETE, nid), "PCPanel Tray cleanup")); log.debug("Windows tray icon added"); var msg = new MSG(); diff --git a/src/main/webui/src/app/models/generated/backend.types.ts b/src/main/webui/src/app/models/generated/backend.types.ts index 44ceee61..5a4a9c03 100644 --- a/src/main/webui/src/app/models/generated/backend.types.ts +++ b/src/main/webui/src/app/models/generated/backend.types.ts @@ -581,6 +581,7 @@ export interface HomeAssistantServerStatus { id: string; name: string; url?: string; + warning?: string; } export interface HomeAssistantSettings { diff --git a/src/test/java/com/getpcpanel/commands/CommandDispatcherTest.java b/src/test/java/com/getpcpanel/commands/CommandDispatcherTest.java new file mode 100644 index 00000000..2d0b91c2 --- /dev/null +++ b/src/test/java/com/getpcpanel/commands/CommandDispatcherTest.java @@ -0,0 +1,178 @@ +package com.getpcpanel.commands; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import javax.annotation.Nullable; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.getpcpanel.commands.PCPanelControlEvent.Source; +import com.getpcpanel.commands.command.ButtonAction; +import com.getpcpanel.commands.command.Command; +import com.getpcpanel.commands.command.DialAction; +import com.getpcpanel.profile.dto.KnobSetting; + +/** + * Routing behaviour of {@link CommandDispatcher} and the {@link Command#toRunnable} action-selection + * contract it relies on: + *

    + *
  • a control event's configured command executes on the handler thread;
  • + *
  • the coalescing map keys on serial+source+knob, so a button's press and release never + * overwrite each other while a burst of dial events for one knob collapses to the latest;
  • + *
  • a command that throws does not take down the handler — later commands still run;
  • + *
  • a command that is both a {@link DialAction} and a {@link ButtonAction} runs as a dial when a + * dial value is present and as a button otherwise;
  • + *
  • a {@code sequential} {@link Commands} advances one command per event, wrapping around.
  • + *
+ */ +@DisplayName("CommandDispatcher routing") +class CommandDispatcherTest { + @Test + @DisplayName("a control event's configured command executes on the handler thread") + void eventExecutesConfiguredCommand() throws Exception { + var dispatcher = startedDispatcher(); + var latch = new CountDownLatch(1); + + dispatcher.onCommand(event("serial", 1, Source.PRESS, null, new ButtonCommand(latch::countDown))); + + assertTrue(latch.await(2, TimeUnit.SECONDS), "the command must run on the handler thread"); + } + + @Test + @DisplayName("press and release of the same knob keep separate map entries; dial events coalesce to the latest") + void coalescingKeys() { + var dispatcher = dispatcher(); // handler not started: the map contents stay observable + + dispatcher.onCommand(event("serial", 1, Source.PRESS, null, new ButtonCommand(() -> { + }))); + dispatcher.onCommand(event("serial", 1, Source.RELEASE, null, new ButtonCommand(() -> { + }))); + assertEquals(2, dispatcher.map.size(), "a quick tap must not lose the press to the release"); + + var ran = new ArrayList(); + dispatcher.onCommand(event("serial", 2, Source.DIAL, dial(10), new ButtonCommand(() -> ran.add("older")))); + dispatcher.onCommand(event("serial", 2, Source.DIAL, dial(20), new ButtonCommand(() -> ran.add("newest")))); + assertEquals(3, dispatcher.map.size(), "dial events for one knob coalesce into a single entry"); + + dispatcher.map.get("serial|DIAL|2").run(); + assertEquals(List.of("newest"), ran, "the coalesced entry holds the most recent event's command"); + } + + @Test + @DisplayName("a throwing command does not stop the handler; later commands still run") + void throwingCommandIsIsolated() throws Exception { + var dispatcher = startedDispatcher(); + var latch = new CountDownLatch(1); + + dispatcher.onCommand(event("serial", 1, Source.PRESS, null, new ButtonCommand(() -> { + throw new IllegalStateException("boom"); + }))); + dispatcher.onCommand(event("serial", 2, Source.PRESS, null, new ButtonCommand(latch::countDown))); + + assertTrue(latch.await(2, TimeUnit.SECONDS), "the handler must survive a throwing command"); + } + + @Test + @DisplayName("a dual dial+button command runs as a dial when a dial value is present, as a button otherwise") + void toRunnablePrefersDialActionWithDialValue() { + var command = new DualCommand(); + + command.toRunnable(false, "serial", dial(42)).run(); + assertTrue(command.dialExecuted, "a present dial value selects the DialAction path"); + assertFalse(command.buttonExecuted, "the ButtonAction path must not also run"); + + command.dialExecuted = false; + command.toRunnable(false, "serial", null).run(); + assertTrue(command.buttonExecuted, "without a dial value the command runs as a button"); + assertFalse(command.dialExecuted); + } + + @Test + @DisplayName("a sequential Commands runs one command per event, wrapping around") + void sequentialCommandsCycle() { + var ran = new ArrayList(); + var commands = new Commands(List.of(new ButtonCommand(() -> ran.add("first")), new ButtonCommand(() -> ran.add("second"))), CommandsType.sequential); + var event = new PCPanelControlEvent("serial", 1, commands, false, null, Source.PRESS); + + event.buildRunnable().run(); + event.buildRunnable().run(); + event.buildRunnable().run(); + + assertEquals(List.of("first", "second", "first"), ran); + } + + /** Handler threads are daemons, so instances left running do not keep the test JVM alive. */ + private static CommandDispatcher startedDispatcher() { + var dispatcher = dispatcher(); + dispatcher.init(); + return dispatcher; + } + + private static CommandDispatcher dispatcher() { + try { + var ctor = CommandDispatcher.class.getDeclaredConstructor(); + ctor.setAccessible(true); + return ctor.newInstance(); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("Unable to construct CommandDispatcher", e); + } + } + + private static PCPanelControlEvent event(String serial, int knob, Source source, @Nullable DialValue vol, Command command) { + return new PCPanelControlEvent(serial, knob, new Commands(List.of(command), CommandsType.allAtOnce), false, vol, source); + } + + private static DialValue dial(int value) { + return new DialValue((KnobSetting) null, value); + } + + private static final class ButtonCommand extends Command implements ButtonAction { + private final Runnable onExecute; + + private ButtonCommand(Runnable onExecute) { + this.onExecute = onExecute; + } + + @Override public void execute() { + onExecute.run(); + } + + @Override public String buildLabel() { + return "button stub"; + } + } + + private static final class DualCommand extends Command implements DialAction, ButtonAction { + private boolean dialExecuted; + private boolean buttonExecuted; + + @Override public void execute(DialActionParameters context) { + dialExecuted = true; + } + + @Override public void execute() { + buttonExecuted = true; + } + + @Override public @Nullable DialCommandParams getDialParams() { + return null; + } + + @Override public boolean hasOverlay() { + // DialAction and ButtonAction both declare a default; a dual-role command must pick one. + return false; + } + + @Override public String buildLabel() { + return "dual stub"; + } + } +} diff --git a/src/test/java/com/getpcpanel/commands/CommandMapperTestFactory.java b/src/test/java/com/getpcpanel/commands/CommandMapperTestFactory.java new file mode 100644 index 00000000..d5c5e860 --- /dev/null +++ b/src/test/java/com/getpcpanel/commands/CommandMapperTestFactory.java @@ -0,0 +1,26 @@ +package com.getpcpanel.commands; + +import java.util.List; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; + +/** + * Builds an {@link ObjectMapper} wired for the command polymorphism exactly as the app does, for + * command-family tests that live outside the {@code com.getpcpanel.commands} package. It supplies + * the given {@link CommandModule}s to a {@link CommandSubtypeRegistrar} and applies its + * customization — the same path the Quarkus {@code @All} injection drives at runtime — so those + * tests do not need access to the registrar's package-private module list. + */ +public final class CommandMapperTestFactory { + private CommandMapperTestFactory() { + } + + public static ObjectMapper mapperFor(CommandModule... modules) { + var mapper = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + var registrar = new CommandSubtypeRegistrar(); + registrar.modules = List.of(modules); + registrar.customize(mapper); + return mapper; + } +} diff --git a/src/test/java/com/getpcpanel/graalvm/ReflectionRegistrationCoverageTest.java b/src/test/java/com/getpcpanel/graalvm/ReflectionRegistrationCoverageTest.java index ba1af705..f22f51b9 100644 --- a/src/test/java/com/getpcpanel/graalvm/ReflectionRegistrationCoverageTest.java +++ b/src/test/java/com/getpcpanel/graalvm/ReflectionRegistrationCoverageTest.java @@ -29,8 +29,10 @@ * Discovery guard: finds missing native-image reflection registrations for the Jackson * {@link Command} hierarchy without being told which to look for. * - *

Every {@link Command} subtype is serialised to/from JSON via {@code @JsonTypeInfo(use=ID.CLASS)} - * (the per-control command maps in the profile, and the device snapshots pushed over the WebSocket). + *

Every {@link Command} subtype is serialised to/from JSON via {@code @JsonTypeInfo(use=Id.NAME)} + * with an explicit subtype allowlist — each command package contributes a {@code CommandModule} bean, + * and {@code CommandSubtypeRegistrar} registers the listed subclasses on the ObjectMapper (the + * per-control command maps in the profile, and the device snapshots pushed over the WebSocket). * In a native image Jackson invokes each type's accessors reflectively, so every concrete subtype — * and every project type it transitively serialises — must be registered for reflection or the first * serialisation throws {@code MissingReflectionRegistrationError}, which kills the WebSocket diff --git a/src/test/java/com/getpcpanel/integration/homeassistant/HomeAssistantClientTest.java b/src/test/java/com/getpcpanel/integration/homeassistant/HomeAssistantClientTest.java new file mode 100644 index 00000000..015159d8 --- /dev/null +++ b/src/test/java/com/getpcpanel/integration/homeassistant/HomeAssistantClientTest.java @@ -0,0 +1,155 @@ +package com.getpcpanel.integration.homeassistant; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.getpcpanel.integration.homeassistant.dto.HomeAssistantServer; +import com.sun.net.httpserver.HttpServer; + +/** + * Exercises {@link HomeAssistantClient} against a stub HTTP server (a JDK {@code HttpServer} on a + * random port): the request shape of {@code ping} and {@code callService} (path, method, bearer + * header, JSON body), the trailing-slash trimming of the base url, and the failure results for an + * unauthorized, unreachable, or unconfigured server. {@code callService} is fire-and-forget, so the + * stub captures each request and the test awaits it with a deadline poll rather than sleeping. + * + *

The suite uses the {@code per_class} test-instance lifecycle, so all mutable state is reset in + * {@code @BeforeEach} — it would otherwise leak between test methods. + */ +@DisplayName("HomeAssistantClient HTTP behaviour") +class HomeAssistantClientTest { + private final ObjectMapper mapper = new ObjectMapper(); + private final ConcurrentLinkedQueue requests = new ConcurrentLinkedQueue<>(); + private HttpServer server; + private volatile int status; + + private record CapturedRequest(String method, String path, String authorization, String contentType, String body) { + } + + @BeforeEach + void startServer() throws IOException { + status = 200; + requests.clear(); + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", exchange -> { + var body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + requests.add(new CapturedRequest( + exchange.getRequestMethod(), + exchange.getRequestURI().getPath(), + exchange.getRequestHeaders().getFirst("Authorization"), + exchange.getRequestHeaders().getFirst("Content-Type"), + body)); + exchange.sendResponseHeaders(status, 0); + exchange.close(); + }); + server.start(); + } + + @AfterEach + void stopServer() { + server.stop(0); + } + + private String baseUrl() { + return "http://127.0.0.1:" + server.getAddress().getPort(); + } + + private HomeAssistantClient client(String url, String token) { + return new HomeAssistantClient(new HomeAssistantServer("id1", "test", url, token), mapper); + } + + /** Waits for the stub to have captured a request (callService responds before the send completes). */ + private CapturedRequest awaitRequest() throws InterruptedException { + var deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); + while (requests.isEmpty() && System.nanoTime() < deadline) { + TimeUnit.MILLISECONDS.sleep(10); + } + var req = requests.poll(); + assertNotNull(req, "expected the request to reach the stub server"); + return req; + } + + @Test + @DisplayName("ping GETs /api/ with the bearer token and succeeds on 200") + void pingSuccess() { + assertTrue(client(baseUrl(), "secret").ping()); + + var req = requests.poll(); + assertEquals("GET", req.method()); + assertEquals("/api/", req.path()); + assertEquals("Bearer secret", req.authorization()); + } + + @Test + @DisplayName("trailing slashes on the base url are trimmed, so the path stays /api/") + void trailingSlashesTrimmed() { + assertTrue(client(baseUrl() + "///", "secret").ping()); + assertEquals("/api/", requests.poll().path()); + } + + @Test + @DisplayName("ping fails on 401, on an unreachable server, and on a blank url") + void pingFailures() { + status = 401; + assertFalse(client(baseUrl(), "wrong").ping()); + + var deadUrl = baseUrl(); + server.stop(0); + assertFalse(client(deadUrl, "secret").ping()); + + assertFalse(client("", "secret").ping()); + } + + @Test + @DisplayName("callService POSTs the data as JSON to /api/services/{domain}/{service}") + void callServicePostsJson() throws Exception { + var ok = client(baseUrl(), "secret").callService("light", "turn_on", Map.of("entity_id", "light.living_room")); + assertTrue(ok); + + var req = awaitRequest(); + assertEquals("POST", req.method()); + assertEquals("/api/services/light/turn_on", req.path()); + assertEquals("Bearer secret", req.authorization()); + assertEquals("application/json", req.contentType()); + assertEquals(Map.of("entity_id", "light.living_room"), mapper.readValue(req.body(), Map.class)); + } + + @Test + @DisplayName("callService with null data sends an empty JSON object") + void callServiceNullData() throws Exception { + assertTrue(client(baseUrl(), "secret").callService("switch", "toggle", null)); + assertEquals("{}", awaitRequest().body()); + } + + @Test + @DisplayName("a null token sends an empty bearer value instead of throwing") + void nullTokenPings() { + assertTrue(client(baseUrl(), null).ping()); + // The JDK client trims the trailing space of the empty "Bearer " value in transit. + assertEquals("Bearer", requests.poll().authorization()); + } + + @Test + @DisplayName("getServer returns the configured server") + void exposesServer() { + var haServer = new HomeAssistantServer("id1", "test", baseUrl(), "secret"); + assertEquals(haServer, new HomeAssistantClient(haServer, mapper).getServer()); + assertNull(requests.poll(), "construction alone must not touch the server"); + } +} diff --git a/src/test/java/com/getpcpanel/integration/homeassistant/HomeAssistantValueMappingTest.java b/src/test/java/com/getpcpanel/integration/homeassistant/HomeAssistantValueMappingTest.java new file mode 100644 index 00000000..4e52e7d2 --- /dev/null +++ b/src/test/java/com/getpcpanel/integration/homeassistant/HomeAssistantValueMappingTest.java @@ -0,0 +1,73 @@ +package com.getpcpanel.integration.homeassistant; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.getpcpanel.util.ValueInterpolator; + +/** + * The dial half of {@link com.getpcpanel.integration.homeassistant.command.CommandHomeAssistantValue}: + * the normalised 0..1 position is translated to a number ({@link ValueInterpolator#translate} — linear + * min/max or an exp4j formula with variable {@code x}) and substituted for the {@code {{ value }}} + * token in the pasted action YAML before it is parsed and sent. The tests run that exact pipeline + * (translate → interpolate → {@link HaActionYaml#parse}) without a server. + */ +@DisplayName("Home Assistant dial value mapping") +class HomeAssistantValueMappingTest { + @Test + @DisplayName("linear min/max: x=0.5 between 10 and 30 is 20") + void linearMinMax() { + assertEquals(20d, ValueInterpolator.translate(0.5, 10d, 30d, null)); + assertEquals(10d, ValueInterpolator.translate(0, 10d, 30d, null)); + assertEquals(30d, ValueInterpolator.translate(1, 10d, 30d, null)); + } + + @Test + @DisplayName("min/max default to 0..100 when unset") + void linearDefaults() { + assertEquals(0d, ValueInterpolator.translate(0, null, null, null)); + assertEquals(50d, ValueInterpolator.translate(0.5, null, null, null)); + assertEquals(100d, ValueInterpolator.translate(1, null, null, null)); + } + + @Test + @DisplayName("an exp4j formula with variable x overrides the linear mapping") + void formulaOverridesLinear() { + assertEquals(127.5, ValueInterpolator.translate(0.5, 0d, 100d, "x * 255")); + assertEquals(127d, ValueInterpolator.translate(0.5, null, null, "floor(x * 255)")); + } + + @Test + @DisplayName("a broken formula falls back to the linear mapping instead of dropping the action") + void brokenFormulaFallsBack() { + assertEquals(20d, ValueInterpolator.translate(0.5, 10d, 30d, "x *")); + } + + @Test + @DisplayName("{{ value }} is substituted with whole numbers rendered as integers") + void tokenSubstitution() { + assertEquals("brightness: 64", ValueInterpolator.interpolate("brightness: {{ value }}", 64)); + assertEquals("brightness: 63.75", ValueInterpolator.interpolate("brightness: {{ value }}", 63.75)); + assertEquals("a: 5, b: 5", ValueInterpolator.interpolate("a: {{value}}, b: {{ value }}", 5)); + } + + @Test + @DisplayName("end-to-end: dial position → formula → YAML token → parsed service call body") + void endToEnd() { + var action = """ + action: light.turn_on + target: + entity_id: light.living_room + data: + brightness: {{ value }} + """; + var yaml = ValueInterpolator.interpolate(action, ValueInterpolator.translate(0.25, null, null, "floor(x * 255)")); + var parsed = HaActionYaml.parse(yaml); + assertEquals("light", parsed.domain()); + assertEquals("turn_on", parsed.service()); + assertEquals(63, parsed.data().get("brightness"), "the whole number must arrive as an integer, not 63.0"); + assertEquals("light.living_room", parsed.data().get("entity_id")); + } +} diff --git a/src/test/java/com/getpcpanel/integration/obs/command/ObsCommandTest.java b/src/test/java/com/getpcpanel/integration/obs/command/ObsCommandTest.java new file mode 100644 index 00000000..cb860deb --- /dev/null +++ b/src/test/java/com/getpcpanel/integration/obs/command/ObsCommandTest.java @@ -0,0 +1,95 @@ +package com.getpcpanel.integration.obs.command; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.getpcpanel.commands.CommandMapperTestFactory; +import com.getpcpanel.commands.command.Command; +import com.getpcpanel.commands.command.DialAction.DialCommandParams; +import com.getpcpanel.integration.obs.command.CommandObsAction.ObsActionType; +import com.getpcpanel.integration.volume.platform.MuteType; + +/** + * The OBS command family: every subtype registered by {@link ObsCommandModule} survives a JSON + * round-trip through the polymorphic mapper (fields intact, nice {@code _type} id, legacy FQCN ids + * still loading), labels render from the configured fields, and each {@link ObsActionType} maps to + * its OBS WebSocket request name. + */ +@DisplayName("OBS commands: JSON round-trip + pure logic") +class ObsCommandTest { + private final ObjectMapper mapper = CommandMapperTestFactory.mapperFor(new ObsCommandModule()); + + private Command roundTrip(Command command) throws Exception { + return mapper.readValue(mapper.writeValueAsString(command), Command.class); + } + + @Test + @DisplayName("obs.action round-trips with its action type") + void actionRoundTrip() throws Exception { + var json = mapper.writeValueAsString(new CommandObsAction(ObsActionType.TOGGLE_STREAM)); + assertTrue(json.contains("\"obs.action\""), () -> "expected the nice id in: " + json); + + var loaded = assertInstanceOf(CommandObsAction.class, mapper.readValue(json, Command.class)); + assertEquals(ObsActionType.TOGGLE_STREAM, loaded.getAction()); + assertEquals("OBS: Toggle streaming", loaded.buildLabel()); + } + + @Test + @DisplayName("obs.mute-source round-trips with source + mute type") + void muteSourceRoundTrip() throws Exception { + var loaded = assertInstanceOf(CommandObsMuteSource.class, roundTrip(new CommandObsMuteSource("Mic/Aux", MuteType.toggle))); + assertEquals("Mic/Aux", loaded.getSource()); + assertEquals(MuteType.toggle, loaded.getType()); + assertEquals("Mute source: Mic/Aux (toggle)", loaded.buildLabel()); + } + + @Test + @DisplayName("obs.set-scene round-trips with the scene name") + void setSceneRoundTrip() throws Exception { + var loaded = assertInstanceOf(CommandObsSetScene.class, roundTrip(new CommandObsSetScene("Gaming"))); + assertEquals("Gaming", loaded.getScene()); + assertEquals("Set scene: Gaming", loaded.buildLabel()); + } + + @Test + @DisplayName("obs.set-source-volume round-trips with source name + dial params") + void setSourceVolumeRoundTrip() throws Exception { + var loaded = assertInstanceOf(CommandObsSetSourceVolume.class, + roundTrip(new CommandObsSetSourceVolume("Desktop Audio", new DialCommandParams(true, 10, 20)))); + assertEquals("Desktop Audio", loaded.getSourceName()); + assertEquals(new DialCommandParams(true, 10, 20), loaded.getDialParams()); + assertEquals("Source volume: Desktop Audio", loaded.buildLabel()); + } + + @Test + @DisplayName("a legacy FQCN _type id from an old save still loads") + void legacyIdLoads() throws Exception { + var loaded = mapper.readValue("{\"_type\":\"com.getpcpanel.commands.command.CommandObsSetScene\",\"scene\":\"Intro\"}", Command.class); + assertInstanceOf(CommandObsSetScene.class, loaded); + assertEquals("Intro", ((CommandObsSetScene) loaded).getScene()); + } + + @Test + @DisplayName("every ObsActionType carries its OBS WebSocket request name") + void actionTypeRequestNames() { + assertEquals("StartStream", ObsActionType.START_STREAM.getRequestType()); + assertEquals("StopRecord", ObsActionType.STOP_RECORD.getRequestType()); + assertEquals("ToggleRecordPause", ObsActionType.TOGGLE_RECORD_PAUSE.getRequestType()); + assertEquals("ToggleVirtualCam", ObsActionType.TOGGLE_VIRTUAL_CAM.getRequestType()); + assertEquals("SaveReplayBuffer", ObsActionType.SAVE_REPLAY_BUFFER.getRequestType()); + } + + @Test + @DisplayName("a null action renders an empty-suffix label and executes as a no-op") + void nullActionIsSafe() { + var command = new CommandObsAction(null); + assertEquals("OBS: ", command.buildLabel()); + // No OBS bean is registered in this test, so reaching for the container would throw. + command.execute(); + } +} diff --git a/src/test/java/com/getpcpanel/integration/osc/command/CommandOscSendTest.java b/src/test/java/com/getpcpanel/integration/osc/command/CommandOscSendTest.java new file mode 100644 index 00000000..80397fbb --- /dev/null +++ b/src/test/java/com/getpcpanel/integration/osc/command/CommandOscSendTest.java @@ -0,0 +1,128 @@ +package com.getpcpanel.integration.osc.command; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.getpcpanel.commands.CommandMapperTestFactory; +import com.getpcpanel.commands.DialValue; +import com.getpcpanel.commands.command.Command; +import com.getpcpanel.commands.command.DialAction.DialActionParameters; +import com.getpcpanel.commands.command.DialAction.DialCommandParams; +import com.getpcpanel.integration.osc.OSCService; +import com.getpcpanel.integration.testutil.FakeCdi; +import com.getpcpanel.profile.dto.KnobSetting; + +/** + * {@link CommandOscSend}: JSON round-trip through the polymorphic mapper, and the value pipeline — + * the 0..1 dial position maps through min/max or the exp4j formula and reaches + * {@link OSCService#send} as a float, while a button press resolves at full scale. The service is a + * hand-written recording stub served through {@link FakeCdi}. + */ +@DisplayName("CommandOscSend: JSON round-trip + value mapping") +class CommandOscSendTest { + private final ObjectMapper mapper = CommandMapperTestFactory.mapperFor(new OscCommandModule()); + private final RecordingOscService osc = new RecordingOscService(); + + @BeforeEach + void setUp() { + // per_class lifecycle (junit-platform.properties) shares one test instance, so the recording + // service is reused across methods — reset it and re-register before each test. + osc.reset(); + FakeCdi.register(OSCService.class, osc); + } + + @AfterEach + void tearDown() { + FakeCdi.clear(); + } + + private static final class RecordingOscService extends OSCService { + private final List addresses = new ArrayList<>(); + private final List values = new ArrayList<>(); + + @Override + public void send(String address, float value) { + addresses.add(address); + values.add(value); + } + + void reset() { + addresses.clear(); + values.clear(); + } + } + + private static DialActionParameters dialAt(int raw) { + return new DialActionParameters("device", false, new DialValue((KnobSetting) null, raw)); + } + + @Test + @DisplayName("osc.send round-trips with address, min/max, formula and dial params") + void roundTrip() throws Exception { + var json = mapper.writeValueAsString(new CommandOscSend("/mixer/ch1", 0.0, 1.0, "x*x", new DialCommandParams(true, null, null))); + assertTrue(json.contains("\"osc.send\""), () -> "expected the nice id in: " + json); + + var loaded = assertInstanceOf(CommandOscSend.class, mapper.readValue(json, Command.class)); + assertEquals("/mixer/ch1", loaded.getAddress()); + assertEquals(0.0, loaded.getMin()); + assertEquals(1.0, loaded.getMax()); + assertEquals("x*x", loaded.getFormula()); + assertEquals(new DialCommandParams(true, null, null), loaded.getDialParams()); + assertEquals("OSC: /mixer/ch1", loaded.buildLabel()); + } + + @Test + @DisplayName("nullable fields stay null through a round-trip") + void roundTripWithNulls() throws Exception { + var loaded = assertInstanceOf(CommandOscSend.class, + mapper.readValue(mapper.writeValueAsString(new CommandOscSend("/a", null, null, null, null)), Command.class)); + assertNull(loaded.getMin()); + assertNull(loaded.getMax()); + assertNull(loaded.getFormula()); + assertNull(loaded.getDialParams()); + } + + @Test + @DisplayName("a legacy FQCN _type id from an old save still loads") + void legacyIdLoads() throws Exception { + var loaded = mapper.readValue("{\"_type\":\"com.getpcpanel.commands.command.CommandOscSend\",\"address\":\"/x\"}", Command.class); + assertEquals("/x", assertInstanceOf(CommandOscSend.class, loaded).getAddress()); + } + + @Test + @DisplayName("the dial position maps linearly between min and max") + void dialMapsLinearly() { + var command = new CommandOscSend("/mixer/ch1", 0.0, 255.0, null, null); + command.execute(dialAt(255)); + command.execute(dialAt(0)); + assertEquals(List.of("/mixer/ch1", "/mixer/ch1"), osc.addresses); + assertEquals(List.of(255f, 0f), osc.values); + } + + @Test + @DisplayName("a formula maps the position through exp4j (variable x)") + void dialMapsThroughFormula() { + var command = new CommandOscSend("/fx", null, null, "x*10+1", null); + command.execute(dialAt(255)); + assertEquals(List.of(11f), osc.values); + } + + @Test + @DisplayName("a button press sends at full scale (x = 1)") + void buttonSendsFullScale() { + var command = new CommandOscSend("/go", 0.0, 5.0, null, null); + command.execute(); + assertEquals(List.of(5f), osc.values); + } +} diff --git a/src/test/java/com/getpcpanel/integration/testutil/FakeCdi.java b/src/test/java/com/getpcpanel/integration/testutil/FakeCdi.java new file mode 100644 index 00000000..b5b1013d --- /dev/null +++ b/src/test/java/com/getpcpanel/integration/testutil/FakeCdi.java @@ -0,0 +1,148 @@ +package com.getpcpanel.integration.testutil; + +import java.lang.annotation.Annotation; +import java.util.Iterator; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import jakarta.enterprise.inject.spi.BeanManager; +import jakarta.enterprise.inject.spi.CDI; +import jakarta.enterprise.inject.Instance; +import jakarta.enterprise.util.TypeLiteral; + +/** + * Minimal {@link CDI} provider for plain unit tests: commands resolve their collaborators through + * {@code CdiHelper.getBean} → {@code CDI.current()}, so registering a hand-written stub here lets a + * command's real {@code execute} path run without a container. Only {@code select(Class)} + + * {@code get()} are implemented — exactly what {@code CdiHelper} uses. + * + *

The provider installs globally for the JVM (there is no unset API), which is harmless: nothing + * else in the test JVM calls {@code CDI.current()}. Tests should still {@link #clear()} their beans + * afterwards so stubs cannot leak between test classes. + */ +public final class FakeCdi extends CDI { + private static final FakeCdi CURRENT = new FakeCdi(); + private static final Map, Object> beans = new ConcurrentHashMap<>(); + + private FakeCdi() { + } + + /** Installs the fake provider (idempotent) and serves {@code bean} for lookups of {@code type}. */ + public static void register(Class type, T bean) { + CDI.setCDIProvider(() -> CURRENT); + beans.put(type, bean); + } + + public static void clear() { + beans.clear(); + } + + @Override + public Instance select(Class subtype, Annotation... qualifiers) { + return new StubInstance<>(subtype); + } + + @Override + public BeanManager getBeanManager() { + throw new UnsupportedOperationException(); + } + + @Override + public Instance select(Annotation... qualifiers) { + throw new UnsupportedOperationException(); + } + + @Override + public Instance select(TypeLiteral subtype, Annotation... qualifiers) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean isUnsatisfied() { + return false; + } + + @Override + public boolean isAmbiguous() { + return false; + } + + @Override + public void destroy(Object instance) { + } + + @Override + public Handle getHandle() { + throw new UnsupportedOperationException(); + } + + @Override + public Iterable> handles() { + throw new UnsupportedOperationException(); + } + + @Override + public Object get() { + throw new UnsupportedOperationException(); + } + + @Override + public Iterator iterator() { + throw new UnsupportedOperationException(); + } + + private record StubInstance(Class type) implements Instance { + @Override + public U get() { + var bean = beans.get(type); + if (bean == null) { + throw new IllegalStateException("No fake bean registered for " + type.getName()); + } + return type.cast(bean); + } + + @Override + public boolean isUnsatisfied() { + return !beans.containsKey(type); + } + + @Override + public boolean isAmbiguous() { + return false; + } + + @Override + public Instance select(Annotation... qualifiers) { + throw new UnsupportedOperationException(); + } + + @Override + public Instance select(Class subtype, Annotation... qualifiers) { + throw new UnsupportedOperationException(); + } + + @Override + public Instance select(TypeLiteral subtype, Annotation... qualifiers) { + throw new UnsupportedOperationException(); + } + + @Override + public void destroy(U instance) { + } + + @Override + public Handle getHandle() { + throw new UnsupportedOperationException(); + } + + @Override + public Iterable> handles() { + throw new UnsupportedOperationException(); + } + + @Override + public Iterator iterator() { + throw new UnsupportedOperationException(); + } + } +} diff --git a/src/test/java/com/getpcpanel/rest/RestDtoSerializationSmokeTest.java b/src/test/java/com/getpcpanel/rest/RestDtoSerializationSmokeTest.java new file mode 100644 index 00000000..eb9a2c1b --- /dev/null +++ b/src/test/java/com/getpcpanel/rest/RestDtoSerializationSmokeTest.java @@ -0,0 +1,433 @@ +package com.getpcpanel.rest; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.io.InputStream; +import java.lang.reflect.Constructor; +import java.lang.reflect.GenericArrayType; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.lang.reflect.WildcardType; +import java.math.BigDecimal; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.Date; +import java.util.Deque; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Stream; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.opentest4j.TestAbortedException; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.getpcpanel.commands.CommandModule; +import com.getpcpanel.commands.CommandSubtypeRegistrar; + +import jakarta.ws.rs.core.Response; + +/** + * REST serialization smoke: catches "compiles but 500s at serialization" wiring breaks across the + * DTO-returning endpoints without booting the app (a {@code @QuarkusTest} is unsafe here — + * {@code DeviceProviderRegistry} starts every device provider on {@code StartupEvent}, which scans + * real HID/serial/MIDI hardware, and there is no {@code %test} config gate for it). + * + *

Discovery is reflective so new endpoints are covered automatically: every compiled + * {@code com.getpcpanel.**} class annotated {@code @jakarta.ws.rs.Path} is a resource; every HTTP + * method on it whose return type is a project DTO (or a collection of one) contributes that type. + * For each type a fully populated instance is built (non-null fields, single-element + * collections — an empty list would hide exactly the class of bug this guards) and serialized + * through an ObjectMapper configured like the app's (registered modules + + * {@link CommandSubtypeRegistrar} with every {@link CommandModule} on the classpath + + * {@code fail-on-unknown-properties=false}). + * + *

Types the dummy-value builder cannot instantiate are skipped with a clear message + * (via {@link TestAbortedException}) rather than failed, so an exotic DTO shape can't turn this + * guard into noise; a sanity floor asserts the discovery keeps finding a healthy number of + * endpoints. + */ +@DisplayName("REST DTO serialization smoke (all @Path resources)") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class RestDtoSerializationSmokeTest { + private static final int MAX_DEPTH = 10; + + private final List> allProjectClasses = scanProjectClasses(); + private final ObjectMapper mapper = buildAppLikeMapper(allProjectClasses); + + /** One entry per resource method whose (unwrapped) return type is serialized by Jackson. */ + record Endpoint(String description, Type returnType) { + @Override + public String toString() { + return description; + } + } + + Stream endpoints() { + var result = new ArrayList(); + for (var resource : allProjectClasses) { + if (!resource.isAnnotationPresent(jakarta.ws.rs.Path.class)) { + continue; + } + Method[] methods; + try { + methods = resource.getDeclaredMethods(); + } catch (Throwable e) { // optional platform deps in signatures + continue; + } + for (var method : methods) { + if (!isHttpMethod(method)) { + continue; + } + var returnType = unwrapAsync(method.getGenericReturnType()); + if (!isJacksonSerialized(returnType)) { + continue; + } + var sub = method.getAnnotation(jakarta.ws.rs.Path.class); + var path = resource.getAnnotation(jakarta.ws.rs.Path.class).value() + (sub == null ? "" : "/" + sub.value()); + result.add(new Endpoint(httpMethodName(method) + " " + path.replaceAll("/+", "/") + " (" + resource.getSimpleName() + "#" + method.getName() + ")", + returnType)); + } + } + result.sort(Comparator.comparing(Endpoint::description)); + return result.stream(); + } + + @Test + @DisplayName("discovery sanity: a healthy number of DTO-returning endpoints is found") + void discoveryFindsEndpoints() { + var count = endpoints().count(); + assertTrue(count >= 15, "expected at least 15 DTO-returning endpoints, found " + count + + " — the reflective discovery is broken, not the endpoints"); + } + + @ParameterizedTest(name = "{0}") + @MethodSource("endpoints") + @DisplayName("a populated response DTO serializes without throwing") + void populatedDtoSerializes(Endpoint endpoint) throws Exception { + Object value; + try { + value = build(endpoint.returnType(), new ArrayDeque<>()); + } catch (Throwable e) { + throw new TestAbortedException("SKIPPED (cannot build a dummy instance of " + endpoint.returnType().getTypeName() + + "): " + e, e); + } + var json = mapper.writeValueAsString(value); + assertFalse(json.isEmpty(), "expected JSON output for " + endpoint); + } + + // ── discovery helpers ───────────────────────────────────────────────────── + + private static boolean isHttpMethod(Method method) { + return method.isAnnotationPresent(jakarta.ws.rs.GET.class) + || method.isAnnotationPresent(jakarta.ws.rs.POST.class) + || method.isAnnotationPresent(jakarta.ws.rs.PUT.class) + || method.isAnnotationPresent(jakarta.ws.rs.DELETE.class) + || method.isAnnotationPresent(jakarta.ws.rs.PATCH.class); + } + + private static String httpMethodName(Method method) { + if (method.isAnnotationPresent(jakarta.ws.rs.GET.class)) + return "GET"; + if (method.isAnnotationPresent(jakarta.ws.rs.POST.class)) + return "POST"; + if (method.isAnnotationPresent(jakarta.ws.rs.PUT.class)) + return "PUT"; + if (method.isAnnotationPresent(jakarta.ws.rs.DELETE.class)) + return "DELETE"; + return "PATCH"; + } + + /** Unwraps {@code CompletionStage}/{@code Uni}-style async wrappers to the payload type. */ + private static Type unwrapAsync(Type type) { + if (type instanceof ParameterizedType pt && pt.getRawType() instanceof Class raw + && (java.util.concurrent.CompletionStage.class.isAssignableFrom(raw) || raw.getName().endsWith(".Uni"))) { + return pt.getActualTypeArguments()[0]; + } + return type; + } + + /** Whether the return type is a body Jackson serializes (not void/Response/streaming/plain text). */ + private static boolean isJacksonSerialized(Type type) { + var raw = rawClass(type); + if (raw == null || raw == void.class || raw == Void.class || raw.isPrimitive()) { + return false; + } + return !Response.class.isAssignableFrom(raw) + && raw != String.class + && raw != byte[].class + && !InputStream.class.isAssignableFrom(raw) + && !File.class.isAssignableFrom(raw); + } + + private static List> scanProjectClasses() { + try { + var classesRoot = Path.of(CommandModule.class.getProtectionDomain().getCodeSource().getLocation().toURI()); + var loader = RestDtoSerializationSmokeTest.class.getClassLoader(); + var result = new ArrayList>(); + try (Stream walk = Files.walk(classesRoot.resolve("com").resolve("getpcpanel"))) { + for (var classFile : walk.filter(p -> p.toString().endsWith(".class")).toList()) { + var relative = classesRoot.relativize(classFile).toString(); + var binaryName = relative.substring(0, relative.length() - ".class".length()).replace(File.separatorChar, '.'); + try { + result.add(Class.forName(binaryName, false, loader)); + } catch (Throwable e) { // optional platform deps etc. + } + } + } + return result; + } catch (Exception e) { + throw new IllegalStateException("cannot scan project classes", e); + } + } + + /** The app's mapper shape: registered datatype modules + the command-subtype customizer. */ + private static ObjectMapper buildAppLikeMapper(List> allProjectClasses) { + var mapper = new ObjectMapper().findAndRegisterModules() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + var modules = new ArrayList(); + for (var clazz : allProjectClasses) { + if (CommandModule.class.isAssignableFrom(clazz) && !clazz.isInterface() && !Modifier.isAbstract(clazz.getModifiers())) { + try { + modules.add((CommandModule) clazz.getDeclaredConstructor().newInstance()); + } catch (Exception e) { + throw new IllegalStateException("CommandModule " + clazz.getName() + " is not no-arg instantiable", e); + } + } + } + assertTrue(modules.size() >= 5, "expected the CommandModule scan to find the feature modules, found " + modules.size()); + try { + var registrar = new CommandSubtypeRegistrar(); + var field = CommandSubtypeRegistrar.class.getDeclaredField("modules"); + field.setAccessible(true); + field.set(registrar, modules); + registrar.customize(mapper); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("cannot wire CommandSubtypeRegistrar", e); + } + return mapper; + } + + // ── populated dummy-instance builder ────────────────────────────────────── + + private Object build(Type type, Deque> stack) throws Exception { + if (stack.size() > MAX_DEPTH) { + return null; + } + if (type instanceof WildcardType wt) { + return build(wt.getUpperBounds()[0], stack); + } + if (type instanceof GenericArrayType gat) { + var element = build(gat.getGenericComponentType(), stack); + var array = java.lang.reflect.Array.newInstance(rawClass(gat.getGenericComponentType()), 1); + java.lang.reflect.Array.set(array, 0, element); + return array; + } + if (type instanceof ParameterizedType pt) { + var raw = (Class) pt.getRawType(); + var args = pt.getActualTypeArguments(); + if (Map.class.isAssignableFrom(raw)) { + var map = new LinkedHashMap<>(); + map.put(build(args[0], stack), build(args[1], stack)); + return map; + } + if (Set.class.isAssignableFrom(raw)) { + return Set.of(build(args[0], stack)); + } + if (Collection.class.isAssignableFrom(raw) || Iterable.class.isAssignableFrom(raw)) { + return List.of(build(args[0], stack)); + } + if (Optional.class.isAssignableFrom(raw)) { + return Optional.ofNullable(build(args[0], stack)); + } + return buildBean(raw, stack); + } + var raw = rawClass(type); + if (raw == null) { + return null; + } + var wellKnown = wellKnownValue(raw); + if (wellKnown != null) { + return wellKnown; + } + if (raw.isEnum()) { + var constants = raw.getEnumConstants(); + return constants.length == 0 ? null : constants[0]; + } + if (raw.isArray()) { + var element = build(raw.getComponentType(), stack); + var array = java.lang.reflect.Array.newInstance(raw.getComponentType(), 1); + java.lang.reflect.Array.set(array, 0, element); + return array; + } + if (Map.class.isAssignableFrom(raw)) { + return Map.of("key", "value"); + } + if (Collection.class.isAssignableFrom(raw)) { + return List.of("value"); + } + return buildBean(raw, stack); + } + + @SuppressWarnings("UseOfObsoleteDateTimeApi") + private static Object wellKnownValue(Class raw) { + if (raw == String.class || raw == Object.class || raw == CharSequence.class) + return "value"; + if (raw == int.class || raw == Integer.class) + return 1; + if (raw == long.class || raw == Long.class) + return 1L; + if (raw == double.class || raw == Double.class) + return 1.0d; + if (raw == float.class || raw == Float.class) + return 1.0f; + if (raw == short.class || raw == Short.class) + return (short) 1; + if (raw == byte.class || raw == Byte.class) + return (byte) 1; + if (raw == boolean.class || raw == Boolean.class) + return Boolean.TRUE; + if (raw == char.class || raw == Character.class) + return 'x'; + if (raw == BigDecimal.class) + return BigDecimal.ONE; + if (raw == UUID.class) + return UUID.fromString("00000000-0000-0000-0000-000000000001"); + if (raw == File.class) + return new File("dummy.txt"); + if (raw == Path.class) + return Path.of("dummy.txt"); + if (raw == URI.class) + return URI.create("http://localhost/"); + if (raw == Instant.class) + return Instant.EPOCH; + if (raw == Duration.class) + return Duration.ofSeconds(1); + if (raw == LocalDate.class) + return LocalDate.EPOCH; + if (raw == LocalDateTime.class) + return LocalDateTime.of(1970, 1, 1, 0, 0); + if (raw == OffsetDateTime.class) + return OffsetDateTime.parse("1970-01-01T00:00:00Z"); + if (raw == Date.class) + return new Date(0); + return null; + } + + /** Builds a project bean/record: records via canonical ctor, else no-arg ctor + populated fields. */ + private Object buildBean(Class raw, Deque> stack) throws Exception { + if (stack.contains(raw)) { + return null; // cyclic reference — leave the inner occurrence null + } + stack.push(raw); + try { + var concrete = raw; + if (raw.isInterface() || Modifier.isAbstract(raw.getModifiers())) { + concrete = findConcreteSubtype(raw); + if (concrete == null) { + throw new IllegalStateException("no concrete subtype found for " + raw.getName()); + } + } + if (concrete.isRecord()) { + var components = concrete.getRecordComponents(); + var types = new Class[components.length]; + var args = new Object[components.length]; + for (var i = 0; i < components.length; i++) { + types[i] = components[i].getType(); + args[i] = build(components[i].getGenericType(), stack); + } + var ctor = concrete.getDeclaredConstructor(types); + ctor.setAccessible(true); + return ctor.newInstance(args); + } + Constructor noArg = null; + try { + noArg = concrete.getDeclaredConstructor(); + } catch (NoSuchMethodException ignored) { + } + if (noArg != null) { + noArg.setAccessible(true); + var instance = noArg.newInstance(); + populateFields(instance, concrete, stack); + return instance; + } + // no no-arg ctor: use the greediest constructor with recursively built arguments + var ctor = Stream.of(concrete.getDeclaredConstructors()) + .max(Comparator.comparingInt(Constructor::getParameterCount)) + .orElseThrow(() -> new IllegalStateException("no constructor on " + raw.getName())); + ctor.setAccessible(true); + var params = ctor.getGenericParameterTypes(); + var args = new Object[params.length]; + for (var i = 0; i < params.length; i++) { + args[i] = build(params[i], stack); + } + return ctor.newInstance(args); + } finally { + stack.pop(); + } + } + + /** Fills every instance field with a built value so serialization exercises populated state. */ + private void populateFields(Object instance, Class concrete, Deque> stack) { + for (var current = concrete; current != null && current != Object.class; current = current.getSuperclass()) { + for (var field : current.getDeclaredFields()) { + if (Modifier.isStatic(field.getModifiers()) || Modifier.isFinal(field.getModifiers())) { + continue; + } + try { + field.setAccessible(true); + var value = build(field.getGenericType(), stack); + if (value != null) { + field.set(instance, value); + } + } catch (Throwable ignored) { // a field that cannot be filled stays at its default + } + } + } + } + + /** First instantiable concrete subtype on the classpath, preferring the fewest-field one. */ + private Class findConcreteSubtype(Class base) { + return allProjectClasses.stream() + .filter(base::isAssignableFrom) + .filter(c -> !c.isInterface() && !Modifier.isAbstract(c.getModifiers()) && !c.isAnonymousClass()) + .min(Comparator.comparingInt(c -> c.getDeclaredFields().length)) + .orElse(null); + } + + private static Class rawClass(Type type) { + if (type instanceof Class c) { + return c; + } + if (type instanceof ParameterizedType pt) { + return (Class) pt.getRawType(); + } + if (type instanceof GenericArrayType gat) { + var component = rawClass(gat.getGenericComponentType()); + return component == null ? null : component.arrayType(); + } + return null; + } +} diff --git a/src/test/java/com/getpcpanel/util/concurrent/DebouncerThrottleLeadingTest.java b/src/test/java/com/getpcpanel/util/concurrent/DebouncerThrottleLeadingTest.java index fed30f16..14b65dea 100644 --- a/src/test/java/com/getpcpanel/util/concurrent/DebouncerThrottleLeadingTest.java +++ b/src/test/java/com/getpcpanel/util/concurrent/DebouncerThrottleLeadingTest.java @@ -1,12 +1,18 @@ package com.getpcpanel.util.concurrent; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; import java.util.List; -import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.Callable; +import java.util.concurrent.Delayed; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -15,55 +21,210 @@ * the caller), bursts inside the window are gated, and the most recent call is flushed once the * window elapses so the final value is never dropped. * - *

The {@link Debouncer} is created per test method (and shut down in a finally) because the suite - * runs with the {@code per_class} test-instance lifecycle, so a shared field would leak its scheduler - * across methods. + *

Fully deterministic: the {@link Debouncer}'s scheduler/clock seam is fed a {@link VirtualScheduler} + * whose time only moves when the test advances it, so there are no real sleeps and no timing windows. + * + *

The suite runs with the {@code per_class} test-instance lifecycle, so the fixture is rebuilt in + * {@code @BeforeEach} rather than held in initialized fields. */ @DisplayName("Debouncer leading+trailing throttle") class DebouncerThrottleLeadingTest { + private VirtualScheduler scheduler; + private Debouncer debouncer; + private List runs; + + @BeforeEach + void setUp() { + scheduler = new VirtualScheduler(); + debouncer = new Debouncer(scheduler, scheduler::now); + runs = new ArrayList<>(); + } + @Test @DisplayName("first call runs instantly; the burst's last value is flushed after the window") - void leadingImmediateTrailingLast() throws Exception { - var debouncer = new Debouncer(); - var runs = new CopyOnWriteArrayList(); - try { - debouncer.throttleLeading("k", () -> runs.add("a"), 150, TimeUnit.MILLISECONDS); - // Leading edge runs on the calling thread, so it is already recorded. - assertEquals(List.of("a"), runs); - - // A burst inside the window: none run immediately, the last one wins the trailing flush. - debouncer.throttleLeading("k", () -> runs.add("b"), 150, TimeUnit.MILLISECONDS); - debouncer.throttleLeading("k", () -> runs.add("c"), 150, TimeUnit.MILLISECONDS); - assertEquals(List.of("a"), runs, "burst must not fire immediately"); - - awaitSize(runs, 2, 2000); - assertEquals(List.of("a", "c"), runs, "only the leading and the final value are sent"); - } finally { - debouncer.shutdown(); - } + void leadingImmediateTrailingLast() { + debouncer.throttleLeading("k", () -> runs.add("a"), 150, TimeUnit.MILLISECONDS); + // Leading edge runs on the calling thread, so it is already recorded. + assertEquals(List.of("a"), runs); + + // A burst inside the window: none run immediately, the last one wins the trailing flush. + debouncer.throttleLeading("k", () -> runs.add("b"), 150, TimeUnit.MILLISECONDS); + debouncer.throttleLeading("k", () -> runs.add("c"), 150, TimeUnit.MILLISECONDS); + assertEquals(List.of("a"), runs, "burst must not fire immediately"); + + scheduler.advanceMillis(149); + assertEquals(List.of("a"), runs, "the trailing flush waits for the full window"); + scheduler.advanceMillis(1); + assertEquals(List.of("a", "c"), runs, "only the leading and the final value are sent"); } @Test @DisplayName("calls spaced beyond the window each run on their own leading edge") - void spacedCallsEachLead() throws Exception { - var debouncer = new Debouncer(); - var runs = new CopyOnWriteArrayList(); - try { - debouncer.throttleLeading("k", () -> runs.add("1"), 60, TimeUnit.MILLISECONDS); - assertEquals(List.of("1"), runs); - TimeUnit.MILLISECONDS.sleep(120); - debouncer.throttleLeading("k", () -> runs.add("2"), 60, TimeUnit.MILLISECONDS); - assertEquals(List.of("1", "2"), runs); - } finally { - debouncer.shutdown(); - } + void spacedCallsEachLead() { + debouncer.throttleLeading("k", () -> runs.add("1"), 60, TimeUnit.MILLISECONDS); + assertEquals(List.of("1"), runs); + + scheduler.advanceMillis(120); + debouncer.throttleLeading("k", () -> runs.add("2"), 60, TimeUnit.MILLISECONDS); + assertEquals(List.of("1", "2"), runs); + } + + @Test + @DisplayName("a trailing flush restarts the window, so a call right after it is gated again") + void trailingFlushRestartsWindow() { + debouncer.throttleLeading("k", () -> runs.add("a"), 100, TimeUnit.MILLISECONDS); + debouncer.throttleLeading("k", () -> runs.add("b"), 100, TimeUnit.MILLISECONDS); + scheduler.advanceMillis(100); + assertEquals(List.of("a", "b"), runs, "the burst's value flushes at the window edge"); + + // The flush counts as a run: a call inside the next window is gated and flushed later. + debouncer.throttleLeading("k", () -> runs.add("c"), 100, TimeUnit.MILLISECONDS); + assertEquals(List.of("a", "b"), runs, "still inside the window started by the flush"); + scheduler.advanceMillis(100); + assertEquals(List.of("a", "b", "c"), runs); + } + + @Test + @DisplayName("independent keys throttle independently") + void keysAreIndependent() { + debouncer.throttleLeading("k1", () -> runs.add("k1-a"), 100, TimeUnit.MILLISECONDS); + debouncer.throttleLeading("k2", () -> runs.add("k2-a"), 100, TimeUnit.MILLISECONDS); + assertEquals(List.of("k1-a", "k2-a"), runs, "each key gets its own leading edge"); } - private static void awaitSize(List list, int size, long timeoutMs) throws InterruptedException { - var deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMs); - while (list.size() < size && System.nanoTime() < deadline) { - TimeUnit.MILLISECONDS.sleep(10); + /** + * Single-threaded scheduler stub on a virtual clock: {@code schedule} records the task and + * {@link #advanceMillis} moves time forward, running every task that comes due (in due order) on + * the test thread. Only the surface the {@link Debouncer} uses is implemented. + */ + private static final class VirtualScheduler implements ScheduledExecutorService { + private final List tasks = new ArrayList<>(); + private long nowNanos; + + long now() { + return nowNanos; + } + + void advanceMillis(long millis) { + var target = nowNanos + TimeUnit.MILLISECONDS.toNanos(millis); + while (true) { + var next = tasks.stream().min(Comparator.comparingLong(t -> t.dueNanos)).orElse(null); + if (next == null || next.dueNanos > target) { + break; + } + tasks.remove(next); + nowNanos = next.dueNanos; + next.task.run(); + } + nowNanos = target; + } + + @Override public ScheduledFuture schedule(Runnable command, long delay, TimeUnit unit) { + var task = new VirtualTask(command, nowNanos + unit.toNanos(delay)); + tasks.add(task); + return task; + } + + @Override public void shutdown() { + } + + @Override public List shutdownNow() { + tasks.clear(); + return List.of(); + } + + @Override public boolean isShutdown() { + return false; + } + + @Override public boolean isTerminated() { + return false; + } + + @Override public boolean awaitTermination(long timeout, TimeUnit unit) { + return true; + } + + @Override public void execute(Runnable command) { + command.run(); + } + + @Override public ScheduledFuture schedule(Callable callable, long delay, TimeUnit unit) { + throw new UnsupportedOperationException(); + } + + @Override public ScheduledFuture scheduleAtFixedRate(Runnable command, long initialDelay, long period, TimeUnit unit) { + throw new UnsupportedOperationException(); + } + + @Override public ScheduledFuture scheduleWithFixedDelay(Runnable command, long initialDelay, long delay, TimeUnit unit) { + throw new UnsupportedOperationException(); + } + + @Override public java.util.concurrent.Future submit(Callable task) { + throw new UnsupportedOperationException(); + } + + @Override public java.util.concurrent.Future submit(Runnable task, T result) { + throw new UnsupportedOperationException(); + } + + @Override public java.util.concurrent.Future submit(Runnable task) { + throw new UnsupportedOperationException(); + } + + @Override public List> invokeAll(Collection> tasks) { + throw new UnsupportedOperationException(); + } + + @Override public List> invokeAll(Collection> tasks, long timeout, TimeUnit unit) { + throw new UnsupportedOperationException(); + } + + @Override public T invokeAny(Collection> tasks) { + throw new UnsupportedOperationException(); + } + + @Override public T invokeAny(Collection> tasks, long timeout, TimeUnit unit) { + throw new UnsupportedOperationException(); + } + + private final class VirtualTask implements ScheduledFuture { + private final Runnable task; + private final long dueNanos; + + private VirtualTask(Runnable task, long dueNanos) { + this.task = task; + this.dueNanos = dueNanos; + } + + @Override public boolean cancel(boolean mayInterruptIfRunning) { + return tasks.remove(this); + } + + @Override public long getDelay(TimeUnit unit) { + return unit.convert(dueNanos - nowNanos, TimeUnit.NANOSECONDS); + } + + @Override public int compareTo(Delayed o) { + return Long.compare(getDelay(TimeUnit.NANOSECONDS), o.getDelay(TimeUnit.NANOSECONDS)); + } + + @Override public boolean isCancelled() { + return false; + } + + @Override public boolean isDone() { + return !tasks.contains(this); + } + + @Override public Object get() { + throw new UnsupportedOperationException(); + } + + @Override public Object get(long timeout, TimeUnit unit) { + throw new UnsupportedOperationException(); + } } - assertTrue(list.size() >= size, "expected at least " + size + " runs, got " + list); } }