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}