Skip to main content

tauri_plugin_devtools_app/desktop/
mod.rs

1#![allow(clippy::items_after_statements, clippy::used_underscore_binding)]
2
3use std::{
4    collections::HashMap,
5    fmt::Display,
6    net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener},
7    process::Child,
8    sync::{
9        atomic::{AtomicBool, Ordering},
10        Arc, Mutex,
11    },
12    time::Duration,
13};
14
15use colored::Colorize;
16use include_dir::{include_dir, Dir};
17use serde::{Deserialize, Serialize, Serializer};
18use serialize_to_javascript::{default_template, DefaultTemplate, Template};
19use tauri::{
20    ipc::CapabilityBuilder, menu::ContextMenu, AppHandle, Emitter, Listener, LogicalSize, Manager,
21    RunEvent, Runtime, State, Url, WindowEvent,
22};
23use tauri::{
24    PhysicalPosition, PhysicalSize, Webview, WebviewBuilder, WebviewUrl, Window, WindowBuilder,
25};
26use tauri_plugin_devtools::{ConnectionInfo, Devtools};
27
28mod devtools_ipc;
29mod utils;
30
31static AUTH_DIST: Dir = include_dir!("$CARGO_MANIFEST_DIR/auth-dist");
32
33const STANDALONE_DEVTOOLS_WINDOW_LABEL_PREFIX: &str = "devtools-";
34const DEVTOOLS_WEBVIEW_LABEL_PREFIX: &str = "tauri-plugin-devtools-";
35const SPLASHSCREEN_LABEL_PREFIX: &str = "devtools-splashscreen-";
36const SPLASHSCREEN_ASSETS_URI_SCHEME: &str = "devtools-app";
37
38const RELOAD_MENU_ID: &str = "devtools-app-menu-reload";
39#[cfg(any(debug_assertions, feature = "context-menu-inspector"))]
40const INSPECT_MENU_ID: &str = "devtools-app-menu-inspect";
41const OPEN_DEVTOOLS_MENU_ID: &str = "devtools-app-menu-open-devtools";
42
43const SPLASHSCREEN_TIMEOUT: Duration = Duration::from_secs(2);
44
45const LOCAL_DEV: bool = option_env!("__DEVTOOLS_LOCAL_DEVELOPMENT").is_some();
46
47#[derive(Debug, thiserror::Error)]
48pub enum Error {
49    #[error(transparent)]
50    Tauri(#[from] tauri::Error),
51    #[error("failed to read stdout: {0}")]
52    ReadStdout(std::io::Error),
53    #[error("failed to start devtools")]
54    FailedToStartDevtools,
55    #[error("IO error: {0}")]
56    Io(#[from] std::io::Error),
57}
58
59type Result<T> = std::result::Result<T, Error>;
60
61/// Builds the URL (scheme + authority) a registered custom protocol is served from.
62type CustomSchemeUrlFn = Box<dyn Fn(&str) -> String + Send + Sync>;
63
64/// URL the runtime serves a custom protocol from, e.g. `{scheme}://localhost` on wry
65/// or `http://{scheme}.localhost` on CEF and on Windows.
66///
67/// `custom_scheme_url` is defined on the `tauri_runtime::RuntimeHandle` supertrait, which tauri does not
68/// re-export; a bound on the wrapper trait still brings it into scope.
69fn runtime_custom_scheme_url<H: tauri::RuntimeHandle>(handle: &H, scheme: &str) -> String {
70    handle.custom_scheme_url(scheme, false)
71}
72
73impl Serialize for Error {
74    fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
75    where
76        S: Serializer,
77    {
78        serializer.serialize_str(self.to_string().as_ref())
79    }
80}
81
82struct AppProcess {
83    server_port: Mutex<u16>,
84    child: Child,
85    outgoing_message_tx: tokio::sync::mpsc::Sender<devtools_ipc::Message>,
86    authenticated: Arc<AtomicBool>,
87}
88
89impl AppProcess {
90    fn server_port(&self) -> u16 {
91        *self.server_port.lock().unwrap()
92    }
93
94    fn dashboard_url(&self, connection: &ConnectionInfo) -> Url {
95        let host = format!("http://localhost:{}", self.server_port());
96        let url = format!("{host}/dash/{}/{}/", connection.host, connection.port);
97        url.parse().unwrap()
98    }
99}
100
101struct DevtoolsApp {
102    auth_protocol: String,
103    /// Custom scheme URL override, see [`Builder::custom_scheme_url`]. `None` asks the runtime.
104    custom_scheme_url: Option<CustomSchemeUrlFn>,
105    app_process: Mutex<Option<AppProcess>>,
106    children: Mutex<Vec<Child>>,
107    // maps a window label to its devtools webview state
108    webviews: Mutex<HashMap<String, DevtoolsWebviewState>>,
109    // maps a standalone window label to its parent window
110    standalone_window_parents: Mutex<HashMap<String, String>>,
111}
112
113impl Default for DevtoolsApp {
114    fn default() -> Self {
115        Self {
116            auth_protocol: String::new(),
117            custom_scheme_url: None,
118            app_process: Mutex::new(None),
119            children: Mutex::new(Vec::new()),
120            webviews: Mutex::new(HashMap::new()),
121            standalone_window_parents: Mutex::new(HashMap::new()),
122        }
123    }
124}
125
126struct DevtoolsWebviewState {
127    opened: AtomicBool,
128    display_mode: Mutex<DisplayMode>,
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
132enum DisplayMode {
133    EmbeddedBottom,
134    EmbeddedLeft,
135    EmbeddedRight,
136    Standalone,
137}
138
139impl Default for DisplayMode {
140    #[cfg(target_os = "linux")]
141    fn default() -> Self {
142        Self::Standalone
143    }
144
145    #[cfg(not(target_os = "linux"))]
146    fn default() -> Self {
147        Self::EmbeddedBottom
148    }
149}
150
151impl Display for DisplayMode {
152    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153        match self {
154            Self::EmbeddedBottom => write!(f, "EmbeddedBottom"),
155            Self::EmbeddedLeft => write!(f, "EmbeddedLeft"),
156            Self::EmbeddedRight => write!(f, "EmbeddedRight"),
157            Self::Standalone => write!(f, "Standalone"),
158        }
159    }
160}
161
162/// Native context menu and the webview that last opened it (only one menu is visible at a time).
163struct DevtoolsContextMenu<R: Runtime> {
164    menu: tauri::menu::Menu<R>,
165    source_webview: Mutex<Option<Webview<R>>>,
166}
167
168fn port_is_available(addr: Ipv4Addr, port: u16) -> bool {
169    TcpListener::bind(SocketAddr::new(IpAddr::V4(addr), port)).is_ok()
170}
171
172impl DevtoolsApp {
173    /// URL a registered custom protocol is served from, used for the pre-auth devtools UI and the splashscreen.
174    fn scheme_url<R: Runtime>(&self, app: &AppHandle<R>, protocol: &str) -> String {
175        match &self.custom_scheme_url {
176            Some(custom_scheme_url) => custom_scheme_url(protocol),
177            None => runtime_custom_scheme_url(app.runtime_handle(), protocol),
178        }
179    }
180
181    fn auth_login_url<R: Runtime>(&self, app: &AppHandle<R>) -> String {
182        self.scheme_url(app, &self.auth_protocol)
183    }
184
185    async fn start<R: Runtime>(&self, window: &Window<R>) -> Result<()> {
186        let child = utils::spawn_devtools_app()?;
187
188        let authenticated = Arc::new(AtomicBool::new(false));
189
190        let (outgoing_message_tx, outgoing_message_rx) = tokio::sync::mpsc::channel(1);
191
192        let app_process = AppProcess {
193            server_port: Mutex::new(0),
194            child,
195            outgoing_message_tx,
196            authenticated: authenticated.clone(),
197        };
198
199        self.app_process.lock().unwrap().replace(app_process);
200
201        let (ready_tx, ready_rx) = std::sync::mpsc::channel();
202        let (mut got_port, mut got_auth, mut ready) = (false, false, false);
203        let window = window.clone();
204
205        devtools_ipc::start(outgoing_message_rx, move |event| match event {
206            devtools_ipc::DevtoolsMessage::ServerPort(port) => {
207                if let Some(app_process) =
208                    &*window.state::<DevtoolsApp>().app_process.lock().unwrap()
209                {
210                    let mut server_port = app_process.server_port.lock().unwrap();
211                    if *server_port == 0 {
212                        *server_port = port;
213                        window.state::<Devtools>().server_handle.allow_origin(
214                            http::HeaderValue::from_str(&format!("http://localhost:{port}"))
215                                .unwrap(),
216                        );
217                    }
218
219                    got_port = true;
220                    if !ready {
221                        ready = got_port && got_auth;
222                        if ready {
223                            ready_tx.send(()).unwrap();
224                        }
225                    }
226                }
227            }
228            devtools_ipc::DevtoolsMessage::Authenticated(auth) => {
229                authenticated.store(auth, Ordering::Relaxed);
230
231                if let Some(devtools_webview) = devtools_webview(&window) {
232                    if let Some(app_process) =
233                        &*window.state::<DevtoolsApp>().app_process.lock().unwrap()
234                    {
235                        let _ = devtools_webview.window().set_focus();
236                        let _ = devtools_webview.navigate(if auth {
237                            app_process.dashboard_url(&window.state::<Devtools>().connection)
238                        } else {
239                            window
240                                .state::<DevtoolsApp>()
241                                .auth_login_url(window.app_handle())
242                                .parse()
243                                .unwrap()
244                        });
245                    }
246                }
247
248                got_auth = true;
249                if !ready {
250                    ready = got_port && got_auth;
251                    if ready {
252                        ready_tx.send(()).unwrap();
253                    }
254                }
255            }
256            devtools_ipc::DevtoolsMessage::AuthError(error) => {
257                if let Some(devtools_webview) = devtools_webview(&window) {
258                    let _ = devtools_webview.emit("auth-error", &error);
259                }
260            }
261        })
262        .await?;
263
264        ready_rx.recv().unwrap();
265
266        Ok(())
267    }
268}
269
270fn create_devtools_webview<R: Runtime>(
271    window: &Window<R>,
272    display_mode: DisplayMode,
273    label: &str,
274    url: WebviewUrl,
275    position: PhysicalPosition<u32>,
276    size: PhysicalSize<u32>,
277) -> Result<Webview<R>> {
278    let webview_builder = WebviewBuilder::new(label, url)
279        .initialization_script(format!(
280            "window.__DEVTOOLS_DISPLAY_MODE__ = '{display_mode}'",
281        ))
282        .auto_resize();
283
284    let webview = window.add_child(webview_builder, position, size)?;
285
286    Ok(webview)
287}
288
289fn app_webview_bounds(
290    window_size: PhysicalSize<u32>,
291    devtools_webview_size: PhysicalSize<u32>,
292    mode: DisplayMode,
293) -> (PhysicalSize<u32>, PhysicalPosition<u32>) {
294    match mode {
295        DisplayMode::EmbeddedBottom => (
296            PhysicalSize::new(
297                window_size.width,
298                window_size.height - devtools_webview_size.height,
299            ),
300            PhysicalPosition::new(0, 0),
301        ),
302        DisplayMode::EmbeddedLeft => (
303            PhysicalSize::new(
304                window_size.width - devtools_webview_size.width,
305                window_size.height,
306            ),
307            PhysicalPosition::new(devtools_webview_size.width, 0),
308        ),
309        DisplayMode::EmbeddedRight => (
310            PhysicalSize::new(
311                window_size.width - devtools_webview_size.width,
312                window_size.height,
313            ),
314            PhysicalPosition::new(0, 0),
315        ),
316        DisplayMode::Standalone => unimplemented!(),
317    }
318}
319
320fn devtools_webview_bounds(
321    app_webview_size: PhysicalSize<u32>,
322    mode: DisplayMode,
323) -> (PhysicalSize<u32>, PhysicalPosition<u32>) {
324    match mode {
325        DisplayMode::EmbeddedBottom => {
326            let height = app_webview_size.height / 3;
327            let size = PhysicalSize::new(app_webview_size.width, height);
328            let position = PhysicalPosition::new(0, app_webview_size.height - height);
329            (size, position)
330        }
331        DisplayMode::EmbeddedLeft => {
332            let width = app_webview_size.width / 3;
333            let size = PhysicalSize::new(width, app_webview_size.height);
334            let position = PhysicalPosition::new(0, 0);
335            (size, position)
336        }
337        DisplayMode::EmbeddedRight => {
338            let width = app_webview_size.width / 3;
339            let size = PhysicalSize::new(width, app_webview_size.height);
340            let position = PhysicalPosition::new(app_webview_size.width - width, 0);
341            (size, position)
342        }
343        DisplayMode::Standalone => unimplemented!(),
344    }
345}
346
347fn app_webview<R: Runtime>(window: &Window<R>) -> Option<Webview<R>> {
348    window.webviews().into_iter().find(|w| {
349        !w.label().starts_with(DEVTOOLS_WEBVIEW_LABEL_PREFIX)
350            && !w.label().starts_with(SPLASHSCREEN_LABEL_PREFIX)
351    })
352}
353
354fn devtools_webview<R: Runtime>(window: &Window<R>) -> Option<Webview<R>> {
355    window
356        .webviews()
357        .into_iter()
358        .find(|w| w.label().starts_with(DEVTOOLS_WEBVIEW_LABEL_PREFIX))
359}
360
361fn create_standalone_window<R: Runtime, M: Manager<R>>(
362    manager: &M,
363    window_size: LogicalSize<f64>,
364    visible: bool,
365) -> tauri::Result<Window<R>> {
366    WindowBuilder::new(
367        manager,
368        format!(
369            "{STANDALONE_DEVTOOLS_WINDOW_LABEL_PREFIX}{}",
370            rand::random::<usize>()
371        ),
372    )
373    .inner_size(window_size.width, window_size.height)
374    .title("CrabNebula DevTools Desktop")
375    .visible(visible)
376    .build()
377}
378
379// initialize the devtools webview for the first time on the given window
380#[allow(clippy::too_many_arguments)]
381fn initialize_devtools_webview<R: Runtime>(
382    // the window that requested the webview to be created
383    parent_window: &Window<R>,
384    // the window where we should inject the webview
385    devtools_window: &Window<R>,
386    // the application webview if we found one
387    app_webview: Option<&Webview<R>>,
388    // splashscreen if we're using one (when the devtools is already ready, we do not use it)
389    splashscreen: Option<Webview<R>>,
390    app_process: &AppProcess,
391    display_mode: DisplayMode,
392    // where we should inject the webview
393    position: PhysicalPosition<u32>,
394    // webview size
395    size: PhysicalSize<u32>,
396) -> Result<()> {
397    let devtools_app = parent_window.state::<DevtoolsApp>();
398    let login_url = devtools_app.auth_login_url(parent_window.app_handle());
399
400    let dashboard_url = app_process.dashboard_url(&parent_window.state::<Devtools>().connection);
401    let dashboard_capability_remote = format!(
402        "{}://{}:{}/**",
403        dashboard_url.scheme(),
404        dashboard_url.host_str().unwrap(),
405        dashboard_url.port().unwrap(),
406    );
407
408    let devtools_url = if app_process.authenticated.load(Ordering::Relaxed) {
409        dashboard_url
410    } else {
411        login_url.parse().unwrap()
412    };
413
414    // adds the permissions required by the devtools frontend
415    parent_window.add_capability(
416        CapabilityBuilder::new("devtools-app-plugin-runtime")
417            .window("*")
418            .webview(format!("{DEVTOOLS_WEBVIEW_LABEL_PREFIX}*"))
419            .remote(dashboard_capability_remote)
420            .remote(login_url)
421            .permission("core:event:default")
422            .permission("devtools-app:default"),
423    )?;
424
425    // on embedded mode we should resize the app webview
426    if display_mode != DisplayMode::Standalone {
427        if let Some(w) = &app_webview {
428            let window_size = parent_window.inner_size()?;
429            let (app_webview_size, app_webview_position) =
430                app_webview_bounds(window_size, size, display_mode);
431            w.set_size(app_webview_size)?;
432            w.set_position(app_webview_position)?;
433        }
434    }
435
436    inject_devtools_webview(
437        devtools_window,
438        display_mode,
439        devtools_url,
440        splashscreen,
441        position,
442        size,
443        app_process,
444    )?;
445
446    Ok(())
447}
448
449// actually create the devtools webview in the given window
450fn inject_devtools_webview<R: Runtime>(
451    // the window where we should inject the devtools webview
452    window: &Window<R>,
453    // the display mode
454    display_mode: DisplayMode,
455    // URL to load
456    url: Url,
457    // if there's a splashscreen running, we should load the devtools as initially hidden
458    // and close the splashscreen when it is ready
459    splashscreen: Option<Webview<R>>,
460    // where we should inject the webview
461    position: PhysicalPosition<u32>,
462    // webview size
463    size: PhysicalSize<u32>,
464    // application process instance
465    app_process: &AppProcess,
466) -> Result<()> {
467    let devtools_webview = if splashscreen.is_some() {
468        // on Linux we use a hidden window to initialize the webview
469        // because the webview sizing is buggy with the default method
470        #[cfg(target_os = "linux")]
471        {
472            let hidden_window = create_standalone_window(
473                window,
474                window.inner_size()?.to_logical(window.scale_factor()?),
475                false,
476            )?;
477            create_devtools_webview(
478                &hidden_window,
479                display_mode,
480                &format!("{DEVTOOLS_WEBVIEW_LABEL_PREFIX}{}", rand::random::<usize>()),
481                WebviewUrl::External(url),
482                PhysicalPosition::new(0, 0),
483                hidden_window.inner_size()?,
484            )?
485        }
486        // use a "hidden webview" to initialize the devtools (a webview with size 0,0)
487        #[cfg(not(target_os = "linux"))]
488        create_devtools_webview(
489            window,
490            display_mode,
491            &format!("{DEVTOOLS_WEBVIEW_LABEL_PREFIX}{}", rand::random::<usize>()),
492            WebviewUrl::External(url),
493            PhysicalPosition::new(0, 0),
494            PhysicalSize::new(0, 0),
495        )?
496    } else {
497        create_devtools_webview(
498            window,
499            display_mode,
500            &format!("{DEVTOOLS_WEBVIEW_LABEL_PREFIX}{}", rand::random::<usize>()),
501            WebviewUrl::External(url),
502            position,
503            size,
504        )?
505    };
506
507    let window_ = window.clone();
508    devtools_webview.listen("disconnect", move |_event| {
509        let _ = hide_embedded_devtools(&window_);
510        window_
511            .state::<DevtoolsApp>()
512            .webviews
513            .lock()
514            .unwrap()
515            .get(window_.label())
516            .unwrap()
517            .opened
518            .store(false, Ordering::Relaxed);
519    });
520
521    let listener_tx = app_process.outgoing_message_tx.clone();
522    devtools_webview.listen("login", move |_event| {
523        let spawn_tx = listener_tx.clone();
524        tauri::async_runtime::spawn(async move {
525            if let Err(error) = spawn_tx.send(devtools_ipc::Message::Login).await {
526                eprintln!("failed sending login message: {error}");
527            }
528        });
529    });
530
531    let close_splashscreen =
532        move |splashscreen_label: &str, app_window: &Window<R>, devtools_webview: &Webview<R>| {
533            if let Some(splashscreen) = app_window.app_handle().get_webview(splashscreen_label) {
534                let _ = splashscreen.close();
535
536                // webview on Linux was initialized on a separate hidden window,
537                // so let's reparent and close that window
538                #[cfg(target_os = "linux")]
539                {
540                    let devtools_window = devtools_webview.window();
541                    let _ = devtools_webview.reparent(app_window);
542                    let _ = devtools_window.close();
543                }
544
545                // webview was created with size 0,0 at position 0,0
546                // so we must set the actual values now
547                #[cfg(not(target_os = "linux"))]
548                {
549                    let _ = devtools_webview.set_size(size);
550                    let _ = devtools_webview.set_position(position);
551                }
552            }
553        };
554
555    if let Some(splashscreen) = splashscreen {
556        let window_ = window.clone();
557        let splashscreen_label = splashscreen.label().to_string();
558        let devtools_webview_ = devtools_webview.clone();
559        devtools_webview
560            .clone()
561            .once("state-changed", move |_event| {
562                close_splashscreen(&splashscreen_label, &window_, &devtools_webview_);
563            });
564
565        // splashscreen timeout in case some weird error happens in the UI and the state-changed event is not fired
566        let window_ = window.clone();
567        let splashscreen_label = splashscreen.label().to_string();
568        tauri::async_runtime::spawn(async move {
569            tokio::time::sleep(SPLASHSCREEN_TIMEOUT).await;
570            close_splashscreen(&splashscreen_label, &window_, &devtools_webview);
571        });
572    }
573
574    Ok(())
575}
576
577// returns a bool indicating whether it was restored or not
578fn restore_embedded_devtools_webview_if_exists<R: Runtime>(
579    window: &Window<R>,
580    display_mode: DisplayMode,
581) -> Result<bool> {
582    if let Some(w) = devtools_webview(window) {
583        let app_webview = app_webview(window);
584        let app_webview_size = if let Some(w) = &app_webview {
585            w.size()?
586        } else {
587            window.inner_size()?
588        };
589
590        let (size, position) = devtools_webview_bounds(app_webview_size, display_mode);
591        w.set_size(size)?;
592        w.set_position(position)?;
593
594        if display_mode != DisplayMode::Standalone {
595            if let Some(w) = &app_webview {
596                let window_size = window.inner_size()?;
597                let (app_webview_size, app_webview_position) =
598                    app_webview_bounds(window_size, size, display_mode);
599                w.set_size(app_webview_size)?;
600                w.set_position(app_webview_position)?;
601            }
602        }
603
604        Ok(true)
605    } else {
606        Ok(false)
607    }
608}
609
610fn hide_embedded_devtools<R: Runtime>(window: &Window<R>) -> Result<()> {
611    for w in window.webviews().into_iter().filter(|w| {
612        w.label().starts_with(DEVTOOLS_WEBVIEW_LABEL_PREFIX)
613            || w.label().starts_with(SPLASHSCREEN_LABEL_PREFIX)
614    }) {
615        if let Some(w) = app_webview(window) {
616            let size = window.inner_size()?;
617            w.set_size(size)?;
618            w.set_position(PhysicalPosition::new(0, 0))?;
619        }
620
621        if w.label().starts_with(SPLASHSCREEN_LABEL_PREFIX) {
622            w.close()?;
623        } else {
624            w.set_size(PhysicalSize::new(0, 0))?;
625        }
626    }
627    Ok(())
628}
629
630#[tauri::command]
631async fn set_display_mode<R: tauri::Runtime>(
632    app: AppHandle<R>,
633    window: Window<R>,
634    webview: Webview<R>,
635    devtools_app: tauri::State<'_, DevtoolsApp>,
636    mode: DisplayMode,
637) -> Result<()> {
638    let devtools_window = if window
639        .label()
640        .starts_with(STANDALONE_DEVTOOLS_WINDOW_LABEL_PREFIX)
641    {
642        let parent_label = devtools_app
643            .standalone_window_parents
644            .lock()
645            .unwrap()
646            .get(window.label())
647            .unwrap()
648            .clone();
649        if let Some(w) = app.get_window(&parent_label) {
650            w
651        } else {
652            return Ok(());
653        }
654    } else {
655        window.clone()
656    };
657
658    if let Some(webviews) = devtools_app
659        .webviews
660        .lock()
661        .unwrap()
662        .get(devtools_window.label())
663    {
664        let current_display_mode = *webviews.display_mode.lock().unwrap();
665
666        if current_display_mode != mode {
667            if current_display_mode == DisplayMode::Standalone {
668                webview.reparent(&devtools_window)?;
669                webview.set_size(PhysicalSize::new(0, 0))?;
670                window.close()?;
671            } else {
672                hide_embedded_devtools(&window)?;
673            }
674
675            if mode == DisplayMode::Standalone {
676                let standalone_window =
677                    create_standalone_window(&app, LogicalSize::new(800., 600.), true)?;
678
679                devtools_app
680                    .standalone_window_parents
681                    .lock()
682                    .unwrap()
683                    .insert(
684                        standalone_window.label().to_string(),
685                        window.label().to_string(),
686                    );
687
688                let window_size = standalone_window.inner_size()?;
689
690                webview.reparent(&standalone_window)?;
691                webview.set_size(window_size)?;
692                webview.set_position(PhysicalPosition::new(0, 0))?;
693            } else {
694                restore_embedded_devtools_webview_if_exists(&devtools_window, mode)?;
695            }
696
697            webview.eval(format!(
698            "window.__DEVTOOLS_DISPLAY_MODE__ = '{mode}'; if (window.__ON_DEVTOOLS_DISPLAY_MODE_CHANGE__) {{ window.__ON_DEVTOOLS_DISPLAY_MODE_CHANGE__() }}",
699        ))?;
700
701            *webviews.display_mode.lock().unwrap() = mode;
702        }
703    }
704
705    Ok(())
706}
707
708#[allow(clippy::too_many_lines)]
709#[tauri::command]
710async fn toggle<R: tauri::Runtime>(
711    window: Window<R>,
712    devtools_app: tauri::State<'_, DevtoolsApp>,
713) -> Result<()> {
714    // TODO: browser behavior is a little different, when you're on a standalone inspector window
715    // and press `ctrl+shift+i` you always get a new window, but that one cannot change its display mode
716    if window
717        .label()
718        .starts_with(STANDALONE_DEVTOOLS_WINDOW_LABEL_PREFIX)
719    {
720        return Ok(());
721    }
722
723    let (opened, display_mode) = {
724        let mut webviews = devtools_app.webviews.lock().unwrap();
725        if let Some(state) = webviews.get(window.label()) {
726            (
727                state.opened.load(Ordering::Relaxed),
728                *state.display_mode.lock().unwrap(),
729            )
730        } else {
731            let opened = false;
732            let display_mode = DisplayMode::default();
733
734            webviews.insert(
735                window.label().to_string(),
736                DevtoolsWebviewState {
737                    opened: AtomicBool::new(opened),
738                    display_mode: Mutex::new(display_mode),
739                },
740            );
741
742            (opened, display_mode)
743        }
744    };
745
746    let (is_app_running, is_devtools_ready) =
747        if let Some(app_process) = &*devtools_app.app_process.lock().unwrap() {
748            (
749                true,
750                !port_is_available(Ipv4Addr::LOCALHOST, app_process.server_port()),
751            )
752        } else {
753            (false, false)
754        };
755
756    let app_webview = app_webview(&window);
757
758    let (devtools_window, size, position) = match display_mode {
759        DisplayMode::Standalone => {
760            let size = PhysicalSize::new(800, 600);
761            let window_size = size.to_logical(window.scale_factor()?);
762            let standalone_window = create_standalone_window(&window, window_size, true)?;
763
764            window
765                .state::<DevtoolsApp>()
766                .standalone_window_parents
767                .lock()
768                .unwrap()
769                .insert(
770                    standalone_window.label().to_string(),
771                    window.label().to_string(),
772                );
773
774            (standalone_window, size, PhysicalPosition::new(0, 0))
775        }
776        mode => {
777            let app_webview_size = if let Some(w) = &app_webview {
778                w.size()?
779            } else {
780                window.inner_size()?
781            };
782
783            let (size, position) = devtools_webview_bounds(app_webview_size, mode);
784            (window.clone(), size, position)
785        }
786    };
787
788    let splashscreen = if is_devtools_ready {
789        None
790    } else {
791        let splashscreen: Webview<_> = create_devtools_webview(
792            &devtools_window,
793            display_mode,
794            &format!("{SPLASHSCREEN_LABEL_PREFIX}-{}", rand::random::<usize>()),
795            WebviewUrl::CustomProtocol(
796                devtools_app
797                    .scheme_url(window.app_handle(), SPLASHSCREEN_ASSETS_URI_SCHEME)
798                    .parse()
799                    .unwrap(),
800            ),
801            position,
802            size,
803        )?;
804
805        Some(splashscreen)
806    };
807
808    if !is_app_running {
809        devtools_app.start(&window).await?;
810    }
811
812    if display_mode == DisplayMode::Standalone {
813        let app_process_guard = devtools_app.app_process.lock().unwrap();
814        let app_process = app_process_guard.as_ref().unwrap();
815        initialize_devtools_webview(
816            &window,
817            &devtools_window,
818            app_webview.as_ref(),
819            splashscreen,
820            app_process,
821            display_mode,
822            position,
823            size,
824        )?;
825    } else if opened {
826        hide_embedded_devtools(&window)?;
827    } else if !restore_embedded_devtools_webview_if_exists(&window, display_mode)? {
828        let app_process_guard = devtools_app.app_process.lock().unwrap();
829        let app_process = app_process_guard.as_ref().unwrap();
830        initialize_devtools_webview(
831            &window,
832            &devtools_window,
833            app_webview.as_ref(),
834            splashscreen,
835            app_process,
836            display_mode,
837            position,
838            size,
839        )?;
840    }
841
842    devtools_app
843        .webviews
844        .lock()
845        .unwrap()
846        .get(window.label())
847        .unwrap()
848        .opened
849        .store(!opened, Ordering::Relaxed);
850
851    Ok(())
852}
853
854#[tauri::command]
855async fn show_context_menu<R: Runtime>(
856    window: Window<R>,
857    webview: Webview<R>,
858    context_menu: State<'_, DevtoolsContextMenu<R>>,
859) -> tauri::Result<()> {
860    *context_menu.source_webview.lock().unwrap() = Some(webview);
861    context_menu.menu.popup(window)?;
862    Ok(())
863}
864
865fn setup_desktop_app_dev(devtools_app: &DevtoolsApp) {
866    // this crate lives in the `crates/v3` workspace; the desktop app and web client are in the root workspace
867    let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../..");
868
869    if port_is_available(Ipv4Addr::LOCALHOST, 5173) {
870        #[cfg(windows)]
871        let mut command = {
872            let mut cmd = std::process::Command::new("powershell");
873            cmd.arg("pnpm");
874            cmd
875        };
876        #[cfg(not(windows))]
877        let mut command = std::process::Command::new("pnpm");
878        // web client is not running, let's do it
879        devtools_app.children.lock().unwrap().push(
880            command
881                .arg("dev")
882                .current_dir(repo_root.join("clients/web"))
883                .spawn()
884                .unwrap(),
885        );
886    }
887    // the Tauri v3 example is a standalone workspace, so `-p desktop` must be resolved against the root one
888    std::process::Command::new("cargo")
889        .args(["build", "-p", "desktop", "--manifest-path"])
890        .arg(repo_root.join("Cargo.toml"))
891        .spawn()
892        .unwrap()
893        .wait()
894        .unwrap();
895}
896
897/// Builder for the devtools-app plugin.
898///
899/// [`init`] is equivalent to `Builder::new().build()`.
900///
901/// The [`custom_scheme_url`](Builder::custom_scheme_url) closure receives a registered
902/// custom-protocol name (e.g. `isolation-…`) and returns the URL it is served from, which is used
903/// for the pre-auth devtools UI and the splashscreen. By default that URL comes from the webview runtime
904/// (`{scheme}://localhost` on wry, `http://{scheme}.localhost` on CEF and on Windows), so it only needs
905/// to be set for runtimes that serve custom protocols somewhere else than they report.
906#[derive(Default)]
907pub struct Builder {
908    custom_scheme_url: Option<CustomSchemeUrlFn>,
909}
910
911impl Builder {
912    #[must_use]
913    pub fn new() -> Self {
914        Self::default()
915    }
916
917    /// Overrides the URL custom protocols are served from instead of asking the webview runtime.
918    #[must_use]
919    pub fn custom_scheme_url<F>(mut self, f: F) -> Self
920    where
921        F: Fn(&str) -> String + Send + Sync + 'static,
922    {
923        self.custom_scheme_url = Some(Box::new(f));
924        self
925    }
926
927    /// Builds the plugin (same as [`init`] when using default options).
928    #[allow(clippy::missing_panics_doc)]
929    #[must_use]
930    pub fn build<R: Runtime>(self) -> tauri::plugin::TauriPlugin<R> {
931        let auth_protocol = format!("isolation-{}", uuid::Uuid::new_v4());
932
933        let devtools_app = DevtoolsApp {
934            custom_scheme_url: self.custom_scheme_url,
935            auth_protocol: auth_protocol.clone(),
936            ..Default::default()
937        };
938
939        // force desktop app to be built on development
940        if LOCAL_DEV {
941            setup_desktop_app_dev(&devtools_app);
942        }
943
944        let mut printed_link = false;
945
946        #[allow(unused_mut)]
947        let mut builder = tauri::plugin::Builder::new("devtools-app")
948            .register_uri_scheme_protocol(SPLASHSCREEN_ASSETS_URI_SCHEME, |_app, _request| {
949                tauri::http::Response::builder()
950                    .header("Content-Type", "text/html")
951                    .body(include_bytes!("../../assets/splashscreen.html").to_vec())
952                    .unwrap()
953            })
954            .register_uri_scheme_protocol(&auth_protocol, |_app, request| {
955                auth_protocol_handler(&request)
956            })
957            .setup(|app, _api| {
958                app.manage(devtools_app);
959                app.add_capability(include_str!("../../capabilities/app.json"))?;
960
961                app.manage(DevtoolsContextMenu {
962                    menu: create_context_menu(app)?,
963                    source_webview: Mutex::new(None),
964                });
965
966                Ok(())
967            })
968            .on_window_ready(move |window| {
969                if !printed_link {
970                    print_link(&window.state::<Devtools>().connection);
971                    printed_link = true;
972                }
973                window.on_menu_event(|window, event| on_menu_event(window, &event));
974            })
975            .on_event(|app, event| match event {
976                RunEvent::Exit => {
977                    let devtools = app.state::<DevtoolsApp>();
978
979                    let mut children = std::mem::take(&mut *devtools.children.lock().unwrap());
980                    for c in &mut children {
981                        kill_child_recursively(c.id());
982                        let _ = c.kill();
983                    }
984
985                    let _ = devtools.app_process.lock().unwrap().take().map(|mut p| {
986                        kill_child_recursively(p.child.id());
987                        let _ = p.child.kill();
988                    });
989                }
990                RunEvent::WindowEvent {
991                    label,
992                    event: WindowEvent::Destroyed,
993                    ..
994                } => {
995                    if let Some(standalone_window) = app
996                        .state::<DevtoolsApp>()
997                        .standalone_window_parents
998                        .lock()
999                        .unwrap()
1000                        .iter()
1001                        .find(|(_standalone_label, parent_label)| parent_label == &label)
1002                        .and_then(|(standalone_label, _parent_label)| {
1003                            app.get_window(standalone_label)
1004                        })
1005                    {
1006                        let _ = standalone_window.close();
1007                    }
1008                }
1009                _ => (),
1010            });
1011
1012        #[derive(Template)]
1013        #[default_template("../../scripts/init.js")]
1014        struct InitJavascript<'a> {
1015            os_name: &'a str,
1016        }
1017
1018        let js_init_script = InitJavascript {
1019            os_name: std::env::consts::OS,
1020        }
1021        .render_default(&serialize_to_javascript::Options::default())
1022        .unwrap()
1023        .into_string();
1024
1025        builder = builder
1026            .initialization_script(js_init_script)
1027            .invoke_handler(tauri::generate_handler![
1028                toggle,
1029                set_display_mode,
1030                show_context_menu
1031            ]);
1032
1033        builder.build()
1034    }
1035}
1036
1037/// Initializes the Tauri plugin (same as `Builder::new().build()`).
1038///
1039/// For options such as a custom pre-auth login URL, use [`Builder`].
1040#[allow(clippy::missing_panics_doc)]
1041#[must_use]
1042pub fn init<R: Runtime>() -> tauri::plugin::TauriPlugin<R> {
1043    Builder::new().build()
1044}
1045
1046fn create_context_menu<R: Runtime>(app: &AppHandle<R>) -> tauri::Result<tauri::menu::Menu<R>> {
1047    let reload =
1048        tauri::menu::MenuItem::with_id(app, RELOAD_MENU_ID, "Reload", true, Option::<&str>::None)?;
1049
1050    #[cfg(any(debug_assertions, feature = "context-menu-inspector"))]
1051    let inspect = tauri::menu::MenuItem::with_id(
1052        app,
1053        INSPECT_MENU_ID,
1054        "Inspect",
1055        true,
1056        Option::<&str>::None,
1057    )?;
1058
1059    let open_devtools = tauri::menu::MenuItem::with_id(
1060        app,
1061        OPEN_DEVTOOLS_MENU_ID,
1062        "Open Devtools",
1063        true,
1064        Option::<&str>::None,
1065    )?;
1066    let context_menu = tauri::menu::Menu::with_items(
1067        app,
1068        &[
1069            &reload,
1070            #[cfg(any(debug_assertions, feature = "context-menu-inspector"))]
1071            &inspect,
1072            &open_devtools,
1073        ],
1074    )?;
1075
1076    Ok(context_menu)
1077}
1078
1079fn auth_protocol_handler(
1080    request: &tauri::http::Request<Vec<u8>>,
1081) -> tauri::http::Response<Vec<u8>> {
1082    // runtimes serve custom protocols on different URLs (`{scheme}://localhost/...` on wry,
1083    // `http://{scheme}.localhost/...` on CEF), so the asset is resolved from the URI path only
1084    let path = request.uri().path().trim_start_matches('/');
1085    let path = if path.is_empty() { "index.html" } else { path };
1086
1087    match AUTH_DIST.get_file(path).map(include_dir::File::contents) {
1088        Some(asset) => tauri::http::Response::builder()
1089            .header(
1090                "Content-Type",
1091                tauri::utils::mime_type::MimeType::parse(asset, path),
1092            )
1093            .body(asset.to_vec())
1094            .unwrap(),
1095        None => tauri::http::Response::builder()
1096            .status(200)
1097            .body(Vec::new())
1098            .unwrap(),
1099    }
1100}
1101
1102fn context_menu_target_webview<R: Runtime>(window: &Window<R>) -> Option<Webview<R>> {
1103    let stored = {
1104        let ctx = window.state::<DevtoolsContextMenu<R>>();
1105        let guard = ctx.source_webview.lock().unwrap();
1106        (*guard).clone()
1107    };
1108    stored.or_else(|| window.webviews().first().cloned())
1109}
1110
1111fn on_menu_event<R: Runtime>(window: &Window<R>, event: &tauri::menu::MenuEvent) {
1112    match event.id().as_ref() {
1113        RELOAD_MENU_ID => {
1114            if let Some(webview) = context_menu_target_webview(window) {
1115                let _ = webview.reload();
1116            }
1117        }
1118        #[cfg(any(debug_assertions, feature = "context-menu-inspector"))]
1119        INSPECT_MENU_ID => {
1120            if let Some(webview) = context_menu_target_webview(window) {
1121                webview.open_devtools();
1122            }
1123        }
1124        OPEN_DEVTOOLS_MENU_ID => {
1125            let window = window.clone();
1126
1127            let devtools_app = window.state::<DevtoolsApp>();
1128            let devtools_window = if window
1129                .label()
1130                .starts_with(STANDALONE_DEVTOOLS_WINDOW_LABEL_PREFIX)
1131            {
1132                let parent_label = devtools_app
1133                    .standalone_window_parents
1134                    .lock()
1135                    .unwrap()
1136                    .get(window.label())
1137                    .unwrap()
1138                    .clone();
1139                if let Some(w) = window.get_window(&parent_label) {
1140                    w
1141                } else {
1142                    return;
1143                }
1144            } else {
1145                window
1146            };
1147
1148            let is_devtools_open = devtools_window
1149                .state::<DevtoolsApp>()
1150                .webviews
1151                .lock()
1152                .unwrap()
1153                .get(devtools_window.label())
1154                .is_some_and(|w| w.opened.load(Ordering::Relaxed));
1155
1156            if !is_devtools_open {
1157                tauri::async_runtime::spawn(async move {
1158                    toggle(devtools_window.clone(), devtools_window.state()).await
1159                });
1160            }
1161        }
1162        _ => (),
1163    }
1164}
1165
1166// kill all children of a process recursively
1167// based on https://github.com/tauri-apps/tauri/blob/4973d73a237dc5c60618c1011e202278e7a29b5c/tooling/cli/src/dev.rs#L453
1168fn kill_child_recursively(process_id: u32) {
1169    #[cfg(windows)]
1170    {
1171        let powershell_path = std::env::var("SYSTEMROOT").map_or_else(
1172            |_| "powershell.exe".to_string(),
1173            |p| format!("{p}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"),
1174        );
1175        let _ = std::process::Command::new(powershell_path)
1176      .arg("-NoProfile")
1177      .arg("-Command")
1178      .arg(format!("function Kill-Tree {{ Param([int]$ppid); Get-CimInstance Win32_Process | Where-Object {{ $_.ParentProcessId -eq $ppid }} | ForEach-Object {{ Kill-Tree $_.ProcessId }}; Stop-Process -Id $ppid -ErrorAction SilentlyContinue }}; Kill-Tree {}", process_id))
1179      .status();
1180    }
1181    #[cfg(unix)]
1182    {
1183        const KILL_CHILDREN_SCRIPT: &[u8] = include_bytes!("../../scripts/kill-children.sh");
1184
1185        let mut kill_children_script_path = std::env::temp_dir();
1186        kill_children_script_path.push("kill-children.sh");
1187
1188        if !kill_children_script_path.exists() {
1189            if let Ok(mut file) = std::fs::File::create(&kill_children_script_path) {
1190                use std::{io::Write, os::unix::fs::PermissionsExt};
1191                let _ = file.write_all(KILL_CHILDREN_SCRIPT);
1192                let mut permissions = file.metadata().unwrap().permissions();
1193                permissions.set_mode(0o770);
1194                let _ = file.set_permissions(permissions);
1195            }
1196        }
1197        let _ = std::process::Command::new(&kill_children_script_path)
1198            .arg(process_id.to_string())
1199            .output();
1200    }
1201}
1202
1203fn print_link(connection: &ConnectionInfo) {
1204    let url = format!(
1205        "crabnebula-devtools://dash/{}/{}",
1206        connection.host, connection.port
1207    );
1208    let help_text = format!(
1209        "Alternatively, press {} or right click to open the embedded devtools",
1210        if cfg!(target_os = "macos") {
1211            "Cmd + Shift + M"
1212        } else {
1213            "Ctrl + Shift + M"
1214        }
1215    );
1216
1217    println!(
1218        r"
1219   {} {}{}
1220   {}   Desktop: {}
1221       {help_text}
1222",
1223        "Tauri Devtools App".bright_purple(),
1224        "v".purple(),
1225        env!("CARGO_PKG_VERSION").purple(),
1226        "→".bright_purple(),
1227        url.underline().blue()
1228    );
1229}
1230
1231#[cfg(test)]
1232mod tests {
1233    use super::{auth_protocol_handler, DevtoolsApp, SPLASHSCREEN_ASSETS_URI_SCHEME};
1234
1235    fn content_type(uri: &str) -> String {
1236        let request = tauri::http::Request::builder()
1237            .uri(uri)
1238            .body(Vec::new())
1239            .unwrap();
1240        let response = auth_protocol_handler(&request);
1241        response.headers()["Content-Type"]
1242            .to_str()
1243            .unwrap()
1244            .to_string()
1245    }
1246
1247    // `Builder::build` is not exercised here: with `__DEVTOOLS_LOCAL_DEVELOPMENT` set (this repository's
1248    // `.cargo/config.toml`) it builds and spawns the desktop app, so the state is constructed directly
1249    #[test]
1250    fn custom_scheme_urls_default_to_the_runtime_format() {
1251        let app = tauri::test::mock_app();
1252
1253        // the mock runtime serves custom protocols from `{scheme}://localhost`
1254        let devtools_app = DevtoolsApp {
1255            auth_protocol: "isolation-test".into(),
1256            ..Default::default()
1257        };
1258        assert_eq!(
1259            devtools_app.scheme_url(app.handle(), SPLASHSCREEN_ASSETS_URI_SCHEME),
1260            format!("{SPLASHSCREEN_ASSETS_URI_SCHEME}://localhost")
1261        );
1262        assert_eq!(
1263            devtools_app.auth_login_url(app.handle()),
1264            "isolation-test://localhost"
1265        );
1266
1267        let devtools_app = DevtoolsApp {
1268            custom_scheme_url: Some(Box::new(|scheme| format!("http://{scheme}.localhost"))),
1269            ..Default::default()
1270        };
1271        assert_eq!(
1272            devtools_app.scheme_url(app.handle(), SPLASHSCREEN_ASSETS_URI_SCHEME),
1273            format!("http://{SPLASHSCREEN_ASSETS_URI_SCHEME}.localhost")
1274        );
1275    }
1276
1277    #[test]
1278    fn auth_assets_are_resolved_from_the_uri_path() {
1279        // wry (`{scheme}://localhost`) and CEF / Windows (`http://{scheme}.localhost`) request URIs
1280        for uri in [
1281            "isolation-x://localhost/assets/index.css",
1282            "http://isolation-x.localhost/assets/index.css",
1283        ] {
1284            assert_eq!(content_type(uri), "text/css", "{uri}");
1285        }
1286        for uri in [
1287            "isolation-x://localhost",
1288            "isolation-x://localhost/",
1289            "http://isolation-x.localhost/?code=1",
1290        ] {
1291            assert!(content_type(uri).starts_with("text/html"), "{uri}");
1292        }
1293    }
1294}