#![allow(clippy::items_after_statements, clippy::used_underscore_binding)]
use std::{
collections::HashMap,
fmt::Display,
net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener},
process::Child,
sync::{
atomic::{AtomicBool, Ordering},
Arc, Mutex,
},
time::Duration,
};
use colored::Colorize;
use include_dir::{include_dir, Dir};
use serde::{Deserialize, Serialize, Serializer};
use serialize_to_javascript::{default_template, DefaultTemplate, Template};
use tauri::{
ipc::CapabilityBuilder, menu::ContextMenu, AppHandle, Emitter, Listener, LogicalSize, Manager,
RunEvent, Runtime, State, Url, WindowEvent,
};
use tauri::{
PhysicalPosition, PhysicalSize, Webview, WebviewBuilder, WebviewUrl, Window, WindowBuilder,
};
use tauri_plugin_devtools::{ConnectionInfo, Devtools};
mod devtools_ipc;
mod utils;
static AUTH_DIST: Dir = include_dir!("$CARGO_MANIFEST_DIR/auth-dist");
const STANDALONE_DEVTOOLS_WINDOW_LABEL_PREFIX: &str = "devtools-";
const DEVTOOLS_WEBVIEW_LABEL_PREFIX: &str = "tauri-plugin-devtools-";
const SPLASHSCREEN_LABEL_PREFIX: &str = "devtools-splashscreen-";
const SPLASHSCREEN_ASSETS_URI_SCHEME: &str = "devtools-app";
const RELOAD_MENU_ID: &str = "devtools-app-menu-reload";
#[cfg(any(debug_assertions, feature = "context-menu-inspector"))]
const INSPECT_MENU_ID: &str = "devtools-app-menu-inspect";
const OPEN_DEVTOOLS_MENU_ID: &str = "devtools-app-menu-open-devtools";
const SPLASHSCREEN_TIMEOUT: Duration = Duration::from_secs(2);
const LOCAL_DEV: bool = option_env!("__DEVTOOLS_LOCAL_DEVELOPMENT").is_some();
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error(transparent)]
Tauri(#[from] tauri::Error),
#[error("failed to read stdout: {0}")]
ReadStdout(std::io::Error),
#[error("failed to start devtools")]
FailedToStartDevtools,
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
}
type Result<T> = std::result::Result<T, Error>;
type CustomSchemeUrlFn = Box<dyn Fn(&str) -> String + Send + Sync>;
fn runtime_custom_scheme_url<H: tauri::RuntimeHandle>(handle: &H, scheme: &str) -> String {
handle.custom_scheme_url(scheme, false)
}
impl Serialize for Error {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.to_string().as_ref())
}
}
struct AppProcess {
server_port: Mutex<u16>,
child: Child,
outgoing_message_tx: tokio::sync::mpsc::Sender<devtools_ipc::Message>,
authenticated: Arc<AtomicBool>,
}
impl AppProcess {
fn server_port(&self) -> u16 {
*self.server_port.lock().unwrap()
}
fn dashboard_url(&self, connection: &ConnectionInfo) -> Url {
let host = format!("http://localhost:{}", self.server_port());
let url = format!("{host}/dash/{}/{}/", connection.host, connection.port);
url.parse().unwrap()
}
}
struct DevtoolsApp {
auth_protocol: String,
custom_scheme_url: Option<CustomSchemeUrlFn>,
app_process: Mutex<Option<AppProcess>>,
children: Mutex<Vec<Child>>,
webviews: Mutex<HashMap<String, DevtoolsWebviewState>>,
standalone_window_parents: Mutex<HashMap<String, String>>,
}
impl Default for DevtoolsApp {
fn default() -> Self {
Self {
auth_protocol: String::new(),
custom_scheme_url: None,
app_process: Mutex::new(None),
children: Mutex::new(Vec::new()),
webviews: Mutex::new(HashMap::new()),
standalone_window_parents: Mutex::new(HashMap::new()),
}
}
}
struct DevtoolsWebviewState {
opened: AtomicBool,
display_mode: Mutex<DisplayMode>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
enum DisplayMode {
EmbeddedBottom,
EmbeddedLeft,
EmbeddedRight,
Standalone,
}
impl Default for DisplayMode {
#[cfg(target_os = "linux")]
fn default() -> Self {
Self::Standalone
}
#[cfg(not(target_os = "linux"))]
fn default() -> Self {
Self::EmbeddedBottom
}
}
impl Display for DisplayMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::EmbeddedBottom => write!(f, "EmbeddedBottom"),
Self::EmbeddedLeft => write!(f, "EmbeddedLeft"),
Self::EmbeddedRight => write!(f, "EmbeddedRight"),
Self::Standalone => write!(f, "Standalone"),
}
}
}
struct DevtoolsContextMenu<R: Runtime> {
menu: tauri::menu::Menu<R>,
source_webview: Mutex<Option<Webview<R>>>,
}
fn port_is_available(addr: Ipv4Addr, port: u16) -> bool {
TcpListener::bind(SocketAddr::new(IpAddr::V4(addr), port)).is_ok()
}
impl DevtoolsApp {
fn scheme_url<R: Runtime>(&self, app: &AppHandle<R>, protocol: &str) -> String {
match &self.custom_scheme_url {
Some(custom_scheme_url) => custom_scheme_url(protocol),
None => runtime_custom_scheme_url(app.runtime_handle(), protocol),
}
}
fn auth_login_url<R: Runtime>(&self, app: &AppHandle<R>) -> String {
self.scheme_url(app, &self.auth_protocol)
}
async fn start<R: Runtime>(&self, window: &Window<R>) -> Result<()> {
let child = utils::spawn_devtools_app()?;
let authenticated = Arc::new(AtomicBool::new(false));
let (outgoing_message_tx, outgoing_message_rx) = tokio::sync::mpsc::channel(1);
let app_process = AppProcess {
server_port: Mutex::new(0),
child,
outgoing_message_tx,
authenticated: authenticated.clone(),
};
self.app_process.lock().unwrap().replace(app_process);
let (ready_tx, ready_rx) = std::sync::mpsc::channel();
let (mut got_port, mut got_auth, mut ready) = (false, false, false);
let window = window.clone();
devtools_ipc::start(outgoing_message_rx, move |event| match event {
devtools_ipc::DevtoolsMessage::ServerPort(port) => {
if let Some(app_process) =
&*window.state::<DevtoolsApp>().app_process.lock().unwrap()
{
let mut server_port = app_process.server_port.lock().unwrap();
if *server_port == 0 {
*server_port = port;
window.state::<Devtools>().server_handle.allow_origin(
http::HeaderValue::from_str(&format!("http://localhost:{port}"))
.unwrap(),
);
}
got_port = true;
if !ready {
ready = got_port && got_auth;
if ready {
ready_tx.send(()).unwrap();
}
}
}
}
devtools_ipc::DevtoolsMessage::Authenticated(auth) => {
authenticated.store(auth, Ordering::Relaxed);
if let Some(devtools_webview) = devtools_webview(&window) {
if let Some(app_process) =
&*window.state::<DevtoolsApp>().app_process.lock().unwrap()
{
let _ = devtools_webview.window().set_focus();
let _ = devtools_webview.navigate(if auth {
app_process.dashboard_url(&window.state::<Devtools>().connection)
} else {
window
.state::<DevtoolsApp>()
.auth_login_url(window.app_handle())
.parse()
.unwrap()
});
}
}
got_auth = true;
if !ready {
ready = got_port && got_auth;
if ready {
ready_tx.send(()).unwrap();
}
}
}
devtools_ipc::DevtoolsMessage::AuthError(error) => {
if let Some(devtools_webview) = devtools_webview(&window) {
let _ = devtools_webview.emit("auth-error", &error);
}
}
})
.await?;
ready_rx.recv().unwrap();
Ok(())
}
}
fn create_devtools_webview<R: Runtime>(
window: &Window<R>,
display_mode: DisplayMode,
label: &str,
url: WebviewUrl,
position: PhysicalPosition<u32>,
size: PhysicalSize<u32>,
) -> Result<Webview<R>> {
let webview_builder = WebviewBuilder::new(label, url)
.initialization_script(format!(
"window.__DEVTOOLS_DISPLAY_MODE__ = '{display_mode}'",
))
.auto_resize();
let webview = window.add_child(webview_builder, position, size)?;
Ok(webview)
}
fn app_webview_bounds(
window_size: PhysicalSize<u32>,
devtools_webview_size: PhysicalSize<u32>,
mode: DisplayMode,
) -> (PhysicalSize<u32>, PhysicalPosition<u32>) {
match mode {
DisplayMode::EmbeddedBottom => (
PhysicalSize::new(
window_size.width,
window_size.height - devtools_webview_size.height,
),
PhysicalPosition::new(0, 0),
),
DisplayMode::EmbeddedLeft => (
PhysicalSize::new(
window_size.width - devtools_webview_size.width,
window_size.height,
),
PhysicalPosition::new(devtools_webview_size.width, 0),
),
DisplayMode::EmbeddedRight => (
PhysicalSize::new(
window_size.width - devtools_webview_size.width,
window_size.height,
),
PhysicalPosition::new(0, 0),
),
DisplayMode::Standalone => unimplemented!(),
}
}
fn devtools_webview_bounds(
app_webview_size: PhysicalSize<u32>,
mode: DisplayMode,
) -> (PhysicalSize<u32>, PhysicalPosition<u32>) {
match mode {
DisplayMode::EmbeddedBottom => {
let height = app_webview_size.height / 3;
let size = PhysicalSize::new(app_webview_size.width, height);
let position = PhysicalPosition::new(0, app_webview_size.height - height);
(size, position)
}
DisplayMode::EmbeddedLeft => {
let width = app_webview_size.width / 3;
let size = PhysicalSize::new(width, app_webview_size.height);
let position = PhysicalPosition::new(0, 0);
(size, position)
}
DisplayMode::EmbeddedRight => {
let width = app_webview_size.width / 3;
let size = PhysicalSize::new(width, app_webview_size.height);
let position = PhysicalPosition::new(app_webview_size.width - width, 0);
(size, position)
}
DisplayMode::Standalone => unimplemented!(),
}
}
fn app_webview<R: Runtime>(window: &Window<R>) -> Option<Webview<R>> {
window.webviews().into_iter().find(|w| {
!w.label().starts_with(DEVTOOLS_WEBVIEW_LABEL_PREFIX)
&& !w.label().starts_with(SPLASHSCREEN_LABEL_PREFIX)
})
}
fn devtools_webview<R: Runtime>(window: &Window<R>) -> Option<Webview<R>> {
window
.webviews()
.into_iter()
.find(|w| w.label().starts_with(DEVTOOLS_WEBVIEW_LABEL_PREFIX))
}
fn create_standalone_window<R: Runtime, M: Manager<R>>(
manager: &M,
window_size: LogicalSize<f64>,
visible: bool,
) -> tauri::Result<Window<R>> {
WindowBuilder::new(
manager,
format!(
"{STANDALONE_DEVTOOLS_WINDOW_LABEL_PREFIX}{}",
rand::random::<usize>()
),
)
.inner_size(window_size.width, window_size.height)
.title("CrabNebula DevTools Desktop")
.visible(visible)
.build()
}
#[allow(clippy::too_many_arguments)]
fn initialize_devtools_webview<R: Runtime>(
parent_window: &Window<R>,
devtools_window: &Window<R>,
app_webview: Option<&Webview<R>>,
splashscreen: Option<Webview<R>>,
app_process: &AppProcess,
display_mode: DisplayMode,
position: PhysicalPosition<u32>,
size: PhysicalSize<u32>,
) -> Result<()> {
let devtools_app = parent_window.state::<DevtoolsApp>();
let login_url = devtools_app.auth_login_url(parent_window.app_handle());
let dashboard_url = app_process.dashboard_url(&parent_window.state::<Devtools>().connection);
let dashboard_capability_remote = format!(
"{}://{}:{}/**",
dashboard_url.scheme(),
dashboard_url.host_str().unwrap(),
dashboard_url.port().unwrap(),
);
let devtools_url = if app_process.authenticated.load(Ordering::Relaxed) {
dashboard_url
} else {
login_url.parse().unwrap()
};
parent_window.add_capability(
CapabilityBuilder::new("devtools-app-plugin-runtime")
.window("*")
.webview(format!("{DEVTOOLS_WEBVIEW_LABEL_PREFIX}*"))
.remote(dashboard_capability_remote)
.remote(login_url)
.permission("core:event:default")
.permission("devtools-app:default"),
)?;
if display_mode != DisplayMode::Standalone {
if let Some(w) = &app_webview {
let window_size = parent_window.inner_size()?;
let (app_webview_size, app_webview_position) =
app_webview_bounds(window_size, size, display_mode);
w.set_size(app_webview_size)?;
w.set_position(app_webview_position)?;
}
}
inject_devtools_webview(
devtools_window,
display_mode,
devtools_url,
splashscreen,
position,
size,
app_process,
)?;
Ok(())
}
fn inject_devtools_webview<R: Runtime>(
window: &Window<R>,
display_mode: DisplayMode,
url: Url,
splashscreen: Option<Webview<R>>,
position: PhysicalPosition<u32>,
size: PhysicalSize<u32>,
app_process: &AppProcess,
) -> Result<()> {
let devtools_webview = if splashscreen.is_some() {
#[cfg(target_os = "linux")]
{
let hidden_window = create_standalone_window(
window,
window.inner_size()?.to_logical(window.scale_factor()?),
false,
)?;
create_devtools_webview(
&hidden_window,
display_mode,
&format!("{DEVTOOLS_WEBVIEW_LABEL_PREFIX}{}", rand::random::<usize>()),
WebviewUrl::External(url),
PhysicalPosition::new(0, 0),
hidden_window.inner_size()?,
)?
}
#[cfg(not(target_os = "linux"))]
create_devtools_webview(
window,
display_mode,
&format!("{DEVTOOLS_WEBVIEW_LABEL_PREFIX}{}", rand::random::<usize>()),
WebviewUrl::External(url),
PhysicalPosition::new(0, 0),
PhysicalSize::new(0, 0),
)?
} else {
create_devtools_webview(
window,
display_mode,
&format!("{DEVTOOLS_WEBVIEW_LABEL_PREFIX}{}", rand::random::<usize>()),
WebviewUrl::External(url),
position,
size,
)?
};
let window_ = window.clone();
devtools_webview.listen("disconnect", move |_event| {
let _ = hide_embedded_devtools(&window_);
window_
.state::<DevtoolsApp>()
.webviews
.lock()
.unwrap()
.get(window_.label())
.unwrap()
.opened
.store(false, Ordering::Relaxed);
});
let listener_tx = app_process.outgoing_message_tx.clone();
devtools_webview.listen("login", move |_event| {
let spawn_tx = listener_tx.clone();
tauri::async_runtime::spawn(async move {
if let Err(error) = spawn_tx.send(devtools_ipc::Message::Login).await {
eprintln!("failed sending login message: {error}");
}
});
});
let close_splashscreen =
move |splashscreen_label: &str, app_window: &Window<R>, devtools_webview: &Webview<R>| {
if let Some(splashscreen) = app_window.app_handle().get_webview(splashscreen_label) {
let _ = splashscreen.close();
#[cfg(target_os = "linux")]
{
let devtools_window = devtools_webview.window();
let _ = devtools_webview.reparent(app_window);
let _ = devtools_window.close();
}
#[cfg(not(target_os = "linux"))]
{
let _ = devtools_webview.set_size(size);
let _ = devtools_webview.set_position(position);
}
}
};
if let Some(splashscreen) = splashscreen {
let window_ = window.clone();
let splashscreen_label = splashscreen.label().to_string();
let devtools_webview_ = devtools_webview.clone();
devtools_webview
.clone()
.once("state-changed", move |_event| {
close_splashscreen(&splashscreen_label, &window_, &devtools_webview_);
});
let window_ = window.clone();
let splashscreen_label = splashscreen.label().to_string();
tauri::async_runtime::spawn(async move {
tokio::time::sleep(SPLASHSCREEN_TIMEOUT).await;
close_splashscreen(&splashscreen_label, &window_, &devtools_webview);
});
}
Ok(())
}
fn restore_embedded_devtools_webview_if_exists<R: Runtime>(
window: &Window<R>,
display_mode: DisplayMode,
) -> Result<bool> {
if let Some(w) = devtools_webview(window) {
let app_webview = app_webview(window);
let app_webview_size = if let Some(w) = &app_webview {
w.size()?
} else {
window.inner_size()?
};
let (size, position) = devtools_webview_bounds(app_webview_size, display_mode);
w.set_size(size)?;
w.set_position(position)?;
if display_mode != DisplayMode::Standalone {
if let Some(w) = &app_webview {
let window_size = window.inner_size()?;
let (app_webview_size, app_webview_position) =
app_webview_bounds(window_size, size, display_mode);
w.set_size(app_webview_size)?;
w.set_position(app_webview_position)?;
}
}
Ok(true)
} else {
Ok(false)
}
}
fn hide_embedded_devtools<R: Runtime>(window: &Window<R>) -> Result<()> {
for w in window.webviews().into_iter().filter(|w| {
w.label().starts_with(DEVTOOLS_WEBVIEW_LABEL_PREFIX)
|| w.label().starts_with(SPLASHSCREEN_LABEL_PREFIX)
}) {
if let Some(w) = app_webview(window) {
let size = window.inner_size()?;
w.set_size(size)?;
w.set_position(PhysicalPosition::new(0, 0))?;
}
if w.label().starts_with(SPLASHSCREEN_LABEL_PREFIX) {
w.close()?;
} else {
w.set_size(PhysicalSize::new(0, 0))?;
}
}
Ok(())
}
#[tauri::command]
async fn set_display_mode<R: tauri::Runtime>(
app: AppHandle<R>,
window: Window<R>,
webview: Webview<R>,
devtools_app: tauri::State<'_, DevtoolsApp>,
mode: DisplayMode,
) -> Result<()> {
let devtools_window = if window
.label()
.starts_with(STANDALONE_DEVTOOLS_WINDOW_LABEL_PREFIX)
{
let parent_label = devtools_app
.standalone_window_parents
.lock()
.unwrap()
.get(window.label())
.unwrap()
.clone();
if let Some(w) = app.get_window(&parent_label) {
w
} else {
return Ok(());
}
} else {
window.clone()
};
if let Some(webviews) = devtools_app
.webviews
.lock()
.unwrap()
.get(devtools_window.label())
{
let current_display_mode = *webviews.display_mode.lock().unwrap();
if current_display_mode != mode {
if current_display_mode == DisplayMode::Standalone {
webview.reparent(&devtools_window)?;
webview.set_size(PhysicalSize::new(0, 0))?;
window.close()?;
} else {
hide_embedded_devtools(&window)?;
}
if mode == DisplayMode::Standalone {
let standalone_window =
create_standalone_window(&app, LogicalSize::new(800., 600.), true)?;
devtools_app
.standalone_window_parents
.lock()
.unwrap()
.insert(
standalone_window.label().to_string(),
window.label().to_string(),
);
let window_size = standalone_window.inner_size()?;
webview.reparent(&standalone_window)?;
webview.set_size(window_size)?;
webview.set_position(PhysicalPosition::new(0, 0))?;
} else {
restore_embedded_devtools_webview_if_exists(&devtools_window, mode)?;
}
webview.eval(format!(
"window.__DEVTOOLS_DISPLAY_MODE__ = '{mode}'; if (window.__ON_DEVTOOLS_DISPLAY_MODE_CHANGE__) {{ window.__ON_DEVTOOLS_DISPLAY_MODE_CHANGE__() }}",
))?;
*webviews.display_mode.lock().unwrap() = mode;
}
}
Ok(())
}
#[allow(clippy::too_many_lines)]
#[tauri::command]
async fn toggle<R: tauri::Runtime>(
window: Window<R>,
devtools_app: tauri::State<'_, DevtoolsApp>,
) -> Result<()> {
if window
.label()
.starts_with(STANDALONE_DEVTOOLS_WINDOW_LABEL_PREFIX)
{
return Ok(());
}
let (opened, display_mode) = {
let mut webviews = devtools_app.webviews.lock().unwrap();
if let Some(state) = webviews.get(window.label()) {
(
state.opened.load(Ordering::Relaxed),
*state.display_mode.lock().unwrap(),
)
} else {
let opened = false;
let display_mode = DisplayMode::default();
webviews.insert(
window.label().to_string(),
DevtoolsWebviewState {
opened: AtomicBool::new(opened),
display_mode: Mutex::new(display_mode),
},
);
(opened, display_mode)
}
};
let (is_app_running, is_devtools_ready) =
if let Some(app_process) = &*devtools_app.app_process.lock().unwrap() {
(
true,
!port_is_available(Ipv4Addr::LOCALHOST, app_process.server_port()),
)
} else {
(false, false)
};
let app_webview = app_webview(&window);
let (devtools_window, size, position) = match display_mode {
DisplayMode::Standalone => {
let size = PhysicalSize::new(800, 600);
let window_size = size.to_logical(window.scale_factor()?);
let standalone_window = create_standalone_window(&window, window_size, true)?;
window
.state::<DevtoolsApp>()
.standalone_window_parents
.lock()
.unwrap()
.insert(
standalone_window.label().to_string(),
window.label().to_string(),
);
(standalone_window, size, PhysicalPosition::new(0, 0))
}
mode => {
let app_webview_size = if let Some(w) = &app_webview {
w.size()?
} else {
window.inner_size()?
};
let (size, position) = devtools_webview_bounds(app_webview_size, mode);
(window.clone(), size, position)
}
};
let splashscreen = if is_devtools_ready {
None
} else {
let splashscreen: Webview<_> = create_devtools_webview(
&devtools_window,
display_mode,
&format!("{SPLASHSCREEN_LABEL_PREFIX}-{}", rand::random::<usize>()),
WebviewUrl::CustomProtocol(
devtools_app
.scheme_url(window.app_handle(), SPLASHSCREEN_ASSETS_URI_SCHEME)
.parse()
.unwrap(),
),
position,
size,
)?;
Some(splashscreen)
};
if !is_app_running {
devtools_app.start(&window).await?;
}
if display_mode == DisplayMode::Standalone {
let app_process_guard = devtools_app.app_process.lock().unwrap();
let app_process = app_process_guard.as_ref().unwrap();
initialize_devtools_webview(
&window,
&devtools_window,
app_webview.as_ref(),
splashscreen,
app_process,
display_mode,
position,
size,
)?;
} else if opened {
hide_embedded_devtools(&window)?;
} else if !restore_embedded_devtools_webview_if_exists(&window, display_mode)? {
let app_process_guard = devtools_app.app_process.lock().unwrap();
let app_process = app_process_guard.as_ref().unwrap();
initialize_devtools_webview(
&window,
&devtools_window,
app_webview.as_ref(),
splashscreen,
app_process,
display_mode,
position,
size,
)?;
}
devtools_app
.webviews
.lock()
.unwrap()
.get(window.label())
.unwrap()
.opened
.store(!opened, Ordering::Relaxed);
Ok(())
}
#[tauri::command]
async fn show_context_menu<R: Runtime>(
window: Window<R>,
webview: Webview<R>,
context_menu: State<'_, DevtoolsContextMenu<R>>,
) -> tauri::Result<()> {
*context_menu.source_webview.lock().unwrap() = Some(webview);
context_menu.menu.popup(window)?;
Ok(())
}
fn setup_desktop_app_dev(devtools_app: &DevtoolsApp) {
let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../..");
if port_is_available(Ipv4Addr::LOCALHOST, 5173) {
#[cfg(windows)]
let mut command = {
let mut cmd = std::process::Command::new("powershell");
cmd.arg("pnpm");
cmd
};
#[cfg(not(windows))]
let mut command = std::process::Command::new("pnpm");
devtools_app.children.lock().unwrap().push(
command
.arg("dev")
.current_dir(repo_root.join("clients/web"))
.spawn()
.unwrap(),
);
}
std::process::Command::new("cargo")
.args(["build", "-p", "desktop", "--manifest-path"])
.arg(repo_root.join("Cargo.toml"))
.spawn()
.unwrap()
.wait()
.unwrap();
}
#[derive(Default)]
pub struct Builder {
custom_scheme_url: Option<CustomSchemeUrlFn>,
}
impl Builder {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn custom_scheme_url<F>(mut self, f: F) -> Self
where
F: Fn(&str) -> String + Send + Sync + 'static,
{
self.custom_scheme_url = Some(Box::new(f));
self
}
#[allow(clippy::missing_panics_doc)]
#[must_use]
pub fn build<R: Runtime>(self) -> tauri::plugin::TauriPlugin<R> {
let auth_protocol = format!("isolation-{}", uuid::Uuid::new_v4());
let devtools_app = DevtoolsApp {
custom_scheme_url: self.custom_scheme_url,
auth_protocol: auth_protocol.clone(),
..Default::default()
};
if LOCAL_DEV {
setup_desktop_app_dev(&devtools_app);
}
let mut printed_link = false;
#[allow(unused_mut)]
let mut builder = tauri::plugin::Builder::new("devtools-app")
.register_uri_scheme_protocol(SPLASHSCREEN_ASSETS_URI_SCHEME, |_app, _request| {
tauri::http::Response::builder()
.header("Content-Type", "text/html")
.body(include_bytes!("../../assets/splashscreen.html").to_vec())
.unwrap()
})
.register_uri_scheme_protocol(&auth_protocol, |_app, request| {
auth_protocol_handler(&request)
})
.setup(|app, _api| {
app.manage(devtools_app);
app.add_capability(include_str!("../../capabilities/app.json"))?;
app.manage(DevtoolsContextMenu {
menu: create_context_menu(app)?,
source_webview: Mutex::new(None),
});
Ok(())
})
.on_window_ready(move |window| {
if !printed_link {
print_link(&window.state::<Devtools>().connection);
printed_link = true;
}
window.on_menu_event(|window, event| on_menu_event(window, &event));
})
.on_event(|app, event| match event {
RunEvent::Exit => {
let devtools = app.state::<DevtoolsApp>();
let mut children = std::mem::take(&mut *devtools.children.lock().unwrap());
for c in &mut children {
kill_child_recursively(c.id());
let _ = c.kill();
}
let _ = devtools.app_process.lock().unwrap().take().map(|mut p| {
kill_child_recursively(p.child.id());
let _ = p.child.kill();
});
}
RunEvent::WindowEvent {
label,
event: WindowEvent::Destroyed,
..
} => {
if let Some(standalone_window) = app
.state::<DevtoolsApp>()
.standalone_window_parents
.lock()
.unwrap()
.iter()
.find(|(_standalone_label, parent_label)| parent_label == &label)
.and_then(|(standalone_label, _parent_label)| {
app.get_window(standalone_label)
})
{
let _ = standalone_window.close();
}
}
_ => (),
});
#[derive(Template)]
#[default_template("../../scripts/init.js")]
struct InitJavascript<'a> {
os_name: &'a str,
}
let js_init_script = InitJavascript {
os_name: std::env::consts::OS,
}
.render_default(&serialize_to_javascript::Options::default())
.unwrap()
.into_string();
builder = builder
.js_init_script(js_init_script)
.invoke_handler(tauri::generate_handler![
toggle,
set_display_mode,
show_context_menu
]);
builder.build()
}
}
#[allow(clippy::missing_panics_doc)]
#[must_use]
pub fn init<R: Runtime>() -> tauri::plugin::TauriPlugin<R> {
Builder::new().build()
}
fn create_context_menu<R: Runtime>(app: &AppHandle<R>) -> tauri::Result<tauri::menu::Menu<R>> {
let reload =
tauri::menu::MenuItem::with_id(app, RELOAD_MENU_ID, "Reload", true, Option::<&str>::None)?;
#[cfg(any(debug_assertions, feature = "context-menu-inspector"))]
let inspect = tauri::menu::MenuItem::with_id(
app,
INSPECT_MENU_ID,
"Inspect",
true,
Option::<&str>::None,
)?;
let open_devtools = tauri::menu::MenuItem::with_id(
app,
OPEN_DEVTOOLS_MENU_ID,
"Open Devtools",
true,
Option::<&str>::None,
)?;
let context_menu = tauri::menu::Menu::with_items(
app,
&[
&reload,
#[cfg(any(debug_assertions, feature = "context-menu-inspector"))]
&inspect,
&open_devtools,
],
)?;
Ok(context_menu)
}
fn auth_protocol_handler(
request: &tauri::http::Request<Vec<u8>>,
) -> tauri::http::Response<Vec<u8>> {
let path = request.uri().path().trim_start_matches('/');
let path = if path.is_empty() { "index.html" } else { path };
match AUTH_DIST.get_file(path).map(include_dir::File::contents) {
Some(asset) => tauri::http::Response::builder()
.header(
"Content-Type",
tauri::utils::mime_type::MimeType::parse(asset, path),
)
.body(asset.to_vec())
.unwrap(),
None => tauri::http::Response::builder()
.status(200)
.body(Vec::new())
.unwrap(),
}
}
fn context_menu_target_webview<R: Runtime>(window: &Window<R>) -> Option<Webview<R>> {
let stored = {
let ctx = window.state::<DevtoolsContextMenu<R>>();
let guard = ctx.source_webview.lock().unwrap();
(*guard).clone()
};
stored.or_else(|| window.webviews().first().cloned())
}
fn on_menu_event<R: Runtime>(window: &Window<R>, event: &tauri::menu::MenuEvent) {
match event.id().as_ref() {
RELOAD_MENU_ID => {
if let Some(webview) = context_menu_target_webview(window) {
let _ = webview.reload();
}
}
#[cfg(any(debug_assertions, feature = "context-menu-inspector"))]
INSPECT_MENU_ID => {
if let Some(webview) = context_menu_target_webview(window) {
webview.open_devtools();
}
}
OPEN_DEVTOOLS_MENU_ID => {
let window = window.clone();
let devtools_app = window.state::<DevtoolsApp>();
let devtools_window = if window
.label()
.starts_with(STANDALONE_DEVTOOLS_WINDOW_LABEL_PREFIX)
{
let parent_label = devtools_app
.standalone_window_parents
.lock()
.unwrap()
.get(window.label())
.unwrap()
.clone();
if let Some(w) = window.get_window(&parent_label) {
w
} else {
return;
}
} else {
window
};
let is_devtools_open = devtools_window
.state::<DevtoolsApp>()
.webviews
.lock()
.unwrap()
.get(devtools_window.label())
.is_some_and(|w| w.opened.load(Ordering::Relaxed));
if !is_devtools_open {
tauri::async_runtime::spawn(async move {
toggle(devtools_window.clone(), devtools_window.state()).await
});
}
}
_ => (),
}
}
fn kill_child_recursively(process_id: u32) {
#[cfg(windows)]
{
let powershell_path = std::env::var("SYSTEMROOT").map_or_else(
|_| "powershell.exe".to_string(),
|p| format!("{p}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"),
);
let _ = std::process::Command::new(powershell_path)
.arg("-NoProfile")
.arg("-Command")
.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))
.status();
}
#[cfg(unix)]
{
const KILL_CHILDREN_SCRIPT: &[u8] = include_bytes!("../../scripts/kill-children.sh");
let mut kill_children_script_path = std::env::temp_dir();
kill_children_script_path.push("kill-children.sh");
if !kill_children_script_path.exists() {
if let Ok(mut file) = std::fs::File::create(&kill_children_script_path) {
use std::{io::Write, os::unix::fs::PermissionsExt};
let _ = file.write_all(KILL_CHILDREN_SCRIPT);
let mut permissions = file.metadata().unwrap().permissions();
permissions.set_mode(0o770);
let _ = file.set_permissions(permissions);
}
}
let _ = std::process::Command::new(&kill_children_script_path)
.arg(process_id.to_string())
.output();
}
}
fn print_link(connection: &ConnectionInfo) {
let url = format!(
"crabnebula-devtools://dash/{}/{}",
connection.host, connection.port
);
let help_text = format!(
"Alternatively, press {} or right click to open the embedded devtools",
if cfg!(target_os = "macos") {
"Cmd + Shift + M"
} else {
"Ctrl + Shift + M"
}
);
println!(
r"
{} {}{}
{} Desktop: {}
{help_text}
",
"Tauri Devtools App".bright_purple(),
"v".purple(),
env!("CARGO_PKG_VERSION").purple(),
"→".bright_purple(),
url.underline().blue()
);
}
#[cfg(test)]
mod tests {
use super::{auth_protocol_handler, DevtoolsApp, SPLASHSCREEN_ASSETS_URI_SCHEME};
fn content_type(uri: &str) -> String {
let request = tauri::http::Request::builder()
.uri(uri)
.body(Vec::new())
.unwrap();
let response = auth_protocol_handler(&request);
response.headers()["Content-Type"]
.to_str()
.unwrap()
.to_string()
}
#[test]
fn custom_scheme_urls_default_to_the_runtime_format() {
let app = tauri::test::mock_app();
let devtools_app = DevtoolsApp {
auth_protocol: "isolation-test".into(),
..Default::default()
};
assert_eq!(
devtools_app.scheme_url(app.handle(), SPLASHSCREEN_ASSETS_URI_SCHEME),
format!("{SPLASHSCREEN_ASSETS_URI_SCHEME}://localhost")
);
assert_eq!(
devtools_app.auth_login_url(app.handle()),
"isolation-test://localhost"
);
let devtools_app = DevtoolsApp {
custom_scheme_url: Some(Box::new(|scheme| format!("http://{scheme}.localhost"))),
..Default::default()
};
assert_eq!(
devtools_app.scheme_url(app.handle(), SPLASHSCREEN_ASSETS_URI_SCHEME),
format!("http://{SPLASHSCREEN_ASSETS_URI_SCHEME}.localhost")
);
}
#[test]
fn auth_assets_are_resolved_from_the_uri_path() {
for uri in [
"isolation-x://localhost/assets/index.css",
"http://isolation-x.localhost/assets/index.css",
] {
assert_eq!(content_type(uri), "text/css", "{uri}");
}
for uri in [
"isolation-x://localhost",
"isolation-x://localhost/",
"http://isolation-x.localhost/?code=1",
] {
assert!(content_type(uri).starts_with("text/html"), "{uri}");
}
}
}