Unify config into shell.json and add Noctalia plugin support
This commit is contained in:
@@ -5,6 +5,9 @@ import Quickshell.Io
|
||||
|
||||
import "plugins/bar"
|
||||
import "services"
|
||||
import "compat/noctalia" as Compat
|
||||
import qs.Services.UI as NoctaliaUI
|
||||
import qs.Commons as NoctaliaCommons
|
||||
|
||||
ShellRoot {
|
||||
id: shell
|
||||
@@ -32,23 +35,363 @@ ShellRoot {
|
||||
}
|
||||
property string omarchyPath: deriveOmarchyPath()
|
||||
readonly property string firstPartyPluginsDir: omarchyPath + "/default/quickshell/omarchy-shell/plugins"
|
||||
readonly property string defaultsPath: omarchyPath + "/default/quickshell/omarchy-shell/shell-defaults.json"
|
||||
readonly property string userConfigPath: home + "/.config/omarchy/shell.json"
|
||||
|
||||
// Bundled fallback so the shell can start even when shell-defaults.json is
|
||||
// missing or unreadable. The bar config here mirrors the on-disk defaults
|
||||
// closely enough to render a usable bar; not authoritative.
|
||||
readonly property var builtinShellConfig: ({
|
||||
version: 1,
|
||||
bar: {
|
||||
position: "top",
|
||||
fontFamily: "JetBrainsMono Nerd Font",
|
||||
centerAnchor: "calendar",
|
||||
layout: {
|
||||
left: [{ id: "omarchy" }, { id: "workspacesPro" }],
|
||||
center: [{ id: "calendar", format: "dddd HH:mm" }],
|
||||
right: [{ id: "audioPanel" }, { id: "controlCenter" }, { id: "powerMenu" }]
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
{ id: "omarchy.bar-settings" },
|
||||
{ id: "omarchy.image-picker" }
|
||||
]
|
||||
})
|
||||
|
||||
property var defaultsConfig: builtinShellConfig
|
||||
property var shellConfig: builtinShellConfig
|
||||
property bool suppressUserReload: false
|
||||
|
||||
function isPlainObject(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function applyShellConfig() {
|
||||
// Decide which source is canonical: a valid user shell.json overrides
|
||||
// defaults entirely; otherwise fall back to defaults. We do not deep-merge.
|
||||
var defaults = isPlainObject(defaultsConfig) ? defaultsConfig : builtinShellConfig
|
||||
var user = null
|
||||
var userText = userConfigFile.text() || ""
|
||||
if (userText.trim()) {
|
||||
try {
|
||||
var parsed = JSON.parse(userText)
|
||||
if (isPlainObject(parsed) && parsed.version === 1) user = parsed
|
||||
else if (isPlainObject(parsed)) console.warn("shell.json missing version: 1, using defaults")
|
||||
} catch (e) {
|
||||
console.warn("shell.json parse failed, using defaults:", e)
|
||||
}
|
||||
}
|
||||
shellConfig = user || defaults
|
||||
}
|
||||
|
||||
function loadDefaults(raw) {
|
||||
var text = String(raw || "").trim()
|
||||
if (!text) {
|
||||
defaultsConfig = builtinShellConfig
|
||||
applyShellConfig()
|
||||
return
|
||||
}
|
||||
try {
|
||||
var parsed = JSON.parse(text)
|
||||
if (isPlainObject(parsed) && parsed.version === 1) defaultsConfig = parsed
|
||||
else defaultsConfig = builtinShellConfig
|
||||
} catch (e) {
|
||||
console.warn("shell-defaults.json parse failed, using builtin:", e)
|
||||
defaultsConfig = builtinShellConfig
|
||||
}
|
||||
applyShellConfig()
|
||||
}
|
||||
|
||||
function persistShellConfig(nextConfig) {
|
||||
suppressUserReload = true
|
||||
var payload = JSON.parse(JSON.stringify(nextConfig))
|
||||
payload.version = 1
|
||||
shellConfig = payload
|
||||
userConfigFile.setText(JSON.stringify(payload, null, 2) + "\n")
|
||||
}
|
||||
|
||||
readonly property var barConfig: shellConfig && isPlainObject(shellConfig.bar) ? shellConfig.bar : builtinShellConfig.bar
|
||||
readonly property var pluginsConfig: shellConfig && Array.isArray(shellConfig.plugins) ? shellConfig.plugins : []
|
||||
|
||||
FileView {
|
||||
id: defaultsFile
|
||||
path: shell.defaultsPath
|
||||
watchChanges: true
|
||||
printErrors: false
|
||||
onLoaded: shell.loadDefaults(text())
|
||||
onLoadFailed: function(error) {
|
||||
console.warn("shell-defaults load failed: " + error + " path=" + shell.defaultsPath)
|
||||
shell.loadDefaults("")
|
||||
}
|
||||
onFileChanged: reload()
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: userConfigFile
|
||||
path: shell.userConfigPath
|
||||
watchChanges: true
|
||||
atomicWrites: true
|
||||
printErrors: false
|
||||
onLoaded: {
|
||||
if (shell.suppressUserReload) {
|
||||
shell.suppressUserReload = false
|
||||
return
|
||||
}
|
||||
shell.applyShellConfig()
|
||||
}
|
||||
onLoadFailed: function(error) { shell.applyShellConfig() }
|
||||
onFileChanged: reload()
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
console.log("omarchy-shell paths",
|
||||
"omarchyPath=" + shell.omarchyPath,
|
||||
"shellDir=" + Quickshell.shellDir,
|
||||
"firstPartyPluginsDir=" + shell.firstPartyPluginsDir)
|
||||
"firstPartyPluginsDir=" + shell.firstPartyPluginsDir,
|
||||
"defaultsPath=" + shell.defaultsPath,
|
||||
"userConfigPath=" + shell.userConfigPath)
|
||||
pluginRegistry.firstPartyDir = shell.firstPartyPluginsDir
|
||||
pluginRegistry.shellConfigProvider = function() { return shell.shellConfig }
|
||||
pluginRegistry.shellConfigMutator = function(mutate) { shell.mutateShellConfig(mutate) }
|
||||
// PluginRegistry.ensureUserDir() runs in its own Component.onCompleted and
|
||||
// chains rescan() once the directory exists. We also kick a scan here in
|
||||
// case the user dir already existed at startup.
|
||||
pluginRegistry.rescan()
|
||||
|
||||
// Wire Noctalia compat singletons. Plugins read state from these globals;
|
||||
// we hand them references to the host so they can route into the right
|
||||
// popup, tooltip, etc.
|
||||
NoctaliaUI.BarService.bar = bar
|
||||
NoctaliaUI.TooltipService.bar = bar
|
||||
NoctaliaUI.PanelService.bar = bar
|
||||
NoctaliaUI.PanelService.shell = shell
|
||||
NoctaliaCommons.Settings.shellConfig = shell.shellConfig
|
||||
}
|
||||
|
||||
// Keep Noctalia.Settings.data in sync with whatever shell.json currently is.
|
||||
onShellConfigChanged: NoctaliaCommons.Settings.shellConfig = shell.shellConfig
|
||||
|
||||
function mutateShellConfig(mutator) {
|
||||
var copy = JSON.parse(JSON.stringify(shellConfig || builtinShellConfig))
|
||||
mutator(copy)
|
||||
persistShellConfig(copy)
|
||||
}
|
||||
|
||||
Bar {
|
||||
id: bar
|
||||
omarchyPath: shell.omarchyPath
|
||||
barWidgetRegistry: shell.barWidgetRegistry
|
||||
barConfig: shell.barConfig
|
||||
pluginRegistry: shell.pluginRegistry
|
||||
shell: shell
|
||||
}
|
||||
|
||||
// ------------------------------------------------- Noctalia plugin API
|
||||
//
|
||||
// One pluginApi instance per plugin, cached for the life of the shell. The
|
||||
// factory is host-owned (shell, not bar) so non-bar Noctalia plugins
|
||||
// (panels, services) can call into it too. Settings lookups walk the live
|
||||
// shell.json on every read so reorders/edits never leave a stale entry
|
||||
// captured in the closure.
|
||||
Compat.PluginApiFactory { id: noctaliaApiFactory }
|
||||
|
||||
property var _noctaliaApis: ({})
|
||||
property var _noctaliaServices: ({})
|
||||
|
||||
function findShellEntry(pluginId) {
|
||||
var config = shellConfig
|
||||
if (!isPlainObject(config)) return null
|
||||
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 === pluginId) return arr[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Array.isArray(config.plugins)) {
|
||||
for (var p = 0; p < config.plugins.length; p++) {
|
||||
if (config.plugins[p] && config.plugins[p].id === pluginId) return config.plugins[p]
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function noctaliaPluginApiFor(pluginId) {
|
||||
var key = String(pluginId)
|
||||
if (!key) return null
|
||||
var existing = _noctaliaApis[key]
|
||||
if (existing) return existing
|
||||
var manifest = pluginRegistry && pluginRegistry.installedPlugins
|
||||
? pluginRegistry.installedPlugins[key] : null
|
||||
if (!manifest || !manifest.__noctaliaCompat) return null
|
||||
|
||||
var api = noctaliaApiFactory.create(
|
||||
key,
|
||||
manifest,
|
||||
function() {
|
||||
// Compute effective settings on every read. We can't capture an entry
|
||||
// reference because mutateSection rebuilds the layout objects and the
|
||||
// captured reference would point at a snapshot.
|
||||
var defaults = (manifest.barWidget && manifest.barWidget.defaults) || manifest.defaults || {}
|
||||
var merged = {}
|
||||
for (var k in defaults) merged[k] = defaults[k]
|
||||
var entry = findShellEntry(key)
|
||||
if (entry) {
|
||||
for (var ek in entry) if (ek !== "id") merged[ek] = entry[ek]
|
||||
}
|
||||
return merged
|
||||
},
|
||||
{
|
||||
persistSettings: function(_pluginId, settings) {
|
||||
if (typeof shell.updateEntryInline === "function")
|
||||
shell.updateEntryInline(key, settings)
|
||||
},
|
||||
openPanel: function(_pluginId, _screen, _btn) {
|
||||
shell.summon(key, JSON.stringify({ source: "noctalia" }))
|
||||
},
|
||||
closePanel: function(_pluginId, _screen) { shell.hide(key) },
|
||||
currentScreen: function() {
|
||||
var screens = Quickshell.screens
|
||||
return screens && screens.length > 0 ? screens[0] : null
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
var next = ({})
|
||||
for (var existingKey in _noctaliaApis) next[existingKey] = _noctaliaApis[existingKey]
|
||||
next[key] = api
|
||||
_noctaliaApis = next
|
||||
return api
|
||||
}
|
||||
|
||||
// -------------------------------------------------- noctalia service main
|
||||
//
|
||||
// Noctalia plugins with `entryPoints.main` (translated to kind="service")
|
||||
// are instantiated once per shell session when enabled. The Main.qml is
|
||||
// typically headless — a data source the plugin's bar widget reads from.
|
||||
// We rely on the shared pluginApi so the service and any bar widgets see
|
||||
// the same handle.
|
||||
function ensureNoctaliaService(pluginId) {
|
||||
var key = String(pluginId)
|
||||
if (_noctaliaServices[key]) return _noctaliaServices[key]
|
||||
var manifest = pluginRegistry && pluginRegistry.installedPlugins
|
||||
? pluginRegistry.installedPlugins[key] : null
|
||||
if (!manifest || !manifest.__noctaliaCompat) return null
|
||||
if (!manifest.entryPoints || !manifest.entryPoints.service) return null
|
||||
var url = pluginRegistry.entryPointUrl(manifest, "service")
|
||||
if (!url) return null
|
||||
var api = noctaliaPluginApiFor(key)
|
||||
if (!api) return null
|
||||
|
||||
var comp = Qt.createComponent(url, Component.PreferSynchronous)
|
||||
function finalize() {
|
||||
if (comp.status !== Component.Ready) {
|
||||
console.warn("noctalia service load failed for " + key + ": " + comp.errorString())
|
||||
return
|
||||
}
|
||||
var inst = comp.createObject(shell, { pluginApi: api })
|
||||
if (!inst) {
|
||||
console.warn("noctalia service createObject returned null for", key)
|
||||
return
|
||||
}
|
||||
var snext = ({})
|
||||
for (var sk in _noctaliaServices) snext[sk] = _noctaliaServices[sk]
|
||||
snext[key] = inst
|
||||
_noctaliaServices = snext
|
||||
api.mainInstance = inst
|
||||
}
|
||||
if (comp.status === Component.Loading) {
|
||||
comp.statusChanged.connect(finalize)
|
||||
return null
|
||||
}
|
||||
finalize()
|
||||
return _noctaliaServices[key] || null
|
||||
}
|
||||
|
||||
function _syncNoctaliaServices() {
|
||||
if (!pluginRegistry || !pluginRegistry.installedPlugins) return
|
||||
var plugins = pluginRegistry.installedPlugins
|
||||
for (var id in plugins) {
|
||||
var m = plugins[id]
|
||||
if (!m || !m.__noctaliaCompat) continue
|
||||
if (!m.entryPoints || !m.entryPoints.service) continue
|
||||
if (!pluginRegistry.isEnabled(id)) continue
|
||||
if (_noctaliaServices[id]) continue
|
||||
ensureNoctaliaService(id)
|
||||
}
|
||||
// Drop services for plugins that have been disabled or removed.
|
||||
for (var existingId in _noctaliaServices) {
|
||||
var stillThere = plugins[existingId]
|
||||
var stillEnabled = stillThere && pluginRegistry.isEnabled(existingId)
|
||||
if (stillThere && stillEnabled) continue
|
||||
var inst = _noctaliaServices[existingId]
|
||||
if (inst && typeof inst.destroy === "function") inst.destroy()
|
||||
var next = ({})
|
||||
for (var k in _noctaliaServices) if (k !== existingId) next[k] = _noctaliaServices[k]
|
||||
_noctaliaServices = next
|
||||
var apis = ({})
|
||||
for (var ak in _noctaliaApis) apis[ak] = _noctaliaApis[ak]
|
||||
if (apis[existingId]) apis[existingId].mainInstance = null
|
||||
_noctaliaApis = apis
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: shell.pluginRegistry
|
||||
function onPluginsChanged() { shell._syncNoctaliaServices() }
|
||||
}
|
||||
|
||||
// Used by Noctalia compat: pluginApi.saveSettings() ends up writing inline
|
||||
// settings to the plugin's entry in shell.json. moduleName is the entry id
|
||||
// (the bare manifest id, e.g. "noctalia.air-quality"); settings is the
|
||||
// merged plugin state. Returns true if anything actually changed.
|
||||
// Compute the proposed new shellConfig in a local clone, and only persist
|
||||
// if anything actually changed. Lets Noctalia plugins call saveSettings()
|
||||
// repeatedly with identical values (common for reactive QML bindings)
|
||||
// without dirtying shell.json or thrashing the file watcher.
|
||||
function updateEntryInline(moduleName, settings) {
|
||||
var stripped = String(moduleName)
|
||||
var copy = JSON.parse(JSON.stringify(shellConfig || builtinShellConfig))
|
||||
if (!isPlainObject(copy.bar)) copy.bar = { layout: { left: [], center: [], right: [] } }
|
||||
if (!isPlainObject(copy.bar.layout)) copy.bar.layout = { left: [], center: [], right: [] }
|
||||
if (!Array.isArray(copy.plugins)) copy.plugins = []
|
||||
|
||||
var sections = ["left", "center", "right"]
|
||||
var foundInLayout = false
|
||||
var dirty = false
|
||||
for (var s = 0; s < sections.length; s++) {
|
||||
var arr = copy.bar.layout[sections[s]] || []
|
||||
for (var i = 0; i < arr.length; i++) {
|
||||
if (arr[i] && arr[i].id === stripped) {
|
||||
var next = { id: stripped }
|
||||
for (var k in settings) if (k !== "id") next[k] = settings[k]
|
||||
if (JSON.stringify(arr[i]) !== JSON.stringify(next)) {
|
||||
arr[i] = next
|
||||
dirty = true
|
||||
}
|
||||
foundInLayout = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!foundInLayout) {
|
||||
for (var j = 0; j < copy.plugins.length; j++) {
|
||||
if (copy.plugins[j] && copy.plugins[j].id === stripped) {
|
||||
var pnext = { id: stripped }
|
||||
for (var pk in settings) if (pk !== "id") pnext[pk] = settings[pk]
|
||||
if (JSON.stringify(copy.plugins[j]) !== JSON.stringify(pnext)) {
|
||||
copy.plugins[j] = pnext
|
||||
dirty = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!dirty) return false
|
||||
persistShellConfig(copy)
|
||||
return true
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- on-demand panels
|
||||
@@ -206,6 +549,11 @@ ShellRoot {
|
||||
if ("manifest" in item) item.manifest = panelEntry.manifest
|
||||
if ("barWidgetRegistry" in item) item.barWidgetRegistry = shell.barWidgetRegistry
|
||||
if ("pluginRegistry" in item) item.pluginRegistry = shell.pluginRegistry
|
||||
// Noctalia panel/overlay/menu plugins expect a pluginApi just like
|
||||
// their bar widgets do. First-party Omarchy plugins don't declare
|
||||
// the property so the `in target` check skips them.
|
||||
if (panelEntry.manifest && panelEntry.manifest.__noctaliaCompat && "pluginApi" in item)
|
||||
item.pluginApi = shell.noctaliaPluginApiFor(panelEntry.pluginId)
|
||||
shell.registerPanelLoader(panelEntry.pluginId, this)
|
||||
}
|
||||
onStatusChanged: {
|
||||
@@ -224,7 +572,10 @@ ShellRoot {
|
||||
|
||||
// Mirror plugin registry state into BarWidgetRegistry whenever it changes.
|
||||
// Each enabled plugin with kind "bar-widget" gets a Component created from
|
||||
// its manifest entry point and registered under the id "plugin:<id>".
|
||||
// its manifest entry point and registered under its plain manifest id.
|
||||
// First-party widget ids (calendar, weather, etc.) are short and don't
|
||||
// collide with namespaced plugin ids like noctalia.air-quality, so we don't
|
||||
// need a separate "plugin:" namespace anymore.
|
||||
Connections {
|
||||
target: shell.pluginRegistry
|
||||
function onPluginsChanged() { shell.syncPluginWidgets() }
|
||||
@@ -241,7 +592,7 @@ ShellRoot {
|
||||
if (!manifest || !manifest.kinds || manifest.kinds.indexOf("bar-widget") === -1) continue
|
||||
if (!shell.pluginRegistry.isEnabled(pluginId)) continue
|
||||
|
||||
var registryKey = "plugin:" + manifest.id
|
||||
var registryKey = String(manifest.id)
|
||||
seen[registryKey] = true
|
||||
|
||||
// Already loaded with matching source — leave it alone.
|
||||
@@ -272,7 +623,7 @@ ShellRoot {
|
||||
var allIds = shell.barWidgetRegistry.availableIds()
|
||||
for (var i = 0; i < allIds.length; i++) {
|
||||
var id = allIds[i]
|
||||
if (id.indexOf("plugin:") !== 0) continue
|
||||
if (!pluginWidgetComponents[id]) continue
|
||||
if (!seen[id]) {
|
||||
shell.barWidgetRegistry.unregister(id)
|
||||
var next = ({})
|
||||
@@ -335,6 +686,13 @@ ShellRoot {
|
||||
return JSON.stringify(out)
|
||||
}
|
||||
|
||||
// Returns the effective shell.json content as JSON. Useful for debugging
|
||||
// and for CLI tools that want to inspect the merged state without
|
||||
// re-implementing the load logic.
|
||||
function listShellConfig(): string {
|
||||
return JSON.stringify(shell.shellConfig || {})
|
||||
}
|
||||
|
||||
function summon(id: string, payloadJson: string): string {
|
||||
return shell.summon(id, payloadJson) ? "ok" : "unknown"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user