diff --git a/shell/services/PluginAppLibraryApi.qml b/shell/services/PluginAppLibraryApi.qml index 00d5e8f03bb..e1b1b1102d7 100644 --- a/shell/services/PluginAppLibraryApi.qml +++ b/shell/services/PluginAppLibraryApi.qml @@ -7,6 +7,7 @@ QtObject { required property string ownerPluginId signal appsChanged() + signal iconIndexChanged() property var _entryName: null property var _entrySubtext: null diff --git a/shell/shell.qml b/shell/shell.qml index 8208062429f..4320999be32 100644 --- a/shell/shell.qml +++ b/shell/shell.qml @@ -1053,6 +1053,10 @@ ShellRoot { Connections { target: shell.appLibrary + function onIconIndexChanged() { + for (var id in shell._pluginAppLibraryApis) + shell._pluginAppLibraryApis[id].iconIndexChanged() + } function onAppsChanged() { for (var id in shell._pluginAppLibraryApis) shell._pluginAppLibraryApis[id].appsChanged() @@ -1316,7 +1320,9 @@ ShellRoot { id: panelEntry required property var modelData readonly property string pluginId: modelData.id - readonly property var manifest: modelData.manifest + // Qt model data turns nested arrays into sequences. Use the registry + // object so capability checks see the validated manifest's array types. + readonly property var manifest: shell.pluginRegistry.installedPlugins[pluginId] || null readonly property string entryKind: modelData.kind readonly property bool keepLoaded: modelData.keepLoaded === true readonly property string sourceUrl: shell.pluginRegistry.entryPointUrl(manifest, entryKind) diff --git a/test/shell.d/fixtures/panel-menu-capability/shell.qml b/test/shell.d/fixtures/panel-menu-capability/shell.qml new file mode 100644 index 00000000000..1a92878af6b --- /dev/null +++ b/test/shell.d/fixtures/panel-menu-capability/shell.qml @@ -0,0 +1,87 @@ +import QtQuick +import QtQml.Models +import Quickshell +import "services" + +ShellRoot { + id: shell + + property var pluginRegistry: ({ installedPlugins: { + "example.menu": { id: "example.menu", kinds: ["menu", "bar-widget"] }, + "example.panel": { id: "example.panel", kinds: ["panel"] } + } }) + property var panelEntries: Object.keys(pluginRegistry.installedPlugins).map(function(id) { + return { id: id, manifest: shell.pluginRegistry.installedPlugins[id] } + }) + property var failures: [] + property int checked: 0 + property int iconCompletions: 0 + property int appChanges: 0 + property QtObject appLibrary: QtObject { + property var iconIndex: ({}) + signal appsChanged() + } + property var _pluginAppLibraryApis: ({ "example.menu": menuLibrary }) + + __APP_LIBRARY_SIGNALS__ + + // Filled from shell.qml by the test runner, not a copy of the implementation. + __MANIFEST_HAS_KIND__ + + PluginAppLibraryApi { + id: menuLibrary + ownerPluginId: "example.menu" + _sortedEntries: function(query) { return [{ entry: { id: "example-app", name: "Example App" } }] } + } + + function pluginAppLibraryFor(cacheKey, key) { return menuLibrary } + + Connections { + target: menuLibrary + function onIconIndexChanged() { shell.iconCompletions += 1 } + function onAppsChanged() { shell.appChanges += 1 } + } + + Component { id: apiComponent; PluginShellApi {} } + + function check(manifest, pluginId) { + var key = pluginId + var cacheKey = key + var api = apiComponent.createObject(null, { + pluginId: key, + appLibrary: __APP_LIBRARY_GRANT__ + }) + var isMenu = pluginId === "example.menu" + var granted = !!api && !!api.appLibrary + if (granted !== isMenu) failures.push(pluginId + ": wrong app-library grant") + if (granted && api.appLibrary.sortedEntries("")[0].entry.id !== "example-app") + failures.push(pluginId + ": app entries unavailable") + if (api) api.destroy() + checked += 1 + } + + Instantiator { + model: shell.panelEntries + delegate: QtObject { + required property var modelData + readonly property string pluginId: modelData.id + __PANEL_MANIFEST_BINDING__ + Component.onCompleted: shell.check(manifest, pluginId) + } + } + + Timer { + interval: 50 + running: true + onTriggered: { + shell.appLibrary.iconIndex = ({ updated: true }) + shell.appLibrary.appsChanged() + if (shell.iconCompletions !== 1) shell.failures.push("icon refresh completion was not forwarded") + if (shell.appChanges !== 1) shell.failures.push("app changes were not forwarded") + if (shell.checked !== 2) shell.failures.push("both plugin kinds must be checked") + if (shell.failures.length) console.error("PANEL_MENU_FAIL", JSON.stringify(shell.failures)) + else console.log("PANEL_MENU_OK menu receives apps; ordinary panel stays restricted") + Qt.quit() + } + } +} diff --git a/test/shell.d/panel-menu-capability-test.sh b/test/shell.d/panel-menu-capability-test.sh new file mode 100755 index 00000000000..dc05edfb0fe --- /dev/null +++ b/test/shell.d/panel-menu-capability-test.sh @@ -0,0 +1,51 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +if ! command -v quickshell >/dev/null 2>&1; then + pass "quickshell not installed; skipping panel menu capability runtime test" + exit 0 +fi +require_command python3 +require_command timeout + +tmpdir=$(mktemp -d) +trap 'rm -rf "$tmpdir"' EXIT +mkdir "$tmpdir/services" +cp "$ROOT/shell/services/PluginShellApi.qml" "$ROOT/shell/services/PluginAppLibraryApi.qml" "$tmpdir/services/" + +# Exercise the production binding and grant across a real Qt model boundary. +# Node alone cannot reproduce Qt's conversion of nested arrays to sequences. +python3 - "$ROOT" "$tmpdir/shell.qml" <<'PY' +from pathlib import Path +import re +import sys + +root = Path(sys.argv[1]) +source = (root / "shell/shell.qml").read_text() +fixture = (root / "test/shell.d/fixtures/panel-menu-capability/shell.qml").read_text() +panel = source[source.index("id: panelEntry"):] +replacements = { + "__PANEL_MANIFEST_BINDING__": re.search(r"readonly property var manifest: [^\n]+", panel).group(), + "__MANIFEST_HAS_KIND__": re.search(r"function manifestHasKind\([^)]*\) \{.*?\n \}", source, re.S).group(), + "__APP_LIBRARY_GRANT__": re.search(r"appLibrary: (shell\.manifestHasKind\(manifest, \"menu\"\).*?),\n", source, re.S).group(1), + "__APP_LIBRARY_SIGNALS__": re.search(r"Connections \{\s+target: shell\.appLibrary\n.*?\n \}", source, re.S).group(), +} +for marker, code in replacements.items(): + fixture = fixture.replace(marker, code) +Path(sys.argv[2]).write_text(fixture) +PY + +# No windows or Wayland services: this test also runs with Qt's offscreen backend. +if ! env -u WAYLAND_DISPLAY QT_QPA_PLATFORM=offscreen \ + timeout 8 quickshell --no-color -p "$tmpdir" >"$tmpdir/log" 2>&1; then + fail "panel menu capability fixture runs" "$(<"$tmpdir/log")" +fi +if ! grep -q 'PANEL_MENU_OK' "$tmpdir/log"; then + fail "menu plugins retain their app library across the Qt model boundary" "$(<"$tmpdir/log")" +fi +pass "menu plugins retain their app library across the Qt model boundary" +pass "ordinary panels cannot access the app library" +pass "app and icon refresh signals reach menu plugins"