import QtQuick import Quickshell import Quickshell.Io // Instance, not a singleton — see BarWidgetRegistry for rationale. QtObject { id: registry property string home: Quickshell.env("HOME") property string pluginsDir: home + "/.config/omarchy/plugins" // Set by shell.qml at startup so we can also scan bundled first-party plugins. property string firstPartyDir: "" // Wired by shell.qml so the registry can read the canonical shell.json // without owning file IO itself. shellConfigProvider returns the current // effective shell config; shellConfigMutator takes a function that receives // a deep-cloned config it can mutate in place and persists the result. property var shellConfigProvider: null property var shellConfigMutator: null // { pluginId: manifest } — manifests have __sourceDir and __isFirstParty stamped in. property var installedPlugins: ({}) property int registryRevision: 0 property bool scanning: false signal pluginsChanged() signal pluginLoadFailed(string id, string error) // ---------------------------------------------------------------- helpers function isPlainObject(value) { return value !== null && typeof value === "object" && !Array.isArray(value) } function fileUrl(path) { return "file://" + String(path).split("/").map(encodeURIComponent).join("/") } function isSafeEntryPoint(value) { if (typeof value !== "string" || value.length === 0) return false if (value.charAt(0) === "/") return false if (value.indexOf("..") !== -1) return false return true } // Detect a Noctalia-shape manifest (https://github.com/noctalia-dev/noctalia-plugins). // Noctalia manifests don't carry our schemaVersion; we recognise them by the // presence of `minNoctaliaVersion` or `metadata.defaultSettings`, or an // `entryPoints` map that names roles Noctalia owns (main/barWidget/panel). function isNoctaliaShape(manifest) { if (!isPlainObject(manifest)) return false if (manifest.schemaVersion !== undefined) return false if (manifest.minNoctaliaVersion !== undefined) return true if (isPlainObject(manifest.metadata) && isPlainObject(manifest.metadata.defaultSettings)) return true if (isPlainObject(manifest.entryPoints)) { var ep = manifest.entryPoints if (ep.barWidget || ep.main || ep.panel || ep.desktopWidget || ep.launcherProvider) return true } return false } // Translate a Noctalia manifest into the shape we validate normally. We do // not modify the on-disk file; the in-memory copy gets a __noctaliaCompat // marker so downstream code can branch where the semantics diverge. function translateNoctaliaManifest(src) { var ep = isPlainObject(src.entryPoints) ? src.entryPoints : {} var kinds = [] var unsupported = [] if (ep.main) kinds.push("service") if (ep.barWidget) kinds.push("bar-widget") if (ep.panel) kinds.push("panel") // Out of scope for v1 — warn loudly so we don't silently strand the plugin. if (ep.desktopWidget) unsupported.push("desktopWidget") if (ep.launcherProvider) unsupported.push("launcherProvider") if (kinds.length === 0) { console.warn("PluginRegistry: noctalia plugin '" + src.id + "' has no supported entryPoints" + (unsupported.length ? " (unsupported in v1: " + unsupported.join(", ") + ")" : "")) return null } if (unsupported.length) { console.warn("PluginRegistry: noctalia plugin '" + src.id + "' uses entryPoints not supported in v1, ignoring: " + unsupported.join(", ")) } var translated = { schemaVersion: 1, id: "noctalia." + String(src.id), name: src.name || src.id, version: src.version || "0.0.0", author: src.author || "", description: src.description || "", kinds: kinds, activation: kinds.indexOf("service") !== -1 ? "persistent" : "on-demand", entryPoints: {}, __noctaliaCompat: true, __noctaliaOriginal: src } if (ep.barWidget) translated.entryPoints.barWidget = ep.barWidget if (ep.main) translated.entryPoints.service = ep.main if (ep.panel) translated.entryPoints.panel = ep.panel if (ep.settings) translated.entryPoints.settings = ep.settings var defaults = (isPlainObject(src.metadata) && isPlainObject(src.metadata.defaultSettings)) ? src.metadata.defaultSettings : {} if (translated.entryPoints.barWidget) { translated.barWidget = { displayName: translated.name, description: translated.description, category: "Noctalia", allowMultiple: false, defaults: defaults, schema: [] } } if (defaults && Object.keys(defaults).length > 0) translated.defaults = defaults return translated } function validateManifest(manifest, sourcePath) { if (!isPlainObject(manifest)) { console.warn("PluginRegistry: manifest is not an object at " + sourcePath) return null } // Noctalia shape — translate before validation. The translated manifest // goes through the standard schemaVersion=1 path below. if (isNoctaliaShape(manifest)) { var preservedSourceDir = manifest.__sourceDir var preservedFirstParty = manifest.__isFirstParty manifest = translateNoctaliaManifest(manifest) if (!manifest) return null manifest.__sourceDir = preservedSourceDir manifest.__isFirstParty = preservedFirstParty } if (manifest.schemaVersion !== 1) { console.warn("PluginRegistry: unsupported schemaVersion at " + sourcePath) return null } var required = ["id", "name", "version", "kinds", "entryPoints"] for (var i = 0; i < required.length; i++) { if (manifest[required[i]] === undefined) { console.warn("PluginRegistry: missing required field '" + required[i] + "' at " + sourcePath) return null } } var id = String(manifest.id) if (!id || id.indexOf("/") !== -1 || id.indexOf("..") !== -1 || id.charAt(0) === "/") { console.warn("PluginRegistry: invalid plugin id '" + id + "' at " + sourcePath) return null } if (!Array.isArray(manifest.kinds) || manifest.kinds.length === 0) { console.warn("PluginRegistry: kinds must be a non-empty array at " + sourcePath) return null } if (!isPlainObject(manifest.entryPoints)) { console.warn("PluginRegistry: entryPoints must be an object at " + sourcePath) return null } // 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. for (var key in manifest.entryPoints) { if (!isSafeEntryPoint(manifest.entryPoints[key])) { console.warn("PluginRegistry: unsafe entryPoint '" + key + "'='" + manifest.entryPoints[key] + "' at " + sourcePath) return null } } return manifest } function entryPointUrl(manifest, kind) { if (!isPlainObject(manifest)) return "" var ep = manifest.entryPoints ? manifest.entryPoints[kind] : null if (!ep) return "" var dir = manifest.__sourceDir || "" if (!dir) return "" // Defense in depth: even after validateManifest, confirm the resolved // path stays inside the plugin's sourceDir. var resolved = dir.replace(/\/$/, "") + "/" + String(ep) var expectedPrefix = dir.replace(/\/$/, "") + "/" if (resolved.indexOf(expectedPrefix) !== 0) { console.warn("PluginRegistry: entry point escapes sourceDir: " + resolved) return "" } return fileUrl(resolved) } // Enabled = the plugin id is referenced somewhere in shell.json. That can // be either a layout entry inside `bar.layout.*` (bar widgets) or a top-level // entry in `plugins[]` (panels, overlays, services). // // Special cases (implicitly always enabled, no shell.json entry needed): // - plugins whose `kinds` contains "bar" are mounted directly by the host. // - first-party plugins are shell infrastructure (settings, // image-picker, ...). Requiring users to add them to plugins[] just to // summon them was a footgun: a stock shell.json with `plugins: []` would // silently make `omarchy launch bar-settings` a no-op. function isEnabled(id) { var key = String(id) var manifest = installedPlugins[key] if (manifest) { if (Array.isArray(manifest.kinds) && manifest.kinds.indexOf("bar") !== -1) return true if (manifest.__isFirstParty) return true } var config = shellConfigProvider ? shellConfigProvider() : null return findEntryLocation(config, key).found } function findEntryLocation(config, id) { if (!isPlainObject(config)) return { found: false } if (isPlainObject(config.bar) && isPlainObject(config.bar.layout)) { var sections = ["left", "center", "right"] for (var s = 0; s < sections.length; s++) { var arr = config.bar.layout[sections[s]] if (!Array.isArray(arr)) continue for (var i = 0; i < arr.length; i++) { if (arr[i] && arr[i].id === id) return { found: true, kind: "bar", section: sections[s], index: i } } } } if (Array.isArray(config.plugins)) { for (var j = 0; j < config.plugins.length; j++) { if (config.plugins[j] && config.plugins[j].id === id) return { found: true, kind: "plugin", index: j } } } return { found: false } } // Adding a plugin places it in the right section based on its declared // kinds. Bar widgets default to the right section; panels/overlays/menus/ // services go into the plugins[] array. function setEnabled(id, value) { var key = String(id) if (!shellConfigMutator) { console.warn("PluginRegistry.setEnabled called before shellConfigMutator wired") return } var manifest = installedPlugins[key] var isBarWidget = manifest && Array.isArray(manifest.kinds) && manifest.kinds.indexOf("bar-widget") !== -1 shellConfigMutator(function(config) { // Ensure shape exists. if (!isPlainObject(config.bar)) config.bar = { layout: { left: [], center: [], right: [] } } if (!isPlainObject(config.bar.layout)) config.bar.layout = { left: [], center: [], right: [] } if (!Array.isArray(config.plugins)) config.plugins = [] var location = findEntryLocation(config, key) if (value && !location.found) { var entry = { id: key } if (isBarWidget) { if (!Array.isArray(config.bar.layout.right)) config.bar.layout.right = [] config.bar.layout.right.push(entry) } else { config.plugins.push(entry) } } else if (!value && location.found) { if (location.kind === "bar") { config.bar.layout[location.section].splice(location.index, 1) } else { config.plugins.splice(location.index, 1) } } }) registryRevision++ pluginsChanged() } function manifestsOfKind(kind) { var result = [] for (var id in installedPlugins) { var m = installedPlugins[id] if (m && Array.isArray(m.kinds) && m.kinds.indexOf(kind) !== -1) result.push(m) } return result } // ---------------------------------------------------------------- scanning // Output format produced by the rescan script: // ===::=== // ... raw manifest.json content ... // === EOM === // (repeating for every manifest found) function parseScanOutput(text) { var lines = String(text || "").split("\n") var firstParty = {} var thirdParty = {} var currentSource = null var currentKind = null var currentJson = [] function flush() { if (!currentSource) return var raw = currentJson.join("\n").trim() try { var manifest = JSON.parse(raw) manifest.__sourceDir = currentSource manifest.__isFirstParty = (currentKind === "firstparty") var validated = validateManifest(manifest, currentSource + "/manifest.json") if (validated) { if (currentKind === "firstparty") firstParty[validated.id] = validated else thirdParty[validated.id] = validated } } catch (e) { console.warn("PluginRegistry: bad manifest at " + currentSource + ": " + e) } currentSource = null currentKind = null currentJson = [] } for (var i = 0; i < lines.length; i++) { var line = lines[i] var startMatch = line.match(/^===([a-z]+)::(.+)===$/) if (startMatch) { flush() currentKind = startMatch[1] currentSource = startMatch[2].replace(/\/$/, "") currentJson = [] continue } if (line === "=== EOM ===") { flush() continue } if (currentSource) currentJson.push(line) } flush() var merged = {} for (var fk in firstParty) merged[fk] = firstParty[fk] // Third-party plugins never shadow a first-party one with the same id. for (var tk in thirdParty) { if (firstParty[tk]) { console.warn("PluginRegistry: plugin " + tk + " rejected: id collides with first-party plugin") continue } merged[tk] = thirdParty[tk] } installedPlugins = merged registryRevision++ scanning = false pluginsChanged() } property Process scanProcess: Process { onExited: function(exitCode) { var output = scanStdout.text || "" registry.parseScanOutput(output) } stdout: StdioCollector { id: scanStdout waitForEnd: true } } property Process initProcess: Process { onExited: registry.rescan() } function rescan() { if (scanning) return scanning = true // $0 = first-party dir, $1 = third-party dir. Some bash versions need the explicit -- separator. var script = "" + "scan() { local dir=\"$1\"; local kind=\"$2\"; " + " [[ -d \"$dir\" ]] || return 0; " + " for sub in \"$dir\"/*/; do " + " [[ -f \"$sub/manifest.json\" ]] || continue; " + " printf '===%s::%s===\\n' \"$kind\" \"$sub\"; " + " cat \"$sub/manifest.json\"; " + " printf '\\n=== EOM ===\\n'; " + " done; " + "}; " + "scan \"$0\" firstparty; " + "scan \"$1\" thirdparty" scanProcess.command = ["bash", "-c", script, registry.firstPartyDir, registry.pluginsDir] scanProcess.running = true } function ensureUserDir() { initProcess.command = ["bash", "-c", "mkdir -p \"$0\"", registry.pluginsDir] initProcess.running = true } Component.onCompleted: ensureUserDir() }