Плагин на Rust
Крейт opensd: структура плагина, события, команды, таймеры, брокеры, картинки и тесты.
Плагин на Rust это библиотека cdylib, собранная для wasm32-unknown-unknown. Крейт opensd даёт типизированные события, команды и тестовый стенд. Внешних зависимостей у него две: serde и serde_json.
Проект
rustup target add wasm32-unknown-unknown
cargo install opensd-cli
opensd new my-pluginopensd.toml манифест
Cargo.toml крейт с crate-type = ["cdylib"]
src/lib.rs код плагина
assets/ картинкиopensd dev и opensd pack вызывают cargo build --release --target wasm32-unknown-unknown сами. Все плагины по умолчанию собираются в общий каталог target в кеше пользователя, поэтому зависимости компилируются один раз на машину.
Структура плагина
Плагин это один тип, который реализует Plugin и получает все события:
use opensd::{Event, Host, Plugin, Result};
use serde::{Deserialize, Serialize};
#[derive(Default, Deserialize, Serialize)]
#[serde(default)]
struct Settings {
count: u64,
}
#[derive(Default)]
struct Counter;
impl Plugin for Counter {
fn event(&mut self, host: &mut Host, event: Event) -> Result {
match event {
Event::Appear(key) => {
let settings: Settings = key.settings()?;
host.key(&key.context).set_title(&settings.count.to_string())?;
}
Event::Tap(key) => {
let mut settings: Settings = key.settings()?;
settings.count += 1;
host.key(&key.context).save_settings(&settings)?.set_title(&settings.count.to_string())?;
}
_ => {}
}
Ok(())
}
}
opensd::export!(Counter);opensd::export! создаёт значение плагина при первом событии и экспортирует ABI. Состояние хранится в полях типа: соединения, кеши, таймеры. Никаких Rc<RefCell<…>> не нужно, обработчик получает &mut self.
Err из обработчика пишется в журнал плагина, плагин продолжает работать. Паника останавливает экземпляр, и приложение перезапускает его с паузой. Текст паники тоже попадает в журнал.
События
Event это перечисление. Основные варианты:
KeyEvent содержит context, action, controller, position, settings, state. Настройки читаются типизированно: key.settings::<T>(). С #[serde(default)] недостающие поля получают значения по умолчанию.
Жесты
Пороги жестов задаются в манифесте, а не в коде:
[[actions]]
id = "com.example.player.next"
name = "Следующий трек"
gestures = { hold = 400, repeat = 250 }Tap приходит при отпускании, только если не сработали LongPress или HoldStart. Кнопке с перемоткой не нужно отличать «отпустили после удержания» от обычного нажатия.
Host и Commands
Host знает состояние приложения: текущее время now(), язык, устройство, общие настройки, видимые кнопки (keys(), keys_of(action), is_visible).
Команды живут в Commands. Это тип нулевого размера с Copy, его можно хранить в любой структуре и вызывать из вспомогательных модулей без передачи Host по цепочке. Host даёт доступ к командам через Deref, поэтому host.info("…") и host.key(context) работают напрямую.
struct Connection {
commands: opensd::Commands,
}
impl Connection {
fn ping(&self) -> opensd::Result {
self.commands.http_get("ping", "http://127.0.0.1:9222/json/version")
}
}Команды Key возвращают Result<&Key>, поэтому их можно соединять в цепочку.
Таймеры
В песочнице нет системных часов и потоков. Время каждого события лежит в host.now(), а периодическая работа делается таймером:
host.set_interval("refresh", 1000)?;
// …
Event::Timer(id) if id == "refresh" => self.refresh(host)?,Включайте таймер, только пока он нужен: например, пока на экране есть кнопки плагина. В остальное время плагин не тратит ресурсы.
Брокеры
Запросы возвращаются сразу, ответ приходит отдельным событием с тем же id:
host.http("list", &Request::get("http://127.0.0.1:9222/json/list").timeout(2000))?;
Event::Http { id, result: Ok(response) } if id == "list" => {
let targets: Vec<Value> = serde_json::from_str(&response.body)?;
}
Event::Denied { id, message } => host.warn(format!("{id}: {message}"))?,Адреса 127.0.0.1 и localhost идут через брокер приложения и требуют разрешения loopback. Остальные адреса требуют network. Подробности в разделе разрешения.
Картинки
opensd::image собирает data URI без лишних зависимостей:
let ring = opensd::image::svg(&format!(r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 144 144"><circle cx="72" cy="72" r="60" stroke="#7c8cff" stroke-width="12" fill="none" pathLength="100" stroke-dasharray="{p} 100"/></svg>"#, p = 42));
host.key(&context).set_image(&ring)?;SVG дешевле всего: приложение растеризует его под размер кнопки. PNG и JPEG передавайте через image::data_uri("image/png", &bytes).
Тесты
opensd::testing::Harness запускает плагин в тестовом процессе и записывает команды:
use opensd::{json, testing::Harness};
#[test]
fn tap_counts() {
let mut deck = Harness::new(Counter::default());
deck.appear("k1", "com.example.counter.counter");
deck.commands();
deck.event(json!({ "type": "tap", "context": "k1", "action": "com.example.counter.counter", "settings": { "count": 1 } })).unwrap();
assert_eq!(deck.commands()[1]["title"], "2");
}deck.advance(ms) двигает часы хоста. Тесты запускаются обычным cargo test без приложения и устройства.
Размер и скорость
- Профиль
releaseв шаблоне собирает модуль сopt-level = "s", LTO иpanic = "abort". opensd packудаляет отладочные секции и ставит модулю предел памяти 64 МиБ.- Каждая зависимость увеличивает модуль.
serde_jsonстоит около 100 КиБ, это нормальная цена; тяжёлые крейты для картинок лучше заменить SVG.