diff --git a/docs/omarchy-shell.md b/docs/omarchy-shell.md index a1349ae4..edbabf2a 100644 --- a/docs/omarchy-shell.md +++ b/docs/omarchy-shell.md @@ -34,9 +34,7 @@ first call. Only one full bar option is active at a time. The built-in `omarchy.bar` is used when `bar.id` is omitted or when a selected third-party bar cannot load. -Panels, overlays, and menus are loaded when summoned. Plugins can set -`keepLoaded: true` to survive between summons. First-party services are -loaded at startup. +Panels, overlays, and menus are loaded when summoned. Plugins can set the top-level manifest key `keepLoaded: true` to survive between summons, and to keep a service mounted across plugin hot-reload (so `omarchy.lock` is not destroyed while Hyprland still holds the session lock). First-party services are loaded at startup. Full schema: [`shell/services/PluginRegistry.qml`](../shell/services/PluginRegistry.qml). diff --git a/shell/README.md b/shell/README.md index e72d02ce..16162b79 100644 --- a/shell/README.md +++ b/shell/README.md @@ -86,8 +86,12 @@ Only one `bar` plugin is active at a time. Missing or invalid selections fall back to the built-in `omarchy.bar`, so users always have a safe path home. Panels, overlays, and menus are loaded when summoned. Plugins that need 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. +picker keeps its overlay window mounted between summons). The same flag +keeps a service mounted across plugin hot-reload, so tearing down a +changed bar widget cannot destroy `omarchy.lock` while Hyprland still +holds the session lock. The kept instance is not replaced, so code +changes to a `keepLoaded` service itself only take effect on a shell +restart. First-party services are loaded at startup. The full schema lives in `services/PluginRegistry.qml`. diff --git a/shell/plugins/README.md b/shell/plugins/README.md index d4252515..66cb74e1 100644 --- a/shell/plugins/README.md +++ b/shell/plugins/README.md @@ -83,6 +83,9 @@ separate PAM services: `omarchy-lock-password` for password auth and, only when fingerprints are enrolled, `omarchy-lock-fingerprint` for fingerprint auth. It mirrors the previous lock screen field dimensions, colors, blurred wallpaper, placeholder, and Hyprland-driven corners. +The plugin sets `keepLoaded: true` so a plugin hot-reload (for example +an installed bar widget changing on disk) does not destroy the lock +client while Hyprland still holds the session lock. ## Polkit agent diff --git a/shell/shell.qml b/shell/shell.qml index 71a9834f..7e1cf75d 100644 --- a/shell/shell.qml +++ b/shell/shell.qml @@ -329,14 +329,23 @@ 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]) { + // A kept instance outlives the rescan; hand it the fresh manifest. + var kept = _services[id] + if (kept && "manifest" in kept) kept.manifest = m + continue + } ensureService(id) } - // Drop services for plugins that have been disabled or removed. + // Drop services for plugins that have been disabled or removed, or that + // no longer declare a service entry point. for (var existingId in _services) { var stillThere = plugins[existingId] + var stillService = stillThere && Array.isArray(stillThere.kinds) + && stillThere.kinds.indexOf("service") !== -1 + && stillThere.entryPoints && stillThere.entryPoints.service var stillEnabled = stillThere && pluginRegistry.isEnabled(existingId) - if (stillThere && stillEnabled) continue + if (stillService && stillEnabled) continue var inst = _services[existingId] if (inst && typeof inst.destroy === "function") inst.destroy() var next = ({}) @@ -345,12 +354,26 @@ ShellRoot { } } + function serviceKeepLoaded(pluginId) { + var plugins = pluginRegistry && pluginRegistry.installedPlugins + var manifest = plugins ? plugins[pluginId] : null + return !!(manifest && manifest.keepLoaded === true) + } + + // keepLoaded services (lock, idle, polkit) must survive plugin hot-reload. + // Destroying omarchy.lock drops the ext-session-lock client while Hyprland + // still holds the lock, which surfaces the crashed-lockscreen fallback. function unloadPluginServices() { + var next = ({}) for (var existingId in _services) { + if (serviceKeepLoaded(existingId)) { + next[existingId] = _services[existingId] + continue + } var inst = _services[existingId] if (inst && typeof inst.destroy === "function") inst.destroy() } - _services = ({}) + _services = next } Connections { diff --git a/test/shell.d/plugins-test.sh b/test/shell.d/plugins-test.sh index 141b4323..5d634dc9 100644 --- a/test/shell.d/plugins-test.sh +++ b/test/shell.d/plugins-test.sh @@ -197,5 +197,18 @@ for (const [id, section] of Object.entries({ } check(byId['omarchy.media']?.barWidget?.defaultSection === undefined, 'omarchy.media must use the center fallback') +for (const id of ['omarchy.lock', 'omarchy.idle', 'omarchy.polkit', 'omarchy.notifications', 'omarchy.media']) { + check(byId[id]?.keepLoaded === true, `${id} must stay loaded across plugin reloads`) +} + +const shellSource = fs.readFileSync(path.join(root, 'shell/shell.qml'), 'utf8') +const unloadMatch = shellSource.match(/function unloadPluginServices\(\) \{[\s\S]*?\n \}/) +check(!!unloadMatch, 'unloadPluginServices is defined') +check(!!unloadMatch && /serviceKeepLoaded/.test(unloadMatch[0]), 'unloadPluginServices honors keepLoaded') +check( + /function _syncServices\(\) \{[\s\S]*Drop services for plugins that have been disabled/.test(shellSource), + '_syncServices still drops disabled or removed services' +) + assert(errors.length === 0, 'plugin manifests match shell registry contract', errors.join('\n')) JS diff --git a/test/shell.d/runtime-smoke-test.sh b/test/shell.d/runtime-smoke-test.sh index f9e52bfd..1b0e5fe9 100755 --- a/test/shell.d/runtime-smoke-test.sh +++ b/test/shell.d/runtime-smoke-test.sh @@ -74,6 +74,44 @@ Item { } QML +# A keepLoaded service must keep its instance (and in-memory state) across a +# plugin rescan. The marker below can only survive if the object does. +keep_service_id="acme.keep-service" +keep_service_dir="$test_home/.config/omarchy/plugins/$keep_service_id" +mkdir -p "$keep_service_dir" +cat >"$keep_service_dir/manifest.json" <"$keep_service_dir/Service.qml" <<'QML' +import QtQuick +import Quickshell.Io + +Item { + property string marker: "" + + IpcHandler { + target: "acme-keep" + + function set(value: string): string { + marker = value + return "ok" + } + + function get(): string { + return marker + } + } +} +QML + cat >"$stub_bin/omarchy-update-available" <<'SH' #!/bin/bash echo "Omarchy update available (test)" @@ -188,6 +226,15 @@ pass "shell IPC summon and hide contract works" jq -e '.hasPlayer | type == "boolean"' <<<"$(shell_ipc media status)" >/dev/null || fail_with_log "media IPC returns status JSON" jq -e '.enabled | type == "boolean"' <<<"$(shell_ipc idle status)" >/dev/null || fail_with_log "idle IPC returns status JSON" jq -e '.locked | type == "boolean"' <<<"$(shell_ipc lock status)" >/dev/null || fail_with_log "lock IPC returns status JSON" +[[ $(shell_ipc shell setPluginEnabled "$keep_service_id" true) == "ok" ]] || + fail_with_log "keepLoaded fixture service could not be enabled" +keep_marker_set="" +for _ in {1..80}; do + keep_marker_set=$(shell_ipc acme-keep set "survived" 2>/dev/null || true) + [[ $keep_marker_set == "ok" ]] && break + sleep 0.1 +done +[[ $keep_marker_set == "ok" ]] || fail_with_log "keepLoaded fixture service IPC responds" [[ $(shell_ipc image-selector ping) == "ok" ]] || fail_with_log "image selector IPC responds" [[ $(shell_ipc osd ping) == "ok" ]] || fail_with_log "OSD IPC responds" [[ $(shell_ipc osd show '{"message":"Runtime smoke","duration":0}') == "ok" ]] || fail_with_log "OSD IPC opens" @@ -215,6 +262,31 @@ shell_ipc_quiet image-selector cancel "$selector_done_file" >/dev/null rm -f "$selector_selection_file" "$selector_done_file" pass "image selector IPC survives plugin rescan" +lock_status_after=$(shell_ipc lock status) +jq -e '.locked | type == "boolean"' <<<"$lock_status_after" >/dev/null || fail_with_log "lock IPC survives plugin rescan" +lock_event_after=$(jq -r '.lastEvent // empty' <<<"$lock_status_after") +[[ $lock_event_after != lock-stranded* ]] || + fail_with_log "plugin rescan does not strand the session lock ($lock_event_after)" +# A recreated instance would answer with a fresh, empty marker. +[[ $(shell_ipc acme-keep get) == "survived" ]] || + fail_with_log "plugin rescan keeps the keepLoaded service instance mounted" +pass "keepLoaded service instance survives plugin rescan" + +# Dropping the service entry point from the manifest must drop the kept +# instance instead of leaving a zombie behind. +jq 'del(.keepLoaded) | .kinds = ["overlay"] | .entryPoints = {"overlay": "Service.qml"}' \ + "$keep_service_dir/manifest.json" >"$keep_service_dir/manifest.json.tmp" +mv "$keep_service_dir/manifest.json.tmp" "$keep_service_dir/manifest.json" +keep_gone="" +for _ in {1..80}; do + keep_gone=$(shell_ipc acme-keep get 2>/dev/null || true) + [[ $keep_gone != "survived" ]] && break + sleep 0.1 +done +[[ $keep_gone != "survived" ]] || + fail_with_log "kept service is dropped when its plugin stops declaring a service" +pass "kept service is dropped when its plugin stops declaring a service" + shell_ipc_quiet omarchy.system-update refresh >/dev/null 2>&1 || true sleep 0.8