Binary patching rust hot-reloading, sub-second rebuilds, independent server/client hot-reload (#3797)

Enable runtime rust hot patching.

* roll back wry to 0.45

* cleanup rustc

* wip

* wip: fix client/server

* get the subsecond cli thing working again

* back to list of builds but with accessors

* migrate some methods to workspace, clean up impl a bit

* pass mode through build context

* use a build context, similar to cargo/rustc

* merge BuildUpdate and HandleUpdate into BuilderUpdate

* Move more resolution of args into runner

* migrate some methods to runner

* hoist out fullstack

* fix build request resolver

* yay request is cleaned up

* fixup resolution a bit

* spawn off the build!

* re-wire android autodetection

* re-wire android autodetection

* wire back up a few more things

* re-wire tooling verification

* I think it's mostly in the right condition for regular app

* add depinfo parser

* yay okay works with regular apps again

* full rebuilds are back

* rewire file wtcher

* wire patch!

* yayyyyyy patching works and is really fast

* yay android works

* clean up stdout piping

* yayyy memap on android so we don't need root

* create a static wasm jump table

* wip: global offset table for wasm

* wip... thinking about making the patches relocatable

* wip: customized relocation

* YESSSSSS RELOCATABLE WASM

* clean up impl a bit

* lil bit more cleanup

* lil bit more

* lil bit more cleanup

* lil bit more cleanup, support 32 bit platforms

* sick, wasm is completely pic

* hmmmm not quite working yet

* woooo, patches loading from rust+wasm

* integrated into the cli 😎

* condense a bit

* cleaned up request a bit

* bust fingerprints

* Make the file diffing logic a bit more sensible

* still working through a few issues, server launches but is acting weird

* fix merge conflict

* more merge issues

* remove stuff we don't want anymore

* revert name change

* wip: server not working anymore :(

* split apart "dioxus fullstack" into "dioxus server" and "dioxus web"

* fixup a few more compile errors

* grrr opening server

* simultaneous frontend and backend patching

* use rustc wrapper

* wip: passing assets to jumptable

* migrate project-like examples

* patchy  patchy server fns!

* rollback some random changes

* unwind the js files

* rollback hash

* no need for patch

* more cleanups

* lil bit more cleanup, remove some old cruft

* allow patching when wasm webpage is closed

* tiny bit more robust

* lil bit of clean up

* clean up ws code

* bit more clean ups

* condense

* undo file moves

* move back other project

* move other project

* migrate out harness and janky CLI

* fix compile

* anonymize some files, heavily document others

* lots more documentation, for posterity!

* ton more docs

* clean up the cli a bit more

* more ws cleanup

* more cleanup

* migrate build id to cli-config

* add command file handling

* random small cleanups

* wip....

* wip....

* fix: use workspace for krate discovery

* fix panic logging on serve by placing it *after* the logs

* reorder logging to be sensible on success

* bring back swc

* ws cruft

* small patches to relocs in loader

* bump krates

* hoist main funcs into the ifunc table, fixing wasm

* fix tui output for long lines

* add more linker logging

* wow, incredible, we build fat binaries, holy heckkkkk

* fix args for frontend/backend

* small cleanups

* properly send along build IDs to clients

* fix workspace compiles

* clean up jump table work a bit

* clean up logging a bit

* open existing

* fixup some commands

* open existing browser integration

* wire up the new dx serve client/server syntax

* fixup `dx run` command

* bring back some old functionality

* fix serverfn feature sets

* remove toast animation and simplify its code a bit

* less intrusive toast

* dont change build status when patching on web

* add proper cache busting to vcomponent

* clean up the patch file a bit

* more lints/checks cleaned up

* go back to TargetArgs but a BuildTargets resolved struct

* clean up more nits

* use an atomicptr for jumptable

* fix interaction with initial web load + suspense

* don't run asset system on wasm/bindgen js in dev

* reduce blast radius of PR

* use profile to determine if we're in release

* cleanup prod html template

* cleanup profiles

* fix feature resolution of fullstack

* if fullstack is explicitly false, don't use it

* light cleanups

* drop carg config2

* pass along linker args properly

* make workspace examples compile with dx again

* fewer unwraps and better error loggign

* bit more error handlign

* small cleanup

* drive-by cleanups

* use queries instead of initialize for aslr stuff

* fix hotpatch with higher opt level

* fix aslr bug from multiple clients

* fix merge conflict

* fix typos

* clippy

* fix miscompile

* fix doctest

* properly rollback wry to 0.45

* fix markdown path issues, other test issues

* fix test in router

* fix release mode test

* fix some more tests, clean up a few more items

* use fnptr instead of typeid

* clean up memozation via subsecond in core

* use "main" as sentinel

* fix imports and re-exports

* get off __rust_alloc for aslr offset detection

* wip

* wip... fixing unoptimized web

* hmmmm mmmm

* close,ish, still missing the wasmbindgen dynaimc imports

* aha! full wasm works now

- disable externref when compilng patch modules
- manually link wasm instrinsics
- displace the __wbindgen_placeholder__ table with the synthesized wbg imports

what an awful pain.

There might be a few items missing, so will test against the docsite shortly

* remove failing test

* hmmmm things aren't perfect

* from what I can tell, relocatable wasm just doesn't work with wasm-bindgen

sad

https://github.com/rustwasm/wasm-bindgen/issues/1420

* AI IS LITERALLY THE WORST

* preserve wasm snippets across compiles

* IT HOTPATCHES THE DOCSITE, WE ARE LIVING IN THE FUTURE

* remove the macos cfg-out

* fix: need to allocate 1 more page

* fix url issue

* properly span props

* properly fill in extra symbols

* light cleanups and good docs

* delete cruft, better docs

* More cleanups and simplifications

* clippy, typos

* remove harnesses

* wasm-bindgen error logging

* clean up deps

* remove cruft

* proper priv/pub

* use ifuncs for all env imports

* cruft

* use par_iter, better output

* implement a cache for the base module

* delete custom sections

* cache the main native module too

* add par iter in a few more places

* clippy, map ident

* better logging and status bars

* wip: some small windows support

* wip more windowss

* fix weird regression

* implement windows jump table

* small fixes for windows

* better windows support

* windows support pt2

* rust lld is not msvc

* whole archive the rlibs

* ignore windows and sys crates

* logging

* ohhhhhh, pop out dlls

* dedupe

* thANK GOD FAT LINKING WORKS ON WINDOWS

* hoist caching

* implement patch based on cache

* fix location for pdb

* pass data symbols, correct object

* add all symbol for windows

* add log

* treat none rva as undefined

* whoops

* handle windows calling convention

* dont use relocated arm64 stub windows

* not all are text?

* WINDOWS FUNCTION PATCHING WORKS

but issue with statics?

* use our own entry

* handle windows imp symbols

* add log

* jump to the real symbol not the layout

* whoops

* hotpatching windows works completely holy heck yay

* drop linker args crate

* disable ssg playwright for now

* light cleanups

* some cleanups / preps:
- bump to serverfn 0.8.2
- fix playwright test
- fix clippy

* use fix from #4042

* bring in other fix for liveview

* hoist some configuration

* use __aslr_reference instead of aslr_reference

* add better errors to patcherror

* fix android

* panic if patching fails

* clean up tomls

* wip: fixing wasm compile for server

* fullstack wasm again

* fix cfg

* fixup feature detection

* fix issue with features

* fix playwright!

* add barebones template test harness

* fix compatibility between the two hotreload engines

* fix preldue of server

* fix check for wasm-opt

* small fixes
This commit is contained in:
Jonathan Kelley authored and GitHub committed 2025-05-06 23:08:59 -07:00
1 parent 71d85e6dd5
commit d976ca184a
142 files changed
+17982 -7156

No files matched your search

-32
View File
@@ -1,32 +0,0 @@
/// 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
}
-856
View File
@@ -1,856 +0,0 @@
use crate::{AppBundle, DioxusCrate, Platform, Result};
use anyhow::Context;
use dioxus_cli_opt::process_file_to;
use std::{
env,
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.
///
/// todo: restructure this such that "open" is a running task instead of blocking the main thread
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 executables but with some extra entropy in their name so we can run two instances of the
/// same app without causing collisions on the filesystem.
pub(crate) entropy_app_exe: Option<PathBuf>,
pub(crate) entropy_server_exe: Option<PathBuf>,
/// 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,
entropy_app_exe: None,
entropy_server_exe: None,
})
}
pub(crate) async fn open(
&mut self,
devserver_ip: SocketAddr,
open_address: Option<SocketAddr>,
start_fullstack_on_address: Option<SocketAddr>,
open_browser: bool,
) -> Result<()> {
let krate = &self.app.build.krate;
// 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_config::CLI_ENABLED_ENV, "true".to_string()),
(
dioxus_cli_config::ALWAYS_ON_TOP_ENV,
krate.settings.always_on_top.unwrap_or(true).to_string(),
),
(
dioxus_cli_config::APP_TITLE_ENV,
krate.config.web.app.title.clone(),
),
("RUST_BACKTRACE", "1".to_string()),
(
dioxus_cli_config::DEVSERVER_IP_ENV,
devserver_ip.ip().to_string(),
),
(
dioxus_cli_config::DEVSERVER_PORT_ENV,
devserver_ip.port().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()),
(
dioxus_cli_config::SESSION_CACHE_DIR,
self.app
.build
.krate
.session_cache_dir()
.display()
.to_string(),
),
];
if let Some(base_path) = &krate.config.web.app.base_path {
envs.push((dioxus_cli_config::ASSET_ROOT_ENV, base_path.clone()));
}
if let Some(env_filter) = env::var_os("RUST_LOG").and_then(|e| e.into_string().ok()) {
envs.push(("RUST_LOG", env_filter));
}
// Launch the server if we were given an address to start it on, and the build includes a server. After we
// start the server, consume its stdout/stderr.
if let (Some(addr), Some(server)) = (start_fullstack_on_address, self.server_exe()) {
tracing::debug!("Proxying fullstack server from port {:?}", addr);
envs.push((dioxus_cli_config::SERVER_IP_ENV, addr.ip().to_string()));
envs.push((dioxus_cli_config::SERVER_PORT_ENV, addr.port().to_string()));
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(open_address.unwrap_or(devserver_ip));
}
None
}
Platform::Ios => Some(self.open_ios_sim(envs).await?),
// https://developer.android.com/studio/run/emulator-commandline
Platform::Android => {
self.open_android_sim(devserver_ip, envs).await;
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(())
}
/// Gracefully kill the process and all of its children
///
/// Uses the `SIGTERM` signal on unix and `taskkill` on windows.
/// This complex logic is necessary for things like window state preservation to work properly.
///
/// Also wipes away the entropy executables if they exist.
pub(crate) async fn cleanup(&mut self) {
// Soft-kill the process by sending a sigkill, allowing the process to clean up
self.soft_kill().await;
// Wipe out the entropy executables if they exist
if let Some(entropy_app_exe) = self.entropy_app_exe.take() {
_ = std::fs::remove_file(entropy_app_exe);
}
if let Some(entropy_server_exe) = self.entropy_server_exe.take() {
_ = std::fs::remove_file(entropy_server_exe);
}
}
/// Kill the app and server exes
pub(crate) async fn soft_kill(&mut self) {
use futures_util::FutureExt;
// Kill any running executables on Windows
let server_process = self.server_child.take();
let client_process = self.app_child.take();
let processes = [server_process, client_process]
.into_iter()
.flatten()
.collect::<Vec<_>>();
for mut process in processes {
let Some(pid) = process.id() else {
_ = process.kill().await;
continue;
};
// on unix, we can send a signal to the process to shut down
#[cfg(unix)]
{
_ = Command::new("kill")
.args(["-s", "TERM", &pid.to_string()])
.spawn();
}
// on windows, use the `taskkill` command
#[cfg(windows)]
{
_ = Command::new("taskkill")
.args(["/F", "/PID", &pid.to_string()])
.spawn();
}
// join the wait with a 100ms timeout
futures_util::select! {
_ = process.wait().fuse() => {}
_ = tokio::time::sleep(std::time::Duration::from_millis(1000)).fuse() => {}
};
}
}
/// 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) async 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.build.asset_dir(),
};
tracing::debug!("Hotreloading asset {changed_file:?} in target {asset_dir:?}");
// If the asset shares the same name in the bundle, reload that
if let Some(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}");
}
}
}
// Canonicalize the path as Windows may use long-form paths "\\\\?\\C:\\".
let changed_file = dunce::canonicalize(changed_file)
.inspect_err(|e| tracing::debug!("Failed to canonicalize hotreloaded asset: {e}"))
.ok()?;
// 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 output_path = asset_dir.join(resource.bundled_path());
// Remove the old asset if it exists
_ = std::fs::remove_file(&output_path);
// And then process the asset with the options into the **old** asset location. If we recompiled,
// the asset would be in a new location because the contents and hash have changed. Since we are
// hotreloading, we need to use the old asset location it was originally written to.
let options = *resource.options();
let res = process_file_to(&options, &changed_file, &output_path);
bundled_name = Some(PathBuf::from(resource.bundled_path()));
if let Err(e) = res {
tracing::debug!("Failed to hotreload asset {e}");
}
}
// If the emulator is android, we need to copy the asset to the device with `adb push asset /data/local/tmp/dx/assets/filename.ext`
if self.app.build.build.platform() == Platform::Android {
if let Some(bundled_name) = bundled_name.as_ref() {
let target = dioxus_cli_config::android_session_cache_dir().join(bundled_name);
tracing::debug!("Pushing asset to device: {target:?}");
let res = tokio::process::Command::new(DioxusCrate::android_adb())
.arg("push")
.arg(&changed_file)
.arg(target)
.output()
.await
.context("Failed to push asset to device");
if let Err(e) = res {
tracing::debug!("Failed to push asset to device: {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> {
// Create a new entropy app exe if we need to
let main_exe = self.app_exe();
let child = Command::new(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, 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_detached(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.build.root_dir()
);
let res = Command::new("xcrun")
.arg("simctl")
.arg("install")
.arg("booted")
.arg(self.app.build.root_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(self.app.build.krate.bundle_identifier())
.envs(ios_envs)
.stderr(Stdio::piped())
.stdout(Stdio::piped())
.kill_on_drop(true)
.spawn()?;
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<()> {
use serde_json::Value;
let app_path = self.app.build.root_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> {
// xcrun devicectl device install app --device <uuid> --path <path> --json-output
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.")
}
#[allow(unused)]
async fn codesign_ios(&self) -> Result<()> {
const CODESIGN_ERROR: &str = r#"This is likely because you haven't
- Created a provisioning profile before
- Accepted the Apple Developer Program License Agreement
The agreement changes frequently and might need to be accepted again.
To accept the agreement, go to https://developer.apple.com/account
To create a provisioning profile, follow the instructions here:
https://developer.apple.com/documentation/xcode/sharing-your-teams-signing-certificates"#;
let profiles_folder = dirs::home_dir()
.context("Your machine has no home-dir")?
.join("Library/MobileDevice/Provisioning Profiles");
if !profiles_folder.exists() || profiles_folder.read_dir()?.next().is_none() {
tracing::error!(
r#"No provisioning profiles found when trying to codesign the app.
We checked the folder: {}
{CODESIGN_ERROR}
"#,
profiles_folder.display()
)
}
let identities = Command::new("security")
.args(["find-identity", "-v", "-p", "codesigning"])
.output()
.await
.context("Failed to run `security find-identity -v -p codesigning`")
.map(|e| {
String::from_utf8(e.stdout)
.context("Failed to parse `security find-identity -v -p codesigning`")
})??;
// Parsing this:
// 51ADE4986E0033A5DB1C794E0D1473D74FD6F871 "Apple Development: jkelleyrtp@gmail.com (XYZYZY)"
let app_dev_name = regex::Regex::new(r#""Apple Development: (.+)""#)
.unwrap()
.captures(&identities)
.and_then(|caps| caps.get(1))
.map(|m| m.as_str())
.context(
"Failed to find Apple Development in `security find-identity -v -p codesigning`",
)?;
// Acquire the provision file
let provision_file = profiles_folder
.read_dir()?
.flatten()
.find(|entry| {
entry
.file_name()
.to_str()
.map(|s| s.contains("mobileprovision"))
.unwrap_or_default()
})
.context("Failed to find a provisioning profile. \n\n{CODESIGN_ERROR}")?;
// The .mobileprovision file has some random binary thrown into into, but it's still basically a plist
// Let's use the plist markers to find the start and end of the plist
fn cut_plist(bytes: &[u8], byte_match: &[u8]) -> Option<usize> {
bytes
.windows(byte_match.len())
.enumerate()
.rev()
.find(|(_, slice)| *slice == byte_match)
.map(|(i, _)| i + byte_match.len())
}
let bytes = std::fs::read(provision_file.path())?;
let cut1 = cut_plist(&bytes, b"<plist").context("Failed to parse .mobileprovision file")?;
let cut2 = cut_plist(&bytes, r#"</dict>"#.as_bytes())
.context("Failed to parse .mobileprovision file")?;
let sub_bytes = &bytes[(cut1 - 6)..cut2];
let mbfile: ProvisioningProfile =
plist::from_bytes(sub_bytes).context("Failed to parse .mobileprovision file")?;
#[derive(serde::Deserialize, Debug)]
struct ProvisioningProfile {
#[serde(rename = "TeamIdentifier")]
team_identifier: Vec<String>,
#[serde(rename = "ApplicationIdentifierPrefix")]
application_identifier_prefix: Vec<String>,
#[serde(rename = "Entitlements")]
entitlements: Entitlements,
}
#[derive(serde::Deserialize, Debug)]
struct Entitlements {
#[serde(rename = "application-identifier")]
application_identifier: String,
#[serde(rename = "keychain-access-groups")]
keychain_access_groups: Vec<String>,
}
let entielements_xml = format!(
r#"
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>application-identifier</key>
<string>{APPLICATION_IDENTIFIER}</string>
<key>keychain-access-groups</key>
<array>
<string>{APP_ID_ACCESS_GROUP}.*</string>
</array>
<key>get-task-allow</key>
<true/>
<key>com.apple.developer.team-identifier</key>
<string>{TEAM_IDENTIFIER}</string>
</dict></plist>
"#,
APPLICATION_IDENTIFIER = mbfile.entitlements.application_identifier,
APP_ID_ACCESS_GROUP = mbfile.entitlements.keychain_access_groups[0],
TEAM_IDENTIFIER = mbfile.team_identifier[0],
);
// write to a temp file
let temp_file = tempfile::NamedTempFile::new()?;
std::fs::write(temp_file.path(), entielements_xml)?;
// codesign the app
let output = Command::new("codesign")
.args([
"--force",
"--entitlements",
temp_file.path().to_str().unwrap(),
"--sign",
app_dev_name,
])
.arg(self.app.build.root_dir())
.output()
.await
.context("Failed to codesign the app")?;
if !output.status.success() {
let stderr = String::from_utf8(output.stderr).unwrap_or_default();
return Err(format!("Failed to codesign the app: {stderr}").into());
}
Ok(())
}
async fn open_android_sim(
&self,
devserver_socket: SocketAddr,
envs: Vec<(&'static str, String)>,
) {
let apk_path = self.app.apk_path();
let session_cache = self.app.build.krate.session_cache_dir();
let full_mobile_app_name = self.app.build.krate.full_mobile_app_name();
// Start backgrounded since .open() is called while in the arm of the top-level match
tokio::task::spawn(async move {
let port = devserver_socket.port();
if let Err(e) = Command::new("adb")
.arg("reverse")
.arg(format!("tcp:{}", port))
.arg(format!("tcp:{}", port))
.stderr(Stdio::piped())
.stdout(Stdio::piped())
.output()
.await
{
tracing::error!("failed to forward port {port}: {e}");
}
// Install
// adb install -r app-debug.apk
if let Err(e) = Command::new(DioxusCrate::android_adb())
.arg("install")
.arg("-r")
.arg(apk_path)
.stderr(Stdio::piped())
.stdout(Stdio::piped())
.output()
.await
{
tracing::error!("Failed to install apk with `adb`: {e}");
};
// Write the env vars to a .env file in our session cache
let env_file = session_cache.join(".env");
let contents: String = envs
.iter()
.map(|(key, value)| format!("{key}={value}"))
.collect::<Vec<_>>()
.join("\n");
_ = std::fs::write(&env_file, contents);
// Push the env file to the device
if let Err(e) = tokio::process::Command::new(DioxusCrate::android_adb())
.arg("push")
.arg(env_file)
.arg(dioxus_cli_config::android_session_cache_dir().join(".env"))
.output()
.await
.context("Failed to push asset to device")
{
tracing::error!("Failed to push .env file to device: {e}");
}
// eventually, use the user's MainActivity, not our MainActivity
// adb shell am start -n dev.dioxus.main/dev.dioxus.main.MainActivity
let activity_name = format!("{}/dev.dioxus.main.MainActivity", full_mobile_app_name,);
if let Err(e) = Command::new(DioxusCrate::android_adb())
.arg("shell")
.arg("am")
.arg("start")
.arg("-n")
.arg(activity_name)
.stderr(Stdio::piped())
.stdout(Stdio::piped())
.output()
.await
{
tracing::error!("Failed to start app with `adb`: {e}");
};
});
}
fn make_entropy_path(exe: &PathBuf) -> PathBuf {
let id = uuid::Uuid::new_v4();
let name = id.to_string();
let some_entropy = name.split('-').next().unwrap();
// Make a copy of the server exe with a new name
let entropy_server_exe = exe.with_file_name(format!(
"{}-{}",
exe.file_name().unwrap().to_str().unwrap(),
some_entropy
));
std::fs::copy(exe, &entropy_server_exe).unwrap();
entropy_server_exe
}
fn server_exe(&mut self) -> Option<PathBuf> {
let mut server = self.app.server_exe()?;
// Create a new entropy server exe if we need to
if cfg!(target_os = "windows") || cfg!(target_os = "linux") {
// If we already have an entropy server exe, return it - this is useful for re-opening the same app
if let Some(existing_server) = self.entropy_server_exe.clone() {
return Some(existing_server);
}
// Otherwise, create a new entropy server exe and save it for re-opning
let entropy_server_exe = Self::make_entropy_path(&server);
self.entropy_server_exe = Some(entropy_server_exe.clone());
server = entropy_server_exe;
}
Some(server)
}
fn app_exe(&mut self) -> PathBuf {
let mut main_exe = self.app.main_exe();
// The requirement here is based on the platform, not necessarily our current architecture.
let requires_entropy = match self.app.build.build.platform() {
// When running "bundled", we don't need entropy
Platform::Web => false,
Platform::MacOS => false,
Platform::Ios => false,
Platform::Android => false,
// But on platforms that aren't running as "bundled", we do.
Platform::Windows => true,
Platform::Linux => true,
Platform::Server => true,
Platform::Liveview => true,
};
if requires_entropy || std::env::var("DIOXUS_ENTROPY").is_ok() {
// If we already have an entropy app exe, return it - this is useful for re-opening the same app
if let Some(existing_app_exe) = self.entropy_app_exe.clone() {
return existing_app_exe;
}
let entropy_app_exe = Self::make_entropy_path(&main_exe);
self.entropy_app_exe = Some(entropy_app_exe.clone());
main_exe = entropy_app_exe;
}
main_exe
}
}
+127 -174
View File
@@ -1,21 +1,16 @@
use crate::{BuildUpdate, Builder, Error, Platform, Result, ServeArgs, TraceController, TraceSrc};
use crate::{AppBuilder, BuildId, BuildMode, BuilderUpdate, Result, ServeArgs, TraceController};
mod ansi_buffer;
mod detect;
mod handle;
mod output;
mod proxy;
mod runner;
mod server;
mod update;
mod 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.
///
@@ -23,11 +18,11 @@ pub(crate) use watcher::*;
///
/// 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.
/// - 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.
///
/// 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
@@ -37,19 +32,11 @@ pub(crate) use watcher::*;
/// - 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(crate) async fn serve_all(mut args: ServeArgs) -> Result<()> {
// Redirect all logging the cli logger
let mut tracer = TraceController::redirect(args.is_interactive_tty());
// Load the krate and resolve the server args against it - this might log so do it after we turn on the tracer first
let krate = args.load_krate().await?;
// Note that starting the builder will queue up a build immediately
let mut screen = Output::start(&args).await?;
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);
pub(crate) async fn serve_all(args: ServeArgs, tracer: &mut TraceController) -> Result<()> {
// Load the args into a plan, resolving all tooling, build dirs, arguments, decoding the multi-target, etc
let mut builder = AppServer::start(args).await?;
let mut devserver = WebServer::start(&builder)?;
let mut screen = Output::start(builder.interactive).await?;
// 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
@@ -63,166 +50,137 @@ pub(crate) async fn serve_all(mut args: ServeArgs) -> Result<()> {
• Press `/` for more commands and shortcuts
Learn more at https://dioxuslabs.com/learn/0.6/getting_started
----------------------------------------------------------------"#,
krate.executable_name()
builder.app_name()
);
let err: Result<(), Error> = loop {
loop {
// Draw the state of the server to the screen
screen.render(&args, &krate, &builder, &devserver, &watcher);
screen.render(&builder, &devserver);
// And then wait for any updates before redrawing
let msg = tokio::select! {
msg = builder.wait() => ServeUpdate::BuildUpdate(msg),
msg = watcher.wait() => msg,
msg = builder.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() {
if files.is_empty() || !builder.hot_reload {
continue;
}
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) = runner.attempt_hot_reload(files).await {
// Only send a hotreload message for templates and assets - otherwise we'll just get a full rebuild
//
// Also make sure the builder isn't busy since that might cause issues with hotreloads
// https://github.com/DioxusLabs/dioxus/issues/3361
if hr.is_empty() || !builder.can_receive_hotreloads() {
tracing::debug!(dx_src = ?TraceSrc::Dev, "Ignoring file change: {}", file);
continue;
}
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);
// We're going to kick off a new build, interrupting the current build if it's ongoing
builder.rebuild(args.build_arguments.clone());
// 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
devserver.send_reload_start().await;
devserver.start_build().await;
} else {
tracing::warn!(
"Rebuild required but is currently paused - press `r` to rebuild manually"
)
}
}
// 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;
}
// 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);
}
// 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, &builder).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);
}
BuildUpdate::BuildFailed { err } => {
tracing::error!("Build failed: {:?}", err);
}
BuildUpdate::BuildReady { bundle } => {
let handle = runner
.open(
bundle,
devserver.devserver_address(),
devserver.displayed_address(),
devserver.proxied_server_address(),
args.open.unwrap_or(false),
)
.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),
}
}
}
}
// 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`"#
);
}
}
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);
tracing::debug!("Starting hotpatching: {:?}", files);
builder.handle_file_change(&files, &mut devserver).await;
}
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
// `Hot-patching:` 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();
builder.full_rebuild().await;
devserver.send_reload_start().await;
devserver.start_build().await
devserver.start_build().await;
}
// Run the server in the background
// Waiting for updates here lets us tap into when clients are added/removed
ServeUpdate::NewConnection { id, aslr_reference } => {
// Send the client
devserver
.send_hotreload(builder.applied_hot_reload_changes(BuildId::CLIENT))
.await;
if builder.server.is_some() {
devserver
.send_hotreload(builder.applied_hot_reload_changes(BuildId::SERVER))
.await;
}
builder.client_connected(id, aslr_reference).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, platform } => {
screen.push_ws_message(platform, &msg);
}
// 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::BuilderUpdate { id, update } => {
let platform = builder.get_build(id).unwrap().build.platform;
// 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
match update {
BuilderUpdate::Progress { .. } => {}
BuilderUpdate::CompilerMessage { message } => {
screen.push_cargo_log(message);
}
BuilderUpdate::BuildFailed { err } => {
tracing::error!("Build failed: {:#?}", err);
}
BuilderUpdate::BuildReady { bundle } => match bundle.mode {
BuildMode::Thin { ref cache, .. } => {
let elapsed =
bundle.time_end.duration_since(bundle.time_start).unwrap();
match builder.hotpatch(&bundle, id, cache).await {
Ok(jumptable) => devserver.send_patch(jumptable, elapsed, id).await,
Err(err) => {
tracing::error!("Failed to hot-patch app: {err}");
if matches!(err, crate::Error::PatchingFailed(_)) {
tracing::info!("Starting full rebuild: {err}");
builder.full_rebuild().await;
devserver.send_reload_start().await;
devserver.start_build().await;
}
}
}
}
BuildMode::Base | BuildMode::Fat => {
_ = builder
.open(bundle, &mut devserver)
.await
.inspect_err(|e| tracing::error!("Failed to open app: {}", e));
}
},
BuilderUpdate::StdoutReceived { msg } => {
screen.push_stdio(platform, msg, tracing::Level::INFO);
}
BuilderUpdate::StderrReceived { msg } => {
screen.push_stdio(platform, msg, tracing::Level::ERROR);
}
BuilderUpdate::ProcessExited { status } => {
if status.success() {
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`"#
);
} else {
tracing::error!("Application [{platform}] exited with error: {status}");
}
}
}
}
ServeUpdate::TracingLog { log } => {
screen.push_log(log);
}
ServeUpdate::OpenApp => {
if let Err(err) = runner.open_existing(&devserver).await {
if let Err(err) = builder.open_all(&devserver, true).await {
tracing::error!("Failed to open app: {err}")
}
}
@@ -232,10 +190,10 @@ pub(crate) async fn serve_all(mut args: ServeArgs) -> Result<()> {
}
ServeUpdate::ToggleShouldRebuild => {
runner.should_full_rebuild = !runner.should_full_rebuild;
builder.automatic_rebuilds = !builder.automatic_rebuilds;
tracing::info!(
"Automatic rebuilds are currently: {}",
if runner.should_full_rebuild {
if builder.automatic_rebuilds {
"enabled"
} else {
"disabled"
@@ -243,21 +201,16 @@ pub(crate) async fn serve_all(mut args: ServeArgs) -> Result<()> {
)
}
ServeUpdate::Exit { error } => match error {
Some(err) => break Err(anyhow::anyhow!("{}", err).into()),
None => break Ok(()),
},
ServeUpdate::Exit { error } => {
_ = builder.cleanup_all().await;
_ = devserver.shutdown().await;
_ = screen.shutdown();
match error {
Some(err) => return Err(anyhow::anyhow!("{}", err).into()),
None => return Ok(()),
}
}
}
};
_ = runner.cleanup().await;
_ = devserver.shutdown().await;
builder.abort_all();
_ = screen.shutdown();
if let Err(err) = err {
eprintln!("Exiting with error: {}", err);
}
Ok(())
}
+61 -71
View File
@@ -1,7 +1,6 @@
use crate::{
serve::{ansi_buffer::AnsiStringLine, Builder, ServeUpdate, Watcher, WebServer},
BuildStage, BuildUpdate, DioxusCrate, Platform, RustcDetails, ServeArgs, TraceContent,
TraceMsg, TraceSrc,
serve::{ansi_buffer::AnsiStringLine, ServeUpdate, WebServer},
BuildStage, BuilderUpdate, Platform, TraceContent, TraceMsg, TraceSrc,
};
use crossterm::{
cursor::{Hide, Show},
@@ -26,6 +25,8 @@ use std::{
};
use tracing::Level;
use super::AppServer;
const TICK_RATE_MS: u64 = 100;
const VIEWPORT_MAX_WIDTH: u16 = 100;
const VIEWPORT_HEIGHT_SMALL: u16 = 5;
@@ -46,7 +47,6 @@ pub struct Output {
// A list of all messages from build, dev, app, and more.
more_modal_open: bool,
interactive: bool,
platform: Platform,
// Whether to show verbose logs or not
// We automatically hide "debug" logs if verbose is false (only showing "info" / "warn" / "error")
@@ -64,31 +64,24 @@ pub struct Output {
// ! needs to be wrapped in an &mut since `render stateful widget` requires &mut... but our
// "render" method only borrows &self (for no particular reason at all...)
throbber: RefCell<throbber_widgets_tui::ThrobberState>,
rustc_details: RustcDetails,
}
#[allow(unused)]
#[derive(Clone, Copy)]
struct RenderState<'a> {
opts: &'a ServeArgs,
krate: &'a DioxusCrate,
build_engine: &'a Builder,
runner: &'a AppServer,
server: &'a WebServer,
watcher: &'a Watcher,
}
impl Output {
pub(crate) async fn start(cfg: &ServeArgs) -> crate::Result<Self> {
pub(crate) async fn start(interactive: bool) -> crate::Result<Self> {
let mut output = Self {
interactive,
term: Rc::new(RefCell::new(None)),
interactive: cfg.is_interactive_tty(),
dx_version: format!(
"{}-{}",
env!("CARGO_PKG_VERSION"),
crate::dx_build_info::GIT_COMMIT_HASH_SHORT.unwrap_or("main")
),
platform: cfg.build_arguments.platform.expect("To be resolved by now"),
events: None,
more_modal_open: false,
pending_logs: VecDeque::new(),
@@ -101,7 +94,6 @@ impl Output {
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
interval
},
rustc_details: RustcDetails::from_cli().await?,
};
output.startup()?;
@@ -113,15 +105,6 @@ impl Output {
/// This is meant to be paired with "shutdown" to restore the terminal to its original state.
fn startup(&mut self) -> io::Result<()> {
if self.interactive {
// set the panic hook to fix the terminal in the event of a panic
// The terminal might be left in a wonky state if a panic occurs, and we don't want it to be completely broken
let original_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
_ = disable_raw_mode();
_ = stdout().execute(Show);
original_hook(info);
}));
// Check if writing the terminal is going to block infinitely.
// If it does, we should disable interactive mode. This ensures we work with programs like `bg`
// which suspend the process and cause us to block when writing output.
@@ -183,7 +166,12 @@ impl Output {
/// Call the shutdown functions that might mess with the terminal settings - see the related code
/// in "startup" for more details about what we need to unset
pub(crate) fn shutdown(&self) -> io::Result<()> {
if self.interactive {
Self::remote_shutdown(self.interactive)?;
Ok(())
}
pub(crate) fn remote_shutdown(interactive: bool) -> io::Result<()> {
if interactive {
stdout()
.execute(Show)?
.execute(DisableFocusChange)?
@@ -329,7 +317,7 @@ impl Output {
}
/// Push a message from the websocket to the logs
pub fn push_ws_message(&mut self, platform: Platform, message: axum::extract::ws::Message) {
pub fn push_ws_message(&mut self, platform: Platform, message: &axum::extract::ws::Message) {
use dioxus_devtools_types::ClientMsg;
// We can only handle text messages from the websocket...
@@ -341,14 +329,18 @@ impl Output {
let res = serde_json::from_str::<ClientMsg>(text.as_str());
// Client logs being errors aren't fatal, but we should still report them them
let ClientMsg::Log { level, messages } = match res {
let msg = match res {
Ok(msg) => msg,
Err(err) => {
tracing::error!(dx_src = ?TraceSrc::Dev, "Error parsing message from {}: {}", platform, err);
tracing::error!(dx_src = ?TraceSrc::Dev, "Error parsing message from {}: {} -> {:?}", platform, err, text.as_str());
return;
}
};
let ClientMsg::Log { level, messages } = msg else {
return;
};
// FIXME(jon): why are we pulling only the first message here?
let content = messages.first().unwrap_or(&String::new()).clone();
@@ -373,26 +365,19 @@ impl Output {
/// approach, but then we'd need to do that *everywhere* instead of simply performing a react-like
/// re-render when external state changes. Ratatui will diff the intermediate buffer, so we at least
/// we won't be drawing it.
pub(crate) fn new_build_update(&mut self, update: &BuildUpdate) {
pub(crate) fn new_build_update(&mut self, update: &BuilderUpdate) {
match update {
BuildUpdate::Progress {
BuilderUpdate::Progress {
stage: BuildStage::Starting { .. },
} => self.tick_animation = true,
BuildUpdate::BuildReady { .. } => self.tick_animation = false,
BuildUpdate::BuildFailed { .. } => self.tick_animation = false,
BuilderUpdate::BuildReady { .. } => self.tick_animation = false,
BuilderUpdate::BuildFailed { .. } => self.tick_animation = false,
_ => {}
}
}
/// Render the current state of everything to the console screen
pub fn render(
&mut self,
opts: &ServeArgs,
config: &DioxusCrate,
build_engine: &Builder,
server: &WebServer,
watcher: &Watcher,
) {
pub fn render(&mut self, runner: &AppServer, server: &WebServer) {
if !self.interactive {
return;
}
@@ -409,16 +394,7 @@ impl Output {
// Then, draw the frame, passing along all the state of the TUI so we can render it properly
_ = term.draw(|frame| {
self.render_frame(
frame,
RenderState {
opts,
krate: config,
build_engine,
server,
watcher,
},
);
self.render_frame(frame, RenderState { runner, server });
});
}
@@ -488,39 +464,46 @@ impl Output {
])
.areas(gauge_area);
let client = &state.runner.client();
self.render_single_gauge(
frame,
app_progress,
state.build_engine.compile_progress(),
client.compile_progress(),
"App: ",
state,
state.build_engine.compile_duration(),
client.compile_duration(),
);
if state.build_engine.request.build.fullstack() {
if state.runner.is_fullstack() {
self.render_single_gauge(
frame,
second_progress,
state.build_engine.server_compile_progress(),
state.runner.server_compile_progress(),
"Server: ",
state,
state.build_engine.compile_duration(),
client.compile_duration(),
);
} else {
self.render_single_gauge(
frame,
second_progress,
state.build_engine.bundle_progress(),
client.bundle_progress(),
"Bundle: ",
state,
state.build_engine.bundle_duration(),
client.bundle_duration(),
);
}
let mut lines = vec!["Status: ".white()];
match &state.build_engine.stage {
match &client.stage {
BuildStage::Initializing => lines.push("Initializing".yellow()),
BuildStage::Starting { .. } => lines.push("Starting build".yellow()),
BuildStage::Starting { patch, .. } => {
if *patch {
lines.push("Hot-patching...".yellow())
} else {
lines.push("Starting build".yellow())
}
}
BuildStage::InstallingTooling => lines.push("Installing tooling".yellow()),
BuildStage::Compiling {
current,
@@ -535,7 +518,6 @@ impl Output {
BuildStage::OptimizingWasm => lines.push("Optimizing wasm".yellow()),
BuildStage::SplittingBundle => lines.push("Splitting bundle".yellow()),
BuildStage::CompressingAssets => lines.push("Compressing assets".yellow()),
BuildStage::PrerenderingRoutes => lines.push("Prerendering static routes".yellow()),
BuildStage::RunningBindgen => lines.push("Running wasm-bindgen".yellow()),
BuildStage::RunningGradle => lines.push("Running gradle assemble".yellow()),
BuildStage::Bundling => lines.push("Bundling app".yellow()),
@@ -552,15 +534,18 @@ impl Output {
}
BuildStage::Success => {
lines.push("Serving ".yellow());
lines.push(state.krate.executable_name().white());
lines.push(client.build.executable_name().white());
lines.push(" 🚀 ".green());
if let Some(comp_time) = state.build_engine.total_build_time() {
if let Some(comp_time) = client.total_build_time() {
lines.push(format!("{:.1}s", comp_time.as_secs_f32()).dark_gray());
}
}
BuildStage::Failed => lines.push("Failed".red()),
BuildStage::Aborted => lines.push("Aborted".red()),
BuildStage::Restarting => lines.push("Restarting".yellow()),
BuildStage::Linking => lines.push("Linking".yellow()),
BuildStage::Hotpatching => lines.push("Hot-patching...".yellow()),
BuildStage::ExtractingAssets => lines.push("Extracting assets".yellow()),
_ => {}
};
@@ -576,7 +561,7 @@ impl Output {
state: RenderState,
time_taken: Option<Duration>,
) {
let failed = state.build_engine.stage == BuildStage::Failed;
let failed = state.runner.client.stage == BuildStage::Failed;
let value = if failed { 1.0 } else { value.clamp(0.0, 1.0) };
let [gauge_row, _, icon] = Layout::horizontal([
@@ -647,11 +632,12 @@ impl Output {
])
.areas(area);
let client = &state.runner.client();
frame.render_widget(
Paragraph::new(Line::from(vec![
"Platform: ".gray(),
self.platform.expected_name().yellow(),
if state.opts.build_arguments.fullstack() {
client.build.platform.expected_name().yellow(),
if state.runner.is_fullstack() {
" + fullstack".yellow()
} else {
" ".dark_gray()
@@ -671,7 +657,7 @@ impl Output {
frame.render_widget_ref(
Paragraph::new(Line::from(vec![
if self.platform == Platform::Web {
if client.build.platform == Platform::Web {
"Serving at: ".gray()
} else {
"ServerFns at: ".gray()
@@ -688,7 +674,7 @@ impl Output {
Paragraph::new(Line::from({
let mut lines = vec!["App features: ".gray(), "[".yellow()];
let feature_list: Vec<String> = state.build_engine.request.all_target_features();
let feature_list: Vec<String> = state.runner.client().build.all_target_features();
let num_features = feature_list.len();
for (idx, feature) in feature_list.into_iter().enumerate() {
@@ -737,7 +723,7 @@ impl Output {
frame.render_widget(
Paragraph::new(Line::from(vec![
"rustc: ".gray(),
self.rustc_details.version.as_str().yellow(),
state.runner.workspace.rustc_version.as_str().yellow(),
])),
meta_list[2],
);
@@ -869,8 +855,8 @@ impl Output {
.iter()
.map(|line| {
// Very important to strip ansi codes before counting graphemes - the ansi codes count as multiple graphemes!
let grapheme_count = console::strip_ansi_codes(line).graphemes(true).count() as u16;
grapheme_count.max(1).div_ceil(term_size.width)
let grapheme_count = console::strip_ansi_codes(line).graphemes(true).count();
grapheme_count.max(1).div_ceil(term_size.width as usize) as u16
})
.sum::<u16>();
@@ -1005,7 +991,11 @@ impl Output {
// Create the ansi -> raw string line with a width of either the viewport width or the max width
let line_length = line.styled_graphemes(Style::default()).count();
lines.push(AnsiStringLine::new(line_length as _).render(&line));
if line_length < u16::MAX as usize {
lines.push(AnsiStringLine::new(line_length as _).render(&line));
} else {
lines.push(line.to_string())
}
}
}
File diff suppressed because it is too large. Load diff
+119 -123
View File
@@ -1,14 +1,13 @@
use crate::{
config::WebHttpsConfig,
serve::{ServeArgs, ServeUpdate},
BuildStage, BuildUpdate, DioxusCrate, Platform, Result, TraceSrc,
config::WebHttpsConfig, serve::ServeUpdate, BuildId, BuildStage, BuilderUpdate, Platform,
Result, TraceSrc,
};
use anyhow::Context;
use axum::{
body::Body,
extract::{
ws::{Message, WebSocket},
Request, State, WebSocketUpgrade,
Query, Request, State, WebSocketUpgrade,
},
http::{
header::{HeaderName, HeaderValue, CACHE_CONTROL, EXPIRES, PRAGMA},
@@ -34,13 +33,18 @@ use std::{
net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener},
path::Path,
sync::{Arc, RwLock},
time::Duration,
};
use subsecond_types::JumpTable;
use tokio::process::Command;
use tower_http::{
cors::Any,
services::fs::{ServeDir, ServeFileSystemResponseBody},
ServiceBuilderExt,
};
use super::AppServer;
/// 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.
///
@@ -52,46 +56,40 @@ pub(crate) struct WebServer {
devserver_bind_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>,
hot_reload_sockets: Vec<ConnectedWsClient>,
build_status_sockets: Vec<ConnectedWsClient>,
new_hot_reload_sockets: UnboundedReceiver<ConnectedWsClient>,
new_build_status_sockets: UnboundedReceiver<ConnectedWsClient>,
build_status: SharedStatus,
application_name: String,
platform: Platform,
}
pub(crate) struct ConnectedWsClient {
socket: WebSocket,
build_id: Option<BuildId>,
aslr_reference: Option<u64>,
}
impl WebServer {
pub const SELF_IP: IpAddr = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
/// 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> {
pub(crate) fn start(runner: &AppServer) -> 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();
const SELF_IP: IpAddr = IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0));
// Use 0.0.0.0 as the default address if none is specified - this will let us expose the
// devserver to the network (for other devices like phones/embedded)
let devserver_bind_ip = args.address.addr.unwrap_or(SELF_IP);
// If the user specified a port, use that, otherwise use any available port, preferring 8080
let devserver_port = args
.address
.port
.unwrap_or_else(|| get_available_port(devserver_bind_ip, Some(8080)).unwrap_or(8080));
// 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_bind_ip, None))
.flatten();
// 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 devserver_bind_ip = runner.devserver_bind_ip;
let devserver_port = runner.devserver_port;
let proxied_port = runner.proxied_port;
let devserver_exposed_ip = devserver_bind_ip;
let devserver_bind_address = SocketAddr::new(devserver_bind_ip, devserver_port);
let listener = std::net::TcpListener::bind(devserver_bind_address).with_context(|| {
anyhow::anyhow!(
@@ -99,21 +97,12 @@ impl WebServer {
)
})?;
// If the IP is 0.0.0.0, we need to get the actual IP of the machine
// This will let ios/android/network clients connect to the devserver
let devserver_exposed_ip = if devserver_bind_ip == SELF_IP {
local_ip_address::local_ip().unwrap_or(devserver_bind_ip)
} else {
devserver_bind_ip
};
let proxied_address = proxied_port.map(|port| SocketAddr::new(devserver_exposed_ip, port));
// 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,
runner,
hot_reload_sockets_tx,
build_status_sockets_tx,
proxied_address,
@@ -122,7 +111,7 @@ impl WebServer {
// And finally, start the server mainloop
tokio::spawn(devserver_mainloop(
krate.config.web.https.clone(),
runner.client().build.config.web.https.clone(),
listener,
router,
));
@@ -137,8 +126,8 @@ impl WebServer {
build_status_sockets: Default::default(),
new_hot_reload_sockets: hot_reload_sockets_rx,
new_build_status_sockets: build_status_sockets_rx,
application_name: krate.executable_name().to_string(),
platform: args.build_arguments.platform(),
application_name: runner.app_name().to_string(),
platform: runner.client.build.platform,
})
}
@@ -150,15 +139,19 @@ impl WebServer {
.hot_reload_sockets
.iter_mut()
.enumerate()
.map(|(idx, socket)| async move { (idx, socket.next().await) })
.map(|(idx, socket)| async move { (idx, socket.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 {
let aslr_reference = new_socket.aslr_reference;
let id = new_socket.build_id.unwrap_or(BuildId::CLIENT);
drop(new_message);
self.hot_reload_sockets.push(new_socket);
return ServeUpdate::NewConnection;
return ServeUpdate::NewConnection { aslr_reference, id };
} else {
panic!("Could not receive a socket - the devtools could not boot - the port is likely already in use");
}
@@ -169,8 +162,8 @@ impl WebServer {
// 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;
if project_info.send_to(&mut new_socket.socket).await.is_ok() {
_ = self.build_status.send_to(&mut new_socket.socket).await;
self.build_status_sockets.push(new_socket);
}
return future::pending::<ServeUpdate>().await;
@@ -180,7 +173,7 @@ impl WebServer {
}
Some((idx, message)) = new_message.next() => {
match message {
Some(Ok(message)) => return ServeUpdate::WsMessage(message),
Some(Ok(msg)) => return ServeUpdate::WsMessage { msg, platform: Platform::Web },
_ => {
drop(new_message);
_ = self.hot_reload_sockets.remove(idx);
@@ -193,26 +186,9 @@ impl WebServer {
}
pub(crate) async fn shutdown(&mut self) {
if matches!(self.platform, Platform::Android) {
use std::process::{Command, Stdio};
if let Err(err) = Command::new("adb")
.arg("reverse")
.arg("--remove")
.arg(format!("tcp:{}", self.devserver_port))
.stderr(Stdio::piped())
.stdout(Stdio::piped())
.output()
{
tracing::error!(
"failed to remove forwarded port {}: {err}",
self.devserver_port
);
}
}
self.send_shutdown().await;
for mut socket in self.hot_reload_sockets.drain(..) {
_ = socket.send(Message::Close(None)).await;
_ = socket.socket.send(Message::Close(None)).await;
}
}
@@ -221,7 +197,7 @@ impl WebServer {
let mut i = 0;
while i < self.build_status_sockets.len() {
let socket = &mut self.build_status_sockets[i];
if self.build_status.send_to(socket).await.is_err() {
if self.build_status.send_to(&mut socket.socket).await.is_err() {
self.build_status_sockets.remove(i);
} else {
i += 1;
@@ -239,13 +215,9 @@ impl WebServer {
}
/// Sends an updated build status to all clients.
pub(crate) async fn new_build_update(
&mut self,
update: &BuildUpdate,
builder: &super::Builder,
) {
pub(crate) async fn new_build_update(&mut self, update: &BuilderUpdate) {
match update {
BuildUpdate::Progress { stage } => {
BuilderUpdate::Progress { stage } => {
// Todo(miles): wire up more messages into the splash screen UI
match stage {
BuildStage::Success => {}
@@ -259,7 +231,10 @@ impl WebServer {
krate,
..
} => {
if !builder.is_finished() {
if !matches!(
self.build_status.get(),
Status::Ready | Status::BuildError { .. }
) {
self.build_status.set(Status::Building {
progress: (*current as f64 / *total as f64).clamp(0.0, 1.0),
build_message: format!("{krate} compiling"),
@@ -273,9 +248,9 @@ impl WebServer {
_ => {}
}
}
BuildUpdate::CompilerMessage { .. } => {}
BuildUpdate::BuildReady { .. } => {}
BuildUpdate::BuildFailed { err } => {
BuilderUpdate::CompilerMessage { .. } => {}
BuilderUpdate::BuildReady { .. } => {}
BuilderUpdate::BuildFailed { err } => {
let error = err.to_string();
self.build_status.set(Status::BuildError {
error: ansi_to_html::convert(&error).unwrap_or(error),
@@ -283,6 +258,9 @@ impl WebServer {
self.send_reload_failed().await;
self.send_build_status().await;
}
BuilderUpdate::StdoutReceived { .. } => {}
BuilderUpdate::StderrReceived { .. } => {}
BuilderUpdate::ProcessExited { .. } => {}
}
}
@@ -292,7 +270,6 @@ impl WebServer {
return;
}
#[cfg(debug_assertions)]
tracing::trace!("Sending hotreload to clients {:?}", reload);
let msg = DevserverMsg::HotReload(reload);
@@ -303,6 +280,7 @@ impl WebServer {
while i < self.hot_reload_sockets.len() {
let socket = &mut self.hot_reload_sockets[i];
if socket
.socket
.send(Message::Text(msg.clone().into()))
.await
.is_err()
@@ -314,15 +292,37 @@ impl WebServer {
}
}
pub(crate) async fn send_patch(
&mut self,
jump_table: JumpTable,
time_taken: Duration,
build: BuildId,
) {
let msg = DevserverMsg::HotReload(HotReloadMsg {
jump_table: Some(jump_table),
ms_elapsed: time_taken.as_millis() as u64,
templates: vec![],
assets: vec![],
for_build_id: Some(build.0 as _),
});
self.send_devserver_message_to_all(msg).await;
}
/// Tells all clients that a hot patch has started.
pub(crate) async fn send_patch_start(&mut self) {
self.send_devserver_message_to_all(DevserverMsg::HotPatchStart)
.await;
}
/// Tells all clients that a full rebuild has started.
pub(crate) async fn send_reload_start(&mut self) {
self.send_devserver_message(DevserverMsg::FullReloadStart)
self.send_devserver_message_to_all(DevserverMsg::FullReloadStart)
.await;
}
/// Tells all clients that a full rebuild has failed.
pub(crate) async fn send_reload_failed(&mut self) {
self.send_devserver_message(DevserverMsg::FullReloadFailed)
self.send_devserver_message_to_all(DevserverMsg::FullReloadFailed)
.await;
}
@@ -334,19 +334,21 @@ impl WebServer {
self.build_status.set(Status::Ready);
self.send_build_status().await;
self.send_devserver_message(DevserverMsg::FullReloadCommand)
self.send_devserver_message_to_all(DevserverMsg::FullReloadCommand)
.await;
}
/// Send a shutdown message to all connected clients.
pub(crate) async fn send_shutdown(&mut self) {
self.send_devserver_message(DevserverMsg::Shutdown).await;
self.send_devserver_message_to_all(DevserverMsg::Shutdown)
.await;
}
/// Sends a devserver message to all connected clients.
async fn send_devserver_message(&mut self, msg: DevserverMsg) {
async fn send_devserver_message_to_all(&mut self, msg: DevserverMsg) {
for socket in self.hot_reload_sockets.iter_mut() {
_ = socket
.socket
.send(Message::Text(serde_json::to_string(&msg).unwrap().into()))
.await;
}
@@ -424,23 +426,24 @@ async fn devserver_mainloop(
/// - Setting up the file serve service
/// - Setting up the websocket endpoint for devtools
fn build_devserver_router(
args: &ServeArgs,
krate: &DioxusCrate,
hot_reload_sockets: UnboundedSender<WebSocket>,
build_status_sockets: UnboundedSender<WebSocket>,
runner: &AppServer,
hot_reload_sockets: UnboundedSender<ConnectedWsClient>,
build_status_sockets: UnboundedSender<ConnectedWsClient>,
fullstack_address: Option<SocketAddr>,
build_status: SharedStatus,
) -> Result<Router> {
let mut router = Router::new();
let build = runner.client();
// Setup proxy for the endpoint specified in the config
for proxy_config in krate.config.web.proxy.iter() {
for proxy_config in build.build.config.web.proxy.iter() {
router = super::proxy::add_proxy(router, proxy_config)?;
}
if args.should_proxy_build() {
// For fullstack, liveview, and server, forward all requests to the inner server
let address = fullstack_address.unwrap();
// For fullstack, liveview, and server, forward all requests to the inner server
if runner.proxied_port.is_some() {
tracing::debug!("Proxying requests to fullstack server at {fullstack_address:?}");
let address = fullstack_address.context("No fullstack address specified")?;
tracing::debug!("Proxying requests to fullstack server at {address}");
router = router.fallback_service(super::proxy::proxy_to(
format!("http://{address}").parse().unwrap(),
@@ -460,7 +463,9 @@ fn build_devserver_router(
// Route file service to output the .wasm and assets if this is a web build
let base_path = format!(
"/{}",
krate
runner
.client()
.build
.config
.web
.app
@@ -470,9 +475,9 @@ fn build_devserver_router(
.trim_matches('/')
);
if base_path == "/" {
router = router.fallback_service(build_serve_dir(args, krate));
router = router.fallback_service(build_serve_dir(runner));
} else {
router = router.nest_service(&base_path, build_serve_dir(args, krate));
router = router.nest_service(&base_path, build_serve_dir(runner));
}
}
@@ -482,6 +487,12 @@ fn build_devserver_router(
build_status_middleware,
));
#[derive(Deserialize, Debug)]
struct ConnectionQuery {
aslr_reference: Option<u64>,
build_id: Option<BuildId>,
}
// Setup websocket endpoint - and pass in the extension layer immediately after
router = router.nest(
"/_dioxus",
@@ -489,9 +500,9 @@ fn build_devserver_router(
.route(
"/",
get(
|ws: WebSocketUpgrade, ext: Extension<UnboundedSender<WebSocket>>| async move {
tracing::debug!("New devtool websocket connection");
ws.on_upgrade(move |socket| async move { _ = ext.0.unbounded_send(socket) })
|ws: WebSocketUpgrade, ext: Extension<UnboundedSender<ConnectedWsClient>>, query: Query<ConnectionQuery>| async move {
tracing::debug!("New devtool websocket connection: {:?}", query);
ws.on_upgrade(move |socket| async move { _ = ext.0.unbounded_send(ConnectedWsClient { socket, aslr_reference: query.aslr_reference, build_id: query.build_id }) })
},
),
)
@@ -499,8 +510,8 @@ fn build_devserver_router(
.route(
"/build_status",
get(
|ws: WebSocketUpgrade, ext: Extension<UnboundedSender<WebSocket>>| async move {
ws.on_upgrade(move |socket| async move { _ = ext.0.unbounded_send(socket) })
|ws: WebSocketUpgrade, ext: Extension<UnboundedSender<ConnectedWsClient>>| async move {
ws.on_upgrade(move |socket| async move { _ = ext.0.unbounded_send(ConnectedWsClient { socket, aslr_reference: None, build_id: None }) })
},
),
)
@@ -520,7 +531,7 @@ fn build_devserver_router(
Ok(router)
}
fn build_serve_dir(args: &ServeArgs, cfg: &DioxusCrate) -> axum::routing::MethodRouter {
fn build_serve_dir(runner: &AppServer) -> axum::routing::MethodRouter {
use tower::ServiceBuilder;
static CORS_UNSAFE: (HeaderValue, HeaderValue) = (
@@ -533,15 +544,19 @@ fn build_serve_dir(args: &ServeArgs, cfg: &DioxusCrate) -> axum::routing::Method
HeaderValue::from_static("same-origin"),
);
let (coep, coop) = match args.cross_origin_policy {
let (coep, coop) = match runner.cross_origin_policy {
true => CORS_REQUIRE.clone(),
false => CORS_UNSAFE.clone(),
};
let out_dir = cfg
.build_dir(Platform::Web, args.build_arguments.release)
let app = &runner.client;
let cfg = &runner.client.build.config;
let out_dir = app
.build
.build_dir(Platform::Web, app.build.release)
.join("public");
let index_on_404 = cfg.config.web.watcher.index_on_404;
let index_on_404: bool = cfg.web.watcher.index_on_404;
get_service(
ServiceBuilder::new()
@@ -641,7 +656,7 @@ async fn get_rustls(web_config: &WebHttpsConfig) -> Result<(String, String)> {
_ = fs::create_dir("ssl");
}
let cmd = tokio::process::Command::new("mkcert")
let cmd = Command::new("mkcert")
.args([
"-install",
"-key-file",
@@ -673,25 +688,6 @@ async fn get_rustls(web_config: &WebHttpsConfig) -> Result<(String, String)> {
Ok((cert_path, key_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, prefer: Option<u16>) -> Option<u16> {
// First, try to bind to the preferred port
if let Some(port) = prefer {
if let Ok(_listener) = TcpListener::bind((address, port)) {
return Some(port);
}
}
// Otherwise, try to bind to any port and return the first one we can
TcpListener::bind((address, 0))
.map(|listener| listener.local_addr().unwrap().port())
.ok()
}
/// Middleware that intercepts html requests if the status is "Building" and returns a loading page instead
async fn build_status_middleware(
@@ -708,7 +704,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/web/loading.html");
let html = include_str!("../../assets/web/dev.loading.html");
return axum::response::Response::builder()
.status(StatusCode::OK)
// Load the html loader then keep loading forever
+12 -27
View File
@@ -1,46 +1,31 @@
use crate::{BuildUpdate, Platform, TraceMsg};
use crate::{BuildId, BuilderUpdate, Platform, TraceMsg};
use axum::extract::ws::Message as WsMessage;
use std::{path::PathBuf, process::ExitStatus};
use std::path::PathBuf;
/// 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 {
NewConnection {
id: BuildId,
aslr_reference: Option<u64>,
},
WsMessage {
platform: Platform,
msg: String,
msg: WsMessage,
},
/// 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,
/// An update regarding the state of the build and running app from an AppBuilder
BuilderUpdate {
id: BuildId,
update: BuilderUpdate,
},
FilesChanged {
files: Vec<PathBuf>,
},
/// Open an existing app bundle, if it exists
OpenApp,
RequestRebuild,
-165
View File
@@ -1,165 +0,0 @@
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 notify::{
event::{MetadataKind, ModifyKind},
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(crate) struct Watcher {
rx: UnboundedReceiver<notify::Event>,
krate: DioxusCrate,
_tx: UnboundedSender<notify::Event>,
watcher: Box<dyn notify::Watcher>,
}
impl Watcher {
pub(crate) fn start(krate: &DioxusCrate, serve: &ServeArgs) -> Self {
let (tx, rx) = futures_channel::mpsc::unbounded();
let mut watcher = Self {
watcher: create_notify_watcher(serve, tx.clone()),
_tx: tx,
krate: krate.clone(),
rx,
};
watcher.watch_filesystem();
watcher
}
/// 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);
}
// Filter the changes
let mut files: Vec<PathBuf> = vec![];
// Decompose the events into a list of all the files that have changed
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 {
// Workaround for notify and vscode-like editor:
// when edit & save a file in vscode, there will be two notifications,
// the first one is a file with empty content.
// filter the empty file notification to avoid false rebuild during hot-reload
if let Ok(metadata) = std::fs::metadata(&path) {
if metadata.len() == 0 {
continue;
}
}
files.push(path);
}
}
tracing::debug!("Files changed: {files:?}");
ServeUpdate::FilesChanged { files }
}
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:?}");
if let Err(err) = self.watcher.watch(&path, RecursiveMode::Recursive) {
handle_notify_error(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);
}
}
// 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;
};
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 (windows)
EventKind::Modify(ModifyKind::Any) => true,
EventKind::Modify(ModifyKind::Metadata(_)) => false,
// Don't care about anything else.
EventKind::Create(_) => true,
EventKind::Remove(_) => true,
_ => false,
};
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));
}
let poll_interval = Duration::from_secs(serve.wsl_file_poll_interval.unwrap_or(2) as u64);
Box::new(
notify::PollWatcher::new(handler, Config::default().with_poll_interval(poll_interval))
.expect(NOTIFY_ERROR_MSG),
)
}