Decision
A plugin may declare a list of settings in itsplugin.json. Cordial renders
that declaration as an AdwPreferencesPage inside its own Settings window,
using its own widgets, in its own process. The user’s answers are written by
Cordial to <profile>/plugins/<id>/preferences.json and delivered to the plugin
read-only.
A plugin never draws. It receives no widget, no window, no surface, no
toolkit binding and no drawing call, and there is no capability by which it
could ask for one.
Four field types, because four is what the renderer can draw:
Every field carries
key, title, an optional description and an optional
group. Fields sharing a group become one AdwPreferencesGroup, in the order
the groups first appear.
The declaration is the only signal that a plugin has a page. There is no
has-preferences manifest key and no capability meaning the same thing, so the
gear on a plugin’s row appears exactly when there is something behind it.
Why not the GNOME Shell arrangement
The comparison a reader reaches for first is GNOME’s Extensions app, and it is the right comparison: a list of extensions, each row with a gear, and the gear opens that extension’s own preferences. Cordial copies the affordance exactly — name, identifier, gear where there is something to configure, enable switch — and deliberately does not copy the mechanism. GNOME extensions can build their own window because their code runs inside the host’s own process. The extension itself is GJS executing inside gnome-shell; its preferences dialog is GJS executing inside the Extensions app, which loads the extension’sprefs.js and lets it return a widget. Either way
the extension is not sandboxed from the process drawing it in any meaningful
sense — it is that process, for the duration, with the same GTK and the same
widget hierarchy.
Cordial’s plugins are the opposite by design. ADR-003
puts each one in its own address space with no memory access to the core, and it
argues at length that this is the only isolation that actually holds — “an
address-space boundary is enforced by the MMU and does not depend on Cordial
being correct.” A plugin is a Deno process with no file, network, environment or
subprocess permission at all. It has no display connection. Handing it one, or a
widget handle, or an RPC that constructs widgets on its behalf, would each undo
that in a different way.
Three specific reasons, beyond “the architecture says so”:
A drawing API is the largest channel in the system.
ADR-007 draws the line as effects,
not channels: presence.set takes a presence structure and Cordial owns the
Discord socket, because “a plugin that can open a socket of its choosing has the
channel, and every narrow guarantee above evaporates.” A general widget-
construction API is that failure in its most complete form. Not one resource —
every pixel of the launcher, with a callback surface attached. There is no
narrow version of “build any interface you like” to broker.
A plugin that can draw in Cordial’s window can draw Cordial’s sign-in
dialog. This is not hypothetical and it is not a new class of risk here:
crates/cordial-shell/src/webview_policy.rs exists specifically to keep
https://www.roblox.com@evil.example/ from rendering, because “a reader
skimming the address sees Roblox.” That module refuses a URL on the grounds
that it might be misread. It would be incoherent to spend that care on the
address bar and then let an installed plugin construct a pixel-identical
credential prompt inside the same window, with no address bar to skim at all. A
plugin’s page is reached from Settings, it is drawn from a declaration, and
every string in it goes through Cordial’s own escaping — so the worst a hostile
declaration achieves is a badly-worded switch.
It would make the plugin boundary depend on Cordial being correct again.
ADR-003’s whole argument is that in-process sandboxing is “a boundary only as
strong as the absence of bugs in it.” A widget-building RPC is exactly that
boundary: a list of allowed constructors and properties, maintained forever,
where one missing check is arbitrary drawing. The declarative schema has no such
list to get wrong, because the renderer is ordinary Cordial code and the plugin’s
input is data.
What the declarative form gives up, honestly
A plugin cannot build a page Cordial cannot already draw. No custom widget, no live preview, no field that appears only when another is switched on, no button that runs the plugin’s own code. Some of those are real losses. GNOME extension preferences do contain genuinely bespoke interfaces, and nothing here can reproduce them. Two of them are worth building later and are not built now: conditional visibility (show-when), and an action row that sends a named message to the
plugin. Both are additions to the schema rather than departures from it — they
keep the property that Cordial constructs the widget — so neither needs this
decision reversed. The rest are the price, and it is the same price ADR-003
already decided to pay for everything else.
The argument for the other side, and why it loses
The strongest case for plugin-drawn windows is that a plugin could draw its preferences in its own process, in its own window, with its own toolkit — not in Cordial’s. That is genuinely different from the phishing shape above: a separate top-level window is the plugin’s, not the launcher’s, and a compositor that draws window decorations makes the distinction visible. It fails on three counts. It requires the plugin to hold a display connection, which is a host resource ADR-007 says a plugin never holds, and on Wayland a display connection is a fat channel — clipboard, input, and under a permissive compositor a good deal more. It requires the plugin to bundle a toolkit, abandoning the property that every Cordial page looks and behaves the same. And it does not actually solve the impersonation problem, because a plugin’s own window can still be titled “Cordial” and still ask for a password; ADR-018’s sub-sandbox does not help, since the risk is what the window says, not what the process can reach.Mechanism
Declaration.preferences in plugin.json, validated by
cordial_plugins::preferences::check_all at manifest-parse time. A bad field
refuses the whole plugin rather than being skipped — the same treatment
manifest::parse already gives an unknown capability, and for the reason it
gives: a plugin whose page is quietly missing one row installs looking correct
and then behaves strangely.
Validation refuses a default outside its own range, a choice whose default is
not one of its options, a duplicate key, more than 64 fields, and any title,
description, group name or option label containing a control character. That
last one is not fussiness: AdwPreferencesRow interprets its title as Pango
markup by default, so the renderer turns use-markup off for titles and escapes
subtitles, and refusing control characters at parse closes the rest.
Storage. <profile>/plugins/<id>/preferences.json, per profile for
ADR-013’s reason — the same installed
plugin tuned one way on a test account must not carry those answers onto the
account somebody plays.
It is deliberately a different file from settings.json, and this is the part
most likely to be “simplified” later by someone who has not hit the bug.
crate::settings is the plugin’s own scratch document and settings.set
replaces it whole, which is correct there: the plugin is its only writer and
needs a way to drop a key. If the user’s answers lived in that document, the
first time the plugin saved anything it would erase them. Two writers, one of
whom replaces wholesale, loses the other’s data on the first write. So Cordial
owns this file, writes it one key at a time, and there is no preferences.set
on the wire at all — a plugin that could rewrite its own answers could set them
to whatever it liked and have the page show the result back as though the user
had chosen it.
Delivery. preferences.get, and the same document on the cordial/init
handshake so the common case costs no round trip. Gated on the existing
settings.read capability rather than a new one: both mean “read what Cordial
keeps for you”, the data is the plugin’s own by construction, and a separate
permission a user could deny would leave a plugin declaring questions it is not
allowed to hear the answers to.
The document handed over is always complete and always valid — every
declared key present, every value fitting its declaration. A saved value that no
longer fits, because an update narrowed a range or renamed an option, falls back
to the current default and says so in the log. This is most of what the
declaration buys an author: the parsing, the range check and the fallback happen
once, in Cordial, rather than once per plugin in whatever way each author
thought of.
The row. Following GNOME’s Extensions app: name, identifier, then the gear
where a schema exists, then the enable switch. No schema, no gear — an
insensitive button reads as broken, an absent one reads as “nothing to
configure”.
Two further row states are designed here and not built:
- A failed plugin should carry an error indicator on its own row. GNOME puts
a red mark on an extension that failed to load. A plugin that is installed,
enabled and silently doing nothing is exactly the shape AGENTS.md’s rule about
stubs exists to prevent, and a row that says so is that rule in the interface.
plugin_host::start_allalready prints every one of these — not granted, no capabilities, could not start — to stdout, where nobody looks. Surfacing it needs a channel from the runtime’s plugin host to the shell that does not exist yet. - An accent-coloured gear when an update is available for that plugin, which
is what GNOME’s coloured gear actually means. Blue and not orange: orange is
libadwaita’s warning colour and says something is wrong, whereas an available
update is information.
plugin_preferences::gear_fortakes the flag and nothing passestrue, because Cordial has no plugin update detection at all. When it gains one it must go throughcordial_update::meteredlike every other download here — an application that updates itself quietly while nagging about its plugins is two policies where there should be one.
The vsync unlock, and why it is not a new capability
A plugin that wants an uncapped frame rate needs the Vulkan swapchain’s present mode changed, which is Cordial’s graphics behaviour and not a FastFlag. That is the first thing a plugin has wanted that is neither a flag nor an existing brokered effect, so it is the test of whether the contract is wide enough. It needed no widening.CordialPresentMode is a Cordial-owned key in the
ordinary flag layers, read by android::vulkan::present_mode_choice and
filtered out of Roblox’s settings document by client_settings::is_roblox_flag
— the arrangement graphics.rs’s CordialGraphicsBackend has used since it was
written. A plugin contributes a value with flags.set; Cordial decides what to
hand vkCreateSwapchainKHR. The plugin never learns a swapchain exists, cannot
name a mode the driver does not advertise, and cannot reach any other Vulkan
call. Effect, not channel.
It also inherits the layering’s precedence for free: the user’s flags.json
beats every plugin’s, so a plugin cannot overrule a present mode somebody chose,
and CORDIAL_PRESENT_MODE beats both.
One consequence of this pattern deserves stating plainly, because it is easy
to arrive at by accident. flags.write lets a plugin set any
Cordial-prefixed key, not only this one — CordialGraphicsBackend and
CordialDeviceProfile included. That was already true before this change;
graphics.rs::plugin_request reads the plugin layers deliberately and says so.
It is worth knowing that flags.write is therefore “contribute to Cordial’s own
runtime configuration” and not merely “contribute FastFlags”, and that every
future Cordial* key inherits that. Keys where a plugin’s opinion would be
inappropriate need to be read from somewhere other than the flag layers, and
that is a decision to make per key rather than a hole to patch here.
Consequences
Accepted: the set of things a plugin’s page can contain is fixed by Cordial’s renderer, and widening it means a change to Cordial, reviewed and released. This is the same trade ADR-007 already accepted for brokered resources, and for the same reason. Accepted: adding a field type is a change to theField enum and to the
renderer, together. That is a feature. A schema able to express something the
page cannot draw would present to the user as a row that silently does not
appear.
Accepted: a plugin cannot react to a preference changing while it runs. The
document is delivered at handshake and on request, and nothing pushes an update.
Most plugins here already take effect at the next launch — flags.set says so
explicitly — so this matches. A cordial/preferences-changed push is the
obvious addition when something needs it.
Accepted, and to be watched: the renderer is generic and must stay generic.
The first if plugin.id == "…" in plugin_preferences.rs makes every other
plugin’s page the second-class one. Its tests use a fabricated schema rather
than any real plugin’s, so the file has no example to grow around.