diff --git a/.changes/expose-test.md b/.changes/expose-test.md new file mode 100644 index 000000000..ca59f8e77 --- /dev/null +++ b/.changes/expose-test.md @@ -0,0 +1,5 @@ +--- +"tauri": patch +--- + +Expose the `test` module behind the `test` Cargo feature. diff --git a/.github/workflows/lint-core.yml b/.github/workflows/lint-core.yml index 0ea262d12..e41264d2a 100644 --- a/.github/workflows/lint-core.yml +++ b/.github/workflows/lint-core.yml @@ -50,7 +50,7 @@ jobs: clippy: - { args: '', key: 'empty' } - { - args: '--features compression,wry,linux-protocol-headers,isolation,custom-protocol,api-all,cli,updater,system-tray,windows7-compat,http-multipart', + args: '--features compression,wry,linux-protocol-headers,isolation,custom-protocol,api-all,cli,updater,system-tray,windows7-compat,http-multipart,test', key: 'all' } - { args: '--features custom-protocol', key: 'custom-protocol' } diff --git a/.github/workflows/test-core.yml b/.github/workflows/test-core.yml index e7dab46ef..7ba58dcc9 100644 --- a/.github/workflows/test-core.yml +++ b/.github/workflows/test-core.yml @@ -56,7 +56,7 @@ jobs: key: api-all } - { - args: --features compression,wry,linux-protocol-headers,isolation,custom-protocol,api-all,cli,updater,system-tray,windows7-compat,http-multipart, + args: --features compression,wry,linux-protocol-headers,isolation,custom-protocol,api-all,cli,updater,system-tray,windows7-compat,http-multipart,test, key: all } diff --git a/core/tauri/Cargo.toml b/core/tauri/Cargo.toml index 9d09bc5f9..abed57e9c 100644 --- a/core/tauri/Cargo.toml +++ b/core/tauri/Cargo.toml @@ -26,6 +26,7 @@ features = [ "devtools", "http-multipart", "icon-png", + "test", "dox" ] rustdoc-args = [ "--cfg", "doc_cfg" ] @@ -126,6 +127,7 @@ cargo_toml = "0.11" [features] default = [ "wry", "compression", "objc-exception" ] +test = [] compression = [ "tauri-macros/compression", "tauri-utils/compression" ] wry = [ "tauri-runtime-wry" ] objc-exception = [ "tauri-runtime-wry/objc-exception" ] diff --git a/core/tauri/src/app.rs b/core/tauri/src/app.rs index 42660a2f4..dcb965cda 100644 --- a/core/tauri/src/app.rs +++ b/core/tauri/src/app.rs @@ -990,7 +990,7 @@ pub struct Builder { invoke_handler: Box>, /// The JS message responder. - invoke_responder: Arc>, + pub(crate) invoke_responder: Arc>, /// The script that initializes the `window.__TAURI_POST_MESSAGE__` function. invoke_initialization_script: String, diff --git a/core/tauri/src/lib.rs b/core/tauri/src/lib.rs index fd05c11b4..3e35152c5 100644 --- a/core/tauri/src/lib.rs +++ b/core/tauri/src/lib.rs @@ -11,6 +11,7 @@ //! The following are a list of [Cargo features](https://doc.rust-lang.org/stable/cargo/reference/manifest.html#the-features-section) that can be enabled or disabled: //! //! - **wry** *(enabled by default)*: Enables the [wry](https://github.com/tauri-apps/wry) runtime. Only disable it if you want a custom runtime. +//! - **test**: Enables the [`test`] module exposing unit test helpers. //! - **dox**: Internal feature to generate Rust documentation without linking on Linux. //! - **objc-exception**: Wrap each msg_send! in a @try/@catch and panics if an exception is caught, preventing Objective-C from unwinding into Rust. //! - **linux-protocol-headers**: Enables headers support for custom protocol requests on Linux. Requires webkit2gtk v2.36 or above. @@ -839,8 +840,8 @@ pub(crate) mod sealed { } } -/// Utilities for unit testing on Tauri applications. -#[cfg(test)] +#[cfg(any(test, feature = "test"))] +#[cfg_attr(doc_cfg, doc(cfg(feature = "test")))] pub mod test; #[cfg(test)] diff --git a/core/tauri/src/scope/ipc.rs b/core/tauri/src/scope/ipc.rs index 3268813bd..c127fce98 100644 --- a/core/tauri/src/scope/ipc.rs +++ b/core/tauri/src/scope/ipc.rs @@ -171,12 +171,16 @@ impl Scope { #[cfg(test)] mod tests { use super::RemoteDomainAccessScope; - use crate::{api::ipc::CallbackFn, test::MockRuntime, App, InvokePayload, Manager, Window}; + use crate::{ + api::ipc::CallbackFn, + test::{assert_ipc_response, mock_app, MockRuntime}, + App, InvokePayload, Manager, Window, + }; const PLUGIN_NAME: &str = "test"; fn test_context(scopes: Vec) -> (App, Window) { - let app = crate::test::mock_app(); + let app = mock_app(); let window = app.get_window("main").unwrap(); for scope in scopes { @@ -186,44 +190,6 @@ mod tests { (app, window) } - fn assert_ipc_response( - window: &Window, - payload: InvokePayload, - expected: Result<&str, &str>, - ) { - let callback = payload.callback; - let error = payload.error; - window.clone().on_message(payload).unwrap(); - - let mut num_tries = 0; - let evaluated_script = loop { - std::thread::sleep(std::time::Duration::from_millis(50)); - let evaluated_script = window.dispatcher().last_evaluated_script(); - if let Some(s) = evaluated_script { - break s; - } - num_tries += 1; - if num_tries == 20 { - panic!("Response script not evaluated"); - } - }; - let (expected_response, fn_name) = match expected { - Ok(payload) => (payload, callback), - Err(payload) => (payload, error), - }; - let expected = format!( - "window[\"_{}\"]({})", - fn_name.0, - crate::api::ipc::serialize_js(&expected_response).unwrap() - ); - - println!("Last evaluated script:"); - println!("{evaluated_script}"); - println!("Expected:"); - println!("{expected}"); - assert!(evaluated_script.contains(&expected)); - } - fn app_version_payload() -> InvokePayload { let callback = CallbackFn(0); let error = CallbackFn(1); diff --git a/core/tauri/src/test/mock_runtime.rs b/core/tauri/src/test/mock_runtime.rs index 76b6c915e..8fe083dfd 100644 --- a/core/tauri/src/test/mock_runtime.rs +++ b/core/tauri/src/test/mock_runtime.rs @@ -3,6 +3,7 @@ // SPDX-License-Identifier: MIT #![allow(dead_code)] +#![allow(missing_docs)] use tauri_runtime::{ menu::{Menu, MenuUpdate}, @@ -12,8 +13,8 @@ use tauri_runtime::{ dpi::{PhysicalPosition, PhysicalSize, Position, Size}, CursorIcon, DetachedWindow, MenuEvent, PendingWindow, WindowEvent, }, - DeviceEventFilter, Dispatch, EventLoopProxy, Icon, Result, RunEvent, Runtime, RuntimeHandle, - UserAttentionType, UserEvent, + DeviceEventFilter, Dispatch, Error, EventLoopProxy, ExitRequestedEventAction, Icon, Result, + RunEvent, Runtime, RuntimeHandle, UserAttentionType, UserEvent, }; #[cfg(all(desktop, feature = "system-tray"))] use tauri_runtime::{ @@ -29,17 +30,60 @@ use uuid::Uuid; use windows::Win32::Foundation::HWND; use std::{ + cell::RefCell, collections::HashMap, fmt, - sync::{Arc, Mutex}, + sync::{ + atomic::{AtomicBool, Ordering}, + mpsc::{channel, sync_channel, Receiver, SyncSender}, + Arc, Mutex, + }, }; type ShortcutMap = HashMap>; +type WindowId = usize; + +enum Message { + Task(Box), + CloseWindow(WindowId), +} + +struct Window; #[derive(Clone)] pub struct RuntimeContext { + is_running: Arc, + windows: Arc>>, shortcuts: Arc>, clipboard: Arc>>, + run_tx: SyncSender, +} + +// SAFETY: we ensure this type is only used on the main thread. +#[allow(clippy::non_send_fields_in_send_ty)] +unsafe impl Send for RuntimeContext {} + +// SAFETY: we ensure this type is only used on the main thread. +#[allow(clippy::non_send_fields_in_send_ty)] +unsafe impl Sync for RuntimeContext {} + +impl RuntimeContext { + fn send_message(&self, message: Message) -> Result<()> { + if self.is_running.load(Ordering::Relaxed) { + self + .run_tx + .send(message) + .map_err(|_| Error::FailedToSendMessage) + } else { + match message { + Message::Task(task) => task(), + Message::CloseWindow(id) => { + self.windows.borrow_mut().remove(&id); + } + } + Ok(()) + } + } } impl fmt::Debug for RuntimeContext { @@ -59,7 +103,7 @@ impl RuntimeHandle for MockRuntimeHandle { type Runtime = MockRuntime; fn create_proxy(&self) -> EventProxy { - unimplemented!() + EventProxy {} } /// Create a new webview window. @@ -67,11 +111,15 @@ impl RuntimeHandle for MockRuntimeHandle { &self, pending: PendingWindow, ) -> Result> { + let id = rand::random(); + self.context.windows.borrow_mut().insert(id, Window); Ok(DetachedWindow { label: pending.label, dispatcher: MockDispatcher { + id, context: self.context.clone(), last_evaluated_script: Default::default(), + url: pending.url, }, menu_ids: Default::default(), js_event_listeners: Default::default(), @@ -80,7 +128,7 @@ impl RuntimeHandle for MockRuntimeHandle { /// Run a task on the main thread. fn run_on_main_thread(&self, f: F) -> Result<()> { - unimplemented!() + self.context.send_message(Message::Task(Box::new(f))) } #[cfg(all(desktop, feature = "system-tray"))] @@ -89,11 +137,24 @@ impl RuntimeHandle for MockRuntimeHandle { &self, system_tray: SystemTray, ) -> Result<>::TrayHandler> { - unimplemented!() + Ok(MockTrayHandler { + context: self.context.clone(), + }) } fn raw_display_handle(&self) -> raw_window_handle::RawDisplayHandle { - unimplemented!() + #[cfg(target_os = "linux")] + return raw_window_handle::RawDisplayHandle::Xlib(raw_window_handle::XlibDisplayHandle::empty()); + #[cfg(target_os = "macos")] + return raw_window_handle::RawDisplayHandle::AppKit( + raw_window_handle::AppKitDisplayHandle::empty(), + ); + #[cfg(windows)] + return raw_window_handle::RawDisplayHandle::Windows( + raw_window_handle::WindowsDisplayHandle::empty(), + ); + #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))] + return unimplemented!(); } /// Shows the application, but does not automatically focus it. @@ -111,7 +172,9 @@ impl RuntimeHandle for MockRuntimeHandle { #[derive(Debug, Clone)] pub struct MockDispatcher { + id: WindowId, context: RuntimeContext, + url: String, last_evaluated_script: Arc>>, } @@ -331,7 +394,7 @@ impl Dispatch for MockDispatcher { type WindowBuilder = MockWindowBuilder; fn run_on_main_thread(&self, f: F) -> Result<()> { - Ok(()) + self.context.send_message(Message::Task(Box::new(f))) } fn on_window_event(&self, f: F) -> Uuid { @@ -354,7 +417,7 @@ impl Dispatch for MockDispatcher { } fn url(&self) -> Result { - todo!() + self.url.parse().map_err(|_| Error::FailedToReceiveMessage) } fn scale_factor(&self) -> Result { @@ -459,7 +522,20 @@ impl Dispatch for MockDispatcher { } fn raw_window_handle(&self) -> Result { - unimplemented!() + #[cfg(target_os = "linux")] + return Ok(raw_window_handle::RawWindowHandle::Xlib( + raw_window_handle::XlibWindowHandle::empty(), + )); + #[cfg(target_os = "macos")] + return Ok(raw_window_handle::RawWindowHandle::AppKit( + raw_window_handle::AppKitWindowHandle::empty(), + )); + #[cfg(windows)] + return Ok(raw_window_handle::RawWindowHandle::Win32( + raw_window_handle::Win32WindowHandle::empty(), + )); + #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))] + return unimplemented!(); } fn center(&self) -> Result<()> { @@ -478,7 +554,19 @@ impl Dispatch for MockDispatcher { &mut self, pending: PendingWindow, ) -> Result> { - unimplemented!() + let id = rand::random(); + self.context.windows.borrow_mut().insert(id, Window); + Ok(DetachedWindow { + label: pending.label, + dispatcher: MockDispatcher { + id, + context: self.context.clone(), + last_evaluated_script: Default::default(), + url: pending.url, + }, + menu_ids: Default::default(), + js_event_listeners: Default::default(), + }) } fn set_resizable(&self, resizable: bool) -> Result<()> { @@ -534,6 +622,7 @@ impl Dispatch for MockDispatcher { } fn close(&self) -> Result<()> { + self.context.send_message(Message::CloseWindow(self.id))?; Ok(()) } @@ -666,6 +755,7 @@ impl EventLoopProxy for EventProxy { #[derive(Debug)] pub struct MockRuntime { + is_running: Arc, pub context: RuntimeContext, #[cfg(all(desktop, feature = "global-shortcut"))] global_shortcut_manager: MockGlobalShortcutManager, @@ -673,15 +763,22 @@ pub struct MockRuntime { clipboard_manager: MockClipboardManager, #[cfg(all(desktop, feature = "system-tray"))] tray_handler: MockTrayHandler, + run_rx: Receiver, } impl MockRuntime { fn init() -> Self { + let is_running = Arc::new(AtomicBool::new(false)); + let (tx, rx) = sync_channel(1); let context = RuntimeContext { + is_running: is_running.clone(), + windows: Default::default(), shortcuts: Default::default(), clipboard: Default::default(), + run_tx: tx, }; Self { + is_running, #[cfg(all(desktop, feature = "global-shortcut"))] global_shortcut_manager: MockGlobalShortcutManager { context: context.clone(), @@ -695,6 +792,7 @@ impl MockRuntime { context: context.clone(), }, context, + run_rx: rx, } } } @@ -720,7 +818,7 @@ impl Runtime for MockRuntime { } fn create_proxy(&self) -> EventProxy { - unimplemented!() + EventProxy {} } fn handle(&self) -> Self::Handle { @@ -740,11 +838,15 @@ impl Runtime for MockRuntime { } fn create_window(&self, pending: PendingWindow) -> Result> { + let id = rand::random(); + self.context.windows.borrow_mut().insert(id, Window); Ok(DetachedWindow { label: pending.label, dispatcher: MockDispatcher { + id, context: self.context.clone(), last_evaluated_script: Default::default(), + url: pending.url, }, menu_ids: Default::default(), js_event_listeners: Default::default(), @@ -791,9 +893,39 @@ impl Runtime for MockRuntime { Default::default() } - fn run) + 'static>(self, callback: F) { + fn run) + 'static>(self, mut callback: F) { + self.is_running.store(true, Ordering::Relaxed); + callback(RunEvent::Ready); + loop { + if let Ok(m) = self.run_rx.try_recv() { + match m { + Message::Task(p) => p(), + Message::CloseWindow(id) => { + let removed = self.context.windows.borrow_mut().remove(&id).is_some(); + if removed { + let is_empty = self.context.windows.borrow().is_empty(); + if is_empty { + let (tx, rx) = channel(); + callback(RunEvent::ExitRequested { tx }); + + let recv = rx.try_recv(); + let should_prevent = matches!(recv, Ok(ExitRequestedEventAction::Prevent)); + + if !should_prevent { + break; + } + } + } + } + } + } + + callback(RunEvent::MainEventsCleared); + std::thread::sleep(std::time::Duration::from_secs(1)); } + + callback(RunEvent::Exit); } } diff --git a/core/tauri/src/test/mod.rs b/core/tauri/src/test/mod.rs index ea19c0926..eaadae04f 100644 --- a/core/tauri/src/test/mod.rs +++ b/core/tauri/src/test/mod.rs @@ -2,23 +2,102 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT +//! Utilities for unit testing on Tauri applications. +//! +//! # Stability +//! +//! This module is unstable. +//! +//! # Examples +//! +//! ```rust +//! #[tauri::command] +//! fn my_cmd() {} +//! +//! fn create_app(mut builder: tauri::Builder) -> tauri::App { +//! builder +//! .setup(|app| { +//! // do something +//! Ok(()) +//! }) +//! .invoke_handler(tauri::generate_handler![my_cmd]) +//! // remove the string argument on your app +//! .build(tauri::generate_context!("test/fixture/src-tauri/tauri.conf.json")) +//! .expect("failed to build app") +//! } +//! +//! fn main() { +//! let app = create_app(tauri::Builder::default()); +//! // app.run(|_handle, _event| {}); +//! } +//! +//! //#[cfg(test)] +//! mod tests { +//! use tauri::Manager; +//! //#[cfg(test)] +//! fn something() { +//! let app = super::create_app(tauri::test::mock_builder()); +//! let window = app.get_window("main").unwrap(); +//! // do something with the app and window +//! // in this case we'll run the my_cmd command with no arguments +//! tauri::test::assert_ipc_response( +//! &window, +//! tauri::InvokePayload { +//! cmd: "my_cmd".into(), +//! tauri_module: None, +//! callback: tauri::api::ipc::CallbackFn(0), +//! error: tauri::api::ipc::CallbackFn(1), +//! inner: serde_json::Value::Null, +//! }, +//! Ok(()) +//! ); +//! } +//! } +//! ``` + #![allow(unused_variables)] mod mock_runtime; pub use mock_runtime::*; +use serde::Serialize; +use serde_json::Value as JsonValue; -#[cfg(shell_scope)] -use std::collections::HashMap; -use std::{borrow::Cow, sync::Arc}; +use std::{ + borrow::Cow, + collections::HashMap, + fmt::Debug, + hash::{Hash, Hasher}, + sync::{ + mpsc::{channel, Sender}, + Arc, Mutex, + }, +}; +use crate::hooks::window_invoke_responder; #[cfg(shell_scope)] use crate::ShellScopeConfig; -use crate::{Manager, Pattern}; +use crate::{api::ipc::CallbackFn, App, Builder, Context, InvokePayload, Manager, Pattern, Window}; use tauri_utils::{ assets::{AssetKey, Assets, CspHash}, config::{CliConfig, Config, PatternKind, TauriConfig}, }; +#[derive(Eq, PartialEq)] +struct IpcKey { + callback: CallbackFn, + error: CallbackFn, +} + +impl Hash for IpcKey { + fn hash(&self, state: &mut H) { + self.callback.0.hash(state); + self.error.0.hash(state); + } +} + +struct Ipc(Mutex>>>); + +/// An empty [`Assets`] implementation. pub struct NoopAsset { csp_hashes: Vec>, } @@ -33,14 +112,16 @@ impl Assets for NoopAsset { } } +/// Creates a new empty [`Assets`] implementation. pub fn noop_assets() -> NoopAsset { NoopAsset { csp_hashes: Default::default(), } } +/// Creates a new [`crate::Context`] for testing. pub fn mock_context(assets: A) -> crate::Context { - crate::Context { + Context { config: Config { schema: None, package: Default::default(), @@ -85,12 +166,113 @@ pub fn mock_context(assets: A) -> crate::Context { } } -pub fn mock_app() -> crate::App { - crate::Builder::::new() - .build(mock_context(noop_assets())) - .unwrap() +/// Creates a new [`Builder`] using the [`MockRuntime`]. +/// +/// To use a dummy [`Context`], see [`mock_app`]. +/// +/// # Examples +/// +/// ```rust +/// #[cfg(test)] +/// fn do_something() { +/// let app = tauri::test::mock_builder() +/// // remove the string argument to use your app's config file +/// .build(tauri::generate_context!("test/fixture/src-tauri/tauri.conf.json")) +/// .unwrap(); +/// } +/// ``` +pub fn mock_builder() -> Builder { + let mut builder = Builder::::new().manage(Ipc(Default::default())); + + builder.invoke_responder = Arc::new(|window, response, callback, error| { + let window_ = window.clone(); + let ipc = window_.state::(); + let mut ipc_ = ipc.0.lock().unwrap(); + if let Some(tx) = ipc_.remove(&IpcKey { callback, error }) { + tx.send(response.into_result()).unwrap(); + } else { + window_invoke_responder(window, response, callback, error) + } + }); + + builder } +/// Creates a new [`App`] for testing using the [`mock_context`] with a [`noop_assets`]. +pub fn mock_app() -> App { + mock_builder().build(mock_context(noop_assets())).unwrap() +} + +/// Executes the given IPC message and assert the response matches the expected value. +/// +/// # Examples +/// +/// ```rust +/// #[tauri::command] +/// fn ping() -> &'static str { +/// "pong" +/// } +/// +/// fn create_app(mut builder: tauri::Builder) -> tauri::App { +/// builder +/// .invoke_handler(tauri::generate_handler![ping]) +/// // remove the string argument on your app +/// .build(tauri::generate_context!("test/fixture/src-tauri/tauri.conf.json")) +/// .expect("failed to build app") +/// } +/// +/// fn main() { +/// let app = create_app(tauri::Builder::default()); +/// // app.run(|_handle, _event| {});} +/// } +/// +/// //#[cfg(test)] +/// mod tests { +/// use tauri::Manager; +/// +/// //#[cfg(test)] +/// fn something() { +/// let app = super::create_app(tauri::test::mock_builder()); +/// let window = app.get_window("main").unwrap(); +/// +/// // run the `ping` command and assert it returns `pong` +/// tauri::test::assert_ipc_response( +/// &window, +/// tauri::InvokePayload { +/// cmd: "ping".into(), +/// tauri_module: None, +/// callback: tauri::api::ipc::CallbackFn(0), +/// error: tauri::api::ipc::CallbackFn(1), +/// inner: serde_json::Value::Null, +/// }, +/// // the expected response is a success with the "pong" payload +/// // we could also use Err("error message") here to ensure the command failed +/// Ok("pong") +/// ); +/// } +/// } +/// ``` +pub fn assert_ipc_response( + window: &Window, + payload: InvokePayload, + expected: Result, +) { + let callback = payload.callback; + let error = payload.error; + let ipc = window.state::(); + let (tx, rx) = channel(); + ipc.0.lock().unwrap().insert(IpcKey { callback, error }, tx); + window.clone().on_message(payload).unwrap(); + + assert_eq!( + rx.recv().unwrap(), + expected + .map(|e| serde_json::to_value(e).unwrap()) + .map_err(|e| serde_json::to_value(e).unwrap()) + ); +} + +#[cfg(test)] pub(crate) fn mock_invoke_context() -> crate::endpoints::InvokeContext { let app = mock_app(); crate::endpoints::InvokeContext { @@ -99,3 +281,25 @@ pub(crate) fn mock_invoke_context() -> crate::endpoints::InvokeContext) + Send + 'static>( + builder: tauri::Builder, + setup: F, +) { #[allow(unused_mut)] - let mut builder = tauri::Builder::default() + let mut builder = builder .setup(move |app| { tray::create_tray(app)?; @@ -70,6 +77,8 @@ pub fn run() { } }); + setup(app); + Ok(()) }) .on_page_load(|window, _| { @@ -103,7 +112,6 @@ pub fn run() { #[cfg(target_os = "macos")] app.set_activation_policy(tauri::ActivationPolicy::Regular); - #[allow(unused_variables)] app.run(move |app_handle, e| { match e { // Application is ready (triggered only once) @@ -152,6 +160,7 @@ pub fn run() { ); } } + #[cfg(not(test))] RunEvent::ExitRequested { api, .. } => { // Keep the event loop running even if all windows are closed // This allow us to catch system tray events when there is no window @@ -161,3 +170,19 @@ pub fn run() { } }) } + +#[cfg(test)] +mod tests { + use tauri::Manager; + + #[test] + fn run_app() { + super::run_app(tauri::test::mock_builder(), |app| { + let window = app.get_window("main").unwrap(); + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_secs(1)); + window.close().unwrap(); + }); + }) + } +} diff --git a/examples/api/src-tauri/src/tray.rs b/examples/api/src-tauri/src/tray.rs index c4e2ba82a..e69f99b34 100644 --- a/examples/api/src-tauri/src/tray.rs +++ b/examples/api/src-tauri/src/tray.rs @@ -8,10 +8,11 @@ use tauri::{ dialog::{MessageDialogBuilder, MessageDialogButtons}, shell, }, - CustomMenuItem, Manager, SystemTray, SystemTrayEvent, SystemTrayMenu, WindowBuilder, WindowUrl, + CustomMenuItem, Manager, Runtime, SystemTray, SystemTrayEvent, SystemTrayMenu, WindowBuilder, + WindowUrl, }; -pub fn create_tray(app: &tauri::App) -> tauri::Result<()> { +pub fn create_tray(app: &tauri::App) -> tauri::Result<()> { let mut tray_menu1 = SystemTrayMenu::new() .add_item(CustomMenuItem::new("toggle", "Toggle")) .add_item(CustomMenuItem::new("new", "New window"))