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/.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 1a8f069..4fdadf6 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -227,7 +227,45 @@ 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. 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, +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, 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 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 @@ -606,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/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/backend-boundary-v1.json b/contracts/backend-boundary-v1.json new file mode 100644 index 0000000..5ec7a95 --- /dev/null +++ b/contracts/backend-boundary-v1.json @@ -0,0 +1,437 @@ +{ + "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" + ], + "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":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"}, + {"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", + "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": 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."}, + {"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/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 2bbd660..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 @@ -30,10 +30,39 @@ 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, 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 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. + +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 | @@ -54,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 | @@ -63,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 | @@ -115,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 @@ -135,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/AudioBackendAdapter.qml b/hancore.shibumi.audio/AudioBackendAdapter.qml new file mode 100644 index 0000000..d2cb7a9 --- /dev/null +++ b/hancore.shibumi.audio/AudioBackendAdapter.qml @@ -0,0 +1,826 @@ +pragma ComponentBehavior: Bound + +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Services.Mpris +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 inside the implementation object, 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 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 + // 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() + + 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() { + // 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 + } + 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 (!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: { + 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 + 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 (!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: { + 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() + 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 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) } + 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 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) + } + + QtObject { + id: implementation + + function backendValue(name, fallback) { + if (root.backendOverride !== null + && root.backendOverride[name] !== undefined) + return root.backendOverride[name] + return fallback + } + + function currentNodes() { + if (!root.active) return [] + if (root.backendOverride !== null) return backendValue("nodes", []) + return Pipewire.nodes ? Pipewire.nodes.values : [] + } + + function currentSink() { + if (!root.active) return null + return root.backendOverride !== null + ? backendValue("defaultAudioSink", null) + : Pipewire.defaultAudioSink + } + + function currentSource() { + if (!root.active) return null + return root.backendOverride !== null + ? backendValue("defaultAudioSource", null) + : Pipewire.defaultAudioSource + } + + function currentVolumeSink() { + const sink = implementation.currentSink() + if (!sink || sink.ready === false) return null + const configuredName = root.backendOverride !== null + ? 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() + 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() { + if (!backendReady()) return 0 + const sink = implementation.currentVolumeNode() + return sink && sink.audio && sink.audio.volume !== undefined + ? Number(sink.audio.volume) : 0 + } + + function outputMuted() { + if (!backendReady()) return false + const sink = implementation.currentVolumeNode() + return sink && sink.audio && sink.audio.muted === true + } + + function inputVolume() { + 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() { + if (!backendReady()) return false + const source = implementation.currentInputNode() + if (!source || source.ready === false) return false + return source.audio && source.audio.muted === true + } + + 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] + if (node && node.isSink && !node.isStream) result.push(node) + } + 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 (node && node.ready !== false + && String(node.name || "") !== "quickshell" + && 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 (node && node.ready !== false + && String(node.name || "").indexOf("omarchy_speaker_tuning") !== 0 + && 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 audioSinks() { + 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() { + 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() { + 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() { + const version = root.generation + void version + return backendReady() ? Model.snapshot(currentSource(), "source", true) : null + } + + 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 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 + } + + function backendReady() { + if (!root.active) return false + return root.backendOverride !== null + ? backendValue("ready", true) !== false + : nativeBackendReady() + } + + function unresolvedId(id, label) { + return result( + false, + backendReady() ? "stale-id" : "unavailable", + label + " is unavailable", + id) + } + + 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", entityId) + 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 (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) + 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, allSinks(currentNodes()), "sink") + if (!node) return unresolvedId(id, "Audio sink") + return setNodeDefault(node, "sink", "setDefaultSink") + } + + function setDefaultSource(id) { + const node = resolveNode(id, allSources(currentNodes()), "source") + if (!node) return unresolvedId(id, "Audio source") + return setNodeDefault(node, "source", "setDefaultSource") + } + + function setOutputVolume(value) { + 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 = implementation.currentVolumeNode() + const failure = mutationError(sink, "sink", "Output") + if (failure) return failure + if (!sink.audio) + 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 = 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 = implementation.currentInputNode() + const failure = mutationError(source, "source", "Input") + if (failure) return failure + if (!source.audio) + 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, 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, 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 = allSinks(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, + backendReady() ? "stale-id" : "unavailable", + "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 new file mode 100644 index 0000000..247c849 --- /dev/null +++ b/hancore.shibumi.audio/AudioModel.js @@ -0,0 +1,306 @@ +function text(value) { + return String(value || "").trim() +} + +function nodeProperties(node) { + return node && node.ready !== false && node.properties + ? 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() + if (!/^[0-9]+$/.test(id)) return "" + return String(kind || "node") + ":" + id +} + +function nodeLabel(node) { + if (!node) return "Audio device" + var properties = nodeProperties(node) + 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) { + 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 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 + 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 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 + 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.ready !== false ? node.audio : null + 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/AudioPanel.qml b/hancore.shibumi.audio/AudioPanel.qml index 06803ab..2c84d18 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 { @@ -201,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) + "%" @@ -209,8 +220,9 @@ 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 } @@ -228,7 +240,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 +311,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 @@ -339,7 +359,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 @@ -361,7 +381,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 +515,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 +567,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 +577,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 +585,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 +597,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 +609,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 +620,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 +634,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..93bf486 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,207 +16,519 @@ Item { property Component panelComponent: null property url panelSource: "" property var panelSettings: ({}) + property var backendReadyOverride: null + property real peakValue: 0 + 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: nativeAudioService !== null + ? nativeAudioService.nativeBackendReady === true + : panel.item !== null && backendReady + readonly property var sinkSnapshot: { + const version = generation + void version + 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 + 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 + 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 + 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 + 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) 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: { + 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 - 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 : [] - 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 + // 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 toggleOutputMute() { - if (!ready || typeof panel.toggleOutputMute !== "function") return false - panel.toggleOutputMute() - return true + function acquirePeakMonitoring() { + if (nativeAudioService !== null + && typeof nativeAudioService.acquirePeakMonitoring === "function") + return nativeAudioService.acquirePeakMonitoring() === true + return typeof peakAcquire === "function" ? peakAcquire() === true : false } - 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 releasePeakMonitoring() { + if (nativeAudioService !== null + && typeof nativeAudioService.releasePeakMonitoring === "function") + return nativeAudioService.releasePeakMonitoring() === true + return typeof peakRelease === "function" ? peakRelease() === true : false } - function toggleInputMute() { - if (!ready || typeof panel.toggleInputMute !== "function") return false - panel.toggleInputMute() - return true + function actionResult(ok, code, message, entityId) { + return Model.actionResult(ok, code, message, entityId, generation) } - 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 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 setDefaultSink(node) { - if (!ready || !node || typeof panel.setDefaultSink !== "function") return false - panel.setDefaultSink(node) - return true + function refreshedDelegatedSuccess(value, entityId) { + return actionResult(true, value.code || "ok", value.message || "", entityId) } - function setDefaultSource(node) { - if (!ready || !node || typeof panel.setDefaultSource !== "function") return false - panel.setDefaultSource(node) - return true + 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 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 (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) + 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 (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) + 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 (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) + 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 (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) + 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 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) + 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 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) + 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 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) + 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 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) + 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 suppressed + 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) + } + } + + 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 +552,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..87162fc 100644 --- a/hancore.shibumi.audio/BarWidget.qml +++ b/hancore.shibumi.audio/BarWidget.qml @@ -12,8 +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 && audioServiceResolved + && audioStateService.nativeBackendEnabled === true readonly property var tokens: bar && "visualTokens" in bar && bar.visualTokens ? bar.visualTokens : hostTokens @@ -30,11 +42,14 @@ 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 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 @@ -90,9 +105,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) { @@ -101,7 +120,9 @@ 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() return true } @@ -119,11 +140,15 @@ Ui.Panel { onOpenedChanged: syncPanelLoader() onAudioReadyChanged: { + if (!audioReady && opened) close() if (opened) syncPanelLoader() if (!audioReady) { wheelCommitTimer.stop() wheelSettleTimer.stop() + wheelCommitSerial++ wheelAdjustmentPending = false + wheelCommitInFlight = false + popupLoader.source = "" } reportAudioState() } @@ -141,9 +166,22 @@ 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() } + : null + peakRelease: root.audioStateService + ? function() { return root.audioStateService.releasePeakMonitoring() } + : null } Loader { id: popupLoader } @@ -152,18 +190,50 @@ Ui.Panel { id: wheelCommitTimer interval: 70 onTriggered: { - if (!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 } - 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() } } Timer { id: wheelSettleTimer - interval: 300 - onTriggered: root.wheelAdjustmentPending = false + interval: 1000 + onTriggered: { + if (!root.wheelAdjustmentPending || !root.wheelCommitInFlight + || root.wheelInFlightSerial !== root.wheelCommitSerial) return + root.wheelAdjustmentPending = false + root.wheelCommitInFlight = false + } + } + + Connections { + target: audioBridge + function onOutputVolumeChanged() { + if (!root.wheelAdjustmentPending || !root.wheelCommitInFlight + || root.wheelInFlightSerial !== root.wheelCommitSerial) + return + if (Math.abs(Number(audioBridge.outputVolume) + - root.wheelInFlightTarget) > 0.005) return + root.wheelAdjustmentPending = false + root.wheelCommitInFlight = false + wheelSettleTimer.stop() + } } Item { diff --git a/hancore.shibumi.audio/Service.qml b/hancore.shibumi.audio/Service.qml index f19fffe..0efbc85 100644 --- a/hancore.shibumi.audio/Service.qml +++ b/hancore.shibumi.audio/Service.qml @@ -12,6 +12,37 @@ Item { property var shell: null property var manifest: null property var reports: [] + // 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 + 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 @@ -19,6 +50,129 @@ 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 + source: Qt.resolvedUrl("AudioBackendAdapter.qml") + onLoaded: { + // 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 + } + } + + 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 + 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 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.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/BluetoothAudioRouteAdapter.qml b/hancore.shibumi.bluetooth/BluetoothAudioRouteAdapter.qml new file mode 100644 index 0000000..6d1899b --- /dev/null +++ b/hancore.shibumi.bluetooth/BluetoothAudioRouteAdapter.qml @@ -0,0 +1,107 @@ +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 bool enabled: true + property var nodesOverride: null + property var outputOverride: null + property var 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: !enabled ? [] + : 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) + } + 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 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 (!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 + ? "" : 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) + 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) + } + if (nodesOverride !== null) + return actionResult( + false, "unsupported", "Fake audio backend action is unavailable", entityId) + Pipewire.preferredDefaultAudioSink = sink + Quickshell.execDetached([ + "omarchy-audio-output-set-default", + String(sink.id), + String(sink.name || "") + ]) + 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) { + if (!ready) + return actionResult(false, "unavailable", "Bluetooth audio is unavailable") + const sink = sinkForDevice(request) + 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 c4375bd..64d16a8 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,8 @@ Item { property var pipewireNodesOverride: null 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 @@ -49,9 +50,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 +68,14 @@ Item { width: 0 height: 0 + Local.BluetoothAudioRouteAdapter { + id: nativeAudioRoute + enabled: root.audioRouteHandoffReady + && root.audioRouteOverride === null && root.backendOverride === null + nodesOverride: root.pipewireNodesOverride + outputOverride: root.audioOutputOverride + } + function backendList(name) { if (backendOverride === null || !(name in backendOverride)) return [] const values = backendOverride[name] @@ -309,36 +318,42 @@ 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 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 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 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 audioRouteResult( + false, "unavailable", "Bluetooth audio route is unavailable", "", 0) + return normalizeAudioRouteResult( + audioRoute.routeBluetoothDevice(request)) } function scheduleAudioOutputSwitch(device) { @@ -349,6 +364,7 @@ Item { deviceName: device && device.deviceName ? device.deviceName : "" } pendingAudioOutputAttempts = 0 + audioIntentTimeout.restart() audioSwitchTimer.restart() } @@ -380,18 +396,22 @@ Item { function switchPendingAudioOutput() { if (!validatePendingAudioOutput()) return const device = nativeDeviceByAddress(pendingAudioOutputDevice.address) - const sink = bluetoothAudioSink(device) - if (sink) { - setDefaultAudioSink(sink) + const routeResult = requestBluetoothAudioRoute(device) + if (routeResult.ok) { pendingAudioOutputDevice = null audioSwitchTimer.stop() + audioIntentTimeout.stop() return } - pendingAudioOutputAttempts++ - if (pendingAudioOutputAttempts >= 8) { + // 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++ audioSwitchTimer.restart() } @@ -403,7 +423,6 @@ Item { if (device && device.connected && deviceUsesCurrentAdapter(device)) { scheduleAudioOutputSwitch(device) audioHandoffIntent = null - audioIntentTimeout.stop() } } validatePendingAudioOutput() @@ -490,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/BluetoothModel.js b/hancore.shibumi.bluetooth/BluetoothModel.js index 6608ab7..fd80082 100644 --- a/hancore.shibumi.bluetooth/BluetoothModel.js +++ b/hancore.shibumi.bluetooth/BluetoothModel.js @@ -92,9 +92,23 @@ 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 properties = nodeProperties(node) + var nodeAddress = normalizedAddress( + properties["api.bluez5.address"] || properties["bluez5.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, invalid, or different. + if (rawAddress !== "") { + if (!isAddressLike(rawAddress)) return false + 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() return label !== "" && text.indexOf(label) !== -1 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/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.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/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/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/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 a6a891a..829f7b0 100644 --- a/packaging/aur/PKGBUILD +++ b/packaging/aur/PKGBUILD @@ -1,7 +1,7 @@ # Maintainer: HANCORE Linux