From d16a9a64948962bf416ace6b1f98871a0ca2b6ba Mon Sep 17 00:00:00 2001 From: Michael Aaron Murphy Date: Fri, 20 Aug 2021 21:15:13 +0200 Subject: [PATCH] feat(pulse): Convert from GJS to Rust --- Cargo.lock | 11 ++ Makefile | 2 +- bin/src/main.rs | 1 + plugins/Cargo.toml | 1 + plugins/src/lib.rs | 1 + plugins/src/pulse/mod.rs | 164 ++++++++++++++++++++++++++ plugins/src/pulse/plugin.ron | 2 +- plugins/src/pulse/pulse.js | 216 ----------------------------------- 8 files changed, 180 insertions(+), 218 deletions(-) create mode 100644 plugins/src/pulse/mod.rs delete mode 100755 plugins/src/pulse/pulse.js diff --git a/Cargo.lock b/Cargo.lock index f1c4750..7d7e66f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -110,6 +110,16 @@ dependencies = [ "futures-micro", ] +[[package]] +name = "async-pidfd" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12177058299bb8e3507695941b6d0d7dc0e4e6515b8bc1bf4609d9e32ef51799" +dependencies = [ + "async-io", + "libc", +] + [[package]] name = "async-process" version = "1.2.0" @@ -901,6 +911,7 @@ dependencies = [ name = "pop-launcher-plugins" version = "1.0.0" dependencies = [ + "async-pidfd", "fork", "freedesktop-desktop-entry", "futures-lite", diff --git a/Makefile b/Makefile index 4a9e0ea..d876ed2 100644 --- a/Makefile +++ b/Makefile @@ -84,7 +84,7 @@ install: install -Dm0755 plugins/src/recent/recent.js $(PLUGIN_DIR)/recent # Pulse plugin - install -Dm0755 plugins/src/pulse/pulse.js $(PLUGIN_DIR)/pulse + ln -sf $(BIN) $(PLUGIN_DIR)/pulse/pulse # Terminal plugin install -Dm0755 plugins/src/terminal/terminal.js $(PLUGIN_DIR)/terminal diff --git a/bin/src/main.rs b/bin/src/main.rs index b21abc4..b07ff6e 100644 --- a/bin/src/main.rs +++ b/bin/src/main.rs @@ -21,6 +21,7 @@ fn main() { "files" => block_on(plugins::files::main()), "pop-launcher" => block_on(service::main()), "pop-shell" => block_on(plugins::pop_shell::main()), + "pulse" => block_on(plugins::pulse::main()), "scripts" => block_on(plugins::scripts::main()), "web" => block_on(plugins::web::main()), unknown => { diff --git a/plugins/Cargo.toml b/plugins/Cargo.toml index 10506d2..03c472b 100644 --- a/plugins/Cargo.toml +++ b/plugins/Cargo.toml @@ -27,3 +27,4 @@ zbus = "1" zvariant = "=2.6" # Restrict for 1.47 human-sort = "0.2.2" human_format = "1.0.3" +async-pidfd = "0.1.4" diff --git a/plugins/src/lib.rs b/plugins/src/lib.rs index 8f17e86..d26fdf4 100644 --- a/plugins/src/lib.rs +++ b/plugins/src/lib.rs @@ -3,6 +3,7 @@ pub mod desktop_entries; pub mod files; pub mod find; pub mod pop_shell; +pub mod pulse; pub mod scripts; pub mod web; diff --git a/plugins/src/pulse/mod.rs b/plugins/src/pulse/mod.rs new file mode 100644 index 0000000..8a679ed --- /dev/null +++ b/plugins/src/pulse/mod.rs @@ -0,0 +1,164 @@ +use async_pidfd::AsyncPidFd; +use futures_lite::prelude::*; +use pop_launcher::*; +use smol::Unblock; +use std::io; + +struct Selection { + pub id: u32, + pub name: String, + pub description: String, +} + +pub struct App { + selections: Vec, + out: Unblock, +} + +impl Default for App { + fn default() -> Self { + Self { + out: async_stdout(), + selections: vec![ + Selection { + id: 0, + name: "Toggle Mute".into(), + description: "Silence and unsilence the default audio sink".into(), + }, + Selection { + id: 1, + name: "Volume Up".into(), + description: "Raise volume 5%".into(), + }, + Selection { + id: 2, + name: "Volume Down".into(), + description: "Lower volume 5%".into(), + }, + ], + } + } +} + +pub async fn main() { + let mut requests = json_input_stream(async_stdin()); + + let mut app = App::default(); + + while let Some(result) = requests.next().await { + match result { + Ok(request) => match request { + Request::Activate(id) => app.activate(id).await, + Request::Search(query) => app.search(query).await, + Request::Exit => break, + _ => (), + }, + Err(why) => { + tracing::error!("malformed JSON input: {}", why); + } + } + } +} + +impl App { + async fn activate(&mut self, id: u32) { + let (cmd, arg1, arg2) = match id { + 0 => ("pactl", "set-sink-mute", "toggle"), + 1 => ("pactl", "set-sink-volume", "+5%"), + 2 => ("pactl", "set-sink-volume", "-5%"), + _ => return, + }; + + let mut handles = Vec::new(); + + let mut sinks = pactl_sinks(); + + use postage::prelude::Stream; + while let Some(id) = sinks.recv().await { + handles.push(smol::spawn(async move { + let args = &[arg1, id.as_str(), arg2]; + let _ = command_spawn(cmd, args).await; + })); + } + + for handle in handles { + let _ = handle.await; + } + } + + async fn search(&mut self, query: String) { + if !query.is_empty() { + for selection in filter(&self.selections, &query.to_ascii_lowercase()) { + crate::send( + &mut self.out, + PluginResponse::Append(PluginSearchResult { + id: selection.id, + name: selection.name.clone(), + description: selection.description.clone(), + ..Default::default() + }), + ) + .await; + } + } + + crate::send(&mut self.out, PluginResponse::Finished).await; + } +} + +fn filter<'a>( + selections: &'a [Selection], + query: &'a str, +) -> impl Iterator + 'a { + selections.iter().filter_map(move |selection| { + if selection.name.to_ascii_lowercase().contains(query) + || selection.description.to_ascii_lowercase().contains(query) + { + Some(selection) + } else { + None + } + }) +} + +async fn command_spawn(cmd: &str, args: &[&str]) -> io::Result<()> { + use std::process::{Command, Stdio}; + + let child = Command::new(cmd) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .args(args) + .spawn()?; + + AsyncPidFd::from_pid(child.id() as i32)?.wait().await; + + Ok(()) +} + +fn pactl_sinks() -> postage::mpsc::Receiver { + let (mut tx, rx) = postage::mpsc::channel(4); + + smol::spawn(async move { + let child = smol::process::Command::new("pactl") + .env("LANG", "C") + .args(&["list", "sinks"]) + .stdout(smol::process::Stdio::piped()) + .spawn(); + + if let Ok(mut child) = child { + if let Some(stdout) = child.stdout.take() { + let mut lines = futures_lite::io::BufReader::new(stdout).lines(); + while let Some(Ok(line)) = lines.next().await { + if let Some(stripped) = line.strip_prefix("Sink #") { + use postage::prelude::Sink; + tx.send(stripped.trim().to_owned()).await; + } + } + } + } + }) + .detach(); + + rx +} diff --git a/plugins/src/pulse/plugin.ron b/plugins/src/pulse/plugin.ron index b5f02f2..1b70c5c 100644 --- a/plugins/src/pulse/plugin.ron +++ b/plugins/src/pulse/plugin.ron @@ -1,6 +1,6 @@ ( name: "PulseAudio Volume Control", description: "Control PulseAudio devices and volume", - bin: (path: "pulse.js"), + bin: (path: "pulse"), icon: Name("multimedia-volume-control") ) \ No newline at end of file diff --git a/plugins/src/pulse/pulse.js b/plugins/src/pulse/pulse.js deleted file mode 100755 index b73921d..0000000 --- a/plugins/src/pulse/pulse.js +++ /dev/null @@ -1,216 +0,0 @@ -#!/usr/bin/gjs - -const { GLib, Gio } = imports.gi; - -const STDIN = new Gio.DataInputStream({ base_stream: new Gio.UnixInputStream({ fd: 0 }) }) -const STDOUT = new Gio.DataOutputStream({ base_stream: new Gio.UnixOutputStream({ fd: 1 }) }) - -/** - * @typedef {Object} Sink - * @property {number} id - * @property {string} description - */ - -/** - * @returns {null | Array} - */ -function pactl_sinks() { - try { - const resp = async_process(["pactl", "list", "sinks"]) - if (!resp) return null - - const { proc, stdout } = resp - - let sinks = new Array() - let sink = {} - - while (true) { - const [bytes] = stdout.read_line(null) - if (bytes === null) break - - const line = imports.byteArray.toString(bytes) - if (line.startsWith("Sink")) { - sink.id = line.substr(6) - } else if (line.includes("Description:")) { - sink.description = line.split(' ').slice(1).join(' ') - sinks.push({ ...sink }) - } - } - - return sinks - } catch (e) { - log(`error: ${e}`) - return null - } -} - -class App { - constructor() { - this.last_query = "" - this.shell_only = false - - this.default_selections = [ - { - id: 0, - name: "Toggle Mute", - description: "Silence and unsilence the default audio sink", - }, - - { - id: 1, - name: "Volume Up", - description: "Raise volume 5%" - }, - - { - id: 2, - name: "Volume Down", - description: "Lower volume 5%" - } - ] - } - - query(input) { - const selections = filter_selections(this.default_selections, input.toLowerCase()); - for (const selection of selections) { - this.send({ "Append": selection}) - } - - this.send("Finished") - } - - submit(id) { - let cmd = null - - let sinks = pactl_sinks() - - switch (id) { - case 0: - cmd = ["pactl set-sink-mute", "toggle"] - break - case 1: - cmd = ["pactl set-sink-volume", "+5%"] - break - case 2: - cmd = ["pactl set-sink-volume", "-5%"] - } - - if (cmd) { - try { - for (const { id } of sinks) { - GLib.spawn_command_line_async(`${cmd[0]} ${id} ${cmd[1]}`) - } - - } catch (e) { - log(`session command '${cmd}' failed: ${e}`) - } - } - } - - send(object) { - STDOUT.write_bytes(new GLib.Bytes(JSON.stringify(object) + "\n"), null) - } -} - -function main() { - /** @type {null | ByteArray} */ - let input_array - - /** @type {string} */ - let input_str - - /** @type {null | LauncherRequest} */ - let event - - let app = new App() - - mainloop: - while (true) { - try { - [input_array,] = STDIN.read_line(null) - } catch (e) { - break - } - - input_str = imports.byteArray.toString(input_array) - if ((event = parse_event(input_str)) !== null) { - if ("Exit" === event) { - break mainloop - } else if ("Search" in event) { - app.query(event.Search) - } else if ("Activate" in event) { - app.submit(event.Activate) - } - } - } -} - -/** - * Parses an IPC event received from STDIN - * @param {string} input - * @returns {null | LauncherRequest} - */ -function parse_event(input) { - try { - return JSON.parse(input) - } catch (e) { - log(`Input not valid JSON`) - return null - } -} - -/** - * - * @param {Array} argv - * @returns {null | Process} - */ -function async_process(argv) { - const { DataInputStream, SubprocessFlags, SubprocessLauncher } = Gio - - try { - const launcher = new SubprocessLauncher({ - flags: SubprocessFlags.STDIN_PIPE - | SubprocessFlags.STDOUT_PIPE - }) - - const proc = launcher.spawnv(argv) - let stdout = new DataInputStream({ - base_stream: proc.get_stdout_pipe(), - close_base_stream: true - }) - - return { proc, stdout } - } catch (e) { - log(`failed to spawn process: ${argv}\n\tCaused by: ${e}`) - return null - } -} - -function filter_selections(initial, input) { - if (input.length === 0) return [] - let selections = initial.map(v => ({ ...v })) - - let remove = new Array() - for (let id = 0; id < selections.length; id += 1) { - const { name, description } = selections[id] - if (name.toLowerCase().includes(input) || description.toLowerCase().includes(input)) continue - remove.push(id) - } - - for (const id of remove.reverse()) swap_remove(selections, id) - - return selections -} - -/** - * - * @param {Array} array - * @param {number} index - * @returns {T | undefined} - */ -function swap_remove(array, index) { - array[index] = array[array.length - 1]; - return array.pop(); -} - -main() \ No newline at end of file