Plugins: a service written in Rust
Most services need no code: service … from dbus, from file, from dir, from listen and from poll (level 5) cover a D-Bus object, a
file, a directory and a command. When a service needs more than that, write it in Rust and
build your own strand binary with it inside. That binary is a
plugin: it answers every strand command (run, check,
call, …) and serves your service beside the builtin ones.
Unstable. The plugin builder is git-only and may change in any commit. Pin the revision you build against.
The crate
A plugin is a binary crate that depends on strand and
strand-services, both from the same git URL and revision (the
#[service] derives name strand_services; two revisions of it do not
compile together):
[dependencies]
strand = { git = "https://github.com/jakeb-grant/strand", rev = "<rev>" }
strand-services = { git = "https://github.com/jakeb-grant/strand", rev = "<rev>" }
Its main.rs declares the service and hands it to the builder. This is
crates/strand/examples/plugin_hello.rs in the repository, a hello
service with a greeting, a count and one action:
use strand_services::{Call, Cx, Msg, ServiceError, Store, service};
// mimalloc and no transparent huge pages, as `strand` runs.
strand::process_setup!();
const SCHEMA: &str = "
/// The plugin example's service.
service hello {
/// The last greeting, `hello, <name>`.
greeting: text
/// How many greetings there have been; 41 after its first read.
count: int rw
/// Greets `name`: sets `greeting` and adds one to `count`.
action greet(name: text)
}
";
#[derive(Call, Debug)]
enum HelloAction {
Greet(String),
}
#[service(name = "hello", schema = SCHEMA, action = HelloAction)]
#[derive(Store, Clone, Debug, Default, PartialEq)]
struct Hello {
greeting: String,
#[store(rw)]
count: i64,
}
impl Hello {
async fn run(mut cx: Cx<Self>) -> Result<(), ServiceError> {
cx.update(|s| s.count = 41);
cx.ready();
while let Some(m) = cx.recv().await {
match m {
Msg::Write(w) => {
let n: i64 = w.value().map_err(ServiceError::from)?;
cx.report(&w, |s| s.count = n);
}
Msg::Action(HelloAction::Greet(name)) => {
cx.update(|s| {
s.greeting = format!("hello, {name}");
s.count += 1;
});
}
_ => {}
}
}
Ok(())
}
}
fn main() -> std::process::ExitCode {
strand::Shell::builder().service::<Hello>().run()
}
- The schema is what
strand check, hovers and completion see. Every entry needs a///doc:--write-schemas(below) refuses a schema with a hover-less name. - The service name is lower case: letters, digits and
_. A builtin's name (battery,clock, …) is refused. runstarts when a config first readshelloand stops 5 s after the last reader goes, as a builtin service does, unless agraphkeeps its history (below).cx.ready()says the first read is in. Addthreadto#[service(…)]for a body that blocks; it then gets a thread of its own.- A registration the builder cannot serve (a bad or taken name, a schema that does not apply, or one that declares more than its own service: another service, an element, function, value, method or token; the records and enums its service uses are its own) stops every command with a message, before anything runs.
Msgis#[non_exhaustive]: match it with a_ => {}arm, as above, so a message a later Strand adds does not break your build.
When a graph keeps your service's history
A config's graph hello.count { history: 60s } shows the last minute
of the value as soon as it mounts, so Strand keeps your service running
while no visible reader holds it (it is kept), for as long as the
loaded config has such a graph. Your body is told with Msg::Keep(Some( period)) (Msg::Keep(None) when no graph keeps it any more; cx.kept()
says which now) and Msg::Visible(false) as usual. What being kept
means for you depends on how your service gets its values:
- It reports changes as they happen (a D-Bus signal, a file watch, an event stream): nothing to do. It is not stopped 5 s after its last reader goes while it is kept, and each value it reports is drawn as a step at the moment it came.
- It samples on a clock (reads
/procor runs a command every second): tick withstrand_services::Ticker.Ticker::new(&cx, every)ticks everyeverywhile a reader is visible, at the kept period (cx.period(every): the coarser ofeveryand the period Strand asks for) while hidden and kept, and never otherwise; calltick.follow(&cx)after each message, and when it returnstrue(it ticks again after not ticking) take a fresh baseline if you take differences, ascpudoes. A body that askscx.periodsays it samples on a clock, so its hidden samples are drawn as lines from the one before across the period they could have changed in. A loop of your own instead of aTickershould sleep forcx.period(every)(None: wait for the next message). - It runs a stream only while watched (
#[store(stream)],cx.watched(field)): a hidden stream stays off, kept or not, and a graph of it holds the last value it had.
The two things every plugin must copy
strand's size and memory figures hold for your binary only if it is
built and set up the way strand is:
strand::process_setup!();at the crate root, once. It makes mimalloc the allocator and turns transparent huge pages off before the first allocation. A library cannot do this for the binary that links it. Without it the shell runs, but uses about twice the memory, and the binary warns on every start:warning: this binary does not invoke `strand::process_setup!()` ….- The workspace's release profile, every line of it, in your
crate's
Cargo.toml:
[profile.release]
lto = "fat"
codegen-units = 1
[profile.release.package.strand-services]
opt-level = "z"
[profile.release.package.zbus]
opt-level = "z"
[profile.release.package.zvariant]
opt-level = "z"
[profile.release.package.zbus_names]
opt-level = "z"
[profile.release.package.tokio]
opt-level = "z"
[profile.release.package.swayipc-types]
opt-level = "z"
[profile.release.package.logind-zbus]
opt-level = "z"
[profile.release.package.strand-icons]
opt-level = "s"
[profile.release.package.strand-introspect]
opt-level = "z"
[profile.release.package.toml_edit]
opt-level = "s"
[profile.release.package.toml_parser]
opt-level = "s"
[profile.release.package.fontique]
opt-level = "s"
[profile.release.package.quick-xml]
opt-level = "s"
[profile.release.package.calloop]
opt-level = "s"
[profile.release.package.xkeysym]
opt-level = "s"
[profile.release.package.miette]
opt-level = "z"
[profile.release.package.chrono]
opt-level = "s"
[profile.release.package.freedesktop-desktop-entry]
opt-level = "z"
[profile.release.package.pipewire]
opt-level = "z"
[profile.release.package.libspa]
opt-level = "z"
[profile.release.package.ureq]
opt-level = "z"
[profile.release.package.ureq-proto]
opt-level = "z"
[profile.release.package.rustls]
opt-level = "z"
[profile.release.package.rustls-native-certs]
opt-level = "z"
Leave your own crate at the release default, opt-level 3: do not add
opt-level = "s" or "z" under [profile.release]. Fat LTO
optimises the whole program at the level of the crate that links it,
so a plugin built for size slows the render path with it.
Schemas for strand check and your editor
strand check and the language server (strand-dev lsp) never load
plugin code. Your binary writes its schema to a file they read:
cargo build --release
target/release/my-shell --write-schemas
It writes hello.schema into $XDG_DATA_HOME/strand/schemas/
(~/.local/share/strand/schemas/ by default), prints each path, and
removes the files it wrote earlier for services it no longer has. Files
another binary wrote are listed and left alone. Run it again whenever
the schema changes; a running shell and the language server read the
directory when they start.
The stock strand reads these files too, so strand check passes a
config that reads hello.greeting. If you run the stock strand on
such a config, the service has no code behind it: reads give its
defaults, writes and calls are refused, and a notice names the file.
Run your plugin binary instead, or delete the file if you no longer use
that plugin.
Running it
Run your binary wherever you ran strand (your compositor's autostart
line):
my-shell run
The other commands work from either binary, since they talk to the
running shell over its socket: strand call hello.greet world, strand set hello.count 0, strand get of a value your config exports.
A config uses the service like a builtin one:
export let greeting = hello.greeting
bar Top {
text greeting
on click { hello.greet("you") }
}