diff --git a/hermes_cli/plugin_events.py b/hermes_cli/plugin_events.py new file mode 100644 index 0000000000..1fc593ed07 --- /dev/null +++ b/hermes_cli/plugin_events.py @@ -0,0 +1,67 @@ +"""Public event bridge for plugin backends (``plugin_api.py``). + +A plugin's backend runs inside the gateway process. To push an update to its +OWN desktop half it emits on the app's global event stream — the same stream +``host.onEvent`` subscribes to in the renderer:: + + from hermes_cli.plugin_events import broadcast_plugin_event + + broadcast_plugin_event("rss-reader", "items", {"count": 3}) + # event "plugin.rss-reader.items" reaches every connected desktop client + +The desktop half filters for its own name:: + + host.onEvent('plugin.rss-reader.items', payload => …) + +This module is the sanctioned door for that: plugin backends must never import +``tui_gateway.server`` privates (``_broadcast_global_event``), whose signature +is core-internal. The ``plugin.`` prefix keeps plugin traffic out of core's own +event names (``skin.changed``, ``session.reclaimed``, …). +""" + +from __future__ import annotations + +import re +from typing import Any, Optional + +#: Every plugin event name starts with this — core owns all other names. +PLUGIN_EVENT_PREFIX = "plugin." + +# Plugin ids are the manifest names (lowercase, dashes): ``rss-reader``. +_PLUGIN_ID_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}$") +# One event segment: no whitespace, no dots (the dot is the name separator). +_EVENT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$") + + +def plugin_event_name(plugin_id: str, event: str) -> str: + """The wire name for one plugin event: ``plugin..``. + + Raises ``ValueError`` for an id or event that cannot form one — a silently + mangled name would strand the desktop half waiting on a name nobody emits. + """ + if not isinstance(plugin_id, str) or not _PLUGIN_ID_RE.match(plugin_id): + raise ValueError(f"invalid plugin id {plugin_id!r}: expected [a-z0-9][a-z0-9._-]{{0,63}}") + if not isinstance(event, str) or not _EVENT_RE.match(event): + raise ValueError( + f"invalid plugin event {event!r}: expected one segment of [A-Za-z0-9_-] " + "(the plugin id already namespaces the name)" + ) + return f"{PLUGIN_EVENT_PREFIX}{plugin_id}.{event}" + + +def broadcast_plugin_event(plugin_id: str, event: str, payload: Optional[dict[str, Any]] = None) -> None: + """Emit ``plugin..`` to every connected client. + + Fire-and-forget and safe to call from any request handler: delivery fans out + over the gateway's live transports, and a wedged peer is skipped rather than + stalling the caller. ``payload`` must be a dict (or ``None`` for ``{}``). + """ + if payload is not None and not isinstance(payload, dict): + raise TypeError(f"plugin event payload must be a dict or None, got {type(payload).__name__}") + + # Late import: plugin backends load before the gateway server is up in some + # hosts (CLI tooling imports plugin_api modules for route inspection), and + # tui_gateway.server pulls in the transport stack. + from tui_gateway.server import _broadcast_global_event + + _broadcast_global_event(plugin_event_name(plugin_id, event), dict(payload or {})) diff --git a/tests/hermes_cli/test_plugin_events.py b/tests/hermes_cli/test_plugin_events.py new file mode 100644 index 0000000000..68e803114a --- /dev/null +++ b/tests/hermes_cli/test_plugin_events.py @@ -0,0 +1,61 @@ +"""Public event bridge for plugin backends (``plugin_api.py``). + +Regression for #116305 item 8: a plugin backend pushes events to its own +desktop half through ``hermes_cli.plugin_events`` instead of importing +``tui_gateway.server._broadcast_global_event``. +""" +from __future__ import annotations + +import pytest + +from hermes_cli import plugin_events + + +def test_event_name_namespaces_under_the_plugin(): + assert plugin_events.plugin_event_name("rss-reader", "items") == "plugin.rss-reader.items" + + +def test_broadcast_reaches_the_global_stream_with_the_namespaced_name(monkeypatch): + seen: list[tuple[str, object]] = [] + + import tui_gateway.server as server + + monkeypatch.setattr(server, "_broadcast_global_event", lambda event, payload=None: seen.append((event, payload))) + + plugin_events.broadcast_plugin_event("rss-reader", "items", {"count": 3}) + + assert seen == [("plugin.rss-reader.items", {"count": 3})] + + +def test_broadcast_defaults_the_payload_to_an_empty_dict(monkeypatch): + seen: list[tuple[str, object]] = [] + + import tui_gateway.server as server + + monkeypatch.setattr(server, "_broadcast_global_event", lambda event, payload=None: seen.append((event, payload))) + + plugin_events.broadcast_plugin_event("kanban", "changed") + + assert seen == [("plugin.kanban.changed", {})] + + +@pytest.mark.parametrize( + ("plugin_id", "event"), + [ + ("", "items"), + ("Bad Id", "items"), + ("has/slash", "items"), + ("ok", ""), + ("ok", "bad name"), + ("ok", "with.dot"), + ("ok", "plugin.other.items"), + ], +) +def test_invalid_names_raise(plugin_id, event): + with pytest.raises(ValueError): + plugin_events.plugin_event_name(plugin_id, event) + + +def test_payload_must_be_a_dict(): + with pytest.raises(TypeError): + plugin_events.broadcast_plugin_event("ok", "items", ["not", "a", "dict"]) # type: ignore[arg-type] diff --git a/website/docs/developer-guide/desktop-plugin-sdk.md b/website/docs/developer-guide/desktop-plugin-sdk.md index a2622df855..a303aa0932 100644 --- a/website/docs/developer-guide/desktop-plugin-sdk.md +++ b/website/docs/developer-guide/desktop-plugin-sdk.md @@ -1376,6 +1376,31 @@ never auto-import Python. This is a security boundary, not an oversight (GHSA-mcfc-hp25-cjv7). ::: +#### Pushing events to your desktop half + +Your backend runs inside the gateway process, so it can push an update to your +own desktop half over the app's global event stream — the same stream +`host.onEvent` subscribes to: + +```python +from hermes_cli.plugin_events import broadcast_plugin_event + +broadcast_plugin_event("rss-reader", "items", {"count": 3}) +# → event "plugin.rss-reader.items" reaches every connected desktop client +``` + +```javascript +host.onEvent('plugin.rss-reader.items', payload => queryClient.invalidateQueries({ queryKey: ['items'] })) +``` + +The name is always `plugin..` — the plugin id namespaces +it, so `broadcast_plugin_event` takes the BARE event name (`"items"`, not +`"plugin.rss-reader.items"`) and raises on anything else. Delivery is +fire-and-forget (a wedged client is skipped, never stalling your handler). Use +this instead of importing `tui_gateway.server` internals; for plugin-scoped +frames with a payload tailored per connection, `ctx.socket('/events')` remains +the richer door. + ### Calling it from the plugin ```javascript