The SDK
The SDK is the crate oxiguitar-plugin. It wraps the plugin interface in safe Rust: you implement traits, and export_plugin! makes the C entry point OxiGuitar loads.
The crate
# An OxiGuitar plugin: a template to copy (docs/plugins/, "Start from the
# template"). Rename the package, the plugin's id and its file, then delete
# the parts you don't need.
[package]
name = "example-plugin"
version = "0.1.0"
edition = "2024"
rust-version = "1.99"
description = "An example OxiGuitar plugin: a small text tab format, a statistics tab and a note-name service."
license = "MIT OR Apache-2.0"
publish = false
[lib]
# An ordinary library for `cargo test`; the `.oxp` file is the same crate
# built with `--features oxp --crate-type cdylib` (oxiguitar-plugin-test
# does that).
crate-type = ["lib"]
[features]
# Turned on only when the `.oxp` file is built: it exports the plugin's
# entry point and manifest. Every plugin needs this feature.
oxp = []
[dependencies]
# The SDK, from OxiGuitar's repository at the tag of the release you build
# for. v0.9.0 has interface 1.3; a plugin asking for 1.1 in manifest.json,
# as this one does, still loads in OxiGuitar 0.2 and later.
oxiguitar-plugin = { git = "https://github.com/mateusrdp/oxiguitar", tag = "v0.9.0" }
# The plugin's settings come and go as JSON objects (Host::settings).
serde_json = "1"
[dev-dependencies]
# The contract suite: tests/contract.rs builds the `.oxp` file and checks it
# through OxiGuitar's own plugin host.
oxiguitar-plugin-test = { git = "https://github.com/mateusrdp/oxiguitar", tag = "v0.9.0" }
[lints.rust]
# Deny `unsafe`, don't forbid it: `export_plugin!` allows it on the two
# attributes it generates (the entry point and the manifest's section).
unsafe_code = "deny"
[profile.release]
# Small files. Keep the default `panic = "unwind"`: a panic is then caught
# and reported as an error instead of closing OxiGuitar.
lto = "thin"
codegen-units = 1
strip = "symbols"
# Its own workspace, wherever the folder is copied.
[workspace]
Four things in it matter to OxiGuitar:
- The
oxpfeature.export_plugin!exports the entry point and the manifest’s section only with it, so the same crate is an ordinary library forcargo testand a plugin file when built with--features oxp --crate-type cdylib. Without the feature inCargo.toml, that build fails with “does not have the featureoxp”. unsafe_code = "deny", not"forbid": the two attributesexport_plugin!generates (#[unsafe(no_mangle)]and#[unsafe(link_section)]) needunsafe, and allow it for themselves only. You write nounsafecode.panic = "unwind", Rust’s default. A panic in a call is then caught, and the plugin is withdrawn for the rest of the session while OxiGuitar carries on; withpanic = "abort"it closes OxiGuitar.- The SDK’s tag:
v0.9.0, the release of OxiGuitar you build for; it has interface 1.3. A plugin loads in every OxiGuitar that has the interface version its manifest asks for, and in every later one: 0.2 and later for 1.0 and 1.1, 0.5 and later for 1.2, 0.9 and later for 1.3 (Interface versions).
The plugin
/// The plugin: built once, when OxiGuitar first uses one of its
/// contributions, and kept until the program ends.
pub struct Example {
statistics: statistics::Statistics,
}
impl Plugin for Example {
fn start(host: Host) -> Result<Example, Error> {
Ok(Example {
statistics: statistics::Statistics::new(host),
})
}
// Each method hands out a contribution the manifest declares, by its id.
fn reader(&self, format: &str) -> Option<&dyn FormatReader> {
(format == format::ID).then_some(&format::ExampleTab as &dyn FormatReader)
}
fn writer(&self, format: &str) -> Option<&dyn FormatWriter> {
(format == format::ID).then_some(&format::ExampleTab as &dyn FormatWriter)
}
fn service(&self, interface: &str, id: &str) -> Option<&dyn Service> {
(interface == service::INTERFACE && id == service::ID)
.then_some(&service::NoteNames as &dyn Service)
}
fn tab(&self, id: &str) -> Option<&dyn Tab> {
(id == statistics::ID).then_some(&self.statistics as &dyn Tab)
}
fn event(&self, event: &oxiguitar_plugin::Event) {
self.statistics.event(event);
}
}
// The plugin's entry point and manifest, and a test (under `cargo test`)
// that the manifest matches the code.
oxiguitar_plugin::export_plugin!(Example);
Plugin has one required method and five optional ones:
| Method | Called when | Returns |
|---|---|---|
start(host) | The plugin is first needed | The plugin, or an error: the plugin is then withdrawn, as if it weren’t installed |
reader(format) | A file of the format format (an id from the manifest) is opened | A FormatReader, or None |
writer(format) | A score is saved in the format | A FormatWriter, or None |
service(interface, id) | The service is probed or called | A Service, or None |
tab(id) | The tab is shown or used | A Tab, or None |
event(event) | Something the manifest’s events subscribe to happened | Nothing |
The ids are the manifest’s. Returning None answers “not found”, which the contract suite checks for a contribution the plugin doesn’t have; cargo test checks that every contribution the manifest declares is found.
Threads
Every call may come from any thread, several at once, so Plugin and every contribution are Send + Sync, and every method takes &self. Keep what changes behind a Mutex or in atomics, and don’t hold a lock while you call the host or do slow work: the template’s tab does
/// The tab. Calls may come from any thread at once, so what changes lives
/// behind a lock.
pub struct Statistics {
host: Host,
state: Mutex<State>,
}
#[derive(Default)]
struct State {
/// A score was opened or changed since it was last counted.
stale: bool,
/// The open score's title, and each track's counts; none when no
/// score is open.
title: String,
tracks: Vec<Counts>,
/// The track shown.
track: usize,
/// The string whose bar was clicked last, from 0 (string 1).
picked: Option<usize>,
/// Count notes tied to the one before; kept in settings.json.
count_tied: bool,
}
/// One track's notes, string by string.
#[derive(Clone, Debug, Default, PartialEq)]
struct Counts {
name: String,
/// Notes on each string, from string 1; empty for a track without
/// strings.
struck: Vec<u32>,
/// Of those, the notes tied to the one before.
tied: Vec<u32>,
}
and takes the lock only to read or change State.
The host
start receives a Host: the core’s services, as the plugin reaches them. Keep a clone wherever you need it (it is cheap to clone).
| Method | What it does | Since |
|---|---|---|
notify(level, text) | Shows a message in the footer, with the plugin’s name: Level::Warning or Error. The footer isn’t a log, so Level::Info messages aren’t shown | 1.0 |
settings() | The plugin’s settings: the JSON object under its id in settings.json (Settings) | 1.0 |
set_settings(map) | Replaces them and saves settings.json | 1.0 |
call_service(interface, method, input) | Calls the service OxiGuitar chose for interface, which the manifest must list under requires (Services) | 1.0 |
redraw(tab) | Asks the core to show one of the plugin’s tabs again (Tabs) | 1.1 |
score() | The open score, or None (Events) | 1.1 |
score2(), apply(revision, name, score) | The open score version 2 with its revision, and a change to it the user can undo (Tools) | 1.2 |
notify_with_link(level, text, link_text, url) | A footer message ending with a link that opens url in the browser; only https: addresses are shown | 1.3 |
folders() | The plugin’s own data and cache folders and the shared folders its manifest lists (Settings) | 1.3 |
Host::detached() answers like a host with no window, no settings and no services: for unit tests.
Errors
A call that fails returns an Error: a kind, one sentence for the user, and where it happened.
Error {
offset: Some(1032), // the byte in the input, if there is one
context: "bar 12, track 2".into(),
..Error::new(ErrorKind::Invalid, "a note is on string 9 of a six-string guitar")
}
| Kind | For |
|---|---|
NotFound | No such contribution, method or widget: Error::not_found(what) |
Unsupported | Input of a kind or version the plugin can’t handle (a newer file version) |
Invalid | Input that is damaged or breaks its rules |
UnexpectedEnd | Input that ends before the data it promises |
Failed | Anything else |
When a file can’t be read, the footer says FILE: message (in context) at byte offset; a crash or a malformed answer is named with your plugin’s name instead. Write the message for a musician, not a programmer: “the file ends in the middle of bar 12”, not “unexpected EOF in read_u32”.
Built in, and in tests
Without the oxp feature the crate is an ordinary library, and oxiguitar_plugin::dispatch(&plugin, request) answers a request the way the plugin file would, messages and all. Our own tests use plugins that way, and so will the mobile apps, which build their plugins in.