Open Stream Deck

Плагин на Rust

Крейт opensd: структура плагина, события, команды, таймеры, брокеры, картинки и тесты.

Плагин на Rust это библиотека cdylib, собранная для wasm32-unknown-unknown. Крейт opensd даёт типизированные события, команды и тестовый стенд. Внешних зависимостей у него две: serde и serde_json.

Проект

rustup target add wasm32-unknown-unknown
cargo install opensd-cli
opensd new my-plugin
opensd.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 это перечисление. Основные варианты:

ВариантКогда
Init(Init)Первое событие: версия приложения, язык, устройство, общие настройки
Appear(KeyEvent), Disappear(KeyEvent)Действие появилось на видимой странице или ушло с неё
KeyDown, KeyUpСырое нажатие и отпускание
TapОтпускание без долгого нажатия и удержания
LongPressДолгое нажатие, кнопка ещё нажата
HoldStart, HoldRepeat, HoldEndУдержание; count у повтора, duration у конца
Settings(KeyEvent)Пользователь изменил настройки кнопки
Globals(Value)Изменились общие настройки плагина
InspectorMessage, SettingsPageMessageСообщение со страницы настроек
Timer(String)Сработал таймер
Http, WsOpen, WsMessage, WsClose, AppStatus, Done, DeniedОтветы брокеров
Other(Value)Событие, которого эта версия SDK ещё не знает

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(context).set_title(…), set_title_styled(…, &TitleStyle), reset_title()Подпись
key(context).set_image(…), reset_image()Картинка из пакета или data URI
key(context).set_state(n)Состояние
key(context).flash_ok(), flash_alert()Отметка поверх кнопки
key(context).save_settings(&T)Настройки кнопки
key(context).to_inspector(&T)Сообщение странице настроек кнопки
set_globals(&T), to_settings_page(&T)Общие настройки и окно настроек плагина
set_timer(id, ms), set_interval(id, ms), clear_timer(id)Таймеры
http(id, &Request), http_get(id, url)HTTP
ws_open, ws_send, ws_closeWebSocket
app_status, app_launch, app_quitОбъявленные приложения
clipboard(id, text)Запись в буфер обмена
open_url(url)Ссылка в браузере после действия пользователя
debug, info, warn, errorЖурнал плагина

Команды 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.