feat(pulse): Convert from GJS to Rust

This commit is contained in:
Michael Aaron Murphy 2021-08-20 21:15:13 +02:00
parent d6e93877ad
commit d16a9a6494
8 changed files with 180 additions and 218 deletions

11
Cargo.lock generated
View file

@ -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",

View file

@ -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

View file

@ -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 => {

View file

@ -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"

View file

@ -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;

164
plugins/src/pulse/mod.rs Normal file
View file

@ -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<Selection>,
out: Unblock<io::Stdout>,
}
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<Item = &'a Selection> + '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<String> {
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
}

View file

@ -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")
)

View file

@ -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<Sink>}
*/
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<string>} 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<T>} array
* @param {number} index
* @returns {T | undefined}
*/
function swap_remove(array, index) {
array[index] = array[array.length - 1];
return array.pop();
}
main()