Initial Release

This commit is contained in:
Michael Aaron Murphy 2021-08-10 01:04:20 +02:00
commit 8b3b95aae8
54 changed files with 5601 additions and 0 deletions

11
plugins/src/lib.rs Normal file
View file

@ -0,0 +1,11 @@
use futures_lite::{AsyncWrite, AsyncWriteExt};
use pop_launcher::PluginResponse;
pub async fn send<W: AsyncWrite + Unpin>(tx: &mut W, response: PluginResponse) {
if let Ok(mut bytes) = serde_json::to_string(&response) {
bytes.push('\n');
let _ = tx.write(bytes.as_bytes()).await;
let _ = tx.flush().await;
}
}

26
plugins/src/main.rs Normal file
View file

@ -0,0 +1,26 @@
mod plugins;
use smol::block_on;
use std::io;
fn main() {
tracing_subscriber::fmt()
.with_writer(io::stderr)
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init();
std::env::args();
if let Some(plugin) = std::env::args().next() {
let start = plugin.rfind('/').map(|v| v + 1).unwrap_or(0);
match &plugin.as_str()[start..] {
"desktop-entries" => block_on(plugins::desktop_entries::main()),
"pop-shell" => block_on(plugins::pop_shell::main()),
"find" => block_on(plugins::find::main()),
"scripts" => block_on(plugins::scripts::main()),
unknown => {
eprintln!("unknown cmd: {}", unknown);
}
}
}
}

View file

@ -0,0 +1,99 @@
#!/usr/bin/gjs
const { GLib, Gio } = imports.gi;
/** The directory that this script is executed from. */
const SCRIPT_DIR = GLib.path_get_dirname(new Error().stack.split(':')[0].slice(1));
/** Add our directory so we can import modules from it. */
imports.searchPath.push(SCRIPT_DIR)
const math = imports.math.math;
math.config({number: 'BigNumber' });
const STDIN = new Gio.DataInputStream({ base_stream: new Gio.UnixInputStream({ fd: 0 }) })
const STDOUT = new Gio.DataOutputStream({ base_stream: new Gio.UnixOutputStream({ fd: 1 }) })
class App {
constructor() {
this.last_query = ""
this.last_value = ""
}
search(input) {
this.last_query = input.substr(1)
try {
this.last_value = math.evaluate(this.last_query).toString()
} catch (e) {
this.last_value = this.last_query + ` x = ?`
}
this.send({ "Append": {
id: 0,
name: this.last_value,
description: '',
icon: { Name: 'accessories-calculator' },
}})
this.send("Finished")
}
activate(_id) {
this.send({ "Fill": '= ' + this.last_value })
}
send(object) {
STDOUT.write_bytes(new GLib.Bytes(JSON.stringify(object) + "\n"), null)
STDOUT.flush(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 ("Search" in event) {
app.search(event.Search);
} else if ("Activate" in event) {
app.activate(event.Activate);
} else if ("Exit" === event) {
break mainloop
}
}
}
}
/**
* 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
}
}
main()

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,11 @@
(
name: "Calculator",
description: "Math.JS calculations",
query: (
regex: "^(=)+",
help: "= ",
isolate: true,
),
bin: (path: "calc.js"),
icon: Name("x-office-spreadsheet")
)

View file

@ -0,0 +1,216 @@
use freedesktop_desktop_entry::{default_paths, DesktopEntry, Iter as DesktopIter, PathSource};
use futures_lite::{AsyncWrite, StreamExt};
use pop_launcher::*;
use pop_launcher_plugins::*;
use std::borrow::Cow;
use std::hash::{Hash, Hasher};
use std::path::PathBuf;
#[derive(Debug, Eq)]
struct Item {
appid: String,
description: String,
exec: String,
icon: Option<String>,
keywords: Option<Vec<String>>,
name: String,
path: PathBuf,
prefers_non_default_gpu: bool,
src: PathSource,
terminal_command: bool,
}
impl Hash for Item {
fn hash<H: Hasher>(&self, state: &mut H) {
self.name.hash(state);
self.src.hash(state);
}
}
impl PartialEq for Item {
fn eq(&self, other: &Self) -> bool {
self.name == other.name && self.src == other.src
}
}
pub async fn main() {
let mut app = DesktopEntryPlugin::new(async_stdout());
app.reload().await;
let mut requests = json_input_stream(async_stdin());
while let Some(result) = requests.next().await {
match result {
Ok(request) => {
tracing::debug!("received request: {:?}", 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 request: {}", why);
}
}
}
}
struct DesktopEntryPlugin<W> {
entries: Vec<Item>,
locale: Option<String>,
tx: W,
}
impl<W: AsyncWrite + Unpin> DesktopEntryPlugin<W> {
fn new(tx: W) -> Self {
let lang = std::env::var("LANG").ok();
Self {
entries: Vec::new(),
locale: lang
.as_ref()
.and_then(|l| l.split('.').next())
.map(String::from),
tx,
}
}
async fn reload(&mut self) {
self.entries.clear();
let locale = self.locale.as_ref().map(String::as_ref);
let mut deduplicator = std::collections::HashSet::new();
let current = current_desktop();
let current = current
.as_ref()
.map(|x| x.split(':').collect::<Vec<&str>>());
for (src, path) in DesktopIter::new(default_paths()) {
if let Ok(bytes) = std::fs::read_to_string(&path) {
if let Ok(entry) = DesktopEntry::decode(&path, &bytes) {
if entry.no_display() {
let matched = current
.as_ref()
.zip(entry.only_show_in())
.map(|(current, desktops)| {
!desktops
.to_ascii_lowercase()
.split(';')
.any(|desktop| current.iter().any(|c| *c == desktop))
})
.unwrap_or(false);
if matched {
continue;
}
}
if let Some((name, exec)) = entry.name(locale).zip(entry.exec()) {
if let Some(exec) = exec.split_ascii_whitespace().next() {
let item = Item {
appid: entry.appid.to_owned(),
name: name.to_owned(),
description: entry.comment(locale).unwrap_or("").to_owned(),
keywords: entry.keywords().map(|keywords| {
keywords.split(';').map(String::from).collect()
}),
icon: entry.icon().map(|x| x.to_owned()),
exec: exec.to_owned(),
path: path.clone(),
terminal_command: entry.terminal(),
prefers_non_default_gpu: entry.prefers_non_default_gpu(),
src,
};
deduplicator.insert(item);
}
}
}
}
}
self.entries.extend(deduplicator)
}
async fn activate(&mut self, id: u32) {
tracing::debug!("activate {} from {:?}", id, self.entries);
if let Some(entry) = self.entries.get(id as usize) {
let response = PluginResponse::DesktopEntry(entry.path.clone());
send(&mut self.tx, response).await;
}
}
async fn search(&mut self, query: &str) {
let query = query.to_ascii_lowercase();
let &mut Self {
ref entries,
ref mut tx,
..
} = self;
let mut items = Vec::with_capacity(16);
for (id, entry) in entries.iter().enumerate() {
items.extend(entry.name.split_ascii_whitespace());
if let Some(keywords) = entry.keywords.as_ref() {
items.extend(keywords.iter().map(String::as_str));
}
items.push(entry.exec.as_str());
for search_interest in items.drain(..) {
let search_interest = search_interest.to_ascii_lowercase();
let append = search_interest.starts_with(&*query)
|| search_interest.contains(&*query)
|| strsim::damerau_levenshtein(&*query, &*search_interest) < 3;
if append {
let response = PluginResponse::Append(SearchMeta {
id: id as u32,
name: entry.name.clone(),
description: format!("{} - {}", path_string(&entry.src), entry.description),
keywords: entry.keywords.clone(),
icon: entry.icon.clone().map(Cow::Owned).map(IconSource::Name),
exec: Some(entry.exec.clone()),
..Default::default()
});
send(tx, response).await;
break;
}
}
}
send(tx, PluginResponse::Finished).await;
}
}
fn current_desktop() -> Option<String> {
std::env::var("XDG_CURRENT_DESKTOP").ok().map(|x| {
let x = x.to_ascii_lowercase();
if x == "unity" {
"gnome".to_owned()
} else {
x
}
})
}
fn path_string(source: &PathSource) -> Cow<'static, str> {
match source {
PathSource::Local | PathSource::LocalDesktop => "Local".into(),
PathSource::LocalFlatpak => "Flatpak".into(),
PathSource::System => "System".into(),
PathSource::SystemFlatpak => "Flatpak (System)".into(),
PathSource::SystemSnap => "Snap (System)".into(),
PathSource::Other(other) => Cow::Owned(other.clone()),
}
}

View file

@ -0,0 +1,6 @@
(
name: "Desktop Entries",
description: "Query applications by their .desktop entries",
bin: (path: "desktop-entries"),
icon: Name("new-window-symbolic"),
)

View file

@ -0,0 +1,225 @@
#!/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 }) })
class App {
constructor() {
/** @type Array<Selection> */
this.selections = new Array()
/** @type string */
this.parent = ""
/** @type string */
this.last_query = ""
}
/**
* Performs tab completion based on the last-given search query.
*
* @param {number} id
*/
complete(id) {
let text
const selected = this.selections[id]
if (selected) {
text = selection_path(this.parent, selected)
} else {
text = this.last_query
}
this.send({ "Fill": text })
}
/**
* Queries the plugin for results from this input
*
* @param {string} input
*/
search(input) {
if (input.startsWith('~')) {
input = GLib.get_home_dir() + input.substr(1)
}
this.last_query = input
// Add `/` to query if the input is a directory
this.last_query = (!input.endsWith('/') && Gio.file_new_for_path(input).query_file_type(0, null) === 2)
? input + '/'
: input
this.selections.splice(0)
this.parent = GLib.path_get_dirname(this.last_query)
/** @type string */
let base = GLib.path_get_basename(this.last_query)
const show_hidden = base.startsWith('.')
if (this.parent.endsWith(base)) base = ""
try {
const dir = Gio.file_new_for_path(this.parent)
if (dir.query_exists(null)) {
const entries = dir.enumerate_children('standard::*', Gio.FileQueryInfoFlags.NONE, null);
let entry;
while ((entry = entries.next_file(null)) !== null) {
/** @type {string} */
const name = entry.get_name()
if (base.length !== 0 && name.toLowerCase().indexOf(base.toLowerCase()) === -1) {
continue
}
if (!show_hidden && name.startsWith('.')) continue
const content_type = entry.get_content_type()
const directory = entry.get_file_type() === 2
this.selections.push({
id: 0,
name,
description: GLib.format_size_for_display(entry.get_size()),
content_type,
directory
})
if (this.selections.length === 20) break
}
}
const pattern_lower = this.last_query.toLowerCase()
this.selections
.sort((a, b) => {
const a_name = a.name.toLowerCase()
const b_name = b.name.toLowerCase()
const a_includes = a_name.includes(pattern_lower)
const b_includes = b_name.includes(pattern_lower)
return ((a_includes && b_includes) || (!a_includes && !b_includes)) ? (a_name > b_name ? 1 : 0) : a_includes ? -1 : b_includes ? 1 : 0;
})
let id = 0
for (const v of this.selections) {
v.id = id
id += 1
}
} catch (e) {
log(`QUERY ERROR: ${e} `)
}
for (const selection of this.selections) {
this.send({ "Append": {
id: selection.id,
name: selection.name,
description: selection.description,
icon: { "Mime": selection.content_type }
}})
}
this.send("Finished")
}
/**
* Applies an option that the user selected
*
* @param {number} id
*/
activate(id) {
const selected = this.selections[id]
if (selected) {
const path = selection_path(this.parent, selected)
try {
GLib.spawn_command_line_async(`xdg-open '${path}'`)
} catch (e) {
log(`xdg-open failed: ${e} `)
}
}
this.send("Close")
}
/**
* Sends message back to Pop Shell
*
* @param {Object} object
*/
send(object) {
STDOUT.write_bytes(new GLib.Bytes(JSON.stringify(object) + "\n"), null)
}
}
/**
*
* @param {string} parent
* @param {Selection} selection
* @returns {string}
*/
function selection_path(parent, selection) {
let text = parent
+ (parent.endsWith("/") ? "" : "/")
+ selection.name
if (selection.directory) text += "/"
return text
}
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 ("Search" in event) {
app.search(event.Search)
} else if ("Activate" in event) {
app.activate(event.Activate)
} else if ("Complete" in event) {
app.complete(event.Complete)
} else if ("Exit" === event) {
break mainloop
}
}
}
}
/**
* 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
}
}
main()

View file

@ -0,0 +1,12 @@
(
name: "File Navigation",
description: "Navigate with tab autocomplete",
query: (
regex: "^(/|~).*",
help: "~/",
isolate: true,
no_sort: true,
),
bin: (path: "files.js"),
icon: Name("system-file-manager")
)

View file

@ -0,0 +1,211 @@
use futures_lite::*;
use pop_launcher::*;
use pop_launcher_plugins::send;
use smol::process::{ChildStdout, Command, Stdio};
use std::borrow::Cow;
use std::cell::Cell;
use std::io;
use std::path::{Path, PathBuf};
use std::rc::Rc;
enum Event {
Activate(u32),
Search(String),
}
pub async fn main() {
let (event_tx, event_rx) = flume::unbounded::<Event>();
// Channel for cancelling searches that are in progress.
let (interrupt_tx, interrupt_rx) = flume::bounded::<()>(0);
// Indicates if a search is being performed in the background.
let active = Rc::new(Cell::new(false));
let mut app = SearchContext {
search_results: Vec::with_capacity(128),
active: active.clone(),
interrupt_rx,
out: async_stdout(),
};
// Manages the external process, tracks search results, and executes activate requests
let search_handler = async move {
while let Ok(search) = event_rx.recv_async().await {
match search {
Event::Activate(id) => {
if let Some(selection) = app.search_results.get(id as usize) {
let path = selection.clone();
let handle = smol::spawn(async move {
xdg_open(&path).await;
});
handle.detach();
send(&mut app.out, PluginResponse::Close).await;
}
}
Event::Search(search) => app.search(search).await,
}
}
};
// Forwards requests to the search handler, and performs an interrupt as necessary.
let request_handler = async move {
let interrupt = || async {
if active.get() && !interrupt_tx.is_full() {
tracing::debug!("sending interrupt");
let _ = interrupt_tx.send_async(()).await;
}
};
let mut requests = json_input_stream(async_stdin());
while let Some(result) = requests.next().await {
match result {
Ok(request) => match request {
// Launch the default application with the selected file
Request::Activate(id) => {
event_tx.send_async(Event::Activate(id)).await?;
}
// Interrupt any active searches being performed
Request::Interrupt => interrupt().await,
// Schedule a new search process to be launched
Request::Search(query) => {
interrupt().await;
let query = match query.find(' ') {
Some(pos) => query[pos..].trim_start(),
None => &query,
};
event_tx.send_async(Event::Search(query.to_owned())).await?;
active.set(true);
}
_ => (),
},
Err(why) => {
tracing::error!("malformed JSON input: {}", why);
}
}
}
Ok::<(), flume::SendError<Event>>(())
};
let _ = future::zip(request_handler, search_handler).await;
}
/// Maintains state for search requests
struct SearchContext {
pub active: Rc<Cell<bool>>,
pub interrupt_rx: flume::Receiver<()>,
pub out: smol::Unblock<io::Stdout>,
pub search_results: Vec<PathBuf>,
}
impl SearchContext {
/// Appends a new search result to the context.
async fn append(&mut self, id: u32, line: String) {
let name = line
.rfind('/')
.map(|pos| line[pos + 1..].to_owned())
.unwrap_or_else(|| line.clone());
let description = ["~/", line.as_str()].concat();
let path = PathBuf::from(line);
let response = PluginResponse::Append(SearchMeta {
id,
description,
name,
icon: Some(IconSource::Mime(if path.is_dir() {
Cow::Borrowed("inode/directory")
} else if let Some(guess) = new_mime_guess::from_path(&path).first() {
Cow::Owned(guess.essence_str().to_owned())
} else {
Cow::Borrowed("text/plain")
})),
..Default::default()
});
send(&mut self.out, response).await;
self.search_results.push(path);
}
/// Submits the query to `fdfind` and actively monitors the search results while handling interrupts.
async fn search(&mut self, search: String) {
tracing::debug!("searching for {}", search);
let mut stdout = match query(&search).await {
Ok(stdout) => futures_lite::io::BufReader::new(stdout).lines(),
Err(why) => {
tracing::error!("failed to spawn fdfind process: {}", why);
self.active.set(false);
return;
}
};
self.search_results.clear();
let mut id = 0;
let mut append;
'stream: loop {
let interrupt = async {
let _ = self.interrupt_rx.recv_async().await;
None
};
match interrupt.or(stdout.next()).await {
Some(result) => match result {
Ok(line) => append = line,
Err(why) => {
tracing::error!("error on stdout line read: {}", why);
break 'stream;
}
},
None => break 'stream,
}
self.append(id, append).await;
id += 1;
if id == 10 {
break 'stream;
}
}
send(&mut self.out, PluginResponse::Finished).await;
}
}
/// Submits the search query to `fdfind`, and returns its stdout pipe.
async fn query(arg: &str) -> io::Result<ChildStdout> {
let mut child = Command::new("fdfind")
.arg(arg)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()?;
match child.stdout.take() {
Some(stdout) => Ok(stdout),
None => Err(io::Error::new(
io::ErrorKind::BrokenPipe,
"stdout pipe is missing",
)),
}
}
/// Launches a file with its default appplication via `xdg-open`.
async fn xdg_open(file: &Path) {
let _ = Command::new("xdg-open").arg(file).spawn();
}

View file

@ -0,0 +1,11 @@
(
name: "Find",
description: "Find files in the home folder",
query: (
regex: "^(find )+",
help: "find ",
isolate: true,
),
bin: (path: "find"),
icon: Name("system-file-manager")
)

View file

@ -0,0 +1,4 @@
pub mod desktop_entries;
pub mod find;
pub mod pop_shell;
pub mod scripts;

View file

@ -0,0 +1,124 @@
use futures_lite::{AsyncWrite, AsyncWriteExt, StreamExt};
use pop_launcher::*;
use pop_launcher_plugins::*;
use serde::{Deserialize, Serialize};
use std::convert::TryFrom;
use zbus::Connection;
use zvariant::{Signature, Type};
const DEST: &str = "com.System76.PopShell";
const PATH: &str = "/com/System76/PopShell";
#[derive(Debug, Deserialize)]
struct Item {
entity: (u32, u32),
name: String,
description: String,
}
impl Type for Item {
fn signature() -> Signature<'static> {
Signature::try_from("((uu)ss)").expect("bad dbus signature")
}
}
pub async fn main() {
let connection = match Connection::new_session() {
Ok(conn) => conn,
Err(_) => return,
};
let mut app = App::new(connection, async_stdout());
app.reload().await;
let mut requests = json_input_stream(async_stdin());
while let Some(request) = requests.next().await {
match request {
Ok(request) => match request {
Request::Activate(id) => app.activate(id).await,
Request::Complete(_) | Request::Interrupt => (),
Request::Quit(_id) => (),
Request::Search(query) => app.search(&query).await,
Request::Exit => break,
},
Err(why) => {
tracing::error!("malformed JSON request: {}", why);
}
}
}
}
struct App<W> {
entries: Vec<Item>,
connection: Connection,
tx: W,
}
impl<W: AsyncWrite + Unpin> App<W> {
fn new(connection: Connection, tx: W) -> Self {
Self {
entries: Vec::new(),
connection,
tx,
}
}
fn call_method<A: Serialize + Type>(
&mut self,
method: &str,
args: &A,
) -> zbus::Result<zbus::Message> {
self.connection
.call_method(Some(DEST), PATH, Some(DEST), method, args)
}
async fn reload(&mut self) {
if let Ok(message) = self.call_method("WindowList", &()) {
self.entries = message
.body::<Vec<Item>>()
.expect("pop-shell returned invalid WindowList response");
}
}
async fn activate(&mut self, id: u32) {
if let Some(id) = self.entries.get(id as usize) {
let entity = id.entity;
let _ = self.call_method("WindowFocus", &(entity,));
}
}
async fn search(&mut self, query: &str) {
let query = query.to_ascii_lowercase();
let haystack = query.split_ascii_whitespace().collect::<Vec<&str>>();
fn contains_pattern(needle: &str, haystack: &[&str]) -> bool {
let needle = needle.to_ascii_lowercase();
haystack.iter().all(|h| needle.contains(h))
}
for (id, item) in self.entries.iter().enumerate() {
let retain = contains_pattern(&item.name, &haystack)
|| contains_pattern(&item.description, &haystack);
if !retain {
continue;
}
send(
&mut self.tx,
PluginResponse::Append(SearchMeta {
id: id as u32,
name: item.name.clone(),
description: item.description.clone(),
icon: Some(IconSource::Window(item.entity)),
window: Some(item.entity),
..Default::default()
}),
)
.await;
}
send(&mut self.tx, PluginResponse::Finished).await;
let _ = self.tx.flush();
}
}

View file

@ -0,0 +1,7 @@
(
name: "Pop Shell Windows",
description: "Active windows controllable via Pop Shell",
query: (persistent: true),
bin: (path: "pop-shell"),
icon: Name("focus-windows-symbolic"),
)

View file

@ -0,0 +1,6 @@
(
name: "PulseAudio Volume Control",
description: "Control PulseAudio devices and volume",
bin: (path: "pulse.js"),
icon: Name("multimedia-volume-control")
)

View file

@ -0,0 +1,216 @@
#!/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()

View file

@ -0,0 +1,11 @@
(
name: "Recent Documents",
description: "Show recently-opened files",
query: (
regex: "^(recent)\\s.*",
help: "recent ",
isolate: true
),
bin: (path: "recent.js"),
icon: Name("system-file-manager")
)

View file

@ -0,0 +1,142 @@
#!/usr/bin/gjs
const { GLib, Gio, Gtk } = 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 }) })
class App {
constructor() {
this.last_query = ""
this.manager = Gtk.RecentManager.get_default()
this.results = new Array()
}
/**
* @returns {null | Array<RecentItem>}
*/
items() {
const recent_items = this.manager.get_items()
log(`got items`)
if (!recent_items) { return null }
const items = new Array()
for (const item of recent_items) {
if (item.exists()) {
items.push({
display_name: item.get_display_name(),
mime: item.get_mime_type(),
uri: item.get_uri()
})
}
}
return items
}
query(input) {
input = input.substr(input.indexOf(" ") + 1).trim()
try {
const items = this.items()
if (items) {
const normalized = input.toLowerCase()
this.results = items
.filter(item => item.display_name.toLowerCase().includes(normalized))
.sort((a, b) => a.display_name.localeCompare(b.display_name))
.slice(0, 7)
log(`sorted`)
let id = 0
for (const item of this.results) {
this.send({ "Append": {
id,
name: item.display_name,
description: decodeURI(item.uri),
icon: { Mime: item.mime }
}})
id += 1
}
}
} catch (why) {
log(`query exception: ${why}`)
}
this.send("Finished")
}
submit(id) {
const result = this.results[id]
if (result) {
try {
GLib.spawn_command_line_async(`xdg-open '${result.uri}'`)
} catch (e) {
log(`xdg-open failed: ${e}`)
}
}
this.send("Close")
}
send(object) {
STDOUT.write_bytes(new GLib.Bytes(JSON.stringify(object) + "\n"), null)
STDOUT.flush(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 ("Search" in event) {
app.query(event.Search)
} else if ("Activate" in event) {
app.submit(event.Activate);
} else if (event === "Exit") {
break mainloop
}
}
}
}
/**
* 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
}
}
main()

View file

@ -0,0 +1,201 @@
use pop_launcher::*;
use pop_launcher_plugins::*;
use flume::Sender;
use futures_lite::{AsyncBufReadExt, StreamExt};
use std::os::unix::process::CommandExt;
use std::{
io,
path::{Path, PathBuf},
process::{Command, Stdio},
};
const LOCAL_PATH: &str = ".local/share/pop-launcher/scripts";
const SYSTEM_ADMIN_PATH: &str = "/etc/pop-launcher/scripts";
const DISTRIBUTION_PATH: &str = "/usr/lib/pop-launcher/scripts";
pub async fn main() {
let mut requests = json_input_stream(async_stdin());
let mut app = App::new();
app.reload().await;
while let Some(result) = requests.next().await {
match result {
Ok(response) => match response {
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);
}
}
}
}
pub struct App {
scripts: Vec<ScriptInfo>,
out: smol::Unblock<io::Stdout>,
}
impl App {
fn new() -> Self {
App {
scripts: Vec::with_capacity(16),
out: async_stdout(),
}
}
async fn activate(&mut self, id: u32) {
use fork::{daemon, Fork};
if let Ok(Fork::Child) = daemon(true, true) {
if let Some(script) = self.scripts.get(id as usize) {
let interpreter = script.interpreter.as_deref().unwrap_or("sh");
let why = dbg!(Command::new(interpreter).arg(script.path.as_os_str()))
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.exec();
tracing::error!("failed to exec: {}", why);
std::process::exit(1);
}
}
send(&mut self.out, PluginResponse::Close).await;
}
async fn reload(&mut self) {
#[allow(deprecated)]
let home = std::env::home_dir()
.expect("user does not have home dir")
.join(LOCAL_PATH);
let paths = &[
&home,
Path::new(SYSTEM_ADMIN_PATH),
Path::new(DISTRIBUTION_PATH),
];
let (tx, rx) = flume::unbounded();
let script_sender = async move {
for path in paths {
load_from(path, tx.clone()).await;
}
};
let script_receiver = async {
while let Ok(script) = rx.recv_async().await {
tracing::debug!("appending script: {:?}", script);
self.scripts.push(script);
}
};
futures_lite::future::zip(script_sender, script_receiver).await;
}
async fn search(&mut self, query: &str) {
let &mut Self {
ref scripts,
ref mut out,
..
} = self;
for (id, script) in scripts.iter().enumerate() {
let should_include = script.name.to_ascii_lowercase().contains(query)
|| script.description.to_ascii_lowercase().contains(query)
|| script.keywords.iter().any(|k| k.contains(query));
if should_include {
send(
out,
PluginResponse::Append(SearchMeta {
id: id as u32,
name: script.name.clone(),
description: script.description.clone(),
icon: script
.icon
.as_ref()
.map(|icon| IconSource::Name(icon.clone().into())),
keywords: Some(script.keywords.clone()),
..Default::default()
}),
)
.await;
}
}
send(out, PluginResponse::Finished).await;
}
}
#[derive(Debug, Default)]
struct ScriptInfo {
interpreter: Option<String>,
name: String,
icon: Option<String>,
path: PathBuf,
keywords: Vec<String>,
description: String,
}
async fn load_from(path: &Path, tx: Sender<ScriptInfo>) {
if let Ok(directory) = path.read_dir() {
for entry in directory.filter_map(Result::ok) {
let tx = tx.clone();
let path = entry.path();
smol::spawn(async move {
let mut file = match smol::fs::File::open(&path).await {
Ok(file) => smol::io::BufReader::new(file).lines(),
Err(why) => {
tracing::error!("cannot open script at {}: {}", path.display(), why);
return;
}
};
let mut info = ScriptInfo {
path,
..Default::default()
};
let mut first = true;
while let Some(Ok(line)) = file.next().await {
if !line.starts_with('#') {
break;
}
let line = line[1..].trim();
if first {
first = false;
if let Some(interpreter) = line.strip_prefix('!') {
info.interpreter = Some(interpreter.to_owned());
continue;
}
}
if let Some(stripped) = line.strip_prefix("name:") {
info.name = stripped.trim_start().to_owned();
} else if let Some(stripped) = line.strip_prefix("description:") {
info.description = stripped.trim_start().to_owned();
} else if let Some(stripped) = line.strip_prefix("icon:") {
info.icon = Some(stripped.trim_start().to_owned());
} else if let Some(stripped) = line.strip_prefix("keywords:") {
info.keywords =
stripped.trim_start().split(' ').map(String::from).collect();
}
}
let _ = tx.send_async(info).await;
})
.detach();
}
}
}

View file

@ -0,0 +1,6 @@
(
name: "Scripts",
description: "Shell scripts as launcher options",
bin: (path: "scripts"),
icon: Name("utilities-terminal"),
)

View file

@ -0,0 +1,11 @@
(
name: "Terminal Commands",
description: "Run commands in a terminal",
query: (
regex: "^(:|t:|run ).*",
help: "run ",
isolate: true,
),
bin: (path: "terminal.js"),
icon: Name("utilities-terminal"),
)

View file

@ -0,0 +1,112 @@
#!/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 }) })
class App {
constructor() {
this.last_query = ""
this.shell_only = false
}
/** @param {string} input */
query(input) {
if (input.startsWith(':')) {
this.shell_only = true
this.last_query = input.substr(1).trim()
} else {
this.shell_only = false
this.last_query = input.startsWith('t:')
? input.substr(2).trim()
: input.substr(input.indexOf(" ") + 1).trim()
}
this.send({ "Append": {
id: 0,
name: this.last_query,
description: "run command in terminal"
}})
this.send("Finished")
}
/** @param {number} _id */
submit(_id) {
try {
let runner
if (this.shell_only) {
runner = ""
} else {
let path = GLib.find_program_in_path('x-terminal-emulator');
let [terminal, splitter] = path ? [path, "-e"] : ["gnome-terminal", "--"];
runner = `${terminal} ${splitter} `
}
GLib.spawn_command_line_async(`${runner}sh -c '${this.last_query}; echo "Press to exit"; read t'`);
} catch (e) {
log(`command launch error: ${e}`)
}
this.send("Close")
}
/** @param {Object} object */
send(object) {
try {
STDOUT.write_bytes(new GLib.Bytes(JSON.stringify(object) + "\n"), null)
} catch (e) {
log(`failed to send response to Pop Shell: ${e}`)
}
}
}
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 ("Search" in event) {
app.query(event.Search)
} else if ("Activate" in event) {
app.submit(event.Activate)
} else if ("Exit" === event) {
break mainloop
}
}
}
}
/**
* 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
}
}
main()

View file

@ -0,0 +1,10 @@
(
name: "Web Search",
description: "Site-specific web search",
query: (
regex: "^(amazon|wiki|bing|ddg|google|yt|stack|crates|arch|pp|ppw|rdt|bc|lib|npm|gist|fh|gh|dev|sdcl|twitch|yh|alie)\\s.*",
help: "ddg ",
),
bin: (path: "web.js"),
icon: Name("system-search"),
)

View file

@ -0,0 +1,128 @@
#!/usr/bin/env 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 }) })
const ENTRIES = new Map([
['wiki', { query: 'https://wikipedia.org/w/index.php?search=', name: 'Wikipedia' }],
['bing', { query: 'https://www.bing.com/search?q=', name: 'Bing' }],
['ddg', { query: 'https://www.duckduckgo.com/?q=', name: 'DuckDuckGo' }],
['google', { query: 'https://www.google.com/search?q=', name: 'Google' }],
['yt', { query: 'https://www.youtube.com/results?search_query=', name: 'YouTube' }],
['amazon', { query: 'https://smile.amazon.com/s?k=', name: 'Amazon' }],
['stack', { query: 'https://stackoverflow.com/search?q=', name: 'Stack Overflow' }],
['crates', { query: 'https://crates.io/search?q=', name: 'Crates.io' }],
['rdt', { query: 'https://www.reddit.com/search/?q=', name: 'reddit' }],
['arch', { query: 'https://wiki.archlinux.org/index.php/', name: 'Arch Wiki' }],
['pp', { query: 'https://pop-planet.info/forums/search/1/?q=', name: 'Pop!_Planet' }],
['ppw', { query: 'https://pop-planet.info/wiki/?search=', name: 'Pop!_Planet Wiki' }],
['bc', { query: 'https://bandcamp.com/search?q=', name: 'Bandcamp' }],
['npm', { query: 'https://www.npmjs.com/search?q=', name: 'npm' }],
['lib', { query: 'https://libraries.io/search?q=', name: 'Libraries.io' }],
['gist', { query: 'https://gist.github.com/search?q=', name: 'GitHub Gist' }],
['fh', { query: 'https://flathub.org/apps/search/', name: 'Flathub' }],
['gh', { query: 'https://github.com/search?q=', name: 'GitHub' }],
['sdcl', { query: 'https://soundcloud.com/search?q=', name: 'SoundCloud' }],
['twitch', { query: 'https://www.twitch.tv/search?term=', name: 'Twitch' }],
['yh', { query: 'https://search.yahoo.com/search?p=', name: 'Yahoo!' }],
['alie', { query: 'https://www.aliexpress.com/wholesale?SearchText=', name: 'AliExpress' }],
['dev', { query: 'https://dev.to/search?q=', name: 'DEV Community' }]
])
class App {
constructor() {
this.last_query = ''
this.last_value = ''
this.query_base = ''
this.name_base = ''
this.app_info = Gio.AppInfo.get_default_for_uri_scheme('https')
}
build_query() {
return `${this.query_base}${encodeURIComponent(this.last_query)}`
}
search(input) {
const delim_position = input.indexOf(' ')
const key = input.substring(0, delim_position)
this.last_query = input.substr(delim_position + 1).trim()
const entry = ENTRIES.get(key) || { query: 'https://www.duckduckgo.com/?q=', name: 'DuckDuckGo' }
this.query_base = entry.query;
this.name_base = entry.name;
this.send({ "Append": {
id: 0,
description: this.build_query(),
name: `${this.name_base}: ${this.last_query}`,
icon: { Name: this.app_info.get_icon().to_string() },
}})
this.send("Finished")
}
submit(_id) {
try {
GLib.spawn_command_line_async(`xdg-open ${this.build_query()}`)
} catch (e) {
log(`xdg-open failed: ${e} `)
}
this.send("Close")
}
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 ("Search" in event) {
app.search(event.Search)
} else if ("Activate" in event) {
app.submit(event.Activate)
} else if ("Exit" === event) {
break mainloop
}
}
}
}
/**
* 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
}
}
main()