Fix bundling/path errors, sidecar support, iOS/Android simulator support, asset hotreload fixes, serverfn hotrelad, native tailwind support (#2779)
* wip: fix manganis import path * upgrade tauri bundler, convert todo!() to unimplemented!() * feat: mobile hotreloading + ios asset configuration * remove oid demo and its associated env vars * nuke all the things that cause cache thrashing * swap to Asset type * add some more logs * display impl for attrvalue * dont panic on collect failure * cut down web deps to 150 * clean up deps in a lot of places, simplify build scripts * clean up asset resolution and cli-dev profile * wire up absolute paths for manganis asset in a particular mode * move document related things around to shorten compile times * move most things to `document::Item` and then decompose the cli-config * switch link to stylesheet for clarity in examples * move manganis workspace example to examples folder * decompose manganis-cli-support * dont need mobile demo anymore * remove gloo dialogs * html doesnt need document * rename hotreload to devtools * really clean up the html crate * fix weird rsx spacing * clean up desktop to use callback * clean up document a bit * re-wire up devsocket * fix utf css * yeeeet that js out of here * synthetic web system works * web crate almost done being cleaned up * desktop mostly cleaned up too * clean up cli a bit * more cli cleanup * cli builds again * clean up cli, inline structs, cut down number of unique types where possible * hotreload works again for desktop and this time, mobile too * cfg out tungstenite * devserver err * more refactor to cli builder * switch to unbounded_send * new structure is much cleaner * add http serve subcommand * bundled hotreload * kick stylsheets * clean up a bit more, split up eventloop * amazingly serve seems done * change from pub to pub(crate) in cli * remove tools * bit more polish to cli * fix issue with join * gracefully handle fullstack without a server * fullstack mobile demo * fix launch function, move projects into example-projects folder * hoist examples * add "run" command * clean up launch * remove old manual websocket receiver * doctor command * allow desktop to scroll * cut apart router crate * dont put launch in prelude * use dioxus::launch where possible * rename rsx, cut out hotreload tests * remove liveview project * bump native * fix compile for renderers * move sync event response out of interpreter * move render in serve * rollback settings change * cli compiles, huzzah! * change uris for asset * fix asset * new tui screen * new cargo-like tui works * very very verty close * it works! very small bug with incorrect grapheme calc * Clean up devserver a bit * status sytem * tidy up debug filters * clean up logging situation * Fix a number of bugs with log printing * new printing system is more reliable * wire up more stuff * things working but fullstack is having issues * fullstack works again! * hotreloading bundled assets works again * bundled hotreload and beginnings of macos bundling * Hotreload desktop * combined server builds * add build handle * fix fullstack assets * make open async, add some hooks for ios * migrate filemap to runner * wip global crate system * fixup bundles + organize asset * fix asset location bug * all the bundled reloading! * open ios simulator! * full hotreload support for mobile + serverfn * basic cleanups * clean up dx * Move filemap * fix cutting of newlines * assets workibg, some android * hoist wry/tao * use sync locks and headers to fix issues with android * desktop -> mac/win/linux with alias * better logging * feat: workspace (entire computer!) hotreload * should rebuild toggle, ios simulator bootup * proper mobile support in launch * more robust handling of assets * fix cargo * bring back some of tauri bundle * make warnings go away, clippy happy on cli * some final clippy cleanups * fmt * move manganis to its own folder * upgrade bundle to stable * drastically slim down manganis, prepping for merge * typos, failing test, docsrs config * remove static gen test * nix static gen test * we use --platform web instead of --platform fullstack now * only bind dev urls in desktop/mobile * install gtk * nix static gen * split build dir by app name
This commit is contained in:
1 parent
ef436e4ed0
commit
7ec3453ca3
156 files changed
+11544
-9005
No files matched your search
@@ -0,0 +1,172 @@
|
||||
use ratatui::prelude::*;
|
||||
use std::fmt::{self, Display, Formatter};
|
||||
|
||||
/// A buffer that can be rendered to and then dumped as raw ansi codes
|
||||
///
|
||||
/// This is taken from a PR on the ratatui repo (https://github.com/ratatui/ratatui/pull/1065) and
|
||||
/// modified to be more appropriate for our use case.
|
||||
pub struct AnsiStringBuffer {
|
||||
buf: Buffer,
|
||||
}
|
||||
|
||||
// The sentinel character used to mark the end of the ansi string so when we dump it, we know where to stop
|
||||
// Not sure if we actually still need this....
|
||||
const SENTINEL: &str = "✆";
|
||||
|
||||
impl AnsiStringBuffer {
|
||||
/// Creates a new `AnsiStringBuffer` with the given width and height.
|
||||
pub(crate) fn new(width: u16, height: u16) -> Self {
|
||||
Self {
|
||||
buf: Buffer::empty(Rect::new(0, 0, width, height)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the given widget to the buffer, returning the string with the ansi codes
|
||||
pub(crate) fn render(mut self, widget: impl Widget) -> String {
|
||||
widget.render(self.buf.area, &mut self.buf);
|
||||
self.trim_end();
|
||||
self.to_string()
|
||||
}
|
||||
|
||||
/// Trims the buffer to the last line, returning the number of cells to be rendered
|
||||
#[allow(deprecated)]
|
||||
fn trim_end(&mut self) {
|
||||
for y in 0..self.buf.area.height {
|
||||
let start_x = self.buf.area.width;
|
||||
let mut first_non_empty = start_x - 1;
|
||||
for x in (0..start_x).rev() {
|
||||
if self.buf.get(x, y) != &buffer::Cell::EMPTY {
|
||||
break;
|
||||
}
|
||||
first_non_empty = x;
|
||||
}
|
||||
self.buf.get_mut(first_non_empty, y).set_symbol(SENTINEL);
|
||||
}
|
||||
}
|
||||
|
||||
fn write_fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
|
||||
let mut last_style = None;
|
||||
for y in 0..self.buf.area.height {
|
||||
for x in 0..self.buf.area.width {
|
||||
let cell = self.buf.cell((x, y)).unwrap();
|
||||
if cell.symbol() == SENTINEL {
|
||||
f.write_str("\n")?;
|
||||
break;
|
||||
}
|
||||
|
||||
let style = (cell.fg, cell.bg, cell.modifier);
|
||||
if last_style.is_none() || last_style != Some(style) {
|
||||
write_cell_style(f, cell)?;
|
||||
last_style = Some(style);
|
||||
}
|
||||
f.write_str(cell.symbol())?;
|
||||
}
|
||||
}
|
||||
f.write_str("\u{1b}[0m")
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for AnsiStringBuffer {
|
||||
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
|
||||
self.write_fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
fn write_cell_style(f: &mut Formatter, cell: &buffer::Cell) -> fmt::Result {
|
||||
f.write_str("\u{1b}[")?;
|
||||
write_modifier(f, cell.modifier)?;
|
||||
write_fg(f, cell.fg)?;
|
||||
write_bg(f, cell.bg)?;
|
||||
f.write_str("m")
|
||||
}
|
||||
|
||||
fn write_modifier(f: &mut Formatter, modifier: Modifier) -> fmt::Result {
|
||||
if modifier.contains(Modifier::BOLD) {
|
||||
f.write_str("1;")?;
|
||||
}
|
||||
if modifier.contains(Modifier::DIM) {
|
||||
f.write_str("2;")?;
|
||||
}
|
||||
if modifier.contains(Modifier::ITALIC) {
|
||||
f.write_str("3;")?;
|
||||
}
|
||||
if modifier.contains(Modifier::UNDERLINED) {
|
||||
f.write_str("4;")?;
|
||||
}
|
||||
if modifier.contains(Modifier::SLOW_BLINK) {
|
||||
f.write_str("5;")?;
|
||||
}
|
||||
if modifier.contains(Modifier::RAPID_BLINK) {
|
||||
f.write_str("6;")?;
|
||||
}
|
||||
if modifier.contains(Modifier::REVERSED) {
|
||||
f.write_str("7;")?;
|
||||
}
|
||||
if modifier.contains(Modifier::HIDDEN) {
|
||||
f.write_str("8;")?;
|
||||
}
|
||||
if modifier.contains(Modifier::CROSSED_OUT) {
|
||||
f.write_str("9;")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_fg(f: &mut Formatter, color: Color) -> fmt::Result {
|
||||
f.write_str(match color {
|
||||
Color::Reset => "39",
|
||||
Color::Black => "30",
|
||||
Color::Red => "31",
|
||||
Color::Green => "32",
|
||||
Color::Yellow => "33",
|
||||
Color::Blue => "34",
|
||||
Color::Magenta => "35",
|
||||
Color::Cyan => "36",
|
||||
Color::Gray => "37",
|
||||
Color::DarkGray => "90",
|
||||
Color::LightRed => "91",
|
||||
Color::LightGreen => "92",
|
||||
Color::LightYellow => "93",
|
||||
Color::LightBlue => "94",
|
||||
Color::LightMagenta => "95",
|
||||
Color::LightCyan => "96",
|
||||
Color::White => "97",
|
||||
_ => "",
|
||||
})?;
|
||||
if let Color::Rgb(red, green, blue) = color {
|
||||
f.write_fmt(format_args!("38;2;{red};{green};{blue}"))?;
|
||||
}
|
||||
if let Color::Indexed(i) = color {
|
||||
f.write_fmt(format_args!("38;5;{i}"))?;
|
||||
}
|
||||
f.write_str(";")
|
||||
}
|
||||
|
||||
fn write_bg(f: &mut Formatter, color: Color) -> fmt::Result {
|
||||
f.write_str(match color {
|
||||
Color::Reset => "49",
|
||||
Color::Black => "40",
|
||||
Color::Red => "41",
|
||||
Color::Green => "42",
|
||||
Color::Yellow => "43",
|
||||
Color::Blue => "44",
|
||||
Color::Magenta => "45",
|
||||
Color::Cyan => "46",
|
||||
Color::Gray => "47",
|
||||
Color::DarkGray => "100",
|
||||
Color::LightRed => "101",
|
||||
Color::LightGreen => "102",
|
||||
Color::LightYellow => "103",
|
||||
Color::LightBlue => "104",
|
||||
Color::LightMagenta => "105",
|
||||
Color::LightCyan => "106",
|
||||
Color::White => "107",
|
||||
_ => "",
|
||||
})?;
|
||||
if let Color::Rgb(red, green, blue) = color {
|
||||
f.write_fmt(format_args!("48;2;{red};{green};{blue}"))?;
|
||||
}
|
||||
if let Color::Indexed(i) = color {
|
||||
f.write_fmt(format_args!("48;5;{i}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
use crate::builder::BuildRequest;
|
||||
use crate::builder::BuildResult;
|
||||
use crate::builder::TargetPlatform;
|
||||
use crate::builder::UpdateBuildProgress;
|
||||
use crate::dioxus_crate::DioxusCrate;
|
||||
use crate::serve::next_or_pending;
|
||||
use crate::serve::Serve;
|
||||
use crate::Result;
|
||||
use futures_channel::mpsc::UnboundedReceiver;
|
||||
use futures_util::future::OptionFuture;
|
||||
use futures_util::stream::select_all;
|
||||
use futures_util::StreamExt;
|
||||
use std::process::Stdio;
|
||||
use tokio::{
|
||||
process::{Child, Command},
|
||||
task::JoinHandle,
|
||||
};
|
||||
|
||||
/// A handle to ongoing builds and then the spawned tasks themselves
|
||||
pub struct Builder {
|
||||
/// The results of the build
|
||||
build_results: Option<JoinHandle<Result<Vec<BuildResult>>>>,
|
||||
|
||||
/// The progress of the builds
|
||||
build_progress: Vec<(TargetPlatform, UnboundedReceiver<UpdateBuildProgress>)>,
|
||||
|
||||
/// The application we are building
|
||||
config: DioxusCrate,
|
||||
|
||||
/// The arguments for the build
|
||||
serve: Serve,
|
||||
|
||||
/// The children of the build process
|
||||
pub children: Vec<(TargetPlatform, Child)>,
|
||||
}
|
||||
|
||||
impl Builder {
|
||||
/// Create a new builder
|
||||
pub fn new(config: &DioxusCrate, serve: &Serve) -> Self {
|
||||
let serve = serve.clone();
|
||||
let config = config.clone();
|
||||
Self {
|
||||
build_results: None,
|
||||
build_progress: Vec::new(),
|
||||
config: config.clone(),
|
||||
serve,
|
||||
children: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a new build - killing the current one if it exists
|
||||
pub fn build(&mut self) -> Result<()> {
|
||||
self.shutdown();
|
||||
let build_requests =
|
||||
BuildRequest::create(true, &self.config, self.serve.build_arguments.clone())?;
|
||||
|
||||
let mut set = tokio::task::JoinSet::new();
|
||||
|
||||
for build_request in build_requests {
|
||||
let (mut tx, rx) = futures_channel::mpsc::unbounded();
|
||||
self.build_progress
|
||||
.push((build_request.target_platform, rx));
|
||||
set.spawn(async move {
|
||||
let res = build_request.build(tx.clone()).await;
|
||||
|
||||
if let Err(err) = &res {
|
||||
let _ = tx.start_send(UpdateBuildProgress {
|
||||
stage: crate::builder::Stage::Finished,
|
||||
update: crate::builder::UpdateStage::Failed(format!("{err}")),
|
||||
});
|
||||
}
|
||||
|
||||
res
|
||||
});
|
||||
}
|
||||
|
||||
self.build_results = Some(tokio::spawn(async move {
|
||||
let mut all_results = Vec::new();
|
||||
while let Some(result) = set.join_next().await {
|
||||
let res = result.map_err(|err| {
|
||||
crate::Error::Unique(format!("Panic while building project: {err:?}"))
|
||||
})??;
|
||||
|
||||
all_results.push(res);
|
||||
}
|
||||
Ok(all_results)
|
||||
}));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Wait for any new updates to the builder - either it completed or gave us a message etc
|
||||
pub async fn wait(&mut self) -> Result<BuilderUpdate> {
|
||||
// Wait for build progress
|
||||
let mut next = select_all(
|
||||
self.build_progress
|
||||
.iter_mut()
|
||||
.map(|(platform, rx)| rx.map(move |update| (*platform, update))),
|
||||
);
|
||||
let next = next_or_pending(next.next());
|
||||
|
||||
// The ongoing builds directly
|
||||
let results: OptionFuture<_> = self.build_results.as_mut().into();
|
||||
let results = next_or_pending(results);
|
||||
|
||||
// The process exits
|
||||
let children_empty = self.children.is_empty();
|
||||
let process_exited = self
|
||||
.children
|
||||
.iter_mut()
|
||||
.map(|(target, child)| Box::pin(async move { (*target, child.wait().await) }));
|
||||
let process_exited = async move {
|
||||
if children_empty {
|
||||
return futures_util::future::pending().await;
|
||||
}
|
||||
futures_util::future::select_all(process_exited).await
|
||||
};
|
||||
|
||||
// Wait for the next build result
|
||||
tokio::select! {
|
||||
build_results = results => {
|
||||
self.build_results = None;
|
||||
|
||||
// If we have a build result, bubble it up to the main loop
|
||||
let build_results = build_results.map_err(|_| crate::Error::Unique("Build join failed".to_string()))??;
|
||||
|
||||
Ok(BuilderUpdate::Ready { results: build_results })
|
||||
}
|
||||
(platform, update) = next => {
|
||||
// If we have a build progress, send it to the screen
|
||||
Ok(BuilderUpdate::Progress { platform, update })
|
||||
}
|
||||
((target, exit_status), _, _) = process_exited => {
|
||||
Ok(BuilderUpdate::ProcessExited { status: exit_status, target_platform: target })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shutdown the current build process
|
||||
pub(crate) fn shutdown(&mut self) {
|
||||
for (_, mut child) in self.children.drain(..) {
|
||||
// Gracefully shtudown the desktop app
|
||||
// It might have a receiver to do some cleanup stuff
|
||||
if let Some(pid) = child.id() {
|
||||
// on unix, we can send a signal to the process to shut down
|
||||
#[cfg(unix)]
|
||||
{
|
||||
_ = Command::new("kill")
|
||||
.args(["-s", "TERM", &pid.to_string()])
|
||||
.stderr(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.spawn();
|
||||
}
|
||||
|
||||
// on windows, use the `taskkill` command
|
||||
#[cfg(windows)]
|
||||
{
|
||||
_ = Command::new("taskkill")
|
||||
.args(["/F", "/PID", &pid.to_string()])
|
||||
.stderr(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.spawn();
|
||||
}
|
||||
}
|
||||
|
||||
// Todo: add a timeout here to kill the process if it doesn't shut down within a reasonable time
|
||||
_ = child.start_kill();
|
||||
}
|
||||
|
||||
if let Some(tasks) = self.build_results.take() {
|
||||
tasks.abort();
|
||||
}
|
||||
self.build_progress.clear();
|
||||
}
|
||||
}
|
||||
|
||||
pub enum BuilderUpdate {
|
||||
Progress {
|
||||
platform: TargetPlatform,
|
||||
update: UpdateBuildProgress,
|
||||
},
|
||||
Ready {
|
||||
results: Vec<BuildResult>,
|
||||
},
|
||||
ProcessExited {
|
||||
target_platform: TargetPlatform,
|
||||
status: Result<std::process::ExitStatus, std::io::Error>,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/// Detects if `dx` is being ran in a WSL environment.
|
||||
///
|
||||
/// We determine this based on whether the keyword `microsoft` or `wsl` is contained within the [`WSL_1`] or [`WSL_2`] files.
|
||||
/// This may fail in the future as it isn't guaranteed by Microsoft.
|
||||
/// See https://github.com/microsoft/WSL/issues/423#issuecomment-221627364
|
||||
pub(crate) fn is_wsl() -> bool {
|
||||
const WSL_1: &str = "/proc/sys/kernel/osrelease";
|
||||
const WSL_2: &str = "/proc/version";
|
||||
const WSL_KEYWORDS: [&str; 2] = ["microsoft", "wsl"];
|
||||
|
||||
// Test 1st File
|
||||
if let Ok(content) = std::fs::read_to_string(WSL_1) {
|
||||
let lowercase = content.to_lowercase();
|
||||
for keyword in WSL_KEYWORDS {
|
||||
if lowercase.contains(keyword) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test 2nd File
|
||||
if let Ok(content) = std::fs::read_to_string(WSL_2) {
|
||||
let lowercase = content.to_lowercase();
|
||||
for keyword in WSL_KEYWORDS {
|
||||
if lowercase.contains(keyword) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
use crate::{AppBundle, Platform, Result};
|
||||
use anyhow::Context;
|
||||
use std::{
|
||||
net::SocketAddr,
|
||||
path::{Path, PathBuf},
|
||||
process::Stdio,
|
||||
};
|
||||
use tokio::{
|
||||
io::{AsyncBufReadExt, BufReader, Lines},
|
||||
process::{Child, ChildStderr, ChildStdout, Command},
|
||||
};
|
||||
|
||||
/// A handle to a running app.
|
||||
///
|
||||
/// Also includes a handle to its server if it exists.
|
||||
/// The actual child processes might not be present (web) or running (died/killed).
|
||||
///
|
||||
/// The purpose of this struct is to accumulate state about the running app and its server, like
|
||||
/// any runtime information needed to hotreload the app or send it messages.
|
||||
///
|
||||
/// We might want to bring in websockets here too, so we know the exact channels the app is using to
|
||||
/// communicate with the devserver. Currently that's a broadcast-type system, so this struct isn't super
|
||||
/// duper useful.
|
||||
pub(crate) struct AppHandle {
|
||||
pub(crate) app: AppBundle,
|
||||
|
||||
// These might be None if the app died or the user did not specify a server
|
||||
pub(crate) app_child: Option<Child>,
|
||||
pub(crate) server_child: Option<Child>,
|
||||
|
||||
// stdio for the app so we can read its stdout/stderr
|
||||
// we don't map stdin today (todo) but most apps don't need it
|
||||
pub(crate) app_stdout: Option<Lines<BufReader<ChildStdout>>>,
|
||||
pub(crate) app_stderr: Option<Lines<BufReader<ChildStderr>>>,
|
||||
pub(crate) server_stdout: Option<Lines<BufReader<ChildStdout>>>,
|
||||
pub(crate) server_stderr: Option<Lines<BufReader<ChildStderr>>>,
|
||||
|
||||
/// The virtual directory that assets will be served from
|
||||
/// Used mostly for apk/ipa builds since they live in simulator
|
||||
pub(crate) runtime_asst_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl AppHandle {
|
||||
pub async fn new(app: AppBundle) -> Result<Self> {
|
||||
Ok(AppHandle {
|
||||
app,
|
||||
runtime_asst_dir: None,
|
||||
app_child: None,
|
||||
app_stderr: None,
|
||||
app_stdout: None,
|
||||
server_child: None,
|
||||
server_stdout: None,
|
||||
server_stderr: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn open(
|
||||
&mut self,
|
||||
devserver_ip: SocketAddr,
|
||||
fullstack_address: Option<SocketAddr>,
|
||||
open_browser: bool,
|
||||
) -> Result<()> {
|
||||
if let Some(addr) = fullstack_address {
|
||||
tracing::debug!("Proxying fullstack server from port {:?}", addr);
|
||||
}
|
||||
|
||||
// Set the env vars that the clients will expect
|
||||
// These need to be stable within a release version (ie 0.6.0)
|
||||
let mut envs = vec![
|
||||
("DIOXUS_CLI_ENABLED", "true".to_string()),
|
||||
(
|
||||
dioxus_cli_config::DEVSERVER_RAW_ADDR_ENV,
|
||||
devserver_ip.to_string(),
|
||||
),
|
||||
// unset the cargo dirs in the event we're running `dx` locally
|
||||
// since the child process will inherit the env vars, we don't want to confuse the downstream process
|
||||
("CARGO_MANIFEST_DIR", "".to_string()),
|
||||
];
|
||||
|
||||
if let Some(addr) = fullstack_address {
|
||||
envs.push((dioxus_cli_config::SERVER_IP_ENV, addr.ip().to_string()));
|
||||
envs.push((dioxus_cli_config::SERVER_PORT_ENV, addr.port().to_string()));
|
||||
}
|
||||
|
||||
// Launch the server if we have one and consume its stdout/stderr
|
||||
if let Some(server) = self.app.server_exe() {
|
||||
tracing::debug!("Launching server from path: {server:?}");
|
||||
let mut child = Command::new(server)
|
||||
.envs(envs.clone())
|
||||
.stderr(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.kill_on_drop(true)
|
||||
.spawn()?;
|
||||
let stdout = BufReader::new(child.stdout.take().unwrap());
|
||||
let stderr = BufReader::new(child.stderr.take().unwrap());
|
||||
self.server_stdout = Some(stdout.lines());
|
||||
self.server_stderr = Some(stderr.lines());
|
||||
self.server_child = Some(child);
|
||||
}
|
||||
|
||||
// We try to use stdin/stdout to communicate with the app
|
||||
let running_process = match self.app.build.build.platform() {
|
||||
// Unfortunately web won't let us get a proc handle to it (to read its stdout/stderr) so instead
|
||||
// use use the websocket to communicate with it. I wish we could merge the concepts here,
|
||||
// like say, opening the socket as a subprocess, but alas, it's simpler to do that somewhere else.
|
||||
Platform::Web => {
|
||||
// Only the first build we open the web app, after that the user knows it's running
|
||||
if open_browser {
|
||||
self.open_web(envs, devserver_ip);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
Platform::Ios => Some(self.open_ios_sim(envs).await?),
|
||||
|
||||
// https://developer.android.com/studio/run/emulator-commandline
|
||||
Platform::Android => {
|
||||
tracing::error!("Android is not yet supported, sorry!");
|
||||
None
|
||||
}
|
||||
|
||||
// These are all just basically running the main exe, but with slightly different resource dir paths
|
||||
Platform::Server
|
||||
| Platform::MacOS
|
||||
| Platform::Windows
|
||||
| Platform::Linux
|
||||
| Platform::Liveview => Some(self.open_with_main_exe(envs)?),
|
||||
};
|
||||
|
||||
// If we have a running process, we need to attach to it and wait for its outputs
|
||||
if let Some(mut child) = running_process {
|
||||
let stdout = BufReader::new(child.stdout.take().unwrap());
|
||||
let stderr = BufReader::new(child.stderr.take().unwrap());
|
||||
self.app_stdout = Some(stdout.lines());
|
||||
self.app_stderr = Some(stderr.lines());
|
||||
self.app_child = Some(child);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Hotreload an asset in the running app.
|
||||
///
|
||||
/// This will modify the build dir in place! Be careful! We generally assume you want all bundles
|
||||
/// to reflect the latest changes, so we will modify the bundle.
|
||||
///
|
||||
/// However, not all platforms work like this, so we might also need to update a separate asset
|
||||
/// dir that the system simulator might be providing. We know this is the case for ios simulators
|
||||
/// and haven't yet checked for android.
|
||||
///
|
||||
/// This will return the bundled name of the asset such that we can send it to the clients letting
|
||||
/// them know what to reload. It's not super important that this is robust since most clients will
|
||||
/// kick all stylsheets without necessarily checking the name.
|
||||
pub(crate) fn hotreload_bundled_asset(&self, changed_file: &PathBuf) -> Option<PathBuf> {
|
||||
let mut bundled_name = None;
|
||||
|
||||
// Use the build dir if there's no runtime asset dir as the override. For the case of ios apps,
|
||||
// we won't actually be using the build dir.
|
||||
let asset_dir = match self.runtime_asst_dir.as_ref() {
|
||||
Some(dir) => dir.to_path_buf().join("assets/"),
|
||||
None => self.app.asset_dir(),
|
||||
};
|
||||
|
||||
tracing::debug!("Hotreloading asset {changed_file:?} in target {asset_dir:?}");
|
||||
|
||||
// If the asset shares the same name in the bundle, reload that
|
||||
let legacy_asset_dir = self.app.build.krate.legacy_asset_dir();
|
||||
if changed_file.starts_with(&legacy_asset_dir) {
|
||||
tracing::debug!("Hotreloading legacy asset {changed_file:?}");
|
||||
let trimmed = changed_file.strip_prefix(legacy_asset_dir).unwrap();
|
||||
let res = std::fs::copy(changed_file, asset_dir.join(trimmed));
|
||||
bundled_name = Some(trimmed.to_path_buf());
|
||||
if let Err(e) = res {
|
||||
tracing::debug!("Failed to hotreload legacy asset {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// The asset might've been renamed thanks to the manifest, let's attempt to reload that too
|
||||
if let Some(resource) = self.app.app.assets.assets.get(changed_file).as_ref() {
|
||||
let res = std::fs::copy(changed_file, asset_dir.join(&resource.bundled));
|
||||
bundled_name = Some(PathBuf::from(&resource.bundled));
|
||||
if let Err(e) = res {
|
||||
tracing::debug!("Failed to hotreload asset {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// Now we can return the bundled asset name to send to the hotreload engine
|
||||
bundled_name
|
||||
}
|
||||
|
||||
/// Open the native app simply by running its main exe
|
||||
///
|
||||
/// Eventually, for mac, we want to run the `.app` with `open` to fix issues with `dylib` paths,
|
||||
/// but for now, we just run the exe directly. Very few users should be caring about `dylib` search
|
||||
/// paths right now, but they will when we start to enable things like swift integration.
|
||||
///
|
||||
/// Server/liveview/desktop are all basically the same, though
|
||||
fn open_with_main_exe(&mut self, envs: Vec<(&str, String)>) -> Result<Child> {
|
||||
let child = Command::new(self.app.main_exe())
|
||||
.envs(envs)
|
||||
.stderr(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.kill_on_drop(true)
|
||||
.spawn()?;
|
||||
Ok(child)
|
||||
}
|
||||
|
||||
/// Open the web app by opening the browser to the given address.
|
||||
/// Check if we need to use https or not, and if so, add the protocol.
|
||||
/// Go to the basepath if that's set too.
|
||||
fn open_web(&self, _envs: Vec<(&str, String)>, address: SocketAddr) {
|
||||
let base_path = self.app.build.krate.config.web.app.base_path.clone();
|
||||
let https = self
|
||||
.app
|
||||
.build
|
||||
.krate
|
||||
.config
|
||||
.web
|
||||
.https
|
||||
.enabled
|
||||
.unwrap_or_default();
|
||||
let protocol = if https { "https" } else { "http" };
|
||||
let base_path = match base_path.as_deref() {
|
||||
Some(base_path) => format!("/{}", base_path.trim_matches('/')),
|
||||
None => "".to_owned(),
|
||||
};
|
||||
_ = open::that(format!("{protocol}://{address}{base_path}"));
|
||||
}
|
||||
|
||||
/// Use `xcrun` to install the app to the simulator
|
||||
/// With simulators, we're free to basically do anything, so we don't need to do any fancy codesigning
|
||||
/// or entitlements, or anything like that.
|
||||
///
|
||||
/// However, if there's no simulator running, this *might* fail.
|
||||
///
|
||||
/// TODO(jon): we should probably check if there's a simulator running before trying to install,
|
||||
/// and open the simulator if we have to.
|
||||
async fn open_ios_sim(&mut self, envs: Vec<(&str, String)>) -> Result<Child> {
|
||||
tracing::debug!("Installing app to simulator {:?}", self.app.app_dir());
|
||||
|
||||
let res = Command::new("xcrun")
|
||||
.arg("simctl")
|
||||
.arg("install")
|
||||
.arg("booted")
|
||||
.arg(self.app.app_dir())
|
||||
.stderr(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
tracing::debug!("Installed app to simulator with exit code: {res:?}");
|
||||
|
||||
// Remap the envs to the correct simctl env vars
|
||||
// iOS sim lets you pass env vars but they need to be in the format "SIMCTL_CHILD_XXX=XXX"
|
||||
let ios_envs = envs
|
||||
.iter()
|
||||
.map(|(k, v)| (format!("SIMCTL_CHILD_{k}"), v.clone()));
|
||||
|
||||
let child = Command::new("xcrun")
|
||||
.arg("simctl")
|
||||
.arg("launch")
|
||||
.arg("--console")
|
||||
.arg("booted")
|
||||
.arg("com.dioxuslabs")
|
||||
.envs(ios_envs)
|
||||
.stderr(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.kill_on_drop(true)
|
||||
.spawn()?;
|
||||
|
||||
tracing::debug!("Launched app on simulator with exit code: {child:?}");
|
||||
|
||||
Ok(child)
|
||||
}
|
||||
|
||||
/// We have this whole thing figured out, but we don't actually use it yet.
|
||||
///
|
||||
/// Launching on devices is more complicated and requires us to codesign the app, which we don't
|
||||
/// currently do.
|
||||
///
|
||||
/// Converting these commands shouldn't be too hard, but device support would imply we need
|
||||
/// better support for codesigning and entitlements.
|
||||
#[allow(unused)]
|
||||
async fn open_ios_device(&self) -> Result<()> {
|
||||
// APP_PATH="target/aarch64-apple-ios/debug/bundle/ios/DioxusApp.app"
|
||||
|
||||
// # get the device id by jq-ing the json of the device list
|
||||
// xcrun devicectl list devices --json-output target/deviceid.json
|
||||
// DEVICE_UUID=$(jq -r '.result.devices[0].identifier' target/deviceid.json)
|
||||
|
||||
// xcrun devicectl device install app --device "${DEVICE_UUID}" "${APP_PATH}" --json-output target/xcrun.json
|
||||
|
||||
// # get the installation url by jq-ing the json of the device install
|
||||
// INSTALLATION_URL=$(jq -r '.result.installedApplications[0].installationURL' target/xcrun.json)
|
||||
|
||||
// # launch the app
|
||||
// # todo: we can just background it immediately and then pick it up for loading its logs
|
||||
// xcrun devicectl device process launch --device "${DEVICE_UUID}" "${INSTALLATION_URL}"
|
||||
|
||||
// # # launch the app and put it in background
|
||||
// # xcrun devicectl device process launch --no-activate --verbose --device "${DEVICE_UUID}" "${INSTALLATION_URL}" --json-output "${XCRUN_DEVICE_PROCESS_LAUNCH_LOG_DIR}"
|
||||
|
||||
// # # Extract background PID of status app
|
||||
// # STATUS_PID=$(jq -r '.result.process.processIdentifier' "${XCRUN_DEVICE_PROCESS_LAUNCH_LOG_DIR}")
|
||||
// # "${GIT_ROOT}/scripts/wait-for-metro-port.sh" 2>&1
|
||||
|
||||
// # # now that metro is ready, resume the app from background
|
||||
// # xcrun devicectl device process resume --device "${DEVICE_UUID}" --pid "${STATUS_PID}" > "${XCRUN_DEVICE_PROCESS_RESUME_LOG_DIR}" 2>&1
|
||||
|
||||
use serde_json::Value;
|
||||
let app_path = self.app.app_dir();
|
||||
|
||||
install_app(&app_path).await?;
|
||||
|
||||
// 2. Determine which device the app was installed to
|
||||
let device_uuid = get_device_uuid().await?;
|
||||
|
||||
// 3. Get the installation URL of the app
|
||||
let installation_url = get_installation_url(&device_uuid, &app_path).await?;
|
||||
|
||||
// 4. Launch the app into the background, paused
|
||||
launch_app_paused(&device_uuid, &installation_url).await?;
|
||||
|
||||
// 5. Pick up the paused app and resume it
|
||||
resume_app(&device_uuid).await?;
|
||||
|
||||
async fn install_app(app_path: &PathBuf) -> Result<()> {
|
||||
let output = Command::new("xcrun")
|
||||
.args(["simctl", "install", "booted"])
|
||||
.arg(app_path)
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(format!("Failed to install app: {:?}", output).into());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_device_uuid() -> Result<String> {
|
||||
let output = Command::new("xcrun")
|
||||
.args([
|
||||
"devicectl",
|
||||
"list",
|
||||
"devices",
|
||||
"--json-output",
|
||||
"target/deviceid.json",
|
||||
])
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
let json: Value =
|
||||
serde_json::from_str(&std::fs::read_to_string("target/deviceid.json")?)
|
||||
.context("Failed to parse xcrun output")?;
|
||||
let device_uuid = json["result"]["devices"][0]["identifier"]
|
||||
.as_str()
|
||||
.ok_or("Failed to extract device UUID")?
|
||||
.to_string();
|
||||
|
||||
Ok(device_uuid)
|
||||
}
|
||||
|
||||
async fn get_installation_url(device_uuid: &str, app_path: &Path) -> Result<String> {
|
||||
let output = Command::new("xcrun")
|
||||
.args([
|
||||
"devicectl",
|
||||
"device",
|
||||
"install",
|
||||
"app",
|
||||
"--device",
|
||||
device_uuid,
|
||||
&app_path.display().to_string(),
|
||||
"--json-output",
|
||||
"target/xcrun.json",
|
||||
])
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(format!("Failed to install app: {:?}", output).into());
|
||||
}
|
||||
|
||||
let json: Value = serde_json::from_str(&std::fs::read_to_string("target/xcrun.json")?)
|
||||
.context("Failed to parse xcrun output")?;
|
||||
let installation_url = json["result"]["installedApplications"][0]["installationURL"]
|
||||
.as_str()
|
||||
.ok_or("Failed to extract installation URL")?
|
||||
.to_string();
|
||||
|
||||
Ok(installation_url)
|
||||
}
|
||||
|
||||
async fn launch_app_paused(device_uuid: &str, installation_url: &str) -> Result<()> {
|
||||
let output = Command::new("xcrun")
|
||||
.args([
|
||||
"devicectl",
|
||||
"device",
|
||||
"process",
|
||||
"launch",
|
||||
"--no-activate",
|
||||
"--verbose",
|
||||
"--device",
|
||||
device_uuid,
|
||||
installation_url,
|
||||
"--json-output",
|
||||
"target/launch.json",
|
||||
])
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(format!("Failed to launch app: {:?}", output).into());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn resume_app(device_uuid: &str) -> Result<()> {
|
||||
let json: Value = serde_json::from_str(&std::fs::read_to_string("target/launch.json")?)
|
||||
.context("Failed to parse xcrun output")?;
|
||||
|
||||
let status_pid = json["result"]["process"]["processIdentifier"]
|
||||
.as_u64()
|
||||
.ok_or("Failed to extract process identifier")?;
|
||||
|
||||
let output = Command::new("xcrun")
|
||||
.args([
|
||||
"devicectl",
|
||||
"device",
|
||||
"process",
|
||||
"resume",
|
||||
"--device",
|
||||
device_uuid,
|
||||
"--pid",
|
||||
&status_pid.to_string(),
|
||||
])
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(format!("Failed to resume app: {:?}", output).into());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
unimplemented!("dioxus-cli doesn't support ios devices yet.")
|
||||
}
|
||||
}
|
||||
@@ -1,313 +0,0 @@
|
||||
use dioxus_core::internal::{HotReloadTemplateWithLocation, HotReloadedTemplate};
|
||||
use dioxus_core_types::HotReloadingContext;
|
||||
use dioxus_rsx::CallBody;
|
||||
use dioxus_rsx_hotreload::{diff_rsx, ChangedRsx};
|
||||
use krates::cm::MetadataCommand;
|
||||
use krates::Cmd;
|
||||
pub use std::collections::HashMap;
|
||||
use std::{ffi::OsStr, path::PathBuf};
|
||||
pub use std::{fs, io, path::Path};
|
||||
pub use std::{fs::File, io::Read};
|
||||
use syn::spanned::Spanned;
|
||||
|
||||
pub struct FileMap {
|
||||
pub map: HashMap<PathBuf, CachedSynFile>,
|
||||
|
||||
/// Any errors that occurred while building the FileMap that were not fatal
|
||||
pub errors: Vec<io::Error>,
|
||||
|
||||
pub in_workspace: HashMap<PathBuf, Option<PathBuf>>,
|
||||
}
|
||||
|
||||
/// A cached file that has been parsed
|
||||
///
|
||||
/// We store the templates found in this file
|
||||
pub struct CachedSynFile {
|
||||
pub raw: String,
|
||||
pub templates: HashMap<String, HotReloadedTemplate>,
|
||||
}
|
||||
|
||||
impl FileMap {
|
||||
/// Create a new FileMap from a crate directory
|
||||
///
|
||||
/// TODO: this should be created with a gitignore filter
|
||||
pub fn create<Ctx: HotReloadingContext>(path: PathBuf) -> io::Result<FileMap> {
|
||||
Self::create_with_filter::<Ctx>(path, |p| {
|
||||
// skip some stuff we know is large by default
|
||||
p.file_name() == Some(OsStr::new("target"))
|
||||
|| p.file_name() == Some(OsStr::new("node_modules"))
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a new FileMap from a crate directory
|
||||
///
|
||||
/// Takes a filter that when returns true, the file will be filtered out (ie not tracked)
|
||||
/// Note that this is inverted from a typical .filter() method.
|
||||
pub fn create_with_filter<Ctx: HotReloadingContext>(
|
||||
crate_dir: PathBuf,
|
||||
mut filter: impl FnMut(&Path) -> bool,
|
||||
) -> io::Result<FileMap> {
|
||||
let FileMapSearchResult { map, errors } = find_rs_files(crate_dir.clone(), &mut filter);
|
||||
|
||||
let mut map = Self {
|
||||
map,
|
||||
errors,
|
||||
in_workspace: HashMap::new(),
|
||||
};
|
||||
|
||||
map.load_assets::<Ctx>(crate_dir.as_path());
|
||||
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
/// Start watching assets for changes
|
||||
///
|
||||
/// This just diffs every file against itself and populates the tracked assets as it goes
|
||||
pub fn load_assets<Ctx: HotReloadingContext>(&mut self, crate_dir: &Path) {
|
||||
let keys = self.map.keys().cloned().collect::<Vec<_>>();
|
||||
for file in keys {
|
||||
_ = self.update_rsx::<Ctx>(file.as_path(), crate_dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a file into the map and force a full rebuild
|
||||
fn full_rebuild(&mut self, file_path: PathBuf, src: String) -> HotreloadError {
|
||||
let cached_file = CachedSynFile {
|
||||
raw: src.clone(),
|
||||
templates: HashMap::new(),
|
||||
};
|
||||
|
||||
self.map.insert(file_path, cached_file);
|
||||
HotreloadError::Notreloadable
|
||||
}
|
||||
|
||||
/// Try to update the rsx in a file
|
||||
pub fn update_rsx<Ctx: HotReloadingContext>(
|
||||
&mut self,
|
||||
file_path: &Path,
|
||||
crate_dir: &Path,
|
||||
) -> Result<Vec<HotReloadTemplateWithLocation>, HotreloadError> {
|
||||
let src = std::fs::read_to_string(file_path)?;
|
||||
|
||||
// If we can't parse the contents we want to pass it off to the build system to tell the user that there's a syntax error
|
||||
let syntax = syn::parse_file(&src).map_err(|_err| HotreloadError::Parse)?;
|
||||
|
||||
let in_workspace = self.child_in_workspace(crate_dir)?;
|
||||
|
||||
// Get the cached file if it exists, otherwise try to create it
|
||||
let Some(old_cached) = self.map.get_mut(file_path) else {
|
||||
// if this is a new file, rebuild the project
|
||||
let mut map = FileMap::create::<Ctx>(crate_dir.to_path_buf())?;
|
||||
|
||||
if let Some(err) = map.errors.pop() {
|
||||
return Err(HotreloadError::Failure(err));
|
||||
}
|
||||
|
||||
// merge the new map into the old map
|
||||
self.map.extend(map.map);
|
||||
|
||||
return Err(HotreloadError::Notreloadable);
|
||||
};
|
||||
|
||||
// If the cached file is not a valid rsx file, rebuild the project, forcing errors
|
||||
// TODO: in theory the error is simply in the RsxCallbody. We could attempt to parse it using partial expansion
|
||||
// And collect out its errors instead of giving up to a full rebuild
|
||||
let old = syn::parse_file(&old_cached.raw).map_err(|_e| HotreloadError::Parse)?;
|
||||
|
||||
let instances = match diff_rsx(&syntax, &old) {
|
||||
// If the changes were just some rsx, we can just update the template
|
||||
//
|
||||
// However... if the changes involved code in the rsx itself, this should actually be a CodeChanged
|
||||
Some(rsx_calls) => rsx_calls,
|
||||
|
||||
// If the changes were some code, we should insert the file into the map and rebuild
|
||||
// todo: not sure we even need to put the cached file into the map, but whatever
|
||||
None => {
|
||||
return Err(self.full_rebuild(file_path.to_path_buf(), src));
|
||||
}
|
||||
};
|
||||
|
||||
let mut out_templates = vec![];
|
||||
|
||||
for calls in instances.into_iter() {
|
||||
let ChangedRsx { old, new } = calls;
|
||||
|
||||
let old_start = old.span().start();
|
||||
|
||||
let old_parsed = syn::parse2::<CallBody>(old.tokens);
|
||||
let new_parsed = syn::parse2::<CallBody>(new.tokens);
|
||||
let (Ok(old_call_body), Ok(new_call_body)) = (old_parsed, new_parsed) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// if the file!() macro is invoked in a workspace, the path is relative to the workspace root, otherwise it's relative to the crate root
|
||||
// we need to check if the file is in a workspace or not and strip the prefix accordingly
|
||||
let prefix = match in_workspace {
|
||||
Some(ref workspace) => workspace,
|
||||
_ => crate_dir,
|
||||
};
|
||||
|
||||
let Ok(file) = file_path.strip_prefix(prefix) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let template_location = template_location(old_start, file);
|
||||
|
||||
// Returns a list of templates that are hotreloadable
|
||||
let hotreload_result = dioxus_rsx_hotreload::HotReloadResult::new::<Ctx>(
|
||||
&old_call_body.body,
|
||||
&new_call_body.body,
|
||||
template_location.clone(),
|
||||
);
|
||||
|
||||
// if the template is not hotreloadable, we need to do a full rebuild
|
||||
let Some(mut results) = hotreload_result else {
|
||||
return Err(self.full_rebuild(file_path.to_path_buf(), src));
|
||||
};
|
||||
|
||||
// Be careful to not send the bad templates
|
||||
results.templates.retain(|idx, template| {
|
||||
// dioxus cannot handle empty templates...
|
||||
if template.roots.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let template_location = format_template_name(&template_location, *idx);
|
||||
|
||||
// if the template is the same, don't send its
|
||||
if old_cached.templates.get(&template_location) == Some(&*template) {
|
||||
return false;
|
||||
};
|
||||
|
||||
// Update the most recent idea of the template
|
||||
// This lets us know if the template has changed so we don't need to send it
|
||||
old_cached
|
||||
.templates
|
||||
.insert(template_location, template.clone());
|
||||
|
||||
true
|
||||
});
|
||||
|
||||
out_templates.extend(results.templates.into_iter().map(|(idx, template)| {
|
||||
HotReloadTemplateWithLocation {
|
||||
location: format_template_name(&template_location, idx),
|
||||
template,
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(out_templates)
|
||||
}
|
||||
|
||||
fn child_in_workspace(&mut self, crate_dir: &Path) -> io::Result<Option<PathBuf>> {
|
||||
if let Some(in_workspace) = self.in_workspace.get(crate_dir) {
|
||||
return Ok(in_workspace.clone());
|
||||
}
|
||||
|
||||
let mut cmd = Cmd::new();
|
||||
let manafest_path = crate_dir.join("Cargo.toml");
|
||||
cmd.manifest_path(&manafest_path);
|
||||
let cmd: MetadataCommand = cmd.into();
|
||||
let metadata = cmd
|
||||
.exec()
|
||||
.map_err(|err| io::Error::new(io::ErrorKind::Other, err))?;
|
||||
|
||||
let in_workspace = metadata.workspace_root != crate_dir;
|
||||
let workspace_path = in_workspace.then(|| metadata.workspace_root.into());
|
||||
self.in_workspace
|
||||
.insert(crate_dir.to_path_buf(), workspace_path.clone());
|
||||
Ok(workspace_path)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn template_location(old_start: proc_macro2::LineColumn, file: &Path) -> String {
|
||||
let line = old_start.line;
|
||||
let column = old_start.column + 1;
|
||||
|
||||
// Always ensure the path components are separated by `/`.
|
||||
let path = file
|
||||
.components()
|
||||
.map(|c| c.as_os_str().to_string_lossy())
|
||||
.collect::<Vec<_>>()
|
||||
.join("/");
|
||||
|
||||
path + ":" + line.to_string().as_str() + ":" + column.to_string().as_str()
|
||||
}
|
||||
|
||||
pub fn format_template_name(name: &str, index: usize) -> String {
|
||||
format!("{}:{}", name, index)
|
||||
}
|
||||
|
||||
struct FileMapSearchResult {
|
||||
map: HashMap<PathBuf, CachedSynFile>,
|
||||
errors: Vec<io::Error>,
|
||||
}
|
||||
|
||||
// todo: we could just steal the mod logic from rustc itself
|
||||
fn find_rs_files(root: PathBuf, filter: &mut impl FnMut(&Path) -> bool) -> FileMapSearchResult {
|
||||
let mut files = HashMap::new();
|
||||
let mut errors = Vec::new();
|
||||
|
||||
if root.is_dir() {
|
||||
let read_dir = match fs::read_dir(root) {
|
||||
Ok(read_dir) => read_dir,
|
||||
Err(err) => {
|
||||
errors.push(err);
|
||||
return FileMapSearchResult { map: files, errors };
|
||||
}
|
||||
};
|
||||
for entry in read_dir.flatten() {
|
||||
let path = entry.path();
|
||||
if !filter(&path) {
|
||||
let FileMapSearchResult {
|
||||
map,
|
||||
errors: child_errors,
|
||||
} = find_rs_files(path, filter);
|
||||
errors.extend(child_errors);
|
||||
files.extend(map);
|
||||
}
|
||||
}
|
||||
} else if root.extension().and_then(|s| s.to_str()) == Some("rs") {
|
||||
if let Ok(mut file) = File::open(root.clone()) {
|
||||
let mut src = String::new();
|
||||
match file.read_to_string(&mut src) {
|
||||
Ok(_) => {
|
||||
let cached_file = CachedSynFile {
|
||||
raw: src.clone(),
|
||||
templates: HashMap::new(),
|
||||
};
|
||||
|
||||
// track assets while we're here
|
||||
files.insert(root, cached_file);
|
||||
}
|
||||
Err(err) => {
|
||||
errors.push(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FileMapSearchResult { map: files, errors }
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum HotreloadError {
|
||||
Failure(io::Error),
|
||||
Parse,
|
||||
Notreloadable,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for HotreloadError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Failure(err) => write!(f, "Failed to parse file: {}", err),
|
||||
Self::Parse => write!(f, "Failed to parse file"),
|
||||
Self::Notreloadable => write!(f, "Template is not hotreloadable"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<io::Error> for HotreloadError {
|
||||
fn from(err: io::Error) -> Self {
|
||||
HotreloadError::Failure(err)
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
+196
-191
@@ -1,253 +1,258 @@
|
||||
use std::future::{poll_fn, Future, IntoFuture};
|
||||
use std::task::Poll;
|
||||
|
||||
use crate::builder::OpenArguments;
|
||||
use crate::cli::serve::Serve;
|
||||
use crate::dioxus_crate::DioxusCrate;
|
||||
use crate::tracer::CLILogControl;
|
||||
use crate::Result;
|
||||
use crate::{
|
||||
builder::{Stage, TargetPlatform, UpdateBuildProgress, UpdateStage},
|
||||
BuildUpdate, Builder, DioxusCrate, Error, Platform, Result, ServeArgs, TraceController,
|
||||
TraceSrc,
|
||||
};
|
||||
use futures_util::FutureExt;
|
||||
use tokio::task::yield_now;
|
||||
|
||||
mod builder;
|
||||
mod hot_reloading_file_map;
|
||||
mod logs_tab;
|
||||
mod ansi_buffer;
|
||||
mod detect;
|
||||
mod handle;
|
||||
mod output;
|
||||
mod proxy;
|
||||
mod render;
|
||||
mod runner;
|
||||
mod server;
|
||||
mod update;
|
||||
mod watcher;
|
||||
|
||||
use builder::*;
|
||||
use output::*;
|
||||
use server::*;
|
||||
use watcher::*;
|
||||
pub(crate) use handle::*;
|
||||
pub(crate) use output::*;
|
||||
pub(crate) use runner::*;
|
||||
pub(crate) use server::*;
|
||||
pub(crate) use update::*;
|
||||
pub(crate) use watcher::*;
|
||||
|
||||
/// For *all* builds the CLI spins up a dedicated webserver, file watcher, and build infrastructure to serve the project.
|
||||
/// For *all* builds, the CLI spins up a dedicated webserver, file watcher, and build infrastructure to serve the project.
|
||||
///
|
||||
/// This includes web, desktop, mobile, fullstack, etc.
|
||||
///
|
||||
/// Platform specifics:
|
||||
/// - Web: we need to attach a filesystem server to our devtools webserver to serve the project. We
|
||||
/// want to emulate GithubPages here since most folks are deploying there and expect things like
|
||||
/// basepath to match.
|
||||
/// - Fullstack: We spin up the same dev server but in this case the fullstack server itself needs to
|
||||
/// proxy all dev requests to our dev server
|
||||
/// - Desktop: We spin up the dev server but without a filesystem server.
|
||||
/// - Mobile: Basically the same as desktop.
|
||||
/// -------------------
|
||||
/// - Web: we need to attach a filesystem server to our devtools webserver to serve the project. We
|
||||
/// want to emulate GithubPages here since most folks are deploying there and expect things like
|
||||
/// basepath to match.
|
||||
/// - Desktop: We spin up the dev server but without a filesystem server.
|
||||
/// - Mobile: Basically the same as desktop.
|
||||
///
|
||||
/// Notes:
|
||||
/// - All filesystem changes are tracked here
|
||||
/// - We send all updates to connected websocket connections. Even desktop connects via the websocket
|
||||
/// - Right now desktop compiles tokio-tungstenite to do the connection but we could in theory reuse
|
||||
/// the websocket logic from the webview for thinner builds.
|
||||
/// When fullstack is enabled, we'll also build for the `server` target and then hotreload the server.
|
||||
/// The "server" is special here since "fullstack" is functionally just an addition to the regular client
|
||||
/// setup.
|
||||
///
|
||||
/// Todos(Jon):
|
||||
/// - I'd love to be able to configure the CLI while it's running so we can change settingaon the fly.
|
||||
/// This would require some light refactoring and potentially pulling in something like ratatui.
|
||||
/// - Build a custom subscriber for logs by tools within this
|
||||
/// - Handle logs from the build engine separately?
|
||||
/// - Consume logs from the wasm for web/fullstack
|
||||
/// - I'd love to be able to configure the CLI while it's running so we can change settings on the fly.
|
||||
/// - I want us to be able to detect a `server_fn` in the project and then upgrade from a static server
|
||||
/// to a dynamic one on the fly.
|
||||
pub async fn serve_all(
|
||||
serve: Serve,
|
||||
dioxus_crate: DioxusCrate,
|
||||
log_control: CLILogControl,
|
||||
) -> Result<()> {
|
||||
// Start the screen first so we collect build logs.
|
||||
let mut screen = Output::start(&serve, log_control).expect("Failed to open terminal logger");
|
||||
let mut builder = Builder::new(&dioxus_crate, &serve);
|
||||
pub(crate) async fn serve_all(args: ServeArgs, krate: DioxusCrate) -> Result<()> {
|
||||
let mut tracer = TraceController::redirect();
|
||||
|
||||
// Start the first build
|
||||
builder.build()?;
|
||||
// Note that starting the builder will queue up a build immediately
|
||||
let mut builder = Builder::start(&krate, args.build_args())?;
|
||||
let mut devserver = WebServer::start(&krate, &args)?;
|
||||
let mut watcher = Watcher::start(&krate, &args);
|
||||
let mut runner = AppRunner::start(&krate);
|
||||
let mut screen = Output::start(&args)?;
|
||||
|
||||
let mut server = Server::start(&serve, &dioxus_crate);
|
||||
let mut watcher = Watcher::start(&serve, &dioxus_crate);
|
||||
// This is our default splash screen. We might want to make this a fancier splash screen in the future
|
||||
// Also, these commands might not be the most important, but it's all we've got enabled right now
|
||||
tracing::info!(
|
||||
r#"Serving your Dioxus app: {} 🚀
|
||||
|
||||
let is_hot_reload = serve.server_arguments.hot_reload.unwrap_or(true);
|
||||
- Press `ctrl+c` to exit the server
|
||||
- Press `r` to rebuild the app
|
||||
- Press `o` to open the app
|
||||
- Press `t` to toggle cargo output
|
||||
- Press `/` for more commands and shortcuts
|
||||
|
||||
loop {
|
||||
// Make sure we don't hog the CPU: these loop { select! {} } blocks can starve the executor
|
||||
yield_now().await;
|
||||
Learn more at https://dioxuslabs.com/learn/0.6/getting_started"#,
|
||||
krate.executable_name()
|
||||
);
|
||||
|
||||
let err: Result<(), Error> = loop {
|
||||
// Draw the state of the server to the screen
|
||||
screen.render(&serve, &dioxus_crate, &builder, &server, &watcher);
|
||||
screen.render(&args, &krate, &builder, &devserver, &watcher);
|
||||
|
||||
// And then wait for any updates before redrawing
|
||||
tokio::select! {
|
||||
// rebuild the project or hotreload it
|
||||
_ = watcher.wait(), if is_hot_reload => {
|
||||
if !watcher.pending_changes() {
|
||||
continue
|
||||
let msg = tokio::select! {
|
||||
msg = builder.wait() => ServeUpdate::BuildUpdate(msg),
|
||||
msg = watcher.wait() => msg,
|
||||
msg = devserver.wait() => msg,
|
||||
msg = screen.wait() => msg,
|
||||
msg = runner.wait() => msg,
|
||||
msg = tracer.wait() => msg,
|
||||
};
|
||||
|
||||
match msg {
|
||||
ServeUpdate::FilesChanged { files } => {
|
||||
if files.is_empty() || !args.should_hotreload() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let changed_files = watcher.dequeue_changed_files(&dioxus_crate);
|
||||
let changed = changed_files.first().cloned();
|
||||
let file = files[0].display().to_string();
|
||||
let file = file.trim_start_matches(&krate.crate_dir().display().to_string());
|
||||
|
||||
// if change is hotreloadable, hotreload it
|
||||
// and then send that update to all connected clients
|
||||
if let Some(hr) = watcher.attempt_hot_reload(&dioxus_crate, changed_files) {
|
||||
if let Some(hr) = runner.attempt_hot_reload(files) {
|
||||
// Only send a hotreload message for templates and assets - otherwise we'll just get a full rebuild
|
||||
if hr.templates.is_empty() && hr.assets.is_empty() && hr.unknown_files.is_empty() {
|
||||
continue
|
||||
if hr.templates.is_empty()
|
||||
&& hr.assets.is_empty()
|
||||
&& hr.unknown_files.is_empty()
|
||||
{
|
||||
tracing::debug!(dx_src = ?TraceSrc::Dev, "Ignoring file change: {}", file);
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(changed_path) = changed {
|
||||
let path_relative = changed_path.strip_prefix(dioxus_crate.crate_dir()).map(|p| p.display().to_string()).unwrap_or_else(|_| changed_path.display().to_string());
|
||||
tracing::info!(dx_src = ?TraceSrc::Dev, "Hotreloaded {}", path_relative);
|
||||
}
|
||||
tracing::info!(dx_src = ?TraceSrc::Dev, "Hotreloading: {}", file);
|
||||
|
||||
devserver.send_hotreload(hr).await;
|
||||
} else if runner.should_full_rebuild {
|
||||
tracing::info!(dx_src = ?TraceSrc::Dev, "Full rebuild: {}", file);
|
||||
|
||||
server.send_hotreload(hr).await;
|
||||
} else {
|
||||
// If the change is not binary patchable, rebuild the project
|
||||
// We're going to kick off a new build, interrupting the current build if it's ongoing
|
||||
builder.build()?;
|
||||
builder.rebuild(args.build_arguments.clone());
|
||||
|
||||
// Clear the hot reload changes
|
||||
watcher.clear_hot_reload_changes();
|
||||
// Clear the hot reload changes so we don't have out-of-sync issues with changed UI
|
||||
runner.clear_hot_reload_changes();
|
||||
runner.file_map.force_rebuild();
|
||||
|
||||
// Tell the server to show a loading page for any new requests
|
||||
server.start_build().await;
|
||||
devserver.start_build().await;
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"Rebuild required but is currently paused - press `r` to rebuild manually"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// reload the page
|
||||
msg = server.wait() => {
|
||||
// Run the server in the background
|
||||
// Waiting for updates here lets us tap into when clients are added/removed
|
||||
match msg {
|
||||
Some(ServerUpdate::NewConnection) => {
|
||||
if let Some(msg) = watcher.applied_hot_reload_changes() {
|
||||
server.send_hotreload(msg).await;
|
||||
}
|
||||
}
|
||||
Some(ServerUpdate::Message(msg)) => {
|
||||
screen.new_ws_message(TargetPlatform::Web, msg);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
// Run the server in the background
|
||||
// Waiting for updates here lets us tap into when clients are added/removed
|
||||
ServeUpdate::NewConnection => {
|
||||
devserver
|
||||
.send_hotreload(runner.applied_hot_reload_changes())
|
||||
.await;
|
||||
|
||||
runner.client_connected().await;
|
||||
}
|
||||
|
||||
// Handle updates from the build engine
|
||||
application = builder.wait() => {
|
||||
// Wait for logs from the build engine
|
||||
// These will cause us to update the screen
|
||||
// We also can check the status of the builds here in case we have multiple ongoing builds
|
||||
match application {
|
||||
Ok(BuilderUpdate::Progress { platform, update }) => {
|
||||
let update_clone = update.clone();
|
||||
screen.new_build_progress(platform, update_clone);
|
||||
server.update_build_status(screen.build_progress.progress(), update.stage.to_string()).await;
|
||||
// Received a message from the devtools server - currently we only use this for
|
||||
// logging, so we just forward it the tui
|
||||
ServeUpdate::WsMessage(msg) => {
|
||||
screen.push_ws_message(Platform::Web, msg);
|
||||
}
|
||||
|
||||
match update {
|
||||
// Send rebuild start message.
|
||||
UpdateBuildProgress { stage: Stage::Compiling, update: UpdateStage::Start } => server.send_reload_start().await,
|
||||
// Send rebuild failed message.
|
||||
UpdateBuildProgress { stage: Stage::Finished, update: UpdateStage::Failed(_) } => server.send_reload_failed().await,
|
||||
_ => {},
|
||||
}
|
||||
// Wait for logs from the build engine
|
||||
// These will cause us to update the screen
|
||||
// We also can check the status of the builds here in case we have multiple ongoing builds
|
||||
ServeUpdate::BuildUpdate(update) => {
|
||||
// Queue any logs to be printed if need be
|
||||
screen.new_build_update(&update);
|
||||
|
||||
// And then update the websocketed clients with the new build status in case they want it
|
||||
devserver.new_build_update(&update).await;
|
||||
|
||||
// And then open the app if it's ready
|
||||
// todo: there might be more things to do here that require coordination with other pieces of the CLI
|
||||
// todo: maybe we want to shuffle the runner around to send an "open" command instead of doing that
|
||||
match update {
|
||||
BuildUpdate::Progress { .. } => {}
|
||||
BuildUpdate::CompilerMessage { message } => {
|
||||
screen.push_cargo_log(message);
|
||||
}
|
||||
Ok(BuilderUpdate::Ready { results }) => {
|
||||
if !results.is_empty() {
|
||||
builder.children.clear();
|
||||
}
|
||||
|
||||
// If we have a build result, open it
|
||||
for build_result in results.iter() {
|
||||
let child = build_result.open(
|
||||
OpenArguments::new(
|
||||
&serve.server_arguments,
|
||||
server.fullstack_address(),
|
||||
&dioxus_crate
|
||||
BuildUpdate::BuildFailed { err } => {
|
||||
tracing::error!("Build failed: {}", err);
|
||||
}
|
||||
BuildUpdate::BuildReady { bundle } => {
|
||||
let handle = runner
|
||||
.open(
|
||||
bundle,
|
||||
devserver.devserver_address(),
|
||||
devserver.proxied_server_address(),
|
||||
args.open.unwrap_or(false),
|
||||
)
|
||||
);
|
||||
match child {
|
||||
Ok(Some(child_proc)) => builder.children.push((build_result.target_platform, child_proc)),
|
||||
Err(e) => {
|
||||
tracing::error!(dx_src = ?TraceSrc::Build, "Failed to open build result: {e}");
|
||||
break;
|
||||
},
|
||||
_ => {}
|
||||
.await;
|
||||
|
||||
match handle {
|
||||
// Update the screen + devserver with the new handle info
|
||||
Ok(_handle) => {
|
||||
devserver.send_reload_command().await;
|
||||
}
|
||||
|
||||
Err(e) => tracing::error!("Failed to open app: {}", e),
|
||||
}
|
||||
|
||||
// Make sure we immediately capture the stdout/stderr of the executable -
|
||||
// otherwise it'll clobber our terminal output
|
||||
screen.new_ready_app(&mut builder, results);
|
||||
|
||||
// And then finally tell the server to reload
|
||||
server.send_reload_command().await;
|
||||
},
|
||||
|
||||
// If the desktop process exited *cleanly*, we can exit
|
||||
Ok(BuilderUpdate::ProcessExited { status, target_platform }) => {
|
||||
// Then remove the child process
|
||||
builder.children.retain(|(platform, _)| *platform != target_platform);
|
||||
match (target_platform, status) {
|
||||
(TargetPlatform::Desktop, Ok(status)) => {
|
||||
if status.success() {
|
||||
break;
|
||||
}
|
||||
else {
|
||||
tracing::error!(dx_src = ?TraceSrc::Dev, "Application exited with status: {status}");
|
||||
}
|
||||
},
|
||||
// Ignore the static generation platform exiting
|
||||
(_ , Ok(_)) => {},
|
||||
(_, Err(e)) => {
|
||||
tracing::error!(dx_src = ?TraceSrc::Dev, "Application exited with error: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
server.send_build_error(err).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle input from the user using our settings
|
||||
res = screen.wait() => {
|
||||
match res {
|
||||
Ok(false) => {}
|
||||
// Request a rebuild.
|
||||
Ok(true) => {
|
||||
builder.build()?;
|
||||
server.start_build().await
|
||||
},
|
||||
// Shutdown the server.
|
||||
Err(_) => break,
|
||||
// If the process exited *cleanly*, we can exit
|
||||
ServeUpdate::ProcessExited { status, platform } => {
|
||||
if !status.success() {
|
||||
tracing::error!("Application [{platform}] exited with error: {status}");
|
||||
} else {
|
||||
tracing::info!(
|
||||
r#"Application [{platform}] exited gracefully.
|
||||
- To restart the app, press `r` to rebuild or `o` to open
|
||||
- To exit the server, press `ctrl+c`"#
|
||||
);
|
||||
}
|
||||
|
||||
runner.kill(platform);
|
||||
}
|
||||
|
||||
ServeUpdate::StdoutReceived { platform, msg } => {
|
||||
screen.push_stdio(platform, msg, tracing::Level::INFO);
|
||||
}
|
||||
|
||||
ServeUpdate::StderrReceived { platform, msg } => {
|
||||
screen.push_stdio(platform, msg, tracing::Level::ERROR);
|
||||
}
|
||||
|
||||
ServeUpdate::TracingLog { log } => {
|
||||
screen.push_log(log);
|
||||
}
|
||||
|
||||
ServeUpdate::RequestRebuild => {
|
||||
// The spacing here is important-ish: we want
|
||||
// `Full rebuild:` to line up with
|
||||
// `Hotreloading:` to keep the alignment during long edit sessions
|
||||
tracing::info!("Full rebuild: triggered manually");
|
||||
builder.rebuild(args.build_arguments.clone());
|
||||
runner.file_map.force_rebuild();
|
||||
devserver.start_build().await
|
||||
}
|
||||
|
||||
ServeUpdate::OpenApp => {
|
||||
runner.open_existing(&devserver).await;
|
||||
}
|
||||
|
||||
ServeUpdate::Redraw => {
|
||||
// simply returning will cause a redraw
|
||||
}
|
||||
|
||||
ServeUpdate::ToggleShouldRebuild => {
|
||||
runner.should_full_rebuild = !runner.should_full_rebuild;
|
||||
tracing::info!(
|
||||
"Automatic rebuilds are currently: {}",
|
||||
if runner.should_full_rebuild {
|
||||
"enabled"
|
||||
} else {
|
||||
"disabled"
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
ServeUpdate::Exit { error } => match error {
|
||||
Some(err) => break Err(anyhow::anyhow!("{}", err).into()),
|
||||
None => break Ok(()),
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Run our cleanup logic here - maybe printing as we go?
|
||||
// todo: more printing, logging, error handling in this phase
|
||||
_ = devserver.shutdown().await;
|
||||
_ = screen.shutdown();
|
||||
_ = server.shutdown().await;
|
||||
builder.shutdown();
|
||||
builder.abort_all();
|
||||
tracer.shutdown();
|
||||
|
||||
if let Err(err) = err {
|
||||
eprintln!("Exiting with error: {}", err);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Grab the output of a future that returns an option or wait forever
|
||||
pub(crate) fn next_or_pending<F, T>(f: F) -> impl Future<Output = T>
|
||||
where
|
||||
F: IntoFuture<Output = Option<T>>,
|
||||
{
|
||||
let pinned = f.into_future().fuse();
|
||||
let mut pinned = Box::pin(pinned);
|
||||
poll_fn(move |cx| {
|
||||
let next = pinned.as_mut().poll(cx);
|
||||
match next {
|
||||
Poll::Ready(Some(next)) => Poll::Ready(next),
|
||||
_ => Poll::Pending,
|
||||
}
|
||||
})
|
||||
.fuse()
|
||||
}
|
||||
+851
-717
File diff suppressed because it is too large.
Load diff
@@ -56,13 +56,13 @@ impl ProxyClient {
|
||||
/// - the exact path of the proxy config's backend URL, e.g. /api
|
||||
/// - the exact path with a trailing slash, e.g. /api/
|
||||
/// - any subpath of the backend URL, e.g. /api/foo/bar
|
||||
pub fn add_proxy(mut router: Router, proxy: &WebProxyConfig) -> Result<Router> {
|
||||
pub(crate) fn add_proxy(mut router: Router, proxy: &WebProxyConfig) -> Result<Router> {
|
||||
let url: Uri = proxy.backend.parse()?;
|
||||
let path = url.path().to_string();
|
||||
let trimmed_path = path.trim_start_matches('/');
|
||||
|
||||
if trimmed_path.is_empty() {
|
||||
return Err(crate::Error::ProxySetupError(format!(
|
||||
return Err(crate::Error::ProxySetup(format!(
|
||||
"Proxy backend URL must have a non-empty path, e.g. {}/api instead of {}",
|
||||
proxy.backend.trim_end_matches('/'),
|
||||
proxy.backend
|
||||
@@ -142,6 +142,9 @@ pub(crate) fn proxy_to(
|
||||
}
|
||||
|
||||
let uri = req.uri().clone();
|
||||
|
||||
// retry with backoff
|
||||
|
||||
let res = client.send(req).await.map_err(handle_error);
|
||||
|
||||
match res {
|
||||
@@ -150,6 +153,7 @@ pub(crate) fn proxy_to(
|
||||
if uri.path().starts_with("/assets")
|
||||
|| uri.path().starts_with("/_dioxus")
|
||||
|| uri.path().starts_with("/public")
|
||||
|| uri.path().starts_with("/wasm")
|
||||
{
|
||||
tracing::trace!(dx_src = ?TraceSrc::Dev, "[{}] {}", res.status().as_u16(), uri);
|
||||
} else {
|
||||
@@ -284,7 +288,7 @@ mod test {
|
||||
};
|
||||
let router = super::add_proxy(Router::new(), &config);
|
||||
match router.unwrap_err() {
|
||||
crate::Error::ProxySetupError(e) => {
|
||||
crate::Error::ProxySetup(e) => {
|
||||
assert_eq!(
|
||||
e,
|
||||
"Proxy backend URL must have a non-empty path, e.g. http://localhost:8000/api instead of http://localhost:8000"
|
||||
|
||||
@@ -1,487 +0,0 @@
|
||||
use super::BuildProgress;
|
||||
use crate::{config::Platform, TraceMsg, TraceSrc};
|
||||
use ansi_to_tui::IntoText as _;
|
||||
use ratatui::{
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Style, Stylize},
|
||||
text::{Line, Span, Text},
|
||||
widgets::{Block, Borders, Clear, List, ListState, Paragraph, Widget, Wrap},
|
||||
Frame,
|
||||
};
|
||||
use regex::Regex;
|
||||
use std::fmt::Write as _;
|
||||
use std::rc::Rc;
|
||||
use tracing::Level;
|
||||
|
||||
pub struct TuiLayout {
|
||||
/// The entire TUI body.
|
||||
_body: Rc<[Rect]>,
|
||||
/// The console where build logs are displayed.
|
||||
console: Rc<[Rect]>,
|
||||
// The filter drawer if the drawer is open.
|
||||
filter_drawer: Option<Rc<[Rect]>>,
|
||||
|
||||
// The border that separates the console and info bars.
|
||||
border_sep: Rect,
|
||||
|
||||
// The status bar that displays build status, platform, versions, etc.
|
||||
status_bar: Rc<[Rect]>,
|
||||
|
||||
// Misc
|
||||
filter_list_state: ListState,
|
||||
}
|
||||
|
||||
impl TuiLayout {
|
||||
pub fn new(frame_size: Rect, filter_open: bool) -> Self {
|
||||
// The full layout
|
||||
let body = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
// Footer Status
|
||||
Constraint::Length(1),
|
||||
// Border Separator
|
||||
Constraint::Length(1),
|
||||
// Console
|
||||
Constraint::Fill(1),
|
||||
// Padding
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.split(frame_size);
|
||||
|
||||
let mut console_constraints = vec![Constraint::Fill(1)];
|
||||
if filter_open {
|
||||
console_constraints.push(Constraint::Length(1));
|
||||
console_constraints.push(Constraint::Length(25));
|
||||
}
|
||||
|
||||
// Build the console, where logs go.
|
||||
let console = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints(console_constraints)
|
||||
.split(body[2]);
|
||||
|
||||
let filter_drawer = match filter_open {
|
||||
false => None,
|
||||
true => Some(
|
||||
Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Length(1),
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.split(console[2]),
|
||||
),
|
||||
};
|
||||
|
||||
// Build the status bar.
|
||||
let status_bar = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Fill(1), Constraint::Fill(1)])
|
||||
.split(body[0]);
|
||||
|
||||
// Specify borders
|
||||
let border_sep_top = body[1];
|
||||
|
||||
Self {
|
||||
_body: body,
|
||||
console,
|
||||
filter_drawer,
|
||||
border_sep: border_sep_top,
|
||||
status_bar,
|
||||
filter_list_state: ListState::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Render all decorations.
|
||||
pub fn render_decor(&self, frame: &mut Frame, filter_open: bool) {
|
||||
frame.render_widget(
|
||||
Block::new()
|
||||
.borders(Borders::TOP)
|
||||
.border_style(Style::new().white()),
|
||||
self.border_sep,
|
||||
);
|
||||
|
||||
if filter_open {
|
||||
frame.render_widget(
|
||||
Block::new()
|
||||
.borders(Borders::LEFT)
|
||||
.border_style(Style::new().white()),
|
||||
self.console[1],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the console and it's logs, returning the number of lines required to render the entire log output.
|
||||
pub fn render_console(
|
||||
&self,
|
||||
frame: &mut Frame,
|
||||
scroll_position: u16,
|
||||
messages: &[TraceMsg],
|
||||
enabled_filters: &[String],
|
||||
) -> u16 {
|
||||
const LEVEL_MAX: usize = "BUILD: ".len();
|
||||
|
||||
let mut out_text = Text::default();
|
||||
|
||||
// Assemble the messages
|
||||
for msg in messages.iter() {
|
||||
let mut sub_line_padding = 0;
|
||||
|
||||
let text = msg.content.trim_end().into_text().unwrap_or_default();
|
||||
|
||||
for (idx, line) in text.lines.into_iter().enumerate() {
|
||||
// Don't add any formatting for cargo messages.
|
||||
let out_line = if msg.source != TraceSrc::Cargo {
|
||||
if idx == 0 {
|
||||
match msg.source {
|
||||
TraceSrc::Dev => {
|
||||
let mut spans = vec![Span::from(" DEV: ").light_magenta()];
|
||||
|
||||
for span in line.spans {
|
||||
spans.push(span);
|
||||
}
|
||||
spans
|
||||
}
|
||||
TraceSrc::Build => {
|
||||
let mut spans = vec![Span::from("BUILD: ").light_blue()];
|
||||
|
||||
for span in line.spans {
|
||||
spans.push(span);
|
||||
}
|
||||
spans
|
||||
}
|
||||
_ => {
|
||||
// Build level tag: `INFO:``
|
||||
// We don't subtract 1 here for `:` because we still want at least 1 padding.
|
||||
let padding =
|
||||
build_msg_padding(LEVEL_MAX - msg.level.to_string().len() - 2);
|
||||
let level = format!("{padding}{}: ", msg.level);
|
||||
sub_line_padding += level.len();
|
||||
|
||||
let level_span = Span::from(level);
|
||||
let level_span = match msg.level {
|
||||
Level::TRACE => level_span.black(),
|
||||
Level::DEBUG => level_span.light_magenta(),
|
||||
Level::INFO => level_span.light_green(),
|
||||
Level::WARN => level_span.light_yellow(),
|
||||
Level::ERROR => level_span.light_red(),
|
||||
};
|
||||
|
||||
let mut out_line = vec![level_span];
|
||||
for span in line.spans {
|
||||
out_line.push(span);
|
||||
}
|
||||
|
||||
out_line
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Not the first line. Append the padding and merge into list.
|
||||
let padding = build_msg_padding(sub_line_padding);
|
||||
|
||||
let mut out_line = vec![Span::from(padding)];
|
||||
for span in line.spans {
|
||||
out_line.push(span);
|
||||
}
|
||||
out_line
|
||||
}
|
||||
} else {
|
||||
line.spans
|
||||
};
|
||||
|
||||
out_text.push_line(Line::from(out_line));
|
||||
}
|
||||
}
|
||||
|
||||
// Only show messages for filters that are enabled.
|
||||
let mut included_line_ids = Vec::new();
|
||||
|
||||
for filter in enabled_filters {
|
||||
let re = Regex::new(filter);
|
||||
for (index, line) in out_text.lines.iter().enumerate() {
|
||||
let line_str = line.to_string();
|
||||
match re {
|
||||
Ok(ref re) => {
|
||||
// sort by provided regex
|
||||
if re.is_match(&line_str) {
|
||||
included_line_ids.push(index);
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
// default to basic string storing
|
||||
if line_str.contains(filter) {
|
||||
included_line_ids.push(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
included_line_ids.sort_unstable();
|
||||
included_line_ids.dedup();
|
||||
|
||||
let out_lines = out_text.lines;
|
||||
let mut out_text = Text::default();
|
||||
|
||||
if enabled_filters.is_empty() {
|
||||
for line in out_lines {
|
||||
out_text.push_line(line.clone());
|
||||
}
|
||||
} else {
|
||||
for id in included_line_ids {
|
||||
if let Some(line) = out_lines.get(id) {
|
||||
out_text.push_line(line.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (console_width, _console_height) = self.get_console_size();
|
||||
|
||||
let paragraph = Paragraph::new(out_text)
|
||||
.left_aligned()
|
||||
.wrap(Wrap { trim: false });
|
||||
|
||||
let num_lines_wrapping = paragraph.line_count(console_width) as u16;
|
||||
|
||||
paragraph
|
||||
.scroll((scroll_position, 0))
|
||||
.render(self.console[0], frame.buffer_mut());
|
||||
|
||||
num_lines_wrapping
|
||||
}
|
||||
|
||||
/// Render the status bar.
|
||||
pub fn render_status_bar(
|
||||
&self,
|
||||
frame: &mut Frame,
|
||||
_platform: Platform,
|
||||
build_progress: &BuildProgress,
|
||||
more_modal_open: bool,
|
||||
filter_menu_open: bool,
|
||||
dx_version: &str,
|
||||
) {
|
||||
// left aligned text
|
||||
let mut spans = vec![
|
||||
Span::from("🧬 dx").white(),
|
||||
Span::from(" ").white(),
|
||||
Span::from(dx_version).white(),
|
||||
Span::from(" | ").dark_gray(),
|
||||
];
|
||||
|
||||
// If there is build progress, render the current status.
|
||||
let is_build_progress = !build_progress.current_builds.is_empty();
|
||||
if is_build_progress {
|
||||
// If the build failed, show a failed status.
|
||||
// Otherwise, render current status.
|
||||
let build_failed = build_progress
|
||||
.current_builds
|
||||
.values()
|
||||
.any(|b| b.failed.is_some());
|
||||
|
||||
if build_failed {
|
||||
spans.push(Span::from("Build failed ❌").red());
|
||||
} else {
|
||||
// spans.push(Span::from("status: ").gray());
|
||||
let build = build_progress
|
||||
.current_builds
|
||||
.values()
|
||||
.min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
|
||||
.unwrap();
|
||||
spans.extend_from_slice(&build.make_spans(Rect::new(
|
||||
0,
|
||||
0,
|
||||
build.max_layout_size(),
|
||||
1,
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// right aligned text
|
||||
let more_span = Span::from("[/] more");
|
||||
let more_span = match more_modal_open {
|
||||
true => more_span.light_yellow(),
|
||||
false => more_span.gray(),
|
||||
};
|
||||
|
||||
let filter_span = Span::from("[f] filter");
|
||||
let filter_span = match filter_menu_open {
|
||||
true => filter_span.light_yellow(),
|
||||
false => filter_span.gray(),
|
||||
};
|
||||
|
||||
// Right-aligned text
|
||||
let right_line = Line::from(vec![
|
||||
Span::from("[o] open").gray(),
|
||||
Span::from(" | ").gray(),
|
||||
Span::from("[r] rebuild").gray(),
|
||||
Span::from(" | ").gray(),
|
||||
filter_span,
|
||||
Span::from(" | ").dark_gray(),
|
||||
more_span,
|
||||
]);
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(spans)).left_aligned(),
|
||||
self.status_bar[0],
|
||||
);
|
||||
|
||||
// Render the info
|
||||
frame.render_widget(
|
||||
Paragraph::new(right_line).right_aligned(),
|
||||
self.status_bar[1],
|
||||
);
|
||||
}
|
||||
|
||||
/// Renders the "more" modal to show extra info/keybinds accessible via the more keybind.
|
||||
pub fn render_more_modal(&self, frame: &mut Frame) {
|
||||
let modal = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Fill(1), Constraint::Length(5)])
|
||||
.split(self.console[0])[1];
|
||||
|
||||
frame.render_widget(Clear, modal);
|
||||
frame.render_widget(Block::default().borders(Borders::ALL), modal);
|
||||
|
||||
// Render under construction message
|
||||
frame.render_widget(
|
||||
Paragraph::new("Under construction, please check back at a later date!")
|
||||
.alignment(Alignment::Center),
|
||||
modal,
|
||||
);
|
||||
}
|
||||
|
||||
/// Render the filter drawer menu.
|
||||
pub fn render_filter_menu(
|
||||
&mut self,
|
||||
frame: &mut Frame,
|
||||
filters: &[(String, bool)],
|
||||
selected_filter_index: usize,
|
||||
search_mode: bool,
|
||||
search_input: Option<&String>,
|
||||
) {
|
||||
let Some(ref filter_drawer) = self.filter_drawer else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Vertical layout
|
||||
let container = Layout::default()
|
||||
.constraints([
|
||||
Constraint::Length(4),
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(7),
|
||||
])
|
||||
.direction(Direction::Vertical)
|
||||
.split(filter_drawer[1]);
|
||||
|
||||
// Render the search section.
|
||||
let top_area = Layout::default()
|
||||
.constraints([
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.direction(Direction::Vertical)
|
||||
.split(container[0]);
|
||||
|
||||
let search_title = Line::from("Search").gray();
|
||||
let search_input_block = Block::new().bg(Color::White);
|
||||
|
||||
let search_text = match search_input {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
if search_mode {
|
||||
"..."
|
||||
} else {
|
||||
"[enter] to type..."
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let search_input = Paragraph::new(Line::from(search_text))
|
||||
.fg(Color::Black)
|
||||
.block(search_input_block);
|
||||
|
||||
frame.render_widget(search_title, top_area[1]);
|
||||
frame.render_widget(search_input, top_area[2]);
|
||||
|
||||
// Render the filters
|
||||
let list_area = container[1];
|
||||
let mut list_items = Vec::new();
|
||||
|
||||
for (filter, enabled) in filters {
|
||||
let filter = Span::from(filter);
|
||||
let filter = match enabled {
|
||||
true => filter.light_yellow(),
|
||||
false => filter.dark_gray(),
|
||||
};
|
||||
list_items.push(filter);
|
||||
}
|
||||
list_items.reverse();
|
||||
|
||||
let list = List::new(list_items).highlight_symbol("» ");
|
||||
self.filter_list_state.select(Some(selected_filter_index));
|
||||
frame.render_stateful_widget(list, list_area, &mut self.filter_list_state);
|
||||
|
||||
// Render the keybind list at the bottom.
|
||||
let keybinds = container[2];
|
||||
let lines = vec![
|
||||
Line::from(""),
|
||||
Line::from("[↑] Up").white(),
|
||||
Line::from("[↓] Down").white(),
|
||||
Line::from("[←] Remove").white(),
|
||||
Line::from("[→] Toggle").white(),
|
||||
Line::from("[enter] Type / Submit").white(),
|
||||
];
|
||||
let text = Text::from(lines);
|
||||
frame.render_widget(text, keybinds);
|
||||
}
|
||||
|
||||
/// Returns the height of the console TUI area in number of lines.
|
||||
pub fn get_console_size(&self) -> (u16, u16) {
|
||||
(self.console[0].width, self.console[0].height)
|
||||
}
|
||||
|
||||
/// Render the current scroll position at the top right corner of the frame
|
||||
pub(crate) fn render_current_scroll(
|
||||
&self,
|
||||
scroll_position: u16,
|
||||
lines: u16,
|
||||
console_height: u16,
|
||||
frame: &mut Frame<'_>,
|
||||
) {
|
||||
let mut row = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(1)])
|
||||
.split(self.console[0])[0];
|
||||
|
||||
// Hack: shove upwards the text to overlap with the border so text selection doesn't accidentally capture the number
|
||||
row.y -= 1;
|
||||
|
||||
let max_scroll = lines.saturating_sub(console_height);
|
||||
if max_scroll == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let remaining_lines = max_scroll.saturating_sub(scroll_position);
|
||||
if remaining_lines != 0 {
|
||||
let text = vec![Span::from(format!(" {remaining_lines}⬇ ")).dark_gray()];
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(text))
|
||||
.alignment(Alignment::Right)
|
||||
.block(Block::default()),
|
||||
row,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a string with a specified number of spaces.
|
||||
fn build_msg_padding(padding_len: usize) -> String {
|
||||
let mut padding = String::new();
|
||||
for _ in 0..padding_len {
|
||||
_ = write!(padding, " ");
|
||||
}
|
||||
padding
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
use super::{AppHandle, ServeUpdate, WebServer};
|
||||
use crate::{
|
||||
AppBundle, DioxusCrate, HotreloadFilemap, HotreloadResult, Platform, Result, TraceSrc,
|
||||
};
|
||||
use dioxus_core::internal::TemplateGlobalKey;
|
||||
use dioxus_devtools_types::HotReloadMsg;
|
||||
use dioxus_html::HtmlCtx;
|
||||
use futures_util::{future::OptionFuture, stream::FuturesUnordered};
|
||||
use ignore::gitignore::Gitignore;
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
net::SocketAddr,
|
||||
path::PathBuf,
|
||||
};
|
||||
use tokio_stream::StreamExt;
|
||||
|
||||
pub(crate) struct AppRunner {
|
||||
pub(crate) running: HashMap<Platform, AppHandle>,
|
||||
pub(crate) krate: DioxusCrate,
|
||||
pub(crate) file_map: HotreloadFilemap,
|
||||
pub(crate) ignore: Gitignore,
|
||||
pub(crate) applied_hot_reload_message: HotReloadMsg,
|
||||
pub(crate) builds_opened: usize,
|
||||
pub(crate) should_full_rebuild: bool,
|
||||
}
|
||||
|
||||
impl AppRunner {
|
||||
/// Create the AppRunner and then initialize the filemap with the crate directory.
|
||||
pub(crate) fn start(krate: &DioxusCrate) -> Self {
|
||||
let mut runner = Self {
|
||||
running: Default::default(),
|
||||
file_map: HotreloadFilemap::new(),
|
||||
applied_hot_reload_message: Default::default(),
|
||||
ignore: krate.workspace_gitignore(),
|
||||
krate: krate.clone(),
|
||||
builds_opened: 0,
|
||||
should_full_rebuild: true,
|
||||
};
|
||||
|
||||
// todo(jon): this might take a while so we should try and background it, or make it lazy somehow
|
||||
// we could spawn a thread to search the FS and then when it returns we can fill the filemap
|
||||
// in testing, if this hits a massive directory, it might take several seconds with no feedback.
|
||||
for krate in krate.all_watched_crates() {
|
||||
runner.fill_filemap(krate);
|
||||
}
|
||||
|
||||
runner
|
||||
}
|
||||
|
||||
pub(crate) async fn wait(&mut self) -> ServeUpdate {
|
||||
// If there are no running apps, we can just return pending to avoid deadlocking
|
||||
if self.running.is_empty() {
|
||||
return futures_util::future::pending().await;
|
||||
}
|
||||
|
||||
self.running
|
||||
.iter_mut()
|
||||
.map(|(platform, handle)| async {
|
||||
use ServeUpdate::*;
|
||||
let platform = *platform;
|
||||
tokio::select! {
|
||||
Some(Ok(Some(msg))) = OptionFuture::from(handle.app_stdout.as_mut().map(|f| f.next_line())) => {
|
||||
StdoutReceived { platform, msg }
|
||||
},
|
||||
Some(Ok(Some(msg))) = OptionFuture::from(handle.app_stderr.as_mut().map(|f| f.next_line())) => {
|
||||
StderrReceived { platform, msg }
|
||||
},
|
||||
Some(status) = OptionFuture::from(handle.app_child.as_mut().map(|f| f.wait())) => {
|
||||
match status {
|
||||
Ok(status) => ProcessExited { status, platform },
|
||||
Err(_err) => todo!("handle error in process joining?"),
|
||||
}
|
||||
}
|
||||
Some(Ok(Some(msg))) = OptionFuture::from(handle.server_stdout.as_mut().map(|f| f.next_line())) => {
|
||||
StdoutReceived { platform: Platform::Server, msg }
|
||||
},
|
||||
Some(Ok(Some(msg))) = OptionFuture::from(handle.server_stderr.as_mut().map(|f| f.next_line())) => {
|
||||
StderrReceived { platform: Platform::Server, msg }
|
||||
},
|
||||
Some(status) = OptionFuture::from(handle.server_child.as_mut().map(|f| f.wait())) => {
|
||||
match status {
|
||||
Ok(status) => ProcessExited { status, platform: Platform::Server },
|
||||
Err(_err) => todo!("handle error in process joining?"),
|
||||
}
|
||||
}
|
||||
else => futures_util::future::pending().await
|
||||
}
|
||||
})
|
||||
.collect::<FuturesUnordered<_>>()
|
||||
.next()
|
||||
.await
|
||||
.expect("Stream to pending if not empty")
|
||||
}
|
||||
|
||||
/// Finally "bundle" this app and return a handle to it
|
||||
pub(crate) async fn open(
|
||||
&mut self,
|
||||
app: AppBundle,
|
||||
devserver_ip: SocketAddr,
|
||||
fullstack_address: Option<SocketAddr>,
|
||||
should_open_web: bool,
|
||||
) -> Result<&AppHandle> {
|
||||
let platform = app.build.build.platform();
|
||||
|
||||
// Drop the old handle
|
||||
// todo(jon): we should instead be sending the kill signal rather than dropping the process
|
||||
// This would allow a more graceful shutdown and fix bugs like desktop not retaining its size
|
||||
self.kill(platform);
|
||||
|
||||
// wait a tiny sec for the processes to die so we don't have fullstack servers on top of each other
|
||||
// todo(jon): we should allow rebinding to the same port in fullstack itself
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
|
||||
// Start the new app before we kill the old one to give it a little bit of time
|
||||
let mut handle = AppHandle::new(app).await?;
|
||||
handle
|
||||
.open(
|
||||
devserver_ip,
|
||||
fullstack_address,
|
||||
self.builds_opened == 0 && should_open_web,
|
||||
)
|
||||
.await?;
|
||||
self.builds_opened += 1;
|
||||
self.running.insert(platform, handle);
|
||||
|
||||
Ok(self.running.get(&platform).unwrap())
|
||||
}
|
||||
|
||||
pub(crate) fn kill(&mut self, platform: Platform) {
|
||||
self.running.remove(&platform);
|
||||
}
|
||||
|
||||
/// Open an existing app bundle, if it exists
|
||||
pub(crate) async fn open_existing(&self, devserver: &WebServer) {
|
||||
if let Some(address) = devserver.server_address() {
|
||||
let url = format!("http://{address}");
|
||||
tracing::debug!("opening url: {url}");
|
||||
_ = open::that(url);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn attempt_hot_reload(
|
||||
&mut self,
|
||||
modified_files: Vec<PathBuf>,
|
||||
) -> Option<HotReloadMsg> {
|
||||
// If we have any changes to the rust files, we need to update the file map
|
||||
let mut templates = vec![];
|
||||
|
||||
// Prepare the hotreload message we need to send
|
||||
let mut edited_rust_files = Vec::new();
|
||||
let mut assets = Vec::new();
|
||||
|
||||
for path in modified_files {
|
||||
// for various assets that might be linked in, we just try to hotreloading them forcefully
|
||||
// That is, unless they appear in an include! macro, in which case we need to a full rebuild....
|
||||
let Some(ext) = path.extension().and_then(|v| v.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// If it's a rust file, we want to hotreload it using the filemap
|
||||
if ext == "rs" {
|
||||
edited_rust_files.push(path);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Otherwise, it might be an asset and we should look for it in all the running apps
|
||||
for runner in self.running.values() {
|
||||
if let Some(bundled_name) = runner.hotreload_bundled_asset(&path) {
|
||||
// todo(jon): don't hardcode this here
|
||||
let asset_relative = PathBuf::from("/assets/").join(bundled_name);
|
||||
assets.push(asset_relative);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Multiple runners might have queued the same asset, so dedup them
|
||||
assets.dedup();
|
||||
|
||||
// Process the rust files
|
||||
for rust_file in edited_rust_files {
|
||||
// Strip the prefix before sending it to the filemap
|
||||
let Ok(path) = rust_file.strip_prefix(self.krate.workspace_dir()) else {
|
||||
tracing::error!(
|
||||
"Hotreloading file outside of the crate directory: {:?}",
|
||||
rust_file
|
||||
);
|
||||
continue;
|
||||
};
|
||||
|
||||
// And grabout the contents
|
||||
let contents = std::fs::read_to_string(&rust_file).unwrap();
|
||||
|
||||
match self.file_map.update_rsx::<HtmlCtx>(path, contents) {
|
||||
HotreloadResult::Rsx(new) => templates.extend(new),
|
||||
|
||||
// The rust file may have failed to parse, but that is most likely
|
||||
// because the user is in the middle of adding new code
|
||||
// We just ignore the error and let Rust analyzer warn about the problem
|
||||
HotreloadResult::Notreloadable => return None,
|
||||
HotreloadResult::NotParseable => {
|
||||
tracing::debug!(dx_src = ?TraceSrc::Dev, "Error hotreloading file - not parseable {rust_file:?}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let msg = HotReloadMsg {
|
||||
templates,
|
||||
assets,
|
||||
unknown_files: vec![],
|
||||
};
|
||||
|
||||
self.add_hot_reload_message(&msg);
|
||||
|
||||
Some(msg)
|
||||
}
|
||||
|
||||
/// Get any hot reload changes that have been applied since the last full rebuild
|
||||
pub(crate) fn applied_hot_reload_changes(&mut self) -> HotReloadMsg {
|
||||
self.applied_hot_reload_message.clone()
|
||||
}
|
||||
|
||||
/// Clear the hot reload changes. This should be called any time a new build is starting
|
||||
pub(crate) fn clear_hot_reload_changes(&mut self) {
|
||||
self.applied_hot_reload_message = Default::default();
|
||||
}
|
||||
|
||||
/// Store the hot reload changes for any future clients that connect
|
||||
fn add_hot_reload_message(&mut self, msg: &HotReloadMsg) {
|
||||
let applied = &mut self.applied_hot_reload_message;
|
||||
|
||||
// Merge the assets, unknown files, and templates
|
||||
// We keep the newer change if there is both a old and new change
|
||||
let mut templates: HashMap<TemplateGlobalKey, _> = std::mem::take(&mut applied.templates)
|
||||
.into_iter()
|
||||
.map(|template| (template.key.clone(), template))
|
||||
.collect();
|
||||
let mut assets: HashSet<PathBuf> =
|
||||
std::mem::take(&mut applied.assets).into_iter().collect();
|
||||
let mut unknown_files: HashSet<PathBuf> = std::mem::take(&mut applied.unknown_files)
|
||||
.into_iter()
|
||||
.collect();
|
||||
for template in &msg.templates {
|
||||
templates.insert(template.key.clone(), template.clone());
|
||||
}
|
||||
assets.extend(msg.assets.iter().cloned());
|
||||
unknown_files.extend(msg.unknown_files.iter().cloned());
|
||||
applied.templates = templates.into_values().collect();
|
||||
applied.assets = assets.into_iter().collect();
|
||||
applied.unknown_files = unknown_files.into_iter().collect();
|
||||
}
|
||||
|
||||
pub(crate) async fn client_connected(&mut self) {
|
||||
for (platform, runner) in self.running.iter_mut() {
|
||||
// Assign the runtime asset dir to the runner
|
||||
if *platform == Platform::Ios {
|
||||
// xcrun simctl get_app_container booted com.dioxuslabs
|
||||
let res = tokio::process::Command::new("xcrun")
|
||||
.arg("simctl")
|
||||
.arg("get_app_container")
|
||||
.arg("booted")
|
||||
.arg("com.dioxuslabs")
|
||||
.output()
|
||||
.await;
|
||||
|
||||
if let Ok(res) = res {
|
||||
tracing::debug!("Using runtime asset dir: {:?}", res);
|
||||
|
||||
if let Ok(out) = String::from_utf8(res.stdout) {
|
||||
let out = out.trim();
|
||||
|
||||
tracing::debug!("Setting Runtime asset dir: {out:?}");
|
||||
runner.runtime_asst_dir = Some(PathBuf::from(out));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fill the filemap with files from the filesystem, using the given filter to determine which files to include.
|
||||
///
|
||||
/// You can use the filter with something like a gitignore to only include files that are relevant to your project.
|
||||
/// We'll walk the filesystem from the given path and recursively search for all files that match the filter.
|
||||
///
|
||||
/// The filter function takes a path and returns true if the file should be included in the filemap.
|
||||
/// Generally this will only be .rs files
|
||||
///
|
||||
/// If a file couldn't be parsed, we don't fail. Instead, we save the error.
|
||||
pub fn fill_filemap(&mut self, path: PathBuf) {
|
||||
if self.ignore.matched(&path, path.is_dir()).is_ignore() {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the file is a .rs file, add it to the filemap
|
||||
if path.extension().and_then(|s| s.to_str()) == Some("rs") {
|
||||
if let Ok(contents) = std::fs::read_to_string(&path) {
|
||||
if let Ok(path) = path.strip_prefix(self.krate.workspace_dir()) {
|
||||
self.file_map.add_file(path.to_path_buf(), contents);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// If it's not, we'll try to read the directory
|
||||
if path.is_dir() {
|
||||
if let Ok(read_dir) = std::fs::read_dir(&path) {
|
||||
for entry in read_dir.flatten() {
|
||||
self.fill_filemap(entry.path());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+342
-324
@@ -1,183 +1,183 @@
|
||||
use crate::config::{Platform, WebHttpsConfig};
|
||||
use crate::serve::{next_or_pending, Serve};
|
||||
use crate::{dioxus_crate::DioxusCrate, TraceSrc};
|
||||
use crate::{Error, Result};
|
||||
use axum::extract::{Request, State};
|
||||
use axum::middleware::{self, Next};
|
||||
use crate::{
|
||||
config::WebHttpsConfig,
|
||||
serve::{ServeArgs, ServeUpdate},
|
||||
BuildStage, BuildUpdate, DioxusCrate, Platform, Result, TraceSrc,
|
||||
};
|
||||
use anyhow::Context;
|
||||
use axum::{
|
||||
body::Body,
|
||||
extract::{
|
||||
ws::{Message, WebSocket},
|
||||
WebSocketUpgrade,
|
||||
Request, State, WebSocketUpgrade,
|
||||
},
|
||||
http::{
|
||||
header::{HeaderName, HeaderValue, CACHE_CONTROL, EXPIRES, PRAGMA},
|
||||
Method, Response, StatusCode,
|
||||
},
|
||||
middleware::{self, Next},
|
||||
response::IntoResponse,
|
||||
routing::{get, get_service},
|
||||
Extension, Router,
|
||||
};
|
||||
use axum_server::tls_rustls::RustlsConfig;
|
||||
use dioxus_devtools::{DevserverMsg, HotReloadMsg};
|
||||
use dioxus_devtools_types::{DevserverMsg, HotReloadMsg};
|
||||
use futures_channel::mpsc::{UnboundedReceiver, UnboundedSender};
|
||||
use futures_util::stream;
|
||||
use futures_util::{stream::FuturesUnordered, StreamExt};
|
||||
use hyper::header::ACCEPT;
|
||||
use futures_util::{
|
||||
future,
|
||||
stream::{self, FuturesUnordered},
|
||||
StreamExt,
|
||||
};
|
||||
use hyper::HeaderMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::net::TcpListener;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::sync::RwLock;
|
||||
use std::{
|
||||
convert::Infallible,
|
||||
fs, io,
|
||||
net::{IpAddr, SocketAddr},
|
||||
process::Command,
|
||||
net::{IpAddr, SocketAddr, TcpListener},
|
||||
path::Path,
|
||||
sync::Arc,
|
||||
sync::RwLock,
|
||||
};
|
||||
use tokio::task::JoinHandle;
|
||||
use tower::ServiceBuilder;
|
||||
use tower_http::{
|
||||
cors::{Any, CorsLayer},
|
||||
cors::Any,
|
||||
services::fs::{ServeDir, ServeFileSystemResponseBody},
|
||||
ServiceBuilderExt,
|
||||
};
|
||||
|
||||
pub enum ServerUpdate {
|
||||
NewConnection,
|
||||
Message(Message),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", content = "data")]
|
||||
enum Status {
|
||||
ClientInit {
|
||||
application_name: String,
|
||||
platform: String,
|
||||
},
|
||||
Building {
|
||||
progress: f64,
|
||||
build_message: String,
|
||||
},
|
||||
BuildError {
|
||||
error: String,
|
||||
},
|
||||
Ready,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct SharedStatus(Arc<RwLock<Status>>);
|
||||
|
||||
impl SharedStatus {
|
||||
fn new(status: Status) -> Self {
|
||||
Self(Arc::new(RwLock::new(status)))
|
||||
}
|
||||
|
||||
fn set(&self, status: Status) {
|
||||
*self.0.write().unwrap() = status;
|
||||
}
|
||||
|
||||
fn get(&self) -> Status {
|
||||
self.0.read().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct Server {
|
||||
pub hot_reload_sockets: Vec<WebSocket>,
|
||||
pub build_status_sockets: Vec<WebSocket>,
|
||||
pub ip: SocketAddr,
|
||||
pub new_hot_reload_sockets: UnboundedReceiver<WebSocket>,
|
||||
pub new_build_status_sockets: UnboundedReceiver<WebSocket>,
|
||||
_server_task: JoinHandle<Result<()>>,
|
||||
/// We proxy (not hot reloading) fullstack requests to this port
|
||||
pub fullstack_port: Option<u16>,
|
||||
|
||||
/// The webserver that serves statics assets (if fullstack isn't already doing that) and the websocket
|
||||
/// communication layer that we use to send status updates and hotreloads to the client.
|
||||
///
|
||||
/// todo(jon): we should merge the build status and hotreload sockets into just a "devtools" socket
|
||||
/// which carries all the message types. This would make it easier for us to add more message types
|
||||
/// and better tooling on the pages that we serve.
|
||||
pub(crate) struct WebServer {
|
||||
devserver_ip: IpAddr,
|
||||
devserver_port: u16,
|
||||
proxied_port: Option<u16>,
|
||||
hot_reload_sockets: Vec<WebSocket>,
|
||||
build_status_sockets: Vec<WebSocket>,
|
||||
new_hot_reload_sockets: UnboundedReceiver<WebSocket>,
|
||||
new_build_status_sockets: UnboundedReceiver<WebSocket>,
|
||||
build_status: SharedStatus,
|
||||
application_name: String,
|
||||
platform: String,
|
||||
platform: Platform,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
pub fn start(serve: &Serve, cfg: &DioxusCrate) -> Self {
|
||||
impl WebServer {
|
||||
/// Start the development server.
|
||||
/// This will set up the default http server if there's no server specified (usually via fullstack).
|
||||
///
|
||||
/// This will also start the websocket server that powers the devtools. If you want to communicate
|
||||
/// with connected devtools clients, this is the place to do it.
|
||||
pub(crate) fn start(krate: &DioxusCrate, args: &ServeArgs) -> Result<Self> {
|
||||
let (hot_reload_sockets_tx, hot_reload_sockets_rx) = futures_channel::mpsc::unbounded();
|
||||
let (build_status_sockets_tx, build_status_sockets_rx) = futures_channel::mpsc::unbounded();
|
||||
|
||||
let build_status = SharedStatus::new(Status::Building {
|
||||
progress: 0.0,
|
||||
build_message: "Starting the build...".to_string(),
|
||||
});
|
||||
let devserver_ip = args.address.addr;
|
||||
let devserver_port = args.address.port;
|
||||
let devserver_address = SocketAddr::new(devserver_ip, devserver_port);
|
||||
|
||||
let addr = serve.server_arguments.address.address();
|
||||
let start_browser = serve.server_arguments.open.unwrap_or_default();
|
||||
// All servers will end up behind us (the devserver) but on a different port
|
||||
// This is so we can serve a loading screen as well as devtools without anything particularly fancy
|
||||
let proxied_port = args
|
||||
.should_proxy_build()
|
||||
.then(|| get_available_port(devserver_ip))
|
||||
.flatten();
|
||||
|
||||
// If we're serving a fullstack app, we need to find a port to proxy to
|
||||
let fullstack_port = if matches!(
|
||||
serve.build_arguments.platform(),
|
||||
Platform::Liveview | Platform::Fullstack
|
||||
) {
|
||||
get_available_port(addr.ip())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let proxied_address = proxied_port.map(|port| SocketAddr::new(devserver_ip, port));
|
||||
|
||||
let fullstack_address = fullstack_port.map(|port| SocketAddr::new(addr.ip(), port));
|
||||
|
||||
let router = setup_router(
|
||||
serve,
|
||||
cfg,
|
||||
// Set up the router with some shared state that we'll update later to reflect the current state of the build
|
||||
let build_status = SharedStatus::new_with_starting_build();
|
||||
let router = build_devserver_router(
|
||||
args,
|
||||
krate,
|
||||
hot_reload_sockets_tx,
|
||||
build_status_sockets_tx,
|
||||
fullstack_address,
|
||||
proxied_address,
|
||||
build_status.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
)?;
|
||||
|
||||
// Actually just start the server, cloning in a few bits of config
|
||||
let web_config = cfg.dioxus_config.web.https.clone();
|
||||
let base_path = cfg.dioxus_config.web.app.base_path.clone();
|
||||
let platform = serve.platform();
|
||||
let _server_task = tokio::spawn(async move {
|
||||
let web_config = web_config.clone();
|
||||
// HTTPS
|
||||
// Before console info so it can stop if mkcert isn't installed or fails
|
||||
// todo: this is the only async thing here - might be nice to
|
||||
let rustls: Option<RustlsConfig> = get_rustls(&web_config).await.unwrap();
|
||||
// Create the listener that we'll pass into the devserver, but save its IP here so
|
||||
// we can display it to the user in the tui
|
||||
let listener = std::net::TcpListener::bind(devserver_address).with_context(|| {
|
||||
anyhow::anyhow!(
|
||||
"Failed to bind server to: {devserver_address}, is there another devserver running?\nTo run multiple devservers, use the --port flag to specify a different port"
|
||||
)
|
||||
})?;
|
||||
|
||||
// Open the browser
|
||||
if start_browser && platform != Platform::Desktop {
|
||||
open_browser(base_path, addr, rustls.is_some());
|
||||
}
|
||||
// And finally, start the server mainloop
|
||||
tokio::spawn(devserver_mainloop(
|
||||
krate.config.web.https.clone(),
|
||||
listener,
|
||||
router,
|
||||
));
|
||||
|
||||
// Start the server with or without rustls
|
||||
if let Some(rustls) = rustls {
|
||||
axum_server::bind_rustls(addr, rustls)
|
||||
.serve(router.into_make_service())
|
||||
.await?
|
||||
} else {
|
||||
// Create a TCP listener bound to the address
|
||||
axum::serve(
|
||||
tokio::net::TcpListener::bind(&addr).await?,
|
||||
router.into_make_service(),
|
||||
)
|
||||
.await?
|
||||
}
|
||||
|
||||
Ok(())
|
||||
});
|
||||
|
||||
Self {
|
||||
Ok(Self {
|
||||
build_status,
|
||||
proxied_port,
|
||||
devserver_ip,
|
||||
devserver_port,
|
||||
hot_reload_sockets: Default::default(),
|
||||
build_status_sockets: Default::default(),
|
||||
new_hot_reload_sockets: hot_reload_sockets_rx,
|
||||
new_build_status_sockets: build_status_sockets_rx,
|
||||
_server_task,
|
||||
ip: addr,
|
||||
fullstack_port,
|
||||
application_name: krate.executable_name().to_string(),
|
||||
platform: args.build_arguments.platform(),
|
||||
})
|
||||
}
|
||||
|
||||
build_status,
|
||||
application_name: cfg.dioxus_config.application.name.clone(),
|
||||
platform: serve.build_arguments.platform().to_string(),
|
||||
/// Wait for new clients to be connected and then save them
|
||||
pub(crate) async fn wait(&mut self) -> ServeUpdate {
|
||||
let mut new_hot_reload_socket = self.new_hot_reload_sockets.next();
|
||||
let mut new_build_status_socket = self.new_build_status_sockets.next();
|
||||
let mut new_message = self
|
||||
.hot_reload_sockets
|
||||
.iter_mut()
|
||||
.enumerate()
|
||||
.map(|(idx, socket)| async move { (idx, socket.next().await) })
|
||||
.collect::<FuturesUnordered<_>>();
|
||||
|
||||
tokio::select! {
|
||||
new_hot_reload_socket = &mut new_hot_reload_socket => {
|
||||
if let Some(new_socket) = new_hot_reload_socket {
|
||||
drop(new_message);
|
||||
self.hot_reload_sockets.push(new_socket);
|
||||
return ServeUpdate::NewConnection;
|
||||
} else {
|
||||
panic!("Could not receive a socket - the devtools could not boot - the port is likely already in use");
|
||||
}
|
||||
}
|
||||
new_build_status_socket = &mut new_build_status_socket => {
|
||||
if let Some(mut new_socket) = new_build_status_socket {
|
||||
drop(new_message);
|
||||
|
||||
// Update the socket with project info and current build status
|
||||
let project_info = SharedStatus::new(Status::ClientInit { application_name: self.application_name.clone(), platform: self.platform });
|
||||
if project_info.send_to(&mut new_socket).await.is_ok() {
|
||||
_ = self.build_status.send_to(&mut new_socket).await;
|
||||
self.build_status_sockets.push(new_socket);
|
||||
}
|
||||
return future::pending::<ServeUpdate>().await;
|
||||
} else {
|
||||
panic!("Could not receive a socket - the devtools could not boot - the port is likely already in use");
|
||||
}
|
||||
}
|
||||
Some((idx, message)) = new_message.next() => {
|
||||
match message {
|
||||
Some(Ok(message)) => return ServeUpdate::WsMessage(message),
|
||||
_ => {
|
||||
drop(new_message);
|
||||
_ = self.hot_reload_sockets.remove(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
future::pending().await
|
||||
}
|
||||
|
||||
pub(crate) async fn shutdown(&mut self) {
|
||||
self.send_shutdown().await;
|
||||
for socket in self.hot_reload_sockets.drain(..) {
|
||||
_ = socket.close().await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,10 +186,7 @@ impl Server {
|
||||
let mut i = 0;
|
||||
while i < self.build_status_sockets.len() {
|
||||
let socket = &mut self.build_status_sockets[i];
|
||||
if send_build_status_to(&self.build_status, socket)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
if self.build_status.send_to(socket).await.is_err() {
|
||||
self.build_status_sockets.remove(i);
|
||||
} else {
|
||||
i += 1;
|
||||
@@ -198,7 +195,7 @@ impl Server {
|
||||
}
|
||||
|
||||
/// Sends a start build message to all clients.
|
||||
pub async fn start_build(&mut self) {
|
||||
pub(crate) async fn start_build(&mut self) {
|
||||
self.build_status.set(Status::Building {
|
||||
progress: 0.0,
|
||||
build_message: "Starting the build...".to_string(),
|
||||
@@ -207,19 +204,54 @@ impl Server {
|
||||
}
|
||||
|
||||
/// Sends an updated build status to all clients.
|
||||
pub async fn update_build_status(&mut self, progress: f64, build_message: String) {
|
||||
if !matches!(self.build_status.get(), Status::Building { .. }) {
|
||||
return;
|
||||
pub(crate) async fn new_build_update(&mut self, update: &BuildUpdate) {
|
||||
match update {
|
||||
BuildUpdate::Progress { stage } => {
|
||||
// Todo(miles): wire up more messages into the splash screen UI
|
||||
match stage {
|
||||
BuildStage::Success => {}
|
||||
BuildStage::Failed => self.send_reload_failed().await,
|
||||
BuildStage::Restarting => self.send_reload_start().await,
|
||||
BuildStage::Initializing => {}
|
||||
BuildStage::InstallingTooling {} => {}
|
||||
BuildStage::Compiling {
|
||||
current,
|
||||
total,
|
||||
krate,
|
||||
..
|
||||
} => {
|
||||
self.build_status.set(Status::Building {
|
||||
progress: (*current as f64 / *total as f64).clamp(0.0, 1.0),
|
||||
build_message: format!("{krate} compiling"),
|
||||
});
|
||||
self.send_build_status().await;
|
||||
}
|
||||
BuildStage::OptimizingWasm {} => {}
|
||||
BuildStage::Aborted => {}
|
||||
BuildStage::CopyingAssets { .. } => {}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
BuildUpdate::CompilerMessage { .. } => {}
|
||||
BuildUpdate::BuildReady { .. } => {}
|
||||
BuildUpdate::BuildFailed { err } => {
|
||||
let error = err.to_string();
|
||||
self.build_status.set(Status::BuildError {
|
||||
error: ansi_to_html::convert(&error).unwrap_or(error),
|
||||
});
|
||||
self.send_build_status().await;
|
||||
}
|
||||
}
|
||||
self.build_status.set(Status::Building {
|
||||
progress,
|
||||
build_message,
|
||||
});
|
||||
self.send_build_status().await;
|
||||
}
|
||||
|
||||
/// Sends hot reloadable changes to all clients.
|
||||
pub async fn send_hotreload(&mut self, reload: HotReloadMsg) {
|
||||
pub(crate) async fn send_hotreload(&mut self, reload: HotReloadMsg) {
|
||||
if reload.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
tracing::debug!("Sending hotreload to clients {:?}", reload);
|
||||
|
||||
let msg = DevserverMsg::HotReload(reload);
|
||||
let msg = serde_json::to_string(&msg).unwrap();
|
||||
|
||||
@@ -235,80 +267,22 @@ impl Server {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait for new clients to be connected and then save them
|
||||
pub async fn wait(&mut self) -> Option<ServerUpdate> {
|
||||
let mut new_hot_reload_socket = self.new_hot_reload_sockets.next();
|
||||
let mut new_build_status_socket = self.new_build_status_sockets.next();
|
||||
let mut new_message = self
|
||||
.hot_reload_sockets
|
||||
.iter_mut()
|
||||
.enumerate()
|
||||
.map(|(idx, socket)| async move { (idx, socket.next().await) })
|
||||
.collect::<FuturesUnordered<_>>();
|
||||
let next_new_message = next_or_pending(new_message.next());
|
||||
|
||||
tokio::select! {
|
||||
new_hot_reload_socket = &mut new_hot_reload_socket => {
|
||||
if let Some(new_socket) = new_hot_reload_socket {
|
||||
drop(new_message);
|
||||
self.hot_reload_sockets.push(new_socket);
|
||||
return Some(ServerUpdate::NewConnection);
|
||||
} else {
|
||||
panic!("Could not receive a socket - the devtools could not boot - the port is likely already in use");
|
||||
}
|
||||
}
|
||||
new_build_status_socket = &mut new_build_status_socket => {
|
||||
if let Some(mut new_socket) = new_build_status_socket {
|
||||
drop(new_message);
|
||||
|
||||
// Update the socket with project info and current build status
|
||||
let project_info = SharedStatus::new(Status::ClientInit { application_name: self.application_name.clone(), platform: self.platform.clone() });
|
||||
if send_build_status_to(&project_info, &mut new_socket).await.is_ok() {
|
||||
_ = send_build_status_to(&self.build_status, &mut new_socket).await;
|
||||
self.build_status_sockets.push(new_socket);
|
||||
}
|
||||
return None;
|
||||
} else {
|
||||
panic!("Could not receive a socket - the devtools could not boot - the port is likely already in use");
|
||||
}
|
||||
}
|
||||
(idx, message) = next_new_message => {
|
||||
match message {
|
||||
Some(Ok(message)) => return Some(ServerUpdate::Message(message)),
|
||||
_ => {
|
||||
drop(new_message);
|
||||
_ = self.hot_reload_sockets.remove(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Converts a `cargo` error to HTML and sends it to clients.
|
||||
pub async fn send_build_error(&mut self, error: Error) {
|
||||
let error = error.to_string();
|
||||
self.build_status.set(Status::BuildError {
|
||||
error: ansi_to_html::convert(&error).unwrap_or(error),
|
||||
});
|
||||
self.send_build_status().await;
|
||||
}
|
||||
|
||||
/// Tells all clients that a full rebuild has started.
|
||||
pub async fn send_reload_start(&mut self) {
|
||||
pub(crate) async fn send_reload_start(&mut self) {
|
||||
self.send_devserver_message(DevserverMsg::FullReloadStart)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Tells all clients that a full rebuild has failed.
|
||||
pub async fn send_reload_failed(&mut self) {
|
||||
pub(crate) async fn send_reload_failed(&mut self) {
|
||||
self.send_devserver_message(DevserverMsg::FullReloadFailed)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Tells all clients to reload if possible for new changes.
|
||||
pub async fn send_reload_command(&mut self) {
|
||||
pub(crate) async fn send_reload_command(&mut self) {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
|
||||
self.build_status.set(Status::Ready);
|
||||
self.send_build_status().await;
|
||||
self.send_devserver_message(DevserverMsg::FullReloadCommand)
|
||||
@@ -316,7 +290,7 @@ impl Server {
|
||||
}
|
||||
|
||||
/// Send a shutdown message to all connected clients.
|
||||
pub async fn send_shutdown(&mut self) {
|
||||
pub(crate) async fn send_shutdown(&mut self) {
|
||||
self.send_devserver_message(DevserverMsg::Shutdown).await;
|
||||
}
|
||||
|
||||
@@ -329,18 +303,48 @@ impl Server {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn shutdown(&mut self) {
|
||||
self.send_shutdown().await;
|
||||
for socket in self.hot_reload_sockets.drain(..) {
|
||||
_ = socket.close().await;
|
||||
}
|
||||
/// Get the address the devserver should run on
|
||||
pub fn devserver_address(&self) -> SocketAddr {
|
||||
SocketAddr::new(self.devserver_ip, self.devserver_port)
|
||||
}
|
||||
|
||||
/// Get the address the fullstack server should run on if we're serving a fullstack app
|
||||
pub fn fullstack_address(&self) -> Option<SocketAddr> {
|
||||
self.fullstack_port
|
||||
.map(|port| SocketAddr::new(self.ip.ip(), port))
|
||||
// Get the address the server should run on if we're serving the user's server
|
||||
pub fn proxied_server_address(&self) -> Option<SocketAddr> {
|
||||
self.proxied_port
|
||||
.map(|port| SocketAddr::new(self.devserver_ip, port))
|
||||
}
|
||||
|
||||
pub fn server_address(&self) -> Option<SocketAddr> {
|
||||
match self.platform {
|
||||
Platform::Web | Platform::Server => Some(self.devserver_address()),
|
||||
_ => self.proxied_server_address(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn devserver_mainloop(
|
||||
https_cfg: WebHttpsConfig,
|
||||
listener: TcpListener,
|
||||
router: Router,
|
||||
) -> Result<()> {
|
||||
// We have a native listener that we're going to give to tokio, so we need to make it non-blocking
|
||||
let _ = listener.set_nonblocking(true);
|
||||
|
||||
// If we're not using rustls, just use regular axum
|
||||
if https_cfg.enabled != Some(true) {
|
||||
axum::serve(listener.try_into().unwrap(), router.into_make_service()).await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// If we're using rustls, we need to get the cert/key paths and then set up rustls
|
||||
let (cert_path, key_path) = get_rustls(&https_cfg).await?;
|
||||
let rustls = axum_server::tls_rustls::RustlsConfig::from_pem_file(cert_path, key_path).await?;
|
||||
|
||||
axum_server::from_tcp_rustls(listener, rustls)
|
||||
.serve(router.into_make_service())
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sets up and returns a router
|
||||
@@ -350,45 +354,25 @@ impl Server {
|
||||
/// - Setting up the proxy to the endpoint specified in the config
|
||||
/// - Setting up the file serve service
|
||||
/// - Setting up the websocket endpoint for devtools
|
||||
fn setup_router(
|
||||
serve: &Serve,
|
||||
config: &DioxusCrate,
|
||||
fn build_devserver_router(
|
||||
args: &ServeArgs,
|
||||
krate: &DioxusCrate,
|
||||
hot_reload_sockets: UnboundedSender<WebSocket>,
|
||||
build_status_sockets: UnboundedSender<WebSocket>,
|
||||
fullstack_address: Option<SocketAddr>,
|
||||
build_status: SharedStatus,
|
||||
) -> Result<Router> {
|
||||
let mut router = Router::new();
|
||||
let platform = serve.build_arguments.platform();
|
||||
|
||||
// Setup proxy for the endpoint specified in the config
|
||||
for proxy_config in config.dioxus_config.web.proxy.iter() {
|
||||
for proxy_config in krate.config.web.proxy.iter() {
|
||||
router = super::proxy::add_proxy(router, proxy_config)?;
|
||||
}
|
||||
|
||||
// server the dir if it's web, otherwise let the fullstack server itself handle it
|
||||
match platform {
|
||||
Platform::Web | Platform::StaticGeneration => {
|
||||
// Route file service to output the .wasm and assets if this is a web build
|
||||
let base_path = format!(
|
||||
"/{}",
|
||||
config
|
||||
.dioxus_config
|
||||
.web
|
||||
.app
|
||||
.base_path
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.trim_matches('/')
|
||||
);
|
||||
|
||||
router = router.nest_service(&base_path, build_serve_dir(serve, config, platform));
|
||||
}
|
||||
Platform::Liveview | Platform::Fullstack => {
|
||||
// For fullstack and static generation, forward all requests to the server
|
||||
let address = fullstack_address.unwrap();
|
||||
|
||||
router = router.nest_service("/",super::proxy::proxy_to(
|
||||
if args.should_proxy_build() {
|
||||
// For fullstack, liveview, and server, forward all requests to the inner server
|
||||
let address = fullstack_address.unwrap();
|
||||
router = router.nest_service("/",super::proxy::proxy_to(
|
||||
format!("http://{address}").parse().unwrap(),
|
||||
true,
|
||||
|error| {
|
||||
@@ -401,8 +385,22 @@ fn setup_router(
|
||||
.unwrap()
|
||||
},
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
} else {
|
||||
// Otherwise, just serve the dir ourselves
|
||||
// Route file service to output the .wasm and assets if this is a web build
|
||||
let base_path = format!(
|
||||
"/{}",
|
||||
krate
|
||||
.config
|
||||
.web
|
||||
.app
|
||||
.base_path
|
||||
.as_deref()
|
||||
.unwrap_or_default()
|
||||
.trim_matches('/')
|
||||
);
|
||||
|
||||
router = router.nest_service(&base_path, build_serve_dir(args, krate));
|
||||
}
|
||||
|
||||
// Setup middleware to intercept html requests if the build status is "Building"
|
||||
@@ -419,7 +417,7 @@ fn setup_router(
|
||||
"/",
|
||||
get(
|
||||
|ws: WebSocketUpgrade, ext: Extension<UnboundedSender<WebSocket>>| async move {
|
||||
tracing::info!("Incoming hotreload websocket request: {ws:?}");
|
||||
tracing::debug!("New devtool websocket connection");
|
||||
ws.on_upgrade(move |socket| async move { _ = ext.0.unbounded_send(socket) })
|
||||
},
|
||||
),
|
||||
@@ -438,7 +436,7 @@ fn setup_router(
|
||||
|
||||
// Setup cors
|
||||
router = router.layer(
|
||||
CorsLayer::new()
|
||||
tower_http::cors::CorsLayer::new()
|
||||
// allow `GET` and `POST` when accessing the resource
|
||||
.allow_methods([Method::GET, Method::POST])
|
||||
// allow requests from any origin
|
||||
@@ -449,11 +447,9 @@ fn setup_router(
|
||||
Ok(router)
|
||||
}
|
||||
|
||||
fn build_serve_dir(
|
||||
serve: &Serve,
|
||||
cfg: &DioxusCrate,
|
||||
platform: Platform,
|
||||
) -> axum::routing::MethodRouter {
|
||||
fn build_serve_dir(args: &ServeArgs, cfg: &DioxusCrate) -> axum::routing::MethodRouter {
|
||||
use tower::ServiceBuilder;
|
||||
|
||||
static CORS_UNSAFE: (HeaderValue, HeaderValue) = (
|
||||
HeaderValue::from_static("unsafe-none"),
|
||||
HeaderValue::from_static("unsafe-none"),
|
||||
@@ -464,17 +460,15 @@ fn build_serve_dir(
|
||||
HeaderValue::from_static("same-origin"),
|
||||
);
|
||||
|
||||
let (coep, coop) = match serve.server_arguments.cross_origin_policy {
|
||||
let (coep, coop) = match args.cross_origin_policy {
|
||||
true => CORS_REQUIRE.clone(),
|
||||
false => CORS_UNSAFE.clone(),
|
||||
};
|
||||
|
||||
let out_dir = match platform {
|
||||
// Static generation only serves files from the public directory
|
||||
Platform::StaticGeneration => cfg.out_dir().join("public"),
|
||||
_ => cfg.out_dir(),
|
||||
};
|
||||
let index_on_404 = cfg.dioxus_config.web.watcher.index_on_404;
|
||||
let out_dir = cfg
|
||||
.build_dir(Platform::Web, args.build_arguments.release)
|
||||
.join("public");
|
||||
let index_on_404 = cfg.config.web.watcher.index_on_404;
|
||||
|
||||
get_service(
|
||||
ServiceBuilder::new()
|
||||
@@ -508,18 +502,23 @@ fn no_cache(
|
||||
// If there's a 404 and we're supposed to index on 404, upgrade that failed request to the index.html
|
||||
// We might want to isnert a header here saying we *did* that but oh well
|
||||
if response.status() == StatusCode::NOT_FOUND && index_on_404 {
|
||||
// First try to find a 404.html or 404/index.html file
|
||||
let out_dir_404_html = out_dir.join("404.html");
|
||||
let out_dir_404_index_html = out_dir.join("404").join("index.html");
|
||||
let path = if out_dir_404_html.exists() {
|
||||
out_dir_404_html
|
||||
} else if out_dir_404_index_html.exists() {
|
||||
out_dir_404_index_html
|
||||
} else {
|
||||
// If we can't find a 404.html or 404/index.html, just use the index.html
|
||||
out_dir.join("index.html")
|
||||
};
|
||||
let body = Body::from(std::fs::read_to_string(path).unwrap());
|
||||
let fallback = out_dir.join("index.html");
|
||||
let contents = std::fs::read_to_string(fallback).unwrap_or_else(|_| {
|
||||
String::from(
|
||||
r#"
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Err 404 - dx is not serving a web app</title>
|
||||
</head>
|
||||
<body>
|
||||
<p>Err 404 - dioxus is not currently serving a web app</p>
|
||||
</body>
|
||||
</html>
|
||||
"#,
|
||||
)
|
||||
});
|
||||
let body = Body::from(contents);
|
||||
|
||||
response = Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
@@ -528,32 +527,28 @@ fn no_cache(
|
||||
};
|
||||
|
||||
insert_no_cache_headers(response.headers_mut());
|
||||
|
||||
response
|
||||
}
|
||||
|
||||
pub fn insert_no_cache_headers(headers: &mut HeaderMap) {
|
||||
pub(crate) fn insert_no_cache_headers(headers: &mut HeaderMap) {
|
||||
headers.insert(CACHE_CONTROL, HeaderValue::from_static("no-cache"));
|
||||
headers.insert(PRAGMA, HeaderValue::from_static("no-cache"));
|
||||
headers.insert(EXPIRES, HeaderValue::from_static("0"));
|
||||
}
|
||||
|
||||
/// Returns an enum of rustls config
|
||||
pub async fn get_rustls(web_config: &WebHttpsConfig) -> Result<Option<RustlsConfig>> {
|
||||
if web_config.enabled != Some(true) {
|
||||
return Ok(None);
|
||||
async fn get_rustls(web_config: &WebHttpsConfig) -> Result<(String, String)> {
|
||||
// If we're not using mkcert, just use the cert/key paths given to use in the config
|
||||
if !web_config.mkcert.unwrap_or(false) {
|
||||
if let (Some(key), Some(cert)) = (web_config.key_path.clone(), web_config.cert_path.clone())
|
||||
{
|
||||
return Ok((cert, key));
|
||||
} else {
|
||||
// missing cert or key
|
||||
return Err("https is enabled but cert or key path is missing".into());
|
||||
}
|
||||
}
|
||||
|
||||
let (cert_path, key_path) = match web_config.mkcert {
|
||||
Some(true) => get_rustls_with_mkcert(web_config)?,
|
||||
_ => get_rustls_without_mkcert(web_config)?,
|
||||
};
|
||||
|
||||
Ok(Some(
|
||||
RustlsConfig::from_pem_file(cert_path, key_path).await?,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn get_rustls_with_mkcert(web_config: &WebHttpsConfig) -> Result<(String, String)> {
|
||||
const DEFAULT_KEY_PATH: &str = "ssl/key.pem";
|
||||
const DEFAULT_CERT_PATH: &str = "ssl/cert.pem";
|
||||
|
||||
@@ -573,7 +568,7 @@ pub fn get_rustls_with_mkcert(web_config: &WebHttpsConfig) -> Result<(String, St
|
||||
_ = fs::create_dir("ssl");
|
||||
}
|
||||
|
||||
let cmd = Command::new("mkcert")
|
||||
let cmd = tokio::process::Command::new("mkcert")
|
||||
.args([
|
||||
"-install",
|
||||
"-key-file",
|
||||
@@ -599,33 +594,18 @@ pub fn get_rustls_with_mkcert(web_config: &WebHttpsConfig) -> Result<(String, St
|
||||
return Err("failed to generate mkcert certificates".into());
|
||||
}
|
||||
Ok(mut cmd) => {
|
||||
cmd.wait()?;
|
||||
cmd.wait().await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok((cert_path, key_path))
|
||||
}
|
||||
|
||||
pub fn get_rustls_without_mkcert(web_config: &WebHttpsConfig) -> Result<(String, String)> {
|
||||
// get paths to cert & key
|
||||
if let (Some(key), Some(cert)) = (web_config.key_path.clone(), web_config.cert_path.clone()) {
|
||||
Ok((cert, key))
|
||||
} else {
|
||||
// missing cert or key
|
||||
Err("https is enabled but cert or key path is missing".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the browser to the address
|
||||
pub(crate) fn open_browser(base_path: Option<String>, address: SocketAddr, https: bool) {
|
||||
let protocol = if https { "https" } else { "http" };
|
||||
let base_path = match base_path.as_deref() {
|
||||
Some(base_path) => format!("/{}", base_path.trim_matches('/')),
|
||||
None => "".to_owned(),
|
||||
};
|
||||
_ = open::that(format!("{protocol}://{address}{base_path}"));
|
||||
}
|
||||
|
||||
/// Bind a listener to any point and return it
|
||||
/// When the listener is dropped, the socket will be closed, but we'll still have a port that we
|
||||
/// can bind our proxy to.
|
||||
///
|
||||
/// Todo: we might want to do this on every new build in case the OS tries to bind things to this port
|
||||
/// and we don't already have something bound to it. There's no great way of "reserving" a port.
|
||||
fn get_available_port(address: IpAddr) -> Option<u16> {
|
||||
TcpListener::bind((address, 0))
|
||||
.map(|listener| listener.local_addr().unwrap().port())
|
||||
@@ -639,7 +619,7 @@ async fn build_status_middleware(
|
||||
next: Next,
|
||||
) -> axum::response::Response {
|
||||
// If the request is for html, and the status is "Building", return the loading page instead of the contents of the response
|
||||
let accepts = request.headers().get(ACCEPT);
|
||||
let accepts = request.headers().get(hyper::header::ACCEPT);
|
||||
let accepts_html = accepts
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|v| v.contains("text/html"));
|
||||
@@ -647,7 +627,7 @@ async fn build_status_middleware(
|
||||
if let Some(true) = accepts_html {
|
||||
let status = state.get();
|
||||
if status != Status::Ready {
|
||||
let html = include_str!("../../assets/loading.html");
|
||||
let html = include_str!("../../assets/web/loading.html");
|
||||
return axum::response::Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
// Load the html loader then keep loading forever
|
||||
@@ -663,10 +643,48 @@ async fn build_status_middleware(
|
||||
next.run(request).await
|
||||
}
|
||||
|
||||
async fn send_build_status_to(
|
||||
build_status: &SharedStatus,
|
||||
socket: &mut WebSocket,
|
||||
) -> Result<(), axum::Error> {
|
||||
let msg = serde_json::to_string(&build_status.get()).unwrap();
|
||||
socket.send(Message::Text(msg)).await
|
||||
#[derive(Debug, Clone)]
|
||||
struct SharedStatus(Arc<RwLock<Status>>);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", content = "data")]
|
||||
enum Status {
|
||||
ClientInit {
|
||||
application_name: String,
|
||||
platform: Platform,
|
||||
},
|
||||
Building {
|
||||
progress: f64,
|
||||
build_message: String,
|
||||
},
|
||||
BuildError {
|
||||
error: String,
|
||||
},
|
||||
Ready,
|
||||
}
|
||||
|
||||
impl SharedStatus {
|
||||
fn new(status: Status) -> Self {
|
||||
Self(Arc::new(RwLock::new(status)))
|
||||
}
|
||||
|
||||
fn new_with_starting_build() -> Self {
|
||||
Self::new(Status::Building {
|
||||
progress: 0.0,
|
||||
build_message: "Starting the build...".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn set(&self, status: Status) {
|
||||
*self.0.write().unwrap() = status;
|
||||
}
|
||||
|
||||
fn get(&self) -> Status {
|
||||
self.0.read().unwrap().clone()
|
||||
}
|
||||
|
||||
async fn send_to(&self, socket: &mut WebSocket) -> Result<(), axum::Error> {
|
||||
let msg = serde_json::to_string(&self.get()).unwrap();
|
||||
socket.send(Message::Text(msg)).await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
use crate::{BuildUpdate, Platform, TraceMsg};
|
||||
use axum::extract::ws::Message as WsMessage;
|
||||
use std::{path::PathBuf, process::ExitStatus};
|
||||
|
||||
/// One fat enum to rule them all....
|
||||
///
|
||||
/// Thanks to libraries like winit for the inspiration
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub(crate) enum ServeUpdate {
|
||||
NewConnection,
|
||||
WsMessage(WsMessage),
|
||||
|
||||
/// A build update from the build engine
|
||||
BuildUpdate(BuildUpdate),
|
||||
|
||||
/// A running process has received a stdout.
|
||||
/// May or may not be a complete line - do not treat it as a line. It will include a line if it is a complete line.
|
||||
///
|
||||
/// We will poll lines and any content in a 50ms interval
|
||||
StdoutReceived {
|
||||
platform: Platform,
|
||||
msg: String,
|
||||
},
|
||||
|
||||
/// A running process has received a stderr.
|
||||
/// May or may not be a complete line - do not treat it as a line. It will include a line if it is a complete line.
|
||||
///
|
||||
/// We will poll lines and any content in a 50ms interval
|
||||
StderrReceived {
|
||||
platform: Platform,
|
||||
msg: String,
|
||||
},
|
||||
|
||||
ProcessExited {
|
||||
platform: Platform,
|
||||
status: ExitStatus,
|
||||
},
|
||||
|
||||
FilesChanged {
|
||||
files: Vec<PathBuf>,
|
||||
},
|
||||
|
||||
/// Open an existing app bundle, if it exists
|
||||
OpenApp,
|
||||
|
||||
RequestRebuild,
|
||||
|
||||
ToggleShouldRebuild,
|
||||
|
||||
Redraw,
|
||||
|
||||
TracingLog {
|
||||
log: TraceMsg,
|
||||
},
|
||||
|
||||
Exit {
|
||||
error: Option<Box<dyn std::error::Error + Send + Sync>>,
|
||||
},
|
||||
}
|
||||
+110
-336
@@ -1,172 +1,66 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::{fs, path::PathBuf, time::Duration};
|
||||
|
||||
use super::hot_reloading_file_map::HotreloadError;
|
||||
use crate::serve::hot_reloading_file_map::FileMap;
|
||||
use crate::TraceSrc;
|
||||
use crate::{cli::serve::Serve, dioxus_crate::DioxusCrate};
|
||||
use dioxus_devtools::HotReloadMsg;
|
||||
use dioxus_html::HtmlCtx;
|
||||
use super::detect::is_wsl;
|
||||
use super::update::ServeUpdate;
|
||||
use crate::{cli::serve::ServeArgs, dioxus_crate::DioxusCrate};
|
||||
use futures_channel::mpsc::{UnboundedReceiver, UnboundedSender};
|
||||
use futures_util::StreamExt;
|
||||
use ignore::gitignore::Gitignore;
|
||||
use notify::{
|
||||
event::{MetadataKind, ModifyKind},
|
||||
Config, EventKind,
|
||||
Config, EventKind, RecursiveMode, Watcher as NotifyWatcher,
|
||||
};
|
||||
use std::{path::PathBuf, time::Duration};
|
||||
|
||||
/// This struct stores the file watcher and the filemap for the project.
|
||||
///
|
||||
/// This is where we do workspace discovery and recursively listen for changes in Rust files and asset
|
||||
/// directories.
|
||||
pub struct Watcher {
|
||||
_tx: UnboundedSender<notify::Event>,
|
||||
pub(crate) struct Watcher {
|
||||
rx: UnboundedReceiver<notify::Event>,
|
||||
_last_update_time: i64,
|
||||
_watcher: Box<dyn notify::Watcher>,
|
||||
queued_events: Vec<notify::Event>,
|
||||
file_map: FileMap,
|
||||
ignore: Gitignore,
|
||||
applied_hot_reload_message: Option<HotReloadMsg>,
|
||||
krate: DioxusCrate,
|
||||
_tx: UnboundedSender<notify::Event>,
|
||||
watcher: Box<dyn notify::Watcher>,
|
||||
}
|
||||
|
||||
impl Watcher {
|
||||
pub fn start(serve: &Serve, config: &DioxusCrate) -> Self {
|
||||
pub(crate) fn start(krate: &DioxusCrate, serve: &ServeArgs) -> Self {
|
||||
let (tx, rx) = futures_channel::mpsc::unbounded();
|
||||
|
||||
// Extend the watch path to include:
|
||||
// - the assets directory - this is so we can hotreload CSS and other assets by default
|
||||
// - the Cargo.toml file - this is so we can hotreload the project if the user changes dependencies
|
||||
// - the Dioxus.toml file - this is so we can hotreload the project if the user changes the Dioxus config
|
||||
let mut allow_watch_path = config.dioxus_config.web.watcher.watch_path.clone();
|
||||
allow_watch_path.push(config.dioxus_config.application.asset_dir.clone());
|
||||
allow_watch_path.push("Cargo.toml".to_string().into());
|
||||
allow_watch_path.push("Dioxus.toml".to_string().into());
|
||||
allow_watch_path.dedup();
|
||||
|
||||
let crate_dir = config.crate_dir();
|
||||
let mut builder = ignore::gitignore::GitignoreBuilder::new(&crate_dir);
|
||||
builder.add(crate_dir.join(".gitignore"));
|
||||
|
||||
let out_dir = config.out_dir();
|
||||
let out_dir_str = out_dir.display().to_string();
|
||||
|
||||
let excluded_paths = vec![
|
||||
".git",
|
||||
".github",
|
||||
".vscode",
|
||||
"target",
|
||||
"node_modules",
|
||||
"dist",
|
||||
&out_dir_str,
|
||||
];
|
||||
for path in excluded_paths {
|
||||
builder
|
||||
.add_line(None, path)
|
||||
.expect("failed to add path to file excluder");
|
||||
}
|
||||
let ignore = builder.build().unwrap();
|
||||
|
||||
// Build the event handler for notify.
|
||||
let notify_event_handler = {
|
||||
let tx = tx.clone();
|
||||
move |info: notify::Result<notify::Event>| {
|
||||
if let Ok(e) = info {
|
||||
if is_allowed_notify_event(&e) {
|
||||
_ = tx.unbounded_send(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// If we are in WSL, we must use Notify's poll watcher due to an event propagation issue.
|
||||
let is_wsl = is_wsl();
|
||||
const NOTIFY_ERROR_MSG: &str = "Failed to create file watcher.\nEnsure you have the required permissions to watch the specified directories.";
|
||||
|
||||
// Create the file watcher.
|
||||
let mut watcher: Box<dyn notify::Watcher> = match is_wsl {
|
||||
true => {
|
||||
let poll_interval = Duration::from_secs(
|
||||
serve.server_arguments.wsl_file_poll_interval.unwrap_or(2) as u64,
|
||||
);
|
||||
|
||||
Box::new(
|
||||
notify::PollWatcher::new(
|
||||
notify_event_handler,
|
||||
Config::default().with_poll_interval(poll_interval),
|
||||
)
|
||||
.expect(NOTIFY_ERROR_MSG),
|
||||
)
|
||||
}
|
||||
false => {
|
||||
Box::new(notify::recommended_watcher(notify_event_handler).expect(NOTIFY_ERROR_MSG))
|
||||
}
|
||||
};
|
||||
|
||||
// Watch the specified paths
|
||||
// todo: make sure we don't double-watch paths if they're nested
|
||||
for sub_path in allow_watch_path {
|
||||
let path = &config.crate_dir().join(sub_path);
|
||||
|
||||
// If the path is ignored, don't watch it
|
||||
if ignore.matched(path, path.is_dir()).is_ignore() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mode = notify::RecursiveMode::Recursive;
|
||||
|
||||
if let Err(err) = watcher.watch(path, mode) {
|
||||
tracing::warn!("Failed to watch path: {}", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Probe the entire project looking for our rsx calls
|
||||
// Whenever we get an update from the file watcher, we'll try to hotreload against this file map
|
||||
let file_map = FileMap::create_with_filter::<HtmlCtx>(config.crate_dir(), |path| {
|
||||
ignore.matched(path, path.is_dir()).is_ignore()
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
Self {
|
||||
let mut watcher = Self {
|
||||
watcher: create_notify_watcher(serve, tx.clone()),
|
||||
_tx: tx,
|
||||
krate: krate.clone(),
|
||||
rx,
|
||||
_watcher: watcher,
|
||||
file_map,
|
||||
ignore,
|
||||
queued_events: Vec::new(),
|
||||
_last_update_time: chrono::Local::now().timestamp(),
|
||||
applied_hot_reload_message: None,
|
||||
}
|
||||
ignore: krate.workspace_gitignore(),
|
||||
};
|
||||
|
||||
watcher.watch_filesystem();
|
||||
|
||||
watcher
|
||||
}
|
||||
|
||||
/// A cancel safe handle to the file watcher
|
||||
///
|
||||
/// todo: this should be simpler logic?
|
||||
pub async fn wait(&mut self) {
|
||||
// Pull off any queued events in succession
|
||||
while let Ok(Some(event)) = self.rx.try_next() {
|
||||
self.queued_events.push(event);
|
||||
/// Wait for changed files to be detected
|
||||
pub(crate) async fn wait(&mut self) -> ServeUpdate {
|
||||
// Wait for the next file to change
|
||||
let mut changes: Vec<_> = self.rx.next().await.into_iter().collect();
|
||||
|
||||
// Dequeue in bulk if we can, we might've received a lot of events in one go
|
||||
while let Some(event) = self.rx.try_next().ok().flatten() {
|
||||
changes.push(event);
|
||||
}
|
||||
|
||||
if !self.queued_events.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// If there are no queued events, wait for the next event
|
||||
if let Some(event) = self.rx.next().await {
|
||||
self.queued_events.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
/// Deques changed files from the event queue, doing the proper intelligent filtering
|
||||
pub fn dequeue_changed_files(&mut self, config: &DioxusCrate) -> Vec<PathBuf> {
|
||||
// Filter the changes
|
||||
let mut all_mods: Vec<PathBuf> = vec![];
|
||||
|
||||
// Decompose the events into a list of all the files that have changed
|
||||
for event in self.queued_events.drain(..) {
|
||||
// We only care about certain events.
|
||||
if !is_allowed_notify_event(&event) {
|
||||
continue;
|
||||
for event in changes.drain(..) {
|
||||
// Make sure we add new folders to the watch list, provided they're not matched by the ignore list
|
||||
// We'll only watch new folders that are found under the crate, and then update our watcher to watch them
|
||||
// This unfortunately won't pick up new krates added "at a distance" - IE krates not within the workspace.
|
||||
if let EventKind::Create(_create_kind) = event.kind {
|
||||
// If it's a new folder, watch it
|
||||
// If it's a new cargo.toml (ie dep on the fly),
|
||||
// todo(jon) support new folders on the fly
|
||||
}
|
||||
|
||||
for path in event.paths {
|
||||
@@ -174,18 +68,8 @@ impl Watcher {
|
||||
}
|
||||
}
|
||||
|
||||
let mut modified_files = vec![];
|
||||
|
||||
// For the non-rust files, we want to check if it's an asset file
|
||||
// This would mean the asset lives somewhere under the /assets directory or is referenced by magnanis in the linker
|
||||
// todo: mg integration here
|
||||
let _asset_dir = config
|
||||
.dioxus_config
|
||||
.application
|
||||
.asset_dir
|
||||
.canonicalize()
|
||||
.ok();
|
||||
|
||||
// Collect the files that have changed
|
||||
let mut files = vec![];
|
||||
for path in all_mods.iter() {
|
||||
if path.extension().is_none() {
|
||||
continue;
|
||||
@@ -201,209 +85,99 @@ impl Watcher {
|
||||
}
|
||||
}
|
||||
|
||||
// If the extension is a backup file, or a hidden file, ignore it completely (no rebuilds)
|
||||
if is_backup_file(path.to_path_buf()) {
|
||||
tracing::trace!("Ignoring backup file: {:?}", path);
|
||||
continue;
|
||||
}
|
||||
|
||||
// If the path is ignored, don't watch it
|
||||
if self.ignore.matched(path, path.is_dir()).is_ignore() {
|
||||
continue;
|
||||
}
|
||||
|
||||
modified_files.push(path.clone());
|
||||
files.push(path.clone());
|
||||
}
|
||||
|
||||
modified_files
|
||||
tracing::debug!("Files changed: {files:?}");
|
||||
|
||||
ServeUpdate::FilesChanged { files }
|
||||
}
|
||||
|
||||
pub fn attempt_hot_reload(
|
||||
&mut self,
|
||||
config: &DioxusCrate,
|
||||
modified_files: Vec<PathBuf>,
|
||||
) -> Option<HotReloadMsg> {
|
||||
// If we have any changes to the rust files, we need to update the file map
|
||||
let crate_dir = config.crate_dir();
|
||||
let mut templates = vec![];
|
||||
fn watch_filesystem(&mut self) {
|
||||
// Watch the folders of the crates that we're interested in
|
||||
for path in self.krate.watch_paths() {
|
||||
tracing::debug!("Watching path {path:?}");
|
||||
|
||||
// Prepare the hotreload message we need to send
|
||||
let mut edited_rust_files = Vec::new();
|
||||
let mut assets = Vec::new();
|
||||
let mut unknown_files = Vec::new();
|
||||
|
||||
for path in modified_files {
|
||||
// for various assets that might be linked in, we just try to hotreloading them forcefully
|
||||
// That is, unless they appear in an include! macro, in which case we need to a full rebuild....
|
||||
let Some(ext) = path.extension().and_then(|v| v.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
match ext {
|
||||
"rs" => edited_rust_files.push(path),
|
||||
_ if path.starts_with("assets") => assets.push(path),
|
||||
_ => unknown_files.push(path),
|
||||
if let Err(err) = self.watcher.watch(&path, RecursiveMode::Recursive) {
|
||||
handle_notify_error(err);
|
||||
}
|
||||
}
|
||||
|
||||
for rust_file in edited_rust_files {
|
||||
match self.file_map.update_rsx::<HtmlCtx>(&rust_file, &crate_dir) {
|
||||
Ok(hotreloaded_templates) => {
|
||||
templates.extend(hotreloaded_templates);
|
||||
}
|
||||
// If the file is not reloadable, we need to rebuild
|
||||
Err(HotreloadError::Notreloadable) => return None,
|
||||
// The rust file may have failed to parse, but that is most likely
|
||||
// because the user is in the middle of adding new code
|
||||
// We just ignore the error and let Rust analyzer warn about the problem
|
||||
Err(HotreloadError::Parse) => {}
|
||||
// Otherwise just log the error
|
||||
Err(err) => {
|
||||
tracing::error!(dx_src = ?TraceSrc::Dev, "Error hotreloading file {rust_file:?}: {err}")
|
||||
}
|
||||
// Also watch the crates themselves, but not recursively, such that we can pick up new folders
|
||||
for krate in self.krate.all_watched_crates() {
|
||||
tracing::debug!("Watching path {krate:?}");
|
||||
if let Err(err) = self.watcher.watch(&krate, RecursiveMode::NonRecursive) {
|
||||
handle_notify_error(err);
|
||||
}
|
||||
}
|
||||
|
||||
let msg = HotReloadMsg {
|
||||
templates,
|
||||
assets,
|
||||
unknown_files,
|
||||
// Also watch the workspace dir, non recursively, such that we can pick up new folders there too
|
||||
if let Err(err) = self
|
||||
.watcher
|
||||
.watch(&self.krate.workspace_dir(), RecursiveMode::NonRecursive)
|
||||
{
|
||||
handle_notify_error(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_notify_error(err: notify::Error) {
|
||||
tracing::debug!("Failed to watch path: {}", err);
|
||||
match err.kind {
|
||||
notify::ErrorKind::Io(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
|
||||
tracing::error!("Failed to watch path: permission denied. {:?}", err.paths)
|
||||
}
|
||||
notify::ErrorKind::MaxFilesWatch => {
|
||||
tracing::error!("Failed to set up file watcher: too many files to watch")
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn create_notify_watcher(
|
||||
serve: &ServeArgs,
|
||||
tx: UnboundedSender<notify::Event>,
|
||||
) -> Box<dyn NotifyWatcher> {
|
||||
// Build the event handler for notify.
|
||||
let handler = move |info: notify::Result<notify::Event>| {
|
||||
let Ok(event) = info else {
|
||||
return;
|
||||
};
|
||||
|
||||
self.add_hot_reload_message(&msg);
|
||||
let is_allowed_notify_event = match event.kind {
|
||||
EventKind::Modify(ModifyKind::Data(_)) => true,
|
||||
EventKind::Modify(ModifyKind::Name(_)) => true,
|
||||
// The primary modification event on WSL's poll watcher.
|
||||
EventKind::Modify(ModifyKind::Metadata(MetadataKind::WriteTime)) => true,
|
||||
// Catch-all for unknown event types.
|
||||
EventKind::Modify(ModifyKind::Any) => false,
|
||||
EventKind::Modify(ModifyKind::Metadata(_)) => false,
|
||||
// Don't care about anything else.
|
||||
EventKind::Create(_) => true,
|
||||
EventKind::Remove(_) => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
Some(msg)
|
||||
}
|
||||
|
||||
/// Get any hot reload changes that have been applied since the last full rebuild
|
||||
pub fn applied_hot_reload_changes(&mut self) -> Option<HotReloadMsg> {
|
||||
self.applied_hot_reload_message.clone()
|
||||
}
|
||||
|
||||
/// Clear the hot reload changes. This should be called any time a new build is starting
|
||||
pub fn clear_hot_reload_changes(&mut self) {
|
||||
self.applied_hot_reload_message.take();
|
||||
}
|
||||
|
||||
/// Store the hot reload changes for any future clients that connect
|
||||
fn add_hot_reload_message(&mut self, msg: &HotReloadMsg) {
|
||||
match &mut self.applied_hot_reload_message {
|
||||
Some(applied) => {
|
||||
// Merge the assets, unknown files, and templates
|
||||
// We keep the newer change if there is both a old and new change
|
||||
let mut templates: HashMap<String, _> = std::mem::take(&mut applied.templates)
|
||||
.into_iter()
|
||||
.map(|template| (template.location.clone(), template))
|
||||
.collect();
|
||||
let mut assets: HashSet<PathBuf> =
|
||||
std::mem::take(&mut applied.assets).into_iter().collect();
|
||||
let mut unknown_files: HashSet<PathBuf> =
|
||||
std::mem::take(&mut applied.unknown_files)
|
||||
.into_iter()
|
||||
.collect();
|
||||
for template in &msg.templates {
|
||||
templates.insert(template.location.clone(), template.clone());
|
||||
}
|
||||
assets.extend(msg.assets.iter().cloned());
|
||||
unknown_files.extend(msg.unknown_files.iter().cloned());
|
||||
applied.templates = templates.into_values().collect();
|
||||
applied.assets = assets.into_iter().collect();
|
||||
applied.unknown_files = unknown_files.into_iter().collect();
|
||||
}
|
||||
None => {
|
||||
self.applied_hot_reload_message = Some(msg.clone());
|
||||
}
|
||||
if is_allowed_notify_event {
|
||||
_ = tx.unbounded_send(event);
|
||||
}
|
||||
};
|
||||
|
||||
const NOTIFY_ERROR_MSG: &str = "Failed to create file watcher.\nEnsure you have the required permissions to watch the specified directories.";
|
||||
|
||||
if !is_wsl() {
|
||||
return Box::new(notify::recommended_watcher(handler).expect(NOTIFY_ERROR_MSG));
|
||||
}
|
||||
|
||||
/// Ensure the changes we've received from the queue are actually legit changes to either assets or
|
||||
/// rust code. We don't care about changes otherwise, unless we get a signal elsewhere to do a full rebuild
|
||||
pub fn pending_changes(&mut self) -> bool {
|
||||
!self.queued_events.is_empty()
|
||||
}
|
||||
}
|
||||
let poll_interval = Duration::from_secs(serve.wsl_file_poll_interval.unwrap_or(2) as u64);
|
||||
|
||||
fn is_backup_file(path: PathBuf) -> bool {
|
||||
// If there's a tilde at the end of the file, it's a backup file
|
||||
if let Some(name) = path.file_name() {
|
||||
if let Some(name) = name.to_str() {
|
||||
if name.ends_with('~') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// if the file is hidden, it's a backup file
|
||||
if let Some(name) = path.file_name() {
|
||||
if let Some(name) = name.to_str() {
|
||||
if name.starts_with('.') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Tests if the provided [`notify::Event`] is something we listen to so we can avoid unescessary hot reloads.
|
||||
fn is_allowed_notify_event(event: ¬ify::Event) -> bool {
|
||||
match event.kind {
|
||||
EventKind::Modify(ModifyKind::Data(_)) => true,
|
||||
EventKind::Modify(ModifyKind::Name(_)) => true,
|
||||
EventKind::Create(_) => true,
|
||||
EventKind::Remove(_) => true,
|
||||
// The primary modification event on WSL's poll watcher.
|
||||
EventKind::Modify(ModifyKind::Metadata(MetadataKind::WriteTime)) => true,
|
||||
// Catch-all for unknown event types.
|
||||
EventKind::Modify(ModifyKind::Any) => true,
|
||||
// Don't care about anything else.
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
const WSL_1: &str = "/proc/sys/kernel/osrelease";
|
||||
const WSL_2: &str = "/proc/version";
|
||||
const WSL_KEYWORDS: [&str; 2] = ["microsoft", "wsl"];
|
||||
|
||||
/// Detects if `dx` is being ran in a WSL environment.
|
||||
///
|
||||
/// We determine this based on whether the keyword `microsoft` or `wsl` is contained within the [`WSL_1`] or [`WSL_2`] files.
|
||||
/// This may fail in the future as it isn't guaranteed by Microsoft.
|
||||
/// See https://github.com/microsoft/WSL/issues/423#issuecomment-221627364
|
||||
fn is_wsl() -> bool {
|
||||
// Test 1st File
|
||||
if let Ok(content) = fs::read_to_string(WSL_1) {
|
||||
let lowercase = content.to_lowercase();
|
||||
for keyword in WSL_KEYWORDS {
|
||||
if lowercase.contains(keyword) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test 2nd File
|
||||
if let Ok(content) = fs::read_to_string(WSL_2) {
|
||||
let lowercase = content.to_lowercase();
|
||||
for keyword in WSL_KEYWORDS {
|
||||
if lowercase.contains(keyword) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_backup_file() {
|
||||
assert!(is_backup_file(PathBuf::from("examples/test.rs~")));
|
||||
assert!(is_backup_file(PathBuf::from("examples/.back")));
|
||||
assert!(is_backup_file(PathBuf::from("test.rs~")));
|
||||
assert!(is_backup_file(PathBuf::from(".back")));
|
||||
|
||||
assert!(!is_backup_file(PathBuf::from("val.rs")));
|
||||
assert!(!is_backup_file(PathBuf::from(
|
||||
"/Users/jonkelley/Development/Tinkering/basic_05_example/src/lib.rs"
|
||||
)));
|
||||
assert!(!is_backup_file(PathBuf::from("exmaples/val.rs")));
|
||||
Box::new(
|
||||
notify::PollWatcher::new(handler, Config::default().with_poll_interval(poll_interval))
|
||||
.expect(NOTIFY_ERROR_MSG),
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user