Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 oxp feature. export_plugin! exports the entry point and the manifest’s section only with it, so the same crate is an ordinary library for cargo test and a plugin file when built with --features oxp --crate-type cdylib. Without the feature in Cargo.toml, that build fails with “does not have the feature oxp”.
  • unsafe_code = "deny", not "forbid": the two attributes export_plugin! generates (#[unsafe(no_mangle)] and #[unsafe(link_section)]) need unsafe, and allow it for themselves only. You write no unsafe code.
  • 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; with panic = "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:

MethodCalled whenReturns
start(host)The plugin is first neededThe 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 openedA FormatReader, or None
writer(format)A score is saved in the formatA FormatWriter, or None
service(interface, id)The service is probed or calledA Service, or None
tab(id)The tab is shown or usedA Tab, or None
event(event)Something the manifest’s events subscribe to happenedNothing

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).

MethodWhat it doesSince
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 shown1.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.json1.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 shown1.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")
}
KindFor
NotFoundNo such contribution, method or widget: Error::not_found(what)
UnsupportedInput of a kind or version the plugin can’t handle (a newer file version)
InvalidInput that is damaged or breaks its rules
UnexpectedEndInput that ends before the data it promises
FailedAnything 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.