Skip to content

A Rust library for building Elgato Stream Deck plugins

License

Unknown, MIT licenses found

Licenses found

Unknown
LICENSE-APACHE
MIT
LICENSE-MIT
Notifications You must be signed in to change notification settings

VeeLume/streamdeck-lib

Repository files navigation

Contributors Forks Stargazers Issues project_license

streamdeck-lib

A Rust library for building Elgato Stream Deck plugins
Explore the docs »

View Demo · Report Bug · Request Feature

Table of Contents
  1. About The Project
  2. Getting Started
  3. Usage
  4. Roadmap
  5. Contributing
  6. License
  7. Contact
  8. Acknowledgments

About The Project

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

(back to top)

Getting Started

Prerequisites

  • Rust (edition 2024+)

Installation

Add to your Cargo.toml:

[dependencies]
streamdeck-lib = { git = "https://github.com/veelume/streamdeck-lib" }

(back to top)

Usage

Minimal Plugin

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

Actions — Full Event Handling

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}");
    }
}

Stream Deck Client — Full API

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");
}

Global Settings — Persistent Plugin State

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");
    });
}

Hooks — Observe Runtime & Stream Deck Lifecycle

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>());

Typed Pub/Sub — Topics & Notifications

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);
        }
    }
}

Adapters — Background Workers

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>());

Extensions — Shared Plugin State

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());
        }
    }
}

SDPI Components — Datasource Support

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(),
                            },
                        ],
                    }),
                ]);
            }
            _ => {}
        }
    }
}

Input System — Keyboard & Mouse Synthesis (Windows)

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)?;

Logging — File-based with Rotation

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())
}

(back to top)

Roadmap

See the open issues for the full roadmap.

(back to top)

Contributing

Contributions make the open source community amazing! Fork the repo, create a feature branch, and open a PR 🚀

  1. Fork the Project
  2. Create your Feature Branch (git checkout -b feature/AmazingFeature)
  3. Commit (git commit -m 'Add some AmazingFeature')
  4. Push (git push origin feature/AmazingFeature)
  5. Open a Pull Request

(back to top)

License

Distributed under the MIT OR Apache-2.0 License. See LICENSE-MIT and LICENSE-APACHE.

(back to top)

Contact

Project Link: https://github.com/veelume/streamdeck-lib

(back to top)

Acknowledgments

(back to top)

About

A Rust library for building Elgato Stream Deck plugins

Topics

Resources

License

Unknown, MIT licenses found

Licenses found

Unknown
LICENSE-APACHE
MIT
LICENSE-MIT

Stars

Watchers

Forks