From 92246a50fbfef71dfb1ec7e1d4276b3a927fc49d Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Sat, 6 Jun 2026 13:25:53 -0400 Subject: [PATCH] Enable plugin hot reloading for add / remove --- bin/omarchy-plugin | 24 +++++++- bin/omarchy-plugin-add | 19 +++++- bin/omarchy-plugin-remove | 3 +- bin/omarchy-plugin-update | 3 +- default/omarchy-skill/SKILL.md | 2 +- docs/omarchy-shell.md | 4 +- shell/README.md | 4 +- shell/plugins/panels/tailscale/README.md | 2 +- shell/services/PluginRegistry.qml | 9 ++- shell/shell.qml | 77 +++++++++++++++++++++--- 10 files changed, 126 insertions(+), 21 deletions(-) diff --git a/bin/omarchy-plugin b/bin/omarchy-plugin index bb411ef2..99c2b46a 100755 --- a/bin/omarchy-plugin +++ b/bin/omarchy-plugin @@ -94,6 +94,21 @@ require_command() { command -v "$command_name" >/dev/null 2>&1 || fail "$command_name is required" } +plugin_discovered() { + local id="$1" plugins + plugins=$(omarchy-shell shell listPlugins 2>/dev/null) || return 1 + jq -e --arg id "$id" 'any(.[]; .id == $id)' <<<"$plugins" >/dev/null 2>&1 +} + +wait_for_plugin_discovery() { + local id="$1" attempt + for (( attempt = 0; attempt < 40; attempt++ )); do + plugin_discovered "$id" && return 0 + sleep 0.05 + done + return 1 +} + section_valid() { [[ $1 =~ ^(left|center|right)$ ]] } @@ -181,7 +196,14 @@ plugin_enabled() { id=$(canonical_widget_id "$id") omarchy-shell shell rescanPlugins >/dev/null 2>&1 || true - omarchy-shell shell setPluginEnabled "$id" "$enabled" || fail "could not update plugin; is omarchy-shell running?" + if [[ $enabled == "true" ]]; then + require_command jq + wait_for_plugin_discovery "$id" || fail "plugin '$id' was not discovered; is omarchy-shell running?" + fi + + local result + result=$(omarchy-shell shell setPluginEnabled "$id" "$enabled") || fail "could not update plugin; is omarchy-shell running?" + [[ $result == "ok" ]] || fail "plugin '$id' is not known; run: omarchy plugin rescan" if [[ $enabled == "true" && $# -gt 0 ]]; then mutate_bar move --id "$id" "$@" diff --git a/bin/omarchy-plugin-add b/bin/omarchy-plugin-add index 0b1048bf..a3324080 100755 --- a/bin/omarchy-plugin-add +++ b/bin/omarchy-plugin-add @@ -29,6 +29,21 @@ interactive() { [[ -t 0 && -t 1 ]] } +plugin_discovered() { + local id="$1" plugins + plugins=$(omarchy-shell shell listPlugins 2>/dev/null) || return 1 + jq -e --arg id "$id" 'any(.[]; .id == $id)' <<<"$plugins" >/dev/null 2>&1 +} + +wait_for_plugin_discovery() { + local id="$1" attempt + for (( attempt = 0; attempt < 40; attempt++ )); do + plugin_discovered "$id" && return 0 + sleep 0.05 + done + return 1 +} + PLUGIN_ID="" FROM_SOURCE="" ENABLE_AFTER="" # "", "true", or "false" @@ -280,7 +295,9 @@ if [[ -z $ENABLE_AFTER ]]; then fi if [[ $ENABLE_AFTER == true ]]; then - if omarchy-shell shell setPluginEnabled "$m_id" true >/dev/null 2>&1; then + if ! wait_for_plugin_discovery "$m_id"; then + echo "Could not enable $m_id because omarchy-shell did not discover it yet. Enable later with: omarchy plugin enable $m_id" >&2 + elif result=$(omarchy-shell shell setPluginEnabled "$m_id" true) && [[ $result == "ok" ]]; then echo "Enabled $m_id." if [[ $m_kinds == *bar-widget* ]]; then echo "It was added to the bar; arrange it with: omarchy plugin bar move $m_id --section " diff --git a/bin/omarchy-plugin-remove b/bin/omarchy-plugin-remove index 26a2e77c..0fe78584 100755 --- a/bin/omarchy-plugin-remove +++ b/bin/omarchy-plugin-remove @@ -102,6 +102,5 @@ fi omarchy-shell shell rescanPlugins >/dev/null 2>&1 || true if [[ $was_enabled == "true" ]]; then - echo "Plugin was enabled; restart the shell to fully unload it:" - echo " omarchy restart shell" + echo "Plugin was enabled and was unloaded from omarchy-shell." fi diff --git a/bin/omarchy-plugin-update b/bin/omarchy-plugin-update index a8bb43b0..ee326231 100755 --- a/bin/omarchy-plugin-update +++ b/bin/omarchy-plugin-update @@ -208,8 +208,7 @@ done if ((any_enabled)); then echo - echo "Updated plugins that were enabled may need a shell restart to fully reload:" - echo " omarchy restart shell" + echo "Updated enabled plugins were hot-reloaded by omarchy-shell." fi exit $rc diff --git a/default/omarchy-skill/SKILL.md b/default/omarchy-skill/SKILL.md index bb35d3e9..a7293aae 100644 --- a/default/omarchy-skill/SKILL.md +++ b/default/omarchy-skill/SKILL.md @@ -200,7 +200,7 @@ cp ~/.config/hypr/bindings.conf ~/.config/hypr/bindings.conf.bak.$(date +%s) # 4. Apply changes # - Hyprland: auto-reloads on save, but MUST validate with `hyprctl reload` and `hyprctl configerrors` -# - Omarchy shell: shell.json hot-reloads; use `omarchy-restart-shell` for plugin/widget code changes +# - Omarchy shell: shell.json hot-reloads; use `omarchy plugin rescan` for plugin/widget code changes # - Launcher: restart with `omarchy restart shell` # - Terminals: MUST restart with `omarchy restart terminal` ``` diff --git a/docs/omarchy-shell.md b/docs/omarchy-shell.md index 157da7c2..87c50af4 100644 --- a/docs/omarchy-shell.md +++ b/docs/omarchy-shell.md @@ -78,9 +78,9 @@ individual plugins (`bar`, `image-selector`, …). | `hide ` | close a previously-summoned | | `toggle ` | summon if closed, hide if open | | `call ` | call an already-loaded plugin | -| `rescanPlugins` | re-walk plugin dirs | +| `rescanPlugins` | re-walk plugin dirs and hot-reload plugin code | | `reloadConfig` | reload shell.json | -| `setPluginEnabled <"true"\|…>` | flip enabled bit | +| `setPluginEnabled <"true"\|…>` | flip enabled bit (`ok` / `unknown`) | | `listPlugins` | JSON of every discovered plugin | `setPluginEnabled` takes a string; only literal `"true"` enables. diff --git a/shell/README.md b/shell/README.md index 3e886d9d..c5adfdc0 100644 --- a/shell/README.md +++ b/shell/README.md @@ -172,9 +172,9 @@ running a separate Quickshell instance. | `hide ` | — | close a previously-summoned plugin | | `toggle ` | — | summon if closed, hide if open | | `call ` | string | call a method on an already-loaded plugin | -| `rescanPlugins` | — | re-walk plugin dirs and pick up new/changed manifests | +| `rescanPlugins` | — | re-walk plugin dirs and hot-reload plugin code | | `reloadConfig` | `ok` | reload `~/.config/omarchy/shell.json` | -| `setPluginEnabled ` | — | flip the persisted enabled bit (see note) | +| `setPluginEnabled ` | `ok` / `unknown` | flip the persisted enabled bit (see note) | | `listPlugins` | JSON | every discovered plugin (id, name, kinds, enabled) | Direct invocation: diff --git a/shell/plugins/panels/tailscale/README.md b/shell/plugins/panels/tailscale/README.md index e4bf8a03..955759e7 100644 --- a/shell/plugins/panels/tailscale/README.md +++ b/shell/plugins/panels/tailscale/README.md @@ -35,4 +35,4 @@ Renders the Tailscale mark natively as a theme-colored 3×3 dot grid, matching t ## Add to the bar -This widget ships as first-party plugin `omarchy.tailscale`. Add it with `omarchy plugin bar add omarchy.tailscale`, or add an entry such as `{ "id": "omarchy.tailscale" }` to one of the `bar.layout` sections in `~/.config/omarchy/shell.json` and restart the shell. +This widget ships as first-party plugin `omarchy.tailscale`. Add it with `omarchy plugin bar add omarchy.tailscale`, or add an entry such as `{ "id": "omarchy.tailscale" }` to one of the `bar.layout` sections in `~/.config/omarchy/shell.json`; the shell reloads `shell.json` automatically. diff --git a/shell/services/PluginRegistry.qml b/shell/services/PluginRegistry.qml index b7637248..f4ca0378 100644 --- a/shell/services/PluginRegistry.qml +++ b/shell/services/PluginRegistry.qml @@ -26,6 +26,7 @@ QtObject { property bool scanning: false signal pluginsChanged() + signal scanFinished() signal pluginLoadFailed(string id, string error) // ---------------------------------------------------------------- helpers @@ -157,9 +158,13 @@ QtObject { var key = Util.canonicalWidgetId(String(id)) if (!shellConfigMutator) { console.warn("PluginRegistry.setEnabled called before shellConfigMutator wired") - return + 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 shellConfigMutator(function(config) { @@ -196,6 +201,7 @@ QtObject { }) registryRevision++ pluginsChanged() + return true } // ---------------------------------------------------------------- scanning @@ -269,6 +275,7 @@ QtObject { registryRevision++ scanning = false pluginsChanged() + scanFinished() } property Process scanProcess: Process { diff --git a/shell/shell.qml b/shell/shell.qml index 095f86be..2bac9598 100644 --- a/shell/shell.qml +++ b/shell/shell.qml @@ -54,6 +54,8 @@ ShellRoot { property var defaultsConfig: builtinShellConfig property var shellConfig: builtinShellConfig property bool suppressUserReload: false + property bool pluginReloading: false + property bool pluginReloadPending: false onShellConfigChanged: { if (failedBarId !== "") failedBarId = "" @@ -245,11 +247,11 @@ ShellRoot { Loader { id: pluginBarLoader - active: shell.activeBarId !== shell.defaultBarId && shell.activeBarSourceUrl !== "" + active: !shell.pluginReloading && shell.activeBarId !== shell.defaultBarId && shell.activeBarSourceUrl !== "" source: shell.activeBarId !== shell.defaultBarId ? shell.activeBarSourceUrl : "" asynchronous: true onLoaded: shell.configureBar(item, shell.activeBarManifest) - onActiveChanged: if (!active && shell.activeBarId === shell.defaultBarId) shell.bar = null + onActiveChanged: if (!active) shell.bar = null onStatusChanged: { if (status === Loader.Error) { var detail = errorString && errorString() ? errorString() : "" @@ -344,9 +346,17 @@ ShellRoot { } } + function unloadPluginServices() { + for (var existingId in _services) { + var inst = _services[existingId] + if (inst && typeof inst.destroy === "function") inst.destroy() + } + _services = ({}) + } + Connections { target: shell.pluginRegistry - function onPluginsChanged() { shell._syncServices() } + function onPluginsChanged() { if (!shell.pluginReloading) shell._syncServices() } } // Writes inline settings to a bar layout entry or top-level plugin entry in @@ -484,6 +494,14 @@ ShellRoot { panelLoaders = next } + function unloadPanels() { + for (var id in panelLoaders) hide(id) + panelEntries = [] + panelLoaders = ({}) + pendingPayloads = ({}) + openPanelIds = ({}) + } + function deliverIfLoaded(pluginId) { var loader = panelLoaders[pluginId] if (!loader || !loader.item) return @@ -549,7 +567,7 @@ ShellRoot { Connections { target: shell.pluginRegistry - function onPluginsChanged() { shell.panelEntries = shell.computePanelEntries() } + function onPluginsChanged() { if (!shell.pluginReloading) shell.panelEntries = shell.computePanelEntries() } } Instantiator { @@ -606,7 +624,7 @@ ShellRoot { // widgets use the same first-party manifest contract as third-party widgets. Connections { target: shell.pluginRegistry - function onPluginsChanged() { shell.syncPluginWidgets() } + function onPluginsChanged() { if (!shell.pluginReloading) shell.syncPluginWidgets() } } property var pluginWidgetComponents: ({}) @@ -670,6 +688,49 @@ ShellRoot { } } + function unloadPluginWidgets() { + for (var id in pluginWidgetComponents) shell.barWidgetRegistry.unregister(id) + pluginWidgetComponents = ({}) + } + + function reloadPlugins() { + if (shell.pluginReloading || shell.pluginRegistry.scanning) { + shell.pluginReloadPending = true + return + } + shell.pluginReloading = true + shell.unloadPanels() + shell.unloadPluginServices() + shell.unloadPluginWidgets() + Qt.callLater(shell.finishPluginReload) + } + + function finishPluginReload() { + if (!shell.pluginReloading) return + if (shell.pluginRegistry.scanning) { + shell.pluginReloadPending = true + return + } + if (typeof Qt.clearComponentCache === "function") Qt.clearComponentCache() + shell.pluginRegistry.rescan() + } + + Connections { + target: shell.pluginRegistry + function onScanFinished() { + if (shell.pluginReloadPending) { + shell.pluginReloadPending = false + shell.pluginReloading = false + Qt.callLater(shell.reloadPlugins) + return + } + shell.pluginReloading = false + shell._syncServices() + shell.panelEntries = shell.computePanelEntries() + shell.syncPluginWidgets() + } + } + function loadPluginWidget(registryKey, url, meta) { var comp = Qt.createComponent(url, Component.Asynchronous) function finalize() { @@ -712,7 +773,7 @@ ShellRoot { } function rescanPlugins(): void { - shell.pluginRegistry.rescan() + shell.reloadPlugins() } function reloadConfig(): string { @@ -720,8 +781,8 @@ ShellRoot { return "ok" } - function setPluginEnabled(id: string, enabled: string): void { - shell.pluginRegistry.setEnabled(id, enabled === "true") + function setPluginEnabled(id: string, enabled: string): string { + return shell.pluginRegistry.setEnabled(id, enabled === "true") ? "ok" : "unknown" } function listPlugins(): string {