diff --git a/Cargo.toml b/Cargo.toml index b1af47e..d6d6e6c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,9 +16,11 @@ categories = ["api-bindings", "wasm"] crate-type = ["cdylib", "rlib"] [dependencies] +derive_more = "0.99" +gloo-console = "0.2" gloo-utils = "0.1.5" js-sys = "0.3.60" -serde = { version = "1.0.147", features = ["derive"] } +serde = {version = "1.0.147", features = ["derive"]} serde_derive = "1.0.147" serde_json = "1.0.87" thiserror = "1.0.37" @@ -32,3 +34,6 @@ wasm-bindgen-test = "0.3.33" [features] default = [] firefox = [] + +[workspace] +members = ["examples/omnibox/new-tab-search"] diff --git a/examples/omnibox/new-tab-search/Cargo.toml b/examples/omnibox/new-tab-search/Cargo.toml new file mode 100644 index 0000000..e62985c --- /dev/null +++ b/examples/omnibox/new-tab-search/Cargo.toml @@ -0,0 +1,37 @@ +[package] +authors = ["Flier Lu "] +edition = "2018" +name = "new-tab-search" +version = "0.1.0" + +[lib] +crate-type = ["cdylib", "rlib"] + +[features] +default = ["console_error_panic_hook"] + +[dependencies] +gloo-console = "0.2" +gloo-utils = "0.1" +js-sys = "0.3" +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" + +# The `console_error_panic_hook` crate provides better debugging of panics by +# logging them with `console.error`. This is great for development, but requires +# all the `std::fmt` and `std::panicking` infrastructure, so isn't great for +# code size when deploying. +console_error_panic_hook = {version = "0.1.6", optional = true} + +# `wee_alloc` is a tiny allocator for wasm that is only ~1K in code size +# compared to the default allocator's ~10K. It is slower than the default +# allocator, however. +wee_alloc = {version = "0.4", optional = true} + +web-extensions = {version = "0.3", path = "../../.."} + +[profile.release] +# Tell `rustc` to optimize for small code size. +codegen-units = 1 +lto = true +opt-level = "s" diff --git a/examples/omnibox/new-tab-search/background.js b/examples/omnibox/new-tab-search/background.js new file mode 100644 index 0000000..d326af5 --- /dev/null +++ b/examples/omnibox/new-tab-search/background.js @@ -0,0 +1,13 @@ +import init, { start } from './pkg/new_tab_search.js'; + +console.log("WASM module loaded") + +async function run() { + await init(); + + console.log("WASM module initialized") + + start(); +} + +run(); diff --git a/examples/omnibox/new-tab-search/manifest.json b/examples/omnibox/new-tab-search/manifest.json new file mode 100644 index 0000000..6796446 --- /dev/null +++ b/examples/omnibox/new-tab-search/manifest.json @@ -0,0 +1,46 @@ +{ + "name": "Omnibox - New Tab Search", + "description": "Type 'nt' plus a search term into the Omnibox to open search in new tab.", + "version": "1.0", + "manifest_version": 3, + "background": { + "service_worker": "background.js", + "type": "module" + }, + "permissions": [ + "activeTab", + "tabs", + "scripting" + ], + "host_permissions": [ + "https://*/*" + ], + "content_security_policy": { + "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'" + }, + "web_accessible_resources": [ + { + "resources": [ + "pkg/new_tab_search_bg.wasm" + ], + "matches": [ + "https://*/*" + ] + } + ], + "omnibox": { + "keyword": "nt" + }, + "action": { + "default_icon": { + "16": "newtab_search16.png", + "32": "newtab_search32.png" + } + }, + "icons": { + "16": "newtab_search16.png", + "32": "newtab_search32.png", + "48": "newtab_search48.png", + "128": "newtab_search128.png" + } +} diff --git a/examples/omnibox/new-tab-search/newtab_search128.png b/examples/omnibox/new-tab-search/newtab_search128.png new file mode 100644 index 0000000..d4b8637 Binary files /dev/null and b/examples/omnibox/new-tab-search/newtab_search128.png differ diff --git a/examples/omnibox/new-tab-search/newtab_search16.png b/examples/omnibox/new-tab-search/newtab_search16.png new file mode 100644 index 0000000..09c8ae2 Binary files /dev/null and b/examples/omnibox/new-tab-search/newtab_search16.png differ diff --git a/examples/omnibox/new-tab-search/newtab_search32.png b/examples/omnibox/new-tab-search/newtab_search32.png new file mode 100644 index 0000000..17a435a Binary files /dev/null and b/examples/omnibox/new-tab-search/newtab_search32.png differ diff --git a/examples/omnibox/new-tab-search/newtab_search48.png b/examples/omnibox/new-tab-search/newtab_search48.png new file mode 100644 index 0000000..5678cec Binary files /dev/null and b/examples/omnibox/new-tab-search/newtab_search48.png differ diff --git a/examples/omnibox/new-tab-search/src/lib.rs b/examples/omnibox/new-tab-search/src/lib.rs new file mode 100644 index 0000000..0b97370 --- /dev/null +++ b/examples/omnibox/new-tab-search/src/lib.rs @@ -0,0 +1,128 @@ +use gloo_console as console; +use js_sys as js; +use wasm_bindgen::prelude::*; + +use web_extensions::{self as ext, omnibox::OnInputEnteredDisposition::*}; + +mod utils; + +// When the `wee_alloc` feature is enabled, use `wee_alloc` as the global +// allocator. +#[cfg(feature = "wee_alloc")] +#[global_allocator] +static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT; + +#[wasm_bindgen] +pub fn start() { + utils::set_panic_hook(); + + console::info!("Starting background script"); + + ext::omnibox::set_default_suggestion(&ext::omnibox::DefaultSuggestResult { + description: "Type anything to search", + }) + .unwrap(); + + ext::omnibox::on_input_started() + .add_listener(|| { + console::debug!("Input started"); + }) + .forget(); + + ext::omnibox::on_input_cancelled() + .add_listener(|| { + console::debug!("Input cancelled"); + }) + .forget(); + + ext::omnibox::on_input_changed() + .add_listener(|text, suggest| { + console::debug!("Input changed", text); + }) + .forget(); + + ext::omnibox::on_input_entered() + .add_listener(|text, disposition| { + console::debug!("Input entered", text, disposition.to_string()); + + let url = format!( + "https://www.google.com/search?q={}", + js::encode_uri_component(text).to_string(), + ); + + wasm_bindgen_futures::spawn_local(async move { + let mut tab_id = None; + + if disposition == CurrentTab { + let query = ext::tabs::QueryDetails { + active: Some(true), + last_focused_window: Some(true), + ..Default::default() + }; + + match ext::tabs::query(&query).await { + Ok(tabs) => { + if let [tab, ..] = &tabs[..] { + console::debug!( + "current tab", + tab.id.map_or(-1, Into::::into) + ); + + tab_id = tab.id; + } + } + Err(err) => { + console::error!("query tabs failed", err.to_string()); + } + } + } + + if disposition == CurrentTab { + console::info!( + "open on the current tab", + &url, + tab_id.map_or(-1, Into::::into) + ); + + match ext::tabs::update( + tab_id, + ext::tabs::UpdateProperties { + url: Some(&url), + ..Default::default() + }, + ) + .await + { + Ok(tab) => { + console::info!( + "opened on the current tab", + &url, + tab.id.map_or(-1, Into::::into) + ) + } + Err(err) => console::error!("update tabs failed", err.to_string()), + } + } else { + console::info!("open on a new tab", &url); + + match ext::tabs::create(ext::tabs::CreateProperties { + active: disposition == NewForegroundTab, + url: &url, + }) + .await + { + Ok(tab) => { + console::info!( + "open on a new tab", + tab.id.map_or(-1, Into::::into) + ) + } + Err(err) => { + console::error!("create tab failed", err.to_string()); + } + }; + } + }) + }) + .forget(); +} diff --git a/examples/omnibox/new-tab-search/src/utils.rs b/examples/omnibox/new-tab-search/src/utils.rs new file mode 100644 index 0000000..b1d7929 --- /dev/null +++ b/examples/omnibox/new-tab-search/src/utils.rs @@ -0,0 +1,10 @@ +pub fn set_panic_hook() { + // When the `console_error_panic_hook` feature is enabled, we can call the + // `set_panic_hook` function at least once during initialization, and then + // we will get better error messages if our code ever panics. + // + // For more details see + // https://github.com/rustwasm/console_error_panic_hook#readme + #[cfg(feature = "console_error_panic_hook")] + console_error_panic_hook::set_once(); +} diff --git a/src/lib.rs b/src/lib.rs index c9e794d..0269788 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,6 +7,7 @@ pub use crate::error::*; pub mod bookmarks; pub mod downloads; pub mod history; +pub mod omnibox; pub mod tabs; #[cfg(feature = "firefox")] diff --git a/src/omnibox.rs b/src/omnibox.rs new file mode 100644 index 0000000..14ba161 --- /dev/null +++ b/src/omnibox.rs @@ -0,0 +1,278 @@ +//! Wrapper for the [`chrome.omnibox` API](https://developer.chrome.com/docs/extensions/reference/omnibox/). + +use derive_more::Display; +use gloo_console as console; +use js_sys::Function; +use serde::{Deserialize, Serialize}; +use wasm_bindgen::prelude::*; +use web_extensions_sys as sys; + +use crate::{event_listener::EventListener, util::*, Error}; + +/// Sets the description and styling for the default suggestion. +/// +/// The default suggestion is the text that is displayed in the first suggestion row underneath the URL bar. +/// +/// +pub fn set_default_suggestion(suggestion: &DefaultSuggestResult<'_>) -> Result<(), Error> { + let js_suggestion = js_from_serde(suggestion)?; + + sys::chrome() + .omnibox() + .set_default_suggestion(&js_suggestion); + + Ok(()) +} + +/// A suggest result. +/// +/// +#[derive(Debug, Clone, Serialize)] +pub struct DefaultSuggestResult<'a> { + /// The text that is displayed in the URL dropdown. + pub description: &'a str, +} + +/// The style type. +/// +/// +#[derive(Debug, Clone, Copy, PartialEq, Eq, Display, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum DescriptionStyleType { + Url, + Match, + Dim, +} + +impl DescriptionStyleType { + pub const DIM: &str = "dim"; + pub const MATCH: &str = "match"; + pub const URL: &str = "url"; +} + +impl TryFrom for DescriptionStyleType { + type Error = Error; + + fn try_from(v: JsValue) -> Result { + v.as_string() + .and_then(|s| match s.as_str() { + Self::DIM => Some(DescriptionStyleType::Dim), + Self::MATCH => Some(DescriptionStyleType::Match), + Self::URL => Some(DescriptionStyleType::Url), + _ => None, + }) + .ok_or(Error::Js(v)) + } +} +/// The window disposition for the omnibox query. +/// +/// This is the recommended context to display results. +/// For example, if the omnibox command is to navigate to a certain URL, +/// a disposition of 'newForegroundTab' means the navigation should take place in a new selected tab. +/// +/// +#[derive(Debug, Clone, Copy, PartialEq, Eq, Display, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum OnInputEnteredDisposition { + CurrentTab, + NewForegroundTab, + NewBackgroundTab, +} + +impl OnInputEnteredDisposition { + pub const CURRENT_TAB: &str = "currentTab"; + pub const NEW_BACKGROUND_TAB: &str = "newBackgroundTab"; + pub const NEW_FOREGROUND_TAB: &str = "newForegroundTab"; +} + +impl TryFrom for OnInputEnteredDisposition { + type Error = Error; + + fn try_from(v: JsValue) -> Result { + v.as_string() + .and_then(|s| match s.as_str() { + Self::CURRENT_TAB => Some(OnInputEnteredDisposition::CurrentTab), + Self::NEW_BACKGROUND_TAB => Some(OnInputEnteredDisposition::NewBackgroundTab), + Self::NEW_FOREGROUND_TAB => Some(OnInputEnteredDisposition::NewForegroundTab), + _ => None, + }) + .ok_or(Error::Js(v)) + } +} + +/// A suggest result. +#[derive(Default, Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SuggestResult<'a> { + /// The text that is put into the URL bar, and that is sent to the extension when the user chooses this entry. + pub content: &'a str, + /// Whether the suggest result can be deleted by the user. + pub deletable: bool, + /// The text that is displayed in the URL dropdown. + pub description: &'a str, +} + +/// User has deleted a suggested result. +pub fn on_delete_suggestion() -> OnDeleteSuggestion { + OnDeleteSuggestion(sys::chrome().omnibox().on_delete_suggestion()) +} + +pub struct OnDeleteSuggestion(sys::EventTarget); + +impl OnDeleteSuggestion { + pub fn add_listener(&self, mut listener: L) -> OnDeleteSuggestionListener + where + L: FnMut(&str) + 'static, + { + let listener = Closure::new(move |text: String| { + console::debug!("on delete suggestion", sys::chrome().omnibox(), &text); + + listener(text.as_str()) + }); + + OnDeleteSuggestionListener(EventListener::raw_new(&self.0, listener)) + } +} + +pub struct OnDeleteSuggestionListener<'a>(EventListener<'a, dyn FnMut(String)>); + +impl OnDeleteSuggestionListener<'_> { + pub fn forget(self) { + self.0.forget() + } +} + +/// User has ended the keyword input session without accepting the input. +pub fn on_input_cancelled() -> OnInputCancelled { + OnInputCancelled(sys::chrome().omnibox().on_input_cancelled()) +} + +pub struct OnInputCancelled(sys::EventTarget); + +impl OnInputCancelled { + pub fn add_listener(&self, mut listener: L) -> OnInputCancelledListener + where + L: FnMut() + 'static, + { + let listener = Closure::new(move || { + console::debug!("on input cancelled", sys::chrome().omnibox()); + + listener() + }); + + OnInputCancelledListener(EventListener::raw_new(&self.0, listener)) + } +} + +pub struct OnInputCancelledListener<'a>(EventListener<'a, dyn FnMut()>); + +impl OnInputCancelledListener<'_> { + pub fn forget(self) { + self.0.forget() + } +} + +/// User has changed what is typed into the omnibox. +pub fn on_input_changed() -> OnInputChanged { + OnInputChanged(sys::chrome().omnibox().on_input_changed()) +} + +pub struct OnInputChanged(sys::EventTarget); + +impl OnInputChanged { + pub fn add_listener(&self, mut listener: L) -> OnInputChangedListener + where + L: FnMut(&str, &mut (dyn FnMut(Vec) -> Result<(), Error> + 'static)) + + 'static, + { + let listener = Closure::new(move |text: String, suggest: Function| { + console::debug!("on input changed", sys::chrome().omnibox(), &text, &suggest); + + let mut f = move |results: Vec| -> Result<(), Error> { + let this = JsValue::null(); + let js_results = js_from_serde(&results).unwrap(); + + suggest.call1(&this, &js_results)?; + + Ok(()) + }; + + listener(text.as_str(), &mut f) + }); + + OnInputChangedListener(EventListener::raw_new(&self.0, listener)) + } +} + +pub struct OnInputChangedListener<'a>(EventListener<'a, dyn FnMut(String, Function)>); + +impl OnInputChangedListener<'_> { + pub fn forget(self) { + self.0.forget() + } +} + +/// User has accepted what is typed into the omnibox. +pub fn on_input_entered() -> OnInputEntered { + OnInputEntered(sys::chrome().omnibox().on_input_entered()) +} + +pub struct OnInputEntered(sys::EventTarget); + +impl OnInputEntered { + pub fn add_listener(&self, mut listener: L) -> OnInputEnteredListener + where + L: FnMut(&str, OnInputEnteredDisposition) + 'static, + { + let callback = Closure::new(move |text: String, disposition: JsValue| { + console::debug!( + "on input entered", + sys::chrome().omnibox(), + &text, + &disposition + ); + + listener(text.as_str(), disposition.try_into().unwrap()) + }); + + OnInputEnteredListener(EventListener::raw_new(&self.0, callback)) + } +} + +pub struct OnInputEnteredListener<'a>(EventListener<'a, dyn FnMut(String, JsValue)>); + +impl OnInputEnteredListener<'_> { + pub fn forget(self) { + self.0.forget() + } +} + +/// User has ended the keyword input session without accepting the input. +pub fn on_input_started() -> OnInputStarted { + OnInputStarted(sys::chrome().omnibox().on_input_started()) +} + +pub struct OnInputStarted(sys::EventTarget); + +impl OnInputStarted { + pub fn add_listener(&self, mut listener: L) -> OnInputStartedListener + where + L: FnMut() + 'static, + { + let listener = Closure::new(move || { + console::debug!("on input started", sys::chrome().omnibox()); + + listener() + }); + + OnInputStartedListener(EventListener::raw_new(&self.0, listener)) + } +} + +pub struct OnInputStartedListener<'a>(EventListener<'a, dyn FnMut()>); + +impl OnInputStartedListener<'_> { + pub fn forget(self) { + self.0.forget() + } +} diff --git a/src/tabs/mod.rs b/src/tabs/mod.rs index 442b52e..ffd34ec 100644 --- a/src/tabs/mod.rs +++ b/src/tabs/mod.rs @@ -30,6 +30,12 @@ impl From for TabId { } } +impl From for i32 { + fn from(id: TabId) -> Self { + id.0 + } +} + mod on_activated; mod on_attached; mod on_created; @@ -93,3 +99,38 @@ pub struct CreateProperties<'a> { pub active: bool, pub url: &'a str, } + +/// Modifies the properties of a tab. +/// +/// Properties that are not specified in updateProperties are not modified. +/// +/// +pub async fn update(tab_id: Option, props: UpdateProperties<'_>) -> Result { + let js_props = js_from_serde(&props)?; + let result = tabs() + .update(tab_id.map(|id| id.0), object_from_js(&js_props)?) + .await; + serde_from_js_result(result) +} + +/// Information necessary to open a new tab. +#[derive(Debug, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateProperties<'a> { + /// Whether the tab should be active. + pub active: Option, + /// Whether the tab should be discarded automatically by the browser when resources are low. + pub auto_discardable: Option, + /// Adds or removes the tab from the current selection. + pub highlighted: Option, + /// Whether the tab should be muted. + pub muted: Option, + /// The ID of the tab that opened this tab. + pub opener_tab_id: Option, + /// Whether the tab should be pinned. + pub pinned: Option, + /// Whether the tab should be selected. + pub selected: Option, + /// A URL to navigate the tab to. + pub url: Option<&'a str>, +} diff --git a/src/tabs/query_details.rs b/src/tabs/query_details.rs index 219fdbc..a3c3df8 100644 --- a/src/tabs/query_details.rs +++ b/src/tabs/query_details.rs @@ -1,7 +1,7 @@ use super::{prelude::*, Status, WindowType}; /// -#[derive(Debug, Serialize)] +#[derive(Default, Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct QueryDetails<'a> { pub active: Option, diff --git a/tests/contextual_identities.rs b/tests/contextual_identities.rs index 98cc10c..4930736 100644 --- a/tests/contextual_identities.rs +++ b/tests/contextual_identities.rs @@ -1,3 +1,5 @@ +#![cfg(feature = "firefox")] + use web_extensions::contextual_identities::*; mod util;