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:
Jonathan Kelley authored and GitHub committed 2024-10-25 17:23:45 -07:00
1 parent ef436e4ed0
commit 7ec3453ca3
156 files changed
+11544 -9005

No files matched your search

+172
View File
@@ -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(())
}
-189
View File
@@ -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>,
},
}
+32
View File
@@ -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
}
+451
View File
@@ -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
View File
@@ -1 +0,0 @@
+196 -191
View File
@@ -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()
}
File diff suppressed because it is too large. Load diff
+7 -3
View File
@@ -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"
-487
View File
@@ -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
}
+312
View File
@@ -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
View File
@@ -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
}
}
+59
View File
@@ -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
View File
@@ -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: &notify::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),
)
}