From 209d4299cc3c151a555f097eb3800b408240f2f4 Mon Sep 17 00:00:00 2001 From: HANCORE-linux <230438592+HANCORE-linux@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:00:56 +0200 Subject: [PATCH 01/18] feat: enforce compact backend boundary --- ARCHITECTURE.md | 33 +- contracts/backend-boundary-v1.json | 379 +++++++++++++++ docs/release-readiness.md | 22 + packaging/aur/PKGBUILD | 2 + scripts/check-aur-package | 3 + scripts/check-production-boundary | 583 +++++++++++++++--------- scripts/rehearse-aur-package | 1 + scripts/shibumi_suite/model.py | 6 +- tests/production-boundary-regression.py | 150 +++++- tests/test_package_release.py | 8 + 10 files changed, 974 insertions(+), 213 deletions(-) create mode 100644 contracts/backend-boundary-v1.json diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1a8f069..5d530c9 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -227,7 +227,38 @@ Only behavior proven to be host-neutral should move from QS Rise V1 to Shibumi. be adapted to the Shibumi ownership model rather than copied together with QS Rise paths, hooks, polling, or lifecycle scripts. -### Why A Full-Bar Plugin +### Compact Enforceable Backend Boundary + +The machine-readable boundary contract is +[`contracts/backend-boundary-v1.json`](contracts/backend-boundary-v1.json). It is +the only repository boundary source for production dependency classification; +it is not a status system and does not replace the suite contract or release +readiness record. `scripts/check-production-boundary` loads that manifest and +fails closed on malformed or incomplete declarations. + +Every mutable capability declares one owner, its current backend boundary, its +output cardinality, and its worker policy. Production dependencies are classified +as exactly one of `hostIntegration`, `nativeApi`, `omarchyBackend`, +`omarchyAction`, or `hostOwnedProvider`. A transitional `omarchyBackend` must +name a known backend, an explicit `removalStep`, and a reason. New categories, +owners, backend identities, host providers, or undeclared occurrences fail the +lint rather than becoming implicit compatibility debt. + +The only host-owned provider exceptions are Omarchy Notifications, OSD, and +Idle. Their provider identities, owners, allowed forms, process-wide cardinality, +and host-owned worker policy are declared explicitly in the boundary manifest. +No other `firstPartyServiceFor`, private provider QML, hidden component, helper +state poller, or output-duplicated capability owner is permitted. OSD remains an +explicit host summon/IPC action; it is not a Shibumi provider. + +The production lint runs in source and package gates. It checks exact reviewed +occurrences, private component paths, provider and first-party service calls, +Omarchy commands, helper-backed state markers, package escapes, symlinks, and +UTF-8 source. Compatibility debt may be removed by a later migration step, but +it may not be broadened silently. This boundary slice does not activate Audio, +Network, Bluetooth, or any other backend cutover. + +## Why A Full-Bar Plugin Replacing individual widgets on Omarchy's built-in bar is the lower-risk choice when a product only changes widget content or visual tokens. Whiterose documents diff --git a/contracts/backend-boundary-v1.json b/contracts/backend-boundary-v1.json new file mode 100644 index 0000000..941c9de --- /dev/null +++ b/contracts/backend-boundary-v1.json @@ -0,0 +1,379 @@ +{ + "schemaVersion": 1, + "manifestId": "hancore.shibumi.backend-boundary", + "categories": [ + "hostIntegration", + "nativeApi", + "omarchyBackend", + "omarchyAction", + "hostOwnedProvider" + ], + "transitionalBackends": [ + "omarchy.agents", + "omarchy.audio", + "omarchy.bluetooth", + "omarchy.media", + "omarchy.model-usage", + "omarchy.monitor", + "omarchy.network", + "omarchy.power", + "omarchy.recording", + "omarchy.system-update", + "omarchy.tray", + "omarchy.voxtype", + "omarchy.weather" + ], + "outputCardinalities": ["process-wide", "output-local"], + "workerPolicies": [ + "none", + "shared-demand-driven", + "surface-bound", + "host-owned", + "action-only", + "output-local-ui-only" + ], + "capabilities": [ + { + "id": "bar-host-routing", + "owner": "hancore.shibumi.bar", + "backendBoundary": "hostIntegration", + "outputCardinality": "output-local", + "workerPolicy": "output-local-ui-only", + "reason": "The selected bar owns output windows, routing, layout, and presentation only." + }, + { + "id": "settings", + "owner": "hancore.shibumi.state", + "backendBoundary": "nativeApi", + "outputCardinality": "process-wide", + "workerPolicy": "none", + "reason": "Validated Shibumi settings use the host configuration and XDG state boundary." + }, + { + "id": "telemetry", + "owner": "hancore.shibumi.telemetry", + "backendBoundary": "nativeApi", + "outputCardinality": "process-wide", + "workerPolicy": "shared-demand-driven", + "reason": "Procfs, sysfs, and public system probes are shared across outputs." + }, + { + "id": "workspaces", + "owner": "hancore.shibumi.workspaces", + "backendBoundary": "nativeApi", + "outputCardinality": "process-wide", + "workerPolicy": "shared-demand-driven", + "reason": "Hyprland workspace state and actions have one process-wide owner." + }, + { + "id": "audio", + "owner": "hancore.shibumi.audio", + "backendBoundary": "omarchyBackend", + "backend": "omarchy.audio", + "outputCardinality": "process-wide", + "workerPolicy": "shared-demand-driven", + "removalStep": "Step 4A", + "reason": "Beta.10 still delegates PipeWire state and actions to the declared host backend pending Step 4A." + }, + { + "id": "bluetooth", + "owner": "hancore.shibumi.bluetooth", + "backendBoundary": "nativeApi", + "outputCardinality": "process-wide", + "workerPolicy": "shared-demand-driven", + "reason": "BlueZ ownership is native; the remaining helper/audio compatibility debt is explicitly bounded below." + }, + { + "id": "network", + "owner": "hancore.shibumi.network", + "backendBoundary": "omarchyBackend", + "backend": "omarchy.network", + "outputCardinality": "process-wide", + "workerPolicy": "shared-demand-driven", + "removalStep": "Step 5", + "reason": "Beta.10 still hosts the official network owner pending Step 5." + }, + { + "id": "displays", + "owner": "hancore.shibumi.brightness", + "backendBoundary": "omarchyBackend", + "backend": "omarchy.monitor", + "outputCardinality": "process-wide", + "workerPolicy": "shared-demand-driven", + "removalStep": "Step 6", + "reason": "Beta.10 still delegates display state and actions to the official monitor owner pending Step 6." + }, + { + "id": "battery-ac", + "owner": "hancore.shibumi.power-state", + "backendBoundary": "nativeApi", + "outputCardinality": "process-wide", + "workerPolicy": "shared-demand-driven", + "reason": "UPower is shared; the remaining Omarchy battery helper is declared as transition debt." + }, + { + "id": "power-profile", + "owner": "hancore.shibumi.power-state", + "backendBoundary": "nativeApi", + "outputCardinality": "process-wide", + "workerPolicy": "shared-demand-driven", + "reason": "One power-profile owner serves every output while the helper route remains transitional." + }, + { + "id": "media", + "owner": "hancore.shibumi.media", + "backendBoundary": "omarchyBackend", + "backend": "omarchy.media", + "outputCardinality": "process-wide", + "workerPolicy": "shared-demand-driven", + "removalStep": "Step 7", + "reason": "Beta.10 consumes the official MPRIS owner pending Step 7." + }, + { + "id": "system-tray", + "owner": "hancore.shibumi.status", + "backendBoundary": "omarchyBackend", + "backend": "omarchy.tray", + "outputCardinality": "process-wide", + "workerPolicy": "shared-demand-driven", + "removalStep": "Step 7", + "reason": "The status composite presents the single official tray owner pending Step 7." + }, + { + "id": "system-update-state", + "owner": "hancore.shibumi.update-center", + "backendBoundary": "omarchyBackend", + "backend": "omarchy.system-update", + "outputCardinality": "process-wide", + "workerPolicy": "shared-demand-driven", + "removalStep": "Step 7", + "reason": "Beta.10 retains the official update detector pending Step 7." + }, + { + "id": "omarchy-update-action", + "owner": "hancore.shibumi.update-center", + "backendBoundary": "omarchyAction", + "outputCardinality": "process-wide", + "workerPolicy": "action-only", + "reason": "The Omarchy update command is an explicit platform action and never a state source." + }, + { + "id": "ai-usage", + "owner": "hancore.shibumi.ai", + "backendBoundary": "omarchyBackend", + "backend": "omarchy.agents", + "outputCardinality": "process-wide", + "workerPolicy": "shared-demand-driven", + "removalStep": "Step 8", + "reason": "Current Agents and legacy model-usage records remain revision-bound compatibility inputs pending Step 8." + }, + { + "id": "weather", + "owner": "hancore.shibumi.center", + "backendBoundary": "omarchyBackend", + "backend": "omarchy.weather", + "outputCardinality": "process-wide", + "workerPolicy": "shared-demand-driven", + "removalStep": "Step 8", + "reason": "The current weather detail and location route remain host-backed pending Step 8." + }, + { + "id": "recording-voxtype", + "owner": "hancore.shibumi.status", + "backendBoundary": "nativeApi", + "outputCardinality": "process-wide", + "workerPolicy": "shared-demand-driven", + "reason": "Status is process-wide; current Omarchy recording and Voxtype helpers are explicit Step 8 debt." + }, + { + "id": "quick-access", + "owner": "hancore.shibumi.quick-access", + "backendBoundary": "nativeApi", + "outputCardinality": "process-wide", + "workerPolicy": "shared-demand-driven", + "reason": "Discovery, selection, and cache lifecycle are shared across visible picker surfaces." + }, + { + "id": "theme-background-selection", + "owner": "hancore.shibumi.quick-access", + "backendBoundary": "omarchyAction", + "outputCardinality": "process-wide", + "workerPolicy": "action-only", + "reason": "Theme and background commands are explicit Omarchy platform actions, not state polling." + }, + { + "id": "surface-idle-inhibition", + "owner": "hancore.shibumi.quick-access", + "backendBoundary": "nativeApi", + "outputCardinality": "output-local", + "workerPolicy": "output-local-ui-only", + "reason": "Each visible Quick Access surface owns only its own IdleInhibitor." + } + ], + "hostOwnedProviders": [ + { + "id": "notifications", + "provider": "omarchy.notifications", + "owner": "omarchy.notifications", + "category": "hostOwnedProvider", + "outputCardinality": "process-wide", + "workerPolicy": "host-owned", + "allowedForms": ["hostId", "firstPartyService"], + "reason": "Omarchy remains the sole NotificationServer, popup, DND, and history owner." + }, + { + "id": "osd", + "provider": "omarchy.osd", + "owner": "omarchy.osd", + "category": "hostOwnedProvider", + "outputCardinality": "process-wide", + "workerPolicy": "host-owned", + "allowedForms": ["hostAction"], + "reason": "Omarchy remains the sole OSD owner; Shibumi uses only the documented summon/IPC path." + }, + { + "id": "idle", + "provider": "omarchy.idle", + "owner": "omarchy.idle", + "category": "hostOwnedProvider", + "outputCardinality": "process-wide", + "workerPolicy": "host-owned", + "allowedForms": ["hostId", "firstPartyService"], + "reason": "Omarchy remains the sole global idle-policy owner; surface inhibition is separate." + } + ], + "rules": [ + {"source": "hancore.shibumi.ai/Service.qml", "kind": "token", "value": "entryPointUrl", "count": 2, "category": "hostIntegration", "owner": "hancore.shibumi.ai", "capability": "ai-usage"}, + {"source": "hancore.shibumi.ai/Service.qml", "kind": "token", "value": "registeredWidgetSource", "count": 2, "category": "hostIntegration", "owner": "hancore.shibumi.ai", "capability": "ai-usage"}, + {"source": "hancore.shibumi.ai/Service.qml", "kind": "token", "value": "standardWidgetSource", "count": 3, "category": "hostIntegration", "owner": "hancore.shibumi.ai", "capability": "ai-usage"}, + {"source": "hancore.shibumi.audio/BarWidget.qml", "kind": "token", "value": "entryPointUrl", "count": 2, "category": "hostIntegration", "owner": "hancore.shibumi.audio", "capability": "audio"}, + {"source": "hancore.shibumi.audio/BarWidget.qml", "kind": "token", "value": "registeredComponent", "count": 2, "category": "hostIntegration", "owner": "hancore.shibumi.audio", "capability": "audio"}, + {"source": "hancore.shibumi.audio/BarWidget.qml", "kind": "token", "value": "registeredSource", "count": 2, "category": "hostIntegration", "owner": "hancore.shibumi.audio", "capability": "audio"}, + {"source": "hancore.shibumi.audio/BarWidget.qml", "kind": "token", "value": "registeredWidgetComponent", "count": 2, "category": "hostIntegration", "owner": "hancore.shibumi.audio", "capability": "audio"}, + {"source": "hancore.shibumi.audio/BarWidget.qml", "kind": "token", "value": "registeredWidgetSource", "count": 2, "category": "hostIntegration", "owner": "hancore.shibumi.audio", "capability": "audio"}, + {"source": "hancore.shibumi.bar/Bar.qml", "kind": "token", "value": "entryPointUrl", "count": 3, "category": "hostIntegration", "owner": "hancore.shibumi.bar", "capability": "bar-host-routing"}, + {"source": "hancore.shibumi.bar/Bar.qml", "kind": "token", "value": "firstPartyServiceFor", "count": 2, "category": "hostIntegration", "owner": "hancore.shibumi.bar", "capability": "bar-host-routing"}, + {"source": "hancore.shibumi.bar/Bar.qml", "kind": "token", "value": "registeredWidgetComponent", "count": 1, "category": "hostIntegration", "owner": "hancore.shibumi.bar", "capability": "bar-host-routing"}, + {"source": "hancore.shibumi.bar/Bar.qml", "kind": "token", "value": "registeredWidgetSource", "count": 1, "category": "hostIntegration", "owner": "hancore.shibumi.bar", "capability": "bar-host-routing"}, + {"source": "hancore.shibumi.bar/core/WidgetSlot.qml", "kind": "token", "value": "registeredWidgetComponent", "count": 4, "category": "hostIntegration", "owner": "hancore.shibumi.bar", "capability": "bar-host-routing"}, + {"source": "hancore.shibumi.bar/services/HostWidgetResolver.qml", "kind": "token", "value": "Qt.createComponent", "count": 1, "category": "hostIntegration", "owner": "hancore.shibumi.bar", "capability": "bar-host-routing"}, + {"source": "hancore.shibumi.bar/services/HostWidgetResolver.qml", "kind": "token", "value": "entryPointUrl", "count": 5, "category": "hostIntegration", "owner": "hancore.shibumi.bar", "capability": "bar-host-routing"}, + {"source": "hancore.shibumi.brightness/Service.qml", "kind": "token", "value": "entryPointUrl", "count": 2, "category": "hostIntegration", "owner": "hancore.shibumi.brightness", "capability": "displays"}, + {"source": "hancore.shibumi.brightness/Service.qml", "kind": "token", "value": "registeredComponent", "count": 2, "category": "hostIntegration", "owner": "hancore.shibumi.brightness", "capability": "displays"}, + {"source": "hancore.shibumi.brightness/Service.qml", "kind": "token", "value": "registeredSource", "count": 2, "category": "hostIntegration", "owner": "hancore.shibumi.brightness", "capability": "displays"}, + {"source": "hancore.shibumi.brightness/Service.qml", "kind": "token", "value": "registeredWidgetComponent", "count": 2, "category": "hostIntegration", "owner": "hancore.shibumi.brightness", "capability": "displays"}, + {"source": "hancore.shibumi.brightness/Service.qml", "kind": "token", "value": "registeredWidgetSource", "count": 2, "category": "hostIntegration", "owner": "hancore.shibumi.brightness", "capability": "displays"}, + {"source": "hancore.shibumi.center/BarWidget.qml", "kind": "token", "value": "entryPointUrl", "count": 2, "category": "hostIntegration", "owner": "hancore.shibumi.center", "capability": "weather"}, + {"source": "hancore.shibumi.center/BarWidget.qml", "kind": "token", "value": "registeredComponent", "count": 2, "category": "hostIntegration", "owner": "hancore.shibumi.center", "capability": "weather"}, + {"source": "hancore.shibumi.center/BarWidget.qml", "kind": "token", "value": "registeredSource", "count": 2, "category": "hostIntegration", "owner": "hancore.shibumi.center", "capability": "weather"}, + {"source": "hancore.shibumi.center/BarWidget.qml", "kind": "token", "value": "registeredWidgetComponent", "count": 2, "category": "hostIntegration", "owner": "hancore.shibumi.center", "capability": "weather"}, + {"source": "hancore.shibumi.center/BarWidget.qml", "kind": "token", "value": "registeredWidgetSource", "count": 2, "category": "hostIntegration", "owner": "hancore.shibumi.center", "capability": "weather"}, + {"source": "hancore.shibumi.center/SystemUpdateWidget.qml", "kind": "token", "value": "firstPartyServiceFor", "count": 2, "category": "hostIntegration", "owner": "hancore.shibumi.center", "capability": "system-update-state"}, + {"source": "hancore.shibumi.media/BarWidget.qml", "kind": "token", "value": "firstPartyServiceFor", "count": 2, "category": "hostIntegration", "owner": "hancore.shibumi.media", "capability": "media"}, + {"source": "hancore.shibumi.media/Service.qml", "kind": "token", "value": "firstPartyServiceFor", "count": 2, "category": "hostIntegration", "owner": "hancore.shibumi.media", "capability": "media"}, + {"source": "hancore.shibumi.network/Service.qml", "kind": "token", "value": "entryPointUrl", "count": 2, "category": "hostIntegration", "owner": "hancore.shibumi.network", "capability": "network"}, + {"source": "hancore.shibumi.network/Service.qml", "kind": "token", "value": "registeredComponent", "count": 2, "category": "hostIntegration", "owner": "hancore.shibumi.network", "capability": "network"}, + {"source": "hancore.shibumi.network/Service.qml", "kind": "token", "value": "registeredSource", "count": 2, "category": "hostIntegration", "owner": "hancore.shibumi.network", "capability": "network"}, + {"source": "hancore.shibumi.network/Service.qml", "kind": "token", "value": "registeredWidgetComponent", "count": 2, "category": "hostIntegration", "owner": "hancore.shibumi.network", "capability": "network"}, + {"source": "hancore.shibumi.network/Service.qml", "kind": "token", "value": "registeredWidgetSource", "count": 2, "category": "hostIntegration", "owner": "hancore.shibumi.network", "capability": "network"}, + {"source": "hancore.shibumi.reactor/ReactorService.qml", "kind": "token", "value": "firstPartyService", "count": 2, "category": "hostIntegration", "owner": "hancore.shibumi.reactor", "capability": "bar-host-routing"}, + {"source": "hancore.shibumi.reactor/ReactorService.qml", "kind": "token", "value": "firstPartyServiceFor", "count": 2, "category": "hostIntegration", "owner": "hancore.shibumi.reactor", "capability": "bar-host-routing"}, + {"source": "hancore.shibumi.status/BarWidget.qml", "kind": "token", "value": "entryPointUrl", "count": 2, "category": "hostIntegration", "owner": "hancore.shibumi.status", "capability": "system-tray"}, + {"source": "hancore.shibumi.status/BarWidget.qml", "kind": "token", "value": "firstPartyServiceFor", "count": 3, "category": "hostIntegration", "owner": "hancore.shibumi.status", "capability": "system-tray"}, + {"source": "hancore.shibumi.status/BarWidget.qml", "kind": "token", "value": "registeredComponent", "count": 3, "category": "hostIntegration", "owner": "hancore.shibumi.status", "capability": "system-tray"}, + {"source": "hancore.shibumi.status/BarWidget.qml", "kind": "token", "value": "registeredSource", "count": 3, "category": "hostIntegration", "owner": "hancore.shibumi.status", "capability": "system-tray"}, + {"source": "hancore.shibumi.status/BarWidget.qml", "kind": "token", "value": "registeredWidgetComponent", "count": 2, "category": "hostIntegration", "owner": "hancore.shibumi.status", "capability": "system-tray"}, + {"source": "hancore.shibumi.status/BarWidget.qml", "kind": "token", "value": "registeredWidgetSource", "count": 2, "category": "hostIntegration", "owner": "hancore.shibumi.status", "capability": "system-tray"}, + {"source": "hancore.shibumi.status/NotificationAdapter.qml", "kind": "token", "value": "firstPartyServiceFor", "count": 2, "category": "hostIntegration", "owner": "hancore.shibumi.status", "capability": "notifications"}, + {"source": "hancore.shibumi.status/Service.qml", "kind": "token", "value": "firstPartyServiceFor", "count": 2, "category": "hostIntegration", "owner": "hancore.shibumi.status", "capability": "notifications"}, + + {"source": "hancore.shibumi.ai/Service.qml", "kind": "provider", "target": "omarchy.agents", "count": 1, "category": "omarchyBackend", "owner": "hancore.shibumi.ai", "capability": "ai-usage", "backend": "omarchy.agents", "removalStep": "Step 8", "reason": "Replace the revision-bound Agents provider path with a Shibumi provider adapter."}, + {"source": "hancore.shibumi.ai/Service.qml", "kind": "provider", "target": "omarchy.model-usage", "count": 2, "category": "omarchyBackend", "owner": "hancore.shibumi.ai", "capability": "ai-usage", "backend": "omarchy.model-usage", "removalStep": "Step 8", "reason": "Remove the legacy private model-usage provider path after the native provider seam is available."}, + {"source": "hancore.shibumi.audio/BarWidget.qml", "kind": "provider", "target": "omarchy.audio", "count": 2, "category": "omarchyBackend", "owner": "hancore.shibumi.audio", "capability": "audio", "backend": "omarchy.audio", "removalStep": "Step 4A", "reason": "Make Shibumi Audio the sole PipeWire state and mutation owner."}, + {"source": "hancore.shibumi.brightness/Service.qml", "kind": "provider", "target": "omarchy.monitor", "count": 2, "category": "omarchyBackend", "owner": "hancore.shibumi.brightness", "capability": "displays", "backend": "omarchy.monitor", "removalStep": "Step 6", "reason": "Replace the hidden monitor component with the native Shibumi monitor service."}, + {"source": "hancore.shibumi.center/BarWidget.qml", "kind": "provider", "target": "omarchy.system-update", "count": 2, "category": "omarchyBackend", "owner": "hancore.shibumi.center", "capability": "system-update-state", "backend": "omarchy.system-update", "removalStep": "Step 7", "reason": "Own update availability and busy/error state directly instead of loading the official detector."}, + {"source": "hancore.shibumi.network/Service.qml", "kind": "provider", "target": "omarchy.network", "count": 2, "category": "omarchyBackend", "owner": "hancore.shibumi.network", "capability": "network", "backend": "omarchy.network", "removalStep": "Step 5", "reason": "Replace the hidden network component with one native process-wide Network owner."}, + {"source": "hancore.shibumi.status/BarWidget.qml", "kind": "provider", "target": "omarchy.tray", "count": 2, "category": "omarchyBackend", "owner": "hancore.shibumi.status", "capability": "system-tray", "backend": "omarchy.tray", "removalStep": "Step 7", "reason": "Own tray rows and actions through one Shibumi SystemTray service."}, + + {"source": "hancore.shibumi.bar/Bar.qml", "kind": "firstPartyService", "target": "omarchy.idle", "count": 1, "category": "hostOwnedProvider", "owner": "omarchy.idle", "capability": "surface-idle-inhibition"}, + {"source": "hancore.shibumi.center/SystemUpdateWidget.qml", "kind": "firstPartyService", "target": "omarchy.system-update", "count": 1, "category": "omarchyBackend", "owner": "hancore.shibumi.center", "capability": "system-update-state", "backend": "omarchy.system-update", "removalStep": "Step 7", "reason": "The current facade still resolves the official update service for its action and availability state."}, + {"source": "hancore.shibumi.media/BarWidget.qml", "kind": "firstPartyService", "target": "omarchy.media", "count": 1, "category": "omarchyBackend", "owner": "hancore.shibumi.media", "capability": "media", "backend": "omarchy.media", "removalStep": "Step 7", "reason": "Make the Shibumi MPRIS service the sole player state and action owner."}, + {"source": "hancore.shibumi.media/Service.qml", "kind": "firstPartyService", "target": "omarchy.media", "count": 1, "category": "omarchyBackend", "owner": "hancore.shibumi.media", "capability": "media", "backend": "omarchy.media", "removalStep": "Step 7", "reason": "Make the Shibumi MPRIS service the sole player state and action owner."}, + {"source": "hancore.shibumi.reactor/ReactorService.qml", "kind": "firstPartyService", "target": "omarchy.media", "count": 1, "category": "omarchyBackend", "owner": "hancore.shibumi.reactor", "capability": "bar-host-routing", "backend": "omarchy.media", "removalStep": "Step 7", "reason": "Reactor event observation must consume the future Shibumi media snapshot rather than the host service."}, + {"source": "hancore.shibumi.status/NotificationAdapter.qml", "kind": "firstPartyService", "target": "omarchy.notifications", "count": 1, "category": "hostOwnedProvider", "owner": "omarchy.notifications", "capability": "notifications"}, + {"source": "hancore.shibumi.status/Service.qml", "kind": "firstPartyService", "target": "omarchy.idle", "count": 1, "category": "hostOwnedProvider", "owner": "omarchy.idle", "capability": "idle-policy"}, + + {"source": "hancore.shibumi.network/Service.qml", "kind": "helper", "value": "omarchy-network-status", "count": 1, "category": "omarchyBackend", "owner": "hancore.shibumi.network", "capability": "network", "backend": "omarchy.network", "removalStep": "Step 5", "reason": "Remove helper-backed Network state polling after the native service publishes throughput and status."}, + {"source": "hancore.shibumi.power-state/Service.qml", "kind": "helper", "value": "omarchy-battery-status", "count": 1, "category": "omarchyBackend", "owner": "hancore.shibumi.power-state", "capability": "battery-ac", "backend": "omarchy.power", "removalStep": "Step 6", "reason": "Use UPower snapshots without the Omarchy battery status helper."}, + {"source": "hancore.shibumi.power-state/Service.qml", "kind": "helper", "value": "omarchy-powerprofiles-list", "count": 1, "category": "omarchyBackend", "owner": "hancore.shibumi.power-state", "capability": "power-profile", "backend": "omarchy.power", "removalStep": "Step 6", "reason": "Use the public power-profiles API without the Omarchy list helper."}, + {"source": "hancore.shibumi.status/Service.qml", "kind": "helper", "value": "/tmp/omarchy-screenrecord-filename", "count": 1, "category": "omarchyBackend", "owner": "hancore.shibumi.status", "capability": "recording-voxtype", "backend": "omarchy.recording", "removalStep": "Step 8", "reason": "Move recording state to a Shibumi-owned public status interface."}, + {"source": "hancore.shibumi.status/Service.qml", "kind": "helper", "value": "omarchy-voxtype-status", "count": 1, "category": "omarchyBackend", "owner": "hancore.shibumi.status", "capability": "recording-voxtype", "backend": "omarchy.voxtype", "removalStep": "Step 8", "reason": "Move Voxtype status behind the typed Shibumi status service."}, + {"source": "hancore.shibumi.status/Service.qml", "kind": "helper", "value": "pgrep", "count": 1, "category": "nativeApi", "owner": "hancore.shibumi.status", "capability": "recording-voxtype"}, + {"source": "hancore.shibumi.quick-access/scripts/shibumi-picker", "kind": "helper", "value": "pgrep", "count": 1, "category": "nativeApi", "owner": "hancore.shibumi.quick-access", "capability": "quick-access"} + ], + "hostIdRules": [ + {"target": "omarchy.active-window", "count": 2, "category": "hostIntegration", "owner": "quattro.host", "capability": "bar-host-routing"}, + {"target": "omarchy.agents", "count": 10, "category": "omarchyBackend", "owner": "hancore.shibumi.ai", "capability": "ai-usage", "backend": "omarchy.agents", "removalStep": "Step 8", "reason": "Remove private Agents provider identifiers after the Shibumi provider adapter is authoritative."}, + {"target": "omarchy.audio", "count": 13, "category": "omarchyBackend", "owner": "hancore.shibumi.audio", "capability": "audio", "backend": "omarchy.audio", "removalStep": "Step 4A", "reason": "Remove the official audio component and its compatibility identity after native Audio activation."}, + {"target": "omarchy.bar", "count": 11, "category": "hostIntegration", "owner": "quattro.host", "capability": "bar-host-routing"}, + {"target": "omarchy.bluetooth", "count": 8, "category": "hostIntegration", "owner": "hancore.shibumi.bluetooth", "capability": "bluetooth"}, + {"target": "omarchy.clock", "count": 7, "category": "hostIntegration", "owner": "hancore.shibumi.center", "capability": "bar-host-routing"}, + {"target": "omarchy.dropbox", "count": 1, "category": "hostIntegration", "owner": "quattro.host", "capability": "bar-host-routing"}, + {"target": "omarchy.idle", "count": 2, "category": "hostOwnedProvider", "owner": "omarchy.idle", "capability": "idle-policy"}, + {"target": "omarchy.indicators", "count": 3, "category": "hostIntegration", "owner": "quattro.host", "capability": "bar-host-routing"}, + {"target": "omarchy.keyboard-layout", "count": 1, "category": "hostIntegration", "owner": "quattro.host", "capability": "bar-host-routing"}, + {"target": "omarchy.media", "count": 7, "category": "omarchyBackend", "owner": "hancore.shibumi.media", "capability": "media", "backend": "omarchy.media", "removalStep": "Step 7", "reason": "Remove the official media identity after the Shibumi MPRIS service owns state and actions."}, + {"target": "omarchy.menu", "count": 1, "category": "hostIntegration", "owner": "quattro.host", "capability": "bar-host-routing"}, + {"target": "omarchy.microphone", "count": 1, "category": "hostIntegration", "owner": "quattro.host", "capability": "bar-host-routing"}, + {"target": "omarchy.model-usage", "count": 8, "category": "omarchyBackend", "owner": "hancore.shibumi.ai", "capability": "ai-usage", "backend": "omarchy.model-usage", "removalStep": "Step 8", "reason": "Remove the legacy model-usage provider identity after native provider adapters are authoritative."}, + {"target": "omarchy.monitor", "count": 10, "category": "omarchyBackend", "owner": "hancore.shibumi.brightness", "capability": "displays", "backend": "omarchy.monitor", "removalStep": "Step 6", "reason": "Remove the official monitor identity after native display ownership is activated."}, + {"target": "omarchy.network", "count": 12, "category": "omarchyBackend", "owner": "hancore.shibumi.network", "capability": "network", "backend": "omarchy.network", "removalStep": "Step 5", "reason": "Remove the official network identity after native Network ownership is activated."}, + {"target": "omarchy.notifications", "count": 3, "category": "hostOwnedProvider", "owner": "omarchy.notifications", "capability": "notifications"}, + {"target": "omarchy.power", "count": 5, "category": "omarchyBackend", "owner": "hancore.shibumi.power-state", "capability": "power-profile", "backend": "omarchy.power", "removalStep": "Step 6", "reason": "Remove the compatibility power identity after Shibumi owns battery and profile state."}, + {"target": "omarchy.shell", "count": 1, "category": "hostIntegration", "owner": "quattro.host", "capability": "bar-host-routing"}, + {"target": "omarchy.speedtest", "count": 1, "category": "omarchyBackend", "owner": "hancore.shibumi.network", "capability": "network", "backend": "omarchy.network", "removalStep": "Step 5", "reason": "Remove the host speed-test panel identity after the shared Shibumi worker is authoritative."}, + {"target": "omarchy.system-update", "count": 11, "category": "omarchyBackend", "owner": "hancore.shibumi.update-center", "capability": "system-update-state", "backend": "omarchy.system-update", "removalStep": "Step 7", "reason": "Remove the official update detector identity after Shibumi owns update state."}, + {"target": "omarchy.tailscale", "count": 1, "category": "hostIntegration", "owner": "quattro.host", "capability": "bar-host-routing"}, + {"target": "omarchy.tray", "count": 10, "category": "omarchyBackend", "owner": "hancore.shibumi.status", "capability": "system-tray", "backend": "omarchy.tray", "removalStep": "Step 7", "reason": "Remove the official tray identity after the Shibumi SystemTray service is authoritative."}, + {"target": "omarchy.weather", "count": 6, "category": "omarchyBackend", "owner": "hancore.shibumi.center", "capability": "weather", "backend": "omarchy.weather", "removalStep": "Step 8", "reason": "Remove the private weather detail identity after Shibumi owns weather state and actions."}, + {"target": "omarchy.wifiqr", "count": 3, "category": "omarchyBackend", "owner": "hancore.shibumi.network", "capability": "network", "backend": "omarchy.network", "removalStep": "Step 5", "reason": "Replace the host QR surface with the Shibumi-owned primitive-data QR presentation."}, + {"target": "omarchy.workspaces", "count": 3, "category": "hostIntegration", "owner": "hancore.shibumi.workspaces", "capability": "workspaces"} + ], + "commandRules": [ + {"value": "omarchy-agent-usage-", "count": 1, "category": "omarchyBackend", "owner": "hancore.shibumi.ai", "capability": "ai-usage", "backend": "omarchy.agents", "removalStep": "Step 8", "reason": "Remove the host Agents collector command after the Shibumi provider adapter is authoritative."}, + {"value": "omarchy-agent-usage-update", "count": 1, "category": "omarchyBackend", "owner": "hancore.shibumi.ai", "capability": "ai-usage", "backend": "omarchy.agents", "removalStep": "Step 8", "reason": "Remove the host Agents update command after the Shibumi provider adapter is authoritative."}, + {"value": "omarchy-audio-output-set-default", "count": 1, "category": "omarchyBackend", "owner": "hancore.shibumi.bluetooth", "capability": "bluetooth", "backend": "omarchy.audio", "removalStep": "Step 4A", "reason": "Bluetooth must request route selection from the sole Shibumi Audio owner."}, + {"value": "omarchy-battery-status", "count": 1, "category": "omarchyBackend", "owner": "hancore.shibumi.power-state", "capability": "battery-ac", "backend": "omarchy.power", "removalStep": "Step 6", "reason": "Remove the Omarchy battery helper after UPower is authoritative."}, + {"value": "omarchy-bluetooth-device", "count": 1, "category": "omarchyBackend", "owner": "hancore.shibumi.bluetooth", "capability": "bluetooth", "backend": "omarchy.bluetooth", "removalStep": "Step 4B", "reason": "Remove ordinary Omarchy Bluetooth helper mutations after native BlueZ actions are complete."}, + {"value": "omarchy-capture-screenrecording", "count": 1, "category": "omarchyBackend", "owner": "hancore.shibumi.status", "capability": "recording-voxtype", "backend": "omarchy.recording", "removalStep": "Step 8", "reason": "Move recording actions behind the typed Shibumi status service."}, + {"value": "omarchy-cmd-missing", "count": 1, "category": "hostIntegration", "owner": "quattro.host", "capability": "bar-host-routing"}, + {"value": "omarchy-cmd-present", "count": 4, "category": "hostIntegration", "owner": "quattro.host", "capability": "bar-host-routing"}, + {"value": "omarchy-dev", "count": 2, "category": "hostIntegration", "owner": "quattro.host", "capability": "bar-host-routing"}, + {"value": "omarchy-keyboard-panel", "count": 18, "category": "hostIntegration", "owner": "quattro.host", "capability": "bar-host-routing"}, + {"value": "omarchy-launch-floating-terminal-with-presentation", "count": 3, "category": "omarchyAction", "owner": "quattro.host", "capability": "omarchy-update-action"}, + {"value": "omarchy-launch-or-focus-tui", "count": 4, "category": "omarchyAction", "owner": "quattro.host", "capability": "bar-host-routing"}, + {"value": "omarchy-launch-screensaver", "count": 1, "category": "omarchyAction", "owner": "quattro.host", "capability": "idle-policy"}, + {"value": "omarchy-menu-timezone", "count": 2, "category": "omarchyAction", "owner": "quattro.host", "capability": "bar-host-routing"}, + {"value": "omarchy-network-speedtest", "count": 1, "category": "omarchyBackend", "owner": "hancore.shibumi.network", "capability": "network", "backend": "omarchy.network", "removalStep": "Step 5", "reason": "Keep speed-test work process-wide and remove the host helper dependency when the native route is ready."}, + {"value": "omarchy-network-status", "count": 1, "category": "omarchyBackend", "owner": "hancore.shibumi.network", "capability": "network", "backend": "omarchy.network", "removalStep": "Step 5", "reason": "Remove helper-backed Network state polling after native state snapshots are authoritative."}, + {"value": "omarchy-plugin-update", "count": 1, "category": "omarchyAction", "owner": "quattro.host", "capability": "settings"}, + {"value": "omarchy-powerprofiles-list", "count": 1, "category": "omarchyBackend", "owner": "hancore.shibumi.power-state", "capability": "power-profile", "backend": "omarchy.power", "removalStep": "Step 6", "reason": "Remove the Omarchy power-profile helper after the public API is authoritative."}, + {"value": "omarchy-restart-shell", "count": 1, "category": "omarchyAction", "owner": "quattro.host", "capability": "bar-host-routing"}, + {"value": "omarchy-screenrecord-filename", "count": 1, "category": "omarchyBackend", "owner": "hancore.shibumi.status", "capability": "recording-voxtype", "backend": "omarchy.recording", "removalStep": "Step 8", "reason": "Move recording state from the host file contract to a typed Shibumi status interface."}, + {"value": "omarchy-shell", "count": 6, "category": "hostIntegration", "owner": "quattro.host", "capability": "bar-host-routing"}, + {"value": "omarchy-system-lock", "count": 1, "category": "omarchyAction", "owner": "quattro.host", "capability": "idle-policy"}, + {"value": "omarchy-system-reboot", "count": 1, "category": "omarchyAction", "owner": "quattro.host", "capability": "bar-host-routing"}, + {"value": "omarchy-system-shutdown", "count": 1, "category": "omarchyAction", "owner": "quattro.host", "capability": "bar-host-routing"}, + {"value": "omarchy-theme-bg-set", "count": 3, "category": "omarchyAction", "owner": "quattro.host", "capability": "theme-background-selection"}, + {"value": "omarchy-theme-bg-switcher", "count": 2, "category": "omarchyAction", "owner": "quattro.host", "capability": "theme-background-selection"}, + {"value": "omarchy-theme-install", "count": 1, "category": "omarchyAction", "owner": "quattro.host", "capability": "theme-background-selection"}, + {"value": "omarchy-theme-remove", "count": 1, "category": "omarchyAction", "owner": "quattro.host", "capability": "theme-background-selection"}, + {"value": "omarchy-theme-set", "count": 4, "category": "omarchyAction", "owner": "quattro.host", "capability": "theme-background-selection"}, + {"value": "omarchy-theme-switcher", "count": 2, "category": "omarchyAction", "owner": "quattro.host", "capability": "theme-background-selection"}, + {"value": "omarchy-update", "count": 1, "category": "omarchyAction", "owner": "quattro.host", "capability": "omarchy-update-action"}, + {"value": "omarchy-voxtype-config", "count": 1, "category": "omarchyBackend", "owner": "hancore.shibumi.status", "capability": "recording-voxtype", "backend": "omarchy.voxtype", "removalStep": "Step 8", "reason": "Move Voxtype actions behind the typed Shibumi status service."}, + {"value": "omarchy-voxtype-model", "count": 1, "category": "omarchyBackend", "owner": "hancore.shibumi.status", "capability": "recording-voxtype", "backend": "omarchy.voxtype", "removalStep": "Step 8", "reason": "Move Voxtype actions behind the typed Shibumi status service."}, + {"value": "omarchy-voxtype-status", "count": 1, "category": "omarchyBackend", "owner": "hancore.shibumi.status", "capability": "recording-voxtype", "backend": "omarchy.voxtype", "removalStep": "Step 8", "reason": "Move Voxtype status behind the typed Shibumi status service."}, + {"value": "omarchy-weather-location", "count": 3, "category": "omarchyBackend", "owner": "hancore.shibumi.center", "capability": "weather", "backend": "omarchy.weather", "removalStep": "Step 8", "reason": "Move weather location persistence to Shibumi-owned XDG state."} + ] +} diff --git a/docs/release-readiness.md b/docs/release-readiness.md index 2bbd660..87b9537 100644 --- a/docs/release-readiness.md +++ b/docs/release-readiness.md @@ -30,6 +30,28 @@ digests in [`quickshell-dots-d0896fc-v2-deec8103.json`](../contracts/baselines/quickshell-dots-d0896fc-v2-deec8103.json); no maintainer-local checkout path is part of the acceptance contract. +## Step 2 boundary checkpoint + +The compact production boundary is implemented without a backend cutover. The +single machine-readable source is +[`backend-boundary-v1.json`](../contracts/backend-boundary-v1.json); it declares +all capability owners, current boundaries, output cardinality, worker policy, +explicit transition steps, and only the Notifications, OSD, and Idle host-owned +exceptions. `scripts/check-production-boundary` is manifest-driven and fails +closed on unknown categories, owners, transitional backends, private providers, +hidden components, helper-backed state, undeclared commands, package escapes, +and output-owner contract drift. + +Validation passed on the dedicated Step 2 worktree based on `e92d8ee`: + +- production boundary lint and 11 focused boundary regression tests; +- package (13), suite (99), and health (44) unit gates passed; +- documentation, plugin-suite, AUR scaffold, `git diff --check`, and the complete + pinned contract regression passed. + +No Audio, Network, Bluetooth, or other backend cutover was performed. Beta.10, +its tag, release, and the consumed Omarchy baselines remain unchanged. + ## Prerelease acceptance summary The beta.10 source candidate plus explicitly retained prior acceptance currently diff --git a/packaging/aur/PKGBUILD b/packaging/aur/PKGBUILD index a6a891a..0cdba1f 100644 --- a/packaging/aur/PKGBUILD +++ b/packaging/aur/PKGBUILD @@ -48,6 +48,8 @@ package() { "$appdir/contracts/host-facade-v1.json" install -Dm644 contracts/package-runtime-v1.json \ "$appdir/contracts/package-runtime-v1.json" + install -Dm644 contracts/backend-boundary-v1.json \ + "$appdir/contracts/backend-boundary-v1.json" install -Dm755 scripts/shibumi-suite "$appdir/scripts/shibumi-suite" cp -a --no-preserve=ownership scripts/shibumi_suite "$appdir/scripts/" install -Dm644 VERSION "$appdir/VERSION" diff --git a/scripts/check-aur-package b/scripts/check-aur-package index ef51c42..5e9d18f 100755 --- a/scripts/check-aur-package +++ b/scripts/check-aur-package @@ -37,6 +37,9 @@ jq -e --arg version "$version" \ packaging/package-metadata.json >/dev/null jq -e --arg version "$version" '.suiteVersion == $version' \ contracts/plugin-suite-v1.json >/dev/null +jq -e '.schemaVersion == 1 and .manifestId == "hancore.shibumi.backend-boundary"' \ + contracts/backend-boundary-v1.json >/dev/null +scripts/check-production-boundary if ! jq -e --arg version "$version" '.version == $version' \ hancore.shibumi.*/manifest.json >/dev/null; then printf 'plugin manifest versions do not match VERSION %s\n' "$version" >&2 diff --git a/scripts/check-production-boundary b/scripts/check-production-boundary index 1b37246..c146a92 100755 --- a/scripts/check-production-boundary +++ b/scripts/check-production-boundary @@ -3,143 +3,73 @@ from __future__ import annotations import argparse +import json import os import posixpath import re import sys from collections import Counter from pathlib import Path +from typing import Any -CODE_SUFFIXES = {"", ".bash", ".js", ".mjs", ".py", ".qml", ".sh"} - -# Exact, reviewed compatibility debt. A capability cutover removes entries; it -# must not broaden them. This is a repository policy gate, not a hostile-code -# sandbox: deliberate lexical obfuscation is outside its threat model. -TOKEN_ALLOWANCES = { - "hancore.shibumi.ai/Service.qml": Counter({ - "entryPointUrl": 2, - "registeredWidgetSource": 2, - "standardWidgetSource": 3, - }), - "hancore.shibumi.audio/BarWidget.qml": Counter({ - "entryPointUrl": 2, - "registeredComponent": 2, - "registeredSource": 2, - "registeredWidgetComponent": 2, - "registeredWidgetSource": 2, - }), - "hancore.shibumi.bar/Bar.qml": Counter({ - "entryPointUrl": 3, - "firstPartyServiceFor": 2, - "registeredWidgetComponent": 1, - "registeredWidgetSource": 1, - }), - "hancore.shibumi.bar/core/WidgetSlot.qml": Counter({ - "registeredWidgetComponent": 4, - }), - "hancore.shibumi.bar/services/HostWidgetResolver.qml": Counter({ - "Qt.createComponent": 1, - "entryPointUrl": 5, - }), - "hancore.shibumi.brightness/Service.qml": Counter({ - "entryPointUrl": 2, - "registeredComponent": 2, - "registeredSource": 2, - "registeredWidgetComponent": 2, - "registeredWidgetSource": 2, - }), - "hancore.shibumi.center/BarWidget.qml": Counter({ - "entryPointUrl": 2, - "registeredComponent": 2, - "registeredSource": 2, - "registeredWidgetComponent": 2, - "registeredWidgetSource": 2, - }), - "hancore.shibumi.center/SystemUpdateWidget.qml": Counter({ - "firstPartyServiceFor": 2, - }), - "hancore.shibumi.media/BarWidget.qml": Counter({ - "firstPartyServiceFor": 2, - }), - "hancore.shibumi.media/Service.qml": Counter({ - "firstPartyServiceFor": 2, - }), - "hancore.shibumi.network/Service.qml": Counter({ - "entryPointUrl": 2, - "registeredComponent": 2, - "registeredSource": 2, - "registeredWidgetComponent": 2, - "registeredWidgetSource": 2, - }), - "hancore.shibumi.reactor/ReactorService.qml": Counter({ - "firstPartyService": 2, - "firstPartyServiceFor": 2, - }), - "hancore.shibumi.status/BarWidget.qml": Counter({ - "entryPointUrl": 2, - "firstPartyServiceFor": 3, - "registeredComponent": 3, - "registeredSource": 3, - "registeredWidgetComponent": 2, - "registeredWidgetSource": 2, - }), - "hancore.shibumi.status/NotificationAdapter.qml": Counter({ - "firstPartyServiceFor": 2, - }), - "hancore.shibumi.status/Service.qml": Counter({ - "firstPartyServiceFor": 2, - }), +CODE_SUFFIXES = {"", ".bash", ".js", ".json", ".mjs", ".py", ".qml", ".sh"} +CATEGORIES = { + "hostIntegration", + "nativeApi", + "omarchyBackend", + "omarchyAction", + "hostOwnedProvider", } - -PROVIDER_ALLOWANCES = { - "hancore.shibumi.ai/Service.qml": { - "omarchy.agents", "omarchy.model-usage", - }, - "hancore.shibumi.audio/BarWidget.qml": {"omarchy.audio"}, - "hancore.shibumi.brightness/Service.qml": {"omarchy.monitor"}, - "hancore.shibumi.center/BarWidget.qml": {"omarchy.system-update"}, - "hancore.shibumi.network/Service.qml": {"omarchy.network"}, - "hancore.shibumi.status/BarWidget.qml": {"omarchy.tray"}, +CARDINALITIES = {"process-wide", "output-local"} +WORKER_POLICIES = { + "none", + "shared-demand-driven", + "surface-bound", + "host-owned", + "action-only", + "output-local-ui-only", } - -FIRST_PARTY_ALLOWANCES = { - "hancore.shibumi.bar/Bar.qml": {"omarchy.idle"}, - "hancore.shibumi.center/SystemUpdateWidget.qml": { - "omarchy.system-update", - }, - "hancore.shibumi.media/BarWidget.qml": {"omarchy.media"}, - "hancore.shibumi.media/Service.qml": {"omarchy.media"}, - "hancore.shibumi.reactor/ReactorService.qml": {"omarchy.media"}, - "hancore.shibumi.status/NotificationAdapter.qml": { - "omarchy.notifications", - }, - "hancore.shibumi.status/Service.qml": { - "omarchy.idle", - }, +RULE_KINDS = {"token", "provider", "firstPartyService", "helper"} +TOKEN_NAMES = { + "entryPointUrl", + "firstPartyService", + "firstPartyServiceFor", + "registeredComponent", + "registeredSource", + "registeredWidgetComponent", + "registeredWidgetSource", + "standardWidgetSource", + "Qt.createComponent", } - -HELPER_ALLOWANCES = { - "hancore.shibumi.network/Service.qml": Counter({ - "omarchy-network-status": 1, - }), - "hancore.shibumi.power-state/Service.qml": Counter({ - "omarchy-battery-status": 1, - "omarchy-powerprofiles-list": 1, - }), - "hancore.shibumi.status/Service.qml": Counter({ - "/tmp/omarchy-screenrecord-filename": 1, - "omarchy-voxtype-status": 1, - "pgrep": 1, - }), - "hancore.shibumi.quick-access/scripts/shibumi-picker": Counter({ - "pgrep": 1, - }), +REQUIRED_CAPABILITIES = { + "bar-host-routing", + "settings", + "telemetry", + "workspaces", + "audio", + "bluetooth", + "network", + "displays", + "battery-ac", + "power-profile", + "media", + "system-tray", + "system-update-state", + "omarchy-update-action", + "ai-usage", + "weather", + "recording-voxtype", + "quick-access", + "theme-background-selection", + "surface-idle-inhibition", } - -PROTECTED_TOKENS = { - token for allowance in TOKEN_ALLOWANCES.values() for token in allowance +HOST_EXCEPTIONS = { + "notifications": "omarchy.notifications", + "osd": "omarchy.osd", + "idle": "omarchy.idle", } +FIXED_OWNERS = {"quattro.host", *HOST_EXCEPTIONS.values()} PRIVATE_PATHS = ( re.compile(r"/usr/share/omarchy/(?:plugins|shell)(?:/|$)"), re.compile(r"(?:^|[/\"'\s])shell/plugins/", re.MULTILINE), @@ -159,9 +89,15 @@ HELPER_MARKER = re.compile( r"\bomarchy-[A-Za-z0-9-]*(?:status|state|list)\b|" r"\b(?:pgrep|pidof)\b" ) +HOST_ID = re.compile(r"[\"'](omarchy\.[A-Za-z0-9_-]+)[\"']") +OMARCHY_COMMAND = re.compile(r"\bomarchy-[A-Za-z0-9_.-]+") RELATIVE_LITERAL = re.compile(r"[\"']([^\"']*\.\.[^\"']*)[\"']") +class ManifestError(ValueError): + pass + + def add(findings: list[str], path: str, kind: str, detail: str) -> None: findings.append(f"{path}: {kind}: {detail}") @@ -183,38 +119,296 @@ def production_files(root: Path) -> list[tuple[Path, Path]]: return files +def require(condition: bool, message: str) -> None: + if not condition: + raise ManifestError(message) + + +def string_field(value: Any, name: str) -> str: + require(isinstance(value, str) and value != "", f"{name} must be a non-empty string") + return value + + +def load_suite_owners(root: Path) -> set[str]: + suite_path = root / "contracts/plugin-suite-v1.json" + require(suite_path.is_file(), "plugin suite contract is required") + try: + suite = json.loads(suite_path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error: + raise ManifestError(f"cannot read plugin suite contract: {error}") from error + plugins = suite.get("plugins") + require( + isinstance(plugins, list) and len(plugins) > 0, + "plugin suite plugins must be a non-empty array", + ) + owners: set[str] = set() + for plugin in plugins: + require(isinstance(plugin, dict), "plugin suite plugin must be an object") + owners.add(string_field(plugin.get("id"), "plugin id")) + return owners + + +def validate_transition( + entry: dict[str, Any], + context: str, + transitional_backends: set[str], +) -> None: + category = entry.get("category", entry.get("backendBoundary")) + if category != "omarchyBackend": + require("removalStep" not in entry, f"{context}: removalStep is only valid for omarchyBackend") + require("backend" not in entry, f"{context}: backend is only valid for omarchyBackend") + return + backend = string_field(entry.get("backend"), f"{context}.backend") + require( + backend in transitional_backends, + f"{context}: unknown transitional backend {backend}", + ) + removal_step = string_field(entry.get("removalStep"), f"{context}.removalStep") + require( + re.fullmatch(r"Step (?:4A|4B|5|6|7|8)", removal_step) is not None, + f"{context}: invalid removalStep {removal_step}", + ) + reason = string_field(entry.get("reason"), f"{context}.reason") + require(len(reason) >= 12, f"{context}: transition reason is too short") + + +def validate_manifest(root: Path, manifest: dict[str, Any]) -> tuple[set[str], bool]: + require(manifest.get("schemaVersion") == 1, "schemaVersion must be 1") + require( + manifest.get("manifestId") == "hancore.shibumi.backend-boundary", + "manifestId is not the Shibumi backend boundary contract", + ) + categories = manifest.get("categories") + require(isinstance(categories, list) and set(categories) == CATEGORIES and len(categories) == len(CATEGORIES), "categories are not the exact boundary enum") + require(set(manifest.get("outputCardinalities", [])) == CARDINALITIES, "output cardinalities drifted") + require(set(manifest.get("workerPolicies", [])) == WORKER_POLICIES, "worker policies drifted") + transitional = manifest.get("transitionalBackends") + require(isinstance(transitional, list), "transitionalBackends must be an array") + require( + len(transitional) > 0 + and len(set(transitional)) == len(transitional) + and all( + isinstance(backend, str) + and re.fullmatch(r"omarchy\.[A-Za-z0-9_.-]+", backend) + for backend in transitional + ), + "transitionalBackends contains an invalid or duplicate backend", + ) + transitional_backends = set(transitional) + + capabilities = manifest.get("capabilities") + require(isinstance(capabilities, list), "capabilities must be an array") + capability_ids = [string_field(item.get("id"), "capability id") for item in capabilities if isinstance(item, dict)] + require(len(capability_ids) == len(capabilities), "each capability must be an object") + require(len(set(capability_ids)) == len(capability_ids), "duplicate capability id") + require(set(capability_ids) == REQUIRED_CAPABILITIES, "capability inventory is incomplete or unknown") + + suite_owners = load_suite_owners(root) + known_owners = set(FIXED_OWNERS) + known_owners.update(suite_owners) + for capability in capabilities: + context = f"capability {capability.get('id', '')}" + owner = string_field(capability.get("owner"), f"{context}.owner") + if suite_owners: + require(owner in suite_owners, f"{context}: unknown owner {owner}") + else: + require(owner in suite_owners, f"{context}: unknown owner {owner}") + boundary = string_field(capability.get("backendBoundary"), f"{context}.backendBoundary") + require(boundary in CATEGORIES, f"{context}: unknown category {boundary}") + require( + boundary != "hostOwnedProvider", + f"{context}: hostOwnedProvider is reserved for Notifications, OSD, and Idle", + ) + cardinality = string_field(capability.get("outputCardinality"), f"{context}.outputCardinality") + require(cardinality in CARDINALITIES, f"{context}: unknown output cardinality {cardinality}") + policy = string_field(capability.get("workerPolicy"), f"{context}.workerPolicy") + require(policy in WORKER_POLICIES, f"{context}: unknown worker policy {policy}") + string_field(capability.get("reason"), f"{context}.reason") + if cardinality == "process-wide" and owner not in FIXED_OWNERS: + owner_manifest = root / owner / "manifest.json" + if owner_manifest.is_file(): + try: + owner_data = json.loads(owner_manifest.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error: + raise ManifestError(f"{context}: cannot read owner manifest: {error}") from error + require( + isinstance(owner_data, dict) + and "service" in owner_data.get("kinds", []) + and isinstance(owner_data.get("entryPoints", {}).get("service"), str), + f"{context}: process-wide owner must expose one service entry point", + ) + validate_transition( + capability, context, transitional_backends + ) + + providers = manifest.get("hostOwnedProviders") + require(isinstance(providers, list), "hostOwnedProviders must be an array") + provider_ids = [string_field(item.get("id"), "host provider id") for item in providers if isinstance(item, dict)] + require(len(provider_ids) == len(providers), "each host provider must be an object") + require( + len(provider_ids) == len(HOST_EXCEPTIONS) + and len(set(provider_ids)) == len(provider_ids) + and set(provider_ids) == set(HOST_EXCEPTIONS), + "Notifications, OSD, and Idle must be the only host exceptions", + ) + host_provider_map: dict[str, dict[str, Any]] = {} + for provider in providers: + context = f"host provider {provider.get('id', '')}" + provider_id = string_field(provider.get("id"), f"{context}.id") + expected = HOST_EXCEPTIONS[provider_id] + require(provider.get("provider") == expected, f"{context}: provider identity drifted") + require(provider.get("owner") == expected, f"{context}: owner must be {expected}") + require(provider.get("category") == "hostOwnedProvider", f"{context}: category must be hostOwnedProvider") + require(provider.get("outputCardinality") == "process-wide", f"{context}: host providers must be process-wide") + require(provider.get("workerPolicy") == "host-owned", f"{context}: worker policy must be host-owned") + forms = provider.get("allowedForms") + expected_forms = { + "notifications": {"hostId", "firstPartyService"}, + "osd": {"hostAction"}, + "idle": {"hostId", "firstPartyService"}, + }[provider_id] + require( + isinstance(forms, list) + and set(forms) == expected_forms + and len(forms) == len(expected_forms), + f"{context}: allowedForms drifted", + ) + string_field(provider.get("reason"), f"{context}.reason") + validate_transition(provider, context, transitional_backends) + host_provider_map[expected] = provider + + allowed_capabilities = REQUIRED_CAPABILITIES | {"notifications", "osd", "idle-policy"} + allowed_owners = set(known_owners) + allowed_owners.update(HOST_EXCEPTIONS.values()) + allowed_owners.add("quattro.host") + rules = manifest.get("rules") + require(isinstance(rules, list), "rules must be an array") + rule_keys: set[tuple[str, str, str]] = set() + for index, rule in enumerate(rules): + context = f"rule {index}" + require(isinstance(rule, dict), f"{context} must be an object") + source = string_field(rule.get("source"), f"{context}.source") + require(".." not in Path(source).parts and source.startswith("hancore.shibumi."), f"{context}: invalid production source") + kind = string_field(rule.get("kind"), f"{context}.kind") + require(kind in RULE_KINDS, f"{context}: unknown rule kind {kind}") + value = rule.get("target") if kind in {"provider", "firstPartyService"} else rule.get("value") + value = string_field(value, f"{context}.value") + key = (source, kind, value) + require(key not in rule_keys, f"{context}: duplicate dependency rule") + rule_keys.add(key) + count = rule.get("count") + require(isinstance(count, int) and count > 0, f"{context}: count must be a positive integer") + category = string_field(rule.get("category"), f"{context}.category") + require(category in CATEGORIES, f"{context}: unknown category {category}") + owner = string_field(rule.get("owner"), f"{context}.owner") + require(owner in allowed_owners, f"{context}: unknown owner {owner}") + capability = string_field(rule.get("capability"), f"{context}.capability") + require(capability in allowed_capabilities, f"{context}: unknown capability {capability}") + if value in HOST_EXCEPTIONS.values(): + require(category == "hostOwnedProvider", f"{context}: host exception identity must be hostOwnedProvider") + require(owner == value, f"{context}: host exception owner must be {value}") + if kind in {"provider", "firstPartyService"}: + require(re.fullmatch(r"omarchy\.[A-Za-z0-9_.-]+", value) is not None, f"{context}: invalid provider id") + if kind == "firstPartyService" and category == "hostOwnedProvider": + require(value in HOST_EXCEPTIONS.values(), f"{context}: undeclared host provider") + if category == "hostOwnedProvider": + require( + value in HOST_EXCEPTIONS.values(), + f"{context}: only Notifications, OSD, and Idle may be hostOwnedProvider", + ) + require( + kind in {"firstPartyService"} + and kind in host_provider_map[value]["allowedForms"], + f"{context}: host provider form is not declared", + ) + validate_transition(rule, context, transitional_backends) + + for key, collection_name in (("hostIdRules", "hostIdRules"), ("commandRules", "commandRules")): + entries = manifest.get(key) + require(isinstance(entries, list), f"{collection_name} must be an array") + seen: set[str] = set() + for index, entry in enumerate(entries): + context = f"{collection_name} {index}" + require(isinstance(entry, dict), f"{context} must be an object") + value_key = "target" if key == "hostIdRules" else "value" + value = string_field(entry.get(value_key), f"{context}.{value_key}") + require(value not in seen, f"{context}: duplicate global dependency") + seen.add(value) + if key == "hostIdRules": + require(re.fullmatch(r"omarchy\.[A-Za-z0-9_-]+", value) is not None, f"{context}: invalid host id") + kind = "hostId" + else: + require(re.fullmatch(r"omarchy-[A-Za-z0-9_.-]+", value) is not None, f"{context}: invalid command") + kind = "hostAction" + count = entry.get("count") + require(isinstance(count, int) and count > 0, f"{context}: count must be a positive integer") + category = string_field(entry.get("category"), f"{context}.category") + require(category in CATEGORIES, f"{context}: unknown category {category}") + owner = string_field(entry.get("owner"), f"{context}.owner") + require(owner in allowed_owners, f"{context}: unknown owner {owner}") + capability = string_field(entry.get("capability"), f"{context}.capability") + require(capability in allowed_capabilities, f"{context}: unknown capability {capability}") + if value in HOST_EXCEPTIONS.values(): + require(category == "hostOwnedProvider", f"{context}: host exception identity must be hostOwnedProvider") + require(owner == value, f"{context}: host exception owner must be {value}") + if category == "hostOwnedProvider": + require( + value in HOST_EXCEPTIONS.values(), + f"{context}: only declared host providers may be hostOwnedProvider", + ) + require( + kind == "hostId" + and "hostId" in host_provider_map[value]["allowedForms"], + f"{context}: host provider form is not declared", + ) + validate_transition(entry, context, transitional_backends) + + return allowed_owners, bool(suite_owners) + + def compare_exact( - findings: list[str], relative: str, kind: str, + findings: list[str], path: str, kind: str, observed: Counter[str], allowed: Counter[str], + *, + report_missing: bool, ) -> None: for token, count in sorted((observed - allowed).items()): - add(findings, relative, kind, f"{token} occurs {count} unapproved time(s)") - for token, count in sorted((allowed - observed).items()): - add( - findings, - relative, - kind, - f"approved {token} occurrence disappeared {count} time(s); " - "remove or update its narrow allowance", - ) + add(findings, path, kind, f"{token} occurs {count} unapproved time(s)") + if report_missing: + for token, count in sorted((allowed - observed).items()): + add( + findings, + path, + kind, + f"approved {token} occurrence disappeared {count} time(s); remove or update its narrow manifest rule", + ) + +def rules_for(manifest: dict[str, Any], source: str, kind: str) -> list[dict[str, Any]]: + return [rule for rule in manifest["rules"] if rule["source"] == source and rule["kind"] == kind] -def check_tree(root: Path) -> list[str]: + +def check_tree(root: Path, manifest_path: Path) -> list[str]: root = root.resolve() findings: list[str] = [] - visited: set[str] = set() - present_plugins = { - path.name for path in root.glob("hancore.shibumi.*") if path.is_dir() - } + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + if not isinstance(manifest, dict): + raise ManifestError("manifest root must be an object") + _, complete_suite = validate_manifest(root, manifest) + except (OSError, UnicodeDecodeError, json.JSONDecodeError, ManifestError) as error: + return [f"boundary manifest: invalid: {error}"] - for path, plugin in production_files(root): + files = production_files(root) + visited: set[str] = set() + present_plugins = {path.name for path in root.glob("hancore.shibumi.*") if path.is_dir()} + source_text: dict[str, str] = {} + for path, plugin in files: relative = path.relative_to(root).as_posix() visited.add(relative) resolved_plugin = plugin.resolve() if path.is_symlink(): - kind = "package escape" \ - if not path.resolve().is_relative_to(resolved_plugin) \ - else "production symlink" + kind = "package escape" if not path.resolve().is_relative_to(resolved_plugin) else "production symlink" add(findings, relative, kind, "production symlinks are not allowed") continue try: @@ -222,55 +416,38 @@ def check_tree(root: Path) -> list[str]: except UnicodeDecodeError: add(findings, relative, "undecodable production source", "not UTF-8") continue + source_text[relative] = text for pattern in PRIVATE_PATHS: if pattern.search(text): add(findings, relative, "private component path", pattern.pattern) - observed_tokens: Counter[str] = Counter() - for token in PROTECTED_TOKENS: + token_rules = rules_for(manifest, relative, "token") + protected_observed: Counter[str] = Counter() + for token in TOKEN_NAMES: count = text.count(token) if "." in token else len(re.findall( - rf"(? list[str]: continue resolved = (path.parent / value).resolve() if not resolved.is_relative_to(resolved_plugin): - add( - findings, - relative, - "package escape", - f"relative literal leaves plugin: {match.group(1)}", - ) + add(findings, relative, "package escape", f"relative literal leaves plugin: {match.group(1)}") - allowed_paths = set(TOKEN_ALLOWANCES) | set(PROVIDER_ALLOWANCES) \ - | set(FIRST_PARTY_ALLOWANCES) | set(HELPER_ALLOWANCES) - complete_suite = (root / "contracts/plugin-suite-v1.json").is_file() + allowed_paths = {rule["source"] for rule in manifest["rules"]} for allowed_path in sorted(allowed_paths - visited): if complete_suite or allowed_path.split("/", 1)[0] in present_plugins: - add( - findings, - allowed_path, - "stale boundary allowance", - "allowlisted production file is missing", - ) + add(findings, allowed_path, "stale boundary rule", "declared production source is missing") + + observed_host_ids: Counter[str] = Counter() + observed_commands: Counter[str] = Counter() + for text in source_text.values(): + observed_host_ids.update(match.group(1) for match in HOST_ID.finditer(text)) + observed_commands.update(match.group(0) for match in OMARCHY_COMMAND.finditer(text)) + allowed_host_ids = Counter({entry["target"]: entry["count"] for entry in manifest["hostIdRules"]}) + allowed_commands = Counter({entry["value"]: entry["count"] for entry in manifest["commandRules"]}) + compare_exact(findings, "", "undeclared host dependency", observed_host_ids, allowed_host_ids, report_missing=complete_suite) + compare_exact(findings, "", "undeclared Omarchy command", observed_commands, allowed_commands, report_missing=complete_suite) return sorted(set(findings)) def main() -> int: - parser = argparse.ArgumentParser( - description="Reject undeclared private backends in shipped Shibumi plugins." - ) - parser.add_argument( - "--root", - type=Path, - default=Path(__file__).resolve().parents[1], - help="tree containing hancore.shibumi.* plugin directories", - ) + parser = argparse.ArgumentParser(description="Reject undeclared private backends in shipped Shibumi plugins.") + parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[1], help="tree containing hancore.shibumi.* plugin directories") + parser.add_argument("--manifest", type=Path, help="boundary manifest; defaults to /contracts/backend-boundary-v1.json") args = parser.parse_args() - if not args.root.is_dir(): - parser.error(f"production root is not a directory: {args.root}") - findings = check_tree(args.root) + root = args.root + if not root.is_dir(): + parser.error(f"production root is not a directory: {root}") + manifest = args.manifest or root / "contracts/backend-boundary-v1.json" + findings = check_tree(root, manifest) if findings: for finding in findings: print(f"production boundary error: {finding}", file=sys.stderr) diff --git a/scripts/rehearse-aur-package b/scripts/rehearse-aur-package index 7cbc23c..d665a82 100755 --- a/scripts/rehearse-aur-package +++ b/scripts/rehearse-aur-package @@ -116,6 +116,7 @@ def main() -> int: "usr/bin/shibumi-shell", "usr/share/shibumi-shell/VERSION", "usr/share/shibumi-shell/PACKAGE-METADATA.json", + "usr/share/shibumi-shell/contracts/backend-boundary-v1.json", "usr/share/shibumi-shell/scripts/shibumi-suite", "usr/share/licenses/shibumi-shell/LICENSE", } diff --git a/scripts/shibumi_suite/model.py b/scripts/shibumi_suite/model.py index d40cceb..04cd426 100644 --- a/scripts/shibumi_suite/model.py +++ b/scripts/shibumi_suite/model.py @@ -395,6 +395,7 @@ def revision(self) -> str: "--ignored=matching", "--", "contracts/plugin-suite-v1.json", + "contracts/backend-boundary-v1.json", *(sorted(self.plugins)), ], check=True, @@ -413,7 +414,10 @@ def revision(self) -> str: except (IndexError, ValueError): dirty = True break - if relative.as_posix() == "contracts/plugin-suite-v1.json": + if relative.as_posix() in { + "contracts/plugin-suite-v1.json", + "contracts/backend-boundary-v1.json", + }: dirty = True break if not relative.parts or relative.parts[0] not in self.plugins: diff --git a/tests/production-boundary-regression.py b/tests/production-boundary-regression.py index ef8ae71..a73077c 100755 --- a/tests/production-boundary-regression.py +++ b/tests/production-boundary-regression.py @@ -2,6 +2,8 @@ from __future__ import annotations +import json +import shutil import subprocess import tempfile import unittest @@ -22,12 +24,26 @@ def run_linter(self, root: Path) -> subprocess.CompletedProcess[str]: text=True, ) + def copy_contracts(self, root: Path, *, suite: bool = True) -> None: + contracts = root / "contracts" + contracts.mkdir(exist_ok=True) + shutil.copy( + ROOT / "contracts/backend-boundary-v1.json", + contracts / "backend-boundary-v1.json", + ) + if suite: + shutil.copy( + ROOT / "contracts/plugin-suite-v1.json", + contracts / "plugin-suite-v1.json", + ) + def fixture_result( self, source: str, name: str = "Service.qml" ) -> subprocess.CompletedProcess[str]: temporary = tempfile.TemporaryDirectory(prefix="shibumi-boundary-test.") self.addCleanup(temporary.cleanup) root = Path(temporary.name) + self.copy_contracts(root) plugin = root / "hancore.shibumi.fixture" plugin.mkdir() (plugin / name).write_text(source, encoding="utf-8") @@ -79,6 +95,7 @@ def test_all_executables_are_scanned_and_invalid_utf8_fails(self) -> None: prefix="shibumi-boundary-test." ) as temporary: root = Path(temporary) + self.copy_contracts(root) plugin = root / "hancore.shibumi.fixture" plugin.mkdir() executable = plugin / "probe.bin" @@ -103,6 +120,7 @@ def test_production_symlinks_fail_closed(self) -> None: prefix="shibumi-boundary-test." ) as temporary: root = Path(temporary) + self.copy_contracts(root) payload = root / "payload" payload.mkdir() (payload / "Service.qml").write_text( @@ -118,6 +136,7 @@ def test_production_symlinks_fail_closed(self) -> None: prefix="shibumi-boundary-test." ) as temporary: root = Path(temporary) + self.copy_contracts(root) plugin = root / "hancore.shibumi.fixture" plugin.mkdir() target = plugin / "payload.bin" @@ -132,6 +151,7 @@ def test_removed_debt_requires_allowance_cleanup(self) -> None: prefix="shibumi-boundary-test." ) as temporary: root = Path(temporary) + self.copy_contracts(root) plugin = root / "hancore.shibumi.status" plugin.mkdir() source = ( @@ -150,13 +170,13 @@ def test_missing_allowlisted_file_fails_when_plugin_is_present(self) -> None: prefix="shibumi-boundary-test." ) as temporary: root = Path(temporary) + self.copy_contracts(root) plugin = root / "hancore.shibumi.media" plugin.mkdir() - (plugin / "manifest.json").write_text("{}\n", encoding="utf-8") result = self.run_linter(root) self.assertEqual(result.returncode, 1, result.stdout + result.stderr) self.assertIn( - "hancore.shibumi.media/Service.qml: stale boundary allowance", + "hancore.shibumi.media/Service.qml: stale boundary rule", result.stderr, ) @@ -165,18 +185,138 @@ def test_complete_suite_rejects_allowance_for_missing_plugin(self) -> None: prefix="shibumi-boundary-test." ) as temporary: root = Path(temporary) + self.copy_contracts(root) contracts = root / "contracts" - contracts.mkdir() (contracts / "plugin-suite-v1.json").write_text( - "{}\n", encoding="utf-8" + (ROOT / "contracts/plugin-suite-v1.json").read_text( + encoding="utf-8" + ), + encoding="utf-8", ) result = self.run_linter(root) self.assertEqual(result.returncode, 1, result.stdout + result.stderr) self.assertIn( - "hancore.shibumi.audio/BarWidget.qml: stale boundary allowance", + "hancore.shibumi.audio/BarWidget.qml: stale boundary rule", result.stderr, ) + def test_manifest_rejects_unknown_categories_owners_backends_and_exceptions(self) -> None: + cases = { + "category": ( + lambda data: data["capabilities"][0].update( + {"backendBoundary": "futureBoundary"} + ), + "unknown category futureBoundary", + ), + "owner": ( + lambda data: data["rules"][0].update( + {"owner": "hancore.shibumi.unknown"} + ), + "unknown owner hancore.shibumi.unknown", + ), + "backend": ( + lambda data: next( + rule for rule in data["rules"] + if rule.get("category") == "omarchyBackend" + ).update({"backend": "omarchy.future"}), + "unknown transitional backend omarchy.future", + ), + "capability-backend": ( + lambda data: next( + capability for capability in data["capabilities"] + if capability["id"] == "audio" + ).update({"backend": "omarchy.future"}), + "unknown transitional backend omarchy.future", + ), + "host-forms": ( + lambda data: data["hostOwnedProviders"][0].update( + {"allowedForms": ["evil"]} + ), + "host provider notifications: allowedForms drifted", + ), + "host-cardinality": ( + lambda data: data["hostOwnedProviders"][2].update( + {"outputCardinality": "output-local"} + ), + "host provider idle: host providers must be process-wide", + ), + "host-reclassified": ( + lambda data: next( + rule for rule in data["hostIdRules"] + if rule["target"] == "omarchy.notifications" + ).update({"category": "hostIntegration"}), + "host exception identity must be hostOwnedProvider", + ), + "duplicate-host": ( + lambda data: data["hostOwnedProviders"].append( + dict(data["hostOwnedProviders"][0]) + ), + "Notifications, OSD, and Idle must be the only host exceptions", + ), + "backend-on-native": ( + lambda data: next( + capability for capability in data["capabilities"] + if capability["id"] == "bluetooth" + ).update({"backend": "omarchy.future"}), + "backend is only valid for omarchyBackend", + ), + "capability-host-owned": ( + lambda data: next( + capability for capability in data["capabilities"] + if capability["id"] == "bluetooth" + ).update({"backendBoundary": "hostOwnedProvider"}), + "hostOwnedProvider is reserved for Notifications, OSD, and Idle", + ), + "host-provider-backend": ( + lambda data: data["hostOwnedProviders"][0].update( + {"backend": "omarchy.notifications"} + ), + "backend is only valid for omarchyBackend", + ), + "host-exception": ( + lambda data: data["hostOwnedProviders"].append( + { + **data["hostOwnedProviders"][0], + "id": "network", + "provider": "omarchy.network", + "owner": "omarchy.network", + } + ), + "Notifications, OSD, and Idle must be the only host exceptions", + ), + } + for name, (mutate, expected) in cases.items(): + with self.subTest(name=name): + with tempfile.TemporaryDirectory( + prefix="shibumi-boundary-manifest-test." + ) as temporary: + root = Path(temporary) + self.copy_contracts(root) + manifest_path = root / "contracts/backend-boundary-v1.json" + data = json.loads(manifest_path.read_text(encoding="utf-8")) + mutate(data) + manifest_path.write_text( + json.dumps(data), encoding="utf-8" + ) + result = self.run_linter(root) + self.assertEqual(result.returncode, 1, result.stdout + result.stderr) + self.assertIn(expected, result.stderr) + + def test_new_undeclared_platform_command_fails_closed(self) -> None: + result = self.fixture_result( + 'property string command: "omarchy-new-platform-action"\n' + ) + self.assertEqual(result.returncode, 1, result.stdout + result.stderr) + self.assertIn("undeclared Omarchy command", result.stderr) + + def test_json_manifests_are_scanned(self) -> None: + result = self.fixture_result( + '{"source": "/usr/share/omarchy/shell/plugins/private.qml"}\n', + name="manifest.json", + ) + self.assertEqual(result.returncode, 1, result.stdout + result.stderr) + self.assertIn("private component path", result.stderr) + def test_package_job_runs_boundary_regression_directly(self) -> None: workflow = ( ROOT / ".github/workflows/package-release.yml" diff --git a/tests/test_package_release.py b/tests/test_package_release.py index a75d83d..773f47c 100644 --- a/tests/test_package_release.py +++ b/tests/test_package_release.py @@ -55,6 +55,14 @@ def test_package_boundary_has_no_user_mutation_hook(self) -> None: self.assertIn('"$pkgdir/usr/bin/shibumi-shell"', pkgbuild) self.assertNotIn("$HOME", pkgbuild) self.assertNotIn(".config/omarchy", pkgbuild) + self.assertIn( + 'contracts/backend-boundary-v1.json', + pkgbuild, + ) + aur_check = (ROOT / "scripts/check-aur-package").read_text( + encoding="utf-8" + ) + self.assertIn("scripts/check-production-boundary", aur_check) hooks = list((ROOT / "packaging").rglob("*.install")) hooks += list((ROOT / "packaging").rglob("*.hook")) self.assertEqual(hooks, []) From 3e047a35082f0feacda1c5476eef78c387ebcd58 Mon Sep 17 00:00:00 2001 From: HANCORE-linux <230438592+HANCORE-linux@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:27:12 +0200 Subject: [PATCH 02/18] fix: close step2 boundary review gaps --- .github/workflows/package-release.yml | 16 +++ ARCHITECTURE.md | 21 ++- contracts/backend-boundary-v1.json | 52 +++++++ docs/release-readiness.md | 22 +-- scripts/check-production-boundary | 180 ++++++++++++++++++++++-- scripts/rehearse-aur-package | 26 +++- tests/production-boundary-regression.py | 156 +++++++++++++++++--- tests/test_package_release.py | 14 ++ 8 files changed, 441 insertions(+), 46 deletions(-) diff --git a/.github/workflows/package-release.yml b/.github/workflows/package-release.yml index 56af832..8135ae5 100644 --- a/.github/workflows/package-release.yml +++ b/.github/workflows/package-release.yml @@ -37,6 +37,22 @@ jobs: set -euo pipefail scripts/build-release-archive --check-reproducible + - name: Rehearse the built AUR package in Arch + run: | + set -euo pipefail + docker run --rm \ + -v "$GITHUB_WORKSPACE:/src:ro" \ + archlinux:base-devel \ + bash -ceu ' + pacman -Syu --noconfirm --needed git python + useradd --create-home builder + mkdir /work + cp -a /src/. /work/ + chown -R builder:builder /work + cd /work + su builder -c ./scripts/rehearse-aur-package + ' + github-release: if: startsWith(github.ref, 'refs/tags/v') needs: package-contract diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 5d530c9..ee3811e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -240,9 +240,13 @@ Every mutable capability declares one owner, its current backend boundary, its output cardinality, and its worker policy. Production dependencies are classified as exactly one of `hostIntegration`, `nativeApi`, `omarchyBackend`, `omarchyAction`, or `hostOwnedProvider`. A transitional `omarchyBackend` must -name a known backend, an explicit `removalStep`, and a reason. New categories, -owners, backend identities, host providers, or undeclared occurrences fail the -lint rather than becoming implicit compatibility debt. +name a known backend, an explicit `removalStep`, and a reason. The manifest's +transition ledger locks every declared transition capability and dependency to +that classification, so a transition cannot be silently reclassified without a +boundary-contract change. Process-wide Shibumi owners must also expose a valid +service manifest. New categories, owners, backend identities, host providers, +or undeclared occurrences fail the lint rather than becoming implicit +compatibility debt. The only host-owned provider exceptions are Omarchy Notifications, OSD, and Idle. Their provider identities, owners, allowed forms, process-wide cardinality, @@ -253,10 +257,13 @@ explicit host summon/IPC action; it is not a Shibumi provider. The production lint runs in source and package gates. It checks exact reviewed occurrences, private component paths, provider and first-party service calls, -Omarchy commands, helper-backed state markers, package escapes, symlinks, and -UTF-8 source. Compatibility debt may be removed by a later migration step, but -it may not be broadened silently. This boundary slice does not activate Audio, -Network, Bluetooth, or any other backend cutover. +Omarchy commands, helper-backed state markers, package escapes, symlinks, UTF-8 +source, transition-ledger consistency, and process-wide owner manifests. The +lint verifies declared output cardinality and worker policy; it does not claim +to prove runtime process cardinality or live worker behavior. Compatibility debt +may be removed by a later migration step, but it may not be broadened silently. +This boundary slice does not activate Audio, Network, Bluetooth, or any other +backend cutover. ## Why A Full-Bar Plugin diff --git a/contracts/backend-boundary-v1.json b/contracts/backend-boundary-v1.json index 941c9de..7ea88bb 100644 --- a/contracts/backend-boundary-v1.json +++ b/contracts/backend-boundary-v1.json @@ -23,6 +23,58 @@ "omarchy.voxtype", "omarchy.weather" ], + "transitionLedger": [ + {"scope":"capability","id":"audio","owner":"hancore.shibumi.audio","outputCardinality":"process-wide","workerPolicy":"shared-demand-driven","backend":"omarchy.audio","removalStep":"Step 4A"}, + {"scope":"capability","id":"network","owner":"hancore.shibumi.network","outputCardinality":"process-wide","workerPolicy":"shared-demand-driven","backend":"omarchy.network","removalStep":"Step 5"}, + {"scope":"capability","id":"displays","owner":"hancore.shibumi.brightness","outputCardinality":"process-wide","workerPolicy":"shared-demand-driven","backend":"omarchy.monitor","removalStep":"Step 6"}, + {"scope":"capability","id":"media","owner":"hancore.shibumi.media","outputCardinality":"process-wide","workerPolicy":"shared-demand-driven","backend":"omarchy.media","removalStep":"Step 7"}, + {"scope":"capability","id":"system-tray","owner":"hancore.shibumi.status","outputCardinality":"process-wide","workerPolicy":"shared-demand-driven","backend":"omarchy.tray","removalStep":"Step 7"}, + {"scope":"capability","id":"system-update-state","owner":"hancore.shibumi.update-center","outputCardinality":"process-wide","workerPolicy":"shared-demand-driven","backend":"omarchy.system-update","removalStep":"Step 7"}, + {"scope":"capability","id":"ai-usage","owner":"hancore.shibumi.ai","outputCardinality":"process-wide","workerPolicy":"shared-demand-driven","backend":"omarchy.agents","removalStep":"Step 8"}, + {"scope":"capability","id":"weather","owner":"hancore.shibumi.center","outputCardinality":"process-wide","workerPolicy":"shared-demand-driven","backend":"omarchy.weather","removalStep":"Step 8"}, + {"scope":"rule","source":"hancore.shibumi.ai/Service.qml","kind":"provider","kindValue":"omarchy.agents","category":"omarchyBackend","owner":"hancore.shibumi.ai","capability":"ai-usage","count":1,"backend":"omarchy.agents","removalStep":"Step 8"}, + {"scope":"rule","source":"hancore.shibumi.ai/Service.qml","kind":"provider","kindValue":"omarchy.model-usage","category":"omarchyBackend","owner":"hancore.shibumi.ai","capability":"ai-usage","count":2,"backend":"omarchy.model-usage","removalStep":"Step 8"}, + {"scope":"rule","source":"hancore.shibumi.audio/BarWidget.qml","kind":"provider","kindValue":"omarchy.audio","category":"omarchyBackend","owner":"hancore.shibumi.audio","capability":"audio","count":2,"backend":"omarchy.audio","removalStep":"Step 4A"}, + {"scope":"rule","source":"hancore.shibumi.brightness/Service.qml","kind":"provider","kindValue":"omarchy.monitor","category":"omarchyBackend","owner":"hancore.shibumi.brightness","capability":"displays","count":2,"backend":"omarchy.monitor","removalStep":"Step 6"}, + {"scope":"rule","source":"hancore.shibumi.center/BarWidget.qml","kind":"provider","kindValue":"omarchy.system-update","category":"omarchyBackend","owner":"hancore.shibumi.center","capability":"system-update-state","count":2,"backend":"omarchy.system-update","removalStep":"Step 7"}, + {"scope":"rule","source":"hancore.shibumi.network/Service.qml","kind":"provider","kindValue":"omarchy.network","category":"omarchyBackend","owner":"hancore.shibumi.network","capability":"network","count":2,"backend":"omarchy.network","removalStep":"Step 5"}, + {"scope":"rule","source":"hancore.shibumi.status/BarWidget.qml","kind":"provider","kindValue":"omarchy.tray","category":"omarchyBackend","owner":"hancore.shibumi.status","capability":"system-tray","count":2,"backend":"omarchy.tray","removalStep":"Step 7"}, + {"scope":"rule","source":"hancore.shibumi.center/SystemUpdateWidget.qml","kind":"firstPartyService","kindValue":"omarchy.system-update","category":"omarchyBackend","owner":"hancore.shibumi.center","capability":"system-update-state","count":1,"backend":"omarchy.system-update","removalStep":"Step 7"}, + {"scope":"rule","source":"hancore.shibumi.media/BarWidget.qml","kind":"firstPartyService","kindValue":"omarchy.media","category":"omarchyBackend","owner":"hancore.shibumi.media","capability":"media","count":1,"backend":"omarchy.media","removalStep":"Step 7"}, + {"scope":"rule","source":"hancore.shibumi.media/Service.qml","kind":"firstPartyService","kindValue":"omarchy.media","category":"omarchyBackend","owner":"hancore.shibumi.media","capability":"media","count":1,"backend":"omarchy.media","removalStep":"Step 7"}, + {"scope":"rule","source":"hancore.shibumi.reactor/ReactorService.qml","kind":"firstPartyService","kindValue":"omarchy.media","category":"omarchyBackend","owner":"hancore.shibumi.reactor","capability":"bar-host-routing","count":1,"backend":"omarchy.media","removalStep":"Step 7"}, + {"scope":"rule","source":"hancore.shibumi.network/Service.qml","kind":"helper","kindValue":"omarchy-network-status","category":"omarchyBackend","owner":"hancore.shibumi.network","capability":"network","count":1,"backend":"omarchy.network","removalStep":"Step 5"}, + {"scope":"rule","source":"hancore.shibumi.power-state/Service.qml","kind":"helper","kindValue":"omarchy-battery-status","category":"omarchyBackend","owner":"hancore.shibumi.power-state","capability":"battery-ac","count":1,"backend":"omarchy.power","removalStep":"Step 6"}, + {"scope":"rule","source":"hancore.shibumi.power-state/Service.qml","kind":"helper","kindValue":"omarchy-powerprofiles-list","category":"omarchyBackend","owner":"hancore.shibumi.power-state","capability":"power-profile","count":1,"backend":"omarchy.power","removalStep":"Step 6"}, + {"scope":"rule","source":"hancore.shibumi.status/Service.qml","kind":"helper","kindValue":"/tmp/omarchy-screenrecord-filename","category":"omarchyBackend","owner":"hancore.shibumi.status","capability":"recording-voxtype","count":1,"backend":"omarchy.recording","removalStep":"Step 8"}, + {"scope":"rule","source":"hancore.shibumi.status/Service.qml","kind":"helper","kindValue":"omarchy-voxtype-status","category":"omarchyBackend","owner":"hancore.shibumi.status","capability":"recording-voxtype","count":1,"backend":"omarchy.voxtype","removalStep":"Step 8"}, + {"scope":"hostIdRule","target":"omarchy.agents","category":"omarchyBackend","owner":"hancore.shibumi.ai","capability":"ai-usage","count":10,"backend":"omarchy.agents","removalStep":"Step 8"}, + {"scope":"hostIdRule","target":"omarchy.audio","category":"omarchyBackend","owner":"hancore.shibumi.audio","capability":"audio","count":13,"backend":"omarchy.audio","removalStep":"Step 4A"}, + {"scope":"hostIdRule","target":"omarchy.media","category":"omarchyBackend","owner":"hancore.shibumi.media","capability":"media","count":7,"backend":"omarchy.media","removalStep":"Step 7"}, + {"scope":"hostIdRule","target":"omarchy.model-usage","category":"omarchyBackend","owner":"hancore.shibumi.ai","capability":"ai-usage","count":8,"backend":"omarchy.model-usage","removalStep":"Step 8"}, + {"scope":"hostIdRule","target":"omarchy.monitor","category":"omarchyBackend","owner":"hancore.shibumi.brightness","capability":"displays","count":10,"backend":"omarchy.monitor","removalStep":"Step 6"}, + {"scope":"hostIdRule","target":"omarchy.network","category":"omarchyBackend","owner":"hancore.shibumi.network","capability":"network","count":12,"backend":"omarchy.network","removalStep":"Step 5"}, + {"scope":"hostIdRule","target":"omarchy.power","category":"omarchyBackend","owner":"hancore.shibumi.power-state","capability":"power-profile","count":5,"backend":"omarchy.power","removalStep":"Step 6"}, + {"scope":"hostIdRule","target":"omarchy.speedtest","category":"omarchyBackend","owner":"hancore.shibumi.network","capability":"network","count":1,"backend":"omarchy.network","removalStep":"Step 5"}, + {"scope":"hostIdRule","target":"omarchy.system-update","category":"omarchyBackend","owner":"hancore.shibumi.update-center","capability":"system-update-state","count":11,"backend":"omarchy.system-update","removalStep":"Step 7"}, + {"scope":"hostIdRule","target":"omarchy.tray","category":"omarchyBackend","owner":"hancore.shibumi.status","capability":"system-tray","count":10,"backend":"omarchy.tray","removalStep":"Step 7"}, + {"scope":"hostIdRule","target":"omarchy.weather","category":"omarchyBackend","owner":"hancore.shibumi.center","capability":"weather","count":6,"backend":"omarchy.weather","removalStep":"Step 8"}, + {"scope":"hostIdRule","target":"omarchy.wifiqr","category":"omarchyBackend","owner":"hancore.shibumi.network","capability":"network","count":3,"backend":"omarchy.network","removalStep":"Step 5"}, + {"scope":"commandRule","value":"omarchy-agent-usage-","category":"omarchyBackend","owner":"hancore.shibumi.ai","capability":"ai-usage","count":1,"backend":"omarchy.agents","removalStep":"Step 8"}, + {"scope":"commandRule","value":"omarchy-agent-usage-update","category":"omarchyBackend","owner":"hancore.shibumi.ai","capability":"ai-usage","count":1,"backend":"omarchy.agents","removalStep":"Step 8"}, + {"scope":"commandRule","value":"omarchy-audio-output-set-default","category":"omarchyBackend","owner":"hancore.shibumi.bluetooth","capability":"bluetooth","count":1,"backend":"omarchy.audio","removalStep":"Step 4A"}, + {"scope":"commandRule","value":"omarchy-battery-status","category":"omarchyBackend","owner":"hancore.shibumi.power-state","capability":"battery-ac","count":1,"backend":"omarchy.power","removalStep":"Step 6"}, + {"scope":"commandRule","value":"omarchy-bluetooth-device","category":"omarchyBackend","owner":"hancore.shibumi.bluetooth","capability":"bluetooth","count":1,"backend":"omarchy.bluetooth","removalStep":"Step 4B"}, + {"scope":"commandRule","value":"omarchy-capture-screenrecording","category":"omarchyBackend","owner":"hancore.shibumi.status","capability":"recording-voxtype","count":1,"backend":"omarchy.recording","removalStep":"Step 8"}, + {"scope":"commandRule","value":"omarchy-network-speedtest","category":"omarchyBackend","owner":"hancore.shibumi.network","capability":"network","count":1,"backend":"omarchy.network","removalStep":"Step 5"}, + {"scope":"commandRule","value":"omarchy-network-status","category":"omarchyBackend","owner":"hancore.shibumi.network","capability":"network","count":1,"backend":"omarchy.network","removalStep":"Step 5"}, + {"scope":"commandRule","value":"omarchy-powerprofiles-list","category":"omarchyBackend","owner":"hancore.shibumi.power-state","capability":"power-profile","count":1,"backend":"omarchy.power","removalStep":"Step 6"}, + {"scope":"commandRule","value":"omarchy-screenrecord-filename","category":"omarchyBackend","owner":"hancore.shibumi.status","capability":"recording-voxtype","count":1,"backend":"omarchy.recording","removalStep":"Step 8"}, + {"scope":"commandRule","value":"omarchy-voxtype-config","category":"omarchyBackend","owner":"hancore.shibumi.status","capability":"recording-voxtype","count":1,"backend":"omarchy.voxtype","removalStep":"Step 8"}, + {"scope":"commandRule","value":"omarchy-voxtype-model","category":"omarchyBackend","owner":"hancore.shibumi.status","capability":"recording-voxtype","count":1,"backend":"omarchy.voxtype","removalStep":"Step 8"}, + {"scope":"commandRule","value":"omarchy-voxtype-status","category":"omarchyBackend","owner":"hancore.shibumi.status","capability":"recording-voxtype","count":1,"backend":"omarchy.voxtype","removalStep":"Step 8"}, + {"scope":"commandRule","value":"omarchy-weather-location","category":"omarchyBackend","owner":"hancore.shibumi.center","capability":"weather","count":3,"backend":"omarchy.weather","removalStep":"Step 8"} + ], "outputCardinalities": ["process-wide", "output-local"], "workerPolicies": [ "none", diff --git a/docs/release-readiness.md b/docs/release-readiness.md index 87b9537..60ed89d 100644 --- a/docs/release-readiness.md +++ b/docs/release-readiness.md @@ -35,17 +35,23 @@ no maintainer-local checkout path is part of the acceptance contract. The compact production boundary is implemented without a backend cutover. The single machine-readable source is [`backend-boundary-v1.json`](../contracts/backend-boundary-v1.json); it declares -all capability owners, current boundaries, output cardinality, worker policy, -explicit transition steps, and only the Notifications, OSD, and Idle host-owned -exceptions. `scripts/check-production-boundary` is manifest-driven and fails -closed on unknown categories, owners, transitional backends, private providers, -hidden components, helper-backed state, undeclared commands, package escapes, -and output-owner contract drift. +all capability owners, current boundaries, declared output cardinality, worker +policy, explicit transition steps, and only the Notifications, OSD, and Idle +host-owned exceptions. Its transition ledger prevents silent reclassification +of existing compatibility debt, and every process-wide Shibumi owner must expose +a valid service manifest. `scripts/check-production-boundary` is +manifest-driven and fails closed on unknown categories, owners, transitional +backends, private providers, hidden components, helper-backed state, undeclared +commands, package escapes, transition-ledger drift, and missing owner manifests. +The lint verifies these declarations; live process and worker cardinality remain +runtime gates. Validation passed on the dedicated Step 2 worktree based on `e92d8ee`: -- production boundary lint and 11 focused boundary regression tests; -- package (13), suite (99), and health (44) unit gates passed; +- production boundary lint and 15 focused boundary regression tests; +- package (14), suite (99), and health (44) unit gates passed; +- the Arch AUR rehearsal builds and extracts the package, including the installed + `contracts/backend-boundary-v1.json` payload; - documentation, plugin-suite, AUR scaffold, `git diff --check`, and the complete pinned contract regression passed. diff --git a/scripts/check-production-boundary b/scripts/check-production-boundary index c146a92..2c3e8c7 100755 --- a/scripts/check-production-boundary +++ b/scripts/check-production-boundary @@ -13,6 +13,13 @@ from pathlib import Path from typing import Any +SCRIPT_ROOT = Path(__file__).resolve().parents[1] +if str(SCRIPT_ROOT) not in sys.path: + sys.path.insert(0, str(SCRIPT_ROOT)) + +from scripts.shibumi_suite.model import ContractError, Suite + + CODE_SUFFIXES = {"", ".bash", ".js", ".json", ".mjs", ".py", ".qml", ".sh"} CATEGORIES = { "hostIntegration", @@ -92,6 +99,7 @@ HELPER_MARKER = re.compile( HOST_ID = re.compile(r"[\"'](omarchy\.[A-Za-z0-9_-]+)[\"']") OMARCHY_COMMAND = re.compile(r"\bomarchy-[A-Za-z0-9_.-]+") RELATIVE_LITERAL = re.compile(r"[\"']([^\"']*\.\.[^\"']*)[\"']") +PLUGIN_ID = re.compile(r"hancore\.shibumi\.[A-Za-z0-9][A-Za-z0-9_.-]*\Z") class ManifestError(ValueError): @@ -144,7 +152,13 @@ def load_suite_owners(root: Path) -> set[str]: owners: set[str] = set() for plugin in plugins: require(isinstance(plugin, dict), "plugin suite plugin must be an object") - owners.add(string_field(plugin.get("id"), "plugin id")) + plugin_id = string_field(plugin.get("id"), "plugin id") + require( + PLUGIN_ID.fullmatch(plugin_id) is not None and ".." not in plugin_id, + f"plugin id is not a safe Shibumi plugin id: {plugin_id}", + ) + require(plugin_id not in owners, f"duplicate plugin id: {plugin_id}") + owners.add(plugin_id) return owners @@ -172,6 +186,103 @@ def validate_transition( require(len(reason) >= 12, f"{context}: transition reason is too short") +TRANSITION_SCOPES = {"capability", "rule", "hostIdRule", "commandRule"} + + +def transition_record(scope: str, entry: dict[str, Any]) -> dict[str, str]: + record: dict[str, str] = {"scope": scope} + if scope == "capability": + record["id"] = string_field(entry.get("id"), "transition capability id") + record["owner"] = string_field(entry.get("owner"), "capability.owner") + record["outputCardinality"] = string_field( + entry.get("outputCardinality"), "capability.outputCardinality" + ) + record["workerPolicy"] = string_field( + entry.get("workerPolicy"), "capability.workerPolicy" + ) + elif scope == "rule": + record["source"] = string_field(entry.get("source"), "transition rule source") + record["kind"] = string_field(entry.get("kind"), "transition rule kind") + value_key = "target" if entry.get("kind") in {"provider", "firstPartyService"} else "value" + value = entry.get(value_key, entry.get("kindValue")) + record["kindValue"] = string_field(value, f"transition rule {value_key}") + elif scope == "hostIdRule": + record["target"] = string_field(entry.get("target"), "transition host id target") + elif scope == "commandRule": + record["value"] = string_field(entry.get("value"), "transition command value") + else: + raise ManifestError(f"unknown transition scope {scope}") + if scope != "capability": + record["category"] = string_field(entry.get("category"), f"{scope}.category") + record["owner"] = string_field(entry.get("owner"), f"{scope}.owner") + record["capability"] = string_field(entry.get("capability"), f"{scope}.capability") + count = entry.get("count") + require(isinstance(count, int) and count > 0, f"{scope}.count must be a positive integer") + record["count"] = str(count) + record["backend"] = string_field(entry.get("backend"), f"{scope}.backend") + record["removalStep"] = string_field(entry.get("removalStep"), f"{scope}.removalStep") + return record + + +def transition_key(record: dict[str, str]) -> str: + return json.dumps(record, sort_keys=True, separators=(",", ":")) + + +def validate_transition_ledger( + manifest: dict[str, Any], transitional_backends: set[str] +) -> None: + ledger = manifest.get("transitionLedger") + require(isinstance(ledger, list) and ledger, "transitionLedger must be a non-empty array") + + actual: list[dict[str, str]] = [] + for capability in manifest["capabilities"]: + if capability.get("backendBoundary") == "omarchyBackend": + actual.append(transition_record("capability", capability)) + for rule in manifest["rules"]: + if rule.get("category") == "omarchyBackend": + actual.append(transition_record("rule", rule)) + for rule in manifest["hostIdRules"]: + if rule.get("category") == "omarchyBackend": + actual.append(transition_record("hostIdRule", rule)) + for rule in manifest["commandRules"]: + if rule.get("category") == "omarchyBackend": + actual.append(transition_record("commandRule", rule)) + + actual_map = {transition_key(record): record for record in actual} + require(len(actual_map) == len(actual), "duplicate transition boundary entry") + ledger_map: dict[str, dict[str, str]] = {} + for index, entry in enumerate(ledger): + context = f"transitionLedger {index}" + require(isinstance(entry, dict), f"{context} must be an object") + scope = string_field(entry.get("scope"), f"{context}.scope") + require(scope in TRANSITION_SCOPES, f"{context}: unknown scope {scope}") + record = transition_record(scope, entry) + require( + set(entry) == set(record), + f"{context}: fields must exactly describe the locked transition", + ) + require( + record["backend"] in transitional_backends, + f"{context}: unknown transitional backend {record['backend']}", + ) + require( + re.fullmatch(r"Step (?:4A|4B|5|6|7|8)", record["removalStep"]) is not None, + f"{context}: invalid removalStep {record['removalStep']}", + ) + key = transition_key(record) + require(key not in ledger_map, f"{context}: duplicate locked transition") + ledger_map[key] = record + + require( + ledger_map == actual_map, + "transitionLedger must exactly match every omarchyBackend capability and rule", + ) + require( + {record["backend"] for record in actual} == transitional_backends, + "every transitional backend must have a locked omarchyBackend entry", + ) + + def validate_manifest(root: Path, manifest: dict[str, Any]) -> tuple[set[str], bool]: require(manifest.get("schemaVersion") == 1, "schemaVersion must be 1") require( @@ -204,6 +315,10 @@ def validate_manifest(root: Path, manifest: dict[str, Any]) -> tuple[set[str], b require(set(capability_ids) == REQUIRED_CAPABILITIES, "capability inventory is incomplete or unknown") suite_owners = load_suite_owners(root) + try: + Suite.load(root) + except ContractError as error: + raise ManifestError(f"suite contract invalid: {error}") from error known_owners = set(FIXED_OWNERS) known_owners.update(suite_owners) for capability in capabilities: @@ -225,18 +340,56 @@ def validate_manifest(root: Path, manifest: dict[str, Any]) -> tuple[set[str], b require(policy in WORKER_POLICIES, f"{context}: unknown worker policy {policy}") string_field(capability.get("reason"), f"{context}.reason") if cardinality == "process-wide" and owner not in FIXED_OWNERS: - owner_manifest = root / owner / "manifest.json" - if owner_manifest.is_file(): - try: - owner_data = json.loads(owner_manifest.read_text(encoding="utf-8")) - except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error: - raise ManifestError(f"{context}: cannot read owner manifest: {error}") from error - require( - isinstance(owner_data, dict) - and "service" in owner_data.get("kinds", []) - and isinstance(owner_data.get("entryPoints", {}).get("service"), str), - f"{context}: process-wide owner must expose one service entry point", - ) + root_dir = root.resolve() + owner_dir = (root / owner).resolve() + require( + owner_dir.is_relative_to(root_dir) and owner_dir.is_dir(), + f"{context}: process-wide owner must be a Shibumi plugin directory", + ) + owner_manifest = owner_dir / "manifest.json" + require( + owner_manifest.is_file() + and owner_manifest.resolve().is_relative_to(owner_dir), + f"{context}: process-wide owner manifest is required inside the owner plugin", + ) + try: + owner_data = json.loads(owner_manifest.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error: + raise ManifestError(f"{context}: cannot read owner manifest: {error}") from error + require(isinstance(owner_data, dict), f"{context}: owner manifest must be an object") + require( + owner_data.get("schemaVersion") == 1, + f"{context}: owner manifest schemaVersion must be 1", + ) + require( + owner_data.get("id") == owner, + f"{context}: owner manifest id must match {owner}", + ) + require( + isinstance(owner_data.get("kinds"), list) + and "service" in owner_data["kinds"], + f"{context}: process-wide owner must declare a service kind", + ) + entry_points = owner_data.get("entryPoints") + require( + isinstance(entry_points, dict), + f"{context}: owner manifest entryPoints must be an object", + ) + service_entry = string_field( + entry_points.get("service"), + f"{context}.entryPoints.service", + ) + service_path = Path(service_entry) + require( + not service_path.is_absolute() and ".." not in service_path.parts, + f"{context}: service entry point must stay inside the owner plugin", + ) + service_file = (owner_manifest.parent / service_path).resolve() + require( + service_file.is_relative_to(owner_manifest.parent.resolve()) + and service_file.is_file(), + f"{context}: service entry point does not exist", + ) validate_transition( capability, context, transitional_backends ) @@ -363,6 +516,7 @@ def validate_manifest(root: Path, manifest: dict[str, Any]) -> tuple[set[str], b ) validate_transition(entry, context, transitional_backends) + validate_transition_ledger(manifest, transitional_backends) return allowed_owners, bool(suite_owners) diff --git a/scripts/rehearse-aur-package b/scripts/rehearse-aur-package index d665a82..40d0de2 100755 --- a/scripts/rehearse-aur-package +++ b/scripts/rehearse-aur-package @@ -16,6 +16,10 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from scripts.shibumi_suite.model import Suite PKGBUILD = ROOT / "packaging/aur/PKGBUILD" @@ -131,12 +135,30 @@ def main() -> int: ["bsdtar", "-xf", str(package), "-C", str(extract)], check=True ) payload = extract / "usr/share/shibumi-shell" + boundary_check = run( + [ + sys.executable, + str(ROOT / "scripts/check-production-boundary"), + "--root", + str(payload), + ], + extract, + ) + if boundary_check.returncode != 0: + print(boundary_check.stdout, file=sys.stderr) + print("installed package boundary validation failed", file=sys.stderr) + return 1 contract = json.loads( (payload / "contracts/plugin-suite-v1.json").read_text(encoding="utf-8") ) - expected_plugins = {item["id"] for item in contract["plugins"]} + try: + suite = Suite.load(payload) + except Exception as error: + print(f"installed package suite validation failed: {error}", file=sys.stderr) + return 1 + expected_plugins = set(suite.plugins) packaged_plugins = { - path.parent.name for path in payload.glob("hancore.shibumi.*/manifest.json") + path.name for path in payload.glob("hancore.shibumi.*") if path.is_dir() } if packaged_plugins != expected_plugins: print( diff --git a/tests/production-boundary-regression.py b/tests/production-boundary-regression.py index a73077c..fb7b951 100755 --- a/tests/production-boundary-regression.py +++ b/tests/production-boundary-regression.py @@ -37,13 +37,19 @@ def copy_contracts(self, root: Path, *, suite: bool = True) -> None: contracts / "plugin-suite-v1.json", ) + def copy_production_tree(self, root: Path) -> None: + self.copy_contracts(root) + for plugin in sorted(ROOT.glob("hancore.shibumi.*")): + if plugin.is_dir(): + shutil.copytree(plugin, root / plugin.name, symlinks=True) + def fixture_result( self, source: str, name: str = "Service.qml" ) -> subprocess.CompletedProcess[str]: temporary = tempfile.TemporaryDirectory(prefix="shibumi-boundary-test.") self.addCleanup(temporary.cleanup) root = Path(temporary.name) - self.copy_contracts(root) + self.copy_production_tree(root) plugin = root / "hancore.shibumi.fixture" plugin.mkdir() (plugin / name).write_text(source, encoding="utf-8") @@ -95,7 +101,7 @@ def test_all_executables_are_scanned_and_invalid_utf8_fails(self) -> None: prefix="shibumi-boundary-test." ) as temporary: root = Path(temporary) - self.copy_contracts(root) + self.copy_production_tree(root) plugin = root / "hancore.shibumi.fixture" plugin.mkdir() executable = plugin / "probe.bin" @@ -120,7 +126,7 @@ def test_production_symlinks_fail_closed(self) -> None: prefix="shibumi-boundary-test." ) as temporary: root = Path(temporary) - self.copy_contracts(root) + self.copy_production_tree(root) payload = root / "payload" payload.mkdir() (payload / "Service.qml").write_text( @@ -136,7 +142,7 @@ def test_production_symlinks_fail_closed(self) -> None: prefix="shibumi-boundary-test." ) as temporary: root = Path(temporary) - self.copy_contracts(root) + self.copy_production_tree(root) plugin = root / "hancore.shibumi.fixture" plugin.mkdir() target = plugin / "payload.bin" @@ -151,9 +157,8 @@ def test_removed_debt_requires_allowance_cleanup(self) -> None: prefix="shibumi-boundary-test." ) as temporary: root = Path(temporary) - self.copy_contracts(root) + self.copy_production_tree(root) plugin = root / "hancore.shibumi.status" - plugin.mkdir() source = ( ROOT / "hancore.shibumi.status/Service.qml" ).read_text(encoding="utf-8") @@ -170,9 +175,12 @@ def test_missing_allowlisted_file_fails_when_plugin_is_present(self) -> None: prefix="shibumi-boundary-test." ) as temporary: root = Path(temporary) - self.copy_contracts(root) - plugin = root / "hancore.shibumi.media" - plugin.mkdir() + self.copy_production_tree(root) + (root / "hancore.shibumi.media/Service.qml").unlink() + owner_manifest = root / "hancore.shibumi.media/manifest.json" + owner_data = json.loads(owner_manifest.read_text(encoding="utf-8")) + owner_data["entryPoints"]["service"] = "BarWidget.qml" + owner_manifest.write_text(json.dumps(owner_data), encoding="utf-8") result = self.run_linter(root) self.assertEqual(result.returncode, 1, result.stdout + result.stderr) self.assertIn( @@ -180,12 +188,13 @@ def test_missing_allowlisted_file_fails_when_plugin_is_present(self) -> None: result.stderr, ) - def test_complete_suite_rejects_allowance_for_missing_plugin(self) -> None: + def test_complete_suite_rejects_missing_process_wide_owner_plugin(self) -> None: with tempfile.TemporaryDirectory( prefix="shibumi-boundary-test." ) as temporary: root = Path(temporary) - self.copy_contracts(root) + self.copy_production_tree(root) + shutil.rmtree(root / "hancore.shibumi.audio") contracts = root / "contracts" (contracts / "plugin-suite-v1.json").write_text( (ROOT / "contracts/plugin-suite-v1.json").read_text( @@ -195,10 +204,125 @@ def test_complete_suite_rejects_allowance_for_missing_plugin(self) -> None: ) result = self.run_linter(root) self.assertEqual(result.returncode, 1, result.stdout + result.stderr) - self.assertIn( - "hancore.shibumi.audio/BarWidget.qml: stale boundary rule", - result.stderr, - ) + self.assertIn("suite contract invalid", result.stderr) + + def test_transition_ledger_rejects_reclassified_entries(self) -> None: + def reclassify(entry: dict[str, object], boundary_key: str) -> None: + entry[boundary_key] = "nativeApi" + entry.pop("backend", None) + entry.pop("removalStep", None) + + mutations = { + "capability": lambda data: reclassify( + next(item for item in data["capabilities"] if item["id"] == "audio"), + "backendBoundary", + ), + "rule": lambda data: reclassify( + next( + item for item in data["rules"] + if item.get("kind") == "provider" + and item.get("target") == "omarchy.audio" + ), + "category", + ), + "host-id": lambda data: reclassify( + next(item for item in data["hostIdRules"] if item["target"] == "omarchy.audio"), + "category", + ), + "command": lambda data: reclassify( + next(item for item in data["commandRules"] if item["value"] == "omarchy-network-status"), + "category", + ), + "capability-owner": lambda data: next( + item for item in data["capabilities"] if item["id"] == "audio" + ).update({"owner": "hancore.shibumi.state"}), + "capability-cardinality": lambda data: next( + item for item in data["capabilities"] if item["id"] == "audio" + ).update({"outputCardinality": "output-local"}), + "capability-worker": lambda data: next( + item for item in data["capabilities"] if item["id"] == "audio" + ).update({"workerPolicy": "none"}), + "rule-owner": lambda data: next( + item for item in data["rules"] + if item.get("kind") == "provider" and item.get("target") == "omarchy.audio" + ).update({"owner": "hancore.shibumi.state"}), + "rule-capability": lambda data: next( + item for item in data["rules"] + if item.get("kind") == "provider" and item.get("target") == "omarchy.audio" + ).update({"capability": "settings"}), + "rule-count": lambda data: next( + item for item in data["rules"] + if item.get("kind") == "provider" and item.get("target") == "omarchy.audio" + ).update({"count": 99}), + } + for name, mutate in mutations.items(): + with self.subTest(name=name): + with tempfile.TemporaryDirectory( + prefix="shibumi-boundary-transition-test." + ) as temporary: + root = Path(temporary) + self.copy_production_tree(root) + manifest_path = root / "contracts/backend-boundary-v1.json" + data = json.loads(manifest_path.read_text(encoding="utf-8")) + mutate(data) + manifest_path.write_text(json.dumps(data), encoding="utf-8") + result = self.run_linter(root) + self.assertEqual(result.returncode, 1, result.stdout + result.stderr) + self.assertIn("transitionLedger must exactly match", result.stderr) + + def test_suite_owner_ids_reject_path_traversal(self) -> None: + with tempfile.TemporaryDirectory( + prefix="shibumi-boundary-owner-id-test." + ) as temporary: + root = Path(temporary) + self.copy_production_tree(root) + suite_path = root / "contracts/plugin-suite-v1.json" + suite = json.loads(suite_path.read_text(encoding="utf-8")) + suite["plugins"][0]["id"] = "hancore.shibumi..external-owner" + suite_path.write_text(json.dumps(suite), encoding="utf-8") + result = self.run_linter(root) + self.assertEqual(result.returncode, 1, result.stdout + result.stderr) + self.assertIn("plugin id is not a safe Shibumi plugin id", result.stderr) + + suite = json.loads(suite_path.read_text(encoding="utf-8")) + suite["plugins"][0]["id"] = suite["plugins"][1]["id"] + suite_path.write_text(json.dumps(suite), encoding="utf-8") + result = self.run_linter(root) + self.assertEqual(result.returncode, 1, result.stdout + result.stderr) + self.assertIn("duplicate plugin id", result.stderr) + + def test_process_wide_owner_manifest_is_required(self) -> None: + with tempfile.TemporaryDirectory( + prefix="shibumi-boundary-owner-test." + ) as temporary: + root = Path(temporary) + self.copy_production_tree(root) + (root / "hancore.shibumi.audio/manifest.json").unlink() + result = self.run_linter(root) + self.assertEqual(result.returncode, 1, result.stdout + result.stderr) + self.assertIn("suite contract invalid", result.stderr) + + def test_process_wide_owner_manifest_is_structurally_valid(self) -> None: + mutations = { + "entry-points-object": lambda data: data.update({"entryPoints": []}), + "missing-service": lambda data: data["entryPoints"].update({"service": "Missing.qml"}), + "wrong-id": lambda data: data.update({"id": "hancore.shibumi.other"}), + "schema-version": lambda data: data.update({"schemaVersion": 999}), + } + for name, mutate in mutations.items(): + with self.subTest(name=name): + with tempfile.TemporaryDirectory( + prefix="shibumi-boundary-owner-test." + ) as temporary: + root = Path(temporary) + self.copy_production_tree(root) + owner_manifest = root / "hancore.shibumi.audio/manifest.json" + data = json.loads(owner_manifest.read_text(encoding="utf-8")) + mutate(data) + owner_manifest.write_text(json.dumps(data), encoding="utf-8") + result = self.run_linter(root) + self.assertEqual(result.returncode, 1, result.stdout + result.stderr) + self.assertIn("suite contract invalid", result.stderr) def test_manifest_rejects_unknown_categories_owners_backends_and_exceptions(self) -> None: cases = { @@ -291,7 +415,7 @@ def test_manifest_rejects_unknown_categories_owners_backends_and_exceptions(self prefix="shibumi-boundary-manifest-test." ) as temporary: root = Path(temporary) - self.copy_contracts(root) + self.copy_production_tree(root) manifest_path = root / "contracts/backend-boundary-v1.json" data = json.loads(manifest_path.read_text(encoding="utf-8")) mutate(data) diff --git a/tests/test_package_release.py b/tests/test_package_release.py index 773f47c..1d441dd 100644 --- a/tests/test_package_release.py +++ b/tests/test_package_release.py @@ -141,6 +141,20 @@ def test_release_workflow_pins_checkout_v7_without_persisted_tokens(self) -> Non self.assertEqual(workflow.count("persist-credentials: false"), 2) self.assertNotIn("persist-credentials: true", workflow) + def test_release_workflow_rehearses_the_installed_aur_package(self) -> None: + workflow = (ROOT / ".github/workflows/package-release.yml").read_text( + encoding="utf-8" + ) + self.assertIn("archlinux:base-devel", workflow) + self.assertIn('"$GITHUB_WORKSPACE:/src:ro"', workflow) + self.assertIn("scripts/rehearse-aur-package", workflow) + rehearsal = (ROOT / "scripts/rehearse-aur-package").read_text(encoding="utf-8") + self.assertIn("usr/share/shibumi-shell/contracts/backend-boundary-v1.json", rehearsal) + self.assertIn("check-production-boundary", rehearsal) + self.assertIn("Suite.load(payload)", rehearsal) + self.assertIn('"--root"', rehearsal) + self.assertNotIn("chown -R builder:builder /src", workflow) + def test_release_workflow_requires_revision_bound_lifecycle_evidence(self) -> None: workflow = (ROOT / ".github/workflows/package-release.yml").read_text( encoding="utf-8" From 2fd915725751e65b439c9bc7926c6bafc445f421 Mon Sep 17 00:00:00 2001 From: HANCORE-linux <230438592+HANCORE-linux@users.noreply.github.com> Date: Sat, 22 Aug 2026 02:01:15 +0200 Subject: [PATCH 03/18] refactor: isolate Bluetooth audio route seam --- .../BluetoothAudioRouteAdapter.qml | 64 +++++++++++++++++++ .../BluetoothBackendAdapter.qml | 57 +++++++---------- hancore.shibumi.bluetooth/qmldir | 1 + tests/bluetooth-audio-intent-regression.qml | 26 ++++++++ tests/bluetooth-plugin-regression.sh | 10 ++- tests/contract-regression.sh | 11 +++- 6 files changed, 131 insertions(+), 38 deletions(-) create mode 100644 hancore.shibumi.bluetooth/BluetoothAudioRouteAdapter.qml diff --git a/hancore.shibumi.bluetooth/BluetoothAudioRouteAdapter.qml b/hancore.shibumi.bluetooth/BluetoothAudioRouteAdapter.qml new file mode 100644 index 0000000..3d0a6b9 --- /dev/null +++ b/hancore.shibumi.bluetooth/BluetoothAudioRouteAdapter.qml @@ -0,0 +1,64 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Quickshell.Services.Pipewire +import "BluetoothModel.js" as Model + +// Focused transitional seam for Bluetooth audio routing. The adapter owns +// PipeWire object access while Bluetooth passes only a primitive route request. +// Step 4A can replace this adapter with the Shibumi Audio service without +// changing Bluetooth discovery, device actions, or presentation. +Item { + id: root + + // Tests inject a side-effect-free node list and route sink. + property var nodesOverride: null + property var outputOverride: null + + readonly property var nodes: nodesOverride !== null + ? nodesOverride + : (Pipewire.nodes ? Pipewire.nodes.values : []) + + function audioSinks() { + const sinks = [] + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i] + if (node && node.isSink && !node.isStream) sinks.push(node) + } + return sinks + } + + function sinkForDevice(request) { + const sinks = audioSinks() + for (let i = 0; i < sinks.length; i++) { + if (Model.bluetoothSinkMatchesDevice(sinks[i], request)) + return sinks[i] + } + return null + } + + function setDefaultSink(sink) { + if (!sink) return false + if (outputOverride !== null + && typeof outputOverride.setDefaultSink === "function") { + outputOverride.setDefaultSink(sink) + return true + } + Pipewire.preferredDefaultAudioSink = sink + if (sink.id === undefined || !sink.name) return true + Quickshell.execDetached([ + "omarchy-audio-output-set-default", + String(sink.id), + String(sink.name) + ]) + return true + } + + // Narrow cross-capability method. The request contains only stable device + // identity and labels; PipeWire objects never cross back into Bluetooth. + function routeBluetoothDevice(request) { + const sink = sinkForDevice(request) + return sink ? setDefaultSink(sink) : false + } +} diff --git a/hancore.shibumi.bluetooth/BluetoothBackendAdapter.qml b/hancore.shibumi.bluetooth/BluetoothBackendAdapter.qml index c4375bd..4bf62ba 100644 --- a/hancore.shibumi.bluetooth/BluetoothBackendAdapter.qml +++ b/hancore.shibumi.bluetooth/BluetoothBackendAdapter.qml @@ -3,7 +3,6 @@ pragma ComponentBehavior: Bound import QtQuick import Quickshell import Quickshell.Bluetooth -import Quickshell.Services.Pipewire import "." as Local import "BluetoothModel.js" as Model @@ -22,6 +21,7 @@ Item { property var pipewireNodesOverride: null property var commandRunnerOverride: null property var audioOutputOverride: null + property var audioRouteOverride: null property int audioSwitchInterval: 500 property int audioIntentTimeoutInterval: 60000 property int discoveryRequestTimeoutInterval: 1500 @@ -49,9 +49,9 @@ Item { readonly property var nativeDevices: nativeDevicesOverride !== null ? nativeDevicesOverride : (backendOverride === null && Bluetooth.devices ? Bluetooth.devices.values : []) - readonly property var pipewireNodes: pipewireNodesOverride !== null - ? pipewireNodesOverride - : (backendOverride === null && Pipewire.nodes ? Pipewire.nodes.values : []) + readonly property var audioRoute: audioRouteOverride !== null + ? audioRouteOverride : nativeAudioRoute + readonly property var pipewireNodes: nativeAudioRoute.nodes readonly property var nativeDeviceGroups: Model.deviceLists(nativeDevices) readonly property var connectedDevices: backendOverride !== null ? backendList("connectedDevices") : nativeDeviceGroups.connected @@ -67,6 +67,12 @@ Item { width: 0 height: 0 + Local.BluetoothAudioRouteAdapter { + id: nativeAudioRoute + nodesOverride: root.pipewireNodesOverride + outputOverride: root.audioOutputOverride + } + function backendList(name) { if (backendOverride === null || !(name in backendOverride)) return [] const values = backendOverride[name] @@ -309,36 +315,21 @@ Item { return runNativeDeviceAction(device, "forget", "forgetting") } - function audioSinks() { - const sinks = [] - for (let i = 0; i < pipewireNodes.length; i++) { - const node = pipewireNodes[i] - if (node && node.isSink && !node.isStream) sinks.push(node) + function audioRouteRequest(device) { + if (!device || !device.address) return null + return { + address: String(device.address).trim(), + name: device.name ? String(device.name) : "", + deviceName: device.deviceName ? String(device.deviceName) : "" } - return sinks - } - - function bluetoothAudioSink(device) { - const sinks = audioSinks() - for (let i = 0; i < sinks.length; i++) - if (Model.bluetoothSinkMatchesDevice(sinks[i], device)) return sinks[i] - return null } - function setDefaultAudioSink(sink) { - if (!sink) return - if (audioOutputOverride !== null - && typeof audioOutputOverride.setDefaultSink === "function") { - audioOutputOverride.setDefaultSink(sink) - return - } - Pipewire.preferredDefaultAudioSink = sink - if (sink.id === undefined || !sink.name) return - Quickshell.execDetached([ - "omarchy-audio-output-set-default", - String(sink.id), - String(sink.name) - ]) + function requestBluetoothAudioRoute(device) { + const request = audioRouteRequest(device) + if (!request || !audioRoute + || typeof audioRoute.routeBluetoothDevice !== "function") + return false + return audioRoute.routeBluetoothDevice(request) === true } function scheduleAudioOutputSwitch(device) { @@ -380,9 +371,7 @@ Item { function switchPendingAudioOutput() { if (!validatePendingAudioOutput()) return const device = nativeDeviceByAddress(pendingAudioOutputDevice.address) - const sink = bluetoothAudioSink(device) - if (sink) { - setDefaultAudioSink(sink) + if (requestBluetoothAudioRoute(device)) { pendingAudioOutputDevice = null audioSwitchTimer.stop() return diff --git a/hancore.shibumi.bluetooth/qmldir b/hancore.shibumi.bluetooth/qmldir index 4510a3f..67f7e0d 100644 --- a/hancore.shibumi.bluetooth/qmldir +++ b/hancore.shibumi.bluetooth/qmldir @@ -1,6 +1,7 @@ singleton BluetoothDiscoveryGuard 1.0 BluetoothDiscoveryGuard.qml BarWidget 1.0 BarWidget.qml BluetoothBackendAdapter 1.0 BluetoothBackendAdapter.qml +BluetoothAudioRouteAdapter 1.0 BluetoothAudioRouteAdapter.qml BluetoothPanel 1.0 BluetoothPanel.qml HostTokens 1.0 HostTokens.qml IconText 1.0 IconText.qml diff --git a/tests/bluetooth-audio-intent-regression.qml b/tests/bluetooth-audio-intent-regression.qml index 3d8b3d9..73e50d9 100644 --- a/tests/bluetooth-audio-intent-regression.qml +++ b/tests/bluetooth-audio-intent-regression.qml @@ -82,6 +82,25 @@ ShellRoot { function setDefaultSink(sink) { count++; lastSink = sink } } + QtObject { + id: routeOverride + property int count: 0 + property var lastRequest: null + function routeBluetoothDevice(request) { + count++ + lastRequest = request + return true + } + } + + Bluetooth.BluetoothBackendAdapter { + id: seamBackend + adapterOverride: nativeAdapter + nativeDevicesOverride: [] + pipewireNodesOverride: [] + audioRouteOverride: routeOverride + } + Bluetooth.BluetoothBackendAdapter { id: backend adapterOverride: nativeAdapter @@ -102,6 +121,13 @@ ShellRoot { if (root.phase === 0) { if (root.ticks < 2) return + if (!seamBackend.requestBluetoothAudioRoute(deviceA) + || routeOverride.count !== 1 + || routeOverride.lastRequest.address !== deviceA.address + || routeOverride.lastRequest.name !== deviceA.name + || "adapter" in routeOverride.lastRequest + || "connected" in routeOverride.lastRequest) + return root.fail("Bluetooth route seam leaked backend state") if (!backend.connectDevice(deviceA) || !backend.connectDevice(deviceB)) return root.fail("could not create ordered connect intents") backend.nativePendingActions = ({}) diff --git a/tests/bluetooth-plugin-regression.sh b/tests/bluetooth-plugin-regression.sh index 4a1533d..552b581 100755 --- a/tests/bluetooth-plugin-regression.sh +++ b/tests/bluetooth-plugin-regression.sh @@ -106,6 +106,7 @@ OMARCHY_PATH="$omarchy_path" \ widget="$repo_root/hancore.shibumi.bluetooth/BarWidget.qml" service="$repo_root/hancore.shibumi.bluetooth/Service.qml" adapter="$repo_root/hancore.shibumi.bluetooth/BluetoothBackendAdapter.qml" +audio_route="$repo_root/hancore.shibumi.bluetooth/BluetoothAudioRouteAdapter.qml" model="$repo_root/hancore.shibumi.bluetooth/BluetoothModel.js" discovery_guard="$repo_root/hancore.shibumi.bluetooth/BluetoothDiscoveryGuard.qml" panel="$repo_root/hancore.shibumi.bluetooth/BluetoothPanel.qml" @@ -133,8 +134,13 @@ jq -e ' || fail "Bluetooth suite metadata still declares a host backend contract" rg -q '^import Quickshell\.Bluetooth$' "$adapter" \ || fail "Bluetooth adapter does not own the native BlueZ model" -rg -q '^import Quickshell\.Services\.Pipewire$' "$adapter" \ - || fail "Bluetooth adapter does not own Bluetooth audio routing" +[[ -f $audio_route ]] || fail "Bluetooth audio route seam is missing" +rg -q '^import Quickshell\.Services\.Pipewire$' "$audio_route" \ + || fail "Bluetooth audio route seam does not own PipeWire access" +rg -q 'BluetoothAudioRouteAdapter' "$adapter" \ + || fail "Bluetooth adapter does not use the audio route seam" +rg -Fq 'routeBluetoothDevice(request)' "$audio_route" \ + || fail "Bluetooth audio route seam lacks its narrow route method" rg -Fq 'executeDeviceCommand(deviceCommand(action, device.address))' "$adapter" \ || fail "Bluetooth adapter does not preserve the device helper contract" rg -Fq 'Model.deviceLists(nativeDevices)' "$adapter" \ diff --git a/tests/contract-regression.sh b/tests/contract-regression.sh index b41ef86..27a99fa 100755 --- a/tests/contract-regression.sh +++ b/tests/contract-regression.sh @@ -719,6 +719,7 @@ jq -e ' || fail "Shibumi Bluetooth plugin entry points are not declared" bluetooth_service=hancore.shibumi.bluetooth/Service.qml bluetooth_adapter=hancore.shibumi.bluetooth/BluetoothBackendAdapter.qml +bluetooth_audio_route=hancore.shibumi.bluetooth/BluetoothAudioRouteAdapter.qml bluetooth_widget=hancore.shibumi.bluetooth/BarWidget.qml bluetooth_panel=hancore.shibumi.bluetooth/BluetoothPanel.qml [[ $(rg -c 'BluetoothBackendAdapter \{' "$bluetooth_service") -eq 1 ]] \ @@ -736,8 +737,14 @@ rg -q 'adapter\.stopDiscovery\(\)' "$bluetooth_service" \ for bluetooth_adapter in "$bluetooth_adapter"; do rg -q '^import Quickshell\.Bluetooth$' "$bluetooth_adapter" \ || fail "$bluetooth_adapter does not own the native BlueZ model" - rg -q '^import Quickshell\.Services\.Pipewire$' "$bluetooth_adapter" \ - || fail "$bluetooth_adapter does not own Bluetooth audio routing" + [[ -f $bluetooth_audio_route ]] \ + || fail "$bluetooth_audio_route is missing" + rg -q '^import Quickshell\.Services\.Pipewire$' "$bluetooth_audio_route" \ + || fail "$bluetooth_audio_route does not own PipeWire access" + rg -q 'BluetoothAudioRouteAdapter' "$bluetooth_adapter" \ + || fail "$bluetooth_adapter does not use the audio route seam" + rg -Fq 'routeBluetoothDevice(request)' "$bluetooth_audio_route" \ + || fail "$bluetooth_audio_route lacks its narrow route method" for device_signal in ConnectedDevices KnownDevices DiscoveredDevices; do rg -U -q "on${device_signal}Changed: \\{[^}]*syncNativePendingActions\\(\\)[^}]*syncNativeAudioHandoffIntents\\(\\)" \ "$bluetooth_adapter" \ From 4e5b2fa74b9bdaa06888b09aef87cde1157e5eb7 Mon Sep 17 00:00:00 2001 From: HANCORE-linux <230438592+HANCORE-linux@users.noreply.github.com> Date: Sat, 22 Aug 2026 09:15:19 +0200 Subject: [PATCH 04/18] refactor: prepare native audio backend seam --- hancore.shibumi.audio/AudioBackendAdapter.qml | 197 ++++++++++++++++++ hancore.shibumi.audio/AudioModel.js | 100 +++++++++ hancore.shibumi.audio/Service.qml | 15 ++ .../BluetoothBackendAdapter.qml | 3 +- tests/audio-media-plugins-regression.sh | 2 + .../audio-native-backend-seam-regression.qml | 183 ++++++++++++++++ tests/bluetooth-audio-intent-regression.qml | 8 +- 7 files changed, 506 insertions(+), 2 deletions(-) create mode 100644 hancore.shibumi.audio/AudioBackendAdapter.qml create mode 100644 hancore.shibumi.audio/AudioModel.js create mode 100644 tests/audio-native-backend-seam-regression.qml diff --git a/hancore.shibumi.audio/AudioBackendAdapter.qml b/hancore.shibumi.audio/AudioBackendAdapter.qml new file mode 100644 index 0000000..7ab7d25 --- /dev/null +++ b/hancore.shibumi.audio/AudioBackendAdapter.qml @@ -0,0 +1,197 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Quickshell.Services.Pipewire +import "AudioModel.js" as Model + +// Test-only/native preparation seam for Step 4A. It is not instantiated by +// production Service.qml until the native Audio activation commit. PipeWire +// objects stay private here; callers receive primitive snapshots and typed +// action results, and every mutation resolves the current object by ID. +Item { + id: root + + property bool active: false + property var backendOverride: null + property int generation: 0 + + readonly property bool ready: active && (backendOverride === null + || backendValue("ready", true) !== false) + readonly property real outputVolume: currentSink() + && currentSink().audio && currentSink().audio.volume !== undefined + ? Number(currentSink().audio.volume) : 0 + readonly property bool outputMuted: currentSink() + && currentSink().audio && currentSink().audio.muted === true + readonly property real inputVolume: currentSource() + && currentSource().audio && currentSource().audio.volume !== undefined + ? Number(currentSource().audio.volume) : 0 + readonly property bool inputMuted: currentSource() + && currentSource().audio && currentSource().audio.muted === true + + readonly property var audioSinks: snapshotList( + filterSinks(currentNodes()), "sink", currentSink()) + readonly property var audioSources: snapshotList( + filterSources(currentNodes()), "source", currentSource()) + readonly property var audioStreams: snapshotList( + filterStreams(currentNodes()), "stream", null) + readonly property var sinkSnapshot: Model.snapshot(currentSink(), "sink", true) + readonly property var sourceSnapshot: Model.snapshot( + currentSource(), "source", true) + + function currentNodes() { + if (!active) return [] + if (backendOverride !== null) return backendValue("nodes", []) + return Pipewire.nodes ? Pipewire.nodes.values : [] + } + + function currentSink() { + if (!active) return null + return backendOverride !== null + ? backendValue("defaultAudioSink", null) + : Pipewire.defaultAudioSink + } + + function currentSource() { + if (!active) return null + return backendOverride !== null + ? backendValue("defaultAudioSource", null) + : Pipewire.defaultAudioSource + } + + function backendValue(name, fallback) { + if (backendOverride !== null && backendOverride[name] !== undefined) + return backendOverride[name] + return fallback + } + + function filterSinks(nodes) { + const result = [] + for (let index = 0; index < nodes.length; index++) { + const node = nodes[index] + if (node && node.isSink && !node.isStream) result.push(node) + } + return result + } + + function filterSources(nodes) { + const result = [] + for (let index = 0; index < nodes.length; index++) { + const node = nodes[index] + if (Model.isAudioSource(node)) result.push(node) + } + return result + } + + function filterStreams(nodes) { + const result = [] + for (let index = 0; index < nodes.length; index++) { + const node = nodes[index] + if (Model.isPlaybackStream(node) && node.audio) result.push(node) + } + return result + } + + function snapshotList(nodes, kind, selected) { + const result = [] + for (let index = 0; index < nodes.length; index++) { + const value = Model.snapshot(nodes[index], kind, nodes[index] === selected) + if (value && value.id) result.push(value) + } + return result + } + + function resolveNode(id, nodes, kind) { + const key = String(id || "") + if (!key) return null + for (let index = 0; index < nodes.length; index++) { + if (Model.stableNodeId(nodes[index], kind) === key) return nodes[index] + } + return null + } + + function result(ok, code, message, entityId) { + return Model.actionResult(ok, code, message, entityId, generation) + } + + function setNodeDefault(node, kind, propertyName) { + const entityId = Model.stableNodeId(node, kind) + if (!node || !entityId) + return result(false, "stale-id", "Audio node is unavailable") + if (backendOverride !== null + && typeof backendOverride[propertyName] === "function") { + backendOverride[propertyName](node) + } else if (propertyName === "setDefaultSink") { + Pipewire.preferredDefaultAudioSink = node + } else { + Pipewire.preferredDefaultAudioSource = node + } + return result(true, "ok", "", entityId) + } + + function setDefaultSink(id) { + const node = resolveNode(id, filterSinks(currentNodes()), "sink") + return setNodeDefault(node, "sink", "setDefaultSink") + } + + function setDefaultSource(id) { + const node = resolveNode(id, filterSources(currentNodes()), "source") + return setNodeDefault(node, "source", "setDefaultSource") + } + + function setOutputVolume(value) { + const sink = currentSink() + if (!sink || !sink.audio) return result(false, "unavailable", "Output is unavailable") + sink.audio.volume = Math.max(0, Math.min(1, Number(value) || 0)) + return result(true, "ok") + } + + function toggleOutputMute() { + const sink = currentSink() + if (!sink || !sink.audio) return result(false, "unavailable", "Output is unavailable") + sink.audio.muted = !sink.audio.muted + return result(true, "ok") + } + + function setInputVolume(value) { + const source = currentSource() + if (!source || !source.audio) return result(false, "unavailable", "Input is unavailable") + source.audio.volume = Math.max(0, Math.min(1, Number(value) || 0)) + return result(true, "ok") + } + + function toggleInputMute() { + const source = currentSource() + if (!source || !source.audio) return result(false, "unavailable", "Input is unavailable") + source.audio.muted = !source.audio.muted + return result(true, "ok") + } + + function setStreamVolume(id, value) { + const node = resolveNode(id, filterStreams(currentNodes()), "stream") + if (!node || !node.audio) + return result(false, "stale-id", "Audio stream is unavailable", id) + node.audio.volume = Math.max(0, Math.min(1.5, Number(value) || 0)) + return result(true, "ok", "", id) + } + + function toggleStreamMute(id) { + const node = resolveNode(id, filterStreams(currentNodes()), "stream") + if (!node || !node.audio) + return result(false, "stale-id", "Audio stream is unavailable", id) + node.audio.muted = !node.audio.muted + return result(true, "ok", "", id) + } + + // This is the only cross-capability route method Bluetooth needs. The + // request is primitive-only; the current PipeWire target is resolved here. + function routeBluetoothDevice(request) { + const sinks = filterSinks(currentNodes()) + for (let index = 0; index < sinks.length; index++) { + const node = sinks[index] + if (!Model.bluetoothSinkMatchesRequest(node, request)) continue + return setNodeDefault(node, "sink", "setDefaultSink") + } + return result(false, "stale-id", "Bluetooth audio sink is unavailable") + } +} diff --git a/hancore.shibumi.audio/AudioModel.js b/hancore.shibumi.audio/AudioModel.js new file mode 100644 index 0000000..6eaf34d --- /dev/null +++ b/hancore.shibumi.audio/AudioModel.js @@ -0,0 +1,100 @@ +function text(value) { + return String(value || "").trim() +} + +function nodeProperties(node) { + return node && node.ready !== false && node.properties + ? node.properties : ({}) +} + +function stableNodeId(node, kind) { + if (!node) return "" + if (node.id === undefined || node.id === null) return "" + return String(kind || "node") + ":" + String(node.id) +} + +function nodeLabel(node) { + if (!node) return "Audio device" + var properties = nodeProperties(node) + return text(node.description) + || text(properties["node.description"]) + || text(properties["device.description"]) + || text(node.name) + || "Audio device" +} + +function isAudioSource(node) { + if (!node || node.isSink || node.isStream) return false + if (node.audio) return true + var type = text(node.type) + return type.indexOf("Audio/Source") !== -1 + || type.indexOf("AudioSource") !== -1 + || type.indexOf("Source") !== -1 +} + +function isPlaybackStream(node) { + if (!node || !node.isStream) return false + if (node.isSink === true) return true + var type = text(node.type) + return type.indexOf("Stream/Output/Audio") !== -1 + || type.indexOf("AudioOutStream") !== -1 + || type.indexOf("Output") !== -1 +} + +function nodeText(node) { + var properties = nodeProperties(node) + return [ + node ? node.name : "", + node ? node.description : "", + node ? node.nickname : "", + node ? node.nick : "", + properties["node.name"], + properties["node.description"], + properties["device.name"], + properties["device.description"], + properties["api.bluez5.address"], + properties["bluez5.address"], + properties["media.name"] + ].join(" ").toLowerCase() +} + +function normalizedAddress(value) { + return text(value).toLowerCase().replace(/[^0-9a-f]/g, "") +} + +function bluetoothSinkMatchesRequest(node, request) { + if (!node || !node.isSink || node.isStream || !request) return false + var address = normalizedAddress(request.address) + var nodeTextValue = nodeText(node) + if (address && normalizedAddress(nodeTextValue).indexOf(address) !== -1) + return true + var label = text(request.deviceName || request.name).toLowerCase() + return !!label && nodeTextValue.indexOf(label) !== -1 +} + +function snapshot(node, kind, isDefault) { + if (!node) return null + var audio = node.audio + return { + id: stableNodeId(node, kind), + kind: String(kind || "node"), + name: text(node.name), + label: nodeLabel(node), + description: text(node.description), + available: node.ready !== false, + isDefault: isDefault === true, + volume: audio && audio.volume !== undefined ? Number(audio.volume) : 0, + muted: audio && audio.muted === true, + isStream: node.isStream === true + } +} + +function actionResult(ok, code, message, entityId, generation) { + return { + ok: ok === true, + code: String(code || (ok ? "ok" : "unavailable")), + message: String(message || ""), + entityId: String(entityId || ""), + generation: Number(generation || 0) + } +} diff --git a/hancore.shibumi.audio/Service.qml b/hancore.shibumi.audio/Service.qml index f19fffe..e04e722 100644 --- a/hancore.shibumi.audio/Service.qml +++ b/hancore.shibumi.audio/Service.qml @@ -12,6 +12,11 @@ Item { property var shell: null property var manifest: null property var reports: [] + // Step 4A preparation remains opt-in until the activation commit makes this + // the sole Shibumi PipeWire owner. + property bool nativeBackendEnabled: false + property var nativeBackendOverride: null + readonly property var nativeBackend: nativeBackendLoader.item property bool ready: false property bool outputMuted: false @@ -19,6 +24,16 @@ Item { width: 0 height: 0 + Loader { + id: nativeBackendLoader + active: root.nativeBackendEnabled + source: Qt.resolvedUrl("AudioBackendAdapter.qml") + onLoaded: { + item.active = true + item.backendOverride = root.nativeBackendOverride + } + } + function report(owner, available, muted) { if (!owner) return false const next = reports.filter(entry => entry.owner !== owner) diff --git a/hancore.shibumi.bluetooth/BluetoothBackendAdapter.qml b/hancore.shibumi.bluetooth/BluetoothBackendAdapter.qml index 4bf62ba..8eac0e3 100644 --- a/hancore.shibumi.bluetooth/BluetoothBackendAdapter.qml +++ b/hancore.shibumi.bluetooth/BluetoothBackendAdapter.qml @@ -329,7 +329,8 @@ Item { if (!request || !audioRoute || typeof audioRoute.routeBluetoothDevice !== "function") return false - return audioRoute.routeBluetoothDevice(request) === true + const result = audioRoute.routeBluetoothDevice(request) + return result === true || (result && result.ok === true) } function scheduleAudioOutputSwitch(device) { diff --git a/tests/audio-media-plugins-regression.sh b/tests/audio-media-plugins-regression.sh index 4edfa0c..5cc414b 100755 --- a/tests/audio-media-plugins-regression.sh +++ b/tests/audio-media-plugins-regression.sh @@ -55,6 +55,8 @@ run_smoke() { } run_smoke audio audio-plugin-smoke.qml 'audio plugin smoke passed' +run_smoke audio audio-native-backend-seam-regression.qml \ + 'audio native backend seam regression passed' run_smoke media media-plugin-smoke.qml 'media plugin smoke passed' run_spectrum_smoke() { diff --git a/tests/audio-native-backend-seam-regression.qml b/tests/audio-native-backend-seam-regression.qml new file mode 100644 index 0000000..5ee3895 --- /dev/null +++ b/tests/audio-native-backend-seam-regression.qml @@ -0,0 +1,183 @@ +import QtQuick +import Quickshell +import "audio" as Audio + +ShellRoot { + id: root + + property int phase: 0 + property int ticks: 0 + + function fail(message) { + console.error("audio-native-backend-seam-regression:", message) + Qt.exit(1) + } + + QtObject { + id: sinkAudioA + property real volume: 0.42 + property bool muted: false + } + + QtObject { + id: sinkAudioB + property real volume: 0.30 + property bool muted: false + } + + QtObject { + id: sourceAudio + property real volume: 0.64 + property bool muted: false + } + + QtObject { + id: streamAudio + property real volume: 0.75 + property bool muted: false + } + + QtObject { + id: sinkA + property int id: 1 + property bool isSink: true + property bool isStream: false + property string name: "alsa_output.pci-1" + property string description: "Built-in Audio" + property var audio: sinkAudioA + property var properties: ({}) + } + + QtObject { + id: sinkB + property int id: 2 + property bool isSink: true + property bool isStream: false + property string name: "bluez_output.AA_00_00_00_00_01.a2dp-sink" + property string description: "Headset Output" + property var audio: sinkAudioB + property var properties: ({ "api.bluez5.address": "AA:00:00:00:00:01" }) + } + + QtObject { + id: sinkWithoutId + property bool isSink: true + property bool isStream: false + property string name: "bluez_output.BB_00_00_00_00_02.a2dp-sink" + property string description: "Unstable Headset" + property var audio: sinkAudioB + property var properties: ({ "api.bluez5.address": "BB:00:00:00:00:02" }) + } + + QtObject { + id: source + property int id: 3 + property bool isSink: false + property bool isStream: false + property string name: "alsa_input.pci-1" + property string description: "Built-in Microphone" + property var audio: sourceAudio + property var properties: ({}) + } + + QtObject { + id: stream + property int id: 8 + property bool isSink: true + property bool isStream: true + property string name: "spotify" + property string description: "Spotify" + property string type: "Stream/Output/Audio" + property var audio: streamAudio + property var properties: ({}) + } + + QtObject { + id: fakeBackend + property bool ready: true + property var nodes: [sinkA, sinkB, sinkWithoutId, source, stream] + property var defaultAudioSink: sinkA + property var defaultAudioSource: source + property int sinkChanges: 0 + function setDefaultSink(node) { + defaultAudioSink = node + sinkChanges++ + } + function setDefaultSource(node) { defaultAudioSource = node } + } + + Audio.AudioBackendAdapter { + id: backend + active: true + backendOverride: fakeBackend + } + + Timer { + interval: 30 + repeat: true + running: true + onTriggered: { + root.ticks++ + if (root.phase === 0) { + if (!backend) return root.fail("native backend component did not instantiate") + if (root.ticks < 3) return + if (!backend.ready || backend.audioSinks.length !== 2 + || backend.audioSources.length !== 1 + || backend.audioStreams.length !== 1) + return root.fail("native backend snapshots") + if (backend.rawNodes !== undefined || backend.sink !== undefined + || backend.source !== undefined + || backend.audioSinks[0].node !== undefined + || backend.audioSinks[0].id !== "sink:1" + || backend.sinkSnapshot.id !== "sink:1" + || backend.sinkSnapshot.volume !== 0.42) + return root.fail("primitive stable sink snapshot") + const routeResult = backend.routeBluetoothDevice({ + address: "AA:00:00:00:00:01", + name: "Headset", + deviceName: "Headset" + }) + const missingIdResult = backend.routeBluetoothDevice({ + address: "BB:00:00:00:00:02", + name: "Unstable Headset", + deviceName: "Unstable Headset" + }) + if (!routeResult.ok || routeResult.entityId !== "sink:2" + || fakeBackend.defaultAudioSink !== sinkB + || missingIdResult.ok || missingIdResult.code !== "stale-id") + return root.fail("Bluetooth route resolution") + root.phase++ + root.ticks = 0 + } else if (root.phase === 1) { + if (root.ticks < 2) return + if (!backend.setOutputVolume(0.55).ok + || Math.abs(sinkAudioB.volume - 0.55) > 0.001 + || !backend.toggleOutputMute().ok || !sinkAudioB.muted) + return root.fail("typed output actions") + if (!backend.setDefaultSource("source:3").ok + || fakeBackend.defaultAudioSource !== source + || !backend.setStreamVolume("stream:8", 0.65).ok + || Math.abs(streamAudio.volume - 0.65) > 0.001 + || !backend.toggleStreamMute("stream:8").ok + || !streamAudio.muted) + return root.fail("typed source/stream actions") + root.phase++ + root.ticks = 0 + } else if (root.phase === 2) { + if (root.ticks < 2) return + backend.active = false + root.phase++ + root.ticks = 0 + } else if (root.phase === 3) { + if (root.ticks < 2) return + if (backend.ready || backend.audioSinks.length !== 0 + || backend.audioSources.length !== 0 + || backend.audioStreams.length !== 0) + return root.fail("inactive native backend remained active") + stop() + console.log("audio native backend seam regression passed") + Qt.quit() + } + } + } +} diff --git a/tests/bluetooth-audio-intent-regression.qml b/tests/bluetooth-audio-intent-regression.qml index 73e50d9..2ab6ac6 100644 --- a/tests/bluetooth-audio-intent-regression.qml +++ b/tests/bluetooth-audio-intent-regression.qml @@ -89,7 +89,13 @@ ShellRoot { function routeBluetoothDevice(request) { count++ lastRequest = request - return true + return { + ok: true, + code: "ok", + message: "", + entityId: "", + generation: 0 + } } } From 6490c637061275422979445d13c1ba7489c480e6 Mon Sep 17 00:00:00 2001 From: HANCORE-linux <230438592+HANCORE-linux@users.noreply.github.com> Date: Sat, 22 Aug 2026 11:43:47 +0200 Subject: [PATCH 05/18] fix: harden step4a audio seam --- hancore.shibumi.audio/AudioBackendAdapter.qml | 390 +++++++++++------- hancore.shibumi.audio/AudioModel.js | 6 +- hancore.shibumi.audio/Service.qml | 4 +- .../BluetoothAudioRouteAdapter.qml | 2 +- hancore.shibumi.bluetooth/BluetoothModel.js | 5 +- .../audio-native-backend-seam-regression.qml | 51 ++- tests/bluetooth-audio-intent-regression.qml | 23 +- 7 files changed, 319 insertions(+), 162 deletions(-) diff --git a/hancore.shibumi.audio/AudioBackendAdapter.qml b/hancore.shibumi.audio/AudioBackendAdapter.qml index 7ab7d25..368ae94 100644 --- a/hancore.shibumi.audio/AudioBackendAdapter.qml +++ b/hancore.shibumi.audio/AudioBackendAdapter.qml @@ -7,8 +7,9 @@ import "AudioModel.js" as Model // Test-only/native preparation seam for Step 4A. It is not instantiated by // production Service.qml until the native Audio activation commit. PipeWire -// objects stay private here; callers receive primitive snapshots and typed -// action results, and every mutation resolves the current object by ID. +// objects stay inside the implementation object, callers receive primitive +// snapshots and typed action results, and every mutation resolves the current +// object by ID. Item { id: root @@ -16,182 +17,267 @@ Item { property var backendOverride: null property int generation: 0 - readonly property bool ready: active && (backendOverride === null - || backendValue("ready", true) !== false) - readonly property real outputVolume: currentSink() - && currentSink().audio && currentSink().audio.volume !== undefined - ? Number(currentSink().audio.volume) : 0 - readonly property bool outputMuted: currentSink() - && currentSink().audio && currentSink().audio.muted === true - readonly property real inputVolume: currentSource() - && currentSource().audio && currentSource().audio.volume !== undefined - ? Number(currentSource().audio.volume) : 0 - readonly property bool inputMuted: currentSource() - && currentSource().audio && currentSource().audio.muted === true - - readonly property var audioSinks: snapshotList( - filterSinks(currentNodes()), "sink", currentSink()) - readonly property var audioSources: snapshotList( - filterSources(currentNodes()), "source", currentSource()) - readonly property var audioStreams: snapshotList( - filterStreams(currentNodes()), "stream", null) - readonly property var sinkSnapshot: Model.snapshot(currentSink(), "sink", true) - readonly property var sourceSnapshot: Model.snapshot( - currentSource(), "source", true) - - function currentNodes() { - if (!active) return [] - if (backendOverride !== null) return backendValue("nodes", []) - return Pipewire.nodes ? Pipewire.nodes.values : [] - } + readonly property bool ready: implementation.ready() + readonly property real outputVolume: implementation.outputVolume() + readonly property bool outputMuted: implementation.outputMuted() + readonly property real inputVolume: implementation.inputVolume() + readonly property bool inputMuted: implementation.inputMuted() + readonly property var audioSinks: implementation.audioSinks() + readonly property var audioSources: implementation.audioSources() + readonly property var audioStreams: implementation.audioStreams() + readonly property var sinkSnapshot: implementation.sinkSnapshot() + readonly property var sourceSnapshot: implementation.sourceSnapshot() - function currentSink() { - if (!active) return null - return backendOverride !== null - ? backendValue("defaultAudioSink", null) - : Pipewire.defaultAudioSink + // Public capability surface: primitive snapshots and typed action results. + // Raw PipeWire nodes remain reachable only through the implementation id. + function setDefaultSink(id) { return implementation.setDefaultSink(id) } + function setDefaultSource(id) { return implementation.setDefaultSource(id) } + function setOutputVolume(value) { return implementation.setOutputVolume(value) } + function toggleOutputMute() { return implementation.toggleOutputMute() } + function setInputVolume(value) { return implementation.setInputVolume(value) } + function toggleInputMute() { return implementation.toggleInputMute() } + function setStreamVolume(id, value) { + return implementation.setStreamVolume(id, value) } - - function currentSource() { - if (!active) return null - return backendOverride !== null - ? backendValue("defaultAudioSource", null) - : Pipewire.defaultAudioSource + function toggleStreamMute(id) { + return implementation.toggleStreamMute(id) } - - function backendValue(name, fallback) { - if (backendOverride !== null && backendOverride[name] !== undefined) - return backendOverride[name] - return fallback + function routeBluetoothDevice(request) { + return implementation.routeBluetoothDevice(request) } - function filterSinks(nodes) { - const result = [] - for (let index = 0; index < nodes.length; index++) { - const node = nodes[index] - if (node && node.isSink && !node.isStream) result.push(node) + QtObject { + id: implementation + + function backendValue(name, fallback) { + if (root.backendOverride !== null + && root.backendOverride[name] !== undefined) + return root.backendOverride[name] + return fallback } - return result - } - function filterSources(nodes) { - const result = [] - for (let index = 0; index < nodes.length; index++) { - const node = nodes[index] - if (Model.isAudioSource(node)) result.push(node) + function currentNodes() { + if (!root.active) return [] + if (root.backendOverride !== null) return backendValue("nodes", []) + return Pipewire.nodes ? Pipewire.nodes.values : [] } - return result - } - function filterStreams(nodes) { - const result = [] - for (let index = 0; index < nodes.length; index++) { - const node = nodes[index] - if (Model.isPlaybackStream(node) && node.audio) result.push(node) + function currentSink() { + if (!root.active) return null + return root.backendOverride !== null + ? backendValue("defaultAudioSink", null) + : Pipewire.defaultAudioSink } - return result - } - function snapshotList(nodes, kind, selected) { - const result = [] - for (let index = 0; index < nodes.length; index++) { - const value = Model.snapshot(nodes[index], kind, nodes[index] === selected) - if (value && value.id) result.push(value) + function currentSource() { + if (!root.active) return null + return root.backendOverride !== null + ? backendValue("defaultAudioSource", null) + : Pipewire.defaultAudioSource } - return result - } - function resolveNode(id, nodes, kind) { - const key = String(id || "") - if (!key) return null - for (let index = 0; index < nodes.length; index++) { - if (Model.stableNodeId(nodes[index], kind) === key) return nodes[index] + function ready() { + return root.active && (root.backendOverride === null + || backendValue("ready", true) !== false) } - return null - } - function result(ok, code, message, entityId) { - return Model.actionResult(ok, code, message, entityId, generation) - } + function outputVolume() { + const sink = currentSink() + return sink && sink.audio && sink.audio.volume !== undefined + ? Number(sink.audio.volume) : 0 + } - function setNodeDefault(node, kind, propertyName) { - const entityId = Model.stableNodeId(node, kind) - if (!node || !entityId) - return result(false, "stale-id", "Audio node is unavailable") - if (backendOverride !== null - && typeof backendOverride[propertyName] === "function") { - backendOverride[propertyName](node) - } else if (propertyName === "setDefaultSink") { - Pipewire.preferredDefaultAudioSink = node - } else { - Pipewire.preferredDefaultAudioSource = node - } - return result(true, "ok", "", entityId) - } + function outputMuted() { + const sink = currentSink() + return sink && sink.audio && sink.audio.muted === true + } - function setDefaultSink(id) { - const node = resolveNode(id, filterSinks(currentNodes()), "sink") - return setNodeDefault(node, "sink", "setDefaultSink") - } + function inputVolume() { + const source = currentSource() + return source && source.audio && source.audio.volume !== undefined + ? Number(source.audio.volume) : 0 + } - function setDefaultSource(id) { - const node = resolveNode(id, filterSources(currentNodes()), "source") - return setNodeDefault(node, "source", "setDefaultSource") - } + function inputMuted() { + const source = currentSource() + return source && source.audio && source.audio.muted === true + } - function setOutputVolume(value) { - const sink = currentSink() - if (!sink || !sink.audio) return result(false, "unavailable", "Output is unavailable") - sink.audio.volume = Math.max(0, Math.min(1, Number(value) || 0)) - return result(true, "ok") - } + function filterSinks(nodes) { + const result = [] + for (let index = 0; index < nodes.length; index++) { + const node = nodes[index] + if (node && node.isSink && !node.isStream) result.push(node) + } + return result + } - function toggleOutputMute() { - const sink = currentSink() - if (!sink || !sink.audio) return result(false, "unavailable", "Output is unavailable") - sink.audio.muted = !sink.audio.muted - return result(true, "ok") - } + function filterSources(nodes) { + const result = [] + for (let index = 0; index < nodes.length; index++) { + const node = nodes[index] + if (Model.isAudioSource(node)) result.push(node) + } + return result + } - function setInputVolume(value) { - const source = currentSource() - if (!source || !source.audio) return result(false, "unavailable", "Input is unavailable") - source.audio.volume = Math.max(0, Math.min(1, Number(value) || 0)) - return result(true, "ok") - } + function filterStreams(nodes) { + const result = [] + for (let index = 0; index < nodes.length; index++) { + const node = nodes[index] + if (Model.isPlaybackStream(node) && node.audio) result.push(node) + } + return result + } - function toggleInputMute() { - const source = currentSource() - if (!source || !source.audio) return result(false, "unavailable", "Input is unavailable") - source.audio.muted = !source.audio.muted - return result(true, "ok") - } + function snapshotList(nodes, kind, selected) { + const result = [] + for (let index = 0; index < nodes.length; index++) { + const value = Model.snapshot(nodes[index], kind, nodes[index] === selected) + if (value && value.id) result.push(value) + } + return result + } - function setStreamVolume(id, value) { - const node = resolveNode(id, filterStreams(currentNodes()), "stream") - if (!node || !node.audio) - return result(false, "stale-id", "Audio stream is unavailable", id) - node.audio.volume = Math.max(0, Math.min(1.5, Number(value) || 0)) - return result(true, "ok", "", id) - } + function audioSinks() { + return snapshotList(filterSinks(currentNodes()), "sink", currentSink()) + } - function toggleStreamMute(id) { - const node = resolveNode(id, filterStreams(currentNodes()), "stream") - if (!node || !node.audio) - return result(false, "stale-id", "Audio stream is unavailable", id) - node.audio.muted = !node.audio.muted - return result(true, "ok", "", id) - } + function audioSources() { + return snapshotList( + filterSources(currentNodes()), "source", currentSource()) + } - // This is the only cross-capability route method Bluetooth needs. The - // request is primitive-only; the current PipeWire target is resolved here. - function routeBluetoothDevice(request) { - const sinks = filterSinks(currentNodes()) - for (let index = 0; index < sinks.length; index++) { - const node = sinks[index] - if (!Model.bluetoothSinkMatchesRequest(node, request)) continue + function audioStreams() { + return snapshotList(filterStreams(currentNodes()), "stream", null) + } + + function sinkSnapshot() { + return Model.snapshot(currentSink(), "sink", true) + } + + function sourceSnapshot() { + return Model.snapshot(currentSource(), "source", true) + } + + function resolveNode(id, nodes, kind) { + const key = String(id || "") + if (!key) return null + for (let index = 0; index < nodes.length; index++) { + if (Model.stableNodeId(nodes[index], kind) === key) return nodes[index] + } + return null + } + + function result(ok, code, message, entityId) { + return Model.actionResult( + ok, code, message, entityId, root.generation) + } + + function mutationError(node, kind, label) { + if (!node || node.ready === false) + return result(false, "unavailable", label + " is unavailable") + const entityId = Model.stableNodeId(node, kind) + if (!entityId) + return result(false, "stale-id", label + " is unavailable") + return null + } + + function setNodeDefault(node, kind, propertyName) { + const failure = mutationError(node, kind, "Audio node") + if (failure) return failure + const entityId = Model.stableNodeId(node, kind) + if (root.backendOverride !== null + && typeof root.backendOverride[propertyName] === "function") { + root.backendOverride[propertyName](node) + } else if (propertyName === "setDefaultSink") { + Pipewire.preferredDefaultAudioSink = node + } else { + Pipewire.preferredDefaultAudioSource = node + } + return result(true, "ok", "", entityId) + } + + function setDefaultSink(id) { + const node = resolveNode(id, filterSinks(currentNodes()), "sink") return setNodeDefault(node, "sink", "setDefaultSink") } - return result(false, "stale-id", "Bluetooth audio sink is unavailable") + + function setDefaultSource(id) { + const node = resolveNode(id, filterSources(currentNodes()), "source") + return setNodeDefault(node, "source", "setDefaultSource") + } + + function setOutputVolume(value) { + const sink = currentSink() + if (!sink || !sink.audio) + return result(false, "unavailable", "Output is unavailable") + const failure = mutationError(sink, "sink", "Output") + if (failure) return failure + sink.audio.volume = Math.max(0, Math.min(1, Number(value) || 0)) + return result(true, "ok", "", Model.stableNodeId(sink, "sink")) + } + + function toggleOutputMute() { + const sink = currentSink() + const failure = mutationError(sink, "sink", "Output") + if (failure) return failure + if (!sink.audio) + return result(false, "unavailable", "Output is unavailable") + sink.audio.muted = !sink.audio.muted + return result(true, "ok", "", Model.stableNodeId(sink, "sink")) + } + + function setInputVolume(value) { + const source = currentSource() + if (!source || !source.audio) + return result(false, "unavailable", "Input is unavailable") + const failure = mutationError(source, "source", "Input") + if (failure) return failure + source.audio.volume = Math.max(0, Math.min(1, Number(value) || 0)) + return result(true, "ok", "", Model.stableNodeId(source, "source")) + } + + function toggleInputMute() { + const source = currentSource() + const failure = mutationError(source, "source", "Input") + if (failure) return failure + if (!source.audio) + return result(false, "unavailable", "Input is unavailable") + source.audio.muted = !source.audio.muted + return result(true, "ok", "", Model.stableNodeId(source, "source")) + } + + function setStreamVolume(id, value) { + const node = resolveNode(id, filterStreams(currentNodes()), "stream") + const failure = mutationError(node, "stream", "Audio stream") + if (failure) return failure + if (!node.audio) + return result(false, "unavailable", "Audio stream is unavailable", id) + node.audio.volume = Math.max(0, Math.min(1.5, Number(value) || 0)) + return result(true, "ok", "", id) + } + + function toggleStreamMute(id) { + const node = resolveNode(id, filterStreams(currentNodes()), "stream") + const failure = mutationError(node, "stream", "Audio stream") + if (failure) return failure + if (!node.audio) + return result(false, "unavailable", "Audio stream is unavailable", id) + node.audio.muted = !node.audio.muted + return result(true, "ok", "", id) + } + + // This is the only cross-capability route method Bluetooth needs. The + // request is primitive-only; the current PipeWire target is resolved here. + function routeBluetoothDevice(request) { + const sinks = filterSinks(currentNodes()) + for (let index = 0; index < sinks.length; index++) { + const node = sinks[index] + if (!Model.bluetoothSinkMatchesRequest(node, request)) continue + return setNodeDefault(node, "sink", "setDefaultSink") + } + return result(false, "stale-id", "Bluetooth audio sink is unavailable") + } } } diff --git a/hancore.shibumi.audio/AudioModel.js b/hancore.shibumi.audio/AudioModel.js index 6eaf34d..a910805 100644 --- a/hancore.shibumi.audio/AudioModel.js +++ b/hancore.shibumi.audio/AudioModel.js @@ -66,8 +66,10 @@ function bluetoothSinkMatchesRequest(node, request) { if (!node || !node.isSink || node.isStream || !request) return false var address = normalizedAddress(request.address) var nodeTextValue = nodeText(node) - if (address && normalizedAddress(nodeTextValue).indexOf(address) !== -1) - return true + // A supplied Bluetooth address is authoritative. Never route to a same-name + // device when its address is absent or different. + if (address) + return normalizedAddress(nodeTextValue).indexOf(address) !== -1 var label = text(request.deviceName || request.name).toLowerCase() return !!label && nodeTextValue.indexOf(label) !== -1 } diff --git a/hancore.shibumi.audio/Service.qml b/hancore.shibumi.audio/Service.qml index e04e722..507474d 100644 --- a/hancore.shibumi.audio/Service.qml +++ b/hancore.shibumi.audio/Service.qml @@ -29,8 +29,10 @@ Item { active: root.nativeBackendEnabled source: Qt.resolvedUrl("AudioBackendAdapter.qml") onLoaded: { - item.active = true + // Apply test/native injection before activation so a fake backend never + // briefly evaluates the real PipeWire service. item.backendOverride = root.nativeBackendOverride + item.active = true } } diff --git a/hancore.shibumi.bluetooth/BluetoothAudioRouteAdapter.qml b/hancore.shibumi.bluetooth/BluetoothAudioRouteAdapter.qml index 3d0a6b9..c4d0041 100644 --- a/hancore.shibumi.bluetooth/BluetoothAudioRouteAdapter.qml +++ b/hancore.shibumi.bluetooth/BluetoothAudioRouteAdapter.qml @@ -39,7 +39,7 @@ Item { } function setDefaultSink(sink) { - if (!sink) return false + if (!sink || sink.ready === false) return false if (outputOverride !== null && typeof outputOverride.setDefaultSink === "function") { outputOverride.setDefaultSink(sink) diff --git a/hancore.shibumi.bluetooth/BluetoothModel.js b/hancore.shibumi.bluetooth/BluetoothModel.js index 6608ab7..9f47a5d 100644 --- a/hancore.shibumi.bluetooth/BluetoothModel.js +++ b/hancore.shibumi.bluetooth/BluetoothModel.js @@ -94,7 +94,10 @@ function bluetoothSinkMatchesDevice(node, device) { var address = normalizedAddress(device.address) var text = nodeText(node) - if (address !== "" && normalizedAddress(text).indexOf(address) !== -1) return true + // A supplied Bluetooth address is authoritative. Never route to a same-name + // sink when its address is absent or different. + if (address !== "") + return normalizedAddress(text).indexOf(address) !== -1 var label = deviceLabel(device).toLowerCase() return label !== "" && text.indexOf(label) !== -1 diff --git a/tests/audio-native-backend-seam-regression.qml b/tests/audio-native-backend-seam-regression.qml index 5ee3895..bf39e67 100644 --- a/tests/audio-native-backend-seam-regression.qml +++ b/tests/audio-native-backend-seam-regression.qml @@ -40,6 +40,7 @@ ShellRoot { QtObject { id: sinkA property int id: 1 + property bool ready: true property bool isSink: true property bool isStream: false property string name: "alsa_output.pci-1" @@ -51,6 +52,7 @@ ShellRoot { QtObject { id: sinkB property int id: 2 + property bool ready: true property bool isSink: true property bool isStream: false property string name: "bluez_output.AA_00_00_00_00_01.a2dp-sink" @@ -61,6 +63,7 @@ ShellRoot { QtObject { id: sinkWithoutId + property bool ready: true property bool isSink: true property bool isStream: false property string name: "bluez_output.BB_00_00_00_00_02.a2dp-sink" @@ -72,6 +75,7 @@ ShellRoot { QtObject { id: source property int id: 3 + property bool ready: true property bool isSink: false property bool isStream: false property string name: "alsa_input.pci-1" @@ -83,6 +87,7 @@ ShellRoot { QtObject { id: stream property int id: 8 + property bool ready: true property bool isSink: true property bool isStream: true property string name: "spotify" @@ -112,6 +117,12 @@ ShellRoot { backendOverride: fakeBackend } + Audio.Service { + id: service + nativeBackendEnabled: true + nativeBackendOverride: fakeBackend + } + Timer { interval: 30 repeat: true @@ -125,8 +136,16 @@ ShellRoot { || backend.audioSources.length !== 1 || backend.audioStreams.length !== 1) return root.fail("native backend snapshots") - if (backend.rawNodes !== undefined || backend.sink !== undefined - || backend.source !== undefined + if (backend.currentNodes !== undefined + || backend.currentSink !== undefined + || backend.currentSource !== undefined + || backend.filterSinks !== undefined + || backend.filterSources !== undefined + || backend.filterStreams !== undefined + || service.nativeBackend === null + || service.nativeBackend.ready !== true + || service.nativeBackend.audioSinks.length !== 2 + || service.nativeBackend.currentNodes !== undefined || backend.audioSinks[0].node !== undefined || backend.audioSinks[0].id !== "sink:1" || backend.sinkSnapshot.id !== "sink:1" @@ -142,9 +161,15 @@ ShellRoot { name: "Unstable Headset", deviceName: "Unstable Headset" }) + const wrongAddressResult = backend.routeBluetoothDevice({ + address: "CC:00:00:00:00:03", + name: "Headset Output", + deviceName: "Headset Output" + }) if (!routeResult.ok || routeResult.entityId !== "sink:2" || fakeBackend.defaultAudioSink !== sinkB - || missingIdResult.ok || missingIdResult.code !== "stale-id") + || missingIdResult.ok || missingIdResult.code !== "stale-id" + || wrongAddressResult.ok || wrongAddressResult.code !== "stale-id") return root.fail("Bluetooth route resolution") root.phase++ root.ticks = 0 @@ -165,6 +190,26 @@ ShellRoot { root.ticks = 0 } else if (root.phase === 2) { if (root.ticks < 2) return + const sinkVolume = sinkAudioB.volume + const sinkMuted = sinkAudioB.muted + const streamVolume = streamAudio.volume + const streamMuted = streamAudio.muted + sinkB.ready = false + source.ready = false + stream.ready = false + const unavailableOutput = backend.setOutputVolume(0.12) + const unavailableMute = backend.toggleOutputMute() + const unavailableSource = backend.setInputVolume(0.12) + const unavailableStream = backend.setStreamVolume("stream:8", 0.12) + const unavailableRoute = backend.setDefaultSink("sink:2") + if (unavailableOutput.ok || unavailableOutput.code !== "unavailable" + || unavailableMute.ok || unavailableSource.ok + || unavailableStream.ok || unavailableRoute.ok + || sinkAudioB.volume !== sinkVolume + || sinkAudioB.muted !== sinkMuted + || streamAudio.volume !== streamVolume + || streamAudio.muted !== streamMuted) + return root.fail("unavailable nodes were mutated") backend.active = false root.phase++ root.ticks = 0 diff --git a/tests/bluetooth-audio-intent-regression.qml b/tests/bluetooth-audio-intent-regression.qml index 2ab6ac6..c7d48a4 100644 --- a/tests/bluetooth-audio-intent-regression.qml +++ b/tests/bluetooth-audio-intent-regression.qml @@ -33,6 +33,18 @@ ShellRoot { property var adapter: nativeAdapter } + QtObject { + id: deviceWrongAddress + property string address: "CC:00:00:00:00:03" + property string name: "Intent A" + property string deviceName: name + property bool connected: true + property bool paired: true + property bool bonded: true + property bool trusted: true + property var adapter: nativeAdapter + } + QtObject { id: deviceB property string address: "BB:00:00:00:00:02" @@ -50,6 +62,7 @@ ShellRoot { property bool isSink: true property bool isStream: false property int id: 801 + property bool ready: true property string name: "bluez_output.AA_00_00_00_00_01.a2dp-sink" property string description: "Intent A" property string nickname: "" @@ -62,6 +75,7 @@ ShellRoot { property bool isSink: true property bool isStream: false property int id: 802 + property bool ready: true property string name: "bluez_output.BB_00_00_00_00_02.a2dp-sink" property string description: "Intent B" property string nickname: "" @@ -127,13 +141,18 @@ ShellRoot { if (root.phase === 0) { if (root.ticks < 2) return + sinkA.ready = false + const unavailableRoute = backend.requestBluetoothAudioRoute(deviceA) + sinkA.ready = true if (!seamBackend.requestBluetoothAudioRoute(deviceA) || routeOverride.count !== 1 || routeOverride.lastRequest.address !== deviceA.address || routeOverride.lastRequest.name !== deviceA.name || "adapter" in routeOverride.lastRequest - || "connected" in routeOverride.lastRequest) - return root.fail("Bluetooth route seam leaked backend state") + || "connected" in routeOverride.lastRequest + || backend.requestBluetoothAudioRoute(deviceWrongAddress) + || unavailableRoute) + return root.fail("Bluetooth route seam leaked, misrouted, or mutated an unavailable sink") if (!backend.connectDevice(deviceA) || !backend.connectDevice(deviceB)) return root.fail("could not create ordered connect intents") backend.nativePendingActions = ({}) From 403365ac3d2782012067dc6bc1243396e19c927a Mon Sep 17 00:00:00 2001 From: HANCORE-linux <230438592+HANCORE-linux@users.noreply.github.com> Date: Sat, 22 Aug 2026 12:31:13 +0200 Subject: [PATCH 06/18] fix: harden typed audio route handoff --- hancore.shibumi.audio/AudioBackendAdapter.qml | 5 +-- hancore.shibumi.audio/AudioModel.js | 10 ++++-- .../BluetoothAudioRouteAdapter.qml | 34 +++++++++++++----- .../BluetoothBackendAdapter.qml | 36 ++++++++++++++++--- hancore.shibumi.bluetooth/BluetoothModel.js | 9 +++-- .../audio-native-backend-seam-regression.qml | 26 ++++++++++++-- tests/bluetooth-audio-intent-regression.qml | 21 +++++++++-- 7 files changed, 116 insertions(+), 25 deletions(-) diff --git a/hancore.shibumi.audio/AudioBackendAdapter.qml b/hancore.shibumi.audio/AudioBackendAdapter.qml index 368ae94..f7d64e8 100644 --- a/hancore.shibumi.audio/AudioBackendAdapter.qml +++ b/hancore.shibumi.audio/AudioBackendAdapter.qml @@ -187,8 +187,9 @@ Item { const failure = mutationError(node, kind, "Audio node") if (failure) return failure const entityId = Model.stableNodeId(node, kind) - if (root.backendOverride !== null - && typeof root.backendOverride[propertyName] === "function") { + if (root.backendOverride !== null) { + if (typeof root.backendOverride[propertyName] !== "function") + return result(false, "unsupported", "Audio backend action is unavailable", entityId) root.backendOverride[propertyName](node) } else if (propertyName === "setDefaultSink") { Pipewire.preferredDefaultAudioSink = node diff --git a/hancore.shibumi.audio/AudioModel.js b/hancore.shibumi.audio/AudioModel.js index a910805..eba2379 100644 --- a/hancore.shibumi.audio/AudioModel.js +++ b/hancore.shibumi.audio/AudioModel.js @@ -64,12 +64,16 @@ function normalizedAddress(value) { function bluetoothSinkMatchesRequest(node, request) { if (!node || !node.isSink || node.isStream || !request) return false - var address = normalizedAddress(request.address) + var rawAddress = text(request.address) + var address = normalizedAddress(rawAddress) var nodeTextValue = nodeText(node) // A supplied Bluetooth address is authoritative. Never route to a same-name - // device when its address is absent or different. - if (address) + // device when its address is absent, invalid, or different. + if (rawAddress) { + if (!/^([0-9a-f]{2}[:-]){5}[0-9a-f]{2}$/i.test(rawAddress)) + return false return normalizedAddress(nodeTextValue).indexOf(address) !== -1 + } var label = text(request.deviceName || request.name).toLowerCase() return !!label && nodeTextValue.indexOf(label) !== -1 } diff --git a/hancore.shibumi.bluetooth/BluetoothAudioRouteAdapter.qml b/hancore.shibumi.bluetooth/BluetoothAudioRouteAdapter.qml index c4d0041..abb629a 100644 --- a/hancore.shibumi.bluetooth/BluetoothAudioRouteAdapter.qml +++ b/hancore.shibumi.bluetooth/BluetoothAudioRouteAdapter.qml @@ -38,27 +38,45 @@ Item { return null } + function actionResult(ok, code, message, entityId) { + return { + ok: ok === true, + code: String(code || (ok ? "ok" : "unavailable")), + message: String(message || ""), + entityId: String(entityId || ""), + generation: 0 + } + } + function setDefaultSink(sink) { - if (!sink || sink.ready === false) return false - if (outputOverride !== null - && typeof outputOverride.setDefaultSink === "function") { + if (!sink || sink.ready === false) + return actionResult(false, "unavailable", "Bluetooth audio sink is unavailable") + const entityId = sink.id === undefined || sink.id === null + ? "" : "sink:" + String(sink.id) + if (!entityId) + return actionResult(false, "stale-id", "Bluetooth audio sink is unavailable") + if (outputOverride !== null) { + if (typeof outputOverride.setDefaultSink !== "function") + return actionResult( + false, "unsupported", "Audio backend action is unavailable", entityId) outputOverride.setDefaultSink(sink) - return true + return actionResult(true, "ok", "", entityId) } Pipewire.preferredDefaultAudioSink = sink - if (sink.id === undefined || !sink.name) return true Quickshell.execDetached([ "omarchy-audio-output-set-default", String(sink.id), - String(sink.name) + String(sink.name || "") ]) - return true + return actionResult(true, "ok", "", entityId) } // Narrow cross-capability method. The request contains only stable device // identity and labels; PipeWire objects never cross back into Bluetooth. function routeBluetoothDevice(request) { const sink = sinkForDevice(request) - return sink ? setDefaultSink(sink) : false + return sink + ? setDefaultSink(sink) + : actionResult(false, "stale-id", "Bluetooth audio sink is unavailable") } } diff --git a/hancore.shibumi.bluetooth/BluetoothBackendAdapter.qml b/hancore.shibumi.bluetooth/BluetoothBackendAdapter.qml index 8eac0e3..40222a2 100644 --- a/hancore.shibumi.bluetooth/BluetoothBackendAdapter.qml +++ b/hancore.shibumi.bluetooth/BluetoothBackendAdapter.qml @@ -324,13 +324,33 @@ Item { } } + function audioRouteResult(ok, code, message, entityId, generation) { + return { + ok: ok === true, + code: String(code || (ok ? "ok" : "unavailable")), + message: String(message || ""), + entityId: String(entityId || ""), + generation: Number(generation || 0) + } + } + + function normalizeAudioRouteResult(value) { + if (value && typeof value === "object" + && typeof value.ok === "boolean") + return value + if (value === true) return audioRouteResult(true, "ok", "", "", 0) + return audioRouteResult( + false, "unavailable", "Bluetooth audio route is unavailable", "", 0) + } + function requestBluetoothAudioRoute(device) { const request = audioRouteRequest(device) if (!request || !audioRoute || typeof audioRoute.routeBluetoothDevice !== "function") - return false - const result = audioRoute.routeBluetoothDevice(request) - return result === true || (result && result.ok === true) + return audioRouteResult( + false, "unavailable", "Bluetooth audio route is unavailable", "", 0) + return normalizeAudioRouteResult( + audioRoute.routeBluetoothDevice(request)) } function scheduleAudioOutputSwitch(device) { @@ -372,7 +392,15 @@ Item { function switchPendingAudioOutput() { if (!validatePendingAudioOutput()) return const device = nativeDeviceByAddress(pendingAudioOutputDevice.address) - if (requestBluetoothAudioRoute(device)) { + const routeResult = requestBluetoothAudioRoute(device) + if (routeResult.ok) { + pendingAudioOutputDevice = null + audioSwitchTimer.stop() + return + } + // Retry only transient route unavailability. Stale identity, invalid + // requests, and unsupported backends must not spin until the attempt cap. + if (routeResult.code !== "unavailable") { pendingAudioOutputDevice = null audioSwitchTimer.stop() return diff --git a/hancore.shibumi.bluetooth/BluetoothModel.js b/hancore.shibumi.bluetooth/BluetoothModel.js index 9f47a5d..16aa900 100644 --- a/hancore.shibumi.bluetooth/BluetoothModel.js +++ b/hancore.shibumi.bluetooth/BluetoothModel.js @@ -92,12 +92,15 @@ function nodeText(node) { function bluetoothSinkMatchesDevice(node, device) { if (!node || !node.isSink || node.isStream || !device) return false - var address = normalizedAddress(device.address) + var rawAddress = String(device.address || "").trim() + var address = normalizedAddress(rawAddress) var text = nodeText(node) // A supplied Bluetooth address is authoritative. Never route to a same-name - // sink when its address is absent or different. - if (address !== "") + // sink when its address is absent, invalid, or different. + if (rawAddress !== "") { + if (!isAddressLike(rawAddress)) return false return normalizedAddress(text).indexOf(address) !== -1 + } var label = deviceLabel(device).toLowerCase() return label !== "" && text.indexOf(label) !== -1 diff --git a/tests/audio-native-backend-seam-regression.qml b/tests/audio-native-backend-seam-regression.qml index bf39e67..3516e1e 100644 --- a/tests/audio-native-backend-seam-regression.qml +++ b/tests/audio-native-backend-seam-regression.qml @@ -111,12 +111,25 @@ ShellRoot { function setDefaultSource(node) { defaultAudioSource = node } } + QtObject { + id: incompleteBackend + property bool ready: true + property var nodes: [sinkA] + property var defaultAudioSink: sinkA + } + Audio.AudioBackendAdapter { id: backend active: true backendOverride: fakeBackend } + Audio.AudioBackendAdapter { + id: incompleteBackendAdapter + active: true + backendOverride: incompleteBackend + } + Audio.Service { id: service nativeBackendEnabled: true @@ -136,7 +149,10 @@ ShellRoot { || backend.audioSources.length !== 1 || backend.audioStreams.length !== 1) return root.fail("native backend snapshots") - if (backend.currentNodes !== undefined + const incompleteResult = + incompleteBackendAdapter.setDefaultSink("sink:1") + if (incompleteResult.ok || incompleteResult.code !== "unsupported" + || backend.currentNodes !== undefined || backend.currentSink !== undefined || backend.currentSource !== undefined || backend.filterSinks !== undefined @@ -166,10 +182,16 @@ ShellRoot { name: "Headset Output", deviceName: "Headset Output" }) + const invalidAddressResult = backend.routeBluetoothDevice({ + address: "ZZ", + name: "Headset Output", + deviceName: "Headset Output" + }) if (!routeResult.ok || routeResult.entityId !== "sink:2" || fakeBackend.defaultAudioSink !== sinkB || missingIdResult.ok || missingIdResult.code !== "stale-id" - || wrongAddressResult.ok || wrongAddressResult.code !== "stale-id") + || wrongAddressResult.ok || wrongAddressResult.code !== "stale-id" + || invalidAddressResult.ok || invalidAddressResult.code !== "stale-id") return root.fail("Bluetooth route resolution") root.phase++ root.ticks = 0 diff --git a/tests/bluetooth-audio-intent-regression.qml b/tests/bluetooth-audio-intent-regression.qml index c7d48a4..54fba77 100644 --- a/tests/bluetooth-audio-intent-regression.qml +++ b/tests/bluetooth-audio-intent-regression.qml @@ -96,6 +96,10 @@ ShellRoot { function setDefaultSink(sink) { count++; lastSink = sink } } + QtObject { + id: incompleteAudioOutput + } + QtObject { id: routeOverride property int count: 0 @@ -144,14 +148,25 @@ ShellRoot { sinkA.ready = false const unavailableRoute = backend.requestBluetoothAudioRoute(deviceA) sinkA.ready = true - if (!seamBackend.requestBluetoothAudioRoute(deviceA) + const invalidAddressRoute = backend.requestBluetoothAudioRoute({ + address: "ZZ", + name: "Intent A", + deviceName: "Intent A" + }) + backend.audioOutputOverride = incompleteAudioOutput + const unsupportedRoute = backend.requestBluetoothAudioRoute(deviceA) + backend.audioOutputOverride = audioOutput + if (!seamBackend.requestBluetoothAudioRoute(deviceA).ok || routeOverride.count !== 1 || routeOverride.lastRequest.address !== deviceA.address || routeOverride.lastRequest.name !== deviceA.name || "adapter" in routeOverride.lastRequest || "connected" in routeOverride.lastRequest - || backend.requestBluetoothAudioRoute(deviceWrongAddress) - || unavailableRoute) + || backend.requestBluetoothAudioRoute(deviceWrongAddress).ok + || invalidAddressRoute.ok + || unsupportedRoute.ok + || unsupportedRoute.code !== "unsupported" + || unavailableRoute.ok) return root.fail("Bluetooth route seam leaked, misrouted, or mutated an unavailable sink") if (!backend.connectDevice(deviceA) || !backend.connectDevice(deviceB)) return root.fail("could not create ordered connect intents") From 812cc21c073f1520320a41e994917d1b7f8962b4 Mon Sep 17 00:00:00 2001 From: HANCORE-linux <230438592+HANCORE-linux@users.noreply.github.com> Date: Sat, 22 Aug 2026 16:42:28 +0200 Subject: [PATCH 07/18] fix: harden bluetooth delegate and audio ids --- hancore.shibumi.audio/AudioBackendAdapter.qml | 22 +++++- hancore.shibumi.audio/AudioModel.js | 7 +- .../BluetoothAudioRouteAdapter.qml | 17 +++-- .../audio-native-backend-seam-regression.qml | 24 ++++++- tests/bluetooth-audio-intent-regression.qml | 68 +++++++++++++++++-- tests/bluetooth-backend-regression.qml | 17 ++++- 6 files changed, 137 insertions(+), 18 deletions(-) diff --git a/hancore.shibumi.audio/AudioBackendAdapter.qml b/hancore.shibumi.audio/AudioBackendAdapter.qml index f7d64e8..d65012c 100644 --- a/hancore.shibumi.audio/AudioBackendAdapter.qml +++ b/hancore.shibumi.audio/AudioBackendAdapter.qml @@ -174,6 +174,19 @@ Item { ok, code, message, entityId, root.generation) } + function backendReady() { + return root.active && (root.backendOverride === null + || backendValue("ready", true) !== false) + } + + function unresolvedId(id, label) { + return result( + false, + backendReady() ? "stale-id" : "unavailable", + label + " is unavailable", + id) + } + function mutationError(node, kind, label) { if (!node || node.ready === false) return result(false, "unavailable", label + " is unavailable") @@ -201,11 +214,13 @@ Item { function setDefaultSink(id) { const node = resolveNode(id, filterSinks(currentNodes()), "sink") + if (!node) return unresolvedId(id, "Audio sink") return setNodeDefault(node, "sink", "setDefaultSink") } function setDefaultSource(id) { const node = resolveNode(id, filterSources(currentNodes()), "source") + if (!node) return unresolvedId(id, "Audio source") return setNodeDefault(node, "source", "setDefaultSource") } @@ -251,6 +266,7 @@ Item { function setStreamVolume(id, value) { const node = resolveNode(id, filterStreams(currentNodes()), "stream") + if (!node) return unresolvedId(id, "Audio stream") const failure = mutationError(node, "stream", "Audio stream") if (failure) return failure if (!node.audio) @@ -261,6 +277,7 @@ Item { function toggleStreamMute(id) { const node = resolveNode(id, filterStreams(currentNodes()), "stream") + if (!node) return unresolvedId(id, "Audio stream") const failure = mutationError(node, "stream", "Audio stream") if (failure) return failure if (!node.audio) @@ -278,7 +295,10 @@ Item { if (!Model.bluetoothSinkMatchesRequest(node, request)) continue return setNodeDefault(node, "sink", "setDefaultSink") } - return result(false, "stale-id", "Bluetooth audio sink is unavailable") + return result( + false, + backendReady() ? "stale-id" : "unavailable", + "Bluetooth audio sink is unavailable") } } } diff --git a/hancore.shibumi.audio/AudioModel.js b/hancore.shibumi.audio/AudioModel.js index eba2379..daf421a 100644 --- a/hancore.shibumi.audio/AudioModel.js +++ b/hancore.shibumi.audio/AudioModel.js @@ -8,9 +8,10 @@ function nodeProperties(node) { } function stableNodeId(node, kind) { - if (!node) return "" - if (node.id === undefined || node.id === null) return "" - return String(kind || "node") + ":" + String(node.id) + if (!node || node.id === undefined || node.id === null) return "" + var id = String(node.id).trim() + if (!/^[0-9]+$/.test(id)) return "" + return String(kind || "node") + ":" + id } function nodeLabel(node) { diff --git a/hancore.shibumi.bluetooth/BluetoothAudioRouteAdapter.qml b/hancore.shibumi.bluetooth/BluetoothAudioRouteAdapter.qml index abb629a..38629c1 100644 --- a/hancore.shibumi.bluetooth/BluetoothAudioRouteAdapter.qml +++ b/hancore.shibumi.bluetooth/BluetoothAudioRouteAdapter.qml @@ -51,16 +51,23 @@ Item { function setDefaultSink(sink) { if (!sink || sink.ready === false) return actionResult(false, "unavailable", "Bluetooth audio sink is unavailable") - const entityId = sink.id === undefined || sink.id === null - ? "" : "sink:" + String(sink.id) - if (!entityId) + const rawId = sink.id === undefined || sink.id === null + ? "" : String(sink.id).trim() + if (!/^[0-9]+$/.test(rawId)) return actionResult(false, "stale-id", "Bluetooth audio sink is unavailable") + const entityId = "sink:" + rawId if (outputOverride !== null) { if (typeof outputOverride.setDefaultSink !== "function") return actionResult( false, "unsupported", "Audio backend action is unavailable", entityId) - outputOverride.setDefaultSink(sink) - return actionResult(true, "ok", "", entityId) + const delegated = outputOverride.setDefaultSink(entityId) + if (delegated && typeof delegated === "object" + && typeof delegated.ok === "boolean") + return delegated + if (delegated === true) + return actionResult(true, "ok", "", entityId) + return actionResult( + false, "unavailable", "Audio backend action failed", entityId) } Pipewire.preferredDefaultAudioSink = sink Quickshell.execDetached([ diff --git a/tests/audio-native-backend-seam-regression.qml b/tests/audio-native-backend-seam-regression.qml index 3516e1e..cbabc00 100644 --- a/tests/audio-native-backend-seam-regression.qml +++ b/tests/audio-native-backend-seam-regression.qml @@ -72,6 +72,18 @@ ShellRoot { property var properties: ({ "api.bluez5.address": "BB:00:00:00:00:02" }) } + QtObject { + id: sinkWithEmptyId + property string id: "" + property bool ready: true + property bool isSink: true + property bool isStream: false + property string name: "bluez_output.DD_00_00_00_00_04.a2dp-sink" + property string description: "Empty ID Headset" + property var audio: sinkAudioA + property var properties: ({ "api.bluez5.address": "DD:00:00:00:00:04" }) + } + QtObject { id: source property int id: 3 @@ -100,7 +112,7 @@ ShellRoot { QtObject { id: fakeBackend property bool ready: true - property var nodes: [sinkA, sinkB, sinkWithoutId, source, stream] + property var nodes: [sinkA, sinkB, sinkWithoutId, sinkWithEmptyId, source, stream] property var defaultAudioSink: sinkA property var defaultAudioSource: source property int sinkChanges: 0 @@ -187,11 +199,17 @@ ShellRoot { name: "Headset Output", deviceName: "Headset Output" }) + const emptyIdResult = backend.routeBluetoothDevice({ + address: "DD:00:00:00:00:04", + name: "Empty ID Headset", + deviceName: "Empty ID Headset" + }) if (!routeResult.ok || routeResult.entityId !== "sink:2" || fakeBackend.defaultAudioSink !== sinkB || missingIdResult.ok || missingIdResult.code !== "stale-id" || wrongAddressResult.ok || wrongAddressResult.code !== "stale-id" - || invalidAddressResult.ok || invalidAddressResult.code !== "stale-id") + || invalidAddressResult.ok || invalidAddressResult.code !== "stale-id" + || emptyIdResult.ok || emptyIdResult.code !== "stale-id") return root.fail("Bluetooth route resolution") root.phase++ root.ticks = 0 @@ -224,9 +242,11 @@ ShellRoot { const unavailableSource = backend.setInputVolume(0.12) const unavailableStream = backend.setStreamVolume("stream:8", 0.12) const unavailableRoute = backend.setDefaultSink("sink:2") + const staleIdResult = backend.setDefaultSink("sink:999") if (unavailableOutput.ok || unavailableOutput.code !== "unavailable" || unavailableMute.ok || unavailableSource.ok || unavailableStream.ok || unavailableRoute.ok + || staleIdResult.ok || staleIdResult.code !== "stale-id" || sinkAudioB.volume !== sinkVolume || sinkAudioB.muted !== sinkMuted || streamAudio.volume !== streamVolume diff --git a/tests/bluetooth-audio-intent-regression.qml b/tests/bluetooth-audio-intent-regression.qml index 54fba77..c5092d1 100644 --- a/tests/bluetooth-audio-intent-regression.qml +++ b/tests/bluetooth-audio-intent-regression.qml @@ -70,6 +70,19 @@ ShellRoot { property var properties: ({ "api.bluez5.address": deviceA.address }) } + QtObject { + id: malformedSinkId + property bool isSink: true + property bool isStream: false + property string id: "abc" + property bool ready: true + property string name: "bluez_output.DD_00_00_00_00_04.a2dp-sink" + property string description: "Malformed ID" + property string nickname: "" + property string nick: "" + property var properties: ({ "api.bluez5.address": "DD:00:00:00:00:04" }) + } + QtObject { id: sinkB property bool isSink: true @@ -92,14 +105,42 @@ ShellRoot { QtObject { id: audioOutput property int count: 0 - property var lastSink: null - function setDefaultSink(sink) { count++; lastSink = sink } + property string lastEntityId: "" + function setDefaultSink(entityId) { + count++ + lastEntityId = String(entityId) + return { + ok: true, + code: "ok", + message: "", + entityId: lastEntityId, + generation: 0 + } + } } QtObject { id: incompleteAudioOutput } + QtObject { + id: typedFailingAudioOutput + function setDefaultSink(_entityId) { + return { + ok: false, + code: "unavailable", + message: "fixture delegate failure", + entityId: "sink:801", + generation: 7 + } + } + } + + QtObject { + id: booleanFailingAudioOutput + function setDefaultSink(_entityId) { return false } + } + QtObject { id: routeOverride property int count: 0 @@ -129,7 +170,7 @@ ShellRoot { id: backend adapterOverride: nativeAdapter nativeDevicesOverride: [deviceA, deviceB] - pipewireNodesOverride: [sinkA, sinkB] + pipewireNodesOverride: [sinkA, sinkB, malformedSinkId] commandRunnerOverride: commandRunner audioOutputOverride: audioOutput audioSwitchInterval: 20 @@ -153,8 +194,17 @@ ShellRoot { name: "Intent A", deviceName: "Intent A" }) + const malformedIdRoute = backend.requestBluetoothAudioRoute({ + address: "DD:00:00:00:00:04", + name: "Malformed ID", + deviceName: "Malformed ID" + }) backend.audioOutputOverride = incompleteAudioOutput const unsupportedRoute = backend.requestBluetoothAudioRoute(deviceA) + backend.audioOutputOverride = typedFailingAudioOutput + const typedFailure = backend.requestBluetoothAudioRoute(deviceA) + backend.audioOutputOverride = booleanFailingAudioOutput + const booleanFailure = backend.requestBluetoothAudioRoute(deviceA) backend.audioOutputOverride = audioOutput if (!seamBackend.requestBluetoothAudioRoute(deviceA).ok || routeOverride.count !== 1 @@ -164,8 +214,17 @@ ShellRoot { || "connected" in routeOverride.lastRequest || backend.requestBluetoothAudioRoute(deviceWrongAddress).ok || invalidAddressRoute.ok + || malformedIdRoute.ok + || malformedIdRoute.code !== "stale-id" || unsupportedRoute.ok || unsupportedRoute.code !== "unsupported" + || typedFailure.ok + || typedFailure.code !== "unavailable" + || typedFailure.message !== "fixture delegate failure" + || typedFailure.entityId !== "sink:801" + || typedFailure.generation !== 7 + || booleanFailure.ok + || booleanFailure.code !== "unavailable" || unavailableRoute.ok) return root.fail("Bluetooth route seam leaked, misrouted, or mutated an unavailable sink") if (!backend.connectDevice(deviceA) || !backend.connectDevice(deviceB)) @@ -183,7 +242,8 @@ ShellRoot { root.ticks = 0 } else if (root.phase === 2) { if (root.ticks < 4) return - if (audioOutput.count !== 1 || audioOutput.lastSink !== sinkB) + if (audioOutput.count !== 1 + || audioOutput.lastEntityId !== "sink:802") return root.fail("latest intent B did not exclusively hand off audio") deviceB.connected = false if (!backend.connectDevice(deviceB)) diff --git a/tests/bluetooth-backend-regression.qml b/tests/bluetooth-backend-regression.qml index bd68561..547bb6d 100644 --- a/tests/bluetooth-backend-regression.qml +++ b/tests/bluetooth-backend-regression.qml @@ -80,8 +80,18 @@ ShellRoot { QtObject { id: audioOutput property int count: 0 - property var lastSink: null - function setDefaultSink(sink) { count++; lastSink = sink } + property string lastEntityId: "" + function setDefaultSink(entityId) { + count++ + lastEntityId = String(entityId) + return { + ok: true, + code: "ok", + message: "", + entityId: lastEntityId, + generation: 0 + } + } } QtObject { @@ -217,7 +227,8 @@ ShellRoot { root.ticks = 0 } else if (root.phase === 1) { if (root.ticks < 25) return - if (audioOutput.count !== 1 || audioOutput.lastSink !== audioSink) + if (audioOutput.count !== 1 + || audioOutput.lastEntityId !== "sink:771") return root.fail("late connection lost its audio intent") if (!discoveryService.discovering || discoveryFixture.discoveryStartAttempts < 2) From 409b6edf11a58eae230289c416d16d60e0de2091 Mon Sep 17 00:00:00 2001 From: HANCORE-linux <230438592+HANCORE-linux@users.noreply.github.com> Date: Sat, 22 Aug 2026 17:53:30 +0200 Subject: [PATCH 08/18] fix: gate audio routing on backend readiness --- hancore.shibumi.audio/AudioBackendAdapter.qml | 15 ++++++---- .../BluetoothAudioRouteAdapter.qml | 9 ++++++ .../audio-native-backend-seam-regression.qml | 17 +++++++++++ tests/bluetooth-audio-intent-regression.qml | 28 +++++++++++++++++++ 4 files changed, 64 insertions(+), 5 deletions(-) diff --git a/hancore.shibumi.audio/AudioBackendAdapter.qml b/hancore.shibumi.audio/AudioBackendAdapter.qml index d65012c..cd6e885 100644 --- a/hancore.shibumi.audio/AudioBackendAdapter.qml +++ b/hancore.shibumi.audio/AudioBackendAdapter.qml @@ -77,8 +77,7 @@ Item { } function ready() { - return root.active && (root.backendOverride === null - || backendValue("ready", true) !== false) + return backendReady() } function outputVolume() { @@ -174,9 +173,15 @@ Item { ok, code, message, entityId, root.generation) } + function nativeBackendReady() { + return Pipewire.nodes !== null && Pipewire.nodes !== undefined + } + function backendReady() { - return root.active && (root.backendOverride === null - || backendValue("ready", true) !== false) + if (!root.active) return false + return root.backendOverride !== null + ? backendValue("ready", true) !== false + : nativeBackendReady() } function unresolvedId(id, label) { @@ -188,7 +193,7 @@ Item { } function mutationError(node, kind, label) { - if (!node || node.ready === false) + if (!backendReady() || !node || node.ready === false) return result(false, "unavailable", label + " is unavailable") const entityId = Model.stableNodeId(node, kind) if (!entityId) diff --git a/hancore.shibumi.bluetooth/BluetoothAudioRouteAdapter.qml b/hancore.shibumi.bluetooth/BluetoothAudioRouteAdapter.qml index 38629c1..8f8e06c 100644 --- a/hancore.shibumi.bluetooth/BluetoothAudioRouteAdapter.qml +++ b/hancore.shibumi.bluetooth/BluetoothAudioRouteAdapter.qml @@ -15,13 +15,18 @@ Item { // Tests inject a side-effect-free node list and route sink. property var nodesOverride: null property var outputOverride: null + property var readyOverride: null + readonly property bool ready: readyOverride !== null + ? readyOverride === true + : Pipewire.nodes !== null && Pipewire.nodes !== undefined readonly property var nodes: nodesOverride !== null ? nodesOverride : (Pipewire.nodes ? Pipewire.nodes.values : []) function audioSinks() { const sinks = [] + if (!ready) return sinks for (let i = 0; i < nodes.length; i++) { const node = nodes[i] if (node && node.isSink && !node.isStream) sinks.push(node) @@ -49,6 +54,8 @@ Item { } function setDefaultSink(sink) { + if (!ready) + return actionResult(false, "unavailable", "Bluetooth audio is unavailable") if (!sink || sink.ready === false) return actionResult(false, "unavailable", "Bluetooth audio sink is unavailable") const rawId = sink.id === undefined || sink.id === null @@ -81,6 +88,8 @@ Item { // Narrow cross-capability method. The request contains only stable device // identity and labels; PipeWire objects never cross back into Bluetooth. function routeBluetoothDevice(request) { + if (!ready) + return actionResult(false, "unavailable", "Bluetooth audio is unavailable") const sink = sinkForDevice(request) return sink ? setDefaultSink(sink) diff --git a/tests/audio-native-backend-seam-regression.qml b/tests/audio-native-backend-seam-regression.qml index cbabc00..00e4897 100644 --- a/tests/audio-native-backend-seam-regression.qml +++ b/tests/audio-native-backend-seam-regression.qml @@ -234,6 +234,23 @@ ShellRoot { const sinkMuted = sinkAudioB.muted const streamVolume = streamAudio.volume const streamMuted = streamAudio.muted + fakeBackend.ready = false + const cachedUnavailableOutput = backend.setOutputVolume(0.12) + const cachedUnavailableMute = backend.toggleOutputMute() + const cachedUnavailableSource = backend.setInputVolume(0.12) + const cachedUnavailableStream = backend.setStreamVolume("stream:8", 0.12) + const cachedUnavailableRoute = backend.setDefaultSink("sink:2") + fakeBackend.ready = true + if (cachedUnavailableOutput.ok + || cachedUnavailableMute.ok + || cachedUnavailableSource.ok + || cachedUnavailableStream.ok + || cachedUnavailableRoute.ok + || sinkAudioB.volume !== sinkVolume + || sinkAudioB.muted !== sinkMuted + || streamAudio.volume !== streamVolume + || streamAudio.muted !== streamMuted) + return root.fail("cached nodes were mutated while backend was unavailable") sinkB.ready = false source.ready = false stream.ready = false diff --git a/tests/bluetooth-audio-intent-regression.qml b/tests/bluetooth-audio-intent-regression.qml index c5092d1..8644c7d 100644 --- a/tests/bluetooth-audio-intent-regression.qml +++ b/tests/bluetooth-audio-intent-regression.qml @@ -166,6 +166,18 @@ ShellRoot { audioRouteOverride: routeOverride } + Bluetooth.BluetoothAudioRouteAdapter { + id: pipewireNotReadyRoute + nodesOverride: [] + readyOverride: false + } + + Bluetooth.BluetoothAudioRouteAdapter { + id: pipewireReadyEmptyRoute + nodesOverride: [] + readyOverride: true + } + Bluetooth.BluetoothBackendAdapter { id: backend adapterOverride: nativeAdapter @@ -186,6 +198,18 @@ ShellRoot { if (root.phase === 0) { if (root.ticks < 2) return + const unavailablePipewireRoute = + pipewireNotReadyRoute.routeBluetoothDevice({ + address: deviceA.address, + name: deviceA.name, + deviceName: deviceA.deviceName + }) + const staleEmptyPipewireRoute = + pipewireReadyEmptyRoute.routeBluetoothDevice({ + address: deviceA.address, + name: deviceA.name, + deviceName: deviceA.deviceName + }) sinkA.ready = false const unavailableRoute = backend.requestBluetoothAudioRoute(deviceA) sinkA.ready = true @@ -207,6 +231,10 @@ ShellRoot { const booleanFailure = backend.requestBluetoothAudioRoute(deviceA) backend.audioOutputOverride = audioOutput if (!seamBackend.requestBluetoothAudioRoute(deviceA).ok + || unavailablePipewireRoute.ok + || unavailablePipewireRoute.code !== "unavailable" + || staleEmptyPipewireRoute.ok + || staleEmptyPipewireRoute.code !== "stale-id" || routeOverride.count !== 1 || routeOverride.lastRequest.address !== deviceA.address || routeOverride.lastRequest.name !== deviceA.name From 908292f132a6bf16e5fbb980fa7ac632712ec8df Mon Sep 17 00:00:00 2001 From: HANCORE-linux <230438592+HANCORE-linux@users.noreply.github.com> Date: Sat, 22 Aug 2026 18:37:37 +0200 Subject: [PATCH 09/18] fix: use PipeWire readiness state --- hancore.shibumi.audio/AudioBackendAdapter.qml | 3 ++- .../BluetoothAudioRouteAdapter.qml | 8 +++++++- tests/bluetooth-audio-intent-regression.qml | 14 ++++++++++++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/hancore.shibumi.audio/AudioBackendAdapter.qml b/hancore.shibumi.audio/AudioBackendAdapter.qml index cd6e885..7af5623 100644 --- a/hancore.shibumi.audio/AudioBackendAdapter.qml +++ b/hancore.shibumi.audio/AudioBackendAdapter.qml @@ -174,7 +174,8 @@ Item { } function nativeBackendReady() { - return Pipewire.nodes !== null && Pipewire.nodes !== undefined + return Pipewire.ready === true + && Pipewire.nodes !== null && Pipewire.nodes !== undefined } function backendReady() { diff --git a/hancore.shibumi.bluetooth/BluetoothAudioRouteAdapter.qml b/hancore.shibumi.bluetooth/BluetoothAudioRouteAdapter.qml index 8f8e06c..c17a685 100644 --- a/hancore.shibumi.bluetooth/BluetoothAudioRouteAdapter.qml +++ b/hancore.shibumi.bluetooth/BluetoothAudioRouteAdapter.qml @@ -19,7 +19,10 @@ Item { readonly property bool ready: readyOverride !== null ? readyOverride === true - : Pipewire.nodes !== null && Pipewire.nodes !== undefined + : nodesOverride !== null + ? true + : Pipewire.ready === true + && Pipewire.nodes !== null && Pipewire.nodes !== undefined readonly property var nodes: nodesOverride !== null ? nodesOverride : (Pipewire.nodes ? Pipewire.nodes.values : []) @@ -76,6 +79,9 @@ Item { return actionResult( false, "unavailable", "Audio backend action failed", entityId) } + if (nodesOverride !== null) + return actionResult( + false, "unsupported", "Fake audio backend action is unavailable", entityId) Pipewire.preferredDefaultAudioSink = sink Quickshell.execDetached([ "omarchy-audio-output-set-default", diff --git a/tests/bluetooth-audio-intent-regression.qml b/tests/bluetooth-audio-intent-regression.qml index 8644c7d..53ec2f7 100644 --- a/tests/bluetooth-audio-intent-regression.qml +++ b/tests/bluetooth-audio-intent-regression.qml @@ -178,6 +178,12 @@ ShellRoot { readyOverride: true } + Bluetooth.BluetoothAudioRouteAdapter { + id: fakeRouteWithoutDelegate + nodesOverride: [sinkA] + readyOverride: true + } + Bluetooth.BluetoothBackendAdapter { id: backend adapterOverride: nativeAdapter @@ -210,6 +216,12 @@ ShellRoot { name: deviceA.name, deviceName: deviceA.deviceName }) + const fakeIsolationRoute = + fakeRouteWithoutDelegate.routeBluetoothDevice({ + address: deviceA.address, + name: deviceA.name, + deviceName: deviceA.deviceName + }) sinkA.ready = false const unavailableRoute = backend.requestBluetoothAudioRoute(deviceA) sinkA.ready = true @@ -235,6 +247,8 @@ ShellRoot { || unavailablePipewireRoute.code !== "unavailable" || staleEmptyPipewireRoute.ok || staleEmptyPipewireRoute.code !== "stale-id" + || fakeIsolationRoute.ok + || fakeIsolationRoute.code !== "unsupported" || routeOverride.count !== 1 || routeOverride.lastRequest.address !== deviceA.address || routeOverride.lastRequest.name !== deviceA.name From 247188caec1c306af166062c171e22bbd7cd32b8 Mon Sep 17 00:00:00 2001 From: HANCORE-linux <230438592+HANCORE-linux@users.noreply.github.com> Date: Sun, 23 Aug 2026 02:24:52 +0200 Subject: [PATCH 10/18] refactor: migrate audio panel to primitive backend seam --- ARCHITECTURE.md | 16 +- contracts/backend-boundary-v1.json | 10 +- hancore.shibumi.audio/AudioBackendAdapter.qml | 510 +++++++++++++++- hancore.shibumi.audio/AudioModel.js | 213 ++++++- hancore.shibumi.audio/AudioPanel.qml | 70 ++- hancore.shibumi.audio/AudioPanelBridge.qml | 551 +++++++++++++----- hancore.shibumi.audio/AudioPeakMonitor.qml | 33 ++ hancore.shibumi.audio/BarWidget.qml | 25 +- hancore.shibumi.audio/Service.qml | 112 +++- hancore.shibumi.bluetooth/BluetoothModel.js | 10 +- tests/audio-media-plugins-regression.sh | 21 +- .../audio-native-backend-seam-regression.qml | 46 +- .../audio-network-ipc-contract-regression.sh | 15 +- tests/audio-plugin-smoke.qml | 65 ++- tests/contract-regression.sh | 20 +- tests/fixtures/AudioTestPanel.qml | 23 + tests/fixtures/AudioTestView.qml | 20 +- 17 files changed, 1468 insertions(+), 292 deletions(-) create mode 100644 hancore.shibumi.audio/AudioPeakMonitor.qml diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ee3811e..4fdadf6 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -644,13 +644,15 @@ Current Phase 2 foundation: Shibumi full/compact widget and lazy mixer panel own the visible V1 presentation, while every volume, mute, device, and stream mutation delegates to that same official instance. It does not create or trigger a second OSD; -- the bridge contains no PipeWire import, process, timer, or file watcher. The - local mixer snapshots exposed lists through one 75 ms settle timer and owns - one `PwNodePeakMonitor` only while open; the official stock panel and its - workers remain closed. Component regressions cover missing-backend behavior, - settings, screen-aware alias routing, action forwarding, unique click - registration, model release, and teardown. Real Wayland panel mapping remains - an acceptance gate. +- during the Step 4A transition, the bridge reads the authoritative + `Pipewire.ready` signal and owns one bounded `PwNodePeakMonitor` only while + the visible mixer is open; it exposes primitive snapshots and typed stable-ID + actions while the official stock panel and its workers remain closed. The + native `AudioBackendAdapter` provides the replacement primitive peak seam and + is lifecycle-gated for activation. Component regressions cover missing-backend + behavior, readiness, settings, screen-aware alias routing, action forwarding, + unique click registration, model release, and teardown. Real Wayland panel + mapping remains an acceptance gate. - G7 replaces the stock AI presentation with one selected-provider Shibumi pill and one lazy local panel. The process-wide `hancore.shibumi.ai` service consumes the primitive schema-v1 records produced by current `omarchy.agents`; it diff --git a/contracts/backend-boundary-v1.json b/contracts/backend-boundary-v1.json index 7ea88bb..5ec7a95 100644 --- a/contracts/backend-boundary-v1.json +++ b/contracts/backend-boundary-v1.json @@ -62,7 +62,10 @@ {"scope":"hostIdRule","target":"omarchy.wifiqr","category":"omarchyBackend","owner":"hancore.shibumi.network","capability":"network","count":3,"backend":"omarchy.network","removalStep":"Step 5"}, {"scope":"commandRule","value":"omarchy-agent-usage-","category":"omarchyBackend","owner":"hancore.shibumi.ai","capability":"ai-usage","count":1,"backend":"omarchy.agents","removalStep":"Step 8"}, {"scope":"commandRule","value":"omarchy-agent-usage-update","category":"omarchyBackend","owner":"hancore.shibumi.ai","capability":"ai-usage","count":1,"backend":"omarchy.agents","removalStep":"Step 8"}, - {"scope":"commandRule","value":"omarchy-audio-output-set-default","category":"omarchyBackend","owner":"hancore.shibumi.bluetooth","capability":"bluetooth","count":1,"backend":"omarchy.audio","removalStep":"Step 4A"}, + {"scope":"commandRule","value":"omarchy-audio-output-set-default","category":"omarchyBackend","owner":"hancore.shibumi.bluetooth","capability":"bluetooth","count":2,"backend":"omarchy.audio","removalStep":"Step 4A"}, + {"scope":"commandRule","value":"omarchy-audio-output-sink","category":"omarchyBackend","owner":"hancore.shibumi.audio","capability":"audio","count":1,"backend":"omarchy.audio","removalStep":"Step 4A"}, + {"scope":"commandRule","value":"omarchy-audio-input-set-default","category":"omarchyBackend","owner":"hancore.shibumi.audio","capability":"audio","count":1,"backend":"omarchy.audio","removalStep":"Step 4A"}, + {"scope":"commandRule","value":"omarchy-audio-sink-availability","category":"omarchyBackend","owner":"hancore.shibumi.audio","capability":"audio","count":1,"backend":"omarchy.audio","removalStep":"Step 4A"}, {"scope":"commandRule","value":"omarchy-battery-status","category":"omarchyBackend","owner":"hancore.shibumi.power-state","capability":"battery-ac","count":1,"backend":"omarchy.power","removalStep":"Step 6"}, {"scope":"commandRule","value":"omarchy-bluetooth-device","category":"omarchyBackend","owner":"hancore.shibumi.bluetooth","capability":"bluetooth","count":1,"backend":"omarchy.bluetooth","removalStep":"Step 4B"}, {"scope":"commandRule","value":"omarchy-capture-screenrecording","category":"omarchyBackend","owner":"hancore.shibumi.status","capability":"recording-voxtype","count":1,"backend":"omarchy.recording","removalStep":"Step 8"}, @@ -394,7 +397,10 @@ "commandRules": [ {"value": "omarchy-agent-usage-", "count": 1, "category": "omarchyBackend", "owner": "hancore.shibumi.ai", "capability": "ai-usage", "backend": "omarchy.agents", "removalStep": "Step 8", "reason": "Remove the host Agents collector command after the Shibumi provider adapter is authoritative."}, {"value": "omarchy-agent-usage-update", "count": 1, "category": "omarchyBackend", "owner": "hancore.shibumi.ai", "capability": "ai-usage", "backend": "omarchy.agents", "removalStep": "Step 8", "reason": "Remove the host Agents update command after the Shibumi provider adapter is authoritative."}, - {"value": "omarchy-audio-output-set-default", "count": 1, "category": "omarchyBackend", "owner": "hancore.shibumi.bluetooth", "capability": "bluetooth", "backend": "omarchy.audio", "removalStep": "Step 4A", "reason": "Bluetooth must request route selection from the sole Shibumi Audio owner."}, + {"value": "omarchy-audio-output-set-default", "count": 2, "category": "omarchyBackend", "owner": "hancore.shibumi.bluetooth", "capability": "bluetooth", "backend": "omarchy.audio", "removalStep": "Step 4A", "reason": "The transitional Bluetooth route and native Audio seam both use this helper until the atomic cutover removes the former."}, + {"value": "omarchy-audio-input-set-default", "count": 1, "category": "omarchyBackend", "owner": "hancore.shibumi.audio", "capability": "audio", "backend": "omarchy.audio", "removalStep": "Step 4A", "reason": "Native Audio must preserve the official input-route handoff until the atomic cutover is complete."}, + {"value": "omarchy-audio-sink-availability", "count": 1, "category": "omarchyBackend", "owner": "hancore.shibumi.audio", "capability": "audio", "backend": "omarchy.audio", "removalStep": "Step 4A", "reason": "Native Audio must preserve the official output-availability filter until the atomic cutover is complete."}, + {"value": "omarchy-audio-output-sink", "count": 1, "category": "omarchyBackend", "owner": "hancore.shibumi.audio", "capability": "audio", "backend": "omarchy.audio", "removalStep": "Step 4A", "reason": "Resolve the effective physical output before native volume and mute mutations."}, {"value": "omarchy-battery-status", "count": 1, "category": "omarchyBackend", "owner": "hancore.shibumi.power-state", "capability": "battery-ac", "backend": "omarchy.power", "removalStep": "Step 6", "reason": "Remove the Omarchy battery helper after UPower is authoritative."}, {"value": "omarchy-bluetooth-device", "count": 1, "category": "omarchyBackend", "owner": "hancore.shibumi.bluetooth", "capability": "bluetooth", "backend": "omarchy.bluetooth", "removalStep": "Step 4B", "reason": "Remove ordinary Omarchy Bluetooth helper mutations after native BlueZ actions are complete."}, {"value": "omarchy-capture-screenrecording", "count": 1, "category": "omarchyBackend", "owner": "hancore.shibumi.status", "capability": "recording-voxtype", "backend": "omarchy.recording", "removalStep": "Step 8", "reason": "Move recording actions behind the typed Shibumi status service."}, diff --git a/hancore.shibumi.audio/AudioBackendAdapter.qml b/hancore.shibumi.audio/AudioBackendAdapter.qml index 7af5623..b195d44 100644 --- a/hancore.shibumi.audio/AudioBackendAdapter.qml +++ b/hancore.shibumi.audio/AudioBackendAdapter.qml @@ -2,6 +2,8 @@ pragma ComponentBehavior: Bound import QtQuick import Quickshell +import Quickshell.Io +import Quickshell.Services.Mpris import Quickshell.Services.Pipewire import "AudioModel.js" as Model @@ -14,10 +16,54 @@ Item { id: root property bool active: false + property int peakMonitoringClients: 0 + readonly property bool peakMonitoringEnabled: peakMonitoringClients > 0 property var backendOverride: null + property var mprisPlayersOverride: null + property string volumeSinkName: "" + property bool volumeSinkResolved: false + property bool volumeSinkResolutionFailed: false + property var sinkAvailability: ({}) + property bool sinkAvailabilityResolved: false + property int sinkAvailabilityRequestGeneration: 0 + property int sinkAvailabilityProcessGeneration: 0 + property bool sinkAvailabilityProcessStarted: false + property int volumeSinkRequestGeneration: 0 + property int volumeSinkProcessGeneration: 0 + property bool volumeSinkProcessStarted: false + property bool volumeSinkProcessDiscarded: false property int generation: 0 readonly property bool ready: implementation.ready() + + onActiveChanged: { + if (root.backendOverride === null) { + root.invalidateVolumeSink() + root.invalidateSinkAvailability() + } + } + onBackendOverrideChanged: { + if (root.backendOverride === null) { + root.invalidateVolumeSink() + root.invalidateSinkAvailability() + } else { + root.volumeSinkResolved = true + root.invalidateSinkAvailability() + } + } + + function invalidateSinkAvailability() { + sinkAvailability = ({}) + sinkAvailabilityRequestGeneration++ + sinkAvailabilityResolved = backendOverride !== null + } + + function invalidateVolumeSink() { + volumeSinkName = "" + volumeSinkRequestGeneration++ + volumeSinkResolutionFailed = false + volumeSinkResolved = backendOverride !== null + } readonly property real outputVolume: implementation.outputVolume() readonly property bool outputMuted: implementation.outputMuted() readonly property real inputVolume: implementation.inputVolume() @@ -26,10 +72,21 @@ Item { readonly property var audioSources: implementation.audioSources() readonly property var audioStreams: implementation.audioStreams() readonly property var sinkSnapshot: implementation.sinkSnapshot() + readonly property var volumeSinkSnapshot: implementation.volumeSinkSnapshot() readonly property var sourceSnapshot: implementation.sourceSnapshot() + readonly property real inputPeak: implementation.inputPeak() // Public capability surface: primitive snapshots and typed action results. // Raw PipeWire nodes remain reachable only through the implementation id. + function acquirePeakMonitoring() { + peakMonitoringClients++ + return true + } + function releasePeakMonitoring() { + peakMonitoringClients = Math.max(0, peakMonitoringClients - 1) + return true + } + function setDefaultSink(id) { return implementation.setDefaultSink(id) } function setDefaultSource(id) { return implementation.setDefaultSource(id) } function setOutputVolume(value) { return implementation.setOutputVolume(value) } @@ -42,6 +99,12 @@ Item { function toggleStreamMute(id) { return implementation.toggleStreamMute(id) } + function nodeLabel(snapshot) { + return snapshot && snapshot.label ? String(snapshot.label) : "Audio device" + } + function streamLabel(snapshot) { + return snapshot && snapshot.label ? String(snapshot.label) : "Application" + } function routeBluetoothDevice(request) { return implementation.routeBluetoothDevice(request) } @@ -76,33 +139,105 @@ Item { : Pipewire.defaultAudioSource } + function currentVolumeSink() { + if (root.backendOverride === null + && (root.volumeSinkResolutionFailed || !root.volumeSinkResolved)) + return null + const sink = implementation.currentSink() + if (!sink || sink.ready === false) return null + const configuredName = root.backendOverride !== null + ? String(backendValue("volumeSinkName", "")) + : String(root.volumeSinkName || "") + if (!configuredName || String(sink.name || "") === configuredName) + return sink + const nodes = implementation.currentNodes() + for (let index = 0; index < nodes.length; index++) { + const node = nodes[index] + if (node && node.isSink && !node.isStream + && String(node.name || "") === configuredName + && node.ready !== false && node.audio) + return node + } + return null + } + + function volumeSinkNameMapped() { + const configuredName = String(root.volumeSinkName || "") + if (!configuredName) return !root.volumeSinkResolutionFailed + const sink = implementation.currentSink() + if (!sink) return false + if (String(sink.name || "") === configuredName) return true + const nodes = implementation.currentNodes() + for (let index = 0; index < nodes.length; index++) { + const node = nodes[index] + if (node && node.isSink && !node.isStream + && String(node.name || "") === configuredName + && node.ready !== false && node.audio) return true + } + return false + } + + function currentVolumeNode() { + const sink = implementation.currentVolumeSink() + if (!sink) return null + return implementation.resolveNode(Model.stableNodeId(sink, "sink"), + implementation.allSinks(implementation.currentNodes()), "sink") + } + + function currentInputNode() { + const source = implementation.currentSource() + if (!source) return null + return implementation.resolveNode(Model.stableNodeId(source, "source"), + implementation.allSources(implementation.currentNodes()), "source") + } + + function monitorSource() { + const source = implementation.currentInputNode() + return source && source.ready !== false ? source : null + } + function ready() { return backendReady() } function outputVolume() { - const sink = currentSink() + if (!backendReady()) return 0 + const sink = implementation.currentVolumeNode() return sink && sink.audio && sink.audio.volume !== undefined ? Number(sink.audio.volume) : 0 } function outputMuted() { - const sink = currentSink() + if (!backendReady()) return false + const sink = implementation.currentVolumeNode() return sink && sink.audio && sink.audio.muted === true } function inputVolume() { - const source = currentSource() - return source && source.audio && source.audio.volume !== undefined + if (!backendReady()) return 0 + const source = implementation.currentInputNode() + if (!source || source.ready === false) return 0 + return source.audio && source.audio.volume !== undefined ? Number(source.audio.volume) : 0 } function inputMuted() { - const source = currentSource() - return source && source.audio && source.audio.muted === true + if (!backendReady()) return false + const source = implementation.currentInputNode() + if (!source || source.ready === false) return false + return source.audio && source.audio.muted === true } - function filterSinks(nodes) { + function inputPeak() { + if (!backendReady() || !root.peakMonitoringEnabled + || !monitorSource()) return 0 + if (root.backendOverride !== null + && root.backendOverride.inputPeak !== undefined) + return Number(root.backendOverride.inputPeak) || 0 + return Number(inputPeakMonitor.peak) || 0 + } + + function allSinks(nodes) { const result = [] for (let index = 0; index < nodes.length; index++) { const node = nodes[index] @@ -111,11 +246,52 @@ Item { return result } + function allSources(nodes) { + const result = [] + for (let index = 0; index < nodes.length; index++) { + const node = nodes[index] + if (node && String(node.name || "") !== "quickshell" + && !node.isSink && !node.isStream + && (node.ready === false || Model.isAudioSource(node))) + result.push(node) + } + return result + } + + function allStreams(nodes) { + const result = [] + for (let index = 0; index < nodes.length; index++) { + const node = nodes[index] + if (node && String(node.name || "").indexOf("omarchy_speaker_tuning") !== 0 + && Model.isPlaybackStream(node)) result.push(node) + } + return result + } + + function filterSinks(nodes) { + const result = [] + const selected = currentSink() + const selectedId = selected ? Model.stableNodeId(selected, "sink") : "" + for (let index = 0; index < nodes.length; index++) { + const node = nodes[index] + if (!node || !node.isSink || node.isStream) continue + const name = String(node.name || "") + const nodeId = Model.stableNodeId(node, "sink") + if (root.sinkAvailabilityResolved + && root.sinkAvailability[name] === false + && nodeId !== selectedId) continue + result.push(node) + } + return result + } + function filterSources(nodes) { const result = [] for (let index = 0; index < nodes.length; index++) { const node = nodes[index] - if (Model.isAudioSource(node)) result.push(node) + if (node && node.ready !== false + && String(node.name || "") !== "quickshell" + && Model.isAudioSource(node)) result.push(node) } return result } @@ -124,7 +300,9 @@ Item { const result = [] for (let index = 0; index < nodes.length; index++) { const node = nodes[index] - if (Model.isPlaybackStream(node) && node.audio) result.push(node) + if (node && node.ready !== false + && String(node.name || "").indexOf("omarchy_speaker_tuning") !== 0 + && Model.isPlaybackStream(node) && node.audio) result.push(node) } return result } @@ -139,24 +317,52 @@ Item { } function audioSinks() { - return snapshotList(filterSinks(currentNodes()), "sink", currentSink()) + const version = root.generation + void version + if (!backendReady()) return [] + const sinks = Model.groupedDeviceNodes(filterSinks(currentNodes())) + return snapshotList(sinks, "sink", currentSink()) } function audioSources() { + const version = root.generation + void version + if (!backendReady()) return [] return snapshotList( filterSources(currentNodes()), "source", currentSource()) } function audioStreams() { - return snapshotList(filterStreams(currentNodes()), "stream", null) + const version = root.generation + void version + if (!backendReady()) return [] + const streams = filterStreams(currentNodes()) + const players = root.mprisPlayersOverride !== null + ? root.mprisPlayersOverride + : root.backendOverride !== null + ? backendValue("mprisPlayers", []) + : (Mpris.players ? Mpris.players.values : []) + return snapshotList(streams, "stream", null, + function(node) { return Model.streamLabel(node, players, streams) }) } function sinkSnapshot() { - return Model.snapshot(currentSink(), "sink", true) + const version = root.generation + void version + return backendReady() ? Model.snapshot(currentSink(), "sink", true) : null + } + + function volumeSinkSnapshot() { + const version = root.generation + void version + return backendReady() + ? Model.snapshot(implementation.currentVolumeNode(), "sink", true) : null } function sourceSnapshot() { - return Model.snapshot(currentSource(), "source", true) + const version = root.generation + void version + return backendReady() ? Model.snapshot(currentSource(), "source", true) : null } function resolveNode(id, nodes, kind) { @@ -173,6 +379,25 @@ Item { ok, code, message, entityId, root.generation) } + function delegatedResult(value, entityId) { + if (value && typeof value === "object" + && typeof value.ok === "boolean") { + value.entityId = entityId + if (value.code === undefined) + value.code = value.ok ? "ok" : "unavailable" + if (value.message === undefined) value.message = "" + if (value.generation === undefined) value.generation = root.generation + return value + } + if (value === false) + return result(false, "unavailable", "Audio backend action failed", entityId) + return null + } + + function refreshedDelegatedSuccess(value, entityId) { + return result(true, value.code || "ok", value.message || "", entityId) + } + function nativeBackendReady() { return Pipewire.ready === true && Pipewire.nodes !== null && Pipewire.nodes !== undefined @@ -194,9 +419,9 @@ Item { } function mutationError(node, kind, label) { + const entityId = node ? Model.stableNodeId(node, kind) : "" if (!backendReady() || !node || node.ready === false) - return result(false, "unavailable", label + " is unavailable") - const entityId = Model.stableNodeId(node, kind) + return result(false, "unavailable", label + " is unavailable", entityId) if (!entityId) return result(false, "stale-id", label + " is unavailable") return null @@ -206,96 +431,128 @@ Item { const failure = mutationError(node, kind, "Audio node") if (failure) return failure const entityId = Model.stableNodeId(node, kind) + if (kind === "sink" && root.sinkAvailabilityResolved + && root.sinkAvailability[String(node.name || "")] === false) + return result(false, "unavailable", "Audio sink is unavailable", entityId) if (root.backendOverride !== null) { if (typeof root.backendOverride[propertyName] !== "function") return result(false, "unsupported", "Audio backend action is unavailable", entityId) - root.backendOverride[propertyName](node) + const delegated = delegatedResult( + root.backendOverride[propertyName](node), entityId) + if (delegated) { + if (!delegated.ok) return delegated + root.generation++ + return refreshedDelegatedSuccess(delegated, entityId) + } } else if (propertyName === "setDefaultSink") { Pipewire.preferredDefaultAudioSink = node + if (node.id !== undefined && node.name) { + Quickshell.execDetached([ + "omarchy-audio-output-set-default", + String(node.id), String(node.name) + ]) + } } else { Pipewire.preferredDefaultAudioSource = node + if (node.id !== undefined && node.name) { + Quickshell.execDetached([ + "omarchy-audio-input-set-default", + String(node.id), String(node.name) + ]) + } } + root.generation++ return result(true, "ok", "", entityId) } function setDefaultSink(id) { - const node = resolveNode(id, filterSinks(currentNodes()), "sink") + const node = resolveNode(id, allSinks(currentNodes()), "sink") if (!node) return unresolvedId(id, "Audio sink") return setNodeDefault(node, "sink", "setDefaultSink") } function setDefaultSource(id) { - const node = resolveNode(id, filterSources(currentNodes()), "source") + const node = resolveNode(id, allSources(currentNodes()), "source") if (!node) return unresolvedId(id, "Audio source") return setNodeDefault(node, "source", "setDefaultSource") } function setOutputVolume(value) { - const sink = currentSink() - if (!sink || !sink.audio) - return result(false, "unavailable", "Output is unavailable") + const sink = implementation.currentVolumeNode() + if (!sink || sink.ready === false || !sink.audio) + return result(false, "unavailable", "Output is unavailable", sink + ? Model.stableNodeId(sink, "sink") : "") const failure = mutationError(sink, "sink", "Output") if (failure) return failure sink.audio.volume = Math.max(0, Math.min(1, Number(value) || 0)) + root.generation++ return result(true, "ok", "", Model.stableNodeId(sink, "sink")) } function toggleOutputMute() { - const sink = currentSink() + const sink = implementation.currentVolumeNode() const failure = mutationError(sink, "sink", "Output") if (failure) return failure if (!sink.audio) - return result(false, "unavailable", "Output is unavailable") + return result(false, "unavailable", "Output is unavailable", + Model.stableNodeId(sink, "sink")) sink.audio.muted = !sink.audio.muted + root.generation++ return result(true, "ok", "", Model.stableNodeId(sink, "sink")) } function setInputVolume(value) { - const source = currentSource() - if (!source || !source.audio) - return result(false, "unavailable", "Input is unavailable") + const source = implementation.currentInputNode() + if (!source || source.ready === false || !source.audio) + return result(false, "unavailable", "Input is unavailable", + source ? Model.stableNodeId(source, "source") : "") const failure = mutationError(source, "source", "Input") if (failure) return failure source.audio.volume = Math.max(0, Math.min(1, Number(value) || 0)) + root.generation++ return result(true, "ok", "", Model.stableNodeId(source, "source")) } function toggleInputMute() { - const source = currentSource() + const source = implementation.currentInputNode() const failure = mutationError(source, "source", "Input") if (failure) return failure if (!source.audio) - return result(false, "unavailable", "Input is unavailable") + return result(false, "unavailable", "Input is unavailable", + Model.stableNodeId(source, "source")) source.audio.muted = !source.audio.muted + root.generation++ return result(true, "ok", "", Model.stableNodeId(source, "source")) } function setStreamVolume(id, value) { - const node = resolveNode(id, filterStreams(currentNodes()), "stream") + const node = resolveNode(id, allStreams(currentNodes()), "stream") if (!node) return unresolvedId(id, "Audio stream") const failure = mutationError(node, "stream", "Audio stream") if (failure) return failure if (!node.audio) return result(false, "unavailable", "Audio stream is unavailable", id) node.audio.volume = Math.max(0, Math.min(1.5, Number(value) || 0)) + root.generation++ return result(true, "ok", "", id) } function toggleStreamMute(id) { - const node = resolveNode(id, filterStreams(currentNodes()), "stream") + const node = resolveNode(id, allStreams(currentNodes()), "stream") if (!node) return unresolvedId(id, "Audio stream") const failure = mutationError(node, "stream", "Audio stream") if (failure) return failure if (!node.audio) return result(false, "unavailable", "Audio stream is unavailable", id) node.audio.muted = !node.audio.muted + root.generation++ return result(true, "ok", "", id) } // This is the only cross-capability route method Bluetooth needs. The // request is primitive-only; the current PipeWire target is resolved here. function routeBluetoothDevice(request) { - const sinks = filterSinks(currentNodes()) + const sinks = allSinks(currentNodes()) for (let index = 0; index < sinks.length; index++) { const node = sinks[index] if (!Model.bluetoothSinkMatchesRequest(node, request)) continue @@ -307,4 +564,195 @@ Item { "Bluetooth audio sink is unavailable") } } + + PwObjectTracker { + id: nativeObjectTracker + objects: root.active && root.backendOverride === null + ? (Pipewire.nodes ? Pipewire.nodes.values : []) : [] + } + + Process { + id: sinkAvailabilityProcess + command: ["omarchy-audio-sink-availability"] + running: root.active && root.backendOverride === null + && implementation.backendReady() && !root.sinkAvailabilityResolved + onStarted: { + root.sinkAvailabilityProcessStarted = true + root.sinkAvailabilityProcessGeneration = + root.sinkAvailabilityRequestGeneration + } + onRunningChanged: { + if (running) { + if (!root.sinkAvailabilityProcessStarted) { + root.sinkAvailabilityProcessStarted = true + root.sinkAvailabilityProcessGeneration = + root.sinkAvailabilityRequestGeneration + } + return + } + if (!root.active || root.backendOverride !== null + || !implementation.backendReady() + || root.sinkAvailabilityResolved) { + root.sinkAvailabilityProcessStarted = false + return + } + root.sinkAvailability = ({}) + root.sinkAvailabilityResolved = true + root.sinkAvailabilityProcessStarted = false + } + onExited: function(exitCode, _exitStatus) { + if (root.sinkAvailabilityProcessGeneration + !== root.sinkAvailabilityRequestGeneration + || !root.active || root.backendOverride !== null + || !implementation.backendReady()) { + root.sinkAvailabilityProcessStarted = false + root.sinkAvailabilityResolved = false + return + } + if (exitCode !== 0) { + root.sinkAvailability = ({}) + root.sinkAvailabilityResolved = true + } + root.sinkAvailabilityProcessStarted = false + } + stdout: StdioCollector { + waitForEnd: true + onStreamFinished: { + if (root.sinkAvailabilityProcessGeneration + !== root.sinkAvailabilityRequestGeneration) return + root.sinkAvailability = Model.parseSinkAvailability(text) + root.sinkAvailabilityResolved = true + } + } + } + + Timer { + interval: 5000 + repeat: true + running: root.active && root.backendOverride === null + onTriggered: { + if (!implementation.backendReady()) return + if (!sinkAvailabilityProcess.running) + root.invalidateSinkAvailability() + if (!volumeSinkProcess.running + && (!root.volumeSinkResolved + || root.volumeSinkResolutionFailed + || !implementation.volumeSinkNameMapped())) + root.invalidateVolumeSink() + } + } + + Process { + id: volumeSinkProcess + command: ["omarchy-audio-output-sink"] + running: root.active && root.backendOverride === null + && implementation.backendReady() && !root.volumeSinkResolved + onStarted: { + root.volumeSinkProcessStarted = true + root.volumeSinkProcessGeneration = root.volumeSinkRequestGeneration + } + onRunningChanged: { + if (running) { + if (!root.volumeSinkProcessStarted) { + root.volumeSinkProcessStarted = true + root.volumeSinkProcessGeneration = root.volumeSinkRequestGeneration + } + return + } + if (root.volumeSinkProcessDiscarded) { + root.volumeSinkProcessDiscarded = false + root.volumeSinkProcessStarted = false + root.volumeSinkResolved = false + return + } + if (!root.active || root.backendOverride !== null + || !implementation.backendReady() + || root.volumeSinkResolved) { + root.volumeSinkProcessStarted = false + return + } + root.volumeSinkName = "" + root.volumeSinkResolutionFailed = true + root.volumeSinkResolved = true + root.volumeSinkProcessStarted = false + } + onExited: function(exitCode, _exitStatus) { + if (root.volumeSinkProcessGeneration + !== root.volumeSinkRequestGeneration + || !root.active || root.backendOverride !== null + || !implementation.backendReady()) { + root.volumeSinkProcessDiscarded = true + root.volumeSinkProcessStarted = false + root.volumeSinkResolved = false + return + } + if (exitCode !== 0) { + root.volumeSinkName = "" + root.volumeSinkResolutionFailed = true + root.volumeSinkResolved = true + } + root.volumeSinkProcessStarted = false + } + stdout: StdioCollector { + waitForEnd: true + onStreamFinished: { + if (root.volumeSinkProcessGeneration + !== root.volumeSinkRequestGeneration) { + root.volumeSinkProcessDiscarded = true + root.volumeSinkResolved = false + return + } + root.volumeSinkName = String(text).trim() + root.volumeSinkResolutionFailed = root.volumeSinkName === "" + root.volumeSinkResolved = true + } + } + } + + Connections { + target: root.active && root.backendOverride === null ? Pipewire : null + function onDefaultAudioSinkChanged() { root.invalidateVolumeSink() } + function onReadyChanged() { + if (Pipewire.ready) { + root.invalidateVolumeSink() + root.invalidateSinkAvailability() + } else { + root.volumeSinkResolved = false + root.sinkAvailabilityResolved = false + } + } + } + + Connections { + target: root.active && root.backendOverride === null ? Pipewire.nodes : null + function onValuesChanged() { + if (Pipewire.ready) { + root.invalidateVolumeSink() + root.invalidateSinkAvailability() + } + } + } + + Connections { + target: root.active && root.backendOverride === null ? Pipewire.links : null + function onValuesChanged() { + if (Pipewire.ready) root.invalidateVolumeSink() + } + } + + Connections { + target: root.active && root.backendOverride === null ? Pipewire.linkGroups : null + function onValuesChanged() { + if (Pipewire.ready) root.invalidateVolumeSink() + } + } + + PwNodePeakMonitor { + id: inputPeakMonitor + node: root.backendOverride === null + ? implementation.monitorSource() : null + enabled: root.active && root.peakMonitoringEnabled + && root.backendOverride === null && implementation.backendReady() + && implementation.monitorSource() !== null + } } diff --git a/hancore.shibumi.audio/AudioModel.js b/hancore.shibumi.audio/AudioModel.js index daf421a..247c849 100644 --- a/hancore.shibumi.audio/AudioModel.js +++ b/hancore.shibumi.audio/AudioModel.js @@ -7,6 +7,18 @@ function nodeProperties(node) { ? node.properties : ({}) } +function parseSinkAvailability(raw) { + var next = {} + var lines = String(raw || "").split("\n") + for (var index = 0; index < lines.length; index++) { + var line = lines[index].trim() + if (!line) continue + var parts = line.split("\t") + if (parts.length >= 2) next[parts[0]] = parts[1] !== "0" + } + return next +} + function stableNodeId(node, kind) { if (!node || node.id === undefined || node.id === null) return "" var id = String(node.id).trim() @@ -17,11 +29,79 @@ function stableNodeId(node, kind) { function nodeLabel(node) { if (!node) return "Audio device" var properties = nodeProperties(node) - return text(node.description) - || text(properties["node.description"]) - || text(properties["device.description"]) - || text(node.name) - || "Audio device" + var profile = usableDeviceLabel(properties["device.profile.description"]) + var label = usableDeviceLabel(node.description) + || usableDeviceLabel(properties["node.description"]) + || usableDeviceLabel(properties["device.description"]) + || usableDeviceLabel(node.nickname) + || usableDeviceLabel(node.nick) + || usableDeviceLabel(properties["node.nick"]) + || usableDeviceLabel(node.name) + if (label && profile + && label.toLowerCase().indexOf(profile.toLowerCase()) === -1) + return label + " " + profile + return label || profile || "Audio device" +} + +function usableDeviceLabel(value) { + var label = text(value) + return !label || label === "(null)" ? "" : label +} + +function nodeDeviceKey(node) { + var properties = nodeProperties(node) + var deviceId = usableDeviceLabel(properties["device.id"]) + if (deviceId) return "id:" + deviceId + + var deviceName = usableDeviceLabel(properties["device.name"]) + if (deviceName) return "device:" + deviceName.toLowerCase() + + var nodeName = usableDeviceLabel(node && node.name) + var profileSeparator = nodeName.lastIndexOf(".") + return "node:" + (profileSeparator > 0 + ? nodeName.slice(0, profileSeparator) : nodeName).toLowerCase() +} + +function nodeProfileKey(node) { + var properties = nodeProperties(node) + return usableDeviceLabel( + properties["device.profile.description"]).toLowerCase() +} + +function groupedDeviceNodes(values) { + var groups = [] + if (!values) return [] + + for (var i = 0; i < values.length; i++) { + var node = values[i] + if (!node) continue + var key = nodeDeviceKey(node) || "index:" + i + var group = null + for (var j = 0; j < groups.length; j++) { + if (groups[j].key === key) { + group = groups[j] + break + } + } + if (!group) { + group = { key: key, nodes: [] } + groups.push(group) + } + group.nodes.push(node) + } + + var result = [] + for (var groupIndex = 0; groupIndex < groups.length; groupIndex++) { + groups[groupIndex].nodes.sort(function(left, right) { + var leftProfile = nodeProfileKey(left) + var rightProfile = nodeProfileKey(right) + return leftProfile < rightProfile ? -1 + : leftProfile > rightProfile ? 1 : 0 + }) + for (var nodeIndex = 0; nodeIndex < groups[groupIndex].nodes.length; nodeIndex++) + result.push(groups[groupIndex].nodes[nodeIndex]) + } + return result } function isAudioSource(node) { @@ -33,6 +113,94 @@ function isAudioSource(node) { || type.indexOf("Source") !== -1 } +function friendlyStreamLabel(label) { + var value = text(label) + if (!value) return "" + return value.toLowerCase() === "spotify" ? "Spotify" : value +} + +function rawStreamLabel(node) { + if (!node) return "" + var properties = nodeProperties(node) + return usableDeviceLabel(properties["application.name"]) + || usableDeviceLabel(node.description) + || usableDeviceLabel(properties["media.name"]) + || usableDeviceLabel(properties["node.name"]) + || usableDeviceLabel(node.name) +} + +function mprisPlayerLabel(player) { + return player + ? friendlyStreamLabel(player.identity || player.desktopEntry || "") : "" +} + +function mprisPlayerIsProxy(player) { + var dbusName = text(player && player.dbusName).toLowerCase() + var desktopEntry = text(player && player.desktopEntry).toLowerCase() + return dbusName.indexOf("playerctld") !== -1 || desktopEntry === "playerctld" +} + +function streamRepresentsMprisPlayer(streamLabelValue, playerLabel) { + var streamKey = friendlyStreamLabel(streamLabelValue).toLowerCase() + var playerKey = text(playerLabel).toLowerCase() + return !!streamKey && !!playerKey + && (streamKey === playerKey + || streamKey.indexOf(playerKey) !== -1 + || playerKey.indexOf(streamKey) !== -1) +} + +function mprisLabelsFor(players, predicate) { + var values = Array.isArray(players) ? players : [] + var playing = [] + var candidates = [] + var playingProxies = [] + var proxies = [] + for (var index = 0; index < values.length; index++) { + var player = values[index] + if (!player || (!player.isPlaying && !player.canPlay)) continue + var label = mprisPlayerLabel(player) + if (!label || !predicate(label)) continue + if (mprisPlayerIsProxy(player)) { + if (player.isPlaying) playingProxies.push(label) + proxies.push(label) + } else { + if (player.isPlaying) playing.push(label) + candidates.push(label) + } + } + if (playing.length === 1) return playing[0] + if (playing.length === 0 && playingProxies.length === 1) return playingProxies[0] + if (candidates.length === 1) return candidates[0] + if (candidates.length === 0 && proxies.length === 1) return proxies[0] + return "" +} + +function streamLabelIsGeneric(label) { + return text(label).toLowerCase() === "audio-src" +} + +function streamLabel(node, players, streams) { + if (!node) return "Stream" + var label = rawStreamLabel(node) + var resolved = "" + if (!streamLabelIsGeneric(label)) { + resolved = mprisLabelsFor(players, function(playerLabel) { + return streamRepresentsMprisPlayer(label, playerLabel) + }) + } else { + resolved = mprisLabelsFor(players, function(playerLabel) { + var values = Array.isArray(streams) ? streams : [] + for (var index = 0; index < values.length; index++) { + var other = rawStreamLabel(values[index]) + if (!streamLabelIsGeneric(other) + && streamRepresentsMprisPlayer(other, playerLabel)) return false + } + return true + }) + } + return friendlyStreamLabel(resolved || label) || "Application" +} + function isPlaybackStream(node) { if (!node || !node.isStream) return false if (node.isSink === true) return true @@ -67,21 +235,52 @@ function bluetoothSinkMatchesRequest(node, request) { if (!node || !node.isSink || node.isStream || !request) return false var rawAddress = text(request.address) var address = normalizedAddress(rawAddress) + var properties = nodeProperties(node) + var nodeAddress = normalizedAddress( + properties["api.bluez5.address"] || properties["bluez5.address"]) var nodeTextValue = nodeText(node) // A supplied Bluetooth address is authoritative. Never route to a same-name // device when its address is absent, invalid, or different. if (rawAddress) { if (!/^([0-9a-f]{2}[:-]){5}[0-9a-f]{2}$/i.test(rawAddress)) return false - return normalizedAddress(nodeTextValue).indexOf(address) !== -1 + if (node.ready === false) { + var nodeName = String(node.name || "").toLowerCase() + return nodeName.indexOf("bluez_output.") === 0 + && normalizedAddress(nodeName).indexOf(address) !== -1 + } + return nodeAddress !== "" && nodeAddress === address } var label = text(request.deviceName || request.name).toLowerCase() return !!label && nodeTextValue.indexOf(label) !== -1 } +function resolveNode(id, values, kind) { + var key = String(id || "") + if (!key || !values) return null + for (var index = 0; index < values.length; index++) { + if (stableNodeId(values[index], kind) === key) return values[index] + } + return null +} + +function snapshotList(values, kind, selected, labeler) { + var result = [] + if (!values) return result + for (var index = 0; index < values.length; index++) { + var value = snapshot(values[index], kind, values[index] === selected) + if (value && value.id) { + var resolvedLabel = labeler ? text(labeler(values[index])) : "" + if (resolvedLabel) value.label = resolvedLabel + result.push(value) + } + } + return result +} + function snapshot(node, kind, isDefault) { if (!node) return null - var audio = node.audio + var audio = node.ready !== false ? node.audio : null return { id: stableNodeId(node, kind), kind: String(kind || "node"), diff --git a/hancore.shibumi.audio/AudioPanel.qml b/hancore.shibumi.audio/AudioPanel.qml index 06803ab..93d23f3 100644 --- a/hancore.shibumi.audio/AudioPanel.qml +++ b/hancore.shibumi.audio/AudioPanel.qml @@ -1,7 +1,6 @@ pragma ComponentBehavior: Bound import QtQuick -import Quickshell.Services.Pipewire import qs.Commons as Commons import qs.Ui as Ui @@ -14,6 +13,7 @@ ShibumiPanel { property var displaySinks: [] property var displaySources: [] property var displayStreams: [] + property bool peakLeaseHeld: false readonly property real outputVolume: audioBackend ? Number(audioBackend.outputVolume || 0) : 0 readonly property bool outputMuted: audioBackend @@ -23,7 +23,7 @@ ShibumiPanel { readonly property bool inputMuted: audioBackend ? audioBackend.inputMuted === true : false readonly property real microphoneLevel: inputMuted || !audioBackend - ? 0 : peakToMeter(inputPeakMonitor.peak) + ? 0 : peakToMeter(audioBackend.inputPeak) readonly property int renderedSinkCount: displaySinks.length readonly property int renderedSourceCount: displaySources.length readonly property int renderedStreamCount: displayStreams.length @@ -91,6 +91,16 @@ ShibumiPanel { } onOpenChanged: { + if (audioBackend + && typeof audioBackend.acquirePeakMonitoring === "function") { + if (open && !peakLeaseHeld + && audioBackend.acquirePeakMonitoring() === true) { + peakLeaseHeld = true + } else if (!open && peakLeaseHeld) { + audioBackend.releasePeakMonitoring() + peakLeaseHeld = false + } + } if (open) { refreshModels() Qt.callLater(refreshModels) @@ -99,6 +109,13 @@ ShibumiPanel { } } + Component.onDestruction: { + if (peakLeaseHeld && audioBackend + && typeof audioBackend.releasePeakMonitoring === "function") + audioBackend.releasePeakMonitoring() + peakLeaseHeld = false + } + Item { width: 0 height: 0 @@ -117,12 +134,6 @@ ShibumiPanel { repeat: false onTriggered: panel.refreshModels() } - - PwNodePeakMonitor { - id: inputPeakMonitor - node: panel.audioBackend ? panel.audioBackend.source : null - enabled: panel.open && panel.audioBackend && panel.audioBackend.source !== null - } } Ui.PanelKeyCatcher { @@ -228,7 +239,9 @@ ShibumiPanel { : panel.controlAccent knobColor: panel.sliderKnobColor(panel.outputMuted, outputSlider.enabled) - enabled: panel.audioBackend && panel.audioBackend.sink !== null + enabled: panel.audioBackend + && panel.audioBackend.volumeSinkSnapshot !== null + && panel.audioBackend.volumeSinkSnapshot.available === true onMoved: function(value) { panel.audioBackend.setOutputVolume(value) } @@ -297,12 +310,18 @@ ShibumiPanel { } } - PanelDivider { visible: panel.audioBackend && panel.audioBackend.source !== null } + PanelDivider { + visible: panel.audioBackend + && panel.audioBackend.sourceSnapshot !== null + && panel.audioBackend.sourceSnapshot.available === true + } Column { width: parent.width spacing: 8 - visible: panel.audioBackend && panel.audioBackend.source !== null + visible: panel.audioBackend + && panel.audioBackend.sourceSnapshot !== null + && panel.audioBackend.sourceSnapshot.available === true Column { id: inputControl @@ -361,7 +380,9 @@ ShibumiPanel { : panel.controlAccent knobColor: panel.sliderKnobColor(panel.inputMuted, inputSlider.enabled) - enabled: panel.audioBackend && panel.audioBackend.source !== null + enabled: panel.audioBackend + && panel.audioBackend.sourceSnapshot !== null + && panel.audioBackend.sourceSnapshot.available === true onMoved: function(value) { panel.audioBackend.setInputVolume(value) } @@ -493,7 +514,7 @@ ShibumiPanel { required property bool sourceDevice required property int rowIndex readonly property var activeNode: sourceDevice - ? panel.audioBackend.source : panel.audioBackend.sink + ? panel.audioBackend.sourceSnapshot : panel.audioBackend.sinkSnapshot readonly property bool current: panel.sameNode(node, activeNode) width: contentColumn.width height: 26 @@ -545,8 +566,8 @@ ShibumiPanel { cursorShape: Qt.PointingHandCursor onClicked: { if (deviceRow.sourceDevice) - panel.audioBackend.setDefaultSource(deviceRow.node) - else panel.audioBackend.setDefaultSink(deviceRow.node) + panel.audioBackend.setDefaultSource(deviceRow.node.id) + else panel.audioBackend.setDefaultSink(deviceRow.node.id) } } } @@ -555,7 +576,6 @@ ShibumiPanel { id: streamRow required property var node required property int rowIndex - readonly property var audio: node ? node.audio : null width: contentColumn.width height: Commons.Style.space(32) @@ -564,8 +584,8 @@ ShibumiPanel { anchors.left: parent.left anchors.top: parent.top width: Commons.Style.space(20) - text: streamRow.audio && streamRow.audio.muted ? "volume_off" : "volume_up" - color: streamRow.audio && streamRow.audio.muted && panel.bar + text: streamRow.node && streamRow.node.muted ? "volume_off" : "volume_up" + color: streamRow.node && streamRow.node.muted && panel.bar ? Qt.rgba(panel.bar.foreground.r, panel.bar.foreground.g, panel.bar.foreground.b, 0.4) : panel.bar ? panel.bar.urgent : Commons.Color.accent @@ -576,7 +596,7 @@ ShibumiPanel { anchors.fill: parent anchors.margins: -Commons.Style.space(3) cursorShape: Qt.PointingHandCursor - onClicked: panel.audioBackend.toggleStreamMute(streamRow.node) + onClicked: panel.audioBackend.toggleStreamMute(streamRow.node.id) } } @@ -588,7 +608,7 @@ ShibumiPanel { anchors.verticalCenter: muteIcon.verticalCenter text: panel.audioBackend.streamLabel(streamRow.node) color: panel.bar ? panel.bar.foreground : Commons.Color.foreground - opacity: streamRow.audio && streamRow.audio.muted ? 0.46 : 1 + opacity: streamRow.node && streamRow.node.muted ? 0.46 : 1 font.family: panel.bar ? panel.bar.fontFamily : Commons.Style.font.family font.pixelSize: Commons.Style.font.body elide: Text.ElideRight @@ -599,7 +619,7 @@ ShibumiPanel { id: streamPercent anchors.right: parent.right anchors.verticalCenter: muteIcon.verticalCenter - text: Math.round(Number(streamRow.audio ? streamRow.audio.volume : 0) * 100) + "%" + text: Math.round(Number(streamRow.node ? streamRow.node.volume : 0) * 100) + "%" color: panel.bar ? panel.bar.urgent : Commons.Color.accent font.family: panel.bar ? panel.bar.fontFamily : Commons.Style.font.family font.pixelSize: Commons.Style.font.body @@ -613,16 +633,16 @@ ShibumiPanel { anchors.bottom: parent.bottom bar: panel.bar maximum: 1 - value: streamRow.audio ? Number(streamRow.audio.volume || 0) : 0 + value: streamRow.node ? Number(streamRow.node.volume || 0) : 0 trackColor: panel.controlActiveFillColor - fillColor: streamRow.audio && streamRow.audio.muted + fillColor: streamRow.node && streamRow.node.muted ? Commons.Util.alpha(panel.controlAccent, 0.4) : panel.controlAccent - knobColor: streamRow.audio && streamRow.audio.muted + knobColor: streamRow.node && streamRow.node.muted ? Commons.Util.alpha(panel.controlAccent, 0.55) : panel.controlAccent onMoved: function(value) { - panel.audioBackend.setStreamVolume(streamRow.node, value) + panel.audioBackend.setStreamVolume(streamRow.node.id, value) } } } diff --git a/hancore.shibumi.audio/AudioPanelBridge.qml b/hancore.shibumi.audio/AudioPanelBridge.qml index ba5d20e..d9ec2b0 100644 --- a/hancore.shibumi.audio/AudioPanelBridge.qml +++ b/hancore.shibumi.audio/AudioPanelBridge.qml @@ -1,6 +1,9 @@ pragma ComponentBehavior: Bound import QtQuick +import Quickshell.Services.Mpris +import Quickshell.Services.Pipewire +import "AudioModel.js" as Model // Keeps Quattro's audio component as the single PipeWire/device/action owner. // Shibumi renders its own bar and popup presentation, while this bridge exposes @@ -13,17 +16,57 @@ Item { property Component panelComponent: null property url panelSource: "" property var panelSettings: ({}) - - readonly property var panel: panelLoader.item - readonly property bool ready: panel !== null - readonly property var sink: ready && panel.sink !== undefined ? panel.sink : null - readonly property var source: ready && panel.source !== undefined ? panel.source : null - readonly property var audioSinks: ready && panel.audioSinks !== undefined - ? groupedDeviceNodes(panel.audioSinks) : [] - readonly property var audioSources: ready && panel.audioSources !== undefined - ? panel.audioSources : [] - readonly property var audioStreams: ready && panel.audioStreams !== undefined - ? panel.audioStreams : [] + property var backendReadyOverride: null + property real peakValue: 0 + property var peakAcquire: null + property var peakRelease: null + property bool nativeBackendAccessEnabled: false + + readonly property bool backendReady: backendReadyOverride !== null + ? backendReadyOverride === true + : nativeBackendAccessEnabled ? Pipewire.ready === true : true + readonly property bool ready: panel.item !== null && backendReady + readonly property var sinkSnapshot: { + const version = generation + void version + return ready && panel.sink !== undefined + ? Model.snapshot(panel.sink, "sink", true) : null + } + readonly property var sourceSnapshot: { + const version = generation + void version + return ready && panel.source !== undefined + ? Model.snapshot(panel.source, "source", true) : null + } + readonly property var volumeSinkSnapshot: { + const version = generation + void version + return ready && panel.volumeSink !== undefined + ? Model.snapshot(panel.volumeSink, "sink", true) : null + } + readonly property var audioSinks: { + const version = generation + void version + return ready && panel.audioSinks !== undefined + ? Model.snapshotList(Model.groupedDeviceNodes(panel.audioSinks), "sink", panel.sink) + : [] + } + readonly property var audioSources: { + const version = generation + void version + return ready && panel.audioSources !== undefined + ? Model.snapshotList(panel.audioSources, "source", panel.source) : [] + } + readonly property var audioStreams: { + const version = generation + void version + if (!ready || panel.audioStreams === undefined) return [] + const streams = panel.audioStreams + const players = root.nativeBackendAccessEnabled + ? (Mpris.players ? Mpris.players.values : []) : [] + return Model.snapshotList(streams, "stream", null, + function(node) { return Model.streamLabel(node, players, streams) }) + } readonly property real outputVolume: ready && panel.outputVolume !== undefined ? Number(panel.outputVolume) : 0 readonly property bool outputMuted: ready @@ -32,188 +75,383 @@ Item { && panel.inputVolume !== undefined ? Number(panel.inputVolume) : 0 readonly property bool inputMuted: ready && panel.inputMuted !== undefined ? panel.inputMuted === true : false + // The native AudioBackendAdapter owns peak monitoring after activation. The + // transitional official owner keeps the same primitive surface until then. + readonly property real inputPeak: !ready ? 0 : Number(peakValue || 0) + property int generation: 0 - function toggleOutputMute() { - if (!ready || typeof panel.toggleOutputMute !== "function") return false - panel.toggleOutputMute() - return true + // Internal-only facade around the loaded official component. It is a child + // id, not a root property, so callers cannot obtain the raw panel object. + QtObject { + id: panel + + readonly property var item: panelLoader.item + readonly property var sink: item && item.sink !== undefined ? item.sink : null + readonly property var volumeSink: item && item.volumeSink !== undefined + ? item.volumeSink : sink + readonly property var source: item && item.source !== undefined ? item.source : null + readonly property var audioSinks: item && item.audioSinks !== undefined + ? item.audioSinks : [] + readonly property var audioSources: item && item.audioSources !== undefined + ? item.audioSources : [] + readonly property var audioStreams: item && item.audioStreams !== undefined + ? item.audioStreams : [] + readonly property var nodes: item && item.nodes !== undefined + ? item.nodes : [] + readonly property var candidateSinks: item && item.candidateSinks !== undefined + ? item.candidateSinks : null + readonly property var candidateSources: item && item.candidateSources !== undefined + ? item.candidateSources : null + readonly property var candidateStreams: item && item.candidateStreams !== undefined + ? item.candidateStreams : null + readonly property var sinkAvailability: item && item.sinkAvailability !== undefined + ? item.sinkAvailability : ({}) + readonly property bool sinkAvailabilityLoaded: + item && item.sinkAvailabilityLoaded === true + readonly property var outputVolume: item && item.outputVolume !== undefined + ? item.outputVolume : 0 + readonly property bool outputMuted: item && item.outputMuted === true + readonly property var inputVolume: item && item.inputVolume !== undefined + ? item.inputVolume : 0 + readonly property bool inputMuted: item && item.inputMuted === true + readonly property var inputPeak: item && item.inputPeak !== undefined + ? item.inputPeak : undefined + readonly property bool opened: item && item.opened === true + + function unsupported() { + return { + ok: false, + code: "unsupported", + message: "Audio backend action is unavailable", + entityId: "", + generation: root.generation + } + } + function toggleOutputMute() { + if (!item || typeof item.toggleOutputMute !== "function") return unsupported() + return item.toggleOutputMute() + } + function setOutputVolume(value) { + if (!item || typeof item.setOutputVolume !== "function") return unsupported() + return item.setOutputVolume(value) + } + function toggleInputMute() { + if (!item || typeof item.toggleInputMute !== "function") return unsupported() + return item.toggleInputMute() + } + function setInputVolume(value) { + if (!item || typeof item.setInputVolume !== "function") return unsupported() + return item.setInputVolume(value) + } + function setDefaultSink(node) { + if (!item || typeof item.setDefaultSink !== "function") return unsupported() + return item.setDefaultSink(node) + } + function setDefaultSource(node) { + if (!item || typeof item.setDefaultSource !== "function") return unsupported() + return item.setDefaultSource(node) + } + function setStreamVolume(node, value) { + if (!item || !node || !node.audio) return unsupported() + node.audio.volume = Math.max(0, Math.min(1.5, Number(value) || 0)) + return true + } + function toggleStreamMute(node) { + if (!item || !node || !node.audio) return unsupported() + node.audio.muted = !node.audio.muted + return true + } + function streamLabel(node) { + return item && typeof item.streamLabel === "function" + ? item.streamLabel(node) : "" + } + function close() { + if (item && typeof item.close === "function") item.close() + } + function suppressKeyboardPanel() { + if (!item || !item.data || item.data.length === undefined) return false + let suppressed = false + for (let index = 0; index < item.data.length; index++) { + const candidate = item.data[index] + if (!candidate || typeof candidate.beginFocusPrime !== "function" + || !("anchorItem" in candidate) || !("open" in candidate) + || !("visible" in candidate) || !("owner" in candidate) + || candidate.owner !== item) continue + candidate.open = false + candidate.visible = false + suppressed = true + } + return suppressed + } + function inject(host, settings) { + if (!item) return + suppressKeyboardPanel() + if ("bar" in item) item.bar = host + if ("moduleName" in item) item.moduleName = "omarchy.audio" + if ("settings" in item) item.settings = settings + if ("manageIpc" in item) item.manageIpc = false + if (item.opened === true && typeof item.close === "function") item.close() + item.opacity = 0 + } } - function setOutputVolume(value) { - if (!ready || typeof panel.setOutputVolume !== "function") return false - panel.setOutputVolume(Math.max(0, Math.min(1, Number(value) || 0))) - return true + function acquirePeakMonitoring() { + return typeof peakAcquire === "function" ? peakAcquire() === true : false } - function toggleInputMute() { - if (!ready || typeof panel.toggleInputMute !== "function") return false - panel.toggleInputMute() - return true + function releasePeakMonitoring() { + return typeof peakRelease === "function" ? peakRelease() === true : false } - function setInputVolume(value) { - if (!ready || typeof panel.setInputVolume !== "function") return false - panel.setInputVolume(Math.max(0, Math.min(1, Number(value) || 0))) - return true + function actionResult(ok, code, message, entityId) { + return Model.actionResult(ok, code, message, entityId, generation) } - function setDefaultSink(node) { - if (!ready || !node || typeof panel.setDefaultSink !== "function") return false - panel.setDefaultSink(node) - return true + function delegatedResult(value, entityId) { + if (value && typeof value === "object" + && typeof value.ok === "boolean") { + value.entityId = entityId + if (value.code === undefined) + value.code = value.ok ? "ok" : "unavailable" + if (value.message === undefined) value.message = "" + if (value.generation === undefined) value.generation = generation + return value + } + if (value === false) + return actionResult(false, "unavailable", "Audio backend action failed", entityId) + return null } - function setDefaultSource(node) { - if (!ready || !node || typeof panel.setDefaultSource !== "function") return false - panel.setDefaultSource(node) - return true + function refreshedDelegatedSuccess(value, entityId) { + return actionResult(true, value.code || "ok", value.message || "", entityId) } - function setStreamVolume(node, value) { - if (!node || !node.audio) return false - node.audio.volume = Math.max(0, Math.min(1.5, Number(value) || 0)) - return true + function advanceGeneration() { + generation++ } - function toggleStreamMute(node) { - if (!node || !node.audio) return false - node.audio.muted = !node.audio.muted - return true + function entityId(entity, kind) { + return entity && entity.id !== undefined + ? String(entity.id) : String(entity || "") } - function callLabelHelper(name, node, fallback) { - if (!ready || typeof panel[name] !== "function") return String(fallback || "") - return String(panel[name](node) || fallback || "") + function liveNode(entity, kind) { + const id = entityId(entity, kind) + const nodes = kind === "sink" ? panel.candidateSinks + : kind === "source" ? panel.candidateSources : panel.candidateStreams + if (!nodes) return null + for (let index = 0; index < nodes.length; index++) { + const node = nodes[index] + if (!node || Model.stableNodeId(node, kind) !== id) continue + return node + } + return null } - function usableDeviceLabel(value) { - const label = String(value || "").trim() - return !label || label === "(null)" ? "" : label + function toggleOutputMute() { + const id = volumeSinkSnapshot ? volumeSinkSnapshot.id + : sinkSnapshot ? sinkSnapshot.id : "" + if (!ready || !panel.volumeSink || panel.volumeSink.ready === false + || !panel.volumeSink.audio) + return actionResult(false, "unavailable", "Output is unavailable", id) + if (typeof panel.toggleOutputMute !== "function") + return actionResult(false, "unsupported", "Audio backend action is unavailable", id) + const delegated = delegatedResult(panel.toggleOutputMute(), id) + if (delegated && !delegated.ok) return delegated + advanceGeneration() + return delegated ? refreshedDelegatedSuccess(delegated, id) + : actionResult(true, "ok", "", id) } - function nodeProperties(node) { - return node && node.ready !== false && node.properties - ? node.properties : ({}) + function setOutputVolume(value) { + const id = volumeSinkSnapshot ? volumeSinkSnapshot.id + : sinkSnapshot ? sinkSnapshot.id : "" + if (!ready || !panel.volumeSink || panel.volumeSink.ready === false + || !panel.volumeSink.audio) + return actionResult(false, "unavailable", "Output is unavailable", id) + if (typeof panel.setOutputVolume !== "function") + return actionResult(false, "unsupported", "Audio backend action is unavailable", id) + const delegated = delegatedResult( + panel.setOutputVolume(Math.max(0, Math.min(1, Number(value) || 0))), id) + if (delegated && !delegated.ok) return delegated + advanceGeneration() + return delegated ? refreshedDelegatedSuccess(delegated, id) + : actionResult(true, "ok", "", id) } - function nodeDeviceKey(node) { - const properties = nodeProperties(node) - const deviceId = usableDeviceLabel(properties["device.id"]) - if (deviceId) return "id:" + deviceId + function toggleInputMute() { + const id = sourceSnapshot ? sourceSnapshot.id : "" + if (!ready || !panel.source || panel.source.ready === false + || !panel.source.audio) + return actionResult(false, "unavailable", "Input is unavailable", id) + if (typeof panel.toggleInputMute !== "function") + return actionResult(false, "unsupported", "Audio backend action is unavailable", id) + const delegated = delegatedResult(panel.toggleInputMute(), id) + if (delegated && !delegated.ok) return delegated + advanceGeneration() + return delegated ? refreshedDelegatedSuccess(delegated, id) + : actionResult(true, "ok", "", id) + } - const deviceName = usableDeviceLabel(properties["device.name"]) - if (deviceName) return "device:" + deviceName.toLowerCase() + function setInputVolume(value) { + const id = sourceSnapshot ? sourceSnapshot.id : "" + if (!ready || !panel.source || panel.source.ready === false + || !panel.source.audio) + return actionResult(false, "unavailable", "Input is unavailable", id) + if (typeof panel.setInputVolume !== "function") + return actionResult(false, "unsupported", "Audio backend action is unavailable", id) + const delegated = delegatedResult( + panel.setInputVolume(Math.max(0, Math.min(1, Number(value) || 0))), id) + if (delegated && !delegated.ok) return delegated + advanceGeneration() + return delegated ? refreshedDelegatedSuccess(delegated, id) + : actionResult(true, "ok", "", id) + } - const nodeName = usableDeviceLabel(node && node.name) - const profileSeparator = nodeName.lastIndexOf(".") - return "node:" + (profileSeparator > 0 - ? nodeName.slice(0, profileSeparator) : nodeName).toLowerCase() + function setDefaultSink(entity) { + const node = ready ? liveNode(entity, "sink") : null + const id = entityId(entity, "sink") + if (!ready) return actionResult(false, "unavailable", "Audio sink is unavailable", id) + if (!node) + return actionResult(false, "stale-id", "Audio sink is unavailable", id) + if (node.ready === false) + return actionResult(false, "unavailable", "Audio sink is unavailable", id) + if (panel.sinkAvailabilityLoaded + && panel.sinkAvailability[String(node.name || "")] === false) + return actionResult(false, "unavailable", "Audio sink is unavailable", id) + if (typeof panel.setDefaultSink !== "function") + return actionResult(false, "unsupported", "Audio backend action is unavailable", id) + const delegated = delegatedResult(panel.setDefaultSink(node), id) + if (delegated && !delegated.ok) return delegated + advanceGeneration() + return delegated ? refreshedDelegatedSuccess(delegated, id) + : actionResult(true, "ok", "", id) } - function nodeProfileKey(node) { - const properties = nodeProperties(node) - return usableDeviceLabel( - properties["device.profile.description"]).toLowerCase() + function setDefaultSource(entity) { + const node = ready ? liveNode(entity, "source") : null + const id = entityId(entity, "source") + if (!ready) return actionResult(false, "unavailable", "Audio source is unavailable", id) + if (!node) + return actionResult(false, "stale-id", "Audio source is unavailable", id) + if (node.ready === false) + return actionResult(false, "unavailable", "Audio source is unavailable", id) + if (typeof panel.setDefaultSource !== "function") + return actionResult(false, "unsupported", "Audio backend action is unavailable", id) + const delegated = delegatedResult(panel.setDefaultSource(node), id) + if (delegated && !delegated.ok) return delegated + advanceGeneration() + return delegated ? refreshedDelegatedSuccess(delegated, id) + : actionResult(true, "ok", "", id) } - function groupedDeviceNodes(values) { - const groups = [] - if (!values) return [] + function setStreamVolume(entity, value) { + const node = ready ? liveNode(entity, "stream") : null + const id = entityId(entity, "stream") + if (!ready) return actionResult(false, "unavailable", "Audio stream is unavailable", id) + if (!node) + return actionResult(false, "stale-id", "Audio stream is unavailable", id) + if (node.ready === false) + return actionResult(false, "unavailable", "Audio stream is unavailable", id) + if (!node.audio) + return actionResult(false, "unavailable", "Audio stream is unavailable", id) + const delegated = delegatedResult(panel.setStreamVolume(node, value), id) + if (delegated && !delegated.ok) return delegated + advanceGeneration() + return delegated ? refreshedDelegatedSuccess(delegated, id) + : actionResult(true, "ok", "", id) + } - for (let i = 0; i < values.length; i++) { - const node = values[i] - if (!node) continue - const key = nodeDeviceKey(node) || "index:" + i - let group = null - for (let j = 0; j < groups.length; j++) { - if (groups[j].key === key) { - group = groups[j] - break - } - } - if (!group) { - group = { key: key, nodes: [] } - groups.push(group) - } - group.nodes.push(node) - } + function toggleStreamMute(entity) { + const node = ready ? liveNode(entity, "stream") : null + const id = entityId(entity, "stream") + if (!ready) return actionResult(false, "unavailable", "Audio stream is unavailable", id) + if (!node) + return actionResult(false, "stale-id", "Audio stream is unavailable", id) + if (node.ready === false) + return actionResult(false, "unavailable", "Audio stream is unavailable", id) + if (!node.audio) + return actionResult(false, "unavailable", "Audio stream is unavailable", id) + const delegated = delegatedResult(panel.toggleStreamMute(node), id) + if (delegated && !delegated.ok) return delegated + advanceGeneration() + return delegated ? refreshedDelegatedSuccess(delegated, id) + : actionResult(true, "ok", "", id) + } - const result = [] - for (let i = 0; i < groups.length; i++) { - groups[i].nodes.sort(function(left, right) { - const leftProfile = nodeProfileKey(left) - const rightProfile = nodeProfileKey(right) - return leftProfile < rightProfile ? -1 - : leftProfile > rightProfile ? 1 : 0 - }) - for (let j = 0; j < groups[i].nodes.length; j++) - result.push(groups[i].nodes[j]) - } - return result - } - - function descriptiveNodeLabel(node) { - if (!node) return "" - const properties = nodeProperties(node) - const profile = usableDeviceLabel(properties["device.profile.description"]) - let label = usableDeviceLabel(node.description) - if (label && profile - && label.toLowerCase().indexOf(profile.toLowerCase()) < 0) - return label + " " + profile - if (label) return label - - label = usableDeviceLabel(properties["node.description"]) - if (label && profile - && label.toLowerCase().indexOf(profile.toLowerCase()) < 0) - return label + " " + profile - if (label) return label - - const device = usableDeviceLabel(properties["device.description"]) - if (device && profile - && device.toLowerCase().indexOf(profile.toLowerCase()) < 0) - return device + " " + profile - return device || profile - } - - function nodeLabel(node) { - return descriptiveNodeLabel(node) - || callLabelHelper("nodeLabel", node, "Audio device") - } - function sinkGlyph(node) { return callLabelHelper("sinkGlyph", node, "speaker") } - function sourceGlyph(node) { return callLabelHelper("sourceGlyph", node, "mic") } - function streamLabel(node) { return callLabelHelper("streamLabel", node, "Application") } - - function suppressBackendKeyboardPanel() { - if (!panel || !panel.data || panel.data.length === undefined) return false - let suppressed = false - for (let index = 0; index < panel.data.length; index++) { - const candidate = panel.data[index] + function nodeLabel(snapshot) { + return snapshot && snapshot.label ? String(snapshot.label) : "Audio device" + } + + function streamLabel(snapshot) { + return snapshot && snapshot.label ? String(snapshot.label) : "Application" + } + + + function officialKeyboardState() { + const item = panel.item + if (!item || !item.data || item.data.length === undefined) + return ({ open: false, visible: false }) + for (let index = 0; index < item.data.length; index++) { + const candidate = item.data[index] if (!candidate || typeof candidate.beginFocusPrime !== "function" || !("anchorItem" in candidate) || !("open" in candidate) || !("visible" in candidate) || !("owner" in candidate) - || candidate.owner !== panel) continue - // The official backend's KeyboardPanel is a separate PanelWindow, so - // hiding the backend Item after Loader.onLoaded cannot hide that window. - // Break only the KeyboardPanel visibility binding; centered authoritative - // overlays (which do not expose beginFocusPrime) remain available. - candidate.open = false - candidate.visible = false - suppressed = true + || candidate.owner !== item) continue + return ({ open: candidate.open === true, visible: candidate.visible === true }) + } + return ({ open: false, visible: false }) + } + + function officialPanelState() { + const item = panel.item + if (!item) return ({ present: false }) + const keyboard = officialKeyboardState() + const firstStream = item.audioStreams && item.audioStreams.length > 0 + ? item.audioStreams[0] : null + return { + present: true, + opacity: Number(item.opacity || 0), + settings: item.settings || ({}), + manageIpc: item.manageIpc === true, + opened: item.opened === true, + openCount: Number(item.openCount || 0), + backendKeyboardPanelOpen: keyboard.open, + backendKeyboardPanelVisible: keyboard.visible, + clickTargetRegistered: !!(item.internalButton + && item.internalButton.registeredBar !== undefined + && item.internalButton.registeredBar === root.bar), + defaultSinkChanges: Number(item.defaultSinkChanges || 0), + sinkId: item.sink && item.sink.id !== undefined ? Number(item.sink.id) : -1, + defaultSourceChanges: Number(item.defaultSourceChanges || 0), + sourceId: item.source && item.source.id !== undefined ? Number(item.source.id) : -1, + outputVolumeChanges: Number(item.outputVolumeChanges || 0), + outputVolume: Number(item.outputVolume || 0), + inputVolumeChanges: Number(item.inputVolumeChanges || 0), + inputVolume: Number(item.inputVolume || 0), + inputMuted: item.inputMuted === true, + streamVolume: firstStream && firstStream.audio + ? Number(firstStream.audio.volume || 0) : 0, + streamMuted: !!(firstStream && firstStream.audio && firstStream.audio.muted) } - return suppressed + } + + function openOfficialPanel() { + if (!panel.item || typeof panel.item.open !== "function") return false + panel.item.open() + return true + } + + function closeOfficialPanel() { + panel.close() + return true } function injectPanel() { - if (!panel) return - suppressBackendKeyboardPanel() - if ("bar" in panel) panel.bar = hostProxy - if ("moduleName" in panel) panel.moduleName = "omarchy.audio" - if ("settings" in panel) panel.settings = panelSettings - if ("manageIpc" in panel) panel.manageIpc = false - if (panel.opened === true && typeof panel.close === "function") panel.close() + if (!panel.item) return // The official button and popup remain instantiated for their backend // state, but Shibumi owns the only visible presentation and click target. - panel.opacity = 0 + panel.inject(hostProxy, panelSettings) } function syncPanelSource() { @@ -237,11 +475,8 @@ Item { onPanelSourceChanged: Qt.callLater(syncPanelSource) Component.onCompleted: Qt.callLater(syncPanelSource) Component.onDestruction: { - const currentPanel = panel - if (currentPanel && currentPanel.opened === true - && typeof currentPanel.close === "function") currentPanel.close() - // Destroy the official panel while its host facade is still valid. Setting - // panel.bar to null first reevaluates unguarded upstream bindings. + panel.close() + // Destroy the official panel while its host facade is still valid. panelLoader.active = false } diff --git a/hancore.shibumi.audio/AudioPeakMonitor.qml b/hancore.shibumi.audio/AudioPeakMonitor.qml new file mode 100644 index 0000000..5d2cc03 --- /dev/null +++ b/hancore.shibumi.audio/AudioPeakMonitor.qml @@ -0,0 +1,33 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell.Services.Pipewire + +// One process-wide, read-only microphone peak capability for the transitional +// official audio owner. Native AudioBackendAdapter replaces it at activation. +Item { + id: root + + property int clients: 0 + readonly property bool ready: Pipewire.ready === true + readonly property real inputPeak: root.clients > 0 && ready + && peakMonitor.node !== null ? Number(peakMonitor.peak) || 0 : 0 + + function acquire() { + clients++ + return true + } + + function release() { + clients = Math.max(0, clients - 1) + return true + } + + PwNodePeakMonitor { + id: peakMonitor + node: Pipewire.ready === true && Pipewire.defaultAudioSource + && Pipewire.defaultAudioSource.ready !== false + ? Pipewire.defaultAudioSource : null + enabled: root.clients > 0 && root.ready && node !== null + } +} diff --git a/hancore.shibumi.audio/BarWidget.qml b/hancore.shibumi.audio/BarWidget.qml index e3818df..3ad6aee 100644 --- a/hancore.shibumi.audio/BarWidget.qml +++ b/hancore.shibumi.audio/BarWidget.qml @@ -14,6 +14,10 @@ Ui.Panel { readonly property url backendPanelSource: registeredSource("omarchy.audio") property Component panelComponent: String(backendPanelSource) ? null : registeredComponent("omarchy.audio") + property var backendReadyOverride: null + readonly property bool nativeBackendAccessEnabled: + backendReadyOverride === null + && (String(backendPanelSource) !== "" || panelComponent !== null) readonly property var tokens: bar && "visualTokens" in bar && bar.visualTokens ? bar.visualTokens : hostTokens @@ -30,7 +34,6 @@ Ui.Panel { readonly property bool compact: displayMode === "icon" readonly property bool horizontalValueVisible: displayMode !== "icon" || tokens.v2Shell !== true - readonly property var audioPanel: audioBridge.panel readonly property var panelItem: popupLoader.item readonly property bool panelLoaded: panelItem !== null readonly property bool audioReady: audioBridge.ready @@ -90,9 +93,13 @@ Ui.Panel { } function ownsPanelWidget(owner) { - return !!owner && (owner === root || owner === audioPanel) + return !!owner && owner === root } + function officialPanelState() { return audioBridge.officialPanelState() } + function openOfficialPanel() { return audioBridge.openOfficialPanel() } + function closeOfficialPanel() { return audioBridge.closeOfficialPanel() } + function toggleOutputMute() { return audioBridge.toggleOutputMute() } function setOutputVolume(value) { return audioBridge.setOutputVolume(value) } function adjustVolume(delta) { @@ -119,11 +126,13 @@ Ui.Panel { onOpenedChanged: syncPanelLoader() onAudioReadyChanged: { + if (!audioReady && opened) close() if (opened) syncPanelLoader() if (!audioReady) { wheelCommitTimer.stop() wheelSettleTimer.stop() wheelAdjustmentPending = false + popupLoader.source = "" } reportAudioState() } @@ -144,6 +153,15 @@ Ui.Panel { panelComponent: root.panelComponent panelSource: root.backendPanelSource panelSettings: root.officialSettings() + backendReadyOverride: root.backendReadyOverride + nativeBackendAccessEnabled: root.nativeBackendAccessEnabled + peakValue: root.audioStateService ? root.audioStateService.inputPeak : 0 + peakAcquire: root.audioStateService + ? function() { return root.audioStateService.acquirePeakMonitoring() } + : null + peakRelease: root.audioStateService + ? function() { return root.audioStateService.releasePeakMonitoring() } + : null } Loader { id: popupLoader } @@ -152,7 +170,8 @@ Ui.Panel { id: wheelCommitTimer interval: 70 onTriggered: { - if (!root.setOutputVolume(root.wheelTargetVolume)) { + const result = root.setOutputVolume(root.wheelTargetVolume) + if (!result || result.ok !== true) { root.wheelAdjustmentPending = false return } diff --git a/hancore.shibumi.audio/Service.qml b/hancore.shibumi.audio/Service.qml index 507474d..5ade0fd 100644 --- a/hancore.shibumi.audio/Service.qml +++ b/hancore.shibumi.audio/Service.qml @@ -16,7 +16,34 @@ Item { // the sole Shibumi PipeWire owner. property bool nativeBackendEnabled: false property var nativeBackendOverride: null - readonly property var nativeBackend: nativeBackendLoader.item + readonly property bool nativeBackendReady: root.nativeBackendEnabled + && nativeBackendLoader.item ? nativeBackendLoader.item.ready === true : false + readonly property var nativeAudioSinks: nativeBackendLoader.item + ? nativeBackendLoader.item.audioSinks : [] + readonly property var nativeAudioSources: nativeBackendLoader.item + ? nativeBackendLoader.item.audioSources : [] + readonly property var nativeAudioStreams: nativeBackendLoader.item + ? nativeBackendLoader.item.audioStreams : [] + readonly property var nativeSinkSnapshot: nativeBackendLoader.item + ? nativeBackendLoader.item.sinkSnapshot : null + readonly property var nativeVolumeSinkSnapshot: nativeBackendLoader.item + ? nativeBackendLoader.item.volumeSinkSnapshot : null + readonly property var nativeSourceSnapshot: nativeBackendLoader.item + ? nativeBackendLoader.item.sourceSnapshot : null + readonly property real nativeOutputVolume: nativeBackendLoader.item + ? Number(nativeBackendLoader.item.outputVolume || 0) : 0 + readonly property bool nativeOutputMuted: nativeBackendLoader.item + ? nativeBackendLoader.item.outputMuted === true : false + readonly property real nativeInputVolume: nativeBackendLoader.item + ? Number(nativeBackendLoader.item.inputVolume || 0) : 0 + readonly property bool nativeInputMuted: nativeBackendLoader.item + ? nativeBackendLoader.item.inputMuted === true : false + readonly property real inputPeak: root.nativeBackendEnabled + ? (nativeBackendLoader.item + ? Number(nativeBackendLoader.item.inputPeak || 0) : 0) + : (peakBackendLoader.item + ? Number(peakBackendLoader.item.inputPeak || 0) : 0) + property int peakMonitorClients: 0 property bool ready: false property bool outputMuted: false @@ -24,6 +51,13 @@ Item { width: 0 height: 0 + Loader { + id: peakBackendLoader + active: !root.nativeBackendEnabled + source: Qt.resolvedUrl("AudioPeakMonitor.qml") + onLoaded: item.clients = root.peakMonitorClients + } + Loader { id: nativeBackendLoader active: root.nativeBackendEnabled @@ -32,10 +66,86 @@ Item { // Apply test/native injection before activation so a fake backend never // briefly evaluates the real PipeWire service. item.backendOverride = root.nativeBackendOverride + item.peakMonitoringClients = root.peakMonitorClients item.active = true } } + onNativeBackendEnabledChanged: { + if (root.nativeBackendEnabled) { + if (peakBackendLoader.item) peakBackendLoader.item.clients = 0 + } else if (nativeBackendLoader.item) { + nativeBackendLoader.item.peakMonitoringClients = 0 + } + } + + function acquirePeakMonitoring() { + const owner = root.nativeBackendEnabled + ? nativeBackendLoader.item : peakBackendLoader.item + const method = root.nativeBackendEnabled + ? "acquirePeakMonitoring" : "acquire" + if (!owner || typeof owner[method] !== "function") { + peakMonitorClients++ + return true + } + const acquired = owner[method]() === true + if (acquired) peakMonitorClients++ + return acquired + } + + function releasePeakMonitoring() { + if (peakMonitorClients <= 0) return false + const owner = root.nativeBackendEnabled + ? nativeBackendLoader.item : peakBackendLoader.item + const method = root.nativeBackendEnabled + ? "releasePeakMonitoring" : "release" + peakMonitorClients-- + if (owner && typeof owner[method] === "function") owner[method]() + return true + } + + function nativeSetDefaultSink(id) { + return nativeBackendLoader.item + ? nativeBackendLoader.item.setDefaultSink(id) : ({ + ok: false, code: "unavailable", message: "Audio backend is unavailable", + entityId: String(id || ""), generation: 0 + }) + } + function nativeSetDefaultSource(id) { + return nativeBackendLoader.item + ? nativeBackendLoader.item.setDefaultSource(id) : ({ + ok: false, code: "unavailable", message: "Audio backend is unavailable", + entityId: String(id || ""), generation: 0 + }) + } + function nativeSetOutputVolume(value) { + return nativeBackendLoader.item + ? nativeBackendLoader.item.setOutputVolume(value) : ({ + ok: false, code: "unavailable", message: "Audio backend is unavailable", + entityId: "", generation: 0 + }) + } + function nativeToggleOutputMute() { + return nativeBackendLoader.item + ? nativeBackendLoader.item.toggleOutputMute() : ({ + ok: false, code: "unavailable", message: "Audio backend is unavailable", + entityId: "", generation: 0 + }) + } + function nativeSetInputVolume(value) { + return nativeBackendLoader.item + ? nativeBackendLoader.item.setInputVolume(value) : ({ + ok: false, code: "unavailable", message: "Audio backend is unavailable", + entityId: "", generation: 0 + }) + } + function nativeToggleInputMute() { + return nativeBackendLoader.item + ? nativeBackendLoader.item.toggleInputMute() : ({ + ok: false, code: "unavailable", message: "Audio backend is unavailable", + entityId: "", generation: 0 + }) + } function report(owner, available, muted) { if (!owner) return false const next = reports.filter(entry => entry.owner !== owner) diff --git a/hancore.shibumi.bluetooth/BluetoothModel.js b/hancore.shibumi.bluetooth/BluetoothModel.js index 16aa900..fd80082 100644 --- a/hancore.shibumi.bluetooth/BluetoothModel.js +++ b/hancore.shibumi.bluetooth/BluetoothModel.js @@ -94,12 +94,20 @@ function bluetoothSinkMatchesDevice(node, device) { var rawAddress = String(device.address || "").trim() var address = normalizedAddress(rawAddress) + var properties = nodeProperties(node) + var nodeAddress = normalizedAddress( + properties["api.bluez5.address"] || properties["bluez5.address"]) var text = nodeText(node) // A supplied Bluetooth address is authoritative. Never route to a same-name // sink when its address is absent, invalid, or different. if (rawAddress !== "") { if (!isAddressLike(rawAddress)) return false - return normalizedAddress(text).indexOf(address) !== -1 + if (node.ready === false) { + var nodeName = String(node.name || "").toLowerCase() + return nodeName.indexOf("bluez_output.") === 0 + && normalizedAddress(nodeName).indexOf(address) !== -1 + } + return nodeAddress !== "" && nodeAddress === address } var label = deviceLabel(device).toLowerCase() diff --git a/tests/audio-media-plugins-regression.sh b/tests/audio-media-plugins-regression.sh index 5cc414b..b4e9b7e 100755 --- a/tests/audio-media-plugins-regression.sh +++ b/tests/audio-media-plugins-regression.sh @@ -185,9 +185,18 @@ rg -q 'readonly property bool museMode: fullMode' \ rg -q 'readonly property int bandCount: 24' \ "$repo_root/hancore.shibumi.media/MediaMuse.qml" \ || fail "G9 FULL presentation does not render 24 bands" -rg -q 'enabled: panel\.open && panel\.audioBackend' \ - "$audio_panel" \ - || fail "microphone metering is not panel-lifecycle bounded" +rg -q 'audioBackend\.inputPeak' "$audio_panel" \ + || fail "audio panel does not consume the primitive microphone peak" +rg -q 'audioBackend\.acquirePeakMonitoring\(\)' "$audio_panel" \ + || fail "audio panel does not acquire microphone peak monitoring" +rg -q 'audioBackend\.releasePeakMonitoring\(\)' "$audio_panel" \ + || fail "audio panel does not release microphone peak monitoring" +rg -q 'PwNodePeakMonitor \{' \ + "$repo_root/hancore.shibumi.audio/AudioBackendAdapter.qml" \ + || fail "native audio backend does not own microphone peak monitoring" +rg -q 'enabled: root\.active && root\.peakMonitoringEnabled' \ + "$repo_root/hancore.shibumi.audio/AudioBackendAdapter.qml" \ + || fail "microphone peak monitoring is not native-backend bounded" if rg -q 'ShibumiSlider \{' "$audio_panel"; then fail "audio panel still exposes the thick legacy slider presentation" fi @@ -215,8 +224,10 @@ fi if rg -q 'tickCount:' "$audio_panel"; then fail "volume sliders must remain continuous and unsegmented" fi -rg -q 'function descriptiveNodeLabel\(node\)' "$audio_bridge" \ - || fail "audio bridge does not restore descriptive device labels" +rg -q 'Model\.snapshotList\(' "$audio_bridge" \ + || fail "audio bridge does not expose primitive audio snapshots" +rg -q 'function nodeLabel\(snapshot\)' "$audio_bridge" \ + || fail "audio bridge does not label primitive snapshots" if rg -q 'wiremix|Open audio|openMixer' "$audio_panel"; then fail "audio panel exposes the retired external mixer action" fi diff --git a/tests/audio-native-backend-seam-regression.qml b/tests/audio-native-backend-seam-regression.qml index 00e4897..72a3e57 100644 --- a/tests/audio-native-backend-seam-regression.qml +++ b/tests/audio-native-backend-seam-regression.qml @@ -112,6 +112,7 @@ ShellRoot { QtObject { id: fakeBackend property bool ready: true + property real inputPeak: 0.4 property var nodes: [sinkA, sinkB, sinkWithoutId, sinkWithEmptyId, source, stream] property var defaultAudioSink: sinkA property var defaultAudioSource: source @@ -130,9 +131,26 @@ ShellRoot { property var defaultAudioSink: sinkA } + QtObject { + id: typedFailingBackend + property bool ready: true + property var nodes: [sinkA] + property var defaultAudioSink: sinkA + function setDefaultSink(_node) { + return { + ok: false, + code: "unavailable", + message: "fixture delegate failure", + entityId: "sink:1", + generation: 9 + } + } + } + Audio.AudioBackendAdapter { id: backend active: true + peakMonitoringClients: 1 backendOverride: fakeBackend } @@ -142,6 +160,12 @@ ShellRoot { backendOverride: incompleteBackend } + Audio.AudioBackendAdapter { + id: typedFailingAdapter + active: true + backendOverride: typedFailingBackend + } + Audio.Service { id: service nativeBackendEnabled: true @@ -163,17 +187,21 @@ ShellRoot { return root.fail("native backend snapshots") const incompleteResult = incompleteBackendAdapter.setDefaultSink("sink:1") + const typedFailure = typedFailingAdapter.setDefaultSink("sink:1") if (incompleteResult.ok || incompleteResult.code !== "unsupported" + || typedFailure.ok + || typedFailure.code !== "unavailable" + || typedFailure.message !== "fixture delegate failure" + || typedFailure.generation !== 9 || backend.currentNodes !== undefined || backend.currentSink !== undefined || backend.currentSource !== undefined || backend.filterSinks !== undefined || backend.filterSources !== undefined || backend.filterStreams !== undefined - || service.nativeBackend === null - || service.nativeBackend.ready !== true - || service.nativeBackend.audioSinks.length !== 2 - || service.nativeBackend.currentNodes !== undefined + || !service.nativeBackendReady + || service.nativeAudioSinks.length !== 2 + || service.nativeAudioSinks[0].node !== undefined || backend.audioSinks[0].node !== undefined || backend.audioSinks[0].id !== "sink:1" || backend.sinkSnapshot.id !== "sink:1" @@ -215,7 +243,11 @@ ShellRoot { root.ticks = 0 } else if (root.phase === 1) { if (root.ticks < 2) return - if (!backend.setOutputVolume(0.55).ok + const generationBeforeVolume = backend.generation + const outputResult = backend.setOutputVolume(0.55) + if (Math.abs(backend.inputPeak - 0.4) > 0.001 + || !outputResult.ok + || outputResult.generation <= generationBeforeVolume || Math.abs(sinkAudioB.volume - 0.55) > 0.001 || !backend.toggleOutputMute().ok || !sinkAudioB.muted) return root.fail("typed output actions") @@ -235,13 +267,15 @@ ShellRoot { const streamVolume = streamAudio.volume const streamMuted = streamAudio.muted fakeBackend.ready = false + const cachedUnavailablePeak = backend.inputPeak const cachedUnavailableOutput = backend.setOutputVolume(0.12) const cachedUnavailableMute = backend.toggleOutputMute() const cachedUnavailableSource = backend.setInputVolume(0.12) const cachedUnavailableStream = backend.setStreamVolume("stream:8", 0.12) const cachedUnavailableRoute = backend.setDefaultSink("sink:2") fakeBackend.ready = true - if (cachedUnavailableOutput.ok + if (cachedUnavailablePeak !== 0 + || cachedUnavailableOutput.ok || cachedUnavailableMute.ok || cachedUnavailableSource.ok || cachedUnavailableStream.ok diff --git a/tests/audio-network-ipc-contract-regression.sh b/tests/audio-network-ipc-contract-regression.sh index a0a1a9a..8d2f432 100755 --- a/tests/audio-network-ipc-contract-regression.sh +++ b/tests/audio-network-ipc-contract-regression.sh @@ -33,12 +33,19 @@ rg -Fq 'manageIpc: false' "$audio_widget" \ rg -Fq 'manageIpc = false' "$audio_bridge" \ || fail 'hidden official Audio backend can duplicate direct IPC ownership' for bridge in "$audio_bridge" "$network_bridge"; do - rg -Fq 'function suppressBackendKeyboardPanel()' "$bridge" \ - || fail "hidden official backend lacks KeyboardPanel suppression: $bridge" + if [[ "$bridge" == "$audio_bridge" ]]; then + rg -Fq 'function suppressKeyboardPanel()' "$bridge" \ + || fail "hidden official backend lacks KeyboardPanel suppression: $bridge" + rg -Fq 'candidate.owner !== item' "$bridge" \ + || fail "hidden backend suppression can match a foreign window: $bridge" + else + rg -Fq 'function suppressBackendKeyboardPanel()' "$bridge" \ + || fail "hidden official backend lacks KeyboardPanel suppression: $bridge" + rg -Fq 'candidate.owner !== panel' "$bridge" \ + || fail "hidden backend suppression can match a foreign window: $bridge" + fi rg -Fq 'typeof candidate.beginFocusPrime !== "function"' "$bridge" \ || fail "hidden backend suppression is not limited to KeyboardPanel: $bridge" - rg -Fq 'candidate.owner !== panel' "$bridge" \ - || fail "hidden backend suppression can match a foreign window: $bridge" rg -Fq 'candidate.open = false' "$bridge" \ || fail "hidden official KeyboardPanel can retain dismissal surfaces: $bridge" rg -Fq 'candidate.visible = false' "$bridge" \ diff --git a/tests/audio-plugin-smoke.qml b/tests/audio-plugin-smoke.qml index 7b7a46f..894f4bb 100644 --- a/tests/audio-plugin-smoke.qml +++ b/tests/audio-plugin-smoke.qml @@ -75,6 +75,7 @@ ShellRoot { bar: fakeBar settings: ({ compact: false }) panelComponent: audioPanelComponent + backendReadyOverride: true popupSource: Qt.resolvedUrl("fixtures/AudioTestView.qml") } } @@ -100,30 +101,31 @@ ShellRoot { const audio = audioLoader.item if (root.phase === 0) { if (!audio || !audio.audioReady || root.phaseTicks < 3) return + const official = audio ? audio.officialPanelState() : ({ present: false }) if (!audio || !audio.audioReady || audio.volume !== 42 || audio.muted || audio.implicitHeight !== 35 || unavailableAudio.visible) return root.fail("backend readiness/state/geometry") if (audio.compact) return root.fail("explicit full presentation resolved compact") - if (!audio.audioPanel || audio.audioPanel.opacity !== 0 - || audio.audioPanel.settings.testSetting !== "retained" - || audio.audioPanel.manageIpc - || audio.audioPanel.opened || audio.audioPanel.openCount !== 0 - || audio.audioPanel.backendKeyboardPanelOpen - || audio.audioPanel.backendKeyboardPanelVisible + if (!official.present || official.opacity !== 0 + || official.settings.testSetting !== "retained" + || official.manageIpc + || official.opened || official.openCount !== 0 + || official.backendKeyboardPanelOpen + || official.backendKeyboardPanelVisible || audio.childPanelWidget("omarchy.audio") !== audio - || !audio.ownsPanelWidget(audio) - || !audio.ownsPanelWidget(audio.audioPanel)) + || !audio.ownsPanelWidget(audio)) return root.fail("official panel bridge/routing/settings") - if (root.clickTargets.length !== 1 - || audio.audioPanel.internalButton.registeredBar === fakeBar) + if (root.clickTargets.length !== 1 || official.clickTargetRegistered) return root.fail("duplicate official click target") - audio.audioPanel.open() - if (!audio.audioPanel.opened - || audio.audioPanel.backendKeyboardPanelOpen - || audio.audioPanel.backendKeyboardPanelVisible) + if (!audio.openOfficialPanel()) + return root.fail("official panel test action unavailable") + const openedOfficial = audio.officialPanelState() + if (!openedOfficial.opened + || openedOfficial.backendKeyboardPanelOpen + || openedOfficial.backendKeyboardPanelVisible) return root.fail("hidden official KeyboardPanel became visible") - audio.audioPanel.close() + audio.closeOfficialPanel() root.fullWidth = audio.implicitWidth audio.settings = ({ compact: true }) @@ -139,24 +141,26 @@ ShellRoot { audio.interactionTarget.triggerPress(Qt.RightButton) if (!audio.muted) return root.fail("right-click mute forwarding") - root.wheelCallsBefore = audio.audioPanel.outputVolumeChanges + root.wheelCallsBefore = audio.officialPanelState().outputVolumeChanges audio.interactionTarget.wheelMoved(120) audio.interactionTarget.wheelMoved(120) audio.interactionTarget.wheelMoved(0) if (audio.volume !== 52) return root.fail("wheel target did not accumulate immediately") - if (audio.audioPanel.outputVolumeChanges !== root.wheelCallsBefore) + if (audio.officialPanelState().outputVolumeChanges !== root.wheelCallsBefore) return root.fail("wheel burst wrote the backend before settling") root.phase++ root.phaseTicks = 0 } else if (root.phase === 2) { if (root.phaseTicks < 3) return - if (audio.audioPanel.outputVolumeChanges !== root.wheelCallsBefore + 1 - || Math.abs(audio.audioPanel.outputVolume - 0.52) > 0.001) + const official = audio.officialPanelState() + if (official.outputVolumeChanges !== root.wheelCallsBefore + 1 + || Math.abs(official.outputVolume - 0.52) > 0.001) return root.fail("wheel burst was not coalesced into one final write") audio.interactionTarget.triggerPress(Qt.LeftButton) - if (!audio.opened || audio.audioPanel.opened - || audio.audioPanel.openCount !== 1) + const closedOfficial = audio.officialPanelState() + if (!audio.opened || closedOfficial.opened + || closedOfficial.openCount !== 1) return root.fail("local mixer lifecycle/official panel isolation") root.phase++ root.phaseTicks = 0 @@ -173,24 +177,25 @@ ShellRoot { !== "SteelSeries Arctis 7 Game") return root.fail("description-first output device labels") if (!audio.panelItem.selectSecondSink() - || audio.audioPanel.defaultSinkChanges !== 1 - || audio.audioPanel.sink.id !== 2) + || audio.officialPanelState().defaultSinkChanges !== 1 + || audio.officialPanelState().sinkId !== 2) return root.fail("output device forwarding") if (!audio.panelItem.selectSecondSource() - || audio.audioPanel.defaultSourceChanges !== 1 - || audio.audioPanel.source.id !== 4) + || audio.officialPanelState().defaultSourceChanges !== 1 + || audio.officialPanelState().sourceId !== 4) return root.fail("input device forwarding") if (!audio.panelItem.setFirstStreamVolume(0.75) - || audio.audioPanel.audioStreams[0].audio.volume !== 0.75) + || Math.abs(audio.officialPanelState().streamVolume - 0.75) > 0.001) return root.fail("stream volume forwarding") if (!audio.panelItem.toggleFirstStreamMute() - || !audio.audioPanel.audioStreams[0].audio.muted) + || !audio.officialPanelState().streamMuted) return root.fail("stream mute forwarding") if (!audio.panelItem.setInputVolume(0.35) - || audio.audioPanel.inputVolumeChanges !== 1 - || Math.abs(audio.audioPanel.inputVolume - 0.35) > 0.001) + || audio.officialPanelState().inputVolumeChanges !== 1 + || Math.abs(audio.officialPanelState().inputVolume - 0.35) > 0.001) return root.fail("input volume forwarding") - if (!audio.panelItem.toggleInputMute() || !audio.audioPanel.inputMuted) + if (!audio.panelItem.toggleInputMute() + || !audio.officialPanelState().inputMuted) return root.fail("input mute forwarding fixture") audio.close() root.phase++ diff --git a/tests/contract-regression.sh b/tests/contract-regression.sh index 27a99fa..adb4381 100755 --- a/tests/contract-regression.sh +++ b/tests/contract-regression.sh @@ -478,10 +478,11 @@ rg -q 'return String\(pluginId \|\| ""\) === "omarchy\.audio" \? root : null' \ || fail "official audio routing is not redirected to the Shibumi owner" rg -q 'manageIpc: false' hancore.shibumi.audio/BarWidget.qml \ || fail "audio aliases must use screen-aware host routing, not duplicate IPC handlers" -if rg -q 'Quickshell\.Services\.Pipewire|Pipewire\.' hancore.shibumi.audio/BarWidget.qml \ - hancore.shibumi.audio/AudioPanelBridge.qml; then +if rg -q 'Quickshell\.Services\.Pipewire|Pipewire\.' hancore.shibumi.audio/BarWidget.qml; then fail "Shibumi audio presentation must not create a second PipeWire owner" fi +rg -q 'Pipewire\.ready === true' hancore.shibumi.audio/AudioPanelBridge.qml \ + || fail "transitional audio bridge does not use the authoritative PipeWire readiness signal" if rg -q 'Process \{|FileView \{' hancore.shibumi.audio/BarWidget.qml \ hancore.shibumi.audio/AudioPanelBridge.qml; then fail "audio presentation bridge must remain event-driven and worker-free" @@ -503,10 +504,17 @@ if rg -U -q 'Timer \{([^}]|\n)*(repeat:[[:space:]]*true|running:[[:space:]]*true hancore.shibumi.audio/BarWidget.qml; then fail "audio wheel timers must remain dormant, non-repeating interaction timers" fi -[[ $(rg -c 'PwNodePeakMonitor \{' hancore.shibumi.audio/AudioPanel.qml) -eq 1 ]] \ - || fail "audio panel must own exactly one lifecycle-bound microphone meter" -rg -q 'enabled: panel\.open' hancore.shibumi.audio/AudioPanel.qml \ - || fail "microphone meter is not bounded to the open mixer lifecycle" +[[ $(rg -c 'PwNodePeakMonitor \{' hancore.shibumi.audio/AudioBackendAdapter.qml) -eq 1 ]] \ + || fail "native audio backend must own exactly one microphone meter" +rg -q 'audioBackend\.inputPeak' hancore.shibumi.audio/AudioPanel.qml \ + || fail "audio panel does not consume the primitive microphone peak" +rg -q 'audioBackend\.acquirePeakMonitoring\(\)' hancore.shibumi.audio/AudioPanel.qml \ + || fail "audio panel does not acquire microphone peak monitoring" +rg -q 'audioBackend\.releasePeakMonitoring\(\)' hancore.shibumi.audio/AudioPanel.qml \ + || fail "audio panel does not release microphone peak monitoring" +rg -q 'enabled: root\.active && root\.peakMonitoringEnabled' \ + hancore.shibumi.audio/AudioBackendAdapter.qml \ + || fail "microphone meter is not bounded to the native backend lifecycle" if rg -q 'Pipewire\.|Process \{|FileView \{' hancore.shibumi.audio/AudioPanel.qml; then fail "Shibumi audio panel duplicates Quattro audio ownership or shell workers" fi diff --git a/tests/fixtures/AudioTestPanel.qml b/tests/fixtures/AudioTestPanel.qml index deea9cb..fd38c08 100644 --- a/tests/fixtures/AudioTestPanel.qml +++ b/tests/fixtures/AudioTestPanel.qml @@ -16,10 +16,17 @@ Item { property real inputVolume: 0.64 property bool inputMuted: false property var sink: sinkA + property var volumeSink: sinkA property var source: sourceA property var audioSinks: [sinkA, sinkC, sinkB] property var audioSources: [sourceA, sourceB] property var audioStreams: [streamA, streamB] + property var nodes: [sinkA, sinkC, sinkB, sourceA, sourceB, streamA, streamB] + property var candidateSinks: [sinkA, sinkC, sinkB] + property var candidateSources: [sourceA, sourceB] + property var candidateStreams: [streamA, streamB] + property var sinkAvailability: ({}) + property bool sinkAvailabilityLoaded: false property int defaultSinkChanges: 0 property int defaultSourceChanges: 0 property int outputVolumeChanges: 0 @@ -127,6 +134,8 @@ Item { QtObject { id: sinkA property int id: 1 + property bool isSink: true + property bool isStream: false property string name: "sink-a" property bool ready: true property string nickname: "SteelSeries Arctis 7" @@ -142,6 +151,8 @@ Item { QtObject { id: sinkB property int id: 2 + property bool isSink: true + property bool isStream: false property string name: "sink-b" property bool ready: true property string nickname: "USB Audio #1" @@ -157,6 +168,8 @@ Item { QtObject { id: sinkC property int id: 7 + property bool isSink: true + property bool isStream: false property string name: "sink-hdmi" property bool ready: true property string nickname: "27GL850" @@ -171,6 +184,8 @@ Item { QtObject { id: sourceA property int id: 3 + property bool isSink: false + property bool isStream: false property string name: "source-a" property string description: "Internal Microphone" property var audio: sourceAudioA @@ -179,6 +194,8 @@ Item { QtObject { id: sourceB property int id: 4 + property bool isSink: false + property bool isStream: false property string name: "source-b" property string description: "USB Microphone" property var audio: sourceAudioB @@ -187,6 +204,9 @@ Item { QtObject { id: streamA property int id: 5 + property bool isSink: true + property bool isStream: true + property string type: "Stream/Output/Audio" property string description: "Browser" property var audio: streamAudioA } @@ -194,6 +214,9 @@ Item { QtObject { id: streamB property int id: 6 + property bool isSink: true + property bool isStream: true + property string type: "Stream/Output/Audio" property string description: "Music" property var audio: streamAudioB } diff --git a/tests/fixtures/AudioTestView.qml b/tests/fixtures/AudioTestView.qml index 274255b..bddddaa 100644 --- a/tests/fixtures/AudioTestView.qml +++ b/tests/fixtures/AudioTestView.qml @@ -21,28 +21,36 @@ Item { return audioBackend.nodeLabel(audioBackend.audioSinks[1]) } + function actionSucceeded(value) { + return value && value.ok === true + } + function selectSecondSink() { - return audioBackend.setDefaultSink(audioBackend.audioSinks[1]) + return actionSucceeded( + audioBackend.setDefaultSink(audioBackend.audioSinks[1].id)) } function selectSecondSource() { - return audioBackend.setDefaultSource(audioBackend.audioSources[1]) + return actionSucceeded( + audioBackend.setDefaultSource(audioBackend.audioSources[1].id)) } function setFirstStreamVolume(value) { - return audioBackend.setStreamVolume(audioBackend.audioStreams[0], value) + return actionSucceeded( + audioBackend.setStreamVolume(audioBackend.audioStreams[0].id, value)) } function toggleFirstStreamMute() { - return audioBackend.toggleStreamMute(audioBackend.audioStreams[0]) + return actionSucceeded( + audioBackend.toggleStreamMute(audioBackend.audioStreams[0].id)) } function toggleInputMute() { - return audioBackend.toggleInputMute() + return actionSucceeded(audioBackend.toggleInputMute()) } function setInputVolume(value) { - return audioBackend.setInputVolume(value) + return actionSucceeded(audioBackend.setInputVolume(value)) } Component.onCompleted: { From 585fa8077fda0f1d94f5cd9beeacefd4585907d4 Mon Sep 17 00:00:00 2001 From: HANCORE-linux <230438592+HANCORE-linux@users.noreply.github.com> Date: Sun, 23 Aug 2026 04:09:26 +0200 Subject: [PATCH 11/18] feat: activate native audio and bluetooth route owner --- hancore.shibumi.audio/AudioPanelBridge.qml | 121 ++++++++++++++---- hancore.shibumi.audio/BarWidget.qml | 24 +++- hancore.shibumi.audio/Service.qml | 33 ++++- .../BluetoothAudioRouteAdapter.qml | 7 +- .../BluetoothBackendAdapter.qml | 20 +-- hancore.shibumi.bluetooth/Service.qml | 18 +++ .../audio-native-backend-seam-regression.qml | 55 +++++++- tests/bluetooth-audio-intent-regression.qml | 47 ++++++- tests/contract-regression.sh | 21 ++- tests/reactor-plugin-smoke.qml | 5 +- 10 files changed, 303 insertions(+), 48 deletions(-) diff --git a/hancore.shibumi.audio/AudioPanelBridge.qml b/hancore.shibumi.audio/AudioPanelBridge.qml index d9ec2b0..93bf486 100644 --- a/hancore.shibumi.audio/AudioPanelBridge.qml +++ b/hancore.shibumi.audio/AudioPanelBridge.qml @@ -21,63 +21,103 @@ Item { property var peakAcquire: null property var peakRelease: null property bool nativeBackendAccessEnabled: false + property var nativeAudioService: null readonly property bool backendReady: backendReadyOverride !== null ? backendReadyOverride === true + : nativeAudioService !== null + ? nativeAudioService.nativeBackendReady === true : nativeBackendAccessEnabled ? Pipewire.ready === true : true - readonly property bool ready: panel.item !== null && backendReady + readonly property bool ready: nativeAudioService !== null + ? nativeAudioService.nativeBackendReady === true + : panel.item !== null && backendReady readonly property var sinkSnapshot: { const version = generation void version - return ready && panel.sink !== undefined + if (!ready) return null + if (nativeAudioService !== null) return nativeAudioService.nativeSinkSnapshot + return panel.sink !== undefined ? Model.snapshot(panel.sink, "sink", true) : null } readonly property var sourceSnapshot: { const version = generation void version - return ready && panel.source !== undefined + if (!ready) return null + if (nativeAudioService !== null) return nativeAudioService.nativeSourceSnapshot + return panel.source !== undefined ? Model.snapshot(panel.source, "source", true) : null } readonly property var volumeSinkSnapshot: { const version = generation void version - return ready && panel.volumeSink !== undefined + if (!ready) return null + if (nativeAudioService !== null) + return nativeAudioService.nativeVolumeSinkSnapshot + return panel.volumeSink !== undefined ? Model.snapshot(panel.volumeSink, "sink", true) : null } readonly property var audioSinks: { const version = generation void version - return ready && panel.audioSinks !== undefined + if (!ready) return [] + if (nativeAudioService !== null) + return nativeAudioService.nativeAudioSinks || [] + return panel.audioSinks !== undefined ? Model.snapshotList(Model.groupedDeviceNodes(panel.audioSinks), "sink", panel.sink) : [] } readonly property var audioSources: { const version = generation void version - return ready && panel.audioSources !== undefined + if (!ready) return [] + if (nativeAudioService !== null) + return nativeAudioService.nativeAudioSources || [] + return panel.audioSources !== undefined ? Model.snapshotList(panel.audioSources, "source", panel.source) : [] } readonly property var audioStreams: { const version = generation void version - if (!ready || panel.audioStreams === undefined) return [] + if (!ready) return [] + if (nativeAudioService !== null) + return nativeAudioService.nativeAudioStreams || [] + if (panel.audioStreams === undefined) return [] const streams = panel.audioStreams const players = root.nativeBackendAccessEnabled ? (Mpris.players ? Mpris.players.values : []) : [] return Model.snapshotList(streams, "stream", null, function(node) { return Model.streamLabel(node, players, streams) }) } - readonly property real outputVolume: ready - && panel.outputVolume !== undefined ? Number(panel.outputVolume) : 0 - readonly property bool outputMuted: ready - && panel.outputMuted !== undefined ? panel.outputMuted === true : false - readonly property real inputVolume: ready - && panel.inputVolume !== undefined ? Number(panel.inputVolume) : 0 - readonly property bool inputMuted: ready - && panel.inputMuted !== undefined ? panel.inputMuted === true : false - // The native AudioBackendAdapter owns peak monitoring after activation. The - // transitional official owner keeps the same primitive surface until then. - readonly property real inputPeak: !ready ? 0 : Number(peakValue || 0) + readonly property real outputVolume: { + if (!ready) return 0 + if (nativeAudioService !== null) + return Number(nativeAudioService.nativeOutputVolume || 0) + return panel.outputVolume !== undefined ? Number(panel.outputVolume) : 0 + } + readonly property bool outputMuted: { + if (!ready) return false + if (nativeAudioService !== null) + return nativeAudioService.nativeOutputMuted === true + return panel.outputMuted !== undefined && panel.outputMuted === true + } + readonly property real inputVolume: { + if (!ready) return 0 + if (nativeAudioService !== null) + return Number(nativeAudioService.nativeInputVolume || 0) + return panel.inputVolume !== undefined ? Number(panel.inputVolume) : 0 + } + readonly property bool inputMuted: { + if (!ready) return false + if (nativeAudioService !== null) + return nativeAudioService.nativeInputMuted === true + return panel.inputMuted !== undefined && panel.inputMuted === true + } + readonly property real inputPeak: { + if (!ready) return 0 + if (nativeAudioService !== null) + return Number(nativeAudioService.inputPeak || 0) + return Number(peakValue || 0) + } property int generation: 0 // Internal-only facade around the loaded official component. It is a child @@ -196,10 +236,16 @@ Item { } function acquirePeakMonitoring() { + if (nativeAudioService !== null + && typeof nativeAudioService.acquirePeakMonitoring === "function") + return nativeAudioService.acquirePeakMonitoring() === true return typeof peakAcquire === "function" ? peakAcquire() === true : false } function releasePeakMonitoring() { + if (nativeAudioService !== null + && typeof nativeAudioService.releasePeakMonitoring === "function") + return nativeAudioService.releasePeakMonitoring() === true return typeof peakRelease === "function" ? peakRelease() === true : false } @@ -226,6 +272,18 @@ Item { return actionResult(true, value.code || "ok", value.message || "", entityId) } + function nativeAction(method, args, entityId) { + if (!nativeAudioService || typeof nativeAudioService[method] !== "function") + return actionResult(false, "unsupported", + "Audio backend action is unavailable", entityId) + const value = nativeAudioService[method].apply(nativeAudioService, args || []) + const delegated = delegatedResult(value, entityId) + if (delegated && !delegated.ok) return delegated + advanceGeneration() + return delegated ? refreshedDelegatedSuccess(delegated, entityId) + : actionResult(true, "ok", "", entityId) + } + function advanceGeneration() { generation++ } @@ -251,6 +309,8 @@ Item { function toggleOutputMute() { const id = volumeSinkSnapshot ? volumeSinkSnapshot.id : sinkSnapshot ? sinkSnapshot.id : "" + if (nativeAudioService !== null) + return nativeAction("nativeToggleOutputMute", [], id) if (!ready || !panel.volumeSink || panel.volumeSink.ready === false || !panel.volumeSink.audio) return actionResult(false, "unavailable", "Output is unavailable", id) @@ -266,6 +326,9 @@ Item { function setOutputVolume(value) { const id = volumeSinkSnapshot ? volumeSinkSnapshot.id : sinkSnapshot ? sinkSnapshot.id : "" + if (nativeAudioService !== null) + return nativeAction("nativeSetOutputVolume", + [Math.max(0, Math.min(1, Number(value) || 0))], id) if (!ready || !panel.volumeSink || panel.volumeSink.ready === false || !panel.volumeSink.audio) return actionResult(false, "unavailable", "Output is unavailable", id) @@ -281,6 +344,8 @@ Item { function toggleInputMute() { const id = sourceSnapshot ? sourceSnapshot.id : "" + if (nativeAudioService !== null) + return nativeAction("nativeToggleInputMute", [], id) if (!ready || !panel.source || panel.source.ready === false || !panel.source.audio) return actionResult(false, "unavailable", "Input is unavailable", id) @@ -295,6 +360,9 @@ Item { function setInputVolume(value) { const id = sourceSnapshot ? sourceSnapshot.id : "" + if (nativeAudioService !== null) + return nativeAction("nativeSetInputVolume", + [Math.max(0, Math.min(1, Number(value) || 0))], id) if (!ready || !panel.source || panel.source.ready === false || !panel.source.audio) return actionResult(false, "unavailable", "Input is unavailable", id) @@ -309,8 +377,10 @@ Item { } function setDefaultSink(entity) { - const node = ready ? liveNode(entity, "sink") : null const id = entityId(entity, "sink") + if (nativeAudioService !== null) + return nativeAction("nativeSetDefaultSink", [id], id) + const node = ready ? liveNode(entity, "sink") : null if (!ready) return actionResult(false, "unavailable", "Audio sink is unavailable", id) if (!node) return actionResult(false, "stale-id", "Audio sink is unavailable", id) @@ -329,8 +399,10 @@ Item { } function setDefaultSource(entity) { - const node = ready ? liveNode(entity, "source") : null const id = entityId(entity, "source") + if (nativeAudioService !== null) + return nativeAction("nativeSetDefaultSource", [id], id) + const node = ready ? liveNode(entity, "source") : null if (!ready) return actionResult(false, "unavailable", "Audio source is unavailable", id) if (!node) return actionResult(false, "stale-id", "Audio source is unavailable", id) @@ -346,8 +418,11 @@ Item { } function setStreamVolume(entity, value) { - const node = ready ? liveNode(entity, "stream") : null const id = entityId(entity, "stream") + if (nativeAudioService !== null) + return nativeAction("nativeSetStreamVolume", + [id, Math.max(0, Math.min(1.5, Number(value) || 0))], id) + const node = ready ? liveNode(entity, "stream") : null if (!ready) return actionResult(false, "unavailable", "Audio stream is unavailable", id) if (!node) return actionResult(false, "stale-id", "Audio stream is unavailable", id) @@ -363,8 +438,10 @@ Item { } function toggleStreamMute(entity) { - const node = ready ? liveNode(entity, "stream") : null const id = entityId(entity, "stream") + if (nativeAudioService !== null) + return nativeAction("nativeToggleStreamMute", [id], id) + const node = ready ? liveNode(entity, "stream") : null if (!ready) return actionResult(false, "unavailable", "Audio stream is unavailable", id) if (!node) return actionResult(false, "stale-id", "Audio stream is unavailable", id) diff --git a/hancore.shibumi.audio/BarWidget.qml b/hancore.shibumi.audio/BarWidget.qml index 3ad6aee..ad42473 100644 --- a/hancore.shibumi.audio/BarWidget.qml +++ b/hancore.shibumi.audio/BarWidget.qml @@ -12,12 +12,20 @@ Ui.Panel { HostTokens { id: hostTokens; bar: root.bar } property url popupSource: Qt.resolvedUrl("AudioPanel.qml") readonly property url backendPanelSource: registeredSource("omarchy.audio") - property Component panelComponent: String(backendPanelSource) ? null - : registeredComponent("omarchy.audio") + property Component panelComponent: !audioServiceResolved + || nativeBackendAccessEnabled ? null + : String(backendPanelSource) ? null : registeredComponent("omarchy.audio") property var backendReadyOverride: null + readonly property bool audioServiceLookupAvailable: + backendReadyOverride !== null + || (bar !== null && bar.shell !== null + && typeof bar.shell.serviceFor === "function") + readonly property bool audioServiceResolved: + backendReadyOverride !== null + || (audioServiceLookupAvailable && audioStateService !== null) readonly property bool nativeBackendAccessEnabled: - backendReadyOverride === null - && (String(backendPanelSource) !== "" || panelComponent !== null) + backendReadyOverride === null && audioServiceResolved + && audioStateService.nativeBackendEnabled === true readonly property var tokens: bar && "visualTokens" in bar && bar.visualTokens ? bar.visualTokens : hostTokens @@ -150,11 +158,15 @@ Ui.Panel { anchors.fill: audioSurface bar: root.bar ownerWidget: root - panelComponent: root.panelComponent - panelSource: root.backendPanelSource + panelComponent: !root.audioServiceResolved + || root.nativeBackendAccessEnabled ? null : root.panelComponent + panelSource: !root.audioServiceResolved || root.nativeBackendAccessEnabled + ? "" : root.backendPanelSource panelSettings: root.officialSettings() backendReadyOverride: root.backendReadyOverride nativeBackendAccessEnabled: root.nativeBackendAccessEnabled + nativeAudioService: root.nativeBackendAccessEnabled + ? root.audioStateService : null peakValue: root.audioStateService ? root.audioStateService.inputPeak : 0 peakAcquire: root.audioStateService ? function() { return root.audioStateService.acquirePeakMonitoring() } diff --git a/hancore.shibumi.audio/Service.qml b/hancore.shibumi.audio/Service.qml index 5ade0fd..0efbc85 100644 --- a/hancore.shibumi.audio/Service.qml +++ b/hancore.shibumi.audio/Service.qml @@ -12,9 +12,8 @@ Item { property var shell: null property var manifest: null property var reports: [] - // Step 4A preparation remains opt-in until the activation commit makes this - // the sole Shibumi PipeWire owner. - property bool nativeBackendEnabled: false + // Native AudioBackendAdapter is now the sole Shibumi PipeWire owner. + property bool nativeBackendEnabled: true property var nativeBackendOverride: null readonly property bool nativeBackendReady: root.nativeBackendEnabled && nativeBackendLoader.item ? nativeBackendLoader.item.ready === true : false @@ -79,6 +78,13 @@ Item { } } + onNativeBackendOverrideChanged: { + if (!nativeBackendLoader.item) return + nativeBackendLoader.item.active = false + nativeBackendLoader.item.backendOverride = root.nativeBackendOverride + nativeBackendLoader.item.active = root.nativeBackendEnabled + } + function acquirePeakMonitoring() { const owner = root.nativeBackendEnabled ? nativeBackendLoader.item : peakBackendLoader.item @@ -146,6 +152,27 @@ Item { entityId: "", generation: 0 }) } + function nativeSetStreamVolume(id, value) { + return nativeBackendLoader.item + ? nativeBackendLoader.item.setStreamVolume(id, value) : ({ + ok: false, code: "unavailable", message: "Audio backend is unavailable", + entityId: String(id || ""), generation: 0 + }) + } + function nativeToggleStreamMute(id) { + return nativeBackendLoader.item + ? nativeBackendLoader.item.toggleStreamMute(id) : ({ + ok: false, code: "unavailable", message: "Audio backend is unavailable", + entityId: String(id || ""), generation: 0 + }) + } + function routeBluetoothDevice(request) { + return nativeBackendLoader.item + ? nativeBackendLoader.item.routeBluetoothDevice(request) : ({ + ok: false, code: "unavailable", message: "Audio backend is unavailable", + entityId: "", generation: 0 + }) + } function report(owner, available, muted) { if (!owner) return false const next = reports.filter(entry => entry.owner !== owner) diff --git a/hancore.shibumi.bluetooth/BluetoothAudioRouteAdapter.qml b/hancore.shibumi.bluetooth/BluetoothAudioRouteAdapter.qml index c17a685..6d1899b 100644 --- a/hancore.shibumi.bluetooth/BluetoothAudioRouteAdapter.qml +++ b/hancore.shibumi.bluetooth/BluetoothAudioRouteAdapter.qml @@ -13,17 +13,20 @@ Item { id: root // Tests inject a side-effect-free node list and route sink. + property bool enabled: true property var nodesOverride: null property var outputOverride: null property var readyOverride: null - readonly property bool ready: readyOverride !== null + readonly property bool ready: !enabled ? false + : readyOverride !== null ? readyOverride === true : nodesOverride !== null ? true : Pipewire.ready === true && Pipewire.nodes !== null && Pipewire.nodes !== undefined - readonly property var nodes: nodesOverride !== null + readonly property var nodes: !enabled ? [] + : nodesOverride !== null ? nodesOverride : (Pipewire.nodes ? Pipewire.nodes.values : []) diff --git a/hancore.shibumi.bluetooth/BluetoothBackendAdapter.qml b/hancore.shibumi.bluetooth/BluetoothBackendAdapter.qml index 40222a2..64d16a8 100644 --- a/hancore.shibumi.bluetooth/BluetoothBackendAdapter.qml +++ b/hancore.shibumi.bluetooth/BluetoothBackendAdapter.qml @@ -22,6 +22,7 @@ Item { property var commandRunnerOverride: null property var audioOutputOverride: null property var audioRouteOverride: null + property bool audioRouteHandoffReady: true property int audioSwitchInterval: 500 property int audioIntentTimeoutInterval: 60000 property int discoveryRequestTimeoutInterval: 1500 @@ -69,6 +70,8 @@ Item { Local.BluetoothAudioRouteAdapter { id: nativeAudioRoute + enabled: root.audioRouteHandoffReady + && root.audioRouteOverride === null && root.backendOverride === null nodesOverride: root.pipewireNodesOverride outputOverride: root.audioOutputOverride } @@ -361,6 +364,7 @@ Item { deviceName: device && device.deviceName ? device.deviceName : "" } pendingAudioOutputAttempts = 0 + audioIntentTimeout.restart() audioSwitchTimer.restart() } @@ -396,20 +400,18 @@ Item { if (routeResult.ok) { pendingAudioOutputDevice = null audioSwitchTimer.stop() + audioIntentTimeout.stop() return } - // Retry only transient route unavailability. Stale identity, invalid - // requests, and unsupported backends must not spin until the attempt cap. + // Retry only transient route unavailability. The handoff timeout bounds + // the wait while allowing slow BlueZ/PipeWire graph creation to settle. if (routeResult.code !== "unavailable") { pendingAudioOutputDevice = null audioSwitchTimer.stop() + audioIntentTimeout.stop() return } pendingAudioOutputAttempts++ - if (pendingAudioOutputAttempts >= 8) { - pendingAudioOutputDevice = null - return - } audioSwitchTimer.restart() } @@ -421,7 +423,6 @@ Item { if (device && device.connected && deviceUsesCurrentAdapter(device)) { scheduleAudioOutputSwitch(device) audioHandoffIntent = null - audioIntentTimeout.stop() } } validatePendingAudioOutput() @@ -508,7 +509,10 @@ Item { id: audioIntentTimeout interval: root.audioIntentTimeoutInterval repeat: false - onTriggered: root.audioHandoffIntent = null + onTriggered: { + root.audioHandoffIntent = null + root.cancelPendingAudioOutput("") + } } Timer { diff --git a/hancore.shibumi.bluetooth/Service.qml b/hancore.shibumi.bluetooth/Service.qml index 8f214f7..ba10a56 100644 --- a/hancore.shibumi.bluetooth/Service.qml +++ b/hancore.shibumi.bluetooth/Service.qml @@ -13,6 +13,14 @@ Item { property var manifest: null property var bar: shell ? shell.bar : null property var backendOverride: null + property var audioRouteOverride: null + readonly property bool audioServiceLookupAvailable: shell !== null + && typeof shell.serviceFor === "function" + readonly property var audioService: audioServiceLookupAvailable + ? (shell.serviceFor("hancore.shibumi.audio") || null) : null + readonly property bool audioServiceUsable: audioService !== null + && audioService.nativeBackendEnabled === true + && typeof audioService.routeBluetoothDevice === "function" property var sessionOwners: [] property int discoveryRetryInterval: 1000 property int discoveryRequestTimeoutInterval: 1500 @@ -69,6 +77,9 @@ Item { function toggleBluetooth() { return adapter.toggleBluetooth() } + function routeBluetoothDevice(request) { + return adapter.requestBluetoothAudioRoute(request) + } function connectDevice(device) { return adapter.connectDevice(device) } function disconnectDevice(device) { return adapter.disconnectDevice(device) } function forgetDevice(device) { return adapter.forgetDevice(device) } @@ -96,6 +107,13 @@ Item { BluetoothBackendAdapter { id: adapter backendOverride: root.backendOverride + audioRouteOverride: root.audioRouteOverride !== null + ? root.audioRouteOverride + : root.backendOverride === null && root.audioServiceUsable + ? root.audioService : null + audioRouteHandoffReady: root.audioRouteOverride !== null + || (root.backendOverride === null + && (!root.audioServiceLookupAvailable || root.audioServiceUsable)) discoveryDesired: root.sessionCount > 0 discoveryRequestTimeoutInterval: root.discoveryRequestTimeoutInterval } diff --git a/tests/audio-native-backend-seam-regression.qml b/tests/audio-native-backend-seam-regression.qml index 72a3e57..ae191d4 100644 --- a/tests/audio-native-backend-seam-regression.qml +++ b/tests/audio-native-backend-seam-regression.qml @@ -172,6 +172,32 @@ ShellRoot { nativeBackendOverride: fakeBackend } + QtObject { + id: fakeBar + property bool vertical: false + property int barSize: 35 + property int sizeHorizontal: 35 + property string position: "top" + property string fontFamily: "monospace" + property color background: "#111111" + property color foreground: "#eeeeee" + property color urgent: "#88bbee" + property bool foregroundAnimationEnabled: false + property var shell: null + property var activePopout: null + property var clickTargets: [] + } + + Item { id: ownerWidget } + + Audio.AudioPanelBridge { + id: nativeBridge + bar: fakeBar + ownerWidget: ownerWidget + nativeBackendAccessEnabled: true + nativeAudioService: service + } + Timer { interval: 30 repeat: true @@ -205,7 +231,11 @@ ShellRoot { || backend.audioSinks[0].node !== undefined || backend.audioSinks[0].id !== "sink:1" || backend.sinkSnapshot.id !== "sink:1" - || backend.sinkSnapshot.volume !== 0.42) + || backend.sinkSnapshot.volume !== 0.42 + || !nativeBridge.ready + || nativeBridge.audioSinks.length !== 2 + || nativeBridge.audioSinks[0].node !== undefined + || nativeBridge.officialPanelState().present) return root.fail("primitive stable sink snapshot") const routeResult = backend.routeBluetoothDevice({ address: "AA:00:00:00:00:01", @@ -258,6 +288,29 @@ ShellRoot { || !backend.toggleStreamMute("stream:8").ok || !streamAudio.muted) return root.fail("typed source/stream actions") + const nativeOutput = nativeBridge.setOutputVolume(0.47) + const nativeMutedBefore = sinkAudioB.muted + const nativeMute = nativeBridge.toggleOutputMute() + const nativeStream = nativeBridge.setStreamVolume("stream:8", 0.58) + const nativeLease = nativeBridge.acquirePeakMonitoring() + const nativePeak = nativeBridge.inputPeak + const nativeRelease = nativeBridge.releasePeakMonitoring() + if (!nativeOutput.ok + || Math.abs(sinkAudioB.volume - 0.47) > 0.001 + || !nativeMute.ok + || sinkAudioB.muted === nativeMutedBefore + || !nativeStream.ok + || Math.abs(streamAudio.volume - 0.58) > 0.001 + || !nativeLease + || Math.abs(nativePeak - 0.4) > 0.001 + || !nativeRelease) + return root.fail("native service facade handoff: "+ JSON.stringify({ + output: nativeOutput, mute: nativeMute, stream: nativeStream, + sinkVolume: sinkAudioB.volume, sinkMuted: sinkAudioB.muted, + sinkMutedBefore: nativeMutedBefore, + streamVolume: streamAudio.volume, lease: nativeLease, + peak: nativePeak, release: nativeRelease + })) root.phase++ root.ticks = 0 } else if (root.phase === 2) { diff --git a/tests/bluetooth-audio-intent-regression.qml b/tests/bluetooth-audio-intent-regression.qml index 53ec2f7..cf29722 100644 --- a/tests/bluetooth-audio-intent-regression.qml +++ b/tests/bluetooth-audio-intent-regression.qml @@ -158,6 +158,36 @@ ShellRoot { } } + QtObject { + id: serviceBackend + property var adapter: nativeAdapter + property var connectedDevices: [] + property var knownDevices: [] + property var discoveredDevices: [] + property var pendingActions: ({}) + } + + QtObject { + id: fakeShell + property var bar: null + function serviceFor(id) { + return id === "hancore.shibumi.audio" ? routeOverride : null + } + } + + Bluetooth.Service { + id: activatedService + shell: fakeShell + backendOverride: serviceBackend + audioRouteOverride: routeOverride + } + + Bluetooth.Service { + id: isolatedFakeService + shell: fakeShell + backendOverride: serviceBackend + } + Bluetooth.BluetoothBackendAdapter { id: seamBackend adapterOverride: nativeAdapter @@ -204,6 +234,16 @@ ShellRoot { if (root.phase === 0) { if (root.ticks < 2) return + const activatedRoute = activatedService.routeBluetoothDevice({ + address: deviceA.address, + name: deviceA.name, + deviceName: deviceA.deviceName + }) + const isolatedFakeRoute = isolatedFakeService.routeBluetoothDevice({ + address: deviceA.address, + name: deviceA.name, + deviceName: deviceA.deviceName + }) const unavailablePipewireRoute = pipewireNotReadyRoute.routeBluetoothDevice({ address: deviceA.address, @@ -242,14 +282,17 @@ ShellRoot { backend.audioOutputOverride = booleanFailingAudioOutput const booleanFailure = backend.requestBluetoothAudioRoute(deviceA) backend.audioOutputOverride = audioOutput - if (!seamBackend.requestBluetoothAudioRoute(deviceA).ok + if (!activatedRoute.ok + || isolatedFakeRoute.ok + || isolatedFakeRoute.code !== "unavailable" + || !seamBackend.requestBluetoothAudioRoute(deviceA).ok || unavailablePipewireRoute.ok || unavailablePipewireRoute.code !== "unavailable" || staleEmptyPipewireRoute.ok || staleEmptyPipewireRoute.code !== "stale-id" || fakeIsolationRoute.ok || fakeIsolationRoute.code !== "unsupported" - || routeOverride.count !== 1 + || routeOverride.count !== 2 || routeOverride.lastRequest.address !== deviceA.address || routeOverride.lastRequest.name !== deviceA.name || "adapter" in routeOverride.lastRequest diff --git a/tests/contract-regression.sh b/tests/contract-regression.sh index adb4381..d3dedec 100755 --- a/tests/contract-regression.sh +++ b/tests/contract-regression.sh @@ -470,7 +470,13 @@ rg -q 'G6: \["hancore.shibumi.audio"\]' core/GroupRegistry.js \ rg -q 'hancore\.shibumi\.audio' contracts/plugin-suite-v1.json \ || fail "Shibumi audio composite is not registered" rg -q 'AudioPanelBridge' hancore.shibumi.audio/BarWidget.qml \ - || fail "audio view does not preserve the official panel owner" + || fail "audio view does not use the primitive backend bridge" +rg -q 'nativeAudioService: root\.nativeBackendAccessEnabled' \ + hancore.shibumi.audio/BarWidget.qml \ + || fail "audio view is not wired to the native process-wide service" +rg -q 'property bool nativeBackendEnabled: true' \ + hancore.shibumi.audio/Service.qml \ + || fail "native audio ownership is not atomically enabled" rg -q 'popupSource: Qt\.resolvedUrl\("AudioPanel\.qml"\)' hancore.shibumi.audio/BarWidget.qml \ || fail "audio widget does not lazy-load the Shibumi mixer panel" rg -q 'return String\(pluginId \|\| ""\) === "omarchy\.audio" \? root : null' \ @@ -749,8 +755,13 @@ for bluetooth_adapter in "$bluetooth_adapter"; do || fail "$bluetooth_audio_route is missing" rg -q '^import Quickshell\.Services\.Pipewire$' "$bluetooth_audio_route" \ || fail "$bluetooth_audio_route does not own PipeWire access" - rg -q 'BluetoothAudioRouteAdapter' "$bluetooth_adapter" \ - || fail "$bluetooth_adapter does not use the audio route seam" + rg -q 'audioRouteOverride' "$bluetooth_adapter" \ + || fail "$bluetooth_adapter does not expose the process-wide audio route seam" + rg -q 'enabled: root\.audioRouteHandoffReady' "$bluetooth_adapter" \ + || fail "$bluetooth_adapter does not gate its legacy PipeWire route" + rg -q 'root\.audioRouteOverride === null && root\.backendOverride === null' \ + "$bluetooth_adapter" \ + || fail "$bluetooth_adapter does not disable its legacy PipeWire route after cutover" rg -Fq 'routeBluetoothDevice(request)' "$bluetooth_audio_route" \ || fail "$bluetooth_audio_route lacks its narrow route method" for device_signal in ConnectedDevices KnownDevices DiscoveredDevices; do @@ -785,6 +796,10 @@ if rg -q 'registeredWidget|registeredSource|registeredComponent|panelSource|pane "$bluetooth_service"; then fail "Bluetooth service still resolves or loads the complete Omarchy panel" fi +rg -q 'serviceFor\("hancore\.shibumi\.audio"\)' "$bluetooth_service" \ + || fail "Bluetooth service does not resolve the process-wide audio owner" +rg -q 'audioRouteOverride: root\.audioRouteOverride' "$bluetooth_service" \ + || fail "Bluetooth service does not hand off audio routing atomically" if rg -q 'Quickshell\.Bluetooth|Quickshell\.Services\.Pipewire|Bluetooth\.|Pipewire\.' \ "$bluetooth_widget" "$bluetooth_panel"; then fail "Bluetooth presentation bypasses the process-wide native adapter" diff --git a/tests/reactor-plugin-smoke.qml b/tests/reactor-plugin-smoke.qml index 1318937..0fbdb88 100644 --- a/tests/reactor-plugin-smoke.qml +++ b/tests/reactor-plugin-smoke.qml @@ -72,7 +72,10 @@ ShellRoot { QtObject { id: fakeNetwork; property string kind: "wifi" } - Audio.Service { id: fakeAudio } + Audio.Service { + id: fakeAudio + nativeBackendEnabled: false + } QtObject { id: fakeUpdate From 25650d0dd9b20b593d9002f66728073bf3764945 Mon Sep 17 00:00:00 2001 From: HANCORE-linux <230438592+HANCORE-linux@users.noreply.github.com> Date: Sun, 23 Aug 2026 18:08:11 +0200 Subject: [PATCH 12/18] fix: isolate telemetry temperature smoke probe --- hancore.shibumi.telemetry/Service.qml | 3 +++ hancore.shibumi.telemetry/ThermalTelemetry.qml | 5 ++++- services/ThermalTelemetry.qml | 5 ++++- shared/telemetry/ThermalTelemetry.qml | 5 ++++- tests/telemetry-plugins-smoke.qml | 2 ++ 5 files changed, 17 insertions(+), 3 deletions(-) diff --git a/hancore.shibumi.telemetry/Service.qml b/hancore.shibumi.telemetry/Service.qml index 8ddc735..ab687cf 100644 --- a/hancore.shibumi.telemetry/Service.qml +++ b/hancore.shibumi.telemetry/Service.qml @@ -8,6 +8,8 @@ Item { property string omarchyPath: "" property var shell: null property var manifest: null + // Test fixtures may keep thermal state deterministic without host probes. + property bool thermalProbeEnabled: true readonly property int contractVersion: 1 readonly property bool ready: true @@ -24,6 +26,7 @@ Item { ThermalTelemetry { id: thermalState + probeEnabled: root.thermalProbeEnabled gpuTelemetry: root.gpuTelemetry } } diff --git a/hancore.shibumi.telemetry/ThermalTelemetry.qml b/hancore.shibumi.telemetry/ThermalTelemetry.qml index 1e7bf4e..3cbb9c0 100644 --- a/hancore.shibumi.telemetry/ThermalTelemetry.qml +++ b/hancore.shibumi.telemetry/ThermalTelemetry.qml @@ -6,6 +6,8 @@ Scope { id: root property int consumers: 0 + // Tests can disable the host probe while retaining the parser/model seam. + property bool probeEnabled: true property var gpuTelemetry: null property var acquiredGpuTelemetry: null property int cpuTemperatureC: 0 @@ -60,7 +62,8 @@ Scope { } function refresh() { - if (consumers > 0 && !probe.running) probe.running = true + if (probeEnabled && consumers > 0 && !probe.running) + probe.running = true } function validTemperature(value) { diff --git a/services/ThermalTelemetry.qml b/services/ThermalTelemetry.qml index 1e7bf4e..3cbb9c0 100644 --- a/services/ThermalTelemetry.qml +++ b/services/ThermalTelemetry.qml @@ -6,6 +6,8 @@ Scope { id: root property int consumers: 0 + // Tests can disable the host probe while retaining the parser/model seam. + property bool probeEnabled: true property var gpuTelemetry: null property var acquiredGpuTelemetry: null property int cpuTemperatureC: 0 @@ -60,7 +62,8 @@ Scope { } function refresh() { - if (consumers > 0 && !probe.running) probe.running = true + if (probeEnabled && consumers > 0 && !probe.running) + probe.running = true } function validTemperature(value) { diff --git a/shared/telemetry/ThermalTelemetry.qml b/shared/telemetry/ThermalTelemetry.qml index 1e7bf4e..3cbb9c0 100644 --- a/shared/telemetry/ThermalTelemetry.qml +++ b/shared/telemetry/ThermalTelemetry.qml @@ -6,6 +6,8 @@ Scope { id: root property int consumers: 0 + // Tests can disable the host probe while retaining the parser/model seam. + property bool probeEnabled: true property var gpuTelemetry: null property var acquiredGpuTelemetry: null property int cpuTemperatureC: 0 @@ -60,7 +62,8 @@ Scope { } function refresh() { - if (consumers > 0 && !probe.running) probe.running = true + if (probeEnabled && consumers > 0 && !probe.running) + probe.running = true } function validTemperature(value) { diff --git a/tests/telemetry-plugins-smoke.qml b/tests/telemetry-plugins-smoke.qml index 3833691..bce75c2 100644 --- a/tests/telemetry-plugins-smoke.qml +++ b/tests/telemetry-plugins-smoke.qml @@ -26,6 +26,8 @@ ShellRoot { Telemetry.Service { id: telemetryService + // Keep this fixture isolated from machine-specific hwmon inventory. + thermalProbeEnabled: false } Cpu.Service { From 3f47a97cd3ba953ba3f129d2d65105f53c79dfa5 Mon Sep 17 00:00:00 2001 From: HANCORE-linux <230438592+HANCORE-linux@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:28:50 +0200 Subject: [PATCH 13/18] fix: stabilize live audio volume controls --- hancore.shibumi.audio/AudioBackendAdapter.qml | 72 +++++++++++++++++-- hancore.shibumi.audio/BarWidget.qml | 25 ++++++- .../audio-native-backend-seam-regression.qml | 4 ++ 3 files changed, 94 insertions(+), 7 deletions(-) diff --git a/hancore.shibumi.audio/AudioBackendAdapter.qml b/hancore.shibumi.audio/AudioBackendAdapter.qml index b195d44..1bae7ef 100644 --- a/hancore.shibumi.audio/AudioBackendAdapter.qml +++ b/hancore.shibumi.audio/AudioBackendAdapter.qml @@ -32,6 +32,11 @@ Item { property int volumeSinkProcessGeneration: 0 property bool volumeSinkProcessStarted: false property bool volumeSinkProcessDiscarded: false + // Keep the last primitive value while the asynchronous volume-sink + // resolver is refreshing. Opening the panel acquires peak monitoring, + // which can transiently invalidate link-derived sink state. + property real lastKnownOutputVolume: 0 + property bool lastKnownOutputMuted: false property int generation: 0 readonly property bool ready: implementation.ready() @@ -64,8 +69,63 @@ Item { volumeSinkResolutionFailed = false volumeSinkResolved = backendOverride !== null } - readonly property real outputVolume: implementation.outputVolume() - readonly property bool outputMuted: implementation.outputMuted() + readonly property real outputVolume: { + // Keep the primitive binding reactive across the asynchronous volume-sink + // resolver. The implementation method intentionally hides backend nodes, + // so these dependencies must be explicit at the public seam. + const active = root.active + const resolved = root.volumeSinkResolved + const failed = root.volumeSinkResolutionFailed + const sinkName = root.volumeSinkName + const requestGeneration = root.volumeSinkRequestGeneration + const backendReady = root.backendOverride !== null + ? root.backendOverride.ready !== false : Pipewire.ready === true + void active + void resolved + void failed + void sinkName + void requestGeneration + void backendReady + if (!backendReady) return 0 + if (!resolved) { + const sink = implementation.currentVolumeNode() + if (sink && sink.audio && sink.audio.volume !== undefined) + return Number(sink.audio.volume) + return root.lastKnownOutputVolume + } + return implementation.outputVolume() + } + onOutputVolumeChanged: { + if (root.volumeSinkResolved && root.ready) + root.lastKnownOutputVolume = outputVolume + } + readonly property bool outputMuted: { + const active = root.active + const resolved = root.volumeSinkResolved + const failed = root.volumeSinkResolutionFailed + const sinkName = root.volumeSinkName + const requestGeneration = root.volumeSinkRequestGeneration + const backendReady = root.backendOverride !== null + ? root.backendOverride.ready !== false : Pipewire.ready === true + void active + void resolved + void failed + void sinkName + void requestGeneration + void backendReady + if (!backendReady) return false + if (!resolved) { + const sink = implementation.currentVolumeNode() + if (sink && sink.audio && sink.audio.muted !== undefined) + return sink.audio.muted === true + return root.lastKnownOutputMuted + } + return implementation.outputMuted() + } + onOutputMutedChanged: { + if (root.volumeSinkResolved && root.ready) + root.lastKnownOutputMuted = outputMuted + } readonly property real inputVolume: implementation.inputVolume() readonly property bool inputMuted: implementation.inputMuted() readonly property var audioSinks: implementation.audioSinks() @@ -140,11 +200,13 @@ Item { } function currentVolumeSink() { - if (root.backendOverride === null - && (root.volumeSinkResolutionFailed || !root.volumeSinkResolved)) - return null const sink = implementation.currentSink() if (!sink || sink.ready === false) return null + // The default sink is already a stable native object while the helper + // resolves the preferred volume sink. Keep live controls usable during + // that short refresh window instead of returning an unavailable result. + if (root.backendOverride === null && !root.volumeSinkResolved) + return sink const configuredName = root.backendOverride !== null ? String(backendValue("volumeSinkName", "")) : String(root.volumeSinkName || "") diff --git a/hancore.shibumi.audio/BarWidget.qml b/hancore.shibumi.audio/BarWidget.qml index ad42473..9c3058d 100644 --- a/hancore.shibumi.audio/BarWidget.qml +++ b/hancore.shibumi.audio/BarWidget.qml @@ -46,6 +46,7 @@ Ui.Panel { readonly property bool panelLoaded: panelItem !== null readonly property bool audioReady: audioBridge.ready property bool wheelAdjustmentPending: false + property bool wheelCommitInFlight: false property real wheelTargetVolume: 0 readonly property real displayedOutputVolume: wheelAdjustmentPending ? wheelTargetVolume : audioBridge.outputVolume @@ -117,6 +118,7 @@ Ui.Panel { wheelTargetVolume = Math.max(0, Math.min(1, Math.round((base + Number(delta)) * 100) / 100)) wheelAdjustmentPending = true + wheelSettleTimer.stop() wheelCommitTimer.restart() return true } @@ -140,6 +142,7 @@ Ui.Panel { wheelCommitTimer.stop() wheelSettleTimer.stop() wheelAdjustmentPending = false + wheelCommitInFlight = false popupLoader.source = "" } reportAudioState() @@ -185,16 +188,34 @@ Ui.Panel { const result = root.setOutputVolume(root.wheelTargetVolume) if (!result || result.ok !== true) { root.wheelAdjustmentPending = false + root.wheelCommitInFlight = false return } + root.wheelCommitInFlight = true wheelSettleTimer.restart() } } Timer { id: wheelSettleTimer - interval: 300 - onTriggered: root.wheelAdjustmentPending = false + interval: 1000 + onTriggered: { + root.wheelAdjustmentPending = false + root.wheelCommitInFlight = false + } + } + + Connections { + target: audioBridge + function onOutputVolumeChanged() { + if (!root.wheelAdjustmentPending || !root.wheelCommitInFlight) + return + if (Math.abs(Number(audioBridge.outputVolume) + - root.wheelTargetVolume) > 0.005) return + root.wheelAdjustmentPending = false + root.wheelCommitInFlight = false + wheelSettleTimer.stop() + } } Item { diff --git a/tests/audio-native-backend-seam-regression.qml b/tests/audio-native-backend-seam-regression.qml index ae191d4..b1f9c1e 100644 --- a/tests/audio-native-backend-seam-regression.qml +++ b/tests/audio-native-backend-seam-regression.qml @@ -235,6 +235,8 @@ ShellRoot { || !nativeBridge.ready || nativeBridge.audioSinks.length !== 2 || nativeBridge.audioSinks[0].node !== undefined + || Math.abs(service.nativeOutputVolume - 0.42) > 0.001 + || Math.abs(nativeBridge.outputVolume - 0.42) > 0.001 || nativeBridge.officialPanelState().present) return root.fail("primitive stable sink snapshot") const routeResult = backend.routeBluetoothDevice({ @@ -297,6 +299,8 @@ ShellRoot { const nativeRelease = nativeBridge.releasePeakMonitoring() if (!nativeOutput.ok || Math.abs(sinkAudioB.volume - 0.47) > 0.001 + || Math.abs(service.nativeOutputVolume - 0.47) > 0.001 + || Math.abs(nativeBridge.outputVolume - 0.47) > 0.001 || !nativeMute.ok || sinkAudioB.muted === nativeMutedBefore || !nativeStream.ok From ff0c6ecf35bc408a9d42746b2ac033a89c9e207b Mon Sep 17 00:00:00 2001 From: HANCORE-linux <230438592+HANCORE-linux@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:40:14 +0200 Subject: [PATCH 14/18] test: align wheel settle contract --- tests/contract-regression.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/contract-regression.sh b/tests/contract-regression.sh index d3dedec..52ee064 100755 --- a/tests/contract-regression.sh +++ b/tests/contract-regression.sh @@ -503,7 +503,7 @@ fi rg -U -q 'Timer \{\n[[:space:]]*id: wheelCommitTimer\n[[:space:]]*interval: 70\n' \ hancore.shibumi.audio/BarWidget.qml \ || fail "audio wheel commit timer contract changed" -rg -U -q 'Timer \{\n[[:space:]]*id: wheelSettleTimer\n[[:space:]]*interval: 300\n' \ +rg -U -q 'Timer \{\n[[:space:]]*id: wheelSettleTimer\n[[:space:]]*interval: 1000\n' \ hancore.shibumi.audio/BarWidget.qml \ || fail "audio wheel settle timer contract changed" if rg -U -q 'Timer \{([^}]|\n)*(repeat:[[:space:]]*true|running:[[:space:]]*true)' \ From 28ceedc3c1446e15d7d430898c78b313309cf58b Mon Sep 17 00:00:00 2001 From: HANCORE-linux <230438592+HANCORE-linux@users.noreply.github.com> Date: Sun, 23 Aug 2026 23:23:03 +0200 Subject: [PATCH 15/18] fix: preserve effective audio sink during refresh --- hancore.shibumi.audio/AudioBackendAdapter.qml | 60 ++++++++++--------- hancore.shibumi.audio/BarWidget.qml | 28 +++++++-- .../audio-native-backend-seam-regression.qml | 39 +++++++++++- tests/audio-plugin-smoke.qml | 6 +- 4 files changed, 98 insertions(+), 35 deletions(-) diff --git a/hancore.shibumi.audio/AudioBackendAdapter.qml b/hancore.shibumi.audio/AudioBackendAdapter.qml index 1bae7ef..d2cb7a9 100644 --- a/hancore.shibumi.audio/AudioBackendAdapter.qml +++ b/hancore.shibumi.audio/AudioBackendAdapter.qml @@ -64,7 +64,9 @@ Item { } function invalidateVolumeSink() { - volumeSinkName = "" + // Retain the last confirmed effective sink while the resolver refreshes. + // A link update must not redirect a mutation to the default sink merely + // because the preferred DSP sink is temporarily unresolved. volumeSinkRequestGeneration++ volumeSinkResolutionFailed = false volumeSinkResolved = backendOverride !== null @@ -86,18 +88,16 @@ Item { void sinkName void requestGeneration void backendReady - if (!backendReady) return 0 - if (!resolved) { - const sink = implementation.currentVolumeNode() - if (sink && sink.audio && sink.audio.volume !== undefined) - return Number(sink.audio.volume) - return root.lastKnownOutputVolume - } - return implementation.outputVolume() + if (!active || !backendReady) return 0 + const sink = implementation.currentVolumeNode() + if (sink && sink.audio && sink.audio.volume !== undefined) + return Number(sink.audio.volume) + return root.lastKnownOutputVolume } onOutputVolumeChanged: { - if (root.volumeSinkResolved && root.ready) - root.lastKnownOutputVolume = outputVolume + const sink = implementation.currentVolumeNode() + if (root.ready && sink && sink.audio && sink.audio.volume !== undefined) + root.lastKnownOutputVolume = Number(sink.audio.volume) } readonly property bool outputMuted: { const active = root.active @@ -113,18 +113,16 @@ Item { void sinkName void requestGeneration void backendReady - if (!backendReady) return false - if (!resolved) { - const sink = implementation.currentVolumeNode() - if (sink && sink.audio && sink.audio.muted !== undefined) - return sink.audio.muted === true - return root.lastKnownOutputMuted - } - return implementation.outputMuted() + if (!active || !backendReady) return false + const sink = implementation.currentVolumeNode() + if (sink && sink.audio && sink.audio.muted !== undefined) + return sink.audio.muted === true + return root.lastKnownOutputMuted } onOutputMutedChanged: { - if (root.volumeSinkResolved && root.ready) - root.lastKnownOutputMuted = outputMuted + const sink = implementation.currentVolumeNode() + if (root.ready && sink && sink.audio && sink.audio.muted !== undefined) + root.lastKnownOutputMuted = sink.audio.muted === true } readonly property real inputVolume: implementation.inputVolume() readonly property bool inputMuted: implementation.inputMuted() @@ -202,14 +200,22 @@ Item { function currentVolumeSink() { const sink = implementation.currentSink() if (!sink || sink.ready === false) return null - // The default sink is already a stable native object while the helper - // resolves the preferred volume sink. Keep live controls usable during - // that short refresh window instead of returning an unavailable result. - if (root.backendOverride === null && !root.volumeSinkResolved) - return sink const configuredName = root.backendOverride !== null - ? String(backendValue("volumeSinkName", "")) + ? String(backendValue("volumeSinkName", root.volumeSinkName || "")) : String(root.volumeSinkName || "") + if (!root.volumeSinkResolved) { + if (!configuredName) return sink + if (String(sink.name || "") === configuredName) return sink + const nodes = implementation.currentNodes() + for (let index = 0; index < nodes.length; index++) { + const node = nodes[index] + if (node && node.isSink && !node.isStream + && String(node.name || "") === configuredName + && node.ready !== false && node.audio) + return node + } + return null + } if (!configuredName || String(sink.name || "") === configuredName) return sink const nodes = implementation.currentNodes() diff --git a/hancore.shibumi.audio/BarWidget.qml b/hancore.shibumi.audio/BarWidget.qml index 9c3058d..87162fc 100644 --- a/hancore.shibumi.audio/BarWidget.qml +++ b/hancore.shibumi.audio/BarWidget.qml @@ -47,6 +47,9 @@ Ui.Panel { readonly property bool audioReady: audioBridge.ready property bool wheelAdjustmentPending: false property bool wheelCommitInFlight: false + property int wheelCommitSerial: 0 + property int wheelInFlightSerial: 0 + property real wheelInFlightTarget: 0 property real wheelTargetVolume: 0 readonly property real displayedOutputVolume: wheelAdjustmentPending ? wheelTargetVolume : audioBridge.outputVolume @@ -117,6 +120,7 @@ Ui.Panel { ? wheelTargetVolume : audioBridge.outputVolume wheelTargetVolume = Math.max(0, Math.min(1, Math.round((base + Number(delta)) * 100) / 100)) + wheelCommitSerial++ wheelAdjustmentPending = true wheelSettleTimer.stop() wheelCommitTimer.restart() @@ -141,6 +145,7 @@ Ui.Panel { if (!audioReady) { wheelCommitTimer.stop() wheelSettleTimer.stop() + wheelCommitSerial++ wheelAdjustmentPending = false wheelCommitInFlight = false popupLoader.source = "" @@ -185,14 +190,24 @@ Ui.Panel { id: wheelCommitTimer interval: 70 onTriggered: { - const result = root.setOutputVolume(root.wheelTargetVolume) + const serial = root.wheelCommitSerial + const target = root.wheelTargetVolume + root.wheelCommitInFlight = true + root.wheelInFlightSerial = serial + root.wheelInFlightTarget = target + const result = root.setOutputVolume(target) + if (serial !== root.wheelCommitSerial) + return if (!result || result.ok !== true) { root.wheelAdjustmentPending = false root.wheelCommitInFlight = false return } - root.wheelCommitInFlight = true - wheelSettleTimer.restart() + // A native/fixture backend may acknowledge synchronously through the + // outputVolume binding. Do not re-arm the fallback after that ack. + if (root.wheelCommitInFlight + && root.wheelInFlightSerial === serial) + wheelSettleTimer.restart() } } @@ -200,6 +215,8 @@ Ui.Panel { id: wheelSettleTimer interval: 1000 onTriggered: { + if (!root.wheelAdjustmentPending || !root.wheelCommitInFlight + || root.wheelInFlightSerial !== root.wheelCommitSerial) return root.wheelAdjustmentPending = false root.wheelCommitInFlight = false } @@ -208,10 +225,11 @@ Ui.Panel { Connections { target: audioBridge function onOutputVolumeChanged() { - if (!root.wheelAdjustmentPending || !root.wheelCommitInFlight) + if (!root.wheelAdjustmentPending || !root.wheelCommitInFlight + || root.wheelInFlightSerial !== root.wheelCommitSerial) return if (Math.abs(Number(audioBridge.outputVolume) - - root.wheelTargetVolume) > 0.005) return + - root.wheelInFlightTarget) > 0.005) return root.wheelAdjustmentPending = false root.wheelCommitInFlight = false wheelSettleTimer.stop() diff --git a/tests/audio-native-backend-seam-regression.qml b/tests/audio-native-backend-seam-regression.qml index b1f9c1e..bdeef7e 100644 --- a/tests/audio-native-backend-seam-regression.qml +++ b/tests/audio-native-backend-seam-regression.qml @@ -319,6 +319,33 @@ ShellRoot { root.ticks = 0 } else if (root.phase === 2) { if (root.ticks < 2) return + fakeBackend.defaultAudioSink = sinkA + sinkB.ready = true + backend.volumeSinkName = sinkB.name + backend.volumeSinkResolved = false + const expectedRetainedVolume = sinkAudioB.volume + const retainedVolume = backend.outputVolume + const retainedResult = backend.setOutputVolume(0.23) + const retainedMute = backend.toggleOutputMute() + if (Math.abs(retainedVolume - expectedRetainedVolume) > 0.001 + || !retainedResult.ok || !retainedMute.ok + || Math.abs(sinkAudioB.volume - 0.23) > 0.001 + || !sinkAudioB.muted + || Math.abs(sinkAudioA.volume - 0.42) > 0.001) + return root.fail("refresh redirected volume mutation") + sinkB.ready = false + if (Math.abs(backend.outputVolume - 0.23) > 0.001 + || !backend.outputMuted) + return root.fail("unresolved mutation was not cached") + sinkB.ready = true + if (!backend.toggleOutputMute().ok) + return root.fail("failed to restore effective mute") + backend.volumeSinkResolved = true + backend.volumeSinkName = "" + fakeBackend.defaultAudioSink = sinkB + if (!backend.setOutputVolume(0.47).ok + || !backend.toggleOutputMute().ok) + return root.fail("failed to restore effective volume state") const sinkVolume = sinkAudioB.volume const sinkMuted = sinkAudioB.muted const streamVolume = streamAudio.volume @@ -360,6 +387,14 @@ ShellRoot { || streamAudio.volume !== streamVolume || streamAudio.muted !== streamMuted) return root.fail("unavailable nodes were mutated") + backend.volumeSinkName = sinkB.name + if (Math.abs(backend.outputVolume - sinkVolume) > 0.001 + || backend.outputMuted !== sinkMuted) + return root.fail("resolved output cache was not retained") + backend.volumeSinkResolved = false + if (Math.abs(backend.outputVolume - sinkVolume) > 0.001 + || backend.outputMuted !== sinkMuted) + return root.fail("unresolved output cache was not retained") backend.active = false root.phase++ root.ticks = 0 @@ -367,7 +402,9 @@ ShellRoot { if (root.ticks < 2) return if (backend.ready || backend.audioSinks.length !== 0 || backend.audioSources.length !== 0 - || backend.audioStreams.length !== 0) + || backend.audioStreams.length !== 0 + || backend.outputVolume !== 0 + || backend.outputMuted) return root.fail("inactive native backend remained active") stop() console.log("audio native backend seam regression passed") diff --git a/tests/audio-plugin-smoke.qml b/tests/audio-plugin-smoke.qml index 894f4bb..33e8979 100644 --- a/tests/audio-plugin-smoke.qml +++ b/tests/audio-plugin-smoke.qml @@ -155,8 +155,10 @@ ShellRoot { if (root.phaseTicks < 3) return const official = audio.officialPanelState() if (official.outputVolumeChanges !== root.wheelCallsBefore + 1 - || Math.abs(official.outputVolume - 0.52) > 0.001) - return root.fail("wheel burst was not coalesced into one final write") + || Math.abs(official.outputVolume - 0.52) > 0.001 + || audio.wheelAdjustmentPending + || audio.wheelCommitInFlight) + return root.fail("wheel burst was not coalesced and acknowledged") audio.interactionTarget.triggerPress(Qt.LeftButton) const closedOfficial = audio.officialPanelState() if (!audio.opened || closedOfficial.opened From c769ac13c0e998fcd4bf6713c4bf2645009a1c38 Mon Sep 17 00:00:00 2001 From: HANCORE-linux <230438592+HANCORE-linux@users.noreply.github.com> Date: Sun, 23 Aug 2026 23:31:43 +0200 Subject: [PATCH 16/18] fix: align audio volume label typography --- hancore.shibumi.audio/AudioPanel.qml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hancore.shibumi.audio/AudioPanel.qml b/hancore.shibumi.audio/AudioPanel.qml index 93d23f3..6e6515c 100644 --- a/hancore.shibumi.audio/AudioPanel.qml +++ b/hancore.shibumi.audio/AudioPanel.qml @@ -220,7 +220,7 @@ ShibumiPanel { ? Commons.Util.alpha(panel.controlAccent, 0.4) : panel.controlAccent font.family: panel.bar ? panel.bar.fontFamily : Commons.Style.font.family - font.pixelSize: 11 + font.pixelSize: Commons.Style.font.body font.weight: Font.Medium renderType: Text.NativeRendering } @@ -358,7 +358,7 @@ ShibumiPanel { ? Commons.Util.alpha(panel.controlAccent, 0.4) : panel.controlAccent font.family: panel.bar ? panel.bar.fontFamily : Commons.Style.font.family - font.pixelSize: 11 + font.pixelSize: Commons.Style.font.body font.weight: Font.Medium horizontalAlignment: Text.AlignRight renderType: Text.NativeRendering From e94e9acc802d5e40a341414053951b8e5a9beac0 Mon Sep 17 00:00:00 2001 From: HANCORE-linux <230438592+HANCORE-linux@users.noreply.github.com> Date: Sun, 23 Aug 2026 23:36:28 +0200 Subject: [PATCH 17/18] fix: right-align output volume label --- hancore.shibumi.audio/AudioPanel.qml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/hancore.shibumi.audio/AudioPanel.qml b/hancore.shibumi.audio/AudioPanel.qml index 6e6515c..2c84d18 100644 --- a/hancore.shibumi.audio/AudioPanel.qml +++ b/hancore.shibumi.audio/AudioPanel.qml @@ -212,7 +212,7 @@ ShibumiPanel { height: Commons.Style.space(30) Text { - anchors.horizontalCenter: parent.horizontalCenter + anchors.right: parent.right anchors.top: parent.top text: panel.outputMuted ? "Muted" : Math.round(panel.outputVolume * 100) + "%" @@ -222,6 +222,7 @@ ShibumiPanel { font.family: panel.bar ? panel.bar.fontFamily : Commons.Style.font.family font.pixelSize: Commons.Style.font.body font.weight: Font.Medium + horizontalAlignment: Text.AlignRight renderType: Text.NativeRendering } From 6ab399535ace4555c85accc204c21267a47c2e95 Mon Sep 17 00:00:00 2001 From: HANCORE-linux <230438592+HANCORE-linux@users.noreply.github.com> Date: Sun, 23 Aug 2026 23:59:13 +0200 Subject: [PATCH 18/18] chore(release): prepare 0.1.1-beta.11 --- .github/release-notes/v0.1.1-beta.11.md | 37 ++++++++++++++++ CHANGELOG.md | 24 ++++++++--- README.md | 4 +- VERSION | 2 +- contracts/plugin-suite-v1.json | 2 +- docs/architecture/quattro-compatibility.md | 2 +- docs/development/packaging.md | 2 +- docs/getting-started.md | 2 +- docs/install.md | 2 +- docs/plugins/README.md | 2 +- docs/release-readiness.md | 45 ++++++++++---------- docs/step1d-notifications-validation.md | 4 +- hancore.shibumi.ai/manifest.json | 2 +- hancore.shibumi.audio/manifest.json | 2 +- hancore.shibumi.bar/manifest.json | 2 +- hancore.shibumi.battery/manifest.json | 2 +- hancore.shibumi.bluetooth/manifest.json | 2 +- hancore.shibumi.brightness/manifest.json | 2 +- hancore.shibumi.center/manifest.json | 2 +- hancore.shibumi.control-center/manifest.json | 2 +- hancore.shibumi.cpu/manifest.json | 2 +- hancore.shibumi.gpu/manifest.json | 2 +- hancore.shibumi.media/manifest.json | 2 +- hancore.shibumi.memory/manifest.json | 2 +- hancore.shibumi.network/manifest.json | 2 +- hancore.shibumi.power-profile/manifest.json | 2 +- hancore.shibumi.power-state/manifest.json | 2 +- hancore.shibumi.quick-access/manifest.json | 2 +- hancore.shibumi.reactor/manifest.json | 2 +- hancore.shibumi.state/manifest.json | 2 +- hancore.shibumi.status/manifest.json | 2 +- hancore.shibumi.storage/manifest.json | 2 +- hancore.shibumi.telemetry/manifest.json | 2 +- hancore.shibumi.temperature/manifest.json | 2 +- hancore.shibumi.update-center/manifest.json | 2 +- hancore.shibumi.workspaces/manifest.json | 2 +- packaging/aur/.SRCINFO | 6 +-- packaging/aur/PKGBUILD | 4 +- packaging/package-metadata.json | 2 +- tests/test_package_release.py | 2 +- tests/test_shibumi_health.py | 12 +++--- tests/test_shibumi_suite.py | 20 ++++----- 42 files changed, 135 insertions(+), 87 deletions(-) create mode 100644 .github/release-notes/v0.1.1-beta.11.md diff --git a/.github/release-notes/v0.1.1-beta.11.md b/.github/release-notes/v0.1.1-beta.11.md new file mode 100644 index 0000000..b1e7be5 --- /dev/null +++ b/.github/release-notes/v0.1.1-beta.11.md @@ -0,0 +1,37 @@ +# Shibumi 0.1.1-beta.11 + +## Summary + +Beta.11 integrates the Step 2, Step 3, Step 4A, and Step 4B work on the exact +clean candidate `e94e9ac`. It activates the native Audio and Bluetooth owners, +keeps the 24-plugin/one-Quickshell-process contract, and records physical local +Audio and Bluetooth acceptance. + +## Included + +- Compact production-boundary completion from Step 2 +- Bluetooth audio-route seam from Step 3 +- Native PipeWire Audio and BlueZ/Bluetooth ownership +- Primitive Audio panel seam with typed action results and stable IDs +- Live output/input/stream volume and mute controls +- Coalesced mouse-wheel volume updates with acknowledgement and stale-update protection +- Jabra Evolve2 55 native Bluetooth pairing, audio routing, disconnect/reconnect, + and remove/fresh-pair/reconnect acceptance +- Consistent and right-aligned Audio panel percentage typography +- 24 managed plugins with one Quickshell process and exact live payload identity + +## Validation + +- Complete local contract regression: passed +- Audio/media plugin regression: passed +- Bluetooth plugin regression: passed +- Local physical Audio acceptance: passed +- Local physical Bluetooth acceptance: passed +- Local live shell: one Quickshell process, `ping: ok` + +## Deferred gates + +Beta.11 is a public beta candidate. Physical second-display, +mixed-scale/hotplug, and enterprise-Wi-Fi acceptance remain separate stable +release gates. Machine2 validation is intentionally out of scope for this +candidate. diff --git a/CHANGELOG.md b/CHANGELOG.md index 822de39..a935a38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,22 +1,32 @@ # Changelog -## 0.1.1-beta.10: Official Omarchy baseline and forward compatibility +## 0.1.1-beta.11: Native Audio and Bluetooth cutover -Release candidate for 2026-08-21. +Public beta candidate for 2026-08-23. ### Added -- Added permanent regression coverage for Omarchy's `connectDirectly()` Network action -- Added official Omarchy `4.0.0-1`/`v4.0.0` compatibility evidence and forward baseline metadata +- Completed the Step 2 compact production-boundary transition and Step 3 Bluetooth audio-route seam +- Activated native PipeWire Audio and BlueZ/Bluetooth owners without duplicating backend ownership +- Added primitive Audio panel snapshots, stable IDs, typed action results, and live app-stream controls +- Added local physical acceptance for Audio and a Jabra Evolve2 55 Bluetooth output ### Changed -- Preserved `connectKnown()` on stable Omarchy while accepting `connectDirectly()` on the forward host -- Kept `omarchy-dev` as a diagnostic-only development-host fallback without changing the official release baseline +- Kept the 24-plugin contract and one production Quickshell process +- Coalesced mouse-wheel volume bursts and retained the effective audio sink during asynchronous refresh +- Aligned output, microphone, and app percentage typography and right-aligned the output value + +### Fixed + +- Prevented stale resolver updates from redirecting volume mutations to the default sink +- Preserved last-known output volume/mute state across temporary sink loss and reconnects +- Completed Bluetooth remove, fresh-pair, reconnect, route, and restore validation ### Known limits -- Physical multi-output, mixed-scale, hardware, and full visual-freeze acceptance remain explicit release gates +- Physical second-display, mixed-scale/hotplug, and enterprise-Wi-Fi acceptance remain stable-release gates +- Machine2 validation is intentionally out of scope for this public beta - AUR publication remains unavailable until the package name can be registered ## 0.1.1-beta.9: Host-owned Notifications compatibility diff --git a/README.md b/README.md index 49ecdcb..ab899c9 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ process.

[Documentation](docs/README.md) ยท [Release status](docs/release-readiness.md) -The current `0.1.1-beta.10` candidate is reviewed against Omarchy +The current `0.1.1-beta.11` candidate is reviewed against Omarchy `4.0.0-1` (source tag `v4.0.0`, revision `f0020448`) and Quickshell `0.3.0.r20.g28771c7-1`. See the [Shibumi host compatibility record](docs/architecture/quattro-compatibility.md) @@ -58,7 +58,7 @@ for the latest validated host versions. ## Install > [!IMPORTANT] -> `0.1.1-beta.10` is a package candidate. AUR registration is currently +> `0.1.1-beta.11` is a package candidate. AUR registration is currently > unavailable, so the package is not published there yet. > Until its release asset and AUR package are published, use the source path > below. Shibumi supports Omarchy Quattro only. diff --git a/VERSION b/VERSION index 468cab4..b727b0c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.1-beta.10 +0.1.1-beta.11 diff --git a/contracts/plugin-suite-v1.json b/contracts/plugin-suite-v1.json index 8b60db8..2ba5537 100644 --- a/contracts/plugin-suite-v1.json +++ b/contracts/plugin-suite-v1.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "suiteId": "hancore.shibumi", - "suiteVersion": "0.1.1-beta.10", + "suiteVersion": "0.1.1-beta.11", "hostContract": "contracts/host-facade-v1.json", "retiredPlugins": ["hancore.shibumi.menu"], "plugins": [ diff --git a/docs/architecture/quattro-compatibility.md b/docs/architecture/quattro-compatibility.md index ccb4fd5..b75af1f 100644 --- a/docs/architecture/quattro-compatibility.md +++ b/docs/architecture/quattro-compatibility.md @@ -8,7 +8,7 @@ not yet passed Shibumi's release gates. ## Current tested host -The `0.1.1-beta.10` candidate is reviewed against the current official +The `0.1.1-beta.11` candidate is reviewed against the current official Omarchy Quattro baseline: | Component | Observed value | diff --git a/docs/development/packaging.md b/docs/development/packaging.md index c4c6438..5837ff4 100644 --- a/docs/development/packaging.md +++ b/docs/development/packaging.md @@ -1,6 +1,6 @@ # Arch packaging and AUR publication -Status: `0.1.1-beta.10` candidate contract +Status: `0.1.1-beta.11` candidate contract Shibumi ships one versioned suite containing 24 separately validated Omarchy Quattro plugin roots. Pacman owns the immutable program files; the Shibumi diff --git a/docs/getting-started.md b/docs/getting-started.md index a03229b..123f83f 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -62,7 +62,7 @@ before editing JSON or suite state by hand. ## Know the beta boundary -Version `0.1.1-beta.10` is the current Arch-package candidate. Its package and +Version `0.1.1-beta.11` is the current Arch-package candidate. Its package and source contracts are tested locally; AUR publication and the complete package lifecycle on the validation system remain release gates. Shibumi supports Omarchy Quattro only. The exact accepted host packages are recorded in the diff --git a/docs/install.md b/docs/install.md index 995b80b..3428b0e 100644 --- a/docs/install.md +++ b/docs/install.md @@ -20,7 +20,7 @@ The exact accepted Omarchy and Quickshell packages are recorded in the ## Install from the Arch package > [!NOTE] -> AUR registration is currently unavailable, so `0.1.1-beta.10` is not +> AUR registration is currently unavailable, so `0.1.1-beta.11` is not > published there yet. This is the supported flow once AUR access returns and > the package is released. diff --git a/docs/plugins/README.md b/docs/plugins/README.md index a8aafa8..43ef213 100644 --- a/docs/plugins/README.md +++ b/docs/plugins/README.md @@ -2,7 +2,7 @@ Status: reference -Shibumi `0.1.1-beta.10` contains 24 independently registered Omarchy plugins. The +Shibumi `0.1.1-beta.11` contains 24 independently registered Omarchy plugins. The default suite profile installs all of them as one verified transaction. The authoritative inventory is diff --git a/docs/release-readiness.md b/docs/release-readiness.md index 60ed89d..d66d701 100644 --- a/docs/release-readiness.md +++ b/docs/release-readiness.md @@ -1,16 +1,16 @@ -# Is Shibumi 0.1.1-beta.10 ready for prerelease testing? +# Is Shibumi 0.1.1-beta.11 ready for prerelease testing? > **Document status: Current validation and release gate.** This page records the latest Shibumi evidence. It cannot override [`../ARCHITECTURE.md`](../ARCHITECTURE.md). -Shibumi `0.1.1-beta.10` carries the host-owned Notifications compatibility -result merged as `6e963f0` plus the current baseline and forward-compatibility -follow-up. Its complete contract remains +Shibumi `0.1.1-beta.11` carries the Step 2, Step 3, Step 4A, and Step 4B +integration on the exact clean candidate. Its complete contract remains revision-bound across the installed-package, installed-source-parity, and forward-compatibility proof axes. Destructive live Wayland acceptance is retained only for the exact revisions that produced it; the release workflow -must record fresh checksummed evidence for the tagged candidate. Physical -multi-monitor, enterprise Wi-Fi, and the remaining Bluetooth workflows still -block a stable public release. +must record fresh checksummed evidence for the tagged candidate. Local physical +Audio and Bluetooth acceptance passed. Physical second-display, mixed-scale, +hotplug, and enterprise-Wi-Fi gates remain stable-release gates; Machine2 is +intentionally outside this candidate's scope. ## Current test target @@ -55,13 +55,14 @@ Validation passed on the dedicated Step 2 worktree based on `e92d8ee`: - documentation, plugin-suite, AUR scaffold, `git diff --check`, and the complete pinned contract regression passed. -No Audio, Network, Bluetooth, or other backend cutover was performed. Beta.10, -its tag, release, and the consumed Omarchy baselines remain unchanged. +The Step 2 boundary remains the base contract. Step 3's Bluetooth audio-route +seam and Step 4A/4B native Audio/Bluetooth cutover are integrated in this +candidate; the consumed Omarchy baselines remain unchanged. ## Prerelease acceptance summary -The beta.10 source candidate plus explicitly retained prior acceptance currently -provides these results. Rows marked historical do not become beta.10 evidence +The beta.11 source candidate plus explicitly retained prior acceptance currently +provides these results. Rows marked historical do not become beta.11 evidence until the revision-bound release collector reruns them: | Gate | Result | @@ -82,7 +83,7 @@ until the revision-bound release collector reruns them: | Real Vesktop notification path | Passed: authoritative host history records `app: "vesktop"` and `appIcon: "vesktop"` | | INC-013 convergence contract | Passed: 14 of 14 | | Baseline locale matrix | Passed under C, C UTF-8, and en_US UTF-8 | -| Transactional live update | Beta.9 to beta.10 exact final-commit update and clean-commit collector still required | +| Transactional live update | Beta.10 to beta.11 exact final-commit update and clean-commit collector still required | | Generic plugin-manager recovery | Passed: individual Bluetooth disable detected and repaired transactionally | | Ownership repair | Passed: all 24 current markerless plugins adopted and marked | | Bar continuity | Passed: Shibumi to Omarchy to Shibumi | @@ -91,7 +92,7 @@ until the revision-bound release collector reruns them: | Network label runtime | Passed on Ethernet and Wi-Fi across repeated V1, V2, Omarchy, and V2-return transitions | | Current QML log | Passed: no Shibumi type, reference, loader, or binding-loop error in the final unpinned status smoke | | Control Center **Bars** view | Passed on the physical Wayland session | -| Bluetooth connection and panel | Passed with a live connected phone | +| Bluetooth connection and panel | Passed with a live Jabra Evolve2 55: fresh remove/pair/trust/connect, native audio route, disconnect/reconnect, and restored state | | Idle/screensaver panel cleanup | Passed in the bar-host regression and deployed live | | Temperature sources | Passed: CPU and core live; absent sources disabled | | Workspace styles | Passed: seven supported styles and geometry checks | @@ -143,19 +144,18 @@ The theme updater disables Git hooks, executable filters, prompts, and external The V1 and V2 shell-update interface is adapted to `shibumi-suite`. The Update Center checks Arch packages and installed Git themes. It doesn't fetch a new Shibumi source revision. -## Private alpha limits +## Public beta limits -The beta.10 prerelease may be committed, tagged, and pushed to the private repository with these limits: +The beta.11 public beta may be committed, tagged, and pushed with these limits: - The complete visual state matrix remains partial for uncommon hover, degraded, account-backed, and device-backed states - the validation system has no physical second display for mixed-scale, hotplug, or unplug-during-drag acceptance - No enterprise Wi-Fi credentials were supplied for a real authentication test -- A live Bluetooth phone connection and panel pass; pairing, audio routing, - disconnect, and forget still need complete physical acceptance +- Local Bluetooth pairing, audio routing, disconnect/reconnect, and forget + passed with the Jabra Evolve2 55 - A Shibumi update starts from a trusted repository checkout -- Step 1D's final tab/layout deployment and Machine 2 bar-continuity transition - were validated; a final physical Machine 2 visual-freeze comparison remains - unclaimed +- Machine2 validation is intentionally excluded from the beta.11 acceptance + target Fixtures cover unavailable and error behavior, but they don't replace the physical gates. The predecessor's multi-monitor and mixed-scale implementation is mapped into the Shibumi source and automated contracts, but inherited behavior does not @@ -163,12 +163,13 @@ replace a physical Shibumi run on the target Quattro release. ## Public release blockers -Before making the repository public: +Before making a stable `v0.1.1` repository release: 1. Complete the remaining rows in [`../contracts/v1-state-matrix.json`](../contracts/v1-state-matrix.json) 2. Test a physical second display, mixed scale, hotplug, and unplug during drag 3. Test a real enterprise Wi-Fi authentication failure and reconnect -4. Test Bluetooth pairing, audio routing, disconnect, and forget +4. Repeat physical Bluetooth pairing, audio routing, disconnect, and forget + on the stable-release target if the beta.11 local evidence is not adopted 5. Repeat the complete validation contract on the exact public-release commit ## Release evidence diff --git a/docs/step1d-notifications-validation.md b/docs/step1d-notifications-validation.md index 264a42a..a1a518a 100644 --- a/docs/step1d-notifications-validation.md +++ b/docs/step1d-notifications-validation.md @@ -1,6 +1,6 @@ # Step 1D Notifications compatibility validation -Status: complete for the beta.10 candidate. This slice keeps Omarchy as the +Status: complete for the beta.11 candidate. This slice keeps Omarchy as the sole Notifications owner and adds only a Shibumi primitive-row adapter; it does not create a second notification daemon or server. @@ -68,6 +68,6 @@ The installed-package and source-parity gates now use the official Omarchy revision-bound snapshots. This slice remains separate from the final physical multi-output, mixed-scale, hardware, and visual-freeze release gates. -Step 1D remains complete. The beta.10 release checkpoint still requires clean +Step 1D remains complete. The beta.11 release checkpoint still requires clean final-commit evidence, independent review, and explicit recording of any unavailable physical visual or interaction gates. diff --git a/hancore.shibumi.ai/manifest.json b/hancore.shibumi.ai/manifest.json index 2258e5d..80ab2b0 100644 --- a/hancore.shibumi.ai/manifest.json +++ b/hancore.shibumi.ai/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "id": "hancore.shibumi.ai", "name": "Shibumi AI Usage", - "version": "0.1.1-beta.10", + "version": "0.1.1-beta.11", "author": "HANCORE", "license": "MIT", "description": "Shibumi Claude, Codex, and OpenCode usage presentation.", diff --git a/hancore.shibumi.audio/manifest.json b/hancore.shibumi.audio/manifest.json index ac1f2f0..d08d486 100644 --- a/hancore.shibumi.audio/manifest.json +++ b/hancore.shibumi.audio/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "id": "hancore.shibumi.audio", "name": "Shibumi Audio", - "version": "0.1.1-beta.10", + "version": "0.1.1-beta.11", "author": "HANCORE", "license": "MIT", "description": "Shibumi audio presentation over Omarchy's PipeWire owner.", diff --git a/hancore.shibumi.bar/manifest.json b/hancore.shibumi.bar/manifest.json index 703b622..bd6b625 100644 --- a/hancore.shibumi.bar/manifest.json +++ b/hancore.shibumi.bar/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "id": "hancore.shibumi.bar", "name": "Shibumi Bar", - "version": "0.1.1-beta.10", + "version": "0.1.1-beta.11", "author": "HANCORE", "license": "MIT", "description": "The Shibumi full-bar host for Omarchy Shell.", diff --git a/hancore.shibumi.battery/manifest.json b/hancore.shibumi.battery/manifest.json index 99da02b..a56f25f 100644 --- a/hancore.shibumi.battery/manifest.json +++ b/hancore.shibumi.battery/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "id": "hancore.shibumi.battery", "name": "Shibumi Battery", - "version": "0.1.1-beta.10", + "version": "0.1.1-beta.11", "author": "HANCORE", "license": "MIT", "description": "Shibumi battery status and detailed battery panel.", diff --git a/hancore.shibumi.bluetooth/manifest.json b/hancore.shibumi.bluetooth/manifest.json index c691508..f0c3bb4 100644 --- a/hancore.shibumi.bluetooth/manifest.json +++ b/hancore.shibumi.bluetooth/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "id": "hancore.shibumi.bluetooth", "name": "Shibumi Bluetooth", - "version": "0.1.1-beta.10", + "version": "0.1.1-beta.11", "author": "HANCORE", "license": "MIT", "description": "Shibumi Bluetooth presentation with a native BlueZ and audio backend.", diff --git a/hancore.shibumi.brightness/manifest.json b/hancore.shibumi.brightness/manifest.json index 15ff22f..03bc54e 100644 --- a/hancore.shibumi.brightness/manifest.json +++ b/hancore.shibumi.brightness/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "id": "hancore.shibumi.brightness", "name": "Shibumi Brightness", - "version": "0.1.1-beta.10", + "version": "0.1.1-beta.11", "author": "HANCORE", "license": "MIT", "description": "Shibumi brightness and display presentation over Omarchy's monitor owner.", diff --git a/hancore.shibumi.center/manifest.json b/hancore.shibumi.center/manifest.json index c690622..dca1042 100644 --- a/hancore.shibumi.center/manifest.json +++ b/hancore.shibumi.center/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "id": "hancore.shibumi.center", "name": "Shibumi Center", - "version": "0.1.1-beta.10", + "version": "0.1.1-beta.11", "author": "HANCORE", "license": "MIT", "description": "Shibumi clock, calendar, weather, status, and update center.", diff --git a/hancore.shibumi.control-center/manifest.json b/hancore.shibumi.control-center/manifest.json index f91a4ef..799ae9c 100644 --- a/hancore.shibumi.control-center/manifest.json +++ b/hancore.shibumi.control-center/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "id": "hancore.shibumi.control-center", "name": "Shibumi Control Center", - "version": "0.1.1-beta.10", + "version": "0.1.1-beta.11", "author": "HANCORE", "license": "MIT", "description": "Shibumi G1 presentation and bar configuration panel.", diff --git a/hancore.shibumi.cpu/manifest.json b/hancore.shibumi.cpu/manifest.json index fe022ad..7304b53 100644 --- a/hancore.shibumi.cpu/manifest.json +++ b/hancore.shibumi.cpu/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "id": "hancore.shibumi.cpu", "name": "Shibumi CPU", - "version": "0.1.1-beta.10", + "version": "0.1.1-beta.11", "author": "HANCORE", "license": "MIT", "description": "Shibumi CPU widget, GPU sampler, and detailed usage panel.", diff --git a/hancore.shibumi.gpu/manifest.json b/hancore.shibumi.gpu/manifest.json index 1d3ff43..288f801 100644 --- a/hancore.shibumi.gpu/manifest.json +++ b/hancore.shibumi.gpu/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "id": "hancore.shibumi.gpu", "name": "Shibumi GPU", - "version": "0.1.1-beta.10", + "version": "0.1.1-beta.11", "author": "HANCORE", "license": "MIT", "description": "Lazy GPU load, hardware, driver, temperature, and VRAM widget.", diff --git a/hancore.shibumi.media/manifest.json b/hancore.shibumi.media/manifest.json index c9d8166..7e23d7d 100644 --- a/hancore.shibumi.media/manifest.json +++ b/hancore.shibumi.media/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "id": "hancore.shibumi.media", "name": "Shibumi Media", - "version": "0.1.1-beta.10", + "version": "0.1.1-beta.11", "author": "HANCORE", "license": "MIT", "description": "Shibumi media controls over Omarchy's MPRIS service.", diff --git a/hancore.shibumi.memory/manifest.json b/hancore.shibumi.memory/manifest.json index dbe99d0..09e5dc8 100644 --- a/hancore.shibumi.memory/manifest.json +++ b/hancore.shibumi.memory/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "id": "hancore.shibumi.memory", "name": "Shibumi Memory", - "version": "0.1.1-beta.10", + "version": "0.1.1-beta.11", "author": "HANCORE", "license": "MIT", "description": "Shibumi memory widget and detailed memory panel.", diff --git a/hancore.shibumi.network/manifest.json b/hancore.shibumi.network/manifest.json index 6f8b1b1..5ab2eed 100644 --- a/hancore.shibumi.network/manifest.json +++ b/hancore.shibumi.network/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "id": "hancore.shibumi.network", "name": "Shibumi Network", - "version": "0.1.1-beta.10", + "version": "0.1.1-beta.11", "author": "HANCORE", "license": "MIT", "description": "Shibumi network presentation over Omarchy's NetworkManager owner.", diff --git a/hancore.shibumi.power-profile/manifest.json b/hancore.shibumi.power-profile/manifest.json index 31c8a5a..6ff5ad9 100644 --- a/hancore.shibumi.power-profile/manifest.json +++ b/hancore.shibumi.power-profile/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "id": "hancore.shibumi.power-profile", "name": "Shibumi Power Profile", - "version": "0.1.1-beta.10", + "version": "0.1.1-beta.11", "author": "HANCORE", "license": "MIT", "description": "Shibumi power-profile status and profile selector.", diff --git a/hancore.shibumi.power-state/manifest.json b/hancore.shibumi.power-state/manifest.json index 2d2c453..8c1c2e9 100644 --- a/hancore.shibumi.power-state/manifest.json +++ b/hancore.shibumi.power-state/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "id": "hancore.shibumi.power-state", "name": "Shibumi Power State", - "version": "0.1.1-beta.10", + "version": "0.1.1-beta.11", "author": "HANCORE", "license": "MIT", "description": "Shared battery and power-profile state for Shibumi widgets.", diff --git a/hancore.shibumi.quick-access/manifest.json b/hancore.shibumi.quick-access/manifest.json index 70b1d44..5291deb 100644 --- a/hancore.shibumi.quick-access/manifest.json +++ b/hancore.shibumi.quick-access/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "id": "hancore.shibumi.quick-access", "name": "Shibumi Quick Access", - "version": "0.1.1-beta.10", + "version": "0.1.1-beta.11", "author": "HANCORE", "license": "MIT", "description": "Shibumi idle inhibitor and theme, wallpaper, screenshot, and video pickers.", diff --git a/hancore.shibumi.reactor/manifest.json b/hancore.shibumi.reactor/manifest.json index 7251309..c982f33 100644 --- a/hancore.shibumi.reactor/manifest.json +++ b/hancore.shibumi.reactor/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "id": "hancore.shibumi.reactor", "name": "Shibumi Reactor", - "version": "0.1.1-beta.10", + "version": "0.1.1-beta.11", "author": "HANCORE", "license": "MIT", "description": "Shibumi event and quote Reactor service for compatible bar styles.", diff --git a/hancore.shibumi.state/manifest.json b/hancore.shibumi.state/manifest.json index dab70f0..54f659e 100644 --- a/hancore.shibumi.state/manifest.json +++ b/hancore.shibumi.state/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "id": "hancore.shibumi.state", "name": "Shibumi State", - "version": "0.1.1-beta.10", + "version": "0.1.1-beta.11", "author": "HANCORE", "license": "MIT", "description": "Normalized Shibumi configuration and theme palette state.", diff --git a/hancore.shibumi.status/manifest.json b/hancore.shibumi.status/manifest.json index ca64cca..809219b 100644 --- a/hancore.shibumi.status/manifest.json +++ b/hancore.shibumi.status/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "id": "hancore.shibumi.status", "name": "Shibumi Status", - "version": "0.1.1-beta.10", + "version": "0.1.1-beta.11", "author": "HANCORE", "license": "MIT", "description": "Shibumi tray and notification composition with shared desktop status state.", diff --git a/hancore.shibumi.storage/manifest.json b/hancore.shibumi.storage/manifest.json index 94f830e..9ac2442 100644 --- a/hancore.shibumi.storage/manifest.json +++ b/hancore.shibumi.storage/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "id": "hancore.shibumi.storage", "name": "Shibumi Storage", - "version": "0.1.1-beta.10", + "version": "0.1.1-beta.11", "author": "HANCORE", "license": "MIT", "description": "Read-only root usage and physical drive inventory.", diff --git a/hancore.shibumi.telemetry/manifest.json b/hancore.shibumi.telemetry/manifest.json index b25946f..bc0f8f6 100644 --- a/hancore.shibumi.telemetry/manifest.json +++ b/hancore.shibumi.telemetry/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "id": "hancore.shibumi.telemetry", "name": "Shibumi Telemetry", - "version": "0.1.1-beta.10", + "version": "0.1.1-beta.11", "author": "HANCORE", "license": "MIT", "description": "Demand-driven CPU and memory telemetry shared by Shibumi widgets.", diff --git a/hancore.shibumi.temperature/manifest.json b/hancore.shibumi.temperature/manifest.json index b4ef4e1..cc70011 100644 --- a/hancore.shibumi.temperature/manifest.json +++ b/hancore.shibumi.temperature/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "id": "hancore.shibumi.temperature", "name": "Shibumi Temperature", - "version": "0.1.1-beta.10", + "version": "0.1.1-beta.11", "author": "HANCORE", "license": "MIT", "description": "Demand-driven hardware temperature widget and sensor panel.", diff --git a/hancore.shibumi.update-center/manifest.json b/hancore.shibumi.update-center/manifest.json index 156f96c..29ee9b9 100644 --- a/hancore.shibumi.update-center/manifest.json +++ b/hancore.shibumi.update-center/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "id": "hancore.shibumi.update-center", "name": "Shibumi Update Center", - "version": "0.1.1-beta.10", + "version": "0.1.1-beta.11", "author": "HANCORE", "license": "MIT", "description": "Safe repository package and user theme update status for Shibumi.", diff --git a/hancore.shibumi.workspaces/manifest.json b/hancore.shibumi.workspaces/manifest.json index 30d6a72..1284637 100644 --- a/hancore.shibumi.workspaces/manifest.json +++ b/hancore.shibumi.workspaces/manifest.json @@ -2,7 +2,7 @@ "schemaVersion": 1, "id": "hancore.shibumi.workspaces", "name": "Shibumi Workspaces", - "version": "0.1.1-beta.10", + "version": "0.1.1-beta.11", "author": "HANCORE", "license": "MIT", "description": "Shared Hyprland workspace state with the Shibumi workspace presentation.", diff --git a/packaging/aur/.SRCINFO b/packaging/aur/.SRCINFO index 46fbf90..592f7be 100644 --- a/packaging/aur/.SRCINFO +++ b/packaging/aur/.SRCINFO @@ -1,6 +1,6 @@ pkgbase = shibumi-shell pkgdesc = Transactional Shibumi desktop shell plugins for Omarchy Quattro - pkgver = 0.1.1beta.10 + pkgver = 0.1.1beta.11 pkgrel = 1 url = https://github.com/HANCORE-linux/Shibumi-Shell arch = any @@ -26,7 +26,7 @@ pkgbase = shibumi-shell optdepends = btop: battery panel system monitor action optdepends = nvidia-utils: NVIDIA GPU telemetry optdepends = git: source-checkout diagnostics and migration - source = shibumi-shell-0.1.1-beta.10.tar.gz::https://github.com/HANCORE-linux/Shibumi-Shell/releases/download/v0.1.1-beta.10/shibumi-shell-0.1.1-beta.10.tar.gz - sha256sums = 63a209a81f731f84d665c6a499a9f66c3d98a0b2e4690d4a6feb5f72b1ecc12c + source = shibumi-shell-0.1.1-beta.11.tar.gz::https://github.com/HANCORE-linux/Shibumi-Shell/releases/download/v0.1.1-beta.11/shibumi-shell-0.1.1-beta.11.tar.gz + sha256sums = a3d1cf0212d12eb95fd959adc914ad453a389c14841ad54dd791760ed11a9469 pkgname = shibumi-shell diff --git a/packaging/aur/PKGBUILD b/packaging/aur/PKGBUILD index 0cdba1f..829f7b0 100644 --- a/packaging/aur/PKGBUILD +++ b/packaging/aur/PKGBUILD @@ -1,7 +1,7 @@ # Maintainer: HANCORE Linux pkgname=shibumi-shell -_upstream_version=0.1.1-beta.10 -_source_sha256=63a209a81f731f84d665c6a499a9f66c3d98a0b2e4690d4a6feb5f72b1ecc12c +_upstream_version=0.1.1-beta.11 +_source_sha256=a3d1cf0212d12eb95fd959adc914ad453a389c14841ad54dd791760ed11a9469 pkgver=${_upstream_version//-/} pkgrel=1 pkgdesc="Transactional Shibumi desktop shell plugins for Omarchy Quattro" diff --git a/packaging/package-metadata.json b/packaging/package-metadata.json index 6ee88e3..e6f11ca 100644 --- a/packaging/package-metadata.json +++ b/packaging/package-metadata.json @@ -1,5 +1,5 @@ { "schemaVersion": 1, "packageName": "shibumi-shell", - "version": "0.1.1-beta.10" + "version": "0.1.1-beta.11" } diff --git a/tests/test_package_release.py b/tests/test_package_release.py index 1d441dd..7c75718 100644 --- a/tests/test_package_release.py +++ b/tests/test_package_release.py @@ -26,7 +26,7 @@ def test_versions_and_all_plugin_manifests_agree(self) -> None: marker = json.loads( (ROOT / "packaging/package-metadata.json").read_text(encoding="utf-8") ) - self.assertEqual(version, "0.1.1-beta.10") + self.assertEqual(version, "0.1.1-beta.11") self.assertEqual(suite["suiteVersion"], version) self.assertEqual(marker["version"], version) for plugin in suite["plugins"]: diff --git a/tests/test_shibumi_health.py b/tests/test_shibumi_health.py index 68ff7bf..6b337fc 100644 --- a/tests/test_shibumi_health.py +++ b/tests/test_shibumi_health.py @@ -233,19 +233,19 @@ def write_state(self, value: dict[str, object]) -> None: def use_package_install( self, *, - installed_version: str | None = "0.1.1beta.10-1", + installed_version: str | None = "0.1.1beta.11-1", available_version: str | None = None, fetch_error: str = "", ) -> Path: self.state.pop("sourceRoot", None) self.state.update( { - "suiteVersion": "0.1.1-beta.10", + "suiteVersion": "0.1.1-beta.11", "installOrigin": "package", "payloadRoot": "/usr/share/shibumi-shell", - "sourceRevision": "package:0.1.1-beta.10", + "sourceRevision": "package:0.1.1-beta.11", "packageName": "shibumi-shell", - "packageVersion": "0.1.1-beta.10", + "packageVersion": "0.1.1-beta.11", } ) self.write_state(self.state) @@ -331,9 +331,9 @@ def test_package_health_uses_pacman_metadata_and_skips_git(self) -> None: self.assertEqual(payload["overall"], "healthy") self.assertEqual(payload["installOrigin"], "package") self.assertEqual(payload["packageName"], "shibumi-shell") - self.assertEqual(payload["packageVersion"], "0.1.1-beta.10") + self.assertEqual(payload["packageVersion"], "0.1.1-beta.11") self.assertEqual(checks["package-status"]["status"], "ok") - self.assertEqual(checks["package-status"]["value"], "0.1.1beta.10-1") + self.assertEqual(checks["package-status"]["value"], "0.1.1beta.11-1") self.assertEqual(checks["package-update"]["value"], "Not checked") self.assertNotIn("source-status", checks) self.assertNotIn("source-update", checks) diff --git a/tests/test_shibumi_suite.py b/tests/test_shibumi_suite.py index e414ae2..ec28da5 100755 --- a/tests/test_shibumi_suite.py +++ b/tests/test_shibumi_suite.py @@ -971,8 +971,8 @@ def test_package_install_records_authoritative_package_origin(self) -> None: state = load_install_state(self.paths, suite) self.assertEqual(state["installOrigin"], "package") self.assertEqual(state["packageName"], "shibumi-shell") - self.assertEqual(state["packageVersion"], "0.1.1-beta.10") - self.assertEqual(state["sourceRevision"], "package:0.1.1-beta.10") + self.assertEqual(state["packageVersion"], "0.1.1-beta.11") + self.assertEqual(state["sourceRevision"], "package:0.1.1-beta.11") self.assertNotIn("sourceRoot", state) self.assertEqual(state["payloadRoot"], str(self.source.resolve())) @@ -991,7 +991,7 @@ def test_update_migrates_checkout_install_to_package_origin(self) -> None: package_state = load_install_state(self.paths, suite) self.assertEqual(package_state["installOrigin"], "package") self.assertEqual(package_state["packageName"], "shibumi-shell") - self.assertEqual(package_state["packageVersion"], "0.1.1-beta.10") + self.assertEqual(package_state["packageVersion"], "0.1.1-beta.11") self.assertNotIn("sourceRoot", package_state) def test_sandbox_update_advances_beta_7_to_beta_9(self) -> None: @@ -1063,7 +1063,7 @@ def test_sandbox_update_advances_beta_7_to_beta_9(self) -> None: plugin_id: spec.payload_digest() for plugin_id, spec in self.suite.plugins.items() } - self.assertEqual(updated["suiteVersion"], "0.1.1-beta.10") + self.assertEqual(updated["suiteVersion"], "0.1.1-beta.11") self.assertEqual(updated["sourceRoot"], str(self.source.resolve())) self.assertEqual(updated["pluginDigests"], expected_digests) self.assertEqual(len(updated["plugins"]), 24) @@ -1081,7 +1081,7 @@ def test_sandbox_update_advances_beta_7_to_beta_9(self) -> None: encoding="utf-8" ) ) - self.assertEqual(manifest["version"], "0.1.1-beta.10") + self.assertEqual(manifest["version"], "0.1.1-beta.11") def test_locked_update_discards_staging_without_live_reconciliation(self) -> None: self.install() @@ -1289,7 +1289,7 @@ def test_update_and_repair_accept_semver_build_metadata(self) -> None: for operation in (command_update, command_repair): with self.subTest(operation=operation.__name__): state = json.loads(state_path.read_text(encoding="utf-8")) - state["suiteVersion"] = "0.1.1-beta.10+installed.9" + state["suiteVersion"] = "0.1.1-beta.11+installed.9" state_path.write_text( json.dumps(state, indent=2) + "\n", encoding="utf-8" ) @@ -1298,7 +1298,7 @@ def test_update_and_repair_accept_semver_build_metadata(self) -> None: 0, ) updated = json.loads(state_path.read_text(encoding="utf-8")) - self.assertEqual(updated["suiteVersion"], "0.1.1-beta.10") + self.assertEqual(updated["suiteVersion"], "0.1.1-beta.11") self.assertEqual( version_key("1.0.0+build.7"), @@ -1361,9 +1361,9 @@ def test_explicit_package_rollback_stages_older_payload_transactionally(self) -> ) rolled_back = load_install_state(self.paths, suite) - self.assertEqual(rolled_back["suiteVersion"], "0.1.1-beta.10") - self.assertEqual(rolled_back["packageVersion"], "0.1.1-beta.10") - self.assertEqual(rolled_back["sourceRevision"], "package:0.1.1-beta.10") + self.assertEqual(rolled_back["suiteVersion"], "0.1.1-beta.11") + self.assertEqual(rolled_back["packageVersion"], "0.1.1-beta.11") + self.assertEqual(rolled_back["sourceRevision"], "package:0.1.1-beta.11") def test_rescan_uses_shell_ipc_contract(self) -> None: runtime = OmarchyRuntime()