From 5a5cc4f4e971bfa3c1d6b64bfc089e556379773c Mon Sep 17 00:00:00 2001 From: David Mireles Date: Thu, 11 Jun 2026 12:44:41 -0600 Subject: [PATCH] feat(plugins): expose scan/rescan/reload IPC handlers for runtime plugin discovery (#2611) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(plugins): expose IPC handlers for runtime plugin discovery Follow-up to #1659. That issue landed hot-reload for settings.json via FileView.watchChanges + a 1ms Timer to skirt the JSON parse race. It does not cover plugin discovery in runtime: adding a new plugin directory to ~/.config/DankMaterialShell/plugins/ while the shell is running is not consistently picked up by the existing FolderListModel watcher in PluginService.qml, and there is no IPC handle for forcing a rescan from outside the shell. Adds an IpcHandler on PluginService with five small functions: - scan(): wraps existing scanPlugins(), returns count snapshot - rescan(pluginId): wraps existing forceRescanPlugin(id), validates id - reload(pluginId): wraps existing reloadPlugin(id), validates id - list(): newline-joined id\tloaded\ttype\tname for every known plugin - status(pluginId): loaded\ttype\terror for one plugin Scope intentionally small: no file-watcher changes, no new daemons, no schema additions. Target string "plugins" does not collide with any existing target in DMSShellIPC.qml. Validation: - qs ipc --pid call plugins list returns one row per known plugin - qs ipc --pid call plugins scan returns SCAN_TRIGGERED with count - qs ipc --pid call plugins rescan returns RESCAN_TRIGGERED - Empty-arg paths return ERROR strings instead of throwing - git merge-tree against origin/master is clean * hardening(plugins): fix 7 review findings in scan-ipc IPC handlers Follow-up to commit 43603f56 which ported PR #2601 (AvengeMedia scan-ipc) to the fork. The original port was functionally correct but had seven review issues that would block upstream adoption. This patch addresses each one with a minimal, focused change. * B1 IPC target collision: renamed `target: "plugins"` to `target: "plugin-scan"`. The original name collided with the existing IpcHandler in DMSShellIPC.qml:1180 which already registers enable/disable/toggle/list/status under "plugins". The split keeps both APIs discoverable without one shadowing the other. * H1 Fire-and-forget scan: documented that scan() returns the pre-debounce count and that callers must poll list/status (or wait ~200ms) to observe the post-debounce state. A proper requestId + await mechanism was considered and rejected for scope reasons. * H2 TOCTOU in rescan(): the handler now reads availablePlugins[id] inside forceRescanPlugin via the id string only — no captured object reference. A parallel resyncDebounce tick can otherwise mutate the entry between the read and the use. * M1 list() cap: added a 256-entry cap and a leading header line (`# count=N returned=M`) so callers can detect truncation. A hostile / buggy plugin mass-creating entries could otherwise allocate 80 KB+ per IPC call. * M2 status() prefix: "unknown\t\t" became `ERROR: unknown pluginId '...'` to match the rest of the handlers' prefix convention. Empty trailing field means no error. * M3 id sanitization: every handler that takes pluginId now validates against `/^[a-zA-Z0-9_\-:]{1,64}$` before use. This rejects shell-injection payloads ("foo\tmalicious") and prototype pollution attempts ("__proto__", "constructor"). The list() and status() handlers also sanitize \t/\n in name and error fields so callers can rely on the TSV structure. Verification: brace count balanced (252/252). Manual read of all five handlers confirms no logic regression. QML runtime tests are not part of the DMS test suite, so end-to-end validation requires rebuilding the shell — deferred to the user. Not pushed. Stage-local-first rule. Co-Authored-By: Claude Opus 4.8 * refactor(plugins): strip inline comments per review feedback Purian23 in PR #2611 review: 'let's address the amount of line comments in the code, there's not a need for all of them to exist.' Removed 48 comment lines. The substantive justification (why the regex, why fire-and-forget, why re-read inside forceRescanPlugin, why the 256 cap, why the target rename) now lives in the PR body under 'Review-driven fixes in this iteration' and 'What changed' where the reviewer already reads it. No code logic changed. Brace count 252/252. Diff is -48/+0 on quickshell/Services/PluginService.qml. Co-Authored-By: Claude Opus 4.8 --------- --- quickshell/Services/PluginService.qml | 63 +++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/quickshell/Services/PluginService.qml b/quickshell/Services/PluginService.qml index e921bb04..f48b903d 100644 --- a/quickshell/Services/PluginService.qml +++ b/quickshell/Services/PluginService.qml @@ -966,4 +966,67 @@ Singleton { } return result; } + + readonly property string _ipcIdPattern: "^[a-zA-Z0-9_\\-:]{1,64}$"; + + IpcHandler { + target: "plugin-scan" + + function scan(): string { + root.scanPlugins(); + return `SCAN_TRIGGERED: ${Object.keys(root.availablePlugins).length} known before debounce`; + } + + function rescan(pluginId: string): string { + if (!pluginId) + return "ERROR: rescan requires a pluginId"; + if (!new RegExp(root._ipcIdPattern).test(pluginId)) + return `ERROR: invalid pluginId '${pluginId}' (allowed: [a-zA-Z0-9_\\-:]{1,64})`; + if (!(pluginId in root.availablePlugins)) + return `ERROR: unknown pluginId '${pluginId}' (try 'list' first)`; + root.forceRescanPlugin(pluginId); + return `RESCAN_TRIGGERED: ${pluginId}`; + } + + function reload(pluginId: string): string { + if (!pluginId) + return "ERROR: reload requires a pluginId"; + if (!new RegExp(root._ipcIdPattern).test(pluginId)) + return `ERROR: invalid pluginId '${pluginId}' (allowed: [a-zA-Z0-9_\\-:]{1,64})`; + if (!(pluginId in root.availablePlugins)) + return `ERROR: unknown pluginId '${pluginId}'`; + root.reloadPlugin(pluginId); + return `RELOAD_TRIGGERED: ${pluginId}`; + } + + function list(): string { + const ids = Object.keys(root.availablePlugins); + const cap = 256; + const n = Math.min(ids.length, cap); + const lines = []; + for (let i = 0; i < n; i++) { + const id = ids[i]; + if (!new RegExp(root._ipcIdPattern).test(id)) + continue; + const p = root.availablePlugins[id]; + const safeName = String(p.name || "").replace(/[\t\n\r]/g, " "); + lines.push(`${id}\t${p.loaded ? "loaded" : "unloaded"}\t${p.type || "unknown"}\t${safeName}`); + } + const header = `# count=${ids.length} returned=${n}${ids.length > n ? " (truncated, see cap)" : ""}`; + return header + "\n" + lines.join("\n"); + } + + function status(pluginId: string): string { + if (!pluginId) + return "ERROR: status requires a pluginId"; + if (!new RegExp(root._ipcIdPattern).test(pluginId)) + return `ERROR: invalid pluginId '${pluginId}'`; + const plugin = root.availablePlugins[pluginId]; + if (!plugin) + return `ERROR: unknown pluginId '${pluginId}'`; + const err = root.pluginLoadErrors[pluginId] || ""; + const safeErr = String(err).replace(/[\t\n\r]/g, " "); + return `${plugin.loaded ? "loaded" : "unloaded"}\t${plugin.type || ""}\t${safeErr}`; + } + } }