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
61type CustomSchemeUrlFn = Box<dyn Fn(&str) -> String + Send + Sync>;
63
64fn 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: Option<CustomSchemeUrlFn>,
105 app_process: Mutex<Option<AppProcess>>,
106 children: Mutex<Vec<Child>>,
107 webviews: Mutex<HashMap<String, DevtoolsWebviewState>>,
109 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
162struct 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 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#[allow(clippy::too_many_arguments)]
381fn initialize_devtools_webview<R: Runtime>(
382 parent_window: &Window<R>,
384 devtools_window: &Window<R>,
386 app_webview: Option<&Webview<R>>,
388 splashscreen: Option<Webview<R>>,
390 app_process: &AppProcess,
391 display_mode: DisplayMode,
392 position: PhysicalPosition<u32>,
394 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 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 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
449fn inject_devtools_webview<R: Runtime>(
451 window: &Window<R>,
453 display_mode: DisplayMode,
455 url: Url,
457 splashscreen: Option<Webview<R>>,
460 position: PhysicalPosition<u32>,
462 size: PhysicalSize<u32>,
464 app_process: &AppProcess,
466) -> Result<()> {
467 let devtools_webview = if splashscreen.is_some() {
468 #[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 #[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 #[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 #[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 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
577fn 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 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 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 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 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#[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 #[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 #[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 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#[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 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
1166fn 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 #[test]
1250 fn custom_scheme_urls_default_to_the_runtime_format() {
1251 let app = tauri::test::mock_app();
1252
1253 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 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}