Initial Release
This commit is contained in:
commit
8b3b95aae8
54 changed files with 5601 additions and 0 deletions
11
plugins/src/lib.rs
Normal file
11
plugins/src/lib.rs
Normal 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
26
plugins/src/main.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
99
plugins/src/plugins/calc/calc.js
Executable file
99
plugins/src/plugins/calc/calc.js
Executable 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()
|
||||
47
plugins/src/plugins/calc/math.js
Normal file
47
plugins/src/plugins/calc/math.js
Normal file
File diff suppressed because one or more lines are too long
11
plugins/src/plugins/calc/plugin.ron
Normal file
11
plugins/src/plugins/calc/plugin.ron
Normal 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")
|
||||
)
|
||||
216
plugins/src/plugins/desktop_entries/mod.rs
Normal file
216
plugins/src/plugins/desktop_entries/mod.rs
Normal 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()),
|
||||
}
|
||||
}
|
||||
6
plugins/src/plugins/desktop_entries/plugin.ron
Normal file
6
plugins/src/plugins/desktop_entries/plugin.ron
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
(
|
||||
name: "Desktop Entries",
|
||||
description: "Query applications by their .desktop entries",
|
||||
bin: (path: "desktop-entries"),
|
||||
icon: Name("new-window-symbolic"),
|
||||
)
|
||||
225
plugins/src/plugins/files/files.js
Executable file
225
plugins/src/plugins/files/files.js
Executable 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()
|
||||
12
plugins/src/plugins/files/plugin.ron
Normal file
12
plugins/src/plugins/files/plugin.ron
Normal 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")
|
||||
)
|
||||
211
plugins/src/plugins/find/mod.rs
Normal file
211
plugins/src/plugins/find/mod.rs
Normal 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();
|
||||
}
|
||||
11
plugins/src/plugins/find/plugin.ron
Normal file
11
plugins/src/plugins/find/plugin.ron
Normal 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")
|
||||
)
|
||||
4
plugins/src/plugins/mod.rs
Normal file
4
plugins/src/plugins/mod.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
pub mod desktop_entries;
|
||||
pub mod find;
|
||||
pub mod pop_shell;
|
||||
pub mod scripts;
|
||||
124
plugins/src/plugins/pop_shell/mod.rs
Normal file
124
plugins/src/plugins/pop_shell/mod.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
7
plugins/src/plugins/pop_shell/plugin.ron
Normal file
7
plugins/src/plugins/pop_shell/plugin.ron
Normal 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"),
|
||||
)
|
||||
6
plugins/src/plugins/pulse/plugin.ron
Normal file
6
plugins/src/plugins/pulse/plugin.ron
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
(
|
||||
name: "PulseAudio Volume Control",
|
||||
description: "Control PulseAudio devices and volume",
|
||||
bin: (path: "pulse.js"),
|
||||
icon: Name("multimedia-volume-control")
|
||||
)
|
||||
216
plugins/src/plugins/pulse/pulse.js
Executable file
216
plugins/src/plugins/pulse/pulse.js
Executable 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()
|
||||
11
plugins/src/plugins/recent/plugin.ron
Normal file
11
plugins/src/plugins/recent/plugin.ron
Normal 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")
|
||||
)
|
||||
142
plugins/src/plugins/recent/recent.js
Executable file
142
plugins/src/plugins/recent/recent.js
Executable 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()
|
||||
201
plugins/src/plugins/scripts/mod.rs
Normal file
201
plugins/src/plugins/scripts/mod.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
6
plugins/src/plugins/scripts/plugin.ron
Normal file
6
plugins/src/plugins/scripts/plugin.ron
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
(
|
||||
name: "Scripts",
|
||||
description: "Shell scripts as launcher options",
|
||||
bin: (path: "scripts"),
|
||||
icon: Name("utilities-terminal"),
|
||||
)
|
||||
11
plugins/src/plugins/terminal/plugin.ron
Normal file
11
plugins/src/plugins/terminal/plugin.ron
Normal 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"),
|
||||
)
|
||||
112
plugins/src/plugins/terminal/terminal.js
Executable file
112
plugins/src/plugins/terminal/terminal.js
Executable 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()
|
||||
10
plugins/src/plugins/web/plugin.ron
Normal file
10
plugins/src/plugins/web/plugin.ron
Normal 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"),
|
||||
)
|
||||
128
plugins/src/plugins/web/web.js
Normal file
128
plugins/src/plugins/web/web.js
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue