1
0
mirror of https://github.com/AvengeMedia/DankMaterialShell.git synced 2026-06-13 14:36:32 -04:00

feat(plugins): expose scan/rescan/reload IPC handlers for runtime plugin discovery (#2611)

* 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 <PID> call plugins list returns one row per known plugin
- qs ipc --pid <PID> call plugins scan returns SCAN_TRIGGERED with count
- qs ipc --pid <PID> call plugins rescan <id> 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------
This commit is contained in:
David Mireles
2026-06-11 12:44:41 -06:00
committed by GitHub
parent cd672c341f
commit 5a5cc4f4e9
+63
View File
@@ -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}`;
}
}
}