Restrict third-party shell plugin capabilities

Reported-by: Roger Piñol <rogerpicar@gmail.com>
This commit is contained in:
acrogenesis committed 2026-09-01 10:55:35 -06:00
1 parent 4d017913d0
commit 1702cf0bee
25 files changed
+1403 -49

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, 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 must be detached snapshots rather than shared objects. Do not expose host objects or 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`.
+2 -10
View File
@@ -41,10 +41,7 @@ Panels, overlays, and menus are loaded when summoned. Plugins can set the
top-level manifest key `keepLoaded: true` to survive between summons.
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, 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 snapshots can be changed only locally without mutating the host registries.
Full schema: [`shell/services/PluginRegistry.qml`](../shell/services/PluginRegistry.qml).
@@ -81,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 QML interfaces protect shell-owned credentials and cross-plugin controls; they are not an operating-system sandbox, so plugin code still 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
+2 -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.
## Seeing what you have
@@ -37,7 +37,7 @@ 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 shell does not expose its authentication state or raw host object tree through the third-party plugin interface. A replacement bar receives additional limited capabilities so it can render built-in widgets and orchestrate the configured non-authentication UI, but it still cannot reach authentication services. This is not an operating-system sandbox: plugin code still 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.
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.
+3 -4
View File
@@ -89,6 +89,8 @@ to outlive a single summon can set `keepLoaded: true` (e.g. the image
picker keeps its overlay window mounted between summons). 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, 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 snapshot cannot mutate the host registry.
The full schema lives in `services/PluginRegistry.qml`.
## Installing a third-party plugin
@@ -104,10 +106,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 protect shell-owned credentials and cross-plugin controls; they are not an operating-system sandbox. 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
+86
View File
@@ -0,0 +1,86 @@
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, so the actual Bar (and its root-shell property) is never retained.
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
@@ -23,6 +23,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
+228 -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,213 @@ 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
if (pluginBarApis[key]) return pluginBarApis[key]
var pluginShell = null
if (registered && root.shell && typeof root.shell.pluginShellForId === "function")
pluginShell = root.shell.pluginShellForId(moduleName)
else if (!registered && root.shell && typeof root.shell.pluginShellForBarEntry === "function")
pluginShell = root.shell.pluginShellForBarEntry(key, moduleName)
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 +815,10 @@ Item {
if (barHoverCount === 0) centerSectionRevealTimer.restart()
}
function setCenterHoverRevealSuppressed(value) {
centerHoverRevealSuppressed = !!value
}
Timer {
id: centerSectionRevealTimer
interval: 120
@@ -1548,6 +1768,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 +1989,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"
],
+34
View File
@@ -0,0 +1,34 @@
// 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 = ({})
function has(id) {
return services[String(id || "")] !== undefined
}
function put(id, service) {
var key = String(id || "")
if (!key || !service) return
if (services[key] && services[key] !== service && typeof services[key].destroy === "function")
services[key].destroy()
services[key] = service
}
function ids() {
return Object.keys(services)
}
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 || ""))
}
}
+29 -1
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
@@ -90,6 +90,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 +620,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 || "")) : ""
}
}
+75
View File
@@ -0,0 +1,75 @@
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. Keeping the host shell out of this object's
// properties also prevents ordinary QML object traversal from turning the
// facade back into the root ShellRoot.
QtObject {
id: api
required property string pluginId
property var appLibrary: null
property var bar: null
property var barConfig: ({})
property var _serviceLookup: null
property var _firstPartyServiceLookup: null
property var _pluginShellLookup: 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 pluginShellForId(id) {
return _pluginShellLookup ? _pluginShellLookup(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
}
}
+524 -23
View File
@@ -7,6 +7,7 @@ import qs.Commons
import "plugins/bar"
import "services"
import "services/AuthServiceStore.js" as AuthServiceStore
ShellRoot {
id: shell
@@ -214,10 +215,10 @@ 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 ("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.barConfig
shell.bar = target
}
@@ -253,8 +254,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 +271,462 @@ ShellRoot {
}
property var _services: ({})
property var _pluginShellApis: ({})
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 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 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)) 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(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 createScopedPluginShell(manifest, cacheKey, allowOwnService, barCapabilities) {
if (_pluginShellApis[cacheKey]) return _pluginShellApis[cacheKey]
var key = String(manifest && manifest.id || "")
if (!key) return null
// 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(),
_serviceLookup: function(requestedId) {
return allowOwnService ? shell.pluginServiceFor(key, requestedId) : null
},
_firstPartyServiceLookup: function(requestedId) {
return barCapabilities ? (firstPartyServices[requestedId] || null) : null
},
_pluginShellLookup: function(requestedId) {
return barCapabilities ? shell.scopedPluginShellForId(requestedId) : null
},
_barEntryShellLookup: function(ownerId, moduleName) {
return barCapabilities
? shell.pluginShellForBarEntry(cacheKey + ":" + ownerId, moduleName) : null
},
_summon: function(requestedId, payloadJson) {
if (!shell.pluginOwnsTarget(key, requestedId)
&& !shell.barPluginMayControl(manifest, requestedId)) return false
return shell.summon(shell.pluginRegistry.resolveEnabledId(requestedId), payloadJson)
},
_hide: function(requestedId) {
if (!shell.pluginOwnsTarget(key, requestedId)
&& !shell.barPluginMayControl(manifest, requestedId)) return false
return shell.hide(shell.pluginRegistry.resolveEnabledId(requestedId))
},
_toggle: function(requestedId, payloadJson) {
if (!shell.pluginOwnsTarget(key, requestedId)
&& !shell.barPluginMayControl(manifest, requestedId)) return false
return shell.toggle(shell.pluginRegistry.resolveEnabledId(requestedId), payloadJson)
},
_isOpen: function(requestedId) {
if (!shell.pluginOwnsTarget(key, requestedId)
&& !shell.barPluginMayControl(manifest, 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 (barCapabilities && shell.barEntryConfigured(requestedId))
return shell.updateEntryInline(requestedId, settings)
return false
},
_mutateBarConfig: function(mutator) {
return barCapabilities ? shell.mutatePluginBarConfig(mutator) : false
}
})
if (!api) return null
var next = ({})
for (var id in _pluginShellApis) next[id] = _pluginShellApis[id]
next[cacheKey] = api
_pluginShellApis = next
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
var cacheKey = owner + "::" + target
if (_pluginBarEntryShellApis[cacheKey]) return _pluginBarEntryShellApis[cacheKey]
var api = pluginShellApiComponent.createObject(null, {
pluginId: target,
barConfig: shell.publicBarConfig(),
_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 shellNext = ({})
for (var shellKey in _pluginShellApis) {
var shellApi = _pluginShellApis[shellKey]
if (shell.pluginApiActive(shellApi, plugins)) shellNext[shellKey] = shellApi
else if (shellApi && typeof shellApi.destroy === "function") shellApi.destroy()
}
_pluginShellApis = shellNext
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)
_pluginShellApis[shellKey].barConfig = shell.publicBarConfig()
for (var entryKey in _pluginBarEntryShellApis)
_pluginBarEntryShellApis[entryKey].barConfig = shell.publicBarConfig()
}
function serviceFor(pluginId) {
return _services[String(pluginId)] || null
@@ -280,6 +736,11 @@ ShellRoot {
return serviceFor(pluginId)
}
function isAuthenticationService(manifest) {
return !!manifest && Array.isArray(manifest.__hostCapabilities)
&& manifest.__hostCapabilities.indexOf("authentication") !== -1
}
function ensureService(pluginId) {
var key = String(pluginId)
if (_services[key]) return _services[key]
@@ -290,6 +751,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)
if (authenticationService && AuthServiceStore.has(key)) return null
var comp = Qt.createComponent(url, Component.PreferSynchronous)
function finalize() {
@@ -297,27 +760,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() {
@@ -329,7 +802,7 @@ ShellRoot {
if (!Array.isArray(m.kinds) || m.kinds.indexOf("service") === -1) continue
if (!m.entryPoints || !m.entryPoints.service) continue
if (!pluginRegistry.isEnabled(id)) continue
if (_services[id]) continue
if (_services[id] || (shell.isAuthenticationService(m) && AuthServiceStore.has(id))) continue
ensureService(id)
}
// Drop services for plugins that have been disabled or removed.
@@ -343,6 +816,16 @@ 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]
if (authenticationManifest && pluginRegistry.isEnabled(authenticationId)
&& shell.isAuthenticationService(authenticationManifest)) continue
AuthServiceStore.destroy(authenticationId)
}
}
function unloadPluginServices() {
@@ -351,11 +834,28 @@ ShellRoot {
if (inst && typeof inst.destroy === "function") inst.destroy()
}
_services = ({})
AuthServiceStore.destroyAll()
}
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
@@ -627,10 +1127,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.
@@ -696,7 +1196,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,12 @@
import QtQuick
import "services/AuthServiceStore.js" as AuthServiceStore
QtObject {
function retain(id, service) {
AuthServiceStore.put(id, service)
}
function has(id) {
return AuthServiceStore.has(id)
}
}
@@ -0,0 +1,8 @@
import QtQuick
import "services/AuthServiceStore.js" as AuthServiceStore
QtObject {
function has(id) {
return AuthServiceStore.has(id)
}
}
@@ -0,0 +1,82 @@
import QtQuick
import Quickshell
import Quickshell.Io
import "services"
ShellRoot {
id: root
property var calls: []
property QtObject ownService: QtObject { property string marker: "own" }
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)
var api = apiComponent.createObject(null, {
pluginId: caller,
_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,
authStoreOwnerRetains: authStoreOwner.has("omarchy.lock") === true,
authStoreImportIsolated: authStoreReader.has("omarchy.lock") === false,
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")
+125
View File
@@ -0,0 +1,125 @@
#!/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"
# 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"
pass "third-party and authentication services are detached from the host object tree"
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"
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"
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"