feat(pulse): Convert from GJS to Rust
This commit is contained in:
parent
d6e93877ad
commit
d16a9a6494
8 changed files with 180 additions and 218 deletions
11
Cargo.lock
generated
11
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
2
Makefile
2
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
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
164
plugins/src/pulse/mod.rs
Normal 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
|
||||
}
|
||||
|
|
@ -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")
|
||||
)
|
||||
|
|
@ -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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue