feat(linux): Linux audio support, plugin SPI, and dictation engine#1
Merged
Conversation
Delivers three parallel capabilities: Linux parity for meeting recording, a ServiceLoader-based SpeechOutputPlugin API for extensible STT output modes, and a built-in DictationPlugin implementing push-to-talk, file transcription, and live captions. **Linux audio backend** - Extract SystemAudioBackend interface; ScreenCaptureBackend wraps the existing ScreenCaptureJniBridge (macOS unchanged) - PipeWireCaptureBackend + compilable C stub (native/PipeWireCaptureBridge) with pthread ring buffer and correct JNI signatures; full pw_stream implementation documented via TODO comments - SystemAudioBackendFactory selects backend at runtime via PlatformInfo - RecordingSessionManager now accepts SystemAudioBackend via constructor injection (default = factory); all existing macOS behaviour preserved - Gradle buildPipeWireCaptureBridge Exec task (Linux-only, wired to desktopProcessResources) **Platform utilities** - PlatformInfo / Platform: testable OS detection (isLinux, isMac, isWayland, isPipeWireAvailable, avx2Supported) - AVX2 guard in WhisperService.loadLibraryOnce() for Linux CPU backend **Plugin SPI** - SpeechOutputPlugin interface + DictationMode enum in commonMain - PluginLoader: child-first URLClassLoader per JAR, isolated error handling, explicit close() on unload - AppSettings.enabledPlugins: Map<String, Boolean> (default-safe serialization for existing settings files) - Settings UI Plugins section with enable/disable toggle **TextInjector abstraction** - TextInjector interface with NOT_INSTALLED / DAEMON_NOT_RUNNING / OK status enum - YdotoolTextInjector: ydotoold health-check, ProcessBuilder injection (injectable for testing), text sanitization - XdotoolTextInjector: DISPLAY/WAYLAND_DISPLAY env guard, same pattern - AutoDetectTextInjector: lazy ydotool-first selection with xdotool fallback **Dictation plugin** - DictationPlugin implements SpeechOutputPlugin with all three modes - PUSH_TO_TALK: in-window-focus MVP (Wayland portal follow-up in ADR-003) - FILE_TRANSCRIPTION: inputPath → WhisperService → outputPath or stdout - LIVE_CAPTIONS: 3s mic chunks → streaming StateFlow<List<String>> - Registered via META-INF/services for zero-config ServiceLoader discovery 184 tests pass; 0 macOS regressions. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds Linux-oriented infrastructure for system-audio capture, introduces a ServiceLoader-based plugin SPI with Settings UI integration, and ships an initial built-in dictation plugin plus related platform/tooling utilities and tests.
Changes:
- Introduces
SystemAudioBackendabstraction with macOS adapter + Linux PipeWire backend scaffolding, wired intoRecordingSessionManager. - Adds plugin SPI (
SpeechOutputPlugin,DictationMode), aPluginLoader, Settings UI for plugin management, and ServiceLoader registration for the built-inDictationPlugin. - Adds Linux dictation text injection backends (ydotool/xdotool + auto-detect) and a Linux AVX2 preflight guard for whisper-jni loading, plus extensive tests and build tooling for PipeWire JNI.
Reviewed changes
Copilot reviewed 42 out of 43 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| project_plans/linux-dictation-plugin/research/stack.md | Research notes for whisper-jni native coverage and PipeWire/JNI build details. |
| project_plans/linux-dictation-plugin/research/pitfalls.md | Research notes on PipeWire permissions, ydotool pitfalls, Wayland hotkeys, and classloader leak risks. |
| project_plans/linux-dictation-plugin/research/features.md | Research notes on injection tools, hotkeys, ServiceLoader usage, and Linux Compose behavior. |
| project_plans/linux-dictation-plugin/research/architecture.md | Proposed architecture tying together platform detection, JNI patterns, and plugin loader structure. |
| project_plans/linux-dictation-plugin/requirements.md | Requirements/spec for Linux parity + plugin SPI + dictation modes. |
| project_plans/linux-dictation-plugin/implementation/validation.md | Test plan mapping requirements to unit/integration/acceptance tests. |
| project_plans/linux-dictation-plugin/implementation/plan.md | Implementation plan/epics for Linux audio + plugins + dictation. |
| native/PipeWireCaptureBridge/Makefile | Build script to produce and place libpipewire-jni.so into desktop resources. |
| native/PipeWireCaptureBridge/jni/PipeWireCaptureBridgeJNI.h | JNI header declaring PipeWire bridge native functions. |
| native/PipeWireCaptureBridge/jni/PipeWireCaptureBridgeJNI.c | Compilable PipeWire JNI stub with ring buffer and placeholder availability/capture APIs. |
| composeApp/src/desktopTest/kotlin/plugin/ServiceLoaderRegistrationTest.kt | Tests that built-in dictation plugin is discoverable via ServiceLoader. |
| composeApp/src/desktopTest/kotlin/plugin/PluginLoaderTest.kt | Tests for PluginLoader behavior (empty dir, unload no-op, classpath discovery). |
| composeApp/src/desktopTest/kotlin/plugin/dictation/DictationPluginTest.kt | Unit tests for DictationPlugin identity and basic activation behavior. |
| composeApp/src/desktopTest/kotlin/platform/PlatformInfoTest.kt | Unit tests for platform detection helpers and guards. |
| composeApp/src/desktopTest/kotlin/injection/XdotoolTextInjectorTest.kt | Unit tests for xdotool injector availability/sanitization behavior. |
| composeApp/src/desktopTest/kotlin/injection/AutoDetectTextInjectorTest.kt | Unit tests for injector auto-detection and caching logic. |
| composeApp/src/desktopTest/kotlin/audio/SystemAudioBackendFactoryTest.kt | Unit tests for backend selection by platform. |
| composeApp/src/desktopTest/kotlin/audio/SilentAudioBackendTest.kt | Unit tests for no-op system audio backend behavior. |
| composeApp/src/desktopTest/kotlin/audio/RecordingSessionManagerBackendTest.kt | Tests verifying RecordingSessionManager uses injected backend and produces WAVs. |
| composeApp/src/desktopTest/kotlin/audio/PipeWireCaptureBackendTest.kt | Tests verifying PipeWire backend reports unavailable gracefully in CI/macOS. |
| composeApp/src/desktopMain/resources/META-INF/services/com.meetingnotes.plugin.SpeechOutputPlugin | Registers the built-in DictationPlugin for ServiceLoader discovery. |
| composeApp/src/desktopMain/kotlin/ui/settings/SettingsScreen.kt | Adds Plugins section wiring to Settings screen. |
| composeApp/src/desktopMain/kotlin/ui/settings/PluginsSettingsSection.kt | New composable rendering plugin successes/failures and enable toggles. |
| composeApp/src/desktopMain/kotlin/transcription/WhisperService.kt | Adds Linux AVX2 preflight guard before loading whisper-jni CPU backend. |
| composeApp/src/desktopMain/kotlin/plugin/PluginLoader.kt | Implements JAR directory scanning + ServiceLoader plugin loading with classloader isolation and unload. |
| composeApp/src/desktopMain/kotlin/platform/PlatformInfo.kt | Adds testable platform detection utility (Linux/mac/Wayland/X11/PipeWire/AVX2). |
| composeApp/src/desktopMain/kotlin/dictation/YdotoolTextInjector.kt | Implements ydotool-based text injection with status checks and injectable subprocess factory. |
| composeApp/src/desktopMain/kotlin/dictation/XdotoolTextInjector.kt | Implements xdotool-based injection with Wayland guard and subprocess checks. |
| composeApp/src/desktopMain/kotlin/dictation/TextInjector.kt | Defines TextInjector interface + availability/status model + exception type. |
| composeApp/src/desktopMain/kotlin/dictation/plugin/DictationPlugin.kt | Built-in SpeechOutputPlugin implementing PUSH_TO_TALK (MVP), FILE_TRANSCRIPTION, LIVE_CAPTIONS. |
| composeApp/src/desktopMain/kotlin/dictation/AutoDetectTextInjector.kt | Auto-selects the first available injector and caches selection. |
| composeApp/src/desktopMain/kotlin/audio/SystemAudioBackendFactory.kt | Selects ScreenCapture vs PipeWire vs no-op system audio backend by platform. |
| composeApp/src/desktopMain/kotlin/audio/SystemAudioBackend.kt | Defines SystemAudioBackend and a no-op fallback implementation. |
| composeApp/src/desktopMain/kotlin/audio/ScreenCaptureBackend.kt | macOS adapter delegating to existing ScreenCaptureJniBridge. |
| composeApp/src/desktopMain/kotlin/audio/RecordingSessionManager.kt | Refactors to use injected SystemAudioBackend instead of direct JNI bridge calls. |
| composeApp/src/desktopMain/kotlin/audio/PipeWireCaptureBackend.kt | PipeWire JNI loader + backend wrapper (resource extraction + native calls). |
| composeApp/src/commonTest/kotlin/domain/plugin/DictationModeTest.kt | Tests DictationMode JSON serialization round-trip. |
| composeApp/src/commonTest/kotlin/domain/AppSettingsTest.kt | Tests AppSettings enabledPlugins serialization/migration defaults. |
| composeApp/src/commonMain/kotlin/plugin/SpeechOutputPlugin.kt | Defines SPI interface and PluginException for plugins (commonMain). |
| composeApp/src/commonMain/kotlin/plugin/DictationMode.kt | Defines serializable DictationMode enum (commonMain). |
| composeApp/src/commonMain/kotlin/domain/model/AppSettings.kt | Adds enabledPlugins: Map<String, Boolean> to persisted settings. |
| composeApp/build.gradle.kts | Adds Linux-only Gradle Exec tasks to build/clean PipeWire JNI library and wires into resources processing. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+316
to
+322
| // On Linux, verify AVX2 support required by whisper-jni's CPU backend. | ||
| if (PlatformInfo.isLinux() && !PlatformInfo.avx2Supported()) { | ||
| throw UnsatisfiedLinkError( | ||
| "Whisper CPU backend requires AVX2 (Intel Haswell 2013+ or AMD Ryzen). " + | ||
| "Check /proc/cpuinfo for 'avx2' flag." | ||
| ) | ||
| } |
Comment on lines
+25
to
+32
| fun avx2Supported(): Boolean { | ||
| if (!isLinux()) return true // assume capable on non-Linux | ||
| return try { | ||
| java.io.File("/proc/cpuinfo").readText().contains("avx2") | ||
| } catch (_: Exception) { | ||
| true | ||
| } | ||
| } |
Comment on lines
+12
to
+17
| * 3. [deactivate] — called when the session ends (may be called multiple times) | ||
| * 4. [close] — called once before the plugin's classloader is closed; do cleanup here | ||
| * | ||
| * Plugin authors must ensure that [activate] and [deactivate] are safe to call from | ||
| * a coroutine context, and that [close] does not block the calling thread for more | ||
| * than a few hundred milliseconds. |
Comment on lines
+41
to
+44
| * A single JAR may contain multiple plugins (each [SpeechOutputPlugin] entry in | ||
| * its META-INF/services file produces a separate result entry). | ||
| */ | ||
| fun loadAll(pluginDir: File): List<PluginLoadResult> { |
Comment on lines
+90
to
+95
|
|
||
| for (plugin in pluginsInJar) { | ||
| loadedPlugins[plugin.id] = Pair(plugin, loader) | ||
| results += PluginLoadResult.Success(plugin, jarPath) | ||
| } | ||
|
|
Comment on lines
+58
to
+66
| /* PipeWire socket check */ | ||
| const char* xdg_runtime = getenv("XDG_RUNTIME_DIR"); | ||
| if (!xdg_runtime) return JNI_FALSE; | ||
|
|
||
| char path[512]; | ||
| snprintf(path, sizeof(path), "%s/pipewire-0", xdg_runtime); | ||
| FILE* sock = fopen(path, "r"); | ||
| if (!sock) return JNI_FALSE; | ||
| fclose(sock); |
Comment on lines
1
to
3
| import org.jetbrains.compose.desktop.application.dsl.TargetFormat | ||
| import org.gradle.internal.os.OperatingSystem | ||
|
|
Comment on lines
+55
to
+73
| // Child-first URLClassLoader: plugin classes override host-app versions. | ||
| val loader = object : URLClassLoader( | ||
| arrayOf(jar.toURI().toURL()), | ||
| parentClassLoader | ||
| ) { | ||
| override fun loadClass(name: String, resolve: Boolean): Class<*> { | ||
| // Try loading from this JAR first (child-first delegation). | ||
| synchronized(getClassLoadingLock(name)) { | ||
| var c = findLoadedClass(name) | ||
| if (c == null) { | ||
| c = try { findClass(name) } catch (_: ClassNotFoundException) { null } | ||
| } | ||
| if (c == null) { | ||
| c = parent.loadClass(name) | ||
| } | ||
| if (resolve) resolveClass(c) | ||
| return c | ||
| } | ||
| } |
- Replace C PipeWire stub with full Rust crate (agrapha-native): pipewire 0.9 Box API (MainLoopBox/ContextBox/StreamBox), ring buffer, x11rb X11 grab, zbus Wayland portal GlobalShortcuts - Add HotkeyService with injectable HotkeyBridge for test isolation; listen() polls in 1s windows so coroutine cancellation is prompt - Wire PUSH_TO_TALK mode in DictationPlugin to the real hotkey listener - Fix HotkeyServiceTest timing: runTest→runBlocking for IO-dispatcher tests - Drop legacy native/PipeWireCaptureBridge C directory Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…pture-kit)
The three-layer Swift → Obj-C → JNI stack is replaced by the single
agrapha-native Rust crate, which now covers both platforms:
macOS – mac_audio_capture.rs wraps ScreenCaptureKit via objc2 0.6 /
objc2-screen-capture-kit 0.3; AudioDelegate implements
SCStreamOutput + SCStreamDelegate using define_class!;
async Obj-C completion handlers are synchronised with Condvar.
Linux – pipewire_capture.rs + global_shortcut.rs (unchanged).
All Linux modules are now cfg-gated so macOS builds never pull in PipeWire
or x11rb. Conversely, macOS deps are target-gated and never affect Linux.
Gradle build task now runs on macOS too, producing libagrapha_native.dylib.
ScreenCaptureJniBridge loads the single dylib instead of the old two-dylib
(@loader_path rpath) pair. native/AudioCaptureBridge/ is deleted entirely.
Compiler story before: swift build + clang + cargo build
Compiler story after: cargo build (Xcode CLTools still needed for Apple SDK)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Captures the full implementation state of feature/linux-dictation-plugin: 21 of 22 stories complete (all Kotlin + Rust implementation done, all 194 tests passing). The single remaining story — the Linux CI job (Story 1.3) — is the PR #1 merge gate; its YAML is specified in docs/tasks/. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…S CI cleanup - Add build-linux job to .github/workflows/build.yml running ubuntu-latest: installs Rust, PipeWire/X11 dev headers, and xvfb; runs desktopTest under xvfb-run so AWT-dependent tests pass headlessly - Fix macOS build job: add Rust toolchain + Cargo cache (required now that buildAgraphaNative runs cargo on macOS); remove stale AudioCaptureBridge step whose directory was deleted in the Swift→Rust migration - Add LiveCaptionsOverlay.kt: always-on-top undecorated frameless Compose Window that observes DictationPlugin.liveSegments and auto-shows/hides based on flow content; non-focusable so it never steals keyboard focus - Wire DictationPlugin into Main.kt and AppRoot.kt so the overlay is live whenever LIVE_CAPTIONS mode populates liveSegments Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… done Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…add integration tests WhisperService.isRepetitionLoop(): the verbatim-repeat branch checked normalized.startsWith(half) where half = normalized.substring(0, len/2) — trivially true for any string, so every sentence ≥ 20 chars was classified as a repetition loop and filtered out. Fix to normalized.endsWith(half) so only strings where the second half duplicates the first are rejected. Exploratory testing on a 19-second recording revealed the bug: Whisper transcribed both sentences correctly (verified via WhisperService logs) but filteredRepeat=2 silenced them both. Also fix three bugs found during Linux exploratory run: - ScreenCaptureJniBridge.load(): always extracted libagrapha_native.dylib even on Linux where the classpath resource is libagrapha_native.so — now detects OS and picks the correct filename - OnboardingScreen: nativeCheckPermission() UnsatisfiedLinkError (Linux) was caught but returned step=0, showing the macOS permission screen; now returns step=1 to skip that step on non-macOS platforms - OnboardingScreen: nativeRequestPermission() button caught Exception but UnsatisfiedLinkError is an Error — changed catch to Throwable Add WhisperServiceIntegrationTest: 4 tests that skip gracefully when no model is present (Assume.assumeNotNull) and activate automatically on dev machines. Budget scales with model size so tiny (~75 MB) gets a 15s ceiling and distil-large-v3 (~1.5 GB) gets 180s, verified on ggml-distil-large-v3. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…il-v3.5 model Implements three parallel improvements to the transcription stack: **Backend abstraction** - New TranscriptionBackend interface (whisper / apple-speech / parakeet) - WhisperTranscriptionBackend wraps existing WhisperService - AppleSpeechBackend + Rust mac_speech_recognizer.rs for macOS on-device SFSpeechRecognizer - TranscriptionBackendFactory selects backend from AppSettings.transcriptionBackend - DictationPlugin now depends on TranscriptionBackend? instead of WhisperService? **Parakeet-TDT-0.6B ONNX backend (experimental)** - MelSpectrogramExtractor: pure-Kotlin Cooley-Tukey FFT + 128-band log-mel - ParakeetOnnxBackend: ONNX Runtime 1.20.0, dynamic tensor name discovery, greedy CTC decode - AppSettings.parakeetModelDir for user-configured model directory - SettingsViewModel validates encoder.onnx presence when backend is selected - SettingsScreen shows model dir field with inline error display **Model catalog (Stream A)** - WhisperModelSpec.sha256 is now nullable (skip integrity check when hash not published) - WhisperModelSpec.sizeBytes=0 falls back to HTTP Content-Length for progress - Adds ggml-distil-large-v3.5 (6x faster than large-v3, successor to distil-large-v3) **Bug fix** - SettingsViewModel.expandPath() expands ~ and $VAR in wiki path validation - Tests: expandPath() coverage added to SettingsViewModelTest Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…Test job.cancel() is non-blocking — the IO thread may still be inside waitOnce() writing to waitCalls when the forEach runs. cancelAndJoin() ensures the coroutine has fully completed before we inspect the list. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…bjc2 0.6 - Rename SCShareableContent method to getShareableContentExcludingDesktopWindows... (API changed in objc2-screen-capture-kit 0.3.2) - Add #[thread_kind = AnyThread] to define_class! to satisfy NSObjectProtocol bounds on SCStreamOutput/SCStreamDelegate impls - Add unsafe impl Send for CaptureState to allow use in static Mutex - Fix NSArray::objectAtIndex now returns Retained<SCDisplay> directly (not Option) - Replace DispatchQueue::global (removed in dispatch2 0.3) with None queue arg - Replace CMSampleBuffer::dataBuffer() (removed) with CMSampleBufferGetDataBuffer C FFI - Replace deprecated msg_send_id! with msg_send! in mac_speech_recognizer - Replace Retained::as_ptr() (removed in objc2 0.6) with &* dereference Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add unsafe impl NSObjectProtocol for AudioDelegate (no longer blanket-impl'd for define_class! types in objc2 0.6; must be explicit) - Fix request_permission completion: *mut c_void -> *mut SCShareableContent to match getShareableContentExcluding... block signature - Fix setSampleRate: takes NSInteger (isize) not f64 in sck 0.3.2 - Fix mac_speech_recognizer: use *mut AnyObject raw returns from msg_send! instead of Retained<AnyObject> (Retained<AnyObject> does not impl Encode) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add release-please-action v4 triggered on main branch merges - release-please-config.json: simple release type, updates packageVersion in composeApp/build.gradle.kts via x-release-please-version marker - .release-please-manifest.json: seed at 1.0.0 - Fix release.yml: remove dead AudioCaptureBridge step (replaced by the Rust native crate in this PR) and add Rust toolchain + Cargo cache so the release DMG build includes libagrapha_native.dylib Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
SystemAudioBackendinterface abstracts macOS ScreenCaptureKit and Linux PipeWire behind a single contract.RecordingSessionManageris refactored to accept the backend via constructor injection — macOS code is completely unchanged.SpeechOutputPlugininterface +PluginLoaderusingjava.util.ServiceLoaderwith child-firstURLClassLoaderisolation. Drop a JAR in~/.config/agrapha/plugins/and it appears in Settings.DictationPluginimplementing push-to-talk (in-window focus, Wayland portal follow-up in ADR-003), file transcription, and live captions via aStateFlow<List<String>>.What's in this PR
New interfaces / abstractions
platform/PlatformInfo.ktaudio/SystemAudioBackend.ktNoOpSystemAudioBackendaudio/ScreenCaptureBackend.ktScreenCaptureJniBridge(macOS)audio/PipeWireCaptureBackend.ktlibpipewire-jni.sofrom classpathaudio/SystemAudioBackendFactory.ktplugin/SpeechOutputPlugin.kt(commonMain)plugin/DictationMode.kt(commonMain)plugin/PluginLoader.ktdictation/TextInjector.ktdictation/YdotoolTextInjector.ktdictation/XdotoolTextInjector.ktdictation/AutoDetectTextInjector.ktdictation/plugin/DictationPlugin.ktModified files
audio/RecordingSessionManager.kt— constructor injection ofSystemAudioBackend(default = factory);ScreenCaptureJniBridgedirect calls replacedtranscription/WhisperService.kt— AVX2 guard before loading native library on Linuxdomain/model/AppSettings.kt—enabledPlugins: Map<String, Boolean>(default-safe)ui/settings/SettingsScreen.kt— Plugins sectionNative
native/PipeWireCaptureBridge/— compilable C stub with pthread ring buffer and all four JNI function signatures. Fullpw_streamimplementation documented via TODO.nativeIsAvailable()returnsJNI_FALSEuntil implementation is complete, so Kotlin falls back toNoOpSystemAudioBackendgracefully.composeApp/build.gradle.kts—buildPipeWireCaptureBridgeExec task (Linux-only)Test plan
./gradlew :composeApp:desktopTest— 184 tests, 0 failuresScreenCaptureJniBridgeuntouched; factory routes macOS toScreenCaptureBackendidentically to prior behaviour~/.config/agrapha/plugins/, verify it appears in SettingsKnown limitations / follow-ups
pw_streamcapture implementation is the natural next PR — the architecture is in place, JNI signatures are correct, and the TODO comments inPipeWireCaptureBridgeJNI.cdocument exactly what to do.xdg-desktop-portalGlobalShortcuts integration (requires dbus-java) is tracked in ADR-003 and planned for a follow-up../gradlew :composeApp:desktopTest(all tests pass without PipeWire hardware).🤖 Generated with Claude Code