682 lines
27 KiB
QML
682 lines
27 KiB
QML
import QtQuick
|
|
import Quickshell
|
|
import Quickshell.Io
|
|
import qs.Commons
|
|
|
|
// 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
|
|
property string lastEnableError: ""
|
|
|
|
signal pluginsChanged()
|
|
signal scanFinished()
|
|
signal pluginLoadFailed(string id, string error)
|
|
signal localPluginChanged(string id)
|
|
|
|
// ---------------------------------------------------------------- helpers
|
|
|
|
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
|
|
}
|
|
|
|
function validateManifest(manifest, sourcePath) {
|
|
if (!Util.isPlainObject(manifest)) {
|
|
console.warn("PluginRegistry: manifest is not an object at " + sourcePath)
|
|
return null
|
|
}
|
|
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 (!Util.isPlainObject(manifest.entryPoints)) {
|
|
console.warn("PluginRegistry: entryPoints must be an object at " + sourcePath)
|
|
return null
|
|
}
|
|
if (manifest.barWidget !== undefined && Util.isPlainObject(manifest.barWidget)
|
|
&& manifest.barWidget.defaultSection !== undefined) {
|
|
var defaultSection = String(manifest.barWidget.defaultSection)
|
|
if (["left", "center", "right"].indexOf(defaultSection) === -1) {
|
|
console.warn("PluginRegistry: invalid barWidget.defaultSection 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 (!Util.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 Util.fileUrl(resolved)
|
|
}
|
|
|
|
// Enabled = the plugin id is referenced somewhere in shell.json. That can
|
|
// be either the active bar option in `bar.id`, 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):
|
|
// - the built-in bar option (`omarchy.bar`) is active when `bar.id` is
|
|
// missing or set to `omarchy.bar`.
|
|
// - first-party non-bar 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. Turning one off
|
|
// is therefore recorded the other way round, in `disabledPlugins[]`.
|
|
function isEnabled(id) {
|
|
var key = String(id)
|
|
var manifest = installedPlugins[key]
|
|
var config = shellConfigProvider ? shellConfigProvider() : null
|
|
if (manifest) {
|
|
if (Array.isArray(manifest.kinds) && manifest.kinds.indexOf("bar") !== -1) {
|
|
var selectedBar = ""
|
|
if (Util.isPlainObject(config) && Util.isPlainObject(config.bar))
|
|
selectedBar = Util.canonicalWidgetId(String(config.bar.id || ""))
|
|
if (!selectedBar) selectedBar = "omarchy.bar"
|
|
return selectedBar === key
|
|
}
|
|
if (isDisabled(config, key)) return false
|
|
if (manifest.__isFirstParty) return true
|
|
}
|
|
return findEntryLocation(config, key).found
|
|
}
|
|
|
|
function isDisabled(config, id) {
|
|
return Util.isPlainObject(config) && Array.isArray(config.disabledPlugins)
|
|
&& config.disabledPlugins.indexOf(Util.canonicalWidgetId(String(id))) !== -1
|
|
}
|
|
|
|
function resolveEnabledId(id) {
|
|
var key = Util.canonicalWidgetId(String(id || ""))
|
|
// Callers keep using the built-in id after cloning; the enabled local
|
|
// manifest is the implementation that should receive the call.
|
|
for (var candidate in installedPlugins) {
|
|
var manifest = installedPlugins[candidate]
|
|
var metadata = manifest && Util.isPlainObject(manifest.omarchy) ? manifest.omarchy : null
|
|
if (metadata && String(metadata.clonedFrom || "") === key && isEnabled(candidate))
|
|
return candidate
|
|
}
|
|
return key
|
|
}
|
|
|
|
// A bar widget is on when it sits in the bar, whoever shipped it. That is a
|
|
// different question from isEnabled(), which decides whether the widget's
|
|
// component is loaded at all — a built-in stays loadable so it can be put
|
|
// back, and so a plugin that is both a widget and a menu (omarchy.menu)
|
|
// cannot be locked out of the shell by taking its button off the bar.
|
|
function inBar(id) {
|
|
var config = shellConfigProvider ? shellConfigProvider() : null
|
|
return findEntryLocation(config, id).kind === "bar"
|
|
}
|
|
|
|
function defaultBarWidgetSection(manifest) {
|
|
var metadata = manifest && Util.isPlainObject(manifest.barWidget) ? manifest.barWidget : null
|
|
var section = metadata ? String(metadata.defaultSection || "") : ""
|
|
return ["left", "center", "right"].indexOf(section) !== -1 ? section : "center"
|
|
}
|
|
|
|
function barEntryId(entry) {
|
|
return Util.canonicalWidgetId(String(Util.isPlainObject(entry) ? entry.id : entry || ""))
|
|
}
|
|
|
|
function findBarLocation(config, id, section) {
|
|
if (!Util.isPlainObject(config) || !Util.isPlainObject(config.bar)
|
|
|| !Util.isPlainObject(config.bar.layout)) return { found: false }
|
|
var key = Util.canonicalWidgetId(String(id))
|
|
var sections = ["left", "center", "right"]
|
|
for (var s = 0; s < sections.length; s++) {
|
|
if (section && sections[s] !== section) continue
|
|
var entries = config.bar.layout[sections[s]]
|
|
if (!Array.isArray(entries)) continue
|
|
for (var i = 0; i < entries.length; i++) {
|
|
if (barEntryId(entries[i]) === key)
|
|
return { found: true, kind: "bar", section: sections[s], index: i }
|
|
}
|
|
}
|
|
return { found: false }
|
|
}
|
|
|
|
function findEntryLocation(config, id) {
|
|
if (!Util.isPlainObject(config)) return { found: false }
|
|
var key = Util.canonicalWidgetId(String(id))
|
|
if (Util.isPlainObject(config.bar)) {
|
|
var selectedBar = Util.canonicalWidgetId(String(config.bar.id || ""))
|
|
if (selectedBar === key) return { found: true, kind: "bar-option" }
|
|
}
|
|
if (Util.isPlainObject(config.bar) && Util.isPlainObject(config.bar.layout)) {
|
|
var barLocation = findBarLocation(config, key, "")
|
|
if (barLocation.found) return barLocation
|
|
}
|
|
if (Array.isArray(config.plugins)) {
|
|
for (var j = 0; j < config.plugins.length; j++) {
|
|
if (config.plugins[j] && Util.canonicalWidgetId(config.plugins[j].id) === key) return { found: true, kind: "plugin", index: j }
|
|
}
|
|
}
|
|
return { found: false }
|
|
}
|
|
|
|
function barTarget(config, placement, fallbackSection) {
|
|
var target = placement || {}
|
|
var section = ["left", "center", "right"].indexOf(String(target.section || "")) !== -1
|
|
? String(target.section) : fallbackSection
|
|
var relativeId = String(target.before || target.after || "")
|
|
if (relativeId) {
|
|
var relative = findBarLocation(config, relativeId, section && target.section ? section : "")
|
|
if (!relative.found) return { error: "could not find target widget " + relativeId }
|
|
return {
|
|
section: relative.section,
|
|
index: relative.index + (target.after ? 1 : 0)
|
|
}
|
|
}
|
|
|
|
if (!Array.isArray(config.bar.layout[section])) config.bar.layout[section] = []
|
|
if (target.index !== undefined && target.index !== null) {
|
|
var requested = Math.max(0, Math.floor(Number(target.index)))
|
|
return { section: section, index: Math.min(requested, config.bar.layout[section].length) }
|
|
}
|
|
|
|
var anchors = { left: "omarchy.workspaces", center: "omarchy.weather", right: "omarchy.tray" }
|
|
var anchor = findBarLocation(config, anchors[section], section)
|
|
return {
|
|
section: section,
|
|
index: anchor.found ? anchor.index + 1 : config.bar.layout[section].length
|
|
}
|
|
}
|
|
|
|
function moveBarEntry(config, id, placement) {
|
|
var key = Util.canonicalWidgetId(String(id))
|
|
var source
|
|
if (placement.fromIndex !== undefined && placement.fromIndex !== null) {
|
|
var fromSection = String(placement.fromSection || "")
|
|
if (!fromSection) return "from-index requires from-section"
|
|
var entries = config.bar.layout[fromSection]
|
|
var fromIndex = Math.floor(Number(placement.fromIndex))
|
|
if (!Array.isArray(entries) || fromIndex < 0 || fromIndex >= entries.length)
|
|
return "no widget at " + fromSection + "[" + fromIndex + "]"
|
|
if (barEntryId(entries[fromIndex]) !== key)
|
|
return "widget at " + fromSection + "[" + fromIndex + "] is not " + key
|
|
source = { found: true, section: fromSection, index: fromIndex }
|
|
} else {
|
|
source = findBarLocation(config, key, String(placement.fromSection || ""))
|
|
if (!source.found) return "could not find widget " + key
|
|
}
|
|
|
|
var entry = config.bar.layout[source.section][source.index]
|
|
config.bar.layout[source.section].splice(source.index, 1)
|
|
var target = barTarget(config, placement, source.section)
|
|
if (target.error) {
|
|
config.bar.layout[source.section].splice(source.index, 0, entry)
|
|
return target.error
|
|
}
|
|
config.bar.layout[target.section].splice(target.index, 0, entry)
|
|
return ""
|
|
}
|
|
|
|
function moveBarWidget(id, placement) {
|
|
var error = ""
|
|
shellConfigMutator(function(config) {
|
|
ensureConfigShape(config)
|
|
error = moveBarEntry(config, id, placement || {})
|
|
})
|
|
if (error) return error
|
|
registryRevision++
|
|
pluginsChanged()
|
|
return ""
|
|
}
|
|
|
|
function setBarWidget(id, key, value, selector) {
|
|
var error = ""
|
|
shellConfigMutator(function(config) {
|
|
ensureConfigShape(config)
|
|
var location
|
|
var requested = selector || {}
|
|
var section = String(requested.fromSection || requested.section || "")
|
|
var index = requested.fromIndex !== undefined && requested.fromIndex !== null
|
|
? requested.fromIndex : requested.index
|
|
if (index !== undefined && index !== null) {
|
|
if (!section) {
|
|
error = "index requires section"
|
|
return
|
|
}
|
|
var entries = config.bar.layout[section]
|
|
var numericIndex = Math.floor(Number(index))
|
|
if (!Array.isArray(entries) || numericIndex < 0 || numericIndex >= entries.length) {
|
|
error = "no widget at " + section + "[" + numericIndex + "]"
|
|
return
|
|
}
|
|
location = { found: true, section: section, index: numericIndex }
|
|
} else {
|
|
location = findBarLocation(config, id, section)
|
|
}
|
|
if (!location.found) {
|
|
error = "could not find widget " + id
|
|
return
|
|
}
|
|
if (barEntryId(config.bar.layout[location.section][location.index]) !== String(id)) {
|
|
error = "widget at " + location.section + "[" + location.index + "] is not " + id
|
|
return
|
|
}
|
|
var entry = config.bar.layout[location.section][location.index]
|
|
if (!Util.isPlainObject(entry)) {
|
|
error = "widget entry must be an object"
|
|
return
|
|
}
|
|
entry[String(key)] = value
|
|
})
|
|
if (error) return error
|
|
registryRevision++
|
|
pluginsChanged()
|
|
return ""
|
|
}
|
|
|
|
function ensureConfigShape(config) {
|
|
if (!Util.isPlainObject(config.bar)) config.bar = { layout: { left: [], center: [], right: [] } }
|
|
if (!Util.isPlainObject(config.bar.layout)) config.bar.layout = { left: [], center: [], right: [] }
|
|
var sections = ["left", "center", "right"]
|
|
for (var i = 0; i < sections.length; i++) {
|
|
if (!Array.isArray(config.bar.layout[sections[i]])) config.bar.layout[sections[i]] = []
|
|
}
|
|
if (!Array.isArray(config.plugins)) config.plugins = []
|
|
}
|
|
|
|
// Bar widgets use the default section declared in their manifest, falling
|
|
// back to center. Panels/overlays/menus/services go into the plugins[] array.
|
|
// Built-ins are already loaded, so shell.json only ever records the
|
|
// deviation: an added third-party plugin in plugins[], a switched-off
|
|
// built-in in disabledPlugins[].
|
|
function removeDisabled(config, id) {
|
|
if (!Array.isArray(config.disabledPlugins)) return
|
|
config.disabledPlugins = config.disabledPlugins.filter(function(entry) { return entry !== id })
|
|
if (config.disabledPlugins.length === 0) delete config.disabledPlugins
|
|
}
|
|
|
|
function addDisabled(config, id) {
|
|
if (isDisabled(config, id)) return
|
|
if (!Array.isArray(config.disabledPlugins)) config.disabledPlugins = []
|
|
config.disabledPlugins.push(id)
|
|
}
|
|
|
|
function cloneShouldRestoreSource(config, id) {
|
|
return Array.isArray(config.cloneSourceRestores) && config.cloneSourceRestores.indexOf(id) !== -1
|
|
}
|
|
|
|
function setCloneShouldRestoreSource(config, id, value) {
|
|
var restores = Array.isArray(config.cloneSourceRestores) ? config.cloneSourceRestores : []
|
|
restores = restores.filter(function(entry) { return entry !== id })
|
|
if (value) restores.push(id)
|
|
if (restores.length) config.cloneSourceRestores = restores
|
|
else delete config.cloneSourceRestores
|
|
}
|
|
|
|
function activeCloneFor(config, sourceId) {
|
|
for (var candidate in installedPlugins) {
|
|
var candidateManifest = installedPlugins[candidate]
|
|
var candidateMetadata = candidateManifest && Util.isPlainObject(candidateManifest.omarchy)
|
|
? candidateManifest.omarchy : null
|
|
if (!candidateMetadata || String(candidateMetadata.clonedFrom || "") !== sourceId) continue
|
|
if (Array.isArray(candidateManifest.kinds) && candidateManifest.kinds.indexOf("bar") !== -1) {
|
|
if (Util.canonicalWidgetId(String(config.bar.id || "")) === candidate) return candidate
|
|
} else if (findEntryLocation(config, candidate).found) {
|
|
return candidate
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
function restoreCloneSource(config, cloneId, sourceId) {
|
|
var cloneManifest = installedPlugins[cloneId]
|
|
var isBarOption = cloneManifest && Array.isArray(cloneManifest.kinds)
|
|
&& cloneManifest.kinds.indexOf("bar") !== -1
|
|
if (isBarOption) {
|
|
if (sourceId === "omarchy.bar") delete config.bar.id
|
|
else config.bar.id = sourceId
|
|
} else {
|
|
var cloneLocation = findEntryLocation(config, cloneId)
|
|
if (cloneLocation.kind === "bar") {
|
|
var cloneEntry = config.bar.layout[cloneLocation.section][cloneLocation.index]
|
|
var sections = ["left", "center", "right"]
|
|
for (var s = 0; s < sections.length; s++) {
|
|
for (var i = config.bar.layout[sections[s]].length - 1; i >= 0; i--) {
|
|
if (barEntryId(config.bar.layout[sections[s]][i]) === sourceId)
|
|
config.bar.layout[sections[s]].splice(i, 1)
|
|
}
|
|
}
|
|
cloneLocation = findBarLocation(config, cloneId, "")
|
|
if (cloneLocation.found) {
|
|
var restoredEntry = Util.isPlainObject(cloneEntry) ? Util.cloneJson(cloneEntry) : {}
|
|
restoredEntry.id = sourceId
|
|
config.bar.layout[cloneLocation.section][cloneLocation.index] = restoredEntry
|
|
}
|
|
} else if (cloneLocation.kind === "plugin") {
|
|
config.plugins.splice(cloneLocation.index, 1)
|
|
}
|
|
}
|
|
|
|
if (cloneShouldRestoreSource(config, cloneId)) removeDisabled(config, sourceId)
|
|
setCloneShouldRestoreSource(config, cloneId, false)
|
|
}
|
|
|
|
function setEnabled(id, value, placement) {
|
|
var key = Util.canonicalWidgetId(String(id))
|
|
lastEnableError = ""
|
|
if (!shellConfigMutator) {
|
|
console.warn("PluginRegistry.setEnabled called before shellConfigMutator wired")
|
|
return false
|
|
}
|
|
var manifest = installedPlugins[key]
|
|
if (value && !manifest) {
|
|
console.warn("PluginRegistry.setEnabled: unknown plugin " + key)
|
|
return false
|
|
}
|
|
var isBarOption = manifest && Array.isArray(manifest.kinds) && manifest.kinds.indexOf("bar") !== -1
|
|
var isBarWidget = manifest && Array.isArray(manifest.kinds) && manifest.kinds.indexOf("bar-widget") !== -1
|
|
var hasNonWidgetKind = manifest && Array.isArray(manifest.kinds)
|
|
&& manifest.kinds.some(function(kind) { return kind !== "bar-widget" })
|
|
var metadata = manifest && Util.isPlainObject(manifest.omarchy) ? manifest.omarchy : null
|
|
var clonedFrom = metadata ? Util.canonicalWidgetId(String(metadata.clonedFrom || "")) : ""
|
|
shellConfigMutator(function(config) {
|
|
ensureConfigShape(config)
|
|
|
|
if (value && placement && (placement.before || placement.after)) {
|
|
var relativeId = String(placement.before || placement.after)
|
|
if (!findBarLocation(config, relativeId, String(placement.section || "")).found) {
|
|
lastEnableError = "could not find target widget " + relativeId
|
|
return
|
|
}
|
|
}
|
|
|
|
if (value && manifest && manifest.__isFirstParty) {
|
|
var activeClone = activeCloneFor(config, key)
|
|
if (activeClone) {
|
|
restoreCloneSource(config, activeClone, key)
|
|
removeDisabled(config, key)
|
|
}
|
|
}
|
|
|
|
if (isBarOption) {
|
|
if (value) {
|
|
config.bar.id = key
|
|
} else if (Util.canonicalWidgetId(String(config.bar.id || "")) === key) {
|
|
if (clonedFrom && clonedFrom !== "omarchy.bar") config.bar.id = clonedFrom
|
|
else delete config.bar.id
|
|
}
|
|
return
|
|
}
|
|
|
|
var isFirstParty = manifest && manifest.__isFirstParty
|
|
var location = findEntryLocation(config, key)
|
|
|
|
if (value) {
|
|
removeDisabled(config, key)
|
|
var entry = { id: key }
|
|
var insertedWithPlacement = false
|
|
if (!location.found && isBarWidget) {
|
|
var sourceLocation = clonedFrom ? findEntryLocation(config, clonedFrom) : { found: false }
|
|
if (sourceLocation.kind === "bar") {
|
|
var sourceEntry = config.bar.layout[sourceLocation.section][sourceLocation.index]
|
|
var replacement = Util.isPlainObject(sourceEntry) ? Util.cloneJson(sourceEntry) : entry
|
|
replacement.id = key
|
|
config.bar.layout[sourceLocation.section][sourceLocation.index] = replacement
|
|
} else {
|
|
var section = defaultBarWidgetSection(manifest)
|
|
var target = barTarget(config, placement || {}, section)
|
|
config.bar.layout[target.section].splice(target.index, 0, entry)
|
|
insertedWithPlacement = true
|
|
}
|
|
} else if (!location.found && !isFirstParty) {
|
|
config.plugins.push(entry)
|
|
}
|
|
|
|
if (isBarWidget && !insertedWithPlacement && placement && Object.keys(placement).length)
|
|
moveBarEntry(config, key, placement)
|
|
|
|
if (clonedFrom && hasNonWidgetKind && !isDisabled(config, clonedFrom)) {
|
|
addDisabled(config, clonedFrom)
|
|
setCloneShouldRestoreSource(config, key, true)
|
|
}
|
|
return
|
|
}
|
|
|
|
if (clonedFrom) restoreCloneSource(config, key, clonedFrom)
|
|
else if (location.kind === "bar") config.bar.layout[location.section].splice(location.index, 1)
|
|
else if (location.kind === "plugin") config.plugins.splice(location.index, 1)
|
|
|
|
// Dropping the layout entry is the whole story for a widget. Anything
|
|
// else built-in loads by default, so switching it off has to be stated.
|
|
if (isFirstParty && !isBarWidget) addDisabled(config, key)
|
|
})
|
|
if (lastEnableError) return false
|
|
registryRevision++
|
|
pluginsChanged()
|
|
return true
|
|
}
|
|
|
|
// ---------------------------------------------------------------- scanning
|
|
|
|
// Output format produced by the rescan script:
|
|
// ===<kind>::<absolute-source-dir>===
|
|
// ... 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 first-party ids. The whole
|
|
// `omarchy.*` namespace is reserved for built-ins, including bar widgets
|
|
// registered outside the manifest-based plugin registry.
|
|
for (var tk in thirdParty) {
|
|
if (firstParty[tk] || String(tk).indexOf("omarchy.") === 0) {
|
|
console.warn("PluginRegistry: plugin " + tk
|
|
+ " rejected: id is reserved for first-party Omarchy plugins")
|
|
continue
|
|
}
|
|
merged[tk] = thirdParty[tk]
|
|
}
|
|
|
|
installedPlugins = merged
|
|
registryRevision++
|
|
scanning = false
|
|
pluginsChanged()
|
|
scanFinished()
|
|
}
|
|
|
|
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: {
|
|
localPluginWatcher.running = true
|
|
registry.rescan()
|
|
}
|
|
}
|
|
|
|
property Process localPluginWatcher: Process {
|
|
command: [
|
|
"inotifywait",
|
|
"-m",
|
|
"-r",
|
|
"-q",
|
|
"-e",
|
|
"close_write,create,delete,move",
|
|
"--format",
|
|
"%w%f",
|
|
registry.pluginsDir
|
|
]
|
|
stdout: SplitParser {
|
|
onRead: function(path) {
|
|
var pluginId = registry.localPluginIdForPath(path)
|
|
if (pluginId) registry.localPluginChanged(pluginId)
|
|
}
|
|
}
|
|
onExited: localPluginWatcherRestart.restart()
|
|
}
|
|
|
|
property Timer localPluginWatcherRestart: Timer {
|
|
interval: 1000
|
|
onTriggered: localPluginWatcher.running = true
|
|
}
|
|
|
|
function rescan() {
|
|
if (scanning) return
|
|
scanning = true
|
|
// $0 = first-party dir, $1 = third-party dir. Some bash versions need the explicit -- separator.
|
|
// First-party plugins may be grouped one level deeper, e.g. panels/audio
|
|
// or services/battery.
|
|
// First-party bar widgets can also carry sibling manifests such as
|
|
// widgets/Clock.manifest.json so multiple widgets can live in one source
|
|
// directory without wrapper folders.
|
|
// Third-party plugins stay at the top level of ~/.config/omarchy/plugins.
|
|
var script = ""
|
|
+ "emit_manifest() { local kind=\"$1\"; local manifest=\"$2\"; local sub; "
|
|
+ " if [[ ${manifest##*/} == \"manifest.json\" ]]; then sub=\"${manifest%/manifest.json}\"; else sub=\"$(dirname -- \"$manifest\")\"; fi; "
|
|
+ " printf '===%s::%s===\\n' \"$kind\" \"$sub\"; "
|
|
+ " cat \"$manifest\"; "
|
|
+ " printf '\\n=== EOM ===\\n'; "
|
|
+ "}; "
|
|
+ "scan_firstparty() { local dir=\"$1\"; "
|
|
+ " [[ -d \"$dir\" ]] || return 0; "
|
|
+ " while IFS= read -r manifest; do emit_manifest firstparty \"$manifest\"; done < <(find \"$dir\" -mindepth 2 -maxdepth 3 -type f \\( -name manifest.json -o -name '*.manifest.json' \\) | sort); "
|
|
+ "}; "
|
|
+ "scan_thirdparty() { local dir=\"$1\"; "
|
|
+ " [[ -d \"$dir\" ]] || return 0; "
|
|
+ " for sub in \"$dir\"/*/; do "
|
|
+ " [[ -f \"$sub/manifest.json\" ]] || continue; "
|
|
+ " emit_manifest thirdparty \"$sub/manifest.json\"; "
|
|
+ " done; "
|
|
+ "}; "
|
|
+ "scan_firstparty \"$0\"; "
|
|
+ "scan_thirdparty \"$1\""
|
|
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
|
|
}
|
|
|
|
function localPluginIdForPath(filePath) {
|
|
var base = pluginsDir.replace(/\/$/, "") + "/"
|
|
var path = String(filePath || "").trim()
|
|
if (path.indexOf(base + "local.") !== 0) return ""
|
|
|
|
var relative = path.slice(base.length)
|
|
if (relative.indexOf("/.git/") !== -1 || relative.endsWith("/.git")) return ""
|
|
|
|
var slash = relative.indexOf("/")
|
|
return slash === -1 ? relative : relative.slice(0, slash)
|
|
}
|
|
|
|
Component.onCompleted: ensureUserDir()
|
|
}
|