System details
AMD Ryzen AI 9 HX 370 (Radeon 890M), Omarchy dev (430e6791, tracking upstream/quattro), Quickshell 0.3.1-1, Hyprland (Wayland). omarchy-debug output can be attached on request.
What's wrong?
Any third-party menu plugin — including the supported clone of the built-in omarchy.menu — gets an empty Apps list ("Nothing here yet"). The app-list data itself is present and healthy; the plugin just never receives the application-library facade it is supposed to get.
Steps to reproduce
- Clone the built-in menu plugin so a third-party copy takes over the apps menu, e.g. copy
shell/plugins/menu/ to ~/.config/omarchy/plugins/ekollof.menu/ and set "omarchy": { "clonedFrom": "omarchy.menu" } in its manifest.json (this is the layout omarchy plugin clone produces).
- Make sure the clone is the enabled menu in
~/.config/omarchy/shell.json (the registry's resolveEnabledId maps omarchy.menu to the active clone via clonedFrom).
omarchy restart shell, then press Super+Alt+Space and open "Apps...".
Expected: the full application list. Actual: "Nothing here yet", every time, across restarts.
Root cause
The panel loader instantiates panel/menu plugins through a Quickshell Instantiator whose model is a JS array of entry objects that each carry the plugin manifest. On assignment the loader does:
// shell.qml, panel loader onLoaded
if ("shell" in item) item.shell = shell.pluginShellFor(panelEntry.manifest)
panelEntry.manifest here is modelData — it has crossed Qt's C++ model boundary, so the manifest object arrives as a QVariantMap (observable: Object.keys() order becomes alphabetical) and the nested kinds array is no longer a JS Array:
// capability check, shell.qml
function manifestHasKind(manifest, kind) {
return !!manifest && Array.isArray(manifest.kinds)
&& manifest.kinds.indexOf(kind) !== -1
}
Array.isArray(QVariantList) is false, so manifestHasKind(manifest, "menu") is false for every panel-delivered manifest, and createScopedPluginShell builds the scoped plugin shell API with appLibrary: null:
appLibrary: shell.manifestHasKind(manifest, "menu")
? shell.pluginAppLibraryFor(cacheKey, key) : null,
The menu plugin's mergeAppRows() then sees root.shell.appLibrary == null and early-returns — the empty state.
First-party plugins are unaffected only by accident: pluginShellFor returns the shared host shell when manifest.__isFirstParty is truthy, and booleans survive the QVariant round-trip. Any third-party manifest with kind "menu" (clones, community menu plugins) silently loses the facade. pluginShellCapabilityProfile has the same exposure: it computes the cached API's profile via manifestHasKind, so the capability cache itself is poisoned by the QVariant-ified manifest — an earlier, correct API gets revoked and replaced by a no-menu-capability one about a second after startup, when the panel model delivers.
Instrumented evidence from a repro (same plugin, two delivery paths):
KIND-DEBUG manifest=ekollof.menu isArray=true idx=0 result=true // from registry (configureBar path)
KIND-DEBUG manifest=ekollof.menu isArray=false result=false // from panel Instantiator modelData
SHELL-DEBUG created api key=ekollof.menu appLibrary=present // early, correct
SHELL-DEBUG created api key=ekollof.menu appLibrary=NULL // after panel model delivers
Proposed fix
Capability decisions should run on the registry's raw manifest for the id, not on a copy that may have crossed a model boundary. PluginRegistry.installedPlugins always holds the original JS objects (kinds is a real array there):
--- a/shell/shell.qml
+++ b/shell/shell.qml
@@ -725,7 +725,13 @@ ShellRoot {
if (!manifest || manifest.__isFirstParty) return shell
var key = String(manifest.id || "")
if (!key) return null
- return shell.createScopedPluginShell(manifest, key, true, shell.pluginHasBarCapabilities(manifest))
+ // A manifest handed through a QObject model (e.g. the panel
+ // Instantiator's modelData) arrives as a QVariantMap: nested arrays are
+ // no longer JS Arrays, so kind checks would under-declare capabilities.
+ // The registry always holds the raw manifest for this id; prefer it.
+ var raw = shell.pluginRegistry ? shell.pluginRegistry.installedPlugins[key] : null
+ var resolved = raw && raw.id === key ? raw : manifest
+ return shell.createScopedPluginShell(resolved, key, true, shell.pluginHasBarCapabilities(resolved))
}
Verified on the affected machine: with this change the cloned menu's Apps submenu is fully populated after omarchy restart shell, and the debug logging above shows a single API creation with the facade intact.
Alternatives worth considering (maintainer's call)
- Make
manifestHasKind (and pluginShellCapabilityProfile) duck-typed instead of relying on Array.isArray, e.g. treat any object with length and index access as a list. Fragile, and leaves pluginHasBarCapabilities/isAuthenticationService with the same trap.
- Stop routing manifests through the panel model: carry only the plugin id (plus scalar fields) in the entry objects and have the delegate fetch
pluginRegistry.installedPlugins[id] directly. Cleanest, but a larger refactor of computePanelEntries and the delegate bindings.
The proposed one-place fix also keeps item.manifest assignments safe as-is, since those already go through publicPluginManifest's JSON round-trip, which restores real arrays.
System details
AMD Ryzen AI 9 HX 370 (Radeon 890M), Omarchy dev (
430e6791, trackingupstream/quattro), Quickshell 0.3.1-1, Hyprland (Wayland).omarchy-debugoutput can be attached on request.What's wrong?
Any third-party menu plugin — including the supported clone of the built-in
omarchy.menu— gets an empty Apps list ("Nothing here yet"). The app-list data itself is present and healthy; the plugin just never receives the application-library facade it is supposed to get.Steps to reproduce
shell/plugins/menu/to~/.config/omarchy/plugins/ekollof.menu/and set"omarchy": { "clonedFrom": "omarchy.menu" }in its manifest.json (this is the layoutomarchy plugin cloneproduces).~/.config/omarchy/shell.json(the registry'sresolveEnabledIdmapsomarchy.menuto the active clone viaclonedFrom).omarchy restart shell, then press Super+Alt+Space and open "Apps...".Expected: the full application list. Actual: "Nothing here yet", every time, across restarts.
Root cause
The panel loader instantiates panel/menu plugins through a Quickshell
Instantiatorwhose model is a JS array of entry objects that each carry the plugin manifest. On assignment the loader does:panelEntry.manifesthere ismodelData— it has crossed Qt's C++ model boundary, so the manifest object arrives as a QVariantMap (observable:Object.keys()order becomes alphabetical) and the nestedkindsarray is no longer a JS Array:Array.isArray(QVariantList)is false, somanifestHasKind(manifest, "menu")is false for every panel-delivered manifest, andcreateScopedPluginShellbuilds the scoped plugin shell API withappLibrary: null:The menu plugin's
mergeAppRows()then seesroot.shell.appLibrary == nulland early-returns — the empty state.First-party plugins are unaffected only by accident:
pluginShellForreturns the shared host shell whenmanifest.__isFirstPartyis truthy, and booleans survive the QVariant round-trip. Any third-party manifest with kind"menu"(clones, community menu plugins) silently loses the facade.pluginShellCapabilityProfilehas the same exposure: it computes the cached API's profile viamanifestHasKind, so the capability cache itself is poisoned by the QVariant-ified manifest — an earlier, correct API gets revoked and replaced by a no-menu-capability one about a second after startup, when the panel model delivers.Instrumented evidence from a repro (same plugin, two delivery paths):
Proposed fix
Capability decisions should run on the registry's raw manifest for the id, not on a copy that may have crossed a model boundary.
PluginRegistry.installedPluginsalways holds the original JS objects (kindsis a real array there):Verified on the affected machine: with this change the cloned menu's Apps submenu is fully populated after
omarchy restart shell, and the debug logging above shows a single API creation with the facade intact.Alternatives worth considering (maintainer's call)
manifestHasKind(andpluginShellCapabilityProfile) duck-typed instead of relying onArray.isArray, e.g. treat any object withlengthand index access as a list. Fragile, and leavespluginHasBarCapabilities/isAuthenticationServicewith the same trap.pluginRegistry.installedPlugins[id]directly. Cleanest, but a larger refactor ofcomputePanelEntriesand the delegate bindings.The proposed one-place fix also keeps
item.manifestassignments safe as-is, since those already go throughpublicPluginManifest's JSON round-trip, which restores real arrays.