Răsfoiți Sursa

WIP desktop serve hot reload

Evan Almloff 1 an în urmă
părinte
comite
0c5025ffa0

+ 2 - 0
packages/cli/Cargo.toml

@@ -81,6 +81,8 @@ rsx-rosetta = { workspace = true }
 dioxus-rsx = { workspace = true }
 dioxus-html = { workspace = true, features = ["hot-reload-context"] }
 dioxus-core = { workspace = true, features = ["serialize"] }
+dioxus-hot-reload = { workspace = true }
+interprocess-docfix = { version = "1.2.2" }
 
 [features]
 default = []

+ 5 - 2
packages/cli/src/builder.rs

@@ -244,7 +244,7 @@ pub fn build(config: &CrateConfig, quiet: bool) -> Result<BuildResult> {
     })
 }
 
-pub fn build_desktop(config: &CrateConfig, _is_serve: bool) -> Result<()> {
+pub fn build_desktop(config: &CrateConfig, _is_serve: bool) -> Result<BuildResult> {
     log::info!("🚅 Running build [Desktop] command...");
 
     let ignore_files = build_assets(config)?;
@@ -365,7 +365,10 @@ pub fn build_desktop(config: &CrateConfig, _is_serve: bool) -> Result<()> {
         );
     }
 
-    Ok(())
+    Ok(BuildResult {
+        warnings: vec![],
+        elapsed_time: 0,
+    })
 }
 
 fn prettier_build(cmd: subprocess::Exec) -> anyhow::Result<Vec<Diagnostic>> {

+ 6 - 2
packages/cli/src/cli/autoformat.rs

@@ -136,12 +136,16 @@ async fn autoformat_project(check: bool) -> Result<()> {
 }
 
 fn collect_rs_files(folder: &Path, files: &mut Vec<PathBuf>) {
-    let Ok(folder) = folder.read_dir() else { return };
+    let Ok(folder) = folder.read_dir() else {
+        return;
+    };
 
     // load the gitignore
 
     for entry in folder {
-        let Ok(entry) = entry else { continue; };
+        let Ok(entry) = entry else {
+            continue;
+        };
 
         let path = entry.path();
 

+ 4 - 6
packages/cli/src/cli/build.rs

@@ -1,3 +1,4 @@
+use crate::cfg::Platform;
 #[cfg(feature = "plugin")]
 use crate::plugin::PluginManager;
 
@@ -42,16 +43,13 @@ impl Build {
         #[cfg(feature = "plugin")]
         let _ = PluginManager::on_build_start(&crate_config, &platform);
 
-        match platform.as_str() {
-            "web" => {
+        match platform {
+            Platform::Web => {
                 crate::builder::build(&crate_config, false)?;
             }
-            "desktop" => {
+            Platform::Desktop => {
                 crate::builder::build_desktop(&crate_config, false)?;
             }
-            _ => {
-                return custom_error!("Unsupported platform target.");
-            }
         }
 
         let temp = gen_page(&crate_config.dioxus_config, false);

+ 17 - 4
packages/cli/src/cli/cfg.rs

@@ -1,3 +1,6 @@
+use clap::ValueEnum;
+use serde::Serialize;
+
 use super::*;
 
 /// Config options for the build system.
@@ -26,8 +29,8 @@ pub struct ConfigOptsBuild {
     pub profile: Option<String>,
 
     /// Build platform: support Web & Desktop [default: "default_platform"]
-    #[clap(long)]
-    pub platform: Option<String>,
+    #[clap(long, value_enum)]
+    pub platform: Option<Platform>,
 
     /// Space separated list of features to activate
     #[clap(long)]
@@ -69,8 +72,8 @@ pub struct ConfigOptsServe {
     pub profile: Option<String>,
 
     /// Build platform: support Web & Desktop [default: "default_platform"]
-    #[clap(long)]
-    pub platform: Option<String>,
+    #[clap(long, value_enum)]
+    pub platform: Option<Platform>,
 
     /// Build with hot reloading rsx [default: false]
     #[clap(long)]
@@ -88,6 +91,16 @@ pub struct ConfigOptsServe {
     pub features: Option<Vec<String>>,
 }
 
+#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Serialize, Deserialize, Debug)]
+pub enum Platform {
+    #[clap(name = "web")]
+    #[serde(rename = "web")]
+    Web,
+    #[clap(name = "desktop")]
+    #[serde(rename = "desktop")]
+    Desktop,
+}
+
 /// Ensure the given value for `--public-url` is formatted correctly.
 pub fn parse_public_url(val: &str) -> String {
     let prefix = if !val.starts_with('/') { "/" } else { "" };

+ 23 - 23
packages/cli/src/cli/serve.rs

@@ -47,33 +47,33 @@ impl Serve {
                 .clone()
         });
 
-        if platform.as_str() == "desktop" {
-            crate::builder::build_desktop(&crate_config, true)?;
-
-            match &crate_config.executable {
-                crate::ExecutableType::Binary(name)
-                | crate::ExecutableType::Lib(name)
-                | crate::ExecutableType::Example(name) => {
-                    let mut file = crate_config.out_dir.join(name);
-                    if cfg!(windows) {
-                        file.set_extension("exe");
+        match platform {
+            cfg::Platform::Web => {
+                // generate dev-index page
+                Serve::regen_dev_page(&crate_config)?;
+
+                // start the develop server
+                server::web::startup(self.serve.port, crate_config.clone(), self.serve.open)
+                    .await?;
+            }
+            cfg::Platform::Desktop => {
+                crate::builder::build_desktop(&crate_config, true)?;
+
+                match &crate_config.executable {
+                    crate::ExecutableType::Binary(name)
+                    | crate::ExecutableType::Lib(name)
+                    | crate::ExecutableType::Example(name) => {
+                        let mut file = crate_config.out_dir.join(name);
+                        if cfg!(windows) {
+                            file.set_extension("exe");
+                        }
+                        Command::new(file.to_str().unwrap())
+                            .stdout(Stdio::inherit())
+                            .output()?;
                     }
-                    Command::new(file.to_str().unwrap())
-                        .stdout(Stdio::inherit())
-                        .output()?;
                 }
             }
-            return Ok(());
-        } else if platform != "web" {
-            return custom_error!("Unsupported platform target.");
         }
-
-        // generate dev-index page
-        Serve::regen_dev_page(&crate_config)?;
-
-        // start the develop server
-        server::startup(self.serve.port, crate_config.clone(), self.serve.open).await?;
-
         Ok(())
     }
 

+ 3 - 3
packages/cli/src/config.rs

@@ -1,4 +1,4 @@
-use crate::error::Result;
+use crate::{cfg::Platform, error::Result};
 use serde::{Deserialize, Serialize};
 use std::{
     collections::HashMap,
@@ -73,7 +73,7 @@ impl Default for DioxusConfig {
         Self {
             application: ApplicationConfig {
                 name: "dioxus".into(),
-                default_platform: "web".to_string(),
+                default_platform: Platform::Web,
                 out_dir: Some(PathBuf::from("dist")),
                 asset_dir: Some(PathBuf::from("public")),
 
@@ -115,7 +115,7 @@ impl Default for DioxusConfig {
 #[derive(Debug, Clone, Serialize, Deserialize)]
 pub struct ApplicationConfig {
     pub name: String,
-    pub default_platform: String,
+    pub default_platform: Platform,
     pub out_dir: Option<PathBuf>,
     pub asset_dir: Option<PathBuf>,
 

+ 255 - 0
packages/cli/src/server/desktop/mod.rs

@@ -0,0 +1,255 @@
+use crate::{
+    builder,
+    serve::Serve,
+    server::{
+        output::{print_console_info, PrettierOptions, WebServerInfo},
+        setup_file_watcher, setup_file_watcher_hot_reload, BuildManager,
+    },
+    BuildResult, CrateConfig, Result,
+};
+use axum::{
+    body::{Full, HttpBody},
+    extract::{ws::Message, Extension, TypedHeader, WebSocketUpgrade},
+    http::{
+        header::{HeaderName, HeaderValue},
+        Method, Response, StatusCode,
+    },
+    response::IntoResponse,
+    routing::{get, get_service},
+    Router,
+};
+use axum_server::tls_rustls::RustlsConfig;
+use cargo_metadata::diagnostic::Diagnostic;
+use dioxus_core::Template;
+use dioxus_hot_reload::HotReloadMsg;
+use dioxus_html::HtmlCtx;
+use dioxus_rsx::hot_reload::*;
+use interprocess_docfix::local_socket::LocalSocketListener;
+use notify::{RecommendedWatcher, Watcher};
+use std::{
+    net::UdpSocket,
+    path::PathBuf,
+    process::{Command, Stdio},
+    sync::{Arc, Mutex},
+};
+use tokio::sync::broadcast::{self, Sender};
+use tower::ServiceBuilder;
+use tower_http::services::fs::{ServeDir, ServeFileSystemResponseBody};
+use tower_http::{
+    cors::{Any, CorsLayer},
+    ServiceBuilderExt,
+};
+
+#[cfg(feature = "plugin")]
+use plugin::PluginManager;
+
+struct WsReloadState {
+    update: broadcast::Sender<()>,
+}
+
+pub async fn startup(config: CrateConfig) -> Result<()> {
+    // ctrl-c shutdown checker
+    let _crate_config = config.clone();
+    let _ = ctrlc::set_handler(move || {
+        #[cfg(feature = "plugin")]
+        let _ = PluginManager::on_serve_shutdown(&_crate_config);
+        std::process::exit(0);
+    });
+
+    match config.hot_reload {
+        true => serve_hot_reload(config).await?,
+        false => serve_default(config).await?,
+    }
+
+    Ok(())
+}
+
+/// Start the server without hot reload
+pub async fn serve_default(config: CrateConfig) -> Result<()> {
+    let first_build_result = start_desktop(&config)?;
+
+    log::info!("🚀 Starting development server...");
+
+    // We got to own watcher so that it exists for the duration of serve
+    // Otherwise full reload won't work.
+    let _watcher = setup_file_watcher(crate::cfg::Platform::Desktop, &config, None, None).await?;
+
+    // Print serve info
+    print_console_info(
+        &config,
+        PrettierOptions {
+            changed: vec![],
+            warnings: first_build_result.warnings,
+            elapsed_time: first_build_result.elapsed_time,
+        },
+        None,
+    );
+
+    Ok(())
+}
+
+/// Start the server without hot reload
+
+/// Start dx serve with hot reload
+pub async fn serve_hot_reload(config: CrateConfig) -> Result<()> {
+    let first_build_result = start_desktop(&config)?;
+
+    println!("🚀 Starting development server...");
+
+    // Setup hot reload
+    let FileMapBuildResult { map, errors } =
+        FileMap::<HtmlCtx>::create(config.crate_dir.clone()).unwrap();
+
+    println!("🚀 Starting development server...");
+
+    for err in errors {
+        log::error!("{}", err);
+    }
+
+    let file_map = Arc::new(Mutex::new(map));
+    let build_manager = Arc::new(BuildManager {
+        platform: crate::cfg::Platform::Web,
+        config: config.clone(),
+        reload_tx: None,
+    });
+
+    let (hot_reload_tx, mut hot_reload_rx) = broadcast::channel(100);
+
+    // States
+    // Setup file watcher
+    // We got to own watcher so that it exists for the duration of serve
+    // Otherwise hot reload won't work.
+    let _watcher = setup_file_watcher_hot_reload(
+        &config,
+        hot_reload_tx,
+        file_map.clone(),
+        build_manager,
+        None,
+    )
+    .await?;
+
+    // Print serve info
+    print_console_info(
+        &config,
+        PrettierOptions {
+            changed: vec![],
+            warnings: first_build_result.warnings,
+            elapsed_time: first_build_result.elapsed_time,
+        },
+        None,
+    );
+
+    #[cfg(target_os = "macos")]
+    {
+        // On unix, if you force quit the application, it can leave the file socket open
+        // This will cause the local socket listener to fail to open
+        // We check if the file socket is already open from an old session and then delete it
+        let paths = ["./dioxusin", "./@dioxusin"];
+        for path in paths {
+            let path = PathBuf::from(path);
+            if path.exists() {
+                let _ = std::fs::remove_file(path);
+            }
+        }
+    }
+
+    let channels = Arc::new(Mutex::new(Vec::new()));
+
+    match LocalSocketListener::bind("@dioxusin") {
+        Ok(local_socket_stream) => {
+            let aborted = Arc::new(Mutex::new(false));
+
+            // listen for connections
+            std::thread::spawn({
+                let file_map = file_map.clone();
+                let channels = channels.clone();
+                let aborted = aborted.clone();
+                let _ = local_socket_stream.set_nonblocking(true);
+                move || {
+                    loop {
+                        if let Ok(mut connection) = local_socket_stream.accept() {
+                            // send any templates than have changed before the socket connected
+                            let templates: Vec<_> = {
+                                file_map
+                                    .lock()
+                                    .unwrap()
+                                    .map
+                                    .values()
+                                    .filter_map(|(_, template_slot)| *template_slot)
+                                    .collect()
+                            };
+                            for template in templates {
+                                if !send_msg(
+                                    HotReloadMsg::UpdateTemplate(template),
+                                    &mut connection,
+                                ) {
+                                    continue;
+                                }
+                            }
+                            channels.lock().unwrap().push(connection);
+                            println!("Connected to hot reloading 🚀");
+                        }
+                        if *aborted.lock().unwrap() {
+                            break;
+                        }
+                    }
+                }
+            });
+
+            // for channel in &mut *channels.lock().unwrap() {
+            //     send_msg(HotReloadMsg::Shutdown, channel);
+            // }
+
+            while let Ok(template) = hot_reload_rx.recv().await {
+                let channels = &mut *channels.lock().unwrap();
+                let mut i = 0;
+                while i < channels.len() {
+                    let channel = &mut channels[i];
+                    if send_msg(HotReloadMsg::UpdateTemplate(template), channel) {
+                        i += 1;
+                    } else {
+                        channels.remove(i);
+                    }
+                }
+            }
+        }
+        Err(error) => println!("failed to connect to hot reloading\n{error}"),
+    }
+
+    Ok(())
+}
+
+fn send_msg(msg: HotReloadMsg, channel: &mut impl std::io::Write) -> bool {
+    if let Ok(msg) = serde_json::to_string(&msg) {
+        if channel.write_all(msg.as_bytes()).is_err() {
+            return false;
+        }
+        if channel.write_all(&[b'\n']).is_err() {
+            return false;
+        }
+        true
+    } else {
+        false
+    }
+}
+
+pub fn start_desktop(config: &CrateConfig) -> Result<BuildResult> {
+    // Run the desktop application
+    let result = crate::builder::build_desktop(config, true)?;
+
+    match &config.executable {
+        crate::ExecutableType::Binary(name)
+        | crate::ExecutableType::Lib(name)
+        | crate::ExecutableType::Example(name) => {
+            let mut file = config.out_dir.join(name);
+            if cfg!(windows) {
+                file.set_extension("exe");
+            }
+            Command::new(file.to_str().unwrap())
+                .stdout(Stdio::inherit())
+                .spawn()?;
+        }
+    }
+
+    Ok(result)
+}

+ 40 - 452
packages/cli/src/server/mod.rs

@@ -1,4 +1,7 @@
-use crate::{builder, serve::Serve, BuildResult, CrateConfig, Result};
+use crate::{
+    builder, cfg::Platform, serve::Serve, server::desktop::start_desktop, BuildResult, CrateConfig,
+    Result,
+};
 use axum::{
     body::{Full, HttpBody},
     extract::{ws::Message, Extension, TypedHeader, WebSocketUpgrade},
@@ -19,7 +22,7 @@ use notify::{RecommendedWatcher, Watcher};
 use std::{
     net::UdpSocket,
     path::PathBuf,
-    process::Command,
+    process::{Command, Stdio},
     sync::{Arc, Mutex},
 };
 use tokio::sync::broadcast::{self, Sender};
@@ -30,416 +33,20 @@ use tower_http::{
     ServiceBuilderExt,
 };
 
-#[cfg(feature = "plugin")]
-use plugin::PluginManager;
-
-mod proxy;
-
-mod hot_reload;
-use hot_reload::*;
-
 mod output;
 use output::*;
-
-pub struct BuildManager {
-    config: CrateConfig,
-    reload_tx: broadcast::Sender<()>,
-}
-
-impl BuildManager {
-    fn rebuild(&self) -> Result<BuildResult> {
-        log::info!("🪁 Rebuild project");
-        let result = builder::build(&self.config, true)?;
-        // change the websocket reload state to true;
-        // the page will auto-reload.
-        if self
-            .config
-            .dioxus_config
-            .web
-            .watcher
-            .reload_html
-            .unwrap_or(false)
-        {
-            let _ = Serve::regen_dev_page(&self.config);
-        }
-        let _ = self.reload_tx.send(());
-        Ok(result)
-    }
-}
-
-struct WsReloadState {
-    update: broadcast::Sender<()>,
-}
-
-pub async fn startup(port: u16, config: CrateConfig, start_browser: bool) -> Result<()> {
-    // ctrl-c shutdown checker
-    let _crate_config = config.clone();
-    let _ = ctrlc::set_handler(move || {
-        #[cfg(feature = "plugin")]
-        let _ = PluginManager::on_serve_shutdown(&_crate_config);
-        std::process::exit(0);
-    });
-
-    let ip = get_ip().unwrap_or(String::from("0.0.0.0"));
-
-    match config.hot_reload {
-        true => serve_hot_reload(ip, port, config, start_browser).await?,
-        false => serve_default(ip, port, config, start_browser).await?,
-    }
-
-    Ok(())
-}
-
-/// Start the server without hot reload
-pub async fn serve_default(
-    ip: String,
-    port: u16,
-    config: CrateConfig,
-    start_browser: bool,
-) -> Result<()> {
-    let first_build_result = crate::builder::build(&config, false)?;
-
-    log::info!("🚀 Starting development server...");
-
-    // WS Reload Watching
-    let (reload_tx, _) = broadcast::channel(100);
-
-    // We got to own watcher so that it exists for the duration of serve
-    // Otherwise full reload won't work.
-    let _watcher = setup_file_watcher(&config, port, ip.clone(), reload_tx.clone()).await?;
-
-    let ws_reload_state = Arc::new(WsReloadState {
-        update: reload_tx.clone(),
-    });
-
-    // HTTPS
-    // Before console info so it can stop if mkcert isn't installed or fails
-    let rustls_config = get_rustls(&config).await?;
-
-    // Print serve info
-    print_console_info(
-        &ip,
-        port,
-        &config,
-        PrettierOptions {
-            changed: vec![],
-            warnings: first_build_result.warnings,
-            elapsed_time: first_build_result.elapsed_time,
-        },
-    );
-
-    // Router
-    let router = setup_router(config, ws_reload_state, None).await?;
-
-    // Start server
-    start_server(port, router, start_browser, rustls_config).await?;
-
-    Ok(())
-}
-
-/// Start dx serve with hot reload
-pub async fn serve_hot_reload(
-    ip: String,
-    port: u16,
-    config: CrateConfig,
-    start_browser: bool,
-) -> Result<()> {
-    let first_build_result = crate::builder::build(&config, false)?;
-
-    log::info!("🚀 Starting development server...");
-
-    // Setup hot reload
-    let (reload_tx, _) = broadcast::channel(100);
-    let FileMapBuildResult { map, errors } =
-        FileMap::<HtmlCtx>::create(config.crate_dir.clone()).unwrap();
-
-    for err in errors {
-        log::error!("{}", err);
-    }
-
-    let file_map = Arc::new(Mutex::new(map));
-    let build_manager = Arc::new(BuildManager {
-        config: config.clone(),
-        reload_tx: reload_tx.clone(),
-    });
-
-    let hot_reload_tx = broadcast::channel(100).0;
-
-    // States
-    let hot_reload_state = Arc::new(HotReloadState {
-        messages: hot_reload_tx.clone(),
-        build_manager: build_manager.clone(),
-        file_map: file_map.clone(),
-        watcher_config: config.clone(),
-    });
-
-    let ws_reload_state = Arc::new(WsReloadState {
-        update: reload_tx.clone(),
-    });
-
-    // Setup file watcher
-    // We got to own watcher so that it exists for the duration of serve
-    // Otherwise hot reload won't work.
-    let _watcher = setup_file_watcher_hot_reload(
-        &config,
-        port,
-        ip.clone(),
-        hot_reload_tx,
-        file_map,
-        build_manager,
-    )
-    .await?;
-
-    // HTTPS
-    // Before console info so it can stop if mkcert isn't installed or fails
-    let rustls_config = get_rustls(&config).await?;
-
-    // Print serve info
-    print_console_info(
-        &ip,
-        port,
-        &config,
-        PrettierOptions {
-            changed: vec![],
-            warnings: first_build_result.warnings,
-            elapsed_time: first_build_result.elapsed_time,
-        },
-    );
-
-    // Router
-    let router = setup_router(config, ws_reload_state, Some(hot_reload_state)).await?;
-
-    // Start server
-    start_server(port, router, start_browser, rustls_config).await?;
-
-    Ok(())
-}
-
-const DEFAULT_KEY_PATH: &str = "ssl/key.pem";
-const DEFAULT_CERT_PATH: &str = "ssl/cert.pem";
-
-/// Returns an enum of rustls config and a bool if mkcert isn't installed
-async fn get_rustls(config: &CrateConfig) -> Result<Option<RustlsConfig>> {
-    let web_config = &config.dioxus_config.web.https;
-    if web_config.enabled != Some(true) {
-        return Ok(None);
-    }
-
-    let (cert_path, key_path) = match web_config.mkcert {
-        // mkcert, use it
-        Some(true) => {
-            // Get paths to store certs, otherwise use ssl/item.pem
-            let key_path = web_config
-                .key_path
-                .clone()
-                .unwrap_or(DEFAULT_KEY_PATH.to_string());
-
-            let cert_path = web_config
-                .cert_path
-                .clone()
-                .unwrap_or(DEFAULT_CERT_PATH.to_string());
-
-            // Create ssl directory if using defaults
-            if key_path == DEFAULT_KEY_PATH && cert_path == DEFAULT_CERT_PATH {
-                _ = fs::create_dir("ssl");
-            }
-
-            let cmd = Command::new("mkcert")
-                .args([
-                    "-install",
-                    "-key-file",
-                    &key_path,
-                    "-cert-file",
-                    &cert_path,
-                    "localhost",
-                    "::1",
-                    "127.0.0.1",
-                ])
-                .spawn();
-
-            match cmd {
-                Err(e) => {
-                    match e.kind() {
-                        io::ErrorKind::NotFound => log::error!("mkcert is not installed. See https://github.com/FiloSottile/mkcert#installation for installation instructions."),
-                        e => log::error!("an error occured while generating mkcert certificates: {}", e.to_string()),
-                    };
-                    return Err("failed to generate mkcert certificates".into());
-                }
-                Ok(mut cmd) => {
-                    cmd.wait()?;
-                }
-            }
-
-            (cert_path, key_path)
-        }
-        // not mkcert
-        Some(false) => {
-            // get paths to cert & key
-            if let (Some(key), Some(cert)) =
-                (web_config.key_path.clone(), web_config.cert_path.clone())
-            {
-                (cert, key)
-            } else {
-                // missing cert or key
-                return Err("https is enabled but cert or key path is missing".into());
-            }
-        }
-        // other
-        _ => return Ok(None),
-    };
-
-    Ok(Some(
-        RustlsConfig::from_pem_file(cert_path, key_path).await?,
-    ))
-}
-
-/// Sets up and returns a router
-async fn setup_router(
-    config: CrateConfig,
-    ws_reload: Arc<WsReloadState>,
-    hot_reload: Option<Arc<HotReloadState>>,
-) -> Result<Router> {
-    // Setup cors
-    let cors = CorsLayer::new()
-        // allow `GET` and `POST` when accessing the resource
-        .allow_methods([Method::GET, Method::POST])
-        // allow requests from any origin
-        .allow_origin(Any)
-        .allow_headers(Any);
-
-    let (coep, coop) = if config.cross_origin_policy {
-        (
-            HeaderValue::from_static("require-corp"),
-            HeaderValue::from_static("same-origin"),
-        )
-    } else {
-        (
-            HeaderValue::from_static("unsafe-none"),
-            HeaderValue::from_static("unsafe-none"),
-        )
-    };
-
-    // Create file service
-    let file_service_config = config.clone();
-    let file_service = ServiceBuilder::new()
-        .override_response_header(
-            HeaderName::from_static("cross-origin-embedder-policy"),
-            coep,
-        )
-        .override_response_header(HeaderName::from_static("cross-origin-opener-policy"), coop)
-        .and_then(
-            move |response: Response<ServeFileSystemResponseBody>| async move {
-                let response = if file_service_config
-                    .dioxus_config
-                    .web
-                    .watcher
-                    .index_on_404
-                    .unwrap_or(false)
-                    && response.status() == StatusCode::NOT_FOUND
-                {
-                    let body = Full::from(
-                        // TODO: Cache/memoize this.
-                        std::fs::read_to_string(
-                            file_service_config
-                                .crate_dir
-                                .join(file_service_config.out_dir)
-                                .join("index.html"),
-                        )
-                        .ok()
-                        .unwrap(),
-                    )
-                    .map_err(|err| match err {})
-                    .boxed();
-                    Response::builder()
-                        .status(StatusCode::OK)
-                        .body(body)
-                        .unwrap()
-                } else {
-                    response.map(|body| body.boxed())
-                };
-                Ok(response)
-            },
-        )
-        .service(ServeDir::new(config.crate_dir.join(&config.out_dir)));
-
-    // Setup websocket
-    let mut router = Router::new().route("/_dioxus/ws", get(ws_handler));
-
-    // Setup proxy
-    for proxy_config in config.dioxus_config.web.proxy.unwrap_or_default() {
-        router = proxy::add_proxy(router, &proxy_config)?;
-    }
-
-    // Route file service
-    router = router.fallback(get_service(file_service).handle_error(
-        |error: std::io::Error| async move {
-            (
-                StatusCode::INTERNAL_SERVER_ERROR,
-                format!("Unhandled internal error: {}", error),
-            )
-        },
-    ));
-
-    // Setup routes
-    router = router
-        .route("/_dioxus/hot_reload", get(hot_reload_handler))
-        .layer(cors)
-        .layer(Extension(ws_reload));
-
-    if let Some(hot_reload) = hot_reload {
-        router = router.layer(Extension(hot_reload))
-    }
-
-    Ok(router)
-}
-
-/// Starts dx serve with no hot reload
-async fn start_server(
-    port: u16,
-    router: Router,
-    start_browser: bool,
-    rustls: Option<RustlsConfig>,
-) -> Result<()> {
-    // If plugins, call on_serve_start event
-    #[cfg(feature = "plugin")]
-    PluginManager::on_serve_start(&config)?;
-
-    // Parse address
-    let addr = format!("0.0.0.0:{}", port).parse().unwrap();
-
-    // Open the browser
-    if start_browser {
-        match rustls {
-            Some(_) => _ = open::that(format!("https://{}", addr)),
-            None => _ = open::that(format!("http://{}", addr)),
-        }
-    }
-
-    // Start the server with or without rustls
-    match rustls {
-        Some(rustls) => {
-            axum_server::bind_rustls(addr, rustls)
-                .serve(router.into_make_service())
-                .await?
-        }
-        None => {
-            axum::Server::bind(&addr)
-                .serve(router.into_make_service())
-                .await?
-        }
-    }
-
-    Ok(())
-}
+pub mod desktop;
+pub mod web;
 
 /// Sets up a file watcher
 async fn setup_file_watcher(
+    platform: Platform,
     config: &CrateConfig,
-    port: u16,
-    watcher_ip: String,
-    reload_tx: Sender<()>,
+    reload_tx: Option<Sender<()>>,
+    web_info: Option<WebServerInfo>,
 ) -> Result<RecommendedWatcher> {
     let build_manager = BuildManager {
+        platform,
         config: config.clone(),
         reload_tx,
     };
@@ -466,14 +73,13 @@ async fn setup_file_watcher(
 
                         #[allow(clippy::redundant_clone)]
                         print_console_info(
-                            &watcher_ip,
-                            port,
                             &config,
                             PrettierOptions {
                                 changed: e.paths.clone(),
                                 warnings: res.warnings,
                                 elapsed_time: res.elapsed_time,
                             },
+                            web_info.clone(),
                         );
 
                         #[cfg(feature = "plugin")]
@@ -504,11 +110,10 @@ async fn setup_file_watcher(
 /// Sets up a file watcher with hot reload
 async fn setup_file_watcher_hot_reload(
     config: &CrateConfig,
-    port: u16,
-    watcher_ip: String,
     hot_reload_tx: Sender<Template<'static>>,
     file_map: Arc<Mutex<FileMap<HtmlCtx>>>,
     build_manager: Arc<BuildManager>,
+    web_info: Option<WebServerInfo>,
 ) -> Result<RecommendedWatcher> {
     // file watcher: check file change
     let allow_watch_path = config
@@ -536,14 +141,13 @@ async fn setup_file_watcher_hot_reload(
                             match build_manager.rebuild() {
                                 Ok(res) => {
                                     print_console_info(
-                                        &watcher_ip,
-                                        port,
                                         &config,
                                         PrettierOptions {
                                             changed: evt.paths,
                                             warnings: res.warnings,
                                             elapsed_time: res.elapsed_time,
                                         },
+                                        web_info.clone(),
                                     );
                                 }
                                 Err(err) => {
@@ -563,14 +167,13 @@ async fn setup_file_watcher_hot_reload(
                                 match build_manager.rebuild() {
                                     Ok(res) => {
                                         print_console_info(
-                                            &watcher_ip,
-                                            port,
                                             &config,
                                             PrettierOptions {
                                                 changed: evt.paths,
                                                 warnings: res.warnings,
                                                 elapsed_time: res.elapsed_time,
                                             },
+                                            web_info.clone(),
                                         );
                                     }
                                     Err(err) => {
@@ -607,49 +210,34 @@ async fn setup_file_watcher_hot_reload(
     Ok(watcher)
 }
 
-/// Get the network ip
-fn get_ip() -> Option<String> {
-    let socket = match UdpSocket::bind("0.0.0.0:0") {
-        Ok(s) => s,
-        Err(_) => return None,
-    };
-
-    match socket.connect("8.8.8.8:80") {
-        Ok(()) => (),
-        Err(_) => return None,
-    };
-
-    match socket.local_addr() {
-        Ok(addr) => Some(addr.ip().to_string()),
-        Err(_) => None,
-    }
+pub struct BuildManager {
+    platform: Platform,
+    config: CrateConfig,
+    reload_tx: Option<broadcast::Sender<()>>,
 }
 
-/// Handle websockets
-async fn ws_handler(
-    ws: WebSocketUpgrade,
-    _: Option<TypedHeader<headers::UserAgent>>,
-    Extension(state): Extension<Arc<WsReloadState>>,
-) -> impl IntoResponse {
-    ws.on_upgrade(|mut socket| async move {
-        let mut rx = state.update.subscribe();
-        let reload_watcher = tokio::spawn(async move {
-            loop {
-                rx.recv().await.unwrap();
-                // ignore the error
-                if socket
-                    .send(Message::Text(String::from("reload")))
-                    .await
-                    .is_err()
+impl BuildManager {
+    fn rebuild(&self) -> Result<BuildResult> {
+        log::info!("🪁 Rebuild project");
+        match self.platform {
+            Platform::Web => {
+                let result = builder::build(&self.config, true)?;
+                // change the websocket reload state to true;
+                // the page will auto-reload.
+                if self
+                    .config
+                    .dioxus_config
+                    .web
+                    .watcher
+                    .reload_html
+                    .unwrap_or(false)
                 {
-                    break;
+                    let _ = Serve::regen_dev_page(&self.config);
                 }
-
-                // flush the errors after recompling
-                rx = rx.resubscribe();
+                let _ = self.reload_tx.as_ref().map(|tx| tx.send(()));
+                Ok(result)
             }
-        });
-
-        reload_watcher.await.unwrap();
-    })
+            Platform::Desktop => start_desktop(&self.config),
+        }
+    }
 }

+ 33 - 21
packages/cli/src/server/output.rs

@@ -11,7 +11,17 @@ pub struct PrettierOptions {
     pub elapsed_time: u128,
 }
 
-pub fn print_console_info(ip: &String, port: u16, config: &CrateConfig, options: PrettierOptions) {
+#[derive(Debug, Clone)]
+pub struct WebServerInfo {
+    pub ip: String,
+    pub port: u16,
+}
+
+pub fn print_console_info(
+    config: &CrateConfig,
+    options: PrettierOptions,
+    web_info: Option<WebServerInfo>,
+) {
     if let Ok(native_clearseq) = Command::new(if cfg!(target_os = "windows") {
         "cls"
     } else {
@@ -70,26 +80,28 @@ pub fn print_console_info(ip: &String, port: u16, config: &CrateConfig, options:
         );
     }
 
-    if config.dioxus_config.web.https.enabled == Some(true) {
-        println!(
-            "\t> Local : {}",
-            format!("https://localhost:{}/", port).blue()
-        );
-        println!(
-            "\t> Network : {}",
-            format!("https://{}:{}/", ip, port).blue()
-        );
-        println!("\t> HTTPS : {}", "Enabled".to_string().green());
-    } else {
-        println!(
-            "\t> Local : {}",
-            format!("http://localhost:{}/", port).blue()
-        );
-        println!(
-            "\t> Network : {}",
-            format!("http://{}:{}/", ip, port).blue()
-        );
-        println!("\t> HTTPS : {}", "Disabled".to_string().red());
+    if let Some(WebServerInfo { ip, port }) = web_info {
+        if config.dioxus_config.web.https.enabled == Some(true) {
+            println!(
+                "\t> Local : {}",
+                format!("https://localhost:{}/", port).blue()
+            );
+            println!(
+                "\t> Network : {}",
+                format!("https://{}:{}/", ip, port).blue()
+            );
+            println!("\t> HTTPS : {}", "Enabled".to_string().green());
+        } else {
+            println!(
+                "\t> Local : {}",
+                format!("http://localhost:{}/", port).blue()
+            );
+            println!(
+                "\t> Network : {}",
+                format!("http://{}:{}/", ip, port).blue()
+            );
+            println!("\t> HTTPS : {}", "Disabled".to_string().red());
+        }
     }
     println!();
     println!("\t> Profile : {}", profile.green());

+ 0 - 0
packages/cli/src/server/hot_reload.rs → packages/cli/src/server/web/hot_reload.rs


+ 475 - 0
packages/cli/src/server/web/mod.rs

@@ -0,0 +1,475 @@
+use crate::{
+    builder,
+    serve::Serve,
+    server::{
+        output::{print_console_info, PrettierOptions, WebServerInfo},
+        setup_file_watcher, setup_file_watcher_hot_reload, BuildManager,
+    },
+    BuildResult, CrateConfig, Result,
+};
+use axum::{
+    body::{Full, HttpBody},
+    extract::{ws::Message, Extension, TypedHeader, WebSocketUpgrade},
+    http::{
+        header::{HeaderName, HeaderValue},
+        Method, Response, StatusCode,
+    },
+    response::IntoResponse,
+    routing::{get, get_service},
+    Router,
+};
+use axum_server::tls_rustls::RustlsConfig;
+use cargo_metadata::diagnostic::Diagnostic;
+use dioxus_core::Template;
+use dioxus_html::HtmlCtx;
+use dioxus_rsx::hot_reload::*;
+use notify::{RecommendedWatcher, Watcher};
+use std::{
+    net::UdpSocket,
+    path::PathBuf,
+    process::Command,
+    sync::{Arc, Mutex},
+};
+use tokio::sync::broadcast::{self, Sender};
+use tower::ServiceBuilder;
+use tower_http::services::fs::{ServeDir, ServeFileSystemResponseBody};
+use tower_http::{
+    cors::{Any, CorsLayer},
+    ServiceBuilderExt,
+};
+
+#[cfg(feature = "plugin")]
+use plugin::PluginManager;
+
+mod proxy;
+
+mod hot_reload;
+use hot_reload::*;
+
+struct WsReloadState {
+    update: broadcast::Sender<()>,
+}
+
+pub async fn startup(port: u16, config: CrateConfig, start_browser: bool) -> Result<()> {
+    // ctrl-c shutdown checker
+    let _crate_config = config.clone();
+    let _ = ctrlc::set_handler(move || {
+        #[cfg(feature = "plugin")]
+        let _ = PluginManager::on_serve_shutdown(&_crate_config);
+        std::process::exit(0);
+    });
+
+    let ip = get_ip().unwrap_or(String::from("0.0.0.0"));
+
+    match config.hot_reload {
+        true => serve_hot_reload(ip, port, config, start_browser).await?,
+        false => serve_default(ip, port, config, start_browser).await?,
+    }
+
+    Ok(())
+}
+
+/// Start the server without hot reload
+pub async fn serve_default(
+    ip: String,
+    port: u16,
+    config: CrateConfig,
+    start_browser: bool,
+) -> Result<()> {
+    let first_build_result = crate::builder::build(&config, false)?;
+
+    log::info!("🚀 Starting development server...");
+
+    // WS Reload Watching
+    let (reload_tx, _) = broadcast::channel(100);
+
+    // We got to own watcher so that it exists for the duration of serve
+    // Otherwise full reload won't work.
+    let _watcher = setup_file_watcher(
+        crate::cfg::Platform::Web,
+        &config,
+        Some(reload_tx.clone()),
+        Some(WebServerInfo {
+            ip: ip.clone(),
+            port,
+        }),
+    )
+    .await?;
+
+    let ws_reload_state = Arc::new(WsReloadState {
+        update: reload_tx.clone(),
+    });
+
+    // HTTPS
+    // Before console info so it can stop if mkcert isn't installed or fails
+    let rustls_config = get_rustls(&config).await?;
+
+    // Print serve info
+    print_console_info(
+        &config,
+        PrettierOptions {
+            changed: vec![],
+            warnings: first_build_result.warnings,
+            elapsed_time: first_build_result.elapsed_time,
+        },
+        Some(crate::server::output::WebServerInfo {
+            ip: ip.clone(),
+            port,
+        }),
+    );
+
+    // Router
+    let router = setup_router(config, ws_reload_state, None).await?;
+
+    // Start server
+    start_server(port, router, start_browser, rustls_config).await?;
+
+    Ok(())
+}
+
+/// Start dx serve with hot reload
+pub async fn serve_hot_reload(
+    ip: String,
+    port: u16,
+    config: CrateConfig,
+    start_browser: bool,
+) -> Result<()> {
+    let first_build_result = crate::builder::build(&config, false)?;
+
+    log::info!("🚀 Starting development server...");
+
+    // Setup hot reload
+    let (reload_tx, _) = broadcast::channel(100);
+    let FileMapBuildResult { map, errors } =
+        FileMap::<HtmlCtx>::create(config.crate_dir.clone()).unwrap();
+
+    for err in errors {
+        log::error!("{}", err);
+    }
+
+    let file_map = Arc::new(Mutex::new(map));
+    let build_manager = Arc::new(BuildManager {
+        platform: crate::cfg::Platform::Web,
+        config: config.clone(),
+        reload_tx: Some(reload_tx.clone()),
+    });
+
+    let hot_reload_tx = broadcast::channel(100).0;
+
+    // States
+    let hot_reload_state = Arc::new(HotReloadState {
+        messages: hot_reload_tx.clone(),
+        build_manager: build_manager.clone(),
+        file_map: file_map.clone(),
+        watcher_config: config.clone(),
+    });
+
+    let ws_reload_state = Arc::new(WsReloadState {
+        update: reload_tx.clone(),
+    });
+
+    // Setup file watcher
+    // We got to own watcher so that it exists for the duration of serve
+    // Otherwise hot reload won't work.
+    let _watcher = setup_file_watcher_hot_reload(
+        &config,
+        hot_reload_tx,
+        file_map,
+        build_manager,
+        Some(WebServerInfo {
+            ip: ip.clone(),
+            port,
+        }),
+    )
+    .await?;
+
+    // HTTPS
+    // Before console info so it can stop if mkcert isn't installed or fails
+    let rustls_config = get_rustls(&config).await?;
+
+    // Print serve info
+    print_console_info(
+        &config,
+        PrettierOptions {
+            changed: vec![],
+            warnings: first_build_result.warnings,
+            elapsed_time: first_build_result.elapsed_time,
+        },
+        Some(WebServerInfo {
+            ip: ip.clone(),
+            port,
+        }),
+    );
+
+    // Router
+    let router = setup_router(config, ws_reload_state, Some(hot_reload_state)).await?;
+
+    // Start server
+    start_server(port, router, start_browser, rustls_config).await?;
+
+    Ok(())
+}
+
+const DEFAULT_KEY_PATH: &str = "ssl/key.pem";
+const DEFAULT_CERT_PATH: &str = "ssl/cert.pem";
+
+/// Returns an enum of rustls config and a bool if mkcert isn't installed
+async fn get_rustls(config: &CrateConfig) -> Result<Option<RustlsConfig>> {
+    let web_config = &config.dioxus_config.web.https;
+    if web_config.enabled != Some(true) {
+        return Ok(None);
+    }
+
+    let (cert_path, key_path) = match web_config.mkcert {
+        // mkcert, use it
+        Some(true) => {
+            // Get paths to store certs, otherwise use ssl/item.pem
+            let key_path = web_config
+                .key_path
+                .clone()
+                .unwrap_or(DEFAULT_KEY_PATH.to_string());
+
+            let cert_path = web_config
+                .cert_path
+                .clone()
+                .unwrap_or(DEFAULT_CERT_PATH.to_string());
+
+            // Create ssl directory if using defaults
+            if key_path == DEFAULT_KEY_PATH && cert_path == DEFAULT_CERT_PATH {
+                _ = fs::create_dir("ssl");
+            }
+
+            let cmd = Command::new("mkcert")
+                .args([
+                    "-install",
+                    "-key-file",
+                    &key_path,
+                    "-cert-file",
+                    &cert_path,
+                    "localhost",
+                    "::1",
+                    "127.0.0.1",
+                ])
+                .spawn();
+
+            match cmd {
+                Err(e) => {
+                    match e.kind() {
+                        io::ErrorKind::NotFound => log::error!("mkcert is not installed. See https://github.com/FiloSottile/mkcert#installation for installation instructions."),
+                        e => log::error!("an error occured while generating mkcert certificates: {}", e.to_string()),
+                    };
+                    return Err("failed to generate mkcert certificates".into());
+                }
+                Ok(mut cmd) => {
+                    cmd.wait()?;
+                }
+            }
+
+            (cert_path, key_path)
+        }
+        // not mkcert
+        Some(false) => {
+            // get paths to cert & key
+            if let (Some(key), Some(cert)) =
+                (web_config.key_path.clone(), web_config.cert_path.clone())
+            {
+                (cert, key)
+            } else {
+                // missing cert or key
+                return Err("https is enabled but cert or key path is missing".into());
+            }
+        }
+        // other
+        _ => return Ok(None),
+    };
+
+    Ok(Some(
+        RustlsConfig::from_pem_file(cert_path, key_path).await?,
+    ))
+}
+
+/// Sets up and returns a router
+async fn setup_router(
+    config: CrateConfig,
+    ws_reload: Arc<WsReloadState>,
+    hot_reload: Option<Arc<HotReloadState>>,
+) -> Result<Router> {
+    // Setup cors
+    let cors = CorsLayer::new()
+        // allow `GET` and `POST` when accessing the resource
+        .allow_methods([Method::GET, Method::POST])
+        // allow requests from any origin
+        .allow_origin(Any)
+        .allow_headers(Any);
+
+    let (coep, coop) = if config.cross_origin_policy {
+        (
+            HeaderValue::from_static("require-corp"),
+            HeaderValue::from_static("same-origin"),
+        )
+    } else {
+        (
+            HeaderValue::from_static("unsafe-none"),
+            HeaderValue::from_static("unsafe-none"),
+        )
+    };
+
+    // Create file service
+    let file_service_config = config.clone();
+    let file_service = ServiceBuilder::new()
+        .override_response_header(
+            HeaderName::from_static("cross-origin-embedder-policy"),
+            coep,
+        )
+        .override_response_header(HeaderName::from_static("cross-origin-opener-policy"), coop)
+        .and_then(
+            move |response: Response<ServeFileSystemResponseBody>| async move {
+                let response = if file_service_config
+                    .dioxus_config
+                    .web
+                    .watcher
+                    .index_on_404
+                    .unwrap_or(false)
+                    && response.status() == StatusCode::NOT_FOUND
+                {
+                    let body = Full::from(
+                        // TODO: Cache/memoize this.
+                        std::fs::read_to_string(
+                            file_service_config
+                                .crate_dir
+                                .join(file_service_config.out_dir)
+                                .join("index.html"),
+                        )
+                        .ok()
+                        .unwrap(),
+                    )
+                    .map_err(|err| match err {})
+                    .boxed();
+                    Response::builder()
+                        .status(StatusCode::OK)
+                        .body(body)
+                        .unwrap()
+                } else {
+                    response.map(|body| body.boxed())
+                };
+                Ok(response)
+            },
+        )
+        .service(ServeDir::new(config.crate_dir.join(&config.out_dir)));
+
+    // Setup websocket
+    let mut router = Router::new().route("/_dioxus/ws", get(ws_handler));
+
+    // Setup proxy
+    for proxy_config in config.dioxus_config.web.proxy.unwrap_or_default() {
+        router = proxy::add_proxy(router, &proxy_config)?;
+    }
+
+    // Route file service
+    router = router.fallback(get_service(file_service).handle_error(
+        |error: std::io::Error| async move {
+            (
+                StatusCode::INTERNAL_SERVER_ERROR,
+                format!("Unhandled internal error: {}", error),
+            )
+        },
+    ));
+
+    // Setup routes
+    router = router
+        .route("/_dioxus/hot_reload", get(hot_reload_handler))
+        .layer(cors)
+        .layer(Extension(ws_reload));
+
+    if let Some(hot_reload) = hot_reload {
+        router = router.layer(Extension(hot_reload))
+    }
+
+    Ok(router)
+}
+
+/// Starts dx serve with no hot reload
+async fn start_server(
+    port: u16,
+    router: Router,
+    start_browser: bool,
+    rustls: Option<RustlsConfig>,
+) -> Result<()> {
+    // If plugins, call on_serve_start event
+    #[cfg(feature = "plugin")]
+    PluginManager::on_serve_start(&config)?;
+
+    // Parse address
+    let addr = format!("0.0.0.0:{}", port).parse().unwrap();
+
+    // Open the browser
+    if start_browser {
+        match rustls {
+            Some(_) => _ = open::that(format!("https://{}", addr)),
+            None => _ = open::that(format!("http://{}", addr)),
+        }
+    }
+
+    // Start the server with or without rustls
+    match rustls {
+        Some(rustls) => {
+            axum_server::bind_rustls(addr, rustls)
+                .serve(router.into_make_service())
+                .await?
+        }
+        None => {
+            axum::Server::bind(&addr)
+                .serve(router.into_make_service())
+                .await?
+        }
+    }
+
+    Ok(())
+}
+
+/// Get the network ip
+fn get_ip() -> Option<String> {
+    let socket = match UdpSocket::bind("0.0.0.0:0") {
+        Ok(s) => s,
+        Err(_) => return None,
+    };
+
+    match socket.connect("8.8.8.8:80") {
+        Ok(()) => (),
+        Err(_) => return None,
+    };
+
+    match socket.local_addr() {
+        Ok(addr) => Some(addr.ip().to_string()),
+        Err(_) => None,
+    }
+}
+
+/// Handle websockets
+async fn ws_handler(
+    ws: WebSocketUpgrade,
+    _: Option<TypedHeader<headers::UserAgent>>,
+    Extension(state): Extension<Arc<WsReloadState>>,
+) -> impl IntoResponse {
+    ws.on_upgrade(|mut socket| async move {
+        let mut rx = state.update.subscribe();
+        let reload_watcher = tokio::spawn(async move {
+            loop {
+                rx.recv().await.unwrap();
+                // ignore the error
+                if socket
+                    .send(Message::Text(String::from("reload")))
+                    .await
+                    .is_err()
+                {
+                    break;
+                }
+
+                // flush the errors after recompling
+                rx = rx.resubscribe();
+            }
+        });
+
+        reload_watcher.await.unwrap();
+    })
+}

+ 0 - 0
packages/cli/src/server/proxy.rs → packages/cli/src/server/web/proxy.rs