alcro/
lib.rs

1//! # Alcro
2//!
3//! Alcro is a library to create desktop apps using rust and modern web technologies.
4//! It uses the existing chrome installation for the UI.
5//!
6//! # Example
7//!
8//! ```no_run
9//! #![windows_subsystem = "windows"]
10//! use alcro::{UIBuilder, Content};
11//! use serde_json::to_value;
12//!
13//! let ui = UIBuilder::new().content(Content::Html("<html><body>Close Me!</body></html>")).run().expect("Unable to launch");
14//! assert_eq!(ui.eval("document.body.innerText").unwrap(), "Close Me!");
15//!
16//! //Expose rust function to js
17//! ui.bind("product",|args| {
18//!     let mut product = 1;
19//!     for arg in args {
20//!         match arg.as_i64() {
21//!             Some(i) => product*=i,
22//!             None => return Err(to_value("Not number").unwrap())
23//!         }
24//!     }
25//!     Ok(to_value(product).unwrap())
26//! }).expect("Unable to bind function");
27//!
28//! assert_eq!(ui.eval("(async () => await product(1,2,3))();").unwrap(), 6);
29//! assert!(ui.eval("(async () => await product(1,2,'hi'))();").is_err());
30//! ui.wait_finish();
31//! ```
32//!
33//! To change the path of the browser launched set the ALCRO_BROWSER_PATH environment variable. Only Chromium based browsers work.
34//!
35
36mod chrome;
37#[cfg(target_family = "windows")]
38use chrome::close_handle;
39use chrome::{bind, bounds, close, eval, load, load_css, load_js, set_bounds, Chrome};
40pub use chrome::{BindingContext, Bounds, JSError, JSObject, JSResult, WindowState};
41mod locate;
42pub use locate::tinyfiledialogs as dialog;
43use locate::{locate_chrome, LocateChromeError};
44use std::sync::{
45    atomic::{AtomicBool, Ordering},
46    Arc,
47};
48
49const DEFAULT_CHROME_ARGS: &[&str] = &[
50    "--disable-background-networking",
51    "--disable-background-timer-throttling",
52    "--disable-backgrounding-occluded-windows",
53    "--disable-breakpad",
54    "--disable-client-side-phishing-detection",
55    "--disable-default-apps",
56    "--disable-dev-shm-usage",
57    "--disable-infobars",
58    "--disable-extensions",
59    "--disable-features=site-per-process",
60    "--disable-hang-monitor",
61    "--disable-ipc-flooding-protection",
62    "--disable-popup-blocking",
63    "--disable-prompt-on-repost",
64    "--disable-renderer-backgrounding",
65    "--disable-sync",
66    "--disable-translate",
67    "--disable-windows10-custom-titlebar",
68    "--metrics-recording-only",
69    "--no-first-run",
70    "--no-default-browser-check",
71    "--safebrowsing-disable-auto-update",
72    "--password-store=basic",
73    "--use-mock-keychain",
74];
75
76/// The browser window
77pub struct UI {
78    chrome: Arc<Chrome>,
79    _tmpdir: Option<tempfile::TempDir>,
80    waited: AtomicBool,
81}
82
83/// Error in launching a UI window
84#[derive(Debug, thiserror::Error)]
85pub enum UILaunchError {
86    /// Cannot create temporary directory
87    #[error("Cannot create temporary directory: {0}")]
88    TempDirectoryCreationError(#[from] std::io::Error),
89    /// The path specified by ALCRO_BROWSER_PATH does not exist
90    #[error("The path {0} specified by ALCRO_BROWSER_PATH does not exist")]
91    BrowserPathInvalid(String),
92    /// Error in locating chrome
93    #[error("Error in locating chrome: {0}")]
94    LocateChromeError(#[from] LocateChromeError),
95    /// Error when initializing chrome
96    #[error("Error when initializing chrome: {0}")]
97    ChromeInitError(#[from] JSError),
98}
99
100impl UI {
101    fn new(
102        url: &str,
103        dir: Option<&std::path::Path>,
104        width: i32,
105        height: i32,
106        custom_args: &[&str],
107    ) -> Result<UI, UILaunchError> {
108        let _tmpdir;
109        let dir = match dir {
110            Some(dir) => {
111                _tmpdir = None;
112                dir
113            }
114            None => {
115                _tmpdir = Some(tempfile::TempDir::new()?);
116                _tmpdir.as_ref().unwrap().path()
117            }
118        };
119
120        let mut args = Vec::from(DEFAULT_CHROME_ARGS);
121        let user_data_dir_arg = format!("--user-data-dir={}", dir.to_str().unwrap());
122        args.push(&user_data_dir_arg);
123        let window_size_arg = format!("--window-size={},{}", width, height);
124        args.push(&window_size_arg);
125        for arg in custom_args {
126            args.push(arg)
127        }
128        args.push("--remote-debugging-pipe");
129
130        let app_arg;
131        if custom_args.contains(&"--headless") {
132            args.push(url);
133        } else {
134            app_arg = format!("--app={}", url);
135            args.push(&app_arg);
136        }
137        let chrome_path = match std::env::var("ALCRO_BROWSER_PATH") {
138            Ok(path) => {
139                if std::fs::metadata(&path).is_ok() {
140                    path
141                } else {
142                    return Err(UILaunchError::BrowserPathInvalid(path));
143                }
144            }
145            Err(_) => locate_chrome()?,
146        };
147        let chrome = Chrome::new_with_args(&chrome_path, &args)?;
148        Ok(UI {
149            chrome,
150            _tmpdir,
151            waited: AtomicBool::new(false),
152        })
153    }
154
155    /// Returns true if the browser is closed
156    pub fn done(&self) -> bool {
157        self.chrome.done()
158    }
159
160    /// Wait for the browser to be closed
161    pub fn wait_finish(&self) {
162        self.chrome.wait_finish();
163        self.waited.store(true, Ordering::Relaxed);
164    }
165
166    /// Close the browser window
167    pub fn close(&self) {
168        close(self.chrome.clone())
169    }
170
171    /// Load content in the browser. It returns Err if it fails.
172    pub fn load(&self, content: Content) -> Result<(), JSError> {
173        let html: String;
174        let url = match content {
175            Content::Url(u) => u,
176            Content::Html(h) => {
177                html = format!("data:text/html,{}", h);
178                &html
179            }
180        };
181        load(self.chrome.clone(), url)
182    }
183
184    /// Bind a rust function so that JS code can use it. It returns Err if it fails.
185    /// The rust function will be executed in a new thread and can be called asynchronously from Javascript
186    ///
187    /// # Arguments
188    ///
189    /// * `name` - Name of the function
190    /// * `f` - The function. It should take a list of `JSObject` as argument and return a `JSResult`
191    ///
192    /// # Examples
193    ///
194    /// ```
195    /// #![windows_subsystem = "windows"]
196    /// use alcro::UIBuilder;
197    /// use serde_json::to_value;
198    ///
199    /// let ui = UIBuilder::new().custom_args(&["--headless"]).run().expect("Unable to launch");
200    /// ui.bind("add",|args| {
201    ///     let mut sum = 0;
202    ///     for arg in args {
203    ///         match arg.as_i64() {
204    ///             Some(i) => sum+=i,
205    ///             None => return Err(to_value("Not number").unwrap())
206    ///         }
207    ///     }
208    ///     Ok(to_value(sum).unwrap())
209    /// }).expect("Unable to bind function");
210    /// assert_eq!(ui.eval("(async () => await add(1,2,3))();").unwrap(), 6);
211    /// assert!(ui.eval("(async () => await add(1,2,'hi'))();").is_err());
212    /// ```
213    pub fn bind<F>(&self, name: &str, f: F) -> Result<(), JSError>
214    where
215        F: Fn(&[JSObject]) -> JSResult + Sync + Send + 'static,
216    {
217        let f = Arc::new(f);
218        bind(
219            self.chrome.clone(),
220            name,
221            Arc::new(move |context| {
222                let f = f.clone();
223                std::thread::spawn(move || {
224                    let result = f(context.args());
225                    context.complete(result);
226                });
227            }),
228        )
229    }
230
231    /// Bind a rust function callable from JS that can complete asynchronously. If you are using
232    /// [`tokio`], you will probably want to be using [`Self::bind_tokio()`] instead.
233    ///
234    /// Unlike `bind()`, this passes ownership of the arguments to the callback function `f`, and
235    /// allows completing the javascript implementation after returning from `f`. This makes async
236    /// behavior much simpler to implement.
237    ///
238    /// For efficency, `f` will be executed in the message processing loop, and therefore should
239    /// avoid blocking by moving work onto another thread, for example with an async runtime
240    /// spawn method.
241    ///
242    /// # Arguments
243    ///
244    /// * `name` - Name of the function
245    /// * `f` - The function. It should take a [`BindingContext`] that gives access to the
246    ///         arguments and allows returning results.
247    ///
248    /// # Examples
249    ///
250    /// `bind()` approximately performs the following:
251    ///
252    /// ```
253    /// #![windows_subsystem = "windows"]
254    /// use alcro::UIBuilder;
255    /// use serde_json::to_value;
256    ///
257    /// let ui = UIBuilder::new().custom_args(&["--headless"]).run().expect("Unable to launch");
258    /// ui.bind_async("add", |context| {
259    ///     std::thread::spawn(|| {
260    ///         // imagine this is very expensive, or hits a network...
261    ///         let mut sum = 0;
262    ///         for arg in context.args() {
263    ///             match arg.as_i64() {
264    ///                 Some(i) => sum+=i,
265    ///                 None => return context.err(to_value("Not number").unwrap())
266    ///             }
267    ///         }
268    ///
269    ///         context.complete(Ok(to_value(sum).unwrap()));
270    ///     });
271    /// }).expect("Unable to bind function");
272    /// assert_eq!(ui.eval("(async () => await add(1,2,3))();").unwrap(), 6);
273    /// assert!(ui.eval("(async () => await add(1,2,'hi'))();").is_err());
274    /// ```
275    pub fn bind_async<F>(&self, name: &str, f: F) -> Result<(), JSError>
276    where
277        F: Fn(BindingContext) + Sync + Send + 'static,
278    {
279        bind(self.chrome.clone(), name, Arc::new(f))
280    }
281
282    /// Bind a rust function callable from JS that can complete asynchronously, using the [`tokio`]
283    /// runtime to wrap `bind_async()`, making usage more ergonomic for `tokio` users.
284    ///
285    /// The callback is closer to `bind()` than `bind_async()` in that you take the JS arguments
286    /// and return the JS result, the main difference is that the arguments are passed by value
287    /// and the result is a [`Future`].
288    ///
289    /// # Arguments
290    ///
291    /// * `name` - Name of the function
292    /// * `f` - The function. It should take a [`Vec`] of [`JSObject`] arguments by value, and
293    ///         return a [`Future`] for the [`JSResult`] (generally, by using an `async move`
294    ///         block body)
295    ///
296    /// # Examples
297    ///
298    /// ```
299    /// #![windows_subsystem = "windows"]
300    /// use alcro::UIBuilder;
301    /// use serde_json::to_value;
302    ///
303    /// # fn main() {
304    /// #   // Ensure a tokio runtime is active for the test. A user will probably be using
305    /// #   // #[tokio::main] instead, which doesn't work in doctests.
306    /// #   let rt = tokio::runtime::Runtime::new().unwrap();
307    /// #   let _guard = rt.enter();
308    /// let ui = UIBuilder::new().custom_args(&["--headless"]).run().expect("Unable to launch");
309    /// ui.bind_tokio("add", |args| async move {
310    ///     // imagine this is very expensive, or hits a network...
311    ///     let mut sum = 0;
312    ///     for arg in &args {
313    ///         match arg.as_i64() {
314    ///             Some(i) => sum+=i,
315    ///             None => return Err(to_value("Not number").unwrap())
316    ///         }
317    ///     }
318    ///
319    ///     Ok(to_value(sum).unwrap())
320    /// }).expect("Unable to bind function");
321    /// assert_eq!(ui.eval("(async () => await add(1,2,3))();").unwrap(), 6);
322    /// assert!(ui.eval("(async () => await add(1,2,'hi'))();").is_err());
323    /// # }
324    /// ```
325    ///
326    /// [`Future`]: std::future::Future
327    #[cfg(feature = "tokio")]
328    pub fn bind_tokio<F, R>(&self, name: &str, f: F) -> Result<(), JSError>
329    where
330        F: Fn(Vec<JSObject>) -> R + Send + Sync + 'static,
331        R: std::future::Future<Output = JSResult> + Send + 'static,
332    {
333        // Capture the callers runtime, as using tokio::spawn() inside the binding function
334        // will fail as the message processing loop does not have a runtime registered.
335        let runtime = tokio::runtime::Handle::try_current()
336            .map_err(|err| JSError::from(JSObject::String(err.to_string())))?;
337
338        self.bind_async(name, move |context| {
339            // Create future outside the spawn, avoiding the async block capturing `f`, which
340            // would require cloning it. This is fine as futures must not have side effects until
341            // polled. For async fn, this means no user code gets run until the await.
342            let fut = f(context.args().to_vec());
343            runtime.spawn(async move {
344                let result = fut.await;
345                context.complete(result);
346            });
347        })
348    }
349
350    /// Evaluates js code and returns the result.
351    ///
352    /// # Examples
353    ///
354    /// ```
355    /// #![windows_subsystem = "windows"]
356    /// use alcro::UIBuilder;
357    /// let ui = UIBuilder::new().custom_args(&["--headless"]).run().expect("Unable to launch");
358    /// assert_eq!(ui.eval("1+1").unwrap(), 2);
359    /// assert_eq!(ui.eval("'Hello'+' World'").unwrap(), "Hello World");
360    /// assert!(ui.eval("xfgch").is_err());
361    /// ```
362
363    pub fn eval(&self, js: &str) -> JSResult {
364        eval(self.chrome.clone(), js)
365    }
366
367    /// Evaluates js code and adds functions before document loads. Loaded js is unloaded on reload.
368    ///
369    /// # Arguments
370    ///
371    /// * `script` - Javascript that should be loaded
372    ///
373    /// # Examples
374    ///
375    /// ```
376    /// #![windows_subsystem = "windows"]
377    /// use alcro::UIBuilder;
378    /// let ui = UIBuilder::new().custom_args(&["--headless"]).run().expect("Unable to launch");
379    /// ui.load_js("function loadedFunction() { return 'This function was loaded from rust'; }").expect("Unable to load js");
380    /// assert_eq!(ui.eval("loadedFunction()").unwrap(), "This function was loaded from rust");
381    /// ```
382
383    pub fn load_js(&self, script: &str) -> Result<(), JSError> {
384        load_js(self.chrome.clone(), script)
385    }
386
387    /// Loads CSS into current window. Loaded CSS is unloaded on reload.
388    ///
389    /// # Arguments
390    ///
391    /// * `css` - CSS that should be loaded
392    ///
393    /// # Examples
394    ///
395    /// ```
396    /// #![windows_subsystem = "windows"]
397    /// use alcro::UIBuilder;
398    /// let ui = UIBuilder::new().custom_args(&["--headless"]).run().expect("Unable to launch");
399    /// ui.load_css("body {display: none;}").expect("Unable to load css");
400    /// ```
401
402    pub fn load_css(&self, css: &str) -> Result<(), JSError> {
403        load_css(self.chrome.clone(), css)
404    }
405
406    /// It changes the size, position or state of the browser window specified by the `Bounds` struct. It returns Err if it fails.
407    ///
408    /// To change the window state alone use `WindowState::to_bounds()`
409    pub fn set_bounds(&self, b: Bounds) -> Result<(), JSError> {
410        set_bounds(self.chrome.clone(), b)
411    }
412
413    /// It gets the size, position and state of the browser window. It returns Err if it fails.
414    pub fn bounds(&self) -> Result<Bounds, JSObject> {
415        bounds(self.chrome.clone())
416    }
417}
418
419/// Closes the browser window
420impl Drop for UI {
421    fn drop(&mut self) {
422        if !self.waited.load(Ordering::Relaxed) && !self.done() {
423            self.close();
424            self.wait_finish();
425        }
426        #[cfg(target_family = "windows")]
427        close_handle(self.chrome.clone());
428    }
429}
430
431/// Specifies the type of content shown by the browser
432#[derive(Debug, PartialEq, Eq, Hash, Clone, Copy)]
433pub enum Content<'a> {
434    /// The URL
435    Url(&'a str),
436    /// HTML text
437    Html(&'a str),
438}
439
440/// Builder for constructing a UI instance.
441pub struct UIBuilder<'a> {
442    content: Content<'a>,
443    dir: Option<&'a std::path::Path>,
444    width: i32,
445    height: i32,
446    custom_args: &'a [&'a str],
447}
448
449impl<'a> Default for UIBuilder<'a> {
450    fn default() -> Self {
451        Self::new()
452    }
453}
454
455impl<'a> UIBuilder<'a> {
456    /// Default UI
457    pub fn new() -> Self {
458        UIBuilder {
459            content: Content::Html(""),
460            dir: None,
461            width: 800,
462            height: 600,
463            custom_args: &[],
464        }
465    }
466
467    /// Return the UI instance. It returns the Err variant if any error occurs.
468    pub fn run(&self) -> Result<UI, UILaunchError> {
469        let html: String;
470        let url = match self.content {
471            Content::Url(u) => u,
472            Content::Html(h) => {
473                html = format!("data:text/html,{}", h);
474                &html
475            }
476        };
477        UI::new(url, self.dir, self.width, self.height, self.custom_args)
478    }
479
480    /// Set the content (url or html text)
481    pub fn content(&mut self, content: Content<'a>) -> &mut Self {
482        self.content = content;
483        self
484    }
485
486    /// Set the user data directory. By default it is a temporary directory.
487    pub fn user_data_dir(&mut self, dir: &'a std::path::Path) -> &mut Self {
488        self.dir = Some(dir);
489        self
490    }
491
492    /// Set the window size
493    pub fn size(&mut self, width: i32, height: i32) -> &mut Self {
494        self.width = width;
495        self.height = height;
496        self
497    }
498
499    /// Add custom arguments to spawn chrome with
500    pub fn custom_args(&mut self, custom_args: &'a [&'a str]) -> &mut Self {
501        self.custom_args = custom_args;
502        self
503    }
504}