Skip to content

refactor: feature-module (plugin-style) restructure#111

Merged
nvdweem merged 49 commits into
mainfrom
worktree-feature-module-restructure
Jun 28, 2026
Merged

refactor: feature-module (plugin-style) restructure#111
nvdweem merged 49 commits into
mainfrom
worktree-feature-module-restructure

Conversation

@nvdweem

@nvdweem nvdweem commented Jun 28, 2026

Copy link
Copy Markdown
Owner

Feature-module (plugin-style) restructure

Restructures the application around feature modules so each feature owns its own code, instead of
the previous split where commands, REST resources, platform backends, and helpers were scattered across
generic top-level packages (cpp/, hid/, commands/command/, rest/, util/).

Guiding principle

  • A package is an integration only if it provides commands. Providers, the hardware-abstraction
    layer, and infrastructure are not integrations.
  • Each command-providing feature (volume, keyboard, program, obs, voicemeeter, wavelink,
    osc, mqtt, homeassistant, discord, analogbands, …) lives under integration.<name> and owns
    its command/ + CommandModule, REST, SPI impls, services, and platform backends.
  • The device layer (device/, device/provider/, device/descriptor/) is the HAL — it provides no
    commands, so it is not an integration. PCPanel is one provider among Deej and MIDI.

Highlights

  • Commands engine vs. commands: commands/ now holds only the engine (Command, the SPIs,
    dispatcher, @CommandMeta/CommandModule registry). Concrete commands moved into their feature
    packages. Command JSON ids are location-independent (@JsonTypeName + legacyIds), so existing
    saved profiles keep working
    .
  • Generated command registry: the frontend command catalog is now generated from @CommandMeta
    (Java is the source of truth), guarded by CommandRegistryGeneratorTest.
  • Implementation hiding: platform keyboard/audio backends are hidden behind build-selected
    interfaces (Keyboard, ISndCtrl) — callers no longer platform-check. Many classes tightened to
    package-private.
  • cpp/ and hid/ grab-bags dissolved: OS audio facade + backends → integration/volume/platform/;
    keyboard/media backends → integration/keyboard/platform/; device HAL + providers → device/.
  • Windows DLL kept correct: the SndCtrl.dll JNI exports were repointed to SndCtrlNative's new
    package and the DLL rebuilt on Windows/MSVC (a new manual build-sndctrl-dll.yml workflow builds it as
    an artifact).
  • util/ given structure (util/image, util/io, …); native-image config references project
    internals by .class instead of brittle String class names.
  • Docs updated: CLAUDE.md, docs/feature-module-structure.md, docs/events.md.

Validation

  • Full JVM test suite green (~437 tests, 0 failures).
  • Generated backend.types.ts is byte-identical to before — the frontend contract is unchanged.
  • Native CI is fully green on this branch across Windows, macOS (×2), Linux .deb/AppImage, and Linux
    Flatpak (run 28329170850). A Windows-only test failure (CRLF/LF mismatch in the new
    CommandRegistryGeneratorTest) was found and fixed.

AI-assisted contribution disclosure

This change is AI-generated, authored by Claude (Opus 4.8) under close human direction: the human set
the goals and architecture principles, repeatedly corrected scope and design, and decided what to move
where. Code was reviewed primarily by the AI; the human reviewed direction, structure, and outcomes
rather than every line. Confidence rests heavily on the automated checks: the full JVM test suite, the
byte-identical generated TS contract, and the green native CI build on every target platform.

This pull request was made by an AI without any human intervention

Niels and others added 30 commits June 28, 2026 14:14
Analysis of the current command/integration scatter and a target structure
where each integration is a self-contained module discovered via CDI +
annotations. Captures the Id.CLASS persisted-type constraint, the ~20
registries an integration command touches today, and a phased migration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… package

Switch Command polymorphism from Jackson Id.CLASS to Id.NAME + an explicit
@JsonSubTypes registry. Each subtype's name is a stable, location-independent
string equal to the class's historical FQCN — so saved profiles.json, the
generated TypeScript _type union, and the frontend command catalog are all
unchanged, while a command class is now free to move into its own feature
package (e.g. com.getpcpanel.voicemeeter.command) without breaking any of them:
only the @type(value=…) reference moves, the name stays frozen.

This is the seam that lets each integration own its command classes.

- CommandSubtypeRegistryTest guards the two invariants: every concrete Command
  subtype is registered exactly once (the 'forgot to register' guard), and every
  persisted id still deserializes to its class (the save-migration guard).
- typescript-generator now emits a CommandUnion tagged union (it keys off
  @JsonSubTypes); control.component.ts casts its two loose command-build sites to
  CommandUnion. backend.types.ts is otherwise unchanged except abstract base
  classes correctly drop out of the concrete _type literal unions.

Pre-existing FocusVolumeOverrideServiceTest failures (3, env-specific focus
detection) are unrelated and unchanged by this commit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…meeter.command

git-mv the five CommandVoiceMeeter* classes from the shared commands/command
pile into the VoiceMeeter module, matching the WaveLink/Discord/HomeAssistant
convention. Enabled by the stable Id.NAME registry: the persisted _type names
stay the historical FQCN, so saved profiles.json, the generated TypeScript
_type union, and the frontend command catalog are unchanged (the union diff is
a pure reordering of the same literal set).

- @JsonSubTypes / CommandConverter / NativeImageConfig / IconService /
  CommandsResource / VoiceMeeterConnectedVolumeService / VoiceMeeterMuteResolver
  updated to import from the new package.
- reachability-metadata.json: stale VoiceMeeter FQCNs repointed.
- pom typescript-generator: collapsed the per-feature command classPatterns to
  one glob com.getpcpanel.**.command.** so future feature command packages are
  picked up with no build-config edit.

History preserved via git mv. ReflectionRegistrationCoverageTest and the new
CommandSubtypeRegistryTest stay green; frontend tsc unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Same pattern as the VoiceMeeter move: git-mv the CommandObs* classes out of the
shared commands/command pile into the OBS module. Persisted _type names stay the
historical FQCN (frozen via @JsonSubTypes), so saves, the TS _type union, and the
frontend catalog are unchanged. Importers (@JsonSubTypes, CommandConverter,
NativeImageConfig, IconService, ObsMuteResolver, ObsConnectedVolumeService,
CommandsResource) and reachability-metadata repointed. classPatterns glob already
covers obs.command. Guards green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…d packages

git-mv CommandOscSend -> com.getpcpanel.osc.command and CommandMqttPublish ->
com.getpcpanel.mqtt.command. Both extend the generic CommandValueOutput base
(which stays in commands/command). Frozen @JsonSubTypes names keep saves/TS/UI
unchanged; NativeImageConfig + @JsonSubTypes imports repointed. Guards green.

With this, all integration commands (VoiceMeeter, OBS, OSC, MQTT — plus the
already-modular WaveLink/Discord/HomeAssistant) live in their feature's
.command package; commands/command now holds only the engine + genuinely-core
commands (volume/media/keystroke/run/brightness/profile/HTTP/value-output).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ter module

The VoiceMeeter-specific mute resolver lived in the generic mutecolor package and
exposed its VM_PATTERN regex for NamedDeviceMuteResolver to reach into. Move it to
com.getpcpanel.voicemeeter; it implements the shared MuteStateResolver SPI so it is
still discovered by MuteColorService via @ALL List<MuteStateResolver> regardless of
package. NamedDeviceMuteResolver now imports it; the resolver imports the mutecolor
SPI + dirty-event types. MuteColorServiceTest stays green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Record the frozen-FQCN-id foundation, the four command relocations + the
VoiceMeeterMuteResolver move as done, and the remaining work (annotation-driven
catalog + pretty ids, resource/icon/settings consolidation, dead-code + events.md
cleanup).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…l list)

Replace the central @JsonSubTypes block on Command with fully decentralized,
per-package self-registration so adding a command (or a whole plugin) never
touches anything outside its own feature package:

- Command keeps @JsonTypeInfo(Id.NAME) but drops @JsonSubTypes entirely.
- Each concrete command declares its own stable id via @JsonTypeName in its own
  file (the id stays the historical FQCN, so saves/TS/frontend are unchanged).
- New CommandModule CDI SPI: each feature has an @ApplicationScoped module that
  lists only its own commands. CommandSubtypeRegistrar (an ObjectMapperCustomizer)
  collects them via @ALL and registers them with Jackson — mirroring the existing
  @All-discovered SPIs (MuteStateResolver, IIconHandler, DeviceProvider).
- typescript-generator emits the _type union from @JsonTypeName (verified): the
  generated literal set is byte-identical, CommandUnion is no longer synthesised
  (control.component reverts its two casts to Command), WsEventUnion is unaffected.

Tests: CommandSubtypeRegistryTest now guards the decentralized invariants (every
command self-identifies with a unique @JsonTypeName; the CommandModule SPI covers
exactly the concrete set; every id resolves). CommandSubtypeRegistrarTest checks the
registrar wiring. CommandKeystrokeTest registers its subtype on its bare mapper.

CoreCommandModule is a temporary single module for the still-shared core commands;
the next commits split it into per-family modules.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
commands/command was still a catch-all mixing unrelated families (volume vs
shortcuts vs media...). Split every core family into its own self-contained
module via git mv, each registering itself through the CommandModule SPI — so
commands/command now holds ONLY the engine (Command, CommandNoOp,
CommandConverter, the Dial/Button/DeviceAction SPIs, EngineCommandModule).

New core modules (package + commands + a CommandModule bean):
  volume.command       — all CommandVolume* (process/focus/device/default-device…)
  keyboard.command     — CommandKeystroke, CommandMedia
  program.command      — CommandRun, CommandShortcut, CommandEndProgram
  device.command       — CommandBrightness
  profile.command      — CommandProfile
  analogbands.command  — CommandAnalogBands (+ AnalogBand)
  output.command       — CommandHttpRequest (+ CommandValueOutput base)

Frozen @JsonTypeName ids keep the persisted _type unchanged, so saves, the TS
_type union (verified byte-identical literal set), and the frontend are
unaffected by the moves. The classPatterns glob and the CommandModule SPI mean
no central file changed. Importers (CommandConverter, NativeImageConfig, etc.)
and reachability-metadata repointed; CommandAnalogBandsTest moved next to its
subject. Full suite green (only the 3 pre-existing FocusVolume failures); tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nable-command registry

Annotate the 39 user-assignable commands with @CommandMeta(label, category, kinds,
integration, icon) in com.getpcpanel.commands.meta — the picker/registry metadata
that is currently hand-maintained in the frontend command-catalog.ts. Values mirror
the catalog exactly (verified). Purely additive: nothing consumes the annotation yet;
the next commit adds a build-time generator that emits the frontend command registry
from these annotations, so a command's metadata lives in Java next to the command, in
its own feature package.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The hand-maintained command-level registry in command-catalog.ts (label, category,
kinds, integration, icon per command) is now generated from the Java @CommandMeta
annotations into command-registry.generated.ts. command-catalog.ts consumes it and
keeps only the field editors (buildEmpty + fields[]), which are Angular UI, not a
registry. So 'which commands exist and how they're classified' is retrieved from the
Java code, per command, in its own feature package.

- CommandRegistryGeneratorTest emits the file and guards staleness (regenerate with
  -Dpcpanel.generate.catalog); it also asserts every @CommandMeta is a concrete
  command with a @JsonTypeName id.
- COMMANDS is assembled by joining the generated metadata with the hand-written field
  schemas by type id. Values are identical to the previous hand catalog (verified),
  so frontend behaviour is unchanged; tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rename the persisted _type of the 39 assignable commands from their frozen FQCN to a
readable id (e.g. com.getpcpanel.commands.command.CommandVoiceMeeterAdvanced ->
voicemeeter.advanced) via @JsonTypeName, while staying backwards compatible:

- @CommandMeta.legacyIds records each command's previous id. CommandSubtypeRegistrar
  registers the current (nice) id as the subtype and installs a Jackson
  DeserializationProblemHandler that maps an unknown legacy id back to its command on
  READ only. So old profiles.json keep loading; re-saving writes the nice id — a
  transparent one-way conversion. New ids are never ambiguous with old ones for
  serialization (the handler is deser-only).
- The generated registry carries  so the frontend joins its hand-written field
  schemas (keyed by the old id) without churn, and stamps the nice _type in buildEmpty.
- CommandSubtypeRegistrarTest covers both directions: nice id loads, legacy FQCN loads,
  and a re-save converts to the nice id.

typescript-generator now emits the nice _type literals; tsc clean; full suite green
(only the 3 pre-existing FocusVolume failures).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CommandsResource (and its CommandType DTO) was a stale, non-authoritative command
list the frontend never called — the frontend command catalog is the real registry,
now generated from @CommandMeta. Remove both and prune their reachability-metadata
entries. backend.types.ts drops the unused CommandType interface; tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…kages

VoiceMeeterResource -> rest/voicemeeter, ObsResource -> rest/obs, OscResource ->
rest/osc, matching the rest/wavelink + rest/discord layout. @path is unchanged so the
URLs (/api/voicemeeter, /api/obs, /api/osc) are identical; reachability-metadata
repointed (the quarkusrestinvoker hash is method-derived, so only the package prefix
changes).

NOTE: rewrites Quarkus native-image REST-invoker metadata, fully verifiable only by a
native build (per CLAUDE.md); JVM build + coverage tests are green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nHandler SPI

Replace the two hardcoded imageHandlers.put(CommandObs/CommandVoiceMeeter,...) entries in
IconService with ObsIconHandler + VoiceMeeterIconHandler @ApplicationScoped beans (mirroring
WaveLinkIconHandler), discovered via @ALL List<IIconHandler> — so a feature's command icon
lives in its package. Behaviour identical (same images); IconService.OBS/VOICEMEETER made
public like DEVICE.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
External-connector integrations are now grouped under a dedicated namespace rather
than sitting at the top level next to core/platform packages:
  obs, voicemeeter, wavelink, discord, homeassistant, mqtt, osc
  -> com.getpcpanel.integration.{obs,voicemeeter,wavelink,discord,homeassistant,mqtt,osc}

Each integration keeps everything it owns (its .command subpackage, service/engine,
icon handler, mute resolver, events). Core command families (volume/keyboard/program/
device/profile/analogbands/output) are not integrations and stay put.

Persisted ids are unaffected: the nice @JsonTypeName ids and the frozen legacyIds
(old FQCN aliases) are location-independent, so saves/TS/frontend don't change (the
backend.types.ts diff is a pure reorder of the same _type literal set). Imports
rewritten codebase-wide (import-anchored, so legacyIds string literals are preserved);
native-image reachability/proxy-config + the VoicemeeterInstance init-at-run-time args
(pom + application.properties, parity-locked) repointed. classPatterns glob
com.getpcpanel.**.command.** still matches. Full suite green; tsc clean.

Native-image REST/JNA wiring is fully verified only by the native CI build (per CLAUDE.md).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ture>.rest

Answers 'why rest/discord not discord/rest': the per-feature REST resources + DTOs now
live with their feature (integration.discord.rest, integration.obs.rest, …) instead of
grouped by layer under rest/. This matches what HomeAssistant already did (homeassistant.rest)
and the feature-locality principle used everywhere else. The shared web bridge
(LocalHttpGuard, EventWebSocket, EventBroadcaster, rest/model) stays in rest/.

@path unchanged so URLs are identical; **.dto.** glob still generates the DTO types;
reachability-metadata + proxy-config repointed. Full suite green; tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…re packages

DiscordMuteResolver -> integration.discord, ObsMuteResolver -> integration.obs,
WaveLinkMuteResolver -> integration.wavelink (matching VoiceMeeterMuteResolver, already
in its package). They implement the shared MuteStateResolver SPI so MuteColorService
still discovers them via @ALL regardless of package. mutecolor/ now holds only the
orchestrator + SPI + integration-agnostic resolvers (process/device/named-device).
Full suite green; tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per the principle 'if it provides commands, it's an integration', the core command
families now live alongside the external connectors under integration.*:
  volume, keyboard, program, device, profile, analogbands, output (.command)
  -> com.getpcpanel.integration.{...}.command

com.getpcpanel.commands is the command ENGINE only (Command, CommandNoOp,
CommandConverter, the Dial/Button/DeviceAction SPIs, CommandValueOutput shared base,
the @CommandMeta/CommandModule registry SPI). CommandValueOutput moved here from
output/ since http/osc/mqtt all extend it.

Non-command infra stays put: volume audio services (used by overlay/rest), the
device-provider framework, profile persistence, the analog-bands colour service.
Persisted ids are location-independent (nice @JsonTypeName + frozen legacyIds), so
saves/TS/frontend are unaffected (backend.types.ts diff is a pure reorder of the same
_type set). Imports rewritten import-anchored (legacyIds string literals preserved);
reachability-metadata repointed. classPatterns glob com.getpcpanel.**.command.** still
matches all of them. Full suite green; tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… in commands/)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The foreground-window/process-lookup helpers were scattered across cpp/{windows,
linux,osx} but are consumed by three unrelated features — the volume audio
backends (SndCtrlPulseAudio/SndCtrlOsx inject them for focus-volume + session
enumeration), the program commands (IPlatformCommand), and Discord screen-share
(via IProcessHelper). By the 3+-unrelated-consumers rule they are shared infra,
not feature-owned, so they move next to their IProcessHelper SPI rather than into
any single integration.

Moves WindowsProcessHelper, LinuxProcessHelper, OsxProcessHelper and
ProcessConditionalHelper (Linux-internal) into com.getpcpanel.platform.process;
IProcessHelper stays in com.getpcpanel.platform. Pure-Java move — no native-image
config touched. Done first so the volume audio backends move onto a stable target.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…n/volume/platform

Dissolves the audio half of the cpp/ grab-bag. The ISndCtrl facade and its shared
value types (AudioDevice, AudioSession, AudioDevice/SessionEvent, DataFlow,
EventType, MuteType, Role) move to com.getpcpanel.integration.volume.platform; the
three per-OS backends move into windows/ (SndCtrl + WindowsAudio* + the internal
ProcessHelper), osx/ (SndCtrlOsx + OsxAudioDevice + CoreAudio*) and linux/
(SndCtrlPulseAudio + PulseAudio*). Audio is the backend the volume commands drive,
so it belongs with the volume feature.

- The cpp/ fluent-accessor lombok.config (name()/volume()/muted()) is replicated at
  the platform/ package root so the audio data classes keep their fluent API.
- WindowFocusChangedEvent moves to com.getpcpanel.profile: it is fired by the Windows
  audio backend but is consumed only for profile-switching-on-focus (ProfileWindowFocus
  Service + DeviceHolder), so leaving it under volume would couple volume<->profile.
- ProxyRegistrationCoverageTest (a global JNA-proxy guard scanning com.getpcpanel.**)
  moves to com.getpcpanel.graalvm next to NativeImageConfig.
- Native-image config repointed in lockstep: jni-config.json (AudioDevice/Session,
  SndCtrlWindows, WindowsAudioDevice), proxy-config.json + NativeImageConfig
  (CoreAudioLib + nested), the CoreAudioLib --initialize-at-run-time entries in BOTH
  pom.xml and application.properties, and every reachability-metadata.json audio entry.

Verified: JVM build green; NativeBuildArgsParity, ReflectionRegistrationCoverage,
ProxyRegistrationCoverage, SndCtrlNativeConfig + the PulseAudio tests all pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mute-colour is the volume feature's per-control LED layer: DeviceMuteResolver/
ProcessMuteResolver/NamedDeviceMuteResolver query ISndCtrl and MuteColorService
observes the audio device/session events. The MuteStateResolver SPI, the orchestrator,
the three ISndCtrl-backed resolvers and MuteOverridesDirtyEvent move to
com.getpcpanel.integration.volume.mutecolor. Per-integration resolvers (Voicemeeter/
OBS/Discord/WaveLink) keep contributing via @ALL from their own packages — only their
import of the SPI repoints. reachability-metadata MuteColorService entry repointed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…olume

Pulls the remaining volume-owned code into the feature package:

- volume/ leftovers (VolumeCoordinatorService, FocusVolumeOverrideService,
  IFocusRedirector, LinuxNewSessionVolumeService) -> integration/volume
- overlay/ (the whole on-screen volume HUD incl. Win32/Linux/NoOp impls, renderers,
  KdeOsdService, OverlayDemoTrigger) -> integration/volume/overlay
- the volume-facing REST surfaces (AudioResource, OverlayResource,
  FocusVolumeDiagnosticsResource) -> integration/volume; rest/ keeps the shared
  EventBroadcaster/EventWebSocket/SettingsResource relay
- the focus/overlay persisted DTOs FocusVolumeOverride, FocusVolumeTarget ->
  integration/volume and OverlayPosition -> integration/volume/overlay; Save keeps
  the fields and imports them from there

Native-image config repointed in lockstep: KdeOsdService (proxy-config + reflect-
config), the OverlayRenderer --initialize-at-run-time entry in pom.xml + application.
properties, NativeImageConfig's FocusVolume*/OverlayPosition imports, and the
reachability-metadata entries for the overlay, the two REST resources (quarkusrest
invoker hashes preserved — they are method-derived), VolumeCoordinatorService and
OverlayPosition. Redundant same-package imports created by the move were stripped.

Verified: JVM build green; parity/coverage guards + overlay + focus-volume tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…atform — cpp/ is gone

Completes the dissolution of the cpp/ grab-bag. The per-OS keystroke/media-key
backends move under the keyboard feature:

- WindowsKeyboard            -> integration/keyboard/platform/windows
- OsxKeyboard, OsxMediaControl (from util/) -> integration/keyboard/platform/osx
- LinuxKeyboard, LinuxMprisMediaControl, MprisPlayer -> integration/keyboard/platform/linux

They are consumed by KeyMacro (engine) and CommandMedia (keyboard integration); the
Linux MPRIS + macOS media controls are the media-key backends peer to the keyboards,
so they sit beside them per platform. With this the com.getpcpanel.cpp package — and
its fluent-accessor lombok.config, which only the audio data classes needed — is
removed entirely.

Native-image config repointed in lockstep: LinuxKeyboard$X11/$XTest --initialize-at-
run-time in BOTH pom.xml and application.properties + reachability-metadata;
OsxKeyboard$CoreGraphics/$CoreFoundation in pom/app.properties + proxy-config;
MprisPlayer in reflect-config + proxy-config. The keyboard *NativeConfigTest +
*KeystrokeTest suites moved with their subjects.

Verified: JVM build green; parity/coverage guards + all keyboard keystroke/native-
config tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Niels and others added 19 commits June 28, 2026 15:33
Co-locates the program/process-control feature's code:

- iconextract/ (the IIconService SPI + the Windows/Linux/Mac impls + the Windows
  JNA/COM backend chain JIconExtract -> Shell32Extra/IShellItemImageFactory/SIZEByValue)
  -> integration/program/iconextract. App-icon extraction serves the program feature's
  process/app picker. The commands/IconService wrapper stays in the engine.
- util/IPlatformCommand (per-OS exec/kill orchestrator) -> integration/program; its only
  consumers are the program commands CommandRun/CommandShortcut/CommandEndProgram.
- rest/IconResource + rest/ProcessResource -> integration/program (the app/process +
  icon REST surfaces feeding the picker).
- util/ShortcutHook -> com.getpcpanel.profile: despite the "shortcut" name it maps
  global shortcuts to PROFILE activation (imports Profile/SaveService, observes SaveEvent),
  so it belongs with the other focus/shortcut-driven profile-switching code, not program.

Native-image config repointed in lockstep: Shell32Extra (proxy-config + the --initialize-
at-run-time entry in BOTH pom.xml and application.properties), and the reachability-
metadata entries for the icon SPI/impls, IPlatformCommand (+ $WindowsPlatformCommand),
ShortcutHook, and the two REST resources (quarkusrestinvoker hashes preserved).

Verified: JVM build green; parity/coverage guards + IconExtractNativeTest pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…iders are NOT integrations)

A device provider supplies hardware input, not commands, so it belongs in the device
hardware-abstraction layer — never under integration/ (which is for command-providing
modules only). hid/ is dissolved accordingly:

- The PCPanel HID provider — DeviceScanner (the "pcpanel" DeviceProvider),
  DeviceCommunicationHandler(+Factory), InputInterpreter, OutputInterpreter, ByteWriter,
  ButtonClickEvent, HidDebug — moves to com.getpcpanel.device.provider.pcpanel, beside the
  deej/ and midi/ providers already in the HAL.
- DeviceHolder -> com.getpcpanel.device: the cross-provider device registry (19 consumers),
  core HAL infra.
- BrightnessService + (next commit) the device colour service -> com.getpcpanel.device:
  they are injected by the HAL's OutputInterpreter, so they must live in the HAL, not in an
  integration the HAL would then depend upward on.
- DialValue + DialValueCalculator -> com.getpcpanel.commands (the engine value model).

com.getpcpanel.integration.device keeps ONLY the brightness command module
(CommandBrightness + DeviceCommandModule) — that genuinely provides a command.
reachability-metadata repointed for all moved provider classes + nested events + tests.

Verified: JVM build green; parity/coverage guards + DialValue(+Calculator)/DeviceHolder
Backfill/Brightness/InputInterpreter/ByteWriter tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e layer

These provide no commands, so they are not integrations — they are the device
subsystem's own surfaces:

- DeviceResource, SerialResource, MidiResource (device lifecycle/profiles/assignments
  + deej/midi provider orchestration) -> com.getpcpanel.device.rest
- ProVisualColorsService (PCPanel-Pro LED colour computation, consumed by the HAL's
  OutputInterpreter, the analog-bands colour service, mute-colour and the device
  snapshot DTO) -> com.getpcpanel.device

reachability-metadata repointed (the REST resources keep their method-derived
quarkusrestinvoker hashes). The shared rest/ bridge keeps EventBroadcaster/EventWebSocket/
SettingsResource and the model/ TS+WS contract.

Verified: JVM build green; parity/coverage guards + ProVisualColorsServiceTest pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The IO transports had exactly one provider consumer each, so co-locate them:
SerialTransport, JSerialCommTransport and JSerialCommArchFix -> device/provider/deej;
MidiTransport and JavaxMidiTransport -> device/provider/midi. Completes the provider
framework's cohesion (each provider owns its transport) and empties device/io. Redundant
same-package imports dropped. No native-image config touched.

Verified: JVM build green; coverage guards + DeejSerialProvider/MidiProvider tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
AnalogBandColorService is the LED-feedback colour-override provider for CommandAnalogBands,
and BandTransition is that command's advance() return type (kept out of commands/command/ to
stay off the TS contract). Both move to the analogbands integration that already owns the
command — AnalogBandColorService at the package root, BandTransition under command/.

Verified: JVM build green; AnalogBandColorService + CommandAnalogBands tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ions

Each connector now owns its persisted-config shape: DiscordSettings/DiscordAuth/
DiscordSeenUser -> integration/discord/dto, MqttSettings (+ HomeAssistantSettings) ->
integration/mqtt/dto, OSCConnectionInfo/OSCBinding -> integration/osc/dto, WaveLinkSettings
-> integration/wavelink/dto. Save/Profile keep the fields and import from the new homes.

They go in a dto/ subpackage so the typescript-generator's **.dto.** classPattern still
emits them — backend.types.ts is byte-for-byte unchanged. profiles.json is unaffected
(field-name serialization, concrete types). NativeImageConfig imports + reachability-metadata
(MqttSettings + $HomeAssistantSettings, OSC*, WaveLinkSettings) repointed.

Verified: JVM build green; backend.types.ts unchanged; parity/coverage guards pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SndCtrlNative moved to com.getpcpanel.integration.volume.platform.windows in the audio
refactor, but the committed SndCtrl.dll still exported the old
Java_com_getpcpanel_cpp_windows_SndCtrlNative_* entry points — Windows native audio would
fail to bind at runtime. Updated the C++ accordingly:

- Renamed the JNI impl/header to com_getpcpanel_integration_volume_platform_windows_
  SndCtrlNative.{cpp,h} and repointed all JNIEXPORT entry-point names.
- Updated the JNI callback method descriptors that construct the moved data classes
  (...)Lcom/getpcpanel/integration/volume/platform/AudioSession; and ...AudioDevice; in
  AudioSession.cpp, Listeners.h, sndctrl.cpp.
- Updated CMakeLists.txt, the VS .vcxproj/.filters, header.bat and README references.

The committed src/main/resources/SndCtrl.dll is the MSVC build produced by the new manual
"Build SndCtrl.dll (Windows)" GitHub Actions workflow (~70 KB). objdump confirms all 12
Java_com_getpcpanel_integration_volume_platform_windows_SndCtrlNative_* exports and zero
old-package exports. Behaviour is unchanged — only package-name strings differ.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ard interface

The keyboard backends were public statics that every caller (KeyMacro, CommandMedia) had
to platform-branch over by hand — leaking implementation choice across the codebase. Now
they follow the project's own ISndCtrl idiom:

- New com.getpcpanel.integration.keyboard.Keyboard interface (executeKeyStroke / typeText /
  sendMediaKey). Exactly one impl is in a build, chosen by @WindowsBuild/@MacBuild/@linuxbuild;
  callers just resolve Keyboard via CdiHelper and never check the OS.
- WindowsKeyboard / OsxKeyboard / LinuxKeyboard become package-private @ApplicationScoped
  beans implementing Keyboard. Their pure key-mapping helpers stay package-private static
  (still unit-tested in-package).
- Media keys are unified onto Keyboard.sendMediaKey: the Windows global-media + Spotify
  WM_APPCOMMAND logic moved out of CommandMedia into WindowsKeyboard; macOS delegates to the
  now package-private OsxMediaControl; Linux keeps its XTEST/MPRIS path. VolumeButton is now
  a clean cross-platform identifier (its Windows key/appcommand codes live in WindowsKeyboard).
- KeyMacro is deleted (its mac-accessibility warning folded into OsxKeyboard); CommandKeystroke
  and CommandMedia inject Keyboard. Removed the obsolete --initialize-at-run-time=…KeyMacro from
  pom.xml + application.properties.

CommandMedia no longer imports User32/SndCtrlWindows or branches on the OS. backend.types.ts
unchanged (VolumeButton is still a name-only enum).

Verified: full JVM suite 437 pass / 0 fail (CDI deploys one Keyboard bean), keyboard
keystroke/native-config tests + parity/coverage guards green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…als by .class

The earlier mqtt -> integration.mqtt move left String classNames in NativeImageConfig
pointing at the gone com.getpcpanel.mqtt.MqttDeviceService$MqttEvent and
MqttHomeAssistantHelper$* — invisible to javac, but the native build would fail with
MissingReflectionRegistrationError. Fixed, and hardened against recurrence:

Every PCPanel-internal class registered for reflection is now referenced by .class in the
@RegisterForReflection targets block (compiler-checked) instead of by a fragile String:
- NativeImageConfig: MqttDeviceService.MqttEvent, the six MqttHomeAssistantHelper.HomeAssistant*
  records, Version(+SemVer), and CoreAudioLib.AudioObjectPropertyAddress/ListenerProc.
- JnaWin32ReflectionConfig: WinShell32(+NOTIFYICONDATA), WinUser32Ext, Win32Desktop, Win32PowerNotify.

To allow this, MqttDeviceService.MqttEvent and the MqttHomeAssistantHelper discovery records
were made public (the rest were already public). classNames now lists only third-party types
(Eclipse Paho, jna-platform CoreFoundation/WinDef/...) whose internals we cannot reference by
.class and which never move under us. Now a package move of any of our own native-registered
classes is a compile error, not a silent native-build break.

Verified: JVM build green; Reflection/Proxy coverage + parity guards pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…specific

App-icon extraction and process enumeration feed the application/process picker used by
multiple features (per-process volume, program run/focus, device assignment) — and the engine's
IconService wraps IIconService directly. No program command touches them, so they are shared
infrastructure, not part of the program integration:

- iconextract/ (IIconService SPI + Windows/Linux/Mac impls + the JNA/COM backend) moves back to
  the top-level com.getpcpanel.iconextract.
- IconResource (/api/icons) and ProcessResource (/api/processes) move back to com.getpcpanel.rest
  (the shared frontend bridge).

integration/program now contains only the program command module plus IPlatformCommand — the
per-OS exec/kill backend those commands actually drive (correctly program-owned). Native-image
config (Shell32Extra init-at-run-time in pom + application.properties, proxy-config, and the
reachability entries incl. the REST quarkusrestinvoker hashes) repointed accordingly.

Verified: JVM build green; IconExtractNativeTest + parity/coverage guards pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Now that each feature owns a cohesive package, the implementation classes that nothing
outside their package references no longer need to be public. Dropped `public` from 35
class declarations across the integration packages — platform audio backends
(SndCtrlPulseAudio/SndCtrlOsx + the PulseAudio*/WindowsAudio*/OsxAudioDevice structs),
the overlay impls (Win32VolumeOverlay/LinuxOverlay/NoOpOverlayWindow/OverlayDemoTrigger),
the mute resolvers and icon handlers (discovered via @ALL by interface, so visibility is
irrelevant to wiring), and per-integration services (MqttDeviceColorService — the example
that prompted this, ObsWebSocketClient, VoiceMeeterMuteService, HomeAssistantClient, …).

Selection was conservative: skipped command classes, @path REST resources, and anything
referenced by .class in the native-image config (those must stay public). The compiler
rejects any cross-package concrete reference, so a missed dependency is a build error, not
a silent runtime break.

Verified: full JVM suite 437 pass / 0 fail — CDI still deploys every bean (no
Unsatisfied/Ambiguous/Unproxyable), coverage/parity guards green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The util/ root had grown to ~22 loose files. Grouped them by concern (joining the existing
version/, coloroverride/, tray/ subpackages):

- util/image/      Images, PngDecoder, PngEncoder
- util/concurrent/ Debouncer, ReconnectBackoff
- util/os/         ProcessHelper, OsxPermissionHelper, ConsoleSupport, Kernel32Console
- util/io/         FileUtil, ExtractUtil, PcPanelRoot, FileChecker
- util/app/        AppEvents, AppShutdownState, ShowMainEvent, ShowMainService, OpenFolderEvent,
                   StartupOnboarding

The ubiquitous cross-cutting primitives stay at the util root: CdiHelper (26 consumers),
Util, SharedHttpClient, ValueInterpolator. Native-image config repointed (the OsxPermission
Helper/Kernel32Console --initialize-at-run-time build-args in pom + application.properties,
proxy-config, and the reachability entries for AppEvents/AppShutdownState/Debouncer/
ExtractUtil/FileUtil/ProcessHelper); the moved tests follow their subjects.

Verified: full JVM suite 437 pass / 0 fail; parity/coverage guards green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…cture

Rewrote docs/feature-module-structure.md as the end-state document: the organising principle
(integration/* = command providers only), the final package layout, and the key patterns
(decentralized command registry, build-selected platform interfaces, the device HAL + providers,
native-image registration by .class, the SndCtrl.dll JNI coupling), plus how to add a command /
integration / provider / platform backend.

Updated CLAUDE.md architecture references to match: the device HAL + PCPanel/deej/midi providers
under device/, the command engine vs integration/*/command split, the OS-audio facade under
integration/volume/platform, the shared-vs-feature REST split, the overlay under integration/volume,
the single **.command.** classPattern, the macOS OsxKeyboard path, and the fluent-accessor
lombok.config now at integration/volume/platform. (src/main/cpp C++-source references are unchanged —
only the Java cpp package was dissolved.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On-demand (workflow_dispatch) job that builds the Windows native audio library with MSVC
(CMake + Visual Studio generator) and uploads SndCtrl.dll as a workflow artifact. It does not
attach the DLL to a release or commit it — after changing the C++ under src/main/cpp/ (e.g. when
SndCtrlNative's package moves), run this, download the artifact, and commit
src/main/resources/SndCtrl.dll by hand.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…el provider

The PCPanel-specific device implementation was sitting in the generic device HAL root, and
DeviceFactory was a misleadingly-generic name for something whose build() only constructs
PCPanel devices. Fixed:

- PCPanelMiniDevice / PCPanelProDevice / PCPanelRGBDevice -> device/provider/pcpanel.
- DescriptorFactory (the built-in PCPanel descriptor catalog) -> device/provider/pcpanel.
- DeviceFactory split by ownership: the PCPanel build() becomes
  device/provider/pcpanel/PcPanelDeviceFactory; the generic GenericDevice construction becomes
  device/GenericDeviceFactory. DeviceHolder injects both and routes by provider.

DeviceType stays in device/ deliberately: it is the device-kind discriminator that the generic
persistence (Profile/DeviceSave/LightingConfig) and the REST/TS contract (DeviceDto) key on, so
relocating it would push those generic layers to import the provider. Removing that cross-layer
dependence on a PCPanel enum is the separate, in-progress device-layer generalization
(docs/device-layer-generalization-plan.md), not a package move.

Verified: full JVM suite 437 pass / 0 fail (CDI deploys both new factory beans); reachability
metadata repointed (DeviceFactory -> PcPanelDeviceFactory + GenericDeviceFactory).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ProVisualColorsService is pure PCPanel-Pro implementation — it bails unless
deviceType()==PCPANEL_PRO and hard-codes the Pro geometry (5 dials, 4 sliders, 5 segments)
and its rainbow/wave/custom LED rendering. It is not generic device infrastructure, so it
belongs in com.getpcpanel.device.provider.pcpanel, not the HAL root. Its consumers
(analogbands, mute-colour, the REST event broadcaster + device snapshot DTO) now import it
from the provider — the honest representation of those features rendering Pro visuals.

Verified: JVM build green; ProVisualColorsService + reflection-coverage tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ts command

BrightnessService is the runtime service for the brightness command (it reads CommandBrightness
out of the profiles to decide the device-wide runtime brightness), so it imported
integration.device.command.CommandBrightness from the device HAL — a HAL→integration smell.
Moving it next to CommandBrightness in integration/device removes that inversion; the pcpanel
OutputInterpreter that consumes the runtime value now depends on the brightness integration
(provider→feature), which is the honest direction for a hardware-output feature.

Verified: JVM build green; BrightnessServiceTest passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rovider

DeviceType is the PCPanel hardware-model catalogue (PCPANEL_RGB/MINI/PRO with their USB
vid/pid and control counts) — provider-internal data, not a generic HAL type. It moves to
com.getpcpanel.device.provider.pcpanel.

This makes the persistence (Profile/DeviceSave/Save/LightingConfig) and the REST/TS contract
(DeviceDto/DeviceSnapshotDto) import the provider for the device-kind enum. That coupling is
the pre-existing generalization debt — those generic layers still key on the PCPanel enum
instead of the generic providerId/deviceKindId — now made explicit at the import rather than
hidden by parking a PCPanel enum in the generic device root. Removing the dependency outright
is the device-layer generalization (docs/device-layer-generalization-plan.md).

With this, com.getpcpanel.device holds only generic HAL types (Device, GenericDevice,
DeviceHolder, GenericDeviceFactory, GlobalBrightnessChangedEvent); all PCPanel-specific device
code lives under device/provider/pcpanel. profiles.json and backend.types.ts are unchanged
(enum name-serialized; TS emission is package-independent).

Verified: full JVM suite 437 pass / 0 fail; backend.types.ts byte-identical; guards green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The generator emits LF line endings, but git's `* text=auto` rewrites the
checked-out command-registry.generated.ts to CRLF on Windows runners
(core.autocrlf), so the freshly-rendered expected (LF) never equalled the
read-from-disk actual (CRLF). Both the test and the generated file are new on
this branch, so the Windows native-image build hit this for the first time and
failed in the test phase. Compare content, not line endings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@nvdweem nvdweem merged commit 7f2a4bd into main Jun 28, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant