Skip to content

Third-party menu plugins (e.g. omarchy.menu clones) get an empty Apps list: manifests passed through the panel Instantiator lose array types #10661

Description

@ekollof

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

  1. 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).
  2. 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).
  3. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions