diff --git a/.github/release-notes/v0.1.1-beta.16.5.md b/.github/release-notes/v0.1.1-beta.16.5.md
new file mode 100644
index 00000000..39015271
--- /dev/null
+++ b/.github/release-notes/v0.1.1-beta.16.5.md
@@ -0,0 +1,47 @@
+# Shibumi v0.1.1-beta.16.5
+
+## Highlights
+
+- **Native Omarchy bar sections preserved.** All six native sections, native center placement, and explicit widget moves survive bar/profile and CLI deactivate/activate round trips, and the host's fixed-edge contract is supplied. Tray-drawer entries are retained and disclosed; use the stock Omarchy bar for their drawer presentation. Omarchy 4.0.5 is admitted as the stable reference; the forward compatibility reference moves to Omarchy `246f33a2`.
+- **Plugin catalog isolates malformed rows.** A malformed third-party manifest no longer disables the whole catalog or all widget actions. Affected rows keep their native information, catalog failures carry diagnostics in Control Center, status, and Health, and development-directory symlinks resolve through the canonical metadata path. Retained layouts survive CLI round trips.
+- **Media animation at a fraction of the cost.** One bounded 40 ms step clock per Media surface drives marquee, Muse rotation, and pulse, and pause easing is preserved. In 480 private renderer sessions against Beta 16.4, playing-state process CPU fell by about 19% in the Default style and about 51% in the Full style (median of paired five-second windows, lower in every pair). Paused and focus behaviour are unchanged within measurement resolution.
+- **Cava 1.0 smoothing restored.** The spectrum keeps its earlier dynamics on cava 1.0 (`noise_reduction = 35`, shared by widget and panel), compared on real music against the previous cava 0.10.7 setting.
+- **Lifecycle and CLI diagnostics.** Stock picker actions recover when routing is absent, uninstall reports leftovers, plugin-specific admission and menu-parse errors are named, shell handoffs are bounded with an owned-process fallback, and network traffic is requested only while its presentation is visible.
+
+Source installation remains the supported route; OPR packaging is planned, not AUR publication.
+
+## Install and update
+
+For a new source installation, use the published release tag:
+
+```bash
+sudo pacman -S --needed python jq curl networkmanager power-profiles-daemon upower xdg-utils libnotify wl-clipboard ttf-material-symbols-variable ttf-jetbrains-mono-nerd-basic noto-fonts-cjk adwaita-fonts
+git clone https://github.com/HANCORE-linux/Shibumi-Shell.git
+cd Shibumi-Shell
+git checkout v0.1.1-beta.16.5
+./scripts/shibumi-suite install --yes
+```
+
+Update an admitted Beta.16.4 installation from its existing checkout:
+
+```bash
+git fetch --tags
+git checkout v0.1.1-beta.16.5
+./scripts/shibumi-suite update --dry-run
+./scripts/shibumi-suite update --yes
+./scripts/shibumi-suite status
+```
+
+The exact Beta.16.4 tag (`64b9369`) and its main integration merge (`aec6c0a`) are admitted predecessors. Older supported identities retain their exact revision and payload checks; an arbitrary checkout with the same version is not admitted. Do not install or update from `main`, or delete lifecycle state to bypass admission. See the [installation and recovery guide](https://github.com/HANCORE-linux/Shibumi-Shell/blob/v0.1.1-beta.16.5/docs/install.md). AUR publication remains deferred.
+
+## Validation
+
+The Media figures above come from a software-rendered fixture with a synthetic spectrum; they describe the tested renderer scenarios, not GPU smoothness or real-cava CPU. Live validation ran on Omarchy 4.0.4 / Quickshell 0.3.1 and on Omarchy 4.0.6 / Quickshell 0.3.2, including the real-music smoothing comparison. The third validation host covered an earlier candidate only.
+
+## Known points
+
+- The Default-style marquee commits two frames per scroll step while text scrolls (about 250 commits for 125 steps in five seconds). There is no visible effect and the cost stays well below Beta 16.4; a layer-free marquee is planned for 0.2.0.
+- Notch can still oscillate at DPR 1.6.
+- Notification focus remains limited when the sender does not provide a supported application identity.
+- If Memory Sweep loses its service owner, the authorization dialog may disappear without a result; avoid restarting Sweep during an owner change.
+- Before downgrading: Beta 15.4 and older cannot read V1 `order.parked` and reset V1 order, roles, and splits even with `--keep-settings`. Keep complete settings and recovery backups; see the installation guide.
diff --git a/AGENTS.md b/AGENTS.md
index 8efda57d..0cb3f639 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -51,10 +51,13 @@ SHIBUMI_INSTALLED_SOURCE_OMARCHY_PATH=/tmp/omarchy-installed-source-v4.0.4 \
./tests/omarchy-installed-source-parity-contract-regression.sh
SHIBUMI_AGENTS_OMARCHY_PATH=/tmp/omarchy-v4.0.0 \
./tests/omarchy-agents-contract-regression.sh
-SHIBUMI_FORWARD_COMPAT_OMARCHY_PATH=/tmp/omarchy-forward-compat-4c1b4cb3 \
+SHIBUMI_FORWARD_COMPAT_OMARCHY_PATH=/tmp/omarchy-forward-compat-246f33a2 \
./tests/omarchy-forward-compat-contract-regression.sh
```
+The forward reference is exactly `246f33a2fef66411c990860d88e0823ae83987fc`;
+its admission does not transfer fixture evidence to a live or differently packaged host.
+
Live acceptance must record what was actually exercised and must not claim unavailable hardware, credentials, multi-monitor, nested-compositor, or clean-chroot evidence as passed. Physical hardware gates require raw setup details, relevant command output, screenshots or video, and sanitized logs; fixtures cannot replace them.
## Change delivery approvals
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index f9c4cfd8..9a92bab7 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -525,6 +525,14 @@ the saved preference.
Normal uninstall removes canonical and legacy settings. `--keep-settings`
retains the complete canonical State entry, including unknown fields and deep
settings, dormant while its plugin payload is absent. Reinstall reuses it.
+ Managed CLI deactivation also retains the Shibumi layout in that entry's
+ `shibumiRetainedLayout` sibling, shared with `uninstall --keep-settings`.
+ Activation consumes it before shell start, preserving placement/order/entry
+ settings except foreign widgets no longer placed in the freshly read native
+ layout. Suite widgets intentionally removed by deactivation are restored;
+ deferred tray data and `--keep-layout` policy are unchanged. The continuity
+ manager's two-owner layout/center-anchor profiles remain a separate journaled
+ mechanism; this fix does not migrate or merge those profile stores.
## Design Rules
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 69fdaa74..55030193 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,29 @@
# Changelog
+## [0.1.1-beta.16.5] - 2026-10-22
+
+### Highlights
+
+- **Native Omarchy bar sections preserved.** All six sections, native center placement, and explicit widget moves survive bar/profile and CLI round trips; the host's fixed-edge contract is supplied. Tray-drawer entries are retained and disclosed; use the stock Omarchy bar for their drawer presentation.
+- **Resilient plugin catalog.** Malformed third-party manifests no longer disable the whole catalog or all widget actions. Affected rows retain native information, failures carry diagnostics, development-directory symlinks resolve canonically, and retained layouts survive CLI round trips.
+- **Lower-cost Media animation.** One bounded 40 ms step clock per surface drives marquee, Muse rotation, and pulse while preserving pause easing.
+- **Cava 1.0 smoothing restored.** Widget and panel share `noise_reduction = 35`, compared on real music against the previous cava 0.10.7 setting.
+- **Lifecycle and CLI diagnostics.** Picker routing recovers, uninstall reports leftovers, admission and menu-parse errors are named, shell handoffs are bounded with an owned-process fallback, and network traffic is requested only while visible.
+
+Omarchy 4.0.5 is admitted as the stable reference; the forward compatibility reference moves to `246f33a2`. Source installation remains the supported route; OPR packaging is planned, not AUR publication.
+
+### Validation
+
+In 480 private renderer sessions against Beta 16.4, playing-state process CPU fell by about **19% for Default** and **51% for Full** (median of paired five-second windows, lower in every pair). The figures come from a software-rendered fixture with a synthetic spectrum, not GPU smoothness or real-cava CPU. Paused and focus behaviour are unchanged within measurement resolution. Live validation ran on Omarchy 4.0.4 / Quickshell 0.3.1 and Omarchy 4.0.6 / Quickshell 0.3.2, including the real-music smoothing comparison; the third host covered an earlier candidate only.
+
+### Known points
+
+- The Default-style marquee commits two frames per scroll step while text scrolls (about 250 commits for 125 steps in five seconds). There is no visible effect and the cost stays well below Beta 16.4; a layer-free marquee is planned for 0.2.0.
+- Notch can still oscillate at DPR 1.6.
+- Notification focus remains limited when the sender does not provide a supported application identity.
+- If Memory Sweep loses its service owner, the authorization dialog may disappear without a result; avoid restarting Sweep during an owner change.
+- Beta 15.4 and older cannot read V1 `order.parked` and reset V1 order, roles, and splits even with `--keep-settings`. Keep complete settings and recovery backups before downgrading.
+
## [0.1.1-beta.16.4] - 2026-10-14
### Highlights
diff --git a/README.md b/README.md
index aeab5f82..0aef7642 100644
--- a/README.md
+++ b/README.md
@@ -16,8 +16,8 @@ process.
[Documentation](docs/README.md) ·
[Release status](docs/release-readiness.md)
-`0.1.1-beta.16.4` is the target GitHub prerelease. See the
-[release notes](.github/release-notes/v0.1.1-beta.16.4.md) for changes. Before using an older version, read the
+`0.1.1-beta.16.5` is the target GitHub prerelease. See the
+[release notes](.github/release-notes/v0.1.1-beta.16.5.md) for changes. Before using an older version, read the
[backup and downgrade warning](docs/install.md#backup-before-downgrade).
The current pinned host contracts use Omarchy and Omarchy Settings `4.0.4-1`
(source tag `v4.0.4`, revision `c668141e`) with Quickshell `0.3.1-1`; the
@@ -68,7 +68,7 @@ for the latest validated host versions.
sudo pacman -S --needed python jq curl networkmanager power-profiles-daemon upower xdg-utils libnotify wl-clipboard ttf-material-symbols-variable ttf-jetbrains-mono-nerd-basic noto-fonts-cjk adwaita-fonts
git clone https://github.com/HANCORE-linux/Shibumi-Shell.git
cd Shibumi-Shell
-git checkout v0.1.1-beta.16.4
+git checkout v0.1.1-beta.16.5
./scripts/shibumi-suite install --yes
```
@@ -76,14 +76,15 @@ Run the Pacman line yourself; it installs the runtime commands and fonts and
is the only step that needs `sudo`. The Shibumi suite never calls Pacman: it
runs as the desktop user and writes only user-scoped plugin state.
-Update an admitted Beta.16.3 source installation to Beta.16.4.
-Exact predecessor identities, not a version range, govern admission; see the
-[update guide](docs/install.md#update).
+Update an admitted Beta.16.4 source installation to Beta.16.5.
+The exact Beta.16.4 tag (`64b9369`) and its main integration merge (`aec6c0a`)
+are admitted predecessors. Exact identities, not a version range, govern
+admission; see the [update guide](docs/install.md#update).
```sh
cd Shibumi-Shell
git fetch --tags
-git checkout v0.1.1-beta.16.4
+git checkout v0.1.1-beta.16.5
./scripts/shibumi-suite update --dry-run
./scripts/shibumi-suite update --yes
./scripts/shibumi-suite status
diff --git a/VERSION b/VERSION
index bdd0e34e..e78874ca 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.1.1-beta.16.4
+0.1.1-beta.16.5
diff --git a/contracts/backend-boundary-v1.json b/contracts/backend-boundary-v1.json
index 51429059..a7ed6e30 100644
--- a/contracts/backend-boundary-v1.json
+++ b/contracts/backend-boundary-v1.json
@@ -414,7 +414,7 @@
{"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-shell","count":4,"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"},
diff --git a/contracts/baselines/omarchy-forward-compat-246f33a2.json b/contracts/baselines/omarchy-forward-compat-246f33a2.json
new file mode 100644
index 00000000..e281392e
--- /dev/null
+++ b/contracts/baselines/omarchy-forward-compat-246f33a2.json
@@ -0,0 +1,41 @@
+{
+ "schemaVersion": 2,
+ "id": "forward-compat-246f33a2",
+ "profile": "forward-compat",
+ "repository": "https://github.com/basecamp/omarchy",
+ "sourceRevision": "246f33a2fef66411c990860d88e0823ae83987fc",
+ "provenance": {
+ "kind": "git",
+ "revision": "246f33a2fef66411c990860d88e0823ae83987fc"
+ },
+ "quickshellPackage": {
+ "name": "quickshell",
+ "version": "0.3.1-1"
+ },
+ "subtrees": [
+ {
+ "path": "shell",
+ "entryPolicy": "regular-files",
+ "entryCount": 280,
+ "inventorySha256": "7b74cb58d74ccf15d4854192eb93adcd0843888d22442b8f701a9f3d17da4999",
+ "structureSha256": "5d6e82d9a6c943061594fc07b8f1d2027642723edd4ef551629c0ca6879a4a97",
+ "contentSha256": "5e89a2f3aa010b045dc74124e512bdbaa7c9aa6b26d7e0c93f1a01df7faf20de"
+ },
+ {
+ "path": "bin",
+ "entryPolicy": "regular-files",
+ "entryCount": 530,
+ "inventorySha256": "4aba7916dbbcb5af3ffb12a3a41d5b36a26fe1b5d311bd9cac8f28327c29f0a6",
+ "structureSha256": "87ded0646ac4b8cd142adf298939c2214f8687e2ed46520d9f7ca8f52bbaf183",
+ "contentSha256": "8230ea725f12838c10ea0413e7cc37053cb63bd9fb8ff6e87b982ff112e08992"
+ },
+ {
+ "path": "config",
+ "entryPolicy": "regular-files",
+ "entryCount": 81,
+ "inventorySha256": "64d4f49e87d6df1ca98e2cc338fe426697d3cb8ef00e0aeb76145caab6a09d9f",
+ "structureSha256": "eb517680186410f36423ccd319b69fdaedd8910749e52e34e48cf2d7dc7dce58",
+ "contentSha256": "ae0e2b426d39a417b1d65812b3c74ecbc9302c9baf82791d3548d17254211904"
+ }
+ ]
+}
diff --git a/contracts/baselines/omarchy-installed-source-parity-v4.0.5.json b/contracts/baselines/omarchy-installed-source-parity-v4.0.5.json
new file mode 100644
index 00000000..9abcd0a0
--- /dev/null
+++ b/contracts/baselines/omarchy-installed-source-parity-v4.0.5.json
@@ -0,0 +1,41 @@
+{
+ "schemaVersion": 2,
+ "id": "installed-source-parity-v4.0.5",
+ "profile": "installed-source-parity",
+ "repository": "https://github.com/basecamp/omarchy",
+ "sourceRevision": "94a8fbc2d67eec5673c7f16b8c5e8a9c8452ffdc",
+ "provenance": {
+ "kind": "git",
+ "revision": "94a8fbc2d67eec5673c7f16b8c5e8a9c8452ffdc"
+ },
+ "quickshellPackage": {
+ "name": "quickshell",
+ "version": "0.3.1-1"
+ },
+ "subtrees": [
+ {
+ "path": "shell",
+ "entryPolicy": "regular-files",
+ "entryCount": 222,
+ "inventorySha256": "be094886de039e6569034aebe40777521b799df2f4d4cf006e90655cadf0fb63",
+ "structureSha256": "beb84ff10f06fe0ae77660bf28f235fffeed89094de9e07b76cabd80b0e6dc66",
+ "contentSha256": "ab2a5ea1ade0c4d90b39e10ef061d20929defcaee1bba755adaeab41aa56daef"
+ },
+ {
+ "path": "bin",
+ "entryPolicy": "regular-files",
+ "entryCount": 449,
+ "inventorySha256": "2ebcf2fe4f323438858510c6cbfed017aa1646d3728d2a2686f8257b55b4d730",
+ "structureSha256": "107b94159d2f7ceabbf72f57be22522ebf5a080145d9b1cc59bc463299bfa8b0",
+ "contentSha256": "ac53e850b1cc9b0467ef96f905651841282aaded842c9bb856af3fb01e693254"
+ },
+ {
+ "path": "config",
+ "entryPolicy": "regular-files",
+ "entryCount": 75,
+ "inventorySha256": "33995e20ae22ffef7a5710c859a5f5577429aae2ecc0976d1b7978838d4277fa",
+ "structureSha256": "0d2f5855e27b9bada33e17730c99a99fa6d5e4bd971cef722c0ec7d50c4e467f",
+ "contentSha256": "adb203e2024c62c397b77fd9b30627f41675f0fd6e1525ba3634061088cf036a"
+ }
+ ]
+}
diff --git a/contracts/lifecycle-predecessors-v1.json b/contracts/lifecycle-predecessors-v1.json
index fe37103b..961125ab 100644
--- a/contracts/lifecycle-predecessors-v1.json
+++ b/contracts/lifecycle-predecessors-v1.json
@@ -1003,9 +1003,72 @@
],
"settingsStorageVersion": 1,
"sourceRevisions": [
+ "64b9369559b0de2ffdc1faf1c563326c8173035c",
+ "aec6c0a3266d71f6a5f9cfa17562d8bec0ce9b17",
"package:0.1.1-beta.16.4"
],
"suiteVersion": "0.1.1-beta.16.4"
+ },
+ {
+ "id": "public-beta.16.5",
+ "payloadDigest": "a090b82858c554f02d44030755f779b2827ae51e55bca750b7163d5b330472dd",
+ "pluginDigests": {
+ "hancore.shibumi.bar": "4fbf8b05f754888ca694181835f7b5f7f4ec0ca6e0ef87e24075f00b8dfb2c93",
+ "hancore.shibumi.state": "eb06588ec365763f2eb207e133b8614e9fcd5f2d2d14598700df9ff91d03a272",
+ "hancore.shibumi.control-center": "078c0cc95304da956b2487953dd6703425517875178fd890cbb2a384b662e521",
+ "hancore.shibumi.reactor": "034024058bfc164b43689ce868ab55a501b9b27fec1e1a7377d15c607f8a8a4e",
+ "hancore.shibumi.telemetry": "dbb2fc5cad623e27c25bc54998a0e3fab1eef7c0fd9e5fe957ae84cf6656cbbf",
+ "hancore.shibumi.power-state": "eec80b1275ad4cef63660ea8487d93fcdafcbc4f90dc0f53b4f6a8ce68a795ae",
+ "hancore.shibumi.workspaces": "2b7c665af4b6fb5b5709506c025853c828ae9ee15465b09eabc067ac935b07d9",
+ "hancore.shibumi.update-center": "4fcbf87694c0eb6a9e29968ada4cfb41d2e62f533e0004f2d0193ef437a2098d",
+ "hancore.shibumi.status": "4f1f4f5c37a4a21feb03871661ba8b2c20ef30feaf95d9bf08f55c02432aab65",
+ "hancore.shibumi.memory": "e45fbe5e731deef60649e10fcca2a8cd46126522b5affc8d70cc3451f12f4884",
+ "hancore.shibumi.cpu": "e80b98005b0ca1ee138bfbffae88408a8041cf0e2be20c22447ba1a89ac2781c",
+ "hancore.shibumi.audio": "ce37cb6379109a7a676219c087bf5c4aa2e3c7677a26d87efcf4d98cd3765f1f",
+ "hancore.shibumi.ai": "4d524454454e780ee0451b7852bc46b09fe03386d6333cd4518ceb5a2a8f19cb",
+ "hancore.shibumi.center": "41d066b47852d46d7a8dcc95d4a33a70c5533fd344f8cca72dc7787c512c4fa0",
+ "hancore.shibumi.media": "e2e5a12b492443a836ce2e5b6c352e68be0d7779f8bc462d89ea30d792aaf928",
+ "hancore.shibumi.quick-access": "d7216bd8a0c27c60cd4eee5eb435ea19953d09f77cdddad625cd5016388c8eed",
+ "hancore.shibumi.network": "5a160456da8b0f820be9401b8b82a772e972ae585cf74656032eae6f7fa8809b",
+ "hancore.shibumi.battery": "30fea24997abc41d2c872c7e57605bd69fdba11673f92a5419137634b1a412c0",
+ "hancore.shibumi.brightness": "c80758354909b827826d118305442bd9baf14f8759f19a0283278a8fef586a0c",
+ "hancore.shibumi.power-profile": "45c6e54a508e8d08893998b64476e5b86a84bf0af6a7887e3eda901332ac09dc",
+ "hancore.shibumi.bluetooth": "87afd5e85b9746b83ee5a808277a05cb4255a0f612ae75c13c93ce06eaff620a",
+ "hancore.shibumi.temperature": "511e3e65bbe89592153bd2f2418946b146327655f2cf9384decaae023835a0b9",
+ "hancore.shibumi.gpu": "079058e91a30478fa85a42893138c7b315679aabd367df225cd16723d59ad5c5",
+ "hancore.shibumi.storage": "e60caae691648bfd9bf0b41164d644dfdfa904fec8da43ae59f4145c1d99d6d5"
+ },
+ "pluginIds": [
+ "hancore.shibumi.bar",
+ "hancore.shibumi.state",
+ "hancore.shibumi.control-center",
+ "hancore.shibumi.reactor",
+ "hancore.shibumi.telemetry",
+ "hancore.shibumi.power-state",
+ "hancore.shibumi.workspaces",
+ "hancore.shibumi.update-center",
+ "hancore.shibumi.status",
+ "hancore.shibumi.memory",
+ "hancore.shibumi.cpu",
+ "hancore.shibumi.audio",
+ "hancore.shibumi.ai",
+ "hancore.shibumi.center",
+ "hancore.shibumi.media",
+ "hancore.shibumi.quick-access",
+ "hancore.shibumi.network",
+ "hancore.shibumi.battery",
+ "hancore.shibumi.brightness",
+ "hancore.shibumi.power-profile",
+ "hancore.shibumi.bluetooth",
+ "hancore.shibumi.temperature",
+ "hancore.shibumi.gpu",
+ "hancore.shibumi.storage"
+ ],
+ "settingsStorageVersion": 1,
+ "sourceRevisions": [
+ "package:0.1.1-beta.16.5"
+ ],
+ "suiteVersion": "0.1.1-beta.16.5"
}
]
}
diff --git a/contracts/plugin-suite-v1.json b/contracts/plugin-suite-v1.json
index e9cffb71..3c7e00f9 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.16.4",
+ "suiteVersion": "0.1.1-beta.16.5",
"settingsStorageVersion": 1,
"hostContract": "contracts/host-facade-v1.json",
"retiredPlugins": [
diff --git a/docs/architecture/quattro-compatibility.md b/docs/architecture/quattro-compatibility.md
index bd972ee5..433709e5 100644
--- a/docs/architecture/quattro-compatibility.md
+++ b/docs/architecture/quattro-compatibility.md
@@ -18,7 +18,7 @@ below applies to its recorded single-output Omarchy 4.0.3 setup:
| Pinned official Omarchy source tag | `v4.0.4` (`c668141e9c42b13c80c9ca4ea108e11708c5e8a5`) |
| Pinned source-parity revision | `c668141e9c42b13c80c9ca4ea108e11708c5e8a5` |
| Immutable Agents reference | `v4.0.0` (`f0020448ca87329199de7cb12f2015ebc4a3e5e7`) |
-| Immutable forward-compatibility revision | `4c1b4cb3a94447aff39af98e2fcc0490a9fb32e7` |
+| Immutable forward-compatibility revision | `246f33a2fef66411c990860d88e0823ae83987fc` |
| Quickshell package | `quickshell 0.3.1-1` |
| Recorded Beta.13 physical validation date (4.0.3) | 2026-09-13 |
@@ -27,7 +27,8 @@ source-parity checkout is pinned to the official `v4.0.4` tag. The 4.0.3 and
4.0.2 package and source manifests remain immutable optional compatibility
references. The older Agents-only gate remains explicitly pinned to `v4.0.0`;
the separate
-`4c1b4cb3` snapshot supersedes `e71a816e` as the bounded forward reference.
+`246f33a2` snapshot supersedes `4c1b4cb3` as the bounded forward reference;
+all earlier manifests remain unchanged.
Manifest, panel geometry and focused Quattro/style checks pass; this is not a
complete candidate forward-gate pass and does not follow a moving branch.
@@ -52,7 +53,7 @@ consumed `shell`, `bin`, and `config` subtrees without conflating their claims:
records the full Git checkout of the official `v4.0.4` source tag;
- the immutable 4.0.3 package/source manifests retain the previous physical
acceptance baseline without claiming that acceptance for 4.0.4;
-- [`omarchy-forward-compat-4c1b4cb3.json`](../../contracts/baselines/omarchy-forward-compat-4c1b4cb3.json)
+- [`omarchy-forward-compat-246f33a2.json`](../../contracts/baselines/omarchy-forward-compat-246f33a2.json)
records the immutable forward-compatibility snapshot at the recorded upstream
revision. It does not follow the moving remote branch.
@@ -66,6 +67,69 @@ tree is accepted only through its matching installed-package,
installed-source-parity, or forward-compat job; callers cannot supply their own
manifest.
+## Six native sections, three presentation regions
+
+At `246f33a2fef66411c990860d88e0823ae83987fc`, native layout coordinates are
+`left`, `centerBegin`, `center`, `centerEnd`, `right`, and `tray`. Identity,
+conflicts, snapshots, State shadow checks, lifecycle persistence and native
+inline deltas cover all six. Missing extension arrays are valid legacy input;
+present malformed arrays are refused. Suite normalization preserves extensions
+on 4.0.4 instead of relying on its three-section `Util.normalizeLayout()`.
+V1/V2 group orders, capacities and presentation regions remain left/center/right.
+
+The visible center is a projection of `centerBegin + center + centerEnd`.
+Complete native entries, their order, section/index and duplicate instances stay
+in native storage; there is no flattened-layout write-back. A native section
+move takes the structural reconciliation path even if the projected order is
+unchanged. Shape-stable unique-provider inline changes use native coordinates;
+ambiguous duplicate-ID changes use structural reconciliation, never an ID-only
+in-place patch. Group-slot settings remain separate from native provider entries.
+
+The three-region V2 editor preserves a center entry's native section when it
+stays in the center. Reordering within one native section is reversible; a swap
+across the invisible native center-section boundaries is refused atomically.
+An explicit move between outer and center presentation regions uses the host's
+native default target. V1 group order remains presentation-only. To deliberately
+change centerBegin/center/centerEnd membership, edit the native layout explicitly;
+Shibumi does not infer such a move from flattened drag order. Legacy centerAnchor
+handling requires an already-present anchor and no split-center keys; no new
+anchor field is introduced.
+
+Catalog transport retains the six-name native manifest declaration, separately
+from the consumer's presentation-region mapping; unknown names are refused.
+`HostContract.nativeDefaultSection` resolves placement using the host's public
+`Util.barSections` contract: missing/default `center` becomes `centerEnd` on the
+six-section host, and remains `center` on 4.0.4. This is not inferred from the
+user's layout. Standalone hosted widgets receive optional `fixedEdge` before
+`bar`, reactively from their actual suite anchoring. The hidden Status tray
+backend and shared embedding allowlist are unchanged.
+
+### Deferred tray-drawer widgets
+
+`bar.layout.tray` entries remain intact across switching, keep-settings,
+uninstall and reinstall, and participate in identity/conflict checks. They are
+not group providers or visible extras, are not relocated by catalog or automatic
+family reconciliation, and are not instantiated in a custom Shibumi drawer.
+The hidden native tray backend does not acquire a native drawer section host.
+The Control Center health warning and one admitted-Bar journal notice explain:
+
+> Tray-drawer bar widgets are preserved but not shown by Shibumi. Open Control
+> Center > Bars and switch to Omarchy (stock bar) to use them.
+
+This limitation does not change Shibumi's existing application-tray views. The
+forward geometry fixture did not reproduce a clipping-forwarding failure; the
+shared panel click-forwarding implementation remains unchanged.
+
+### Quickshell provenance and evidence limits
+
+The forward manifest records the independently verified Arch `quickshell 0.3.1-1`
+validation package (package ownership and all 123 installed files checked).
+The upstream install list names unversioned `quickshell`; migration `1787399318`
+records the return to 0.3.1's synchronous termination. A reported edge host uses
+Omarchy-repository `0.3.2-6`; no result here establishes that package's live
+acceptance. Source contracts and isolated component fixtures do not establish
+real stock-bar switching, physical output behavior or desktop acceptance on it.
+
## What Shibumi validates
The Quattro source contract binds `SuiteRuntime.HostContract` as one layer for its first-party IDs, IPC targets and host marker/theme paths; it adds no host authority or runtime version discovery.
@@ -94,7 +158,7 @@ SHIBUMI_INSTALLED_SOURCE_OMARCHY_PATH=/path/to/omarchy-v4.0.4 \
./tests/omarchy-installed-source-parity-contract-regression.sh
SHIBUMI_AGENTS_OMARCHY_PATH=/path/to/omarchy-v4.0.0 \
./tests/omarchy-agents-contract-regression.sh
-SHIBUMI_FORWARD_COMPAT_OMARCHY_PATH=/path/to/omarchy-forward-compat-4c1b4cb3 \
+SHIBUMI_FORWARD_COMPAT_OMARCHY_PATH=/path/to/omarchy-forward-compat-246f33a2 \
./tests/omarchy-forward-compat-contract-regression.sh
```
diff --git a/docs/architecture/shared-runtime-v1.md b/docs/architecture/shared-runtime-v1.md
index c384f929..72fccbe4 100644
--- a/docs/architecture/shared-runtime-v1.md
+++ b/docs/architecture/shared-runtime-v1.md
@@ -292,13 +292,20 @@ listPlugins`; separate bounded `/proc` checks bind that PID to the injected
`id/name/kinds/enabled/active/canDisable/firstParty/clonedFrom` identity and adds
only bounded display/search metadata from the matching package manifest. It is
not an `installedPlugins` manifest map, component resolver, settings store or
-native revision. Manifest discovery is count/byte/probe bounded; every path
-component and final file is opened without following symlinks, and changed or
-ambiguous files refuse publication. This enrichment grants no mutation right:
+native revision. Manifest discovery is count/byte/probe bounded. Third-party
+plugin directories may resolve through symlinks, matching the registry's scan;
+the resolved directory is opened component-wise without following further links.
+Manifest files still require no-follow, bounded, stable, single-link regular-file
+reads. Invalid, unreadable, missing or ambiguous metadata removes only that row's
+presentation fields and supplies a `catalogIssue` code; its native fields remain.
+When an unreadable manifest has no attributable ID, the unmatched row reports
+`metadata-missing` rather than guessing its identity. Identity, IPC, native DTO
+shape, stale-catalog and resource-limit failures still refuse the whole catalog.
+This enrichment grants no mutation right:
a current exact host component selection is revalidated before activation, so
`defaultSection` can only suggest placement after that authority exists. Native
-ordering is retained. Empty-but-valid is ready; malformed, ambiguous or
-unavailable is not. JSON keys and catalog IDs must be unique; booleans, Unicode,
+ordering is retained. Empty-but-valid and metadata-degraded catalogs are ready;
+malformed native identity or unavailable transport is not. JSON keys and catalog IDs must be unique; booleans, Unicode,
structural depth and sizes are checked before publication.
Ancestry must complete within one immutable current observation and 32 entries,
without missing or cyclic parents, mixed `entries`/`byId` rows, or inherited
@@ -328,7 +335,10 @@ bytes and three seconds; strict UTF-8/JSON rejects duplicate keys and nonfinite
values. The native shell PID selects the IPC server; bounded `/proc` identity
checks bind that PID to the exact shell directory before and after the read, so
an overlapping older instance of the same path cannot answer. It exposes no
-native stderr. The QML command owns a five-second one-shot
+native stderr; refusal emits one path-free code token, forwarded to the catalog
+error state and existing page/action text. Status and Health each use one helper
+invocation (including its unchanged-snapshot verification), not an additional
+registry scan, and warn once for refusal or per-row degradation. The QML command owns a five-second one-shot
deadline and TERM-to-KILL cancellation with a 1.5-second grace. A separate
short-lived custodian owns the IPC process group, permits launch only after its
ready/start handshake and retains group identity until cleanup. Launcher death,
diff --git a/docs/development/packaging.md b/docs/development/packaging.md
index 72a97d2f..f0a949a8 100644
--- a/docs/development/packaging.md
+++ b/docs/development/packaging.md
@@ -1,6 +1,6 @@
# Source installation and planned OPR packaging
-Status: install `0.1.1-beta.16.4` from source. OPR (omarchy-pkgs) packaging
+Status: install `0.1.1-beta.16.5` from source. OPR (omarchy-pkgs) packaging
is planned; AUR publication is no longer the target.
Shibumi ships one versioned suite containing 24 separately validated Omarchy
@@ -69,7 +69,7 @@ and lifecycle code before the user-level update runs, so Beta.12's storage guard
cannot reject that package afterward. There is no reverse migration to legacy
storage.
-The additional [Beta.16.4 downgrade warning and backup requirements](../install.md#backup-before-downgrade)
+The additional [Beta.16.5 downgrade warning and backup requirements](../install.md#backup-before-downgrade)
apply even to readers using canonical State storage: Beta.15.4 and older
cannot preserve a V1 layout containing `order.parked`. `--keep-settings` does
not prevent this. Restore the settings with a matching payload.
diff --git a/docs/development/release.md b/docs/development/release.md
index c07e4f6b..aa60f36e 100644
--- a/docs/development/release.md
+++ b/docs/development/release.md
@@ -126,10 +126,16 @@ SHIBUMI_INSTALLED_SOURCE_OMARCHY_PATH=/path/to/omarchy-v4.0.4 \
python3 tests/native-catalog-resource-regression.py
SHIBUMI_AGENTS_OMARCHY_PATH=/path/to/omarchy-v4.0.0 \
./tests/omarchy-agents-contract-regression.sh
-SHIBUMI_FORWARD_COMPAT_OMARCHY_PATH=/path/to/omarchy-forward-compat-4c1b4cb3 \
+SHIBUMI_FORWARD_COMPAT_OMARCHY_PATH=/path/to/omarchy-forward-compat-246f33a2 \
./tests/omarchy-forward-compat-contract-regression.sh
```
+The forward checkout must be exactly `246f33a2fef66411c990860d88e0823ae83987fc`.
+Private forward-alignment patches do not authorize runner reprovisioning,
+Actions-variable changes, installation, or publication. Record Quickshell
+package provenance and keep `0.3.1-1` fixture results separate from edge
+`0.3.2-6` live acceptance.
+
The release-evidence runner preflights all three external checkouts before any long host gate. Every path component must be canonical and must not be a symbolic link. The runner passes the accepted canonical paths to every gate. It verifies each checkout root, pinned HEAD commit, Git tree, cleanliness, complete tracked/untracked working-tree digest, file count, file type, and mode. Malformed, relative, missing, dirty, or wrong-revision inputs fail closed. The NativeCatalog resource regression is a required gate and resolves its unchanged pinned 4.0.4 shell assertion from `SHIBUMI_INSTALLED_SOURCE_OMARCHY_PATH/shell`; its existing warmup and resource ceilings are not release-runner overrides.
The candidate and all external-baseline identities are revalidated immediately before and after every evidence gate and once more after the final gate. Candidate content, mode, staged-state, untracked-state, commit, or tree drift and baseline cleanliness, revision, tree, content, or mode drift invalidate the evidence. These checks detect drift present at a validation boundary; they do not detect an input that is changed and restored entirely while one gate is running. The validation runner therefore remains a trusted, access-controlled system, and concurrent writers must be excluded for the full run. Gate commands are repository-owned tests from the exact reviewed commit, so a malicious gate means the candidate itself is already compromised and is outside the collector's threat model. Same-UID replacement of in-progress log or archive paths and symlink manipulation of output-parent directories are accepted release-machine risks; use a clean, non-symlinked output tree with no untrusted or concurrent writers.
diff --git a/docs/development/testing.md b/docs/development/testing.md
index c7a8caf6..531826bb 100644
--- a/docs/development/testing.md
+++ b/docs/development/testing.md
@@ -71,7 +71,7 @@ The forward-compat job separately requires the immutable upstream snapshot used
by the engineering audit:
```bash
-SHIBUMI_FORWARD_COMPAT_OMARCHY_PATH=/path/to/omarchy-forward-compat-4c1b4cb3 \
+SHIBUMI_FORWARD_COMPAT_OMARCHY_PATH=/path/to/omarchy-forward-compat-246f33a2 \
./tests/omarchy-forward-compat-contract-regression.sh
```
@@ -99,8 +99,14 @@ non-overlapping claims; the Agents job selects its separate narrower manifest:
remain immutable optional compatibility references alongside 4.0.2; choose
a historical manifest with `SHIBUMI_OMARCHY_BASELINE_VERSION=4.0.3` or
`SHIBUMI_OMARCHY_BASELINE_VERSION=4.0.2`;
-- `contracts/baselines/omarchy-forward-compat-4c1b4cb3.json` records the
- upstream snapshot pinned for the separate forward-compatibility gate.
+- `contracts/baselines/omarchy-forward-compat-246f33a2.json` records
+ `246f33a2fef66411c990860d88e0823ae83987fc` for the separate forward gate.
+ Its Quickshell provenance is the independently verified Arch `0.3.1-1`
+ validation package; edge-host `0.3.2-6` results require separate evidence.
+ Six native layout sections must remain distinct from three presentation regions.
+ Use the existing State/lifecycle and inline-delta fixtures; tray data is deferred,
+ not dropped. The stable 37-step bar-switch and 140-case renderer rest checks,
+ plus N3 bar-switch/focus measurements versus Beta.16.4, remain required.
All three manifests bind the complete consumed `shell`, `bin`, and `config`
subtrees by entry count, path inventory, entry type, executable state, symlink
@@ -576,7 +582,10 @@ An active malformed `busy` value cannot override an exact accepted-operation dra
throwing cancellation remains revoked and cannot strand pre-start bookkeeping.
The transport leaf has inert output/error/exit/timeout/descendant controls at all
-byte limits. A separate two-instance regression starts older and newer isolated
+byte limits. Its manifest cases cover registry-compatible development-directory
+symlinks and per-row metadata degradation, while native identity/IPC/limit
+refusals remain fail-closed. The Control Center fixture distinguishes a refused
+catalog from an empty filter using the existing text surface. A separate two-instance regression starts older and newer isolated
shells at one path: path-only IPC demonstrates the ambiguity, while the production
helper must return the DTO owned by each exact PID. A separate QML fixture executes actual NativeCatalog, QProcess and
helper/custodian code, replacing **only** the IPC leaf with an inert process tree.
diff --git a/docs/development/validation-host.md b/docs/development/validation-host.md
index d3ca2b15..a2b27435 100644
--- a/docs/development/validation-host.md
+++ b/docs/development/validation-host.md
@@ -115,7 +115,12 @@ reviewed Shibumi checkout, match these manifest `sourceRevision` values exactly:
| --- | --- | --- |
| `omarchy-installed-source-parity-v4.0.4.json` | `c668141e9c42b13c80c9ca4ea108e11708c5e8a5` | `SHIBUMI_INSTALLED_SOURCE_OMARCHY_PATH` |
| `omarchy-agents-v4.0.0.json` | `f0020448ca87329199de7cb12f2015ebc4a3e5e7` | `SHIBUMI_AGENTS_OMARCHY_PATH` |
-| `omarchy-forward-compat-4c1b4cb3.json` | `4c1b4cb3a94447aff39af98e2fcc0490a9fb32e7` | `SHIBUMI_FORWARD_COMPAT_OMARCHY_PATH` |
+| `omarchy-forward-compat-246f33a2.json` | `246f33a2fef66411c990860d88e0823ae83987fc` | `SHIBUMI_FORWARD_COMPAT_OMARCHY_PATH` |
+
+The private Beta.16.5 alignment round uses an isolated reference checkout and
+Arch Quickshell `0.3.1-1` (package ownership/integrity independently checked).
+It does not reprovision the runner or change its Actions variable. Reported edge
+Quickshell `0.3.2-6` is a separate validation variable, not covered by that manifest.
After provisioning approval, clone `https://github.com/basecamp/omarchy` separately
for each profile and `git checkout --detach `. Check `HEAD^{commit}`,
diff --git a/docs/install.md b/docs/install.md
index 0d8e045c..ef2ea399 100644
--- a/docs/install.md
+++ b/docs/install.md
@@ -6,8 +6,9 @@ Shibumi installs 24 independent plugin roots into Omarchy's normal plugin
directory. The repository root is a suite source, not a single installable
Omarchy plugin.
-`0.1.1-beta.16.4` is the target GitHub prerelease. Install from the release
-tag using the source workflow below.
+`0.1.1-beta.16.5` is the target GitHub prerelease. See the
+[release notes](../.github/release-notes/v0.1.1-beta.16.5.md) for changes.
+Install from the release tag using the source workflow below.
Do not install or update from `main`.
## Requirements
@@ -30,7 +31,7 @@ For an intentional non-interactive installation:
sudo pacman -S --needed python jq curl networkmanager power-profiles-daemon upower xdg-utils libnotify wl-clipboard ttf-material-symbols-variable ttf-jetbrains-mono-nerd-basic noto-fonts-cjk adwaita-fonts
git clone https://github.com/HANCORE-linux/Shibumi-Shell.git
cd Shibumi-Shell
-git checkout v0.1.1-beta.16.4
+git checkout v0.1.1-beta.16.5
./scripts/shibumi-suite install --yes
```
@@ -46,7 +47,7 @@ To inspect the transaction before installing:
sudo pacman -S --needed python jq curl networkmanager power-profiles-daemon upower xdg-utils libnotify wl-clipboard ttf-material-symbols-variable ttf-jetbrains-mono-nerd-basic noto-fonts-cjk adwaita-fonts
git clone https://github.com/HANCORE-linux/Shibumi-Shell.git
cd Shibumi-Shell
-git checkout v0.1.1-beta.16.4
+git checkout v0.1.1-beta.16.5
./scripts/shibumi-suite install --dry-run
./scripts/shibumi-suite install
```
@@ -126,14 +127,14 @@ Pacman updating `/usr/share/shibumi-shell` does not silently change a running
desktop. The explicit update validates, stages, reloads, and verifies all 24
plugins as one transaction.
-To update an admitted Beta.16.3 source installation to Beta.16.4, use its
-trusted checkout. The exact published Beta.16.3 tag and the documented
-source-installation integration merge are admitted predecessors:
+Update an admitted Beta.16.4 source installation to Beta.16.5 from its
+trusted checkout. The exact published Beta.16.4 tag (`64b9369`) and its main
+integration merge (`aec6c0a`) are admitted predecessors:
```bash
cd Shibumi-Shell
git fetch --tags
-git checkout v0.1.1-beta.16.4
+git checkout v0.1.1-beta.16.5
./scripts/shibumi-suite update --dry-run
./scripts/shibumi-suite update --yes
./scripts/shibumi-suite status
@@ -186,7 +187,7 @@ authoritative origin. The old checkout is not deleted or modified.
### Roll back a package version
> [!WARNING]
-> **Beta.16.4 downgrade warning.**
+> **Beta.16.5 downgrade warning.**
> Beta.15.4 and older cannot read V1 layouts containing
> `order.parked`. They reset V1 order, roles, and splits to defaults; other
> settings and V2 remain. `uninstall --keep-settings` does not prevent this.
@@ -386,7 +387,7 @@ then install the published tag. For example, only if the recorded revision is
```bash
git checkout e92d8ee1ce9821d0b203a9d4d5dd9932d2e460fb
./scripts/shibumi-suite uninstall --keep-settings --yes
-git checkout v0.1.1-beta.16.4
+git checkout v0.1.1-beta.16.5
./scripts/shibumi-suite install --yes
```
diff --git a/docs/plugins/README.md b/docs/plugins/README.md
index fe81dbd3..48e86120 100644
--- a/docs/plugins/README.md
+++ b/docs/plugins/README.md
@@ -2,7 +2,7 @@
Status: reference
-Shibumi `0.1.1-beta.16.4` contains 24 independently registered Omarchy plugins. The
+Shibumi `0.1.1-beta.16.5` 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 f390277e..1465b189 100644
--- a/docs/release-readiness.md
+++ b/docs/release-readiness.md
@@ -1,10 +1,80 @@
-# Shibumi 0.1.1-beta.16.4 release readiness
+# Shibumi 0.1.1-beta.16.5 release readiness
> **Document status: Revision-bound validation record.** This page separates
> recorded product evidence from release, package, and physical acceptance.
> It cannot override [`../ARCHITECTURE.md`](../ARCHITECTURE.md).
-## Candidate and published predecessor
+## Accepted Beta.16.5 product series and validation boundary
+
+The accepted local C1–C8 series is based on
+`aec6c0a3266d71f6a5f9cfa17562d8bec0ce9b17`, the main integration merge of
+published Beta.16.4. Its complete tree equals the frozen predecessor tag
+`64b9369559b0de2ffdc1faf1c563326c8173035c`. Both predecessor revisions remain
+admitted with unchanged Beta.16.4 plugin and suite payload pins.
+
+| Commit | Accepted revision |
+| --- | --- |
+| C1 | `077bdd52fb636ebcfcbfd98b3d04f8e22837a658` |
+| C2 | `2d4e2b10ecc1d8956ee57321b0457a81a60dbfd2` |
+| C3 | `0c15f588383b5c4ebb42d9196a332b6df36487d6` |
+| C4 | `bc488cf3aae9f3a341c3b31bcc47886303ecf18f` |
+| C5 | `e744f50e6972de5d37b23ea234e2d33f92184fda` |
+| C6 | `784beb417c8e1005fa5eaed546c382ee2768041b` |
+| C7 | `56192010503c7437909f8152db3570e4136947dc` |
+| C8 | `201c4130dc9e7cae6bcd7a6c770f08a01817e83a` |
+
+Independent patch-to-commit fidelity review passed for all eight commits.
+C8 reproduces the accepted R9 source bytes and modes; this audit is not a
+substitute for final release gates. R9 payload identities remain:
+
+- Suite (24 plugins): `a090b82858c554f02d44030755f779b2827ae51e55bca750b7163d5b330472dd`.
+- Media: `e2e5a12b492443a836ce2e5b6c352e68be0d7779f8bc462d89ea30d792aaf928`.
+- Control Center: `078c0cc95304da956b2487953dd6703425517875178fd890cbb2a384b662e521`.
+
+Omarchy 4.0.5 is admitted as the stable reference; the forward reference is
+`246f33a2fef66411c990860d88e0823ae83987fc`. The installed-package/source-parity
+4.0.4 and Agents pins below remain separate proof axes.
+
+**Candidate gates:** R8 (patches 01–32, snapshot 14; suite
+`89682a68a8816c96ffba131f165258e3c0536ac0a0bbaafdb40a044271bae246`)
+passed the complete stable 4.0.4, forward `246f33a2` and additional 4.0.5
+aggregates; audio/media on all three references; suite 180/180 in each of the
+stable script, stable module and forward script runs; package 27/27 and
+admission 40/40; and renderer-rest 140/140 exact comparisons against Beta.16.4.
+Lifecycle passed eight members on each primary reference (stable/forward),
+with the ninth, dirty-source dry-run, correctly refused. R9 (R8 + patches
+33–34; four paths for Media's `noise_reduction` change and repin) passed
+audio/media on all three references, suite 180/180 in the same three invocation
+forms, package 27/27, admission 40/40 and lifecycle 8/9 on each primary
+reference, again with the dirty-source dry-run correctly refused. Complete
+aggregates and renderer-rest were intentionally not repeated for R9. The
+standalone 37-step bar-switch fixture passed 37/37 on an earlier round-402
+16.5 payload; it is not part of those aggregates and was not rerun for R8/R9.
+Host round trips (Shibumi → stock bar → back, including retained layouts)
+were checked live on main, .140 and machine2 (R4 only) instead; these are not
+new 37-step fixture results. Exact-final-commit gates remain pending against
+`FINAL_SHA`.
+
+**N3 window 02 (R9 versus Beta.16.4):** accepted analysis of 480 private
+renderer sessions found playing-state process CPU about 19% lower for Default
+and 51% lower for Full (median of paired five-second windows, lower in every
+pair). These figures come from a software-rendered fixture with a synthetic
+spectrum; they describe the tested renderer scenarios, not GPU smoothness or
+real-cava CPU. They do not establish a blanket desktop speedup.
+
+**Live validation:** main ran Omarchy 4.0.4 / Quickshell 0.3.1; .140 ran
+Omarchy 4.0.6 / Quickshell 0.3.2, including the real-music cava smoothing
+comparison. Machine2 evidence covers R4 only, not the final R9 Media/catalog
+candidate. Fixture results and these revision-scoped live results are distinct.
+
+**Final release boundary:** the approved release date is 2026-10-22. The
+exact-final-commit gates, reproducible archive verification and independent
+release review are pending and will be recorded against `FINAL_SHA`. C8 is
+not the final release commit or tag target. Published-predecessor and peeled-tag
+identity must be rechecked for final evidence. Historical Beta.16.4 results
+below do not transfer to this release.
+
+## Historical Beta.16.4 candidate and published predecessor
The Beta.16.4 candidate adds Memory Sweep, Frost for V2 Notch, bounded charging
plasma, Rings placement fixes, persistent power-profile selection, shared
@@ -17,8 +87,9 @@ collector, and reproducible archive verification must record their exact final
commit externally. No predecessor gate result transfers to this candidate.
Beta.16.3 was published on 2026-10-10 as a GitHub prerelease from
-`1a13d6be7d5b4bc9aa03590b77b6531aefcad497`. It is the frozen rolling
-predecessor in [`release-predecessor.json`](../tests/fixtures/release-predecessor.json).
+`1a13d6be7d5b4bc9aa03590b77b6531aefcad497`. It was the frozen rolling
+predecessor for Beta.16.4; [`release-predecessor.json`](../tests/fixtures/release-predecessor.json)
+now records Beta.16.4 for the private Beta.16.5 preparation.
That exact tag checkout and the source-installation integration merge
`494fa69b9e348fa595eb4d55d2e11b4aa2fd4470` (PR #77) are admitted by
[`lifecycle-predecessors-v1.json`](../contracts/lifecycle-predecessors-v1.json),
@@ -30,7 +101,7 @@ tree is not identical to the release tag. Older exact identities remain admitted
a version string alone never authorizes recovery or replacement. Installation
remains source-based; OPR packaging is planned, not AUR publication.
-## Recorded evidence and outstanding gates
+## Historical Beta.16.4 evidence and outstanding gates at that snapshot
| Gate | Evidence and boundary |
| --- | --- |
@@ -65,7 +136,7 @@ The release's installed-package and source-parity contracts target Omarchy
| Installed package profile | `installed-package-v4.0.4` |
| Source parity | `installed-source-parity-v4.0.4`, `c668141e9c42b13c80c9ca4ea108e11708c5e8a5` |
| Agents reference | `v4.0.0`, `f0020448ca87329199de7cb12f2015ebc4a3e5e7` |
-| Forward reference | `4c1b4cb3a94447aff39af98e2fcc0490a9fb32e7` |
+| Forward reference | `246f33a2fef66411c990860d88e0823ae83987fc` |
The manifests in [`contracts/baselines/`](../contracts/baselines/) bind consumed
files and subtrees. Historical 4.0.3/4.0.2 manifests remain explicit compatibility
@@ -102,6 +173,16 @@ was performed. Each operation needs its own maintainer Go.
## Known limits and downgrade warning
+- The Default-style marquee commits two frames per scroll step while text
+ scrolls (about 250 commits for 125 steps in five seconds). There is no visible
+ effect and the cost stays well below Beta 16.4; a layer-free marquee is planned
+ for 0.2.0.
+- Tray-drawer entries are retained and disclosed but not rendered in Shibumi;
+ use the stock Omarchy bar for their drawer presentation.
+- Notch can still oscillate at DPR 1.6. Notification focus remains limited when
+ the sender does not provide a supported application identity.
+- If Memory Sweep loses its service owner, the authorization dialog may disappear
+ without a result; avoid restarting Sweep during an owner change.
- Horizontal Top and Bottom are supported. Vertical Full clipping and Icon
alignment remain outside acceptance; the vertical bar is deferred to V3 in
`0.2.0-beta`.
@@ -124,7 +205,7 @@ was performed. Each operation needs its own maintainer Go.
physical acceptance are not claimed.
- **Downgrade:** Beta.15.4 and older cannot read V1 `order.parked`; they reset
V1 order, roles, and splits while retaining other settings and V2.
- `--keep-settings` does not prevent this. The [release notes](../.github/release-notes/v0.1.1-beta.16.3.md)
+ `--keep-settings` does not prevent this. The [release notes](../.github/release-notes/v0.1.1-beta.16.5.md)
and [backup guide](install.md#backup-before-downgrade) identify the complete
settings and recovery evidence to preserve. Restore the settings with a
matching payload.
@@ -151,4 +232,4 @@ release checklist. Beta.16's published changes and limits remain in its
revision `30746f5` passed the complete contract and 32-generation runtime;
those fixtures retained known teardown warnings and did not establish blanket
hardware acceptance. Earlier readiness snapshots remain in Git history; none
-confers acceptance on Beta.16.4.
+confers acceptance on Beta.16.5.
diff --git a/hancore.shibumi.ai/Service.qml b/hancore.shibumi.ai/Service.qml
index 16f0e104..c309cfc5 100644
--- a/hancore.shibumi.ai/Service.qml
+++ b/hancore.shibumi.ai/Service.qml
@@ -19,7 +19,7 @@ Item {
SuiteRuntime.HostShell { id: suiteShell; host: root.shell }
SuiteRuntime.Provider {
pluginId: "hancore.shibumi.ai"
- implementationVersion: "0.1.1-beta.16.4"
+ implementationVersion: "0.1.1-beta.16.5"
owner: root
host: root.shell
manifest: root.manifest
diff --git a/hancore.shibumi.ai/manifest.json b/hancore.shibumi.ai/manifest.json
index e711d627..441c25a0 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.16.4",
+ "version": "0.1.1-beta.16.5",
"author": "HANCORE",
"license": "MIT",
"description": "Shibumi Claude, Codex, and OpenCode usage presentation.",
diff --git a/hancore.shibumi.audio/Service.qml b/hancore.shibumi.audio/Service.qml
index fd7c099a..6e926ef3 100644
--- a/hancore.shibumi.audio/Service.qml
+++ b/hancore.shibumi.audio/Service.qml
@@ -14,7 +14,7 @@ Item {
SuiteRuntime.Provider {
id: runtimeProvider
pluginId: "hancore.shibumi.audio"
- implementationVersion: "0.1.1-beta.16.4"
+ implementationVersion: "0.1.1-beta.16.5"
owner: root
host: root.shell
manifest: root.manifest
diff --git a/hancore.shibumi.audio/manifest.json b/hancore.shibumi.audio/manifest.json
index 58ae658a..4915f16a 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.16.4",
+ "version": "0.1.1-beta.16.5",
"author": "HANCORE",
"license": "MIT",
"description": "Shibumi audio presentation over Omarchy's PipeWire owner.",
diff --git a/hancore.shibumi.bar/Bar.qml b/hancore.shibumi.bar/Bar.qml
index fc4cfab7..f286956b 100644
--- a/hancore.shibumi.bar/Bar.qml
+++ b/hancore.shibumi.bar/Bar.qml
@@ -28,7 +28,7 @@ Item {
SuiteRuntime.Provider {
id: barRuntimeProvider
pluginId: "hancore.shibumi.bar"
- implementationVersion: "0.1.1-beta.16.4"
+ implementationVersion: "0.1.1-beta.16.5"
owner: root
host: suiteHostShell.host
manifest: root.manifest
@@ -57,6 +57,7 @@ Item {
property bool hostReady: false
readonly property bool mutationAdmissionReady:
hostReady && startupAdmissionSatisfied && !shutdownPrepared
+ && GroupRegistry.validNativeLayout(barConfig && barConfig.layout)
// Consumed only by the process-singleton runtime IPC handler. A handler is
// admitted after complete host injection and startup, and is revoked before
// this Bar begins shutdown or whenever Bar ownership overlaps.
@@ -101,6 +102,17 @@ Item {
layout: { left: [], center: [], right: [] }
})
property var layoutConfig: fallbackBarConfig.layout
+ readonly property string trayDrawerDiagnostic: SuiteRuntime.HostContract.trayDrawerDiagnostic
+ readonly property bool trayDrawerDeferred: !!layoutConfig
+ && Array.isArray(layoutConfig.tray) && layoutConfig.tray.length > 0
+ readonly property bool trayDrawerNoticeReady: mutationAdmissionReady && trayDrawerDeferred
+ property bool trayDrawerNoticeLogged: false
+ onTrayDrawerNoticeReadyChanged: {
+ if (trayDrawerNoticeReady && !trayDrawerNoticeLogged) {
+ trayDrawerNoticeLogged = true
+ console.warn(trayDrawerDiagnostic)
+ }
+ }
property string centerAnchor: ""
property string position: "top"
// Quattro keeps this facade surface for stock-widget compatibility, but
@@ -363,10 +375,9 @@ Item {
function hostWidgetConfiguredEnabled(pluginId) {
const id = validDiagnosticPluginId(pluginId)
const layout = barConfig && barConfig.layout
- if (id === "" || !layout) return false
- for (const region of ["left", "center", "right"]) {
- const entries = layout[region]
- if (!Array.isArray(entries)) return false
+ if (id === "" || !GroupRegistry.validNativeLayout(layout)) return false
+ for (const region of GroupRegistry.NativeSections) {
+ const entries = layout[region] || []
for (let index = 0; index < entries.length; index++) {
const entry = entries[index]
if (entryId(entry) === id
@@ -557,8 +568,9 @@ Item {
}
function inlineSettingsDelta(current, next) {
- if (!Util.isPlainObject(current) || !Util.isPlainObject(next)) return null
- const regions = ["left", "center", "right"]
+ if (!GroupRegistry.validNativeLayout(current)
+ || !GroupRegistry.validNativeLayout(next)) return null
+ const regions = GroupRegistry.NativeSections
const counts = Object.create(null)
for (let regionIndex = 0; regionIndex < regions.length; regionIndex++) {
const entries = Array.isArray(next[regions[regionIndex]])
@@ -572,6 +584,8 @@ Item {
const changes = []
for (let regionIndex = 0; regionIndex < regions.length; regionIndex++) {
const region = regions[regionIndex]
+ if (Object.prototype.hasOwnProperty.call(current, region)
+ !== Object.prototype.hasOwnProperty.call(next, region)) return null
const currentEntries = Array.isArray(current[region])
? current[region] : []
const nextEntries = Array.isArray(next[region]) ? next[region] : []
@@ -611,11 +625,14 @@ Item {
// same-ID WidgetSlot to invalidate. If any changed entry lacks a live slot,
// use the structural path rather than leaving that consumer stale.
for (let changeIndex = 0; changeIndex < changes.length; changeIndex++) {
- const id = entryId(changes[changeIndex].entry)
+ const change = changes[changeIndex]
+ const id = entryId(change.entry)
let found = false
for (let slotIndex = 0; slotIndex < moduleSlots.length; slotIndex++) {
const slot = moduleSlots[slotIndex]
if (slot && slot.moduleName === id
+ && (!("nativeSection" in slot)
+ || (slot.nativeSection === change.region && slot.nativeIndex === change.index))
&& typeof slot.applyInlineSettings === "function") {
found = true
break
@@ -635,6 +652,8 @@ Item {
for (let slotIndex = 0; slotIndex < moduleSlots.length; slotIndex++) {
const slot = moduleSlots[slotIndex]
if (slot && slot.moduleName === id
+ && (!("nativeSection" in slot)
+ || (slot.nativeSection === change.region && slot.nativeIndex === change.index))
&& typeof slot.applyInlineSettings === "function")
slot.applyInlineSettings(change.entry)
}
@@ -642,12 +661,26 @@ Item {
return true
}
+ function normalizeNativeLayout(layout) {
+ if (!GroupRegistry.validNativeLayout(layout)) return null
+ const result = ({})
+ for (const section of GroupRegistry.NativeSections) {
+ if (GroupRegistry.Regions.indexOf(section) >= 0
+ || Object.prototype.hasOwnProperty.call(layout, section))
+ result[section] = Util.normalizeLayoutSection(layout[section])
+ }
+ return result
+ }
+
function applyBarConfig() {
const config = Util.isPlainObject(barConfig) ? barConfig : fallbackBarConfig
+ const nextLayout = normalizeNativeLayout(config.layout)
+ if (!nextLayout) return
position = normalizePosition(config.position)
requestedStyleId = String(config.style || "shibumi")
- centerAnchor = Util.canonicalWidgetId(config.centerAnchor || "")
- const nextLayout = Util.normalizeLayout(config.layout)
+ centerAnchor = !("centerBegin" in config.layout)
+ && !("centerEnd" in config.layout)
+ ? Util.canonicalWidgetId(config.centerAnchor || "") : ""
// QML cannot diff reassigned JavaScript layout arrays: replacing one for
// an inline setting (for example a saved game score) recreates every
// widget and closes its panel. Match Omarchy's stock host by updating
@@ -780,7 +813,10 @@ Item {
}
function unassignedLayoutEntries(region) {
- const entries = GroupRegistry.unassignedEntries(layoutConfig, region)
+ const entries = []
+ for (const section of GroupRegistry.NativeSections)
+ if (GroupRegistry.presentationRegion(section) === region)
+ entries.push(...GroupRegistry.unassignedEntries(layoutConfig, section))
const removing = layoutTransitionBusy && layoutTransition.operation
&& layoutTransition.operation.intent
&& Array.isArray(layoutTransition.operation.intent.removeIds)
@@ -815,9 +851,9 @@ Item {
const specs = []
const seen = Object.create(null)
for (const region of ["left", "center", "right"]) {
- const entries = Array.isArray(source[region]) ? source[region] : []
+ const entries = GroupRegistry.projectedEntries(source, region)
for (let index = 0; index < entries.length; index++) {
- const entry = entries[index]
+ const entry = entries[index].entry
const id = entryId(entry)
const hasBarWidget = hasBarWidgetEntryPoint(id)
const shibumiModule = hasBarWidget && ((Util.isPlainObject(entry)
@@ -910,10 +946,19 @@ Item {
return reconcileWidgetFamilyProviders()
}
+ function isDeferredTrayWidget(widgetId) {
+ return GroupRegistry.validNativeLayout(layoutConfig)
+ && (layoutConfig.tray || []).some(entry => entryId(entry) === String(widgetId || ""))
+ }
+
+ function isTrayOnlyWidget(widgetId) {
+ return GroupRegistry.trayOnly(layoutConfig, String(widgetId || ""))
+ }
+
function layoutContains(widgetId) {
const id = String(widgetId || "")
- if (!id) return false
- for (const region of ["left", "center", "right"]) {
+ if (!id || !GroupRegistry.validNativeLayout(layoutConfig)) return false
+ for (const region of GroupRegistry.NativeSections) {
const entries = layoutEntries(region)
for (let index = 0; index < entries.length; index++) {
if (entryId(entries[index]) !== id) continue
@@ -932,7 +977,7 @@ Item {
function v1AdditionalWidgetPlaced(widgetId) {
const id = String(widgetId || "")
return !layoutStateController.v2Mode && isV1AdditionalSuiteWidget(id)
- && GroupRegistry.configuredEntry(layoutConfig, id) !== null
+ && GroupRegistry.configuredEntry(layoutConfig, id, true) !== null
&& layoutStateController.groupLocation("G:" + id) !== null
}
@@ -985,12 +1030,16 @@ Item {
}
function renderedProviderEntries(layoutValue, region) {
+ // Native conflict inventory includes deferred providers, even aliases.
+ if (region === "tray") return GroupRegistry.validNativeLayout(layoutValue)
+ ? layoutValue.tray || [] : []
return GroupRegistry.unassignedEntries(layoutValue, region)
}
function providerFamilyGroupsInLayout(layoutValue) {
const groups = []
- for (const region of ["left", "center", "right"]) {
+ for (const region of GroupRegistry.NativeSections) {
+ if (region === "tray") continue // Deferred entries do not own visible groups.
const entries = renderedProviderEntries(layoutValue, region)
for (let index = 0; index < entries.length; index++) {
const familyGroups = widgetFamilyGroups(entryId(entries[index]))
@@ -1007,7 +1056,7 @@ Item {
function conflictingLayoutProviderIds(widgetId) {
const id = String(widgetId || "")
const result = id !== "" ? [id] : []
- for (const region of ["left", "center", "right"]) {
+ for (const region of GroupRegistry.NativeSections) {
const entries = renderedProviderEntries(layoutConfig, region)
for (let index = 0; index < entries.length; index++) {
const candidateId = entryId(entries[index])
@@ -1022,7 +1071,7 @@ Item {
function conflictingProviderIdsInLayout(layoutValue) {
const capabilityOwners = ({})
const removed = []
- for (const region of ["left", "center", "right"]) {
+ for (const region of GroupRegistry.NativeSections) {
const entries = renderedProviderEntries(layoutValue, region)
for (let index = 0; index < entries.length; index++) {
const id = entryId(entries[index])
@@ -1062,15 +1111,17 @@ Item {
}
function currentLayoutSnapshot() {
- return JSON.parse(JSON.stringify({
- left: layoutEntries("left"),
- center: layoutEntries("center"),
- right: layoutEntries("right")
- }))
+ return GroupRegistry.validNativeLayout(layoutConfig)
+ ? JSON.parse(JSON.stringify(layoutConfig)) : null
+ }
+
+ function nativeTargetSection(region) {
+ return SuiteRuntime.HostContract.nativeDefaultSection(region,
+ "barSections" in Util ? Util.barSections : undefined)
}
function planV2DynamicLayout(layoutValue, slotsValue) {
- if (!Util.isPlainObject(slotsValue) || !Util.isPlainObject(layoutValue)
+ if (!Util.isPlainObject(slotsValue) || !GroupRegistry.validNativeLayout(layoutValue)
|| !["left", "center", "right"].every(region =>
Array.isArray(layoutValue[region]) && Array.isArray(slotsValue[region]))) return null
const nextLayout = JSON.parse(JSON.stringify(layoutValue))
@@ -1096,12 +1147,13 @@ Item {
// Count before moving or reordering so duplicates across regions also
// fail closed without ever reaching shell-config persistence.
const actualCounts = Object.create(null)
- for (const region of ["left", "center", "right"]) {
- const entries = nextLayout[region]
+ for (const region of GroupRegistry.NativeSections) {
+ const entries = nextLayout[region] || []
for (let entryIndex = 0; entryIndex < entries.length; entryIndex++) {
const moduleId = entryId(entries[entryIndex])
if (!Object.prototype.hasOwnProperty.call(desiredIds, moduleId))
continue
+ if (region === "tray") return false
actualCounts[moduleId] = (actualCounts[moduleId] || 0) + 1
}
}
@@ -1116,8 +1168,8 @@ Item {
const moduleId = desired[index]
let sourceRegion = ""
let sourceIndex = -1
- for (const candidateRegion of ["left", "center", "right"]) {
- const entries = nextLayout[candidateRegion]
+ for (const candidateRegion of GroupRegistry.NativeSections) {
+ const entries = nextLayout[candidateRegion] || []
const candidateIndex = entries.findIndex(function(entry) {
return entryId(entry) === moduleId
})
@@ -1128,9 +1180,12 @@ Item {
}
}
if (sourceRegion === "") return false
- if (sourceRegion === region) continue
+ if (GroupRegistry.presentationRegion(sourceRegion) === region) continue
+ const targetSection = nativeTargetSection(region)
+ if (targetSection === "") return false
const entry = nextLayout[sourceRegion].splice(sourceIndex, 1)[0]
- nextLayout[region].push(entry)
+ if (!Array.isArray(nextLayout[targetSection])) nextLayout[targetSection] = []
+ nextLayout[targetSection].push(entry)
changed = true
}
}
@@ -1139,26 +1194,22 @@ Item {
// unassigned entries retain their relative positions and settings.
for (const region of ["left", "center", "right"]) {
const desired = desiredByRegion[region]
- const desiredSet = Object.create(null)
- for (let index = 0; index < desired.length; index++) {
- const moduleId = desired[index]
- desiredSet[moduleId] = true
- const entries = nextLayout[region]
- const entryIndex = entries.findIndex(function(entry) {
- return entryId(entry) === moduleId
- })
- if (entryIndex < 0) return false
- dynamicById[moduleId] = entries[entryIndex]
+ const positions = GroupRegistry.projectedEntries(nextLayout, region)
+ .filter(function(row) { return desired.indexOf(row.id) >= 0 })
+ if (positions.length !== desired.length) return false
+ const sectionById = Object.create(null)
+ for (const row of positions) {
+ dynamicById[row.id] = row.entry
+ sectionById[row.id] = row.section
}
- let dynamicIndex = 0
- for (let entryIndex = 0;
- entryIndex < nextLayout[region].length; entryIndex++) {
- const currentId = entryId(nextLayout[region][entryIndex])
- if (!desiredSet[currentId]) continue
- if (dynamicIndex >= desired.length) return false
- const desiredId = desired[dynamicIndex++]
- if (currentId === desiredId) continue
- nextLayout[region][entryIndex] = dynamicById[desiredId]
+ for (let index = 0; index < positions.length; index++) {
+ const position = positions[index]
+ const desiredId = desired[index]
+ // The three-region editor cannot express a native center boundary.
+ // Refuse cross-boundary swaps rather than silently remapping entries.
+ if (sectionById[desiredId] !== position.section) return false
+ if (position.id === desiredId) continue
+ nextLayout[position.section][position.index] = dynamicById[desiredId]
changed = true
}
}
@@ -1172,9 +1223,9 @@ Item {
if (intent.kind === "provider-snapshot") {
const snapshotLayout = intent.layout
const expectedCurrent = intent.expectedCurrentLayout
- if (!Util.isPlainObject(layoutValue)
- || !Util.isPlainObject(snapshotLayout)
- || !Util.isPlainObject(expectedCurrent)
+ if (!GroupRegistry.validNativeLayout(layoutValue)
+ || !GroupRegistry.validNativeLayout(snapshotLayout)
+ || !GroupRegistry.validNativeLayout(expectedCurrent)
|| !["left", "center", "right"].every(function(region) {
return Array.isArray(layoutValue[region])
&& Array.isArray(snapshotLayout[region])
@@ -1184,35 +1235,56 @@ Item {
layoutValue, expectedCurrent)) return null
return JSON.parse(JSON.stringify(snapshotLayout))
}
- if (intent.kind !== "catalog-layout" || !Util.isPlainObject(layoutValue)
+ if (intent.kind !== "catalog-layout" || !GroupRegistry.validNativeLayout(layoutValue)
|| !["left", "center", "right"].every(function(region) {
return Array.isArray(layoutValue[region])
})) return null
const id = String(intent.id || "")
const region = String(intent.region || "")
if (!LayoutModel.validPluginId(id)
- || ["left", "center", "right"].indexOf(region) < 0
+ || GroupRegistry.NativeSections.indexOf(region) < 0
|| typeof intent.installed !== "boolean"
|| !Array.isArray(intent.removeIds)
|| intent.removeIds.some(function(value) {
return !LayoutModel.validPluginId(value)
})) return null
+ if (region === "tray" || (layoutValue.tray || []).some(entry =>
+ intent.removeIds.indexOf(entryId(entry)) >= 0
+ || (intent.installed && entryId(entry) === id))) return null
const next = JSON.parse(JSON.stringify(layoutValue))
- let retained = null
- for (const part of ["left", "center", "right"]) {
+ let retained = false
+ const relocated = []
+ for (const part of GroupRegistry.NativeSections) {
+ if (!Object.prototype.hasOwnProperty.call(next, part)) continue
next[part] = next[part].filter(function(entry) {
const entryValue = entryId(entry)
- if (entryValue === id && retained === null) retained = entry
+ if (intent.installed && entryValue === id) {
+ if (GroupRegistry.presentationRegion(part)
+ === GroupRegistry.presentationRegion(region)) return true
+ relocated.push(entry)
+ return false
+ }
return intent.removeIds.indexOf(entryValue) < 0
+ }).map(function(entry) {
+ if (!intent.installed || entryId(entry) !== id) return entry
+ // Re-selection updates the native entry in place, never its default.
+ retained = true
+ const configured = Util.isPlainObject(entry) ? entry : {id: id}
+ configured.shibumiModule = true
+ return configured
})
}
if (intent.installed) {
- const entry = retained && Util.isPlainObject(retained)
- ? JSON.parse(JSON.stringify(retained)) : {id: id}
- entry.id = id
- entry.shibumiModule = true
- next[region].push(entry)
+ // Only an explicit cross-region move or a new placement uses the target.
+ if (!retained && relocated.length === 0) relocated.push({id: id})
+ if (relocated.length > 0 && !Array.isArray(next[region])) next[region] = []
+ for (const entry of relocated) {
+ const configured = Util.isPlainObject(entry) ? entry : {id: id}
+ configured.shibumiModule = true
+ next[region].push(configured)
+ }
} else if (intent.keepConfigured === true) {
+ if (!Array.isArray(next[region])) next[region] = []
next[region].push({id: id})
}
return next
@@ -1268,7 +1340,7 @@ Item {
const config = Util.isPlainObject(barConfig) ? barConfig : null
const layout = config && Util.isPlainObject(config.layout)
? config.layout : null
- if (!layout || !["left", "center", "right"].every(function(region) {
+ if (!GroupRegistry.validNativeLayout(layout) || !["left", "center", "right"].every(function(region) {
return Array.isArray(layout[region])
})) return null
return JSON.parse(JSON.stringify(layout))
@@ -1333,7 +1405,7 @@ Item {
function restoreProviderLayoutSnapshot(snapshotValue) {
if (!mutationAdmissionReady || !Util.isPlainObject(snapshotValue)
- || !Util.isPlainObject(snapshotValue.layout)
+ || !GroupRegistry.validNativeLayout(snapshotValue.layout)
|| !Util.isPlainObject(snapshotValue.groupStates)) return false
const layout = snapshotValue.layout
for (const region of ["left", "center", "right"]) {
@@ -1383,10 +1455,13 @@ Item {
}
function layoutWithoutProviderIds(layoutValue, providerIds) {
- const source = Util.isPlainObject(layoutValue) ? layoutValue : ({})
+ if (!GroupRegistry.validNativeLayout(layoutValue)) return null
+ const source = layoutValue
const ids = Array.isArray(providerIds) ? providerIds : []
- const result = { left: [], center: [], right: [] }
- for (const region of ["left", "center", "right"]) {
+ if ((source.tray || []).some(entry => ids.indexOf(entryId(entry)) >= 0)) return null
+ const result = JSON.parse(JSON.stringify(source))
+ for (const region of GroupRegistry.NativeSections) {
+ if (!Object.prototype.hasOwnProperty.call(source, region)) continue
const entries = Array.isArray(source[region]) ? source[region] : []
result[region] = entries.filter(function(entry) {
return ids.indexOf(entryId(entry)) < 0
@@ -1590,7 +1665,7 @@ Item {
|| service.writePending !== false) return false
const id = String(widgetId || "")
const next = planNativeTransition(currentLayoutSnapshot(), {
- kind: "catalog-layout", id: id, installed: true, region: region,
+ kind: "catalog-layout", id: id, installed: true, region: nativeTargetSection(region),
removeIds: conflictingLayoutProviderIds(id)
})
if (!next) return false
@@ -1629,8 +1704,8 @@ Item {
if (!row || row.id !== id || !Array.isArray(row.kinds)
|| row.kinds.indexOf("bar-widget") < 0) return null
}
- const targetRegion = WidgetFamilies.targetRegion(id, region,
- pluginRegistry, catalogObservation, suiteHostShell.scoped)
+ const targetSection = nativeTargetSection(WidgetFamilies.targetRegion(id, region,
+ pluginRegistry, catalogObservation, suiteHostShell.scoped))
const removeIds = installed === true
? conflictingLayoutProviderIds(id) : [id]
const affectedGroups = widgetFamilyGroups(id)
@@ -1640,7 +1715,7 @@ Item {
}
const current = currentLayoutSnapshot()
const intent = {kind: "catalog-layout", id: id,
- installed: installed === true, region: targetRegion,
+ installed: installed === true, region: targetSection,
removeIds: removeIds, keepConfigured: installed !== true
&& isV1AdditionalSuiteWidget(id),
providerGroups: affectedGroups.slice()}
@@ -1737,10 +1812,11 @@ Item {
// original would tell Omarchy to restore the source and remove the clone.
if (id !== requestedId && JSON.stringify(widgetFamilyGroups(id).sort())
!== JSON.stringify(widgetFamilyGroups(requestedId).sort())) return false
- const targetRegion = WidgetFamilies.targetRegion(
- id, region, pluginRegistry, catalogObservation, suiteHostShell.scoped)
+ const targetSection = nativeTargetSection(WidgetFamilies.targetRegion(
+ id, region, pluginRegistry, catalogObservation, suiteHostShell.scoped))
+ const targetRegion = GroupRegistry.presentationRegion(targetSection)
const familyGroups = widgetFamilyGroups(id)
- if (!id || !shell || typeof shell.mutateShellConfig !== "function")
+ if (!id || !targetRegion || !shell || typeof shell.mutateShellConfig !== "function")
return false
// V1's fixed family slot has one provider identity, not instance keys.
// Refuse an ambiguous multi-instance replacement before changing layout,
@@ -1752,8 +1828,8 @@ Item {
const previousSpecs = activePluginSpecs()
const previousLayout = currentLayoutSnapshot()
- const existingEntries = ["left", "center", "right"].reduce(
- (entries, part) => entries.concat(previousLayout[part].filter(
+ const existingEntries = GroupRegistry.NativeSections.reduce(
+ (entries, part) => entries.concat((previousLayout[part] || []).filter(
entry => entryId(entry) === id)), [])
if (installed === true && !widgetAllowsMultiple(id, selection)
&& existingEntries.length > 1) return false
@@ -1774,15 +1850,18 @@ Item {
displacedGroups.push(groups[groupIndex])
}
}
- const nextLayout = layoutWithoutProviderIds(
+ let nextLayout = layoutWithoutProviderIds(
previousLayout, replacedProviderIds)
+ if (!nextLayout) return false
+ if (!Array.isArray(nextLayout[targetSection])) nextLayout[targetSection] = []
if (installed === true) {
- const entry = existingEntries.length === 1 && Util.isPlainObject(existingEntries[0])
- ? JSON.parse(JSON.stringify(existingEntries[0])) : { id: id }
- entry.shibumiModule = true
- nextLayout[targetRegion].push(entry)
+ nextLayout = planNativeTransition(previousLayout, {
+ kind: "catalog-layout", id: id, installed: true,
+ region: targetSection, removeIds: replacedProviderIds
+ })
+ if (!nextLayout) return false
} else if (isV1AdditionalSuiteWidget(id))
- nextLayout[targetRegion].push({ id: id })
+ nextLayout[targetSection].push({ id: id })
const nextOwnedGroups = providerFamilyGroupsInLayout(nextLayout)
const stateValues = ({})
if (installed === true) {
@@ -1811,8 +1890,8 @@ Item {
if (installed === true && registryWasEnabled !== true
&& (!pluginRegistry || typeof pluginRegistry.setEnabled !== "function"))
return false
- const desiredSpecs = activePluginSpecs(replacedProviderIds,
- installed === true ? { id: id, region: targetRegion } : null)
+ const desiredSpecs = activePluginSpecsForLayout(nextLayout,
+ installed === true ? [] : replacedProviderIds)
if (!reconcileActivePluginGroups(
desiredSpecs, false, layoutStateController.v2Mode))
return false
@@ -1859,7 +1938,7 @@ Item {
function applyProviderLayoutTransaction(nextLayout, desiredSpecs,
stateValues) {
- if (!legacyFamilyMutationAllowed()) return false
+ if (!legacyFamilyMutationAllowed() || !GroupRegistry.validNativeLayout(nextLayout)) return false
if (!shell || typeof shell.mutateShellConfig !== "function") return false
const previousSpecs = activePluginSpecs()
const previousLayout = currentLayoutSnapshot()
@@ -1917,7 +1996,7 @@ Item {
function widgetFamilyAlternativesInstalled(groupId) {
const group = String(groupId || "")
if (GroupRegistry.GroupIds.indexOf(group) < 0) return false
- for (const region of ["left", "center", "right"]) {
+ for (const region of GroupRegistry.NativeSections) {
const entries = renderedProviderEntries(layoutConfig, region)
for (let index = 0; index < entries.length; index++) {
if (widgetFamilyGroups(entryId(entries[index])).indexOf(group) >= 0)
@@ -2042,8 +2121,11 @@ Item {
let changed = false
shell.mutateShellConfig(function(config) {
if (!Util.isPlainObject(config.bar)
- || !Util.isPlainObject(config.bar.layout)) return
- for (const section of ["left", "center", "right"]) {
+ || !GroupRegistry.validNativeLayout(config.bar.layout)) return
+ if ((config.bar.layout.tray || []).some(entry =>
+ family.alternatives.indexOf(entryId(entry)) >= 0)) return
+ for (const section of GroupRegistry.NativeSections) {
+ if (!Object.prototype.hasOwnProperty.call(config.bar.layout, section)) continue
const source = Array.isArray(config.bar.layout[section])
? config.bar.layout[section] : []
const filtered = source.filter(function(entry) {
@@ -2960,7 +3042,7 @@ Item {
? layoutConfig : ({})
let expectedTruncated = false
let inspectedEntries = 0
- for (const region of ["left", "center", "right"]) {
+ for (const region of GroupRegistry.NativeSections) {
const entries = Array.isArray(configuredLayout[region])
? configuredLayout[region] : []
for (let index = 0; index < entries.length; index++) {
diff --git a/hancore.shibumi.bar/core/GroupRegistry.js b/hancore.shibumi.bar/core/GroupRegistry.js
index 3df75477..7dbf0b36 100644
--- a/hancore.shibumi.bar/core/GroupRegistry.js
+++ b/hancore.shibumi.bar/core/GroupRegistry.js
@@ -55,6 +55,8 @@ var ConsumedAliases = [
"omarchy.clock",
"omarchy.indicators"
]
+// Native persistence/identity and presentation capacity are distinct contracts.
+var NativeSections = ["left", "centerBegin", "center", "centerEnd", "right", "tray"]
var Regions = ["left", "center", "right"]
var DynamicGroupPrefix = "G:"
@@ -105,23 +107,73 @@ function copyGroupModuleSettings(value) {
return result
}
+function validNativeLayout(layout) {
+ if (!isObject(layout)) return false
+ for (var r = 0; r < NativeSections.length; r++) {
+ var section = NativeSections[r]
+ if (Object.prototype.hasOwnProperty.call(layout, section)
+ && !Array.isArray(layout[section])) return false
+ }
+ return true
+}
+
function configuredEntries(layout) {
var result = []
- var source = isObject(layout) ? layout : {}
- for (var r = 0; r < Regions.length; r++) {
- var entries = Array.isArray(source[Regions[r]]) ? source[Regions[r]] : []
+ if (!validNativeLayout(layout)) return result
+ for (var r = 0; r < NativeSections.length; r++) {
+ var section = NativeSections[r]
+ var entries = layout[section] || []
for (var i = 0; i < entries.length; i++) {
var id = entryId(entries[i])
- if (id) result.push({ id: id, entry: entries[i], region: Regions[r] })
+ if (id) result.push({ id: id, entry: entries[i], region: section,
+ section: section, index: i })
}
}
return result
}
-function configuredEntry(layout, moduleId) {
+function presentationRegion(section) {
+ return section === "centerBegin" || section === "centerEnd" ? "center"
+ : Regions.indexOf(section) >= 0 ? section : ""
+}
+
+// Projection retains the original entry objects; native coordinates remain
+// authoritative and are never serialized into provider-owned settings.
+function projectedEntries(layout, region) {
+ if (Regions.indexOf(region) < 0) return []
+ return configuredEntries(layout).filter(function(row) {
+ return presentationRegion(row.section) === region
+ })
+}
+
+function nativeLocation(layout, entry, moduleId) {
+ var rows = configuredEntries(layout)
+ var match = null
+ var count = 0
+ for (var i = 0; i < rows.length; i++) {
+ if (rows[i].entry === entry) return rows[i]
+ if (rows[i].id === moduleId) { match = rows[i]; count++ }
+ }
+ // Group-derived entries are copies. Only a unique native provider is safe.
+ return count === 1 ? match : null
+}
+
+function trayOnly(layout, moduleId) {
+ var rows = configuredEntries(layout)
+ var found = false
+ for (var i = 0; i < rows.length; i++) {
+ if (rows[i].id !== moduleId) continue
+ if (rows[i].section !== "tray") return false
+ found = true
+ }
+ return found
+}
+
+function configuredEntry(layout, moduleId, visibleOnly) {
var configured = configuredEntries(layout)
for (var i = 0; i < configured.length; i++) {
- if (configured[i].id === moduleId) return configured[i].entry
+ if (configured[i].id === moduleId
+ && (visibleOnly !== true || configured[i].section !== "tray")) return configured[i].entry
}
return null
}
@@ -140,7 +192,7 @@ function groupSettings(groupValue, moduleId, moduleCount) {
}
function childSettingsFor(groupValue, layout, moduleId) {
- var result = copySettings(configuredEntry(layout, moduleId))
+ var result = copySettings(configuredEntry(layout, moduleId, true))
var local = isObject(groupValue) ? copySettings(groupValue[moduleId]) : {}
for (var key in local) result[key] = local[key]
return result
@@ -159,11 +211,15 @@ function moduleIdsFor(groupValue, layout) {
var groupId = String(groupValue || "")
var dynamicModule = dynamicModuleIdForGroup(groupId)
if (dynamicModule !== "")
- return configuredEntry(layout, dynamicModule) !== null
+ return configuredEntry(layout, dynamicModule, true) !== null
? [dynamicModule] : []
if (GroupIds.indexOf(groupId) < 0) return []
- var result = (BaseEntries[groupId] || []).slice()
- var configured = configuredEntries(layout)
+ var result = (BaseEntries[groupId] || []).filter(function(id) {
+ return !trayOnly(layout, id)
+ })
+ var configured = configuredEntries(layout).filter(function(row) {
+ return row.section !== "tray"
+ })
var explicitlyInstalled = {}
for (var i = 0; i < configured.length; i++) {
if (isObject(configured[i].entry)
@@ -192,7 +248,7 @@ function entryFor(groupValue, moduleValue, groupValueSettings, layout) {
var id = String(moduleValue || "")
var ids = moduleIdsFor(groupValue, layout)
if (!id || ids.indexOf(id) < 0) return { id: id }
- return mergeEntry(id, configuredEntry(layout, id),
+ return mergeEntry(id, configuredEntry(layout, id, true),
groupSettings(groupValueSettings, id, ids.length))
}
@@ -200,7 +256,7 @@ function entryFor(groupValue, moduleValue, groupValueSettings, layout) {
// write can preserve equal-valued overrides and survive consecutive updates.
function hostEntryFor(moduleValue, layout) {
var id = String(moduleValue || "")
- var entry = configuredEntry(layout, id)
+ var entry = configuredEntry(layout, id, true)
return entry !== null ? entry : { id: id }
}
@@ -238,7 +294,7 @@ function isAssignedModule(moduleValue) {
function unassignedEntries(layout, regionValue) {
var region = String(regionValue || "")
- if (Regions.indexOf(region) < 0 || !isObject(layout)
+ if (NativeSections.indexOf(region) < 0 || !validNativeLayout(layout)
|| !Array.isArray(layout[region])) return []
var result = []
for (var i = 0; i < layout[region].length; i++) {
diff --git a/hancore.shibumi.bar/core/WidgetFamilies.js b/hancore.shibumi.bar/core/WidgetFamilies.js
index c4ffb60d..5343726c 100644
--- a/hancore.shibumi.bar/core/WidgetFamilies.js
+++ b/hancore.shibumi.bar/core/WidgetFamilies.js
@@ -363,11 +363,13 @@ function v1SlotBindings(specs, order, registry, observation, scoped) {
}
function targetRegion(pluginValue, fallbackValue, registry, observation, scoped) {
+ var fallback = String(fallbackValue || "")
+ if (fallback !== "" && ["left", "centerBegin", "center", "centerEnd", "right", "tray"].indexOf(fallback) < 0) return ""
+ // Deferred tray requests must not become visible family placements.
+ if (fallback === "tray") return fallback
var family = familyForPlugin(pluginValue, registry, observation, scoped)
if (family) return family.region
- var fallback = String(fallbackValue || "")
- return ["left", "center", "right"].indexOf(fallback) >= 0
- ? fallback : "right"
+ return fallback !== "" ? fallback : "right"
}
function replacementTargets(pluginValue, registry, observation, scoped) {
diff --git a/hancore.shibumi.bar/core/WidgetSlot.qml b/hancore.shibumi.bar/core/WidgetSlot.qml
index 3b977e8c..a8b02aa0 100644
--- a/hancore.shibumi.bar/core/WidgetSlot.qml
+++ b/hancore.shibumi.bar/core/WidgetSlot.qml
@@ -3,6 +3,7 @@ pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import qs.Commons as Commons
+import "GroupRegistry.js" as GroupRegistry
Item {
id: root
@@ -10,6 +11,15 @@ Item {
required property var bar
required property var entry
property string region: ""
+ readonly property string hostFixedEdge: {
+ const controller = bar && "layoutController" in bar ? bar.layoutController : null
+ const location = controller && typeof controller.groupLocation === "function"
+ ? controller.groupLocation(region) : null
+ // Group slots follow the suite order, extras their actual anchored row.
+ // Native centerBegin/centerEnd metadata is not an anchoring instruction.
+ const actualRegion = location ? location.region : region.replace(/-extra$/, "")
+ return actualRegion === "left" ? "start" : actualRegion === "right" ? "end" : "center"
+ }
property string screenName: ""
property real availableWidth: 0
property bool layoutBusy: false
@@ -42,10 +52,9 @@ Item {
function configuredInStructuralLayout(widgetId) {
const id = String(widgetId || "")
const layout = structuralLayout
- if (id === "" || !layout) return false
- for (const section of ["left", "center", "right"]) {
- const entries = layout[section]
- if (!Array.isArray(entries)) return false
+ if (id === "" || !GroupRegistry.validNativeLayout(layout)) return false
+ for (const section of GroupRegistry.NativeSections) {
+ const entries = layout[section] || []
for (let index = 0; index < entries.length; index++) {
const candidate = bar.entryId(entries[index])
if (candidate === id) return true
@@ -53,6 +62,12 @@ Item {
}
return false
}
+ readonly property var nativeCoordinate:
+ GroupRegistry.nativeLocation(structuralLayout, hostEntry, moduleName)
+ readonly property string nativeSection:
+ nativeCoordinate ? nativeCoordinate.section : ""
+ readonly property int nativeIndex:
+ nativeCoordinate ? nativeCoordinate.index : -1
readonly property bool scopedConfigured:
scopedHost && configuredInStructuralLayout(moduleName)
// Omarchy 4.0.3 republishes a detached widgets object for every registration.
@@ -224,6 +239,7 @@ Item {
}
onSlotCompleteChanged: requestLoaderSourceSync()
onAvailableWidthChanged: injectProperties()
+ onHostFixedEdgeChanged: injectProperties()
onLayoutBusyChanged: injectLayoutBusy()
onActiveItemChanged: {
// A binding notification for the resident item can arrive after onLoaded.
@@ -648,6 +664,7 @@ Item {
function injectProperties() {
const target = activeItem
if (!target || _barConnectionRevoked) return
+ if ("fixedEdge" in target) target.fixedEdge = hostFixedEdge
if ("bar" in target) target.bar = bar
if ("moduleName" in target) target.moduleName = moduleName
if ("hostGroupId" in target) target.hostGroupId = region
diff --git a/hancore.shibumi.bar/manifest.json b/hancore.shibumi.bar/manifest.json
index 7f1496a8..d483728b 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.16.4",
+ "version": "0.1.1-beta.16.5",
"author": "HANCORE",
"license": "MIT",
"description": "The Shibumi full-bar host for Omarchy Shell.",
diff --git a/hancore.shibumi.bar/services/HostWidgetResolver.qml b/hancore.shibumi.bar/services/HostWidgetResolver.qml
index 40fbce8e..0216cf7f 100644
--- a/hancore.shibumi.bar/services/HostWidgetResolver.qml
+++ b/hancore.shibumi.bar/services/HostWidgetResolver.qml
@@ -1,6 +1,7 @@
pragma ComponentBehavior: Bound
import QtQuick
+import "../core/GroupRegistry.js" as GroupRegistry
// Legacy hosts need locally owned Components because they expose manifest URLs.
// Scoped hosts are read through their public snapshots only for non-rendering
@@ -34,10 +35,9 @@ QtObject {
function configured(id) {
const layout = configuredLayout
- if (!layout) return false
- for (const region of ["left", "center", "right"]) {
- const entries = layout[region]
- if (!Array.isArray(entries)) return false
+ if (!GroupRegistry.validNativeLayout(layout)) return false
+ for (const region of GroupRegistry.NativeSections) {
+ const entries = layout[region] || []
for (const entry of entries)
if (entry === id || (entry && entry.id === id)) return true
}
diff --git a/hancore.shibumi.battery/manifest.json b/hancore.shibumi.battery/manifest.json
index 770954fd..e64f847a 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.16.4",
+ "version": "0.1.1-beta.16.5",
"author": "HANCORE",
"license": "MIT",
"description": "Shibumi battery status and detailed battery panel.",
diff --git a/hancore.shibumi.bluetooth/Service.qml b/hancore.shibumi.bluetooth/Service.qml
index 47511fff..d25d9645 100644
--- a/hancore.shibumi.bluetooth/Service.qml
+++ b/hancore.shibumi.bluetooth/Service.qml
@@ -15,7 +15,7 @@ Item {
SuiteRuntime.HostShell { id: suiteShell; host: root.shell }
SuiteRuntime.Provider {
pluginId: "hancore.shibumi.bluetooth"
- implementationVersion: "0.1.1-beta.16.4"
+ implementationVersion: "0.1.1-beta.16.5"
owner: root
host: root.shell
manifest: root.manifest
diff --git a/hancore.shibumi.bluetooth/manifest.json b/hancore.shibumi.bluetooth/manifest.json
index 2ff73106..1bb818a1 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.16.4",
+ "version": "0.1.1-beta.16.5",
"author": "HANCORE",
"license": "MIT",
"description": "Shibumi Bluetooth presentation with a native BlueZ and audio backend.",
diff --git a/hancore.shibumi.brightness/Service.qml b/hancore.shibumi.brightness/Service.qml
index badafa18..ffc9781a 100644
--- a/hancore.shibumi.brightness/Service.qml
+++ b/hancore.shibumi.brightness/Service.qml
@@ -13,7 +13,7 @@ Item {
property var manifest: null
SuiteRuntime.Provider {
pluginId: "hancore.shibumi.brightness"
- implementationVersion: "0.1.1-beta.16.4"
+ implementationVersion: "0.1.1-beta.16.5"
owner: root
host: root.shell
manifest: root.manifest
diff --git a/hancore.shibumi.brightness/manifest.json b/hancore.shibumi.brightness/manifest.json
index c0f0c77a..59167efc 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.16.4",
+ "version": "0.1.1-beta.16.5",
"author": "HANCORE",
"license": "MIT",
"description": "Shibumi brightness and display presentation over Omarchy's monitor owner.",
diff --git a/hancore.shibumi.center/Service.qml b/hancore.shibumi.center/Service.qml
index d8f28fad..7830d913 100644
--- a/hancore.shibumi.center/Service.qml
+++ b/hancore.shibumi.center/Service.qml
@@ -10,7 +10,7 @@ Item {
property var manifest: null
SuiteRuntime.Provider {
pluginId: "hancore.shibumi.center"
- implementationVersion: "0.1.1-beta.16.4"
+ implementationVersion: "0.1.1-beta.16.5"
owner: root
host: root.shell
manifest: root.manifest
diff --git a/hancore.shibumi.center/manifest.json b/hancore.shibumi.center/manifest.json
index a3c0c34d..01b421a8 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.16.4",
+ "version": "0.1.1-beta.16.5",
"author": "HANCORE",
"license": "MIT",
"description": "Shibumi clock, calendar, weather, status, and update center.",
diff --git a/hancore.shibumi.control-center/BarWidget.qml b/hancore.shibumi.control-center/BarWidget.qml
index 27656298..d2f26a60 100644
--- a/hancore.shibumi.control-center/BarWidget.qml
+++ b/hancore.shibumi.control-center/BarWidget.qml
@@ -3,6 +3,7 @@ pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Effects
import qs.Ui as Ui
+import qs.Commons as Commons
import "HostIdentity.js" as HostIdentity
import "../hancore.shibumi.state/runtime" as SuiteRuntime
import "../hancore.shibumi.state/lib/presentation" as Presentation
@@ -16,6 +17,12 @@ Ui.Panel {
SuiteRuntime.HostShell { id: suiteShell; host: root.bar ? root.bar.shell : null }
readonly property var hostShell: suiteShell
+ readonly property var nativeSections: SuiteRuntime.HostContract.nativeSections
+ readonly property string trayDrawerDiagnostic: SuiteRuntime.HostContract.trayDrawerDiagnostic
+ function nativeDefaultSection(value) {
+ return SuiteRuntime.HostContract.nativeDefaultSection(value,
+ "barSections" in Commons.Util ? Commons.Util.barSections : undefined)
+ }
readonly property var tokens: bar && "visualTokens" in bar
&& bar.visualTokens ? bar.visualTokens : hostTokens
readonly property color widgetInk: tokens
diff --git a/hancore.shibumi.control-center/ControlCenterPanel.qml b/hancore.shibumi.control-center/ControlCenterPanel.qml
index 5d80222f..b5f5509c 100644
--- a/hancore.shibumi.control-center/ControlCenterPanel.qml
+++ b/hancore.shibumi.control-center/ControlCenterPanel.qml
@@ -168,6 +168,11 @@ ShibumiPanel {
? bar.shell.serviceFor("hancore.shibumi.control-center") : null)
readonly property bool nativeCatalogRequired: effectivePluginUpdateService
&& effectivePluginUpdateService.scopedHost === true
+ readonly property string pluginCatalogErrorCode: nativeCatalogRequired
+ && effectivePluginUpdateService.catalogReady !== true
+ && typeof effectivePluginUpdateService.catalogErrorCode === "string"
+ && effectivePluginUpdateService.catalogErrorCode !== "unavailable"
+ ? effectivePluginUpdateService.catalogErrorCode : ""
readonly property int pluginLayoutTransitionSerial: bar
&& "layoutTransitionSerial" in bar
? Number(bar.layoutTransitionSerial || 0) : 0
@@ -562,10 +567,7 @@ ShibumiPanel {
const tag = String(declaredTags[tagIndex] || "").trim()
if (tag !== "" && searchTags.indexOf(tag) < 0) searchTags.push(tag)
}
- const placement = manifest.barWidget
- && ["left", "center", "right"].indexOf(
- String(manifest.barWidget.defaultSection || "")) >= 0
- ? String(manifest.barWidget.defaultSection) : "center"
+ const placement = nativeWidgetSection(manifest)
// A full bar is a mutually exclusive shell host, not a widget/plugin
// toggle. It must only be changed through a dedicated bar selector.
if (kinds.indexOf("bar") >= 0) continue
@@ -592,10 +594,12 @@ ShibumiPanel {
? widgetInstalled(id)
: nativeCatalogRequired ? enabled
: barWidget ? widgetInstalled(id) : enabled,
- styleAvailable: true,
+ styleAvailable: placement !== "",
suiteManaged: suiteManaged,
- userToggleable: barWidget && (!suiteManaged || group !== ""),
+ userToggleable: barWidget && placement !== "" && (!suiteManaged || group !== ""),
defaultSection: placement,
+ presentationRegion: placement === "tray" ? ""
+ : placement.indexOf("center") === 0 ? "center" : placement,
compatibility: pluginCompatibility(manifest),
firstParty: manifest.__isFirstParty === true,
removable: manifest.__isFirstParty !== true && !suiteManaged,
@@ -746,7 +750,8 @@ ShibumiPanel {
const id = String(pluginId || "")
const observation = nativeCatalogRequired ? pluginCatalogObservation : null
if (nativeCatalogRequired && !observation) {
- pluginActionError = "The plugin catalog is not ready."
+ pluginActionError = "The plugin catalog is not ready"
+ + (pluginCatalogErrorCode ? " (" + pluginCatalogErrorCode + ")" : "") + "."
return false
}
const manifest = nativeCatalogRequired && observation.snapshot.byId
@@ -771,6 +776,14 @@ ShibumiPanel {
? suiteManagedPlugin(id)
: String(shibumi.suiteId || "") === "hancore.shibumi"
if (kinds.indexOf("bar-widget") >= 0) {
+ const candidates = bar && typeof bar.conflictingLayoutProviderIds === "function"
+ ? bar.conflictingLayoutProviderIds(id) : [id]
+ if (nativeWidgetSection(manifest) === "tray" || (bar
+ && typeof bar.isDeferredTrayWidget === "function"
+ && candidates.some(candidate => bar.isDeferredTrayWidget(candidate)))) {
+ pluginActionError = ownerWidget.trayDrawerDiagnostic
+ return false
+ }
const group = shibumiWidgetGroup(id)
if (group !== "") {
if (!v2LayoutActive
@@ -785,10 +798,7 @@ ShibumiPanel {
|| setGroupEnabled(v1Group, enabled === true,
coalescePresentation === true)
}
- const section = manifest.barWidget
- && ["left", "center", "right"].indexOf(
- String(manifest.barWidget.defaultSection || "")) >= 0
- ? String(manifest.barWidget.defaultSection) : "right"
+ const section = nativeWidgetSection(manifest)
const changed = setPluginBarWidgetEnabled(
id, enabled === true, section)
if (!changed)
@@ -822,10 +832,7 @@ ShibumiPanel {
pluginActionError = "This suite service cannot be placed in the bar."
return false
}
- const section = manifest.barWidget
- && ["left", "center", "right"].indexOf(
- String(manifest.barWidget.defaultSection || "")) >= 0
- ? String(manifest.barWidget.defaultSection) : "center"
+ const section = nativeWidgetSection(manifest)
const changed = setPluginBarWidgetEnabled(id, enabled === true, section)
if (!changed)
pluginActionError = enabled === true
@@ -847,7 +854,19 @@ ShibumiPanel {
&& pluginRegistry.setEnabled(id, enabled === true)
}
+ function nativeWidgetSection(manifest) {
+ const metadata = manifest ? manifest.barWidget : null
+ if (metadata && !Commons.Util.isPlainObject(metadata)) return ""
+ return ownerWidget && typeof ownerWidget.nativeDefaultSection === "function"
+ ? ownerWidget.nativeDefaultSection(metadata ? metadata.defaultSection : undefined) : ""
+ }
+
function setPluginBarWidgetEnabled(pluginId, enabled, section) {
+ if (!ownerWidget || !Array.isArray(ownerWidget.nativeSections)
+ || ownerWidget.nativeSections.indexOf(section) < 0) {
+ pluginActionError = "The native widget section is unsupported."
+ return false
+ }
const observation = pluginCatalogObservation
return runWithControlCenterRestore(function() {
return bar && typeof bar.setBarWidgetInstalled === "function"
diff --git a/hancore.shibumi.control-center/NativeCatalog.qml b/hancore.shibumi.control-center/NativeCatalog.qml
index 68b8ab6f..61cab743 100644
--- a/hancore.shibumi.control-center/NativeCatalog.qml
+++ b/hancore.shibumi.control-center/NativeCatalog.qml
@@ -175,7 +175,8 @@ Item {
} else {
_ready = false
if (current(op)) _publication = null
- if (current(op)) errorCode = "read-failed"
+ if (current(op)) errorCode = op.event && !op.event.ok
+ && /^[a-z][a-z-]{0,63}$/.test(op.event.output) ? op.event.output : "read-failed"
}
var result = current(op) ? (value ? "confirmed" : "failed") : "revoked"
if (_operation !== op) return
diff --git a/hancore.shibumi.control-center/NativeCatalogCommand.qml b/hancore.shibumi.control-center/NativeCatalogCommand.qml
index 89ca9b33..2869a41e 100644
--- a/hancore.shibumi.control-center/NativeCatalogCommand.qml
+++ b/hancore.shibumi.control-center/NativeCatalogCommand.qml
@@ -37,8 +37,8 @@ Item {
if (!Number.isInteger(processId) || processId <= 1) return false
var op = { serial: serial, owner: sourceToken, directory: shellDirectory,
shellProcessId: processId,
- started: false, exited: false, collected: false, cancelled: false,
- hardCancel: false, output: "", ok: false }
+ started: false, exited: false, collected: false, errorCollected: false, cancelled: false,
+ hardCancel: false, output: "", errorCode: "", ok: false }
operation = op
_busy = true
if (!current(op)) { cancel(); return false }
@@ -76,7 +76,7 @@ Item {
function finish() {
var op = operation
- if (!op || worker.running || (op.started && (!op.exited || !op.collected))) return
+ if (!op || worker.running || (op.started && (!op.exited || !op.collected || !op.errorCollected))) return
var publish = current(op)
deadline.stop()
cancellationDeadline.stop()
@@ -84,7 +84,7 @@ Item {
_busy = false
// Busy notifications can replace the owner or request before completion.
if (publish && admitted && op.owner === sourceToken && op.directory === shellDirectory)
- completed(op.serial, op.output, op.started && op.ok)
+ completed(op.serial, op.ok ? op.output : op.errorCode, op.started && op.ok)
drained(op.serial)
}
@@ -109,6 +109,16 @@ Item {
root.operation.ok = exitCode === 0 && exitStatus === 0
Qt.callLater(root.finish)
}
+ stderr: StdioCollector {
+ waitForEnd: true
+ onStreamFinished: {
+ if (!root.operation) return
+ // The trusted helper emits only one path-free protocol token.
+ root.operation.errorCode = /^[a-z][a-z-]{0,63}\n?$/.test(text) ? text.trim() : ""
+ root.operation.errorCollected = true
+ root.finish()
+ }
+ }
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: {
diff --git a/hancore.shibumi.control-center/NativeCatalogModel.js b/hancore.shibumi.control-center/NativeCatalogModel.js
index f2624948..1d58f9fb 100644
--- a/hancore.shibumi.control-center/NativeCatalogModel.js
+++ b/hancore.shibumi.control-center/NativeCatalogModel.js
@@ -3,8 +3,11 @@
// Public listPlugins DTOs, NOT manifests, settings, components or native revision.
var MaximumText = 262144
var MaximumEntries = 512
-var Fields = ["id", "name", "kinds", "enabled", "active", "canDisable", "firstParty", "clonedFrom",
- "description", "author", "version", "tags", "barWidget"]
+var NativeSections = ["left", "centerBegin", "center", "centerEnd", "right", "tray"]
+var NativeFields = ["id", "name", "kinds", "enabled", "active", "canDisable", "firstParty", "clonedFrom"]
+var Fields = NativeFields.concat(["description", "author", "version", "tags", "barWidget"])
+var MetadataIssues = ["metadata-missing", "metadata-ambiguous", "metadata-stale",
+ "manifest-shape", "manifest-race", "manifest-root", "manifest-metadata", "invalid-json", "duplicate-key"]
var BarWidgetFields = ["displayName", "description", "category", "semanticCapabilities",
"defaultSection", "allowMultiple"]
@@ -46,6 +49,17 @@ function normalize(value) {
var byId = Object.create(null)
for (var i = 0; i < value.length; i++) {
var row = value[i]
+ var issue = plain(row) && Object.prototype.hasOwnProperty.call(row, "catalogIssue")
+ ? row.catalogIssue : ""
+ if (issue !== "") {
+ if (MetadataIssues.indexOf(issue) < 0 || Object.keys(row).length !== NativeFields.length + 1
+ || !NativeFields.every(function(key) { return Object.prototype.hasOwnProperty.call(row, key) })) return null
+ // Empty presentation defaults are local UI shape, not manifest metadata.
+ row = Object.assign({}, row, { description: "", author: "", version: "", tags: [],
+ barWidget: { displayName: "", description: "", category: "", semanticCapabilities: [],
+ defaultSection: "center", allowMultiple: false } })
+ delete row.catalogIssue
+ }
if (!plain(row) || Object.keys(row).length !== Fields.length
|| !Fields.every(function(key) { return Object.prototype.hasOwnProperty.call(row, key) })
|| !id(row.id) || Object.prototype.hasOwnProperty.call(byId, row.id)
@@ -63,7 +77,7 @@ function normalize(value) {
|| !text(row.barWidget.displayName, 512, true)
|| !text(row.barWidget.description, 4096, true)
|| !text(row.barWidget.category, 128, true)
- || ["left", "center", "right"].indexOf(row.barWidget.defaultSection) < 0
+ || NativeSections.indexOf(row.barWidget.defaultSection) < 0
|| typeof row.barWidget.allowMultiple !== "boolean") return null
var kinds = []
for (var k = 0; k < row.kinds.length; k++) {
@@ -79,12 +93,16 @@ function normalize(value) {
var barWidget = Object.freeze({ displayName: row.barWidget.displayName,
description: row.barWidget.description, category: row.barWidget.category,
semanticCapabilities: capabilities, defaultSection: row.barWidget.defaultSection,
+ presentationRegion: row.barWidget.defaultSection === "tray" ? ""
+ : row.barWidget.defaultSection.indexOf("center") === 0 ? "center" : row.barWidget.defaultSection,
allowMultiple: row.barWidget.allowMultiple })
- var entry = Object.freeze({ id: row.id, name: row.name, kinds: Object.freeze(kinds),
+ var entry = { id: row.id, name: row.name, kinds: Object.freeze(kinds),
enabled: row.enabled, active: row.active, canDisable: row.canDisable,
firstParty: row.firstParty, clonedFrom: row.clonedFrom,
description: row.description, author: row.author, version: row.version,
- tags: tags, barWidget: barWidget })
+ tags: tags, barWidget: barWidget }
+ if (issue) entry.catalogIssue = issue
+ Object.freeze(entry)
byId[entry.id] = entry
entries.push(entry)
}
diff --git a/hancore.shibumi.control-center/PluginCatalogPage.qml b/hancore.shibumi.control-center/PluginCatalogPage.qml
index 63e48f28..1fcb2b92 100644
--- a/hancore.shibumi.control-center/PluginCatalogPage.qml
+++ b/hancore.shibumi.control-center/PluginCatalogPage.qml
@@ -99,6 +99,12 @@ Column {
} catch (error) { return null }
}
+ readonly property string catalogRefusalCode: catalogServiceCandidate
+ && catalogServiceCandidate.catalogReady !== true
+ && typeof catalogServiceCandidate.catalogErrorCode === "string"
+ && catalogServiceCandidate.catalogErrorCode !== "unavailable"
+ ? catalogServiceCandidate.catalogErrorCode : ""
+
readonly property var allEntries: (controller.pluginEntries || [])
.filter(function(entry) {
return entry.userToggleable === true
@@ -1149,8 +1155,9 @@ Column {
Text {
visible: root.filteredEntries.length === 0
width: parent.width
- text: root.controller.pluginsScanning
- ? "Scanning plugins …"
+ text: root.catalogRefusalCode !== ""
+ ? "Plugin catalog unavailable (" + root.catalogRefusalCode + ")"
+ : root.controller.pluginsScanning ? "Scanning plugins …"
: root.favoritesOnly && root.favoriteCount === 0
? "No favorites yet. Star any plugin to keep it here."
: "No matching bar plugins."
diff --git a/hancore.shibumi.control-center/PluginUpdateService.qml b/hancore.shibumi.control-center/PluginUpdateService.qml
index 2fd51471..619862da 100644
--- a/hancore.shibumi.control-center/PluginUpdateService.qml
+++ b/hancore.shibumi.control-center/PluginUpdateService.qml
@@ -20,7 +20,7 @@ Item {
SuiteRuntime.Provider {
id: runtimeProvider
pluginId: "hancore.shibumi.control-center"
- implementationVersion: "0.1.1-beta.16.4"
+ implementationVersion: "0.1.1-beta.16.5"
owner: root
host: root.shell
manifest: root.manifest
diff --git a/hancore.shibumi.control-center/WidgetAppearanceWorkbench.qml b/hancore.shibumi.control-center/WidgetAppearanceWorkbench.qml
index b2569b4f..43c2c729 100644
--- a/hancore.shibumi.control-center/WidgetAppearanceWorkbench.qml
+++ b/hancore.shibumi.control-center/WidgetAppearanceWorkbench.qml
@@ -394,6 +394,9 @@ Column {
const settingsGroup = String(groups[groupIndex] || "")
const catalogGroup = catalogGroupForActiveGroup(settingsGroup)
const source = catalogOptionForGroup(catalogGroup)
+ const activeBar = controller && "bar" in controller ? controller.bar : null
+ if (activeBar && typeof activeBar.isTrayOnlyWidget === "function"
+ && activeBar.isTrayOnlyWidget(pluginIdForGroup(settingsGroup))) continue
const supportedGroup = activeGroupIds.indexOf(catalogGroup) >= 0
|| settingsGroup.indexOf("G:") === 0
if (!source || !supportedGroup || seen[catalogGroup] === true
diff --git a/hancore.shibumi.control-center/manager/shibumi-health b/hancore.shibumi.control-center/manager/shibumi-health
index 677b7573..b98ad6ee 100755
--- a/hancore.shibumi.control-center/manager/shibumi-health
+++ b/hancore.shibumi.control-center/manager/shibumi-health
@@ -10,6 +10,7 @@ import os
import re
import stat
import subprocess
+import sys
import time
from dataclasses import asdict, dataclass
from pathlib import Path
@@ -17,6 +18,7 @@ from typing import Any
SUITE_ID = "hancore.shibumi"
+NATIVE_SECTIONS = ("left", "centerBegin", "center", "centerEnd", "right", "tray")
MANAGED_MARKER = ".shibumi-managed.json"
STATUS_ORDER = {"error": 0, "warning": 1, "ok": 2, "info": 3}
ERROR_PATTERNS = re.compile(
@@ -540,6 +542,18 @@ class Probe:
"""Read the form from canonical State, with pre-migration compatibility."""
bar = self.config.get("bar")
bar = bar if isinstance(bar, dict) else {}
+ layout = bar.get("layout", {})
+ if not isinstance(layout, dict) or any(
+ not isinstance(layout[section], list)
+ for section in NATIVE_SECTIONS if section in layout
+ ):
+ raise ValueError("native bar layout section is malformed")
+ if any(
+ self.config_entry_id(layout_entry) == STATE_PLUGIN_ID
+ for region in NATIVE_SECTIONS
+ for layout_entry in layout.get(region, [])
+ ):
+ raise ValueError("State service must not be a bar layout entry")
entries = self.config.get("plugins")
canonical_expected = self.state.get("settingsStorageVersion") == 1
@@ -578,12 +592,6 @@ class Probe:
)
):
raise ValueError("canonical State shell envelope is invalid")
- if any(
- self.config_entry_id(layout_entry) == STATE_PLUGIN_ID
- for region in ("left", "center", "right")
- for layout_entry in layout[region]
- ):
- raise ValueError("State service must not be a bar layout entry")
if (
not isinstance(entry, dict)
or type(entry.get(STATE_STORAGE_SCHEMA)) not in (int, float)
@@ -660,6 +668,15 @@ class Probe:
f"Shell form read from {self.shell_style_source} configuration.",
source,
)
+ bar = self.config["bar"]
+ layout = bar.get("layout", {})
+ if (bar.get("id") == "hancore.shibumi.bar" and isinstance(layout, dict)
+ and isinstance(layout.get("tray"), list) and layout["tray"]):
+ self.add("tray-drawer", "Runtime", "Tray-drawer bar widgets", "warning",
+ "Deferred by Shibumi",
+ "Tray-drawer bar widgets are preserved but not shown by Shibumi.",
+ source, "Open Control Center > Bars and switch to Omarchy (stock bar) to use them.",
+ owner=OWNER_SHIBUMI)
except (OSError, ValueError, json.JSONDecodeError) as error:
self.add(
"configuration",
@@ -678,41 +695,47 @@ class Probe:
def load_registry(self) -> None:
fixture = os.environ.get("SHIBUMI_HEALTH_REGISTRY_FILE")
+ code = ""
try:
if fixture:
values = read_json_list(Path(fixture))
else:
- command = self.omarchy_root / "bin/omarchy-shell"
- environment = os.environ.copy()
- environment["OMARCHY_PATH"] = str(self.omarchy_root)
+ if len(self.production_pids) != 1 or not self.production_instance_id:
+ code = "process-identity"
+ raise RuntimeError(code)
+ # Reuse the enriched native read for every registry diagnostic;
+ # never launch a second listPlugins scan for this check.
result = run(
- [str(command), "shell", "listPlugins"],
- timeout=5,
- environment=environment,
+ [sys.executable, "-I", "-S", str(Path(__file__).with_name("shibumi-native-catalog")),
+ "--shell", str(self.omarchy_root / "shell"), "--pid", str(self.production_pids[0])],
+ timeout=8,
)
if result.returncode != 0:
- raise RuntimeError(result.stderr or result.stdout or "registry failed")
+ token = result.stderr.strip()
+ code = token if re.fullmatch(r"[a-z][a-z-]{0,63}", token) else "read-failed"
+ raise RuntimeError(code)
values = json.loads(result.stdout)
- if not isinstance(values, list):
- raise ValueError("registry result is not an array")
- self.registry = [item for item in values if isinstance(item, dict)]
+ if not isinstance(values, list) or any(not isinstance(item, dict) for item in values):
+ code = "catalog-shape"
+ raise ValueError(code)
+ self.registry = values
+ issues = {item["catalogIssue"] for item in values if "catalogIssue" in item}
+ if any(not isinstance(issue, str) or not re.fullmatch(r"[a-z][a-z-]{0,63}", issue) for issue in issues):
+ code = "catalog-shape"
+ raise ValueError(code)
+ self.add("plugin-catalog", "Plugins", "Plugin catalog",
+ "warning" if issues else "ok", "Degraded" if issues else "Ready",
+ ", ".join(sorted(issues)))
except (
OSError,
ValueError,
RuntimeError,
json.JSONDecodeError,
+ TypeError,
subprocess.TimeoutExpired,
- ) as error:
- self.add(
- "plugin-registry",
- "Plugins",
- "Plugin registry",
- "error",
- "Unavailable",
- str(error),
- self.omarchy_root / "bin/omarchy-shell",
- "Confirm that the Omarchy shell answers IPC requests.",
- )
+ ):
+ self.add("plugin-catalog", "Plugins", "Plugin catalog", "warning",
+ "Unavailable", code or "ipc-failed")
self.registry = []
def load_processes(self) -> None:
@@ -1779,8 +1802,8 @@ class Probe:
def result(self) -> dict[str, Any]:
self.load_state_and_config()
- self.load_registry()
self.load_processes()
+ self.load_registry()
outputs = self.load_outputs()
self.check_bar_and_outputs(outputs)
self.check_processes()
diff --git a/hancore.shibumi.control-center/manager/shibumi-manager b/hancore.shibumi.control-center/manager/shibumi-manager
index 12dd5a1a..eac0d971 100755
--- a/hancore.shibumi.control-center/manager/shibumi-manager
+++ b/hancore.shibumi.control-center/manager/shibumi-manager
@@ -19,6 +19,7 @@ from typing import Any
REGIONS = ("left", "center", "right")
+NATIVE_SECTIONS = ("left", "centerBegin", "center", "centerEnd", "right", "tray")
SUITE_ID = "hancore.shibumi"
@@ -273,6 +274,19 @@ def entry_id(value: Any) -> str:
return ""
+def native_sections(layout: dict[str, Any]) -> tuple[str, ...]:
+ if any(not isinstance(layout[section], list)
+ for section in NATIVE_SECTIONS if section in layout):
+ raise ManagerError("invalid bar layout region")
+ return tuple(section for section in NATIVE_SECTIONS
+ if section in REGIONS or section in layout)
+
+
+def legacy_center_anchor(bar: dict[str, Any]) -> bool:
+ layout = bar.get("layout", {})
+ return "centerAnchor" in bar and not any(section in layout for section in ("centerBegin", "centerEnd"))
+
+
def normalize(config: dict[str, Any]) -> dict[str, Any]:
result = copy.deepcopy(config)
result["version"] = 1
@@ -281,8 +295,8 @@ def normalize(config: dict[str, Any]) -> dict[str, Any]:
bar = result["bar"]
if not isinstance(bar.get("layout"), dict):
bar["layout"] = {}
- for region in REGIONS:
- if not isinstance(bar["layout"].get(region), list):
+ for region in native_sections(bar["layout"]):
+ if region not in bar["layout"]:
bar["layout"][region] = []
if not isinstance(result.get("plugins"), list):
result["plugins"] = []
@@ -295,10 +309,16 @@ def copy_layout(layout: Any) -> dict[str, list[Any]]:
region: copy.deepcopy(source.get(region))
if isinstance(source.get(region), list)
else []
- for region in REGIONS
+ for region in native_sections(source)
}
+def preserve_native_extensions(target: dict[str, list[Any]], source: dict[str, Any]) -> None:
+ for section in native_sections(source):
+ if section not in REGIONS and (section == "tray" or section not in target):
+ target[section] = copy.deepcopy(source[section])
+
+
def profile_path(runtime_paths: dict[str, Path]) -> Path:
return runtime_paths["state"] / "shell-layout-profiles.json"
@@ -374,34 +394,10 @@ def snapshot_layout(
legacy: bool,
trusted_layout: dict[str, list[Any]] | None = None,
) -> dict[str, list[Any]]:
- layout = copy_layout(config["bar"]["layout"])
- if shell_id != "shibumi":
- return layout
-
- trusted_omarchy: dict[str, int] = {}
- if not legacy and trusted_layout is not None:
- for region in REGIONS:
- for entry in trusted_layout[region]:
- plugin_id = entry_id(entry)
- if plugin_id.startswith("omarchy."):
- trusted_omarchy[plugin_id] = (
- trusted_omarchy.get(plugin_id, 0) + 1
- )
-
- # Pre-profile continuity builds and the suite CLI could merge the complete
- # stock layout into Shibumi as "extras". Keep user/third-party widgets and
- # only those Omarchy widgets already recorded in the trusted Shibumi profile.
- snapshot: dict[str, list[Any]] = {region: [] for region in REGIONS}
- for region in REGIONS:
- for entry in layout[region]:
- plugin_id = entry_id(entry)
- if plugin_id.startswith("omarchy."):
- remaining = trusted_omarchy.get(plugin_id, 0)
- if remaining <= 0:
- continue
- trusted_omarchy[plugin_id] = remaining - 1
- snapshot[region].append(entry)
- return snapshot
+ # Current configured placements are authoritative. A native namespace or
+ # absence from an older profile cannot distinguish a user placement from
+ # historical stock imports; filtering either silently loses user widgets.
+ return copy_layout(config["bar"]["layout"])
def validate_state(
@@ -418,7 +414,7 @@ def validate_state(
raise ManagerError(
f"{label} predates or violates the continuity contract"
)
- for region in REGIONS:
+ for region in native_sections(activation["layout"]):
if not isinstance(activation["layout"].get(region), list):
raise ManagerError(f"{label} activation layout is malformed")
return state
@@ -452,7 +448,7 @@ def state_settings(config: dict[str, Any]) -> dict[str, Any]:
or any(not isinstance(layout.get(region), list) for region in REGIONS)):
raise ManagerError("invalid State container; run suite update/repair")
if any(entry_id(entry) == "hancore.shibumi.state"
- for region in REGIONS for entry in layout.get(region, [])):
+ for region in native_sections(layout) for entry in layout.get(region, [])):
raise ManagerError("State service must not be a bar layout entry")
matches = [entry for entry in entries if entry_id(entry) == "hancore.shibumi.state"]
if len(matches) != 1 or not isinstance(matches[0], dict):
@@ -481,6 +477,7 @@ def activate_config(
for region in REGIONS
}
result["bar"]["layout"] = copy_layout(selected_layout)
+ preserve_native_extensions(result["bar"]["layout"], config["bar"]["layout"])
service_ids = list(activation["enableServices"])
existing_services = {
@@ -498,7 +495,8 @@ def activate_config(
for plugin_id in service_ids
]
result["bar"]["id"] = str(activation["activeBar"])
- result["bar"]["centerAnchor"] = "hancore.shibumi.center"
+ if legacy_center_anchor(result["bar"]):
+ result["bar"]["centerAnchor"] = "hancore.shibumi.center"
result["bar"]["style"] = "shibumi"
return result
@@ -519,11 +517,14 @@ def deactivate_config(
if target_layout is not None
else defaults.get("bar", {}).get("layout", {})
)
+ preserve_native_extensions(result["bar"]["layout"], config["bar"]["layout"])
# In the stock Omarchy bar the continuity entry is a quiet return anchor,
# not Shibumi's primary left-side wordmark. Keep exactly one instance at
# the far right; BarWidget renders its dedicated mark on the stock host.
continuity_widget = "hancore.shibumi.control-center"
- for region in REGIONS:
+ for region in native_sections(result["bar"]["layout"]):
+ if region == "tray":
+ continue
result["bar"]["layout"][region] = [
entry
for entry in result["bar"]["layout"][region]
@@ -531,8 +532,8 @@ def deactivate_config(
]
if not any(
entry_id(entry) == continuity_widget
- for region in REGIONS
- for entry in result["bar"]["layout"][region]
+ for region in native_sections(result["bar"]["layout"])
+ for entry in result["bar"]["layout"].get(region, [])
):
result["bar"]["layout"]["right"].append({"id": continuity_widget})
existing_services = {
@@ -550,7 +551,7 @@ def deactivate_config(
]
if result["bar"].get("id") == activation["activeBar"]:
result["bar"].pop("id", None)
- if result["bar"].get("centerAnchor") == "hancore.shibumi.center":
+ if legacy_center_anchor(result["bar"]) and result["bar"].get("centerAnchor") == "hancore.shibumi.center":
default_bar = defaults.get("bar")
anchor = (
target_center_anchor
@@ -904,6 +905,7 @@ def plugin_snapshot(runtime_paths: dict[str, Path]) -> dict[str, dict[str, Any]]
def prune_uninstalled_layout_plugins(
layout: dict[str, list[Any]], plugins: dict[str, dict[str, Any]],
managed: set[str], source_layout: dict[str, list[Any]],
+ *, append_source: bool = True,
) -> list[str]:
# Managed-row presence is necessary, not proof of completeness; pruning
# relies on the pinned host's authoritative-snapshot contract.
@@ -914,7 +916,9 @@ def prune_uninstalled_layout_plugins(
or any(not isinstance(row.get(field), bool) for field in ("firstParty", "enabled"))
for row in plugins.values())):
return []
- source_entries = [entry for region in REGIONS for entry in source_layout[region]]
+ source_coordinates = [(region, entry) for region in native_sections(source_layout)
+ if region != "tray" for entry in source_layout.get(region, [])]
+ source_entries = [entry for region, entry in source_coordinates]
source_ids = [
entry if isinstance(entry, str)
else entry.get("id") if isinstance(entry, dict) else None
@@ -923,19 +927,25 @@ def prune_uninstalled_layout_plugins(
if (any(not isinstance(plugin_id, str) or not plugin_id for plugin_id in source_ids)
or len(source_ids) != len(set(source_ids))):
return []
- present = {entry_id(entry) for region in REGIONS for entry in layout[region]}
+ present = {entry_id(entry) for region in native_sections(layout)
+ for entry in layout.get(region, [])}
missing = present - plugins.keys() - managed
pruned = sorted(
plugin_id for plugin_id in missing
if plugin_id and not plugin_id.startswith("omarchy.")
)
- added = [copy.deepcopy(entry) for entry, plugin_id in zip(source_entries, source_ids)
- if plugin_id not in present and plugin_id not in managed and plugin_id in plugins
+ added = [(region if region not in REGIONS else "right", copy.deepcopy(entry))
+ for (region, entry), plugin_id in zip(source_coordinates, source_ids)
+ if append_source and plugin_id not in present
+ and plugin_id not in managed and plugin_id in plugins
and plugins[plugin_id]["enabled"] and not plugins[plugin_id]["firstParty"]
and "bar-widget" in plugins[plugin_id]["kinds"]]
- for region in REGIONS:
- layout[region] = [entry for entry in layout[region] if entry_id(entry) not in pruned]
- layout["right"].extend(added)
+ for region in native_sections(layout):
+ if region != "tray":
+ layout[region] = [entry for entry in layout.get(region, [])
+ if entry_id(entry) not in pruned]
+ for region, entry in added:
+ layout.setdefault(region, []).append(entry)
return pruned
@@ -948,11 +958,17 @@ def activation_verification(
activation = state["activation"]
layout_ids = {
entry_id(entry)
- for region in REGIONS
- for entry in config["bar"]["layout"][region]
+ for region in native_sections(config["bar"]["layout"])
+ for entry in config["bar"]["layout"].get(region, [])
}
active_bar = str(activation["activeBar"])
- registry_enabled = layout_ids | {active_bar}
+ # Deferred drawer-only entries are identities, not required live widgets.
+ layout = config["bar"]["layout"]
+ tray_only = {entry_id(entry) for entry in layout.get("tray", [])} - {
+ entry_id(entry) for section in native_sections(layout)
+ if section != "tray" for entry in layout.get(section, [])
+ }
+ registry_enabled = (layout_ids - tray_only) | {active_bar}
service_ids = set(activation["enableServices"])
configured_services = {
entry_id(entry) for entry in config["plugins"]
@@ -995,10 +1011,11 @@ def verify(
state,
)
else:
+ layout = current_config(runtime_paths)["bar"]["layout"]
layout_ids = {
entry_id(entry)
- for region in REGIONS
- for entry in current_config(runtime_paths)["bar"]["layout"][region]
+ for region in native_sections(layout)
+ for entry in layout.get(region, [])
}
allowed = set(activation["enableServices"]) | layout_ids
enabled = {
@@ -1087,12 +1104,16 @@ def perform(target: str) -> int:
target_layout = layouts.get(runtime_target)
if target_layout is None:
target_layout = initial_layout(runtime_target, defaults, state)
+ preserve_native_extensions(target_layout, current["bar"]["layout"])
try:
installed_plugins = plugin_snapshot(runtime_paths)
except ManagerError:
installed_plugins = {}
+ # A stock-side selection is not a Shibumi placement request. Restore
+ # the saved Shibumi profile without importing the stock-only providers.
pruned_ids = prune_uninstalled_layout_plugins(
- target_layout, installed_plugins, set(state["plugins"]), layouts[current_shell]
+ target_layout, installed_plugins, set(state["plugins"]), layouts[current_shell],
+ append_source=runtime_target != "shibumi",
)
layouts[runtime_target] = copy_layout(target_layout)
target_center_anchor = center_anchors.get(runtime_target)
diff --git a/hancore.shibumi.control-center/manager/shibumi-native-catalog b/hancore.shibumi.control-center/manager/shibumi-native-catalog
index 40083b99..9ed50f3d 100644
--- a/hancore.shibumi.control-center/manager/shibumi-native-catalog
+++ b/hancore.shibumi.control-center/manager/shibumi-native-catalog
@@ -27,6 +27,16 @@ class Refused(Exception):
pass
+def refusal_code(error):
+ # Only fixed protocol codes, never exception paths or native stderr.
+ code = str(error) if isinstance(error, Refused) else "ipc-failed"
+ return code if re.fullmatch(r"[a-z][a-z-]{0,63}", code) else "ipc-failed"
+
+
+def report_refusal(error):
+ os.write(2, (refusal_code(error) + "\n").encode("ascii"))
+
+
def collect(command, *, group_owner=False):
# Production runs in the custodian's reserved group. Direct inert unit tests
# retain the standalone owned-child-group seam.
@@ -146,8 +156,8 @@ def guarded(job):
try:
job()
status = 0
- except BaseException:
- pass # Only the fixed status byte crosses the private channel.
+ except BaseException as error:
+ report_refusal(error) # One sanitized token, not native stderr.
child_socket.sendall(bytes([status]))
# Never exit before launcher cleanup; otherwise launcher death after
# our exit could orphan descendants without a parent-death receiver.
@@ -174,14 +184,15 @@ def guarded(job):
signal.pthread_sigmask(signal.SIG_SETMASK, mask - signals)
parent_socket.settimeout(5)
if parent_socket.recv(1) != b"G" or os.getpgid(pid) != pid:
- return 1
+ raise Refused("process-identity")
group_ready = True
if cancelled:
return 1
parent_socket.sendall(b"S")
status = parent_socket.recv(1)
return 0 if status == b"\0" and not cancelled else 1
- except (OSError, Refused):
+ except (OSError, Refused) as error:
+ report_refusal(Refused("deadline") if isinstance(error, TimeoutError) else error)
return 1
finally:
signal.pthread_sigmask(signal.SIG_BLOCK, signals)
@@ -232,13 +243,14 @@ def file_identity(metadata):
def read_regular(path, maximum=MAX_MANIFEST, *, directory=None):
- descriptor = os.open(path, os.O_RDONLY | os.O_CLOEXEC | os.O_NOFOLLOW,
+ descriptor = os.open(path, os.O_RDONLY | os.O_CLOEXEC | os.O_NOFOLLOW | os.O_NONBLOCK,
dir_fd=directory)
try:
before = os.fstat(descriptor)
- if not stat.S_ISREG(before.st_mode) or before.st_nlink != 1 \
- or before.st_size < 0 or before.st_size > maximum:
+ if not stat.S_ISREG(before.st_mode) or before.st_nlink != 1 or before.st_size < 0:
raise Refused("manifest-shape")
+ if before.st_size > maximum:
+ raise Refused("manifest-limit")
chunks = []
total = 0
while True:
@@ -293,17 +305,25 @@ def manifest_sources(shell_directory):
probes = [0]
aggregate = [0]
- def append_manifest(directory, name, first_party):
+ def append_manifest(directory, name, first_party, hint=""):
if len(sources) >= MAX_MANIFESTS:
raise Refused("manifest-count")
+ issue = ""
+ raw = b""
try:
raw = read_regular(name, directory=directory)
except FileNotFoundError:
- return
+ issue = "metadata-missing"
+ except OSError:
+ issue = "manifest-shape"
+ except Refused as error:
+ if str(error) == "manifest-limit":
+ raise
+ issue = refusal_code(error)
aggregate[0] += len(raw)
if aggregate[0] > MAX_MANIFEST_TOTAL:
raise Refused("manifest-limit")
- sources.append((raw, first_party))
+ sources.append((raw, first_party, hint, issue))
first_root = None
try:
@@ -314,7 +334,10 @@ def manifest_sources(shell_directory):
except OSError:
continue
try:
- first_entries = bounded_names(first, probes)
+ try:
+ first_entries = bounded_names(first, probes)
+ except OSError:
+ continue
for name in first_entries:
if name == "manifest.json" or name.endswith(".manifest.json"):
append_manifest(first, name, True)
@@ -324,15 +347,22 @@ def manifest_sources(shell_directory):
except OSError:
continue
try:
- for name in bounded_names(second, probes):
+ try:
+ second_entries = bounded_names(second, probes)
+ except OSError:
+ continue
+ for name in second_entries:
if name == "manifest.json" or name.endswith(".manifest.json"):
append_manifest(second, name, True)
finally:
os.close(second)
finally:
os.close(first)
- except FileNotFoundError:
- pass
+ except OSError:
+ pass # Missing/unreadable metadata never revokes native rows.
+ except Refused as error:
+ if str(error) != "manifest-root":
+ raise
finally:
if first_root is not None:
os.close(first_root)
@@ -345,15 +375,21 @@ def manifest_sources(shell_directory):
if name.startswith("."):
continue
try:
- plugin = child_directory(local_root, name)
+ # The registry follows development-checkout directory links. Resolve
+ # that directory only; walk the result without links and keep the
+ # held directory FD and final-manifest O_NOFOLLOW checks.
+ plugin = open_directory(os.path.realpath(os.path.join(local, name), strict=True))
except OSError:
continue
try:
- append_manifest(plugin, "manifest.json", False)
+ append_manifest(plugin, "manifest.json", False, name)
finally:
os.close(plugin)
- except FileNotFoundError:
+ except OSError:
pass
+ except Refused as error:
+ if str(error) != "manifest-root":
+ raise
finally:
if local_root is not None:
os.close(local_root)
@@ -363,7 +399,8 @@ def manifest_sources(shell_directory):
def valid_text(value, maximum, empty=True):
return (isinstance(value, str) and len(value) <= maximum
and (empty or bool(value))
- and not any(ord(char) < 32 or 127 <= ord(char) <= 159 for char in value))
+ and not any(ord(char) < 32 or 127 <= ord(char) <= 159
+ or 0xD800 <= ord(char) <= 0xDFFF for char in value))
def string_list(value, maximum_items=64, maximum_text=128):
@@ -383,7 +420,9 @@ def presentation(manifest):
if not isinstance(bar, dict):
raise Refused("manifest-metadata")
section = str(bar.get("defaultSection", "center"))
- if section not in ("left", "center", "right"):
+ # Keep the manifest's native declaration, not a three-region UI mapping.
+ # HostContract resolves missing/center placement at the active Bar boundary.
+ if section not in ("left", "centerBegin", "center", "centerEnd", "right", "tray"):
raise Refused("manifest-metadata")
values = {
"description": manifest.get("description", ""),
@@ -435,47 +474,71 @@ def enrich_catalog(rows, shell_directory):
"active", "canDisable", "firstParty", "clonedFrom"}
or not valid_text(row.get("id"), 160, False)
or re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", row["id"]) is None
- or ".." in row["id"] or row["id"] in native):
+ or ".." in row["id"] or row["id"] in native
+ or not valid_text(row.get("name"), 512, False)
+ or not isinstance(row.get("kinds"), list) or not 1 <= len(row["kinds"]) <= 8
+ or any(not valid_text(kind, 64, False) for kind in row["kinds"])
+ or len(set(row["kinds"])) != len(row["kinds"])
+ or any(type(row.get(key)) is not bool for key in ("enabled", "active", "canDisable", "firstParty"))
+ or not valid_text(row.get("clonedFrom"), 160)
+ or (row["clonedFrom"] and (re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", row["clonedFrom"]) is None
+ or ".." in row["clonedFrom"]))):
+ raise Refused("catalog-shape")
+ bar = "bar" in row["kinds"]
+ if row["canDisable"] != (not bar) or (row["enabled"] != row["active"] if bar else row["active"]):
raise Refused("catalog-shape")
native[row["id"]] = row
first_party_manifests = {}
third_party_manifests = {}
total = 0
- for raw, first_party in manifest_sources(shell_directory):
+ for raw, first_party, hint, issue in manifest_sources(shell_directory):
total += len(raw)
if total > MAX_MANIFEST_TOTAL:
raise Refused("manifest-limit")
- try:
- manifest = json.loads(raw.decode("utf-8", errors="strict"), object_pairs_hook=unique,
- parse_constant=reject, parse_float=finite_float)
- except (UnicodeError, ValueError, RecursionError):
- continue
- if not isinstance(manifest, dict) or not valid_manifest(manifest):
- continue
- plugin_id = manifest["id"]
+ manifest = None
+ if not issue:
+ try:
+ manifest = json.loads(raw.decode("utf-8", errors="strict"), object_pairs_hook=unique,
+ parse_constant=reject, parse_float=finite_float)
+ except Refused as error:
+ issue = refusal_code(error)
+ except (UnicodeError, ValueError, RecursionError):
+ issue = "invalid-json"
+ plugin_id = manifest.get("id", hint) if isinstance(manifest, dict) else hint
+ if (not valid_text(plugin_id, 160, False)
+ or re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", plugin_id) is None
+ or ".." in plugin_id):
+ continue # No trustworthy association; unmatched rows get metadata-missing.
+ if not issue and (not isinstance(manifest, dict) or not valid_manifest(manifest)):
+ issue = "manifest-shape"
target = first_party_manifests if first_party else third_party_manifests
if plugin_id in target:
- raise Refused("metadata-ambiguous")
- target[plugin_id] = (manifest, first_party)
+ target[plugin_id] = (None, first_party, "metadata-ambiguous")
+ else:
+ target[plugin_id] = (manifest, first_party, issue)
manifests = dict(first_party_manifests)
for plugin_id, candidate in third_party_manifests.items():
if plugin_id not in first_party_manifests and not plugin_id.startswith("omarchy."):
manifests[plugin_id] = candidate
enriched = []
for row in rows:
- candidate = manifests.get(row["id"])
- if not candidate:
- raise Refused("metadata-missing")
- manifest, first_party = candidate
- cloned = manifest.get("omarchy", {})
- cloned_from = str(cloned.get("clonedFrom", "")) if isinstance(cloned, dict) else ""
- if (first_party is not row.get("firstParty")
- or manifest.get("name") != row.get("name")
- or manifest.get("kinds") != row.get("kinds")
- or cloned_from != row.get("clonedFrom")):
- raise Refused("metadata-stale")
value = dict(row)
- value.update(presentation(manifest))
+ manifest, first_party, issue = manifests.get(row["id"], (None, False, "metadata-missing"))
+ if not issue:
+ cloned = manifest.get("omarchy", {})
+ cloned_from = str(cloned.get("clonedFrom", "")) if isinstance(cloned, dict) else ""
+ if (first_party is not row.get("firstParty")
+ or manifest.get("name") != row.get("name")
+ or manifest.get("kinds") != row.get("kinds")
+ or cloned_from != row.get("clonedFrom")):
+ issue = "metadata-stale"
+ else:
+ try:
+ value.update(presentation(manifest))
+ except Refused as error:
+ issue = refusal_code(error)
+ if issue:
+ value["catalogIssue"] = issue
enriched.append(value)
encoded = json.dumps(enriched, ensure_ascii=False, separators=(",", ":"), allow_nan=False).encode()
if len(encoded) > MAX_OUTPUT:
@@ -570,9 +633,12 @@ def main(*, group_owner=False):
command = ["/usr/bin/quickshell", "ipc", "--pid", raw_pid,
"call", "--", "shell", "listPlugins"]
raw = collect(command, group_owner=group_owner)
- text = raw.decode("utf-8", errors="strict")
- value = json.loads(text, object_pairs_hook=unique, parse_constant=reject,
- parse_float=finite_float)
+ try:
+ text = raw.decode("utf-8", errors="strict")
+ value = json.loads(text, object_pairs_hook=unique, parse_constant=reject,
+ parse_float=finite_float)
+ except (UnicodeError, ValueError, RecursionError):
+ raise Refused("catalog-shape") from None
encoded = enrich_catalog(value, directory)
# Bind display/search metadata to one unchanged native identity snapshot.
# It grants no mutation right; the scoped Bar revalidates host component
@@ -588,6 +654,6 @@ def main(*, group_owner=False):
if __name__ == "__main__":
try:
raise SystemExit(guarded(lambda: main(group_owner=True)))
- except (Refused, OSError, ValueError, RecursionError, subprocess.SubprocessError):
- # Never log native stderr, metadata, paths or arbitrary exception text.
+ except (Refused, OSError, ValueError, RecursionError, subprocess.SubprocessError) as error:
+ report_refusal(error)
raise SystemExit(1)
diff --git a/hancore.shibumi.control-center/manifest.json b/hancore.shibumi.control-center/manifest.json
index 3e5fa2f1..1264f599 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.16.4",
+ "version": "0.1.1-beta.16.5",
"author": "HANCORE",
"license": "MIT",
"description": "Shibumi G1 presentation and bar configuration panel.",
diff --git a/hancore.shibumi.cpu/Service.qml b/hancore.shibumi.cpu/Service.qml
index 75fe2b95..7a6fc260 100644
--- a/hancore.shibumi.cpu/Service.qml
+++ b/hancore.shibumi.cpu/Service.qml
@@ -13,7 +13,7 @@ Item {
SuiteRuntime.Provider {
id: runtimeProvider
pluginId: "hancore.shibumi.cpu"
- implementationVersion: "0.1.1-beta.16.4"
+ implementationVersion: "0.1.1-beta.16.5"
owner: root
host: root.shell
manifest: root.manifest
diff --git a/hancore.shibumi.cpu/manifest.json b/hancore.shibumi.cpu/manifest.json
index 5818c996..aeb83cd0 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.16.4",
+ "version": "0.1.1-beta.16.5",
"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 82c8a876..0af2fce4 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.16.4",
+ "version": "0.1.1-beta.16.5",
"author": "HANCORE",
"license": "MIT",
"description": "Lazy GPU load, hardware, driver, temperature, and VRAM widget.",
diff --git a/hancore.shibumi.media/BarWidget.qml b/hancore.shibumi.media/BarWidget.qml
index be4f439b..c87eb36a 100644
--- a/hancore.shibumi.media/BarWidget.qml
+++ b/hancore.shibumi.media/BarWidget.qml
@@ -2,6 +2,7 @@ pragma ComponentBehavior: Bound
import QtQuick
import QtQuick.Effects
+import "MediaMotion.js" as Motion
import qs.Commons as Commons
import qs.Ui as Ui
import "../hancore.shibumi.state/runtime" as SuiteRuntime
@@ -49,9 +50,15 @@ Ui.Panel {
readonly property bool museMode: fullMode
readonly property bool spectrumEnabled: setting("spectrum", true) !== false
readonly property bool spectrumRequested: visible && active && museMode
- && (!bar || !bar.vertical)
- readonly property var museLevels: spectrumService && spectrumService.levels
+ && !opened && (!bar || !bar.vertical)
+ readonly property var pendingLevels: spectrumService && spectrumService.levels
? spectrumService.levels : []
+ property var museLevels: []
+ readonly property bool pulseSettling: (horizontalPulse.visible && horizontalPulse.settling)
+ || (verticalPulse.visible && verticalPulse.settling)
+ readonly property bool motionRunning: visible && !opened && (playing || pulseSettling)
+ property bool motionStepping: false
+ property int motionStep: 0
readonly property string spectrumState: spectrumService
? String(spectrumService.state || "inactive") : "unavailable"
property var leasedSpectrumService: null
@@ -71,6 +78,38 @@ Ui.Panel {
implicitHeight: visible ? (bar && bar.vertical
? mediaSurface.implicitHeight : bar ? bar.barSize : 28) : 0
+ function advanceMotion() {
+ if (!motionRunning) return
+ // Fixed animation-time steps: never catch up a delayed tick with a visible
+ // jump (vinyl 4.5 degrees; marquee at most 2 logical pixels per step).
+ const delta = 40
+ motionStepping = true
+ motionStep++
+ if (activeRow.visible) {
+ if (playing) marqueeClip.advance(delta)
+ horizontalPulse.advance(delta)
+ }
+ if (verticalPulse.visible) verticalPulse.advance(delta)
+ if (playing && muse.visible) {
+ muse.advance(delta)
+ if (!Motion.sameLevels(museLevels, pendingLevels)) museLevels = pendingLevels
+ }
+ motionStepping = false
+ }
+
+ // One clock per output-local widget, not one animation driver per primitive.
+ Timer {
+ interval: 40
+ repeat: true
+ running: root.motionRunning
+ onTriggered: root.advanceMotion()
+ }
+
+ onPendingLevelsChanged: {
+ if (visible && !playing && !Motion.sameLevels(museLevels, pendingLevels))
+ museLevels = pendingLevels
+ }
+
function runAction(action) {
return mediaService && typeof mediaService.runAction === "function"
? mediaService.runAction(String(action || ""), false) === true : false
@@ -237,16 +276,25 @@ Ui.Panel {
height: mediaSurface.height
anchors.verticalCenter: parent.verticalCenter
+ property real elapsed: 0
+
function reset() {
- marqueeAnimation.stop()
+ elapsed = 0
marqueeText.x = 0
- Qt.callLater(function() {
- if (root.visible && root.playing && !root.opened
- && marqueeText.implicitWidth > marqueeClip.width)
- marqueeAnimation.start()
- })
}
+ function advance(milliseconds) {
+ if (marqueeText.implicitWidth <= width) { reset(); return }
+ const distance = marqueeText.implicitWidth - width + 4
+ const duration = Math.max(100, distance) * 20
+ elapsed = (elapsed + milliseconds) % (2000 + duration + 900)
+ marqueeText.x = elapsed < 2000 ? 0
+ : -distance * Math.min(1, (elapsed - 2000) / duration)
+ }
+
+ onWidthChanged: reset()
+ onVisibleChanged: reset()
+
// Keep the reservation above, but give the offscreen paint layer an
// integer physical extent and origin. A fractional layer would filter
// the already aligned native text a second time when composited.
@@ -280,6 +328,7 @@ Ui.Panel {
font.pixelSize: root.tokens ? root.tokens.labelSize : Commons.Style.font.body
renderType: Text.NativeRendering
onTextChanged: marqueeClip.reset()
+ onImplicitWidthChanged: marqueeClip.reset()
}
}
@@ -289,25 +338,11 @@ Ui.Panel {
function onOpenedChanged() { marqueeClip.reset() }
function onVisibleChanged() { marqueeClip.reset() }
}
-
- SequentialAnimation {
- id: marqueeAnimation
- loops: Animation.Infinite
- PauseAnimation { duration: 2000 }
- NumberAnimation {
- target: marqueeText
- property: "x"
- to: -(marqueeText.implicitWidth - marqueeClip.width + 4)
- duration: Math.max(100,
- marqueeText.implicitWidth - marqueeClip.width + 4) * 20
- easing.type: Easing.Linear
- }
- PauseAnimation { duration: 900 }
- PropertyAction { target: marqueeText; property: "x"; value: 0 }
- }
}
MediaPulse {
+ id: horizontalPulse
+ admitted: root.visible && !root.opened
anchors.verticalCenter: parent.verticalCenter
playing: root.playing
tint: root.widgetInk
@@ -381,6 +416,8 @@ Ui.Panel {
}
MediaPulse {
+ id: verticalPulse
+ admitted: root.visible && !root.opened
anchors.horizontalCenter: parent.horizontalCenter
playing: root.playing
tint: root.widgetInk
diff --git a/hancore.shibumi.media/MediaMotion.js b/hancore.shibumi.media/MediaMotion.js
new file mode 100644
index 00000000..dddffff3
--- /dev/null
+++ b/hancore.shibumi.media/MediaMotion.js
@@ -0,0 +1,36 @@
+.pragma library
+
+// Sample the original pulse sequences, including their first-cycle start and
+// last-to-first transitions. No animation driver or clock belongs to a primitive.
+var pulseDurations = [[220, 300, 260, 280], [310, 280, 340, 290], [380, 320, 350, 400]]
+var pulseTargets = [[0.85, 0.18, 0.70, 0.10], [0.45, 0.92, 0.28, 0.65], [0.60, 0.12, 0.95, 0.32]]
+
+function pulse(milliseconds, band, firstLevel) {
+ const durations = pulseDurations[band], targets = pulseTargets[band]
+ const period = durations.reduce(function(sum, value) { return sum + value }, 0)
+ let time = milliseconds % period
+ let from = milliseconds < period
+ ? (firstLevel === undefined ? 0.08 : firstLevel) : targets[3]
+ for (let index = 0; index < durations.length; index++) {
+ if (time <= durations[index]) {
+ const eased = (1 - Math.cos(Math.PI * time / durations[index])) / 2
+ return from + (targets[index] - from) * eased
+ }
+ time -= durations[index]
+ from = targets[index]
+ }
+ return targets[3]
+}
+
+function pulseRest(milliseconds, band, from) {
+ const duration = [380, 430, 480][band]
+ const remaining = 1 - Math.min(1, Math.max(0, milliseconds) / duration)
+ return 0.08 + (from - 0.08) * remaining * remaining * remaining
+}
+
+function sameLevels(left, right) {
+ if (!left || !right || left.length !== right.length) return false
+ for (let index = 0; index < left.length; index++)
+ if (left[index] !== right[index]) return false
+ return true
+}
diff --git a/hancore.shibumi.media/MediaMuse.qml b/hancore.shibumi.media/MediaMuse.qml
index fbd09220..5b19fc6e 100644
--- a/hancore.shibumi.media/MediaMuse.qml
+++ b/hancore.shibumi.media/MediaMuse.qml
@@ -16,6 +16,12 @@ Item {
width: implicitWidth
height: implicitHeight
+ function advance(milliseconds) {
+ vinylMark.rotation = (vinylMark.rotation + milliseconds * 360 / 3200) % 360
+ }
+
+ onPlayingChanged: if (playing) vinylMark.rotation = 0
+
Row {
anchors.centerIn: parent
spacing: 7
@@ -56,14 +62,6 @@ Item {
}
Component.onCompleted: requestPaint()
}
-
- NumberAnimation on rotation {
- from: 0
- to: 360
- duration: 3200
- loops: Animation.Infinite
- running: root.visible && root.playing
- }
}
Item {
diff --git a/hancore.shibumi.media/MediaPanel.qml b/hancore.shibumi.media/MediaPanel.qml
index cc84b5fb..5c760c26 100644
--- a/hancore.shibumi.media/MediaPanel.qml
+++ b/hancore.shibumi.media/MediaPanel.qml
@@ -1,6 +1,7 @@
pragma ComponentBehavior: Bound
import QtQuick
+import "MediaMotion.js" as Motion
import Quickshell.Services.Mpris
import qs.Commons as Commons
import qs.Ui as Ui
@@ -16,8 +17,15 @@ ShibumiPanel {
property string focusSection: "controls"
property int cursorIndex: 1
property real currentPosition: 0
- property real currentLength: 0
- property real lastReportedPosition: -1
+ readonly property real currentLength: player && player.lengthSupported === true
+ ? Math.max(0, Number(player.length) || 0) : 0
+ property real lastPositionRead: 0
+ property bool motionStepping: false
+ property int motionStep: 0
+ readonly property bool motionRunning: open && visible && playing
+ readonly property bool progressShown: open && active && !!player
+ && player.positionSupported === true && player.lengthSupported === true
+ && isFinite(currentLength) && currentLength > 0
property var leasedSpectrumService: null
readonly property var player: mediaService ? mediaService.activePlayer : null
@@ -25,9 +33,10 @@ ShibumiPanel {
? mediaService.sourcePlayers : []
readonly property bool active: !!(player && (player.trackTitle || player.trackArtist))
readonly property bool playing: active && player.isPlaying === true
- readonly property bool spectrumRequested: open && active && spectrumEnabled
- readonly property var levels: spectrumService && spectrumService.levels
+ readonly property bool spectrumRequested: open && active && spectrumEnabled && visible
+ readonly property var pendingLevels: spectrumService && spectrumService.levels
? spectrumService.levels : flatLevels(active ? 0.04 : 0.02)
+ property var levels: flatLevels(0.04)
readonly property var spectrumThemeColors: spectrumService
&& spectrumService.themeColors ? spectrumService.themeColors : []
readonly property int renderedSourceCount: sourceRepeater.count
@@ -128,22 +137,33 @@ ShibumiPanel {
}
function syncPosition() {
- if (!player) return
- const reported = Number(player.position || 0)
- currentLength = Math.max(0, Number(player.length || 0))
- if (Math.abs(reported - lastReportedPosition) > 0.05) {
- currentPosition = reported
- lastReportedPosition = reported
- } else if (playing) {
- const cap = currentLength > 0 ? currentLength : reported + 1e9
- currentPosition = Math.min(cap, currentPosition + 0.5)
- }
+ // Revocation can notify before dependent QML bindings have reevaluated.
+ const target = player
+ if (!progressShown || !target) return
+ const reported = Number(target.position)
+ currentPosition = Math.floor(Math.max(0, Math.min(currentLength,
+ isFinite(reported) ? reported : 0)))
+ lastPositionRead = Date.now()
}
+ // Discrete native changes refresh immediately, including pause/seek. Only
+ // periodic reads are restricted to playing; a closed panel never samples.
function resetPosition() {
- currentPosition = player ? Number(player.position || 0) : 0
- currentLength = player ? Math.max(0, Number(player.length || 0)) : 0
- lastReportedPosition = -1
+ if (!open) return
+ if (progressShown) syncPosition()
+ else currentPosition = 0
+ }
+
+ function advanceMotion() {
+ if (!motionRunning) return
+ const now = Date.now()
+ const delta = 40
+ motionStepping = true
+ motionStep++
+ vinylArtwork.advance(delta)
+ if (!Motion.sameLevels(levels, pendingLevels)) levels = pendingLevels
+ if (progressShown && now - lastPositionRead >= 1000) syncPosition()
+ motionStepping = false
}
function formatTime(seconds) {
@@ -173,8 +193,23 @@ ShibumiPanel {
}
onPlayerChanged: resetPosition()
- onPlayingChanged: {
- lastReportedPosition = -1
+ onPlayingChanged: resetPosition()
+ onOpenChanged: if (open) resetPosition()
+ onPendingLevelsChanged: {
+ if (open && visible && !playing && !Motion.sameLevels(levels, pendingLevels))
+ levels = pendingLevels
+ }
+
+ property Connections positionSignals: Connections {
+ target: panel.open ? panel.player : null
+ ignoreUnknownSignals: true
+ function onPositionChanged() { panel.resetPosition() }
+ function onLengthChanged() { panel.resetPosition() }
+ function onPositionSupportedChanged() { panel.resetPosition() }
+ function onLengthSupportedChanged() { panel.resetPosition() }
+ function onUniqueIdChanged() { panel.resetPosition() }
+ function onTrackTitleChanged() { panel.resetPosition() }
+ function onTrackArtistChanged() { panel.resetPosition() }
}
onSpectrumRequestedChanged: syncSpectrumLease()
onSpectrumServiceChanged: syncSpectrumLease()
@@ -192,12 +227,13 @@ ShibumiPanel {
}
Component.onDestruction: releaseSpectrumLease()
+ // The existing panel clock also steps the vinyl/bands. Only every elapsed
+ // second can sample position; no other animation or progress timer is needed.
property Timer positionTimer: Timer {
- interval: 500
+ interval: 40
repeat: true
- running: panel.open && panel.active
- triggeredOnStart: true
- onTriggered: panel.syncPosition()
+ running: panel.motionRunning
+ onTriggered: panel.advanceMotion()
}
Ui.PanelKeyCatcher {
@@ -327,7 +363,7 @@ ShibumiPanel {
Item {
width: parent.width
height: Commons.Style.space(18)
- visible: panel.active && panel.currentLength > 0
+ visible: panel.progressShown
MouseArea {
id: seekMouse
@@ -361,7 +397,6 @@ ShibumiPanel {
panel.currentPosition / Math.max(1, panel.currentLength))
radius: height / 2
color: panel.bar ? panel.bar.urgent : Commons.Color.accent
- Behavior on width { NumberAnimation { duration: 450 } }
}
}
@@ -571,14 +606,20 @@ ShibumiPanel {
}
}
- // Panel-local Muse-style mark: no bar geometry, band or animation defaults change.
+ // Panel-local Muse-style mark, sampled by the panel's shared clock.
component PanelVinyl: Item {
id: vinyl
required property color tint
required property string artSource
required property bool spinning
property int revolutionMs: 6000
- readonly property bool rotating: spin.running
+ readonly property bool rotating: visible && spinning && panel.motionRunning
+
+ function advance(milliseconds) {
+ if (rotating) rotation = (rotation + milliseconds * 360 / revolutionMs) % 360
+ }
+
+ onSpinningChanged: if (spinning) rotation = 0
Canvas {
anchors.fill: parent
@@ -629,13 +670,6 @@ ShibumiPanel {
color: panel.bar ? panel.bar.background : Commons.Color.background
}
}
- NumberAnimation on rotation {
- id: spin
- from: 0; to: 360
- duration: vinyl.revolutionMs
- loops: Animation.Infinite
- running: vinyl.visible && vinyl.spinning
- }
}
component IconAction: Ui.CursorSurface {
diff --git a/hancore.shibumi.media/MediaPulse.qml b/hancore.shibumi.media/MediaPulse.qml
index f913500d..4ae84f8c 100644
--- a/hancore.shibumi.media/MediaPulse.qml
+++ b/hancore.shibumi.media/MediaPulse.qml
@@ -1,6 +1,7 @@
pragma ComponentBehavior: Bound
import QtQuick
+import "MediaMotion.js" as Motion
Item {
id: root
@@ -14,43 +15,48 @@ Item {
implicitWidth: 16
implicitHeight: 14
- function reset() { restAnimation.restart() }
+ property real elapsed: 0
+ property bool admitted: true
+ property bool settling: false
+ property real restElapsed: 0
+ property var restStart: [0.08, 0.08, 0.08]
+ property var pulseStart: [0.08, 0.08, 0.08]
- onPlayingChanged: if (!playing) reset()
-
- SequentialAnimation {
- running: root.visible && root.playing
- loops: Animation.Infinite
- NumberAnimation { target: root; property: "barH1"; to: 0.85; duration: 220; easing.type: Easing.InOutSine }
- NumberAnimation { target: root; property: "barH1"; to: 0.18; duration: 300; easing.type: Easing.InOutSine }
- NumberAnimation { target: root; property: "barH1"; to: 0.70; duration: 260; easing.type: Easing.InOutSine }
- NumberAnimation { target: root; property: "barH1"; to: 0.10; duration: 280; easing.type: Easing.InOutSine }
- }
-
- SequentialAnimation {
- running: root.visible && root.playing
- loops: Animation.Infinite
- NumberAnimation { target: root; property: "barH2"; to: 0.45; duration: 310; easing.type: Easing.InOutSine }
- NumberAnimation { target: root; property: "barH2"; to: 0.92; duration: 280; easing.type: Easing.InOutSine }
- NumberAnimation { target: root; property: "barH2"; to: 0.28; duration: 340; easing.type: Easing.InOutSine }
- NumberAnimation { target: root; property: "barH2"; to: 0.65; duration: 290; easing.type: Easing.InOutSine }
+ function advance(milliseconds) {
+ if (playing) {
+ elapsed += milliseconds
+ barH1 = Motion.pulse(elapsed, 0, pulseStart[0])
+ barH2 = Motion.pulse(elapsed, 1, pulseStart[1])
+ barH3 = Motion.pulse(elapsed, 2, pulseStart[2])
+ } else if (settling) {
+ restElapsed = Math.min(480, restElapsed + milliseconds)
+ barH1 = Motion.pulseRest(restElapsed, 0, restStart[0])
+ barH2 = Motion.pulseRest(restElapsed, 1, restStart[1])
+ barH3 = Motion.pulseRest(restElapsed, 2, restStart[2])
+ settling = barH1 !== 0.08 || barH2 !== 0.08 || barH3 !== 0.08
+ }
}
- SequentialAnimation {
- running: root.visible && root.playing
- loops: Animation.Infinite
- NumberAnimation { target: root; property: "barH3"; to: 0.60; duration: 380; easing.type: Easing.InOutSine }
- NumberAnimation { target: root; property: "barH3"; to: 0.12; duration: 320; easing.type: Easing.InOutSine }
- NumberAnimation { target: root; property: "barH3"; to: 0.95; duration: 350; easing.type: Easing.InOutSine }
- NumberAnimation { target: root; property: "barH3"; to: 0.32; duration: 400; easing.type: Easing.InOutSine }
+ function cancelRest() {
+ settling = false
+ if (!playing) { barH1 = 0.08; barH2 = 0.08; barH3 = 0.08 }
}
- ParallelAnimation {
- id: restAnimation
- NumberAnimation { target: root; property: "barH1"; to: 0.08; duration: 380; easing.type: Easing.OutCubic }
- NumberAnimation { target: root; property: "barH2"; to: 0.08; duration: 430; easing.type: Easing.OutCubic }
- NumberAnimation { target: root; property: "barH3"; to: 0.08; duration: 480; easing.type: Easing.OutCubic }
+ // The parent's existing cadence may finish this bounded OutCubic tail, but
+ // hiding/revoking the surface cancels it immediately. No primitive clock.
+ onPlayingChanged: {
+ elapsed = 0
+ if (playing) {
+ settling = false
+ pulseStart = [barH1, barH2, barH3]
+ } else if (visible && admitted) {
+ restElapsed = 0
+ restStart = [barH1, barH2, barH3]
+ settling = barH1 !== 0.08 || barH2 !== 0.08 || barH3 !== 0.08
+ } else cancelRest()
}
+ onVisibleChanged: if (!visible) cancelRest()
+ onAdmittedChanged: if (!admitted) cancelRest()
Row {
anchors.fill: parent
diff --git a/hancore.shibumi.media/Service.qml b/hancore.shibumi.media/Service.qml
index af4773d7..afa06a11 100644
--- a/hancore.shibumi.media/Service.qml
+++ b/hancore.shibumi.media/Service.qml
@@ -4,6 +4,7 @@ import QtQuick
import Quickshell
import Quickshell.Io
import "CavaThemeModel.js" as CavaThemeModel
+import "MediaMotion.js" as Motion
import "../hancore.shibumi.state/runtime" as SuiteRuntime
// One process-wide, lazy spectrum owner for every Shibumi output. Omarchy's
@@ -18,7 +19,7 @@ Item {
SuiteRuntime.Provider {
id: runtimeProvider
pluginId: "hancore.shibumi.media"
- implementationVersion: "0.1.1-beta.16.4"
+ implementationVersion: "0.1.1-beta.16.5"
owner: root
host: root.shell
manifest: root.manifest
@@ -69,7 +70,8 @@ Item {
}
function resetLevels() {
- levels = flatLevels(active ? 0.04 : 0.02)
+ const next = flatLevels(active ? 0.04 : 0.02)
+ if (!Motion.sameLevels(levels, next)) levels = next
}
function applyTheme(raw) {
@@ -110,7 +112,9 @@ Item {
"[smoothing]",
"monstercat = 0",
"waves = 0",
- "noise_reduction = 20",
+ // cava 1.0.0 changed smoothing (upstream #752); 35 on 1.0.0 restores
+ // 0.10.7-at-20 dynamics (Hermes measurement 2026-10-21).
+ "noise_reduction = 35",
""
].join("\n")
}
@@ -144,7 +148,9 @@ Item {
result.push(isNaN(parsed)
? 0 : Math.max(0, Math.min(1, parsed / 100)))
}
- levels = result
+ // Event-fed latest frame only. Each surface publishes it on its own step,
+ // so one output cannot drive another output's repaint between steps.
+ if (!Motion.sameLevels(levels, result)) levels = result
}
function startCava() {
diff --git a/hancore.shibumi.media/manifest.json b/hancore.shibumi.media/manifest.json
index a80e419c..dea5770c 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.16.4",
+ "version": "0.1.1-beta.16.5",
"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 6d5af893..e60935bd 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.16.4",
+ "version": "0.1.1-beta.16.5",
"author": "HANCORE",
"license": "MIT",
"description": "Shibumi memory widget and detailed memory panel.",
diff --git a/hancore.shibumi.network/BarWidget.qml b/hancore.shibumi.network/BarWidget.qml
index ce1c9460..637fc819 100644
--- a/hancore.shibumi.network/BarWidget.qml
+++ b/hancore.shibumi.network/BarWidget.qml
@@ -50,6 +50,9 @@ Ui.Panel {
&& displayMode === "full" && !v2Presentation
readonly property bool v2TrafficPresentation: mode === "ethernet"
&& displayMode === "full" && v2Presentation
+ // Match content's horizontal full-content branch, including its fallback.
+ readonly property bool trafficPresentationVisible: visible && !!bar && !!tokens
+ && !bar.vertical && displayMode !== "icon" && displayMode !== "text"
property var downloadHistory: []
property var uploadHistory: []
readonly property int trafficHistoryLimit: 30
@@ -134,8 +137,9 @@ Ui.Panel {
function syncTrafficConsumer() {
const service = networkService
const nativeDemand = !!service && networkReady && "wiredConnected" in service
- const desired = networkReady && (nativeDemand ? service.wiredConnected
- : mode === "ethernet") ? service : null
+ const desired = trafficPresentationVisible && networkReady
+ && (nativeDemand ? service.wiredConnected : mode === "ethernet")
+ ? service : null
if (trafficService === desired) {
trafficRetry.stop()
return
@@ -224,6 +228,7 @@ Ui.Panel {
}
onOpenedChanged: syncPanelLoader()
+ onTrafficPresentationVisibleChanged: syncTrafficConsumer()
onNetworkReadyChanged: {
syncPanelLoader()
syncTrafficConsumer()
@@ -301,6 +306,7 @@ Ui.Panel {
interval: 2000
repeat: true
running: root.mode === "ethernet" && !root.v2Presentation
+ && root.trafficPresentationVisible
onTriggered: root.appendTrafficSample()
}
diff --git a/hancore.shibumi.network/Service.qml b/hancore.shibumi.network/Service.qml
index a8c26999..103d417e 100644
--- a/hancore.shibumi.network/Service.qml
+++ b/hancore.shibumi.network/Service.qml
@@ -15,7 +15,7 @@ Item {
property var manifest: null
SuiteRuntime.Provider {
pluginId: "hancore.shibumi.network"
- implementationVersion: "0.1.1-beta.16.4"
+ implementationVersion: "0.1.1-beta.16.5"
owner: root
host: root.shell
manifest: root.manifest
diff --git a/hancore.shibumi.network/manifest.json b/hancore.shibumi.network/manifest.json
index 74715b8f..0369eecf 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.16.4",
+ "version": "0.1.1-beta.16.5",
"author": "HANCORE",
"license": "MIT",
"description": "Native Shibumi NetworkManager ownership, telemetry, actions, QR, and speed testing.",
diff --git a/hancore.shibumi.power-profile/manifest.json b/hancore.shibumi.power-profile/manifest.json
index e65515b1..919d7688 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.16.4",
+ "version": "0.1.1-beta.16.5",
"author": "HANCORE",
"license": "MIT",
"description": "Shibumi power-profile status and profile selector.",
diff --git a/hancore.shibumi.power-state/Service.qml b/hancore.shibumi.power-state/Service.qml
index 2edfb2b6..05d61c15 100644
--- a/hancore.shibumi.power-state/Service.qml
+++ b/hancore.shibumi.power-state/Service.qml
@@ -23,7 +23,7 @@ Item {
SuiteRuntime.Provider {
id: runtimeProvider
pluginId: "hancore.shibumi.power-state"
- implementationVersion: "0.1.1-beta.16.4"
+ implementationVersion: "0.1.1-beta.16.5"
owner: root
host: root.shell
manifest: root.manifest
diff --git a/hancore.shibumi.power-state/manifest.json b/hancore.shibumi.power-state/manifest.json
index 190c633a..e0b885f3 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.16.4",
+ "version": "0.1.1-beta.16.5",
"author": "HANCORE",
"license": "MIT",
"description": "Shared battery and power-profile state for Shibumi widgets.",
diff --git a/hancore.shibumi.quick-access/Service.qml b/hancore.shibumi.quick-access/Service.qml
index 103eaa7b..e6a8a728 100644
--- a/hancore.shibumi.quick-access/Service.qml
+++ b/hancore.shibumi.quick-access/Service.qml
@@ -18,7 +18,7 @@ Item {
SuiteRuntime.HostShell { id: suiteShell; host: root.shell }
SuiteRuntime.Provider {
pluginId: "hancore.shibumi.quick-access"
- implementationVersion: "0.1.1-beta.16.4"
+ implementationVersion: "0.1.1-beta.16.5"
owner: root
host: root.shell
manifest: root.manifest
diff --git a/hancore.shibumi.quick-access/manifest.json b/hancore.shibumi.quick-access/manifest.json
index 4fc2f87f..bb5c3173 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.16.4",
+ "version": "0.1.1-beta.16.5",
"author": "HANCORE",
"license": "MIT",
"description": "Shibumi idle inhibitor and theme, wallpaper, screenshot, and video pickers.",
diff --git a/hancore.shibumi.reactor/Service.qml b/hancore.shibumi.reactor/Service.qml
index 7237a117..9c336275 100644
--- a/hancore.shibumi.reactor/Service.qml
+++ b/hancore.shibumi.reactor/Service.qml
@@ -18,7 +18,7 @@ Item {
SuiteRuntime.Provider {
id: runtimeProvider
pluginId: "hancore.shibumi.reactor"
- implementationVersion: "0.1.1-beta.16.4"
+ implementationVersion: "0.1.1-beta.16.5"
owner: root
host: root.shell
manifest: root.manifest
diff --git a/hancore.shibumi.reactor/manifest.json b/hancore.shibumi.reactor/manifest.json
index d75ac520..5bab44b1 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.16.4",
+ "version": "0.1.1-beta.16.5",
"author": "HANCORE",
"license": "MIT",
"description": "Shibumi event and quote Reactor service for compatible bar styles.",
diff --git a/hancore.shibumi.state/Service.qml b/hancore.shibumi.state/Service.qml
index e65ee947..ab869cc3 100644
--- a/hancore.shibumi.state/Service.qml
+++ b/hancore.shibumi.state/Service.qml
@@ -16,7 +16,7 @@ Item {
SuiteRuntime.Provider {
id: runtimeProvider
pluginId: "hancore.shibumi.state"
- implementationVersion: "0.1.1-beta.16.4"
+ implementationVersion: "0.1.1-beta.16.5"
owner: root
host: root.shell
manifest: root.manifest
diff --git a/hancore.shibumi.state/StateStorageModel.js b/hancore.shibumi.state/StateStorageModel.js
index d82c96f7..f9d3d1e6 100644
--- a/hancore.shibumi.state/StateStorageModel.js
+++ b/hancore.shibumi.state/StateStorageModel.js
@@ -2,6 +2,7 @@
var pluginId = "hancore.shibumi.state"
var schemaKey = "shibumiStateSchemaVersion"
+var nativeSections = ["left", "centerBegin", "center", "centerEnd", "right", "tray"]
function plain(value) {
return value !== null && typeof value === "object" && !Array.isArray(value)
@@ -64,8 +65,11 @@ function parse(raw) {
found = item
}
// The host gives layout entries priority over plugins[]. Never write there.
- for (var r = 0; r < 3; r++) {
- var entries = config.bar.layout[["left", "center", "right"][r]]
+ for (var r = 0; r < nativeSections.length; r++) {
+ var section = nativeSections[r]
+ if (!["left", "center", "right"].includes(section)
+ && !Object.prototype.hasOwnProperty.call(config.bar.layout, section)) continue
+ var entries = config.bar.layout[section]
if (!Array.isArray(entries)) return null
for (var j = 0; j < entries.length; j++)
if (entries[j] === pluginId || (plain(entries[j]) && entries[j].id === pluginId)) return null
diff --git a/hancore.shibumi.state/manifest.json b/hancore.shibumi.state/manifest.json
index 53cb3933..28ad71a3 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.16.4",
+ "version": "0.1.1-beta.16.5",
"author": "HANCORE",
"license": "MIT",
"description": "Normalized Shibumi configuration and theme palette state.",
diff --git a/hancore.shibumi.state/runtime/HostContract.qml b/hancore.shibumi.state/runtime/HostContract.qml
index ceb63438..ddc3999d 100644
--- a/hancore.shibumi.state/runtime/HostContract.qml
+++ b/hancore.shibumi.state/runtime/HostContract.qml
@@ -19,6 +19,19 @@ QtObject {
bluetooth: "omarchy.bluetooth", network: "omarchy.network"
})
readonly property string shellCommand: "omarchy-shell"
+ readonly property var nativeSections: ["left", "centerBegin", "center", "centerEnd", "right", "tray"]
+ readonly property string trayDrawerDiagnostic: "Tray-drawer bar widgets are preserved but not shown by Shibumi. Open Control Center > Bars and switch to Omarchy (stock bar) to use them."
+
+ // The public Util.barSections capability, not layout contents or a version
+ // guess, selects native default-placement semantics. Its absence is the
+ // admitted legacy contract. An unknown present contract fails closed.
+ function nativeDefaultSection(value, hostSections) {
+ const section = value === undefined ? "center" : value
+ if (nativeSections.indexOf(section) < 0) return ""
+ if (hostSections === undefined) return section
+ if (JSON.stringify(hostSections) !== JSON.stringify(nativeSections)) return ""
+ return section === "center" ? "centerEnd" : section
+ }
function isFirstPartyService(id) {
return firstPartyServices.indexOf(String(id || "")) >= 0
diff --git a/hancore.shibumi.state/runtime/Runtime.qml b/hancore.shibumi.state/runtime/Runtime.qml
index ee223624..afb003b3 100644
--- a/hancore.shibumi.state/runtime/Runtime.qml
+++ b/hancore.shibumi.state/runtime/Runtime.qml
@@ -11,7 +11,7 @@ QtObject {
id: runtime
readonly property int contractVersion: 1
- readonly property string suiteVersion: "0.1.1-beta.16.4"
+ readonly property string suiteVersion: "0.1.1-beta.16.5"
readonly property var providerIds: [
"hancore.shibumi.state", "hancore.shibumi.control-center",
"hancore.shibumi.reactor", "hancore.shibumi.telemetry",
diff --git a/hancore.shibumi.status/Service.qml b/hancore.shibumi.status/Service.qml
index a2965732..d21ecda4 100644
--- a/hancore.shibumi.status/Service.qml
+++ b/hancore.shibumi.status/Service.qml
@@ -16,7 +16,7 @@ Item {
SuiteRuntime.HostShell { id: suiteShell; host: root.shell }
SuiteRuntime.Provider {
pluginId: "hancore.shibumi.status"
- implementationVersion: "0.1.1-beta.16.4"
+ implementationVersion: "0.1.1-beta.16.5"
owner: root
host: root.shell
manifest: root.manifest
diff --git a/hancore.shibumi.status/manifest.json b/hancore.shibumi.status/manifest.json
index 167cd201..d747fc53 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.16.4",
+ "version": "0.1.1-beta.16.5",
"author": "HANCORE",
"license": "MIT",
"description": "Shibumi tray and notification composition with shared desktop status state.",
diff --git a/hancore.shibumi.storage/Service.qml b/hancore.shibumi.storage/Service.qml
index e4bf333b..d0d59507 100644
--- a/hancore.shibumi.storage/Service.qml
+++ b/hancore.shibumi.storage/Service.qml
@@ -13,7 +13,7 @@ Item {
SuiteRuntime.Provider {
id: runtimeProvider
pluginId: "hancore.shibumi.storage"
- implementationVersion: "0.1.1-beta.16.4"
+ implementationVersion: "0.1.1-beta.16.5"
owner: root
host: root.shell
manifest: root.manifest
diff --git a/hancore.shibumi.storage/manifest.json b/hancore.shibumi.storage/manifest.json
index 5f875c06..89d29050 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.16.4",
+ "version": "0.1.1-beta.16.5",
"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 a30673c2..ab7190ba 100644
--- a/hancore.shibumi.telemetry/Service.qml
+++ b/hancore.shibumi.telemetry/Service.qml
@@ -15,7 +15,7 @@ Item {
SuiteRuntime.Provider {
id: runtimeProvider
pluginId: "hancore.shibumi.telemetry"
- implementationVersion: "0.1.1-beta.16.4"
+ implementationVersion: "0.1.1-beta.16.5"
owner: root
host: root.shell
manifest: root.manifest
diff --git a/hancore.shibumi.telemetry/manifest.json b/hancore.shibumi.telemetry/manifest.json
index f8a78949..f916b2de 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.16.4",
+ "version": "0.1.1-beta.16.5",
"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 dea6c397..7057ca99 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.16.4",
+ "version": "0.1.1-beta.16.5",
"author": "HANCORE",
"license": "MIT",
"description": "Demand-driven hardware temperature widget and sensor panel.",
diff --git a/hancore.shibumi.update-center/Service.qml b/hancore.shibumi.update-center/Service.qml
index 991c750e..3124ff0d 100644
--- a/hancore.shibumi.update-center/Service.qml
+++ b/hancore.shibumi.update-center/Service.qml
@@ -12,7 +12,7 @@ Item {
property var manifest: null
SuiteRuntime.Provider {
pluginId: "hancore.shibumi.update-center"
- implementationVersion: "0.1.1-beta.16.4"
+ implementationVersion: "0.1.1-beta.16.5"
owner: root
host: root.shell
manifest: root.manifest
diff --git a/hancore.shibumi.update-center/manifest.json b/hancore.shibumi.update-center/manifest.json
index 5666746e..44a476b5 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.16.4",
+ "version": "0.1.1-beta.16.5",
"author": "HANCORE",
"license": "MIT",
"description": "Safe repository package and user theme update status for Shibumi.",
diff --git a/hancore.shibumi.workspaces/WorkspaceService.qml b/hancore.shibumi.workspaces/WorkspaceService.qml
index 5bfc77d2..60c24c80 100644
--- a/hancore.shibumi.workspaces/WorkspaceService.qml
+++ b/hancore.shibumi.workspaces/WorkspaceService.qml
@@ -14,7 +14,7 @@ Item {
SuiteRuntime.Provider {
id: runtimeProvider
pluginId: "hancore.shibumi.workspaces"
- implementationVersion: "0.1.1-beta.16.4"
+ implementationVersion: "0.1.1-beta.16.5"
owner: root
host: root.shell
manifest: root.manifest
diff --git a/hancore.shibumi.workspaces/manifest.json b/hancore.shibumi.workspaces/manifest.json
index db58ba7d..f07f5784 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.16.4",
+ "version": "0.1.1-beta.16.5",
"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 b2dae4fb..e2cdfbe1 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.16.4
+ pkgver = 0.1.1beta.16.5
pkgrel = 1
url = https://github.com/HANCORE-linux/Shibumi-Shell
arch = any
@@ -30,7 +30,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.16.4.tar.gz::https://github.com/HANCORE-linux/Shibumi-Shell/releases/download/v0.1.1-beta.16.4/shibumi-shell-0.1.1-beta.16.4.tar.gz
- sha256sums = 8a3ab979a52f1fe3ecb9c67fbf8e82999675442a82a29b9bf4edf863b95652ad
+ source = shibumi-shell-0.1.1-beta.16.5.tar.gz::https://github.com/HANCORE-linux/Shibumi-Shell/releases/download/v0.1.1-beta.16.5/shibumi-shell-0.1.1-beta.16.5.tar.gz
+ sha256sums = 2eac5b0c462dc4a7b3c3fcfa1fd457f3e31ac62ba5113d3c09f55ac0a9f299ce
pkgname = shibumi-shell
diff --git a/packaging/aur/PKGBUILD b/packaging/aur/PKGBUILD
index a4e20769..031b9660 100644
--- a/packaging/aur/PKGBUILD
+++ b/packaging/aur/PKGBUILD
@@ -1,7 +1,7 @@
# Maintainer: HANCORE Linux
pkgname=shibumi-shell
-_upstream_version=0.1.1-beta.16.4
-_source_sha256=8a3ab979a52f1fe3ecb9c67fbf8e82999675442a82a29b9bf4edf863b95652ad
+_upstream_version=0.1.1-beta.16.5
+_source_sha256=2eac5b0c462dc4a7b3c3fcfa1fd457f3e31ac62ba5113d3c09f55ac0a9f299ce
pkgver=${_upstream_version//-/}
pkgrel=1
pkgdesc="Transactional Shibumi desktop shell plugins for Omarchy Quattro"
diff --git a/packaging/package-metadata.json b/packaging/package-metadata.json
index 3b62c5fb..618f4330 100644
--- a/packaging/package-metadata.json
+++ b/packaging/package-metadata.json
@@ -1,5 +1,5 @@
{
"schemaVersion": 1,
"packageName": "shibumi-shell",
- "version": "0.1.1-beta.16.4"
+ "version": "0.1.1-beta.16.5"
}
diff --git a/scripts/check-aur-package b/scripts/check-aur-package
index 978ec43c..53cb682a 100755
--- a/scripts/check-aur-package
+++ b/scripts/check-aur-package
@@ -45,7 +45,8 @@ jq -e '.schemaVersion == 1
"public-beta.11", "step-5-tip", "public-beta.12", "public-beta.13",
"source-beta.14", "public-beta.14.1", "public-beta.15", "public-beta.15.1",
"public-beta.15.2", "public-beta.15.3", "public-beta.15.4", "public-beta.16",
- "public-beta.16.1", "public-beta.16.2", "public-beta.16.3", "public-beta.16.4"
+ "public-beta.16.1", "public-beta.16.2", "public-beta.16.3", "public-beta.16.4",
+ "public-beta.16.5"
]' \
contracts/lifecycle-predecessors-v1.json >/dev/null
grep -Fq 'contracts/lifecycle-predecessors-v1.json' "$pkgbuild" || {
diff --git a/scripts/collect-release-evidence b/scripts/collect-release-evidence
index 1efb5548..3dbf2e16 100755
--- a/scripts/collect-release-evidence
+++ b/scripts/collect-release-evidence
@@ -64,7 +64,7 @@ EXTERNAL_BASELINES: tuple[tuple[str, Path], ...] = (
),
(
"SHIBUMI_FORWARD_COMPAT_OMARCHY_PATH",
- ROOT / "contracts/baselines/omarchy-forward-compat-4c1b4cb3.json",
+ ROOT / "contracts/baselines/omarchy-forward-compat-246f33a2.json",
),
)
diff --git a/scripts/shibumi_suite/admission.py b/scripts/shibumi_suite/admission.py
index 2cd6f441..a4ca255a 100644
--- a/scripts/shibumi_suite/admission.py
+++ b/scripts/shibumi_suite/admission.py
@@ -11,6 +11,7 @@
from typing import Any
from . import MANAGED_MARKER, STATE_SCHEMA_VERSION, SUITE_ID
+from .config import ConfigError, native_sections
from .model import Suite, payload_path_excluded, suite_payload_digest
from .runtime import RuntimePaths
@@ -511,6 +512,13 @@ def classify_install_state(
or type(state["menuExtension"].get("createdFile")) is not bool
):
raise AdmissionError("installation metadata values are malformed")
+ previous_layout = state.get("previousBar", {}).get("layout", {})
+ if not isinstance(previous_layout, dict):
+ raise AdmissionError("previous bar layout is malformed")
+ try:
+ native_sections(previous_layout)
+ except ConfigError as error:
+ raise AdmissionError("previous bar layout is malformed") from error
if "migratedFrom" in state:
migrated = state["migratedFrom"]
if (
diff --git a/scripts/shibumi_suite/cli.py b/scripts/shibumi_suite/cli.py
index e0a5bc47..c0ff08bd 100644
--- a/scripts/shibumi_suite/cli.py
+++ b/scripts/shibumi_suite/cli.py
@@ -25,6 +25,7 @@
entry_id,
migrate_legacy_config,
migrate_state_settings,
+ native_sections,
validate_state_settings,
parse_config_bytes,
read_config,
@@ -42,6 +43,10 @@
suite_payload_digest,
)
from .menu_extension import (
+ END_MARKER,
+ START_MARKER,
+ MenuExtensionError,
+ StockMenuError,
generated_extension_is_empty,
install_picker_routing,
read_extension,
@@ -52,6 +57,7 @@
PluginTransaction,
SuiteLock,
TransactionError,
+ _read_bounded_regular_path,
is_managed_target,
preflight_legacy_removals,
preflight_removals,
@@ -344,8 +350,24 @@ def install_picker_menu_extension(
runtime: OmarchyRuntime,
previous_state: dict[str, Any] | None = None,
) -> dict[str, Any]:
- raw = read_extension(transaction.paths.menu_extension_file)
- payload = install_picker_routing(raw).encode("utf-8")
+ menu_path = transaction.paths.menu_extension_file
+ stock_path = transaction.paths.omarchy_root / "default/omarchy/omarchy-menu.jsonc"
+ source_path = menu_path
+ try:
+ raw = read_extension(source_path)
+ source_path = stock_path
+ stock_menu = read_extension(source_path)
+ source_path = menu_path
+ payload = install_picker_routing(raw, stock_menu).encode("utf-8")
+ except MenuExtensionError as error:
+ if isinstance(error, StockMenuError):
+ source_path = stock_path
+ remedy = (
+ "restore the Omarchy stock menu, then retry the same command"
+ if source_path == stock_path else
+ "fix the JSONC syntax or routing markers in this regular file, then retry the same command"
+ )
+ raise CliError(f"cannot update Omarchy menu {source_path}: {error}; {remedy}") from error
transaction.write_menu_extension(payload)
runtime.refresh_menu()
previous = (
@@ -397,8 +419,8 @@ def activation_drift(
layout = bar.get("layout") if isinstance(bar.get("layout"), dict) else {}
layout_ids = {
entry_id(entry)
- for region in ("left", "center", "right")
- for entry in (layout.get(region) if isinstance(layout.get(region), list) else [])
+ for region in native_sections(layout)
+ for entry in layout.get(region, [])
}
service_ids = {entry_id(entry) for entry in config.get("plugins", [])}
drift: list[str] = []
@@ -474,10 +496,8 @@ def external_runtime_enabled_plugin_ids(
layout = bar.get("layout") if isinstance(bar.get("layout"), dict) else {}
enabled = {
entry_id(entry)
- for region in ("left", "center", "right")
- for entry in (
- layout.get(region) if isinstance(layout.get(region), list) else []
- )
+ for region in native_sections(layout)
+ for entry in layout.get(region, [])
}
enabled.update(
plugin_id
@@ -1134,6 +1154,7 @@ def desired_for(
True,
CONTINUITY_PLUGIN_IDS,
previous_bar_for_state(state, defaults),
+ retain_layout=not external_activation(state, config),
)
), suite.retired_plugins),
state,
@@ -1268,10 +1289,49 @@ def desired_for(config: dict[str, Any]) -> dict[str, Any]:
runtime.verify_uninstall(set(plugin_ids))
transaction.finish(None, archive_previous=False)
- if paths.state_dir.is_dir():
- shutil.rmtree(paths.state_dir)
- if paths.cache_dir.is_dir():
- shutil.rmtree(paths.cache_dir)
+ leftovers: dict[Path, str] = {}
+ for directory in (paths.state_dir, paths.cache_dir):
+ try:
+ if directory.is_dir():
+ shutil.rmtree(directory)
+ except OSError as error:
+ leftovers[directory] = f"cleanup failed: {error}"
+ try:
+ directory.lstat()
+ except FileNotFoundError:
+ pass
+ except OSError as error:
+ leftovers[directory] = f"cannot verify removal: {error}"
+ else:
+ leftovers.setdefault(directory, "directory remains")
+ menu = paths.menu_extension_file
+ try:
+ menu.lstat()
+ except FileNotFoundError:
+ pass
+ except OSError as error:
+ leftovers[menu] = f"cannot verify routing removal: {error}"
+ else:
+ try:
+ payload = _read_bounded_regular_path(menu, "Omarchy menu extension")
+ if START_MARKER.encode() in payload or END_MARKER.encode() in payload:
+ leftovers[menu] = "Shibumi picker routing markers remain"
+ except (OSError, TransactionError) as error:
+ leftovers[menu] = f"cannot verify routing removal: {error}"
+ for path, reason in leftovers.items():
+ remedy = (
+ "remove only the Shibumi picker routing start/end block from this file"
+ if path == menu else
+ "remove this Shibumi-owned directory after confirming the stock bar is active"
+ )
+ print(
+ f"shibumi-suite: uninstall incomplete: {path}: {reason}; "
+ "re-run uninstall --yes from the same checkout if install.json remains, "
+ f"or manually {remedy}.",
+ file=sys.stderr,
+ )
+ if leftovers:
+ return 1
print("Uninstalled Shibumi and restored the stock Omarchy bar.")
return 0
@@ -1297,17 +1357,65 @@ def command_status_with_admission(suite: Suite, paths: RuntimePaths) -> int:
file=sys.stderr,
)
return 1
- except AdmissionError:
+ except AdmissionError as error:
print(
"shibumi-suite: status admission refused; malformed, unsafe, "
"ambiguous, or unsupported state was not inspected further; "
"no recovery or mutation was attempted",
file=sys.stderr,
)
+ reason = str(error)
+ # Only format the existing preflight result: never inspect refused
+ # state again. Keep authority paths redacted, as in other status errors.
+ rendered = reason
+ for root, label in ((paths.state_dir, ""),
+ (paths.plugin_dir, ""),
+ (suite.root, "")):
+ rendered = rendered.replace(str(root), label)
+ print(f"shibumi-suite: refusal reason: {rendered}", file=sys.stderr)
+ plugin_ids = sorted(set(re.findall(
+ re.escape(str(paths.plugin_dir) + "/")
+ + r"([A-Za-z0-9][A-Za-z0-9._-]{0,199})(?=/|$)", reason,
+ )))
+ if plugin_ids:
+ # The existing combined check does not distinguish these causes.
+ # Do not invent a diagnosis or rescan to disambiguate it.
+ combined = reason.startswith("live plugin marker or payload")
+ labels = ("Locally modified", "Ownership mismatch") if combined else (
+ "Locally modified" if "payload" in reason else "Ownership mismatch",
+ )
+ qualifier = " (possible; preflight reports marker or payload mismatch)" if combined else ""
+ for label in labels:
+ print(f"{label}: {', '.join(plugin_ids)}{qualifier}", file=sys.stderr)
return 1
return command_status(suite, paths)
+def catalog_status_warning(suite: Suite, paths: RuntimePaths) -> str:
+ """One PID-bound, read-only helper read; no separate listPlugins scan."""
+ runtime = OmarchyRuntime(paths.omarchy_root)
+ try:
+ instances = runtime.shell_instances()
+ if len(instances) != 1 or type(instances[0].get("pid")) is not int or instances[0]["pid"] <= 1:
+ return "process-identity"
+ helper = suite.root / "hancore.shibumi.control-center/manager/shibumi-native-catalog"
+ result = runtime.run([sys.executable, "-I", "-S", str(helper),
+ "--shell", str(paths.omarchy_root / "shell"), "--pid", str(instances[0]["pid"])],
+ timeout=8, check=False)
+ if result.returncode:
+ code = result.stderr.strip()
+ return code if re.fullmatch(r"[a-z][a-z-]{0,63}", code) else "read-failed"
+ rows = json.loads(result.stdout)
+ if not isinstance(rows, list) or any(not isinstance(row, dict) for row in rows):
+ return "catalog-shape"
+ codes = {row["catalogIssue"] for row in rows if "catalogIssue" in row}
+ if any(not isinstance(code, str) or not re.fullmatch(r"[a-z][a-z-]{0,63}", code) for code in codes):
+ return "catalog-shape"
+ return ", ".join(sorted(codes))
+ except (RuntimeFailure, ValueError, TypeError):
+ return "ipc-failed"
+
+
def command_status(suite: Suite, paths: RuntimePaths) -> int:
state_path = install_state_file(paths)
state: dict[str, Any] | None = None
@@ -1404,6 +1512,9 @@ def command_status(suite: Suite, paths: RuntimePaths) -> int:
print(f"Config: invalid ({error})")
print("Repair with: shibumi-suite repair")
return 1
+ catalog_issue = catalog_status_warning(suite, paths)
+ if catalog_issue:
+ print(f"Warning: plugin-catalog ({catalog_issue})")
unhealthy = bool(
missing or unmanaged or modified or pending_retirement or drift
)
diff --git a/scripts/shibumi_suite/config.py b/scripts/shibumi_suite/config.py
index ff56b3cb..659dfe00 100644
--- a/scripts/shibumi_suite/config.py
+++ b/scripts/shibumi_suite/config.py
@@ -12,6 +12,7 @@
REGIONS = ("left", "center", "right")
+NATIVE_SECTIONS = ("left", "centerBegin", "center", "centerEnd", "right", "tray")
LEGACY_PLUGIN_PREFIX = "hancore.qsrise"
PLUGIN_PREFIX = "hancore.shibumi"
OMARCHY_PLUGIN_PREFIX = "omarchy."
@@ -29,6 +30,20 @@ def _object(value: Any) -> dict[str, Any]:
return copy.deepcopy(value) if isinstance(value, dict) else {}
+def native_sections(layout: dict[str, Any]) -> tuple[str, ...]:
+ """Canonical coordinates; absent extension sections are valid legacy input."""
+ if any(not isinstance(layout[section], list)
+ for section in NATIVE_SECTIONS if section in layout):
+ raise ConfigError("invalid bar layout region")
+ return tuple(section for section in NATIVE_SECTIONS
+ if section in REGIONS or section in layout)
+
+
+def legacy_center_anchor(bar: dict[str, Any]) -> bool:
+ layout = bar.get("layout", {})
+ return "centerAnchor" in bar and not any(section in layout for section in ("centerBegin", "centerEnd"))
+
+
def _array(value: Any) -> list[Any]:
return copy.deepcopy(value) if isinstance(value, list) else []
@@ -80,12 +95,12 @@ def migrate_legacy_config(config: dict[str, Any]) -> dict[str, Any]:
if isinstance(bar.get("id"), str):
bar["id"] = _migrated_identifier(bar["id"])
- if isinstance(bar.get("centerAnchor"), str):
+ if legacy_center_anchor(bar) and isinstance(bar.get("centerAnchor"), str):
bar["centerAnchor"] = _migrated_identifier(bar["centerAnchor"])
if bar.get("style") == "qsrise":
bar["style"] = "shibumi"
- for region in REGIONS:
+ for region in native_sections(bar["layout"]):
bar["layout"][region] = [
_migrated_value(entry) for entry in bar["layout"][region]
]
@@ -101,6 +116,7 @@ def validate_state_envelope(config: dict[str, Any]) -> None:
or not isinstance(config["bar"].get("layout"), dict)
or any(not isinstance(config["bar"]["layout"].get(region), list) for region in REGIONS)):
raise ConfigError("invalid canonical State shell envelope")
+ native_sections(config["bar"]["layout"])
def state_entry(config: dict[str, Any]) -> dict[str, Any] | None:
@@ -116,10 +132,8 @@ def state_entry(config: dict[str, Any]) -> dict[str, Any] | None:
if not isinstance(bar, dict) or not isinstance(bar.get("layout", {}), dict):
raise ConfigError("invalid bar layout")
layout = bar.get("layout", {})
- if any(not isinstance(layout.get(region, []), list) for region in REGIONS):
- raise ConfigError("invalid bar layout region")
if any(entry_id(entry) == STATE_PLUGIN_ID
- for region in REGIONS for entry in layout.get(region, [])):
+ for region in native_sections(layout) for entry in layout.get(region, [])):
raise ConfigError("State service must not be a bar layout entry")
return matches[0] if matches else None
@@ -254,7 +268,7 @@ def _normalize(config: dict[str, Any]) -> dict[str, Any]:
result["version"] = 1
result["bar"] = _object(result.get("bar"))
result["bar"]["layout"] = _object(result["bar"].get("layout"))
- for region in REGIONS:
+ for region in native_sections(result["bar"]["layout"]):
result["bar"]["layout"][region] = _array(
result["bar"]["layout"].get(region)
)
@@ -270,7 +284,9 @@ def _prune_missing_layout_plugins(layout: dict[str, list[Any]], managed_ids: set
or row.is_dir(follow_symlinks=True)}
except OSError:
return
- for region in REGIONS:
+ for region in native_sections(layout):
+ if region == "tray":
+ continue # Deferred drawer entries are not inferred from visible payloads.
layout[region] = [
entry for entry in layout[region]
if not (plugin_id := entry_id(entry)) or plugin_id in managed_ids
@@ -294,8 +310,9 @@ def retained_layout(config: dict[str, Any]) -> dict[str, Any] | None:
or any(not (isinstance(entry, str) and entry
or isinstance(entry, dict) and isinstance(entry.get("id"), str)
and entry["id"])
- for r in REGIONS for entry in layout[r])):
+ for r in native_sections(layout) for entry in layout.get(r, []))):
raise ConfigError("invalid retained Shibumi layout entries")
+ native_sections(layout)
validate_state_settings(config)
return copy.deepcopy(layout)
@@ -316,13 +333,28 @@ def apply_profile(
if saved_layout is not None and result["bar"].get("id") == profile.active_bar:
# An older release may have left the record behind; the active bar wins.
saved_layout = copy.deepcopy(result["bar"]["layout"])
+ if saved_layout is not None:
+ # Native listPlugins.enabled for a widget means placement (inBar), not
+ # first-party component availability. Use the freshly read host layout,
+ # including the post-drain reread, without requiring another live IPC.
+ enabled_widgets = {entry_id(entry) for region in native_sections(result["bar"]["layout"])
+ for entry in result["bar"]["layout"][region]}
+ for region in native_sections(saved_layout):
+ if region == "tray":
+ continue # Deferred drawer data is not our visible presentation.
+ saved_layout[region] = [entry for entry in saved_layout[region]
+ if entry_id(entry) in plugins or entry_id(entry) in enabled_widgets]
existing_entries: dict[str, Any] = {}
- for region in REGIONS:
+ for region in native_sections(result["bar"]["layout"]):
for entry in result["bar"]["layout"][region]:
plugin_id = entry_id(entry)
if plugin_id in managed_widget_ids and plugin_id not in existing_entries:
existing_entries[plugin_id] = copy.deepcopy(entry)
+ preserved_ids = {
+ entry_id(entry) for section in native_sections(result["bar"]["layout"])
+ if section not in REGIONS for entry in result["bar"]["layout"][section]
+ }
imported: set[str] = set()
for region in REGIONS:
extras = []
@@ -335,7 +367,8 @@ def apply_profile(
imported.add(plugin_id)
managed = [
copy.deepcopy(existing_entries.get(plugin_id, {"id": plugin_id}))
- for plugin_id in profile.layout[region]
+ for plugin_id in profile.layout.get(region, ())
+ if plugin_id not in preserved_ids
]
result["bar"]["layout"][region] = managed + extras
@@ -355,14 +388,15 @@ def apply_profile(
result["plugins"] = plugin_entries
result["bar"]["id"] = profile.active_bar
- result["bar"]["centerAnchor"] = "hancore.shibumi.center"
+ if legacy_center_anchor(result["bar"]):
+ result["bar"]["centerAnchor"] = "hancore.shibumi.center"
result["bar"]["style"] = "shibumi"
if result["bar"].get("position") not in ("top", "bottom"):
result["bar"]["position"] = "top"
if saved_layout is not None:
- # Reinstall restores the exact previous Shibumi layout, not the profile
- # plus stock extras. Consume only our sibling field before publication
- # and shell start, restoring the complete pre-uninstall State entry.
+ # Reinstall/reactivation restores the prior layout except explicitly
+ # disabled foreign widgets, never the profile plus stock extras. Consume
+ # only our sibling field before publication and shell start.
result["bar"]["layout"] = saved_layout
state_entry(result).pop(RETAINED_LAYOUT)
else:
@@ -393,7 +427,7 @@ def remove_plugin_ids(
"""Remove retired Shibumi roots from services and any stale bar layout."""
result = _normalize(config)
retired = set(plugin_ids)
- for region in REGIONS:
+ for region in native_sections(result["bar"]["layout"]):
result["bar"]["layout"][region] = [
entry
for entry in result["bar"]["layout"][region]
@@ -415,7 +449,7 @@ def reconcile_profile_additions(
result = _normalize(config)
present = {
entry_id(entry)
- for region in REGIONS
+ for region in native_sections(result["bar"]["layout"])
for entry in result["bar"]["layout"][region]
}
introduced = set(profile.install) - previous_plugin_ids
@@ -469,7 +503,9 @@ def remove_suite(
retained_entry = copy.deepcopy(state_entry(result)) if keep_settings else None
plugin_ids = set(plugins)
preserved = preserve_ids or set()
- for region in REGIONS:
+ for region in native_sections(result["bar"]["layout"]):
+ if region == "tray":
+ continue # Retained stock-only drawer data is not a suite presentation.
result["bar"]["layout"][region] = [
entry
for entry in result["bar"]["layout"][region]
@@ -493,16 +529,21 @@ def remove_suite(
filtered_layout = result["bar"]["layout"]
# Do not tidy existing stock duplicates. Only additions are globally
# unique, including repeated IDs within the additions themselves.
- known = {entry_id(entry) for region in REGIONS
+ sections = native_sections({**restored_layout, **filtered_layout})
+ native_sections(restored_layout)
+ known = {entry_id(entry) for region in sections
for entry in _array(restored_layout.get(region))}
- for region in REGIONS:
+ for region in sections:
entries = _array(restored_layout.get(region))
- for entry in filtered_layout[region]:
+ for entry in filtered_layout.get(region, []):
plugin_id = entry_id(entry)
if plugin_id and plugin_id not in known:
entries.append(copy.deepcopy(entry))
known.add(plugin_id)
restored_layout[region] = entries
+ for section in NATIVE_SECTIONS:
+ if section not in REGIONS and section in filtered_layout:
+ restored_layout[section] = copy.deepcopy(filtered_layout[section])
restored["layout"] = restored_layout
result["bar"] = restored
_prune_missing_layout_plugins(restored_layout, plugin_ids, plugin_dir)
@@ -518,7 +559,7 @@ def remove_suite(
result["bar"]["shibumi"] = copy.deepcopy(current_bar["shibumi"])
elif result["bar"].get("id") == active_bar:
result["bar"].pop("id", None)
- if result["bar"].get("centerAnchor") == "hancore.shibumi.center":
+ if legacy_center_anchor(result["bar"]) and result["bar"].get("centerAnchor") == "hancore.shibumi.center":
if default_center_anchor:
result["bar"]["centerAnchor"] = default_center_anchor
else:
diff --git a/scripts/shibumi_suite/menu_extension.py b/scripts/shibumi_suite/menu_extension.py
index 16feb7ed..2edb4111 100644
--- a/scripts/shibumi_suite/menu_extension.py
+++ b/scripts/shibumi_suite/menu_extension.py
@@ -8,6 +8,10 @@ class MenuExtensionError(RuntimeError):
pass
+class StockMenuError(MenuExtensionError):
+ """The host defaults, not the user's extension, failed validation."""
+
+
START_MARKER = "// shibumi-picker-routing-start"
END_MARKER = "// shibumi-picker-routing-end"
EMPTY_EXTENSION = "{\n}\n"
@@ -184,11 +188,39 @@ def remove_picker_routing(raw: str) -> str:
line_end = raw.find("\n", end + len(END_MARKER))
line_end = len(raw) if line_end < 0 else line_end + 1
result = raw[:line_start] + raw[line_end:]
- _decoded(result)
+ try:
+ _decoded(result)
+ except MenuExtensionError as error:
+ decoder = error.__cause__
+ if isinstance(decoder, json.JSONDecodeError):
+ # Diagnostics name the on-disk file, not the shorter intermediate
+ # string after removing our generated block. Acceptance is unchanged.
+ position = decoder.pos + (line_end - line_start if decoder.pos >= line_start else 0)
+ original = json.JSONDecodeError(decoder.msg, raw, position)
+ raise MenuExtensionError(f"invalid Omarchy menu extension JSONC: {original}") from original
+ raise
return result
-def install_picker_routing(raw: str | None) -> str:
+def install_picker_routing(raw: str | None, stock_menu: str | None = None) -> str:
+ # Default actions are the stable host contract. The lifecycle supplies the
+ # actual host menu so forward hosts retain their native theme-picker route.
+ stock_actions = {
+ "style.theme": 'theme=$(omarchy-theme-switcher); [[ -n $theme ]] && omarchy-theme-set "$theme"',
+ "style.background": 'background=$(omarchy-theme-bg-switcher); [[ -n $background ]] && omarchy-theme-bg-set "$background"',
+ }
+ if stock_menu is not None:
+ try:
+ stock = _decoded(stock_menu)
+ stock = stock.get("items", stock)
+ for key in stock_actions:
+ entry = stock.get(key) if isinstance(stock, dict) else None
+ action = entry.get("action") if isinstance(entry, dict) else None
+ if not isinstance(action, str) or not action.strip():
+ raise MenuExtensionError(f"Omarchy stock menu has no action for {key}")
+ stock_actions[key] = action
+ except MenuExtensionError as error:
+ raise StockMenuError(str(error)) from error
base = raw if raw and raw.strip() else EMPTY_EXTENSION
base = remove_picker_routing(base)
decoded = _decoded(base)
@@ -219,13 +251,13 @@ def install_picker_routing(raw: str | None) -> str:
"icon": "",
"label": "Theme",
"aliases": ["theme", "themes"],
- "action": f"{ROUTER} theme",
+ "action": f'if [[ -x {ROUTER} ]]; then {ROUTER} theme; else {stock_actions["style.theme"]}; fi',
},
"style.background": {
"icon": "",
"label": "Background",
"aliases": ["background", "wallpaper"],
- "action": f"{ROUTER} wallpaper",
+ "action": f'if [[ -x {ROUTER} ]]; then {ROUTER} wallpaper; else {stock_actions["style.background"]}; fi',
},
}
block = (
diff --git a/scripts/shibumi_suite/runtime.py b/scripts/shibumi_suite/runtime.py
index c2978531..e2851554 100644
--- a/scripts/shibumi_suite/runtime.py
+++ b/scripts/shibumi_suite/runtime.py
@@ -2,7 +2,10 @@
import json
import os
+import select
+import signal
import subprocess
+import sys
import time
from dataclasses import dataclass
from pathlib import Path
@@ -317,10 +320,18 @@ def reload_config(self, *, timeout: float = 30) -> None:
time.sleep(0.1)
raise RuntimeFailure(f"reloadConfig did not settle: {detail}")
- def restart_shell(self, *, timeout: float = 30) -> None:
- """Restart Quattro for wholesale provider removal without hot-unload races."""
+ def restart_shell(self, *, timeout: float | None = None) -> None:
+ """Allow the host's per-instance stop, dying-instance and ready phases."""
+ if timeout is None:
+ # v4.0.5: 30 s per kill, including the terminating failed attempt,
+ # then 30 s for a dying instance and 60 s for native readiness.
+ count = len(self.shell_instances())
+ timeout = 30 * (count + 1) + 30 + 60
+ registry_timeout = 60
+ else:
+ registry_timeout = min(60, timeout)
self.run([self.command("omarchy-restart-shell")], timeout=timeout)
- self.wait_for_single_shell_instance(timeout=timeout)
+ self.wait_for_single_shell_instance(timeout=registry_timeout)
@property
def shell_config_file(self) -> Path:
@@ -359,24 +370,27 @@ def shell_instances(self, *, timeout: float = 6) -> list[dict[str, Any]]:
matches.append(value)
return matches
- def verify_single_shell_instance(self) -> None:
- instances = self.shell_instances()
+ def verify_single_shell_instance(self, *, timeout: float = 6) -> None:
+ instances = self.shell_instances(timeout=timeout)
if len(instances) != 1:
raise RuntimeFailure(
"expected exactly one Omarchy shell instance, "
f"found {len(instances)}; foreign Quickshell configs were left untouched"
)
- def wait_for_single_shell_instance(self, *, timeout: float = 30) -> None:
+ def wait_for_single_shell_instance(self, *, timeout: float = 60) -> None:
deadline = time.monotonic() + timeout
detail = "the Omarchy shell instance is not registered"
- while time.monotonic() < deadline:
+ while True:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ break
try:
- self.verify_single_shell_instance()
+ self.verify_single_shell_instance(timeout=min(6, remaining))
return
except RuntimeFailure as error:
detail = str(error)
- time.sleep(0.1)
+ time.sleep(min(0.1, max(0, deadline - time.monotonic())))
raise RuntimeFailure(f"Omarchy shell restart did not settle: {detail}")
def bar_layers(self) -> list[tuple[str, int | None]] | None:
@@ -445,16 +459,133 @@ def verify_bar_layer_ownership(self, expected_namespace: str) -> None:
"the foreign instance was left untouched"
)
+ def _shell_process_identity(self, pid: int) -> int:
+ """Bind signal escalation to the same user's explicit exact shell path."""
+ try:
+ directory = os.open(
+ f"/proc/{pid}", os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC | os.O_NOFOLLOW,
+ )
+ try:
+ if os.fstat(directory).st_uid != os.getuid():
+ raise ValueError("different process owner")
+
+ def field(name: str, limit: int) -> bytes:
+ descriptor = os.open(
+ name, os.O_RDONLY | os.O_CLOEXEC | os.O_NOFOLLOW,
+ dir_fd=directory,
+ )
+ with os.fdopen(descriptor, "rb") as stream:
+ raw = stream.read(limit + 1)
+ if len(raw) > limit:
+ raise ValueError("process identity field exceeds its bound")
+ return raw
+
+ def started() -> int:
+ return int(field("stat", 4096).rsplit(b") ", 1)[1].split()[19])
+
+ before = started()
+ command = [part.decode("utf-8") for part in field("cmdline", 65536).split(b"\0") if part]
+ after = started()
+ finally:
+ os.close(directory)
+ if before != after or not command or Path(command[0]).name not in ("quickshell", "qs"):
+ raise ValueError("not the same Quickshell process")
+ paths = []
+ index = 1
+ while index < len(command):
+ argument = command[index]
+ if argument in ("-p", "--path"):
+ index += 1
+ paths.append(command[index])
+ elif argument.startswith("--path="):
+ paths.append(argument[len("--path="):])
+ index += 1
+ if len(paths) != 1 or not Path(paths[0]).is_absolute():
+ raise ValueError("no unique absolute shell path")
+ path = Path(paths[0])
+ config = path if path.name == "shell.qml" else path / "shell.qml"
+ if config.resolve(strict=False) != self.shell_config_file:
+ raise ValueError("foreign shell configuration")
+ return after
+ except (OSError, UnicodeError, ValueError, IndexError) as error:
+ raise RuntimeFailure(
+ f"shell drain: refusing signals to unverified PID {pid}: {error}"
+ ) from error
+
+ def _terminate_timed_out_shell(self, target: dict[str, Any], deadline: float) -> None:
+ """TERM, then KILL, through one held kernel process reference only."""
+ pid = target["pid"]
+ identity = target.get("id")
+ if not isinstance(identity, str) or not identity:
+ raise RuntimeFailure("shell drain: signal fallback requires a registry identity")
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ return
+ current = self.shell_instances(timeout=min(6, remaining))
+ if not any(value.get("pid") == pid and value.get("id") == identity for value in current):
+ return # A replacement is handled by the outer registry drain.
+ try:
+ descriptor = os.pidfd_open(pid)
+ except ProcessLookupError:
+ return
+ except (OSError, AttributeError) as error:
+ raise RuntimeFailure(f"shell drain: cannot bind PID {pid} for signal fallback: {error}") from error
+ try:
+ def exited(wait: float = 0) -> bool:
+ remaining = max(0, deadline - time.monotonic())
+ return bool(select.select([descriptor], [], [], min(wait, remaining))[0])
+
+ if exited():
+ return
+ try:
+ started = self._shell_process_identity(pid)
+ except RuntimeFailure:
+ if exited():
+ return
+ raise
+ if exited() or time.monotonic() >= deadline:
+ return
+ print(f"shibumi-suite: shell drain: shutdown IPC timed out for PID {pid}; sending TERM", file=sys.stderr)
+ signal.pidfd_send_signal(descriptor, signal.SIGTERM)
+ if exited(5) or time.monotonic() >= deadline:
+ return
+ try:
+ if self._shell_process_identity(pid) != started:
+ raise RuntimeFailure("shell drain: process identity changed; refusing KILL")
+ except RuntimeFailure:
+ if exited():
+ return
+ raise
+ if exited() or time.monotonic() >= deadline:
+ return
+ print(f"shibumi-suite: shell drain: TERM grace expired for PID {pid}; sending KILL", file=sys.stderr)
+ signal.pidfd_send_signal(descriptor, signal.SIGKILL)
+ exited(2) # Registry emptiness, not signal delivery, still proves success.
+ except ProcessLookupError:
+ return
+ except OSError as error:
+ raise RuntimeFailure(f"shell drain: signal fallback failed for PID {pid}: {error}") from error
+ finally:
+ os.close(descriptor)
+
def stop_shell(
self,
*,
- timeout: float = 6,
+ timeout: float | None = None,
quiet_period: float = 0.1,
) -> None:
"""Drain the exact Quattro registry until it remains stably empty."""
shell_path = self.omarchy_root / "shell" if self.omarchy_root else None
if shell_path is None:
raise RuntimeFailure("cannot stop shell without an Omarchy root")
+ initial_instances = None
+ if timeout is None:
+ initial_instances = self.shell_instances()
+ # Freeze the budget once; respawns must never extend it. Per initial
+ # instance: 30 s IPC, 5 s TERM, 2 s KILL, two 6 s registry reads,
+ # and 1 s for preparation/quiet observation. Discovery is bounded
+ # separately by shell_instances' existing 6-second limit.
+ timeout = 50 * max(1, len(initial_instances))
if timeout <= 0:
raise RuntimeFailure("shell drain timeout must be positive")
if quiet_period < 0:
@@ -489,7 +620,11 @@ def record(values: list[dict[str, Any]]) -> None:
if registry_budget <= 0:
break
try:
- instances = self.shell_instances(timeout=min(6, registry_budget))
+ if initial_instances is not None:
+ instances = initial_instances
+ initial_instances = None
+ else:
+ instances = self.shell_instances(timeout=min(6, registry_budget))
except RuntimeFailure as error:
if time.monotonic() < deadline:
raise
@@ -556,14 +691,18 @@ def record(values: list[dict[str, Any]]) -> None:
try:
self.run(
["quickshell", "kill", "--pid", str(pid)],
- timeout=min(6, kill_budget),
+ timeout=min(30, kill_budget),
check=False,
)
except RuntimeFailure as error:
if time.monotonic() < deadline:
- raise
- deadline_operation_error = str(error)
- break
+ if isinstance(error.__cause__, subprocess.TimeoutExpired):
+ self._terminate_timed_out_shell(target, deadline)
+ else:
+ raise
+ else:
+ deadline_operation_error = str(error)
+ break
now = time.monotonic()
if now < deadline:
time.sleep(min(poll_interval, deadline - now))
diff --git a/scripts/shibumi_suite/transaction.py b/scripts/shibumi_suite/transaction.py
index c7d1533f..5e574e31 100644
--- a/scripts/shibumi_suite/transaction.py
+++ b/scripts/shibumi_suite/transaction.py
@@ -21,7 +21,7 @@
supported_install_identities,
verify_transaction_artifact_bindings,
)
-from .config import atomic_write, parse_config_bytes, read_config
+from .config import atomic_write, native_sections, parse_config_bytes, read_config
from .model import (
ContractError,
PluginSpec,
@@ -712,10 +712,8 @@ def entry_id(value: Any) -> str:
return False
return any(
entry_id(entry) == plugin_id
- for section in ("left", "center", "right")
- for entry in (
- layout.get(section) if isinstance(layout.get(section), list) else []
- )
+ for section in native_sections(layout)
+ for entry in layout.get(section, [])
)
@@ -1730,6 +1728,17 @@ def _archive_transaction_backups_open(
if paths.plugin_dir.is_dir():
_fsync_directory(paths.plugin_dir)
+ # A first install has replacement records but no preceding payloads.
+ # Retire its empty slot before it can displace a real retained backup.
+ if not any(destination.iterdir()):
+ slot = backup_root / logical_destination.name
+ opened = destination.stat()
+ current = slot.lstat()
+ if (opened.st_dev, opened.st_ino) != (current.st_dev, current.st_ino):
+ raise TransactionError("empty transaction archive directory was replaced")
+ slot.rmdir()
+ _fsync_directory(backup_root)
+
if backup_root.is_dir():
retained = sorted(
(path for path in backup_root.iterdir() if path.is_dir()),
diff --git a/tests/audio-media-plugins-regression.sh b/tests/audio-media-plugins-regression.sh
index 6ea1d64d..f93efa3f 100755
--- a/tests/audio-media-plugins-regression.sh
+++ b/tests/audio-media-plugins-regression.sh
@@ -27,7 +27,7 @@ stage_media_runtime() {
}
run_smoke() {
- local name=$1 test_file=$2 marker=$3
+ local name=$1 test_file=$2 marker=$3 deadline=${4:-8} cadence=${5:-0}
local smoke_root
smoke_root=$(mktemp -d "$tmpdir/$name.XXXX")
@@ -50,6 +50,7 @@ run_smoke() {
fi
local -a qs_fixture_env=(
+ SHIBUMI_MEDIA_QUIESCENCE="$cadence" \
QT_QPA_PLATFORM=offscreen \
WAYLAND_DISPLAY= \
XDG_RUNTIME_DIR="$smoke_root/runtime" \
@@ -58,12 +59,13 @@ run_smoke() {
)
set +e
local output rc
- output=$(qs_run_fixture "$smoke_root/shell.qml" "$smoke_root" 8 2>&1)
+ output=$(qs_run_fixture "$smoke_root/shell.qml" "$smoke_root" "$deadline" 2>&1)
rc=$?
set -e
printf '%s\n' "$output"
[[ $rc -eq 0 ]] || fail "$name smoke exited $rc"
+ if grep -Fq 'TypeError:' <<<"$output"; then fail "$name runtime TypeError"; fi
grep -F "$marker" <<<"$output" >/dev/null \
|| fail "$name success marker missing"
}
@@ -73,6 +75,7 @@ run_smoke audio audio-native-backend-seam-regression.qml \
'audio native backend seam regression passed'
run_smoke audio audio-runtime-smoke.qml 'audio runtime smoke passed'
run_smoke media media-plugin-smoke.qml 'media plugin smoke passed'
+run_smoke media media-plugin-smoke.qml 'media quiescence smoke passed' 40 1
run_spectrum_smoke() {
local mode=$1 marker=$2
diff --git a/tests/audio-native-backend-seam-regression.qml b/tests/audio-native-backend-seam-regression.qml
index 6e1999c3..2eedc419 100644
--- a/tests/audio-native-backend-seam-regression.qml
+++ b/tests/audio-native-backend-seam-regression.qml
@@ -174,7 +174,7 @@ ShellRoot {
Audio.Service {
id: service
shell: serviceHost
- manifest: ({id: "hancore.shibumi.audio", version: "0.1.1-beta.16.4", kinds: ["service"]})
+ manifest: ({id: "hancore.shibumi.audio", version: "0.1.1-beta.16.5", kinds: ["service"]})
nativeBackendEnabled: true
nativeBackendOverride: fakeBackend
}
diff --git a/tests/audio-runtime-smoke.qml b/tests/audio-runtime-smoke.qml
index b6ab722c..8a70b9cd 100644
--- a/tests/audio-runtime-smoke.qml
+++ b/tests/audio-runtime-smoke.qml
@@ -11,7 +11,7 @@ ShellRoot {
property var savedPanel: null
property real originalRadius: 0
property var audioManifest: ({id: "hancore.shibumi.audio",
- version: "0.1.1-beta.16.4", kinds: ["service", "bar-widget"]})
+ version: "0.1.1-beta.16.5", kinds: ["service", "bar-widget"]})
function check(value, message) {
if (value) return
console.error("audio-runtime-smoke:", message)
@@ -48,9 +48,9 @@ ShellRoot {
SuiteRuntime.Provider {
id: stateProvider
pluginId: "hancore.shibumi.state"
- implementationVersion: "0.1.1-beta.16.4"
+ implementationVersion: "0.1.1-beta.16.5"
owner: tokenState
- manifest: ({id: "hancore.shibumi.state", version: "0.1.1-beta.16.4", kinds: ["service"]})
+ manifest: ({id: "hancore.shibumi.state", version: "0.1.1-beta.16.5", kinds: ["service"]})
}
Fixtures.AudioRuntimeBackend { id: backend }
Audio.Service { id: service; nativeBackendOverride: backend }
diff --git a/tests/bar-catalog-consumer-smoke.qml b/tests/bar-catalog-consumer-smoke.qml
index 355c332d..5ab62d6e 100644
--- a/tests/bar-catalog-consumer-smoke.qml
+++ b/tests/bar-catalog-consumer-smoke.qml
@@ -67,10 +67,10 @@ ShellRoot {
SuiteRuntime.Provider {
id: stateProvider
pluginId: "hancore.shibumi.state"
- implementationVersion: "0.1.1-beta.16.4"
+ implementationVersion: "0.1.1-beta.16.5"
owner: stateService
host: stateHost
- manifest: ({ id: "hancore.shibumi.state", version: "0.1.1-beta.16.4",
+ manifest: ({ id: "hancore.shibumi.state", version: "0.1.1-beta.16.5",
kinds: ["service"] })
}
@@ -92,7 +92,7 @@ ShellRoot {
id: fakePluginRegistry
property string pluginId: "hancore.shibumi.bar"
property var manifest: ({ id: "hancore.shibumi.bar",
- version: "0.1.1-beta.16.4", kinds: ["bar"] })
+ version: "0.1.1-beta.16.5", kinds: ["bar"] })
property bool enabled: true
property var _entryPointUrl: null
readonly property var installedPlugins: {
@@ -138,11 +138,11 @@ ShellRoot {
property int catalogReadSerial: currentObservation ? currentObservation.serial : 0
property int catalogGeneration: currentObservation ? currentObservation.generation : 0
property var providerManifest: ({ id: "hancore.shibumi.control-center",
- version: "0.1.1-beta.16.4", kinds: ["service"] })
+ version: "0.1.1-beta.16.5", kinds: ["service"] })
QtObject { id: serviceHost; property string pluginId: "hancore.shibumi.control-center" }
SuiteRuntime.Provider {
pluginId: "hancore.shibumi.control-center"
- implementationVersion: "0.1.1-beta.16.4"
+ implementationVersion: "0.1.1-beta.16.5"
owner: service
host: serviceHost
manifest: service.providerManifest
@@ -192,7 +192,7 @@ ShellRoot {
root.bar = barFactory.createObject(root, {
omarchyPath: "/fixture/omarchy",
shell: scopedBarHost,
- manifest: ({ id: "hancore.shibumi.bar", version: "0.1.1-beta.16.4",
+ manifest: ({ id: "hancore.shibumi.bar", version: "0.1.1-beta.16.5",
kinds: ["bar"] }),
pluginRegistry: fakePluginRegistry,
barWidgetRegistry: fakeWidgetRegistry,
@@ -339,7 +339,7 @@ ShellRoot {
} else if (root.stage === 4) {
if (root.firstService.acquireCalls !== 2) return
root.firstService.providerManifest = ({ id: "invalid",
- version: "0.1.1-beta.16.4", kinds: ["service"] })
+ version: "0.1.1-beta.16.5", kinds: ["service"] })
root.stage = 5; root.waits = 0
} else if (root.stage === 5) {
if (root.firstService.releaseCalls !== 2) return
diff --git a/tests/bar-host-registry-regression.sh b/tests/bar-host-registry-regression.sh
index 7785b104..0274376d 100755
--- a/tests/bar-host-registry-regression.sh
+++ b/tests/bar-host-registry-regression.sh
@@ -177,6 +177,7 @@ cp -a "$repo_root/hancore.shibumi.state/lib" "$repo_root/hancore.shibumi.state/r
"$lifecycle_root/hancore.shibumi.state/"
install -m 0644 "$widget_slot_source" \
"$lifecycle_root/staged/barcore/WidgetSlot.qml"
+cp "$bar_root/core/GroupRegistry.js" "$lifecycle_root/staged/barcore/"
install -m 0644 "$repo_root/tests/fixtures/BarContextLifecycleHost.qml" \
"$lifecycle_root/shell.qml"
install -m 0644 "$repo_root/tests/fixtures/NetworkTestView.qml" \
@@ -291,6 +292,10 @@ import sys
from pathlib import Path
repo, target = map(Path, sys.argv[1:])
panel = (repo / 'hancore.shibumi.control-center/ControlCenterPanel.qml').read_text()
+owner = (repo / 'hancore.shibumi.control-center/BarWidget.qml').read_text()
+owner_methods = owner[owner.index(' readonly property var nativeSections:'):owner.index(' readonly property var tokens:')]
+placement_start = panel.index(' function nativeWidgetSection(')
+placement_end = panel.index(' function setPluginBarWidgetEnabled(', placement_start)
removal_start = panel.index(' function removePlugin(pluginId) {')
removal_end = panel.index(' function rescanPlugins() {', removal_start)
catalog_start = panel.index(' function pluginGlyph(pluginId, kinds) {')
@@ -304,7 +309,8 @@ for marker in (' // INJECT_REMOVE_PLUGIN', ' // INJECT_BUILD_PLUGIN_ENTRIES'):
fixture = fixture.replace(
' // INJECT_REMOVE_PLUGIN', panel[removal_start:removal_end])
fixture = fixture.replace(' // INJECT_BUILD_PLUGIN_ENTRIES',
- panel[catalog_start:catalog_end] + panel[group_states_start:group_states_end])
+ owner_methods + panel[placement_start:placement_end]
+ + panel[catalog_start:catalog_end] + panel[group_states_start:group_states_end])
(target / 'fixtures/PluginRemovalChecks.qml').write_text(fixture)
PY
cp "$repo_root/tests/fixtures/DirectPreferredHostedPanelWidget.qml" "$tmpdir/fixtures/"
diff --git a/tests/bar-host-registry-smoke.qml b/tests/bar-host-registry-smoke.qml
index dbbb8ce9..39967dd2 100644
--- a/tests/bar-host-registry-smoke.qml
+++ b/tests/bar-host-registry-smoke.qml
@@ -993,6 +993,10 @@ ShellRoot {
if (hostBar.inlineSettingsDelta(
hostBar.layoutConfig, nextBarConfig.layout) === null)
return root.fail("inline settings fixture changed layout structure")
+ const splitCurrent = {left: [], centerBegin: [], center: [], centerEnd: [{id: "example.inline-state", value: 1}], right: [], tray: []}
+ const splitNext = JSON.parse(JSON.stringify(splitCurrent)); splitNext.centerEnd[0].value = 2
+ const splitDelta = hostBar.inlineSettingsDelta(splitCurrent, splitNext)
+ if (!splitDelta || splitDelta.length !== 1 || splitDelta[0].region !== "centerEnd") return root.fail("native centerEnd inline delta was lost")
const structuralCurrent = {
left: [], center: [],
right: [{ id: "example.inline-state", shibumiModule: true }]
@@ -1295,7 +1299,7 @@ ShellRoot {
return root.fail("Add plugin did not work with the active V2 layout")
}
const replacedConfig = fakeShell.shellConfig.bar
- const centerEntries = replacedConfig.layout.center || []
+ const centerEntries = (l => Quickshell.env("SHIBUMI_OMARCHY_BASELINE_ID") === "forward-compat-246f33a2" ? ((l.center || []).length ? [] : l.centerEnd || []) : l.center || [])(replacedConfig.layout)
if (!replacedConfig.shibumi
|| !replacedConfig.shibumi.widgets
|| !replacedConfig.shibumi.widgets.G8
@@ -1308,7 +1312,7 @@ ShellRoot {
+ " Shibumi Center in the center region")
}
if (!hostBar.removeWidgetFamilyAlternatives("G8")
- || (fakeShell.shellConfig.bar.layout.center || []).length !== 0) {
+ || (l => (l.center || []).concat(Quickshell.env("SHIBUMI_OMARCHY_BASELINE_ID") === "forward-compat-246f33a2" ? l.centerEnd || [] : []))(fakeShell.shellConfig.bar.layout).length !== 0) {
return root.fail("Shibumi Center did not remove its alternatives")
}
@@ -1452,7 +1456,7 @@ ShellRoot {
return root.fail("V2 generic third-party cross-region move")
const movedDynamicV2Group = hostBar.layoutController.groupLocation(
"G:example.inline-state")
- const movedDynamicEntries = fakeShell.shellConfig.bar.layout.center || []
+ const movedDynamicEntries = (l => Quickshell.env("SHIBUMI_OMARCHY_BASELINE_ID") === "forward-compat-246f33a2" ? ((l.center || []).length ? [] : l.centerEnd || []) : l.center || [])(fakeShell.shellConfig.bar.layout)
if (!movedDynamicV2Group || movedDynamicV2Group.region !== "center"
|| movedDynamicEntries.length !== 1
|| String(movedDynamicEntries[0].id || "")
@@ -1466,7 +1470,7 @@ ShellRoot {
"example.inline-state", false, "center")
|| hostBar.layoutController.groupLocation(
"G:example.inline-state") !== null
- || (fakeShell.shellConfig.bar.layout.center || []).length !== 0)
+ || (l => (l.center || []).concat(Quickshell.env("SHIBUMI_OMARCHY_BASELINE_ID") === "forward-compat-246f33a2" ? l.centerEnd || [] : []))(fakeShell.shellConfig.bar.layout).length !== 0)
return root.fail("V2 generic third-party removal")
hostBar.layoutConfig = JSON.parse(JSON.stringify(
fakeShell.shellConfig.bar.layout))
@@ -1521,7 +1525,7 @@ ShellRoot {
|| !hostBar.setBarWidgetInstalled(
"example.future-clock", true, "center"))
return root.fail("same-capability provider replacement")
- const complementaryEntries = fakeShell.shellConfig.bar.layout.center
+ const complementaryEntries = (l => Quickshell.env("SHIBUMI_OMARCHY_BASELINE_ID") === "forward-compat-246f33a2" ? ((l.center || []).length === 1 && (l.centerEnd || []).length === 1 ? l.center.concat(l.centerEnd) : []) : l.center)(fakeShell.shellConfig.bar.layout)
if (complementaryEntries.length !== 2
|| String(complementaryEntries[0].id || complementaryEntries[0])
!== "omarchy.weather"
@@ -1532,8 +1536,8 @@ ShellRoot {
fakeShell.shellConfig.bar.layout))
if (!hostBar.removeBarWidgetAndRestoreFamilies(
"omarchy.weather", ["G8"])
- || (fakeShell.shellConfig.bar.layout.center || []).length !== 1
- || String(fakeShell.shellConfig.bar.layout.center[0].id || "")
+ || (l => Quickshell.env("SHIBUMI_OMARCHY_BASELINE_ID") === "forward-compat-246f33a2" ? ((l.center || []).length ? [] : l.centerEnd || []) : l.center || [])(fakeShell.shellConfig.bar.layout).length !== 1
+ || String(fakeShell.shellConfig.bar.layout[Quickshell.env("SHIBUMI_OMARCHY_BASELINE_ID") === "forward-compat-246f33a2" ? "centerEnd" : "center"][0].id || "")
!== "example.future-clock"
|| stateService.groupEnabledForVariant("G8", "v1")
|| stateService.groupEnabledForVariant("G8", "v2"))
@@ -1546,14 +1550,14 @@ ShellRoot {
}))
return root.fail("rejected provider restore was reported as success")
stateService.rejectGroupVariantStates = false
- if ((fakeShell.shellConfig.bar.layout.center || []).length !== 1
+ if ((l => Quickshell.env("SHIBUMI_OMARCHY_BASELINE_ID") === "forward-compat-246f33a2" ? ((l.center || []).length ? [] : l.centerEnd || []) : l.center || [])(fakeShell.shellConfig.bar.layout).length !== 1
|| stateService.groupEnabledForVariant("G8", "v1")
|| stateService.groupEnabledForVariant("G8", "v2"))
return root.fail("rejected provider restore did not roll back")
if (!hostBar.restoreWidgetFamilyProviderStates({
G8: { v1: true, v2: false }
})
- || (fakeShell.shellConfig.bar.layout.center || []).length !== 0
+ || (l => (l.center || []).concat(Quickshell.env("SHIBUMI_OMARCHY_BASELINE_ID") === "forward-compat-246f33a2" ? l.centerEnd || [] : []))(fakeShell.shellConfig.bar.layout).length !== 0
|| !stateService.groupEnabledForVariant("G8", "v1")
|| stateService.groupEnabledForVariant("G8", "v2"))
return root.fail("exact cross-variant provider restore")
diff --git a/tests/bar-visibility-ipc-smoke.qml b/tests/bar-visibility-ipc-smoke.qml
index 861607d6..d423c0b3 100644
--- a/tests/bar-visibility-ipc-smoke.qml
+++ b/tests/bar-visibility-ipc-smoke.qml
@@ -60,7 +60,7 @@ ShellRoot {
shell: fakeShell
manifest: ({
id: "hancore.shibumi.bar",
- version: "0.1.1-beta.16.4",
+ version: "0.1.1-beta.16.5",
kinds: ["bar"]
})
pluginRegistry: fakePluginRegistry
diff --git a/tests/baseline-contract-regression.sh b/tests/baseline-contract-regression.sh
index 09ec1567..aa4fdc1f 100755
--- a/tests/baseline-contract-regression.sh
+++ b/tests/baseline-contract-regression.sh
@@ -12,7 +12,7 @@ compat_package_baseline="$repo_root/contracts/baselines/omarchy-installed-packag
compat_source_baseline="$repo_root/contracts/baselines/omarchy-installed-source-parity-v4.0.2.json"
historical_package_baseline="$repo_root/contracts/baselines/omarchy-installed-package-v4.0.0.json"
historical_source_baseline="$repo_root/contracts/baselines/omarchy-installed-source-parity-v4.0.0.json"
-forward_compat_baseline="$repo_root/contracts/baselines/omarchy-forward-compat-4c1b4cb3.json"
+forward_compat_baseline="$repo_root/contracts/baselines/omarchy-forward-compat-246f33a2.json"
agents_baseline="$repo_root/contracts/baselines/omarchy-agents-v4.0.0.json"
predecessor_baseline="$repo_root/contracts/baselines/quickshell-dots-d0896fc-v2-deec8103.json"
installed_package_job="$repo_root/tests/omarchy-installed-package-contract-regression.sh"
@@ -68,6 +68,7 @@ source "$helper"
for manifest in \
"$installed_package_baseline" \
"$installed_source_baseline" \
+ "$repo_root/contracts/baselines/omarchy-installed-source-parity-v4.0.5.json" \
"$previous_package_baseline" \
"$previous_source_baseline" \
"$compat_package_baseline" \
@@ -184,9 +185,9 @@ for parity_subtree in shell config; do
done
jq -e '
- .id == "forward-compat-4c1b4cb3"
+ .id == "forward-compat-246f33a2"
and .profile == "forward-compat"
- and .sourceRevision == "4c1b4cb3a94447aff39af98e2fcc0490a9fb32e7"
+ and .sourceRevision == "246f33a2fef66411c990860d88e0823ae83987fc"
and .provenance.kind == "git"
and .provenance.revision == .sourceRevision
and .quickshellPackage == {"name": "quickshell", "version": "0.3.1-1"}
diff --git a/tests/catalog-service-smoke.qml b/tests/catalog-service-smoke.qml
index 72ba591c..a37224ab 100644
--- a/tests/catalog-service-smoke.qml
+++ b/tests/catalog-service-smoke.qml
@@ -62,7 +62,7 @@ Scope {
}
}
function manifest(valid) {
- return {id: "hancore.shibumi.control-center", version: valid ? "0.1.1-beta.16.4" : "invalid",
+ return {id: "hancore.shibumi.control-center", version: valid ? "0.1.1-beta.16.5" : "invalid",
kinds: ["service", "bar-widget"]}
}
function changedCatalogOutput() {
diff --git a/tests/center-plugin-regression.sh b/tests/center-plugin-regression.sh
index 893a6533..382754d9 100755
--- a/tests/center-plugin-regression.sh
+++ b/tests/center-plugin-regression.sh
@@ -50,6 +50,8 @@ mkdir -p "$tmpdir/runtime"
chmod 700 "$tmpdir/runtime"
shibumi_stage_suite_runtime "$repo_root" "$tmpdir"
mkdir -p "$tmpdir/hancore.shibumi.bar"
+install -Dm0644 "$repo_root/hancore.shibumi.bar/core/GroupRegistry.js" \
+ "$tmpdir/hancore.shibumi.bar/core/GroupRegistry.js"
cp -a -- "$repo_root/hancore.shibumi.bar/services" \
"$tmpdir/hancore.shibumi.bar/services"
cp -a -- "$repo_root/hancore.shibumi.center" "$tmpdir/center"
diff --git a/tests/contract-regression.sh b/tests/contract-regression.sh
index c106e10e..5bb71693 100755
--- a/tests/contract-regression.sh
+++ b/tests/contract-regression.sh
@@ -688,9 +688,17 @@ if rg -q 'Quickshell\.Services\.(Mpris|Pipewire)|Mpris\.|Pipewire\.' \
}' hancore.shibumi.media/MediaPanel.qml; then
fail "Shibumi media presentation must not create a second media owner"
fi
-if rg -q 'Process \{|Timer \{|FileView \{' hancore.shibumi.media/BarWidget.qml \
- hancore.shibumi.media/MediaPulse.qml hancore.shibumi.media/MediaSpectrum.qml hancore.shibumi.media/MediaMuse.qml; then
- fail "closed media bar presentation must remain worker-free"
+# Approved media exception: one visible/panel-revoked 25 Hz clock for playing
+# or the <=480 ms pause tail. Primitives cannot own clocks or backend workers.
+if rg -q 'Process \{|FileView \{|Animation.*\{|Qt\.callLater' hancore.shibumi.media/BarWidget.qml \
+ || rg -q 'Process \{|Timer \{|FileView \{|Animation.*\{|Qt\.callLater' \
+ hancore.shibumi.media/MediaPulse.qml hancore.shibumi.media/MediaSpectrum.qml hancore.shibumi.media/MediaMuse.qml \
+ || [[ $(rg -c 'Timer \{' hancore.shibumi.media/BarWidget.qml) -ne 1 ]] \
+ || ! rg -q 'interval: 40' hancore.shibumi.media/BarWidget.qml \
+ || ! rg -q 'running: root.motionRunning' hancore.shibumi.media/BarWidget.qml \
+ || ! rg -Fq 'readonly property bool motionRunning: visible && !opened && (playing || pulseSettling)' hancore.shibumi.media/BarWidget.qml \
+ || ! rg -Fq 'restElapsed = Math.min(480, restElapsed + milliseconds)' hancore.shibumi.media/MediaPulse.qml; then
+ fail "media surface must own exactly one bounded cadence and no backend workers"
fi
rg -q 'serviceFor\("hancore\.shibumi\.media"\)' hancore.shibumi.media/BarWidget.qml \
|| fail "media presentation does not resolve the process-wide spectrum service"
diff --git a/tests/control-center-regression.sh b/tests/control-center-regression.sh
index 7bae6077..ff4adf55 100755
--- a/tests/control-center-regression.sh
+++ b/tests/control-center-regression.sh
@@ -43,15 +43,20 @@ import sys
from pathlib import Path
panel = (Path(sys.argv[1]) / "hancore.shibumi.control-center/ControlCenterPanel.qml").read_text()
bar = (Path(sys.argv[1]) / "hancore.shibumi.bar/Bar.qml").read_text()
+owner = (Path(sys.argv[1]) / "hancore.shibumi.control-center/BarWidget.qml").read_text()
+owner_methods = owner[owner.index(" readonly property var nativeSections:"):owner.index(" readonly property var tokens:")]
methods = panel[panel.index(" function setPluginEnabled("):panel.index(" function setPluginBarWidgetEnabled(")]
methods += panel[panel.index(" function shibumiWidgetGroup("):panel.index(" function widgetInstalled(")]
methods += ''.join(bar[bar.index(' function ' + start + '('):bar.index(' function ' + end + '(')] for start, end in [('layoutEntries', 'deduplicatedUnassignedEntries'), ('layoutContains', 'hasBarWidgetEntryPoint'), ('entryId', 'entrySettings')])
Path(sys.argv[2]).write_text('''import QtQuick
import Quickshell
import qs.Commons
+import qs.Commons as Commons
+import "hancore.shibumi.state/runtime" as SuiteRuntime
import "BAR_CORE/GroupRegistry.js" as GroupRegistry
import "BAR_CORE/LayoutModel.js" as LayoutModel
QtObject { id: root
+ readonly property var ownerWidget: root
property bool nativeCatalogRequired: false; property bool v2LayoutActive: false; property string pluginActionError: ""
property var pluginRegistry: ({installedPlugins: {}, setEnabled: function() { return false }})
property var layout: []; property var groups: ({}); property int layoutWrites: 0
@@ -73,7 +78,7 @@ QtObject { id: root
const next = LayoutModel.reconcilePluginGroups(order, LayoutModel.resizeSplits(LayoutModel.defaultSplits(), order), ids.map(id => ({pluginId:id,region:"right"})), ["G:hancore.shibumi.gpu"])
if (!next || next.unplaced.length || next.order.left[8] !== "G:hancore.shibumi.temperature" || JSON.stringify(next.order.right) !== JSON.stringify(order.right) || JSON.stringify(next.order.center) !== JSON.stringify(order.center) || JSON.stringify(next.order.left.slice(0,8)) !== JSON.stringify(order.left.slice(0,8))) { console.error("P14 disabled GPU did not release its exact additional slot"); Qt.exit(1); return }
let failures = 0; for (const id of ["hancore.shibumi.temperature", "hancore.shibumi.gpu", "hancore.shibumi.storage"]) { pluginRegistry.installedPlugins[id] = {kinds:["bar-widget"]}; for (const on of [true, false, true, false]) { layout = [{id:"other"}, {id:"hancore.shibumi.gpu", shibumiModule:true}, {id:"hancore.shibumi.temperature"}, {id:"hancore.shibumi.storage"}]; const before = JSON.stringify(layout); groups["G:" + id] = !on; if (!setPluginEnabled(id, on, true) || groupEnabled("G:" + id) !== on || layoutWrites !== 0 || JSON.stringify(layout) !== before) { console.error("V1 configured widget did not toggle its group without layout mutation", id, on, pluginActionError); failures++ } } } if (failures) Qt.exit(1); else { console.log("V1 configured widget toggles passed with actual Bar membership"); Qt.quit() } })
-''' .replace('BAR_CORE', str(Path(sys.argv[1]) / 'hancore.shibumi.bar/core')) + methods + '}\n')
+''' .replace('BAR_CORE', str(Path(sys.argv[1]) / 'hancore.shibumi.bar/core')) + owner_methods + methods + '}\n')
PY
timeout 5 "$quickshell_bin" -p "$tmpdir/v1-layout-noop.qml"
set +e
@@ -1633,11 +1638,11 @@ if rg -Fq 'height: visible ? Commons.Style.space(48) : 0' \
fail "plugin feedback still shifts the catalog layout"
fi
for suite_boundary in \
- 'userToggleable: barWidget && (!suiteManaged || group !== "")' \
- 'styleAvailable: true' \
+ 'userToggleable: barWidget && placement !== "" && (!suiteManaged || group !== "")' \
+ 'styleAvailable: placement !== ""' \
'No free place in the V1 bar. Turn off another additional widget first.' \
'Control Center rejected suite-internal plugin toggle:' \
- '? String(manifest.barWidget.defaultSection) : "center"' \
+ '? ownerWidget.nativeDefaultSection(metadata ? metadata.defaultSection : undefined) : ""' \
'setPluginBarWidgetEnabled(id, enabled === true, section)'; do
rg -Fq "$suite_boundary" "$control_dir/ControlCenterPanel.qml" \
|| fail "plugin-manager suite boundary drifted: $suite_boundary"
diff --git a/tests/control-center-smoke.qml b/tests/control-center-smoke.qml
index 9fed684c..f5b5cd5b 100644
--- a/tests/control-center-smoke.qml
+++ b/tests/control-center-smoke.qml
@@ -1100,6 +1100,21 @@ ShellRoot {
|| !plugins.catalogConsumerActive
|| plugins.catalogObservation === null)
return root.fail("scoped Plugins page did not acquire the catalog observation")
+ const savedObservation = pluginUpdateService.currentCatalogObservation
+ const savedEntries = panel.pluginEntries
+ pluginUpdateService.currentCatalogObservation = null
+ pluginUpdateService.catalogErrorCode = "process-identity"
+ panel.pluginEntries = []
+ const emptyText = plugins.children.filter(function(child) {
+ return "text" in child && (child.text.indexOf("No matching bar plugins.") >= 0
+ || child.text.indexOf("Plugin catalog unavailable") >= 0)
+ })
+ if (emptyText.length !== 1 || !emptyText[0].visible
+ || emptyText[0].text !== "Plugin catalog unavailable (process-identity)")
+ return root.fail("catalog refusal still looks like an empty filter")
+ pluginUpdateService.currentCatalogObservation = savedObservation
+ pluginUpdateService.catalogErrorCode = ""
+ panel.pluginEntries = savedEntries
root.selectedUpdateService = null
root.updateRebindPhase++
return
diff --git a/tests/core-services-smoke.qml b/tests/core-services-smoke.qml
index 76f294c8..08611fcb 100644
--- a/tests/core-services-smoke.qml
+++ b/tests/core-services-smoke.qml
@@ -20,14 +20,14 @@ ShellRoot {
id: telemetry
shell: fakeShell
thermalProbeEnabled: false
- manifest: ({id: "hancore.shibumi.telemetry", version: "0.1.1-beta.16.4", kinds: ["service"]})
+ manifest: ({id: "hancore.shibumi.telemetry", version: "0.1.1-beta.16.5", kinds: ["service"]})
}
Cpu.Service {
id: cpu
shell: fakeShell
gpuProbeEnabled: false
- manifest: ({id: "hancore.shibumi.cpu", version: "0.1.1-beta.16.4", kinds: ["service"]})
+ manifest: ({id: "hancore.shibumi.cpu", version: "0.1.1-beta.16.5", kinds: ["service"]})
}
QtObject {
@@ -40,7 +40,7 @@ ShellRoot {
PowerState.Service {
id: power
shell: fakeShell
- manifest: ({id: "hancore.shibumi.power-state", version: "0.1.1-beta.16.4", kinds: ["service"]})
+ manifest: ({id: "hancore.shibumi.power-state", version: "0.1.1-beta.16.5", kinds: ["service"]})
batterySnapshotOverride: ({ready: false})
commandOverrides: ({}) // This pure projection smoke may never launch helpers.
}
diff --git a/tests/documentation-regression.py b/tests/documentation-regression.py
index 94bdf589..37d04717 100755
--- a/tests/documentation-regression.py
+++ b/tests/documentation-regression.py
@@ -257,7 +257,7 @@ def legacy_storage_error(documents: dict[str, str]) -> str | None:
"contracts/baselines/omarchy-installed-package-v4.0.3.json",
"contracts/baselines/omarchy-installed-source-parity-v4.0.3.json",
"SHIBUMI_OMARCHY_BASELINE_VERSION=4.0.2",
- "contracts/baselines/omarchy-forward-compat-4c1b4cb3.json",
+ "contracts/baselines/omarchy-forward-compat-246f33a2.json",
"./tests/omarchy-installed-package-contract-regression.sh",
"./tests/omarchy-installed-source-parity-contract-regression.sh",
"./tests/omarchy-forward-compat-contract-regression.sh",
diff --git a/tests/fixtures/PluginRemovalChecks.qml b/tests/fixtures/PluginRemovalChecks.qml
index a5418e79..76af8dc2 100644
--- a/tests/fixtures/PluginRemovalChecks.qml
+++ b/tests/fixtures/PluginRemovalChecks.qml
@@ -1,10 +1,13 @@
import QtQuick
+import qs.Commons as Commons
+import "../hancore.shibumi.state/runtime" as SuiteRuntime
// The runner injects exact ControlCenterPanel methods here. Only process and
// catalog dependencies are controlled; no command can execute in this helper.
Item {
id: checks
required property var bar
+ readonly property var ownerWidget: checks
property var pluginEntries: []
readonly property bool stockOmarchyHost: false
readonly property bool v2LayoutActive: bar.layoutController.v2Mode
diff --git a/tests/fixtures/PluginUpdateTestService.qml b/tests/fixtures/PluginUpdateTestService.qml
index 0e51b68a..4a47b409 100644
--- a/tests/fixtures/PluginUpdateTestService.qml
+++ b/tests/fixtures/PluginUpdateTestService.qml
@@ -25,6 +25,7 @@ QtObject {
property var catalogToken: null
property var catalogHolder: null
property var currentCatalogObservation: null
+ property string catalogErrorCode: ""
readonly property bool catalogReady: currentCatalogObservation !== null
readonly property int catalogReadSerial: catalogReady
? Number(currentCatalogObservation.serial || 0) : 0
diff --git a/tests/fixtures/native-catalog-transport.py b/tests/fixtures/native-catalog-transport.py
index 8b610653..4b556f0f 100644
--- a/tests/fixtures/native-catalog-transport.py
+++ b/tests/fixtures/native-catalog-transport.py
@@ -35,6 +35,8 @@ def must_refuse(code, diagnostic):
require(helper.MAX_OUTPUT == 262144 and helper.MAX_ERROR == 8192 and helper.DEADLINE == 3,
'production acquisition bounds changed')
+for section in ("centerBegin", "centerEnd", "tray"):
+ require(helper.presentation({"barWidget": {"defaultSection": section}})["barWidget"]["defaultSection"] == section, "native section metadata refused")
for size in (0, 262143, 262144):
require(len(helper.collect(command(f'import sys;sys.stdout.buffer.write(b"x"*{size})'))) == size,
'stdout boundary refused')
@@ -125,30 +127,52 @@ def identity(pid, directory):
and enriched["barWidget"]["defaultSection"] == "left",
'package metadata/defaultSection was not retained')
-manifest_path = fixture_shell / 'plugins/widget/manifest.json'
-duplicate_directory = fixture_shell / 'plugins/duplicate'
-duplicate_directory.mkdir()
-(duplicate_directory / 'manifest.json').write_text(json.dumps(manifest))
+# The native registry follows development-checkout directory links.
+local = Path(os.environ['HOME']) / '.config/omarchy/plugins'
+local.mkdir(parents=True)
+checkout = Path('development-checkout').absolute()
+checkout.mkdir()
+(checkout / 'manifest.json').write_text(json.dumps(dict(manifest, id='fixture.link')))
+(local / 'fixture.link').symlink_to(checkout, target_is_directory=True)
+linked_row = dict(native_row[0], id='fixture.link', firstParty=False)
try:
- invoke(native_payload)
-except helper.Refused:
- pass
-else:
- raise AssertionError('duplicate manifest identity accepted')
-(duplicate_directory / 'manifest.json').unlink()
-duplicate_directory.rmdir()
-
+ linked = json.loads(invoke(json.dumps(native_row + [linked_row]).encode()))
+except (helper.Refused, OSError) as error:
+ linked = str(error)
+require(isinstance(linked, list) and len(linked) == 2
+ and linked[1] == dict(enriched, **linked_row), 'symlinked plugin directory lost the catalog')
+(local / 'fixture.link').unlink()
+
+# Metadata failures revoke presentation only, never native row identity.
+manifest_path = fixture_shell / 'plugins/widget/manifest.json'
manifest_backup = manifest_path.with_suffix('.backup')
-manifest_path.rename(manifest_backup)
-manifest_path.symlink_to(manifest_backup.name)
-try:
- invoke(native_payload)
-except (helper.Refused, OSError):
- pass
-else:
- raise AssertionError('symlinked manifest metadata accepted')
-manifest_path.unlink()
-manifest_backup.rename(manifest_path)
+duplicate_directory = fixture_shell / 'plugins/duplicate'
+for case, issue in (('invalid', 'manifest-metadata'), ('duplicate', 'metadata-ambiguous'),
+ ('symlink', 'metadata-missing'), ('hardlink', 'metadata-missing')):
+ manifest_path.rename(manifest_backup)
+ try:
+ if case == 'duplicate':
+ manifest_path.write_bytes(manifest_backup.read_bytes())
+ duplicate_directory.mkdir()
+ (duplicate_directory / 'manifest.json').write_bytes(manifest_backup.read_bytes())
+ elif case == 'symlink':
+ manifest_path.symlink_to(manifest_backup.name)
+ elif case == 'hardlink':
+ os.link(manifest_backup, manifest_path)
+ else:
+ manifest_path.write_text(json.dumps(dict(manifest, author={'invalid': True})))
+ try:
+ degraded = json.loads(invoke(native_payload))
+ except (helper.Refused, OSError) as error:
+ degraded = str(error)
+ require(degraded == [dict(native_row[0], catalogIssue=issue)],
+ 'metadata failure discarded native row: ' + case)
+ finally:
+ manifest_path.unlink()
+ if duplicate_directory.exists():
+ (duplicate_directory / 'manifest.json').unlink()
+ duplicate_directory.rmdir()
+ manifest_backup.rename(manifest_path)
saved_probe_limit = helper.MAX_MANIFEST_PROBES
helper.MAX_MANIFEST_PROBES = 0
diff --git a/tests/fixtures/native-runtime/probe.py b/tests/fixtures/native-runtime/probe.py
index 8dfb5c8e..52511183 100644
--- a/tests/fixtures/native-runtime/probe.py
+++ b/tests/fixtures/native-runtime/probe.py
@@ -132,7 +132,7 @@ def catalog_probe():
"actual native catalog identity DTO mismatch")
control = ready["snapshot"]["byId"]["hancore.shibumi.control-center"]
check(control["description"] and control["author"] == "HANCORE"
- and control["version"] == "0.1.1-beta.16.4"
+ and control["version"] == "0.1.1-beta.16.5"
and control["barWidget"]["defaultSection"] == "left",
"package-bound catalog presentation metadata was lost")
check(host_config() == original, "read-only catalog changed native config")
diff --git a/tests/fixtures/release-predecessor.json b/tests/fixtures/release-predecessor.json
index 6890f316..b7c9ab76 100644
--- a/tests/fixtures/release-predecessor.json
+++ b/tests/fixtures/release-predecessor.json
@@ -1,11 +1,11 @@
{
"schemaVersion": 1,
- "version": "0.1.1-beta.16.3",
- "tag": "v0.1.1-beta.16.3",
- "revision": "1a13d6be7d5b4bc9aa03590b77b6531aefcad497",
- "payloadDigest": "9d8cca435b465c52b0ff21d7a6780f90251a805dc1d1e72b78473ca2a8338a88",
- "releaseId": 409150061,
- "releaseUrl": "https://github.com/HANCORE-linux/Shibumi-Shell/releases/tag/v0.1.1-beta.16.3",
- "publishedAt": "2026-10-10T19:12:27Z",
+ "version": "0.1.1-beta.16.4",
+ "tag": "v0.1.1-beta.16.4",
+ "revision": "64b9369559b0de2ffdc1faf1c563326c8173035c",
+ "payloadDigest": "bc60b476803f72d5604e592647db7f50c1f00723b986ae87e7730cba79779d34",
+ "releaseId": 413874192,
+ "releaseUrl": "https://github.com/HANCORE-linux/Shibumi-Shell/releases/tag/v0.1.1-beta.16.4",
+ "publishedAt": "2026-10-15T13:55:25Z",
"prerelease": true
}
diff --git a/tests/gpu-selection-state-smoke.qml b/tests/gpu-selection-state-smoke.qml
index 1aa33e9c..e2004fde 100644
--- a/tests/gpu-selection-state-smoke.qml
+++ b/tests/gpu-selection-state-smoke.qml
@@ -25,13 +25,13 @@ ShellRoot {
id: stateService
shell: fakeShell
omarchyPath: Quickshell.env("OMARCHY_PATH")
- manifest: ({id: "hancore.shibumi.state", version: "0.1.1-beta.16.4", kinds: ["service"]})
+ manifest: ({id: "hancore.shibumi.state", version: "0.1.1-beta.16.5", kinds: ["service"]})
}
Cpu.Service {
id: cpuService
shell: fakeShell
- manifest: ({id: "hancore.shibumi.cpu", version: "0.1.1-beta.16.4", kinds: ["service"]})
+ manifest: ({id: "hancore.shibumi.cpu", version: "0.1.1-beta.16.5", kinds: ["service"]})
gpuProbeEnabled: false
}
diff --git a/tests/host-registry-prime-smoke.qml b/tests/host-registry-prime-smoke.qml
index 9f9b9b07..5163ac47 100644
--- a/tests/host-registry-prime-smoke.qml
+++ b/tests/host-registry-prime-smoke.qml
@@ -50,11 +50,11 @@ ShellRoot {
Shared.Provider {
id: stateProvider
pluginId: "hancore.shibumi.state"
- implementationVersion: "0.1.1-beta.16.4"
+ implementationVersion: "0.1.1-beta.16.5"
owner: stateOwner
host: stateHost
manifest: ({ id: "hancore.shibumi.state",
- version: "0.1.1-beta.16.4", kinds: ["service"] })
+ version: "0.1.1-beta.16.5", kinds: ["service"] })
}
QtObject {
@@ -77,11 +77,11 @@ ShellRoot {
}
Shared.Provider {
pluginId: "hancore.shibumi.bar"
- implementationVersion: "0.1.1-beta.16.4"
+ implementationVersion: "0.1.1-beta.16.5"
owner: owner
host: owner.providerHost
manifest: ({ id: "hancore.shibumi.bar",
- version: "0.1.1-beta.16.4", kinds: ["bar"] })
+ version: "0.1.1-beta.16.5", kinds: ["bar"] })
}
}
}
diff --git a/tests/layout-transition-regression.py b/tests/layout-transition-regression.py
index 84f691c0..3c3b6e0e 100644
--- a/tests/layout-transition-regression.py
+++ b/tests/layout-transition-regression.py
@@ -60,9 +60,11 @@ def main():
commons = Path(os.environ.get("OMARCHY_PATH", "/usr/share/omarchy")) / "shell/Commons"
sources["Commons/Util.qml"] = (read_regular(commons / "Util.qml", 65536), False)
sources["Commons/qmldir"] = (b"singleton Util 1.0 Util.qml\n", False)
+ sources["runtime/HostContract.qml"] = (read_regular(ROOT / "hancore.shibumi.state/runtime/HostContract.qml", 65536), False)
+ sources["runtime/qmldir"] = (b"singleton HostContract 1.0 HostContract.qml\n", False)
bar = read_regular(ROOT / "hancore.shibumi.bar/Bar.qml", 262144)
fragments = []
- for start, end in ((b" function planV2DynamicLayout(", b" function requestV2LayoutTransition("),
+ for start, end in ((b" function nativeTargetSection(", b" function requestV2LayoutTransition("),
(b" function entryId(", b" function entrySettings(")):
if bar.count(start) != 1 or bar.count(end) != 1:
raise RuntimeError("Bar planner extraction anchor drifted")
diff --git a/tests/layout-transition-smoke.qml b/tests/layout-transition-smoke.qml
index c206002a..13e386e0 100644
--- a/tests/layout-transition-smoke.qml
+++ b/tests/layout-transition-smoke.qml
@@ -3,6 +3,7 @@ import Quickshell
import qs.Commons
import qs.Commons as Commons
import "core" as Core
+import "runtime" as SuiteRuntime
import "core/V2LayoutModel.js" as Layout
import "core/GroupRegistry.js" as GroupRegistry
import "StateStorageModel.js" as Model
diff --git a/tests/lib/baselines.sh b/tests/lib/baselines.sh
index 386879fb..7adc91c0 100644
--- a/tests/lib/baselines.sh
+++ b/tests/lib/baselines.sh
@@ -172,6 +172,7 @@ shibumi_validate_omarchy_baseline_schema() {
jq -e '
if .id == "installed-package-v4.0.4"
or .id == "installed-source-parity-v4.0.4"
+ or .id == "installed-source-parity-v4.0.5"
or .id == "installed-package-v4.0.3"
or .id == "installed-source-parity-v4.0.3"
or .id == "installed-package-v4.0.2"
@@ -179,7 +180,8 @@ shibumi_validate_omarchy_baseline_schema() {
or .id == "forward-compat-ed7bae4a"
or .id == "forward-compat-50d687a1"
or .id == "forward-compat-e71a816e"
- or .id == "forward-compat-4c1b4cb3" then
+ or .id == "forward-compat-4c1b4cb3"
+ or .id == "forward-compat-246f33a2" then
.quickshellPackage == {"name": "quickshell", "version": "0.3.1-1"}
elif .id == "installed-package-v4.0.0"
or .id == "installed-source-parity-v4.0.0" then
@@ -731,6 +733,8 @@ shibumi_load_omarchy_baseline() {
|| shibumi_baseline_fail \
'OMARCHY_PATH is required for installed-source-parity' || return
case $baseline_version in
+ 4.0.5)
+ manifest="$shibumi_baseline_repo_root/contracts/baselines/omarchy-installed-source-parity-v4.0.5.json" ;;
4.0.4)
manifest="$shibumi_baseline_repo_root/contracts/baselines/omarchy-installed-source-parity-v4.0.4.json" ;;
4.0.3)
@@ -748,7 +752,7 @@ shibumi_load_omarchy_baseline() {
[[ -n $requested_path ]] \
|| shibumi_baseline_fail \
'OMARCHY_PATH is required for forward-compat' || return
- manifest="$shibumi_baseline_repo_root/contracts/baselines/omarchy-forward-compat-4c1b4cb3.json"
+ manifest="$shibumi_baseline_repo_root/contracts/baselines/omarchy-forward-compat-246f33a2.json"
;;
agents-current)
requested_path=${OMARCHY_PATH:-}
diff --git a/tests/media-plugin-smoke.qml b/tests/media-plugin-smoke.qml
index 822f660f..c509849b 100644
--- a/tests/media-plugin-smoke.qml
+++ b/tests/media-plugin-smoke.qml
@@ -13,6 +13,213 @@ ShellRoot {
property string mediaShellStyle: "shibumi"
property var mediaSettings: ({ spectrum: false, mediaStyle: "default" })
+ readonly property bool quiescenceCheck: Quickshell.env("SHIBUMI_MEDIA_QUIESCENCE") === "1"
+ property int quietCase: 0
+ property int quietTicks: 0
+ property var quietObservers: []
+ property var quietMeasurements: []
+ property real quietStarted: 0
+ property int positionReads: 0
+ property int initialPositionReads: 0
+ property var quietFailures: []
+ property var quietPulse: null
+ property var quietPulseStart: []
+ property bool quietTailValid: true
+ property real quietTransitionAt: 0
+ readonly property var quietCases: [
+ ["shibumi", "default", false, false], ["full", "default", false, false],
+ ["shibumi", "full", false, false], ["full", "full", false, false],
+ ["shibumi", "default", true, false], ["full", "default", true, false],
+ ["shibumi", "default", false, true], ["full", "default", false, true],
+ ["shibumi", "default", false, false, "paused"], ["full", "full", false, false, "paused"],
+ ["shibumi", "default", false, false, "hidden"], ["full", "full", false, false, "hidden"],
+ ["full", "full", false, true, "paused"], ["full", "full", false, true, "no-length"],
+ ["full", "full", false, true, "hidden"]
+ ]
+
+ // Count synchronous geometry-change batches, not samples from the fixture
+ // timer: all properties of a surface must change inside its one cadence step.
+ // Hidden descendants are observed too, catching the old hidden marquee.
+ function observeSurface(surface, excluded) {
+ const measured = { batches: {}, events: 0, outside: 0, positions: [], fractional: false }
+ function connect(signal, callback) {
+ signal.connect(callback)
+ quietObservers.push([signal, callback])
+ }
+ function changed() {
+ measured.events++
+ if (surface.motionStepping !== true) measured.outside++
+ measured.batches[String(surface.motionStep)] = true
+ }
+ function visit(node) {
+ if (node === excluded) return
+ for (const key of ["x", "y", "width", "height", "rotation", "opacity", "text"]) {
+ if (node[key + "Changed"]) connect(node[key + "Changed"], changed)
+ }
+ const children = node.children || []
+ for (let i = 0; i < children.length; i++) visit(children[i])
+ }
+ visit(surface)
+ if (surface.currentPositionChanged) connect(surface.currentPositionChanged, function() {
+ measured.positions.push(Date.now())
+ measured.fractional = measured.fractional || surface.currentPosition !== Math.floor(surface.currentPosition)
+ })
+ return measured
+ }
+
+ function checkDiscretePosition(media) {
+ media.close()
+ media.visible = true
+ playerA.isPlaying = false
+ playerA.length = 245
+ playerA.position = 10.75
+ media.toggle()
+ const panel = media.panelItem
+ if (!panel) return false
+ let valid = panel.currentPosition === 10
+ playerA.position = 20.75
+ valid = valid && panel.currentPosition === 20
+ playerA.length = 15.5
+ valid = valid && panel.currentPosition === 15
+ playerA.length = 245
+ let reads = 0
+ playerA.position = { valueOf: function() { reads++; return 30.9 } }
+ const before = reads
+ playerA.uniqueId++
+ valid = valid && reads === before + 1 && panel.currentPosition === 30
+ playerA.isPlaying = true
+ valid = valid && reads === before + 2
+ media.close()
+ const closedReads = reads
+ playerA.uniqueId++
+ playerA.isPlaying = false
+ valid = valid && reads === closedReads
+ console.log("MEDIA_DISCRETE_POSITION", JSON.stringify({ valid: valid, reads: reads }))
+ return valid
+ }
+
+ function visiblePulse(node) {
+ if ("barH1" in node && node.visible) return node
+ const children = node.children || []
+ for (let i = 0; i < children.length; i++) {
+ const found = visiblePulse(children[i])
+ if (found) return found
+ }
+ return null
+ }
+
+ function checkPulseRevocation(media) {
+ let valid = true
+ for (const vertical of [false, true]) {
+ fakeBar.vertical = vertical
+ for (const panel of [false, true]) {
+ playerA.isPlaying = true
+ const pulse = visiblePulse(media)
+ if (!pulse) return false
+ pulse.elapsed = 0
+ pulse.advance(200)
+ playerA.isPlaying = false
+ valid = valid && pulse.settling === true && media.motionRunning
+ if (panel) media.toggle()
+ else media.visible = false
+ valid = valid && pulse.settling === false && !media.motionRunning
+ if (panel) media.close()
+ else media.visible = true
+ valid = valid && pulse.settling === false && !media.motionRunning
+ }
+ }
+ fakeBar.vertical = false
+ console.log("MEDIA_PAUSE_REVOCATION", JSON.stringify({ valid: valid }))
+ return valid
+ }
+
+ function checkQuiescence(media) {
+ if (!media) return
+ const scenario = quietCases[quietCase]
+ if (quietTicks === 0) {
+ media.close()
+ media.visible = true
+ mediaShellStyle = scenario[0]
+ mediaSettings = ({ spectrum: false, mediaStyle: scenario[1] })
+ fakeBar.vertical = scenario[2]
+ playerA.length = scenario[4] === "no-length" ? 0 : 245
+ quietPulse = null
+ quietTailValid = true
+ if (quietCase === 8) {
+ playerA.isPlaying = true
+ quietPulse = visiblePulse(media)
+ if (quietPulse) {
+ quietPulse.elapsed = 0
+ quietPulse.advance(200)
+ quietPulseStart = [quietPulse.barH1, quietPulse.barH2, quietPulse.barH3]
+ }
+ }
+ playerA.isPlaying = scenario[4] !== "paused"
+ quietTransitionAt = Date.now()
+ if (quietCase === 8) quietTailValid = !!quietPulse
+ && [quietPulse.barH1, quietPulse.barH2, quietPulse.barH3].every((value, i) => value === quietPulseStart[i] && value > 0.08)
+ const epoch = Date.now()
+ // Like the native computed getter: advances without position notifications.
+ playerA.position = { valueOf: function() {
+ root.positionReads++
+ return 61 + (Date.now() - epoch) / 1000
+ } }
+ if (scenario[3]) media.toggle()
+ if (scenario[4] === "hidden") media.visible = false
+ }
+ quietTicks++
+ if (quietTicks === 3 && quietPulse) {
+ const values = [quietPulse.barH1, quietPulse.barH2, quietPulse.barH3]
+ quietTailValid = quietTailValid && values.every((value, i) => value > 0.08 && value < quietPulseStart[i]
+ && Math.abs(value - (0.08 + (quietPulseStart[i] - 0.08) * Math.pow(1 - Math.min(1, quietPulse.restElapsed / [380, 430, 480][i]), 3))) < 1e-12)
+ console.log("MEDIA_PAUSE_EASE", JSON.stringify({ values: values, from: quietPulseStart,
+ elapsed: quietPulse.restElapsed, valid: quietTailValid }))
+ }
+ if (quietTicks === 9 && scenario[3] && scenario[4] === "hidden" && media.panelItem)
+ media.panelItem.visible = false
+ if (quietTicks === 10) {
+ if (quietPulse) quietTailValid = quietTailValid && Date.now() - quietTransitionAt >= 500
+ && !media.motionRunning && [quietPulse.barH1, quietPulse.barH2, quietPulse.barH3].every(value => value === 0.08)
+ && checkPulseRevocation(media)
+ quietMeasurements = [observeSurface(media, media.panelItem)]
+ if (media.panelItem) quietMeasurements.push(observeSurface(media.panelItem))
+ quietStarted = Date.now()
+ initialPositionReads = positionReads
+ }
+ if (quietTicks > 10 && quietTicks < 40 && playerA.isPlaying && scenario[4] !== "hidden") {
+ // Two event-fed frames between ticks: only the latest can be displayed.
+ fakeSpectrum.levels = Array(24).fill(0.1)
+ fakeSpectrum.levels = Array(24).fill(quietTicks % 2 ? 0.8 : 0.4)
+ }
+ if (quietTicks < 40) return
+ for (const observer of quietObservers) observer[0].disconnect(observer[1])
+ quietObservers = []
+ const seconds = (Date.now() - quietStarted) / 1000
+ const stopped = scenario[4] === "paused" || scenario[4] === "hidden"
+ const rows = quietMeasurements.map(function(m, index) {
+ const count = Object.keys(m.batches).length
+ const shouldMove = !stopped && (index === 1 || !scenario[3])
+ const gaps = m.positions.slice(1).map((time, i) => time - m.positions[i])
+ return { batches: count, events: m.events, outside: m.outside,
+ positions: m.positions, fractional: m.fractional,
+ valid: m.outside === 0 && count <= Math.ceil(27.5 * seconds)
+ && (shouldMove ? count > 0 : m.events === 0)
+ && !m.fractional && gaps.every(gap => gap >= 1000)
+ && m.positions.length <= Math.ceil(seconds) }
+ })
+ const reads = positionReads - initialPositionReads
+ const expectReads = scenario[3] && !stopped && scenario[4] !== "no-length"
+ const valid = quietTailValid && rows.every(row => row.valid) && (!scenario[3] || rows.length === 2)
+ && (expectReads ? reads > 0 && reads <= Math.ceil(seconds) : reads === 0)
+ if (!valid) quietFailures = quietFailures.concat([quietCase])
+ console.log("MEDIA_CADENCE", JSON.stringify({ scenario: scenario, seconds: seconds,
+ surfaces: rows, positionReads: reads, valid: valid }))
+ if (++quietCase < quietCases.length) { quietTicks = 0; return }
+ if (quietFailures.length !== 0 || !checkDiscretePosition(media)) return fail("media cadence/stop/position contract: " + JSON.stringify(quietFailures))
+ console.log("media quiescence smoke passed")
+ Qt.quit()
+ }
+
function fail(message) {
console.error("media-plugin-smoke:", message)
Qt.exit(1)
@@ -38,7 +245,7 @@ ShellRoot {
property bool shuffleSupported: true; property bool loopSupported: true
property bool canSeek: true; property bool positionSupported: true; property bool lengthSupported: true
property bool shuffle: false; property int loopState: 0; property int uniqueId: 1
- property real position: 61
+ property var position: 61
property real length: 245
}
@@ -247,6 +454,7 @@ ShellRoot {
running: true
onTriggered: {
const media = mediaLoader.item
+ if (root.quiescenceCheck) { root.checkQuiescence(media); return }
if (root.phase === 0) {
if (!media || !media.mediaService || !media.active || !media.playing
|| media.trackLabel.indexOf("Artist A") < 0 || media.panelLoaded
diff --git a/tests/media-spectrum-service-smoke.qml b/tests/media-spectrum-service-smoke.qml
index b7a00e6d..d00f345d 100644
--- a/tests/media-spectrum-service-smoke.qml
+++ b/tests/media-spectrum-service-smoke.qml
@@ -9,6 +9,12 @@ ShellRoot {
property int phase: 0
property int waits: 0
property int failedStartCount: -1
+ property int levelNotifications: 0
+
+ Connections {
+ target: spectrum
+ function onLevelsChanged() { root.levelNotifications++ }
+ }
function fail(message) {
console.error("media-spectrum-service-smoke:", mode + ": " + message)
@@ -42,7 +48,7 @@ ShellRoot {
Media.Service {
id: spectrum
shell: fakeShell
- manifest: ({id: "hancore.shibumi.media", version: "0.1.1-beta.16.4", kinds: ["service", "bar-widget"]})
+ manifest: ({id: "hancore.shibumi.media", version: "0.1.1-beta.16.5", kinds: ["service", "bar-widget"]})
}
Timer {
@@ -118,6 +124,11 @@ ShellRoot {
if (!spectrum.workerRunning || spectrum.clientCount !== 2
|| spectrum.workerStartCount !== 1)
return root.fail("multi-client single-worker lease")
+ const before = root.levelNotifications
+ const nextLine = Array(24).fill("75").join(";") + ";"
+ spectrum.applySpectrumLine(nextLine)
+ spectrum.applySpectrumLine(nextLine)
+ if (root.levelNotifications !== before + 1 || spectrum.levels.some(value => value !== 0.75)) return root.fail("changed spectrum frame or duplicate publication")
spectrum.endSpectrum(clientA)
root.phase++
} else if (root.phase === 4) {
diff --git a/tests/native-catalog-command-smoke.qml b/tests/native-catalog-command-smoke.qml
index e74cc951..513aefe5 100644
--- a/tests/native-catalog-command-smoke.qml
+++ b/tests/native-catalog-command-smoke.qml
@@ -60,7 +60,7 @@ Scope {
root.mode = "failure"; root.stage = 1; break
case 1:
if (root.catalog.refreshing) break
- root.assert(!root.catalog.ready && root.catalog.errorCode === "read-failed", "failed IPC was published")
+ root.assert(!root.catalog.ready && root.catalog.errorCode === "ipc-failed", "failed IPC was published")
root.mode = "happy"; root.stage = 2; break
case 2:
if (!root.catalog.ready || root.catalog.refreshing) break
diff --git a/tests/native-catalog-resource-regression.py b/tests/native-catalog-resource-regression.py
index d79d3428..f8cb9aee 100644
--- a/tests/native-catalog-resource-regression.py
+++ b/tests/native-catalog-resource-regression.py
@@ -91,11 +91,11 @@
b" while (Date.now() < fixtureBurnUntil) {}\n"
b" op.event = { output: output, ok: ok === true }"),)),
("HELPER CPU BURN", "catalog cycle exceeded CPU ceiling", "helper",
- ((b" text = raw.decode(\"utf-8\", errors=\"strict\")",
- b" fixture_burn_until = time.process_time() + 0.6\n"
- b" while time.process_time() < fixture_burn_until:\n"
- b" pass\n"
- b" text = raw.decode(\"utf-8\", errors=\"strict\")"),)),
+ ((b" text = raw.decode(\"utf-8\", errors=\"strict\")",
+ b" fixture_burn_until = time.process_time() + 0.6\n"
+ b" while time.process_time() < fixture_burn_until:\n"
+ b" pass\n"
+ b" text = raw.decode(\"utf-8\", errors=\"strict\")"),)),
("PSS RETENTION", "warm catalog cycles exceeded retained PSS ceiling", "catalog",
((b" property var _publication: null",
b" property var _publication: null\n property var _fixtureRetention: []"),
@@ -527,10 +527,10 @@ def main():
print("NATIVE CATALOG RESOURCE CANDIDATE POSITIVE", flush=True)
run_positive(native, base, "CANDIDATE")
- cpu_anchor = b" text = raw.decode(\"utf-8\", errors=\"strict\")"
- cpu_allowance = (b" fixture_burn_until = time.process_time() + 0.15\n"
- b" while time.process_time() < fixture_burn_until:\n"
- b" pass\n" + cpu_anchor)
+ cpu_anchor = b" text = raw.decode(\"utf-8\", errors=\"strict\")"
+ cpu_allowance = (b" fixture_burn_until = time.process_time() + 0.15\n"
+ b" while time.process_time() < fixture_burn_until:\n"
+ b" pass\n" + cpu_anchor)
original, original_hash = mutate_private(
paths["helper"], ((cpu_anchor, cpu_allowance),))
try:
diff --git a/tests/native-catalog-smoke.qml b/tests/native-catalog-smoke.qml
index 7d7ea576..2f3ccf85 100644
--- a/tests/native-catalog-smoke.qml
+++ b/tests/native-catalog-smoke.qml
@@ -121,6 +121,8 @@ Scope {
assert(!("installedPlugins" in parsed) && !Model.entry(parsed, "constructor"), "fabricated registry or inherited entry")
assert(JSON.stringify(Model.ancestry(parsed, "user.audio")) === '["user.audio","omarchy.audio"]', "ancestry failed")
assert(Model.parse("[]") !== null, "empty ready catalog refused")
+ for (var section of ["centerBegin", "centerEnd", "tray"])
+ assert(!bad(function(r) { r.barWidget.defaultSection = section }), "native section metadata refused")
assert(bad(function(_, a) { a.push(a[0]) }), "duplicate catalog admitted")
for (var flag of ["enabled", "active", "canDisable", "firstParty"])
assert(bad(function(r) { r[flag] = 1 }), "numeric Boolean admitted")
diff --git a/tests/network-plugin-smoke.qml b/tests/network-plugin-smoke.qml
index d01f07bf..e90a866d 100644
--- a/tests/network-plugin-smoke.qml
+++ b/tests/network-plugin-smoke.qml
@@ -13,6 +13,42 @@ ShellRoot {
property var dynamicNetworkService: null
property int caseBLastTrafficOwners: -1
property int caseBLastTrafficEnds: -1
+ property int demandCase: 0
+ readonly property var demandCases: [
+ { name: "full-v1", traffic: 1, clock: true },
+ { name: "icon", mode: "icon" },
+ { name: "text", mode: "text" },
+ { name: "vertical", vertical: true },
+ { name: "hidden-parent", hidden: true },
+ { name: "full-v2", v2: true, traffic: 1 },
+ { name: "restored-v1", traffic: 1, clock: true },
+ { name: "unknown-full-fallback", mode: "future-mode", traffic: 1, clock: true },
+ { name: "icon-panel", mode: "icon", panel: true },
+ { name: "native-full", native: true, traffic: 1, clock: true },
+ { name: "native-icon", native: true, mode: "icon" },
+ { name: "native-text", native: true, mode: "text" },
+ { name: "native-vertical", native: true, vertical: true },
+ { name: "native-hidden", native: true, hidden: true },
+ { name: "native-no-wire", native: true, wired: false, clock: true },
+ { name: "native-restored", native: true, traffic: 1, clock: true },
+ { name: "native-icon-panel", native: true, mode: "icon", panel: true }
+ ]
+
+ function configureDemandCase(widget) {
+ const row = demandCases[demandCase]
+ widget.close()
+ demandNativeService.wiredConnected = row.wired !== false
+ widget.networkServiceOverride = row.native
+ ? demandNativeService : sharedNetworkService
+ fakeBar.vertical = row.vertical === true
+ firstLoader.visible = row.hidden !== true
+ fakeBar.visualTokens = Object.assign({}, fakeBar.visualTokens, {
+ v2Shell: row.v2 === true
+ })
+ widget.settings = appearanceSettings(row.mode || "full")
+ if (row.panel) widget.open()
+ ticks = 0
+ }
function fail(message) {
console.error("network-widget-smoke:", message)
@@ -38,6 +74,11 @@ ShellRoot {
Fixtures.NetworkTestService { id: replacementNetworkService }
Fixtures.NetworkTestService { id: unavailableService; ready: false }
Fixtures.NetworkTestService { id: vanishedCaseAService; kind: "ethernet" }
+ Fixtures.NetworkTestService {
+ id: demandNativeService
+ kind: "ethernet"
+ property bool wiredConnected: true
+ }
Component {
id: dynamicNetworkServiceComponent
@@ -107,8 +148,13 @@ ShellRoot {
function targetBelongsToWindow(_target, _window) { return true }
}
+ // A parentless, windowless QQuickItem cannot regain effective visibility
+ // after hiding. Keep a visible fixture parent for the hide/show controls.
+ Item { id: presentationParent }
+
Loader {
id: firstLoader
+ parent: presentationParent
active: true
sourceComponent: Component {
Network.BarWidget {
@@ -323,6 +369,8 @@ ShellRoot {
|| sharedNetworkService.refreshCount !== 1)
return root.fail("right-click scanner request")
sharedNetworkService.trafficFailuresRemaining = 1
+ // Both original lease/cleanup controls use actual traffic presentations.
+ first.settings = root.appearanceSettings("full")
sharedNetworkService.kind = "ethernet"
sharedNetworkService.label = "enp1s0"
sharedNetworkService.downloadRate = 1536
@@ -463,6 +511,51 @@ ShellRoot {
return root.fail("destroyed failed-acquire service was not cleared")
console.log("network failed-acquire destruction regression passed")
vanishedCaseBLoader.active = false
+ sharedNetworkService.kind = "ethernet"
+ firstLoader.active = true
+ root.phase = 80
+ root.ticks = 0
+ return
+ }
+
+ if (root.phase === 80) {
+ if (!first || !first.networkReady) return
+ root.configureDemandCase(first)
+ root.phase = 81
+ return
+ }
+
+ if (root.phase === 81) {
+ if (root.ticks < 3) return
+ const row = root.demandCases[root.demandCase]
+ const historyTimers = []
+ for (const resource of first.resources) {
+ if (resource.interval === 2000 && resource.repeat === true)
+ historyTimers.push(resource)
+ }
+ const observed = [
+ sharedNetworkService.trafficConsumerCount + demandNativeService.trafficConsumerCount,
+ first.trafficService === first.networkService,
+ historyTimers.length === 1 && historyTimers[0].running,
+ sharedNetworkService.sessionCount + demandNativeService.sessionCount,
+ first.panelLoaded
+ ]
+ const expected = [row.traffic || 0, !!row.traffic,
+ row.clock === true, row.panel ? 1 : 0, row.panel === true]
+ console.log("NETWORK_TRAFFIC_DEMAND", row.name,
+ JSON.stringify({ observed: observed, expected: expected,
+ visible: first.visible, parentVisible: firstLoader.visible,
+ ready: first.networkReady, vertical: fakeBar.vertical,
+ displayMode: first.displayMode, demand: first.trafficPresentationVisible }))
+ if (JSON.stringify(observed) !== JSON.stringify(expected))
+ return root.fail("traffic demand/history/session mismatch: " + row.name)
+ root.demandCase++
+ if (root.demandCase < root.demandCases.length) {
+ root.configureDemandCase(first)
+ return
+ }
+ first.close()
+ firstLoader.active = false
console.log("network plugin smoke passed")
Qt.quit()
stop()
diff --git a/tests/panel-window-geometry-regression.sh b/tests/panel-window-geometry-regression.sh
index 56f817e9..2a05674c 100755
--- a/tests/panel-window-geometry-regression.sh
+++ b/tests/panel-window-geometry-regression.sh
@@ -23,6 +23,10 @@ if [[ $SHIBUMI_OMARCHY_SOURCE_REVISION == 50d687a1f27063513cf42e6f33c3a4fcb17c24
keyboard_panel_sha=e509278a51a7e9b45fa3a6fa630bedcd671a0f34202bfb7413a5b6a2f88d7e22
elif [[ $SHIBUMI_OMARCHY_SOURCE_REVISION == 4c1b4cb3a94447aff39af98e2fcc0490a9fb32e7 ]]; then
keyboard_panel_sha=b7243c0397608a0a2a2dc2a3202a88c1e4b4b35d8da37895a1db8993422ce36f
+elif [[ $SHIBUMI_OMARCHY_SOURCE_REVISION == 246f33a2fef66411c990860d88e0823ae83987fc ]]; then
+ keyboard_panel_sha=7a5dc2284b1990b2bf53a806221458c6a64ea538cb92b4787dca846c7cc33a54
+elif [[ $SHIBUMI_OMARCHY_SOURCE_REVISION == 94a8fbc2d67eec5673c7f16b8c5e8a9c8452ffdc ]]; then
+ keyboard_panel_sha=c8b3b0fd52d8a79469675e925ff851e1dd0588eb1eeb90bd4df004a4c7d72a0a
fi
[[ $(sha256sum "$keyboard_panel" | awk '{print $1}') == "$keyboard_panel_sha" ]] \
|| fail 'KeyboardPanel.qml does not match pinned Omarchy source'
@@ -124,7 +128,7 @@ base="$tmpdir/base"
mkdir -p "$base/fixtures" "$base/shared" "$base/hancore.shibumi.bar/core"
cp -a -- "$OMARCHY_PATH/shell/Commons" "$base/"
cp -a -- "$OMARCHY_PATH/shell/Ui" "$base/"
-cp -- "$repo_root/hancore.shibumi.bar/core/WidgetSlot.qml" \
+cp -- "$repo_root/hancore.shibumi.bar/core/"{WidgetSlot.qml,GroupRegistry.js} \
"$base/hancore.shibumi.bar/core/"
cp -- "$repo_root/tests/fixtures/FixturePanelWindow.qml" \
"$repo_root/tests/fixtures/ProviderBoundHostedPanelWidget.qml" "$base/fixtures/"
diff --git a/tests/plugin-update-service-smoke.qml b/tests/plugin-update-service-smoke.qml
index 66225e6f..a8e8efa5 100644
--- a/tests/plugin-update-service-smoke.qml
+++ b/tests/plugin-update-service-smoke.qml
@@ -209,7 +209,7 @@ ShellRoot {
if (service.available || service.check(true))
return root.fail("scoped update worker started before manifest admission")
service.manifest = {id: "hancore.shibumi.control-center",
- version: "0.1.1-beta.16.4", kinds: ["service", "bar-widget"]}
+ version: "0.1.1-beta.16.5", kinds: ["service", "bar-widget"]}
root.phase++
root.ticks = 0
return
diff --git a/tests/power-deferred-smoke.qml b/tests/power-deferred-smoke.qml
index 36b9eabd..4283cf87 100644
--- a/tests/power-deferred-smoke.qml
+++ b/tests/power-deferred-smoke.qml
@@ -57,7 +57,7 @@ ShellRoot {
PowerState.Service {
id: power
shell: host
- manifest: root.allowed ? ({id: "hancore.shibumi.power-state", version: "0.1.1-beta.16.4", kinds: ["service"]}) : null
+ manifest: root.allowed ? ({id: "hancore.shibumi.power-state", version: "0.1.1-beta.16.5", kinds: ["service"]}) : null
batterySnapshotOverride: battery
commandOverrides: ({
profiles: ["/usr/bin/python3", "-I", root.helper, root.directory, "profiles"],
diff --git a/tests/power-runtime-smoke.qml b/tests/power-runtime-smoke.qml
index a20dbb7b..be4ad386 100644
--- a/tests/power-runtime-smoke.qml
+++ b/tests/power-runtime-smoke.qml
@@ -24,7 +24,7 @@ ShellRoot {
readonly property var power: serviceLoader.item
readonly property string fixturePath: Quickshell.env("SHIBUMI_POWER_SCOPE_DIR")
readonly property string helper: decodeURIComponent(String(Qt.resolvedUrl("fixtures/power-runtime-helper.py")).substring(7))
- readonly property var validManifest: ({ id: "hancore.shibumi.power-state", version: "0.1.1-beta.16.4", kinds: ["service"], entryPoints: { service: "Service.qml" } })
+ readonly property var validManifest: ({ id: "hancore.shibumi.power-state", version: "0.1.1-beta.16.5", kinds: ["service"], entryPoints: { service: "Service.qml" } })
readonly property var fakeCommands: ({
profiles: ["/usr/bin/python3", "-I", helper, fixturePath, "profiles"],
activeProfile: ["/usr/bin/python3", "-I", helper, fixturePath, "activeProfile"],
@@ -55,10 +55,10 @@ ShellRoot {
}
SuiteRuntime.Provider {
pluginId: "hancore.shibumi.state"
- implementationVersion: "0.1.1-beta.16.4"
+ implementationVersion: "0.1.1-beta.16.5"
owner: state
host: root.stateEnabled ? stateHost : null
- manifest: ({ id: "hancore.shibumi.state", version: "0.1.1-beta.16.4", kinds: ["service"] })
+ manifest: ({ id: "hancore.shibumi.state", version: "0.1.1-beta.16.5", kinds: ["service"] })
}
QtObject {
id: battery
diff --git a/tests/power-service-runtime-smoke.qml b/tests/power-service-runtime-smoke.qml
index d6e64b9d..6cab4eaa 100644
--- a/tests/power-service-runtime-smoke.qml
+++ b/tests/power-service-runtime-smoke.qml
@@ -14,7 +14,7 @@ ShellRoot {
PowerState.Service {
id: power
shell: scopedHost
- manifest: ({ id: "hancore.shibumi.power-state", version: "0.1.1-beta.16.4",
+ manifest: ({ id: "hancore.shibumi.power-state", version: "0.1.1-beta.16.5",
kinds: ["service"], entryPoints: { service: "Service.qml" } })
batterySnapshotOverride: ({ ready: false })
commandOverrides: ({
diff --git a/tests/quattro-contract-regression.sh b/tests/quattro-contract-regression.sh
index ab694196..bbe05a0e 100755
--- a/tests/quattro-contract-regression.sh
+++ b/tests/quattro-contract-regression.sh
@@ -104,7 +104,7 @@ for needle in \
done
case $SHIBUMI_OMARCHY_SOURCE_REVISION in
- 0534987009061cbe2dacdde4ad564092ab698d12|c668141e9c42b13c80c9ca4ea108e11708c5e8a5|50d687a1f27063513cf42e6f33c3a4fcb17c24ed|e71a816e9032f040225e086c7256f73e8080ae45|4c1b4cb3a94447aff39af98e2fcc0490a9fb32e7)
+ 0534987009061cbe2dacdde4ad564092ab698d12|c668141e9c42b13c80c9ca4ea108e11708c5e8a5|94a8fbc2d67eec5673c7f16b8c5e8a9c8452ffdc|50d687a1f27063513cf42e6f33c3a4fcb17c24ed|e71a816e9032f040225e086c7256f73e8080ae45|4c1b4cb3a94447aff39af98e2fcc0490a9fb32e7|246f33a2fef66411c990860d88e0823ae83987fc)
for needle in \
'target.shell = shell.pluginShellFor(manifest)' \
'target.manifest = shell.publicPluginManifest(manifest)' \
@@ -187,5 +187,6 @@ for package_name in omarchy omarchy-dev; do
fi
done
-printf 'Quattro contract regression passed (%s, %d plugins)\n' \
+printf 'Quattro contract regression passed (source=%s revision=%s; local package=%s; %d plugins)\n' \
+ "$SHIBUMI_OMARCHY_BASELINE_ID" "$SHIBUMI_OMARCHY_SOURCE_REVISION" \
"$omarchy_version" "${#plugin_ids[@]}"
diff --git a/tests/quick-access-plugin-regression.sh b/tests/quick-access-plugin-regression.sh
index 1638ff5c..9f339325 100755
--- a/tests/quick-access-plugin-regression.sh
+++ b/tests/quick-access-plugin-regression.sh
@@ -50,6 +50,7 @@ EOF
cp -a -- "$omarchy_path/shell/Ui" "$fixture_root/Ui"
install -m 0644 "$repo_root/hancore.shibumi.bar/core/WidgetSlot.qml" \
"$fixture_root/barcore/WidgetSlot.qml"
+ cp "$repo_root/hancore.shibumi.bar/core/GroupRegistry.js" "$fixture_root/barcore/"
# The fixture exercises the production overlay body without creating a real
# layer surface. Only the window-shell-only properties are removed.
python3 -I -S - "$fixture_root/quickaccess/PickerOverlay.qml" <<'PY'
diff --git a/tests/reactor-plugin-smoke.qml b/tests/reactor-plugin-smoke.qml
index 24f39f12..02decf99 100644
--- a/tests/reactor-plugin-smoke.qml
+++ b/tests/reactor-plugin-smoke.qml
@@ -76,7 +76,7 @@ ShellRoot {
Audio.Service {
id: fakeAudio
shell: fakeShell
- manifest: ({id: "hancore.shibumi.audio", version: "0.1.1-beta.16.4", kinds: ["service"]})
+ manifest: ({id: "hancore.shibumi.audio", version: "0.1.1-beta.16.5", kinds: ["service"]})
nativeBackendEnabled: false
}
@@ -115,7 +115,7 @@ ShellRoot {
Reactor.Service {
id: reactorService
- manifest: ({id: "hancore.shibumi.reactor", version: "0.1.1-beta.16.4", kinds: ["service"]})
+ manifest: ({id: "hancore.shibumi.reactor", version: "0.1.1-beta.16.5", kinds: ["service"]})
shell: fakeShell
runtimeProbesEnabled: false
}
diff --git a/tests/reactor-runtime-smoke.qml b/tests/reactor-runtime-smoke.qml
index 0cf9114e..c7539665 100644
--- a/tests/reactor-runtime-smoke.qml
+++ b/tests/reactor-runtime-smoke.qml
@@ -16,7 +16,7 @@ ShellRoot {
property int beforeLoss: 0
property var cachedMode: null
property var cachedTest: null
- readonly property var manifest: ({id: "hancore.shibumi.reactor", version: "0.1.1-beta.16.4", kinds: ["service"]})
+ readonly property var manifest: ({id: "hancore.shibumi.reactor", version: "0.1.1-beta.16.5", kinds: ["service"]})
function check(value, message) {
if (value) return
console.error("reactor-runtime:", message)
@@ -44,14 +44,14 @@ ShellRoot {
property bool outputMuted: true
}
SuiteRuntime.Provider {
- pluginId: "hancore.shibumi.state"; implementationVersion: "0.1.1-beta.16.4"
+ pluginId: "hancore.shibumi.state"; implementationVersion: "0.1.1-beta.16.5"
owner: state; host: root.stateEnabled ? stateHost : null
- manifest: ({id: "hancore.shibumi.state", version: "0.1.1-beta.16.4", kinds: ["service"]})
+ manifest: ({id: "hancore.shibumi.state", version: "0.1.1-beta.16.5", kinds: ["service"]})
}
SuiteRuntime.Provider {
- pluginId: "hancore.shibumi.audio"; implementationVersion: "0.1.1-beta.16.4"
+ pluginId: "hancore.shibumi.audio"; implementationVersion: "0.1.1-beta.16.5"
owner: audio; host: root.audioEnabled ? audioHost : null
- manifest: ({id: "hancore.shibumi.audio", version: "0.1.1-beta.16.4", kinds: ["service"]})
+ manifest: ({id: "hancore.shibumi.audio", version: "0.1.1-beta.16.5", kinds: ["service"]})
}
Reactor.Service { id: service; runtimeProbesEnabled: false }
Reactor.Service { id: duplicate; runtimeProbesEnabled: false }
diff --git a/tests/scoped-loader-admission-regression.sh b/tests/scoped-loader-admission-regression.sh
index 11e4320f..b16044a1 100755
--- a/tests/scoped-loader-admission-regression.sh
+++ b/tests/scoped-loader-admission-regression.sh
@@ -30,7 +30,7 @@ prepare_fixture() {
"$fixture_dir/hancore.shibumi.bar/services"
chmod 700 "$fixture_dir/runtime"
cp -a "$OMARCHY_PATH/shell/Commons" "$fixture_dir/"
- cp "$repo_root/hancore.shibumi.bar/core/WidgetSlot.qml" \
+ cp "$repo_root/hancore.shibumi.bar/core/"{WidgetSlot.qml,GroupRegistry.js} \
"$fixture_dir/hancore.shibumi.bar/core/"
cp "$repo_root/hancore.shibumi.bar/services/HostWidgetResolver.qml" \
"$fixture_dir/hancore.shibumi.bar/services/"
diff --git a/tests/scoped-state-regression.qml b/tests/scoped-state-regression.qml
index 90fb32b6..243b0d63 100644
--- a/tests/scoped-state-regression.qml
+++ b/tests/scoped-state-regression.qml
@@ -36,7 +36,7 @@ ShellRoot {
id: state
shell: host
omarchyPath: Quickshell.env("OMARCHY_PATH")
- manifest: ({ id: "hancore.shibumi.state", version: "0.1.1-beta.16.4",
+ manifest: ({ id: "hancore.shibumi.state", version: "0.1.1-beta.16.5",
kinds: ["service"], __sourceDir: Quickshell.env("DECOY_MARKER_DIR") })
}
BarProbe.Marker {
diff --git a/tests/shared-runtime-regression.qml b/tests/shared-runtime-regression.qml
index c5c6f92d..fe366056 100644
--- a/tests/shared-runtime-regression.qml
+++ b/tests/shared-runtime-regression.qml
@@ -54,7 +54,7 @@ ShellRoot {
id: state
shell: stateApi
omarchyPath: Quickshell.env("OMARCHY_PATH")
- manifest: ({id: "hancore.shibumi.state", version: "0.1.1-beta.16.4", kinds: ["service"]})
+ manifest: ({id: "hancore.shibumi.state", version: "0.1.1-beta.16.5", kinds: ["service"]})
}
BarProbe.Marker { id: markerBar }
Item {
@@ -66,10 +66,10 @@ ShellRoot {
Shared.Provider {
id: barProvider
pluginId: "hancore.shibumi.bar"
- implementationVersion: "0.1.1-beta.16.4"
+ implementationVersion: "0.1.1-beta.16.5"
owner: barOwner
host: barOwner.injectedHost
- manifest: ({id: "hancore.shibumi.bar", version: "0.1.1-beta.16.4", kinds: ["bar"]})
+ manifest: ({id: "hancore.shibumi.bar", version: "0.1.1-beta.16.5", kinds: ["bar"]})
}
}
Shared.HostShell { id: client; host: stateApi }
@@ -82,7 +82,7 @@ ShellRoot {
property int value: 0
Shared.Provider {
pluginId: "hancore.shibumi.audio"
- implementationVersion: "0.1.1-beta.16.4"
+ implementationVersion: "0.1.1-beta.16.5"
owner: peer
host: peer.injectedHost
manifest: peer.injectedManifest
@@ -152,7 +152,7 @@ ShellRoot {
check(left.state === null && right.state === null && !state.ready && !state.writePending,
"version drift retained State authority or queued intent")
check(!state.setGroupSetting("G4", "compact", true), "revoked State accepted intent")
- state.manifest = {id: "hancore.shibumi.state", version: "0.1.1-beta.16.4", kinds: ["service"]}
+ state.manifest = {id: "hancore.shibumi.state", version: "0.1.1-beta.16.5", kinds: ["service"]}
check(left.state === state && Shared.Runtime._selected("hancore.shibumi.state") !== oldLease,
"State did not register a new lifetime")
Shared.Runtime.unregisterProvider(oldLease)
@@ -161,10 +161,10 @@ ShellRoot {
check(first !== null && client.serviceFor("hancore.shibumi.audio") === null,
"parentless service published before injection")
first.injectedHost = audioApi
- first.injectedManifest = {id: "hancore.shibumi.audio", version: "0.1.1-beta.16.4", kinds: ["service"]}
+ first.injectedManifest = {id: "hancore.shibumi.audio", version: "0.1.1-beta.16.5", kinds: ["service"]}
check(client.serviceFor("hancore.shibumi.audio") === first, "provider registration failed")
second = serviceFactory.createObject(null, {injectedHost: audioApi,
- injectedManifest: {id: "hancore.shibumi.audio", version: "0.1.1-beta.16.4", kinds: ["service"]}})
+ injectedManifest: {id: "hancore.shibumi.audio", version: "0.1.1-beta.16.5", kinds: ["service"]}})
check(client.serviceFor("hancore.shibumi.audio") === null,
"duplicate provider was last-writer-wins: second=" + JSON.stringify({
manifest: second.injectedManifest, kindsArray: Array.isArray(second.injectedManifest.kinds),
@@ -192,7 +192,7 @@ ShellRoot {
first.injectedHost = audioApi
check(client.serviceFor("hancore.shibumi.audio") === first, "scope recovery stayed unavailable")
let getterReached = false
- const malformed = {version: "0.1.1-beta.16.4", kinds: ["service"]}
+ const malformed = {version: "0.1.1-beta.16.5", kinds: ["service"]}
Object.defineProperty(malformed, "id", {get: function() {
getterReached = true
throw new Error("controlled malformed metadata getter")
diff --git a/tests/shibumi-suite-quattro-runtime.sh b/tests/shibumi-suite-quattro-runtime.sh
index dc5810c4..72d87b13 100755
--- a/tests/shibumi-suite-quattro-runtime.sh
+++ b/tests/shibumi-suite-quattro-runtime.sh
@@ -33,7 +33,7 @@ cleanup() {
(( status == 0 )) || failed=1
if [[ -n $stop_shells && -x $stop_shells ]]; then
- timeout --kill-after=1s 8s env \
+ timeout --kill-after=1s 30s env \
SHIBUMI_TEST_SERVICE_FILE="$service_file" \
SHIBUMI_TEST_CLEANUP_LOG="$cleanup_log" \
SHIBUMI_TEST_SERVICE_PREFIX="$service_prefix" "$stop_shells" \
@@ -45,7 +45,7 @@ cleanup() {
printf 'Runtime fixture did not exercise the cleanup KILL fallback\n' >&2
cleanup_error=1
fi
- if (( SECONDS - cleanup_started > 9 )); then
+ if (( SECONDS - cleanup_started > 31 )); then
printf 'Runtime fixture cleanup exceeded its wall-clock budget\n' >&2
cleanup_error=1
fi
@@ -273,7 +273,10 @@ for unit in "${units[@]}"; do
timeout --kill-after=0.2s 0.8s systemctl --user --machine=@.host stop "$unit" \
>/dev/null 2>&1 || true
done
-for _ in {1..20}; do
+# Each observation can inspect 33 generations. Count elapsed time, not
+# nominal sleeps: slow systemctl calls must not multiply twenty full scans.
+term_deadline=$(( SECONDS + 2 ))
+while (( SECONDS < term_deadline )); do
all_inactive && exit 0
result=$?
(( result == 1 )) || exit 1
@@ -289,7 +292,10 @@ for unit in "${units[@]}"; do
(( result == 1 )) || exit 1
fi
done
-for _ in {1..20}; do
+# KILL delivery is not proof that the service manager has settled. Keep
+# observing, within the caller's independent 30-second total drain bound.
+kill_deadline=$(( SECONDS + 10 ))
+while (( SECONDS < kill_deadline )); do
all_inactive && exit 0
result=$?
(( result == 1 )) || exit 1
@@ -350,7 +356,7 @@ cat >"$fixture_omarchy/bin/omarchy-restart-shell" <<'RESTART'
#!/usr/bin/env bash
set -euo pipefail
-timeout --kill-after=1s 8s "$SHIBUMI_TEST_STOP_SHELLS"
+timeout --kill-after=1s 30s "$SHIBUMI_TEST_STOP_SHELLS"
"$SHIBUMI_TEST_START_SHELL" >/dev/null
for _ in {1..100}; do
if [[ $("$OMARCHY_PATH/bin/omarchy-shell" shell ping 2>/dev/null || true) == ok ]]; then
@@ -648,7 +654,7 @@ run_keep_settings_cycle() {
}
drain_fixture_shells() {
- timeout --kill-after=1s 8s env \
+ timeout --kill-after=1s 30s env \
SHIBUMI_TEST_SERVICE_FILE="$service_file" \
SHIBUMI_TEST_CLEANUP_LOG="$cleanup_log" \
SHIBUMI_TEST_SERVICE_PREFIX="$service_prefix" "$stop_shells"
diff --git a/tests/state-service-smoke.qml b/tests/state-service-smoke.qml
index 84bf53b7..f5f5fb95 100644
--- a/tests/state-service-smoke.qml
+++ b/tests/state-service-smoke.qml
@@ -55,7 +55,7 @@ ShellRoot {
id: state
omarchyPath: Quickshell.env("OMARCHY_PATH")
shell: fakeShell
- manifest: ({id: "hancore.shibumi.state", version: "0.1.1-beta.16.4", kinds: ["service"]})
+ manifest: ({id: "hancore.shibumi.state", version: "0.1.1-beta.16.5", kinds: ["service"]})
}
Component.onCompleted: steps = [
function() {
diff --git a/tests/status-plugin-regression.sh b/tests/status-plugin-regression.sh
index 41037beb..ebed5780 100755
--- a/tests/status-plugin-regression.sh
+++ b/tests/status-plugin-regression.sh
@@ -28,6 +28,8 @@ shibumi_stage_suite_runtime "$repo_root" "$tmpdir"
printf '{"suiteId":"hancore.shibumi","suitePayloadDigest":"%064d"}\n' 0 \
> "$tmpdir/hancore.shibumi.state/.shibumi-managed.json"
mkdir -p "$tmpdir/hancore.shibumi.bar"
+install -Dm0644 "$repo_root/hancore.shibumi.bar/core/GroupRegistry.js" \
+ "$tmpdir/hancore.shibumi.bar/core/GroupRegistry.js"
cp -a -- "$repo_root/hancore.shibumi.bar/services" \
"$tmpdir/hancore.shibumi.bar/services"
cp -a -- "$repo_root/hancore.shibumi.status" "$tmpdir/status"
diff --git a/tests/status-plugin-smoke.qml b/tests/status-plugin-smoke.qml
index 892625a4..b1e71ec4 100644
--- a/tests/status-plugin-smoke.qml
+++ b/tests/status-plugin-smoke.qml
@@ -204,11 +204,11 @@ ShellRoot {
SuiteRuntime.Provider {
id: leasedBarProvider
pluginId: "hancore.shibumi.bar"
- implementationVersion: "0.1.1-beta.16.4"
+ implementationVersion: "0.1.1-beta.16.5"
owner: leasedBarOwner
host: leasedBarOwner.providerHost
manifest: ({ id: "hancore.shibumi.bar",
- version: "0.1.1-beta.16.4", kinds: ["bar"] })
+ version: "0.1.1-beta.16.5", kinds: ["bar"] })
}
}
@@ -226,7 +226,7 @@ ShellRoot {
Status.Service {
shell: rawScopedStatusShell
manifest: ({ id: "hancore.shibumi.status",
- version: "0.1.1-beta.16.4", kinds: ["service"] })
+ version: "0.1.1-beta.16.5", kinds: ["service"] })
actionRunner: actionRecorder
runtimeProbesEnabled: false
}
diff --git a/tests/telemetry-plugins-smoke.qml b/tests/telemetry-plugins-smoke.qml
index 23d2ac93..f40ac4b6 100644
--- a/tests/telemetry-plugins-smoke.qml
+++ b/tests/telemetry-plugins-smoke.qml
@@ -27,7 +27,7 @@ ShellRoot {
Telemetry.Service {
id: telemetryService
shell: fakeShell
- manifest: ({id: "hancore.shibumi.telemetry", version: "0.1.1-beta.16.4", kinds: ["service"]})
+ manifest: ({id: "hancore.shibumi.telemetry", version: "0.1.1-beta.16.5", kinds: ["service"]})
// Keep this fixture isolated from machine-specific hwmon inventory.
thermalProbeEnabled: false
}
@@ -35,7 +35,7 @@ ShellRoot {
Cpu.Service {
id: cpuService
shell: fakeShell
- manifest: ({id: "hancore.shibumi.cpu", version: "0.1.1-beta.16.4", kinds: ["service"]})
+ manifest: ({id: "hancore.shibumi.cpu", version: "0.1.1-beta.16.5", kinds: ["service"]})
gpuProbeEnabled: false
}
diff --git a/tests/telemetry-runtime-smoke.qml b/tests/telemetry-runtime-smoke.qml
index dcbc4e69..52c0cbc0 100644
--- a/tests/telemetry-runtime-smoke.qml
+++ b/tests/telemetry-runtime-smoke.qml
@@ -22,7 +22,7 @@ ShellRoot {
Qt.exit(1)
throw new Error(message)
}
- function manifest(id) { return {id: id, version: "0.1.1-beta.16.4", kinds: ["service"]} }
+ function manifest(id) { return {id: id, version: "0.1.1-beta.16.5", kinds: ["service"]} }
component ScopedHost: QtObject {
property string pluginId: ""
function serviceFor(id) { root.check(false, "raw scoped lookup: " + id); return null }
@@ -43,7 +43,7 @@ ShellRoot {
}
SuiteRuntime.Provider {
pluginId: "hancore.shibumi.state"
- implementationVersion: "0.1.1-beta.16.4"
+ implementationVersion: "0.1.1-beta.16.5"
host: stateHost
owner: state
manifest: root.manifest("hancore.shibumi.state")
diff --git a/tests/test_inc013_drain_contract.py b/tests/test_inc013_drain_contract.py
index 6da8ad41..1512d3d5 100644
--- a/tests/test_inc013_drain_contract.py
+++ b/tests/test_inc013_drain_contract.py
@@ -598,6 +598,19 @@ def test_all_finite_populations_converge_without_touching_foreign_instances(self
"an empty matching registry must not issue a kill",
)
+ def test_runtime_accepts_shutdown_longer_than_the_old_six_second_budget(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ clock = SyntheticClock()
+ scenario = self.make_scenario(
+ tmp, matching_count=1, clock=clock, kill_latency=8.0,
+ )
+ foreign_before = tuple(scenario.foreign)
+ RuntimeAdapter.run(scenario, clock)
+ self.assertEqual(
+ (scenario.matching, tuple(scenario.foreign), 8 <= clock.now <= 50),
+ ([], foreign_before, True),
+ )
+
def test_supported_shibumi_bar_is_prepared_before_kill(self):
for adapter in ADAPTERS:
with self.subTest(path=adapter.name), tempfile.TemporaryDirectory() as tmp:
diff --git a/tests/test_lifecycle_admission.py b/tests/test_lifecycle_admission.py
index a87a8aec..ea94e8a4 100644
--- a/tests/test_lifecycle_admission.py
+++ b/tests/test_lifecycle_admission.py
@@ -606,11 +606,22 @@ def test_status_rejects_unsafe_state_without_diagnostic_or_leak(self) -> None:
),
"oversized": oversized,
}
- expected = (
+ refusal = (
"shibumi-suite: status admission refused; malformed, unsafe, "
"ambiguous, or unsupported state was not inspected further; "
"no recovery or mutation was attempted\n"
)
+
+ def expected_refusal() -> str:
+ # Status forwards the preflight reason, not the refused document.
+ # Keep exact-output and all existing redaction/read-only assertions.
+ try:
+ preflight_lifecycle_state(self.paths, self.suite)
+ except AdmissionError as error:
+ reason = str(error).replace(str(self.paths.state_dir), "")
+ return refusal + f"shibumi-suite: refusal reason: {reason}\n"
+ return refusal
+
for name, payload in cases.items():
with self.subTest(name=name):
state_path.write_bytes(payload)
@@ -618,6 +629,7 @@ def test_status_rejects_unsafe_state_without_diagnostic_or_leak(self) -> None:
stderr = io.StringIO()
with redirect_stdout(stdout), redirect_stderr(stderr):
result = command_status_with_admission(self.suite, self.paths)
+ expected = expected_refusal()
self.assertEqual(result, 1)
self.assertEqual(stdout.getvalue(), "")
self.assertEqual(stderr.getvalue(), expected)
@@ -628,6 +640,7 @@ def test_status_rejects_unsafe_state_without_diagnostic_or_leak(self) -> None:
state_path.unlink()
state_path.symlink_to(external)
+ expected = expected_refusal()
stdout = io.StringIO()
stderr = io.StringIO()
with redirect_stdout(stdout), redirect_stderr(stderr):
@@ -1620,6 +1633,10 @@ def test_exact_live_payload_and_marker_schema_are_required(self) -> None:
)
with self.assertRaisesRegex(AdmissionError, "marker or payload"):
preflight_lifecycle_state(self.paths, self.suite)
+ refused = io.StringIO()
+ with redirect_stderr(refused), patch("shibumi_suite.cli.command_status", side_effect=AssertionError("refused state inspected again")):
+ result = command_status_with_admission(self.suite, self.paths)
+ self.assertEqual((result, f"Locally modified: {first}" in refused.getvalue()), (1, True))
self.assertEqual(
preflight_lifecycle_state(
self.paths, self.suite, allow_payload_repair=True
diff --git a/tests/test_package_release.py b/tests/test_package_release.py
index 7b252bab..918c3904 100644
--- a/tests/test_package_release.py
+++ b/tests/test_package_release.py
@@ -253,10 +253,20 @@ def test_lifecycle_contract_pins_exact_release_identities(self) -> None:
},
"public-beta.16.4": {
"suiteVersion": "0.1.1-beta.16.4",
- "sourceRevisions": ["package:0.1.1-beta.16.4"],
+ "sourceRevisions": [
+ "64b9369559b0de2ffdc1faf1c563326c8173035c",
+ "aec6c0a3266d71f6a5f9cfa17562d8bec0ce9b17",
+ "package:0.1.1-beta.16.4",
+ ],
"settingsStorageVersion": 1,
"payloadDigest": "bc60b476803f72d5604e592647db7f50c1f00723b986ae87e7730cba79779d34",
},
+ "public-beta.16.5": {
+ "suiteVersion": "0.1.1-beta.16.5",
+ "sourceRevisions": ["package:0.1.1-beta.16.5"],
+ "settingsStorageVersion": 1,
+ "payloadDigest": "a090b82858c554f02d44030755f779b2827ae51e55bca750b7163d5b330472dd",
+ },
}
self.assertEqual(set(states), set(expected))
for identity_id, pinned in expected.items():
diff --git a/tests/test_shibumi_manager.py b/tests/test_shibumi_manager.py
index d2769bd6..94e3aac2 100644
--- a/tests/test_shibumi_manager.py
+++ b/tests/test_shibumi_manager.py
@@ -752,6 +752,19 @@ def test_switch_appends_configured_installed_widget_to_target_profile(self) -> N
self.assertTrue(rollback["failure"]["profileChanged"])
self.assertEqual(rollback["retry"]["phase"], "complete")
+ def test_stock_widgets_do_not_enter_saved_shibumi_profile(self) -> None:
+ evidence = self._saved_profile_switch_evidence(
+ "thirdparty.stock-only", installed=True, plugin_enabled=True,
+ target="shibumi", target_contains_plugin=False,
+ source_entry={"id": "thirdparty.stock-only", "shibumiModule": True,
+ "opaque": {"keep": [False, "Hej"]}},
+ plugin_kinds=["bar-widget", "service"],
+ )
+ expected = self.module["initial_layout"]("shibumi", self.defaults, self.state)
+ ids = sorted(entry["id"] for entries in expected.values() for entry in entries)
+ observed = (evidence["error"], evidence["phase"], evidence["savedProfiles"]["layouts"]["shibumi"], evidence["layout"])
+ self.assertEqual(observed, ("", "complete", expected, ids))
+
def test_switch_does_not_append_disabled_mixed_kind_plugin(self) -> None:
plugin_id = "thirdparty.disabled-service-widget"
source_entry = {"id": plugin_id, "inlineSettings": {"interval": 9}}
@@ -1173,7 +1186,21 @@ def test_activation_reports_missing_managed_plugin_only_as_managed(self) -> None
self.assertIn("missing-from-registry=[]", detail)
self.assertIn("disabled=[]", detail)
- def test_legacy_shibumi_snapshot_removes_merged_stock_layout(self) -> None:
+ def test_snapshot_keeps_native_placement_absent_from_old_profile(self) -> None:
+ configured = copy.deepcopy(self.active)
+ configured["bar"]["layout"]["right"].append({
+ "id": "omarchy.elsewhen", "shibumiModule": True,
+ "zones": "Berlin|Europe/Berlin", "opaque": {"keep": [False, "Hej"]},
+ })
+ for trusted in (None, self.active["bar"]["layout"]):
+ with self.subTest(legacy=trusted is None):
+ actual = self.module["snapshot_layout"](
+ configured, "shibumi", self.state,
+ legacy=trusted is None, trusted_layout=trusted,
+ )
+ self.assertEqual(actual, configured["bar"]["layout"])
+
+ def test_legacy_shibumi_snapshot_preserves_current_native_layout(self) -> None:
snapshot = self.module["snapshot_layout"](
self.active, "shibumi", self.state, legacy=True
)
@@ -1185,9 +1212,9 @@ def test_legacy_shibumi_snapshot_removes_merged_stock_layout(self) -> None:
self.assertIn("hancore.shibumi.control-center", ids)
self.assertIn("hancore.shibumi.widget", ids)
self.assertIn("user.widget", ids)
- self.assertNotIn("omarchy.weather", ids)
+ self.assertEqual(snapshot, self.active["bar"]["layout"])
- def test_saved_shibumi_profile_rejects_new_stock_contamination(self) -> None:
+ def test_saved_shibumi_profile_captures_current_native_placements(self) -> None:
config = self.root / "config/omarchy/shell.json"
defaults = self.root / "omarchy/config/omarchy/shell.json"
state_dir = self.root / "state/shibumi"
@@ -1249,7 +1276,7 @@ def test_saved_shibumi_profile_rejects_new_stock_contamination(self) -> None:
globals_map["verify"] = original_verify
saved = json.loads(profile.read_text(encoding="utf-8"))
- self.assertEqual(saved["layouts"]["shibumi"], trusted_layout)
+ self.assertEqual(saved["layouts"]["shibumi"], contaminated["bar"]["layout"])
def test_failed_worker_restores_exact_shell_config(self) -> None:
config = self.root / "config/omarchy/shell.json"
diff --git a/tests/test_shibumi_suite.py b/tests/test_shibumi_suite.py
index 3e5affb0..645fa5bd 100755
--- a/tests/test_shibumi_suite.py
+++ b/tests/test_shibumi_suite.py
@@ -8,6 +8,8 @@
import os
import re
import runpy
+import select
+import signal
import shutil
import stat
import subprocess
@@ -15,7 +17,8 @@
import tarfile
import tempfile
import unittest
-from contextlib import redirect_stdout
+from contextlib import redirect_stderr, redirect_stdout
+from importlib import import_module
from pathlib import Path
from types import SimpleNamespace
from typing import Callable
@@ -48,6 +51,7 @@
command_activate,
command_deactivate,
command_install,
+ install_picker_menu_extension,
command_migrate,
command_repair,
command_status,
@@ -76,6 +80,7 @@
from shibumi_suite.menu_extension import ( # noqa: E402
END_MARKER,
START_MARKER,
+ _decoded,
install_picker_routing,
remove_picker_routing,
)
@@ -89,6 +94,8 @@
)
+drain_contract = import_module((__package__ + "." if __package__ else "") + "test_inc013_drain_contract")
+
QUICKSHELL_EMPTY_REGISTRY = "No running instances.\n"
SESSION_LOCK_FIELDS = (
"locked",
@@ -511,6 +518,84 @@ def test_restart_waits_for_instance_without_redundant_ping(self) -> None:
self.omarchy_root / "bin/omarchy-shell"
), "shell", "ping"], calls)
+ def test_default_restart_covers_the_host_shutdown_dying_and_ready_phases(self):
+ clock = drain_contract.SyntheticClock()
+ config = self.omarchy_root / "shell/shell.qml"
+
+ def run(command, *, timeout=20, **_kwargs):
+ duration = 119.5 if command[0].endswith("omarchy-restart-shell") else 0.05
+ clock.advance_command(min(duration, timeout))
+ if duration > timeout:
+ try:
+ raise subprocess.TimeoutExpired(command, timeout)
+ except subprocess.TimeoutExpired as error:
+ raise RuntimeFailure("host restart phase timed out") from error
+ return self.result(self.instance_json(config))
+
+ self.runtime.run = Mock(side_effect=run)
+ timing = OmarchyRuntime.restart_shell.__globals__["time"]
+ with patch.object(timing, "monotonic", side_effect=clock.monotonic), \
+ patch.object(timing, "sleep", side_effect=clock.sleep):
+ self.runtime.restart_shell()
+ self.assertTrue(119.5 <= clock.now <= 180)
+
+ def test_shutdown_timeout_escalates_only_through_a_verified_pidfd(self):
+ # Refusal before TERM or after its grace must never permit KILL.
+ for stubborn, refused in ((False, 0), (True, 0), (True, 1), (True, 2)):
+ with self.subTest(stubborn=stubborn, refused=refused):
+ clock = drain_contract.SyntheticClock()
+ scenario = drain_contract.DrainScenario(self.omarchy_root, 1, clock=clock)
+ foreign = tuple(scenario.foreign)
+ sent = []
+ output = io.StringIO()
+ failure = None
+
+ def run(command, **kwargs):
+ if command[:2] == ["quickshell", "kill"]:
+ clock.advance_command(kwargs["timeout"])
+ try:
+ raise subprocess.TimeoutExpired(command, kwargs["timeout"])
+ except subprocess.TimeoutExpired as error:
+ raise RuntimeFailure("shutdown IPC timed out") from error
+ return scenario.run(command, **kwargs)
+
+ def send(_descriptor, value, *_args):
+ sent.append(value)
+ if value == signal.SIGKILL or not stubborn:
+ scenario.matching.clear()
+
+ def ready(readers, _writers, _errors, timeout):
+ if not scenario.matching:
+ return readers, [], []
+ clock.sleep(timeout)
+ return [], [], []
+
+ self.runtime.run = Mock(side_effect=run)
+ timing = OmarchyRuntime.stop_shell.__globals__["time"]
+ with patch.object(timing, "monotonic", side_effect=clock.monotonic), \
+ patch.object(timing, "sleep", side_effect=clock.sleep), \
+ patch.object(os, "pidfd_open", return_value=42), \
+ patch.object(os, "close") as close, \
+ patch.object(select, "select", side_effect=ready), \
+ patch.object(signal, "pidfd_send_signal", side_effect=send), \
+ patch.object(self.runtime, "_shell_process_identity", create=True,
+ return_value=123,
+ side_effect=(RuntimeFailure("foreign shell process") if refused == 1
+ else [123, RuntimeFailure("changed shell identity")] if refused == 2
+ else None)), \
+ redirect_stderr(output):
+ try:
+ self.runtime.stop_shell()
+ except RuntimeFailure as error:
+ failure = error
+ expected = [] if refused == 1 else [signal.SIGTERM] + ([signal.SIGKILL] if stubborn and not refused else [])
+ self.assertEqual((sent, failure is None, bool(scenario.matching), tuple(scenario.foreign)), (expected, not refused, bool(refused), foreign))
+ close.assert_called_once_with(42)
+ self.assertLessEqual(clock.now, 50.15)
+ if refused != 1:
+ self.assertIn("sending TERM", output.getvalue())
+ self.assertEqual("sending KILL" in output.getvalue(), stubborn and not refused)
+
def test_session_lock_preflight_accepts_only_explicitly_unlocked_status(
self,
) -> None:
@@ -1995,6 +2080,38 @@ def runtime_activity_snapshot(self) -> dict[str, object]:
"shellRunning": self.runtime.shell_running,
}
+ def test_menu_parse_diagnostic_names_source_location_and_remedy(self) -> None:
+ menu = self.paths.menu_extension_file
+ stock = self.paths.omarchy_root / "default/omarchy/omarchy-menu.jsonc"
+ menu.parent.mkdir(parents=True, exist_ok=True)
+ stock.parent.mkdir(parents=True, exist_ok=True)
+ malformed = '{\n // retained comment\n "one": {}\n "two": {}\n}\n'
+ marked = malformed.replace("// retained comment", START_MARKER + "\n // generated block\n " + END_MARKER)
+ for raw, host_raw, source in ((malformed, None, menu), (marked, None, menu), ("{}\n", malformed, stock)):
+ with self.subTest(source=source, markers=START_MARKER in raw):
+ menu.write_text(raw)
+ if host_raw is None:
+ stock.unlink(missing_ok=True)
+ else:
+ stock.write_text(host_raw)
+ writes = []
+ transaction = SimpleNamespace(paths=self.paths, menu_extension_existed=True, write_menu_extension=writes.append)
+ failure = None
+ try:
+ install_picker_menu_extension(transaction, self.runtime)
+ except Exception as error:
+ failure = error
+ invalid = host_raw if source == stock else raw
+ position = invalid.index('"two"')
+ line = invalid[:position].count("\n") + 1
+ remedy = ("restore the Omarchy stock menu, then retry the same command" if source == stock
+ else "fix the JSONC syntax or routing markers in this regular file, then retry the same command")
+ expected = f"cannot update Omarchy menu {source}: invalid Omarchy menu extension JSONC: Expecting ',' delimiter: line {line} column 3 (char {position}); {remedy}"
+ self.assertEqual(
+ (type(failure), str(failure), writes, self.runtime.menu_refreshes, menu.read_text(), stock.read_text() if stock.exists() else None),
+ (CliError, expected, [], 0, raw, host_raw),
+ )
+
def test_menu_extension_merge_is_idempotent_and_reversible(self) -> None:
original = (
"{\n"
@@ -2019,6 +2136,53 @@ def test_menu_extension_merge_is_idempotent_and_reversible(self) -> None:
self.assertEqual(install_picker_routing(installed), installed)
self.assertEqual(remove_picker_routing(installed), original)
+ def test_picker_routes_fall_back_only_when_router_is_not_executable(self) -> None:
+ home = self.root / "home with spaces"
+ router = home / ".config/omarchy/plugins/hancore.shibumi.quick-access/scripts/shibumi-picker-route"
+ router.parent.mkdir(parents=True)
+ commands = self.root / "commands"
+ commands.mkdir()
+ log = self.root / "route.log"
+ scripts = {
+ "omarchy-theme-switcher": 'printf "theme-switcher\\n" >>"$ROUTE_LOG"; printf "Theme With Spaces"',
+ "omarchy-theme-set": 'printf "theme-set:<%s>\\n" "$1" >>"$ROUTE_LOG"',
+ "omarchy-theme-bg-switcher": 'printf "background-switcher\\n" >>"$ROUTE_LOG"; printf "Background With Spaces"',
+ "omarchy-theme-bg-set": 'printf "background-set:<%s>\\n" "$1" >>"$ROUTE_LOG"',
+ "omarchy-shell": 'printf "native:" >>"$ROUTE_LOG"; printf "<%s>" "$@" >>"$ROUTE_LOG"; printf "\\n" >>"$ROUTE_LOG"',
+ }
+ for name, body in scripts.items():
+ target = commands / name
+ target.write_text("#!/bin/bash\n" + body + "\n")
+ target.chmod(0o700)
+ background = 'background=$(omarchy-theme-bg-switcher); [[ -n $background ]] && omarchy-theme-bg-set "$background"'
+ stock_menu = self.paths.omarchy_root / "default/omarchy/omarchy-menu.jsonc"
+ stock_menu.parent.mkdir(parents=True)
+ for theme, theme_calls in (
+ ('theme=$(omarchy-theme-switcher); [[ -n $theme ]] && omarchy-theme-set "$theme"', "theme-switcher\ntheme-set:\n"),
+ ('omarchy-shell shell summon omarchy.image-picker \'{"source":"themes"}\'', 'native:<{"source":"themes"}>\n'),
+ ):
+ stock_menu.write_text(json.dumps({"style.theme": {"action": theme}, "style.background": {"action": background}}))
+ legacy_actions = {"style.theme": "theme", "style.background": "wallpaper"}
+ legacy = "{\n" + START_MARKER + "\n" + ",\n".join(json.dumps(key) + ": " + json.dumps({"action": '\"$HOME/.config/omarchy/plugins/hancore.shibumi.quick-access/scripts/shibumi-picker-route\" ' + value}) for key, value in legacy_actions.items()) + "\n" + END_MARKER + "\n}\n"
+ self.paths.menu_extension_file.parent.mkdir(parents=True, exist_ok=True)
+ self.paths.menu_extension_file.write_text(legacy)
+ payloads = []
+ transaction = SimpleNamespace(paths=self.paths, menu_extension_existed=True, write_menu_extension=payloads.append)
+ install_picker_menu_extension(transaction, self.runtime)
+ rendered = payloads[-1].decode()
+ self.assertEqual((rendered.count(START_MARKER), rendered.count(END_MARKER), remove_picker_routing(rendered)), (1, 1, "{\n}\n"))
+ for mode in ("missing", "non-executable", "present", "failing"):
+ router.unlink(missing_ok=True)
+ if mode != "missing":
+ router.write_text('#!/bin/bash\nprintf "router:<%s>\\n" "$1" >>"$ROUTE_LOG"\nexit ' + ("17" if mode == "failing" else "0") + "\n")
+ router.chmod(0o600 if mode == "non-executable" else 0o700)
+ for key, argument in legacy_actions.items():
+ with self.subTest(theme=theme, mode=mode, route=key):
+ log.write_text("")
+ result = subprocess.run(["/bin/bash", "--noprofile", "--norc", "-c", _decoded(rendered)[key]["action"]], env={"HOME": str(home), "PATH": str(commands), "ROUTE_LOG": str(log)}, capture_output=True, text=True, timeout=3)
+ expected = (theme_calls if key == "style.theme" else "background-switcher\nbackground-set:\n") if mode in ("missing", "non-executable") else "router:<" + argument + ">\n"
+ self.assertEqual((result.returncode, log.read_text()), (17 if mode == "failing" else 0, expected), result.stderr)
+
def test_menu_extension_supports_items_wrapper(self) -> None:
original = (
"{\n"
@@ -2499,6 +2663,37 @@ def test_install_and_uninstall_manage_picker_routes(self) -> None:
self.assertEqual(self.runtime.restarts, restarts_before + 1)
self.assertFalse(extension.exists())
+ def test_uninstall_reports_postcommit_leftovers(self) -> None:
+ self.install()
+ marked = self.paths.menu_extension_file.read_text()
+ finish = PluginTransaction.finish
+ rmtree = shutil.rmtree
+ def finish_with_leftovers(transaction, *args, **kwargs):
+ finish(transaction, *args, **kwargs)
+ # Explicit post-commit external-writer / cleanup-failure control,
+ # not attribution of the reporter's unknown live sequence.
+ self.paths.menu_extension_file.parent.mkdir(parents=True, exist_ok=True)
+ self.paths.menu_extension_file.write_text(marked)
+ self.paths.state_dir.mkdir(parents=True, exist_ok=True)
+ (self.paths.state_dir / "leftover").write_text("fixture\n")
+ def denied_state_cleanup(path, *args, **kwargs):
+ if Path(path) == self.paths.state_dir:
+ raise PermissionError("injected state cleanup refusal")
+ return rmtree(path, *args, **kwargs)
+ output = io.StringIO()
+ with patch.object(PluginTransaction, "finish", finish_with_leftovers), patch("shibumi_suite.cli.shutil.rmtree", side_effect=denied_state_cleanup), redirect_stdout(output), redirect_stderr(output):
+ try:
+ code = command_uninstall(self.args(), self.suite, self.paths, self.runtime)
+ except OSError:
+ code = -1
+ lines = output.getvalue().splitlines()
+ reports = [line for line in lines if str(self.paths.menu_extension_file) in line or str(self.paths.state_dir) in line]
+ self.assertEqual((code, len(reports), all("same checkout" in line for line in reports), "Uninstalled Shibumi and restored the stock Omarchy bar." in lines), (1, 2, True, False), output.getvalue())
+
+ def test_first_install_does_not_retain_empty_backup_slots(self) -> None:
+ self.install()
+ self.assertEqual([path.name for path in (self.paths.state_dir / "backups").glob("*") if path.is_dir() and not any(path.iterdir())], [])
+
def test_uninstall_uses_full_restart_instead_of_hot_reload(self) -> None:
self.install()
reloads_before = self.runtime.reloads
@@ -2843,6 +3038,9 @@ def test_previous_bar_restore_prunes_orphans_but_unreadable_root_preserves_all(
self.assertIn("omarchy.absent-official", restored_ids)
restored["bar"]["layout"]["right"].append({"id": "foreign.unreadable"})
+ # Exercise legacy/profile import, not the exact retained-layout branch
+ # now created by managed deactivate (which must not import stock extras).
+ state_entry(restored).pop("shibumiRetainedLayout", None)
atomic_write(self.paths.config_file, encode_config(restored))
real_scandir = os.scandir
failed_reads = 0
@@ -3219,14 +3417,19 @@ def test_state_storage_keeps_complete_entry_through_uninstall_reinstall(self) ->
entry["shibumi"]["picker"] = {"imageStyle": "tanzaku", "mediaStyle": "hearthstone"}
entry["shibumi"]["widgets"] = {"G8": {"omarchy.clock": {"format": "HH:mm", "deep": [3, 2, 1]}}}
retained = copy.deepcopy(entry)
+ for section in ("centerBegin", "centerEnd", "tray"):
+ config["bar"]["layout"][section] = [{"id": "omarchy.clock", "opaque": [section, False, "Malmö"]}]
atomic_write(self.paths.config_file, encode_config(config))
self.assertEqual(command_uninstall(self.args(keep_settings=True), self.suite, self.paths, self.runtime), 0)
kept = json.loads(self.paths.config_file.read_text())
self.assertEqual(state_entry(kept), {**retained, "shibumiRetainedLayout": {
"schemaVersion": 1, "layout": config["bar"]["layout"]}})
self.assertFalse((self.paths.plugin_dir / "hancore.shibumi.state").exists())
+ for section in ("centerBegin", "centerEnd", "tray"):
+ self.assertEqual(kept["bar"]["layout"][section], config["bar"]["layout"][section])
self.assertEqual(command_install(self.args(), self.suite, self.paths, self.runtime), 0)
self.assertEqual(state_entry(json.loads(self.paths.config_file.read_text())), retained)
+ self.assertEqual(json.loads(self.paths.config_file.read_text())["bar"]["layout"], config["bar"]["layout"])
def test_state_migration_refuses_corruption_and_does_not_resurrect_legacy(self) -> None:
config = {"version": 1, "bar": {"id": "omarchy.bar", "shibumi": {"version": 1, "legacy": True}},
@@ -3234,6 +3437,9 @@ def test_state_migration_refuses_corruption_and_does_not_resurrect_legacy(self)
migrated = migrate_state_settings(config)
self.assertEqual(state_entry(migrated)["foreign"], {"value": 7})
self.assertEqual(migrate_state_settings(migrated), migrated)
+ shadow = copy.deepcopy(migrated)
+ shadow["bar"]["layout"]["centerEnd"] = ["hancore.shibumi.state"]
+ with self.assertRaises(ConfigError): state_entry(shadow)
corrupt = copy.deepcopy(migrated)
state_entry(corrupt)["shibumiStateSchemaVersion"] = 2
with self.assertRaises(ConfigError):
@@ -4636,6 +4842,9 @@ def test_deactivate_activate_round_trip_preserves_user_state_and_payload(self) -
{"id": "user.weather", "custom": {"city": "Berlin"}}
)
(self.paths.plugin_dir / "user.weather").mkdir()
+ config["bar"]["layout"]["right"].append({"id": "omarchy.elsewhen",
+ "displayMode": "full", "opaque": [False, {"city": "Malmö"}]})
+ placed_layout = copy.deepcopy(config["bar"]["layout"])
config["plugins"].append({"id": "user.service", "interval": 17})
state_entry(config)["shibumi"]["testSetting"] = "retained"
atomic_write(self.paths.config_file, encode_config(config))
@@ -4690,6 +4899,23 @@ def test_deactivate_activate_round_trip_preserves_user_state_and_payload(self) -
if entry_id(entry) == "user.service"
)
self.assertEqual(user_service["interval"], 17)
+ for enabled in (True, False):
+ expected_layout = copy.deepcopy(placed_layout)
+ codes = (0, 0)
+ if not enabled:
+ deactivated = command_deactivate(self.args(), self.suite, self.paths, self.runtime)
+ stock = json.loads(self.paths.config_file.read_text())
+ # Native widget disable removes its placement, not its package.
+ for region in stock["bar"]["layout"]:
+ stock["bar"]["layout"][region] = [entry for entry in stock["bar"]["layout"][region]
+ if entry_id(entry) != "omarchy.elsewhen"]
+ expected_layout[region] = [entry for entry in expected_layout[region]
+ if entry_id(entry) != "omarchy.elsewhen"]
+ atomic_write(self.paths.config_file, encode_config(stock))
+ codes = (deactivated, command_activate(self.args(), self.suite, self.paths, self.runtime))
+ active = json.loads(self.paths.config_file.read_text())
+ self.assertEqual((codes, active["bar"]["layout"], state_entry(active).get("shibumiRetainedLayout")),
+ ((0, 0), expected_layout, None), "native placement must survive unless disabled while inactive")
def test_failed_deactivation_restores_config_and_install_state(self) -> None:
self.install()
diff --git a/tests/theme-palette-runtime-smoke.qml b/tests/theme-palette-runtime-smoke.qml
index 9fbbf126..a772d5a3 100644
--- a/tests/theme-palette-runtime-smoke.qml
+++ b/tests/theme-palette-runtime-smoke.qml
@@ -26,7 +26,7 @@ ShellRoot {
id: state
shell: fakeShell
omarchyPath: Quickshell.env("OMARCHY_PATH")
- manifest: ({id: "hancore.shibumi.state", version: "0.1.1-beta.16.4", kinds: ["service"]})
+ manifest: ({id: "hancore.shibumi.state", version: "0.1.1-beta.16.5", kinds: ["service"]})
}
function colorText(value) {
diff --git a/tests/third-party-integration-smoke.qml b/tests/third-party-integration-smoke.qml
index 2ca9bdcd..e0e91dd0 100644
--- a/tests/third-party-integration-smoke.qml
+++ b/tests/third-party-integration-smoke.qml
@@ -38,14 +38,14 @@ ShellRoot {
AudioOwner.Service {
shell: fakeShell
nativeBackendEnabled: false // Controlled peak stub; this fixture tests IPC.
- manifest: ({ id: "hancore.shibumi.audio", version: "0.1.1-beta.16.4", kinds: ["service"] })
+ manifest: ({ id: "hancore.shibumi.audio", version: "0.1.1-beta.16.5", kinds: ["service"] })
}
StateOwner.Service {
shell: fakeShell
omarchyPath: ""
manifest: ({
- id: "hancore.shibumi.state", version: "0.1.1-beta.16.4", kinds: ["service"]
+ id: "hancore.shibumi.state", version: "0.1.1-beta.16.5", kinds: ["service"]
})
}
diff --git a/tests/widget-pipeline-diagnostics-smoke.qml b/tests/widget-pipeline-diagnostics-smoke.qml
index faacf06d..e5234235 100644
--- a/tests/widget-pipeline-diagnostics-smoke.qml
+++ b/tests/widget-pipeline-diagnostics-smoke.qml
@@ -237,11 +237,11 @@ ShellRoot {
Shared.Provider {
id: stateProvider
pluginId: "hancore.shibumi.state"
- implementationVersion: "0.1.1-beta.16.4"
+ implementationVersion: "0.1.1-beta.16.5"
owner: stateOwner
host: stateHost
manifest: ({ id: "hancore.shibumi.state",
- version: "0.1.1-beta.16.4", kinds: ["service"] })
+ version: "0.1.1-beta.16.5", kinds: ["service"] })
}
QtObject {
@@ -290,7 +290,7 @@ ShellRoot {
barWidgetRegistry: scopedWidgetRegistry
manifest: ({
id: "hancore.shibumi.bar",
- version: "0.1.1-beta.16.4",
+ version: "0.1.1-beta.16.5",
kinds: ["bar"]
})
barConfig: ({
diff --git a/tests/workspaces-plugin-smoke.qml b/tests/workspaces-plugin-smoke.qml
index 97d93901..fe481473 100644
--- a/tests/workspaces-plugin-smoke.qml
+++ b/tests/workspaces-plugin-smoke.qml
@@ -125,7 +125,7 @@ ShellRoot {
Workspaces.WorkspaceService {
id: workspaceState
shell: fakeShell
- manifest: ({id: "hancore.shibumi.workspaces", version: "0.1.1-beta.16.4", kinds: ["service"]})
+ manifest: ({id: "hancore.shibumi.workspaces", version: "0.1.1-beta.16.5", kinds: ["service"]})
stateService: preferenceState
backendOverride: QtObject {
property var focusedWorkspace: ({ id: 8 })
diff --git a/tests/workspaces-runtime-smoke.qml b/tests/workspaces-runtime-smoke.qml
index dc7a2a79..51d1669a 100644
--- a/tests/workspaces-runtime-smoke.qml
+++ b/tests/workspaces-runtime-smoke.qml
@@ -15,7 +15,7 @@ ShellRoot {
property var cachedPreference: null
property int callsBeforeLoss: 0
property int writesBeforeLoss: 0
- readonly property var validManifest: ({id: "hancore.shibumi.workspaces", version: "0.1.1-beta.16.4", kinds: ["service"]})
+ readonly property var validManifest: ({id: "hancore.shibumi.workspaces", version: "0.1.1-beta.16.5", kinds: ["service"]})
function check(value, message) {
if (value) return
console.error("workspaces-runtime:", message)
@@ -44,10 +44,10 @@ ShellRoot {
}
SuiteRuntime.Provider {
pluginId: "hancore.shibumi.state"
- implementationVersion: "0.1.1-beta.16.4"
+ implementationVersion: "0.1.1-beta.16.5"
owner: state
host: root.stateEnabled ? stateHost : null
- manifest: ({id: "hancore.shibumi.state", version: "0.1.1-beta.16.4", kinds: ["service"]})
+ manifest: ({id: "hancore.shibumi.state", version: "0.1.1-beta.16.5", kinds: ["service"]})
}
QtObject {
id: backend