Merge pull request #9618 from acrogenesis/security/plugin-auth-boundary

Restrict third-party plugin access to authentication services
This commit is contained in:
Ryan Hughes authored and GitHub committed 2026-09-07 03:14:48 -04:00
commit e78d89ee2a
27 files changed
+2016 -58

No files matched your search

+1 -3
View File
@@ -20,9 +20,7 @@ Run `omarchy-restart-shell` after making changes to QML files.
[`docs/omarchy-shell.md`](../../docs/omarchy-shell.md) and
`shell/services/PluginRegistry.qml` for the current contract; fields such as
`activation` are optional.
- Entry-point QML files are `Item`s (not `ShellRoot`), and accept the
shell-injected properties `omarchyPath`, `shell`, `manifest`, and
`pluginRegistry` / `barWidgetRegistry` as appropriate.
- Entry-point QML files are `Item`s (not `ShellRoot`), and accept the shell-injected properties `omarchyPath`, `shell`, `manifest`, and `pluginRegistry` / `barWidgetRegistry` as appropriate. First-party plugins receive the host objects. Third-party plugins receive capability-scoped facades: ordinary plugins may look up and control only their own service and lifecycle, built-in clones retain narrow source-specific configuration and UI compatibility, menu plugins receive an application-library facade, and plugins can read detached scalar bar state; full-bar plugins additionally receive detached bar configuration and widget-catalog snapshots, narrow proxies for the non-authentication services used by built-in bar widgets, and lifecycle control over configured non-authentication UI plugins. Authentication capabilities must be stamped from trusted first-party manifests, and third-party registry views and bar configuration must be detached snapshots rather than shared objects. These facades reduce accidental authority but are not a same-process QML sandbox: a visual bar widget can walk its parent hierarchy to ordinary host objects. Authentication services must therefore remain outside both `ShellRoot._services` and the host QObject tree. Do not expose authentication services through new third-party-facing properties.
- Panel / overlay / menu plugins must expose `open(payloadJson)` and
`close()` lifecycle methods for `shell summon` and `shell hide`.
+4 -10
View File
@@ -39,10 +39,9 @@ Only one full bar option is active at a time. The built-in `omarchy.bar` is
used when `bar.id` is omitted or when a selected third-party bar cannot load.
Panels, overlays, and menus are loaded when summoned. Plugins can set the top-level manifest key `keepLoaded: true` to survive between summons, and to keep a service mounted across plugin hot-reload (so `omarchy.lock` is not destroyed while Hyprland still holds the session lock). First-party services are loaded at startup.
Entry points are QML `Item`s. Panel, overlay, and menu entry points expose
`open(payloadJson)` and `close()` for summon/hide; on load the host injects
`omarchyPath`, `shell`, `manifest`, and the registries (`pluginRegistry` /
`barWidgetRegistry`) as properties.
Entry points are QML `Item`s. Panel, overlay, and menu entry points expose `open(payloadJson)` and `close()` for summon/hide; on load the host injects `omarchyPath`, `shell`, `manifest`, and the registries (`pluginRegistry` / `barWidgetRegistry`) as properties. Built-in plugins receive the trusted host objects. Third-party plugins receive capability-scoped facades instead: ordinary plugins may look up and control only their own service and lifecycle, built-in clones retain narrow source-specific configuration and UI compatibility, menu plugins receive an application-library facade, and plugins can read detached scalar bar state. A full-bar plugin additionally receives detached bar configuration and widget-catalog snapshots, narrow proxies for the non-authentication services used by built-in bar widgets, and lifecycle control over configured non-authentication UI plugins. Authentication capabilities are stamped from trusted first-party manifests, authentication services are kept out of the host's public service map and QML object tree, and third-party registry/configuration snapshots can be changed only locally without mutating host state. The facades are API boundaries, not same-process QML sandboxes: a visual widget shares the host bar's scene and can walk its parent hierarchy to ordinary host objects. Sensitive state must not rely on the facade alone for isolation.
A third-party replacement bar can render registered widget components, but widgets it hosts receive a service-less entry facade. Allowing the bar to manufacture an own-service facade for an arbitrary widget would also let it retrieve that plugin's live service object. Service-backed third-party widgets therefore retain their full integration only under the trusted built-in bar; a replacement bar may still provide their target-scoped lifecycle and settings operations.
Full schema: [`shell/services/PluginRegistry.qml`](../shell/services/PluginRegistry.qml).
@@ -79,12 +78,7 @@ one replaces the active bar, and it is therefore never offered under Disable.
Bar widgets may set `barWidget.defaultSection` to `left`, `center`, or `right`;
widgets that omit it default to `center`.
Plugins run as **unsandboxed code** inside `omarchy-shell`. Adding warns you
before cloning, plugins land disabled so you can review the code before
`omarchy plugin enable`, and updates show a diff before touching anything.
Commands confirm in a terminal even when given arguments; without one they
refuse rather than guess. Add `--yes` to skip every prompt (the path for
scripts and agents).
Plugins run as **unsandboxed code** inside `omarchy-shell`. Adding warns you before cloning, plugins land disabled so you can review the code before `omarchy plugin enable`, and updates show a diff before touching anything. Commands confirm in a terminal even when given arguments; without one they refuse rather than guess. Add `--yes` to skip every prompt (the path for scripts and agents). The scoped interfaces remove direct access to authentication services and avoid handing generic cross-plugin service factories to replacement bars, but visual plugins can still traverse ordinary objects in their shared QML scene. Plugin code also has the same user-level file and process access as the shell.
You can still install by hand: drop a plugin into
`~/.config/omarchy/plugins/<id>/`, run `omarchy-shell shell rescanPlugins`, then
+4 -2
View File
@@ -4,7 +4,7 @@ The Omarchy desktop runs as a single long-lived Quickshell process called `omarc
That's not just an implementation detail. It means you can turn pieces of the desktop off, swap them out, or write your own without touching a line of Omarchy's source.
The first-party plugins ship with Omarchy and live in `$OMARCHY_PATH/shell/plugins/`. Anything you add yourself — your own experiments, or something you found on GitHub — lives in `~/.config/omarchy/plugins/`. Both are discovered the same way at startup; the only difference is where they sit on disk.
The first-party plugins ship with Omarchy and live in `$OMARCHY_PATH/shell/plugins/`. Anything you add yourself — your own experiments, or something you found on GitHub — lives in `~/.config/omarchy/plugins/`. Both are discovered the same way at startup, but built-ins receive trusted shell interfaces while third-party plugins receive a limited interface scoped to their own service and lifecycle. Clones of built-ins keep only the source-specific configuration and UI calls needed for the original behavior.
## Seeing what you have
@@ -37,7 +37,9 @@ A third-party plugin is just a git repo with a `manifest.json` at its root.
omarchy plugin add https://github.com/acme/omarchy-weather.git --enable
```
Before it does anything, it tells you plainly that plugins run as arbitrary, unsandboxed code inside your long-lived shell process, shows you the URL, and asks you to confirm. Take that seriously. A plugin isn't a config file — it's code that runs for as long as your session does, with everything your user account can reach. Only add repos you're willing to run, and read them before you enable them.
Before it does anything, it tells you plainly that plugins run as arbitrary, unsandboxed code inside your long-lived shell process, shows you the URL, and asks you to confirm. Take that seriously. The third-party plugin interface does not directly expose authentication services, and a replacement bar receives only limited capabilities for configured non-authentication UI. Visual plugins still share the shell's QML scene and can walk ordinary parent objects, while all plugin code runs with everything your user account can reach. Authentication state is protected separately by keeping those services outside the reachable host object graph. Only add repos you're willing to run, and read them before you enable them.
A replacement bar can render installed widgets, but service-backed third-party widgets may have reduced functionality there because the bar is not allowed to request another plugin's live service object. Switch back to the built-in `omarchy.bar` if such a widget needs its companion service.
Then it clones the repo into a staging directory, validates the manifest, refuses the install if another plugin already claims that id, and moves it into `~/.config/omarchy/plugins/<id>/`. Without `--enable` it asks whether you want it on now, and you can say no and go read the code first. It never runs anything from the plugin, never executes an install hook, and never asks for sudo — it clones files, checks the manifest, and flips a bit over IPC.
+5 -4
View File
@@ -93,6 +93,10 @@ holds the session lock. The kept instance is not replaced, so code
changes to a `keepLoaded` service itself only take effect on a shell
restart. First-party services are loaded at startup.
Entry points may declare `omarchyPath`, `shell`, `manifest`, `pluginRegistry`, and `barWidgetRegistry` properties for host injection. Built-in plugins receive the trusted host objects. Third-party plugins receive capability-scoped facades: ordinary plugins can look up and control only their own service and lifecycle, built-in clones retain narrow source-specific configuration and UI compatibility, menu plugins receive an application-library facade, and plugins can read detached scalar bar state. A full-bar plugin additionally receives detached bar configuration and widget-catalog snapshots, narrow proxies for the non-authentication services used by built-in bar widgets, and lifecycle control over configured non-authentication UI plugins. Authentication capabilities are stamped from trusted first-party manifests, authentication services are retained outside the host's public service map and QML object tree, and changing a third-party registry or configuration snapshot cannot mutate host state. Facades do not isolate visual widgets from the parent hierarchy of the shared QML scene, so sensitive state must remain outside that reachable graph.
Widgets rendered by a third-party replacement bar receive a service-less entry facade with target-scoped lifecycle and settings operations. Their live service objects are available only when the trusted built-in bar hosts them; otherwise the replacement bar could request and retain any configured widget's service.
The full schema lives in `services/PluginRegistry.qml`.
## Installing a third-party plugin
@@ -108,10 +112,7 @@ omarchy plugin update # updates every git-managed plugin
omarchy plugin remove acme.weather
```
> ⚠️ **Plugins run as unsandboxed code inside `omarchy-shell`.** Adding warns
> you before cloning, plugins land disabled so you can review the code before
> enabling, and updates show a diff of the changes before touching anything.
> Only add repos whose code you are willing to run.
> ⚠️ **Plugins run as unsandboxed code inside `omarchy-shell`.** Adding warns you before cloning, plugins land disabled so you can review the code before enabling, and updates show a diff of the changes before touching anything. The scoped QML interfaces remove direct authentication-service and generic replacement-bar service lookups, but visual plugins still share and can traverse the ordinary host scene. Only add repos whose code you are willing to run.
Each command is **interactive** when run bare in a terminal (gum pickers,
confirmation, a diff to review) and fully **non-interactive** when given
+87
View File
@@ -0,0 +1,87 @@
import QtQuick
// Bar surface exposed to an installed third-party widget. Scalar presentation
// state is mirrored by Bar.qml and operations are delegated through scoped
// callbacks. The facade avoids direct host-Bar injection; it cannot isolate a
// visual child from the parent hierarchy of the QML scene that renders it.
QtObject {
id: api
required property string pluginId
required property string moduleName
property var shell: null
property color foreground: "transparent"
property color barForeground: "transparent"
property color background: "transparent"
property color urgent: "transparent"
property string fontFamily: ""
property string position: "top"
property bool vertical: false
property int barSize: 0
property bool transparent: false
property bool foregroundAnimationEnabled: true
property bool centerSectionRevealHeld: false
property bool _centerHoverRevealSuppressed: false
readonly property bool centerHoverRevealSuppressed: _centerHoverRevealSuppressed
property var activePopout: null
property var clickTargets: []
property var layoutConfig: ({})
readonly property var foreignPopoutMarker: ({ foreign: true })
property var _showTooltip: null
property var _hideTooltip: null
property var _registerClickTarget: null
property var _unregisterClickTarget: null
property var _requestPopout: null
property var _releasePopout: null
property var _switchPanelFrom: null
property var _targetBelongsToWindow: null
property var _moduleWidgets: null
property var _run: null
property var _setCenterHoverRevealSuppressed: null
function setCenterHoverRevealSuppressed(value) {
if (_setCenterHoverRevealSuppressed) _setCenterHoverRevealSuppressed(!!value)
}
function showTooltip(target, text) {
if (_showTooltip) _showTooltip(target, String(text || ""))
}
function hideTooltip(target) {
if (_hideTooltip) _hideTooltip(target)
}
function registerClickTarget(target) {
if (_registerClickTarget) _registerClickTarget(target)
}
function unregisterClickTarget(target) {
if (_unregisterClickTarget) _unregisterClickTarget(target)
}
function requestPopout(owner) {
if (_requestPopout) _requestPopout(owner)
}
function releasePopout(owner) {
if (_releasePopout) _releasePopout(owner)
}
function switchPanelFrom(owner, direction) {
return _switchPanelFrom ? _switchPanelFrom(owner, direction) : false
}
function targetBelongsToWindow(target, window) {
return _targetBelongsToWindow ? _targetBelongsToWindow(target, window) : false
}
function moduleWidgets(id) {
return _moduleWidgets ? _moduleWidgets(String(id || "")) : []
}
function run(command) {
if (_run) _run(String(command || ""))
}
}
+1
View File
@@ -25,6 +25,7 @@ PanelSectionHeader 1.0 PanelSectionHeader.qml
PanelSeparator 1.0 PanelSeparator.qml
PanelSlider 1.0 PanelSlider.qml
PanelToolTip 1.0 PanelToolTip.qml
PluginBarApi 1.0 PluginBarApi.qml
PointerMoveGate 1.0 PointerMoveGate.qml
ScreenMoveRemap 1.0 ScreenMoveRemap.qml
PopupCard 1.0 PopupCard.qml
+238 -4
View File
@@ -12,20 +12,29 @@ Item {
id: root
// The omarchy-shell host injects omarchyPath from OMARCHY_PATH.
required property string omarchyPath
property string omarchyPath: Quickshell.env("OMARCHY_PATH")
// Injected by the host shell so bar slots can resolve enabled widgets.
required property var barWidgetRegistry
property var barWidgetRegistry: fallbackBarWidgetRegistry
// Read-only registry view for third-party full bars; the built-in bar does
// not otherwise need it, but declaring it keeps clone construction atomic.
property var pluginRegistry: null
// Injected by the host shell every time shell.json is reloaded. Holds the
// `bar:` subtree: position, centerAnchor, layout. The host owns file IO;
// the bar just renders whatever it's handed. The bar font follows the
// OS-level fontconfig monospace binding — it is not stored in shell.json.
required property var barConfig
property var barConfig: ({})
// Injected by the host shell. Used for shell-wide actions such as opening
// settings and persisting inline widget state.
property var shell: null
// Manifest for the active bar option. Present for custom bars and useful for
// diagnostics; the built-in bar does not otherwise need it.
property var manifest: null
QtObject {
id: fallbackBarWidgetRegistry
property var widgets: ({})
property int revision: 0
function metadataFor(id) { return null }
}
// Mirrors the on-disk `bar-off` flag so the user can hide the bar without
// killing the entire shell. Hidden panels stay mapped but park off-screen
// without an exclusion zone; updated by the FileView watcher further down.
@@ -100,6 +109,223 @@ Item {
property var barMoveScreen: null
property var clickTargets: []
property var moduleSlots: []
property var pluginBarApis: ({})
property var pluginObjectOwners: []
Component {
id: pluginBarApiComponent
PluginBarApi { }
}
function publicLayoutConfig() {
return JSON.parse(JSON.stringify(root.layoutConfig || {}))
}
function bindPluginBarApi(api) {
if (!api) return
api.foreground = Qt.binding(function() { return root.foreground })
api.barForeground = Qt.binding(function() { return root.barForeground })
api.background = Qt.binding(function() { return root.background })
api.urgent = Qt.binding(function() { return root.urgent })
api.fontFamily = Qt.binding(function() { return root.fontFamily })
api.position = Qt.binding(function() { return root.position })
api.vertical = Qt.binding(function() { return root.vertical })
api.barSize = Qt.binding(function() { return root.barSize })
api.transparent = Qt.binding(function() { return root.transparent })
api.foregroundAnimationEnabled = Qt.binding(function() { return root.foregroundAnimationEnabled })
api.centerSectionRevealHeld = Qt.binding(function() { return root.centerSectionRevealHeld })
api._centerHoverRevealSuppressed = Qt.binding(function() { return root.centerHoverRevealSuppressed })
root.syncPluginBarApiObjects(api)
}
function syncPluginBarApiObjects(api) {
if (!api) return
api.activePopout = root.pluginOwnsBarObject(api.pluginId, root.activePopout)
? root.activePopout : (root.activePopout ? api.foreignPopoutMarker : null)
api.clickTargets = root.pluginClickTargets(api.pluginId)
api.layoutConfig = root.publicLayoutConfig()
}
function pluginObjectRecord(target) {
for (var i = 0; i < pluginObjectOwners.length; i++) {
var record = pluginObjectOwners[i]
if (record && record.target === target) return record
}
return null
}
function markPluginObject(pluginId, target, role) {
var key = String(pluginId || "")
if (!key || !target) return false
var record = root.pluginObjectRecord(target)
if (record && record.pluginId !== key) return false
var next = []
for (var i = 0; i < pluginObjectOwners.length; i++) {
var existing = pluginObjectOwners[i]
if (!existing || existing.target !== target) next.push(existing)
}
var updated = record || { target: target, pluginId: key, clickTarget: false, popout: false }
updated[role] = true
next.push(updated)
pluginObjectOwners = next
return true
}
function unmarkPluginObject(pluginId, target, role) {
var key = String(pluginId || "")
var next = []
for (var i = 0; i < pluginObjectOwners.length; i++) {
var record = pluginObjectOwners[i]
if (!record || record.target !== target || record.pluginId !== key) {
next.push(record)
continue
}
record[role] = false
if (record.clickTarget || record.popout) next.push(record)
}
pluginObjectOwners = next
}
function pluginOwnsBarObject(pluginId, target) {
var record = target ? root.pluginObjectRecord(target) : null
return !!record && record.pluginId === String(pluginId || "")
}
function pluginClickTargets(pluginId) {
var out = []
for (var i = 0; i < root.clickTargets.length; i++) {
var target = root.clickTargets[i]
if (root.pluginOwnsBarObject(pluginId, target)) out.push(target)
}
return out
}
function syncAllPluginBarApiObjects() {
for (var id in pluginBarApis) root.syncPluginBarApiObjects(pluginBarApis[id])
}
function registerPluginClickTarget(pluginId, target) {
if (!root.markPluginObject(pluginId, target, "clickTarget")) return
root.registerClickTarget(target)
}
function unregisterPluginClickTarget(pluginId, target) {
if (!root.pluginOwnsBarObject(pluginId, target)) return
root.unregisterClickTarget(target)
root.unmarkPluginObject(pluginId, target, "clickTarget")
}
function requestPluginPopout(pluginId, owner) {
if (!root.markPluginObject(pluginId, owner, "popout")) return
root.requestPopout(owner)
}
function releasePluginPopout(pluginId, owner) {
if (!root.pluginOwnsBarObject(pluginId, owner)) return
root.releasePopout(owner)
root.unmarkPluginObject(pluginId, owner, "popout")
}
function pluginBarApiFor(pluginId, moduleName, registered) {
var key = String(pluginId || "")
if (!key) return null
var pluginShell = null
if (registered && root.shell && typeof root.shell.pluginShellForId === "function") {
// Only the trusted built-in bar receives ShellRoot and can request a
// service-capable facade for the widget it is instantiating.
pluginShell = root.shell.pluginShellForId(moduleName)
} else if (root.shell && typeof root.shell.pluginShellForBarEntry === "function") {
// Replacement bars receive a service-less entry facade. Giving an
// untrusted bar a generic facade factory would let it retrieve another
// third-party plugin's live service object.
pluginShell = root.shell.pluginShellForBarEntry(key, moduleName)
}
if (pluginBarApis[key]) {
pluginBarApis[key].shell = pluginShell
return pluginBarApis[key]
}
var api = pluginBarApiComponent.createObject(null, {
pluginId: key,
moduleName: String(moduleName || ""),
shell: pluginShell,
_showTooltip: function(target, text) { root.showTooltip(target, text) },
_hideTooltip: function(target) { root.hideTooltip(target) },
_registerClickTarget: function(target) { root.registerPluginClickTarget(key, target) },
_unregisterClickTarget: function(target) { root.unregisterPluginClickTarget(key, target) },
_requestPopout: function(owner) { root.requestPluginPopout(key, owner) },
_releasePopout: function(owner) { root.releasePluginPopout(key, owner) },
_switchPanelFrom: function(owner, direction) { return root.switchPanelFrom(owner, direction) },
_targetBelongsToWindow: function(target, window) { return root.targetBelongsToWindow(target, window) },
_moduleWidgets: function(requestedId) {
return String(requestedId || "") === String(moduleName || "")
? root.moduleWidgets(moduleName) : []
},
_run: function(command) { root.run(command) },
_setCenterHoverRevealSuppressed: function(value) {
root.centerHoverRevealSuppressed = !!value
}
})
if (!api) return null
root.bindPluginBarApi(api)
var next = ({})
for (var id in pluginBarApis) next[id] = pluginBarApis[id]
next[key] = api
pluginBarApis = next
return api
}
function pluginBarApiUsed(pluginId) {
for (var i = 0; i < moduleSlots.length; i++) {
var slot = moduleSlots[i]
if (slot && slot.pluginApiId === pluginId) return true
}
return false
}
function releasePluginObjects(pluginId) {
var owned = pluginObjectOwners.slice()
for (var i = 0; i < owned.length; i++) {
var record = owned[i]
if (!record || record.pluginId !== pluginId) continue
if (record.clickTarget) root.unregisterClickTarget(record.target)
if (record.popout && root.activePopout === record.target) root.releasePopout(record.target)
}
pluginObjectOwners = pluginObjectOwners.filter(function(record) {
return record && record.pluginId !== pluginId
})
}
function prunePluginBarApis() {
var next = ({})
for (var id in pluginBarApis) {
var api = pluginBarApis[id]
if (root.pluginBarApiUsed(id)) {
next[id] = api
continue
}
root.releasePluginObjects(id)
if (api && typeof api.destroy === "function") api.destroy()
}
pluginBarApis = next
}
onActivePopoutChanged: syncAllPluginBarApiObjects()
onClickTargetsChanged: syncAllPluginBarApiObjects()
onLayoutConfigChanged: syncAllPluginBarApiObjects()
onModuleSlotsChanged: Qt.callLater(prunePluginBarApis)
Component.onDestruction: {
for (var id in pluginBarApis) {
root.releasePluginObjects(id)
if (pluginBarApis[id] && typeof pluginBarApis[id].destroy === "function")
pluginBarApis[id].destroy()
}
pluginBarApis = ({})
}
function registerClickTarget(target) {
if (!target || clickTargets.indexOf(target) !== -1) return
@@ -599,6 +825,10 @@ Item {
if (barHoverCount === 0) centerSectionRevealTimer.restart()
}
function setCenterHoverRevealSuppressed(value) {
centerHoverRevealSuppressed = !!value
}
Timer {
id: centerSectionRevealTimer
interval: 120
@@ -1548,6 +1778,9 @@ Item {
readonly property string moduleName: root.entryId(entry)
readonly property var moduleSettings: root.entrySettings(entry)
readonly property string customType: root.customModuleType(entry)
readonly property var registryMetadata: root.barWidgetRegistry.metadataFor(root.canonicalWidgetId(moduleName))
readonly property bool firstParty: registryMetadata && registryMetadata.firstParty === true
readonly property string pluginApiId: registered ? root.canonicalWidgetId(moduleName) : "bar-entry:" + moduleName
// Re-evaluate when the registry mutates (Component reference changes,
// plugin enabled/disabled, etc.). Reading the `widgets` property creates
// the binding dependency — the wrapped function call alone wouldn't.
@@ -1766,7 +1999,8 @@ Item {
function injectProps() {
var target = activeItem
if (!target) return
if ("bar" in target) target.bar = root
if ("bar" in target) target.bar = firstParty
? root : root.pluginBarApiFor(pluginApiId, moduleName, registered)
if ("moduleName" in target) target.moduleName = moduleName
if ("settings" in target) target.settings = moduleSettings
}
+5
View File
@@ -5,6 +5,11 @@
"version": "1.0.0",
"author": "Omarchy",
"description": "Quickshell session lock with separate password and fingerprint PAM flows.",
"omarchy": {
"capabilities": [
"authentication"
]
},
"kinds": [
"service"
],
+3 -1
View File
@@ -119,7 +119,9 @@ Panel {
// Summoning by hotkey moves no pointer, so a hover the bar was still
// holding must not keep the center indicators revealed behind the panel.
function setCenterHoverRevealSuppressed(value) {
if (root.bar && "centerHoverRevealSuppressed" in root.bar)
if (root.bar && typeof root.bar.setCenterHoverRevealSuppressed === "function")
root.bar.setCenterHoverRevealSuppressed(value)
else if (root.bar && "centerHoverRevealSuppressed" in root.bar)
root.bar.centerHoverRevealSuppressed = value
}
+3 -1
View File
@@ -63,7 +63,9 @@ Panel {
}
function setCenterHoverRevealSuppressed(value) {
if (root.bar && "centerHoverRevealSuppressed" in root.bar)
if (root.bar && typeof root.bar.setCenterHoverRevealSuppressed === "function")
root.bar.setCenterHoverRevealSuppressed(value)
else if (root.bar && "centerHoverRevealSuppressed" in root.bar)
root.bar.centerHoverRevealSuppressed = value
}
+5
View File
@@ -5,6 +5,11 @@
"version": "1.0.0",
"author": "Omarchy",
"description": "Theme-aware authentication dialog for privileged actions.",
"omarchy": {
"capabilities": [
"authentication"
]
},
"kinds": [
"service"
],
+2 -1
View File
@@ -16,7 +16,8 @@ Item {
readonly property string stayAwakeStatePath: stayAwakeStateDir + "/stay-awake"
readonly property int defaultScreensaverSeconds: 150
readonly property int defaultLockSeconds: 300
readonly property var idleConfig: shell && shell.shellConfig && shell.shellConfig.idle ? shell.shellConfig.idle : ({})
readonly property var idleConfig: shell && shell.shellConfig && shell.shellConfig.idle
? shell.shellConfig.idle : (shell && shell.idleConfig ? shell.idleConfig : ({}))
readonly property int screensaverTimeoutSeconds: secondsFromConfig(idleConfig.screensaver, defaultScreensaverSeconds)
readonly property int lockTimeoutSeconds: secondsFromConfig(idleConfig.lock, defaultLockSeconds)
readonly property int firstIdleTimeoutSeconds: Math.min(screensaverTimeoutSeconds, lockTimeoutSeconds)
+45
View File
@@ -0,0 +1,45 @@
// Intentionally not `.pragma library`: QML JavaScript imports get a private
// module instance per importing component. shell.qml's instance retains the
// authentication services; a third-party plugin importing this file receives
// a separate empty store rather than a shared path to credential-bearing QML.
var services = ({})
var trustedIds = ({})
function has(id) {
return services[String(id || "")] !== undefined
}
function put(id, service) {
var key = String(id || "")
if (!key || !service) return
trustedIds[key] = true
if (services[key] && services[key] !== service && typeof services[key].destroy === "function")
services[key].destroy()
services[key] = service
}
function isTrusted(id) {
return trustedIds[String(id || "")] === true
}
function ids() {
return Object.keys(services)
}
function updateManifest(id, manifest) {
var service = services[String(id || "")]
if (service && "manifest" in service) service.manifest = manifest
}
function destroy(id) {
var key = String(id || "")
var service = services[key]
if (service && typeof service.destroy === "function") service.destroy()
delete services[key]
}
function destroyAll() {
var keys = ids()
for (var i = 0; i < keys.length; i++) destroy(keys[i])
}
+46
View File
@@ -0,0 +1,46 @@
import QtQuick
// Detached application-library capability for third-party menus. Callbacks
// expose the supported app-list operations without retaining AppLibrary or its
// ShellRoot parent in the plugin-visible object graph.
QtObject {
required property string ownerPluginId
signal appsChanged()
property var _entryName: null
property var _entrySubtext: null
property var _sortedEntries: null
property var _iconSource: null
property var _refreshIcons: null
property var _launch: null
property var _remove: null
function entryName(entry) {
return _entryName ? _entryName(entry) : ""
}
function entrySubtext(entry) {
return _entrySubtext ? _entrySubtext(entry) : ""
}
function sortedEntries(query) {
return _sortedEntries ? _sortedEntries(String(query || "")) : []
}
function iconSource(icon) {
return _iconSource ? _iconSource(icon) : ""
}
function refreshIcons() {
if (_refreshIcons) _refreshIcons()
}
function launch(desktopId, name) {
if (_launch) _launch(String(desktopId || ""), String(name || ""))
}
function remove(desktopId, name) {
if (_remove) _remove(String(desktopId || ""), String(name || ""))
}
}
+12
View File
@@ -0,0 +1,12 @@
import QtQuick
// Scalar-only view of the active bar for plugins that position independent
// windows. The active Bar QObject is never retained here.
QtObject {
required property string ownerPluginId
property bool barHidden: false
property int barSize: 0
property string fontFamily: ""
property string position: "top"
}
@@ -0,0 +1,24 @@
import QtQuick
// Detached widget-catalogue snapshot for third-party full-bar implementations.
// Plugins can render the referenced components, but mutating this local view
// cannot replace a registration in the host registry.
QtObject {
id: api
property var widgets: ({})
property int revision: 0
function metadataFor(id) {
var entry = widgets[String(id || "")]
return entry ? entry.metadata : null
}
function availableIds() {
return Object.keys(widgets)
}
function has(id) {
return widgets[String(id || "")] !== undefined
}
}
@@ -0,0 +1,46 @@
import QtQuick
// Narrow proxy for the non-authentication first-party services used by the
// built-in bar. It intentionally has no generic property or method forwarding.
QtObject {
required property string ownerPluginId
required property string serviceId
property bool stayAwake: false
property bool enabled: false
property bool doNotDisturb: false
property var activePlayer: null
property var sourcePlayers: []
property var _setIdleEnabled: null
property var _setNightlight: null
property var _setDoNotDisturb: null
property var _runAction: null
property var _playerKey: null
property var _selectPlayer: null
function setIdleEnabled(value) {
if (serviceId === "omarchy.idle" && _setIdleEnabled) _setIdleEnabled(!!value)
}
function setNightlight(value) {
if (serviceId === "omarchy.nightlight" && _setNightlight) _setNightlight(!!value)
}
function setDoNotDisturb(value) {
if (serviceId === "omarchy.notifications" && _setDoNotDisturb) _setDoNotDisturb(!!value)
}
function runAction(action, showFeedback, playerId) {
if (serviceId === "omarchy.media" && _runAction)
_runAction(String(action || ""), !!showFeedback, String(playerId || ""))
}
function playerKey(player) {
return serviceId === "omarchy.media" && _playerKey ? _playerKey(player) : ""
}
function selectPlayer(playerId) {
if (serviceId === "omarchy.media" && _selectPlayer) _selectPlayer(String(playerId || ""))
}
}
+30 -3
View File
@@ -20,7 +20,7 @@ QtObject {
property var shellConfigProvider: null
property var shellConfigMutator: null
// { pluginId: manifest } — manifests have __sourceDir and __isFirstParty stamped in.
// { pluginId: manifest } — manifests have source/trust metadata stamped in.
property var installedPlugins: ({})
property int registryRevision: 0
property bool scanning: false
@@ -78,8 +78,7 @@ QtObject {
}
}
// Every entry point must be a relative path inside the plugin's source
// directory. Reject the whole manifest if anything looks like an attempt
// to escape the plugin's sandbox.
// directory. Reject the whole manifest if an entry point escapes it.
for (var key in manifest.entryPoints) {
if (!isSafeEntryPoint(manifest.entryPoints[key])) {
console.warn("PluginRegistry: unsafe entryPoint '" + key + "'='"
@@ -90,6 +89,32 @@ QtObject {
return manifest
}
function trustedCapabilities(manifest) {
if (!manifest || !manifest.__isFirstParty) return []
var metadata = Util.isPlainObject(manifest.omarchy) ? manifest.omarchy : null
var declared = metadata && Array.isArray(metadata.capabilities) ? metadata.capabilities : []
var out = []
for (var i = 0; i < declared.length; i++) {
var capability = String(declared[i] || "")
if (capability && out.indexOf(capability) === -1) out.push(capability)
}
return out
}
function stampHostCapabilities(firstParty, thirdParty) {
for (var firstPartyId in firstParty)
firstParty[firstPartyId].__hostCapabilities = trustedCapabilities(firstParty[firstPartyId])
for (var thirdPartyId in thirdParty) {
var manifest = thirdParty[thirdPartyId]
var metadata = manifest && Util.isPlainObject(manifest.omarchy) ? manifest.omarchy : null
var clonedFrom = metadata ? String(metadata.clonedFrom || "") : ""
var source = clonedFrom ? firstParty[clonedFrom] : null
manifest.__hostCapabilities = source && Array.isArray(source.__hostCapabilities)
? source.__hostCapabilities.slice() : []
}
}
function entryPointUrl(manifest, kind) {
if (!Util.isPlainObject(manifest)) return ""
var ep = manifest.entryPoints ? manifest.entryPoints[kind] : null
@@ -594,6 +619,8 @@ QtObject {
}
flush()
stampHostCapabilities(firstParty, thirdParty)
var merged = {}
for (var fk in firstParty) merged[fk] = firstParty[fk]
// Third-party plugins never shadow first-party ids. The whole
+32
View File
@@ -0,0 +1,32 @@
import QtQuick
// Read-only, self-scoped registry view for an installed third-party plugin.
// The host updates manifest/enabled when it rescans; no host registry object is
// retained here, so `parent` and property traversal cannot reach ShellRoot.
QtObject {
id: api
required property string pluginId
property var manifest: null
property bool enabled: false
property var _entryPointUrl: null
readonly property var installedPlugins: {
var out = ({})
if (manifest) out[pluginId] = manifest
return out
}
function isEnabled(id) {
return String(id || "") === pluginId && enabled
}
function resolveEnabledId(id) {
return String(id || "") === pluginId ? pluginId : ""
}
function entryPointUrl(candidate, kind) {
if (!candidate || String(candidate.id || "") !== pluginId) return ""
return _entryPointUrl ? _entryPointUrl(String(kind || "")) : ""
}
}
+70
View File
@@ -0,0 +1,70 @@
import QtQuick
// Capability-scoped shell surface for installed third-party plugins.
//
// The callbacks are closed over one plugin id by shell.qml. A plugin can call
// them directly, but it cannot widen their scope: ordinary plugins are limited
// to their own id, and full-bar callbacks independently enforce their explicit
// non-authentication UI scope. This object avoids directly injecting the host
// shell, but it is not a QML sandbox: visual plugins share the host object tree.
QtObject {
id: api
required property string pluginId
property var appLibrary: null
property var bar: null
property var barConfig: ({})
property var idleConfig: ({})
property var _serviceLookup: null
property var _firstPartyServiceLookup: null
property var _barEntryShellLookup: null
property var _summon: null
property var _hide: null
property var _toggle: null
property var _isOpen: null
property var _updateSettings: null
property var _mutateBarConfig: null
function serviceFor(id) {
return _serviceLookup ? _serviceLookup(String(id || "")) : null
}
// Only full-bar facades receive narrow proxies for the specific
// non-authentication services used by the built-in bar widgets.
function firstPartyServiceFor(id) {
return _firstPartyServiceLookup
? _firstPartyServiceLookup(String(id || "")) : null
}
function pluginShellForBarEntry(ownerId, moduleName) {
return _barEntryShellLookup
? _barEntryShellLookup(String(ownerId || ""), String(moduleName || "")) : null
}
function summon(id, payloadJson) {
return _summon ? _summon(String(id || ""), String(payloadJson || "")) : false
}
function hide(id) {
return _hide ? _hide(String(id || "")) : false
}
function toggle(id, payloadJson) {
return _toggle ? _toggle(String(id || ""), String(payloadJson || "")) : false
}
function isPluginOpen(id) {
return _isOpen ? _isOpen(String(id || "")) : false
}
function updateEntryInline(id, settings) {
return _updateSettings ? _updateSettings(String(id || ""), settings) : false
}
function mutateShellConfig(mutator) {
return _mutateBarConfig && typeof mutator === "function"
? _mutateBarConfig(mutator) : false
}
}
+704 -29
View File
@@ -7,6 +7,7 @@ import qs.Commons
import "plugins/bar"
import "services"
import "services/AuthServiceStore.js" as AuthServiceStore
ShellRoot {
id: shell
@@ -113,7 +114,10 @@ ShellRoot {
}
readonly property var barConfig: shellConfig && Util.isPlainObject(shellConfig.bar) ? shellConfig.bar : builtinShellConfig.bar
onBarConfigChanged: if (bar && "barConfig" in bar) bar.barConfig = shell.barConfig
onBarConfigChanged: {
if (bar && "barConfig" in bar)
bar.barConfig = shell.barConfigFor(shell.activeBarManifest)
}
FileView {
id: defaultsFile
path: shell.defaultsPath
@@ -214,11 +218,11 @@ ShellRoot {
function configureBar(target, manifest) {
if (!target) return
if ("omarchyPath" in target) target.omarchyPath = shell.omarchyPath
if ("shell" in target) target.shell = shell
if ("manifest" in target) target.manifest = manifest
if ("barWidgetRegistry" in target) target.barWidgetRegistry = shell.barWidgetRegistry
if ("pluginRegistry" in target) target.pluginRegistry = shell.pluginRegistry
if ("barConfig" in target) target.barConfig = shell.barConfig
if ("shell" in target) target.shell = shell.pluginShellFor(manifest)
if ("manifest" in target) target.manifest = shell.publicPluginManifest(manifest)
if ("barWidgetRegistry" in target) target.barWidgetRegistry = shell.pluginBarWidgetRegistryFor(manifest)
if ("pluginRegistry" in target) target.pluginRegistry = shell.pluginRegistryFor(manifest)
if ("barConfig" in target) target.barConfig = shell.barConfigFor(manifest)
shell.bar = target
}
@@ -253,8 +257,7 @@ ShellRoot {
onActiveChanged: if (!active) shell.bar = null
onStatusChanged: {
if (status === Loader.Error) {
var detail = errorString && errorString() ? errorString() : ""
console.warn("bar option " + shell.activeBarId + " failed to load, falling back to " + shell.defaultBarId + ":", detail)
console.warn("bar option " + shell.activeBarId + " failed to load, falling back to " + shell.defaultBarId)
shell.failedBarId = shell.activeBarId
}
}
@@ -271,6 +274,599 @@ ShellRoot {
}
property var _services: ({})
property var _pluginShellApis: ({})
property var _pluginShellApiDescriptors: ({})
property var _pluginBarEntryShellApis: ({})
property var _pluginRegistryApis: ({})
property var _pluginBarWidgetRegistryApis: ({})
property var _pluginAppLibraryApis: ({})
property var _pluginBarStateApis: ({})
property var _pluginFirstPartyServiceApis: ({})
Component {
id: pluginShellApiComponent
PluginShellApi { }
}
Component {
id: pluginRegistryApiComponent
PluginRegistryApi { }
}
Component {
id: pluginBarWidgetRegistryApiComponent
PluginBarWidgetRegistryApi { }
}
Component {
id: pluginAppLibraryApiComponent
PluginAppLibraryApi { }
}
Component {
id: pluginBarStateApiComponent
PluginBarStateApi { }
}
Component {
id: pluginFirstPartyServiceApiComponent
PluginFirstPartyServiceApi { }
}
function publicPluginManifest(manifest) {
if (!manifest) return null
if (manifest.__isFirstParty) return manifest
var copy = JSON.parse(JSON.stringify(manifest))
delete copy.__sourceDir
delete copy.__isFirstParty
delete copy.__hostCapabilities
return copy
}
function publicBarConfig() {
return JSON.parse(JSON.stringify(shell.barConfig || {}))
}
function barConfigFor(manifest) {
return !manifest || manifest.__isFirstParty
? shell.barConfig : shell.publicBarConfig()
}
function publicBarWidgetSnapshot() {
var source = shell.barWidgetRegistry.widgets || {}
var snapshot = {}
for (var id in source) {
var entry = source[id]
if (!entry) continue
snapshot[id] = {
component: entry.component,
metadata: JSON.parse(JSON.stringify(entry.metadata || {}))
}
}
return snapshot
}
function manifestHasKind(manifest, kind) {
return !!manifest && Array.isArray(manifest.kinds)
&& manifest.kinds.indexOf(kind) !== -1
}
function pluginHasBarCapabilities(manifest) {
return shell.manifestHasKind(manifest, "bar")
}
function publicIdleConfigFor(manifest) {
var metadata = manifest && Util.isPlainObject(manifest.omarchy) ? manifest.omarchy : null
if (!metadata || String(metadata.clonedFrom || "") !== "omarchy.idle") return ({})
var idle = shell.shellConfig && Util.isPlainObject(shell.shellConfig.idle)
? shell.shellConfig.idle : ({})
return JSON.parse(JSON.stringify(idle))
}
function pluginCloneMaySummon(manifest, requestedId) {
var metadata = manifest && Util.isPlainObject(manifest.omarchy) ? manifest.omarchy : null
var sourceId = metadata ? String(metadata.clonedFrom || "") : ""
var allowed = {
"omarchy.audio": ["omarchy.osd"],
"omarchy.media": ["omarchy.osd"],
"omarchy.monitor": ["omarchy.osd"],
"omarchy.network": ["omarchy.speedtest", "omarchy.wifiqr"]
}
var targets = allowed[sourceId] || []
return targets.indexOf(String(requestedId || "")) !== -1
}
function pluginOwnsTarget(pluginId, requestedId) {
var caller = String(pluginId || "")
if (!caller) return false
return shell.pluginRegistry.resolveEnabledId(String(requestedId || "")) === caller
}
function pluginServiceFor(pluginId, requestedId) {
if (!shell.pluginOwnsTarget(pluginId, requestedId)) return null
return shell.serviceFor(shell.pluginRegistry.resolveEnabledId(requestedId))
}
function barEntryConfigured(pluginId) {
var location = shell.pluginRegistry.findEntryLocation(shell.shellConfig, pluginId)
return location && location.kind === "bar"
}
function barPluginMayControl(manifest, requestedId) {
if (!shell.pluginHasBarCapabilities(manifest)) return false
var id = shell.pluginRegistry.resolveEnabledId(String(requestedId || ""))
var target = shell.pluginRegistry.installedPlugins[id]
if (!target || shell.isAuthenticationService(target, id)) return false
if (shell.barEntryConfigured(id)) return true
var uiKinds = ["bar-widget", "panel", "overlay", "menu"]
for (var i = 0; i < uiKinds.length; i++)
if (shell.manifestHasKind(target, uiKinds[i])) return true
return false
}
function mutatePluginBarConfig(mutator) {
if (typeof mutator !== "function") return false
shell.mutateShellConfig(function(config) {
var scoped = { bar: JSON.parse(JSON.stringify(config.bar || {})) }
mutator(scoped)
if (Util.isPlainObject(scoped.bar)) config.bar = JSON.parse(JSON.stringify(scoped.bar))
})
return true
}
function pluginAppLibraryFor(cacheKey, pluginId) {
if (_pluginAppLibraryApis[cacheKey]) return _pluginAppLibraryApis[cacheKey]
var api = pluginAppLibraryApiComponent.createObject(null, {
ownerPluginId: pluginId,
_entryName: function(entry) { return shell.appLibrary.entryName(entry) },
_entrySubtext: function(entry) { return shell.appLibrary.entrySubtext(entry) },
_sortedEntries: function(query) { return shell.appLibrary.sortedEntries(query) },
_iconSource: function(icon) { return shell.appLibrary.iconSource(icon) },
_refreshIcons: function() { shell.appLibrary.refreshIcons() },
_launch: function(desktopId, name) { shell.appLibrary.launch(desktopId, name) },
_remove: function(desktopId, name) { shell.appLibrary.remove(desktopId, name) }
})
if (!api) return null
var next = ({})
for (var id in _pluginAppLibraryApis) next[id] = _pluginAppLibraryApis[id]
next[cacheKey] = api
_pluginAppLibraryApis = next
return api
}
function pluginBarStateFor(cacheKey, pluginId) {
if (_pluginBarStateApis[cacheKey]) return _pluginBarStateApis[cacheKey]
var api = pluginBarStateApiComponent.createObject(null, { ownerPluginId: pluginId })
if (!api) return null
api.barHidden = Qt.binding(function() { return shell.bar ? shell.bar.barHidden === true : false })
api.barSize = Qt.binding(function() { return shell.bar ? Math.max(0, shell.bar.barSize || 0) : 0 })
api.fontFamily = Qt.binding(function() { return shell.bar ? String(shell.bar.fontFamily || "") : "" })
api.position = Qt.binding(function() { return shell.bar ? String(shell.bar.position || "top") : "top" })
var next = ({})
for (var id in _pluginBarStateApis) next[id] = _pluginBarStateApis[id]
next[cacheKey] = api
_pluginBarStateApis = next
return api
}
function pluginFirstPartyServiceFor(cacheKey, pluginId, requestedId) {
var id = String(requestedId || "")
var allowed = ["omarchy.idle", "omarchy.media", "omarchy.nightlight", "omarchy.notifications"]
if (allowed.indexOf(id) === -1) return null
var proxyKey = cacheKey + "::" + id
if (_pluginFirstPartyServiceApis[proxyKey]) return _pluginFirstPartyServiceApis[proxyKey]
function service() {
return shell.serviceFor(shell.pluginRegistry.resolveEnabledId(id))
}
var api = pluginFirstPartyServiceApiComponent.createObject(null, {
ownerPluginId: pluginId,
serviceId: id,
_setIdleEnabled: function(value) {
var target = service()
if (target && typeof target.setIdleEnabled === "function") target.setIdleEnabled(value)
},
_setNightlight: function(value) {
var target = service()
if (target && typeof target.setNightlight === "function") target.setNightlight(value)
},
_setDoNotDisturb: function(value) {
var target = service()
if (target && typeof target.setDoNotDisturb === "function") target.setDoNotDisturb(value)
},
_runAction: function(action, showFeedback, targetKey) {
var target = service()
if (target && typeof target.runAction === "function") target.runAction(action, showFeedback, targetKey)
},
_playerKey: function(player) {
var target = service()
return target && typeof target.playerKey === "function" ? target.playerKey(player) : ""
},
_selectPlayer: function(playerKey) {
var target = service()
if (target && typeof target.selectPlayer === "function") target.selectPlayer(playerKey)
}
})
if (!api) return null
api.stayAwake = Qt.binding(function() {
var target = service()
return target ? target.stayAwake === true : false
})
api.enabled = Qt.binding(function() {
var target = service()
return target ? target.enabled === true : false
})
api.doNotDisturb = Qt.binding(function() {
var target = service()
return target ? target.doNotDisturb === true : false
})
api.activePlayer = Qt.binding(function() {
var target = service()
return target ? target.activePlayer : null
})
api.sourcePlayers = Qt.binding(function() {
var target = service()
return target && Array.isArray(target.sourcePlayers) ? target.sourcePlayers : []
})
var next = ({})
for (var existing in _pluginFirstPartyServiceApis) next[existing] = _pluginFirstPartyServiceApis[existing]
next[proxyKey] = api
_pluginFirstPartyServiceApis = next
return api
}
function pluginShellCapabilityProfile(manifest, allowOwnService, barCapabilities) {
return [
allowOwnService ? "own-service" : "no-own-service",
barCapabilities ? "bar" : "no-bar",
shell.manifestHasKind(manifest, "menu") ? "menu" : "no-menu"
].join("|")
}
function cacheWithoutKey(cache, key, destroyValue) {
var next = ({})
for (var existing in cache) {
if (existing === key) {
var value = cache[existing]
if (destroyValue && value && typeof value.destroy === "function") value.destroy()
} else {
next[existing] = cache[existing]
}
}
return next
}
function cacheWithoutPrefix(cache, prefix) {
var next = ({})
for (var existing in cache) {
if (existing.indexOf(prefix) === 0) {
var value = cache[existing]
if (value && typeof value.destroy === "function") value.destroy()
} else {
next[existing] = cache[existing]
}
}
return next
}
function revokePluginShellApi(cacheKey) {
var key = String(cacheKey || "")
if (!key) return
_pluginAppLibraryApis = shell.cacheWithoutKey(_pluginAppLibraryApis, key, true)
_pluginFirstPartyServiceApis = shell.cacheWithoutPrefix(_pluginFirstPartyServiceApis, key + "::")
_pluginBarEntryShellApis = shell.cacheWithoutPrefix(_pluginBarEntryShellApis, key + ":")
_pluginShellApis = shell.cacheWithoutKey(_pluginShellApis, key, true)
_pluginShellApiDescriptors = shell.cacheWithoutKey(_pluginShellApiDescriptors, key, false)
}
function createScopedPluginShell(manifest, cacheKey, allowOwnService, barCapabilities) {
var key = String(manifest && manifest.id || "")
if (!key) return null
var profile = shell.pluginShellCapabilityProfile(manifest, allowOwnService, barCapabilities)
var cached = _pluginShellApis[cacheKey]
var descriptor = _pluginShellApiDescriptors[cacheKey]
if (cached && descriptor && descriptor.pluginId === key
&& descriptor.profile === profile) return cached
if (cached || descriptor) shell.revokePluginShellApi(cacheKey)
function currentManifest() {
return shell.pluginRegistry.installedPlugins[key] || null
}
function hasCurrentBarCapabilities() {
return barCapabilities && shell.pluginHasBarCapabilities(currentManifest())
}
// Construct the narrow service proxies before any plugin binding can call
// firstPartyServiceFor(). Creating a QObject while evaluating that binding
// makes QML re-enter the binding and report a loop on the caller's service
// property, even though the resulting proxy is otherwise acyclic.
var firstPartyServices = ({})
if (barCapabilities) {
var serviceIds = ["omarchy.idle", "omarchy.media", "omarchy.nightlight", "omarchy.notifications"]
for (var i = 0; i < serviceIds.length; i++) {
var serviceId = serviceIds[i]
firstPartyServices[serviceId] = shell.pluginFirstPartyServiceFor(cacheKey, key, serviceId)
}
}
var api = pluginShellApiComponent.createObject(null, {
pluginId: key,
appLibrary: shell.manifestHasKind(manifest, "menu")
? shell.pluginAppLibraryFor(cacheKey, key) : null,
bar: shell.pluginBarStateFor(cacheKey, key),
barConfig: shell.publicBarConfig(),
idleConfig: shell.publicIdleConfigFor(manifest),
_serviceLookup: function(requestedId) {
return allowOwnService ? shell.pluginServiceFor(key, requestedId) : null
},
_firstPartyServiceLookup: function(requestedId) {
if (allowOwnService && shell.pluginOwnsTarget(key, requestedId))
return shell.pluginServiceFor(key, requestedId)
return hasCurrentBarCapabilities() ? (firstPartyServices[requestedId] || null) : null
},
_barEntryShellLookup: function(ownerId, moduleName) {
return hasCurrentBarCapabilities()
? shell.pluginShellForBarEntry(cacheKey + ":" + ownerId, moduleName) : null
},
_summon: function(requestedId, payloadJson) {
if (!shell.pluginOwnsTarget(key, requestedId)
&& !shell.barPluginMayControl(currentManifest(), requestedId)
&& !shell.pluginCloneMaySummon(currentManifest(), requestedId)) return false
return shell.summon(shell.pluginRegistry.resolveEnabledId(requestedId), payloadJson)
},
_hide: function(requestedId) {
if (!shell.pluginOwnsTarget(key, requestedId)
&& !shell.barPluginMayControl(currentManifest(), requestedId)) return false
return shell.hide(shell.pluginRegistry.resolveEnabledId(requestedId))
},
_toggle: function(requestedId, payloadJson) {
if (!shell.pluginOwnsTarget(key, requestedId)
&& !shell.barPluginMayControl(currentManifest(), requestedId)) return false
return shell.toggle(shell.pluginRegistry.resolveEnabledId(requestedId), payloadJson)
},
_isOpen: function(requestedId) {
if (!shell.pluginOwnsTarget(key, requestedId)
&& !shell.barPluginMayControl(currentManifest(), requestedId)) return false
return shell.isPluginOpen(shell.pluginRegistry.resolveEnabledId(requestedId))
},
_updateSettings: function(requestedId, settings) {
if (shell.pluginOwnsTarget(key, requestedId)) return shell.updateEntryInline(key, settings)
if (hasCurrentBarCapabilities() && shell.barEntryConfigured(requestedId))
return shell.updateEntryInline(requestedId, settings)
return false
},
_mutateBarConfig: function(mutator) {
return hasCurrentBarCapabilities() ? shell.mutatePluginBarConfig(mutator) : false
}
})
if (!api) return null
var next = ({})
for (var id in _pluginShellApis) next[id] = _pluginShellApis[id]
next[cacheKey] = api
_pluginShellApis = next
var descriptorNext = ({})
for (var descriptorKey in _pluginShellApiDescriptors)
descriptorNext[descriptorKey] = _pluginShellApiDescriptors[descriptorKey]
descriptorNext[cacheKey] = {
pluginId: key,
allowOwnService: allowOwnService === true,
profile: profile
}
_pluginShellApiDescriptors = descriptorNext
return api
}
function scopedPluginShellForId(pluginId) {
var key = String(pluginId || "")
var manifest = shell.pluginRegistry.installedPlugins[key]
if (!manifest) return null
if (!manifest.__isFirstParty) return shell.pluginShellFor(manifest)
return shell.createScopedPluginShell(manifest, "hosted:" + key, false, false)
}
function pluginShellForId(pluginId) {
return shell.scopedPluginShellForId(pluginId)
}
function pluginShellForBarEntry(ownerId, moduleName) {
var owner = String(ownerId || "")
var target = String(moduleName || "")
if (!owner || !target) return null
if (!shell.barEntryConfigured(target)) return null
var cacheKey = owner + "::" + target
if (_pluginBarEntryShellApis[cacheKey]) return _pluginBarEntryShellApis[cacheKey]
function owns(requestedId) {
return shell.pluginRegistry.resolveEnabledId(String(requestedId || ""))
=== shell.pluginRegistry.resolveEnabledId(target)
}
function currentManifest() {
var id = shell.pluginRegistry.resolveEnabledId(target)
return shell.pluginRegistry.installedPlugins[id] || null
}
var api = pluginShellApiComponent.createObject(null, {
pluginId: target,
barConfig: shell.publicBarConfig(),
_summon: function(requestedId, payloadJson) {
if (!owns(requestedId)
&& !shell.pluginCloneMaySummon(currentManifest(), requestedId)) return false
return shell.summon(shell.pluginRegistry.resolveEnabledId(requestedId), payloadJson)
},
_hide: function(requestedId) {
return owns(requestedId)
? shell.hide(shell.pluginRegistry.resolveEnabledId(target)) : false
},
_toggle: function(requestedId, payloadJson) {
return owns(requestedId)
? shell.toggle(shell.pluginRegistry.resolveEnabledId(target), payloadJson) : false
},
_isOpen: function(requestedId) {
return owns(requestedId)
? shell.isPluginOpen(shell.pluginRegistry.resolveEnabledId(target)) : false
},
_updateSettings: function(requestedId, settings) {
return String(requestedId || "") === target
? shell.updateEntryInline(target, settings) : false
}
})
if (!api) return null
var next = ({})
for (var id in _pluginBarEntryShellApis) next[id] = _pluginBarEntryShellApis[id]
next[cacheKey] = api
_pluginBarEntryShellApis = next
return api
}
function pluginShellFor(manifest) {
if (!manifest || manifest.__isFirstParty) return shell
var key = String(manifest.id || "")
if (!key) return null
return shell.createScopedPluginShell(manifest, key, true, shell.pluginHasBarCapabilities(manifest))
}
function pluginRegistryFor(manifest) {
if (!manifest || manifest.__isFirstParty) return shell.pluginRegistry
var key = String(manifest.id || "")
if (!key) return null
if (_pluginRegistryApis[key]) return _pluginRegistryApis[key]
var api = pluginRegistryApiComponent.createObject(null, {
pluginId: key,
manifest: shell.publicPluginManifest(manifest),
enabled: shell.pluginRegistry.isEnabled(key),
_entryPointUrl: function(kind) {
var current = shell.pluginRegistry.installedPlugins[key]
return current ? shell.pluginRegistry.entryPointUrl(current, kind) : ""
}
})
if (!api) return null
var next = ({})
for (var id in _pluginRegistryApis) next[id] = _pluginRegistryApis[id]
next[key] = api
_pluginRegistryApis = next
return api
}
function pluginBarWidgetRegistryFor(manifest) {
if (!manifest || manifest.__isFirstParty) return shell.barWidgetRegistry
var key = String(manifest.id || "")
if (!key) return null
if (_pluginBarWidgetRegistryApis[key]) return _pluginBarWidgetRegistryApis[key]
var api = pluginBarWidgetRegistryApiComponent.createObject(null, {
widgets: shell.publicBarWidgetSnapshot(),
revision: shell.barWidgetRegistry.revision
})
if (!api) return null
var next = ({})
for (var id in _pluginBarWidgetRegistryApis) next[id] = _pluginBarWidgetRegistryApis[id]
next[key] = api
_pluginBarWidgetRegistryApis = next
return api
}
function pluginApiActive(api, plugins) {
var id = api ? String(api.pluginId || api.ownerPluginId || "") : ""
var manifest = id ? plugins[id] : null
return !!manifest && shell.pluginRegistry.isEnabled(id)
}
function prunePluginApis() {
var plugins = shell.pluginRegistry.installedPlugins
var shellKeys = Object.keys(_pluginShellApis)
for (var si = 0; si < shellKeys.length; si++) {
var shellKey = shellKeys[si]
var shellApi = _pluginShellApis[shellKey]
var descriptor = _pluginShellApiDescriptors[shellKey]
var manifest = descriptor ? plugins[descriptor.pluginId] : null
var barCapabilities = descriptor && descriptor.allowOwnService
&& shell.pluginHasBarCapabilities(manifest)
var expectedProfile = descriptor
? shell.pluginShellCapabilityProfile(manifest, descriptor.allowOwnService, barCapabilities) : ""
var active = descriptor && manifest && shell.pluginRegistry.isEnabled(descriptor.pluginId)
if (!active || descriptor.profile !== expectedProfile)
shell.revokePluginShellApi(shellKey)
}
var registryNext = ({})
for (var registryKey in _pluginRegistryApis) {
var registryApi = _pluginRegistryApis[registryKey]
if (shell.pluginApiActive(registryApi, plugins)) registryNext[registryKey] = registryApi
else if (registryApi && typeof registryApi.destroy === "function") registryApi.destroy()
}
_pluginRegistryApis = registryNext
var widgetNext = ({})
for (var widgetKey in _pluginBarWidgetRegistryApis) {
var widgetApi = _pluginBarWidgetRegistryApis[widgetKey]
if (plugins[widgetKey] && shell.pluginRegistry.isEnabled(widgetKey)) widgetNext[widgetKey] = widgetApi
else if (widgetApi && typeof widgetApi.destroy === "function") widgetApi.destroy()
}
_pluginBarWidgetRegistryApis = widgetNext
var appNext = ({})
for (var appKey in _pluginAppLibraryApis) {
var appApi = _pluginAppLibraryApis[appKey]
if (shell.pluginApiActive(appApi, plugins)) appNext[appKey] = appApi
else if (appApi && typeof appApi.destroy === "function") appApi.destroy()
}
_pluginAppLibraryApis = appNext
var barStateNext = ({})
for (var barStateKey in _pluginBarStateApis) {
var barStateApi = _pluginBarStateApis[barStateKey]
if (shell.pluginApiActive(barStateApi, plugins)) barStateNext[barStateKey] = barStateApi
else if (barStateApi && typeof barStateApi.destroy === "function") barStateApi.destroy()
}
_pluginBarStateApis = barStateNext
var serviceNext = ({})
for (var serviceKey in _pluginFirstPartyServiceApis) {
var serviceApi = _pluginFirstPartyServiceApis[serviceKey]
if (shell.pluginApiActive(serviceApi, plugins)) serviceNext[serviceKey] = serviceApi
else if (serviceApi && typeof serviceApi.destroy === "function") serviceApi.destroy()
}
_pluginFirstPartyServiceApis = serviceNext
var entryNext = ({})
for (var entryKey in _pluginBarEntryShellApis) {
var entryApi = _pluginBarEntryShellApis[entryKey]
if (entryApi && shell.barEntryConfigured(entryApi.pluginId)) entryNext[entryKey] = entryApi
else if (entryApi && typeof entryApi.destroy === "function") entryApi.destroy()
}
_pluginBarEntryShellApis = entryNext
}
function syncPluginApis() {
shell.prunePluginApis()
var plugins = shell.pluginRegistry.installedPlugins
for (var id in _pluginRegistryApis) {
var registryApi = _pluginRegistryApis[id]
var manifest = plugins[id]
registryApi.manifest = shell.publicPluginManifest(manifest)
registryApi.enabled = !!manifest && shell.pluginRegistry.isEnabled(id)
}
for (var widgetId in _pluginBarWidgetRegistryApis) {
var widgetApi = _pluginBarWidgetRegistryApis[widgetId]
widgetApi.widgets = shell.publicBarWidgetSnapshot()
widgetApi.revision = shell.barWidgetRegistry.revision
}
for (var shellKey in _pluginShellApis) {
var shellApi = _pluginShellApis[shellKey]
var descriptor = _pluginShellApiDescriptors[shellKey]
var shellManifest = descriptor ? plugins[descriptor.pluginId] : null
shellApi.barConfig = shell.publicBarConfig()
shellApi.idleConfig = shell.publicIdleConfigFor(shellManifest)
}
for (var entryKey in _pluginBarEntryShellApis)
_pluginBarEntryShellApis[entryKey].barConfig = shell.publicBarConfig()
}
// Reassigned as each service registers, so a binding that reads this before
// looking a service up by id re-evaluates once that service exists.
@@ -281,7 +877,14 @@ ShellRoot {
}
function firstPartyServiceFor(pluginId) {
return serviceFor(pluginId)
return serviceFor(shell.pluginRegistry.resolveEnabledId(pluginId))
}
function isAuthenticationService(manifest, pluginId) {
var key = String(pluginId || (manifest && manifest.id) || "")
return AuthServiceStore.isTrusted(key)
|| (!!manifest && Array.isArray(manifest.__hostCapabilities)
&& manifest.__hostCapabilities.indexOf("authentication") !== -1)
}
function ensureService(pluginId) {
@@ -294,6 +897,8 @@ ShellRoot {
if (!manifest.entryPoints || !manifest.entryPoints.service) return null
var url = pluginRegistry.entryPointUrl(manifest, "service")
if (!url) return null
var authenticationService = shell.isAuthenticationService(manifest, key)
if (authenticationService && AuthServiceStore.has(key)) return null
var comp = Qt.createComponent(url, Component.PreferSynchronous)
function finalize() {
@@ -301,27 +906,37 @@ ShellRoot {
console.warn("service plugin load failed for " + key + ": " + comp.errorString())
return
}
var inst = comp.createObject(serviceHost)
// Authentication services and third-party services have no visual
// parent. Parenting either to serviceHost would let a plugin's object
// traversal walk between the host and credential-bearing QML.
var inst = comp.createObject(manifest.__isFirstParty && !authenticationService ? serviceHost : null)
if (!inst) {
console.warn("service plugin createObject returned null for", key)
return
}
if ("omarchyPath" in inst) inst.omarchyPath = shell.omarchyPath
if ("shell" in inst) inst.shell = shell
if ("manifest" in inst) inst.manifest = manifest
if ("barWidgetRegistry" in inst) inst.barWidgetRegistry = shell.barWidgetRegistry
if ("pluginRegistry" in inst) inst.pluginRegistry = shell.pluginRegistry
var snext = ({})
for (var sk in _services) snext[sk] = _services[sk]
snext[key] = inst
_services = snext
if ("shell" in inst) inst.shell = shell.pluginShellFor(manifest)
if ("manifest" in inst) inst.manifest = shell.publicPluginManifest(manifest)
if ("barWidgetRegistry" in inst) inst.barWidgetRegistry = shell.pluginBarWidgetRegistryFor(manifest)
if ("pluginRegistry" in inst) inst.pluginRegistry = shell.pluginRegistryFor(manifest)
if (authenticationService) {
// Never publish lock/polkit through ShellRoot._services. The private JS
// import retains their lifetime without adding a traversable property
// or QObject parent back to the host shell.
AuthServiceStore.put(key, inst)
} else {
var snext = ({})
for (var sk in _services) snext[sk] = _services[sk]
snext[key] = inst
_services = snext
}
}
if (comp.status === Component.Loading) {
comp.statusChanged.connect(finalize)
return null
}
finalize()
return _services[key] || null
return authenticationService ? null : (_services[key] || null)
}
function _syncServices() {
@@ -333,11 +948,33 @@ ShellRoot {
if (!Array.isArray(m.kinds) || m.kinds.indexOf("service") === -1) continue
if (!m.entryPoints || !m.entryPoints.service) continue
if (!pluginRegistry.isEnabled(id)) continue
var authenticationService = shell.isAuthenticationService(m, id)
if (_services[id]) {
// A kept instance outlives the rescan; hand it the fresh manifest.
var kept = _services[id]
if (kept && "manifest" in kept) kept.manifest = m
continue
if (authenticationService) {
// A service that gains a trusted authentication capability must move
// out of the host's public service map before it is recreated.
var published = _services[id]
if (published && typeof published.destroy === "function") published.destroy()
var withoutPublished = ({})
for (var publishedId in _services)
if (publishedId !== id) withoutPublished[publishedId] = _services[publishedId]
_services = withoutPublished
} else {
// A kept instance outlives the rescan; hand it the fresh manifest.
var kept = _services[id]
if (kept && "shell" in kept) kept.shell = shell.pluginShellFor(m)
if (kept && "manifest" in kept) kept.manifest = shell.publicPluginManifest(m)
continue
}
}
if (AuthServiceStore.has(id)) {
if (authenticationService) {
AuthServiceStore.updateManifest(id, shell.publicPluginManifest(m))
continue
}
// A service that loses its trusted authentication capability can move
// back to the ordinary service map only after the isolated copy dies.
AuthServiceStore.destroy(id)
}
ensureService(id)
}
@@ -356,6 +993,21 @@ ShellRoot {
for (var k in _services) if (k !== existingId) next[k] = _services[k]
_services = next
}
// Authentication services are retained outside the root object graph, so
// reconcile their disable/remove lifecycle separately from _services.
var authenticationIds = AuthServiceStore.ids()
for (var ai = 0; ai < authenticationIds.length; ai++) {
var authenticationId = authenticationIds[ai]
var authenticationManifest = plugins[authenticationId]
var stillAuthenticationService = authenticationManifest
&& Array.isArray(authenticationManifest.kinds)
&& authenticationManifest.kinds.indexOf("service") !== -1
&& authenticationManifest.entryPoints
&& authenticationManifest.entryPoints.service
if (stillAuthenticationService && pluginRegistry.isEnabled(authenticationId)
&& shell.isAuthenticationService(authenticationManifest, authenticationId)) continue
AuthServiceStore.destroy(authenticationId)
}
}
function serviceKeepLoaded(pluginId) {
@@ -378,11 +1030,33 @@ ShellRoot {
if (inst && typeof inst.destroy === "function") inst.destroy()
}
_services = next
var authenticationIds = AuthServiceStore.ids()
for (var ai = 0; ai < authenticationIds.length; ai++) {
var authenticationId = authenticationIds[ai]
if (!serviceKeepLoaded(authenticationId))
AuthServiceStore.destroy(authenticationId)
}
}
Connections {
target: shell.pluginRegistry
function onPluginsChanged() { if (!shell.pluginReloading) shell._syncServices() }
function onPluginsChanged() {
shell.syncPluginApis()
if (!shell.pluginReloading) shell._syncServices()
}
}
Connections {
target: shell.barWidgetRegistry
function onChanged() { shell.syncPluginApis() }
}
Connections {
target: shell.appLibrary
function onAppsChanged() {
for (var id in shell._pluginAppLibraryApis)
shell._pluginAppLibraryApis[id].appsChanged()
}
}
// Writes inline settings to a bar layout entry or top-level plugin entry in
@@ -654,10 +1328,10 @@ ShellRoot {
onLoaded: {
if (!item) return
if ("omarchyPath" in item) item.omarchyPath = shell.omarchyPath
if ("shell" in item) item.shell = shell
if ("manifest" in item) item.manifest = panelEntry.manifest
if ("barWidgetRegistry" in item) item.barWidgetRegistry = shell.barWidgetRegistry
if ("pluginRegistry" in item) item.pluginRegistry = shell.pluginRegistry
if ("shell" in item) item.shell = shell.pluginShellFor(panelEntry.manifest)
if ("manifest" in item) item.manifest = shell.publicPluginManifest(panelEntry.manifest)
if ("barWidgetRegistry" in item) item.barWidgetRegistry = shell.pluginBarWidgetRegistryFor(panelEntry.manifest)
if ("pluginRegistry" in item) item.pluginRegistry = shell.pluginRegistryFor(panelEntry.manifest)
// Plugins that pair a panel UI with a service entry read shared
// state off `service`. Hand them the matching singleton if one was
// loaded.
@@ -723,7 +1397,8 @@ ShellRoot {
schema: meta.schema || [],
pluginId: manifest.id,
sourceDir: manifest.__sourceDir || "",
source: "plugin"
source: "plugin",
firstParty: !!manifest.__isFirstParty
}
// A load already in flight for this URL registers itself when it
@@ -0,0 +1,20 @@
import QtQuick
import "services/AuthServiceStore.js" as AuthServiceStore
QtObject {
function retain(id, service) {
AuthServiceStore.put(id, service)
}
function has(id) {
return AuthServiceStore.has(id)
}
function isTrusted(id) {
return AuthServiceStore.isTrusted(id)
}
function updateManifest(id, manifest) {
AuthServiceStore.updateManifest(id, manifest)
}
}
@@ -0,0 +1,8 @@
import QtQuick
import "services/AuthServiceStore.js" as AuthServiceStore
QtObject {
function has(id) {
return AuthServiceStore.has(id)
}
}
@@ -0,0 +1,92 @@
import QtQuick
import Quickshell
import Quickshell.Io
import "services"
ShellRoot {
id: root
property var calls: []
property QtObject ownService: QtObject {
property string marker: "own"
property var manifest: null
}
AuthStoreOwner { id: authStoreOwner }
AuthStoreReader { id: authStoreReader }
Component {
id: apiComponent
PluginShellApi { }
}
FileView {
id: resultFile
path: Quickshell.env("OMARCHY_QML_TEST_RESULT")
atomicWrites: true
}
Component.onCompleted: {
var caller = "example.safe"
authStoreOwner.retain("omarchy.lock", root.ownService)
authStoreOwner.updateManifest("omarchy.lock", { version: "kept" })
var api = apiComponent.createObject(null, {
pluginId: caller,
idleConfig: { screensaver: 60, lock: 120 },
_serviceLookup: function(requestedId) {
return requestedId === caller ? root.ownService : null
},
_summon: function(requestedId) {
if (requestedId !== caller) return false
root.calls = root.calls.concat(["summon"])
return true
},
_hide: function(requestedId) {
if (requestedId !== caller) return false
root.calls = root.calls.concat(["hide"])
return true
},
_toggle: function(requestedId) {
if (requestedId !== caller) return false
root.calls = root.calls.concat(["toggle"])
return true
},
_isOpen: function(requestedId) { return requestedId === caller },
_updateSettings: function(requestedId) {
if (requestedId !== caller) return false
root.calls = root.calls.concat(["settings"])
return true
}
})
var own = api.serviceFor(caller)
var result = {
detached: api.parent === undefined || api.parent === null,
ownService: own && own.marker === "own",
foreignService: api.serviceFor("omarchy.lock") === null,
firstPartyService: api.firstPartyServiceFor("omarchy.polkit") === null,
ownSummon: api.summon(caller, "{}") === true,
foreignSummon: api.summon("omarchy.lock", "{}") === false,
ownHide: api.hide(caller) === true,
foreignHide: api.hide("omarchy.lock") === false,
ownToggle: api.toggle(caller, "{}") === true,
foreignToggle: api.toggle("omarchy.lock", "{}") === false,
ownOpen: api.isPluginOpen(caller) === true,
foreignOpen: api.isPluginOpen("omarchy.lock") === false,
ownSettings: api.updateEntryInline(caller, {}) === true,
foreignSettings: api.updateEntryInline("omarchy.lock", {}) === false,
detachedIdleConfig: api.idleConfig.screensaver === 60 && api.idleConfig.lock === 120,
authStoreOwnerRetains: authStoreOwner.has("omarchy.lock") === true,
authStoreOwnerRemembersTrust: authStoreOwner.isTrusted("omarchy.lock") === true,
authStoreOwnerUpdatesManifest: root.ownService.manifest
&& root.ownService.manifest.version === "kept",
authStoreImportIsolated: authStoreReader.has("omarchy.lock") === false,
noGenericPluginShellFactory: typeof api.pluginShellForId !== "function",
calls: root.calls
}
result.ok = Object.keys(result).every(function(key) {
return key === "ok" || key === "calls" || result[key] === true
}) && JSON.stringify(result.calls) === JSON.stringify(["summon", "hide", "toggle", "settings"])
resultFile.setText(JSON.stringify(result))
}
}
@@ -83,6 +83,9 @@ ShellRoot {
scan += block("firstparty", "/first/bar", manifest("omarchy.bar", ["bar"], { bar: "Bar.qml" }))
scan += block("firstparty", "/first/panels/grouped", manifest("omarchy.grouped-panel", ["panel"], { panel: "Panel.qml" }))
scan += block("firstparty", "/first/hybrid", manifest("omarchy.hybrid", ["menu", "bar-widget"], { menu: "Menu.qml", barWidget: "Widget.qml" }))
var futureAuth = manifest("omarchy.future-auth", ["service"], { service: "Service.qml" })
futureAuth.omarchy = { capabilities: ["authentication"] }
scan += block("firstparty", "/first/future-auth", futureAuth)
scan += block("thirdparty", "/third/panel", manifest("third.panel", ["panel"], { panel: "Panel.qml" }))
scan += block("thirdparty", "/third/widget", manifest("third.widget", ["bar-widget"], { barWidget: "Widget.qml" }, { defaultSection: "left" }))
scan += block("thirdparty", "/third/center-widget", manifest("third.center-widget", ["bar-widget"], { barWidget: "Widget.qml" }))
@@ -103,6 +106,12 @@ ShellRoot {
localBar.omarchy = { clonedFrom: "omarchy.bar" }
scan += block("thirdparty", "/third/local-bar", localBar)
scan += block("thirdparty", "/third/bar", manifest("third.bar", ["bar"], { bar: "Bar.qml" }))
var localFutureAuth = manifest("local.future-auth", ["service"], { service: "Service.qml" })
localFutureAuth.omarchy = { clonedFrom: "omarchy.future-auth" }
scan += block("thirdparty", "/third/local-future-auth", localFutureAuth)
var spoofedAuth = manifest("third.spoofed-auth", ["service"], { service: "Service.qml" })
spoofedAuth.omarchy = { capabilities: ["authentication"] }
scan += block("thirdparty", "/third/spoofed-auth", spoofedAuth)
scan += block("thirdparty", "/third/shadow", manifest("omarchy.first-widget", ["panel"], { panel: "Panel.qml" }))
scan += block("thirdparty", "/third/reserved", manifest("omarchy.reserved", ["panel"], { panel: "Panel.qml" }))
scan += block("thirdparty", "/third/unsafe", manifest("third.unsafe", ["panel"], { panel: "../Panel.qml" }))
@@ -116,22 +125,28 @@ ShellRoot {
root.assertDeepEqual(pluginIds(), [
"local.bar",
"local.first-widget",
"local.future-auth",
"local.grouped-panel",
"local.hybrid",
"local.weather",
"omarchy.bar",
"omarchy.first-widget",
"omarchy.future-auth",
"omarchy.grouped-panel",
"omarchy.hybrid",
"third.bar",
"third.center-widget",
"third.panel",
"third.right-widget",
"third.spoofed-auth",
"third.widget"
], "registry merges valid first-party and third-party manifests")
root.assertTrue(registry.installedPlugins["omarchy.first-widget"].__isFirstParty === true, "first-party manifests are stamped")
root.assertTrue(registry.installedPlugins["third.panel"].__isFirstParty === false, "third-party manifests are stamped")
root.assertDeepEqual(registry.installedPlugins["omarchy.future-auth"].__hostCapabilities, ["authentication"], "trusted manifests stamp authentication capability")
root.assertDeepEqual(registry.installedPlugins["local.future-auth"].__hostCapabilities, ["authentication"], "clones inherit trusted host capabilities")
root.assertDeepEqual(registry.installedPlugins["third.spoofed-auth"].__hostCapabilities, [], "third-party manifests cannot self-grant host capabilities")
root.assertEqual(registry.installedPlugins["omarchy.grouped-panel"].__sourceDir, "/first/panels/grouped", "grouped plugin source paths are preserved")
root.assertEqual(registry.entryPointUrl(registry.installedPlugins["third.panel"], "panel"), "file:///third/panel/Panel.qml", "entryPointUrl resolves plugin-relative paths")
root.assertEqual(registry.entryPointUrl(registry.installedPlugins["third.widget"], "barWidget"), "file:///third/widget/Widget.qml", "entryPointUrl resolves bar widget paths")
+201
View File
@@ -0,0 +1,201 @@
#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
TMPDIR=""
QS_PID=""
cleanup() {
if [[ -n $QS_PID ]] && kill -0 "$QS_PID" 2>/dev/null; then
kill "$QS_PID" 2>/dev/null || true
wait "$QS_PID" 2>/dev/null || true
fi
if [[ -n $TMPDIR && -d $TMPDIR ]]; then
rm -rf "$TMPDIR"
fi
}
trap cleanup EXIT
shell_qml="$ROOT/shell/shell.qml"
bar_qml="$ROOT/shell/plugins/bar/Bar.qml"
plugin_shell_api="$ROOT/shell/services/PluginShellApi.qml"
idle_service="$ROOT/shell/plugins/services/idle/Service.qml"
# Normalize horizontal and vertical whitespace so the wiring assertions survive
# harmless QML reflow. The runtime fixture below behaviorally covers
# PluginShellApi and AuthServiceStore; these checks remain the guard for their
# integration through shell.qml and Bar.qml, including without a compositor.
qml_matches() {
local file=$1
local pattern=$2
tr '\n\r\t' ' ' < "$file" | grep -Eq "$pattern"
}
qml_matches "$shell_qml" 'comp\.createObject\( *manifest\.__isFirstParty *&& *!authenticationService *\? *serviceHost *: *null *\)' ||
fail "third-party and authentication services are detached from the host object tree"
qml_matches "$shell_qml" 'AuthServiceStore\.put\( *key, *inst *\)' ||
fail "authentication services are retained outside the host service map"
qml_matches "$shell_qml" 'AuthServiceStore\.isTrusted\( *key *\)' ||
fail "live authentication classification survives public manifest mutation"
qml_matches "$shell_qml" 'AuthServiceStore\.updateManifest\( *id, *shell\.publicPluginManifest\( *m *\) *\)' ||
fail "kept authentication services receive only a public manifest snapshot"
qml_matches "$shell_qml" 'if *\( *!serviceKeepLoaded\( *authenticationId *\) *\) *AuthServiceStore\.destroy\( *authenticationId *\)' ||
fail "keepLoaded authentication services survive plugin rescans"
pass "third-party and authentication services are detached from the host object tree"
run_node_test <<'JS'
const fs = require('fs')
const vm = require('vm')
const store = {}
vm.createContext(store)
vm.runInContext(
fs.readFileSync(path.join(root, 'shell/services/AuthServiceStore.js'), 'utf8'),
store
)
const service = { destroy() {} }
store.put('omarchy.lock', service)
store.destroy('omarchy.lock')
assert(
!store.has('omarchy.lock') && store.isTrusted('omarchy.lock'),
'authentication classification survives service teardown'
)
JS
qml_matches "$shell_qml" 'inst\.shell *= *shell\.pluginShellFor\( *manifest *\)' ||
fail "service plugins receive a scoped shell facade"
qml_matches "$shell_qml" 'item\.shell *= *shell\.pluginShellFor\( *panelEntry\.manifest *\)' ||
fail "panel plugins receive a scoped shell facade"
qml_matches "$shell_qml" 'target\.shell *= *shell\.pluginShellFor\( *manifest *\)' ||
fail "full-bar plugins receive a scoped shell facade"
pass "third-party entry points receive scoped shell facades"
if qml_matches "$plugin_shell_api" 'function +pluginShellForId\('; then
fail "replacement-bar facade exposes a generic plugin-shell factory"
fi
qml_matches "$bar_qml" 'else if *\( *root\.shell *&& *typeof root\.shell\.pluginShellForBarEntry *=== *"function" *\) *\{[^}]*pluginShell *= *root\.shell\.pluginShellForBarEntry\( *key, *moduleName *\)' ||
fail "replacement bars do not fall back to a service-less entry facade"
pass "replacement bars cannot manufacture another plugin's service facade"
qml_matches "$shell_qml" 'target\.barConfig *= *shell\.barConfigFor\( *manifest *\)' ||
fail "initial replacement-bar configuration is not detached"
qml_matches "$shell_qml" 'bar\.barConfig *= *shell\.barConfigFor\( *shell\.activeBarManifest *\)' ||
fail "replacement-bar configuration updates are not detached"
pass "replacement bars receive detached configuration snapshots"
qml_matches "$bar_qml" 'target\.bar *= *firstParty *\? *root *: *root\.pluginBarApiFor\( *pluginApiId, *moduleName, *registered *\)' ||
fail "third-party widgets receive a bar facade instead of the host bar"
qml_matches "$bar_qml" 'api\.clickTargets *= *root\.pluginClickTargets\( *api\.pluginId *\)' ||
fail "third-party bar facades exclude other widgets from their object graph"
pass "third-party widgets receive a bar facade instead of the host bar"
qml_matches "$shell_qml" 'widgets: *shell\.publicBarWidgetSnapshot\( *\)' ||
fail "third-party widget registries receive detached snapshots"
qml_matches "$bar_qml" 'root\.markPluginObject\( *pluginId, *target, *"clickTarget" *\)' ||
fail "third-party bar-object ownership is stamped by the host callback"
qml_matches "$bar_qml" 'root\.markPluginObject\( *pluginId, *owner, *"popout" *\)' ||
fail "owner-less popouts receive trusted ownership before activation"
qml_matches "$shell_qml" 'manifest\.__hostCapabilities\.indexOf\( *"authentication" *\)' ||
fail "authentication isolation follows host-stamped capabilities"
pass "registry mutation and ownership boundaries are host-controlled"
qml_matches "$bar_qml" 'root\.moduleWidgets\( *moduleName *\)' ||
fail "custom bar module widget lookups use their real module name"
qml_matches "$shell_qml" 'shell\.pluginShellForBarEntry\( *cacheKey *\+ *":" *\+ *ownerId, *moduleName *\)' ||
fail "full-bar plugins receive a scoped settings facade for custom modules"
pass "custom bar modules retain settings and popout identity"
if qml_matches "$bar_qml" 'on(Foreground|BarForeground|Background|Urgent|FontFamily|Vertical|BarSize|Transparent)Changed: *sync'; then
fail "animated scalar properties still trigger full facade resyncs"
fi
qml_matches "$bar_qml" 'api\.foreground *= *Qt\.binding\( *function\( *\) *\{ *return root\.foreground *\} *\)' ||
fail "third-party bar scalar mirrors use bindings"
qml_matches "$shell_qml" 'shell\.prunePluginApis\( *\)' ||
fail "disabled plugin facade caches are pruned"
pass "plugin facade synchronization is bounded"
qml_matches "$shell_qml" 'descriptor\.profile *!== *expectedProfile[^}]*shell\.revokePluginShellApi\( *shellKey *\)' ||
fail "manifest capability changes do not revoke cached plugin facades"
qml_matches "$shell_qml" 'shell\.barPluginMayControl\( *currentManifest\( *\), *requestedId *\)' ||
fail "bar lifecycle callbacks do not validate the current manifest"
qml_matches "$shell_qml" 'return hasCurrentBarCapabilities\( *\) *\? *shell\.mutatePluginBarConfig\( *mutator *\) *: *false' ||
fail "bar configuration mutation does not validate the current manifest"
pass "manifest changes revoke cached facade capabilities"
qml_matches "$shell_qml" 'idleConfig: *shell\.publicIdleConfigFor\( *manifest *\)' ||
fail "cloned idle services do not receive their configured timeouts"
qml_matches "$shell_qml" 'shellApi\.idleConfig *= *shell\.publicIdleConfigFor\( *shellManifest *\)' ||
fail "cloned idle service configuration does not refresh"
qml_matches "$idle_service" 'shell *&& *shell\.idleConfig *\? *shell\.idleConfig *: *\(\{\}\)' ||
fail "the idle service does not consume its scoped configuration"
bar_entry_shell=$(sed -n '/^ function pluginShellForBarEntry(/,/^ function pluginShellFor(/p' "$shell_qml")
tr '\n\r\t' ' ' <<<"$bar_entry_shell" |
grep -Eq 'var id *= *shell\.pluginRegistry\.resolveEnabledId\( *target *\)[^}]*return shell\.pluginRegistry\.installedPlugins\[id\] *\|\| *null' ||
fail "replacement-bar clone authorization does not follow the enabled implementation"
tr '\n\r\t' ' ' <<<"$bar_entry_shell" |
grep -Eq 'shell\.pluginCloneMaySummon\( *currentManifest\( *\), *requestedId *\)' ||
fail "built-in clones in replacement bars cannot summon their existing auxiliary UI"
qml_matches "$shell_qml" 'shell\.pluginCloneMaySummon\( *currentManifest\( *\), *requestedId *\)' ||
fail "built-in clones cannot summon their existing auxiliary UI"
qml_matches "$shell_qml" '"omarchy\.media": *\["omarchy\.osd"\]' ||
fail "media clones cannot summon their existing OSD target"
qml_matches "$shell_qml" '"omarchy\.network": *\["omarchy\.speedtest", *"omarchy\.wifiqr"\]' ||
fail "network clones cannot summon their existing auxiliary panels"
pass "built-in service and widget clones retain narrow configuration and UI integration"
qml_matches "$shell_qml" 'shell\.serviceFor\( *shell\.pluginRegistry\.resolveEnabledId\( *id *\) *\)' ||
fail "narrow first-party service proxies do not resolve enabled clones"
qml_matches "$shell_qml" 'return serviceFor\( *shell\.pluginRegistry\.resolveEnabledId\( *pluginId *\) *\)' ||
fail "trusted first-party service lookups do not resolve enabled clones"
qml_matches "$shell_qml" 'allowOwnService *&& *shell\.pluginOwnsTarget\( *key, *requestedId *\)[^}]*return shell\.pluginServiceFor\( *key, *requestedId *\)' ||
fail "cloned widgets cannot use a source id to reach their own service"
pass "service facades resolve enabled clones without widening replacement-bar access"
require_compositor "plugin authentication boundary runtime test"
if ! command -v quickshell >/dev/null 2>&1; then
pass "quickshell not installed; skipping plugin authentication boundary runtime test"
exit 0
fi
require_command jq
TMPDIR=$(mktemp -d)
result="$TMPDIR/result.json"
log="$TMPDIR/quickshell.log"
config_dir="$TMPDIR/plugin-auth-boundary"
mkdir -p "$config_dir" "$TMPDIR/home"
cp "$SHELL_TEST_DIR/fixtures/plugin-auth-boundary/"*.qml "$config_dir/"
ln -s "$ROOT/shell/services" "$config_dir/services"
OMARCHY_QML_TEST_RESULT="$result" \
HOME="$TMPDIR/home" \
XDG_CONFIG_HOME="$TMPDIR/home/.config" \
XDG_CACHE_HOME="$TMPDIR/home/.cache" \
XDG_STATE_HOME="$TMPDIR/home/.local/state" \
quickshell -p "$config_dir" --no-color >"$log" 2>&1 &
QS_PID=$!
for _ in {1..80}; do
[[ -s $result ]] && break
if ! kill -0 "$QS_PID" 2>/dev/null; then
sed -n '1,220p' "$log" >&2
fail "plugin authentication boundary fixture exited before writing result"
fi
sleep 0.1
done
[[ -s $result ]] || {
sed -n '1,220p' "$log" >&2
fail "plugin authentication boundary runtime test timed out"
}
if ! jq -e '.ok == true' "$result" >/dev/null; then
jq . "$result" >&2
sed -n '1,220p' "$log" >&2
fail "plugin authentication boundary runtime behavior"
fi
pass "plugin authentication boundary runtime behavior"
+313
View File
@@ -112,6 +112,200 @@ Item {
}
QML
# A replacement bar must not receive a generic factory for another plugin's
# live service, and its barConfig must be a detached snapshot on both initial
# injection and later host-config updates.
victim_service_id="acme.victim-service"
victim_service_dir="$test_home/.config/omarchy/plugins/$victim_service_id"
mkdir -p "$victim_service_dir"
cat >"$victim_service_dir/manifest.json" <<JSON
{
"schemaVersion": 1,
"id": "$victim_service_id",
"name": "Victim Service",
"version": "1.0.0",
"kinds": ["service"],
"entryPoints": {"service": "Service.qml"}
}
JSON
cat >"$victim_service_dir/Service.qml" <<'QML'
import QtQuick
Item {
property string privateValue: "victim-secret"
}
QML
# A clone of the built-in media service exercises both supported service paths:
# its own widget receives the raw companion service under the trusted bar, while
# a replacement bar receives only the narrow media proxy resolved to the clone.
media_clone_id="acme.media-clone"
media_clone_dir="$test_home/.config/omarchy/plugins/$media_clone_id"
mkdir -p "$media_clone_dir"
cat >"$media_clone_dir/manifest.json" <<JSON
{
"schemaVersion": 1,
"id": "$media_clone_id",
"name": "Media Clone",
"version": "1.0.0",
"kinds": ["service", "bar-widget"],
"entryPoints": {"service": "Service.qml", "barWidget": "BarWidget.qml"},
"barWidget": {"defaultSection": "center"},
"omarchy": {"clonedFrom": "omarchy.media"}
}
JSON
cat >"$media_clone_dir/Service.qml" <<'QML'
import QtQuick
import Quickshell.Io
Item {
id: root
property string marker: "clone-service"
property bool enabled: true
property var activePlayer: null
property var sourcePlayers: []
property var shell: null
function runAction(action, showFeedback, targetKey) {}
function playerKey(player) { return "" }
function selectPlayer(playerKey) {}
IpcHandler {
target: "acme-media-clone-service"
function ping(): string { return marker }
function summonOsd(): string {
return root.shell && root.shell.summon("omarchy.osd", "{}") ? "true" : "false"
}
}
}
QML
cat >"$media_clone_dir/BarWidget.qml" <<'QML'
import QtQuick
import Quickshell.Io
Item {
id: root
property var bar: null
IpcHandler {
target: "acme-media-clone-widget"
function probeOwnService(): string {
var service = root.bar && root.bar.shell
? root.bar.shell.firstPartyServiceFor("omarchy.media") : null
return JSON.stringify({
reachable: !!service,
marker: service ? String(service.marker || "") : ""
})
}
}
}
QML
review_bar_id="acme.review-bar"
review_bar_dir="$test_home/.config/omarchy/plugins/$review_bar_id"
mkdir -p "$review_bar_dir"
cat >"$review_bar_dir/manifest.json" <<JSON
{
"schemaVersion": 1,
"id": "$review_bar_id",
"name": "Review Bar",
"version": "1.0.0",
"kinds": ["bar", "service"],
"keepLoaded": true,
"entryPoints": {"bar": "Bar.qml", "service": "Service.qml"}
}
JSON
cat >"$review_bar_dir/Bar.qml" <<'QML'
import QtQuick
import Quickshell.Io
Item {
id: root
property var shell: null
property var barConfig: ({})
IpcHandler {
target: "acme-review-bar"
function probeVictim(): string {
var genericFactory = root.shell
&& typeof root.shell.pluginShellForId === "function"
var entryFacade = root.shell
&& typeof root.shell.pluginShellForBarEntry === "function"
? root.shell.pluginShellForBarEntry("probe", "acme.victim-service") : null
var victim = entryFacade && typeof entryFacade.serviceFor === "function"
? entryFacade.serviceFor("acme.victim-service") : null
return JSON.stringify({
genericFactory: !!genericFactory,
entryFacade: !!entryFacade,
victimServiceReachable: !!victim
})
}
function snapshot(): string {
return JSON.stringify(root.barConfig || {})
}
function probeMediaProxy(): string {
var service = root.shell
? root.shell.firstPartyServiceFor("omarchy.media") : null
return JSON.stringify({ reachable: !!service, enabled: service ? service.enabled === true : false })
}
function probeMediaWidgetSummon(): string {
var entryFacade = root.shell
&& typeof root.shell.pluginShellForBarEntry === "function"
? root.shell.pluginShellForBarEntry("probe-media", "acme.media-clone") : null
return JSON.stringify({
entryFacade: !!entryFacade,
osdSummoned: entryFacade ? entryFacade.summon("omarchy.osd", "{}") : false,
foreignSummoned: entryFacade ? entryFacade.summon("omarchy.lock", "{}") : false
})
}
function mutateSnapshot(): string {
if (root.barConfig && root.barConfig.layout
&& root.barConfig.layout.left && root.barConfig.layout.left.length > 0)
root.barConfig.layout.left[0].id = "tampered.by.review-bar"
return snapshot()
}
}
}
QML
cat >"$review_bar_dir/Service.qml" <<'QML'
import QtQuick
import Quickshell.Io
Item {
id: root
property var shell: null
property var retainedShell: null
onShellChanged: if (!retainedShell && shell) retainedShell = shell
function mutationAllowed(candidate) {
if (!candidate) return false
try {
return typeof candidate.mutateShellConfig === "function"
&& candidate.mutateShellConfig(function(config) {}) === true
} catch (e) {
return false
}
}
IpcHandler {
target: "acme-review-capability"
function probe(): string {
return JSON.stringify({
currentAllowed: root.mutationAllowed(root.shell),
retainedAllowed: root.mutationAllowed(root.retainedShell)
})
}
}
}
QML
cat >"$stub_bin/omarchy-update-available" <<'SH'
#!/bin/bash
echo "Omarchy update available (test)"
@@ -434,3 +628,122 @@ jq -e 'all(.bar.layout.right[]; (.id // .) != "omarchy.keyboard-layout")' \
<<<"$(shell_ipc shell listShellConfig)" >/dev/null ||
fail_with_log "bar put added a second copy of a widget already on the bar"
pass "bar put leaves a widget already on the bar alone"
# Run the replacement-bar probes last: switching bar loaders can transiently
# leave bar-aware panels without a visual host, which should not add noise to
# the default-bar assertions above.
[[ $(shell_ipc shell setPluginEnabled "$media_clone_id" true) == "ok" ]] ||
fail_with_log "media clone fixture could not be enabled"
clone_widget_probe=""
for _ in {1..80}; do
clone_widget_probe=$(shell_ipc acme-media-clone-widget probeOwnService 2>/dev/null || true)
if jq -e '.reachable == true and .marker == "clone-service"' \
<<<"$clone_widget_probe" >/dev/null 2>&1; then
break
fi
sleep 0.1
done
jq -e '.reachable == true and .marker == "clone-service"' \
<<<"$clone_widget_probe" >/dev/null || {
printf 'Clone own-service probe: %s\n' "$clone_widget_probe" >&2
fail_with_log "a cloned widget resolves its source id to its own companion service"
}
pass "trusted bar gives a cloned widget its own companion service"
[[ $(shell_ipc acme-media-clone-service summonOsd) == "true" ]] ||
fail_with_log "a cloned media service cannot summon its existing OSD target"
pass "a cloned built-in service retains its auxiliary UI integration"
[[ $(shell_ipc shell setPluginEnabled "$victim_service_id" true) == "ok" ]] ||
fail_with_log "victim service fixture could not be enabled"
[[ $(shell_ipc shell enablePlugin "$review_bar_id" '{}') == "ok" ]] ||
fail_with_log "replacement-bar fixture could not be enabled"
review_probe=""
for _ in {1..80}; do
review_probe=$(shell_ipc acme-review-bar probeVictim 2>/dev/null || true)
if jq -e '.genericFactory == false and .entryFacade == false and .victimServiceReachable == false' \
<<<"$review_probe" >/dev/null 2>&1; then
break
fi
if ! kill -0 "$QS_PID" 2>/dev/null; then
fail_with_log "test shell exited while loading the replacement-bar fixture"
fi
sleep 0.1
done
jq -e '.genericFactory == false and .entryFacade == false and .victimServiceReachable == false' \
<<<"$review_probe" >/dev/null || {
printf 'Replacement-bar service probe: %s\n' "$review_probe" >&2
fail_with_log "replacement bar cannot recover another plugin's live service"
}
media_proxy_probe=$(shell_ipc acme-review-bar probeMediaProxy)
jq -e '.reachable == true and .enabled == true' <<<"$media_proxy_probe" >/dev/null || {
printf 'Replacement-bar media proxy probe: %s\n' "$media_proxy_probe" >&2
fail_with_log "replacement-bar service proxies resolve enabled clones"
}
media_summon_probe=$(shell_ipc acme-review-bar probeMediaWidgetSummon)
jq -e '.entryFacade == true and .osdSummoned == true and .foreignSummoned == false' \
<<<"$media_summon_probe" >/dev/null || {
printf 'Replacement-bar media summon probe: %s\n' "$media_summon_probe" >&2
fail_with_log "replacement-bar clone facades retain only their auxiliary UI integration"
}
bar_config_before=$(shell_ipc shell listShellConfig | jq -c '.bar')
shell_ipc acme-review-bar mutateSnapshot >/dev/null
bar_config_after=$(shell_ipc shell listShellConfig | jq -c '.bar')
[[ $bar_config_after == "$bar_config_before" ]] ||
fail_with_log "replacement bar mutated the initially injected host configuration"
[[ $(shell_ipc shell setBarWidget omarchy.clock format '"HH:mm:ss"' '{}') == "ok" ]] ||
fail_with_log "host bar configuration could not be updated for snapshot testing"
updated_snapshot=""
for _ in {1..80}; do
updated_snapshot=$(shell_ipc acme-review-bar snapshot 2>/dev/null || true)
if jq -e 'any(.layout.center[]; (.id // .) == "omarchy.clock" and .format == "HH:mm:ss")' \
<<<"$updated_snapshot" >/dev/null 2>&1; then
break
fi
sleep 0.1
done
jq -e 'any(.layout.center[]; (.id // .) == "omarchy.clock" and .format == "HH:mm:ss")' \
<<<"$updated_snapshot" >/dev/null ||
fail_with_log "replacement bar did not receive the refreshed configuration snapshot"
bar_config_before=$(shell_ipc shell listShellConfig | jq -c '.bar')
shell_ipc acme-review-bar mutateSnapshot >/dev/null
bar_config_after=$(shell_ipc shell listShellConfig | jq -c '.bar')
[[ $bar_config_after == "$bar_config_before" ]] ||
fail_with_log "replacement bar mutated a refreshed host configuration"
pass "replacement-bar service and configuration boundaries hold at runtime"
capability_before=$(shell_ipc acme-review-capability probe)
jq -e '.currentAllowed == true and .retainedAllowed == true' \
<<<"$capability_before" >/dev/null ||
fail_with_log "bar service fixture did not initially receive bar capabilities"
# Keep the same enabled plugin ID and service instance while dropping the bar
# kind. Both the currently injected facade and a reference retained by the
# plugin must lose the old configuration capability after the manifest rescan.
jq '.kinds = ["service"] | .entryPoints = {"service": "Service.qml"}' \
"$review_bar_dir/manifest.json" >"$review_bar_dir/manifest.json.tmp"
mv "$review_bar_dir/manifest.json.tmp" "$review_bar_dir/manifest.json"
capability_after=""
for _ in {1..80}; do
capability_after=$(shell_ipc acme-review-capability probe 2>/dev/null || true)
if jq -e '.currentAllowed == false and .retainedAllowed == false' \
<<<"$capability_after" >/dev/null 2>&1; then
break
fi
if ! kill -0 "$QS_PID" 2>/dev/null; then
fail_with_log "test shell exited while revoking changed manifest capabilities"
fi
sleep 0.1
done
jq -e '.currentAllowed == false and .retainedAllowed == false' \
<<<"$capability_after" >/dev/null || {
printf 'Capability revocation probe: %s\n' "$capability_after" >&2
fail_with_log "cached plugin facades revoke capabilities removed from the manifest"
}
pass "manifest reload revokes cached facade capabilities"