Skip to content

WebEngine bridge, host side

qgis_sdk.bridge is the QGIS-free host half of the transport a web view speaks. Nothing in it imports Qt or QGIS, and nothing in it takes a callable from the wire: a view names an operation id, and the host decides whether that view may have it.

from qgis_sdk.bridge import Dispatcher
runtime = PluginRuntime(MyPlugin, context).start()
dispatcher = Dispatcher.from_runtime(
runtime,
authorize=lambda binding, operation: binding.origin == "qgis://desktop",
)
binding, handshake = dispatcher.handshake(view_id="view-1", origin="qgis://desktop")
reply = dispatcher.dispatch(binding, request) # never raises
  • One manifest, three views. from_description/from_runtime project the allowlist from the plugin’s own declarations, so the operations a view may call, the operations the handshake advertises and the schemas a generated client will be built from are the same object (decision-26). A declared action without a handler is refused when the dispatcher is wired, not when a view calls it.

  • Strict inputs, lenient results. A declared request schema is validated before the handler runs - unknown properties, missing required ones, mistyped values and integers outside the safe range are refused as invalid_payload with the field path - and the payload is bound as keyword arguments. A declared result schema is checked afterwards: extra properties are tolerated, a missing or mistyped one is invalid_result naming the field, never the value.

  • One narrow entry point. Protocol, generation, authorization, allowlist, payload and serialization are decided in that order. Authorization runs before the allowlist lookup, so a refused view cannot enumerate operations by watching which refusals differ.

  • Trusted identity only. ViewBinding is minted by the host; a forged view_id or generation inside the payload is ignored, and advance_generation() retires every live binding with stale_generation.

  • Subscriptions. @app.stream on the application or register_stream(operation, factory) on the dispatcher declares a stream, and dispatch accepts {"kind": "subscribe"}. Frames carry a per-subscription seq, the queue is bounded (MAX_QUEUED_EVENTS), a pushed stream that overruns it ends with a gap terminal carrying the dropped count, progress may be coalesced while it is still queued and data never is, and pump(binding) hands the frames to whatever transport owns the view.

  • Release by kind. {"kind": "release"} disposes handles the way their kind requires: a borrowed layer is never deleted, an owned temporary output is deleted once, and a subscription that already ended reports already_released instead of failing.

  • Transport. qgis_sdk.bridge.transport.ChannelTransport moves JSON strings in and out; qgis_sdk.adapters.webchannel.create_channel_object wraps it in the QObject a QWebChannel registers, importing Qt only when asked.

The declarations a plugin already writes are the only source of the page’s types. One command renders the value types, an Operations and a Streams interface and the parsed description as web/src/bridge.schema.ts - no QGIS, no Qt, no handler ever runs - and the page builds its QgisSdk on it (decision-44; the TypeScript side is in doc-13):

Terminal window
$ qgis-sdk bridge generate --plugin my_plugin.bridge:app
wrote web/src/bridge.schema.ts

--out web/src/bridge.ts still renders the 0.3.0 client - validators and createOperations(client) - for one minor, with a note.

  • Deterministic. The same declarations produce the same bytes, so the file is reviewed and committed like source, and CI regenerates it and fails on any difference (--check reports the drift without writing).
  • A description works too. --plugin imports module:Attribute from the working directory; --description plugin-description.json does the same from a description a host already shipped, for a frontend with no Python at hand.
  • Closed subset. A schema outside the declaration subset ($ref, an object inside a union) fails the build instead of emitting any.
  • Strict in, lenient out. The SDK refuses unknown arguments and accepts a response that gained a field, so a newer host does not break an older view; the generator emits no validator of its own.
  • Ids are the method tree. QgisSdk nests methods by the dots in an id (sites.summary is qgis.sites.summary), so the generator refuses an id that is a prefix of another declared id and one whose first segment is a QgisSdk member (client, host, schema, ready, close).
  • Streams come from the same declarations (decision-28). @app.stream(id=..., events={"data": SiteChange, "progress": ScanProgress}) marks a method that returns an iterator of StreamEvents; its request schema is bound like an action’s, and each declared event kind is a dataclass schema. The runtime registers the stream under its id in the same namespace as the actions, the host advertises the event schemas in the handshake and refuses to emit an event the stream did not declare (the subscription ends with error), and the page calls qgis.sites.watch(input, handlers) with a typed event union. The frame envelope (seq, gap) is not generated: it is the protocol’s, owned by the TypeScript kernel.

A view can reach the curated services the plugin already implements - and nothing else of the QGIS API. The inventory is a declaration, not a convention:

from qgis_sdk.bridge import Dispatcher
from qgis_sdk.bridge.host import HostServices, host_grants, register_host_operations
dispatcher = Dispatcher(
description=runtime.description,
authorize=lambda binding, operation: host_grants()[operation] in grants_for(binding),
)
register_host_operations(
dispatcher,
HostServices(layers=layers, project=project, settings=settings,
styles=styles, notifications=notifications),
)
  • Six operations, each with a canonical grant. layers.list, layers.get, project.layer_ids, settings.get, notifications.info, styles.apply. Grants are per operation (layers.read, project.read, …), never per namespace, so a method added later is not automatically allowed.
  • Everything is declared. Each entry names the Protocol method it wraps, its gui affinity and its request/result schema. Every other method of the curated Protocols - open_vector, selection, add_layer, set, execute - is listed in DEFERRED_HOST_METHODS with the task that owns it, and a test reflects the Protocols so a new method cannot appear unaccounted for.
  • Nothing unbounded, nothing by path. Every list is paged ({limit?, cursor?} in, {items, truncated, next_cursor?} out; 50 by default, 200 at most), a cursor the host never issued is refused rather than silently read as the first page, and no operation takes a filesystem path.
  • Versioned with the client. The handshake advertises host_schema_version and the registered ids, and the TypeScript side ships generated inside the npm package as @archont561/qgis-sdk/host.