PluginSystem: plugins hot reload enabled when using NOCTALIA_DEBUG=1 as env var.

This commit is contained in:
Lemmy
2025-12-17 19:31:03 -05:00
parent bced5446df
commit 9edf747404
6 changed files with 285 additions and 9 deletions
+6 -1
View File
@@ -2112,7 +2112,12 @@
"update-incompatible": "Requires Noctalia v{version} or higher",
"update-success": "Updated {plugin} to v{version}",
"update-version": "v{current} → v{new}",
"updating": "Updating {plugin}..."
"updating": "Updating {plugin}...",
"hot-reload": {
"description": "Automatically reload plugins when their files change. Useful for plugin development.",
"label": "Hot reload (dev mode)"
},
"hot-reloaded": "Reloaded plugin: {name}"
},
"screen-recorder": {
"audio": {
+24 -2
View File
@@ -51,12 +51,34 @@ Item {
return root.widgetId !== "" && BarWidgetRegistry.hasWidget(root.widgetId);
}
// Force reload counter - incremented when plugin widget registry changes
property int reloadCounter: 0
// Listen for plugin widget registry changes to force reload
Connections {
target: BarWidgetRegistry
enabled: BarWidgetRegistry.isPluginWidget(root.widgetId)
function onPluginWidgetRegistryUpdated() {
// Force the loader to reload by toggling active
if (BarWidgetRegistry.hasWidget(root.widgetId)) {
root.reloadCounter++;
Logger.d("BarWidgetLoader", "Plugin widget registry updated, reloading:", root.widgetId);
}
}
}
Loader {
id: loader
anchors.fill: parent
asynchronous: false
active: root.checkWidgetExists()
sourceComponent: root.checkWidgetExists() ? BarWidgetRegistry.getWidget(root.widgetId) : null
// Include reloadCounter in the binding to force re-evaluation
active: root.checkWidgetExists() && (root.reloadCounter >= 0)
sourceComponent: {
// Depend on reloadCounter to force re-fetch of component
var _ = root.reloadCounter;
return root.checkWidgetExists() ? BarWidgetRegistry.getWidget(root.widgetId) : null;
}
onLoaded: {
if (!item)
+16 -3
View File
@@ -16,6 +16,18 @@ Variants {
// Direct binding to registry's widgets property for reactivity
readonly property var registeredWidgets: DesktopWidgetRegistry.widgets
// Force reload counter - incremented when plugin widget registry changes
property int pluginReloadCounter: 0
Connections {
target: DesktopWidgetRegistry
function onPluginWidgetRegistryUpdated() {
root.pluginReloadCounter++;
Logger.d("DesktopWidgets", "Plugin widget registry updated, reload counter:", root.pluginReloadCounter);
}
}
delegate: Loader {
id: screenLoader
required property ShellScreen modelData
@@ -67,14 +79,15 @@ Variants {
delegate: Loader {
id: widgetLoader
// Bind to registeredWidgets to re-evaluate when plugins register/unregister
active: (modelData.id in root.registeredWidgets)
// Bind to registeredWidgets and pluginReloadCounter to re-evaluate when plugins register/unregister
active: (modelData.id in root.registeredWidgets) && (root.pluginReloadCounter >= 0)
property var widgetData: modelData
property int widgetIndex: index
sourceComponent: {
// Access registeredWidgets to create reactive binding
// Access registeredWidgets and pluginReloadCounter to create reactive binding
var _ = root.pluginReloadCounter;
var widgets = root.registeredWidgets;
return widgets[modelData.id] || null;
}
+208 -3
View File
@@ -14,6 +14,7 @@ Singleton {
signal pluginUnloaded(string pluginId)
signal pluginEnabled(string pluginId)
signal pluginDisabled(string pluginId)
signal pluginReloaded(string pluginId)
signal availablePluginsUpdated
signal allPluginsLoaded
@@ -38,6 +39,16 @@ Singleton {
property var pluginErrors: ({})
signal pluginLoadError(string pluginId, string entryPoint, string error)
// Hot reload: file watchers for plugin directories
property var pluginFileWatchers: ({}) // { pluginId: FileView }
property bool hotReloadEnabled: Settings.isDebug
onHotReloadEnabledChanged: {
if (root.initialized) {
setHotReloadEnabled(root.hotReloadEnabled);
}
}
// Track active fetches
property var activeFetches: ({})
@@ -581,10 +592,14 @@ Singleton {
Logger.i("PluginService", "Plugin loaded:", pluginId);
root.pluginLoaded(pluginId);
// Set up hot reload watcher if enabled
setupPluginFileWatcher(pluginId);
}
// Unload a plugin
function unloadPlugin(pluginId) {
// preserveSettings: if true, don't remove desktop widget settings (used for hot reload)
function unloadPlugin(pluginId, preserveSettings) {
var plugin = root.loadedPlugins[pluginId];
if (!plugin) {
Logger.w("PluginService", "Plugin not loaded:", pluginId);
@@ -593,14 +608,20 @@ Singleton {
Logger.i("PluginService", "Unloading plugin:", pluginId);
// Remove hot reload watcher
removePluginFileWatcher(pluginId);
// Unregister from BarWidgetRegistry
if (plugin.manifest.entryPoints && plugin.manifest.entryPoints.barWidget) {
BarWidgetRegistry.unregisterPluginWidget(pluginId);
}
// Unregister from DesktopWidgetRegistry and clean up saved widget instances
// Unregister from DesktopWidgetRegistry
if (plugin.manifest.entryPoints && plugin.manifest.entryPoints.desktopWidget) {
removePluginDesktopWidgetsFromSettings(pluginId);
// Only remove settings when uninstalling, not during hot reload
if (!preserveSettings) {
removePluginDesktopWidgetsFromSettings(pluginId);
}
DesktopWidgetRegistry.unregisterPluginWidget(pluginId);
}
@@ -1211,4 +1232,188 @@ Singleton {
function hasPluginError(pluginId) {
return pluginId in root.pluginErrors;
}
// ----- Hot reload functions -----
// Set up file watcher for a plugin directory
function setupPluginFileWatcher(pluginId) {
if (!root.hotReloadEnabled) {
return;
}
// Don't create duplicate watchers
if (root.pluginFileWatchers[pluginId]) {
return;
}
var manifest = PluginRegistry.getPluginManifest(pluginId);
if (!manifest) {
return;
}
var pluginDir = PluginRegistry.getPluginDir(pluginId);
// Create a debounce timer for this plugin
var debounceTimer = Qt.createQmlObject(`
import QtQuick
Timer {
property string targetPluginId: ""
property var reloadCallback: null
interval: 500
repeat: false
onTriggered: {
if (reloadCallback) reloadCallback(targetPluginId);
}
}
`, root, "HotReloadDebounce_" + pluginId);
// Set properties after creation to pass the callback
debounceTimer.targetPluginId = pluginId;
debounceTimer.reloadCallback = root.reloadPlugin;
// Watch the manifest file - changes here indicate plugin updates
var manifestWatcher = Qt.createQmlObject(`
import Quickshell.Io
FileView {
path: "${pluginDir}/manifest.json"
watchChanges: true
}
`, root, "ManifestWatcher_" + pluginId);
var watchers = [manifestWatcher];
// Only watch entry points that actually exist in the manifest
var entryPoints = manifest.entryPoints || {};
var entryPointFiles = [];
if (entryPoints.main)
entryPointFiles.push(entryPoints.main);
if (entryPoints.barWidget)
entryPointFiles.push(entryPoints.barWidget);
if (entryPoints.desktopWidget)
entryPointFiles.push(entryPoints.desktopWidget);
if (entryPoints.panel)
entryPointFiles.push(entryPoints.panel);
if (entryPoints.settings)
entryPointFiles.push(entryPoints.settings);
for (var i = 0; i < entryPointFiles.length; i++) {
var entryPointFile = entryPointFiles[i];
var watcher = Qt.createQmlObject(`
import Quickshell.Io
FileView {
path: "${pluginDir}/${entryPointFile}"
watchChanges: true
}
`, root, "FileWatcher_" + pluginId + "_" + i);
watchers.push(watcher);
}
// Connect all watchers to the debounce timer
for (var j = 0; j < watchers.length; j++) {
watchers[j].fileChanged.connect(function () {
debounceTimer.restart();
});
}
root.pluginFileWatchers[pluginId] = {
watchers: watchers,
debounceTimer: debounceTimer
};
Logger.d("PluginService", "Set up hot reload watcher for plugin:", pluginId);
}
// Remove file watcher for a plugin
function removePluginFileWatcher(pluginId) {
var watcherData = root.pluginFileWatchers[pluginId];
if (!watcherData) {
return;
}
// Destroy all watchers
if (watcherData.watchers) {
for (var i = 0; i < watcherData.watchers.length; i++) {
if (watcherData.watchers[i]) {
watcherData.watchers[i].destroy();
}
}
}
// Destroy debounce timer
if (watcherData.debounceTimer) {
watcherData.debounceTimer.destroy();
}
delete root.pluginFileWatchers[pluginId];
Logger.d("PluginService", "Removed hot reload watcher for plugin:", pluginId);
}
// Reload a plugin (hot reload)
function reloadPlugin(pluginId) {
if (!root.loadedPlugins[pluginId]) {
Logger.w("PluginService", "Cannot reload: plugin not loaded:", pluginId);
return false;
}
Logger.i("PluginService", "Hot reloading plugin:", pluginId);
var manifest = PluginRegistry.getPluginManifest(pluginId);
if (!manifest) {
Logger.e("PluginService", "Cannot reload: manifest not found for:", pluginId);
return false;
}
// Unregister widget instances from the bar
BarService.destroyPluginWidgetInstances(pluginId);
// Unload the plugin (destroys components and instances)
// Pass true to preserve desktop widget settings during hot reload
unloadPlugin(pluginId, true);
// Increment load version to invalidate Qt's component cache
PluginRegistry.incrementPluginLoadVersion(pluginId);
// Use Qt.callLater to ensure destruction is complete before reloading
// This prevents IPC handler conflicts and other timing issues
Qt.callLater(function () {
// Reload the plugin
loadPlugin(pluginId);
// Re-setup file watcher (it was destroyed during unload)
setupPluginFileWatcher(pluginId);
// Emit signal
root.pluginReloaded(pluginId);
// Show toast notification
var pluginName = manifest.name || pluginId;
ToastService.showNotice(I18n.tr("settings.plugins.hot-reloaded", {
"name": pluginName
}), "");
Logger.i("PluginService", "Hot reload complete for plugin:", pluginId);
});
return true;
}
// Enable/disable hot reload for all loaded plugins
function setHotReloadEnabled(enabled) {
root.hotReloadEnabled = enabled;
if (enabled) {
// Set up watchers for all loaded plugins
for (var pluginId in root.loadedPlugins) {
setupPluginFileWatcher(pluginId);
}
Logger.i("PluginService", "Hot reload enabled for all plugins");
} else {
// Remove all watchers
for (var pluginId in root.pluginFileWatchers) {
removePluginFileWatcher(pluginId);
}
Logger.i("PluginService", "Hot reload disabled");
}
}
}
+26
View File
@@ -226,6 +226,32 @@ Singleton {
return false;
}
// Unregister all widget instances for a plugin (used during hot reload)
// Note: We don't destroy instances here - the Loader manages that when the component is unregistered
function destroyPluginWidgetInstances(pluginId) {
var widgetId = "plugin:" + pluginId;
var keysToRemove = [];
// Find all instances of this plugin's widget
for (var key in widgetInstances) {
var widget = widgetInstances[key];
if (widget && widget.widgetId === widgetId) {
keysToRemove.push(key);
Logger.d("BarService", "Unregistering plugin widget instance:", key);
}
}
// Remove from registry
for (var i = 0; i < keysToRemove.length; i++) {
delete widgetInstances[keysToRemove[i]];
}
if (keysToRemove.length > 0) {
Logger.i("BarService", "Unregistered", keysToRemove.length, "instance(s) of plugin widget:", widgetId);
root.activeWidgetsChanged();
}
}
// Get pill direction for a widget instance
function getPillDirection(widgetInstance) {
try {
+5
View File
@@ -8,6 +8,9 @@ import qs.Modules.Bar.Widgets
Singleton {
id: root
// Signal emitted when plugin widgets are registered/unregistered
signal pluginWidgetRegistryUpdated
// Widget registry object mapping widget names to components
property var widgets: ({
"ActiveWindow": activeWindowComponent,
@@ -381,6 +384,7 @@ Singleton {
widgetMetadata[widgetId] = metadata || {};
Logger.i("BarWidgetRegistry", "Registered plugin widget:", widgetId);
root.pluginWidgetRegistryUpdated();
return true;
}
@@ -399,6 +403,7 @@ Singleton {
delete widgetMetadata[widgetId];
Logger.i("BarWidgetRegistry", "Unregistered plugin widget:", widgetId);
root.pluginWidgetRegistryUpdated();
return true;
}