A Rust library for building Elgato Stream Deck plugins
Explore the docs »
View Demo
·
Report Bug
·
Request Feature
Table of Contents
streamdeck-lib is a Rust library to simplify writing Elgato Stream Deck plugins.
It provides a structured runtime, typed events, and a cross-platform input system (with Windows SendInput support).
Key features:
- Typed Stream Deck event handling (
serde-powered) - Plugin lifecycle management (actions, adapters, hooks)
- Keyboard and mouse input synthesis
- Global + per-action hooks
- Custom background code with adapters
- Rust (edition 2024+)
Add to your Cargo.toml:
[dependencies]
streamdeck-lib = { git = "https://github.com/veelume/streamdeck-lib" }use streamdeck_lib::prelude::*;
use tracing::info;
const PLUGIN_ID: &str = "com.example.hello";
#[derive(Default)]
struct HelloAction;
impl ActionStatic for HelloAction {
const ID: &'static str = "com.example.hello.action";
}
impl Action for HelloAction {
fn id(&self) -> &str { Self::ID }
fn key_down(&mut self, cx: &Context, ev: &incoming::KeyDown) {
cx.sd().set_title_simple(&ev.context, "👋");
cx.sd().show_ok(&ev.context);
info!("HelloAction pressed on {}", ev.context);
}
}
fn main() -> anyhow::Result<()> {
// Initialize file-based logging (keeps last 4 runs)
let _guard = streamdeck_lib::logger::init(PLUGIN_ID);
// Build and run the plugin
let plugin = Plugin::new()
.add_action(ActionFactory::default_of::<HelloAction>());
run_plugin(plugin)
}use streamdeck_lib::prelude::*;
use tracing::{info, debug};
#[derive(Default)]
struct DemoAction {
press_count: u32,
}
impl ActionStatic for DemoAction {
const ID: &'static str = "com.example.demo.action";
}
impl Action for DemoAction {
fn id(&self) -> &str { Self::ID }
// Called when this action instance is created
fn init(&mut self, _cx: &Context, ctx_id: &str) {
info!("Action initialized: {ctx_id}");
}
// Called when the action appears on the Stream Deck
fn will_appear(&mut self, cx: &Context, ev: &incoming::WillAppear) {
cx.sd().set_title_simple(&ev.context, "Ready");
}
// Key press events
fn key_down(&mut self, cx: &Context, ev: &incoming::KeyDown) {
self.press_count += 1;
cx.sd().set_title_simple(&ev.context, &format!("#{}", self.press_count));
cx.sd().show_ok(&ev.context);
}
fn key_up(&mut self, _cx: &Context, ev: &incoming::KeyUp) {
debug!("Key released on {}", ev.context);
}
// Encoder/dial events (Stream Deck+ and Neo)
fn dial_rotate(&mut self, cx: &Context, ev: &incoming::DialRotate) {
info!("Dial rotated by {} ticks", ev.payload.ticks);
cx.sd().set_feedback(&ev.context, serde_json::json!({
"value": ev.payload.ticks
}));
}
fn dial_down(&mut self, _cx: &Context, ev: &incoming::DialDown) {
info!("Dial pressed on {}", ev.context);
}
fn touch_tap(&mut self, _cx: &Context, ev: &incoming::TouchTap) {
info!("Touch at ({}, {})", ev.payload.tap_pos.0, ev.payload.tap_pos.1);
}
// Settings received from Stream Deck
fn did_receive_settings(&mut self, _cx: &Context, ev: &incoming::DidReceiveSettings) {
debug!("Settings: {:?}", ev.payload.settings);
}
// Property Inspector communication
fn did_receive_property_inspector_message(
&mut self,
cx: &Context,
ev: &incoming::DidReceivePropertyInspectorMessage,
_is_sdpi: bool,
) {
// Echo back to PI
cx.sd().send_to_property_inspector(&ev.context, ev.payload.clone());
}
// React to global Stream Deck events
fn on_global_event(&mut self, _cx: &Context, ev: &incoming::IncomingEvent) {
debug!("Global event: {:?}", ev);
}
// Typed notifications from the internal bus
fn topics(&self) -> &'static [&'static str] { &["demo.topic"] }
fn on_notify(&mut self, _cx: &Context, ctx_id: &str, ev: &ErasedTopic) {
debug!("Notification on {ctx_id}: {:?}", ev);
}
// Cleanup when action is removed
fn teardown(&mut self, _cx: &Context, ctx_id: &str) {
info!("Action torn down: {ctx_id}");
}
}use streamdeck_lib::prelude::*;
fn demonstrate_sd_client(cx: &Context, context: &str) {
let sd = cx.sd();
// ---- Titles ----
sd.set_title_simple(context, "Hello");
sd.set_title(context, Some("Multi".into()), Some(0), Some(outgoing::Target::Both));
sd.clear_title(context);
// ---- Images ----
sd.set_image_b64(context, "data:image/png;base64,...");
sd.set_image(context, Some("base64...".into()), Some(0), None);
// ---- States ----
sd.set_state(context, 1); // Switch to state 1
// ---- Settings ----
sd.get_settings(context);
sd.set_settings(context, serde_json::Map::from_iter([
("key".into(), serde_json::json!("value"))
]));
// ---- Feedback (Stream Deck+ / Neo LCD) ----
sd.set_feedback(context, serde_json::json!({
"title": "Volume",
"value": "75%",
"indicator": { "value": 75 }
}));
sd.set_feedback_layout(context, "$X1");
// ---- Trigger descriptions (encoder tooltips) ----
sd.set_trigger_description(
context,
Some("Long press action".into()), // long_touch
Some("Push action".into()), // push
Some("Rotate action".into()), // rotate
Some("Tap action".into()), // touch
);
// ---- Toasts / Alerts ----
sd.show_ok(context); // Green checkmark
sd.show_alert(context); // Yellow warning
// ---- Property Inspector ----
sd.send_to_property_inspector(context, serde_json::json!({
"message": "Hello from plugin!"
}));
// ---- Utilities ----
sd.open_url("https://example.com");
sd.log_message("Debug message visible in Stream Deck logs");
}use streamdeck_lib::prelude::*;
fn demonstrate_global_settings(cx: &Context) {
let globals = cx.globals();
// ---- Read ----
let snapshot = globals.snapshot(); // Clone entire map
let val = globals.get("my_key"); // Get single key
let vals = globals.get_many(&["key1", "key2"]); // Get multiple keys
// ---- Write (auto-syncs to Stream Deck) ----
globals.set("counter", serde_json::json!(42));
globals.set_many([
("name", serde_json::json!("Alice")),
("enabled", serde_json::json!(true)),
]);
globals.replace(serde_json::Map::new()); // Replace all
// ---- Delete ----
globals.delete("old_key");
globals.delete_many(&["key1", "key2"]);
globals.delete_all();
// ---- Batch edit (single push) ----
globals.with_mut(|map| {
map.insert("a".into(), serde_json::json!(1));
map.insert("b".into(), serde_json::json!(2));
map.remove("c");
});
}use streamdeck_lib::prelude::*;
use tracing::{info, debug};
let hooks = AppHooks::new()
.append(|cx, ev| match ev {
// Runtime lifecycle
HookEvent::Init => info!("Plugin initialized"),
HookEvent::Exit => info!("Plugin shutting down"),
HookEvent::Tick => { /* Called each runtime tick */ }
// Application monitoring
HookEvent::ApplicationDidLaunch(app) => info!("App launched: {app}"),
HookEvent::ApplicationDidTerminate(app) => info!("App closed: {app}"),
// Device events
HookEvent::DeviceDidConnect(dev, info) => {
info!("Device connected: {dev} ({}x{})", info.size.columns, info.size.rows);
}
HookEvent::DeviceDidDisconnect(dev) => info!("Device disconnected: {dev}"),
// Deep links (streamdeck://...)
HookEvent::DidReceiveDeepLink(url) => info!("Deep link: {url}"),
// Global settings changed
HookEvent::DidReceiveGlobalSettings(settings) => {
debug!("Global settings updated: {settings:?}");
}
// Raw message observation
HookEvent::Incoming(msg) => debug!("← SD: {msg:?}"),
HookEvent::Outgoing(msg) => debug!("→ SD: {msg:?}"),
// Internal bus events
HookEvent::ActionNotify(ev) => debug!("Action notify: {:?}", ev),
HookEvent::AdapterNotify(target, ev) => debug!("Adapter notify to {target:?}: {:?}", ev),
HookEvent::AdapterControl(ctl) => debug!("Adapter control: {ctl:?}"),
_ => {}
});
let plugin = Plugin::new()
.set_hooks(hooks)
.add_action(ActionFactory::default_of::<MyAction>());use std::sync::Arc;
use streamdeck_lib::prelude::*;
use tracing::info;
// 1) Define strongly-typed topics
pub const VOLUME_CHANGED: TopicId<VolumeEvent> = TopicId::new("audio.volume");
pub const TRACK_CHANGED: TopicId<TrackEvent> = TopicId::new("audio.track");
#[derive(Debug, Clone)]
pub struct VolumeEvent { pub level: u8 }
#[derive(Debug, Clone)]
pub struct TrackEvent { pub title: String, pub artist: String }
// 2) Publish from anywhere with a Context
fn publish_events(cx: &Context) {
let bus = cx.bus();
// Broadcast to all subscribers (actions + adapters)
bus.publish_t(VOLUME_CHANGED, VolumeEvent { level: 75 });
// Target specific actions
bus.action_notify_all_t(TRACK_CHANGED, TrackEvent {
title: "Song".into(),
artist: "Artist".into(),
});
// Target by action ID
bus.action_notify_id_t(
"com.example.volume",
VOLUME_CHANGED,
VolumeEvent { level: 50 },
);
// Target by context (single instance)
bus.action_notify_context_t(
"ABC123",
VOLUME_CHANGED,
VolumeEvent { level: 100 },
);
}
// 3) Subscribe in an Action
#[derive(Default)]
struct VolumeAction;
impl ActionStatic for VolumeAction {
const ID: &'static str = "com.example.volume";
}
impl Action for VolumeAction {
fn id(&self) -> &str { Self::ID }
fn topics(&self) -> &'static [&'static str] {
&[VOLUME_CHANGED.name, TRACK_CHANGED.name]
}
fn on_notify(&mut self, cx: &Context, ctx_id: &str, ev: &ErasedTopic) {
if let Some(vol) = ev.downcast(VOLUME_CHANGED) {
info!("Volume now {}%", vol.level);
cx.sd().set_title_simple(ctx_id, &format!("{}%", vol.level));
}
if let Some(track) = ev.downcast(TRACK_CHANGED) {
info!("Now playing: {} - {}", track.artist, track.title);
}
}
}use std::sync::Arc;
use streamdeck_lib::prelude::*;
use tracing::info;
#[derive(Default)]
struct AudioAdapter;
impl AdapterStatic for AudioAdapter {
const NAME: &'static str = "audio.monitor";
}
impl Adapter for AudioAdapter {
fn name(&self) -> &'static str { Self::NAME }
// When to start this adapter
fn policy(&self) -> StartPolicy {
StartPolicy::Eager // Start immediately
// StartPolicy::OnAppLaunch // Start when monitored app launches
// StartPolicy::Manual // Start/stop via bus control messages
}
// Topics this adapter subscribes to
fn topics(&self) -> &'static [&'static str] {
&[VOLUME_CHANGED.name]
}
fn start(
&self,
_cx: &Context,
bus: Arc<dyn Bus>,
inbox: crossbeam_channel::Receiver<Arc<ErasedTopic>>,
) -> AdapterResult {
let (tx_stop, rx_stop) = crossbeam_channel::bounded::<()>(1);
let handle = std::thread::spawn(move || {
info!("AudioAdapter started");
loop {
crossbeam_channel::select! {
// Handle incoming messages
recv(inbox) -> msg => {
match msg {
Ok(ev) => {
if let Some(vol) = ev.downcast(VOLUME_CHANGED) {
info!("Adapter received volume: {}", vol.level);
// Forward to all actions
bus.action_notify(
ActionTarget::All,
Arc::new(ErasedTopic::new(VOLUME_CHANGED, vol.clone())),
);
}
}
Err(_) => break,
}
}
// Handle shutdown signal
recv(rx_stop) -> _ => break,
}
}
info!("AudioAdapter stopped");
});
Ok(AdapterHandle::from_crossbeam(handle, tx_stop))
}
}
// Register the adapter
let plugin = Plugin::new()
.add_adapter(AudioAdapter::default())
.add_action(ActionFactory::default_of::<VolumeAction>());use std::sync::{Arc, RwLock};
use streamdeck_lib::prelude::*;
// Define shared state
struct AppState {
counter: RwLock<u32>,
config: Config,
}
struct Config {
api_key: String,
}
// Register extensions when building plugin
let state = Arc::new(AppState {
counter: RwLock::new(0),
config: Config { api_key: "secret".into() },
});
let plugin = Plugin::new()
.add_extension(state)
.add_action(ActionFactory::default_of::<MyAction>());
// Access from any action
impl Action for MyAction {
fn id(&self) -> &str { Self::ID }
fn key_down(&mut self, cx: &Context, ev: &incoming::KeyDown) {
// Get the extension (panics if not registered)
let state = cx.exts().require::<AppState>();
// Or safely try to get it
if let Some(state) = cx.try_ext::<AppState>() {
let mut counter = state.counter.write().unwrap();
*counter += 1;
cx.sd().set_title_simple(&ev.context, &counter.to_string());
}
}
}use streamdeck_lib::prelude::*;
impl Action for MyAction {
fn id(&self) -> &str { Self::ID }
// Handle sdpi-components datasource requests
fn did_receive_sdpi_request(&mut self, cx: &Context, req: &DataSourceRequest<'_>) {
match req.event {
"getColors" => {
cx.sdpi().reply(req, [
DataSourceResultItem::Item(DataSourceItem {
disabled: None,
label: Some("Red".into()),
value: "#ff0000".into(),
}),
DataSourceResultItem::Item(DataSourceItem {
disabled: None,
label: Some("Green".into()),
value: "#00ff00".into(),
}),
// Grouped items
DataSourceResultItem::Group(DataSourceGroup {
label: Some("Blues".into()),
children: vec![
DataSourceItem {
disabled: None,
label: Some("Light Blue".into()),
value: "#add8e6".into(),
},
DataSourceItem {
disabled: Some(true),
label: Some("Dark Blue (disabled)".into()),
value: "#00008b".into(),
},
],
}),
]);
}
_ => {}
}
}
}use streamdeck_lib::input::*;
// Create executor (Windows SendInput)
let executor = DefaultExecutor::new();
// Single key press
executor.key_press(Key::A)?;
executor.key_press(Key::Enter)?;
// Key combinations
let combo = InputCombo::new(Input::Key(Key::C))
.with_ctrl();
executor.execute_combo(&combo)?; // Ctrl+C
// Complex combos
let combo = InputCombo::new(Input::Key(Key::S))
.with_ctrl()
.with_shift();
executor.execute_combo(&combo)?; // Ctrl+Shift+S
// Mouse actions
executor.mouse_click(MouseButton::Left)?;
executor.mouse_double_click(MouseButton::Left)?;
executor.scroll(ScrollDirection::Up, 3)?;
// Hold keys
executor.key_down(Key::LShift)?;
executor.key_press(Key::A)?;
executor.key_press(Key::B)?;
executor.key_up(Key::LShift)?;
// Parse from string (useful for user configs)
let parser = DefaultParser::new();
let combo = parser.parse("Ctrl+Alt+Delete")?;
executor.execute_combo(&combo)?;use streamdeck_lib::logger;
fn main() -> anyhow::Result<()> {
// Initialize logging (stores in %APPDATA%/<plugin_id>/)
// Keeps last 4 log files by default
let _guard = logger::init("com.example.plugin");
// Or customize
let _guard = logger::init_with(
"com.example.plugin",
"my-plugin", // File prefix
10, // Keep last 10 runs
);
// Now use tracing macros anywhere
tracing::info!("Plugin starting");
tracing::debug!("Debug info");
tracing::error!("Something went wrong");
// Guard must be held until shutdown to flush logs
run_plugin(build_plugin())
}See the open issues for the full roadmap.
Contributions make the open source community amazing! Fork the repo, create a feature branch, and open a PR 🚀
- Fork the Project
- Create your Feature Branch (
git checkout -b feature/AmazingFeature) - Commit (
git commit -m 'Add some AmazingFeature') - Push (
git push origin feature/AmazingFeature) - Open a Pull Request
Distributed under the MIT OR Apache-2.0 License.
See LICENSE-MIT and LICENSE-APACHE.
Project Link: https://github.com/veelume/streamdeck-lib