Proof Portal
Omarchy
ProbeLabs65 findings · 95 requirementsA proof layer — requirements, tests and verified fixes — for two of Omarchy's subsystems: the application menu (launcher scripts, QML model, JSONC config, search and selection) and the lock screen (lock scripts, QML, PAM authentication). Scope is deliberately limited to those components of omacom/omarchy; the rest of the distribution is not covered.
4.0.3: third-party/cloned menu plugins get shell.appLibrary = null, so the Apps submenu is empty
Reported by DanielSRojo · filed Sep 10, 2026Closed upstream on 2026-09-20 by bjarneo via an automated message, as a duplicate of #10661 (R-860). GitHub close reason: completed.
At a glance
- 1 · What is claimed?
- Only the report text below — no validator has restated it as checkable claims yet.
- 2 · What does it affect?
- Not yet mapped to any requirement or component.
- 3 · Is it reproducible?
- No validation has been run yet.
- 4 · What remains unclear?
- Not validated yet: no claims, no reproducer.
- 5 · What happens next?
- Our staff decide whether the report is valid. Where this project allows it, agents decide whether it duplicates another report or a known issue. No decision has been recorded yet.
Original report
What happened
Since 4.0.3, any third-party or cloned menu plugin receives shell.appLibrary as null, so its Apps submenu is permanently empty ("Nothing here yet"). App icons, launching and entry removal are dead in that menu too, since they all go through the same object.
The built-in omarchy.menu is unaffected.
This bites anyone who ran omarchy plugin clone omarchy.menu (I clone it to add Ctrl+N/J/P/K navigation), and any third-party menu plugin.
Steps to reproduce
omarchy plugin clone omarchy.menu(creates<user>.menu, enabled in place of the built-in)omarchy restart shellomarchy menu summon apps
Expected: the alphabetical app list, as the built-in menu shows.
Actual: an empty submenu, "Nothing here yet". Nothing is logged — no QML warning, no error.
Disabling the clone so omarchy.menu takes over restores the list immediately, which makes the first-party/third-party split easy to confirm on one machine.
Root cause
shell.manifestHasKind() (shell/shell.qml:349) tests Array.isArray(manifest.kinds). The panel loader passes the manifest it got out of the Instantiator model (shell/shell.qml:1342, item.shell = shell.pluginShellFor(panelEntry.manifest)), and computePanelEntries() puts the manifest into that model at shell/shell.qml:1313. Round-tripping through the model converts the nested array to a QVariantList, and Array.isArray then returns false even though the contents are intact:
Instantiator {
model: [{ manifest: { kinds: ["menu", "bar-widget"] } }]
delegate: QtObject {
required property var modelData
Component.onCompleted: {
console.log(Array.isArray(shell.entries[0].manifest.kinds)) // true
console.log(Array.isArray(modelData.manifest.kinds)) // false
console.log(JSON.stringify(modelData.manifest.kinds)) // ["menu","bar-widget"]
}
}
}PROBE direct Array.isArray(kinds) = true | indexOf = 0
PROBE viaModel Array.isArray(kinds) = false | kinds = ["menu","bar-widget"]So the gate at shell/shell.qml:603 takes its null branch:
appLibrary: shell.manifestHasKind(manifest, "menu")
? shell.pluginAppLibraryFor(cacheKey, key) : null,and Menu.qml:291 (if (!root.appLibrary) return) bails before adding a single row.
Instrumenting a cloned menu shows exactly that — the manifest still carries the menu kind, bar (assigned unconditionally on the same object) arrives fine, only the gated appLibrary is null:
mergeAppRows shell= PluginShellApi_QMLTYPE_18 appLibrary= null
pluginId= dsrojo.menu hasBar= true manifestKinds= ["menu","bar-widget"]First-party plugins never reach the check: pluginShellFor() short-circuits on __isFirstParty and returns the real shell (shell/shell.qml:739-740), which is why only third-party menus break.
Why 4.0.3
shell/plugins/menu/Menu.qml is byte-identical between 4.0.2 and 4.0.3 (52234 bytes, verified against omarchy-4.0.2-1-any.pkg.tar.zst). The whole capability-scoping layer — manifestHasKind, pluginShellFor, PluginShellApi, pluginAppLibraryFor, publicPluginManifest — is new in 4.0.3. In 4.0.2 the panel loader handed every plugin the real shell:
if ("shell" in item) item.shell = shell // 4.0.2, shell.qml:630
if ("shell" in item) item.shell = shell.pluginShellFor(panelEntry.manifest) // 4.0.3, shell.qml:1342so cloned menus resolved shell.appLibrary to the real AppLibrary and listed apps normally.
Suggested fix
Either make the check tolerant of a QVariantList:
function manifestHasKind(manifest, kind) {
var kinds = manifest ? manifest.kinds : null
if (!kinds || typeof kinds.length !== "number") return false
for (var i = 0; i < kinds.length; i++)
if (String(kinds[i]) === kind) return true
return false
}or have the panel loader hand pluginShellFor() the registry's own manifest object rather than the model copy:
item.shell = shell.pluginShellFor(shell.pluginRegistry.installedPlugins[panelEntry.pluginId])The first is the safer of the two — isBarOptionManifest() (shell.qml:200) and computePanelEntries() (shell.qml:1306) test Array.isArray(manifest.kinds) as well, so any other path that acquires a manifest across a model or property boundary has the same trap waiting.
System details
- Omarchy 4.0.3-1 (regressed from 4.0.2-1)
- Quickshell 0.3.1-1
- Hyprland 0.56.2, kernel 7.2.3-arch1-3, x86_64
- AMD Ryzen 5 5600G with Radeon Graphics
View raw text
### What happened
Since 4.0.3, any **third-party or cloned menu plugin** receives `shell.appLibrary` as `null`, so its **Apps submenu is permanently empty** ("Nothing here yet"). App icons, launching and entry removal are dead in that menu too, since they all go through the same object.
The built-in `omarchy.menu` is unaffected.
This bites anyone who ran `omarchy plugin clone omarchy.menu` (I clone it to add Ctrl+N/J/P/K navigation), and any third-party menu plugin.
### Steps to reproduce
1. `omarchy plugin clone omarchy.menu` (creates `<user>.menu`, enabled in place of the built-in)
2. `omarchy restart shell`
3. `omarchy menu summon apps`
**Expected:** the alphabetical app list, as the built-in menu shows.
**Actual:** an empty submenu, "Nothing here yet". Nothing is logged — no QML warning, no error.
Disabling the clone so `omarchy.menu` takes over restores the list immediately, which makes the first-party/third-party split easy to confirm on one machine.
### Root cause
`shell.manifestHasKind()` (`shell/shell.qml:349`) tests `Array.isArray(manifest.kinds)`. The panel loader passes the manifest it got out of the `Instantiator` model (`shell/shell.qml:1342`, `item.shell = shell.pluginShellFor(panelEntry.manifest)`), and `computePanelEntries()` puts the manifest into that model at `shell/shell.qml:1313`. Round-tripping through the model converts the nested array to a `QVariantList`, and `Array.isArray` then returns **false** even though the contents are intact:
```qml
Instantiator {
model: [{ manifest: { kinds: ["menu", "bar-widget"] } }]
delegate: QtObject {
required property var modelData
Component.onCompleted: {
console.log(Array.isArray(shell.entries[0].manifest.kinds)) // true
console.log(Array.isArray(modelData.manifest.kinds)) // false
console.log(JSON.stringify(modelData.manifest.kinds)) // ["menu","bar-widget"]
}
}
}
```
```
PROBE direct Array.isArray(kinds) = true | indexOf = 0
PROBE viaModel Array.isArray(kinds) = false | kinds = ["menu","bar-widget"]
```
So the gate at `shell/shell.qml:603` takes its `null` branch:
```qml
appLibrary: shell.manifestHasKind(manifest, "menu")
? shell.pluginAppLibraryFor(cacheKey, key) : null,
```
and `Menu.qml:291` (`if (!root.appLibrary) return`) bails before adding a single row.
Instrumenting a cloned menu shows exactly that — the manifest still carries the `menu` kind, `bar` (assigned unconditionally on the same object) arrives fine, only the gated `appLibrary` is null:
```
mergeAppRows shell= PluginShellApi_QMLTYPE_18 appLibrary= null
pluginId= dsrojo.menu hasBar= true manifestKinds= ["menu","bar-widget"]
```
First-party plugins never reach the check: `pluginShellFor()` short-circuits on `__isFirstParty` and returns the real `shell` (`shell/shell.qml:739-740`), which is why only third-party menus break.
### Why 4.0.3
`shell/plugins/menu/Menu.qml` is **byte-identical** between 4.0.2 and 4.0.3 (52234 bytes, verified against `omarchy-4.0.2-1-any.pkg.tar.zst`). The whole capability-scoping layer — `manifestHasKind`, `pluginShellFor`, `PluginShellApi`, `pluginAppLibraryFor`, `publicPluginManifest` — is new in 4.0.3. In 4.0.2 the panel loader handed every plugin the real shell:
```qml
if ("shell" in item) item.shell = shell // 4.0.2, shell.qml:630
if ("shell" in item) item.shell = shell.pluginShellFor(panelEntry.manifest) // 4.0.3, shell.qml:1342
```
so cloned menus resolved `shell.appLibrary` to the real `AppLibrary` and listed apps normally.
### Suggested fix
Either make the check tolerant of a `QVariantList`:
```js
function manifestHasKind(manifest, kind) {
var kinds = manifest ? manifest.kinds : null
if (!kinds || typeof kinds.length !== "number") return false
for (var i = 0; i < kinds.length; i++)
if (String(kinds[i]) === kind) return true
return false
}
```
or have the panel loader hand `pluginShellFor()` the registry's own manifest object rather than the model copy:
```qml
item.shell = shell.pluginShellFor(shell.pluginRegistry.installedPlugins[panelEntry.pluginId])
```
The first is the safer of the two — `isBarOptionManifest()` (`shell.qml:200`) and `computePanelEntries()` (`shell.qml:1306`) test `Array.isArray(manifest.kinds)` as well, so any other path that acquires a manifest across a model or property boundary has the same trap waiting.
### System details
- Omarchy 4.0.3-1 (regressed from 4.0.2-1)
- Quickshell 0.3.1-1
- Hyprland 0.56.2, kernel 7.2.3-arch1-3, x86_64
- AMD Ryzen 5 5600G with Radeon Graphics
Validation
Not checked yet. A validator will restate this report as checkable claims, map it to the project's requirements and write a reproducer that the proof engine runs.
Linked
Not linked to any known issue, other report or change yet.
Decision
Decisions are recorded by our staff — or, where this project allows it, by agents: checked by a second, independent agent, or by a single agent where the project's policy says so. Any decision reached so far is shown below.
No decision has been recorded yet.