Skip to content

Feature/camera stream v1 - #391

Open
jugurtha114 wants to merge 31 commits into
Ylianst:masterfrom
jugurtha114:feature/camera-stream-v1
Open

Feature/camera stream v1#391
jugurtha114 wants to merge 31 commits into
Ylianst:masterfrom
jugurtha114:feature/camera-stream-v1

Conversation

@jugurtha114

Copy link
Copy Markdown

Summary

In this pull request, the following changes are made:

  • Foobar was changed to FooFoo, because ...
  • Relates to #issue-number
  • Resolves #issue-number
Please follow this checklist to avoid unnecessary back and forth (click to expand)
  • 🧠 I used LLMs/AI in this contribution and reviewed all generated content.
    I understand that I am responsible for and able to explain every line of code I submit.
  • 🛠️ I have self-reviewed my code and self-tested it against a MeshCentral server to ensure it works as expected.
  • 🖥️ My change compiles on every platform it affects (Windows / Linux / macOS / FreeBSD), and I have considered
    the impact on platforms and architectures I could not test.
  • 📦 If I changed JavaScript modules under modules/, I re-embedded them so the compiled-in copies in
    microscript/ILibDuktape_Polyfills.c match (the agent runs the embedded copies, not the files on disk).
  • 🤖 I ran the agent self-test where appropriate (see "Self Test" in readme.md).
  • 📄 Documentation updates are included (if applicable), e.g. the .msh options table in readme.md.
  • 🧰 Updates to vendored dependencies (OpenSSL, zlib, ...) are listed and explained.
  • ⚠️ CI passes and is green (Windows / Linux / macOS / FreeBSD builds and CodeQL).

Testing

Platform (OS / distro / arch) Tested Result
e.g. Windows 11 x64
e.g. Debian 13 x64 (Wayland)

Screenshots for Visual Changes

Event Before After
Before After

Jugurtha-Green added 30 commits August 15, 2026 20:44
Windows agents never showed the audio button because windows_audio.c was
never compiled: _KVM_AUDIO was only defined for Linux builds, and the
Windows KVM had no audio hooks. As a result the agent never sent
MNG_AUDIO_CAPS, so the browser kept the speaker icon hidden.

Agent (meshcore/KVM/Windows/windows_audio.c), rewritten for robustness:
- WASAPI shared-mode loopback capture of the default render endpoint
- Handles any mix format Windows may pick: float32 and 16/24/32-bit PCM,
  arbitrary channel counts (downmixed to mono) and any sample rate
  (linearly resampled to 48 kHz)
- Ring buffer guarantees exactly 960-sample frames reach opus_encode,
  independent of the buffer sizes WASAPI returns
- Polls instead of using EVENTCALLBACK: Windows does not reliably signal
  the event handle for an idle loopback stream, which could stall capture
- Correct AUDCLNT_BUFFERFLAGS_SILENT handling (buffer contents are
  undefined when set; emit true silence with identical cursor arithmetic)
- Resampler state is per-session (thread-local) so a restart cannot reuse
  stale interpolation phase
- CRITICAL_SECTION around encoder/handler, Interlocked shutdown flag,
  COM balanced on every exit path including RPC_E_CHANGED_MODE
- <initguid.h> emits the COM GUIDs locally, avoiding a uuid.lib dependency

Windows KVM (meshcore/KVM/Windows/kvm.c):
- Handle MNG_AUDIO_START/STOP/QUERY in kvm_server_inputdata
- Call kvm_audio_init after hStdOut is acquired and next to
  kvm_send_display_list; initialising earlier wrote CAPS to an invalid
  handle in service (slave) mode, so the frame was silently lost
- kvm_audio_cleanup on KVM shutdown

Build integration (previously missing entirely):
- MeshService{,-2022}.vcxproj and MeshConsole{,-2022}.vcxproj: add
  windows_audio.c and kvm_audio.h, define _KVM_AUDIO, add
  lib-opus\includes, and link the per-architecture libopus.lib plus
  Ole32/Winmm across all 38 KVM-enabled configurations (x86/x64/ARM64)

The wire format is byte-identical to the working Linux implementation
(only the CAPS platform byte differs: 2 = Windows), so the existing
browser code needs no changes.

Verified: windows_audio.c compiles clean under -Wall -Wextra against
Windows API stubs; the real capture thread was exercised end-to-end
against a mock WASAPI device across 48/44.1/96 kHz, stereo/5.1/mono and
float32/s16/s32, confirming one CAPS frame, correct frame counts, no
sequence gaps, valid headers and safe double start/stop/cleanup. The
Linux x86-64 build still compiles and links unchanged.
The Windows agent is built with Visual Studio, which cannot run on the
Linux dev host, so the WASAPI code previously could only be reviewed by
inspection or compiled against hand-written stubs. Stubs cannot catch a
mismatch against Microsoft's real COM interfaces, which is precisely the
risk in this file.

MinGW-w64 ships the genuine WASAPI headers (mmdeviceapi.h, audioclient.h)
and Wine can execute the resulting PE, so this adds a container that:

  1. compiles windows_audio.c for x64 and x86 against the real Windows
     headers with -Wall -Wextra
  2. asserts the COM GUIDs resolve locally via <initguid.h> (so no
     uuid.lib is required) and that the only external Win32 imports are
     ones Ole32.lib provides
  3. links an actual PE executable and runs it under Wine to check CAPS
     emission and the start/stop/restart/cleanup lifecycle, including the
     no-audio-device path
  4. compiles kvm.c with and without _KVM_AUDIO and asserts the audio
     hooks add no new errors

kvm.c cannot fully compile under GCC because it uses MSVC __try/__except,
so step 4 compares error counts between the two configurations rather
than requiring a clean build.

This does not replace a Visual Studio build or testing on real hardware,
where the audio endpoint actually exists; it catches API and lifecycle
regressions before that point.

Run with:
  docker build -f docker/Dockerfile.windows-audio-check -t meshagent-winaudio .
  docker run --rm -v "$PWD:/src" -w /src meshagent-winaudio \
    bash docker/check-windows-audio.sh
The Windows CI build failed with 92 link errors. Two independent causes,
both invisible to the MinGW cross-compile check because MinGW papers over
each one.

1. Unresolved COM identifiers (LNK2001 on CLSID_MMDeviceEnumerator,
   IID_IMMDeviceEnumerator, IID_IAudioClient, IID_IAudioCaptureClient).

   MSVC only emits a GUID definition when INITGUID is defined *before* the
   declaring header is first seen. windows_audio.c included <initguid.h>
   ahead of <mmdeviceapi.h>, but mmdeviceapi.h had already been pulled in
   transitively via kvm_audio.h -> ILibParsers.h -> windows.h, so the
   definitions were never generated and only declarations remained.

   Rather than fight include order, the four identifiers are now defined
   directly in this translation unit under MESH_ names. Their values are
   fixed in the Windows SDK. MinGW hid this because its DEFINE_GUID always
   uses DECLSPEC_SELECTANY, which defines the GUID unconditionally.

2. libopus.lib was built with MinGW/GCC, not MSVC.

   The committed archives contain .o members referencing ___chkstk_ms,
   __mingw_vfprintf, __memcpy_chk and __memset_chk -- GCC runtime helpers
   the Microsoft CRT does not provide -- so MSVC could never link them.
   They were carried over from the previous branch and had not been
   exercised on Windows before.

   A static library must be produced by the toolchain that consumes it, so
   the Windows build now compiles Opus 1.5.2 from source with the same
   MSVC that builds the agent, for x86, x64 and ARM64, and caches the
   result per architecture. The unusable archives are removed, the path is
   gitignored, and lib-opus/windows/README.md documents the reasoning and
   the local equivalent. The Linux and macOS libraries stay committed,
   since GCC/Clang builds both the library and the agent there.

Also tightens docker/check-windows-audio.sh: it previously asserted the
GUIDs were "defined locally", which is true on MinGW regardless of the
bug. It now asserts the object leaves no COM identifier unresolved, which
is precisely the condition MSVC reports as LNK2001, so the check would
have caught cause 1.
x64 and ARM64 now build, but x86 failed with:

  MSVCRT.lib(chandler4gs.obj) : error LNK2019: unresolved external
  symbol __except_handler4_common

The preceding LNK4217 warnings identify the cause: malloc/free were
"imported by" libopus, meaning Opus had been built against the dynamic
CRT (/MD) while the agent links the static CRT (/MT). That mismatch makes
libopus emit /DEFAULTLIB:MSVCRT, which pulls in chandler4gs.obj and its
reference to __except_handler4_common. That symbol is part of x86-only
SEH, so x64 and ARM64 tolerated the same mismatched library while x86
failed hard -- which is exactly the pattern CI reported.

CMAKE_MSVC_RUNTIME_LIBRARY was already being passed, but it is only
honoured when policy CMP0091 is NEW, so it was silently ignored. The
workflow now sets CMAKE_POLICY_DEFAULT_CMP0091=NEW, additionally forces
/MT through CMAKE_C_FLAGS_RELEASE, checks the cmake exit codes, and runs
dumpbin /directives over the result to fail immediately if the library
still carries a DEFAULTLIB:MSVCRT directive. Verifying rather than
assuming matters here because two of the three architectures link a
mismatched CRT without complaining.

Separately, MeshService{,-2022}.vcxproj set RuntimeLibrary to
MultiThreadedDebug in Release|Win32 while every other Release
configuration used MultiThreaded, and that same configuration links the
release OpenSSL (libcrypto32MT.lib). Mixing debug and release CRTs in one
image is unsupported and compounds the failure above, so Release|Win32
now matches the other Release configurations. This is pre-existing
upstream and unrelated to audio, but it is corrected here because adding
libopus to that link is what exposed it.
The CRT verification added in the previous commit did its job: it stopped
the x86 build with "Opus was built against the dynamic CRT" instead of
letting the mismatched library reach the linker. Two things went wrong.

CMake reported:

  Manually-specified variables were not used by the project:
    CMAKE_POLICY_DEFAULT_CMP0091

so the policy override never applied and CMAKE_MSVC_RUNTIME_LIBRARY was
ignored again. Opus also listed OPUS_STATIC_RUNTIME among its disabled
features -- it has a purpose-built option for this, which is a better fit
than overriding CMake's runtime handling from outside. Switched to
-DOPUS_STATIC_RUNTIME=ON and dropped the CMAKE_C_FLAGS_RELEASE override,
which was additionally clobbering Opus's own optimisation flags.

Because CMake only warns (never fails) about unknown variables, the
configure step now reads the resulting cache back and aborts unless
OPUS_STATIC_RUNTIME really is ON, rather than trusting the command line.

Hardened the CRT check too: it previously skipped verification entirely
if dumpbin was not found, which would quietly disable the guard that just
caught this. It now searches both Program Files locations, fails if
dumpbin is missing, and requires a positive LIBCMT directive rather than
only the absence of MSVCRT.

Bumped the cache key to opus-...-msvc-static-v2. The x64 and ARM64 jobs
succeeded on the previous run and cached dynamic-CRT libraries under the
old key; without the bump they would restore those and skip the fix. They
linked only because __except_handler4_common is x86-only SEH, so their
CRT mismatch was latent rather than harmless.
All three jobs failed with "OPUS_STATIC_RUNTIME was not applied" even
though the log printed OPUS_STATIC_RUNTIME:BOOL=ON. The option was set
correctly and Opus built fine -- the check I added was wrong and rejected
a good build.

`cmake -L` prints Opus's feature summary as well as the cache, so
Select-String matched two lines and $cached was an array. In PowerShell
-match/-notmatch FILTER when the left operand is an array rather than
returning a boolean, so the expression evaluated to the feature-summary
line (which contains no "=ON"), and a non-empty array is truthy, so the
guard always threw. Both checks now join their output into a single
string first, and the cache test matches the exact entry
OPUS_STATIC_RUNTIME:BOOL=ON. Verified in pwsh against the literal strings
from the failing run: the old form throws on a correct build, the new
form passes on ON and still throws on OFF, and the dumpbin test accepts a
static-CRT library while rejecting a dynamic-CRT one.

Also switched the build from Opus 1.5.2 to 1.6.1. The headers vendored in
lib-opus/includes turn out to be 1.6.1 verbatim -- all six compare
byte-identical -- so 1.5.2 was compiling the agent against one version's
API while linking another's library. 1.6.1 makes the two agree, which is
what the version comment in the workflow now records.

Cache key bumped to opus-1.6.1-...-v3 so nothing is restored from the
earlier 1.5.2 attempts.
Building the agents in CI only helped if someone then downloaded the
artifacts and committed them by hand, which is why the Windows agents the
server was handing out were still the March binaries with no audio code
while Linux worked: that one had been rebuilt and committed manually.

Adds a deploy workflow that runs after a successful Windows or Linux
build on this branch, downloads the artifacts, installs them into
MeshCentral-jugu with the tools/install-*-agents.js scripts, and commits
the result. Both platforms trigger the same workflow, so it is serialised
with a concurrency group and retries the push after rebasing if the other
platform landed first.

The agents are committed to git rather than fetched by the server at
container start. What a server hands out determines what every managed
device installs, so it should be reviewable and revertible, the image
should stay self-contained and reproducible, and the production host
should not need GitHub credentials or a working network path to GitHub
during boot.

Also passes AUDIO=1 to the KVM Linux builds (ARCHID 5, 6, 26). Without it
CI would have published Linux agents with the audio support silently
removed, undoing what already works today. The NOKVM and Alpine targets
are deliberately left alone: they have no KVM, and only x86, x86-64 and
arm64 have a committed lib-opus to link against.

Requires a MESHCENTRAL_DEPLOY_TOKEN secret with write access to
MeshCentral-jugu; without it the deploy job fails at checkout and the
build jobs are unaffected.
The deploy workflow only runs on completion of a Windows or Linux build,
so it could not fire for the run that predated it. This empty commit
starts a fresh build whose artifacts it can publish.
Adds the reverse of the existing speaker feature: an administrator can
speak to the person at the managed device. Because that makes a remote
voice audible in someone's room, playback is gated on the local user
agreeing, and the gate is enforced in the agent rather than only in the
browser or the server.

Protocol (meshcore/meshdefines.h), using the next free command ids:
  MNG_MIC_QUERY 95, MNG_MIC_CAPS 96, MNG_MIC_START 97,
  MNG_MIC_STOP 98, MNG_MIC_DATA 99
MNG_MIC_DATA reuses the audio frame layout, so the two directions share
one wire format.

meshcore/KVM/kvm_mic.h with linux_mic.c (PulseAudio) and windows_mic.c
(WASAPI shared render) decode Opus to 48 kHz mono and play it. Both
refuse to open the output device without consent and discard every
MNG_MIC_DATA frame while it is absent, so a client that skips the
handshake gains nothing. Consent is per session: stopping playback,
revoking, or ending the tunnel all clear it, and the next attempt
prompts again. Timing out never counts as agreement.

windows_mic.c declares the COM GUIDs locally for the same reason as
windows_audio.c, and converts the decoded mono into whatever mix format
the endpoint reports rather than assuming one.

test/mic/run.sh compiles the real linux_mic.c against stub Opus and
PulseAudio and asserts the gate directly: audio discarded before
consent, start() refused without it, playback stopping the instant it is
revoked, stop() clearing it, and malformed frames rejected safely. It
needs no audio hardware, so it runs anywhere.

Verified: Linux x86-64 builds clean, and windows_mic.c compiles without
warnings for x64 and x86 against the real Windows headers.
…or's

The first implementation had the direction backwards. It played the
administrator's microphone through the device's speakers, which is the
push-to-talk feature intended for later, not the one asked for. The
browser consequently prompted the administrator for microphone access,
which it should never do.

Corrected so the device's own microphone is captured and streamed to the
operator, letting them hear the user speaking and diagnose noises such as
fans or clicking drives. This is the same direction as the existing audio
feature; only the source differs, so linux_mic.c and windows_mic.c are now
siblings of linux_audio.c and windows_audio.c rather than their inverse.
Linux records from PulseAudio's default input rather than
"@DEFAULT_MONITOR@", and Windows opens the eCapture endpoint without
AUDCLNT_STREAMFLAGS_LOOPBACK.

While fixing this I found the consent path could never have worked:
MNG_MIC_START called kvm_mic_start(), which correctly refuses without
consent, but nothing ever called kvm_mic_set_consent(1). Added
MNG_MIC_CONSENT (100), sent only by the agent's own consent flow once the
local user accepts. Keeping it separate from MNG_MIC_START means a
browser frame can request capture but never grant permission for it, and
meshcore.js drops command 100 if it ever arrives over the tunnel.

kvm_mic_feed() is retained as a no-op so the KVM command switch stays
symmetrical with the audio path; audio only travels device -> browser
here, so anything inbound on MNG_MIC_DATA is discarded.

test/mic now asserts the capture-direction properties: the microphone is
not opened without consent, nothing is encoded before it is granted,
capture stops when it is revoked, and stop() clears it so a later start
re-prompts. Fixed a flaw in the harness itself, where the fake
libpulse-simple referenced a counter in the test executable that dlopen
cannot resolve, which made the library fail to load and the test report
passes for the wrong reason.
The Windows agent sent a hard-coded 0x07 in the MNG_MIC_CAPS flags byte,
left over from the audio implementation it was derived from. The browser
reads bit0 as "microphone available" and bit1 as "consent granted", so
0x07 claimed a microphone that might not exist and, worse, always claimed
consent had been given. The button state was therefore meaningless on
Windows.

It now probes for a default capture endpoint through the same COM path
capture uses, and reports the live consent flag, matching what
linux_mic.c already sent. A machine with no microphone hides the control
instead of offering a button that cannot work.
The deploy workflow filtered workflow_run to feature/audio-stream, so the
microphone work on feature/mic-stream-v2 built successfully and then
deployed nothing. The failure was silent: the builds went green while the
server carried on serving the previous agents, which is exactly the gap
this workflow exists to close.

The branch filter is removed, and the job now publishes to the MeshCentral
branch of the same name when one exists, falling back to
feature/audio-stream otherwise. Work on a feature branch therefore lands
on its own counterpart instead of on the shared branch.
…om native code

Windows kvm_mic_start() never checked g_consent at all: MNG_MIC_START from
the browser started real microphone capture unconditionally, as long as an
encoder existed and nothing was already running. The doc comment, the Linux
implementation, and the whole design promised the opposite (fail closed
without a local decision) -- this brings Windows in line with that.

With the gate now real on both platforms, the consent prompt still needs a
way to fire. Two attempts at that earlier this branch (a plain-object relay
piped in place of the native KVM stream, and a wrapper on kvm.write) broke
the desktop video stream, because JS has no safe way to intercept a
multiplexed KVM tunnel from either side of pipe()/unpipe().

So this drives it from the one place that already sees every MNG_MIC_START
and already knows the true consent state: native code. kvm_mic_start(), in
linux_mic.c / windows_mic.c, now emits a new upward-only command,
MNG_MIC_CONSENT_NEEDED, through the exact same slave-to-master channel
MNG_MIC_CAPS/MNG_MIC_DATA already use in production -- when it refuses
specifically because consent (not hardware) is missing. agentcore.c's
existing KVM write-sink (already special-cases MNG_AUDIO_QUERY/MNG_MIC_QUERY
there) intercepts it and calls into meshcore.js's onMicConsentNeeded()
via duk_peval_string(), the same native-to-JS mechanism already used
elsewhere in that file (RemoteDesktop_EndSink's SendCommand call) --
never touching the stream/pipe machinery that broke last time.

kvm_mic_start() is now also called once, speculatively, right after the KVM
slave starts on both platforms, so a device with a real microphone offers
the consent prompt at session open instead of only after the operator
clicks. Calling it there is a no-op unless consent is genuinely the only
thing blocking it, so it costs nothing on devices with no microphone.

test/mic/run.sh's consent-gate suite, run against the real linux_mic.c
(now 21 assertions, up from 15), covers the new notify behavior: fires once
per genuine refusal, not when hardware is absent, not while already
capturing, and again after stop() revokes consent. Windows validated by
CI/Wine only, per how this project already validates Windows changes.
Companion to the MeshCentral-jugu commit adding Audio/Mic quick-toggle
buttons that connect without opening the Desktop panel. Without this, such
a session still did full screen capture/diff/send under the hood, just
discarded unread by the browser -- not the point of an audio/mic-only mode.

g_remotepause was already set correctly by the existing MNG_KVM_PAUSE
switch case on both platforms, but nothing ever read it: grepping the
whole tree found zero readers. This wires a read into each platform's
tile-capture loop, skipping straight to a paced sleep+continue when set.

Linux: inserted right after the incoming-command read (so an unpause can
still arrive) and before screen capture starts, explicitly closing the
per-iteration X11 display handle first so it isn't leaked by skipping the
end-of-iteration cleanup that normally closes it.

Windows: KVM command handling runs on its own thread (kvm_mainloopinput),
independent of this tile loop, so the check can sit right at the top and
skip everything below unconditionally.

Re-ran test/mic/run.sh against the real linux_mic.c (21/21) as a
regression check -- this doesn't touch consent state, but it's adjacent
code in the same file this session has broken twice before.
…indows)

kvm_mic_start()'s refusal path notified the JS layer to show a consent
prompt whenever consent was missing, without checking whether a real
capture device even exists -- only g_enc (the Opus encoder, which is
software and always creatable) gated it, not real hardware. On a machine
with no microphone at all, the browser correctly never shows the mic
button (mic_send_caps() already calls microphone_available() for that),
but the operator opening a desktop session would still make the local
user see "wants to listen through your microphone" for a device that was
never offered as available in the first place.

Fixed by gating the notify on the same microphone_available() COM probe
already used for CAPS reporting, mirroring how the Linux side already
gates on g_pa_lib (the PulseAudio library) before ever deciding to notify.
Two problems with the consent prompt, both reported from real use.

First, the prompt fired at KVM session open, so opening a desktop session
asked the local user about the microphone even when the operator only
wanted the screen and never touched the mic -- a permission dialog for
something nobody requested. The speculative kvm_mic_start() at session
start is removed on both platforms; the prompt now happens only on a real
MNG_MIC_START, which is the thing the local user is actually being asked
about.

Second, clicking the microphone button again to cancel (or after clicking
it by mistake) left the dialog on the device screen, asking about a
request nobody was waiting on any more. kvm_mic_stop() now emits
MNG_MIC_CONSENT_CANCEL, the counterpart of the existing
MNG_MIC_CONSENT_NEEDED, which agentcore.c turns into a call to
onMicConsentCancelled() so the JS layer closes the dialog.

Whether a prompt is outstanding is tracked explicitly (g_promptOutstanding)
rather than inferred from consent/thread state. The test caught why that
matters: inferring it made a second stop emit a stray cancel, which would
later close an unrelated prompt. Set when the prompt is raised, cleared
when it is answered or cancelled.

test/mic/run.sh extended to cover both (23 assertions): a stop while
awaiting consent cancels exactly once, and a stop with nothing pending
does not cancel at all.
The mic encoder was fully hardcoded (28 kbps, FEC/DTX on, complexity 5,
and even a cross-platform mismatch: Linux used VOIP application mode,
Windows used AUDIO) with no way for the operator to trade quality for
bandwidth. Extends MNG_MIC_START with an optional 8-byte settings
payload (bitrate, application mode, VBR/CBR, bandwidth, frame size,
complexity, DTX, FEC, packet loss hint) that kvm_mic_start() applies to
the live encoder -- recreating it only when application mode changes,
since that's not safely live-updatable, and applying everything else
via opus_encoder_ctl without interrupting an already-running session.

Fully backward compatible in both directions: a legacy 4-byte START (or
one from a server that predates this) is treated as "keep current
settings", and MNG_MIC_CAPS now reports a protocol-version byte so the
browser can tell whether a given agent understands the extended frame
at all before offering profile selection.

Also unifies both platforms on OPUS_APPLICATION_VOIP as the compiled-in
default (this captures speech/room noise, not music), and moves the
capture frame size from a compile-time constant to a runtime value
bounded by a max-size buffer, since profiles can now request up to 60ms
frames instead of the previous fixed 20ms.
…efault

Adds MNG_MIC_DEVICE_QUERY/MNG_MIC_DEVICE_LIST so the browser can ask an
agent to enumerate its capture devices and offer them for selection,
defaulting to the system's default input as before. The enumerated
order is that session's index space; MNG_MIC_START grows a 9th
settings byte (deviceIndex, 0xFF = default, out-of-range also falls
back to default) referencing it.

Linux: enumeration needs PulseAudio's async context API, which
pa_simple never exposed -- added a second, independent dlopen of the
full libpulse.so.0 (pa_simple-only capture still works unaffected if
this one is missing) driving a bounded synchronous mainloop-iterate
loop (3s timeout, own polling rather than trusting pa_mainloop_iterate's
blocking variant). Struct layouts (pa_source_info et al) come from the
real <pulse/pulseaudio.h> at compile time -- confirmed needed the hard
way, since this file's own hand-rolled pa_sample_spec/pa_stream_direction_t
now collided with it once both were in scope -- while every function is
still resolved via dlsym(), never linked, so a system without PulseAudio
still runs everything else in this binary. linux-build.yml gains
libpulse-dev for the header only.

Windows: enumeration is IMMDeviceEnumerator::EnumAudioEndpoints, already
the same family of interfaces this file uses for capture; two more
locally-defined GUIDs (IMMDeviceCollection, IPropertyStore) follow the
file's existing INITGUID workaround.

Neither platform's audio API has a live "switch device" primitive, so
unlike every other MNG_MIC_START field, a device change makes
mic_apply_params() report back to kvm_mic_start(), which stops and
restarts the capture thread on the new endpoint -- without touching
consent, so the operator isn't re-prompted just for changing input.

Both native implementations were syntax/type-checked against their real
platform headers before this commit (gcc -fsyntax-only against the
actual libpulse-dev + this repo's own lib-opus/includes for Linux; MSVC
itself isn't available to verify Windows, so that side leans on closely
mirroring the already-working capture code's COM patterns).
Root cause of "Mic panel: consent prompt never shows, on both
platforms": kvm_mic_query_devices() ran its PulseAudio/WASAPI
enumeration directly on the same single-threaded KVM command dispatch
loop that also processes MNG_MIC_START. The Mic panel sends
SendMicDeviceQuery() immediately before SendMicStart() (see
p22Toggle() in MeshCentral-jugu), so a slow or unresponsive audio
daemon delayed the very command that triggers the prompt behind
however long enumeration took (up to ~6s in the worst case) --
exactly the class of bug mic_capture_thread already exists to avoid
for the streaming side, which this function didn't follow. Both
platforms now spawn the actual enumeration on its own thread and
return immediately; a query that arrives while one is already running
is coalesced into a no-op rather than started concurrently, since two
overlapping enumerations would stomp each other's device list.

Also adds a way for the Mic panel specifically to skip the interactive
local-user prompt entirely, per feedback: an operator on that panel
isn't watching the desktop anyway, so there's nothing for an
interactive prompt to add, whereas the Desktop panel's own mic button
should keep going through it whenever server policy requires one.
Implemented as a *request* bit in MNG_MIC_START's miscFlags, carried
through MNG_MIC_CONSENT_NEEDED to the agent's JS layer -- native code
still never grants its own consent (kvm_mic_start()'s fail-closed gate
is unchanged), and the actual authorization decision is made by
agents/meshcore.js's micConsentHandleStart(), the same trusted place
that already decides whether policy requires a prompt at all, treating
"panel asked to skip it" as a second way to reach the exact fast path
policy-bypass already uses (still logged, still tells native to
actually start capturing -- setting the flag without that was the
original bug this fast path was written to fix).

Separately, investigated the reported Windows Desktop-tab symptom
(prompt doesn't appear until a second click, which cancels it, then it
flashes and disappears): micConsentHandleStart()'s Windows "enhanced"
dialog path calls server_getUserImage() -- a round-trip to the server
-- before creating the actual win-userconsent dialog. A cancel that
arrives during that window finds nothing to close yet (the dialog
doesn't exist, so pr.close()/pr.__childPromise.close() are both still
undefined) and is silently lost. Now closes the dialog immediately
upon creation if a cancellation was already requested, reusing the
exact close() path the normal cancel flow already relies on rather
than a second bespoke one. This path is pre-existing and unrelated to
the enumeration-threading fix above; not confirmed to be a regression
from recent work, but it matches the reported symptom precisely.
Adds the device-side half of remote webcam viewing: MNG_CAM_* commands
(105-116), the kvm_cam.h contract, and a V4L2 implementation built on
the same shape linux_mic.c already proved -- a capture thread that owns
the device, a fail-closed consent gate driven by the agent's JS layer,
and enumeration that never runs on the KVM command thread.

Two deliberate design choices worth recording:

Streams complete JPEG frames rather than a video codec. Essentially
every UVC webcam already emits MJPEG in hardware, so the common path
forwards the camera's own bytes untouched and spends zero CPU encoding
-- which is what makes this usable on the ARM boards this agent runs
on, where a software H.264 encoder would saturate the machine it is
meant to be diagnosing. Self-contained frames also mean a drop costs
one frame instead of corrupting everything until the next keyframe.
Cameras that cannot produce MJPEG fall back to YUYV plus libjpeg-turbo,
already linked for desktop tiles, so no new third-party library is
introduced.

Does NOT reuse the desktop's tile differencing. That works because
desktop content is static enough for most tiles to be byte-identical
frame to frame; camera sensors perturb essentially every pixel of every
frame with thermal and shot noise, so exact-match tiling would find
almost nothing to skip while costing CPU to look. The equivalent that
does work is whole-frame suppression with a tolerance -- decide whether
the scene changed rather than whether the bytes did -- implemented via
libjpeg-turbo's scaled (1/8) decode, which downscales during entropy
decoding rather than after. An unattended room then costs near-zero
bandwidth while staying live the instant anything moves.

Frames exceeding the KVM header's 16-bit length are sent wrapped in
MNG_JUMBO, which both receivers already understand generically, so
nothing downstream needed changing to carry full-resolution stills.

Gated behind CAMERA=1 / -D_KVM_CAMERA. Verified that camera-enabled,
audio-only and plain-KVM configurations all still compile, and that
linux_cam.c reduces to an empty translation unit when the flag is off.
Enables CAMERA=1 on the same three KVM-capable targets that already get
AUDIO=1: x86 (5), x86-64 (6) and arm64 (26). The NOKVM and Alpine
targets stay without it.

No new package is required. V4L2 is kernel ioctls through
linux/videodev2.h, which comes from libc6-dev and is already installed,
and the JPEG encode/decode the camera path uses is the libjpeg-turbo the
KVM build already links for desktop tiles -- so unlike AUDIO=1, which
needed libpulse-dev added here, this adds nothing to the install step.

Verified with "make -n" that CAMERA=1 compiles linux_cam.c and defines
_KVM_CAMERA exactly as AUDIO=1 does for linux_mic.c, and that neither
appears without the flag.
…rleave

A pipe write is only atomic up to PIPE_BUF (4 KiB) and a camera frame is
far larger, so two threads writing at once would interleave their bytes
and corrupt both frames.

This was reachable, not theoretical: the one-shot snapshot worker only
spawns when nothing is streaming, but the operator can start the stream
while that worker is still in flight, which puts two writers on the same
fd at the same moment. send_caps() and the device-list sender were also
writing directly rather than through cam_write_out(), so either could
land in the middle of a frame the capture thread was emitting.

All four senders now go through cam_write_out(), which serializes on its
own mutex -- deliberately not g_lock, since callers reach it both holding
and not holding that, and a frame write must not block the capture
thread's per-frame settings checks behind it.
MNG_CAM_CONSENT carried no payload saying which kind of request it
was answering, so its dispatcher case always called kvm_cam_start(),
even when the local user was only granting a one-shot snapshot -- a
photo request would silently turn on the live stream too, and any
settings the original request carried were dropped across the
consent round-trip since neither fail-closed branch ever applied
them.

Native now tracks what was actually pending (g_pendingAction, a
bitmask so a rapid snapshot-then-start click before the user answers
doesn't lose either one) and replays exactly that once consent is
granted, via the new kvm_cam_consent_granted(). Snapshots also no
longer piggyback on the live stream's resolution: cam_snapshot_worker
now pauses a running stream, opens the device at its own resolution
(the operator's request, or the camera's true maximum via the new
VIDIOC_ENUM_FRAMESIZES-based cam_find_max_resolution() when none was
given), and resumes the stream afterward. CAM_SNAPSHOT_QUALITY is
bumped to 100 so a still defaults to the camera's best.
Implements windows_cam.c as the Windows counterpart to linux_cam.c: MJPEG
passthrough when the camera offers it, YUY2/NV12 raw fallback via WIC
otherwise, static-scene suppression, independent-resolution snapshots, device
enumeration, and the same fail-closed consent gate. Live streaming uses an
asynchronous IMFSourceReaderCallback (stopped via Flush(), never a blocking
read) so a wedged or unplugged camera cannot hang session teardown; snapshots
stay synchronous since they are already bounded by a small attempt count.
kvm_cam_consent_granted() ports verbatim from Linux, which is what keeps
Windows from reintroducing the "photo also starts the stream" bug already
fixed there.

Wires the MNG_CAM_* dispatcher into kvm.c using the already-fixed consent
pattern (not the still-buggy kvm_mic_start(NULL,0) pattern next to it), and
adds windows_cam.c/kvm_cam.h plus the Media Foundation and WIC import libs to
every build configuration in both vcxproj files.
…rnal

CI (all three architectures) failed with LNK2019 on MFGetAttributeSize --
this SDK/lib configuration doesn't provide it as the header-inline helper
it was assumed to be. Replaced the one call site with a direct
IMFAttributes::GetUINT64 vtable call and manual high/low-32-bit unpack of
MF_MT_FRAME_SIZE, matching every other MF method call in this file that
already links cleanly.
camera_available() called MFCreateAttributes()/MFEnumDeviceSources() without
initializing COM on the calling thread. Every other MF-calling function in
this file runs on a worker thread that wraps CoInitializeEx/CoUninitialize
around its own body, but camera_available() is called from send_caps(),
which also runs directly on the KVM command dispatch thread for
kvm_cam_init()/kvm_cam_set_consent()/kvm_cam_stop()/kvm_cam_resend_caps() --
a thread nothing else here ever initializes COM on. MFCreateAttributes()
failing with CO_E_NOTINITIALIZED on that thread made a real, present camera
get reported as unavailable. Fixed by wrapping the function's own body with
CoInitializeEx/CoUninitialize, mirroring windows_mic.c's
microphone_available(), which already does this correctly.
Two reliability gaps, neither hit in normal testing but both real on
hardware beyond a typical USB UVC webcam:

- cam_dev_open() only ever tried MJPEG then YUYV. A camera offering neither
  (UYVY, common on some webcams/capture dongles; NV12, the ISP-native planar
  format on many ARM/CSI-connected cameras -- boards this agent explicitly
  targets) would enumerate fine in V4L2 yet be entirely unusable here.
  Extended the fallback chain to MJPEG -> YUYV -> UYVY -> NV12, refactored
  encode_yuyv_jpeg() into a generic encode_rgb_jpeg() plus per-format
  raw_to_rgb()/raw_thumbnail() dispatchers so adding a format means one new
  converter pair, not touching every call site. cam_find_max_resolution()'s
  fallback chain in cam_snapshot_worker() extended to match.
- The live stream sent its very first frame(s) immediately after opening the
  device, unlike the snapshot path, which already discards the first 3
  attempts while auto-exposure/white-balance settle. On a camera with real
  settling time, that meant the stream's first visible frame could be dark
  or half-exposed, and static-scene suppression would lock onto it as the
  baseline until a genuine scene change happened to clear it. Added the same
  3-frame warmup discard to capture_thread(), counted on every grabbed frame
  independent of pacing.

Verified via gcc -fsyntax-only -Wall -Wextra (zero warnings) -- same
platform-neutral logic can't be exercised against real hardware in this
environment, so behavioral verification is still a follow-up.
The camera code was added to meshconsole/MeshConsole*.vcxproj, but the
binaries MeshCentral actually deploys are MeshService.exe / MeshService64.exe
/ MeshServiceARM64.exe, built from meshservice/MeshService*.vcxproj. Those
projects carry windows_mic.c and _KVM_AUDIO but never got windows_cam.c or
_KVM_CAMERA, so every camera symbol compiled to nothing and the agent had no
MNG_CAM_* handling whatsoever -- which is why Windows reported "This device
has no usable camera" no matter how many times the agent was reinstalled.

Confirmed empirically rather than by inspection: the deployed MeshService64.exe
imports ADVAPI32/COMCTL32/CRYPT32/dbghelp/GDI32/gdiplus/IPHLPAPI/KERNEL32/
ncrypt/ole32/sas/Shcore/SHELL32/User32/Ws2_32 and nothing else -- no Mf.dll,
Mfplat.dll, Mfreadwrite.dll or Windowscodecs.dll -- while Opus (i.e.
_KVM_AUDIO) is present, so the mic half shipped and the camera half did not.

Applies the same four-part edit already made to the meshconsole projects, to
all 12 configurations of MeshService-2022.vcxproj (which the CI solution
builds for Win32/x64/ARM64) and all 8 of the legacy MeshService.vcxproj.
cam_dev_open() called IMFActivate::ShutdownObject() immediately after
ActivateObject(), while still holding and intending to use the media source
it had just created. Microsoft's contract is explicit -- "call ShutdownObject
when you are done using the object" -- so this tore the source down before a
single frame could be read, and every later call on it returned
MF_E_SHUTDOWN.

That is why Windows could detect a camera but never actually use one: device
enumeration (MFEnumDeviceSources) never activates anything, so caps correctly
reported a camera present, while both the live stream and the snapshot failed
the instant they tried to open it. Releasing the activation object alone is
correct and does not disturb the source.

Also gives the live session's IMFMediaSource a deterministic lifetime: it is
now held in g_liveSource and explicitly Shutdown() by cam_teardown_live()
after the reader is released, instead of being dropped and left for COM to
destroy whenever the reader's last reference went away. The device is
single-open and cam_snapshot_worker() pauses the live stream specifically so
it can reopen the same camera, so the device has to be free the moment
teardown returns, not eventually.
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