v2: App-managed virtual sinks (ADR-0011) - #7
Merged
Conversation
…normalization RulesDocument gains a virtualSinks array (ADR-0011 declared sink set) and CurrentVersion bumps to 2. A v1 file deserializes with the list null and is normalized in memory (empty list, version bumped) so the next save writes v2. SinkNameValidator centralizes the name/description restrictions that keep the generated conf.d drop-in and pactl argv quoting-safe. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QpVPmCWx5yF3NoBhwBRf5
…roller) Runtime sink lifecycle via pactl, mirroring PwLinker's posture: failures are returned, never thrown. Loads stamp autoroute.managed=true into sink_properties; list-modules parses 'pactl list modules short' tolerantly and only surfaces module-null-sink rows with a parseable sink_name. Module indexes are treated as ephemeral — identity is always sink_name. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QpVPmCWx5yF3NoBhwBRf5
…le loop SinkDropInWriter generates the pipewire-pulse drop-in from the declared set — write-if-changed, atomic (AtomicFile extracted from RuleStore's pattern), empty set deletes the file. SinkReconciler converges runtime state each pass: syncs the drop-in, loads declared-but-absent sinks (guarded by a modules-list check so the boot race with the drop-in can't double-create), unloads stale tagged modules only, and backs off per-name on load failures. RoutingWorker runs the sink pass before link reconcile; each half fails independently. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QpVPmCWx5yF3NoBhwBRf5
PulseConfImporter scans pipewire-pulse.conf.d/*.conf (excluding our own generated drop-in) for load-module module-null-sink entries with a tolerant parser, and appends not-yet-declared sinks to rules.json in one save — idempotent, skip-on-failure, never blocks startup. RoutingWorker runs it once after loading rules; files still creating sinks statically surface through AppNotices (UI banner) and as journal warnings, per the decision that retiring the legacy file stays manual. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QpVPmCWx5yF3NoBhwBRf5
…cy banner '+ New Sink' toolbar flyout (name validated + unique, description defaults to name, stereo/mono) declares the sink and loads it instantly. Managed columns — name membership in the declared set — get a VIRTUAL chip and a delete button whose confirm flyout lists the rules/suppressions referencing the sink, with an 'also delete these' checkbox (default on) so sink + policy go in one atomic save. Legacy static-conf files surface as an amber banner. IBoardCoordinator gains CreateSinkAsync/PreviewDeleteSink/DeleteSinkAsync; harnesses get a MockSinkController and the previewer declares GameSink. Also removes the stale Wave-1 NotImplementedException catch in SafeReconcile. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QpVPmCWx5yF3NoBhwBRf5
scripts/v2-gate.sh verifies the pactl mechanics the hybrid design depends on against the real PipeWire session (module index, node-prop round-trip, module list shape, unload, drop-in boot with --restart, pw-cli negative, duplicate-name behaviour) and captures live fixtures. ADR-0011 moves to accepted with the settled fork (hybrid, pactl, rules.json v2, name-based ownership). PLAN.v2.md is the v2 build spec mirroring PLAN.md's structure; contracts.md gains the frozen v2 surfaces; CONTEXT.md gains Declared/Managed/Adopted sink vocabulary. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QpVPmCWx5yF3NoBhwBRf5
Step 1-3, 5 pass; step 6 confirms duplicate load-module creates a second same-named module, validating the reconciler's modules-list guard. The autoroute.managed node prop round-trips through pw-dump as a JSON boolean, matching the v1 link-tag behaviour. Step 4 (drop-in boot) pending a --restart run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QpVPmCWx5yF3NoBhwBRf5
… truncation The full-gate run (--restart, 11/11 pass) is recorded in ADR-0011: the drop-in boots the sink and boot-loaded modules are pactl-visible, closing the last unverified assumption. The live pactl-modules capture replaces the hand-authored sample (real multi-line libpipewire args, the user's four legacy sinks, the tagged gate sink) and pw-dump.managed-sink.json is the real captured node. The capture exposed a real bug: the legacy conf shape uses bare sink_properties=device.description='Music Sink' (no outer double quotes), and the importer tokenizer stripped the single quotes before the description regex ran, truncating 'Music Sink' to 'Music'. The description is now extracted from the raw args string, covering both the legacy shape and our generated drop-in shape; the sample conf fixture now mirrors the real file. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QpVPmCWx5yF3NoBhwBRf5
Part A is fully shell-executable against the live session — declaring and deleting TestSink by editing rules.json exercises the same hot-reload + SinkReconciler path the UI flyouts call — covering create, idempotence, the flagship rule loop on a v2 sink, PipeWire restart without double-creation, drop-in-only survival with AutoRoute stopped, and deletion convergence, with backup/cleanup guards around the user's real rules.json. Part B is a short human checklist for the flyout-only behaviours (validation, VIRTUAL chip, delete-impact confirm, legacy banner). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QpVPmCWx5yF3NoBhwBRf5
…ire restart End-to-end run A4 found that with the legacy virtual-sinks.conf still present (warn-only retirement) and our drop-in declaring the same sinks, a pipewire-pulse restart created every legacy sink twice — and the duplicate node.name produced duplicate board-column keys, which threw in the ViewModel diff-merge inside a dispatcher callback and took down the whole app (SIGABRT). Prevention: the drop-in now excludes names any other conf file still declares (PulseConfImporter.ScanExternalSinkNames, consumed by SinkReconciler), so boot creation stays the legacy file's job until the user retires it. Cure: when a declared name exists as multiple modules the reconciler keeps exactly one — preferring the user's untagged copy — and unloads only tagged extras; duplicate names in the graph snapshot trigger the cleanup pass, since restarts change the graph, not the rules. Hardening: column keys are unique even for duplicate node names, the diff-merge tolerates duplicates, and a failed board rebuild is logged and skipped instead of aborting the process. Runbook A0/A4/A5 now assert per-name module counts and app liveness (the checks that would have caught this), and ADR-0011 records the field finding. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QpVPmCWx5yF3NoBhwBRf5
The Create and Delete-sink flyout buttons did nothing: Button.OnClick raises Click before invoking the bound Command, and the Click handler's synchronous Flyout.Hide() tore down the popup DataContext, nulling the Command binding before the invoke. The hide is now deferred via Dispatcher.Post, and the headless UI smoke gains a regression check reproducing the exact ordering (verified failing against the old code). Column titles overflowed because the VIRTUAL-chip wrapper was a horizontal StackPanel (infinite measure width defeats CharacterEllipsis); it's now a left-aligned star Grid so titles trim, with the full name as a tooltip on title and subtitle. Legacy migration is now detect + OFFER: startup only detects what the static conf declares (never writes rules.json) and the banner offers an 'Import into AutoRoute' button — the sole path that imports. Deleting a sink now unloads only tagged modules, so un-declaring a legacy-owned sink no longer kills the user's live module. ADR-0011, contracts, and the verification runbook updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QpVPmCWx5yF3NoBhwBRf5
Detection only ran at startup, so un-declaring an imported legacy sink (or importing pending ones) left the banner stale until a relaunch. The worker now re-detects on every rules change — detection is a read-only scan that never saves, so there's no feedback loop. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QpVPmCWx5yF3NoBhwBRf5
…mark Avalonia Shape.Stretch=Uniform anchors a non-square geometry at the top-left of a fixed square box, so the tall mic glyph sat off-center in its palette tile. The tile glyphs now render through a centered Viewbox that scales the tight geometry bounds, centering the short axis for every icon shape. The window/taskbar + tray icon (Assets/tray-icon.png) and the packaging icon (packaging/autoroute.png) are regenerated from the toolbar logo mark — rounded indigo-gradient tile with the white wave — via the new scripts/gen-icon.py so the asset stays reproducible from the design constants. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QpVPmCWx5yF3NoBhwBRf5
The stock Fluent scrollbar idled as a 2px rail and expanded into a gray band with arrow buttons on hover — hard to grab and off-theme. The board's ScrollViewer now sets AllowAutoHide=false so the bar is full-size by default, with the arrow buttons hidden and a rounded slate thumb (hover lighten, accent while dragging) on a transparent rail. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QpVPmCWx5yF3NoBhwBRf5
Searching for a sink by name left its column visible but filtered out every card whose own text didn't match — a matching column rendered empty (count chip still populated) while links existed, reading as a data bug. A column that matches the filter now shows everything feeding it; card-level filtering applies only when the column itself isn't the match. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QpVPmCWx5yF3NoBhwBRf5
Real-world device names (BurrBrown-length ALSA ids) painted past the palette and column edges. Root cause: ScrollViewer.Padding sits outside the scroll-content measure, so items were measured against a wider constraint than the visible panel — Avalonia's StackPanel then arranges children at their desired width, and trimmed-at-the-wrong-width text spilled out. The padding moved onto the scroll content (the pattern BoardView's board scroller already documented), matching measure to the panel. Hardening: the design graph now carries a deliberately long-named capture device wired into a column, and the headless smoke asserts no palette item, card, or TextBlock paints past its panel — so this class of overflow fails CI instead of shipping. Palette and card titles/subtitles get full-name tooltips, the drag ghost caps its width, and the palette/column borders clip as a last line of defense. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014QpVPmCWx5yF3NoBhwBRf5
The Unix file-mode calls (SetUnixFileMode, CreateDirectory with UnixFileMode)
warned about an unsupported Windows path — but AutoRoute drives PipeWire and
is Linux-only by nature. SupportedOSPlatform("linux") on every assembly tells
the platform analyzer those APIs are always supported here, which is the
semantic fix rather than a blanket rule suppression. Build is now 0 warnings.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QpVPmCWx5yF3NoBhwBRf5
Dragging a source into a sink and removing it spammed warn logs: routing is
deliberately multi-actor (optimistic UI plus reconciler passes on a possibly
stale snapshot), so the loser of each benign race got EEXIST on create ('File
exists') or ENOENT on delete ('No such file or directory'). Both mean the link
is already in the desired state — PwLinker now returns success and logs at
debug, keeping warnings for genuine failures (vanished ports etc.).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QpVPmCWx5yF3NoBhwBRf5
SimplyJustJessie
pushed a commit
that referenced
this pull request
Aug 24, 2026
… and faster AppImage startup' (#7) from claude/spout2pw-video-routing into main Reviewed-on: https://git.bussy.cloud/jessie/AutoRoute/pulls/7
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.
Implements v2 per ADR-0011 (now accepted) and the new PLAN.v2.md build spec: AutoRoute owns the virtual-sink lifecycle — create/delete null sinks from the board, keep them alive across PipeWire restarts, recreate them at boot without AutoRoute running, and retire the static
virtual-sinks.conf.What's in here
rules.json(schema v2, newvirtualSinksarray; v1 files load untouched and upgrade on next save). A generatedpipewire-pulse.conf.d/autoroute-sinks.confdrop-in recreates sinks at boot;pactl load-module/unload-modulegives instant runtime effect.SinkReconcilerruns before link reconcile on every pass: syncs the drop-in (write-if-changed, atomic), loads declared-but-missing sinks with apactl list modules shortguard (no double-creation racing the drop-in at boot), unloads only tagged stale modules, per-name backoff on failures.scripts/v2-gate.sh --restart): prop tag round-trips through pw-dump, drop-in-booted modules are pactl-visible (load-path symmetry), pw-cli one-shot nodes die with the process, duplicate loads confirmed the modules-list guard is required. The committed fixtures are the live captures — which also exposed and fixed an importer bug (baresink_properties=device.description='Music Sink'would have truncated descriptions to their first word).Testing
--smoke,--smoke-ui,--check-host,--screenshotall pass.TestSinkviarules.jsonhot-reload, PipeWire restart, drop-in-only survival, deletion convergence, with backup/cleanup guards); Part B is a ~3-minute human checklist for the flyout-only behaviours.🤖 Generated with Claude Code
https://claude.ai/code/session_014QpVPmCWx5yF3NoBhwBRf5
Generated by Claude Code