Skip to main content

tauri_plugin_midi/
lib.rs

1//! A WebMIDI-compatible plugin for Tauri
2//!
3//! Refer to the [init](fn.init.html) function for more information on how to use this plugin or checkout [the example](https://github.com/specta-rs/tauri-plugin-midi/tree/main/example).
4
5use std::{
6    collections::BTreeMap,
7    sync::{Arc, Mutex, PoisonError},
8    thread::spawn,
9    time::{Duration, Instant, SystemTime, UNIX_EPOCH},
10};
11
12use midir::{MidiInput, MidiOutput};
13use tauri::{
14    plugin::{Builder, TauriPlugin},
15    Emitter, Manager, Runtime,
16};
17use tokio::time::sleep_until;
18
19#[derive(Default)]
20struct MidiState {
21    input_connections: BTreeMap<String, midir::MidiInputConnection<()>>,
22    output_connections: BTreeMap<String, midir::MidiOutputConnection>,
23}
24
25type State = Arc<Mutex<MidiState>>;
26
27const PLUGIN_NAME: &str = "midi";
28const RUNTIME_POLYFILL: &str = include_str!("polyfill.js");
29
30fn get_inputs(midi_in: &midir::MidiInput) -> Result<Vec<(String, String)>, String> {
31    midi_in
32        .ports()
33        .iter()
34        .map(|p| {
35            Ok((
36                p.id(),
37                midi_in
38                    .port_name(p)
39                    .map_err(|e| format!("Failed to get port name: {e}"))?,
40            ))
41        })
42        .collect()
43}
44
45fn get_outputs(midi_out: &midir::MidiOutput) -> Result<Vec<(String, String)>, String> {
46    midi_out
47        .ports()
48        .iter()
49        .map(|p| {
50            Ok((
51                p.id(),
52                midi_out
53                    .port_name(p)
54                    .map_err(|e| format!("Failed to get port name: {e}"))?,
55            ))
56        })
57        .collect()
58}
59
60#[tauri::command(async)]
61#[cfg_attr(feature = "specta", specta::specta)]
62fn open_input<R: tauri::Runtime>(
63    app: tauri::AppHandle<R>,
64    state: tauri::State<State>,
65    id: String,
66) -> Result<(), String> {
67    let mut state = state.lock().unwrap_or_else(PoisonError::into_inner);
68
69    if state.input_connections.contains_key(&id) {
70        return Ok(());
71    }
72
73    let mut midi_in = MidiInput::new("").unwrap();
74    midi_in.ignore(midir::Ignore::None);
75
76    let ports = midi_in.ports();
77    let port = ports
78        .iter()
79        .find(|p| p.id() == id)
80        .ok_or_else(|| format!("Failed to find port by id '{id}'"))?;
81
82    let connection = midi_in
83        .connect(
84            &port,
85            "",
86            {
87                let id = id.clone();
88                move |_, msg, _| {
89                    let epoch = std::time::SystemTime::now()
90                        .duration_since(std::time::UNIX_EPOCH)
91                        .expect("System time is before epoch")
92                        .as_millis();
93
94                    if let Err(err) = app.emit(
95                        "plugin:midi:midi-message",
96                        MIDIMessage(id.to_string(), epoch.to_string(), msg.to_vec()),
97                    ) {
98                        eprintln!("Failed to emit MIDI message: {err}");
99                    }
100                }
101            },
102            (),
103        )
104        .map_err(|e| format!("Failed to open MIDI input to id '{id}': {e}"))?;
105
106    state.input_connections.insert(id, connection);
107
108    Ok(())
109}
110
111#[tauri::command(async)]
112#[cfg_attr(feature = "specta", specta::specta)]
113fn close_input(state: tauri::State<State>, id: String) {
114    let mut state = state.lock().unwrap_or_else(PoisonError::into_inner);
115
116    if let Some(connection) = state.input_connections.remove(&id) {
117        connection.close();
118    }
119}
120
121#[tauri::command(async)]
122#[cfg_attr(feature = "specta", specta::specta)]
123fn open_output(state: tauri::State<State>, id: String) -> Result<(), String> {
124    let mut state = state.lock().unwrap_or_else(PoisonError::into_inner);
125
126    if state.output_connections.contains_key(&id) {
127        return Ok(());
128    }
129
130    let midi_out = MidiOutput::new("").map_err(|e| format!("Failed to create MIDI output: {e}"))?;
131
132    let ports = midi_out.ports();
133    let port = ports
134        .iter()
135        .find(|p| p.id() == id)
136        .ok_or_else(|| format!("Failed to find port by id '{id}'"))?;
137
138    let connection = midi_out
139        .connect(&port, "")
140        .map_err(|e| format!("Failed to open MIDI output to id '{id}': {e}"))?;
141
142    state.output_connections.insert(id, connection);
143
144    Ok(())
145}
146
147#[tauri::command(async)]
148#[cfg_attr(feature = "specta", specta::specta)]
149fn close_output(state: tauri::State<State>, id: String) {
150    let mut state = state.lock().unwrap_or_else(PoisonError::into_inner);
151
152    if let Some(connection) = state.output_connections.remove(&id) {
153        connection.close();
154    }
155}
156
157#[tauri::command(async)]
158#[cfg_attr(feature = "specta", specta::specta)]
159fn output_send(
160    tstate: tauri::State<State>,
161    id: String,
162    msg: Vec<u8>,
163    timestamp: Option<String>,
164) -> Result<(), String> {
165    let timestamp = timestamp
166        .map(|s| {
167            s.parse::<u128>()
168                .map_err(|e| format!("Failed to parse timestamp: {e}"))
169        })
170        .transpose()?;
171
172    let mut state = tstate.lock().unwrap_or_else(PoisonError::into_inner);
173    let connection = state
174        .output_connections
175        .get_mut(&id)
176        .ok_or_else(|| format!("Failed to find output connection by name '{id}'"))?;
177
178    // TODO: Support this with OS timers properly: https://github.com/Boddlnagg/midir/issues/45
179    if let Some(timestamp) = timestamp {
180        drop(state);
181        let tstate = (*tstate).clone();
182        tauri::async_runtime::spawn(async move {
183            let current_epoch = SystemTime::now()
184                .duration_since(UNIX_EPOCH)
185                .expect("SystemTime is before epoch")
186                .as_millis();
187
188            let delay_ms = timestamp.saturating_sub(current_epoch);
189            let until = Instant::now() + Duration::from_millis(delay_ms as u64);
190            sleep_until(until.into()).await;
191
192            let mut state = tstate.lock().unwrap_or_else(PoisonError::into_inner);
193            if let Some(connection) = state.output_connections.get_mut(&id) {
194                connection.send(&msg).ok();
195            }
196        });
197    } else {
198        connection
199            .send(&msg)
200            .map_err(|err| format!("Failed to send MIDI message to port '{id}': {err}"))?;
201    }
202
203    Ok(())
204}
205
206#[derive(serde::Serialize, Clone, Debug)]
207#[cfg_attr(feature = "specta", derive(specta::Type, tauri_specta::Event))]
208struct StateChange {
209    inputs: Vec<(String, String)>,
210    outputs: Vec<(String, String)>,
211}
212
213#[derive(serde::Serialize, Clone)]
214#[cfg_attr(feature = "specta", derive(specta::Type, tauri_specta::Event))]
215struct MIDIMessage(String, String, Vec<u8>);
216
217/// Initialise the plugin which will take care of polyfilling WebMIDI into any Tauri webview.
218///
219/// # Usage
220///
221/// Using this plugin is very simple. Just add it to your Tauri builder:
222///
223/// ```rust
224///  tauri::Builder::default()
225///        .plugin(tauri_plugin_midi::init()) // <- This bit here
226/// # ;
227///        // .... rest of your builder
228/// ```
229///
230/// Then give permissions to the plugin by adding the `midi:default` permissions to your application.
231///
232/// This can be done by modifying the `capabilities/default.json` file:
233/// ```json
234/// {
235///   "$schema": "../gen/schemas/desktop-schema.json",
236///   "identifier": "default",
237///   "description": "Capability for the main window",
238///   "windows": ["main"],
239///   "permissions": ["core:default", "midi:default"] // <- add `midi:default` into here
240/// }
241/// ```
242///
243/// and now you can use the regular [WebMIDI API](https://developer.mozilla.org/en-US/docs/Web/API/Web_MIDI_API) from within your webview.
244///
245/// The polyfill is injected into every frame (not just the main frame) so that it can be
246/// used from within iframes. Tauri itself only injects `__TAURI_INTERNALS__` and the IPC
247/// bridge into the main frame (and, on Windows, into subframes too), so in a same-origin
248/// subframe on macOS/Linux the polyfill bridges its IPC calls to the top frame over
249/// `postMessage`. See [issue #7](https://github.com/specta-rs/tauri-plugin-midi/issues/7).
250pub fn init<R: Runtime>() -> TauriPlugin<R> {
251    // Tauri did a breaking change in 2.7.0 so we do this outside to ensure backwards compatibility
252    let polyfill: String = RUNTIME_POLYFILL.into();
253
254    Builder::new(PLUGIN_NAME)
255        .invoke_handler(tauri::generate_handler![
256            open_input,
257            close_input,
258            open_output,
259            close_output,
260            output_send
261        ])
262        // Inject into all frames, not just the main frame. The polyfill is frame-aware: it
263        // only activates in the top frame or in a same-origin child frame of it. Tauri's own
264        // `__TAURI_INTERNALS__`/IPC scripts remain main-frame-only on macOS and Linux, which is
265        // why subframes need the `postMessage` bridge rather than the IPC bridge directly.
266        .js_init_script_on_all_frames(polyfill)
267        .setup(move |app, _| {
268            app.manage(State::default());
269
270            let app = app.clone();
271
272            spawn(move || loop {
273                // Creating a MIDI client can fail transiently (e.g. the OS is
274                // still tearing down a previous client). Never let that kill
275                // device detection for the rest of the process - log it and
276                // retry instead.
277                let midi_in = match midir::MidiInput::new("tauri-plugin-midi blank input") {
278                    Ok(midi_in) => midi_in,
279                    Err(e) => {
280                        eprintln!("Failed to create MIDI input: {e}");
281                        std::thread::sleep(Duration::from_millis(1000));
282                        continue;
283                    }
284                };
285                let midi_out = match midir::MidiOutput::new("tauri-plugin-midi blank output") {
286                    Ok(midi_out) => midi_out,
287                    Err(e) => {
288                        eprintln!("Failed to create MIDI output: {e}");
289                        std::thread::sleep(Duration::from_millis(1000));
290                        continue;
291                    }
292                };
293
294                loop {
295                    let state_change = StateChange {
296                        inputs: get_inputs(&midi_in).unwrap_or_default(),
297                        outputs: get_outputs(&midi_out).unwrap_or_default(),
298                    };
299                    if let Err(err) = app.emit("plugin:midi:state-change", state_change) {
300                        eprintln!("Failed to emit MIDI state change: {err}");
301                    }
302
303                    std::thread::sleep(Duration::from_millis(1000));
304                }
305            });
306
307            Ok(())
308        })
309        .build()
310}
311
312#[cfg(all(test, feature = "specta"))]
313mod test {
314    use super::*;
315
316    #[test]
317    fn export_types() {
318        tauri_specta::Builder::<tauri::Wry>::new()
319            .plugin_name(PLUGIN_NAME)
320            .commands(tauri_specta::collect_commands![
321                open_input::<tauri::Wry>,
322                close_input,
323                open_output,
324                close_output,
325                output_send
326            ])
327            .events(tauri_specta::collect_events![StateChange, MIDIMessage])
328            .error_handling(tauri_specta::ErrorHandlingMode::Throw)
329            .export(
330                specta_typescript::Typescript::default(),
331                "./guest-js/bindings.ts",
332            )
333            .unwrap();
334    }
335}