From 48f09be4c9065c222bdd37f38c533121d5a791a1 Mon Sep 17 00:00:00 2001 From: Michael Aaron Murphy Date: Sun, 15 Aug 2021 14:32:50 +0200 Subject: [PATCH] improv(plugins): Convert web plugin to Rust plugin, with config support --- Cargo.lock | 9 ++ Makefile | 12 +-- bin/src/main.rs | 10 +-- plugins/Cargo.toml | 3 + plugins/src/desktop_entries/mod.rs | 2 +- plugins/src/find/mod.rs | 16 ++-- plugins/src/lib.rs | 8 +- plugins/src/pop_shell/mod.rs | 2 +- plugins/src/scripts/mod.rs | 10 ++- plugins/src/web/config.ron | 111 +++++++++++++++++++++++++ plugins/src/web/config.rs | 71 ++++++++++++++++ plugins/src/web/mod.rs | 91 ++++++++++++++++++++ plugins/src/web/plugin.ron | 7 +- plugins/src/web/web.js | 128 ----------------------------- service/src/lib.rs | 8 +- 15 files changed, 324 insertions(+), 164 deletions(-) create mode 100644 plugins/src/web/config.ron create mode 100644 plugins/src/web/config.rs create mode 100644 plugins/src/web/mod.rs delete mode 100644 plugins/src/web/web.js diff --git a/Cargo.lock b/Cargo.lock index 680c69f..0f9b6b7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -812,12 +812,15 @@ dependencies = [ "futures_codec", "new_mime_guess", "pop-launcher", + "ron", "serde", "serde_json", + "slab", "smol", "strsim", "tracing", "tracing-subscriber", + "urlencoding", "zbus", "zvariant", ] @@ -1270,6 +1273,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3" +[[package]] +name = "urlencoding" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68b90931029ab9b034b300b797048cf23723400aa757e8a2bfb9d748102f9821" + [[package]] name = "version_check" version = "0.9.3" diff --git a/Makefile b/Makefile index f03dc8f..db899f0 100644 --- a/Makefile +++ b/Makefile @@ -2,10 +2,10 @@ TARGET = debug DEBUG ?= 0 ifeq ($(DESTDIR),) -BASE_PATH = $(HOME)/.local/ +BASE_PATH = $(HOME)/.local LIB_PATH = $(BASE_PATH)/share else -BASE_PATH = $(DESTDIR)/usr/ +BASE_PATH = $(DESTDIR)/usr LIB_PATH = $(BASE_PATH)/lib endif @@ -55,7 +55,7 @@ install: for plugin in $(PLUGINS); do \ dest=$(PLUGIN_DIR)/$${plugin}; \ mkdir -p $${dest}; \ - install -Dm0644 plugins/src/$${plugin}/plugin.ron $${dest}/plugin.ron; \ + install -Dm0644 plugins/src/$${plugin}/*.ron $${dest}; \ done install -Dm0755 target/$(TARGET)/pop-launcher-bin $(BIN) @@ -72,6 +72,9 @@ install: # Scripts plugin ln -sf $(BIN) $(PLUGIN_DIR)/scripts/scripts + # Web plugin + ln -sf $(BIN) $(PLUGIN_DIR)/web/web + # Calculator plugin install -Dm0755 plugins/src/calc/calc.js $(PLUGIN_DIR)/calc install -Dm0644 plugins/src/calc/math.js $(PLUGIN_DIR)/calc @@ -88,9 +91,6 @@ install: # Terminal plugin install -Dm0755 plugins/src/terminal/terminal.js $(PLUGIN_DIR)/terminal - # Web plugin - install -Dm0755 plugins/src/web/web.js $(PLUGIN_DIR)/web - # Scripts mkdir -p $(SCRIPTS_DIR) for script in $(PWD)/scripts/*; do \ diff --git a/bin/src/main.rs b/bin/src/main.rs index 6b07ed0..7ac5020 100644 --- a/bin/src/main.rs +++ b/bin/src/main.rs @@ -1,5 +1,5 @@ use pop_launcher_plugins as plugins; -use pop_launcher_service::Service; +use pop_launcher_service as service; use smol::block_on; use std::io; @@ -15,14 +15,12 @@ fn main() { let start = plugin.rfind('/').map(|v| v + 1).unwrap_or(0); let cmd = &plugin.as_str()[start..]; match cmd { - "pop-launcher" => { - let stdout = io::stdout(); - block_on(Service::new(stdout.lock()).exec()) - }, "desktop-entries" => block_on(plugins::desktop_entries::main()), - "pop-shell" => block_on(plugins::pop_shell::main()), "find" => block_on(plugins::find::main()), + "pop-launcher" => block_on(service::main()), + "pop-shell" => block_on(plugins::pop_shell::main()), "scripts" => block_on(plugins::scripts::main()), + "web" => block_on(plugins::web::main()), unknown => { eprintln!("unknown cmd: {}", unknown); } diff --git a/plugins/Cargo.toml b/plugins/Cargo.toml index e16b940..0677588 100644 --- a/plugins/Cargo.toml +++ b/plugins/Cargo.toml @@ -21,3 +21,6 @@ tracing = "0.1" tracing-subscriber = "0.2" zbus = "1" zvariant = "2" +ron = "0.6.4" +urlencoding = "2.1.0" +slab = "0.4.4" diff --git a/plugins/src/desktop_entries/mod.rs b/plugins/src/desktop_entries/mod.rs index 590871a..c047621 100644 --- a/plugins/src/desktop_entries/mod.rs +++ b/plugins/src/desktop_entries/mod.rs @@ -1,7 +1,7 @@ +use crate::*; use freedesktop_desktop_entry::{default_paths, DesktopEntry, Iter as DesktopIter, PathSource}; use futures_lite::{AsyncWrite, StreamExt}; use pop_launcher::*; -use crate::*; use std::borrow::Cow; use std::hash::{Hash, Hasher}; use std::path::PathBuf; diff --git a/plugins/src/find/mod.rs b/plugins/src/find/mod.rs index 60e96b4..124edf5 100644 --- a/plugins/src/find/mod.rs +++ b/plugins/src/find/mod.rs @@ -1,11 +1,10 @@ use futures_lite::*; use pop_launcher::*; -use crate::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::path::PathBuf; use std::rc::Rc; enum Event { @@ -37,12 +36,12 @@ pub async fn main() { 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; + crate::xdg_open(&path); }); handle.detach(); - send(&mut app.out, PluginResponse::Close).await; + crate::send(&mut app.out, PluginResponse::Close).await; } } @@ -135,7 +134,7 @@ impl SearchContext { ..Default::default() }); - send(&mut self.out, response).await; + crate::send(&mut self.out, response).await; self.search_results.push(path); } @@ -183,7 +182,7 @@ impl SearchContext { } } - send(&mut self.out, PluginResponse::Finished).await; + crate::send(&mut self.out, PluginResponse::Finished).await; } } @@ -204,8 +203,3 @@ async fn query(arg: &str) -> io::Result { )), } } - -/// Launches a file with its default appplication via `xdg-open`. -async fn xdg_open(file: &Path) { - let _ = Command::new("xdg-open").arg(file).spawn(); -} diff --git a/plugins/src/lib.rs b/plugins/src/lib.rs index ac2fa51..3397ba5 100644 --- a/plugins/src/lib.rs +++ b/plugins/src/lib.rs @@ -2,10 +2,11 @@ pub mod desktop_entries; pub mod find; pub mod pop_shell; pub mod scripts; +pub mod web; use futures_lite::{AsyncWrite, AsyncWriteExt}; - use pop_launcher::PluginResponse; +use std::ffi::OsStr; pub async fn send(tx: &mut W, response: PluginResponse) { if let Ok(mut bytes) = serde_json::to_string(&response) { @@ -14,3 +15,8 @@ pub async fn send(tx: &mut W, response: PluginResponse) { let _ = tx.flush().await; } } + +/// Launches a file with its default appplication via `xdg-open`. +pub fn xdg_open>(file: S) { + let _ = smol::process::Command::new("xdg-open").arg(file).spawn(); +} diff --git a/plugins/src/pop_shell/mod.rs b/plugins/src/pop_shell/mod.rs index 532e38b..61d0bdb 100644 --- a/plugins/src/pop_shell/mod.rs +++ b/plugins/src/pop_shell/mod.rs @@ -1,6 +1,6 @@ +use crate::*; use futures_lite::{AsyncWrite, AsyncWriteExt, StreamExt}; use pop_launcher::*; -use crate::*; use serde::{Deserialize, Serialize}; use std::convert::TryFrom; use zbus::Connection; diff --git a/plugins/src/scripts/mod.rs b/plugins/src/scripts/mod.rs index 35ed943..a5192b2 100644 --- a/plugins/src/scripts/mod.rs +++ b/plugins/src/scripts/mod.rs @@ -1,5 +1,5 @@ -use pop_launcher::*; use crate::*; +use pop_launcher::*; use flume::Sender; use futures_lite::{AsyncBufReadExt, StreamExt}; @@ -75,9 +75,11 @@ impl App { let (path_tx, path_rx) = flume::unbounded::(); #[allow(deprecated)] - let _ = path_tx.send(std::env::home_dir() - .expect("user does not have home dir") - .join(LOCAL_PATH)); + let _ = path_tx.send( + std::env::home_dir() + .expect("user does not have home dir") + .join(LOCAL_PATH), + ); let _ = path_tx.send(Path::new(SYSTEM_ADMIN_PATH).to_owned()); let _ = path_tx.send(Path::new(DISTRIBUTION_PATH).to_owned()); diff --git a/plugins/src/web/config.ron b/plugins/src/web/config.ron new file mode 100644 index 0000000..35ed7df --- /dev/null +++ b/plugins/src/web/config.ron @@ -0,0 +1,111 @@ +( + rules: [ + ( + matches: ["ali", "alie"], + queries: [(name: "AliExpress", query: "aliexpress.com/wholesale?SearchText=")] + ), + ( + matches: ["a", "amazon"], + queries: [(name: "Amazon", query: "smile.amazon.com/s?k=" )] + ), + ( + matches: ["arch"], + queries: [(name: "Arch Wiki", query: "wiki.archlinux.org/index.php/" )] + ), + ( + matches: ["bc", "bandcamp"], + queries: [(name: "Bandcamp", query: "bandcamp.com/search?q=")] + ), + ( + matches: ["b", "bing"], + queries: [(name: "Bing", query: "bing.com/search?q=")] + ), + ( + matches: ["crates"], + queries: [ + (name: "Crates.io", query: "crates.io/search?q="), + (name: "Lib.rs", query: "lib.rs/search?q="), + ] + ), + ( + matches: ["ddg"], + queries: [(name: "DuckDuckGo", query: "duckduckgo.com/?q=")] + ), + ( + matches: ["dev"], + queries: [(name: "DEV Community", query: "dev.to/search?q=")], + ), + ( + matches: ["fh"], + queries: [(name: "Flathub", query: "flathub.org/apps/search/")] + ), + ( + matches: ["gh"], + queries: [(name: "GitHub", query: "github.com/search?q=")] + ), + ( + matches: ["gist"], + queries: [(name: "GitHub Gist", query: "gist.github.com/search?q=")] + ), + ( + matches: ["g", "google"], + queries: [(name: "Search", query: "google.com/search?q=")] + ), + ( + matches: ["gi"], + queries: [(name: "Images", query: "google.com/images?q=")] + ), + ( + matches: ["gm"], + queries: [(name: "Maps", query: "google.com/maps?q=")] + ), + ( + matches: ["gn"], + queries: [(name: "News", query: "google.com/news?q=")] + ), + ( + matches: ["lib"], + queries: [(name: "Libraries.io", query: "libraries.io/search?q=")] + ), + ( + matches: ["npm"], + queries: [(name: "npm", query: "npmjs.com/search?q=")] + ), + ( + matches: ["pp"], + queries: [(name: "Pop!_Planet", query: "pop-planet.info/forums/search/1/?q=")] + ), + ( + matches: ["ppw"], + queries: [(name: "Pop!_Planet Wiki", query: "pop-planet.info/wiki/?search=")] + ), + ( + matches: ["rdt", "reddit"], + queries: [(name: "reddit", query: "reddit.com/search/?q=")] + ), + ( + matches: ["sc", "sdcl"], + queries: [(name: "SoundCloud", query: "soundcloud.com/search?q=")] + ), + ( + matches: ["stack"], + queries: [(name: "Stack Overflow", query: "stackoverflow.com/search?q=")] + ), + ( + matches: ["twitch"], + queries: [(name: "Twitch", query: "twitch.tv/search?term=")] + ), + ( + matches: ["wiki"], + queries: [(name: "Wikipedia", query: "wikipedia.org/w/index.php?search=")] + ), + ( + matches: ["yh"], + queries: [(name: "Yahoo!", query: "search.yahoo.com/search?p=")] + ), + ( + matches: ["yt"], + queries: [(name: "YouTube", query: "youtube.com/results?search_query=")] + ), + ] +) \ No newline at end of file diff --git a/plugins/src/web/config.rs b/plugins/src/web/config.rs new file mode 100644 index 0000000..7e58fc6 --- /dev/null +++ b/plugins/src/web/config.rs @@ -0,0 +1,71 @@ +use serde::Deserialize; +use slab::Slab; +use std::collections::HashMap; + +#[derive(Default)] +pub struct Config { + matches: HashMap, + queries: Slab>, +} + +impl Config { + pub fn new(rules: RawConfig) -> Self { + let mut config = Self::default(); + + for rule in rules.rules { + let idx = config.queries.insert(rule.queries); + for keyword in rule.matches { + config.matches.insert(keyword, idx as u32); + } + } + + config + } + + pub fn get(&self, word: &str) -> Option<&[Definition]> { + self.matches + .get(word) + .and_then(|idx| self.queries.get(*idx as usize)) + .map(|vec| &vec[..]) + } +} + +#[derive(Debug, Deserialize)] +pub struct RawConfig { + pub rules: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct Rule { + pub matches: Vec, + pub queries: Vec, +} + +#[derive(Debug, Deserialize)] +pub struct Definition { + pub name: String, + pub query: String, +} + +pub fn load() -> Config { + pop_launcher::config::find("web") + .next() + .and_then(|path| { + let string = match std::fs::read_to_string(&path) { + Ok(string) => string, + Err(why) => { + tracing::error!("failed to read config: {}", why); + return None; + } + }; + + match ron::from_str::(&string) { + Ok(config) => Some(Config::new(config)), + Err(why) => { + tracing::error!("failed to deserialize config: {}", why); + None + } + } + }) + .unwrap_or_default() +} diff --git a/plugins/src/web/mod.rs b/plugins/src/web/mod.rs new file mode 100644 index 0000000..f030848 --- /dev/null +++ b/plugins/src/web/mod.rs @@ -0,0 +1,91 @@ +mod config; + +use self::config::{Config, Definition}; +use futures_lite::StreamExt; +use pop_launcher::*; + +use smol::Unblock; +use std::io; + +pub async fn main() { + let mut app = App::new(); + + let mut requests = json_input_stream(async_stdin()); + + 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) + } + } + } +} + +pub struct App { + config: Config, + queries: Vec, + out: Unblock, +} + +impl App { + pub fn new() -> Self { + Self { + config: config::load(), + queries: Vec::new(), + out: async_stdout(), + } + } + + pub async fn activate(&mut self, id: u32) { + if let Some(query) = self.queries.get(id as usize) { + eprintln!("got query: {}", query); + crate::xdg_open(query); + } + + crate::send(&mut self.out, PluginResponse::Close).await; + } + + pub async fn search(&mut self, query: String) { + self.queries.clear(); + if let Some(word) = query.split_ascii_whitespace().next() { + if let Some(defs) = self.config.get(word) { + for (id, def) in defs.iter().enumerate() { + let (_, mut query) = query.split_at(word.len()); + query = query.trim(); + let encoded = build_query(def, query); + + crate::send( + &mut self.out, + PluginResponse::Append(PluginSearchResult { + id: id as u32, + name: [&def.name, ": ", query].concat(), + description: encoded.clone(), + ..Default::default() + }), + ) + .await; + + self.queries.push(encoded); + } + } + } + + crate::send(&mut self.out, PluginResponse::Finished).await; + } +} + +fn build_query(definition: &Definition, query: &str) -> String { + let prefix = if definition.query.starts_with("https://") { + "" + } else { + "https://" + }; + + [prefix, &*definition.query, &*urlencoding::encode(query)].concat() +} diff --git a/plugins/src/web/plugin.ron b/plugins/src/web/plugin.ron index d0f408a..8dcfd7e 100644 --- a/plugins/src/web/plugin.ron +++ b/plugins/src/web/plugin.ron @@ -1,10 +1,7 @@ ( 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"), + query: (help: "ddg "), + bin: (path: "web"), icon: Name("system-search"), ) \ No newline at end of file diff --git a/plugins/src/web/web.js b/plugins/src/web/web.js deleted file mode 100644 index 193c16a..0000000 --- a/plugins/src/web/web.js +++ /dev/null @@ -1,128 +0,0 @@ -#!/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() diff --git a/service/src/lib.rs b/service/src/lib.rs index 9d5263c..a093da7 100644 --- a/service/src/lib.rs +++ b/service/src/lib.rs @@ -7,7 +7,7 @@ use flume::{unbounded, Receiver, Sender}; use futures_lite::{future, StreamExt}; use regex::Regex; use slab::Slab; -use std::io::Write; +use std::io::{self, Write}; pub type PluginKey = usize; @@ -23,6 +23,12 @@ pub struct PluginHelp { pub description: String, pub help: Option, } + +pub async fn main() { + let stdout = io::stdout(); + Service::new(stdout.lock()).exec().await +} + pub struct Service { active_search: Vec<(PluginKey, PluginSearchResult)>, awaiting_results: usize,