Files
appsandbox/tools/linux/agent/appsandbox-audio.c
T
James Stringer 2309cec029 Add Linux VM support with GPU-PV
Ubuntu 26.04 LTS guest end-to-end:

- cidata-driven autoinstall pipeline (subiquity user-data + nested
  cloud-init bootcmd) in disk_util.c. Static NAT IP, ssh-via-vsock
  marker, serial-debug kernel cmdline injected into ESP grub.cfg by
  iso-patch.exe. Five separate firstboot pipeline fixes get the VM
  to GDM unattended on Hyper-V Gen 2.

- Linux guest stack under tools/linux/:
    - appsandbox-agent  (vsock :1 control + clipboard helper spawn)
    - appsandbox-audio  (snd-aloop -> vsock :4)
    - appsandbox-display (KMS framebuffer capture -> vsock :2)
    - appsandbox-input  (uinput injection <- vsock :3)
    - appsandbox-clipboard (XCB selection bridge <-> vsock :5/:6)
  Helper binaries staged via release/resources/linux/.

- asb_drm DRM module: virtual single-CRTC display sink the host
  captures over vsock. Platform-bus, DRIVER_GEM/MODESET/ATOMIC/RENDER,
  linear shmem framebuffers. asb-evict-simpledrm.service unbinds the
  EFI simple-framebuffer before display-manager so mutter binds
  asb_drm as the sole DRM card.

- dxgkrnl vendored byte-identical from microsoft/WSL2-Linux-Kernel
  linux-msft-wsl-6.18.y with our DKMS/out-of-tree shim (dxgkrnl_compat.h
  + Kbuild + dkms.conf). Autoinstall late-commands dkms-builds against
  every /target/lib/modules/<kver> so kernel-version drift between ISO
  and post-update reboot still leaves a working .ko.

- wsl-mesa custom build (Mesa 25.3 + d3d12 gallium + microsoft-
  experimental dzn Vulkan). Installs to /opt/wsl-mesa parallel to
  Ubuntu's stock Mesa. Prebuilt tarball for ubuntu-26.04-amd64
  shipped under tools/linux/wsl-mesa/prebuilt/ (21 MB).

- Per-VM GPU acceleration policy:
    - /etc/systemd/user-environment-generators/50-appsandbox-gpu
      emits GALLIUM_DRIVER=d3d12 + LD_LIBRARY_PATH + VK_DRIVER_FILES
      into the user@1000 systemd-user environment when /dev/dxg and
      /opt/wsl-mesa are present. Inherited by every app-*.scope a
      .desktop launch creates.
    - /etc/systemd/user/org.gnome.Shell@.service.d/no-gpu.conf
      UnsetEnvironment= strips these for the compositor — mutter
      stays on llvmpipe so asb_drm scanout works (the d3d12 pipe has
      no GBM compatibility with asb_drm).
    - /usr/local/bin/appsandbox-gpu wrapper for terminal-launched
      opt-in GPU acceleration (probes /dev/dxg; no-op on no-GPU VMs).

- Host changes:
    - vm_display_idd.c, vm_clipboard.c, vm_agent.c, vm_ssh_proxy.c,
      asb_display.c: hcs_service_guid(os_type, port) so the same code
      paths reach the per-OS service identifiers (HV_GUID for Windows,
      vsock template for Linux).
    - asb_core.c: linux_create_thread builds an empty VHDX, cidata
      ISO with extras (agent ELFs, dxgkrnl src, asb_drm src, wsl-mesa
      tarball, wsl-deps libs), and boots the desktop ISO + cidata.
    - hcs_vm.c: HostLxssLib Plan9 share appended for Linux VMs;
      VideoMonitor + ComPorts retained for serial debug.
    - gpu_enum.c/h: gpu_append_lxsslib_share helper.
    - vm_clipboard.c file-drop support (CF_HDROP / x-special/gnome-
      copied-files round-trip).
    - vm_display_idd.c: 32-bit S32_LE PCM audio path matches the
      Linux daemon's snd-aloop capture format.

- iso-patch.c gains a patch_ubuntu_esp_grub_for_debug pass that
  rewrites /EFI/ubuntu/grub.cfg via the FAT32 ESP, plus a qcow2->
  vhdx converter with a RFC 1951 DEFLATE inflater for compressed
  cluster handling.

- wsl-deps: Microsoft's libd3d12.so / libd3d12core.so / libdxcore.so
  fetched on demand from the public NuGet feed
  pkgs.dev.azure.com/shine-oss/wsl (no auth). Staged at
  /opt/appsandbox/wsl-deps on the guest and put on /etc/ld.so.conf.d
  rather than /usr/lib/wsl/lib (which is overlaid by the agent's
  9P mount).

UI: web/{app.js,index.html} expose Linux VM creation + GPU selection,
distribution dropdown for cached Ubuntu ISOs, and the ISO picker for
custom images.
2026-05-19 20:21:43 -04:00

323 lines
11 KiB
C

/*
* appsandbox-audio.c — Linux audio capture for AppSandbox.
*
* Listens on AF_VSOCK port 4, captures the system audio output from the
* snd-aloop kernel loopback device, and ships PCM frames over vsock in
* the ASA1 wire format the Windows host already understands (see
* src/backend_win/vm_display_idd.c).
*
* Architecture — kernel-level capture, no PipeWire/PulseAudio in the
* hot path:
*
* app → pipewire (or any audio API)
* ↓ ALSA backend
* hw:Loopback,0 (playback)
* ↓ kernel snd-aloop module loops here
* hw:Loopback,1 (capture) ← we read this with libasound
* ↓
* vsock :4 → host WASAPI render
*
* The user-session audio routing (telling PipeWire to use hw:Loopback,0
* as the default sink) is set up separately via a wireplumber policy
* file; this daemon assumes it's already in place.
*
* Wire protocol (see docs/linux-idd-implementation-plan.md §"Port 4"):
*
* one-shot AudioHeader on connect:
* u32 magic = 'ASA1' (0x31415341)
* u32 sample_rate = 48000
* u16 channels = 2
* u16 bits = 16
* u16 format_tag = 1 (WAVE_FORMAT_PCM)
* u16 block_align = 4
*
* then repeating:
* u32 bytes
* u8 pcm[bytes]
*
* Build: gcc -O2 -Wall -o appsandbox-audio appsandbox-audio.c -lasound
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdarg.h>
#include <stdint.h>
#include <unistd.h>
#include <errno.h>
#include <signal.h>
#include <sys/socket.h>
#include <linux/vm_sockets.h>
#include <alsa/asoundlib.h>
#define VSOCK_PORT 4
#define AUDIO_HEADER_MAGIC 0x31415341u /* 'ASA1' */
/* Negotiated format. snd-aloop requires the capture and playback halves
* to have matching hw_params — we can't negotiate independently. PipeWire
* opens its end as S32_LE / 48k / 2ch / 1024-frame period, so we follow
* suit. WASAPI on the host side accepts S32 PCM via format_tag=1
* (WAVE_FORMAT_PCM) and bits_per_sample=32. */
#define SAMPLE_RATE 48000
#define CHANNELS 2
#define BITS_PER_SAMPLE 32
#define BLOCK_ALIGN ((CHANNELS * BITS_PER_SAMPLE) / 8) /* 8 */
/* PipeWire's ALSA backend opens snd-aloop with a 1024-frame period at
* 48 kHz = ~21 ms. Match it so the capture side's hw_params line up. */
#define FRAMES_PER_PACKET 1024
#define BYTES_PER_PACKET (FRAMES_PER_PACKET * BLOCK_ALIGN)
/* snd-aloop pairs playback subdev N on device 0 with capture subdev N
* on device 1. Any of the eight subdevs would work; pick 0. */
#define LOOPBACK_CAPTURE_PCM "hw:Loopback,1,0"
#pragma pack(push, 1)
struct audio_header {
uint32_t magic;
uint32_t sample_rate;
uint16_t channels;
uint16_t bits_per_sample;
uint16_t format_tag;
uint16_t block_align;
};
struct audio_frame_header {
uint32_t bytes;
};
#pragma pack(pop)
static volatile sig_atomic_t g_stop = 0;
static void on_signal(int s) { (void)s; g_stop = 1; }
static void aud_log(const char *fmt, ...)
{
va_list ap;
va_start(ap, fmt);
fprintf(stderr, "[audio] ");
vfprintf(stderr, fmt, ap);
fprintf(stderr, "\n");
va_end(ap);
}
/* ---- vsock listener ---- */
static int vsock_listen(unsigned port)
{
int s = socket(AF_VSOCK, SOCK_STREAM, 0);
if (s < 0) { aud_log("socket: %s", strerror(errno)); return -1; }
struct sockaddr_vm sa = {0};
sa.svm_family = AF_VSOCK;
sa.svm_cid = VMADDR_CID_ANY;
sa.svm_port = port;
if (bind(s, (struct sockaddr *)&sa, sizeof(sa)) < 0) {
aud_log("bind :%u: %s", port, strerror(errno));
close(s); return -1;
}
if (listen(s, 1) < 0) {
aud_log("listen: %s", strerror(errno));
close(s); return -1;
}
return s;
}
static ssize_t send_all(int fd, const void *buf, size_t len)
{
const uint8_t *p = (const uint8_t *)buf;
size_t left = len;
while (left > 0) {
ssize_t n = send(fd, p, left, MSG_NOSIGNAL);
if (n < 0) {
if (errno == EINTR) continue;
return -1;
}
if (n == 0) return -1;
p += n; left -= (size_t)n;
}
return (ssize_t)len;
}
/* ---- ALSA capture from snd-aloop ---- */
static snd_pcm_t *open_loopback_capture(void)
{
snd_pcm_t *pcm = NULL;
snd_pcm_hw_params_t *hw = NULL;
int rc;
rc = snd_pcm_open(&pcm, LOOPBACK_CAPTURE_PCM, SND_PCM_STREAM_CAPTURE, 0);
if (rc < 0) {
aud_log("snd_pcm_open %s: %s", LOOPBACK_CAPTURE_PCM, snd_strerror(rc));
return NULL;
}
snd_pcm_hw_params_alloca(&hw);
snd_pcm_hw_params_any(pcm, hw);
/* MUST be non-interleaved: snd-aloop's loopback_check_format() in
* sound/drivers/aloop.c returns -EIO when capture and playback
* disagree on is_access_interleaved(). PipeWire opens its end as
* MMAP_NONINTERLEAVED, so we follow suit. We interleave in user
* space before shipping over the wire because the host's WASAPI
* IAudioClient expects interleaved L/R samples. */
snd_pcm_hw_params_set_access (pcm, hw, SND_PCM_ACCESS_RW_NONINTERLEAVED);
snd_pcm_hw_params_set_format (pcm, hw, SND_PCM_FORMAT_S32_LE);
snd_pcm_hw_params_set_channels(pcm, hw, CHANNELS);
rc = snd_pcm_hw_params_set_rate(pcm, hw, SAMPLE_RATE, 0);
if (rc < 0) aud_log("set_rate strict failed: %s — retrying _near", snd_strerror(rc));
if (rc < 0) {
unsigned rate = SAMPLE_RATE;
snd_pcm_hw_params_set_rate_near(pcm, hw, &rate, NULL);
}
/* snd-aloop requires the period AND buffer size to match the
* playback half. PipeWire opens its end as 1024-frame period,
* 32768-frame buffer; we mirror exactly. Without strict matching,
* snd_pcm_readi returns -EIO regardless of how active the playback
* side is. */
rc = snd_pcm_hw_params_set_period_size(pcm, hw, FRAMES_PER_PACKET, 0);
if (rc < 0) {
aud_log("set_period_size strict failed: %s", snd_strerror(rc));
snd_pcm_uframes_t period = FRAMES_PER_PACKET;
snd_pcm_hw_params_set_period_size_near(pcm, hw, &period, NULL);
}
rc = snd_pcm_hw_params_set_buffer_size(pcm, hw, FRAMES_PER_PACKET * 32);
if (rc < 0) {
aud_log("set_buffer_size strict failed: %s", snd_strerror(rc));
snd_pcm_uframes_t buffer = FRAMES_PER_PACKET * 32;
snd_pcm_hw_params_set_buffer_size_near(pcm, hw, &buffer);
}
rc = snd_pcm_hw_params(pcm, hw);
if (rc < 0) {
aud_log("snd_pcm_hw_params: %s", snd_strerror(rc));
snd_pcm_close(pcm);
return NULL;
}
snd_pcm_prepare(pcm);
{
snd_pcm_uframes_t pp = 0, bp = 0;
unsigned actual_rate = 0;
int dir = 0;
snd_pcm_hw_params_get_rate (hw, &actual_rate, &dir);
snd_pcm_hw_params_get_period_size(hw, &pp, &dir);
snd_pcm_hw_params_get_buffer_size(hw, &bp);
aud_log("capturing from %s @ %u Hz, %u ch, S32_LE, period=%lu, buffer=%lu",
LOOPBACK_CAPTURE_PCM, actual_rate, CHANNELS,
(unsigned long)pp, (unsigned long)bp);
}
return pcm;
}
/* ---- session: handshake + stream loop ---- */
static void serve(int client_fd)
{
snd_pcm_t *pcm = open_loopback_capture();
if (!pcm) return;
struct audio_header h = {
.magic = AUDIO_HEADER_MAGIC,
.sample_rate = SAMPLE_RATE,
.channels = CHANNELS,
.bits_per_sample = BITS_PER_SAMPLE,
.format_tag = 1, /* WAVE_FORMAT_PCM */
.block_align = BLOCK_ALIGN,
};
if (send_all(client_fd, &h, sizeof(h)) < 0) {
aud_log("send header: %s", strerror(errno));
snd_pcm_close(pcm);
return;
}
/* Stream loop. snd_pcm_readi blocks until a period's worth of frames
* is available — the snd-aloop kernel module fills its ring as soon
* as the playback side writes, so this also paces our send rate.
*
* When nothing is playing into the loopback, snd-aloop returns -EIO
* (no producer). Recover and keep the connection open by sending
* silence at the same cadence — keeps the host's WASAPI render alive
* and avoids the connect/disconnect churn the host would otherwise
* see while the desktop is idle. */
/* Non-interleaved capture: snd-aloop requires it (see access mode
* comment in open_loopback_capture). Read into per-channel arrays
* and interleave into `interleaved[]` for the wire send. */
int32_t left[FRAMES_PER_PACKET];
int32_t right[FRAMES_PER_PACKET];
void *channel_bufs[CHANNELS] = { left, right };
uint8_t interleaved[BYTES_PER_PACKET];
uint8_t silence[BYTES_PER_PACKET] = {0};
int eio_warned = 0;
struct timespec next;
clock_gettime(CLOCK_MONOTONIC, &next);
const long packet_ns = 10L * 1000 * 1000; /* 10 ms */
while (!g_stop) {
snd_pcm_sframes_t n = snd_pcm_readn(pcm, channel_bufs, FRAMES_PER_PACKET);
if (n == -EPIPE || n == -EIO) {
if (!eio_warned) {
aud_log("readi err %s (%ld) — shipping silence",
snd_strerror((int)n), (long)n);
eio_warned = 1;
}
snd_pcm_prepare(pcm);
struct audio_frame_header fh = { .bytes = BYTES_PER_PACKET };
if (send_all(client_fd, &fh, sizeof(fh)) < 0) break;
if (send_all(client_fd, silence, BYTES_PER_PACKET) < 0) break;
/* Pace at one packet per period. */
next.tv_nsec += packet_ns;
while (next.tv_nsec >= 1000000000L) {
next.tv_nsec -= 1000000000L; next.tv_sec += 1;
}
clock_nanosleep(CLOCK_MONOTONIC, TIMER_ABSTIME, &next, NULL);
continue;
}
if (n < 0) {
aud_log("snd_pcm_readi: %s", snd_strerror((int)n));
break;
}
if (eio_warned) { aud_log("loopback live again"); eio_warned = 0; }
/* Interleave L/R int32 samples into the send buffer. */
for (snd_pcm_sframes_t i = 0; i < n; i++) {
((int32_t *)interleaved)[i * 2 + 0] = left[i];
((int32_t *)interleaved)[i * 2 + 1] = right[i];
}
size_t bytes = (size_t)n * BLOCK_ALIGN;
struct audio_frame_header fh = { .bytes = (uint32_t)bytes };
if (send_all(client_fd, &fh, sizeof(fh)) < 0) break;
if (send_all(client_fd, interleaved, bytes) < 0) break;
clock_gettime(CLOCK_MONOTONIC, &next); /* resync timer */
}
aud_log("session ended");
snd_pcm_drop(pcm);
snd_pcm_close(pcm);
}
int main(void)
{
signal(SIGPIPE, SIG_IGN);
signal(SIGINT, on_signal);
signal(SIGTERM, on_signal);
int srv = vsock_listen(VSOCK_PORT);
if (srv < 0) return 1;
aud_log("listening on vsock :%u", VSOCK_PORT);
while (!g_stop) {
struct sockaddr_vm peer;
socklen_t plen = sizeof(peer);
int c = accept(srv, (struct sockaddr *)&peer, &plen);
if (c < 0) {
if (errno == EINTR) continue;
aud_log("accept: %s", strerror(errno));
break;
}
aud_log("client connected (cid=%u)", peer.svm_cid);
serve(c);
close(c);
}
close(srv);
return 0;
}